apiVersion: v1
kind: ConfigMap
metadata:
  name: spark-analytics-webview
  labels:
    app.kubernetes.io/name: spark-analytics
    app.kubernetes.io/part-of: nowchess
data:
  serve.py: |
    #!/usr/bin/env python3
    """Spark analytics results viewer — queries PostgreSQL analytics tables, serves HTML tables."""
    import html
    import json
    import os
    import re
    import urllib.error
    import urllib.request
    import psycopg2
    import psycopg2.extras
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

    PORT = int(os.environ.get("PORT", "8080"))

    _url = os.environ.get("NOWCHESS_JDBC_URL", "")
    _m = re.match(r"jdbc:postgresql://([^:/]+)(?::(\d+))?/([^?]+)", _url)
    DB_HOST = _m.group(1) if _m else "localhost"
    DB_PORT = int(_m.group(2)) if (_m and _m.group(2)) else 5432
    DB_NAME = _m.group(3) if _m else "nowchess"
    DB_USER = os.environ.get("NOWCHESS_DB_USER", "nowchess")
    DB_PASS = os.environ.get("NOWCHESS_DB_PASS", "")

    # Each tuple: (url-slug, display label, postgres table name)
    DATASETS = [
        ("opening-book",          "Opening Book (Top 1000)",   "analytics_opening_stats"),
        ("player-stats",          "Player Statistics",          "analytics_player_stats"),
        ("cluster-archetypes",    "Cluster Archetypes",         "analytics_cluster_archetypes"),
        ("component-sizes",       "Graph Component Sizes",      "analytics_component_sizes"),
        ("game-length",           "Game Length Distribution",   "analytics_game_length_distribution"),
        ("game-length-by-result", "Game Length by Result",      "analytics_game_length_by_result"),
        ("color-advantage",       "Color Advantage",            "analytics_color_advantage"),
        ("elo-distribution",      "ELO Distribution",           "analytics_elo_distribution"),
        ("time-control",          "Time Control Analysis",      "analytics_time_control_stats"),
        ("hourly-activity",       "Hourly Activity",            "analytics_hourly_activity"),
        ("weekly-activity",       "Weekly Activity",            "analytics_weekly_activity"),
        ("rating-mismatch",       "Rating Mismatch (Upsets)",   "analytics_rating_mismatch"),
        ("termination-stats",     "Termination Types",          "analytics_termination_stats"),
        ("accuracy-by-rating",    "Accuracy by Rating",         "analytics_accuracy_by_rating"),
        ("blunder-outcome",       "Blunder Outcome",            "analytics_blunder_outcome"),
        ("clock-by-rating",       "Clock Pressure by Rating",   "analytics_clock_by_rating"),
        ("scramble-outcome",      "Scramble Outcome",           "analytics_scramble_outcome"),
        ("smurf-anomaly",         "Smurf Anomaly Scores",       "analytics_smurf_anomaly"),
        ("flagged-smurfs",        "Flagged Smurfs",             "analytics_flagged_smurfs"),
    ]

    CSS = """
    * { box-sizing: border-box; }
    body { font-family: 'Segoe UI', sans-serif; margin: 0; background: #0d1117; color: #c9d1d9; }
    header { background: #161b22; border-bottom: 1px solid #30363d; padding: 1rem 2rem; display: flex; align-items: center; gap: 1rem; }
    header h1 { margin: 0; color: #58a6ff; font-size: 1.25rem; font-weight: 600; flex: 1; }
    header span { color: #8b949e; font-size: 0.875rem; }
    .settings-btn { background: none; border: 1px solid #30363d; border-radius: 6px; color: #8b949e;
                    cursor: pointer; padding: 4px 8px; font-size: 0.8rem; display: flex; align-items: center; gap: 4px; }
    .settings-btn:hover { border-color: #58a6ff; color: #58a6ff; }
    .settings-btn.active { border-color: #3fb950; color: #3fb950; }
    main { padding: 1.5rem 2rem; }
    h2 { color: #e6edf3; font-size: 1rem; font-weight: 600; margin: 0 0 1rem; }
    .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; }
    .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1rem; }
    .card h3 { margin: 0 0 0.5rem; font-size: 0.9rem; color: #58a6ff; }
    .card a { text-decoration: none; color: inherit; display: block; }
    .card a:hover .card-label { text-decoration: underline; }
    .badge { display: inline-block; background: #21262d; border: 1px solid #30363d;
             border-radius: 12px; padding: 1px 8px; font-size: 0.75rem; color: #8b949e; }
    .badge.ready { border-color: #238636; color: #3fb950; }
    .back { color: #58a6ff; text-decoration: none; font-size: 0.875rem; }
    .back:hover { text-decoration: underline; }
    .meta { color: #8b949e; font-size: 0.8rem; margin: 0.5rem 0 1rem; }
    table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
    thead th { background: #161b22; position: sticky; top: 0; padding: 6px 12px;
               text-align: left; color: #8b949e; border-bottom: 1px solid #30363d;
               font-weight: 600; white-space: nowrap; }
    tbody td { padding: 5px 12px; border-bottom: 1px solid #21262d; }
    tbody tr:hover td { background: #161b22; }
    .table-wrap { overflow-x: auto; border: 1px solid #30363d; border-radius: 6px; }
    .notice { background: #161b22; border: 1px solid #30363d; border-radius: 6px;
              padding: 1.5rem; color: #8b949e; }
    /* Settings modal */
    .modal-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.6);
                     z-index: 100; align-items: center; justify-content: center; }
    .modal-overlay.open { display: flex; }
    .modal { background: #161b22; border: 1px solid #30363d; border-radius: 10px;
             padding: 1.5rem; width: 420px; max-width: 95vw; }
    .modal h3 { margin: 0 0 0.25rem; color: #e6edf3; font-size: 0.95rem; }
    .modal p { margin: 0 0 1rem; color: #8b949e; font-size: 0.8rem; }
    .modal input { width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
                   color: #c9d1d9; font-size: 0.85rem; padding: 8px 10px; margin-bottom: 0.75rem;
                   font-family: monospace; }
    .modal input:focus { outline: none; border-color: #58a6ff; }
    .modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; }
    .btn { border-radius: 6px; padding: 6px 14px; font-size: 0.8rem; cursor: pointer; border: 1px solid transparent; }
    .btn-primary { background: #238636; color: #fff; border-color: #2ea043; }
    .btn-primary:hover { background: #2ea043; }
    .btn-danger { background: none; color: #f85149; border-color: #f85149; }
    .btn-danger:hover { background: rgba(248,81,73,0.1); }
    .btn-ghost { background: none; color: #8b949e; border-color: #30363d; }
    .btn-ghost:hover { color: #c9d1d9; border-color: #8b949e; }
    /* Explain panel */
    .explain-bar { display: flex; align-items: center; gap: 0.75rem; margin: 1rem 0; }
    .explain-btn { background: #1f3a5f; border: 1px solid #388bfd; color: #58a6ff; border-radius: 6px;
                   padding: 6px 14px; font-size: 0.8rem; cursor: pointer; display: flex; align-items: center; gap: 6px; }
    .explain-btn:hover { background: #263d6a; }
    .explain-btn:disabled { opacity: 0.5; cursor: not-allowed; }
    .no-key-hint { color: #8b949e; font-size: 0.78rem; }
    .explain-panel { background: #161b22; border: 1px solid #388bfd; border-radius: 8px;
                     padding: 1.25rem; margin-bottom: 1rem; }
    .explain-panel h4 { margin: 0 0 0.5rem; color: #58a6ff; font-size: 0.85rem; font-weight: 600; }
    .explain-panel .explain-text { color: #c9d1d9; font-size: 0.85rem; line-height: 1.6; white-space: pre-wrap; }
    .explain-panel .explain-error { color: #f85149; font-size: 0.82rem; }
    .spinner { display: inline-block; width: 12px; height: 12px; border: 2px solid #388bfd;
               border-top-color: transparent; border-radius: 50%; animation: spin 0.7s linear infinite; }
    @keyframes spin { to { transform: rotate(360deg); } }
    /* Per-row AI */
    .row-ai-btn { background: none; border: 1px solid #30363d; border-radius: 4px; color: #8b949e;
                  cursor: pointer; padding: 1px 5px; font-size: 0.72rem; line-height: 1.4; }
    .row-ai-btn:hover { border-color: #388bfd; color: #58a6ff; }
    .row-ai-btn:disabled { opacity: 0.4; cursor: not-allowed; }
    .row-ai-btn.done { border-color: #238636; color: #3fb950; }
    .row-expand-tr td { background: #0a0e14; padding: 0.6rem 1rem 0.6rem 2rem;
                        font-size: 0.8rem; line-height: 1.6; border-bottom: 1px solid #30363d; }
    .row-explain-text { color: #c9d1d9; white-space: pre-wrap; }
    .row-explain-error { color: #f85149; }
    """

    JS = """
    const NIM_KEY_STORAGE = 'nim_api_key';
    const rowCache = {};
    let nimQueue = Promise.resolve();

    function getKey() { return localStorage.getItem(NIM_KEY_STORAGE) || ''; }
    function hasKey() { return !!getKey(); }

    function updateSettingsBtn() {
      const btn = document.getElementById('settings-btn');
      if (!btn) return;
      if (hasKey()) {
        btn.classList.add('active');
        btn.title = 'NIM API key set — click to change';
      } else {
        btn.classList.remove('active');
        btn.title = 'Configure NVIDIA NIM API key for AI explanations';
      }
    }

    function openSettings() {
      document.getElementById('nim-modal').classList.add('open');
      const inp = document.getElementById('nim-key-input');
      inp.value = getKey();
      inp.focus();
      updateNoKeyHint();
    }

    function closeSettings() {
      document.getElementById('nim-modal').classList.remove('open');
    }

    function saveSettings() {
      const val = document.getElementById('nim-key-input').value.trim();
      if (val) localStorage.setItem(NIM_KEY_STORAGE, val);
      else localStorage.removeItem(NIM_KEY_STORAGE);
      closeSettings();
      updateSettingsBtn();
      updateNoKeyHint();
    }

    function clearKey() {
      localStorage.removeItem(NIM_KEY_STORAGE);
      document.getElementById('nim-key-input').value = '';
      updateSettingsBtn();
      updateNoKeyHint();
    }

    function updateNoKeyHint() {
      const hint = document.getElementById('no-key-hint');
      if (!hint) return;
      hint.style.display = hasKey() ? 'none' : 'inline';
      const btn = document.getElementById('explain-btn');
      if (btn) btn.disabled = !hasKey();
    }

    async function nimStream(payload, onDelta) {
      const ctrl = new AbortController();
      const tid = setTimeout(() => ctrl.abort(), 180000);
      try {
        const resp = await fetch('/explain', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(payload),
          signal: ctrl.signal,
        });
        if (resp.status === 429) {
          const retryAfter = parseInt(resp.headers.get('Retry-After') || '10', 10);
          const err = new Error('Rate limited — retry in ' + retryAfter + 's');
          err.retryAfter = retryAfter;
          throw err;
        }
        if (!resp.ok) {
          const msg = await resp.text();
          throw new Error('Proxy ' + resp.status + ': ' + msg);
        }
        const reader = resp.body.getReader();
        const dec = new TextDecoder();
        const SEP = String.fromCharCode(10, 10);  // SSE frame separator (two newlines)
        let buf = '', full = '';
        for (;;) {
          const { done, value } = await reader.read();
          if (done) break;
          buf += dec.decode(value, { stream: true });
          let idx;
          while ((idx = buf.indexOf(SEP)) >= 0) {
            const frame = buf.slice(0, idx).trim();
            buf = buf.slice(idx + 2);
            if (!frame.startsWith('data:')) continue;
            const obj = JSON.parse(frame.slice(5).trim());
            if (obj.error) throw new Error(obj.error);
            if (obj.delta) { full += obj.delta; onDelta(full); }
          }
        }
        return full || '(no response)';
      } finally {
        clearTimeout(tid);
      }
    }

    function queueNim(payload, onDelta) {
      const next = nimQueue.then(() => nimStream(payload, onDelta));
      nimQueue = next.catch(() => {});
      return next;
    }

    async function explainDataset() {
      const key = getKey();
      if (!key) { openSettings(); return; }

      const btn = document.getElementById('explain-btn');
      const panel = document.getElementById('explain-panel');
      const textEl = document.getElementById('explain-text');

      btn.disabled = true;
      btn.innerHTML = '<span class="spinner"></span> Thinking…';
      panel.style.display = 'block';
      textEl.className = 'explain-text';
      textEl.textContent = '';

      try {
        const text = await queueNim(
          { key: key, mode: 'dataset', dataset: window.DATASET },
          partial => { textEl.textContent = partial; },
        );
        textEl.textContent = text;
      } catch (e) {
        textEl.className = 'explain-error';
        textEl.textContent = 'Error: ' + e.message;
      } finally {
        btn.disabled = false;
        btn.innerHTML = '✦ Analyse top 10 rows';
        updateNoKeyHint();
      }
    }

    async function analyzeRow(idx) {
      const key = getKey();
      if (!key) { openSettings(); return; }
      if (rowCache[idx] !== undefined) return;

      const btn = document.getElementById('row-btn-' + idx);
      const expandRow = document.getElementById('row-expand-' + idx);
      const textEl = document.getElementById('row-explain-' + idx);
      if (!btn || !expandRow || !textEl) return;

      const dataset = window.DATASET;
      const row = dataset.rows && dataset.rows[idx];
      if (!row) {
        expandRow.style.display = 'table-row';
        textEl.className = 'row-explain-error';
        textEl.textContent = 'Row data not available.';
        return;
      }

      btn.disabled = true;
      btn.innerHTML = '<span class="spinner"></span>';
      expandRow.style.display = 'table-row';
      textEl.className = 'row-explain-text';
      textEl.textContent = 'Analyzing…';

      try {
        const text = await queueNim(
          {
            key: key, mode: 'row',
            dataset: { label: dataset.label, headers: dataset.headers, row: row },
          },
          partial => { textEl.textContent = partial; },
        );
        rowCache[idx] = text;
        textEl.textContent = text;
        btn.innerHTML = '✓';
        btn.classList.add('done');
      } catch (e) {
        textEl.className = 'row-explain-error';
        textEl.textContent = e.message;
        btn.disabled = false;
        btn.innerHTML = '⚡';
      }
    }

    document.addEventListener('DOMContentLoaded', () => {
      updateSettingsBtn();
      updateNoKeyHint();
      document.getElementById('nim-modal')?.addEventListener('click', e => {
        if (e.target === e.currentTarget) closeSettings();
      });
      document.getElementById('nim-key-input')?.addEventListener('keydown', e => {
        if (e.key === 'Enter') saveSettings();
        if (e.key === 'Escape') closeSettings();
      });
    });
    """

    SETTINGS_MODAL = """
    <div class="modal-overlay" id="nim-modal">
      <div class="modal">
        <h3>NVIDIA NIM API Key</h3>
        <p>Used client-side only — stored in your browser's localStorage, never sent to this server.</p>
        <input type="password" id="nim-key-input" placeholder="nvapi-…" autocomplete="off" spellcheck="false" />
        <div class="modal-actions">
          <button class="btn btn-danger" onclick="clearKey()">Clear</button>
          <button class="btn btn-ghost" onclick="closeSettings()">Cancel</button>
          <button class="btn btn-primary" onclick="saveSettings()">Save</button>
        </div>
      </div>
    </div>
    """

    def get_conn():
        return psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS)

    def all_row_counts() -> dict:
        counts = {}
        try:
            with get_conn() as conn:
                with conn.cursor() as cur:
                    for _, _, table in DATASETS:
                        try:
                            cur.execute(f"SELECT COUNT(*) FROM {table}")
                            counts[table] = cur.fetchone()[0]
                        except Exception:
                            conn.rollback()
                            counts[table] = -1
        except Exception:
            for _, _, table in DATASETS:
                counts.setdefault(table, -1)
        return counts

    def fetch_rows(table: str, limit: int = 10_000):
        with get_conn() as conn:
            with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
                cur.execute(f"SELECT * FROM {table} LIMIT %s", (limit,))
                rows = cur.fetchall()
                if not rows:
                    return [], []
                headers = list(rows[0].keys())
                data = [
                    [str(row[h]) if row[h] is not None else "" for h in headers]
                    for row in rows
                ]
                return headers, data

    def page(title: str, body: str, dataset_json: str = "null") -> str:
        return (
            f"<!doctype html><html lang=en><head><meta charset=utf-8>"
            f"<meta name=viewport content='width=device-width,initial-scale=1'>"
            f"<title>{html.escape(title)} — Spark Analytics</title>"
            f"<style>{CSS}</style></head><body>"
            f"<header>"
            f"<h1>Spark Analytics</h1><span>NowChess · staging</span>"
            f"<button class='settings-btn' id='settings-btn' onclick='openSettings()' title='Configure NIM API key'>⚙ NIM Key</button>"
            f"</header>"
            f"<main>{body}</main>"
            f"{SETTINGS_MODAL}"
            f"<script>window.DATASET={dataset_json};\n{JS}</script>"
            f"</body></html>"
        )

    def index_html() -> str:
        counts = all_row_counts()
        cards = ""
        for slug, label, table in DATASETS:
            count = counts.get(table, -1)
            if count > 0:
                badge = f'<span class="badge ready">{count} rows</span>'
            elif count == 0:
                badge = '<span class="badge">no data yet</span>'
            else:
                badge = '<span class="badge">table missing</span>'
            cards += (
                f'<div class="card"><a href="/{slug}">'
                f'<div class="card-label"><h3>{html.escape(label)}</h3></div>'
                f"{badge}</a></div>"
            )
        return page("Results", f"<h2>Datasets</h2><div class='cards'>{cards}</div>")

    def table_html(slug: str, label: str, table: str) -> str:
        back = '<a class="back" href="/">← All datasets</a>'
        try:
            headers, rows = fetch_rows(table)
        except Exception as e:
            return page(
                label,
                f"{back}<h2>{html.escape(label)}</h2>"
                f"<div class='notice'>Error querying {html.escape(table)} on {html.escape(DB_HOST)}:{DB_PORT}/{html.escape(DB_NAME)}: {html.escape(str(e))}</div>",
            )
        if not headers:
            return page(
                label,
                f"{back}<h2>{html.escape(label)}</h2>"
                "<div class='notice'>No data yet. Run the CronJob first, then check back.</div>",
            )

        ai_rows = rows[:200]
        dataset_json = json.dumps({
            "label": label,
            "headers": headers,
            "sample": rows[:10],
            "rows": ai_rows,
            "total_rows": len(rows),
        })

        explain_bar = (
            "<div class='explain-bar'>"
            "<button class='explain-btn' id='explain-btn' onclick='explainDataset()'>✦ Analyse top 10 rows</button>"
            "<span class='no-key-hint' id='no-key-hint'>— <a href='#' onclick='openSettings();return false;' style='color:#58a6ff'>add NIM key</a> to enable AI explanations</span>"
            "</div>"
            "<div class='explain-panel' id='explain-panel' style='display:none'>"
            "<h4>AI Explanation</h4>"
            "<div class='explain-text' id='explain-text'></div>"
            "</div>"
        )

        num_cols = len(headers)
        ths = "".join(f"<th>{html.escape(h)}</th>" for h in headers) + "<th></th>"
        trs = ""
        for i, row in enumerate(rows[:10_000]):
            cells = "".join(f"<td>{html.escape(str(c))}</td>" for c in row)
            if i < 200:
                ai_cell = (
                    f"<td><button class='row-ai-btn' id='row-btn-{i}' "
                    f"onclick='analyzeRow({i})' title='Analyse this row with AI'>⚡</button></td>"
                )
                expand = (
                    f"<tr class='row-expand-tr' id='row-expand-{i}' style='display:none'>"
                    f"<td colspan='{num_cols + 1}'>"
                    f"<span id='row-explain-{i}'></span>"
                    f"</td></tr>"
                )
            else:
                ai_cell = "<td></td>"
                expand = ""
            trs += f"<tr>{cells}{ai_cell}</tr>{expand}"

        truncated = f" (showing first 10 000 of {len(rows)})" if len(rows) > 10_000 else ""
        return page(
            label,
            f"{back}<h2>{html.escape(label)}</h2>"
            f"<p class='meta'>{len(rows)} rows{truncated}</p>"
            f"{explain_bar}"
            f"<div class='table-wrap'><table><thead><tr>{ths}</tr></thead>"
            f"<tbody>{trs}</tbody></table></div>",
            dataset_json=dataset_json,
        )

    SLUG_MAP = {s: (label, table) for s, label, table in DATASETS}

    class Handler(BaseHTTPRequestHandler):
        def log_message(self, fmt, *args):
            pass

        def do_GET(self):
            path = self.path.split("?")[0].lstrip("/")
            if path == "" or path == "index.html":
                self._send(index_html())
            elif path in SLUG_MAP:
                label, table = SLUG_MAP[path]
                self._send(table_html(path, label, table))
            else:
                self.send_response(404)
                self.end_headers()

        def do_POST(self):
            path = self.path.split("?")[0].lstrip("/")
            if path != "explain":
                self.send_response(404)
                self.end_headers()
                return
            length = int(self.headers.get("Content-Length", 0))
            headers_sent = False
            try:
                body = json.loads(self.rfile.read(length))
                api_key = body.get("key", "")
                mode = body.get("mode", "dataset")
                dataset = body.get("dataset", {})
                if not api_key:
                    raise ValueError("missing key")

                label = dataset.get("label", "")
                headers = dataset.get("headers", [])

                if mode == "row":
                    row = dataset.get("row", [])
                    row_desc = ", ".join(f"{h}: {v}" for h, v in zip(headers, row))
                    system_prompt = (
                        "You are a chess analytics expert. Analyze a single data entry from a chess analytics dataset. "
                        "Be specific and insightful — 3-4 sentences. "
                        "If the entry involves a chess opening (ECO code or opening name present), explain the opening's "
                        "strategic ideas, strengths and weaknesses, and why players choose it. "
                        "For player data, explain what the stats reveal about their playing style. "
                        "For other types, explain what makes this entry notable."
                    )
                    user_prompt = (
                        f"Dataset: \"{label}\"\n"
                        f"Columns: {', '.join(headers)}\n\n"
                        f"Entry to analyze: {row_desc}\n\n"
                        "Provide a detailed, chess-specific analysis of this entry."
                    )
                    max_tokens = 300
                else:
                    sample = dataset.get("sample", [])
                    rows_text = "\n".join(
                        f"Row {i + 1}: " + ", ".join(f"{h}: {v}" for h, v in zip(headers, row))
                        for i, row in enumerate(sample)
                    )
                    system_prompt = (
                        "You are a chess analytics expert. Analyze the top 10 entries from a chess analytics dataset. "
                        "For each entry write 1-2 specific sentences about what is notable. "
                        "If entries are chess openings (ECO codes or opening names present), name each opening properly "
                        "and explain its strategic character and why it ranks here. "
                        "Format your response as 'Row N: [analysis]' for each row."
                    )
                    user_prompt = (
                        f"Dataset: \"{label}\" ({dataset.get('total_rows', 0)} total rows)\n"
                        f"Columns: {', '.join(headers)}\n\n"
                        f"Top 10 entries:\n{rows_text}\n\n"
                        "Analyze each entry."
                    )
                    max_tokens = 512

                payload = json.dumps({
                    "model": "meta/llama-3.3-70b-instruct",
                    "messages": [
                        {"role": "system", "content": system_prompt},
                        {"role": "user", "content": user_prompt},
                    ],
                    "max_tokens": max_tokens,
                    "temperature": 0.4,
                    "stream": True,
                }).encode("utf-8")
                req = urllib.request.Request(
                    "https://integrate.api.nvidia.com/v1/chat/completions",
                    data=payload,
                    headers={
                        "Authorization": f"Bearer {api_key}",
                        "Content-Type": "application/json",
                    },
                    method="POST",
                )
                try:
                    upstream = urllib.request.urlopen(req, timeout=110)
                except urllib.error.HTTPError as he:
                    if he.code == 429:
                        retry_after = he.headers.get("Retry-After", "10")
                        resp_body = json.dumps({"error": f"Rate limited — retry in {retry_after}s"}).encode("utf-8")
                        self.send_response(429)
                        self.send_header("Content-Type", "application/json")
                        self.send_header("Content-Length", str(len(resp_body)))
                        self.send_header("Retry-After", str(retry_after))
                        self.end_headers()
                        try:
                            self.wfile.write(resp_body)
                        except BrokenPipeError:
                            pass
                        return
                    raise RuntimeError(f"NIM API error {he.code}: {he.reason}")

                # Relay NIM's SSE stream to the browser as our own SSE.
                self.send_response(200)
                self.send_header("Content-Type", "text/event-stream")
                self.send_header("Cache-Control", "no-cache")
                self.send_header("X-Accel-Buffering", "no")
                self.end_headers()
                headers_sent = True
                for raw in upstream:
                    line = raw.decode("utf-8", "replace").strip()
                    if not line.startswith("data:"):
                        continue
                    data = line[5:].strip()
                    if data == "[DONE]":
                        break
                    try:
                        obj = json.loads(data)
                    except json.JSONDecodeError:
                        continue
                    piece = obj.get("choices", [{}])[0].get("delta", {}).get("content", "")
                    if piece and not self._emit({"delta": piece}):
                        return
                self._emit({"done": True})
            except Exception as e:
                if not headers_sent:
                    self.send_response(200)
                    self.send_header("Content-Type", "text/event-stream")
                    self.send_header("Cache-Control", "no-cache")
                    self.end_headers()
                self._emit({"error": str(e)})

        def _emit(self, obj: dict) -> bool:
            try:
                self.wfile.write(("data: " + json.dumps(obj) + "\n\n").encode("utf-8"))
                self.wfile.flush()
                return True
            except (BrokenPipeError, ConnectionResetError):
                return False

        def _send(self, body: str):
            data = body.encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            try:
                self.wfile.write(data)
            except BrokenPipeError:
                pass

        def _send_json(self, obj: dict):
            data = json.dumps(obj).encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.send_header("Content-Length", str(len(data)))
            self.end_headers()
            try:
                self.wfile.write(data)
            except BrokenPipeError:
                pass

    if __name__ == "__main__":
        server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
        print(f"Listening on :{PORT}  DB={DB_HOST}:{DB_PORT}/{DB_NAME}  JDBC_URL={_url!r}", flush=True)
        server.serve_forever()
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spark-analytics-webview
  labels:
    app.kubernetes.io/name: spark-analytics
    app.kubernetes.io/part-of: nowchess
spec:
  replicas: 0
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app: spark-analytics-webview
  template:
    metadata:
      labels:
        app: spark-analytics-webview
        app.kubernetes.io/name: spark-analytics
        app.kubernetes.io/part-of: nowchess
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65534
        fsGroup: 65534
      containers:
        - name: webview
          image: python:3.12-slim
          command: ["sh", "-c", "pip install psycopg2-binary --quiet --no-cache-dir --target=/tmp/pkg && PYTHONPATH=/tmp/pkg python /scripts/serve.py"]
          ports:
            - containerPort: 8080
          env:
            - name: PORT
              value: "8080"
            - name: NOWCHESS_JDBC_URL
              valueFrom:
                secretKeyRef:
                  name: ncs-db-secrets
                  key: STORE_DB_URL
            - name: NOWCHESS_DB_USER
              valueFrom:
                secretKeyRef:
                  name: ncs-db-secrets
                  key: STORE_DB_USER
            - name: NOWCHESS_DB_PASS
              valueFrom:
                secretKeyRef:
                  name: ncs-db-secrets
                  key: STORE_DB_PASSWORD
          volumeMounts:
            - name: scripts
              mountPath: /scripts
          readinessProbe:
            httpGet:
              path: /
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 10
          resources:
            requests:
              cpu: 10m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 256Mi
      volumes:
        - name: scripts
          configMap:
            name: spark-analytics-webview
            defaultMode: 0755
---
apiVersion: v1
kind: Service
metadata:
  name: spark-analytics-webview
  labels:
    app.kubernetes.io/name: spark-analytics
    app.kubernetes.io/part-of: nowchess
spec:
  selector:
    app: spark-analytics-webview
  ports:
    - name: http
      port: 8080
      targetPort: 8080
