#!/usr/bin/env python3
"""
Donjon Platform - Settings Tab (HTML)
Fetches platform configuration from API and renders
editable settings fields.
All CSS/JS inline for air-gap operation.
"""

import json


def _ai_backend_catalog():
    """Return (backends, offline) for the AI settings UI.

    Pulls the canonical backend list + the air-gap flag straight from the
    engine at render time so the dashboard never drifts from AIEngine. When
    DONJON_OFFLINE is set, cloud backends are dropped from the catalog entirely
    so they are never offered in the UI. Falls back to a minimal local-only
    catalog if the engine cannot be imported (keeps the page rendering).
    """
    try:
        import os
        import sys
        from pathlib import Path
        lib_dir = str(Path(__file__).resolve().parent.parent / 'lib')
        if lib_dir not in sys.path:
            sys.path.insert(0, lib_dir)
        from ai_engine import AIEngine, _is_offline  # type: ignore
        offline = bool(_is_offline())
        backends = AIEngine.get_supported_backends()
    except Exception:
        offline = str(__import__('os').environ.get('DONJON_OFFLINE', '')).strip().lower() \
            in ('1', 'true', 'yes', 'on')
        backends = [
            {'id': 'ollama', 'label': 'Ollama (Local)', 'airgap': True, 'metered': False,
             'requires': 'OLLAMA_URL or localhost:11434'},
            {'id': 'custom', 'label': 'Custom (OpenAI-compatible URL)', 'airgap': True,
             'metered': False, 'requires': 'api_url (+ api_key if needed)'},
            {'id': 'generic', 'label': 'Generic HTTP (any endpoint)', 'airgap': True,
             'metered': False, 'requires': 'api_url + request_template + response_path'},
            {'id': 'anthropic', 'label': 'Anthropic Claude (Direct)', 'airgap': False,
             'metered': True, 'requires': 'ANTHROPIC_API_KEY'},
            {'id': 'openai', 'label': 'OpenAI', 'airgap': False, 'metered': True,
             'requires': 'OPENAI_API_KEY'},
            {'id': 'gemini', 'label': 'Google Gemini', 'airgap': False, 'metered': True,
             'requires': 'GEMINI_API_KEY'},
            {'id': 'template', 'label': 'Template Fallback (No LLM)', 'airgap': True,
             'metered': False, 'requires': 'Nothing'},
        ]
    # In air-gapped mode, never even list a cloud backend.
    if offline:
        backends = [b for b in backends if b.get('airgap')]
    # Keep only the fields the client needs and guarantee template is present/last.
    slim = [{
        'id': b.get('id'),
        'label': b.get('label', b.get('id')),
        'airgap': bool(b.get('airgap')),
        'metered': bool(b.get('metered')),
        'requires': b.get('requires', ''),
    } for b in backends if b.get('id')]
    if not any(b['id'] == 'template' for b in slim):
        slim.append({'id': 'template', 'label': 'Template Fallback (No LLM)',
                     'airgap': True, 'metered': False, 'requires': 'Nothing'})
    return slim, offline


def generate_settings_html() -> str:
    """Return the settings tab HTML as a string."""
    parts = []

    ai_backends, ai_offline = _ai_backend_catalog()

    parts.append('<style>')
    parts.append(
        '.st-section {'
        '  background: var(--bg-surface);'
        '  border: 1px solid var(--border);'
        '  border-radius: var(--radius);'
        '  padding: 20px;'
        '  margin-bottom: 16px;'
        '}'
        '.st-section-title {'
        '  font-size: 0.95rem;'
        '  font-weight: 600;'
        '  color: var(--text);'
        '  margin-bottom: 16px;'
        '  padding-bottom: 8px;'
        '  border-bottom: 1px solid var(--border);'
        '}'
        '.st-row {'
        '  display: flex;'
        '  align-items: center;'
        '  justify-content: space-between;'
        '  padding: 10px 0;'
        '  border-bottom: 1px solid var(--border);'
        '}'
        '.st-row:last-child { border-bottom: none; }'
        '.st-row-label {'
        '  font-size: 0.82rem;'
        '  font-weight: 500;'
        '  color: var(--text-muted);'
        '  min-width: 200px;'
        '}'
        '.st-input {'
        '  background: var(--bg-body);'
        '  border: 1px solid var(--border);'
        '  border-radius: 6px;'
        '  padding: 6px 12px;'
        '  color: var(--text);'
        '  font-size: 0.85rem;'
        '  font-family: var(--font-data);'
        '  width: 280px;'
        '}'
        '.st-input:focus { outline: none; border-color: var(--accent); }'
        '.st-btn {'
        '  background: var(--accent);'
        '  color: #fff;'
        '  border: none;'
        '  border-radius: 6px;'
        '  padding: 8px 20px;'
        '  font-size: 0.85rem;'
        '  font-weight: 600;'
        '  cursor: pointer;'
        '  font-family: var(--font-ui);'
        '  transition: background 0.2s;'
        '}'
        '.st-btn:hover { background: var(--accent-hover); }'
        '.st-save-bar {'
        '  display: flex;'
        '  align-items: center;'
        '  gap: 12px;'
        '  margin-top: 16px;'
        '}'
        '.st-msg {'
        '  font-size: 0.82rem;'
        '  font-weight: 500;'
        '  padding: 4px 12px;'
        '  border-radius: 6px;'
        '  display: none;'
        '}'
        '.st-msg.ok { display: inline-block; background: rgba(34,197,94,0.15); color: #22c55e; }'
        '.st-msg.err { display: inline-block; background: rgba(239,68,68,0.15); color: #ef4444; }'
        '.st-paths {'
        '  font-family: var(--font-data);'
        '  font-size: 0.82rem;'
        '  color: var(--text);'
        '}'
        # --- AI engine controls ---
        '.st-select {'
        '  background: var(--bg-body);'
        '  border: 1px solid var(--border);'
        '  border-radius: 6px;'
        '  padding: 6px 12px;'
        '  color: var(--text);'
        '  font-size: 0.85rem;'
        '  font-family: var(--font-ui);'
        '  width: 280px;'
        '}'
        '.st-select:focus { outline: none; border-color: var(--accent); }'
        '.st-ai-row { display: none; }'
        '.st-ai-row.show { display: flex; }'
        '.st-hint {'
        '  font-size: 0.72rem;'
        '  color: var(--text-muted);'
        '  margin: 2px 0 0 0;'
        '  max-width: 280px;'
        '  text-align: right;'
        '}'
        '.st-badge {'
        '  display: inline-block;'
        '  padding: 1px 8px;'
        '  border-radius: 9px;'
        '  font-size: 0.68rem;'
        '  font-weight: 600;'
        '  margin-left: 6px;'
        '  vertical-align: middle;'
        '}'
        '.st-badge.airgap { background: rgba(34,197,94,0.15); color: #22c55e; }'
        '.st-badge.cloud { background: rgba(234,179,8,0.15); color: #eab308; }'
        '.st-badge.metered { background: rgba(239,68,68,0.12); color: #ef4444; }'
        '.st-note {'
        '  display: flex;'
        '  gap: 8px;'
        '  align-items: flex-start;'
        '  background: rgba(34,197,94,0.10);'
        '  border: 1px solid rgba(34,197,94,0.35);'
        '  border-radius: 8px;'
        '  padding: 10px 14px;'
        '  margin-bottom: 14px;'
        '  font-size: 0.8rem;'
        '  color: var(--text);'
        '}'
        '.st-note .st-note-icon { font-weight: 700; color: #22c55e; }'
        '.st-btn-secondary {'
        '  background: transparent;'
        '  color: var(--text);'
        '  border: 1px solid var(--border);'
        '  border-radius: 6px;'
        '  padding: 6px 16px;'
        '  font-size: 0.82rem;'
        '  font-weight: 600;'
        '  cursor: pointer;'
        '  font-family: var(--font-ui);'
        '}'
        '.st-btn-secondary:hover { border-color: var(--accent); }'
        'textarea.st-input { width: 280px; min-height: 60px; resize: vertical; }'
    )
    parts.append('</style>')

    # Scan settings section
    parts.append(
        '<div class="st-section">'
        '<div class="st-section-title">Scan Settings</div>'
        '<div class="st-row">'
        '<span class="st-row-label">Default Timeout (seconds)</span>'
        '<input class="st-input" id="stScanTimeout" data-key="scan.default_timeout" />'
        '</div>'
        '<div class="st-row">'
        '<span class="st-row-label">Max Concurrent Scans</span>'
        '<input class="st-input" id="stScanConcurrent" data-key="scan.max_concurrent" />'
        '</div>'
        '<div class="st-row">'
        '<span class="st-row-label">Retry Count</span>'
        '<input class="st-input" id="stScanRetry" data-key="scan.retry_count" />'
        '</div>'
        '</div>'
    )

    # Reporting settings
    parts.append(
        '<div class="st-section">'
        '<div class="st-section-title">Reporting</div>'
        '<div class="st-row">'
        '<span class="st-row-label">Company Name</span>'
        '<input class="st-input" id="stCompany" data-key="reporting.company_name" />'
        '</div>'
        '<div class="st-row">'
        '<span class="st-row-label">Output Format</span>'
        '<input class="st-input" id="stFormat" data-key="reporting.output_format" placeholder="pdf" />'
        '</div>'
        '</div>'
    )

    # AI settings — data-driven from AIEngine.get_supported_backends().
    # The backend catalog + offline flag are emitted server-side so the UI can
    # never offer a cloud backend in air-gapped mode.
    parts.append('<div class="st-section">')
    parts.append('<div class="st-section-title">AI Engine</div>')
    parts.append(
        '<script type="application/json" id="stAIBackends">'
        + json.dumps(ai_backends)
        + '</script>'
    )
    parts.append(
        '<script type="application/json" id="stAIOffline">'
        + json.dumps({'offline': bool(ai_offline)})
        + '</script>'
    )

    # Air-gapped banner (rendered only when DONJON_OFFLINE is active).
    if ai_offline:
        parts.append(
            '<div class="st-note">'
            '<span class="st-note-icon">&#9679;</span>'
            '<span><strong>Air-gapped (DONJON_OFFLINE=1):</strong> local AI only. '
            'Cloud providers are disabled; the AI engine will not make any outbound '
            'connection. Use Ollama or point the Custom/Generic backend at a '
            'loopback or private (RFC1918) endpoint.</span>'
            '</div>'
        )

    # Live status row (populated from /api/v1/ai/status).
    parts.append(
        '<div class="st-row">'
        '<span class="st-row-label">Current Status</span>'
        '<span class="st-paths" id="stAIStatus">Loading...</span>'
        '</div>'
    )

    # Backend selector (a <select>, not free text).
    parts.append(
        '<div class="st-row">'
        '<span class="st-row-label">Backend</span>'
        '<select class="st-select" id="stAIBackend"></select>'
        '</div>'
    )
    parts.append(
        '<div class="st-row" style="border-bottom:none;padding-top:0;">'
        '<span class="st-row-label"></span>'
        '<p class="st-hint" id="stAIBackendHint"></p>'
        '</div>'
    )

    # Conditional fields — toggled by JS based on the selected backend.
    parts.append(
        '<div class="st-ai-row st-row" id="rowAIModel">'
        '<span class="st-row-label">Model</span>'
        '<input class="st-input" id="stAIModel" placeholder="e.g. qwen2.5-coder:14b" />'
        '</div>'
        '<div class="st-ai-row st-row" id="rowAIUrl">'
        '<span class="st-row-label" id="lblAIUrl">Endpoint / Base URL</span>'
        '<input class="st-input" id="stAIUrl" placeholder="http://localhost:11434" />'
        '</div>'
        '<div class="st-ai-row st-row" id="rowAIKey">'
        '<span class="st-row-label">API Key (BYO)</span>'
        '<input class="st-input" id="stAIKey" type="password" '
        'placeholder="leave blank to use the environment variable" autocomplete="off" />'
        '</div>'
        '<div class="st-ai-row st-row" id="rowAITemplate">'
        '<span class="st-row-label">Request Template (JSON)</span>'
        '<textarea class="st-input" id="stAITemplate" '
        'placeholder=\'{"prompt": "{prompt}", "system": "{system}"}\'></textarea>'
        '</div>'
        '<div class="st-ai-row st-row" id="rowAIRespPath">'
        '<span class="st-row-label">Response Path</span>'
        '<input class="st-input" id="stAIRespPath" '
        'placeholder="choices[0].message.content" />'
        '</div>'
    )

    # AI-specific action bar (separate from the global Save Settings button).
    parts.append(
        '<div class="st-save-bar">'
        '<button class="st-btn" id="stAISaveBtn">Save AI Backend</button>'
        '<button class="st-btn-secondary" id="stAITestBtn">Test Connection</button>'
        '<span class="st-msg" id="stAIMsg"></span>'
        '</div>'
    )
    parts.append('</div>')

    # Notifications settings
    parts.append(
        '<div class="st-section">'
        '<div class="st-section-title">Notifications</div>'
        '<div class="st-row">'
        '<span class="st-row-label">Enabled</span>'
        '<input class="st-input" id="stNotifEnabled" data-key="notifications.enabled" placeholder="true" />'
        '</div>'
        '<div class="st-row">'
        '<span class="st-row-label">Email</span>'
        '<input class="st-input" id="stNotifEmail" data-key="notifications.email" />'
        '</div>'
        '</div>'
    )

    # Dashboard settings
    parts.append(
        '<div class="st-section">'
        '<div class="st-section-title">Dashboard</div>'
        '<div class="st-row">'
        '<span class="st-row-label">Refresh Interval (seconds)</span>'
        '<input class="st-input" id="stDashRefresh" data-key="dashboard.refresh_interval" placeholder="30" />'
        '</div>'
        '<div class="st-row">'
        '<span class="st-row-label">Logging Level</span>'
        '<input class="st-input" id="stLogLevel" data-key="logging.level" placeholder="INFO" />'
        '</div>'
        '</div>'
    )

    # Paths section (read-only)
    parts.append(
        '<div class="st-section">'
        '<div class="st-section-title">Data Paths</div>'
        '<div id="stPaths">'
        '<div style="color:var(--text-muted);text-align:center;padding:12px;">Loading...</div>'
        '</div>'
        '</div>'
    )

    # Save bar
    parts.append(
        '<div class="st-save-bar">'
        '<button class="st-btn" id="stSaveBtn">Save Settings</button>'
        '<span class="st-msg" id="stMsg"></span>'
        '</div>'
    )

    # JavaScript
    parts.append('<script>')
    parts.append(r'''(function(){
    var loaded = false;

    function loadSettings() {
        if (loaded) return;
        loaded = true;

        // Fetch current config
        fetch("/api/v1/settings/config").then(function(r){return r.json();}).then(function(d){
            var cfg = d.config || d;
            // Populate all inputs by data-key
            document.querySelectorAll("[data-key]").forEach(function(input) {
                var key = input.getAttribute("data-key");
                var parts = key.split(".");
                var val = cfg;
                for (var i = 0; i < parts.length && val; i++) {
                    val = val[parts[i]];
                }
                if (val !== undefined && val !== null) {
                    input.value = val;
                }
            });
        }).catch(function(){
            // Fields stay empty — that's fine for initial setup
        });

        // Fetch paths
        fetch("/api/v1/settings/paths").then(function(r){return r.json();}).then(function(d){
            var paths = d.paths || d;
            var el = document.getElementById("stPaths");
            var html = "";
            for (var k in paths) {
                html += '<div class="st-row">' +
                    '<span class="st-row-label">' + k + '</span>' +
                    '<span class="st-paths">' + paths[k] + '</span></div>';
            }
            el.innerHTML = html || '<div style="color:var(--text-muted);text-align:center;">No paths configured.</div>';
        }).catch(function(){
            document.getElementById("stPaths").innerHTML =
                '<div style="color:var(--text-muted);text-align:center;">Path info unavailable.</div>';
        });

        // Save button
        document.getElementById("stSaveBtn").addEventListener("click", function() {
            var payload = {};
            document.querySelectorAll("[data-key]").forEach(function(input) {
                var val = input.value.trim();
                if (val !== "") {
                    payload[input.getAttribute("data-key")] = val;
                }
            });

            var msg = document.getElementById("stMsg");
            msg.className = "st-msg";
            msg.textContent = "";

            fetch("/api/v1/settings/config", {
                method: "PUT",
                headers: {"Content-Type": "application/json"},
                body: JSON.stringify(payload)
            }).then(function(r){
                if (!r.ok) throw new Error("API " + r.status);
                return r.json();
            }).then(function(){
                msg.className = "st-msg ok";
                msg.textContent = "Settings saved successfully.";
                setTimeout(function(){ msg.className = "st-msg"; }, 3000);
            }).catch(function(err){
                msg.className = "st-msg err";
                msg.textContent = "Save failed: " + err.message;
                setTimeout(function(){ msg.className = "st-msg"; }, 5000);
            });
        });

        initAIEngine();
    }

    // ---------------------------------------------------------------
    // AI Engine backend selector (driven by AIEngine.get_supported_backends())
    // ---------------------------------------------------------------
    function parseJSONTag(id, fallback) {
        var el = document.getElementById(id);
        if (!el) return fallback;
        try { return JSON.parse(el.textContent || "null") || fallback; }
        catch (e) { return fallback; }
    }

    function initAIEngine() {
        var sel = document.getElementById("stAIBackend");
        if (!sel || sel.dataset.ready === "1") return;
        sel.dataset.ready = "1";

        var backends = parseJSONTag("stAIBackends", []);
        var offline = (parseJSONTag("stAIOffline", {offline:false}) || {}).offline === true;
        var byId = {};

        // Populate the <select> with labelled, tagged options.
        backends.forEach(function(b) {
            byId[b.id] = b;
            var opt = document.createElement("option");
            opt.value = b.id;
            var tag = b.airgap ? "  [air-gap]" : "  [cloud]";
            if (b.metered) tag += " [metered]";
            opt.textContent = b.label + tag;
            sel.appendChild(opt);
        });

        function showRow(id, on) {
            var el = document.getElementById(id);
            if (el) el.className = on ? "st-ai-row st-row show" : "st-ai-row st-row";
        }

        // Show only the fields a given backend needs.
        function applyBackend(id) {
            var b = byId[id] || {};
            var isOllama = (id === "ollama");
            var isCustom = (id === "custom");
            var isGeneric = (id === "generic");
            var isTemplate = (id === "template");
            // Cloud/openai-compatible BYO-key backends: anything not local/template.
            var isCloud = !isOllama && !isCustom && !isGeneric && !isTemplate;

            showRow("rowAIModel", !isTemplate);
            showRow("rowAIUrl", isOllama || isCustom || isGeneric || id === "openai" || id === "azure");
            showRow("rowAIKey", isCloud || isCustom || isGeneric);
            showRow("rowAITemplate", isGeneric);
            showRow("rowAIRespPath", isGeneric);

            var urlLbl = document.getElementById("lblAIUrl");
            var urlIn = document.getElementById("stAIUrl");
            if (urlLbl && urlIn) {
                if (isOllama) { urlLbl.textContent = "Ollama Base URL"; urlIn.placeholder = "http://localhost:11434"; }
                else if (isGeneric) { urlLbl.textContent = "Endpoint URL"; urlIn.placeholder = "http://127.0.0.1:9000/infer"; }
                else { urlLbl.textContent = "Endpoint / Base URL"; urlIn.placeholder = "http://127.0.0.1:8000/v1/chat/completions"; }
            }

            var hint = document.getElementById("stAIBackendHint");
            if (hint) {
                var txt = b.requires ? ("Needs: " + b.requires) : "";
                if (isCloud && offline) txt = "Disabled in air-gapped mode.";
                else if (isCloud) txt += "  -- billed per-token to YOUR account/key (AI is a Pro feature).";
                else if (isGeneric) txt += "  -- request_template must contain {prompt}; response_path extracts the answer.";
                else if (isCustom) txt += "  -- any OpenAI-compatible endpoint (llama.cpp, vLLM, LM Studio, gateway).";
                hint.textContent = txt;
            }
        }

        sel.addEventListener("change", function() { applyBackend(sel.value); });

        // Pull current config to preselect the active backend + fill fields.
        fetch("/api/v1/ai/config").then(function(r){return r.json();}).then(function(cfg){
            cfg = cfg || {};
            var cur = cfg.backend || "template";
            if (!byId[cur]) {
                // Backend saved but not offered in this mode (e.g. cloud while offline).
                var ph = document.createElement("option");
                ph.value = cur; ph.textContent = cur + " (unavailable in this mode)";
                sel.insertBefore(ph, sel.firstChild);
                byId[cur] = {id: cur, requires: ""};
            }
            sel.value = cur;
            if (cfg.model && cfg.model !== "None") document.getElementById("stAIModel").value = cfg.model;
            var url = cfg.api_url || cfg.ollama_url || "";
            if (url) document.getElementById("stAIUrl").value = url;
            if (cfg.request_template) document.getElementById("stAITemplate").value = cfg.request_template;
            if (cfg.response_path) document.getElementById("stAIRespPath").value = cfg.response_path;
            // api_key is returned masked (****...) — never prefill the password box.
            applyBackend(cur);
        }).catch(function(){ applyBackend(sel.value); });

        refreshAIStatus();
        document.getElementById("stAISaveBtn").addEventListener("click", saveAIBackend);
        document.getElementById("stAITestBtn").addEventListener("click", testAIBackend);
    }

    function refreshAIStatus() {
        var el = document.getElementById("stAIStatus");
        if (!el) return;
        fetch("/api/v1/ai/status").then(function(r){return r.json();}).then(function(s){
            var b = s.backend || "unknown";
            var m = (s.model && s.model !== "None") ? (" / " + s.model) : "";
            el.textContent = (s.available === false ? "unavailable" : b) + m;
        }).catch(function(){ el.textContent = "unavailable"; });
    }

    function collectAIPayload() {
        var sel = document.getElementById("stAIBackend");
        var id = sel.value;
        var payload = {backend: id};
        var model = document.getElementById("stAIModel").value.trim();
        var url = document.getElementById("stAIUrl").value.trim();
        var key = document.getElementById("stAIKey").value;
        var tmpl = document.getElementById("stAITemplate").value.trim();
        var rpath = document.getElementById("stAIRespPath").value.trim();

        if (id === "ollama") {
            if (url) payload.ollama_url = url;
            if (model) { payload.ollama_model = model; payload.model = model; }
        } else if (id === "custom") {
            payload.api_url = url;
            if (model) payload.model = model;
            if (key) payload.api_key = key;
        } else if (id === "generic") {
            payload.api_url = url;
            if (tmpl) payload.request_template = tmpl;
            if (rpath) payload.response_path = rpath;
            if (model) payload.model = model;
            if (key) payload.api_key = key;
        } else if (id !== "template") {
            if (url) payload.api_url = url;
            if (model) payload.model = model;
            if (key) payload.api_key = key;
        }
        return payload;
    }

    // Client-side guard mirroring the engine's air-gap rule (server re-checks).
    function offlineRejection(payload) {
        var offline = (parseJSONTag("stAIOffline", {offline:false}) || {}).offline === true;
        if (!offline) return null;
        var local = ["ollama", "custom", "generic", "template"];
        if (local.indexOf(payload.backend) === -1)
            return "Air-gapped mode: '" + payload.backend + "' is a cloud backend and is blocked.";
        var ep = payload.api_url || payload.ollama_url || "";
        if (payload.backend !== "template") {
            if (!ep) return "Air-gapped mode: a loopback/private endpoint URL is required.";
            var ok = /^https?:\/\/(localhost|127\.|10\.|192\.168\.|172\.(1[6-9]|2[0-9]|3[01])\.)/i.test(ep) ||
                     /^https?:\/\/[^/]*\.(local|internal|lan|home|corp|intra)(:|\/|$)/i.test(ep);
            if (!ok) return "Air-gapped mode: '" + ep + "' is not a loopback/private endpoint.";
        }
        return null;
    }

    function setAIMsg(cls, text, ms) {
        var msg = document.getElementById("stAIMsg");
        msg.className = "st-msg " + cls;
        msg.textContent = text;
        if (ms) setTimeout(function(){ msg.className = "st-msg"; }, ms);
    }

    function saveAIBackend() {
        var payload = collectAIPayload();
        var rej = offlineRejection(payload);
        if (rej) { setAIMsg("err", rej, 6000); return; }
        setAIMsg("", "");
        fetch("/api/v1/ai/config", {
            method: "POST",
            headers: {"Content-Type": "application/json"},
            body: JSON.stringify(payload)
        }).then(function(r){
            if (!r.ok) throw new Error("API " + r.status);
            return r.json();
        }).then(function(d){
            setAIMsg("ok", "AI backend saved: " + (d.backend || payload.backend), 4000);
            refreshAIStatus();
        }).catch(function(err){
            setAIMsg("err", "Save failed: " + err.message, 6000);
        });
    }

    function testAIBackend() {
        // Persist first so the engine tests the selected backend, then probe.
        var payload = collectAIPayload();
        var rej = offlineRejection(payload);
        if (rej) { setAIMsg("err", rej, 6000); return; }
        setAIMsg("", "Testing...");
        fetch("/api/v1/ai/config", {
            method: "POST",
            headers: {"Content-Type": "application/json"},
            body: JSON.stringify(payload)
        }).then(function(r){ return r.json(); }).then(function(){
            return fetch("/api/v1/ai/test", {method: "POST"});
        }).then(function(r){ return r.json(); }).then(function(res){
            if (res.success) setAIMsg("ok", "Connection OK (" + (res.backend||"") + ")", 5000);
            else setAIMsg("err", "Test failed: " + (res.error || "no response"), 7000);
            refreshAIStatus();
        }).catch(function(err){
            setAIMsg("err", "Test failed: " + err.message, 7000);
        });
    }

    document.addEventListener("tabload", function(e) {
        if (e.detail && e.detail.tab === "settings") {
            loadSettings();
        }
    });
})();''')
    parts.append('</script>')

    return ''.join(parts)
