<style>
.proto-switcher {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 12px;
    margin-top: 8px;
}
.proto-option {
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 14px 16px;
    border: 2px solid var(--border);
    border-radius: var(--radius);
    cursor: pointer;
    transition: border-color 0.15s, background 0.15s;
    position: relative;
}
.proto-option input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
.proto-option:hover { border-color: var(--accent); background: var(--accent-glow); }
.proto-option.active { border-color: var(--accent); background: var(--accent-glow); }
.proto-icon { font-size: 22px; }
.proto-name { font-weight: 600; font-size: 14px; }
.proto-desc { font-size: 11px; color: var(--text-muted); }
.section-subtitle {
    font-size: 13px;
    font-weight: 600;
    color: var(--text-secondary);
    margin-bottom: 8px;
}

.code-textarea {
    font-family: 'JetBrains Mono', 'Consolas', 'Monaco', monospace;
    font-size: 13px;
    background: var(--bg-primary);
    color: var(--text-primary);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    padding: 15px;
    width: 100%;
    resize: vertical;
    line-height: 1.5;
}
.code-textarea:focus {
    outline: none;
    border-color: var(--accent);
    box-shadow: 0 0 0 4px var(--accent-glow);
}

.code-block-wrap {
    position: relative;
    margin-top: 10px;
}
.code-block {
    margin: 0;
    padding: 14px;
    background: var(--bg-primary);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    font-family: 'JetBrains Mono', 'Consolas', 'Monaco', monospace;
    font-size: 13px;
    line-height: 1.5;
    overflow: auto;
    white-space: pre;
}
.config-copy-btn {
    position: absolute;
    top: 8px;
    right: 8px;
    background: var(--bg-secondary);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 3px 10px;
    font-size: 11px;
    cursor: pointer;
    color: var(--text-muted);
}
.config-copy-btn.is-copied {
    color: var(--success);
    border-color: var(--success);
}
.config-hint {
    margin-bottom: 0;
}
.input-with-button {
    display: flex;
    gap: 8px;
    align-items: center;
}
.input-with-button input { flex: 1; }
.input-with-button .btn { white-space: nowrap; flex-shrink: 0; }

.ssh-warning {
    background: var(--warning-bg);
    border: 1px solid var(--warning);
    border-radius: var(--radius-sm);
    padding: 12px 16px;
    margin-bottom: 16px;
    color: var(--warning);
    display: flex;
    align-items: center;
    gap: 10px;
}
.ssh-warning-icon { font-size: 20px; }

.node-info-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
    gap: 12px;
}
.node-info-item {
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 10px 14px;
    background: var(--bg-secondary);
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
}
.node-info-label {
    font-size: 11px;
    color: var(--text-muted);
    text-transform: uppercase;
    letter-spacing: 0.5px;
}
.node-info-value {
    font-size: 14px;
    font-weight: 600;
    color: var(--text-primary);
}
.logs-output {
    background: rgba(0, 0, 0, 0.5);
    border: 1px solid var(--glass-border);
    color: #4ade80;
    padding: 15px;
    border-radius: var(--radius-sm);
    font-family: 'Consolas', 'Monaco', monospace;
    font-size: 12px;
    max-height: 500px;
    overflow-y: auto;
    white-space: pre-wrap;
    word-break: break-all;
    margin-top: 10px;
}
.logs-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
}
.logs-output .error { color: #ff6b6b; }
.logs-output .success { color: #51cf66; }
.logs-output .warning { color: #ffd43b; }
.form-group.is-inactive {
    opacity: 0.55;
}

@media (max-width: 640px) {
    .form-row-3 { grid-template-columns: 1fr; }
}

.node-advanced-details {
    border: 1px solid var(--border);
    border-radius: var(--radius);
    margin-bottom: 16px;
    background: var(--bg-secondary);
}
.node-advanced-summary {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 20px;
    cursor: pointer;
    font-weight: 600;
    font-size: 14px;
    color: var(--text-secondary);
    user-select: none;
    list-style: none;
    gap: 8px;
}
.node-advanced-summary::-webkit-details-marker { display: none; }
.node-advanced-summary:hover { color: var(--text-primary); }
.node-advanced-summary span { display: flex; align-items: center; gap: 8px; }
.node-advanced-chevron {
    transition: transform 0.2s;
    font-size: 16px;
    flex-shrink: 0;
}
.node-advanced-details[open] .node-advanced-chevron {
    transform: rotate(180deg);
}
.node-advanced-details > .form-section {
    border-top: 1px solid var(--border);
    border-radius: 0;
    margin-bottom: 0;
    margin-top: 0;
    padding: 20px 20px 16px;
    background: transparent;
}
.node-advanced-details > .form-section:last-of-type {
    border-bottom-left-radius: var(--radius);
    border-bottom-right-radius: var(--radius);
}

/* ── Extra Xray inbounds ── */
.extra-inbounds-section {
    margin-top: 24px;
    padding-top: 18px;
    border-top: 1px dashed var(--border);
}
.extra-inbounds-header {
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    gap: 16px;
    margin-bottom: 12px;
}
.extra-inbounds-title {
    font-size: 14px;
    font-weight: 600;
    color: var(--text-primary);
    display: flex;
    align-items: center;
    gap: 8px;
}
.extra-inbounds-hint {
    color: var(--text-muted);
    font-size: 12px;
    display: block;
    margin-top: 4px;
}
.extra-inbound-card { margin-bottom: 12px; }
.extra-inbound-card .extra-inbound-summary {
    margin-left: 8px;
    padding: 2px 8px;
    border-radius: 10px;
    background: var(--bg-primary);
    border: 1px solid var(--border);
    color: var(--text-muted);
    font-size: 11px;
    font-weight: 500;
    letter-spacing: 0.2px;
}
.extra-inbound-card .extra-inbound-title {
    color: var(--text-primary);
    font-weight: 600;
}
.input-error {
    border-color: var(--error, #ff6b6b) !important;
    box-shadow: 0 0 0 3px rgba(255, 107, 107, 0.15);
}

/* ── SNI Scanner modal ── */
.sni-modal-overlay {
    position: fixed;
    inset: 0;
    background: rgba(8, 8, 10, 0.55);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    z-index: 9000;
    display: flex;
    align-items: flex-start;
    justify-content: center;
    padding: 40px 16px 16px;
    overflow-y: auto;
}
.sni-modal-card {
    background: var(--glass-bg-strong, rgba(28, 28, 32, 0.78));
    border: 1px solid var(--glass-border-strong, rgba(255, 255, 255, 0.1));
    border-radius: var(--radius);
    backdrop-filter: var(--glass-blur, blur(20px));
    -webkit-backdrop-filter: var(--glass-blur, blur(20px));
    box-shadow: var(--glass-shadow-lg, 0 24px 64px rgba(0, 0, 0, 0.5));
    width: min(820px, 100%);
    display: flex;
    flex-direction: column;
    box-shadow: 0 32px 80px rgba(0,0,0,.6);
    margin: auto;
}
.sni-modal-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 16px 22px 14px;
    border-bottom: 1px solid var(--glass-border, var(--border));
    flex-shrink: 0;
}
.sni-modal-header h3 {
    margin: 0;
    font-size: 15px;
    font-weight: 600;
    display: flex;
    align-items: center;
    gap: 8px;
}
.sni-modal-body {
    padding: 20px 22px 22px;
}
.sni-modal-hint {
    font-size: 12px;
    color: var(--text-muted);
    margin: 0 0 16px;
    line-height: 1.5;
}
/* 2-row params grid */
.sni-params {
    display: grid;
    grid-template-columns: 1fr 100px;
    gap: 12px;
    margin-bottom: 14px;
}
.sni-params-row2 {
    display: grid;
    grid-template-columns: 1fr 1fr 160px;
    gap: 12px;
    margin-bottom: 14px;
}
.sni-scan-controls {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 10px;
}
.sni-scan-status {
    font-size: 12px;
    color: var(--text-muted);
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.sni-progress-bar {
    height: 3px;
    background: var(--border);
    border-radius: 2px;
    overflow: hidden;
    margin-bottom: 16px;
}
.sni-progress-fill {
    height: 100%;
    background: var(--accent);
    transition: width .1s linear;
}
.sni-results-header {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 8px;
}
.sni-results-title { font-weight: 600; font-size: 13px; }
.sni-results-count {
    font-size: 12px;
    color: var(--text-muted);
    background: var(--bg-secondary);
    border: 1px solid var(--border);
    border-radius: 10px;
    padding: 1px 8px;
}
.sni-table-wrap {
    border: 1px solid var(--border);
    border-radius: var(--radius-sm);
    max-height: 360px;
    overflow-y: auto;
    overflow-x: hidden;
}
.sni-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 13px;
    table-layout: fixed;
}
.sni-table th {
    position: sticky;
    top: 0;
    background: var(--bg-secondary);
    padding: 8px 12px;
    text-align: left;
    font-weight: 600;
    font-size: 11px;
    color: var(--text-muted);
    text-transform: uppercase;
    letter-spacing: .5px;
    border-bottom: 1px solid var(--border);
    white-space: nowrap;
}
.sni-table td {
    padding: 9px 12px;
    border-bottom: 1px solid var(--border);
    vertical-align: middle;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}
.sni-table tr:last-child td { border-bottom: none; }
.sni-table tr:hover td { background: var(--accent-glow); }
/* Column widths */
.sni-table th:nth-child(1), .sni-table td:nth-child(1) { width: 72px; }   /* Ping   */
.sni-table th:nth-child(2), .sni-table td:nth-child(2) { width: auto; }    /* Domain */
.sni-table th:nth-child(3), .sni-table td:nth-child(3) { width: 140px; }  /* IP     */
.sni-table th:nth-child(4), .sni-table td:nth-child(4) { width: 130px; }  /* Issuer */
.sni-table th:nth-child(5), .sni-table td:nth-child(5) { width: 48px; text-align:center; } /* Apply */
.sni-ping-fast  { color: var(--success); font-weight: 600; }
.sni-ping-mid   { color: var(--warning); font-weight: 600; }
.sni-ping-slow  { color: var(--text-muted); }
.sni-domain     { font-weight: 500; }
.sni-issuer     { font-size: 11px; color: var(--text-muted); }
.sni-empty {
    text-align: center;
    color: var(--text-muted);
    font-size: 13px;
    padding: 28px 0;
}
.sni-ip-neighbor {
    font-size: 12px;
    font-family: 'JetBrains Mono', monospace;
    color: var(--success);
}
.sni-ip-other {
    font-size: 12px;
    font-family: 'JetBrains Mono', monospace;
    color: var(--text-muted);
}
.sni-apply-btn {
    padding: 5px 8px !important;
    font-size: 16px !important;
    line-height: 1;
}
.sni-modal-close {
    background: none;
    border: none;
    color: var(--text-muted);
    cursor: pointer;
    padding: 4px 6px;
    border-radius: 6px;
    font-size: 18px;
    transition: color .15s;
    line-height: 1;
}
.sni-modal-close:hover { color: var(--text-primary); }
</style>

<script>
const nodeType = '<%= node?.type || "hysteria" %>';
const lastInitScript = <%- JSON.stringify(lastInitScript || '').replace(/</g, '\\u003c') %>;
const i18n = {
    autoSetupConfirm: <%- JSON.stringify(t("nodes.autoSetupConfirm")) %>,
    autoSetupConfirmXray: <%- JSON.stringify(t("nodes.autoSetupConfirmXray")) %>,
    setupRunning: <%- JSON.stringify(t("nodes.setupRunning")) %>,
    loadingLogs: <%- JSON.stringify(t("nodes.loadingLogs")) %>,
    checking: <%- JSON.stringify(t("nodes.checking")) %>,
    resettingStatus: <%- JSON.stringify(t("nodes.resettingStatus")) %>,
    statusReset: <%- JSON.stringify(t("nodes.statusReset")) %>,
    autoSetup: <%- JSON.stringify(t("nodes.autoSetup")) %>,
    error: <%- JSON.stringify(t("common.error")) %>,
    confirmDeployCascade: <%- JSON.stringify(t("nodes.confirmDeployCascade")) %>,
    confirmUndeployCascade: <%- JSON.stringify(t("nodes.confirmUndeployCascade")) %>,
    confirmDeleteCascade: <%- JSON.stringify(t("nodes.confirmDeleteCascade")) %>,
    xrayGenerateKeysConfirm: <%- JSON.stringify(t("nodes.xrayGenerateKeysConfirm")) %>,
    xrayKeysSuccess: <%- JSON.stringify(t("nodes.xrayKeysSuccess")) %>
};
const i18nConfig = {
    copied: <%- JSON.stringify(t("common.copied")) %>,
    loadConfigConfirm: <%- JSON.stringify(t("nodes.loadConfigConfirm")) %>,
    configLoaded: <%- JSON.stringify(t("nodes.configLoaded")) %>,
    saveFirst: <%- JSON.stringify(t("nodes.saveFirst")) %>,
    error: <%- JSON.stringify(t("common.error")) %>,
    baseUrl: <%- JSON.stringify(baseUrl) %>
};

// ==================== Protocol switcher ====================

const PANEL_DOMAIN = '<%= panelDomain %>';

// Agent port is not exposed in the form, but we still need it in the
// conflict set so a default extra port never lands on the agent socket.
// Declared here so checkXrayPortConflict (below) sees it without TDZ risk.
const XRAY_AGENT_PORT = <%- JSON.stringify(node?.xray?.agentPort || 62080) %>;

function checkXrayPortConflict() {
    const warn = document.getElementById('xrayPortConflictWarn');
    const proto = document.querySelector('input[name="type"]:checked')?.value || 'hysteria';
    const ip = (document.getElementById('ip')?.value || '').trim().toLowerCase();
    const port = parseInt(document.getElementById('port')?.value || '443', 10);
    const panelHostname = PANEL_DOMAIN.split(':')[0].toLowerCase();
    const isSameServer = ip === 'localhost' || ip === '127.0.0.1' || ip === '::1' || ip === panelHostname;
    const isConflictPort = port === 443 || port === 80;
    if (warn) {
        warn.style.display = (proto === 'xray' && isSameServer && isConflictPort) ? '' : 'none';
    }

    // Flag duplicates among main port, API port, agent port and extras.
    const apiPort = parseInt(document.getElementById('xrayApiPort')?.value || '0', 10);
    const counts = new Map();
    const bump = (p) => {
        if (Number.isFinite(p) && p > 0) counts.set(p, (counts.get(p) || 0) + 1);
    };
    bump(port);
    bump(apiPort);
    bump(XRAY_AGENT_PORT);
    document.querySelectorAll('input[name="xray_extra_port"]').forEach(input => {
        const inputPort = parseInt(input.value, 10);
        bump(inputPort);
        const card = input.closest('details.extra-inbound-card');
        if (card) {
            // Mark dup ports with an outline; also mark tag duplicates below.
            const isDup = Number.isFinite(inputPort) && (counts.get(inputPort) || 0) > 1;
            input.classList.toggle('input-error', isDup);
        }
    });

    const tagCounts = new Map();
    // JSON.stringify guarantees a valid JS string literal even when the tag
    // contains special characters (defense-in-depth — the schema pattern
    // already restricts tags, but never trust render context).
    const mainTag = <%- JSON.stringify(node?.xray?.inboundTag || 'vless-in') %>;
    tagCounts.set(mainTag, 1);
    document.querySelectorAll('input[name="xray_extra_inboundTag"]').forEach(input => {
        const v = (input.value || '').trim();
        if (v) tagCounts.set(v, (tagCounts.get(v) || 0) + 1);
    });
    document.querySelectorAll('input[name="xray_extra_inboundTag"]').forEach(input => {
        const v = (input.value || '').trim();
        const isDup = !!v && (tagCounts.get(v) || 0) > 1;
        input.classList.toggle('input-error', isDup);
    });
}

function switchProto(proto) {
    const xraySection = document.getElementById('xraySection');
    const virtualSection = document.getElementById('virtualSection');
    const cascadeSection = document.getElementById('cascadeRoleSection');
    const labelHysteria = document.getElementById('protoHysteriaLabel');
    const labelXray = document.getElementById('protoXrayLabel');
    const labelVirtual = document.getElementById('protoVirtualLabel');
    const hysteriaOnlyEls = document.querySelectorAll('.hysteria-only');
    const ipFormGroup = document.getElementById('ipFormGroup');
    const ipInput = document.getElementById('ip');

    [labelHysteria, labelXray, labelVirtual].forEach(l => l && l.classList.remove('active'));

    if (proto === 'virtual') {
        if (xraySection) xraySection.style.display = 'none';
        if (cascadeSection) cascadeSection.style.display = 'none';
        if (virtualSection) virtualSection.style.display = '';
        if (labelVirtual) labelVirtual.classList.add('active');
        hysteriaOnlyEls.forEach(el => el.style.display = 'none');
        // Hide everything except: protocol switcher, identification, groups, virtual section.
        document.querySelectorAll('.form-section').forEach(sec => {
            if (sec.id === 'virtualSection') return;
            if (sec.querySelector('.proto-switcher')) return;
            if (sec.querySelector('input[name="groups"]')) return;
            sec.classList.add('virtual-hidden');
            sec.style.display = 'none';
        });
        // Non-form-section elements that don't belong on virtual nodes
        // (init-script <details>, ad-hoc cards, etc.). The .non-virtual-only
        // class is the canonical marker — server-side renders the same
        // state via inline `display:none`.
        document.querySelectorAll('.non-virtual-only').forEach(el => {
            el.classList.add('virtual-hidden');
            el.style.display = 'none';
        });
        if (ipFormGroup) ipFormGroup.style.display = 'none';
        if (ipInput) ipInput.removeAttribute('required');
        checkXrayPortConflict();
        return;
    }

    // Restore everything hidden by 'virtual'.
    if (virtualSection) virtualSection.style.display = 'none';
    document.querySelectorAll('.form-section.virtual-hidden, .non-virtual-only.virtual-hidden').forEach(sec => {
        sec.style.display = '';
        sec.classList.remove('virtual-hidden');
    });
    if (ipFormGroup) ipFormGroup.style.display = '';
    if (ipInput) ipInput.setAttribute('required', 'required');

    if (proto === 'xray') {
        xraySection.style.display = '';
        if (cascadeSection) cascadeSection.style.display = '';
        if (labelXray) labelXray.classList.add('active');
        hysteriaOnlyEls.forEach(el => el.style.display = 'none');
        onCascadeRoleChange();
        // Domain group is shared (no longer .hysteria-only). Its visibility
        // depends on the current Xray TLS source — defer to updateXrayTlsSourceUI.
        updateXrayTlsSourceUI();
    } else {
        xraySection.style.display = 'none';
        if (cascadeSection) cascadeSection.style.display = 'none';
        if (labelHysteria) labelHysteria.classList.add('active');
        hysteriaOnlyEls.forEach(el => el.style.display = '');
        toggleAcmeByDomain();
        toggleObfsPassword();
        toggleMasqueradeType();
        toggleBandwidthFields();
        toggleUdpFields();
        toggleSniffFields();
        toggleQuicFields();
        toggleResolverFields();
        toggleAclFields();
        const domainGroup = document.getElementById('domainGroup');
        if (domainGroup) domainGroup.style.display = '';
        const domainHintHy = document.getElementById('domainHintHysteria');
        const domainHintXr = document.getElementById('domainHintXrayManual');
        if (domainHintHy) domainHintHy.style.display = '';
        if (domainHintXr) domainHintXr.style.display = 'none';
    }
    checkXrayPortConflict();
}

function selectCascadeRole(role, cardEl) {
    document.getElementById('cascadeRole').value = role;
    document.querySelectorAll('.role-card').forEach(function(c) { c.classList.remove('active'); });
    if (cardEl) cardEl.classList.add('active');
    onCascadeRoleChange();
}

function onCascadeRoleChange() {
    const role = document.getElementById('cascadeRole').value;
    const isBridge = role === 'bridge';
    const isPortal = role === 'portal';

    var bridgeHint = document.getElementById('cascadeBridgeHint');
    var portalHint = document.getElementById('cascadePortalHint');
    if (bridgeHint) bridgeHint.style.display = isBridge ? '' : 'none';
    if (portalHint) portalHint.style.display = isPortal ? '' : 'none';

    // Bridge nodes: hide all client-facing settings.
    // They only need name, IP, SSH, and cascade role.
    ['xraySection', 'networkSection', 'limitsSection'].forEach(function(id) {
        var el = document.getElementById(id);
        if (el) el.style.display = isBridge ? 'none' : '';
    });
}

// ==================== Xray UI ====================

// VLESS fallbacks: tcp+tls only.
function _refreshMainFallbackVisibility() {
    const fb = document.getElementById('xrayFallbackGroup');
    if (!fb) return;
    const transport = document.getElementById('xrayTransport')?.value || 'tcp';
    const security = document.getElementById('xraySecurity')?.value || 'reality';
    fb.style.display = (transport === 'tcp' && security === 'tls') ? '' : 'none';
}

function updateXrayTransportUI() {
    const transport = document.getElementById('xrayTransport').value;
    document.getElementById('wsFields').style.display = transport === 'ws' ? '' : 'none';
    document.getElementById('grpcFields').style.display = transport === 'grpc' ? '' : 'none';
    document.getElementById('xhttpFields').style.display = transport === 'xhttp' ? '' : 'none';
    const flowGroup = document.getElementById('xrayFlowGroup');
    if (flowGroup) flowGroup.style.display = transport === 'tcp' ? '' : 'none';
    _refreshMainFallbackVisibility();
}

// True if main or any extra inbound uses TLS — drives #xrayTlsConfig visibility.
function _anyXrayTlsInboundActive() {
    const main = document.getElementById('xraySecurity')?.value || '';
    if (main === 'tls') return true;
    const extras = document.querySelectorAll('select[name="xray_extra_security"]');
    for (const el of extras) {
        if (el.value === 'tls') return true;
    }
    return false;
}

// Toggle the main inbound fingerprint UI between "single" and "pool" modes.
// The pool checkboxes are disabled in single mode so a (possibly stale)
// selection is never submitted; the empty hidden fallback still clears the pool.
function updateXrayFingerprintModeUI() {
    const checked = document.querySelector('input[name="xrayFingerprintMode"]:checked');
    const mode = checked ? checked.value : 'single';
    const single = document.getElementById('xrayFingerprintSingle');
    const poolBlock = document.getElementById('xrayFingerprintPoolBlock');
    if (single) single.style.display = mode === 'single' ? '' : 'none';
    if (poolBlock) poolBlock.style.display = mode === 'pool' ? '' : 'none';
    document.querySelectorAll('input.xray-fp-pool-cb').forEach(cb => { cb.disabled = mode !== 'pool'; });
}

function updateXraySecurityUI() {
    const security = document.getElementById('xraySecurity').value;
    document.getElementById('realityFields').style.display = security === 'reality' ? '' : 'none';
    document.getElementById('alpnGroup').style.display = security === 'tls' ? '' : 'none';
    const tlsConfig = document.getElementById('xrayTlsConfig');
    if (tlsConfig) tlsConfig.style.display = _anyXrayTlsInboundActive() ? '' : 'none';
    _refreshMainFallbackVisibility();
    updateXrayTlsSourceUI();
}

// Picks one of four TLS-source sub-blocks; manual+acme also reveal #domainGroup.
function updateXrayTlsSourceUI() {
    const proto = document.querySelector('input[name="type"]:checked')?.value || 'hysteria';
    const checked = document.querySelector('input[name="xray.tlsSource"]:checked');
    const source = checked ? checked.value : 'panel';
    const panelInfo = document.getElementById('xrayTlsPanelInfo');
    const acmeBlock = document.getElementById('xrayTlsAcmeBlock');
    const manualBlock = document.getElementById('xrayTlsManualBlock');
    const selfSignedInfo = document.getElementById('xrayTlsSelfSignedInfo');
    if (panelInfo) panelInfo.style.display = source === 'panel' ? '' : 'none';
    if (acmeBlock) acmeBlock.style.display = source === 'acme' ? '' : 'none';
    if (manualBlock) manualBlock.style.display = source === 'manual' ? '' : 'none';
    if (selfSignedInfo) selfSignedInfo.style.display = source === 'self-signed' ? '' : 'none';

    // Domain visibility: hysteria always; xray only when manual/acme TLS source.
    const domainGroup = document.getElementById('domainGroup');
    const domainHintHy = document.getElementById('domainHintHysteria');
    const domainHintXr = document.getElementById('domainHintXrayManual');
    if (domainGroup) {
        const xrayNeedsDomain = (source === 'manual' || source === 'acme');
        const showForXrayDomain = (proto === 'xray' && _anyXrayTlsInboundActive() && xrayNeedsDomain);
        const showForHysteria = (proto !== 'xray');
        domainGroup.style.display = (showForXrayDomain || showForHysteria) ? '' : 'none';
        if (domainHintHy) domainHintHy.style.display = showForHysteria ? '' : 'none';
        if (domainHintXr) domainHintXr.style.display = showForXrayDomain ? '' : 'none';
    }
}

// Lightweight client-side PEM check; authoritative validation happens
// server-side. Backreference forces matching BEGIN/END prefixes.
const _PEM_CERT_RE = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/;
const _PEM_KEY_RE = /-----BEGIN (RSA |EC |ECDSA |)PRIVATE KEY-----[\s\S]+?-----END \1PRIVATE KEY-----/;
const _MANUAL_KEY_PLACEHOLDER = '***SET***';
// Localized labels are injected at render time so the inline status string
// matches the operator's language without round-tripping through any global
// translation cache (which is server-only in this template).
const _PEM_LABELS = {
    valid:   <%- JSON.stringify(t('nodes.xrayPemValid'))   %>,
    invalid: <%- JSON.stringify(t('nodes.xrayPemInvalid')) %>,
    stored:  <%- JSON.stringify(t('nodes.xrayPemStored'))  %>,
};

function validateXrayPemField(textarea, kind) {
    if (!textarea) return;
    const status = document.getElementById(textarea.getAttribute('aria-describedby'));
    if (!status) return;
    const value = (textarea.value || '').trim();
    if (!value) {
        status.textContent = '';
        status.classList.remove('is-valid', 'is-invalid');
        return;
    }
    if (kind === 'key' && value === _MANUAL_KEY_PLACEHOLDER) {
        status.textContent = _PEM_LABELS.stored;
        status.classList.remove('is-invalid');
        status.classList.add('is-valid');
        return;
    }
    const ok = (kind === 'cert' ? _PEM_CERT_RE : _PEM_KEY_RE).test(value);
    status.textContent = ok ? _PEM_LABELS.valid : _PEM_LABELS.invalid;
    status.classList.toggle('is-valid', ok);
    status.classList.toggle('is-invalid', !ok);
}

// When the operator clicks into the masked manualKey textarea the first time,
// clear the ***SET*** placeholder so they can paste a replacement. If they
// blur without entering anything, restore the placeholder so the form submit
// continues to mean "keep the stored key".
function onXrayManualKeyFocus(textarea) {
    if (!textarea) return;
    const stored = textarea.getAttribute('data-has-stored-key') === '1';
    if (!stored) return;
    if ((textarea.value || '').trim() === _MANUAL_KEY_PLACEHOLDER) {
        textarea.value = '';
        // One-shot restore handler — re-attach a blur listener that runs once.
        const restore = function() {
            if ((textarea.value || '').trim() === '') {
                textarea.value = _MANUAL_KEY_PLACEHOLDER;
                validateXrayPemField(textarea, 'key');
            }
            textarea.removeEventListener('blur', restore);
        };
        textarea.addEventListener('blur', restore);
    }
}

// ==================== Xray extra inbounds ====================

// Pick the next free port for a new extra inbound. Defaults follow the
// well-known TLS ports clients usually accept on restrictive networks.
function nextFreeXrayPort() {
    const taken = new Set();
    const mainPort = parseInt(document.getElementById('port')?.value || '443', 10);
    if (Number.isFinite(mainPort)) taken.add(mainPort);
    const apiPort = parseInt(document.getElementById('xrayApiPort')?.value || '0', 10);
    if (Number.isFinite(apiPort) && apiPort > 0) taken.add(apiPort);
    if (Number.isFinite(XRAY_AGENT_PORT) && XRAY_AGENT_PORT > 0) taken.add(XRAY_AGENT_PORT);
    document.querySelectorAll('input[name="xray_extra_port"]').forEach(input => {
        const p = parseInt(input.value, 10);
        if (Number.isFinite(p) && p > 0) taken.add(p);
    });
    const candidates = [8443, 2053, 2083, 2087, 2096, 8080, 8880];
    for (const c of candidates) {
        if (!taken.has(c)) return c;
    }
    let p = 10000;
    while (taken.has(p) && p < 65535) p++;
    return p;
}

// Compute the next default tag like vless-extra-N where N is the smallest
// integer not yet used. Keeps tags stable when adding/removing inbounds.
function nextFreeXrayTag() {
    const taken = new Set();
    document.querySelectorAll('input[name="xray_extra_inboundTag"]').forEach(input => {
        const m = String(input.value || '').match(/^vless-extra-(\d+)$/);
        if (m) taken.add(parseInt(m[1], 10));
    });
    let n = 1;
    while (taken.has(n)) n++;
    return `vless-extra-${n}`;
}

// Add a new extra inbound by cloning the structure of an existing card or
// building one from scratch. Uses a hidden <template> populated below.
function addExtraInbound() {
    const container = document.getElementById('extraInboundsContainer');
    if (!container) return;

    const port = nextFreeXrayPort();
    const tag = nextFreeXrayTag();
    const id = (window.crypto && window.crypto.randomUUID)
        ? window.crypto.randomUUID()
        : 'extra-' + Date.now() + '-' + Math.random().toString(16).slice(2);

    const tpl = document.getElementById('extraInboundTemplate');
    if (!tpl) return;
    const node = tpl.content.cloneNode(true);
    const root = node.querySelector('details');
    root.dataset.extraIdx = String(container.children.length);

    root.querySelector('input[name="xray_extra_id"]').value = id;
    root.querySelector('input[name="xray_extra_port"]').value = port;
    root.querySelector('input[name="xray_extra_inboundTag"]').value = tag;
    // Keep unique-name checkbox value in sync with the row id (the parser
    // identifies checked rows by id, since unchecked checkboxes are not sent).
    const uniqueNameCb = root.querySelector('input[name="xray_extra_uniqueName"]');
    if (uniqueNameCb) {
        uniqueNameCb.value = id;
        uniqueNameCb.checked = false;
    }

    const titleSpan = root.querySelector('.extra-inbound-title');
    if (titleSpan) titleSpan.textContent = (i18n.xrayExtraInbound || 'Extra inbound') + ' #' + (container.children.length + 1);
    const summarySpan = root.querySelector('.extra-inbound-summary');
    if (summarySpan) summarySpan.textContent = 'TCP / reality : ' + port;

    root.open = true;
    container.appendChild(node);
    checkXrayPortConflict();
    if (typeof updateXraySecurityUI === 'function') updateXraySecurityUI();
}

function removeExtraInbound(btn) {
    const card = btn.closest('details.extra-inbound-card');
    if (!card) return;
    if (!confirm(i18n.xrayRemoveInboundConfirm || 'Remove this inbound?')) return;
    card.remove();
    checkXrayPortConflict();
    if (typeof updateXraySecurityUI === 'function') updateXraySecurityUI();
}

function _findExtraCard(el) {
    return el.closest ? el.closest('details.extra-inbound-card') : null;
}

// VLESS fallbacks: tcp+tls only (extra inbound).
function _refreshExtraFallbackVisibility(card) {
    const fb = card.querySelector('.extra-fallback-group');
    if (!fb) return;
    const transport = card.querySelector('select[name="xray_extra_transport"]')?.value || 'tcp';
    const security = card.querySelector('select[name="xray_extra_security"]')?.value || 'reality';
    fb.style.display = (transport === 'tcp' && security === 'tls') ? '' : 'none';
}

function updateExtraXrayTransportUI(selectEl) {
    const card = _findExtraCard(selectEl);
    if (!card) return;
    const transport = selectEl.value;
    const ws = card.querySelector('.extra-ws-fields');
    const grpc = card.querySelector('.extra-grpc-fields');
    const xhttp = card.querySelector('.extra-xhttp-fields');
    const flow = card.querySelector('.extra-flow-group');
    if (ws) ws.style.display = transport === 'ws' ? '' : 'none';
    if (grpc) grpc.style.display = transport === 'grpc' ? '' : 'none';
    if (xhttp) xhttp.style.display = transport === 'xhttp' ? '' : 'none';
    if (flow) flow.style.display = transport === 'tcp' ? '' : 'none';
    _refreshExtraFallbackVisibility(card);
}

function updateExtraXraySecurityUI(selectEl) {
    const card = _findExtraCard(selectEl);
    if (!card) return;
    const security = selectEl.value;
    const reality = card.querySelector('.extra-reality-fields');
    const alpn = card.querySelector('.extra-alpn-group');
    if (reality) reality.style.display = security === 'reality' ? '' : 'none';
    if (alpn) alpn.style.display = security === 'tls' ? '' : 'none';
    _refreshExtraFallbackVisibility(card);
    if (typeof updateXraySecurityUI === 'function') updateXraySecurityUI();
}

// Live-update the collapsed summary of an inbound card so admins can see
// transport / security / port without opening every card.
function updateExtraInboundSummary(el) {
    const card = _findExtraCard(el);
    if (!card) return;
    const transport = card.querySelector('select[name="xray_extra_transport"]')?.value || 'tcp';
    const security = card.querySelector('select[name="xray_extra_security"]')?.value || 'reality';
    const port = card.querySelector('input[name="xray_extra_port"]')?.value || '';
    const summary = card.querySelector('.extra-inbound-summary');
    if (summary) summary.textContent = transport.toUpperCase() + ' / ' + security + ' : ' + port;
}

// Serialize a row's checked fingerprint-pool checkboxes into its sibling hidden
// CSV input. The hidden input (not the checkboxes) is submitted so the parallel
// xray_extra_*[] arrays stay index-aligned (one value per row).
function syncExtraFingerprintPool(el) {
    const card = _findExtraCard(el);
    if (!card) return;
    const hidden = card.querySelector('input.extra-fp-pool-csv');
    if (!hidden) return;
    const values = Array.from(card.querySelectorAll('input.extra-fp-pool-cb:checked')).map(c => c.value);
    hidden.value = values.join(',');
}

// Toggle an extra inbound's fingerprint UI between "single" and "pool" modes.
// The hidden CSV input is the source of truth: filled from the checked boxes in
// pool mode, emptied in single mode (so the pool is cleared on save).
function updateExtraFingerprintModeUI(selectEl) {
    const card = _findExtraCard(selectEl);
    if (!card) return;
    const mode = selectEl.value;
    const single = card.querySelector('.extra-fp-single');
    const poolBlock = card.querySelector('.extra-fp-pool-block');
    const hidden = card.querySelector('input.extra-fp-pool-csv');
    if (single) single.style.display = mode === 'single' ? '' : 'none';
    if (poolBlock) poolBlock.style.display = mode === 'pool' ? '' : 'none';
    if (!hidden) return;
    hidden.value = (mode === 'pool')
        ? Array.from(card.querySelectorAll('input.extra-fp-pool-cb:checked')).map(c => c.value).join(',')
        : '';
}

function updateExtraInboundTitle(input) {
    const card = _findExtraCard(input);
    if (!card) return;
    const title = card.querySelector('.extra-inbound-title');
    if (title) {
        const v = String(input.value || '').trim();
        const idx = parseInt(card.dataset.extraIdx || '0', 10);
        title.textContent = v || ((i18n.xrayExtraInbound || 'Extra inbound') + ' #' + (idx + 1));
    }
}

<% if (node?._id && node.type === 'xray') { %>
async function generateExtraXrayKeys(btn) {
    const card = _findExtraCard(btn);
    if (!card) return;
    if (!confirm(i18n.xrayGenerateKeysConfirm)) return;
    const original = btn.innerHTML;
    btn.disabled = true;
    btn.innerHTML = '<i class="ti ti-loader-2"></i>';
    try {
        const res = await fetch('/api/nodes/<%= node._id %>/generate-xray-keys', {
            method: 'POST',
            credentials: 'include',
        });
        const data = await res.json();
        if (data && data.success) {
            const priv = card.querySelector('.extra-reality-priv');
            const pub = card.querySelector('.extra-reality-pub');
            if (priv) priv.value = data.privateKey;
            if (pub) pub.value = data.publicKey;
        } else {
            alert(i18n.error + ': ' + ((data && data.error) || 'Unknown error'));
        }
    } catch (err) {
        alert(i18n.error + ': ' + err.message);
    } finally {
        btn.disabled = false;
        btn.innerHTML = original;
    }
}
<% } else { %>
function generateExtraXrayKeys() {
    alert(i18n.saveFirst || 'Save the node first to generate Reality keys.');
}
<% } %>

// ==================== Hysteria helpers ====================

function toggleObfsPassword() {
    const type = document.getElementById('obfsType').value;
    document.getElementById('obfsPasswordGroup').style.display = type ? '' : 'none';
}

function toggleCustomConfig() {
    const checked = document.getElementById('useCustomConfig').checked;
    document.getElementById('customConfigSection').style.display = checked ? 'block' : 'none';
}

function useLastInitScript() {
    const ta = document.getElementById('initScript');
    if (!ta || !lastInitScript) return;
    ta.value = lastInitScript;
    const details = document.getElementById('initScriptDetails');
    if (details) details.open = true;
    ta.focus();
}

function toggleAcmeByDomain() {
    const domain = (document.getElementById('domain')?.value || '').trim();
    const toggleGroup = document.getElementById('acmeAdvancedToggleGroup');
    if (toggleGroup) toggleGroup.style.display = domain ? '' : 'none';

    if (!domain) {
        const cb = document.getElementById('acmeAdvancedEnabled');
        if (cb) cb.checked = false;
    }

    toggleAcmeAdvancedFields();
    toggleSniField();
}

function toggleAcmeAdvancedFields() {
    const domain = (document.getElementById('domain')?.value || '').trim();
    const enabled = !!document.getElementById('acmeAdvancedEnabled')?.checked;
    const wrap = document.getElementById('acmeAdvancedFieldsWrap');
    if (wrap) wrap.style.display = (domain && enabled) ? '' : 'none';
    if (domain && enabled) toggleAcmeTypeFields();
}

function toggleAcmeTypeFields() {
    const advancedEnabled = !!document.getElementById('acmeAdvancedEnabled')?.checked;
    const domain = (document.getElementById('domain')?.value || '').trim();
    const type = document.getElementById('acmeType')?.value || '';
    const altPorts = document.getElementById('acmeAltPortFields');
    const httpGroup = document.getElementById('acmeHttpFieldGroup');
    const tlsGroup = document.getElementById('acmeTlsFieldGroup');
    const dns = document.getElementById('acmeDnsFields');
    const active = advancedEnabled && !!domain;
    if (altPorts) altPorts.style.display = active && (type === 'http' || type === 'tls') ? '' : 'none';
    if (httpGroup) httpGroup.classList.toggle('is-inactive', !active || type !== 'http');
    if (tlsGroup) tlsGroup.classList.toggle('is-inactive', !active || type !== 'tls');
    if (dns) dns.style.display = active && type === 'dns' ? '' : 'none';
}

function toggleMasqueradeType() {
    const type = document.getElementById('masqueradeType')?.value || 'proxy';
    const proxy = document.getElementById('masqueradeProxyFields');
    const str = document.getElementById('masqueradeStringFields');
    if (proxy) proxy.style.display = type === 'proxy' ? '' : 'none';
    if (str) str.style.display = type === 'string' ? '' : 'none';
    toggleSniField();
}

function toggleSniField() {
    const proto = document.querySelector('input[name="type"]:checked')?.value || 'hysteria';
    const group = document.getElementById('sniGroup');
    if (!group) return;
    if (proto === 'xray') {
        group.style.display = 'none';
        return;
    }
    const hasDomain = !!(document.getElementById('domain')?.value || '').trim();
    group.style.display = hasDomain ? 'none' : '';
}

function toggleResolverFields() {
    const enabled = document.getElementById('resolverEnabled')?.checked;
    const wrap = document.getElementById('resolverFieldsWrap');
    if (wrap) wrap.style.display = enabled ? '' : 'none';
    if (enabled) toggleResolverTypeFields();
}

function toggleResolverTypeFields() {
    const type = document.getElementById('resolverType')?.value || 'udp';
    const ids = ['resolverUdpFields', 'resolverTcpFields', 'resolverTlsFields', 'resolverHttpsFields'];
    ids.forEach(id => {
        const el = document.getElementById(id);
        if (!el) return;
        const expected = id.replace('resolver', '').replace('Fields', '').toLowerCase();
        el.style.display = expected === type ? '' : 'none';
    });
}

function toggleAclTypeFields() {
    const enabled = !!document.getElementById('aclEnabled')?.checked;
    const type = document.getElementById('aclType')?.value || 'inline';
    const fileGroup = document.getElementById('aclFileGroup');
    const typeGroup = document.getElementById('aclTypeGroup');
    const inlineRulesGroup = document.getElementById('aclInlineRulesGroup');
    if (fileGroup) fileGroup.style.display = enabled && type === 'file' ? '' : 'none';
    if (typeGroup) typeGroup.style.gridColumn = (enabled && type === 'file') ? '' : '1 / -1';
    if (inlineRulesGroup) inlineRulesGroup.style.display = enabled && type === 'inline' ? '' : 'none';
}

function toggleAclFields() {
    const enabled = !!document.getElementById('aclEnabled')?.checked;
    const wrap = document.getElementById('aclFieldsWrap');
    if (wrap) wrap.style.display = enabled ? '' : 'none';
    if (enabled) toggleAclTypeFields();
}

function toggleBandwidthFields() {
    const enabled = !!document.getElementById('bandwidthEnabled')?.checked;
    const wrap = document.getElementById('bandwidthFieldsWrap');
    if (wrap) wrap.style.display = enabled ? '' : 'none';
}

function toggleUdpFields() {
    const enabled = !!document.getElementById('udpOptionsEnabled')?.checked;
    const wrap = document.getElementById('udpFieldsWrap');
    if (wrap) wrap.style.display = enabled ? '' : 'none';
}

function toggleSniffFields() {
    const enabled = !!document.getElementById('sniffEnabled')?.checked;
    const wrap = document.getElementById('sniffFieldsWrap');
    if (wrap) wrap.style.display = enabled ? '' : 'none';
}

function toggleQuicFields() {
    const enabled = !!document.getElementById('quicEnabled')?.checked;
    const wrap = document.getElementById('quicFieldsWrap');
    if (wrap) wrap.style.display = enabled ? '' : 'none';
}

function generateSecret() {
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let secret = '';
    for (let i = 0; i < 32; i++) secret += chars.charAt(Math.floor(Math.random() * chars.length));
    document.getElementById('statsSecret').value = secret;
}

function generateObfsPassword() {
    const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*';
    let pwd = '';
    for (let i = 0; i < 24; i++) pwd += chars.charAt(Math.floor(Math.random() * chars.length));
    document.getElementById('obfsPassword').value = pwd;
}

async function generateConfig() {
    const form = document.getElementById('nodeForm');
    const output = document.getElementById('customConfig');
    if (!form || !output) return;

    const formData = new URLSearchParams(new FormData(form));
    try {
        const res = await fetch('/panel/nodes/preview-config', {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: formData.toString(),
        });
        const data = await res.json();
        if (!res.ok || !data.success) {
            throw new Error(data.error || 'Failed to generate preview');
        }
        output.value = data.config || '';
    } catch (err) {
        alert(i18nConfig.error + ': ' + err.message);
    }
}

async function loadCurrentConfig() {
    <% if (node) { %>
    if (!confirm(i18nConfig.loadConfigConfirm)) return;
    try {
        const res = await fetch('/panel/nodes/<%= node._id %>/get-config');
        const data = await res.json();
        if (data.success) {
            document.getElementById('customConfig').value = data.config;
            alert(i18nConfig.configLoaded);
        } else {
            alert(i18nConfig.error + ': ' + data.error);
        }
    } catch (err) {
        alert(i18nConfig.error + ': ' + err.message);
    }
    <% } else { %>
    alert(i18nConfig.saveFirst);
    <% } %>
}

function fallbackCopyText(text) {
    const ta = document.createElement('textarea');
    ta.value = text;
    ta.style.cssText = 'position:fixed;left:-9999px;top:0';
    document.body.appendChild(ta);
    ta.focus();
    ta.select();
    let ok = false;
    try {
        ok = document.execCommand('copy');
    } catch (_) {
        ok = false;
    }
    document.body.removeChild(ta);
    return ok;
}

async function copyConfig(btn) {
    const code = document.querySelector('#nodeConfigCode code')?.textContent || '';
    if (!code) return;

    let copied = false;
    if (navigator.clipboard?.writeText) {
        try {
            await navigator.clipboard.writeText(code);
            copied = true;
        } catch (_) {
            copied = fallbackCopyText(code);
        }
    } else {
        copied = fallbackCopyText(code);
    }

    if (!btn) return;
    if (!btn.dataset.orig) {
        btn.dataset.orig = btn.textContent;
    }

    if (copied) {
        btn.textContent = `${i18nConfig.copied} ✓`;
        btn.classList.add('is-copied');
        setTimeout(() => {
            btn.textContent = btn.dataset.orig;
            btn.classList.remove('is-copied');
        }, 1500);
    }
}

// ==================== Management actions ====================

<% if (node) { %>
function showLogs(logs) {
    const container = document.getElementById('logsContainer');
    const output = document.getElementById('logsOutput');
    container.style.display = 'block';
    let html = '';
    if (Array.isArray(logs)) {
        html = logs.map(line => {
            if (line.includes('✓') || line.includes('success')) return '<span class="success">' + escapeHtml(line) + '</span>';
            if (line.includes('❌') || line.includes('Error') || line.includes('[STDERR]')) return '<span class="error">' + escapeHtml(line) + '</span>';
            if (line.includes('⚠')) return '<span class="warning">' + escapeHtml(line) + '</span>';
            return escapeHtml(line);
        }).join('\n');
    } else {
        html = escapeHtml(String(logs));
    }
    output.innerHTML = html;
    output.scrollTop = output.scrollHeight;
}

function escapeHtml(text) {
    const div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
}

function clearLogs() {
    document.getElementById('logsContainer').style.display = 'none';
    document.getElementById('logsOutput').innerHTML = '';
}

async function setupNode() {
    const confirmMsg = nodeType === 'xray' ? i18n.autoSetupConfirmXray : i18n.autoSetupConfirm;
    if (!confirm(confirmMsg)) return;
    const btn = document.getElementById('btnSetup');
    btn.disabled = true;
    btn.innerHTML = '<span class="action-icon"><i class="ti ti-loader-2"></i></span><span>...</span>';
    document.getElementById('setupResult').innerHTML = '<div class="alert">' + i18n.setupRunning + '</div>';
    try {
        const res = await fetch('/panel/nodes/<%= node._id %>/setup', { method: 'POST' });
        const data = await res.json();
        if (data.logs) showLogs(data.logs);
        if (data.success) {
            document.getElementById('setupResult').innerHTML = '<div class="alert alert-success"><i class="ti ti-check"></i> ' + data.message + '</div>';
        } else {
            document.getElementById('setupResult').innerHTML = '<div class="alert alert-error"><i class="ti ti-x"></i> ' + (data.error || i18n.error) + '</div>';
        }
    } catch (err) {
        document.getElementById('setupResult').innerHTML = '<div class="alert alert-error"><i class="ti ti-x"></i> ' + err.message + '</div>';
    } finally {
        btn.disabled = false;
        btn.innerHTML = '<span class="action-icon"><i class="ti ti-settings"></i></span><span>' + i18n.autoSetup + '</span>';
    }
}

async function getNodeLogs() {
    document.getElementById('setupResult').innerHTML = '<div class="alert">' + i18n.loadingLogs + '</div>';
    try {
        const res = await fetch('/panel/nodes/<%= node._id %>/logs');
        const data = await res.json();
        if (data.success) {
            document.getElementById('setupResult').innerHTML = '';
            showLogs(data.logs);
        } else {
            document.getElementById('setupResult').innerHTML = '<div class="alert alert-error"><i class="ti ti-x"></i> ' + data.error + '</div>';
        }
    } catch (err) {
        document.getElementById('setupResult').innerHTML = '<div class="alert alert-error"><i class="ti ti-x"></i> ' + err.message + '</div>';
    }
}

async function checkStatus() {
    document.getElementById('setupResult').innerHTML = '<div class="alert">' + i18n.checking + '</div>';
    try {
        const res = await fetch('/api/nodes/<%= node._id %>/status', { credentials: 'include' });
        const data = await res.json();
        const statusColors = { online: 'alert-success', offline: 'alert-warning', error: 'alert-error' };
        const statusIcons = { online: '<i class="ti ti-check"></i>', offline: '<i class="ti ti-alert-triangle"></i>', error: '<i class="ti ti-x"></i>' };
        const color = statusColors[data.status] || '';
        const icon = statusIcons[data.status] || '<i class="ti ti-help"></i>';
        document.getElementById('setupResult').innerHTML = '<div class="alert ' + color + '">' + icon + ' Status: ' + data.status + '</div>';
    } catch (err) {
        document.getElementById('setupResult').innerHTML = '<div class="alert alert-error">' + i18n.error + ': ' + err.message + '</div>';
    }
}

function openTerminal() {
    window.open('/panel/nodes/<%= node._id %>/terminal', '_blank', 'width=1000,height=600');
}

async function resetStatus() {
    document.getElementById('setupResult').innerHTML = '<div class="alert">' + i18n.resettingStatus + '</div>';
    try {
        const res = await fetch('/api/nodes/<%= node._id %>/reset-status', { method: 'POST', credentials: 'include' });
        const data = await res.json();
        if (data.success) {
            document.getElementById('setupResult').innerHTML = '<div class="alert alert-success">' + i18n.statusReset + '</div>';
        } else {
            document.getElementById('setupResult').innerHTML = '<div class="alert alert-error"><i class="ti ti-x"></i> ' + (data.error || i18n.error) + '</div>';
        }
    } catch (err) {
        document.getElementById('setupResult').innerHTML = '<div class="alert alert-error">' + i18n.error + ': ' + err.message + '</div>';
    }
}

<% } %>

<% if (node?._id && node.type === 'xray') { %>
async function refreshNodeInfo() {
    const btn = document.getElementById('btnRefreshInfo');
    if (btn) { btn.disabled = true; btn.innerHTML = '<i class="ti ti-loader-2"></i>'; }
    const errEl = document.getElementById('nodeInfoError');
    if (errEl) errEl.style.display = 'none';
    try {
        const res = await fetch('/api/nodes/<%= node._id %>/agent-info', { credentials: 'include' });
        const data = await res.json();
        if (data.xray_version) document.getElementById('infoXrayVersion').textContent = data.xray_version;
        if (data.agent_version) document.getElementById('infoAgentVersion').textContent = data.agent_version;
        if (data.users_count !== undefined) document.getElementById('infoUsersCount').textContent = data.users_count;
        const statusEl = document.getElementById('infoAgentStatus');
        if (statusEl) {
            const online = res.ok && !data.error;
            statusEl.innerHTML = `<span class="badge badge-${online ? 'success' : 'danger'}">${online ? 'online' : 'offline'}</span>`;
        }
        document.getElementById('infoLastSeen').textContent = new Date().toLocaleString();
    } catch (e) {
        if (errEl) {
            errEl.innerHTML = '<div class="alert alert-error"><i class="ti ti-x"></i> ' + e.message + '</div>';
            errEl.style.display = 'block';
        }
    } finally {
        if (btn) { btn.disabled = false; btn.innerHTML = '<i class="ti ti-refresh"></i>'; }
    }
}
<% } %>

<% if (node?._id && node.type === 'xray') { %>
async function generateXrayKeys() {
    if (!confirm(i18n.xrayGenerateKeysConfirm)) return;
    const btn = event.target;
    btn.disabled = true;
    btn.innerHTML = '<i class="ti ti-loader-2"></i>';
    try {
        const res = await fetch('/api/nodes/<%= node._id %>/generate-xray-keys', {
            method: 'POST',
            credentials: 'include',
        });
        const data = await res.json();
        if (data.success) {
            document.getElementById('xrayRealityPrivKey').value = data.privateKey;
            document.getElementById('xrayRealityPubKey').value = data.publicKey;
            alert(i18n.xrayKeysSuccess.replace('{key}', data.publicKey));
        } else {
            alert(i18n.error + ': ' + (data.error || 'Unknown error'));
        }
    } catch (err) {
        alert(i18n.error + ': ' + err.message);
    } finally {
        btn.disabled = false;
        btn.innerHTML = '<i class="ti ti-refresh"></i>';
    }
}
<% } %>

// Init UI on page load
(function() {
    const proto = document.querySelector('input[name="type"]:checked')?.value || 'hysteria';
    switchProto(proto);
    toggleAcmeByDomain();
    toggleAcmeTypeFields();
    toggleObfsPassword();
    toggleMasqueradeType();
    toggleBandwidthFields();
    toggleUdpFields();
    toggleSniffFields();
    toggleQuicFields();
    toggleResolverFields();
    toggleAclFields();
    if (proto === 'xray') {
        updateXrayTransportUI();
        updateXraySecurityUI();
        updateXrayFingerprintModeUI();
        // Run an initial PEM validation pass for any pre-filled textarea so
        // the operator sees the inline status icon without having to focus.
        const certTa = document.getElementById('xrayManualCert');
        const keyTa = document.getElementById('xrayManualKey');
        if (certTa) validateXrayPemField(certTa, 'cert');
        if (keyTa) validateXrayPemField(keyTa, 'key');
    }

    // Live duplicate-port / duplicate-tag detection across extra inbounds.
    document.addEventListener('input', function(ev) {
        const target = ev.target;
        if (!target || !target.name) return;
        if (target.name === 'xray_extra_port' || target.name === 'xray_extra_inboundTag' || target.id === 'xrayApiPort') {
            checkXrayPortConflict();
        }
    });
})();

// SSH private key file upload
document.getElementById('sshPrivateKeyFile')?.addEventListener('change', function() {
    const file = this.files[0];
    if (!file) return;
    const reader = new FileReader();
    reader.onload = (e) => {
        document.getElementById('sshPrivateKey').value = e.target.result;
        document.getElementById('sshPrivateKeyBadge').style.display = '';
    };
    reader.readAsText(file);
    this.value = '';
});

// Clear private key button
document.getElementById('clearPrivateKeyBtn')?.addEventListener('click', function() {
    document.getElementById('sshPrivateKey').value = '';
    document.getElementById('clearPrivateKeyInput').value = '1';
    document.getElementById('sshPrivateKeyBadge').style.display = 'none';
    this.style.display = 'none';
});

// Show badge when user types in textarea
document.getElementById('sshPrivateKey')?.addEventListener('input', function() {
    document.getElementById('sshPrivateKeyBadge').style.display = this.value.trim() ? '' : 'none';
});

<% if (node && node._id) { %>
// Generate & Install SSH Key
document.getElementById('generateSshKeyBtn')?.addEventListener('click', async function() {
    const warning = '<%= t('nodes.generateSshKeyWarning').replace(/\n/g, '\\n').replace(/'/g, "\\'") %>';
    if (!confirm(warning)) return;

    const btn = this;
    const statusEl = document.getElementById('generateSshKeyStatus');
    btn.disabled = true;
    btn.textContent = '<%= t('nodes.sshKeyGenerating') %>';
    statusEl.textContent = '';
    statusEl.style.color = '';

    try {
        const res = await fetch('/panel/nodes/<%= node._id %>/generate-ssh-key', {
            method: 'POST',
            credentials: 'include',
            headers: { 'X-Requested-With': 'XMLHttpRequest' },
        });
        const data = await res.json();
        if (data.success) {
            statusEl.textContent = '<%= t('nodes.sshKeyInstalled') %>';
            statusEl.style.color = 'var(--color-success, #22c55e)';
            // Update UI to show key is now set
            const label = document.querySelector('label[for="sshPrivateKey"]');
            if (label && !label.querySelector('.badge')) {
                const badge = document.createElement('span');
                badge.className = 'badge badge-success';
                badge.textContent = '<%= t('nodes.sshPrivateKeySet') %>';
                label.appendChild(document.createTextNode(' '));
                label.appendChild(badge);
            }
        } else {
            statusEl.textContent = '<%= t('nodes.sshKeyError') %>: ' + (data.error || 'Unknown error');
            statusEl.style.color = 'var(--color-danger, #ef4444)';
        }
    } catch (err) {
        statusEl.textContent = '<%= t('nodes.sshKeyError') %>: ' + err.message;
        statusEl.style.color = 'var(--color-danger, #ef4444)';
    } finally {
        btn.disabled = false;
        btn.textContent = '<%= t('nodes.generateSshKey') %>';
    }
});
<% } %>

// Run port conflict check on page load and when IP changes
(function () {
    checkXrayPortConflict();
    const ipInput = document.getElementById('ip');
    if (ipInput) ipInput.addEventListener('input', checkXrayPortConflict);
})();

// ==================== Sibling node hint ====================

<% if (!node?._id) { %>
(function () {
    let siblingCheckTimer = null;

    async function checkSiblingNode() {
        const ipInput = document.getElementById('ip');
        const hint = document.getElementById('siblingNodeHint');
        const hintText = document.getElementById('siblingNodeHintText');
        if (!ipInput || !hint || !hintText) return;

        const ip = ipInput.value.trim();
        const type = document.querySelector('input[name="type"]:checked')?.value || 'hysteria';
        if (!ip) { hint.style.display = 'none'; return; }

        try {
            const res = await fetch('/api/nodes/check-ip?ip=' + encodeURIComponent(ip), { credentials: 'same-origin' });
            if (!res.ok) { hint.style.display = 'none'; return; }
            const data = await res.json();
            const sibling = (data.nodes || []).find(n => n.type !== type);
            if (sibling) {
                const protoLabel = sibling.type === 'xray' ? 'Xray VLESS' : 'Hysteria 2';
                var siblingMsg = '<%= typeof t !== "undefined" ? t("nodes.siblingExistsHint") : "{protocol} node already exists at this IP — SSH credentials will be synced automatically." %>';
                hintText.textContent = siblingMsg.replace('{protocol}', protoLabel);
                hint.style.display = '';
            } else {
                hint.style.display = 'none';
            }
        } catch (_e) {
            hint.style.display = 'none';
        }
    }

    function debouncedCheck() {
        clearTimeout(siblingCheckTimer);
        siblingCheckTimer = setTimeout(checkSiblingNode, 400);
    }

    const ipInput = document.getElementById('ip');
    if (ipInput) {
        ipInput.addEventListener('input', debouncedCheck);
        ipInput.addEventListener('blur', checkSiblingNode);
        // Also re-check when protocol type changes
        document.querySelectorAll('input[name="type"]').forEach(function(r) {
            r.addEventListener('change', checkSiblingNode);
        });
    }
})();
<% } %>

// Keep SNI visibility synced with domain state
(function () {
    const domain = document.getElementById('domain');
    if (domain) domain.addEventListener('input', toggleSniField);
    toggleSniField();
})();

// ==================== Cascade link actions ====================

async function cascadeAction(linkId, action, btn) {
    const confirmMsg = action === 'deploy' ? i18n.confirmDeployCascade : i18n.confirmUndeployCascade;
    if (!confirm(confirmMsg)) return;
    if (btn) { btn.disabled = true; btn.innerHTML = '<i class="ti ti-loader-2" style="animation:spin 0.7s linear infinite;display:inline-block"></i>'; }
    try {
        const res = await fetch('/api/cascade/links/' + linkId + '/' + action, { method: 'POST' });
        if (res.ok) {
            location.reload();
        } else {
            const data = await res.json().catch(() => ({}));
            alert(i18n.error + ': ' + (data.error || 'Unknown error'));
            if (btn) { btn.disabled = false; btn.innerHTML = action === 'deploy' ? '<i class="ti ti-upload"></i>' : '<i class="ti ti-upload-off"></i>'; }
        }
    } catch (err) {
        alert(i18n.error + ': ' + err.message);
        if (btn) { btn.disabled = false; }
    }
}

async function cascadeDelete(linkId, name) {
    const confirmMsg = i18n.confirmDeleteCascade.replace('{name}', name);
    if (!confirm(confirmMsg)) return;
    try {
        const res = await fetch('/api/cascade/links/' + linkId, { method: 'DELETE' });
        if (res.ok) {
            location.reload();
        } else {
            const data = await res.json().catch(() => ({}));
            alert(i18n.error + ': ' + (data.error || 'Unknown error'));
        }
    } catch (err) {
        alert(i18n.error + ': ' + err.message);
    }
}

function switchNodesViewNetwork() {
    window.location.href = '/panel/nodes';
    sessionStorage.setItem('nodesView', 'network');
}

// ── SNI Scanner ───────────────────────────────────────────────────────────────

const sniI18n = {
    scanning:   <%- JSON.stringify(t('nodes.sniScanScanning'))   %>,
    verifying:  <%- JSON.stringify(t('nodes.sniScanVerifying'))  %>,
    found:      <%- JSON.stringify(t('nodes.sniScanFound'))      %>,
    done:       <%- JSON.stringify(t('nodes.sniScanDone'))       %>,
    noResults:  <%- JSON.stringify(t('nodes.sniScanNoResults'))  %>,
    errInvalidIp: <%- JSON.stringify(t('nodes.sniScanErrIp'))   %>,
};

let _sniReader = null; // active SSE reader (for cancellation)

function openSniScanner() {
    const modal = document.getElementById('sniScanModal');
    if (!modal) return;
    // Pre-fill IP from the node IP field
    const nodeIp = (document.getElementById('ip')?.value || '').trim();
    const ipInput = document.getElementById('sniScanIp');
    if (ipInput && nodeIp) ipInput.value = nodeIp;
    modal.style.display = 'flex';
    document.body.style.overflow = 'hidden';
}

function closeSniScanner() {
    stopSniScan();
    const modal = document.getElementById('sniScanModal');
    if (modal) modal.style.display = 'none';
    document.body.style.overflow = '';
}

// Close on overlay click
document.getElementById('sniScanModal')?.addEventListener('click', function(e) {
    if (e.target === this) closeSniScanner();
});

// Delegated handler for "Apply" buttons in results table
document.getElementById('sniScanTableBody')?.addEventListener('click', function(e) {
    const btn = e.target.closest('.sni-apply-btn');
    if (btn) applySniResult(btn.dataset.domain);
});

function _sniSetStatus(msg) {
    const el = document.getElementById('sniScanStatus');
    if (el) el.textContent = msg;
}

function _sniSetProgress(done, total) {
    const fill = document.getElementById('sniScanProgressFill');
    if (fill) fill.style.width = (total > 0 ? Math.round(done / total * 100) : 0) + '%';
}

function _sniPingClass(ping) {
    if (ping <= 20) return 'sni-ping-fast';
    if (ping <= 60) return 'sni-ping-mid';
    return 'sni-ping-slow';
}

function _sniAddRow(result) {
    const tbody = document.getElementById('sniScanTableBody');
    if (!tbody) return;

    // Insert in sorted order by ping (table is sorted ascending)
    const rows = tbody.querySelectorAll('tr');
    const newPing = result.ping;
    let insertBefore = null;
    for (const row of rows) {
        if (parseInt(row.dataset.ping, 10) > newPing) { insertBefore = row; break; }
    }

    const scanSubnet = (document.getElementById('sniScanIp')?.value || '').split('.').slice(0,3).join('.');
    const ipSubnet   = (result.ip || '').split('.').slice(0,3).join('.');
    const isNeighbor = scanSubnet && ipSubnet && scanSubnet === ipSubnet;
    const ipBadge    = isNeighbor
        ? `<span class="sni-ip-neighbor" title="<%- t('nodes.sniScanNeighborHint') %>">${_escHtml(result.ip)}</span>`
        : `<span class="sni-ip-other"    title="<%- t('nodes.sniScanOtherHint') %>">${_escHtml(result.ip)}</span>`;

    const tr = document.createElement('tr');
    tr.dataset.ping = newPing;
    tr.innerHTML = `
        <td><span class="${_sniPingClass(newPing)}">${newPing}ms</span></td>
        <td class="sni-domain">${_escHtml(result.domain)}</td>
        <td>${ipBadge}</td>
        <td><span class="sni-issuer" title="${_escHtml(result.issuer)}">${_escHtml(result.issuer)}</span></td>
        <td style="text-align:center"><button type="button" class="btn btn-sm btn-primary sni-apply-btn"
                    data-domain="${_escHtml(result.domain)}"
                    title="<%- t('nodes.sniScanApply') %>">
            <i class="ti ti-check"></i>
        </button></td>`;

    if (insertBefore) tbody.insertBefore(tr, insertBefore);
    else tbody.appendChild(tr);

    // Update count badge
    const count = tbody.querySelectorAll('tr').length;
    const badge = document.getElementById('sniScanCount');
    if (badge) badge.textContent = `${count} ${sniI18n.found}`;
}

function _escHtml(s) {
    return String(s || '')
        .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}

async function startSniScan() {
    const ip      = (document.getElementById('sniScanIp')?.value      || '').trim();
    const port    = parseInt(document.getElementById('sniScanPort')?.value    || '443', 10);
    const threads = parseInt(document.getElementById('sniScanThreads')?.value || '50',  10);
    const timeout = parseInt(document.getElementById('sniScanTimeout')?.value || '5',   10);

    if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(ip)) {
        _sniSetStatus(sniI18n.errInvalidIp);
        return;
    }

    // Reset UI
    document.getElementById('sniScanTableBody').innerHTML = '';
    document.getElementById('sniScanResults').style.display = 'none';
    document.getElementById('sniScanEmpty').style.display   = 'none';
    document.getElementById('sniScanProgressWrap').style.display = '';
    document.getElementById('sniScanStartBtn').style.display  = 'none';
    document.getElementById('sniScanStopBtn').style.display   = '';
    document.getElementById('sniScanCount').textContent = '';
    _sniSetProgress(0, 1);
    _sniSetStatus(sniI18n.scanning);

    try {
        const response = await fetch('/panel/nodes/scan-sni', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ ip, port, threads, timeout }),
            credentials: 'include',
        });

        if (!response.ok) {
            const err = await response.json().catch(() => ({}));
            _sniSetStatus((err.error || 'Scan failed'));
            _resetSniButtons();
            return;
        }

        const reader = response.body.getReader();
        _sniReader = reader;
        const decoder = new TextDecoder();
        let buffer = '';
        let foundAny = false;

        while (true) {
            const { done, value } = await reader.read();
            if (done) break;
            buffer += decoder.decode(value, { stream: true });

            const lines = buffer.split('\n');
            buffer = lines.pop(); // keep incomplete trailing line

            for (const line of lines) {
                if (!line.startsWith('data: ')) continue;
                let evt;
                try { evt = JSON.parse(line.slice(6)); } catch { continue; }

                if (evt.type === 'verifying') {
                    _sniSetStatus(sniI18n.verifying);
                } else if (evt.type === 'progress') {
                    _sniSetProgress(evt.done, evt.total);
                    _sniSetStatus(`${sniI18n.scanning} ${evt.done}/${evt.total}`);
                } else if (evt.type === 'result') {
                    foundAny = true;
                    document.getElementById('sniScanResults').style.display = '';
                    _sniAddRow(evt);
                } else if (evt.type === 'done') {
                    _sniSetProgress(1, 1);
                    const count = document.getElementById('sniScanTableBody').querySelectorAll('tr').length;
                    _sniSetStatus(`${sniI18n.done} — ${count} ${sniI18n.found}`);
                    if (!foundAny) document.getElementById('sniScanEmpty').style.display = '';
                } else if (evt.type === 'error') {
                    _sniSetStatus(evt.message || 'Scan error');
                }
            }
        }
    } catch (err) {
        if (err.name !== 'AbortError') _sniSetStatus(err.message);
    } finally {
        _sniReader = null;
        _resetSniButtons();
    }
}

function stopSniScan() {
    if (_sniReader) { _sniReader.cancel(); _sniReader = null; }
    _resetSniButtons();
}

function _resetSniButtons() {
    document.getElementById('sniScanStartBtn').style.display = '';
    document.getElementById('sniScanStopBtn').style.display  = 'none';
}

function applySniResult(domain) {
    const destInput = document.getElementById('xrayRealityDest');
    const sniInput  = document.getElementById('xrayRealitySni');
    if (destInput) destInput.value = `${domain}:443`;
    if (sniInput)  sniInput.value  = domain;
    closeSniScanner();
}
</script>
