// FAST_ERROR_MS is the timing threshold used to distinguish "no listener
// on :443" (very fast browser error, usually TCP RST) from "something
// answered TCP, TLS handshake failed because of untrusted cert" (slower
// error). The exact cutoff is fuzzy and varies by browser/network, but
// the gap between the two cases is large enough (single-digit ms vs.
// 100+ ms) that this works as a heuristic. We don't expose milliseconds
// to the user — they'd be misleading without context.
const FAST_ERROR_MS = 150;

// copyTextToClipboard attempts navigator.clipboard.writeText first (modern
// async API, requires a secure context — HTTPS or localhost). On insecure
// contexts (plain HTTP at a LAN IP), the Clipboard API is unavailable, so
// we fall back to the legacy document.execCommand("copy") path using a
// throwaway off-screen textarea. Returns true on success, false on
// failure. Both paths preserve the page's current focus.
async function copyTextToClipboard(text) {
    if (navigator.clipboard && window.isSecureContext) {
        try {
            await navigator.clipboard.writeText(text);
            return true;
        } catch (e) {
            // Fall through to the legacy path — some browsers still reject
            // even when isSecureContext claims true (e.g. iframes without
            // the clipboard-write permission).
        }
    }

    const ta = document.createElement("textarea");
    ta.value = text;
    ta.setAttribute("readonly", "");
    ta.style.position = "absolute";
    ta.style.left = "-9999px";
    ta.style.top = "0";
    document.body.appendChild(ta);

    const previousActive = document.activeElement;
    ta.select();

    let ok = false;
    try {
        ok = document.execCommand("copy");
    } catch (e) {
        ok = false;
    }

    document.body.removeChild(ta);

    if (previousActive && typeof previousActive.focus === "function") {
        previousActive.focus();
    }

    return ok;
}

async function probeBrowser443(lanHost, listenerPort, statusEl, serverLocalhostOK, serverLanOK) {
    const line = document.createElement("div");
    line.style.fontSize = "0.85em";
    line.style.marginTop = "2px";
    line.style.color = "#666";
    line.innerText = "⏱ Checking from your browser too…";
    statusEl.appendChild(line);

    const start = performance.now();
    let outcome;
    try {
        // mode:"no-cors" lets the request go on the wire even though the response
        // would be opaque. We only care about success-or-fail and timing — not
        // the response body, which we can't read anyway with an untrusted cert.
        await fetch("https://" + lanHost + ":443/", {
            mode: "no-cors",
            cache: "no-store",
            signal: AbortSignal.timeout(2000),
        });
        outcome = { reached: true, elapsed: performance.now() - start };
    } catch (e) {
        outcome = { reached: false, elapsed: performance.now() - start, err: e };
    }

    let msg;
    let color;
    if (outcome.reached) {
        color = "#2e7d32";
        msg = "✅ Your browser also reaches <code>:443</code> on <code>" + lanHost + "</code>.";
    } else if (outcome.elapsed >= FAST_ERROR_MS) {
        color = "#2e7d32";
        msg = "✅ Your browser reached <code>:" + lanHost + ":443</code> — the failure that follows is the expected " +
            "untrusted-CA error, not a missing listener.";
    } else {
        color = "#c62828";
        msg = "❌ Your browser sees no listener on <code>" + lanHost + ":443</code> " +
            "(fast error, likely connection refused).";
    }

    // Hint when server and browser disagree — that almost always means NAT,
    // split-horizon DNS, or a host firewall sitting between AfterTouch and
    // the speaker. Worth pointing out because it's invisible to the server.
    const browserSees443 = outcome.reached || outcome.elapsed >= FAST_ERROR_MS;
    if (serverLanOK && !browserSees443) {
        msg += " <em>(Server sees :443 but your browser doesn't — check intermediate firewalls / split-horizon DNS.)</em>";
        color = "#c62828";
    } else if (!serverLanOK && browserSees443) {
        msg += " <em>(Your browser reaches :443 but the AfterTouch host can't — likely a host-firewall rule on the AfterTouch machine itself.)</em>";
        color = "#c62828";
    }

    line.style.color = color;
    line.innerHTML = msg;
}

async function fetchSpotifyStatus() {
    try {
        const settingsResponse = await fetch("/api/setup/settings");
        const settings = await settingsResponse.json();
        const header = document.getElementById("spotify-status-header");
        const nameEl = document.getElementById("spotify-account-name");
        const linkBtn = document.getElementById("link-spotify-btn");

        if (!settings.spotify_configured) {
            if (header) header.style.display = "none";
            return;
        }

        if (header) header.style.display = "flex";

        const response = await fetch("/api/mgmt/spotify/accounts");
        if (!response.ok) {
            // Don't fail silently: a stale/hidden Spotify status here is exactly
            // what made #269 look like a Spotify bug instead of a Management API
            // login that never completed.
            if (header) {
                header.style.background = "#f8d7da";
                header.style.border = "1px solid #dc3545";
            }
            if (nameEl) {
                nameEl.innerText = response.status === 401
                    ? "Unable to check (Management login required — retry, or reload the page)"
                    : `Unable to check (HTTP ${response.status})`;
            }
            return;
        }
        const data = await response.json();

        if (data.accounts && data.accounts.length > 0) {
            header.style.background = "#e6ffed";
            header.style.border = "1px solid #28a745";
            nameEl.innerText = data.accounts[0].display_name || data.accounts[0].user_id || "Linked";
            if (linkBtn) linkBtn.style.display = "none";

            // Show Prime Spotify buttons on all devices
            document.querySelectorAll(".btn-spotify").forEach((btn) => {
                btn.style.display = "inline-block";
            });
        } else {
            header.style.background = "#f0f0f0";
            header.style.border = "1px solid #ccc";
            nameEl.innerText = "Not Linked";
            if (linkBtn) linkBtn.style.display = "inline-block";

            document.querySelectorAll(".btn-spotify").forEach((btn) => {
                btn.style.display = "none";
            });
        }
    } catch (error) {
        console.error("Failed to fetch Spotify status", error);
    }
}

function toggleInfo(id) {
    const el = document.getElementById(id);
    if (el) {
        el.style.display = el.style.display === "block" ? "none" : "block";
    }
}

async function linkSpotify() {
    try {
        const response = await fetch("/api/mgmt/spotify/init", {method: "POST"});
        if (!response.ok) {
            if (response.status === 401) {
                alert("Management login failed or was cancelled. Please try again — your browser should prompt for the Management API username/password.");
            } else {
                const err = await response.text();
                alert("Failed to initialize Spotify link: " + err);
            }
            return;
        }
        const data = await response.json();
        if (data.redirectUrl) {
            // Open in a new tab
            const win = window.open(data.redirectUrl, "_blank");
            if (win) {
                win.focus();
                // Start polling for status change
                const pollInterval = setInterval(async () => {
                    const statusResponse = await fetch("/api/mgmt/spotify/accounts");
                    if (statusResponse.ok) {
                        const statusData = await statusResponse.json();
                        if (statusData.accounts && statusData.accounts.length > 0) {
                            clearInterval(pollInterval);
                            fetchSpotifyStatus();
                        }
                    }
                }, 2000);
                // Stop polling after 2 minutes
                setTimeout(() => clearInterval(pollInterval), 120000);
            } else {
                alert("Please allow popups to link your Spotify account.");
            }
        }
    } catch (error) {
        alert("Error linking Spotify: " + error.message);
    }
}

async function primeSpotify(deviceId) {
    const btn = document.getElementById("prime-spotify-" + deviceId);
    const originalText = btn.innerText;
    btn.innerText = "Priming...";
    btn.disabled = true;

    try {
        const response = await fetch(`/api/mgmt/spotify/prime?deviceId=${encodeURIComponent(deviceId)}`, {
            method: "POST",
        },);
        if (response.ok) {
            btn.innerText = "✅ Primed";
            btn.style.background = "#28a745";
            setTimeout(() => {
                btn.innerText = originalText;
                btn.style.background = "";
                btn.disabled = false;
            }, 3000);
        } else {
            const err = await response.text();
            alert("Failed to prime Spotify: " + err);
            btn.innerText = "❌ Failed";
            setTimeout(() => {
                btn.innerText = originalText;
                btn.disabled = false;
            }, 3000);
        }
    } catch (error) {
        alert("Error priming Spotify: " + error.message);
        btn.innerText = originalText;
        btn.disabled = false;
    }
}

// setIntegrationBadge updates the Active/Saved/Inactive pill shown in an
// integration panel's summary (visible even when the panel is collapsed).
function setIntegrationBadge(id, state) {
    const el = document.getElementById(id);
    if (!el) return;
    const map = {
        active: {cls: "badge-active", text: "✅ Active"},
        saved: {cls: "badge-saved", text: "⚠ Saved — re-save to apply"},
        inactive: {cls: "badge-inactive", text: "❌ Inactive"},
    };
    const m = map[state] || map.inactive;
    el.className = "integration-badge " + m.cls;
    el.textContent = m.text;
}

async function fetchSettings() {
    try {
        const response = await fetch("/api/setup/settings");
        const settings = await response.json();
        if (settings.server_url) {
            document.getElementById("target-domain").value = settings.server_url;
        }
        const httpsEff = document.getElementById("https-url-effective");
        if (httpsEff) {
            httpsEff.textContent = settings.https_server_url || "—";
        }
        const httpsOverrideInput = document.getElementById("https-url-override");
        if (httpsOverrideInput) {
            httpsOverrideInput.value = settings.https_server_url_override || "";
        }
        const httpsEffNote = document.getElementById("https-url-effective-note");
        if (httpsEffNote) {
            httpsEffNote.textContent = settings.https_server_url_override
                ? "(override)"
                : "(derived from Target Domain)";
        }
        const resolved = document.getElementById("target-domain-resolved");
        if (resolved) {
            if (settings.server_url_resolved_ip) {
                resolved.style.color = "#2e7d32";
                resolved.innerHTML = "✅ DNS will hand out <code>" + settings.server_url_resolved_ip +
                    "</code> for intercepted Bose hostnames. Speakers must be able to reach this address.";
            } else if (settings.server_url_resolve_error) {
                resolved.style.color = "#c62828";
                resolved.innerText = "❌ " + settings.server_url_resolve_error;
            } else {
                resolved.innerText = "";
            }
        }

        const port443 = document.getElementById("https-443-status");
        if (port443) {
            // The :443 check only applies to the DNS-migration path. Hide the row
            // entirely when AfterTouch's DNS interception is off — those users are
            // either using SDK overrides (port-explicit URLs) or external DNS
            // interception (in which case they can read /api/setup/settings JSON
            // directly if they want the result).
            if (!settings.dns_enabled) {
                port443.innerHTML = "";
            } else if (settings.https_443_check_skipped) {
                port443.style.color = "#2e7d32";
                port443.innerHTML = "✅ HTTPS listener bound directly to <code>:443</code> — speakers can connect.";
            } else if (settings.https_443_not_applicable) {
                port443.style.color = "#1565c0";
                port443.innerHTML = "ℹ️ <code>:443</code> reachability check not applicable. " +
                    (settings.https_443_reason || "");
            } else {
                const localhostOK = settings.https_443_localhost_reachable;
                const lanOK = settings.https_443_lan_reachable;
                const lanHost = settings.https_443_lan_host || "";
                const listenerPort = settings.https_listener_port || "8443";
                if (localhostOK && lanOK) {
                    port443.style.color = "#2e7d32";
                    port443.innerHTML = "✅ <code>:443</code> reachable on <code>localhost</code> and <code>" +
                        (lanHost || "LAN address") + "</code> (forwarded to <code>:" + listenerPort + "</code>).";
                } else {
                    port443.style.color = "#c62828";
                    const details = [];
                    details.push("localhost:443 " +
                        (localhostOK ? "✓" : "❌ " + (settings.https_443_localhost_error || "unreachable")));
                    details.push((lanHost || "LAN") + ":443 " +
                        (lanOK ? "✓" : "❌ " + (settings.https_443_lan_error || "unreachable")));
                    port443.innerHTML = "❌ Speakers connect to <code>:443</code> but AfterTouch listens on <code>:" +
                        listenerPort + "</code>. " + details.join(" · ") +
                        ". Set up iptables / setcap / reverse proxy — see " +
                        "<a href=\"https://github.com/gesellix/Bose-SoundTouch/blob/main/docs/guides/HTTPS-SETUP.md\" target=\"_blank\">HTTPS-SETUP.md</a>.";
                }

                // Browser-side probe runs in parallel. Mirrors what speakers see from
                // the LAN; the server-side probe runs from inside AfterTouch's host
                // and can disagree when there is NAT / split-horizon / a firewall in
                // between. We can't see TLS-cert vs. TCP-RST from JS, so we fall back
                // to timing: a fast error suggests no listener; a slower error
                // suggests the connection got far enough to start TLS, which proves
                // something is answering. The CA cert is not trusted by the browser
                // by default, so a clean ✅ resolution is rare — that's fine, the
                // timing alone is the diagnostic signal.
                if (lanHost) {
                    probeBrowser443(lanHost, listenerPort, port443, localhostOK, lanOK);
                }
            }
        }
        if (settings.discovery_interval) {
            document.getElementById("discovery-interval").value = settings.discovery_interval;
        }
        if (settings.discovery_enabled !== undefined) {
            document.getElementById("discovery-enabled").checked = settings.discovery_enabled;
        }
        if (settings.update_check_interval) {
            document.getElementById("update-check-interval").value = settings.update_check_interval;
        }
        if (settings.update_check_enabled !== undefined) {
            document.getElementById("update-check-enabled").checked = settings.update_check_enabled;
        }
        if (settings.default_landing) {
            document.getElementById("default-landing").value = settings.default_landing;
        }
        if (settings.admin_area_auth !== undefined) {
            document.getElementById("admin-area-auth").value = settings.admin_area_auth || "";
        }
        if (settings.dns_enabled !== undefined) {
            document.getElementById("dns-enabled").checked = settings.dns_enabled;
        }
        if (settings.dns_upstream) {
            document.getElementById("dns-upstream").value = settings.dns_upstream;
        }
        if (settings.dns_bind_addr) {
            document.getElementById("dns-bind").value = settings.dns_bind_addr;
        }

        const dnsCurrentUpstream = document.getElementById("dns-current-upstream");
        if (dnsCurrentUpstream && settings.dns_upstream) {
            dnsCurrentUpstream.innerText = "Current upstreams: " + settings.dns_upstream;
        } else if (dnsCurrentUpstream) {
            dnsCurrentUpstream.innerText = "";
        }

        if (settings.internal_paths) {
            document.getElementById("internal-paths").value = settings.internal_paths.join("\n");
        }

        if (Array.isArray(settings.tls_extra_hosts)) {
            document.getElementById("tls-extra-hosts").value = settings.tls_extra_hosts.join("\n");
        }
        const effective = document.getElementById("tls-san-effective");
        if (effective) {
            if (Array.isArray(settings.tls_san_hosts) && settings.tls_san_hosts.length) {
                effective.innerText = "Currently covered by TLS cert: " + settings.tls_san_hosts.join(", ");
            } else {
                effective.innerText = "";
            }
        }

        // Spotify credential fields
        if (settings.spotify_client_id !== undefined) {
            document.getElementById("spotify-client-id").value = settings.spotify_client_id || "";
        }
        // Secret is masked to "***" by the backend when set; leave the password input blank
        // so the placeholder "(leave blank to keep existing)" is shown.
        document.getElementById("spotify-client-secret").value = "";
        if (settings.spotify_redirect_uri !== undefined) {
            document.getElementById("spotify-redirect-uri").value = settings.spotify_redirect_uri || "";
        }
        const spotifyStatus = document.getElementById("spotify-config-status");
        if (settings.spotify_configured) {
            if (spotifyStatus) spotifyStatus.innerHTML = '<span style="color: green;">✅ Active</span>';
            setIntegrationBadge("spotify-badge", "active");
        } else if (settings.spotify_client_id) {
            if (spotifyStatus) spotifyStatus.innerHTML = '<span style="color: orange;">⚠ Credentials saved — restart or re-save to activate</span>';
            setIntegrationBadge("spotify-badge", "saved");
        } else {
            if (spotifyStatus) spotifyStatus.innerHTML = '<span style="color: #666;">❌ Not configured</span>';
            setIntegrationBadge("spotify-badge", "inactive");
        }

        // Amazon credential fields
        if (settings.amazon_client_id !== undefined) {
            document.getElementById("amazon-client-id").value = settings.amazon_client_id || "";
        }
        document.getElementById("amazon-client-secret").value = "";
        if (settings.amazon_redirect_uri !== undefined) {
            document.getElementById("amazon-redirect-uri").value = settings.amazon_redirect_uri || "";
        }
        const amazonStatus = document.getElementById("amazon-config-status");
        if (settings.amazon_configured) {
            if (amazonStatus) amazonStatus.innerHTML = '<span style="color: green;">✅ Active</span>';
            setIntegrationBadge("amazon-badge", "active");
        } else if (settings.amazon_client_id) {
            if (amazonStatus) amazonStatus.innerHTML = '<span style="color: orange;">⚠ Credentials saved — restart or re-save to activate</span>';
            setIntegrationBadge("amazon-badge", "saved");
        } else {
            if (amazonStatus) amazonStatus.innerHTML = '<span style="color: #666;">❌ Not configured</span>';
            setIntegrationBadge("amazon-badge", "inactive");
        }

        // TTS fields (secrets are masked server-side; leave password inputs blank)
        if (settings.tts_provider !== undefined) {
            document.getElementById("tts-provider").value = settings.tts_provider || "translate";
        }
        document.getElementById("tts-google-api-key").value = "";
        document.getElementById("tts-app-key").value = "";
        if (settings.tts_language !== undefined) {
            document.getElementById("tts-language").value = settings.tts_language || "";
        }
        if (settings.tts_voice !== undefined) {
            document.getElementById("tts-voice").value = settings.tts_voice || "";
        }
        if (settings.tts_volume !== undefined) {
            document.getElementById("tts-volume").value = settings.tts_volume || 0;
        }
        const ttsStatus = document.getElementById("tts-config-status");
        if (settings.tts_configured) {
            if (ttsStatus) ttsStatus.innerHTML = '<span style="color: green;">✅ Active (' + (settings.tts_provider || "translate") + ")</span>";
            setIntegrationBadge("tts-badge", "active");
        } else {
            const need = settings.tts_provider === "google-cloud" ? "an app_key and a Google API key" : "an app_key";
            if (ttsStatus) ttsStatus.innerHTML = '<span style="color: #666;">❌ Not active — set ' + need + "</span>";
            setIntegrationBadge("tts-badge", "inactive");
        }

        fetchLoggingSettings();
        fetchSpotifyStatus();
        return settings;
    } catch (error) {
        console.error("Failed to fetch settings", error);
    }
}

async function fetchLoggingSettings() {
    try {
        const response = await fetch("/api/setup/logging-settings");
        const settings = await response.json();
        document.getElementById("logging-redact").checked = settings.redact;
        document.getElementById("logging-log-body").checked = settings.log_body;
        document.getElementById("logging-record").checked = settings.record;
    } catch (error) {
        console.error("Failed to fetch proxy settings", error);
    }
}

async function updateLoggingSettings() {
    const settings = {
        redact: document.getElementById("logging-redact").checked,
        log_body: document.getElementById("logging-log-body").checked,
        record: document.getElementById("logging-record").checked,
    };
    try {
        await fetch("/api/setup/logging-settings", {
            method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(settings),
        });
    } catch (error) {
        console.error("Failed to update proxy settings", error);
    }
}

async function updateSettings() {
    const httpsOverrideEl = document.getElementById("https-url-override");
    const settings = {
        server_url: document.getElementById("target-domain").value,
        https_server_url_override: httpsOverrideEl ? httpsOverrideEl.value.trim() : "",
        default_landing: document.getElementById("default-landing").value,
        admin_area_auth: document.getElementById("admin-area-auth").value,
        discovery_interval: document.getElementById("discovery-interval").value,
        discovery_enabled: document.getElementById("discovery-enabled").checked,
        update_check_interval: document.getElementById("update-check-interval").value,
        update_check_enabled: document.getElementById("update-check-enabled").checked,
        dns_enabled: document.getElementById("dns-enabled").checked,
        dns_upstream: document.getElementById("dns-upstream").value,
        dns_bind_addr: document.getElementById("dns-bind").value,
        internal_paths: document
            .getElementById("internal-paths")
            .value.split("\n")
            .map((s) => s.trim())
            .filter((s) => s !== ""),
        spotify_client_id: document.getElementById("spotify-client-id").value,
        spotify_client_secret: document.getElementById("spotify-client-secret").value,
        spotify_redirect_uri: document.getElementById("spotify-redirect-uri").value,
        amazon_client_id: document.getElementById("amazon-client-id").value,
        amazon_client_secret: document.getElementById("amazon-client-secret").value,
        amazon_redirect_uri: document.getElementById("amazon-redirect-uri").value,
        tts_provider: document.getElementById("tts-provider").value,
        tts_google_api_key: document.getElementById("tts-google-api-key").value,
        tts_app_key: document.getElementById("tts-app-key").value,
        tts_language: document.getElementById("tts-language").value,
        tts_voice: document.getElementById("tts-voice").value,
        tts_volume: parseInt(document.getElementById("tts-volume").value, 10) || 0,
        tls_extra_hosts: document
            .getElementById("tls-extra-hosts")
            .value.split("\n")
            .map((s) => s.trim())
            .filter((s) => s !== ""),
    };
    const status = document.getElementById("settings-status");
    status.innerText = "Saving...";
    status.style.color = "blue";

    try {
        const response = await fetch("/api/setup/settings", {
            method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify(settings),
        });
        if (response.ok) {
            status.innerText = "✅ Settings saved. Restart service to apply all changes (like certificate SANs).";
            status.style.color = "green";
            setTimeout(() => fetchSettings(), 500); // Give backend a moment to settle
        } else {
            const err = await response.text();
            status.innerText = "❌ Failed: " + err;
            status.style.color = "red";
        }
    } catch (error) {
        status.innerText = "❌ Error: " + error.message;
        status.style.color = "red";
    }
}

// LIVE_INFO_CONCURRENCY caps how many live /info probes run at once. The
// browser allows ~6 connections per origin over HTTP/1.1; staying below
// that leaves sockets free so a navigation (or other request) is never
// stuck behind a batch of slow/offline-device probes.
const LIVE_INFO_CONCURRENCY = 3;

// mapLimit runs fn over items with at most `limit` concurrent invocations,
// resolving when all have settled. fn may be async; its rejections are
// swallowed so one failure does not stop the rest.
async function mapLimit(items, limit, fn) {
    let next = 0;
    const worker = async () => {
        while (next < items.length) {
            const item = items[next++];
            try {
                await fn(item);
            } catch (_) {
                /* individual probe failures are non-fatal */
            }
        }
    };
    await Promise.all(
        Array.from({ length: Math.min(limit, items.length) }, worker),
    );
}

async function fetchDevices() {
    try {
        const response = await fetch("/api/setup/devices");
        const devices = await response.json();
        const container = document.getElementById("device-list");
        const syncSelector = document.getElementById("sync-device-list");
        const migrationSelector = document.getElementById("migration-device-list");

        if (devices.length === 0) {
            container.innerHTML = "No devices known yet.";
        } else {
            // Built via DOM APIs rather than innerHTML/template strings: device
            // fields (name, IDs, serials, ...) come from speakers and third-party
            // pairing tools (see #634) and are not restricted to HTML/JS-safe
            // characters, so they must never be parsed as markup or concatenated
            // into inline event-handler attributes.
            const table = document.createElement("table");
            const headerRow = document.createElement("tr");
            for (const label of ["Name & Model", "IP Address", "Device & Account ID", "Firmware & Serial", "Method", "Action"]) {
                const th = document.createElement("th");
                th.textContent = label;
                headerRow.appendChild(th);
            }
            table.appendChild(headerRow);

            // Clear and repopulate selectors
            const currentSyncVal = syncSelector.value;
            const currentMigrationVal = migrationSelector.value;
            const eventSelector = document.getElementById("event-device-selector");
            const currentEventVal = eventSelector ? eventSelector.value : "";

            syncSelector.innerHTML = '<option value="">-- Select a device --</option>';
            migrationSelector.innerHTML = '<option value="">-- Select a device --</option>';
            if (eventSelector) eventSelector.innerHTML = '<option value="">-- Select a device --</option>';

            devices.forEach((d) => {
                const methodLabel = d.discovery_method === "manual" ? "👤 Manual" : "🔍 Auto";

                const nameModelCell = document.createElement("td");
                nameModelCell.className = "col-name-model";
                const nameDiv = document.createElement("div");
                nameDiv.className = "col-name";
                nameDiv.textContent = d.name;
                const modelDiv = document.createElement("div");
                modelDiv.className = "col-model";
                modelDiv.style.cssText = "font-size: 0.8em; color: #666;";
                modelDiv.textContent = d.product_code;
                nameModelCell.append(nameDiv, modelDiv);

                const ipCell = document.createElement("td");
                ipCell.className = "col-ip";
                ipCell.textContent = d.ip_address;

                const idsCell = document.createElement("td");
                idsCell.className = "col-ids";
                const deviceIdDiv = document.createElement("div");
                deviceIdDiv.className = "col-deviceid";
                deviceIdDiv.textContent = d.device_id;
                const accountIdDiv = document.createElement("div");
                accountIdDiv.className = "col-accountid";
                accountIdDiv.style.cssText = "font-size: 0.8em; color: #666;";
                accountIdDiv.textContent = d.account_id || "default";
                idsCell.append(deviceIdDiv, accountIdDiv);

                const fwCell = document.createElement("td");
                fwCell.className = "col-fw-serial";
                const fwDiv = document.createElement("div");
                fwDiv.className = "col-firmware";
                fwDiv.textContent = d.firmware_version || "0.0.0";
                const serialDiv = document.createElement("div");
                serialDiv.className = "col-serial";
                serialDiv.style.cssText = "font-size: 0.8em; color: #666;";
                serialDiv.textContent = d.device_serial_number;
                fwCell.append(fwDiv, serialDiv);

                const methodCell = document.createElement("td");
                methodCell.className = "col-method";
                methodCell.textContent = methodLabel;

                const makeActionButton = (label, onClick, extra) => {
                    const btn = document.createElement("button");
                    btn.textContent = label;
                    btn.addEventListener("click", onClick);
                    if (extra) Object.assign(btn, extra);
                    return btn;
                };

                const actionCell = document.createElement("td");
                actionCell.append(
                    makeActionButton("Inspect", () => toggleDeviceSummary(d.device_id)),
                    makeActionButton("Sync Data", () => prepareSync(d.device_id)),
                    makeActionButton("Migrate", () => prepareMigration(d.device_id)),
                    makeActionButton("Prime Spotify", () => primeSpotify(d.device_id), {
                        id: `prime-spotify-${d.device_id}`,
                        className: "btn-spotify",
                    }),
                    makeActionButton("Remove", () => removeDevice(d.device_id, d.name), {className: "btn-danger"}),
                );
                actionCell.querySelector(".btn-spotify").style.display = "none";

                const row = document.createElement("tr");
                row.id = `device-row-${d.device_id}`;
                row.append(nameModelCell, ipCell, idsCell, fwCell, methodCell, actionCell);

                const summaryRow = document.createElement("tr");
                summaryRow.id = `device-summary-${d.device_id}`;
                summaryRow.style.display = "none";
                const summaryCell = document.createElement("td");
                summaryCell.colSpan = 6;
                summaryCell.id = `device-summary-cell-${d.device_id}`;
                summaryCell.style.cssText = "background: #fafafa; padding: 12px;";
                summaryRow.appendChild(summaryCell);

                table.append(row, summaryRow);

                const optSync = document.createElement("option");
                optSync.value = d.device_id;
                optSync.textContent = `${d.name} (${d.ip_address})`;
                syncSelector.appendChild(optSync);

                const optMigrate = document.createElement("option");
                optMigrate.value = d.device_id;
                optMigrate.textContent = `${d.name} (${d.ip_address})`;
                migrationSelector.appendChild(optMigrate);

                if (eventSelector) {
                    const optEvent = document.createElement("option");
                    optEvent.value = d.device_id;
                    optEvent.textContent = `${d.name} (${d.ip_address})`;
                    eventSelector.appendChild(optEvent);
                }
            });
            container.replaceChildren(table);

            if (currentSyncVal) syncSelector.value = currentSyncVal;
            if (currentMigrationVal) migrationSelector.value = currentMigrationVal;
            if (eventSelector && currentEventVal) eventSelector.value = currentEventVal;

            // Refresh each device's live /info, but cap how many run at
            // once. A browser only opens ~6 connections per origin over
            // HTTP/1.1; an offline speaker holds its /info request until the
            // timeout, so probing every device at once can starve the pool
            // and make the whole page (including navigating away) wait for
            // those requests to clear. Capping below the limit keeps sockets
            // free for navigation and other requests.
            mapLimit(devices, LIVE_INFO_CONCURRENCY, (d) => updateDeviceInfo(d.device_id, d.ip_address));
            fetchSpotifyStatus();
        }

        return devices.length;
    } catch (error) {
        document.getElementById("device-list").textContent = "Error loading devices: " + error;
        return 0;
    }
}

function prepareSync(deviceId) {
    document.getElementById("sync-device-list").value = deviceId;
    openTab(null, "tab-sync");
}

function prepareMigration(deviceId) {
    document.getElementById("migration-device-list").value = deviceId;
    openTab(null, "tab-migration");
    showSummary(deviceId);
}

function openTab(evt, tabId) {
    const tabcontents = document.getElementsByClassName("tab-content");
    for (let i = 0; i < tabcontents.length; i++) {
        tabcontents[i].className = tabcontents[i].className.replace(" active", "");
    }

    const tablinks = document.getElementsByClassName("tab-btn");
    for (let i = 0; i < tablinks.length; i++) {
        tablinks[i].className = tablinks[i].className.replace(" active", "");
    }

    const content = document.getElementById(tabId);
    if (content) {
        content.className += " active";
    }

    if (tabId === "tab-interactions") {
        fetchInteractionStats();
        fetchInteractions();
        fetchDNSDiscoveries();
    }

    if (tabId === "tab-account") {
        fetchAccountList();
    }

    if (tabId === "tab-health") {
        fetchHealth();
    }

    if (tabId === "tab-logs") {
        startLogsPolling();
    } else {
        stopLogsPolling();
    }

    if (evt) {
        evt.currentTarget.className += " active";
        let hash = tabId;
        if (tabId === "tab-migration") {
            const dev = document.getElementById("migration-device-list").value;
            if (dev) hash += "?" + dev;
        }
        history.pushState(null, '', '#' + hash);
    } else {
        // Find the button that corresponds to the tabId and activate it
        for (let i = 0; i < tablinks.length; i++) {
            const onclick = tablinks[i].getAttribute("onclick");
            if (onclick && onclick.includes(tabId)) {
                tablinks[i].className += " active";
                break;
            }
        }
    }
}

window.addEventListener('popstate', function() {
    const hash = window.location.hash.slice(1);
    const [tabId, extra] = hash.split('?');
    if (!tabId || !document.getElementById(tabId)) {
        openTab(null, "tab-overview");
        return;
    }
    openTab(null, tabId);
    if (tabId === "tab-migration") {
        selectMigrationDevice(extra);
    }
});

function selectMigrationDevice(deviceId) {
    const sel = document.getElementById("migration-device-list");
    if (sel) {
        for (let i = 0; i < sel.options.length; i++) {
            if (sel.options[i].value === deviceId) {
                sel.value = deviceId;
                showSummary(deviceId);
                return;
            }
        }
        sel.value = "";
        document.getElementById("migration-summary").style.display = "none";
        history.replaceState(null, '', '#tab-migration');
    }
}

function getDeviceDisplayName(deviceId) {
    if (!deviceId) return "Unknown Device";

    // 1. Try migration selector
    const migrationSelector = document.getElementById("migration-device-list");
    if (migrationSelector) {
        for (let opt of migrationSelector.options) {
            if (opt.value === deviceId && opt.textContent !== "-- Select a device --") {
                return opt.textContent;
            }
        }
    }

    // 2. Try sync selector
    const syncSelector = document.getElementById("sync-device-list");
    if (syncSelector) {
        for (let opt of syncSelector.options) {
            if (opt.value === deviceId && opt.textContent !== "-- Select a device --") {
                return opt.textContent;
            }
        }
    }

    // 3. Try table lookup
    const rows = document.querySelectorAll("#device-list tr");
    for (const row of rows) {
        const idCell = row.querySelector(".col-deviceid");
        if (idCell && idCell.innerText === deviceId) {
            const name = row.querySelector(".col-name").innerText;
            const ip = row.querySelector(".col-ip").innerText;
            return `${name} (${ip})`;
        }
    }

    return deviceId;
}

async function startSync() {
    const deviceId = document.getElementById("sync-device-list").value;
    if (!deviceId) {
        alert("Please select a device first");
        return;
    }

    const status = document.getElementById("sync-status");
    const results = document.getElementById("sync-results");
    const log = document.getElementById("sync-log");

    status.style.display = "block";
    status.style.backgroundColor = "#eef";
    const display = getDeviceDisplayName(deviceId);
    status.textContent = "Syncing data from " + display + "...";
    results.style.display = "none";
    log.innerHTML = "";

    try {
        const response = await fetch("/api/setup/sync/" + encodeURIComponent(deviceId), {method: "POST"},);
        if (response.ok) {
            status.style.backgroundColor = "#dfd";
            status.textContent = "✅ Sync completed successfully for " + display + "!";
            results.style.display = "block";
            log.textContent = "Data fetched and saved to local datastore for " + display + ".\nPresets: OK\nRecents: OK\nSources: OK";
        } else {
            const err = await response.text();
            throw new Error(err);
        }
    } catch (error) {
        status.style.backgroundColor = "#fdd";
        status.textContent = "❌ Sync failed for " + display + ": " + error.message;
    }
}

// fetchAnnouncements loads and renders the active announcements for this
// area (see #419 design doc, _/i419/design-admin-area-auth-gate.md). Not
// tab-scoped: the container lives outside the tab-content divs so a banner
// stays visible regardless of which tab is open.
async function fetchAnnouncements() {
    const container = document.getElementById("announcements-banner");
    if (!container) return;

    try {
        const response = await fetch("/api/announcements?target=admin");
        if (!response.ok) return;

        const data = await response.json();
        const announcements = data.announcements || [];

        container.innerHTML = announcements.map(a => `
            <div class="announcement-banner announcement-${a.level || "info"}" data-announcement-id="${a.id}" style="display:flex; align-items:flex-start; justify-content:space-between; gap:12px; padding:10px 14px; margin-bottom:10px; border-radius:4px; background:#e7f3ff; border:1px solid #b6d9f7; color:#1a4a6e; font-size:0.9em;">
                <span>${escapeHtml(a.message)}${a.link_url ? ` <a href="${escapeHtml(a.link_url)}" target="_blank" rel="noopener" style="color:inherit; text-decoration:underline;">${escapeHtml(a.link_text || a.link_url)}</a>` : ""}</span>
                <button onclick="dismissAnnouncement('${a.id}')" title="Dismiss" style="background:none; border:none; cursor:pointer; font-size:1.1em; line-height:1; color:inherit; flex-shrink:0;">&times;</button>
            </div>
        `).join("");
    } catch (error) {
        console.error("Failed to fetch announcements", error);
    }
}

// dismissAnnouncement records the dismissal server-side (so it stays
// dismissed across sessions/devices — not a client-only localStorage flag)
// and removes it from the DOM immediately rather than waiting on a refetch.
async function dismissAnnouncement(id) {
    try {
        await fetch(`/api/announcements/${encodeURIComponent(id)}/dismiss`, {method: "POST"});
    } catch (error) {
        console.error("Failed to dismiss announcement", error);
    }

    const el = document.querySelector(`[data-announcement-id="${id}"]`);
    if (el) el.remove();
}

async function fetchVersion() {
    try {
        const response = await fetch("/api/setup/version");
        const data = await response.json();
        const info = document.getElementById("version-info");
        if (info && data.version) {
            let versionStr = data.version;
            if (data.release_url) {
                versionStr = `<a href="${data.release_url}" target="_blank" style="color: inherit; text-decoration: underline;">${data.version}</a>`;
            }
            let commitStr = data.commit;
            if (data.commit_url) {
                const shortCommit = data.commit.substring(0, 7);
                commitStr = `<a href="${data.commit_url}" target="_blank" style="color: inherit; text-decoration: underline;">${shortCommit}</a>`;
            }
            info.innerHTML = `AfterTouch ${versionStr} (${commitStr}) • ${data.date}`;
        }

        const dataDirEl = document.getElementById("settings-data-dir");
        if (dataDirEl && data.data_dir) {
            dataDirEl.textContent = data.data_dir;
        }
    } catch (error) {
        console.error("Failed to fetch version info", error);
    }
}

async function fetchAccountList() {
    try {
        const response = await fetch("/api/mgmt/accounts");
        if (!response.ok) {
            const selector = document.getElementById("account-selector");
            if (selector) {
                selector.innerHTML = response.status === 401
                    ? `<option value="">Management login required — retry or reload the page</option>`
                    : `<option value="">Unable to load accounts (HTTP ${response.status})</option>`;
            }
            return;
        }
        const data = await response.json();
        const selector = document.getElementById("account-selector");
        if (selector) {
            // Account IDs can contain non-alphanumeric characters (e.g.
            // "stick@local", #634) — built via DOM APIs, not innerHTML.
            selector.replaceChildren(...data.accounts.map(acc => {
                const opt = document.createElement("option");
                opt.value = acc;
                opt.textContent = acc;
                return opt;
            }));
            if (data.accounts.length > 0) {
                fetchAccountDetails(selector.value);
            }
        }
    } catch (error) {
        console.error("Failed to fetch account list", error);
    }
}

async function fetchAccountDetails(accountId) {
    if (!accountId) return;
    const metadataEl = document.getElementById("account-metadata");
    const devicesEl = document.getElementById("account-devices-list");
    const regStatus = document.getElementById("spotify-reg-status");
    const amazonRegStatus = document.getElementById("amazon-reg-status");

    if (regStatus) regStatus.innerText = "";
    if (amazonRegStatus) amazonRegStatus.innerText = "";
    if (metadataEl) metadataEl.innerHTML = "Loading...";
    if (devicesEl) devicesEl.innerHTML = "Loading devices...";

    try {
        const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accountId)}`);
        if (!response.ok) {
            if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Failed to load account details: ${escapeHtml(response.statusText)}</span>`;
            return;
        }
        const data = await response.json();

        // Render Metadata
        if (metadataEl) {
            const warningNotice = data.account.is_placeholder ?
                `<div style="background: #fff3cd; color: #856404; padding: 10px; border: 1px solid #ffeeba; border-radius: 4px; margin-bottom: 10px; font-size: 0.85em;">
                    <strong>Notice:</strong> This account hasn't saved any custom settings yet (language, provider preferences). Defaults are in effect — they'll be saved once you change something below.
                </div>` : "";

            metadataEl.innerHTML = `
                ${warningNotice}
                <table style="width: 100%; font-size: 0.9em;">
                    <tr><td style="padding: 4px"><strong>Account ID:</strong></td><td style="padding: 4px">${escapeHtml(data.account.account_id)}</td></tr>
                    <tr><td style="padding: 4px"><strong>Language:</strong></td><td style="padding: 4px">
                        <select id="account-language-select" style="font-size: 0.9em; padding: 2px;">
                            <option value="en" ${data.account.preferred_language === "en" || !data.account.preferred_language ? "selected" : ""}>en</option>
                            <option value="de" ${data.account.preferred_language === "de" ? "selected" : ""}>de</option>
                        </select>
                        <span id="language-update-status" style="margin-left: 8px; font-size: 0.8em; display: none;">Saving...</span>
                    </td></tr>
                    <tr><td style="padding: 4px"><strong>Provider Settings:</strong></td><td style="padding: 4px">
                        ${data.account.provider_settings && data.account.provider_settings.length > 0 ?
                            (() => {
                                const grouped = data.account.provider_settings.reduce((acc, s) => {
                                    const pName = s.provider_name || s.provider_id;
                                    if (!acc[pName]) acc[pName] = [];
                                    acc[pName].push(s);
                                    return acc;
                                }, {});
                                return Object.entries(grouped).map(([pName, settings]) => `
                                    <div style="margin-bottom: 8px;">
                                        <strong>${escapeHtml(pName)}</strong>
                                        <ul style="margin: 2px 0 0 0; padding-left: 20px; list-style-type: disc;">
                                            ${settings.map(s => {
                                                if ((s.provider_name === "SPOTIFY" || s.provider_id === "15") && s.key_name === "STREAMING_QUALITY") {
                                                    return `
                                                        <li style="margin-bottom: 4px;">
                                                            Music Streaming Quality:
                                                            <select class="provider-setting-select"
                                                                    data-account-id="${escapeHtml(data.account.account_id)}"
                                                                    data-provider-id="${escapeHtml(s.provider_id)}"
                                                                    data-key="${escapeHtml(s.key_name)}"
                                                                    style="font-size: 0.9em; padding: 2px; margin-left: 4px;">
                                                                <option value="1" ${s.value === "1" ? "selected" : ""}>Fastest Streaming - up to 128 kbit/s</option>
                                                                <option value="2" ${s.value === "2" ? "selected" : ""}>Balanced Quality and Speed - up to 192 kbit/s</option>
                                                                <option value="3" ${s.value === "3" ? "selected" : ""}>Best Quality - up to 320 kbit/s</option>
                                                            </select>
                                                            <span class="setting-update-status" style="margin-left: 8px; font-size: 0.8em; display: none;">Saving...</span>
                                                        </li>
                                                    `;
                                                }
                                                return `<li>${escapeHtml(s.key_name)}: ${escapeHtml(s.value)}</li>`;
                                            }).join("")}
                                        </ul>
                                    </div>
                                `).join("");
                            })() : "None"}
                    </td></tr>
                </table>
            `;

            const languageSelect = document.getElementById("account-language-select");
            if (languageSelect) {
                languageSelect.addEventListener("change", async (e) => {
                    const statusEl = document.getElementById("language-update-status");
                    const newLang = e.target.value;
                    if (statusEl) {
                        statusEl.innerText = "Saving...";
                        statusEl.style.display = "inline";
                        statusEl.style.color = "#666";
                    }
                    try {
                        const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(data.account.account_id)}/language`, {
                            method: "POST",
                            headers: {
                                "Content-Type": "application/json",
                            },
                            body: JSON.stringify({ language: newLang }),
                        });
                        if (response.ok) {
                            if (statusEl) {
                                statusEl.innerText = "Saved!";
                                statusEl.style.color = "#28a745";
                                setTimeout(() => {
                                    statusEl.style.display = "none";
                                }, 2000);
                            }
                        } else {
                            throw new Error(await response.text());
                        }
                    } catch (error) {
                        console.error("Failed to update language", error);
                        if (statusEl) {
                            statusEl.innerText = "Error!";
                            statusEl.style.color = "#dc3545";
                        }
                    }
                });
            }

            const providerSettingSelects = document.querySelectorAll(".provider-setting-select");
            providerSettingSelects.forEach(select => {
                select.addEventListener("change", async (e) => {
                    const statusEl = e.target.parentElement.querySelector(".setting-update-status");
                    const accID = e.target.dataset.accountId;
                    const provID = e.target.dataset.providerId;
                    const key = e.target.dataset.key;
                    const newValue = e.target.value;

                    if (statusEl) {
                        statusEl.innerText = "Saving...";
                        statusEl.style.display = "inline";
                        statusEl.style.color = "#666";
                    }

                    try {
                        const response = await fetch(`/api/mgmt/accounts/${encodeURIComponent(accID)}/provider-settings`, {
                            method: "POST",
                            headers: {
                                "Content-Type": "application/json",
                            },
                            body: JSON.stringify({
                                provider_id: provID,
                                key: key,
                                value: newValue
                            }),
                        });
                        if (response.ok) {
                            if (statusEl) {
                                statusEl.innerText = "Saved!";
                                statusEl.style.color = "#28a745";
                                setTimeout(() => {
                                    statusEl.style.display = "none";
                                }, 2000);
                            }
                        } else {
                            throw new Error(await response.text());
                        }
                    } catch (error) {
                        console.error("Failed to update provider setting", error);
                        if (statusEl) {
                            statusEl.innerText = "Error!";
                            statusEl.style.color = "#dc3545";
                        }
                    }
                });
            });
        }

        // Render Devices
        if (devicesEl) {
            if (!data.devices || data.devices.length === 0) {
                devicesEl.innerHTML = "No devices found for this account.";
                return;
            }

            devicesEl.innerHTML = data.devices.map(device => `
                <div class="summary-box" style="margin-bottom: 15px; border-left: 5px solid #007bff; padding: 15px;">
                    <div class="device-summary-header" data-toggle-target="device-details-${escapeHtml(device.device_id)}" style="display: flex; justify-content: space-between; cursor: pointer; align-items: center;">
                        <h4 style="margin: 0">${escapeHtml(device.name || "Unnamed Device")} (${escapeHtml(device.product_code)})</h4>
                        <div style="font-size: 0.8em; color: #666">
                            ${escapeHtml(device.ip_address)} | ${escapeHtml(device.device_id)} <span style="font-size: 1.2em; vertical-align: middle;">&#9662;</span>
                        </div>
                    </div>

                    <div id="device-details-${escapeHtml(device.device_id)}" style="display: none; margin-top: 15px; padding-top: 10px; border-top: 1px solid #eee">
                        <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px">
                            <div>
                                <h5 style="margin: 10px 0 5px 0">Device Metadata</h5>
                                <div style="font-size: 0.85em; background: #f8f9fa; padding: 8px; border-radius: 4px; border: 1px solid #e9ecef">
                                    <strong>Serial:</strong> ${escapeHtml(device.device_serial_number || device.serial_number || "N/A")}<br>
                                    <strong>MAC:</strong> ${escapeHtml(device.mac_address || "N/A")}<br>
                                    <strong>Version:</strong> ${escapeHtml(device.firmware_version || "N/A")}<br>
                                    <strong>Discovery:</strong> ${escapeHtml(device.discovery_method || "N/A")}
                                </div>

                                <h5 style="margin: 15px 0 5px 0">Hardware Components</h5>
                                <ul style="font-size: 0.8em; padding-left: 20px; margin: 0">
                                    ${device.components ? device.components.map(c => `<li><strong>${escapeHtml(c.category || c.type || 'Component')}</strong>: ${escapeHtml(c.firmware_version || 'N/A')} <br><small style="color:#777">S/N: ${escapeHtml(c.serial_number || 'N/A')}</small></li>`).join("") : "<li>No components found</li>"}
                                </ul>
                            </div>

                            <div>
                                <h5 style="margin: 10px 0 5px 0">Presets (1-6)</h5>
                                <div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px">
                                    ${Array.from({length: 6}, (_, i) => {
                                        const p = device.presets ? device.presets.find(pr => pr.button_number == i + 1) : null;
                                        let itemName = "Empty";
                                        let sourceLabel = "";

                                        if (p) {
                                            itemName = p.name || (p.source ? (p.source.source_label || p.source.name || p.source.type) : "Unknown");
                                            if (p.source) {
                                                const s = p.source;
                                                const name = s.provider_label || s.display_name || s.source_name || s.name || s.type;
                                                const account = (s.account && s.account !== s.username && s.account !== name) ? ` [${s.account}]` : "";
                                                const finalName = name || s.type || "Unknown Source";
                                                if (finalName) {
                                                    sourceLabel = `<br><small style="color: #666; font-size: 0.85em;">via ${escapeHtml(finalName)}${escapeHtml(account)}</small>`;
                                                }
                                            }
                                        }

                                        return `
                                            <div style="border: 1px solid #ddd; padding: 5px; font-size: 0.8em; background: ${p ? "#e6ffed" : "#f8f9fa"}; border-radius: 3px;">
                                                <strong>#${i + 1}</strong>: ${escapeHtml(itemName)}${sourceLabel}
                                            </div>
                                        `;
                                    }).join("")}
                                </div>

                                <h5 style="margin: 15px 0 5px 0">Recent Items</h5>
                                <div style="max-height: 150px; overflow-y: auto; font-size: 0.8em; border: 1px solid #eee; padding: 5px; border-radius: 4px">
                                    <ul style="padding-left: 15px; margin: 0">
                                        ${device.recents ? device.recents.slice(0, 10).map(r => {
                                            const name = r.name || (r.source ? (r.source.source_label || r.source.name || r.source.type) : "Unknown");
                                            let sourceLabel = "";
                                            if (r.source) {
                                                const s = r.source;
                                                const sName = s.provider_label || s.display_name || s.source_name || s.name || s.type;
                                                const account = (s.account && s.account !== s.username && s.account !== sName) ? ` [${s.account}]` : "";
                                                const finalSName = sName || s.type || "Unknown Source";
                                                if (finalSName) {
                                                    sourceLabel = `<br><small style="color: #666; font-size: 0.9em;">via ${escapeHtml(finalSName)}${escapeHtml(account)}</small>`;
                                                }
                                            }
                                            const dateRaw = r.last_played_at || r.created_on;
                                            const dateObj = dateRaw ? (isNaN(Number(dateRaw)) ? new Date(dateRaw) : new Date(Number(dateRaw) * 1000)) : null;
                                            const dateStr = dateObj ? dateObj.toLocaleString('sv-SE') : 'N/A'; // sv-SE produces YYYY-MM-DD HH:MM:SS with 24h time
                                            return `<li>${escapeHtml(name)}${sourceLabel} <br><small style="color:#888">${escapeHtml(dateStr)}</small></li>`;
                                        }).join("") : "<li>No recents</li>"}
                                    </ul>
                                </div>
                            </div>
                        </div>

                        <div style="margin-top: 15px; border-top: 1px dashed #ddd; padding-top: 10px">
                            <h5 style="margin: 0 0 5px 0">Configured Sources</h5>
                            <div style="display: flex; flex-wrap: wrap; gap: 5px">
                                ${device.sources ? device.sources.filter(s => (s.source_label || s.source_name || s.name || s.type)).map(s => {
                                    const sourceName = s.provider_label || s.display_name || s.source_name || s.name || s.type;
                                    const usernameSuffix = (s.username && s.username !== "Local") ? ` (${s.username})` : "";
                                    const accountSuffix = (s.account && s.account !== s.username && s.account !== sourceName) ? ` [${s.account}]` : "";
                                    return `
                                        <span style="background: #eefbff; color: #0056b3; border: 1px solid #b8daff; padding: 2px 8px; border-radius: 12px; font-size: 0.75em" title="Source Type: ${escapeHtml(s.type)}">
                                            ${escapeHtml(sourceName)}${escapeHtml(usernameSuffix)}${escapeHtml(accountSuffix)}
                                        </span>
                                    `;
                                }).join("") : "<small style='color:#999'>None</small>"}
                            </div>
                        </div>
                    </div>
                </div>
            `).join("");

            // data-toggle-target (not an inline onclick) avoids re-embedding
            // speaker-controlled device_id inside a JS-string-in-HTML-attribute
            // context, which HTML-escaping alone cannot make safe.
            devicesEl.querySelectorAll(".device-summary-header").forEach(el => {
                el.addEventListener("click", () => toggleInfo(el.dataset.toggleTarget));
            });
        }

    } catch (error) {
        if (metadataEl) metadataEl.innerHTML = `<span style="color:red">Error: ${escapeHtml(error.message)}</span>`;
        console.error("Failed to fetch account details", error);
    }
}

async function connectSpotifyToAccount() {
    const selector = document.getElementById("account-selector");
    const accountId = selector ? selector.value : "default";
    const statusEl = document.getElementById("spotify-reg-status");

    if (statusEl) statusEl.innerHTML = "Initializing Spotify authorization...";

    try {
        const response = await fetch(`/api/mgmt/spotify/init?account=${encodeURIComponent(accountId)}`, {
            method: "POST"
        });
        if (!response.ok) {
            const err = await response.text();
            throw new Error(err || response.statusText);
        }

        const data = await response.json();
        const redirectUrl = data.redirectUrl;

        if (statusEl) {
            statusEl.innerHTML = `Spotify authorization window opened. <br/>If it didn't open, <a href="${redirectUrl}" target="_blank">click here to authorize</a>.`;
        }

        // Open Spotify auth in a new window
        window.open(redirectUrl, "SpotifyAuth", "width=600,height=800");

        // Simple poll to see when we might be done (refresh every 5s for 5 mins)
        let pollCount = 0;
        const interval = setInterval(async () => {
            pollCount++;
            if (pollCount > 60) {
                clearInterval(interval);
                return;
            }
            // Refresh account details to see if source appeared
            await fetchAccountDetails(accountId);
        }, 5000);

    } catch (error) {
        if (statusEl) statusEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
        console.error("Spotify link failed", error);
    }
}

async function connectAmazonToAccount() {
    const selector = document.getElementById("account-selector");
    const accountId = selector ? selector.value : "default";
    const statusEl = document.getElementById("amazon-reg-status");

    if (statusEl) statusEl.innerHTML = "Initializing Amazon Music authorization...";

    try {
        const response = await fetch(`/api/mgmt/amazon/init?account=${encodeURIComponent(accountId)}`, {
            method: "POST"
        });
        if (!response.ok) {
            const err = await response.text();
            throw new Error(err || response.statusText);
        }

        const data = await response.json();
        const redirectUrl = data.redirectUrl;

        if (statusEl) {
            statusEl.innerHTML = `Amazon Music authorization window opened. <br/>If it didn't open, <a href="${redirectUrl}" target="_blank">click here to authorize</a>.`;
        }

        window.open(redirectUrl, "AmazonAuth", "width=600,height=800");

        let pollCount = 0;
        const interval = setInterval(async () => {
            pollCount++;
            if (pollCount > 60) {
                clearInterval(interval);
                return;
            }
            await fetchAccountDetails(accountId);
        }, 5000);

    } catch (error) {
        if (statusEl) statusEl.innerHTML = `<span style="color:red">Error: ${error.message}</span>`;
        console.error("Amazon link failed", error);
    }
}

async function fetchInteractionStats() {
    console.log("Fetching interaction stats...");
    try {
        const response = await fetch("/api/setup/interaction-stats");
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const stats = await response.json();
        console.log("Fetched interaction stats:", stats);

        document.getElementById("total-requests").innerText = stats.total_requests || stats.TotalRequests || 0;

        const statsContainer = document.getElementById("interaction-stats-container",);
        if (statsContainer) {
            statsContainer.style.display = "block";
        }

        const serviceList = document.getElementById("stats-by-service");
        serviceList.innerHTML = "";
        const byService = stats.by_service || stats.ByService;
        if (byService) {
            Object.entries(byService).forEach(([service, count]) => {
                const li = document.createElement("li");
                li.innerHTML = `<strong>${service || "unknown"}:</strong> ${count || 0} requests`;
                serviceList.appendChild(li);
            });
        }

        const sessionList = document.getElementById("stats-by-session");
        const sessionFilter = document.getElementById("filter-session");
        const currentFilter = sessionFilter.value;

        sessionList.innerHTML = "";
        sessionFilter.innerHTML = '<option value="">All Sessions</option>';

        const bySession = stats.by_session || stats.BySession;
        if (bySession) {
            // Sort by session ID (timestamp) descending
            const sortedSessions = Object.entries(bySession).sort((a, b) => {
                const sessionA = a[0] || "";
                const sessionB = b[0] || "";
                return sessionB.localeCompare(sessionA);
            });

            sortedSessions.forEach(([session, count]) => {
                // Session format is like 20260215-160705-99213
                // Try to make it more readable: 2026-02-15 16:07:05 (PID 99213)
                let sessionDisplay = session || "unknown";
                if (session && session.includes("-")) {
                    const parts = session.split("-");
                    if (parts.length >= 2) {
                        const date = parts[0]; // 20260215
                        const time = parts[1]; // 160705
                        if (date.length === 8 && time.length === 6) {
                            sessionDisplay = `${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)} ${time.substring(0, 2)}:${time.substring(2, 4)}:${time.substring(4, 6)}`;
                            if (parts.length >= 3) {
                                sessionDisplay += ` (PID ${parts[2]})`;
                            }
                        }
                    }
                }

                const li = document.createElement("li");
                li.innerHTML = `
                    <span class="session-info"><strong>${sessionDisplay}:</strong> ${count || 0} requests</span>
                    <div style="display: flex; gap: 5px;">
                        <button onclick="downloadSession('${session || ""}')" class="btn-info" style="font-size: 0.8em; padding: 2px 5px;">Download</button>
                        <button onclick="filterBySession('${session || ""}')" style="font-size: 0.8em; padding: 2px 5px;">Filter</button>
                        <button onclick="deleteSession('${session || ""}')" class="btn-danger" style="font-size: 0.8em; padding: 2px 5px;">Delete</button>
                    </div>
                `;
                sessionList.appendChild(li);

                const opt = document.createElement("option");
                opt.value = session || "";
                opt.innerText = sessionDisplay;
                sessionFilter.appendChild(opt);
            });

            sessionFilter.value = currentFilter;
        }
    } catch (error) {
        console.error("Failed to fetch interaction stats", error);
    }
}

function downloadSession(sessionId) {
    if (!sessionId) return;
    window.location.href = `/api/setup/interactions/sessions/${sessionId}/download`;
}

async function filterBySession(sessionId) {
    document.getElementById("filter-session").value = sessionId;
    fetchInteractions();
    const browseContainer = document.getElementById("browse-recordings");
    if (browseContainer) {
        browseContainer.scrollIntoView({behavior: "smooth"});
    }
}

async function deleteSession(sessionId) {
    if (!sessionId) return;
    if (!confirm(`Are you sure you want to delete session ${sessionId}?`)) {
        return;
    }

    try {
        const response = await fetch(`/api/setup/interactions/sessions/${sessionId}`, {
            method: "DELETE",
        });
        if (response.ok) {
            // If the deleted session was selected in the filter, clear the filter
            const sessionFilter = document.getElementById("filter-session");
            if (sessionFilter.value === sessionId) {
                sessionFilter.value = "";
                fetchInteractions();
            }
            fetchInteractionStats();
        } else {
            const err = await response.text();
            alert("Failed to delete session: " + err);
        }
    } catch (error) {
        alert("Error deleting session: " + error.message);
    }
}

async function cleanupSessions() {
    if (!confirm("Are you sure you want to cleanup old sessions? Only the 10 most recent ones will be kept.",)) {
        return;
    }

    try {
        const response = await fetch("/api/setup/interactions/sessions?keep=10", {
            method: "DELETE",
        });
        if (response.ok) {
            // Refresh everything
            document.getElementById("filter-session").value = "";
            fetchInteractionStats();
            fetchInteractions();
        } else {
            const err = await response.text();
            alert("Failed to cleanup sessions: " + err);
        }
    } catch (error) {
        alert("Error cleaning up sessions: " + error.message);
    }
}

async function fetchInteractions() {
    console.log("Fetching interactions...");
    const session = document.getElementById("filter-session").value;
    const category = document.getElementById("filter-category").value;
    const since = document.getElementById("filter-since").value;

    let url = "/api/setup/interactions";
    const params = [];
    if (session) params.push(`session=${encodeURIComponent(session)}`);
    if (category) params.push(`category=${encodeURIComponent(category)}`);
    if (since) params.push(`since=${encodeURIComponent(since)}`);
    if (params.length > 0) url += "?" + params.join("&");

    try {
        const response = await fetch(url);
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const interactions = await response.json();
        console.log("Fetched interactions:", interactions);
        const list = document.getElementById("interactions-list");
        if (!list) {
            console.error("Could not find interactions-list element");
            return;
        }

        // Show the parent summary box if it was hidden
        const browseContainer = list.closest(".summary-box");
        if (browseContainer) {
            browseContainer.style.display = "block";
        }

        list.innerHTML = "";

        if (!interactions || interactions.length === 0) {
            list.innerHTML = '<tr><td colspan="8" style="padding: 20px; text-align: center; color: #666;">No interactions found for current filters.</td></tr>';
            return;
        }

        // Default sort: Session desc, then Counter asc
        // If a specific session is selected, sort primarily by counter asc
        interactions.sort((a, b) => {
            const sessionA = a.session || a.Session || "";
            const sessionB = b.session || b.Session || "";
            if (sessionA !== sessionB) {
                return sessionB.localeCompare(sessionA);
            }
            const counterA = a.counter || a.Counter || 0;
            const counterB = b.counter || b.Counter || 0;
            return counterA - counterB;
        });

        interactions.forEach((i) => {
            const tr = document.createElement("tr");
            tr.style.borderBottom = "1px solid #eee";

            const counter = i.counter || i.Counter || 0;
            const timestamp = i.timestamp || i.Timestamp || "";
            const method = i.method || i.Method || "";
            const path = i.path || i.Path || "";
            const status = i.status || i.Status || "";
            const category = i.category || i.Category || "";
            const session = i.session || i.Session || "";
            const file = i.file || i.File || "";
            const scmudcData = i.scmudc_data || i.SCMUDCData || null;

            let statusClass = "";
            if (status >= 200 && status < 300) statusClass = "status-success"; else if (status >= 400) statusClass = "status-error";

            // Create SCMUDC event details
            let eventDetails = "";
            if (scmudcData) {
                const originIcon = getOriginIcon(scmudcData.origin);
                const actionIcon = getActionIcon(scmudcData.action);
                const truncatedCommand = scmudcData.command && scmudcData.command.length > 30 ? scmudcData.command.substring(0, 27) + "..." : scmudcData.command || "";

                eventDetails = `<span style="font-size: 0.9em;">
                    ${originIcon} ${actionIcon} ${truncatedCommand}
                    ${scmudcData.decoded_data ? '<span style="cursor: pointer;" onclick="showSCMUDCDetails(\'' + file + "')\">(...)</span>" : ""}
                </span>`;
            }

            tr.innerHTML = `
                <td style="padding: 8px; color: #888;">${counter}</td>
                <td style="padding: 8px; font-size: 0.8em; white-space: nowrap;">${timestamp}</td>
                <td style="padding: 8px; font-family: monospace;">${method}</td>
                <td style="padding: 8px; font-size: 0.9em;">${path}</td>
                <td style="padding: 8px;"><span class="badge ${statusClass}">${status || "???"}</span></td>
                <td style="padding: 8px;"><span class="badge category-${category}">${category}</span></td>
                <td style="padding: 8px;">${eventDetails}</td>
                <td style="padding: 8px;"><button onclick="viewInteraction('${file}')">View</button></td>
            `;
            list.appendChild(tr);
        });
    } catch (error) {
        console.error("Failed to fetch interactions", error);
    }
}

// Helper functions for SCMUDC event display
function getOriginIcon(origin) {
    const icons = {
        gabbo: "📱", // SoundTouch App
        console: "🎛️", // Device Hardware
        device: "🔄", // Internal System
    };
    return icons[origin] || "❓";
}

function getActionIcon(action) {
    const icons = {
        "play-pressed": "▶️",
        "pause-pressed": "⏸️",
        "power-pressed": "⚡",
        "preset-pressed": "⭐",
        "play-item": "🎵",
        "skip-forward-pressed": "⏭️",
        "stop-pressed": "⏹️",
    };
    return icons[action] || "🔘";
}

function showSCMUDCDetails(file) {
    // Find the interaction data for this file
    fetch("/api/setup/interactions")
        .then((response) => response.json())
        .then((interactions) => {
            const interaction = interactions.find((i) => (i.file || i.File) === file);
            if (interaction && interaction.scmudc_data) {
                displaySCMUDCPopover(interaction.scmudc_data);
            }
        })
        .catch((error) => {
            console.error("Failed to fetch SCMUDC details:", error);
        });
}

function displaySCMUDCPopover(scmudcData) {
    const modal = document.createElement("div");
    modal.style.cssText = `
        position: fixed; top: 0; left: 0; right: 0; bottom: 0;
        background: rgba(0,0,0,0.5); z-index: 1000;
        display: flex; align-items: center; justify-content: center;
    `;

    const content = document.createElement("div");
    content.style.cssText = `
        background: white; padding: 20px; border-radius: 8px;
        max-width: 600px; max-height: 80%; overflow-y: auto;
        box-shadow: 0 4px 6px rgba(0,0,0,0.1);
    `;

    let html = `
        <h3>SCMUDC Event Details</h3>
        <p><strong>Origin:</strong> ${getOriginDescription(scmudcData.origin)} (${scmudcData.origin})</p>
        <p><strong>Action:</strong> ${scmudcData.action}</p>
        <p><strong>Summary:</strong> ${scmudcData.summary}</p>
    `;

    if (scmudcData.decoded_data) {
        html += `
            <h4>Decoded Content:</h4>
            <p><strong>Source:</strong> ${scmudcData.decoded_data.content_type}</p>
            <p><strong>Item:</strong> ${scmudcData.decoded_data.item_name}</p>
        `;

        if (scmudcData.decoded_data.source_account) {
            html += `<p><strong>Account:</strong> ${scmudcData.decoded_data.source_account}</p>`;
        }

        if (scmudcData.decoded_data.artwork_url) {
            html += `<p><strong>Artwork:</strong> <a href="${scmudcData.decoded_data.artwork_url}" target="_blank">View</a></p>`;
        }

        if (scmudcData.decoded_data.is_presetable) {
            html += `<p><strong>Presetable:</strong> Yes</p>`;
        }

        if (scmudcData.decoded_data.xml_content) {
            html += `
                <h4>Full XML Content:</h4>
                <pre style="background: #f4f4f4; padding: 10px; border-radius: 4px; overflow-x: auto; font-size: 0.8em;">${escapeHtml(scmudcData.decoded_data.xml_content)}</pre>
            `;
        }
    }

    html += '<br><button onclick="this.closest(\'div\').remove()" style="padding: 8px 16px;">Close</button>';
    content.innerHTML = html;
    modal.appendChild(content);
    document.body.appendChild(modal);

    // Close on background click
    modal.addEventListener("click", (e) => {
        if (e.target === modal) modal.remove();
    });
}

function getOriginDescription(origin) {
    const descriptions = {
        gabbo: "SoundTouch App", console: "Device Hardware", device: "Internal System",
    };
    return descriptions[origin] || origin;
}

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

async function viewInteraction(file) {
    try {
        const response = await fetch(`/api/setup/interaction-content?file=${encodeURIComponent(file)}`,);
        const content = await response.text();

        document.getElementById("viewer-filename").innerText = file;
        document.getElementById("interaction-content").innerText = content;
        document.getElementById("interaction-viewer").style.display = "block";
        document
            .getElementById("interaction-viewer")
            .scrollIntoView({behavior: "smooth"});
    } catch (error) {
        alert("Failed to load interaction content: " + error);
    }
}

async function fetchDNSDiscoveries() {
    console.log("Fetching DNS discoveries...");
    try {
        const response = await fetch("/api/setup/dns-discoveries");
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        const discoveries = await response.json();
        console.log("Fetched DNS discoveries:", discoveries);
        const list = document.getElementById("dns-discoveries-list");
        if (!list) {
            console.error("Could not find dns-discoveries-list element");
            return;
        }

        list.innerHTML = "";

        if (!discoveries || discoveries.length === 0) {
            list.innerHTML = '<tr><td colspan="6" style="padding: 20px; text-align: center; color: #666;">No DNS queries discovered yet.</td></tr>';
            return;
        }

        discoveries.forEach((d) => {
            const tr = document.createElement("tr");
            tr.style.borderBottom = "1px solid #eee";

            const hostname = d.hostname || "";
            const lastSeen = d.last_seen || "";
            const count = d.query_count || 0;
            const isBose = d.is_bose_service ? "✅" : "❌";
            const category = d.is_intercepted ? "self" : "upstream";
            const remoteAddr = d.remote_addr || "unknown";

            tr.innerHTML = `
                <td style="padding: 8px; font-weight: bold;">${hostname}</td>
                <td style="padding: 8px; font-size: 0.85em;">${lastSeen}</td>
                <td style="padding: 8px; text-align: center;">${count}</td>
                <td style="padding: 8px; text-align: center;">${isBose}</td>
                <td style="padding: 8px;"><span class="badge category-${category}">${category}</span></td>
                <td style="padding: 8px; font-size: 0.8em; color: #666;">${remoteAddr}</td>
            `;
            list.appendChild(tr);
        });
    } catch (error) {
        console.error("Failed to fetch DNS discoveries", error);
    }
}

async function clearDNSDiscoveries() {
    if (!confirm("Are you sure you want to clear all DNS discovery logs?")) {
        return;
    }

    try {
        const response = await fetch("/api/setup/dns-discoveries", {
            method: "DELETE",
        });
        if (response.ok) {
            fetchDNSDiscoveries();
        } else {
            const err = await response.text();
            alert("Failed to clear DNS discoveries: " + err);
        }
    } catch (error) {
        alert("Error clearing DNS discoveries: " + error.message);
    }
}

function downloadDNSDiscoveries() {
    window.location.href = "/api/setup/dns-discoveries/download";
}

async function showDeviceEvents() {
    const overlay = document.getElementById("device-events-overlay");
    overlay.style.display = "block";
    overlay.scrollIntoView({behavior: "smooth"});

    // Ensure device selector is populated (handled by fetchDevices)
    // but if it's still empty, we can try to trigger a fetch
    const selector = document.getElementById("event-device-selector");
    if (selector.options.length <= 1) {
        fetchDevices();
    }
}

async function fetchDeviceEvents(deviceId) {
    if (!deviceId) return;

    const list = document.getElementById("events-list");
    list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">Loading events...</td></tr>';

    try {
        const response = await fetch(`/api/setup/devices/${encodeURIComponent(deviceId)}/events`);
        const data = await response.json();
        const events = data.events;

        list.innerHTML = "";
        if (!events || events.length === 0) {
            list.innerHTML = '<tr><td colspan="3" style="padding: 20px; text-align: center; color: #666;">No events found for this device.</td></tr>';
            return;
        }

        // Sort events by time descending
        events.sort((a, b) => (b.time || "").localeCompare(a.time || ""));

        events.forEach((e) => {
            const tr = document.createElement("tr");
            tr.style.borderBottom = "1px solid #eee";

            const time = e.time || "";
            const type = e.type || "";
            const data = JSON.stringify(e.data || {});

            tr.innerHTML = `
                <td style="padding: 8px; font-size: 0.8em; white-space: nowrap;">${time}</td>
                <td style="padding: 8px;"><span class="badge category-self">${type}</span></td>
                <td style="padding: 8px; font-size: 0.85em; font-family: monospace; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title='${data}'>${data}</td>
            `;
            list.appendChild(tr);
        });
    } catch (error) {
        list.innerHTML = `<tr><td colspan="3" style="padding: 20px; text-align: center; color: #f44336;">Error loading events: ${error.message}</td></tr>`;
    }
}
function formatBody(body, contentType) {
    if (!body) return "";
    contentType = (contentType || "").toLowerCase();
    if (contentType.includes("json")) {
        return formatJSON(body);
    }
    if (contentType.includes("xml")) {
        return formatXML(body);
    }
    return body;
}

function formatJSON(json) {
    if (!json) return "";
    try {
        const obj = typeof json === "string" ? JSON.parse(json) : json;
        return JSON.stringify(obj, null, 2);
    } catch (e) {
        return json;
    }
}

function formatXML(xml) {
    if (!xml) return "";
    try {
        let formatted = "";
        let reg = /(>)(<)(\/*)/g;
        xml = xml.replace(reg, "$1\r\n$2$3");
        let pad = 0;
        xml.split("\r\n").forEach(function (node) {
            let indent = 0;
            if (node.match(/.+<\/\w[^>]*>$/)) {
                indent = 0;
            } else if (node.match(/^<\/\w/)) {
                if (pad !== 0) pad -= 1;
            } else if (node.match(/^<\w[^>]*[^\/]>.*$/)) {
                indent = 1;
            } else {
                indent = 0;
            }

            let padding = "";
            for (let i = 0; i < pad; i++) padding += "  ";
            formatted += padding + node + "\r\n";
            pad += indent;
        });
        return formatted.trim();
    } catch (e) {
        return xml;
    }
}

document.addEventListener("DOMContentLoaded", async () => {
    const cfg = await fetchSettings();
    fetchVersion();
    fetchAnnouncements();
    const deviceCount = await fetchDevices();
    // Only sweep automatically on a cold start (no devices known yet). When
    // devices are already in the store, rely on the cached list plus the
    // periodic sweep and the explicit Discover button. Re-probing offline
    // speakers on every admin page load was slow and surprising.
    if (deviceCount === 0 && cfg?.discovery_enabled !== false) {
        triggerDiscovery();
    }

    const hash = window.location.hash.slice(1);
    const [tabId, extra] = hash.split('?');
    if (tabId && document.getElementById(tabId)) {
        openTab(null, tabId);
        if (tabId === "tab-migration") {
            selectMigrationDevice(extra);
        }
    }

    const syncBtn = document.getElementById("sync-now-btn");
    if (syncBtn) syncBtn.onclick = startSync;
});

async function addManualDevice() {
    const ip = document.getElementById("add-manual-ip").value.trim();
    if (!ip) {
        alert("Please enter an IP address");
        return;
    }

    try {
        const response = await fetch("/api/setup/devices", {
            method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({ip: ip}),
        });

        if (response.ok) {
            document.getElementById("add-manual-ip").value = "";
            fetchDevices();
        } else {
            const err = await response.text();
            alert("Failed to add device: " + err);
        }
    } catch (error) {
        alert("Error adding device: " + error.message);
    }
}

async function removeDevice(deviceId, name) {
    if (!confirm(`Are you sure you want to remove device "${name}"?`)) {
        return;
    }

    try {
        const response = await fetch(`/api/setup/devices/${encodeURIComponent(deviceId)}`, {
            method: "DELETE",
        });

        if (response.ok) {
            fetchDevices();
        } else {
            const err = await response.text();
            alert("Failed to remove device: " + err);
        }
    } catch (error) {
        alert("Error removing device: " + error.message);
    }
}

async function triggerDiscovery() {
    const indicator = document.getElementById("discovery-indicator");
    if (indicator) indicator.style.display = "inline";
    try {
        const response = await fetch("/api/setup/discover", {method: "POST"});
        if (!response.ok) {
            const err = await response.text();
            alert("Failed to start discovery: " + err);
            if (indicator) indicator.style.display = "none";
            return;
        }
        pollDiscoveryStatus();
    } catch (error) {
        console.error("Failed to trigger discovery", error);
        if (indicator) indicator.style.display = "none";
    }
}

async function pollDiscoveryStatus() {
    const indicator = document.getElementById("discovery-indicator");
    try {
        const response = await fetch("/api/setup/discovery-status");
        const data = await response.json();
        if (data.discovering) {
            setTimeout(pollDiscoveryStatus, 2000);
        } else {
            if (indicator) indicator.style.display = "none";
            fetchDevices();
        }
    } catch (error) {
        console.error("Failed to check discovery status", error);
        if (indicator) indicator.style.display = "none";
    }
}

async function updateDeviceInfo(deviceId, ip) {
    try {
        const response = await fetch("/api/setup/info/" + encodeURIComponent(deviceId));
        if (!response.ok) return;
        const info = await response.json();

        const rowId = "device-row-" + deviceId;
        const row = document.getElementById(rowId);
        if (row) {
            const nameEl = row.querySelector(".col-name");
            if (nameEl && info.name) nameEl.innerText = info.name;

            const modelEl = row.querySelector(".col-model");
            if (modelEl && info.type) modelEl.innerText = info.type;

            const serialEl = row.querySelector(".col-serial");
            if (serialEl && info.serialNumber) serialEl.innerText = info.serialNumber;

            const firmwareEl = row.querySelector(".col-firmware");
            if (firmwareEl && info.softwareVersion) firmwareEl.innerText = info.softwareVersion;

            const deviceIdEl = row.querySelector(".col-deviceid");
            if (deviceIdEl && info.deviceID) deviceIdEl.innerText = info.deviceID;

            const accountIdEl = row.querySelector(".col-accountid");
            if (accountIdEl) {
                if (info.margeAccountUUID) {
                    accountIdEl.innerText = info.margeAccountUUID;
                    accountIdEl.style.color = "#666";
                } else {
                    // Empty <margeAccountUUID/> in /info → speaker is
                    // either factory-reset or never paired. The
                    // Migration tab's wizard already detects this
                    // state and prompts for re-pairing; this badge
                    // surfaces the affordance from the devices list
                    // so users don't have to know to open the
                    // Migration tab cold. See issue #234.
                    accountIdEl.replaceChildren();
                    const badge = document.createElement("a");
                    badge.href = "#";
                    badge.onclick = (e) => {
                        e.preventDefault();
                        prepareMigration(deviceId);
                    };
                    badge.innerText = "⚠ Not paired — re-pair";
                    badge.style.color = "#c62828";
                    badge.title = "Open the Migration tab to re-pair this speaker (factory-reset or never paired).";
                    accountIdEl.appendChild(badge);
                }
            }
        }
    } catch (error) {
        console.warn("Failed to fetch live info for " + ip, error);
    }
}

async function showSummary(deviceId) {
    if (!deviceId) {
        document.getElementById("migration-summary").style.display = "none";
        return;
    }
    const newHash = '#tab-migration?' + deviceId;
    if (window.location.hash !== newHash) {
        history.pushState(null, '', newHash);
    }
    const targetUrl = document.getElementById("target-domain").value;

    // Per-field URL overrides (Plan card). The summary endpoint uses
    // these to render the planned-config diff so the user sees the
    // exact XML that the migration will write.
    const opts = readPlanURLOptions();

    const statusDiv = document.getElementById("status");
    statusDiv.style.display = "block";
    statusDiv.style.backgroundColor = "#ffffcc";

    const display = getDeviceDisplayName(deviceId);
    statusDiv.textContent = "Fetching summary for " + display + "...";

    const outputBox = document.getElementById("command-output-box");
    if (outputBox) outputBox.style.display = "none";

    let query = "?target_url=" + encodeURIComponent(targetUrl);
    for (let k in opts) {
        query += "&" + k + "=" + encodeURIComponent(opts[k]);
    }

    try {
        const response = await fetch("/api/setup/summary/" + encodeURIComponent(deviceId) + query,);
        if (!response.ok) {
            const errorText = await response.text();
            throw new Error(errorText);
        }
        const summary = await response.json();

        statusDiv.style.display = "none";

        const ip = summary.ip_address || deviceId;
        const finalDisplay = summary.device_name ? `${summary.device_name} (${ip})` : ip;
        document.getElementById("summary-device-display").innerText = finalDisplay;

        // Detect a device switch BEFORE we clobber the hidden id input.
        // When the user moves between speakers in the dropdown, any
        // per-device form state (plan-card URL edits, the soundcork
        // checkbox, the "saved" hint) belongs to the previous device
        // and would otherwise leak into the new device's preview.
        const prevDeviceId = document.getElementById("summary-device-id").value;
        const deviceChanged = prevDeviceId && prevDeviceId !== deviceId;

        // Keep deviceId hidden for subsequent calls
        document.getElementById("summary-device-id").value = deviceId;

        if (deviceChanged) {
            resetPlanCardForDeviceSwitch();
        }

        // Update table row if it exists
        const rowId = "device-row-" + deviceId;
        const row = document.getElementById(rowId);
        if (row) {
            const nameEl = row.querySelector(".col-name");
            if (nameEl && summary.device_name) nameEl.innerText = summary.device_name;

            const modelEl = row.querySelector(".col-model");
            if (modelEl && summary.device_model) modelEl.innerText = summary.device_model;

            const serialEl = row.querySelector(".col-serial");
            if (serialEl && summary.device_serial) serialEl.innerText = summary.device_serial;

            const firmwareEl = row.querySelector(".col-firmware");
            if (firmwareEl && summary.firmware_version) firmwareEl.innerText = summary.firmware_version;

            const deviceIdEl = row.querySelector(".col-deviceid");
            if (deviceIdEl && summary.device_id) deviceIdEl.innerText = summary.device_id;

            const accountIdEl = row.querySelector(".col-accountid");
            if (accountIdEl && summary.account_id) accountIdEl.innerText = summary.account_id;
        }

        renderMigrationState(summary, targetUrl);
        renderPlan(summary);
        renderPlanCurrentURLs(summary);
        renderPlanPairing(summary, deviceId);
        renderPreflightWarnings(summary);

        // Mirror the global target URL into the Plan card's input.
        // Reset the "saved" feedback to the persisted value so a
        // subsequent edit can flag it as unsaved without confusing
        // the user.
        const planInput = document.getElementById("plan-target-url");
        if (planInput) planInput.value = targetUrl || "";
        const planSaved = document.getElementById("plan-target-saved");
        if (planSaved) {
            planSaved.dataset.savedValue = targetUrl || "";
            planSaved.innerText = "";
        }

        // Pre-fill the Plan card per-field URL inputs with the canonical
        // defaults from target URL. fillPlanURLInputs preserves any
        // existing user edits across summary refreshes — the user has
        // to click "Reset to defaults" to clobber them, which matches
        // the Telnet pane's existing semantics.
        const soundcork = document.getElementById("plan-soundcork-mode") &&
            document.getElementById("plan-soundcork-mode").checked;
        fillPlanURLInputs(defaultServiceURLs(targetUrl, {soundcorkMode: soundcork}));

        const migrationStatus = document.getElementById("migration-status");
        const otherTarget = isMigratedToOtherTarget(summary);
        migrationStatus.innerText = summary.is_migrated ? "✅ Migrated to AfterTouch"
            : otherTarget ? "⚠️ Migrated (URL mismatch)"
            : "❌ Not Migrated";
        migrationStatus.style.color = summary.is_migrated ? "green" : otherTarget ? "orange" : "red";
        migrationStatus.style.fontWeight = "bold";

        // Trust CA Now button now lives inside the state card's CA / TLS
        // cell. Show only when SSH is reachable AND the CA isn't already
        // trusted on the device.
        const trustBtn = document.getElementById("trust-ca-btn");
        if (trustBtn) {
            const canTrust = summary.ssh_success && !summary.ca_cert_trusted;
            trustBtn.style.display = canTrust ? "inline-block" : "none";
            trustBtn.onclick = () => trustCA(deviceId, ip);
        }

        // The HTTPS Connection Test runs `curl` on the device via SSH —
        // upload-temp-CA + run-curl — so the panel is irrelevant when
        // SSH isn't reachable. The implicit telnet-poke + observation
        // alternative is on the roadmap but not implemented yet.
        const connectionTestPane = document.getElementById("connection-test");
        if (connectionTestPane) {
            connectionTestPane.style.display = summary.ssh_success ? "block" : "none";
        }

        // Stays visible either way (the user may still want to check it),
        // but the default Suggested Plan never needs HTTPS — only note it
        // as required when the Target URL itself is https://.
        const connectionTestNote = document.getElementById("connection-test-relevance-note");
        if (connectionTestNote) {
            if (isHttpsTarget(targetUrl)) {
                connectionTestNote.innerText = "Required for your current plan (HTTPS)";
                connectionTestNote.style.color = "#c62828";
            } else {
                connectionTestNote.innerText = "Optional for your current plan (HTTP)";
                connectionTestNote.style.color = "#666";
            }
        }

        const currentConfigElem = document.getElementById("current-config");
        currentConfigElem.innerText = summary.current_config;
        currentConfigElem.style.color = summary.ssh_success ? "black" : "red";

        document.getElementById("planned-config").innerText = summary.planned_config;
        document.getElementById("planned-resolv").innerText = summary.planned_resolv || "";

        const resolveErrEl = document.getElementById("resolve-ip-error");
        if (summary.resolve_ip_error) {
            document.getElementById("resolve-ip-error-msg").innerText = summary.resolve_ip_error;
            resolveErrEl.style.display = "block";
        } else {
            resolveErrEl.style.display = "none";
        }

        const currentResolvElem = document.getElementById("current-resolv-content");
        if (currentResolvElem) {
            currentResolvElem.innerText = summary.current_resolv_conf || "Not available";
        }

        const testUrlElem = document.getElementById("test-url");
        testUrlElem.innerText = summary.server_https_url || "N/A";
        const testResultDiv = document.getElementById("test-result");
        testResultDiv.style.display = "none";
        testResultDiv.innerText = "";

        document.getElementById("test-connection-explicit-btn").onclick = () => testConnection(deviceId, true);
        document.getElementById("test-connection-trusted-btn").onclick = () => testConnection(deviceId, false);
        document.getElementById("test-dns-btn").onclick = () => testDNSRedirection(deviceId);

        renderCustomizeForm(summary);

        // Migration and reboot are gated on having *some* transport
        // reachable. Telnet is enough for the telnet method (no SSH
        // required); the server returns a clear error if the user picks a
        // method whose transport isn't actually available.
        const anyTransport = summary.ssh_success || summary.telnet_reachable;

        // Stash device id/ip on the customize form so applyCustomPlan
        // can find them without globals.
        const customizeForm = document.getElementById("customize-form");
        if (customizeForm) {
            customizeForm.dataset.deviceId = deviceId;
            customizeForm.dataset.deviceIp = ip || "";
        }

        const revertBtn = document.getElementById("revert-migrate-btn");
        revertBtn.onclick = () => revert(deviceId, ip);
        revertBtn.disabled = !summary.ssh_success;
        revertBtn.style.display = summary.original_config ? "inline-block" : "none";

        const rebootBtn = document.getElementById("reboot-speaker-btn");
        rebootBtn.onclick = () => reboot(deviceId, ip);
        rebootBtn.disabled = !anyTransport;
        rebootBtn.style.border = "none"; // Reset border if it was set during migration

        const remoteBtn = document.getElementById("ensure-remote-btn");
        remoteBtn.onclick = () => ensureRemoteServices(deviceId, ip);
        remoteBtn.disabled = !summary.ssh_success;

        const removeRemoteBtn = document.getElementById("remove-remote-btn");
        removeRemoteBtn.onclick = () => removeRemoteServices(deviceId, ip);
        removeRemoteBtn.disabled = !summary.ssh_success || !summary.remote_services_enabled;

        document.getElementById("migration-summary").style.display = "block";
        document.getElementById("migration-summary").scrollIntoView();
    } catch (error) {
        statusDiv.style.backgroundColor = "#ffcccc";
        statusDiv.textContent = "Error fetching summary for " + display + ": " + error;
    }
}

function refreshSummary() {
    // Prefer the device id of the most recently shown summary; fall back
    // to the dropdown so the refresh button works even before the user
    // has loaded a summary once.
    const deviceId =
        document.getElementById("summary-device-id").value ||
        document.getElementById("migration-device-list").value;
    if (deviceId) {
        showSummary(deviceId);
    }
}

function showCommandOutput(result) {
    const outputBox = document.getElementById("command-output-box");
    const outputText = document.getElementById("command-output");
    if (outputBox && outputText && result.output) {
        outputBox.style.display = "block";
        outputText.innerText = result.output;
    } else if (outputBox) {
        outputBox.style.display = "none";
    }
}

async function revert(deviceId, ip) {
    if (!deviceId) {
        alert("Please select a device.");
        return;
    }
    const display = getDeviceDisplayName(deviceId);
    if (!confirm("Are you sure you want to revert " + display + " to Bose cloud defaults?",)) {
        return;
    }

    const summaryDiv = document.getElementById("migration-summary");
    summaryDiv.style.display = "none";

    const statusDiv = document.getElementById("status");
    statusDiv.style.display = "block";
    statusDiv.style.backgroundColor = "#ffffcc";
    statusDiv.textContent = "Reverting " + display + " to defaults...";

    try {
        const response = await fetch("/api/setup/revert/" + encodeURIComponent(deviceId), {method: "POST"},);
        const result = await response.json();
        showCommandOutput(result);
        if (result.ok) {
            statusDiv.style.backgroundColor = "#ccffcc";
            statusDiv.textContent = "Successfully started revert for " + display + ".";
        } else {
            statusDiv.style.backgroundColor = "#ffcccc";
            statusDiv.textContent = "Revert failed for " + display + ": " + (result.message || "Unknown error");
        }
    } catch (error) {
        statusDiv.style.backgroundColor = "#ffcccc";
        statusDiv.textContent = "Error reverting " + display + ": " + error;
    }
}

async function reboot(deviceId, ip) {
    if (!deviceId) {
        alert("Please select a device.");
        return;
    }
    const display = getDeviceDisplayName(deviceId);
    if (!confirm("Are you sure you want to reboot the speaker at " + display + "?")) {
        return;
    }

    // Pick the reboot transport from the Customize form's URL flip
    // selection — telnet there strongly implies the user is going
    // SSH-less, so reboot via telnet too. Fall back to ssh otherwise.
    const flip = (document.querySelector('input[name="customize-url-flip"]:checked') || {}).value || "";
    const rebootMethod = flip === "telnet" ? "telnet" : "ssh";

    const statusDiv = document.getElementById("status");
    statusDiv.style.display = "block";
    statusDiv.style.backgroundColor = "#ffffcc";
    // textContent avoids reinterpreting the (user-controlled) device name as HTML.
    statusDiv.textContent = "Rebooting " + display + " via " + rebootMethod + "...";

    try {
        const url = "/api/setup/reboot/" + encodeURIComponent(deviceId)
            + "?method=" + encodeURIComponent(rebootMethod);
        const response = await fetch(url, {method: "POST"},);
        const result = await response.json();
        showCommandOutput(result);
        if (result.ok) {
            statusDiv.style.backgroundColor = "#ccffcc";
            statusDiv.textContent = "Successfully started reboot for " + display + ".";
        } else {
            statusDiv.style.backgroundColor = "#ffcccc";
            statusDiv.textContent = "Reboot failed for " + display + ": " + (result.message || "Unknown error");
        }
    } catch (error) {
        statusDiv.style.backgroundColor = "#ffcccc";
        statusDiv.textContent = "Error rebooting " + display + ": " + error;
    }
}

// --- Plan card: account pairing ----------------------------------------

// renderPlanPairing populates the Account pairing section of the Plan
// card from the live summary (margeAccountUUID is in summary.account_id
// after populateDeviceInfo) and loads known IDs from the datastore.
async function renderPlanPairing(summary, deviceId) {
    const currentP = document.getElementById("plan-pair-current");
    const input = document.getElementById("plan-pair-id");
    const status = document.getElementById("plan-pair-status");

    if (currentP) {
        if (summary.is_paired && summary.account_id) {
            currentP.replaceChildren(
                document.createTextNode("Current: ✅ Paired (account "),
                Object.assign(document.createElement("strong"), {textContent: summary.account_id}),
                document.createTextNode(") — leave as-is to keep, or change the ID to re-pair"),
            );
        } else {
            currentP.replaceChildren(
                document.createTextNode("Current: ❌ Not paired (factory-reset or never paired) — set an ID to pair as part of Apply"),
            );
        }
    }

    if (input) {
        input.value = summary.account_id || "";
        input.dataset.currentId = summary.account_id || "";
        input.style.borderColor = "";
    }
    if (status) {
        status.innerText = "";
        status.style.color = "#666";
    }

    await loadPlanAccountSuggestions(deviceId);

    // Run the change handler once so the status hint reflects the
    // pre-filled value (matches current → "no pairing needed").
    onPlanPairIDChange();
}

// loadPlanAccountSuggestions populates the datastore-pick dropdown
// from /api/setup/account-id-suggestions. Quietly degrades on failure —
// the input + Generate button still work standalone.
async function loadPlanAccountSuggestions(deviceId) {
    const select = document.getElementById("plan-pair-existing");
    if (!select) return;

    select.replaceChildren();
    const placeholder = document.createElement("option");
    placeholder.value = "";
    placeholder.innerText = "— pick from datastore —";
    select.appendChild(placeholder);

    try {
        const resp = await fetch("/api/setup/account-id-suggestions/" + encodeURIComponent(deviceId));
        if (!resp.ok) return;
        const data = await resp.json();
        for (const id of data.known || []) {
            const opt = document.createElement("option");
            opt.value = String(id);
            opt.textContent = String(id);
            select.appendChild(opt);
        }
    } catch (e) { /* best-effort */ }
}

// onPlanPairPick mirrors the dropdown choice into the input and
// triggers validation.
function onPlanPairPick() {
    const select = document.getElementById("plan-pair-existing");
    const input = document.getElementById("plan-pair-id");
    if (select && select.value && input) {
        input.value = select.value;
        onPlanPairIDChange();
    }
}

// generatePlanAccountID picks a random 7-digit ID, avoiding values
// already shown in the dropdown so we don't accidentally collide with
// known datastore entries on this machine.
function generatePlanAccountID() {
    const input = document.getElementById("plan-pair-id");
    if (!input) return;
    const select = document.getElementById("plan-pair-existing");
    const known = new Set();
    if (select) {
        for (const o of select.options) if (o.value) known.add(o.value);
    }
    for (let i = 0; i < 32; i++) {
        const n = Math.floor(Math.random() * 9_000_000) + 1_000_000;
        const s = String(n);
        if (!known.has(s)) {
            input.value = s;
            onPlanPairIDChange();
            return;
        }
    }
    input.value = "";
    onPlanPairIDChange();
}

// onPlanPairIDChange validates the input and surfaces the implicit
// intent: empty/matches-current → no pairing step queued; differs
// → pairing step queued at Apply time.
function onPlanPairIDChange() {
    const input = document.getElementById("plan-pair-id");
    const status = document.getElementById("plan-pair-status");
    if (!input || !status) return;

    const v = (input.value || "").trim();
    const currentId = input.dataset.currentId || "";

    if (!v) {
        if (currentId) {
            status.innerText = "→ no pairing step (current ID retained)";
            status.style.color = "#666";
        } else {
            status.innerText = "→ no pairing step (device stays unpaired — pair via the official Bose app later if needed)";
            status.style.color = "#bf6900";
        }
        input.style.borderColor = "";
        return;
    }

    if (!/^\d{7}$/.test(v)) {
        status.innerText = "❌ Account ID must be exactly 7 digits";
        status.style.color = "#c62828";
        input.style.borderColor = "#c62828";
        return;
    }

    if (v === currentId) {
        status.innerText = "→ matches current ID — no pairing step needed";
        status.style.color = "#666";
        input.style.borderColor = "";
        return;
    }

    if (currentId) {
        status.innerText = `→ will re-pair from ${currentId} to ${v}`;
    } else {
        status.innerText = `→ will pair with ${v}`;
    }
    status.style.color = "#1976d2";
    input.style.borderColor = "";
}

// readPlanPairTarget returns null when no pairing step should run, or
// the {accountId, valid} for Apply orchestration. Empty input or input
// matching current = null (no step). Invalid input also returns null
// but with valid=false so callers can refuse to continue.
function readPlanPairTarget() {
    const input = document.getElementById("plan-pair-id");
    if (!input) return null;
    const v = (input.value || "").trim();
    if (!v) return null;
    if (!/^\d{7}$/.test(v)) return {accountId: v, valid: false};
    const currentId = input.dataset.currentId || "";
    if (v === currentId) return null;
    return {accountId: v, valid: true};
}

// pairAccount POSTs to /api/setup/pair-account and throws on failure so
// the Apply orchestrator's first-failure-aborts logic kicks in.
async function pairAccount(deviceId, accountId) {
    const url = `/api/setup/pair-account/${encodeURIComponent(deviceId)}?account_id=${encodeURIComponent(accountId)}`;
    const resp = await fetch(url, {method: "POST"});
    const result = await resp.json();
    if (!resp.ok || !result.ok) {
        throw new Error(result.error || result.message || `pair-account returned ${resp.status}`);
    }
}

// migrate runs a single migrate call against the backend. method is
// passed explicitly by the caller (suggested-plan or custom-plan
// orchestrator); the legacy migration-method dropdown is gone.
async function migrate(deviceId, ip, method) {
    if (!deviceId) {
        alert("Please select a device.");
        return;
    }

    if (!method) {
        alert("Migration method is required");
        return;
    }

    const targetUrl = document.getElementById("target-domain").value;

    // Refuse to migrate when the Plan card's per-field URLs don't pass
    // basic validation — typoed URLs would silently brick the speaker
    // until reverted, and the validator highlights exactly which field
    // is broken.
    if (!validatePlanURLs()) {
        const statusDiv = document.getElementById("status");
        statusDiv.style.display = "block";
        statusDiv.style.backgroundColor = "#ffcccc";
        statusDiv.textContent = "Cannot migrate: one or more service URLs are invalid (see the validation errors above).";
        return;
    }

    // Per-field URL overrides from the Plan card. The backend's
    // applyURLOverrides honors these for both XML and Telnet
    // migrations. Empty inputs are omitted so the canonical-default
    // fallback runs server-side.
    const opts = readPlanURLOptions();

    const summaryDiv = document.getElementById("migration-summary");
    summaryDiv.style.display = "none";

    const statusDiv = document.getElementById("status");
    statusDiv.style.display = "block";
    statusDiv.style.backgroundColor = "#ffffcc";
    const display = getDeviceDisplayName(deviceId);
    statusDiv.textContent = "Migrating " + display + " using " + method + "...";

    let query = "?method=" + encodeURIComponent(method) + "&target_url=" + encodeURIComponent(targetUrl);
    for (let k in opts) {
        query += "&" + k + "=" + encodeURIComponent(opts[k]);
    }

    try {
        const response = await fetch("/api/setup/migrate/" + encodeURIComponent(deviceId) + query, {method: "POST"},);
        const result = await response.json();
        showCommandOutput(result);
        if (result.ok) {
            statusDiv.style.backgroundColor = "#ccffcc";
            // replaceChildren keeps the user-controlled `display` outside any
            // HTML-parsing context, while preserving the intentional <strong>.
            statusDiv.replaceChildren(
                document.createTextNode("Successfully started migration for " + display + ". "),
                Object.assign(document.createElement("strong"), {
                    textContent: "Please reboot the device to activate the changes.",
                }),
            );

            // Make reboot button available and prominent. It lives in the
            // always-visible "Speaker controls" row (see #621 — it used to
            // be reachable only after expanding "Customize this migration"),
            // so no need to force any collapsed container open here.
            const rebootBtn = document.getElementById("reboot-speaker-btn");
            rebootBtn.style.display = "inline-block";
            rebootBtn.disabled = false;
            rebootBtn.style.border = "2px solid #000";

            // Re-show summary but with prominence on reboot
            summaryDiv.style.display = "block";
        } else {
            statusDiv.style.backgroundColor = "#ffcccc";
            statusDiv.textContent = "Migration failed for " + display + ": " + (result.message || "Unknown error");
        }
    } catch (error) {
        statusDiv.style.backgroundColor = "#ffcccc";
        statusDiv.textContent = "Error migrating " + display + ": " + error;
    }
}

async function trustCA(deviceId, ip) {
    if (!deviceId) {
        alert("Please select a device.");
        return;
    }
    const statusDiv = document.getElementById("status");
    statusDiv.style.display = "block";
    statusDiv.style.backgroundColor = "#ffffcc";
    const display = getDeviceDisplayName(deviceId);
    statusDiv.textContent = "Injecting Root CA into shared trust store on " + display + "...";

    try {
        const response = await fetch("/api/setup/trust-ca/" + encodeURIComponent(deviceId), {method: "POST"},);
        const result = await response.json();
        showCommandOutput(result);
        if (result.ok) {
            statusDiv.style.backgroundColor = "#ccffcc";
            statusDiv.textContent = "Successfully injected Root CA on " + display + ".";
            showSummary(deviceId); // Refresh to update status
        } else {
            statusDiv.style.backgroundColor = "#ffcccc";
            statusDiv.textContent = "Failed to trust CA on " + display + ": " + (result.message || "Unknown error");
        }
    } catch (error) {
        statusDiv.style.backgroundColor = "#ffcccc";
        statusDiv.textContent = "Error trusting CA on " + display + ": " + error;
    }
}

async function ensureRemoteServices(deviceId, ip) {
    if (!deviceId) {
        alert("Please select a device.");
        return;
    }
    const summaryDiv = document.getElementById("migration-summary");
    summaryDiv.style.display = "none";

    const statusDiv = document.getElementById("status");
    statusDiv.style.display = "block";
    statusDiv.style.backgroundColor = "#ffffcc";
    const display = getDeviceDisplayName(deviceId);
    statusDiv.textContent = "Ensuring remote services for " + display + "...";

    try {
        const response = await fetch("/api/setup/ensure-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
        const result = await response.json();
        showCommandOutput(result);
        if (result.ok) {
            statusDiv.style.backgroundColor = "#ccffcc";
            statusDiv.textContent = "Successfully ensured remote services for " + display + ".";
        } else {
            statusDiv.style.backgroundColor = "#ffcccc";
            statusDiv.textContent = "Failed to ensure remote services for " + display + ": " + (result.message || "Unknown error");
        }
    } catch (error) {
        statusDiv.style.backgroundColor = "#ffcccc";
        statusDiv.textContent = "Error ensuring remote services for " + display + ": " + error;
    }
}

async function removeRemoteServices(deviceId, ip) {
    if (!deviceId) {
        alert("Please select a device.");
        return;
    }
    const display = getDeviceDisplayName(deviceId);
    if (!confirm("Remove the remote_services file from " + display + "? SSH will be disabled after the next reboot.")) {
        return;
    }
    const summaryDiv = document.getElementById("migration-summary");
    summaryDiv.style.display = "none";

    const statusDiv = document.getElementById("status");
    statusDiv.style.display = "block";
    statusDiv.style.backgroundColor = "#ffffcc";
    statusDiv.textContent = "Removing remote services for " + display + "...";

    try {
        const response = await fetch("/api/setup/remove-remote-services/" + encodeURIComponent(deviceId), {method: "POST"},);
        const result = await response.json();
        showCommandOutput(result);
        if (result.ok) {
            statusDiv.style.backgroundColor = "#ccffcc";
            statusDiv.textContent = "Successfully removed remote services from " + display + ".";
        } else {
            statusDiv.style.backgroundColor = "#ffcccc";
            statusDiv.textContent = "Failed to remove remote services for " + display + ": " + (result.message || "Unknown error");
        }
    } catch (error) {
        statusDiv.style.backgroundColor = "#ffcccc";
        statusDiv.textContent = "Error removing remote services for " + display + ": " + error;
    }
}

async function backupConfig(deviceId, ip) {
    if (!deviceId) {
        alert("Please select a device.");
        return;
    }
    const statusDiv = document.getElementById("status");
    statusDiv.style.display = "block";
    statusDiv.style.backgroundColor = "#ffffcc";
    const display = getDeviceDisplayName(deviceId);
    statusDiv.textContent = "Creating backup for " + display + "...";

    try {
        const response = await fetch("/api/setup/backup/" + encodeURIComponent(deviceId), {method: "POST"},);
        const result = await response.json();
        showCommandOutput(result);
        if (result.ok) {
            statusDiv.style.backgroundColor = "#ccffcc";
            statusDiv.textContent = "Successfully created backup for " + display + ".";
            showSummary(deviceId); // Refresh
        } else {
            statusDiv.style.backgroundColor = "#ffcccc";
            statusDiv.textContent = "Backup failed for " + display + ": " + (result.message || "Unknown error");
        }
    } catch (error) {
        statusDiv.style.backgroundColor = "#ffcccc";
        statusDiv.textContent = "Error creating backup for " + display + ": " + error;
    }
}

async function testConnection(deviceId, useExplicitCA) {
    const testUrl = document.getElementById("test-url").innerText;
    const testResultDiv = document.getElementById("test-result");
    const display = getDeviceDisplayName(deviceId);

    testResultDiv.style.display = "block";
    testResultDiv.style.backgroundColor = "#f0f0f0";
    testResultDiv.style.color = "black";
    testResultDiv.innerText = "Running connection test from " + display + "...\n(This may take a few seconds)";

    try {
        const query = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=${useExplicitCA}`;
        const response = await fetch(`/api/setup/test-connection/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
        const result = await response.json();

        if (result.ok) {
            testResultDiv.style.backgroundColor = "#ccffcc";
            testResultDiv.innerText = "✅ " + result.message + "\n\nOutput:\n" + result.output;
        } else {
            testResultDiv.style.backgroundColor = "#ffcccc";
            testResultDiv.innerText = "❌ Connection failed: " + result.message + "\n\nOutput:\n" + result.output;
        }
    } catch (error) {
        testResultDiv.style.backgroundColor = "#ffcccc";
        testResultDiv.innerText = "❌ Error triggering test: " + error;
    }
}

async function testDNSRedirection(deviceId) {
    const targetUrl = document.getElementById("target-domain").value;
    const testResultDiv = document.getElementById("dns-test-result");
    const display = getDeviceDisplayName(deviceId);

    testResultDiv.style.display = "block";
    testResultDiv.style.backgroundColor = "#f0f0f0";
    testResultDiv.style.color = "black";
    testResultDiv.innerText = "Running DNS redirection test from " + display + "...\n(This may take a few seconds)";

    try {
        const query = `?target_url=${encodeURIComponent(targetUrl)}`;
        const response = await fetch(`/api/setup/test-dns/${encodeURIComponent(deviceId)}${query}`, {method: "POST"},);
        const result = await response.json();

        if (result.ok) {
            testResultDiv.style.backgroundColor = "#ccffcc";
            testResultDiv.innerText = "✅ " + result.message + "\n\nOutput:\n" + result.output;
        } else {
            testResultDiv.style.backgroundColor = "#ffcccc";
            testResultDiv.innerText = "❌ Test failed: " + result.message + "\n\nOutput:\n" + result.output;
        }
    } catch (error) {
        testResultDiv.style.backgroundColor = "#ffcccc";
        testResultDiv.innerText = "❌ Error triggering test: " + error;
    }
}

// onPlanTargetURLChange mirrors the migration tab's plan-target-url
// input back into the canonical #target-domain input on the Settings
// tab. Keeps the rest of the migration flow (which still reads
// #target-domain) working transparently.
function onPlanTargetURLChange() {
    const v = document.getElementById("plan-target-url").value;
    const canonical = document.getElementById("target-domain");
    if (canonical) canonical.value = v;
    // Hide stale "saved" feedback now that the value diverged from the
    // last persisted state.
    const saved = document.getElementById("plan-target-saved");
    if (saved && saved.dataset.savedValue && saved.dataset.savedValue !== v) {
        saved.innerText = "✏️ unsaved change — click \"Save as default\" to persist";
        saved.style.color = "#bf6900";
    }

    // Re-derive the four service URL fields from the new Target URL, same
    // as the initial pre-fill on summary render. fillPlanURLInputs still
    // only overwrites fields the user hasn't hand-edited (tracked via
    // dataset.autofilled), so this doesn't clobber genuinely manual edits.
    // Without this, changing Target Domain to e.g. localhost left the four
    // fields pointed at a stale default with no warning until the user
    // edited them by hand (#621 follow-up).
    const soundcork = document.getElementById("plan-soundcork-mode") &&
        document.getElementById("plan-soundcork-mode").checked;
    fillPlanURLInputs(defaultServiceURLs(v, {soundcorkMode: soundcork}));
}

// onPlanURLFieldEdited marks a Plan-card URL input as manually edited so
// fillPlanURLInputs stops treating it as an auto-fillable default, then
// re-validates. Wired from each of the four fields' oninput instead of
// calling validatePlanURLs() directly.
function onPlanURLFieldEdited(el) {
    el.dataset.autofilled = "";
    validatePlanURLs();
}

// saveTargetURLAsDefault posts the current plan-target-url value to
// /api/setup/settings as the global default. Other settings on that
// endpoint are read first and re-posted unchanged ("***" secrets and
// other fields are preserved verbatim — the backend treats "***" as
// "unchanged").
async function saveTargetURLAsDefault() {
    const newURL = document.getElementById("plan-target-url").value.trim();
    if (!newURL) {
        alert("Target URL is empty");
        return;
    }

    const btn = document.getElementById("plan-save-default-btn");
    const saved = document.getElementById("plan-target-saved");
    btn.disabled = true;

    try {
        const cur = await (await fetch("/api/setup/settings")).json();
        cur.server_url = newURL;

        const resp = await fetch("/api/setup/settings", {
            method: "POST",
            headers: {"Content-Type": "application/json"},
            body: JSON.stringify(cur),
        });
        if (!resp.ok) throw new Error(await resp.text());

        if (saved) {
            saved.innerText = "✅ Saved. Restart the service to apply changes that need a fresh certificate set.";
            saved.style.color = "green";
            saved.dataset.savedValue = newURL;
        }
    } catch (e) {
        if (saved) {
            saved.innerText = "❌ Failed to save: " + e.message;
            saved.style.color = "red";
        }
    } finally {
        btn.disabled = false;
    }
}

// defaultServiceURLs returns the canonical four URLs derived from a
// service base, with an optional Soundcork-mode adjustment that
// appends /marge to margeServerUrl. Mirrors setup.defaultTelnetURLs
// (Go) for the soundtouch-service case; the soundcorkMode option is
// the UI-side equivalent of the documented soundcork recipe — see
// docs/analysis/TELNET-MIGRATION-METHOD.md §2.1.
function defaultServiceURLs(targetUrl, options = {}) {
    const base = (targetUrl || "").replace(/\/+$/, "");
    return {
        marge: options.soundcorkMode ? base + "/marge" : base,
        stats: base,
        sw_update: base + "/updates/soundtouch",
        bmx: base + "/bmx/registry/v1/services",
    };
}

// fillPlanURLInputs writes the four URLs into the Plan card inputs.
// force=true overwrites existing values (used by Reset and the
// Soundcork toggle); force=false only fills empties and fields still
// flagged dataset.autofilled=true (used on summary render and on Target
// URL changes, so manual edits survive but a still-default value tracks
// Target URL). Every field this function writes to is (re-)flagged
// autofilled; onPlanURLFieldEdited clears the flag the moment a user
// types into a field directly.
function fillPlanURLInputs(urls, {force = false} = {}) {
    const fields = [
        ["plan-marge-url", urls.marge],
        ["plan-stats-url", urls.stats],
        ["plan-sw_update-url", urls.sw_update],
        ["plan-bmx-url", urls.bmx],
    ];
    for (const [id, value] of fields) {
        const el = document.getElementById(id);
        if (!el) continue;
        if (force || !el.value || el.dataset.autofilled === "true") {
            el.value = value;
            el.dataset.autofilled = "true";
        }
    }
    validatePlanURLs();
}

// readPlanURLOptions returns the four inputs as the option keys the
// backend expects (marge_url / stats_url / sw_update_url / bmx_url).
// Empty inputs are omitted so the service-side canonical-default
// fallback runs.
function readPlanURLOptions() {
    const out = {};
    const pairs = [
        ["marge_url", "plan-marge-url"],
        ["stats_url", "plan-stats-url"],
        ["sw_update_url", "plan-sw_update-url"],
        ["bmx_url", "plan-bmx-url"],
    ];
    for (const [optKey, elemId] of pairs) {
        const el = document.getElementById(elemId);
        if (el && el.value) out[optKey] = el.value.trim();
    }
    return out;
}

// validateURL classifies a string as an OK service URL.
// Empty value is valid (means "use the canonical default"). Otherwise
// the URL must parse, the scheme must be http or https, the hostname
// must be non-empty, and we flag loopback hostnames because they only
// reach AfterTouch in the on-device-install case (AfterTouch running
// on the speaker itself). For the typical "AfterTouch on a separate
// host" deployment, the speaker can't reach loopback on a different
// machine, so the URL must be a LAN-reachable IP or hostname.
//
// referenceOrigin (optional) is the plan's own Target URL origin. A
// loopback value that matches it is exempted from the warning: it means
// this is exactly what the service itself is already configured to
// answer as (e.g. an on-device install's `http://localhost:8000`,
// auto-set since #546), not a mistaken paste. Without this exemption,
// every on-device install's Suggested Plan fails validation by
// default and silently disables Apply/Pre-flight before the user does
// anything (#546 follow-up, reported via #621).
function validateURL(value, referenceOrigin) {
    const v = (value || "").trim();
    if (!v) return {ok: true, error: ""};

    let u;
    try {
        u = new URL(v);
    } catch (e) {
        return {ok: false, error: "not a valid URL"};
    }

    if (u.protocol !== "http:" && u.protocol !== "https:") {
        return {ok: false, error: "scheme must be http or https"};
    }

    if (!u.hostname) return {ok: false, error: "hostname is empty"};

    const isLoopback = u.hostname === "localhost" || u.hostname === "127.0.0.1";
    if (isLoopback && u.origin !== referenceOrigin) {
        return {ok: false, error: "loopback URL — speakers can only reach this if AfterTouch is installed on the speaker itself (on-device install). For the typical multi-device setup, use a LAN-reachable IP or hostname."};
    }

    return {ok: true, error: ""};
}

// validatePlanURLs validates each of the four Plan-card inputs. Returns
// true when all are valid. Surfaces inline errors in the validation
// box, colours invalid input borders red, and disables the Apply
// Suggested Plan button when anything is invalid.
function validatePlanURLs() {
    const fields = [
        ["margeServerUrl", "plan-marge-url"],
        ["statsServerUrl", "plan-stats-url"],
        ["swUpdateUrl", "plan-sw_update-url"],
        ["bmxRegistryUrl", "plan-bmx-url"],
    ];

    const targetUrl = (document.getElementById("plan-target-url") || {}).value || "";
    let referenceOrigin = "";
    try {
        referenceOrigin = new URL(targetUrl).origin;
    } catch (e) {
        // Target URL isn't a valid absolute URL yet (e.g. empty) — leave
        // referenceOrigin empty, so a loopback field simply won't match
        // it and falls back to today's warning, same as before this
        // exemption existed.
    }

    const errors = [];

    for (const [name, elemId] of fields) {
        const el = document.getElementById(elemId);
        if (!el) continue;
        const v = validateURL(el.value, referenceOrigin);
        el.style.borderColor = v.ok ? "" : "#c62828";
        if (!v.ok) errors.push(`${name}: ${v.error}`);
    }

    const errorBox = document.getElementById("plan-url-validation");
    if (errorBox) {
        if (errors.length === 0) {
            errorBox.style.display = "none";
            errorBox.replaceChildren();
        } else {
            errorBox.replaceChildren();
            const ul = document.createElement("ul");
            ul.style.cssText = "margin: 0; padding-left: 1.2em";
            for (const e of errors) {
                const li = document.createElement("li");
                li.innerText = e;
                ul.appendChild(li);
            }
            errorBox.appendChild(ul);
            errorBox.style.display = "block";
        }
    }

    // Apply Suggested Plan is gated on URL validity (in addition to its
    // existing data-method check). The dataset.method field is set by
    // renderPlan based on what computeSuggestedPlan returned. The
    // Pre-flight sibling shares the same gate so the two buttons
    // enable/disable in lockstep.
    const applyBtn = document.getElementById("plan-apply-btn");
    const preflightBtn = document.getElementById("plan-preflight-btn");
    const noPlan = !applyBtn || !applyBtn.dataset.method;
    if (applyBtn) applyBtn.disabled = noPlan || errors.length > 0;
    if (preflightBtn) preflightBtn.disabled = noPlan || errors.length > 0;

    // Refresh the client-side XML preview so the Customize panel's
    // Planned Config pane tracks every keystroke without a backend
    // round-trip.
    renderPlannedXMLPreview();

    return errors.length === 0;
}

// resetPlanURLsToDefaults wipes manual edits and reapplies the
// canonical defaults derived from the current target URL, honoring
// the Soundcork-mode checkbox.
function resetPlanURLsToDefaults() {
    const targetUrl = document.getElementById("plan-target-url").value;
    const soundcork = document.getElementById("plan-soundcork-mode") &&
        document.getElementById("plan-soundcork-mode").checked;
    fillPlanURLInputs(defaultServiceURLs(targetUrl, {soundcorkMode: soundcork}), {force: true});
}

// resetPlanCardForDeviceSwitch clears every per-device form state on
// the Plan card so the previously-edited values don't leak into the
// new device's preview. Called from showSummary the moment the user
// picks a different speaker in the dropdown.
//
// Doesn't refill defaults itself — that happens further down in
// showSummary via fillPlanURLInputs(defaults), which writes only into
// empty inputs.
function resetPlanCardForDeviceSwitch() {
    const inputs = ["plan-marge-url", "plan-stats-url", "plan-sw_update-url", "plan-bmx-url"];
    for (const id of inputs) {
        const el = document.getElementById(id);
        if (el) {
            el.value = "";
            el.style.borderColor = "";
        }
    }

    const soundcork = document.getElementById("plan-soundcork-mode");
    if (soundcork) soundcork.checked = false;

    const saved = document.getElementById("plan-target-saved");
    if (saved) {
        saved.innerText = "";
        delete saved.dataset.savedValue;
    }

    const validationBox = document.getElementById("plan-url-validation");
    if (validationBox) {
        validationBox.style.display = "none";
        validationBox.replaceChildren();
    }

    const applyStatus = document.getElementById("plan-apply-status");
    if (applyStatus) applyStatus.innerText = "";

    const customizeStatus = document.getElementById("customize-apply-status");
    if (customizeStatus) customizeStatus.innerText = "";

    // Pairing input — clear so the renderPlanPairing call later in
    // showSummary populates it from the new device's account_id
    // rather than the previous device's pre-typed value.
    const pairInput = document.getElementById("plan-pair-id");
    if (pairInput) {
        pairInput.value = "";
        pairInput.style.borderColor = "";
        delete pairInput.dataset.currentId;
    }
    const pairStatus = document.getElementById("plan-pair-status");
    if (pairStatus) pairStatus.innerText = "";
}

// toggleSoundcorkMode reapplies defaults so the /marge suffix appears
// or disappears on margeServerUrl. Manual edits are intentionally
// reset — the checkbox is a deliberate "give me the canonical
// soundcork shape" affordance, not a soft hint.
function toggleSoundcorkMode() {
    resetPlanURLsToDefaults();
}

// computeSuggestedPlan picks the most conservative migration recipe
// for the device based on which transports are reachable. The chosen
// default is XML over SSH with HTTP — fewest moving parts, no DNS or
// CA install required. Telnet is the fallback when SSH is unavailable.
function computeSuggestedPlan(summary) {
    if (summary.is_migrated) {
        return {
            available: false,
            summary: "✅ Already migrated to AfterTouch",
            note: "Use Customize below to make further changes, or click Reboot if needed.",
        };
    }

    if (summary.ssh_success) {
        return {
            available: true,
            method: "xml",
            summary: "XML migration over SSH (HTTP)",
            steps: [
                "Backup existing /opt/Bose/etc/SoundTouchSdkPrivateCfg.xml",
                "Write new XML pointing at AfterTouch (HTTP, no CA install)",
                "Reboot the device to load the new configuration",
            ],
        };
    }

    if (summary.telnet_reachable) {
        return {
            available: true,
            method: "telnet",
            summary: "Telnet (Port 17000) URL flip (HTTP)",
            steps: [
                "Connect to the diagnostic shell on TCP/17000",
                "Write the four URLs via `sys configuration` + `envswitch boseurls set`",
                "Reboot the device via telnet to persist the change",
            ],
        };
    }

    return {
        available: false,
        summary: "❌ No supported transport reachable",
        note: "Neither SSH nor Telnet:17000 answers. Use the official Bose app to pair before EOS, or unlock remote_services via USB stick to enable SSH.",
    };
}

// renderPlannedXMLPreview rewrites the #planned-config <pre> from the
// current Plan card inputs so the preview tracks the user's edits live
// — without a backend round-trip. The output mirrors what
// migrateViaXML actually writes: target-derived defaults, with each
// per-field override (marge_url / stats_url / sw_update_url /
// bmx_url) substituted in. The three boolean fields are constants
// matching the Go-side PrivateCfg{} the migration constructs.
//
// Pure client-side once GetMigrationSummary has populated everything,
// so the preview updates on every keystroke and stays in sync with
// validatePlanURLs's input border / Apply-button state.
function renderPlannedXMLPreview() {
    const targetUrl = (document.getElementById("plan-target-url") || {}).value || "";
    const overrides = readPlanURLOptions();
    const defaults = defaultServiceURLs(targetUrl);

    const marge    = overrides.marge_url     || defaults.marge;
    const stats    = overrides.stats_url     || defaults.stats;
    const swUpdate = overrides.sw_update_url || defaults.sw_update;
    const bmx      = overrides.bmx_url       || defaults.bmx;

    const xml = [
        '<?xml version="1.0" encoding="utf-8"?>',
        '<SoundTouchSdkPrivateCfg>',
        `  <margeServerUrl>${escapeForXMLText(marge)}</margeServerUrl>`,
        `  <statsServerUrl>${escapeForXMLText(stats)}</statsServerUrl>`,
        `  <swUpdateUrl>${escapeForXMLText(swUpdate)}</swUpdateUrl>`,
        '  <usePandoraProductionServer>true</usePandoraProductionServer>',
        '  <isZeroconfEnabled>true</isZeroconfEnabled>',
        '  <saveMargeCustomerReport>false</saveMargeCustomerReport>',
        `  <bmxRegistryUrl>${escapeForXMLText(bmx)}</bmxRegistryUrl>`,
        '</SoundTouchSdkPrivateCfg>',
    ].join("\n");

    const el = document.getElementById("planned-config");
    if (el) el.innerText = xml;
}

// escapeForXMLText escapes the characters that have special meaning
// inside XML text content. URLs typically only need & escaping, but
// covering < and > too keeps the preview honest if the user pastes
// something exotic.
function escapeForXMLText(s) {
    return String(s)
        .replace(/&/g, "&amp;")
        .replace(/</g, "&lt;")
        .replace(/>/g, "&gt;");
}

// renderPlanCurrentURLs populates the "Current on Device" cells in the
// Service URLs table from whichever transport answered (telnet
// getpdo, falling back to the SSH-read XML config).
function renderPlanCurrentURLs(summary) {
    const live = parseTelnetVerifiedConfig(summary.telnet_verified_config || "");
    const xml = summary.parsed_current_config || {};
    const cells = [
        ["plan-current-marge",     live.margeServerUrl     || xml.margeServerUrl],
        ["plan-current-stats",     live.statsServerUrl     || xml.statsServerUrl],
        ["plan-current-sw_update", live.swUpdateUrl        || xml.swUpdateUrl],
        ["plan-current-bmx",       live.bmxRegistryUrl     || xml.bmxRegistryUrl],
    ];
    for (const [id, value] of cells) {
        const el = document.getElementById(id);
        if (el) el.innerText = value || "—";
    }
}

// renderPlan populates the Plan card: capabilities header + suggested
// plan box. Reads only fields the backend already exposes; the
// suggested-plan logic lives in computeSuggestedPlan.
function renderPlan(summary) {
    // Capabilities — list which transports are available on the device
    // and which migration recipes AfterTouch can offer given those
    // transports.
    const detectedEl = document.getElementById("plan-detected");
    if (detectedEl) {
        detectedEl.replaceChildren();
        detectedEl.appendChild(transportChip("SSH", summary.ssh_success));
        detectedEl.appendChild(document.createTextNode("   "));
        detectedEl.appendChild(transportChip("Telnet:17000", summary.telnet_reachable));
    }

    const possibleEl = document.getElementById("plan-possible");
    if (possibleEl) {
        const offers = [];
        if (summary.ssh_success) {
            offers.push("XML migration", "DNS interception (resolv.conf hook)", "CA install");
        }
        if (summary.telnet_reachable) {
            offers.push("Telnet URL flip");
        }
        possibleEl.innerText = offers.length ? offers.join(" · ") : "(no path — see suggested plan below)";
    }

    // Suggested plan
    const plan = computeSuggestedPlan(summary);
    const summaryEl = document.getElementById("plan-suggestion-summary");
    const stepsEl = document.getElementById("plan-suggestion-steps");
    const applyBtn = document.getElementById("plan-apply-btn");
    const statusEl = document.getElementById("plan-apply-status");
    const box = document.getElementById("plan-suggestion");

    if (summaryEl) summaryEl.innerText = plan.summary;

    if (stepsEl) {
        stepsEl.replaceChildren();
        if (plan.steps) {
            for (const s of plan.steps) {
                const li = document.createElement("li");
                li.innerText = s;
                stepsEl.appendChild(li);
            }
        }
        if (plan.note) {
            const li = document.createElement("li");
            li.style.cssText = "list-style: none; margin-left: -1em; color: #555";
            li.innerText = plan.note;
            stepsEl.appendChild(li);
        }
    }

    if (applyBtn) {
        applyBtn.disabled = !plan.available;
        applyBtn.dataset.method = plan.method || "";
    }

    if (statusEl) statusEl.innerText = "";

    // Tint the box neutrally for non-actionable suggestions so the
    // green "ready to apply" framing is reserved for the case where
    // we actually have a plan to apply.
    if (box) {
        if (plan.available) {
            box.style.background = "#f1f8e9";
            box.style.borderColor = "#c8e6c9";
        } else {
            box.style.background = "#f5f5f5";
            box.style.borderColor = "#ddd";
        }
    }
}

// transportChip returns "<name>: ✅ available" or "<name>: ❌ unreachable"
// as a coloured DOM fragment, for the Capabilities row.
function transportChip(name, ok) {
    const wrap = document.createElement("span");
    const label = document.createElement("strong");
    label.textContent = name + ": ";
    wrap.appendChild(label);
    const status = document.createElement("span");
    status.innerText = ok ? "✅" : "❌";
    status.style.color = ok ? "green" : "red";
    wrap.appendChild(status);
    return wrap;
}

// --- Pre-flight panel: rendering helpers ----------------------------

function showPreflightPanel() {
    const panel = document.getElementById("apply-preflight-panel");
    if (!panel) return;
    panel.style.display = "block";
    panel.scrollIntoView({behavior: "smooth", block: "nearest"});
}

function hidePreflightPanel() {
    const panel = document.getElementById("apply-preflight-panel");
    if (panel) panel.style.display = "none";
}

function clearPreflightPanel() {
    const list = document.getElementById("apply-preflight-list");
    if (list) list.replaceChildren();
    const summary = document.getElementById("apply-preflight-summary");
    if (summary) {
        summary.replaceChildren();
        summary.style.color = "";
    }
    const actions = document.getElementById("apply-preflight-actions");
    if (actions) actions.replaceChildren();
}

// addPreflightItem appends a row to the panel's check list and returns
// the <li> for later status updates.
function addPreflightItem(name) {
    const list = document.getElementById("apply-preflight-list");
    if (!list) return null;
    const li = document.createElement("li");
    li.style.padding = "2px 0";
    li.dataset.name = name;
    li.innerText = `🕐 ${name} — pending`;
    li.style.color = "#666";
    list.appendChild(li);
    return li;
}

function setPreflightItemStatus(li, status, message) {
    if (!li) return;
    let icon, color, suffix;
    if (status === "running") { icon = "⟳"; color = "#1976d2"; suffix = " — running…"; }
    else if (status === "ok")  { icon = "✅"; color = "green";   suffix = " — passed"; }
    else if (status === "skip") { icon = "—"; color = "#666";    suffix = message ? ` — ${message}` : " — skipped"; }
    else if (status === "warn") { icon = "⚠️"; color = "#ed6c02"; suffix = message ? ` — ${message}` : " — warning"; }
    else                       { icon = "❌"; color = "red";     suffix = message ? ` — ${message}` : " — failed"; }
    li.innerText = `${icon} ${li.dataset.name}${suffix}`;
    li.style.color = color;
}

// --- Pre-flight panel: individual checks ----------------------------

// Each check returns {status, message?} where status ∈ {"ok","fail","skip"}.

// preflightConnectionTestURL picks the URL the pre-flight HTTPS test
// should exercise from the device. Default behaviour used to always
// test summary.server_https_url (the HTTPS health endpoint), which
// was a useful baseline but didn't reflect HTTP-target migrations
// at all. Now:
//
//   - For DNS interception (resolv): the device hits https://*.bose.com
//     after migration, which DNS redirects to our service over HTTPS.
//     server_https_url is the meaningful test target.
//   - For URL-flip migrations (xml / telnet): test the user's actual
//     targetUrl, since that's the URL the migration will write into
//     the speaker. /health is appended if targetUrl has no path so
//     we hit a small known endpoint regardless of scheme.
//
// Falls back to server_https_url when targetUrl can't be parsed, so a
// legacy call site without a target still works.
function preflightConnectionTestURL(summary, methods, targetUrl) {
    if (methods.includes("resolv") && summary.server_https_url) {
        return summary.server_https_url;
    }
    if (targetUrl) {
        try {
            const u = new URL(targetUrl);
            return `${u.protocol}//${u.host}/health`;
        } catch (e) { /* fall through */ }
    }
    return summary.server_https_url || null;
}

async function checkConnectionFromDevice(deviceId, testUrl) {
    try {
        // use_explicit_ca=true uploads the CA temporarily so the test
        // exercises the trust path even before CA install completes —
        // forward-looking when CA install is part of the plan, and
        // equivalent to use_explicit_ca=false when CA is already
        // installed.
        const q = `?target_url=${encodeURIComponent(testUrl)}&use_explicit_ca=true`;
        const resp = await fetch(`/api/setup/test-connection/${encodeURIComponent(deviceId)}${q}`, {method: "POST"});
        const result = await resp.json();
        if (result.ok) return {status: "ok"};
        return {status: "fail", message: (result.message || "connection failed").split("\n")[0]};
    } catch (e) {
        return {status: "fail", message: String(e)};
    }
}

async function checkDNSRedirectionFromDevice(deviceId, targetUrl) {
    try {
        const q = `?target_url=${encodeURIComponent(targetUrl)}`;
        const resp = await fetch(`/api/setup/test-dns/${encodeURIComponent(deviceId)}${q}`, {method: "POST"});
        const result = await resp.json();
        if (result.ok) return {status: "ok"};
        return {status: "fail", message: (result.message || "DNS test failed").split("\n")[0]};
    } catch (e) {
        return {status: "fail", message: String(e)};
    }
}

// checkPeerReachability is the post-migration passive observation
// check: register interest in the device IP, nudge :8090/swUpdateCheck,
// and report whether any inbound from that IP landed on this service
// within the timeout. Used in place of the active swUpdateUrl round-
// trip on already-migrated speakers, where the swUpdate daemon caches
// its URL at boot and the active flip can't reach it without a reboot.
// See pkg/service/setup/peer_probe.go for orchestration details.
async function checkPeerReachability(deviceId) {
    try {
        const resp = await fetch(`/api/setup/peer-probe/${encodeURIComponent(deviceId)}`, {method: "POST"});
        const result = await resp.json();
        if (result.ok) {
            const ms = result.result && result.result.elapsed_ms;
            const path = result.result && result.result.observed_path;
            let msg = "";
            if (ms !== undefined && ms !== null) msg = `${ms}ms`;
            if (path) msg = msg ? `${msg} (${path})` : path;
            return {status: "ok", message: msg || undefined};
        }
        if (result.error) return {status: "fail", message: result.error.split("\n")[0]};
        if (result.result && result.result.reached === false) {
            return {status: "warn", message: "no inbound seen within the wait window — usually just timing (the update daemon dials out on its own slow schedule; a reboot after Apply validates it), not a port or config problem. Safe to proceed."};
        }
        return {status: "fail", message: "probe failed"};
    } catch (e) {
        return {status: "fail", message: String(e)};
    }
}

// --- Pre-flight panel: orchestrator ---------------------------------

// runApplyPreflight runs the checks visible in the pre-flight panel.
// First check (backend summary re-fetch) drives availability of the
// later device-side tests via the returned summary. Returns
// {results, summary}.
async function runApplyPreflight(deviceId, methods, opts, targetUrl) {
    clearPreflightPanel();
    showPreflightPanel();

    const results = [];

    // Step 1: backend summary re-check (authoritative state).
    const summaryItem = addPreflightItem("Backend summary re-check");
    setPreflightItemStatus(summaryItem, "running");

    const r = await runPreflightCheck(deviceId, methods, opts, targetUrl);
    if (!r.ok) {
        setPreflightItemStatus(summaryItem, "fail", r.issues.join("; "));
        results.push({name: "Backend summary re-check", status: "fail", message: r.issues.join("; ")});
        return {results, summary: r.summary};
    }
    setPreflightItemStatus(summaryItem, "ok");
    results.push({name: "Backend summary re-check", status: "ok"});
    const summary = r.summary;

    // Step 2: reachability from the device. Two evidence sources:
    //
    //   - SSH (curl from device) verifies inbound TCP from the
    //     speaker to our HTTP/HTTPS port using the speaker's normal
    //     userspace stack. Works pre- or post-migration as long as
    //     SSH is unlocked.
    //   - Passive observer (post-migration only) verifies the
    //     swUpdate daemon is actually dialing this service. Replaces
    //     the deprecated active swUpdateUrl round-trip, which the
    //     daemon ignores because it caches its URL at boot.
    //
    // On an unmigrated/partially-migrated telnet-only speaker, no
    // no-reboot validation of the daemon's outbound is possible — we
    // surface a skip row explaining "Apply + reboot is required to
    // validate the fan-out". Per-axis migration state is still visible
    // in the State card above, so the user can see which parts are
    // already in place.
    const connectionTestURL = preflightConnectionTestURL(summary, methods, targetUrl);
    const ranAnyReachability = (summary.ssh_success && !!connectionTestURL) || summary.telnet_reachable;

    if (summary.ssh_success && connectionTestURL) {
        const scheme = connectionTestURL.startsWith("https:") ? "HTTPS" : "HTTP";
        const label = `${scheme} connection from device`;
        const item = addPreflightItem(label);
        setPreflightItemStatus(item, "running");
        const cr = await checkConnectionFromDevice(deviceId, connectionTestURL);
        setPreflightItemStatus(item, cr.status, cr.message);
        results.push({name: label, ...cr});
    }

    if (summary.telnet_reachable) {
        if (summary.is_migrated) {
            const label = "Reachability check (passive observer)";
            const item = addPreflightItem(label);
            setPreflightItemStatus(item, "running");
            const cr = await checkPeerReachability(deviceId);
            setPreflightItemStatus(item, cr.status, cr.message);
            results.push({name: label, ...cr});
        } else {
            const label = "Round-trip validation runs after Apply + reboot";
            const item = addPreflightItem(label);
            setPreflightItemStatus(item, "skip", "daemon caches swUpdateUrl at boot; reboot required to validate fan-out");
            results.push({name: label, status: "skip", message: "runs after Apply + reboot"});
        }
    }

    if (!ranAnyReachability) {
        const item = addPreflightItem("Reachability from device");
        setPreflightItemStatus(item, "skip", "neither SSH nor Telnet:17000 is reachable");
        results.push({name: "Reachability from device", status: "skip"});
    }

    // Step 3: DNS redirection test (only for resolv plans).
    if (methods.includes("resolv") && summary.ssh_success) {
        const item = addPreflightItem("DNS redirection from device");
        setPreflightItemStatus(item, "running");
        const cr = await checkDNSRedirectionFromDevice(deviceId, targetUrl);
        setPreflightItemStatus(item, cr.status, cr.message);
        results.push({name: "DNS redirection from device", ...cr});
    }

    return {results, summary};
}

// awaitPreflightDecision renders the summary line and buttons, then
// resolves with the user's choice ("proceed" or "cancel"). Auto-
// proceeds without buttons when every check passed — the user still
// sees the green panel briefly via the caller's animation budget.
function awaitPreflightDecision(results) {
    const failed = results.filter(r => r.status === "fail").length;
    const skipped = results.filter(r => r.status === "skip").length;
    const passed = results.filter(r => r.status === "ok").length;

    const summary = document.getElementById("apply-preflight-summary");
    const actions = document.getElementById("apply-preflight-actions");
    if (!summary || !actions) return Promise.resolve("proceed");

    summary.replaceChildren();

    if (failed === 0) {
        const warned = results.filter(r => r.status === "warn").length;
        let extras = skipped > 0 ? ` (${skipped} skipped)` : "";
        if (warned > 0) extras += ` (${warned} warning${warned === 1 ? "" : "s"})`;
        summary.innerText = `✅ ${passed} of ${results.length} checks passed${extras}`;
        summary.style.color = warned > 0 ? "#ed6c02" : "green";
        actions.replaceChildren();
        // Auto-proceed: caller adds a brief delay so the user sees the
        // green frame before Apply kicks off.
        return Promise.resolve("proceed");
    }

    summary.innerText = `❌ ${failed} of ${results.length} checks failed`;
    summary.style.color = "red";

    return new Promise(resolve => {
        actions.replaceChildren();
        const proceed = document.createElement("button");
        proceed.type = "button";
        proceed.innerText = "Proceed Anyway";
        proceed.style.cssText = "background: #ff9800; color: white; border: none; padding: 8px 14px";
        proceed.onclick = () => resolve("proceed");

        const cancel = document.createElement("button");
        cancel.type = "button";
        cancel.innerText = "Cancel";
        cancel.style.cssText = "margin-left: 10px; padding: 8px 14px";
        cancel.onclick = () => resolve("cancel");

        actions.appendChild(proceed);
        actions.appendChild(cancel);
    });
}

function preflightSleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// runPreflightCheck does an authoritative round-trip to the backend
// just before Apply, catching issues the optimistic client-side
// preview can't see: hostname resolution from the device's
// perspective, transport reachability re-verified after any prior
// step, and a sanity check that the backend's planned config
// reflects the per-field URL overrides we're about to send.
//
// Returns {ok: bool, issues: string[], summary}. The caller decides
// whether to proceed (typically with a confirm() dialog listing the
// issues so the user can override on a known-false-positive).
async function runPreflightCheck(deviceId, methods, opts, targetUrl) {
    let q = "?target_url=" + encodeURIComponent(targetUrl);
    for (const k in opts) q += "&" + k + "=" + encodeURIComponent(opts[k]);

    let summary;
    try {
        const resp = await fetch("/api/setup/summary/" + encodeURIComponent(deviceId) + q);
        if (!resp.ok) {
            return {ok: false, issues: [`Failed to refresh summary: ${await resp.text()}`]};
        }
        summary = await resp.json();
    } catch (e) {
        return {ok: false, issues: [`Failed to refresh summary: ${e}`]};
    }

    const issues = [];

    if (summary.resolve_ip_error) {
        issues.push("Hostname resolution from the device failed: " + summary.resolve_ip_error);
    }

    const wantsSSH = methods.some(m => m === "xml" || m === "resolv" || m === "trust-ca");
    const wantsTelnet = methods.includes("telnet");

    if (wantsSSH && !summary.ssh_success) {
        issues.push("SSH is no longer reachable — required for: " +
            methods.filter(m => m === "xml" || m === "resolv" || m === "trust-ca").join(", "));
    }

    if (wantsTelnet && !summary.telnet_reachable) {
        issues.push("Telnet:17000 is no longer reachable — required for the telnet URL flip.");
    }

    // Sanity-check that the backend's planned config reflects the
    // overrides we're about to write. Only meaningful for URL-flip
    // methods (xml/telnet). Looser substring match: each of the four
    // override URLs we sent must appear in the rendered planned XML.
    if (methods.some(m => m === "xml" || m === "telnet")) {
        const planned = summary.planned_config || "";
        const expected = ["marge_url", "stats_url", "sw_update_url", "bmx_url"]
            .map(k => opts[k])
            .filter(u => !!u);
        const missing = expected.filter(u => !planned.includes(u));
        if (missing.length > 0) {
            issues.push("Backend's planned config doesn't reflect these overrides: " + missing.join(", "));
        }
    }

    return {ok: issues.length === 0, issues, summary};
}

// previewPreflight runs the same check sequence as the Apply paths
// but stops at the results — no migrate / trust-ca / pair-account
// call happens. The panel stays open with a Close button so the
// user can inspect the outcome ("test first, decide later").
//
// Called by preflightSuggestedPlan and preflightCustomPlan, which
// share UI plumbing with their Apply counterparts.
async function previewPreflight(deviceId, methods, opts, targetUrl) {
    const {results} = await runApplyPreflight(deviceId, methods, opts, targetUrl);
    renderPreflightPreviewSummary(results);
}

// renderPreflightPreviewSummary mirrors awaitPreflightDecision's
// rendering shape (summary line + actions row) but with a single
// Close button instead of Proceed Anyway / Cancel — there's nothing
// to proceed to in preview mode.
function renderPreflightPreviewSummary(results) {
    const failed  = results.filter(r => r.status === "fail").length;
    const passed  = results.filter(r => r.status === "ok").length;
    const skipped = results.filter(r => r.status === "skip").length;

    const summary = document.getElementById("apply-preflight-summary");
    const actions = document.getElementById("apply-preflight-actions");
    if (!summary || !actions) return;

    if (failed === 0) {
        const warned = results.filter(r => r.status === "warn").length;
        let extras = skipped > 0 ? ` (${skipped} skipped)` : "";
        if (warned > 0) extras += ` (${warned} warning${warned === 1 ? "" : "s"})`;
        summary.innerText = `✅ Pre-flight passed — ${passed} of ${results.length} checks${extras}`;
        summary.style.color = warned > 0 ? "#ed6c02" : "green";
    } else {
        summary.innerText = `❌ Pre-flight: ${failed} of ${results.length} checks failed`;
        summary.style.color = "red";
    }

    actions.replaceChildren();
    const close = document.createElement("button");
    close.type = "button";
    close.innerText = "Close";
    close.style.cssText = "padding: 8px 14px";
    close.onclick = () => hidePreflightPanel();
    actions.appendChild(close);
}

// preflightSuggestedPlan runs the Suggested Plan's checks without
// applying. Reads the chosen method off plan-apply-btn.dataset.method
// (set by renderPlan from computeSuggestedPlan), same source the
// real Apply uses, so what's tested matches what would be applied.
async function preflightSuggestedPlan() {
    const applyBtn = document.getElementById("plan-apply-btn");
    const method = applyBtn && applyBtn.dataset.method;
    if (!method) return;

    const deviceId = document.getElementById("summary-device-id").value;
    if (!deviceId) return;

    if (!validatePlanURLs()) return;

    const targetUrl = document.getElementById("plan-target-url").value;
    const opts = readPlanURLOptions();
    await previewPreflight(deviceId, [method], opts, targetUrl);
}

// preflightCustomPlan walks the same radio choices applyCustomPlan
// reads and queues the same methods array, then runs the checks
// against it. Kept structurally close to applyCustomPlan so the two
// stay in sync as new axes are added.
async function preflightCustomPlan() {
    const form = document.getElementById("customize-form");
    const deviceId = form && form.dataset.deviceId;
    if (!deviceId) return;

    if (!validatePlanURLs()) return;

    const flip = (document.querySelector('input[name="customize-url-flip"]:checked') || {}).value || "none";
    const dns  = (document.querySelector('input[name="customize-dns"]:checked')      || {}).value || "none";
    const caInstall = !!(document.getElementById("customize-ca-install") || {}).checked;
    const pair = readPlanPairTarget();

    const methods = [];
    if (flip === "xml" || flip === "telnet") methods.push(flip);
    if (dns === "resolv") methods.push("resolv");
    if (caInstall && dns !== "resolv") methods.push("trust-ca");
    if (pair && pair.valid) methods.push("pair-account");

    if (methods.length === 0) {
        // Pre-flight with nothing queued is still useful — it shows
        // transport reachability. Run the summary check at least.
    }

    const targetUrl = document.getElementById("plan-target-url").value;
    const opts = readPlanURLOptions();
    await previewPreflight(deviceId, methods, opts, targetUrl);
}

// applySuggestedPlan triggers the recipe computeSuggestedPlan picked.
// Passes the chosen method directly to migrate() — the legacy
// migration-method dropdown is gone.
async function applySuggestedPlan() {
    const btn = document.getElementById("plan-apply-btn");
    const status = document.getElementById("plan-apply-status");
    const method = btn && btn.dataset.method;
    if (!method) return;

    const deviceId = document.getElementById("summary-device-id").value;
    if (!deviceId) {
        if (status) status.innerText = "❌ no device selected";
        return;
    }

    if (status) {
        status.innerText = "Pre-flight check…";
        status.style.color = "#555";
    }

    // Pair-target intent from the Plan card. null = no pairing step;
    // {valid:false} = invalid input that blocks Apply.
    const pair = readPlanPairTarget();
    if (pair && !pair.valid) {
        if (status) {
            status.innerText = "Aborted — invalid account ID";
            status.style.color = "#c62828";
        }
        return;
    }

    // Visible pre-flight panel — backend summary re-fetch, plus the
    // applicable device-side checks (HTTPS connection, DNS redirection)
    // run automatically so the user gets feedback without having to
    // click the manual Test buttons.
    const targetUrl = document.getElementById("plan-target-url").value;
    const opts = readPlanURLOptions();
    const {results} = await runApplyPreflight(deviceId, [method], opts, targetUrl);
    const decision = await awaitPreflightDecision(results);
    if (decision !== "proceed") {
        hidePreflightPanel();
        if (status) {
            status.innerText = "Aborted — pre-flight issues unresolved";
            status.style.color = "#c62828";
        }
        return;
    }

    // Brief glance at the green panel so the success state registers
    // before it disappears.
    const failed = results.filter(r => r.status === "fail").length;
    if (failed === 0) await preflightSleep(700);
    hidePreflightPanel();

    if (status) {
        status.innerText = "Applying " + method + "…";
        status.style.color = "#555";
    }

    // ip is unused by migrate() — the backend resolves the IP from
    // device id. The empty string keeps the existing call shape.
    await migrate(deviceId, "", method);

    // Pairing runs after the URL flip so the user sees migration
    // succeed before pairing — pair-account is independent of the
    // migration target so order is purely UX.
    if (pair && pair.valid) {
        if (status) {
            status.innerText = "Pairing account " + pair.accountId + "…";
            status.style.color = "#555";
        }
        try {
            await pairAccount(deviceId, pair.accountId);
        } catch (e) {
            if (status) {
                status.innerText = "❌ Pair failed: " + e.message;
                status.style.color = "#c62828";
            }
            return;
        }
    }

    if (status) status.innerText = "";
}

// looksTransient classifies a probe-error message as likely-flaky-but-
// retriable. Conservative substring match — only marks errors that
// pattern-match a TCP/I/O timeout, which is the exact failure shape
// observed on healthy FW 27.0.6 devices that recover on retry.
function looksTransient(msg) {
    if (!msg) return false;
    const m = msg.toLowerCase();
    return m.includes("timeout") || m.includes("timed out") || m.includes("connection reset");
}

// renderMigrationState fills the three-axis state card at the top of
// the migration summary: transports, migration-state axes (URL config,
// DNS interception, CA/TLS), and preconditions (remote_services,
// pairing, backup). Reads only fields the backend already exposes —
// is_migrated remains the OR of the per-axis booleans.
//
// targetUrl is the current Target Domain value, used only to judge
// whether CA/TLS is actually relevant to the current plan (see
// isHttpsTarget) — the default Suggested Plan never needs it.
function renderMigrationState(summary, targetUrl) {
    // --- Transports ---
    setStateChip("state-ssh", summary.ssh_success, "Reachable", "Unreachable");
    setStateChip("state-telnet", summary.telnet_reachable, "Reachable", "Unreachable");

    const bannerEl = document.getElementById("state-telnet-banner");
    if (bannerEl) bannerEl.innerText = summary.telnet_banner ? `(${summary.telnet_banner})` : "";

    const errorEl = document.getElementById("state-telnet-error");
    if (errorEl) {
        if (summary.telnet_probe_error && !summary.telnet_reachable) {
            errorEl.replaceChildren();
            const line = document.createElement("div");
            line.innerText = "Probe error: " + summary.telnet_probe_error;
            errorEl.appendChild(line);

            // The diagnostic shell on FW 27.0.6 occasionally drops the
            // first connection attempt under load. When the error wraps
            // an i/o timeout, the next probe almost always succeeds —
            // so nudge the user toward the ↻ refresh button rather than
            // letting them assume telnet is permanently unreachable.
            if (looksTransient(summary.telnet_probe_error)) {
                const hint = document.createElement("div");
                hint.style.cssText = "margin-top: 4px; font-size: 0.85em; color: #5d4037";
                hint.innerText = "💡 Telnet probes are occasionally flaky on this firmware. Click the ↻ refresh button next to the device dropdown to retry.";
                errorEl.appendChild(hint);
            }

            errorEl.style.display = "block";
        } else {
            errorEl.style.display = "none";
        }
    }

    // --- URL Configuration axis ---
    const urlCell = document.getElementById("state-url");
    if (urlCell) {
        urlCell.replaceChildren();
        const verdict = urlConfigVerdict(summary);
        urlCell.appendChild(stateLine(verdict.icon, verdict.text, verdict.note));

        const live = parseTelnetVerifiedConfig(summary.telnet_verified_config || "");
        const xml = summary.parsed_current_config || {};
        const fields = [
            ["margeServerUrl", "marge"],
            ["statsServerUrl", "stats"],
            ["swUpdateUrl", "sw_update"],
            ["bmxRegistryUrl", "bmx"],
        ];
        const detail = document.createElement("div");
        detail.style.cssText = "margin-top: 4px; font-family: monospace; font-size: 0.85em; color: #555";
        for (const [key] of fields) {
            const xmlVal = xml[key] || "";
            const telVal = live[key] || "";
            const row = document.createElement("div");
            row.style.cssText = "padding: 1px 0";
            const label = document.createElement("strong");
            label.style.cssText = "color: #333";
            label.textContent = key + ": ";
            row.appendChild(label);
            row.appendChild(document.createTextNode(formatURLPair(xmlVal, telVal)));
            detail.appendChild(row);
        }
        urlCell.appendChild(detail);
    }

    // --- DNS Interception axis ---
    const dnsCell = document.getElementById("state-dns");
    if (dnsCell) {
        dnsCell.replaceChildren();
        const v = dnsInterceptionVerdict(summary);
        dnsCell.appendChild(stateLine(v.icon, v.text, v.note));
    }

    // --- CA / TLS axis ---
    // The cell hosts both the verdict text and two action affordances
    // (Trust CA Now button + Download CA cert link). We only rewrite
    // the verdict span so the buttons stay put across re-renders.
    const caLine = document.getElementById("state-ca-line");
    if (caLine) {
        caLine.replaceChildren();
        const v = caVerdict(summary, isHttpsTarget(targetUrl));
        caLine.appendChild(stateLine(v.icon, v.text, v.note));
    }

    // --- Preconditions ---
    // Like CA/TLS above, the cell also hosts the Enable/Disable SSH
    // buttons as siblings of this line — only rewrite the verdict span so
    // they stay put across re-renders.
    const remoteLine = document.getElementById("state-remote-services-line");
    if (remoteLine) {
        remoteLine.replaceChildren();
        const v = remoteServicesVerdict(summary);
        remoteLine.appendChild(stateLine(v.icon, v.text, v.note));
    }

    const pairedCell = document.getElementById("state-paired");
    if (pairedCell) {
        pairedCell.replaceChildren();
        if (summary.is_paired) {
            pairedCell.appendChild(stateLine("✅", "Paired", `(account ${summary.account_id || "?"})`));
        } else {
            pairedCell.appendChild(stateLine("❌", "Not paired", "(/setMargeAccount or telnet envswitch needed)"));
        }
    }

    const backupCell = document.getElementById("state-backup");
    if (backupCell) {
        backupCell.replaceChildren();
        if (summary.original_config) {
            backupCell.appendChild(stateLine("✅", "Found .original", ""));
        } else {
            backupCell.appendChild(stateLine("❌", "Not found", "(only relevant for the XML migration method)"));
        }
    }
}

// setStateChip writes a green ✅ / red ❌ chip into the element.
function setStateChip(elemId, ok, okText, badText) {
    const el = document.getElementById(elemId);
    if (!el) return;
    if (ok) {
        el.innerText = "✅ " + okText;
        el.style.color = "green";
    } else {
        el.innerText = "❌ " + badText;
        el.style.color = "red";
    }
}

// stateLine returns a DOM fragment "<icon> <bold text> <muted note>".
function stateLine(icon, text, note) {
    const wrap = document.createElement("span");
    wrap.appendChild(document.createTextNode(icon + " "));
    const strong = document.createElement("strong");
    strong.textContent = text;
    wrap.appendChild(strong);
    if (note) {
        const muted = document.createElement("span");
        muted.style.cssText = "color: #666; margin-left: 6px; font-size: 0.9em";
        muted.textContent = note;
        wrap.appendChild(muted);
    }
    return wrap;
}

// formatURLPair renders the on-disk vs live values for one URL field
// in a compact way: agreement → single value; disagreement → both,
// labelled.
function formatURLPair(xmlVal, telVal) {
    if (!xmlVal && !telVal) return "—";
    if (!xmlVal) return `live: ${telVal}`;
    if (!telVal) return `xml: ${xmlVal}`;
    if (xmlVal === telVal) return xmlVal;
    return `xml: ${xmlVal}  •  live: ${telVal}`;
}

// isMigratedToOtherTarget returns true when the speaker's on-device URLs have
// been changed away from Bose cloud but don't match the Settings Target Domain —
// i.e. the speaker was previously migrated to a different AfterTouch hostname
// (or the Settings Target Domain drifted after migration). In this state
// xml_migrated and telnet_migrated are both false (they only match the *current*
// Settings Target Domain), yet labelling the speaker "Original (Bose cloud)"
// would be factually wrong.
function isMigratedToOtherTarget(summary) {
    if (summary.xml_migrated || summary.telnet_migrated) return false;
    const boseDomains = ["streaming.bose.com", "stats.bose.com", "updates.bose.com", "bmx.bose.com"];
    const cfg = summary.parsed_current_config;
    if (!cfg) return false;
    const urls = [cfg.margeServerUrl, cfg.statsServerUrl, cfg.swUpdateUrl, cfg.bmxRegistryUrl].filter(Boolean);
    if (urls.length === 0) return false;
    return !urls.some(u => boseDomains.some(d => u.includes(d)));
}

// urlConfigVerdict reports the URL-flip axis in the context of DNS
// interception, because "URLs still point at Bose" is only a problem
// if nothing else is redirecting them. When the DNS hook (or, less
// preferably, /etc/hosts) is intercepting the Bose hostnames, leaving
// the on-device URL config untouched is the *expected* migrated state
// for that method — flagging it red would be misleading.
function urlConfigVerdict(summary) {
    const xml = !!summary.xml_migrated;
    const tel = !!summary.telnet_migrated;
    const dns = !!summary.resolv_migrated || !!summary.hosts_migrated;

    if (xml && tel) return {icon: "✅", text: "AfterTouch URLs", note: "(XML + telnet runtime in sync)"};
    if (xml) return {icon: "✅", text: "AfterTouch URLs", note: "(XML only — telnet runtime may still hold the old URLs)"};
    if (tel) return {icon: "✅", text: "AfterTouch URLs", note: "(telnet runtime — reboot to persist into the on-disk XML)"};

    // Neither URL-flip mechanism matched the Settings Target Domain.
    // Before falling back to "Original (Bose cloud)", check whether the
    // device's URLs already point somewhere that is clearly not Bose cloud
    // — e.g. the speaker was migrated to "http://spotify:8000" but the
    // Settings Target Domain is now an IP address (or vice versa). The
    // speaker is effectively migrated; "Original (Bose cloud)" would be
    // misleading and alarming.
    if (isMigratedToOtherTarget(summary)) {
        const margeUrl = (summary.parsed_current_config || {}).margeServerUrl || "unknown";
        if (dns) return {icon: "✅", text: "Migrated (URL mismatch)", note: `— device has ${margeUrl}, also intercepted via DNS`};
        return {icon: "⚠️", text: "Migrated (URL mismatch)", note: `— device has ${margeUrl}; the speaker must be able to reach the service there. Update Settings Target Domain to match, or apply the plan to change the device URL`};
    }

    // URLs are genuinely pointing at Bose cloud (or the config is unreadable).
    if (dns) return {icon: "✅", text: "Original (Bose cloud)", note: "— intercepted via DNS, device reaches AfterTouch"};
    return {icon: "❌", text: "Original (Bose cloud)", note: "— not intercepted, device will reach the real Bose cloud"};
}

function dnsInterceptionVerdict(summary) {
    const hosts = !!summary.hosts_migrated;
    const resolv = !!summary.resolv_migrated;
    if (!hosts && !resolv) return {icon: "—", text: "None", note: ""};
    if (resolv) return {icon: "✅", text: "/etc/resolv.conf hook active", note: hosts ? "(also: /etc/hosts entries)" : ""};
    return {icon: "⚠️", text: "/etc/hosts redirects", note: "(deprecated method)"};
}

// isHttpsTarget reports whether a target/service URL uses the https
// scheme. Used to distinguish "CA/TLS optional" (the default Suggested
// Plan for both XML-over-SSH and Telnet migrates over plain HTTP, no CA
// involved) from "CA/TLS required" (Target Domain is https://, or the
// Customize form's DNS-interception method is chosen — that one always
// targets https://*.bose.com).
function isHttpsTarget(url) {
    return /^https:/i.test((url || "").trim());
}

function caVerdict(summary, httpsRelevant) {
    if (summary.ca_cert_trusted) return {icon: "✅", text: "Local root CA installed", note: ""};
    if (httpsRelevant) {
        return {icon: "❌", text: "Not installed", note: "(required — your Target URL is HTTPS; install it before migrating, or click Trust CA Now)"};
    }
    return {icon: "⚪", text: "Not installed", note: "(not needed — your Target URL is HTTP; only required if you switch to HTTPS or use the DNS-interception method)"};
}

function remoteServicesVerdict(summary) {
    if (!summary.ssh_success) return {icon: "❓", text: "Unknown", note: "(SSH not reachable)"};
    if (!summary.remote_services_enabled) return {icon: "❌", text: "SSH not enabled", note: "(insert USB stick with remote_services file and reboot to enable)"};
    if (!summary.remote_services_persistent) return {icon: "⚠️", text: "SSH enabled (not persistent)", note: "(will be lost on reboot — use 'Enable SSH' button to persist)"};

    return {icon: "✅", text: "SSH enabled (persistent)", note: ""};
}

// parseTelnetVerifiedConfig extracts field values from the device's
// `getpdo CurrentSystemConfiguration` reply. Mirrors
// setup.parseGetpdoConfig (Go); supports both the protobuf-text-like
// nested-block format observed on FW 27.0.6 (`key { text: "value" }`)
// and the flat key=value format kept as a tolerance path. Banner text,
// prompt characters (`->`, `->OK`), and unrelated lines are silently
// ignored.
function parseTelnetVerifiedConfig(text) {
    const out = {};
    if (!text) return out;

    const isIdentifier = (s) => !!s && /^[A-Za-z0-9_]+$/.test(s);

    let currentKey = "";
    for (const raw of text.split("\n")) {
        const line = raw.trim();
        if (!line) continue;

        // Block open: "<key> {".
        if (line.endsWith("{")) {
            const head = line.slice(0, -1).trim();
            if (isIdentifier(head)) currentKey = head;
            continue;
        }

        // Block close.
        if (line === "}") {
            currentKey = "";
            continue;
        }

        // "text: ..." inside a block is the field value.
        if (currentKey && line.startsWith("text:")) {
            let val = line.slice("text:".length).trim();
            if (val.startsWith('"') && val.endsWith('"')) val = val.slice(1, -1);
            out[currentKey] = val;
            continue;
        }

        // Flat key=value (tolerance path).
        const eq = line.indexOf("=");
        if (eq > 0) {
            const key = line.slice(0, eq).trim();
            if (isIdentifier(key)) {
                out[key] = line.slice(eq + 1).trim();
            }
        }
    }

    return out;
}

// renderPreflightWarnings shows summary.warnings as a yellow banner
// above the migration controls. An empty/missing list hides the banner.
function renderPreflightWarnings(summary) {
    const banner = document.getElementById("preflight-warnings");
    const list = document.getElementById("preflight-warnings-list");
    if (!banner || !list) return;

    list.replaceChildren();

    const warnings = summary.warnings || [];
    if (warnings.length === 0) {
        banner.style.display = "none";
        return;
    }

    for (const w of warnings) {
        const li = document.createElement("li");
        li.innerText = w;
        list.appendChild(li);
    }
    banner.style.display = "block";
}

// renderCustomizeForm sets the per-axis radio availability and pane
// visibility based on the summary's transport reachability and the
// current radio choices. Disabled options get a small "(why)" hint
// next to them.
function renderCustomizeForm(summary) {
    const xmlRadio = document.querySelector('input[name="customize-url-flip"][value="xml"]');
    const telnetRadio = document.querySelector('input[name="customize-url-flip"][value="telnet"]');
    const dnsRadio = document.querySelector('input[name="customize-dns"][value="resolv"]');
    const caCheckbox = document.getElementById("customize-ca-install");

    const setHint = (axis, text) => {
        const el = document.querySelector(`.customize-hint[data-axis="${axis}"]`);
        if (el) el.innerText = text;
    };

    // XML over SSH requires SSH.
    if (xmlRadio) {
        const ok = !!summary.ssh_success;
        xmlRadio.disabled = !ok;
        setHint("xml", ok ? "" : "(SSH unreachable)");
        if (!ok && xmlRadio.checked) {
            // Pick the next-best fallback so the form is in a valid
            // state on first render.
            if (telnetRadio && summary.telnet_reachable) telnetRadio.checked = true;
            else document.querySelector('input[name="customize-url-flip"][value="none"]').checked = true;
        }
    }

    // Telnet requires the diagnostic shell on port 17000.
    if (telnetRadio) {
        const ok = !!summary.telnet_reachable;
        telnetRadio.disabled = !ok;
        setHint("telnet", ok ? "" : "(Telnet:17000 unreachable)");
    }

    // Resolv DNS hook requires SSH (writes to /etc/resolv.conf etc).
    if (dnsRadio) {
        const ok = !!summary.ssh_success;
        dnsRadio.disabled = !ok;
        setHint("resolv", ok ? "" : "(SSH unreachable)");
        if (!ok && dnsRadio.checked) {
            document.querySelector('input[name="customize-dns"][value="none"]').checked = true;
        }
    }

    // CA install: SSH-only, and skipped silently if already trusted.
    if (caCheckbox) {
        const sshOk = !!summary.ssh_success;
        caCheckbox.disabled = !sshOk;
        if (!sshOk) caCheckbox.checked = false;
        if (!sshOk) setHint("ca", "(SSH unreachable)");
        else if (summary.ca_cert_trusted) setHint("ca", "(already trusted)");
        else setHint("ca", "");
    }

    onCustomizeChange();
}

// onCustomizeChange runs whenever any axis radio/checkbox changes.
// Validates the combination, updates pane visibility for legacy diff
// and resolv panes, and toggles the Apply Custom Plan button.
function onCustomizeChange() {
    const flip = (document.querySelector('input[name="customize-url-flip"]:checked') || {}).value || "none";
    const dns = (document.querySelector('input[name="customize-dns"]:checked') || {}).value || "none";
    const caInstall = !!(document.getElementById("customize-ca-install") || {}).checked;

    // Visibility of the diff pairs and per-method panes. Each diff
    // pair is its own .diff-container row so the Current/Planned
    // columns line up side-by-side per axis instead of mixing into
    // a single 3+ column layout.
    const show = (id, on) => {
        const el = document.getElementById(id);
        if (el) el.style.display = on ? "" : "none";
    };
    show("xml-diff-row",     flip === "xml");
    show("resolv-diff-row",  dns === "resolv");
    show("telnet-method-pane", flip === "telnet");
    show("dns-redirection-test", dns === "resolv");

    // Validate the combination and toggle the Apply button.
    const errors = [];
    if (flip === "none" && dns === "none" && !caInstall) {
        errors.push("Pick at least one axis — URL flip, DNS interception, or CA install.");
    }

    const errorEl = document.getElementById("customize-validation");
    const applyBtn = document.getElementById("customize-apply-btn");
    if (errorEl) {
        if (errors.length === 0) {
            errorEl.style.display = "none";
            errorEl.innerText = "";
        } else {
            errorEl.innerText = errors[0];
            errorEl.style.display = "block";
        }
    }
    if (applyBtn) applyBtn.disabled = errors.length > 0;
    const preflightCustomBtn = document.getElementById("customize-preflight-btn");
    if (preflightCustomBtn) preflightCustomBtn.disabled = errors.length > 0;
}

// applyCustomPlan runs the chosen sequence of backend operations:
// optional URL flip (xml or telnet), optional DNS hook (resolv —
// already includes a CA install, so the explicit CA step is skipped
// in that case), and an optional standalone CA install. Each step
// runs sequentially; the first failure aborts the rest.
async function applyCustomPlan() {
    const form = document.getElementById("customize-form");
    const deviceId = form && form.dataset.deviceId;
    const ip = form && form.dataset.deviceIp;
    if (!deviceId) {
        alert("No device selected");
        return;
    }

    if (!validatePlanURLs()) {
        // The Plan card already surfaced the per-field errors; just
        // refuse to run.
        return;
    }

    const flip = (document.querySelector('input[name="customize-url-flip"]:checked') || {}).value || "none";
    const dns = (document.querySelector('input[name="customize-dns"]:checked') || {}).value || "none";
    const caInstall = !!(document.getElementById("customize-ca-install") || {}).checked;

    const status = document.getElementById("customize-apply-status");
    const setStatus = (msg, color) => {
        if (!status) return;
        status.innerText = msg;
        status.style.color = color || "#555";
    };

    // Pair-target intent from the Plan card. null = no pairing step;
    // {valid:false} = invalid input that blocks Apply.
    const pair = readPlanPairTarget();
    if (pair && !pair.valid) {
        setStatus("Aborted — invalid account ID", "#c62828");
        return;
    }

    const steps = [];
    const methods = [];
    if (flip === "xml" || flip === "telnet") {
        steps.push({label: `URL flip via ${flip}`, run: () => migrate(deviceId, ip, flip)});
        methods.push(flip);
    }
    if (dns === "resolv") {
        steps.push({label: "DNS interception (resolv.conf hook + CA install)", run: () => migrate(deviceId, ip, "resolv")});
        methods.push("resolv");
    }
    if (caInstall && dns !== "resolv") {
        steps.push({label: "Install local CA", run: () => trustCA(deviceId, ip)});
        methods.push("trust-ca");
    }
    if (pair && pair.valid) {
        steps.push({label: `Pair account ${pair.accountId}`, run: () => pairAccount(deviceId, pair.accountId)});
        methods.push("pair-account");
    }

    if (steps.length === 0) {
        setStatus("Pick at least one axis above (or change the pairing ID).", "#c62828");
        return;
    }

    const applyBtn = document.getElementById("customize-apply-btn");
    if (applyBtn) applyBtn.disabled = true;

    // Visible pre-flight panel — same set of checks as the Suggested
    // path (backend summary + device-side connection / DNS tests),
    // gated on the methods this multi-step plan actually queues. Each
    // step's preconditions are validated against this single fresh
    // summary; we don't re-fetch between steps because each step
    // touches independent device state.
    setStatus("Pre-flight check…", "#555");
    const targetUrl = document.getElementById("plan-target-url").value;
    const opts = readPlanURLOptions();
    const {results} = await runApplyPreflight(deviceId, methods, opts, targetUrl);
    const decision = await awaitPreflightDecision(results);
    if (decision !== "proceed") {
        hidePreflightPanel();
        setStatus("Aborted — pre-flight issues unresolved", "#c62828");
        if (applyBtn) applyBtn.disabled = false;
        return;
    }
    const failed = results.filter(r => r.status === "fail").length;
    if (failed === 0) await preflightSleep(700);
    hidePreflightPanel();

    try {
        for (const step of steps) {
            setStatus(`Running: ${step.label}…`, "#555");
            await step.run();
        }
        setStatus("✅ Custom plan applied. Reboot to activate.", "green");
        if (typeof refreshSummary === "function") refreshSummary();
    } catch (e) {
        setStatus(`❌ Failed: ${e}`, "#c62828");
    } finally {
        if (applyBtn) applyBtn.disabled = false;
    }
}

// ---------------------------------------------------------------------------
// Health tab
// ---------------------------------------------------------------------------

async function fetchHealth() {
    const findingsEl = document.getElementById("health-findings");
    const generatedAtEl = document.getElementById("health-generated-at");
    if (!findingsEl) return;

    findingsEl.textContent = "Loading…";
    if (generatedAtEl) generatedAtEl.textContent = "";

    try {
        const resp = await fetch("/api/setup/health");
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        const data = await resp.json();
        renderHealthChecks(data, findingsEl, generatedAtEl);
    } catch (e) {
        findingsEl.textContent = `Failed to load health checks: ${e.message || e}`;
    }
}

async function downloadDiagnostic() {
    const statusEl = document.getElementById("health-diagnostic-status");
    if (statusEl) statusEl.textContent = "Building diagnostic report…";

    try {
        const resp = await fetch("/api/setup/export/diagnostic");
        if (!resp.ok) {
            const text = await resp.text().catch(() => resp.statusText);
            throw new Error(`HTTP ${resp.status}: ${text}`);
        }

        const disposition = resp.headers.get("Content-Disposition") || "";
        const match = disposition.match(/filename[^;=\n]*=(?:"([^"]+)"|([^;\n]+))/);
        const filename = (match && (match[1] || match[2])) || "aftertouch-diagnostic.age";

        const blob = await resp.blob();
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);

        if (statusEl) {
            const safe = filename.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
            statusEl.innerHTML =
                `Downloaded: <strong>${safe}</strong><br>` +
                `To share it, please <strong>prefer email</strong>: ` +
                `<a href="mailto:aftertouch-support@gesellix.net?subject=Diagnostic%20report&body=Please%20attach%20${encodeURIComponent(safe)}%20to%20this%20email.">aftertouch-support@gesellix.net</a>. ` +
                `Alternatively, open a <a href="https://github.com/gesellix/Bose-SoundTouch/issues" target="_blank" rel="noopener">GitHub issue</a> ` +
                `and attach the file renamed to <code>${safe}.txt</code> ` +
                `(GitHub blocks <code>.age</code> uploads; adding <code>.txt</code> works around that).`;
        }
    } catch (e) {
        if (statusEl) statusEl.textContent = `Failed to download diagnostic: ${e.message || e}`;
    }
}

function renderHealthChecks(data, findingsEl, generatedAtEl) {
    if (generatedAtEl && data.generatedAt) {
        generatedAtEl.textContent = `Last run: ${data.generatedAt}`;
    }

    const checks = data.checks || [];
    if (checks.length === 0) {
        findingsEl.textContent = "No checks are registered.";
        return;
    }

    findingsEl.innerHTML = "";
    for (const check of checks) {
        findingsEl.appendChild(renderHealthCheck(check));
    }
}

function renderHealthCheck(check) {
    const box = document.createElement("div");
    box.className = "summary-box";

    const header = document.createElement("h3");
    header.style.margin = "0 0 8px 0";
    header.appendChild(severityBadge(check.severity));
    header.appendChild(document.createTextNode(" " + check.title));
    box.appendChild(header);

    const idLine = document.createElement("div");
    idLine.style.fontSize = "0.75em";
    idLine.style.color = "#888";
    idLine.style.marginBottom = "8px";
    idLine.textContent = `id: ${check.id}`;
    box.appendChild(idLine);

    const findings = check.findings || [];
    if (findings.length === 0) {
        const ok = document.createElement("div");
        ok.style.color = "#2e7d32";
        ok.textContent = "✓ No issues detected.";
        box.appendChild(ok);
        return box;
    }

    for (const f of findings) {
        box.appendChild(renderFinding(check.id, f));
    }
    return box;
}

function renderFinding(checkId, finding) {
    const row = document.createElement("div");
    row.style.borderTop = "1px solid #e0e0e0";
    row.style.padding = "10px 0";

    const title = document.createElement("div");
    title.appendChild(severityBadge(finding.severity));
    title.appendChild(document.createTextNode(" " + (finding.message || "")));
    row.appendChild(title);

    const target = finding.target || {};
    if (target.account || target.device || target.name || target.ip) {
        const t = document.createElement("div");
        t.style.fontSize = "0.8em";
        t.style.color = "#666";
        t.style.marginTop = "4px";
        const parts = [];
        if (target.name) parts.push(target.name);
        if (target.account) parts.push(`account ${target.account}`);
        if (target.device) parts.push(`device ${target.device}`);
        if (target.ip) parts.push(target.ip);
        t.textContent = parts.join(" · ");
        row.appendChild(t);
    }

    if (finding.details) {
        const d = document.createElement("div");
        d.style.fontSize = "0.85em";
        d.style.color = "#444";
        d.style.marginTop = "6px";
        d.textContent = finding.details;
        row.appendChild(d);
    }

    const fixes = finding.quickFixes || [];
    if (fixes.length > 0) {
        const actions = document.createElement("div");
        actions.style.marginTop = "8px";
        actions.style.display = "flex";
        actions.style.gap = "8px";
        actions.style.flexWrap = "wrap";
        for (const fix of fixes) {
            const btn = document.createElement("button");
            btn.textContent = fix.label || fix.id;
            btn.onclick = () => runQuickFix(checkId, fix.id, target, fix.confirm, btn);
            actions.appendChild(btn);
        }
        const status = document.createElement("span");
        status.className = "health-fix-status";
        status.style.fontSize = "0.85em";
        status.style.alignSelf = "center";
        actions.appendChild(status);
        row.appendChild(actions);
    }

    const manualCommands = finding.manualCommands || [];
    for (const cmd of manualCommands) {
        row.appendChild(renderManualCommand(cmd));
    }

    return row;
}

function renderManualCommand(cmd) {
    const wrap = document.createElement("div");
    wrap.style.marginTop = "8px";
    wrap.style.padding = "8px";
    wrap.style.background = "#f4f4f4";
    wrap.style.borderRadius = "4px";
    wrap.style.fontSize = "0.85em";

    if (cmd.label) {
        const label = document.createElement("div");
        label.style.color = "#444";
        label.style.marginBottom = "4px";
        label.textContent = cmd.label;
        wrap.appendChild(label);
    }

    const row = document.createElement("div");
    row.style.display = "flex";
    row.style.alignItems = "stretch";
    row.style.gap = "8px";

    const code = document.createElement("code");
    code.style.flex = "1";
    code.style.padding = "6px 8px";
    code.style.background = "#fff";
    code.style.border = "1px solid #ddd";
    code.style.borderRadius = "3px";
    code.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace";
    code.style.whiteSpace = "pre-wrap";
    code.style.wordBreak = "break-all";
    code.textContent = cmd.command;
    row.appendChild(code);

    const copyBtn = document.createElement("button");
    copyBtn.textContent = "Copy";
    copyBtn.style.alignSelf = "flex-start";
    copyBtn.onclick = async () => {
        const ok = await copyTextToClipboard(cmd.command);
        copyBtn.textContent = ok ? "Copied" : "Copy failed";
        if (ok) {
            setTimeout(() => { copyBtn.textContent = "Copy"; }, 1200);
        }
    };
    row.appendChild(copyBtn);

    wrap.appendChild(row);

    if (cmd.hint) {
        const hint = document.createElement("div");
        hint.style.fontSize = "0.8em";
        hint.style.color = "#666";
        hint.style.marginTop = "4px";
        hint.textContent = cmd.hint;
        wrap.appendChild(hint);
    }

    return wrap;
}

function severityBadge(severity) {
    const span = document.createElement("span");
    span.style.fontSize = "0.75em";
    span.style.padding = "2px 6px";
    span.style.borderRadius = "10px";
    span.style.fontWeight = "bold";
    const palette = {
        ok:      { bg: "#e8f5e9", fg: "#2e7d32", label: "OK" },
        info:    { bg: "#e3f2fd", fg: "#1565c0", label: "INFO" },
        warning: { bg: "#fff8e1", fg: "#a06800", label: "WARN" },
        error:   { bg: "#ffebee", fg: "#c62828", label: "ERROR" },
    };
    const p = palette[severity] || palette.info;
    span.style.background = p.bg;
    span.style.color = p.fg;
    span.textContent = p.label;
    return span;
}

async function runQuickFix(checkId, fixId, target, confirmMsg, button) {
    if (confirmMsg && !window.confirm(confirmMsg)) return;

    const status = button.parentElement.querySelector(".health-fix-status");
    button.disabled = true;
    if (status) {
        status.textContent = "Applying…";
        status.style.color = "#555";
    }

    try {
        const resp = await fetch("/api/setup/health/fix", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ checkId, fixId, target }),
        });
        const data = await resp.json().catch(() => ({}));
        if (!resp.ok) throw new Error(data.error || data.message || `HTTP ${resp.status}`);

        if (status) {
            status.textContent = data.message || "Done.";
            status.style.color = "#2e7d32";
        }
        // Re-fetch health so resolved findings disappear from the list.
        // Skipped when the server signals refresh:false (persistent
        // affordances like play_ding that don't change check state).
        if (data.refresh !== false) setTimeout(fetchHealth, 400);
    } catch (e) {
        if (status) {
            status.textContent = `Failed: ${e.message || e}`;
            status.style.color = "#c62828";
        }
        button.disabled = false;
    }
}

// ---------------------------------------------------------------------------
// Logs tab
// ---------------------------------------------------------------------------

const logsState = {
    timerId: null,
    nextSince: 0,
    entries: [],
    maxEntries: 5000,         // client-side cap; UI lag is the bottleneck
    droppedTotal: 0,
    pollIntervalMs: 1500,
    followTail: true,
    initialised: false,
};

function startLogsPolling() {
    initLogsTabOnce();
    // Reset window each time the tab opens so the user gets a
    // fresh snapshot rather than picking up stale state.
    logsState.entries = [];
    logsState.nextSince = 0;
    logsState.droppedTotal = 0;
    renderLogs();
    pollLogsOnce();
    if (logsState.timerId !== null) clearInterval(logsState.timerId);
    logsState.timerId = setInterval(pollLogsOnce, logsState.pollIntervalMs);
}

function stopLogsPolling() {
    if (logsState.timerId !== null) {
        clearInterval(logsState.timerId);
        logsState.timerId = null;
    }
}

function initLogsTabOnce() {
    if (logsState.initialised) return;
    logsState.initialised = true;

    const filterEl = document.getElementById("logs-filter");
    if (filterEl) {
        filterEl.addEventListener("input", () => renderLogs());
    }

    const followEl = document.getElementById("logs-follow");
    if (followEl) {
        followEl.addEventListener("change", () => {
            logsState.followTail = followEl.checked;
            if (logsState.followTail) scrollLogsToBottom();
        });
    }

    const viewEl = document.getElementById("logs-view");
    if (viewEl) {
        // Disengage follow-tail when the user scrolls up; re-engage
        // when they're back at the bottom. tail -f muscle memory.
        viewEl.addEventListener("scroll", () => {
            const distanceFromBottom = viewEl.scrollHeight - viewEl.scrollTop - viewEl.clientHeight;
            const atBottom = distanceFromBottom < 8;
            if (atBottom !== logsState.followTail) {
                logsState.followTail = atBottom;
                const followEl = document.getElementById("logs-follow");
                if (followEl) followEl.checked = atBottom;
            }
        });
    }
}

async function pollLogsOnce() {
    if (typeof document !== "undefined" && document.hidden) return;

    const statusEl = document.getElementById("logs-status");
    try {
        const url = `/api/setup/logs?since=${encodeURIComponent(logsState.nextSince)}`;
        const resp = await fetch(url);
        if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
        const data = await resp.json();

        if (Array.isArray(data.entries) && data.entries.length > 0) {
            logsState.entries.push(...data.entries);
            // Trim from the front when we exceed the client cap.
            if (logsState.entries.length > logsState.maxEntries) {
                logsState.entries.splice(0, logsState.entries.length - logsState.maxEntries);
            }
        }

        if (typeof data.nextSince === "number") {
            logsState.nextSince = data.nextSince;
        }

        if (typeof data.dropped === "number" && data.dropped > 0) {
            logsState.droppedTotal += data.dropped;
        }

        renderLogs();
        if (statusEl) {
            const now = new Date().toLocaleTimeString();
            const droppedNote = logsState.droppedTotal > 0
                ? ` · ${logsState.droppedTotal} dropped`
                : "";
            statusEl.textContent = `${logsState.entries.length} buffered${droppedNote} · last update ${now}`;
        }
    } catch (e) {
        if (statusEl) statusEl.textContent = `Polling failed: ${e.message || e}`;
    }
}

function renderLogs() {
    const viewEl = document.getElementById("logs-view");
    if (!viewEl) return;

    const filterEl = document.getElementById("logs-filter");
    const filter = filterEl ? filterEl.value.trim().toLowerCase() : "";

    const lines = [];
    for (const entry of logsState.entries) {
        if (filter && entry.message.toLowerCase().indexOf(filter) === -1) continue;
        const ts = entry.time ? entry.time.replace("T", " ").replace("Z", "") : "";
        lines.push(`${ts}  ${entry.message}`);
    }

    viewEl.textContent = lines.join("\n");

    if (logsState.followTail) {
        scrollLogsToBottom();
    }
}

function scrollLogsToBottom() {
    const viewEl = document.getElementById("logs-view");
    if (viewEl) viewEl.scrollTop = viewEl.scrollHeight;
}

// ---------------------------------------------------------------------------
// Device summary (Devices tab — per-device "Inspect" panel)
// ---------------------------------------------------------------------------

async function toggleDeviceSummary(deviceId) {
    const row = document.getElementById(`device-summary-${deviceId}`);
    const cell = document.getElementById(`device-summary-cell-${deviceId}`);
    if (!row || !cell) return;

    if (row.style.display !== "none") {
        row.style.display = "none";
        return;
    }

    row.style.display = "";
    cell.innerHTML = '<em style="color:#666;">Probing speaker…</em>';

    try {
        const resp = await fetch(`/api/setup/device-summary/${encodeURIComponent(deviceId)}`);
        if (!resp.ok) {
            const txt = await resp.text();
            cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${resp.status} ${escapeHtml(txt)}</span>`;
            return;
        }
        const data = await resp.json();
        cell.innerHTML = "";
        cell.appendChild(renderDeviceSummary(data));
    } catch (e) {
        cell.innerHTML = `<span style="color:#c62828;">Summary failed: ${escapeHtml(e.message || String(e))}</span>`;
    }
}

function renderDeviceSummary(data) {
    const wrap = document.createElement("div");
    wrap.style.display = "grid";
    wrap.style.gridTemplateColumns = "repeat(auto-fit, minmax(280px, 1fr))";
    wrap.style.gap = "12px";

    wrap.appendChild(summaryCard("Speaker /info", renderSpeakerInfoBody(data.speaker.info)));
    wrap.appendChild(summaryCard("Speaker /sources", renderSpeakerSourcesBody(data.speaker.sources, data.service)));
    wrap.appendChild(summaryCard("Speaker /presets", renderSpeakerPresetsBody(data.speaker.presets, data.service)));
    wrap.appendChild(summaryCard("Service-side state", renderServiceBody(data.service)));
    wrap.appendChild(summaryCard("Pairing inference", renderPairingBody(data.pairing, data.service)));

    const footer = document.createElement("div");
    footer.style.gridColumn = "1 / -1";
    footer.style.fontSize = "0.75em";
    footer.style.color = "#888";
    footer.textContent = `Generated at ${data.generated_at}`;
    wrap.appendChild(footer);

    return wrap;
}

function summaryCard(title, bodyNode) {
    const card = document.createElement("div");
    card.style.background = "#fff";
    card.style.border = "1px solid #ddd";
    card.style.borderRadius = "4px";
    card.style.padding = "10px 12px";

    const h = document.createElement("div");
    h.style.fontWeight = "bold";
    h.style.marginBottom = "8px";
    h.style.fontSize = "0.9em";
    h.textContent = title;
    card.appendChild(h);

    if (bodyNode) card.appendChild(bodyNode);
    return card;
}

function renderSpeakerInfoBody(info) {
    const body = document.createElement("div");
    body.style.fontSize = "0.85em";

    if (!info.reachable) {
        body.appendChild(unreachableBlock(info));
        return body;
    }

    body.appendChild(kv("name", info.name));
    body.appendChild(kv("type", info.type));
    body.appendChild(kv("margeAccountUUID", info.marge_account_uuid || "(empty)"));
    body.appendChild(kv("margeURL", info.marge_url || "(empty)"));
    return body;
}

function renderSpeakerSourcesBody(sources, service) {
    const body = document.createElement("div");
    body.style.fontSize = "0.85em";

    if (!sources.reachable) {
        body.appendChild(unreachableBlock(sources));
        return body;
    }

    const types = sources.types || [];
    body.appendChild(kv("count", String(types.length)));
    body.appendChild(kv("types", types.length ? types.join(", ") : "(none)"));

    const svcTypes = (service && service.service_source_types) || [];
    const missingOnSpeaker = svcTypes.filter(t => types.indexOf(t) < 0);
    const extraOnSpeaker = types.filter(t => svcTypes.indexOf(t) < 0);

    if (missingOnSpeaker.length > 0) {
        body.appendChild(kv("missing on speaker", missingOnSpeaker.join(", "), "#a06800"));
    }
    if (extraOnSpeaker.length > 0) {
        body.appendChild(kv("extra on speaker", extraOnSpeaker.join(", "), "#666"));
    }
    return body;
}

function renderSpeakerPresetsBody(presets, service) {
    const body = document.createElement("div");
    body.style.fontSize = "0.85em";

    if (!presets.reachable) {
        body.appendChild(unreachableBlock(presets));
        return body;
    }

    const ids = presets.ids || [];
    body.appendChild(kv("count", String(ids.length)));
    body.appendChild(kv("slots", ids.length ? ids.join(", ") : "(none)"));

    if (service && typeof service.service_preset_count === "number") {
        if (service.service_preset_count !== ids.length) {
            body.appendChild(kv("service count", String(service.service_preset_count), "#a06800"));
        }
    }
    return body;
}

function renderServiceBody(service) {
    const body = document.createElement("div");
    body.style.fontSize = "0.85em";

    body.appendChild(kv("server URL", service.server_url || "(unset)"));

    const hosts = service.expected_hosts || [];
    body.appendChild(kv("expected hosts", hosts.length ? hosts.join(", ") : "(none)"));

    body.appendChild(kv("Sources.xml", service.sources_xml_present ? "present" : "MISSING", service.sources_xml_present ? null : "#c62828"));
    body.appendChild(kv("Presets.xml", service.presets_xml_present ? `${service.service_preset_count} preset(s)` : "(empty)"));
    return body;
}

function renderPairingBody(pairing, service) {
    const body = document.createElement("div");
    body.style.fontSize = "0.85em";

    body.appendChild(kv("paired", pairing.paired ? "yes" : "NO", pairing.paired ? null : "#c62828"));
    body.appendChild(kv("speaker marge host", pairing.speaker_marge_host || "(unknown)"));

    const matches = pairing.marge_url_matches_service;
    body.appendChild(kv("matches service?", matches ? "yes" : "NO", matches ? null : "#a06800"));
    return body;
}

function kv(label, value, valueColor) {
    const row = document.createElement("div");
    row.style.display = "flex";
    row.style.gap = "8px";
    row.style.marginBottom = "2px";
    row.style.alignItems = "baseline";

    const l = document.createElement("span");
    l.style.color = "#666";
    l.style.minWidth = "120px";
    l.style.flexShrink = "0";
    l.textContent = label;
    row.appendChild(l);

    const v = document.createElement("span");
    v.style.wordBreak = "break-all";
    if (valueColor) v.style.color = valueColor;
    v.textContent = value;
    row.appendChild(v);
    return row;
}

function unreachableBlock(probe) {
    const wrap = document.createElement("div");

    const msg = document.createElement("div");
    msg.style.color = "#a06800";
    msg.style.marginBottom = "6px";
    msg.textContent = probe.error ? `Unreachable: ${probe.error}` : "Unreachable from this service host.";
    wrap.appendChild(msg);

    if (probe.curl_command) {
        const hint = document.createElement("div");
        hint.style.fontSize = "0.85em";
        hint.style.color = "#444";
        hint.style.marginBottom = "4px";
        hint.textContent = "Run from your LAN:";
        wrap.appendChild(hint);

        const row = document.createElement("div");
        row.style.display = "flex";
        row.style.gap = "8px";
        row.style.alignItems = "stretch";

        const code = document.createElement("code");
        code.style.flex = "1";
        code.style.padding = "4px 6px";
        code.style.background = "#f4f4f4";
        code.style.border = "1px solid #ddd";
        code.style.borderRadius = "3px";
        code.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, monospace";
        code.style.fontSize = "0.8em";
        code.style.whiteSpace = "pre-wrap";
        code.style.wordBreak = "break-all";
        code.textContent = probe.curl_command;
        row.appendChild(code);

        const btn = document.createElement("button");
        btn.textContent = "Copy";
        btn.onclick = async () => {
            const ok = await copyTextToClipboard(probe.curl_command);
            btn.textContent = ok ? "Copied" : "Copy failed";
            if (ok) {
                setTimeout(() => { btn.textContent = "Copy"; }, 1200);
            }
        };
        row.appendChild(btn);
        wrap.appendChild(row);
    }

    return wrap;
}
