"""OHD Win11 setup UI — set which Topbox / listen Pi feeds progressives."""
from __future__ import annotations

import ipaddress
import json
import re
import socket
from pathlib import Path
from typing import Callable, Optional, Tuple
from urllib.parse import urlparse

from extended_configparser.parser import ExtendedConfigParser
from machine_identity import read_machine_identity
from ohd_progressive_modes import MODE_1_MAX_SEC, MODE_1_MIN_SEC, clamp_mode_1_display_time
from topbox_discovery import scan_local_topbox_devices

try:
    from ohd_win11_ota import (
        check_for_updates as ohd_check_for_updates,
        ota_status as ohd_ota_status,
        promote_now as ohd_promote_now,
        rollback_now as ohd_rollback_now,
    )
except ImportError:  # pragma: no cover - Win11-only module; Pi uses stubs
    def ohd_ota_status() -> dict:
        return {"win11": False, "error": "ohd_win11_ota missing"}

    def ohd_check_for_updates() -> dict:
        return {"ok": False, "error": "ohd_win11_ota missing", "status": ohd_ota_status()}

    def ohd_promote_now(*, restart: bool = True) -> dict:
        return {"ok": False, "error": "ohd_win11_ota missing", "status": ohd_ota_status()}

    def ohd_rollback_now(*, restart: bool = True) -> dict:
        return {"ok": False, "error": "ohd_win11_ota missing", "status": ohd_ota_status()}

PROG_MODE_LABELS = {
    1: "1 — Equal cycle",
    2: "2 — Highest jackpot focus",
    3: "3 — Most-active focus",
    4: "4 — Activity-weighted dwell (delta ratios)",
}

DEFAULT_FEED_PORT = 31234
_HOST_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9.\-]{0,251}[A-Za-z0-9])?$")


def build_progressives_url(host: str, port: int = DEFAULT_FEED_PORT) -> str:
    host = (host or "").strip()
    return f"http://{host}:{int(port)}/EtherGold/progressives.csv"


def parse_feed_from_source(source: str) -> Tuple[str, int]:
    """Return (host, port) from a progressives_source URL, or ("", DEFAULT_FEED_PORT)."""
    text = (source or "").strip()
    if not text:
        return "", DEFAULT_FEED_PORT
    try:
        parsed = urlparse(text if "://" in text else f"http://{text}")
    except ValueError:
        return "", DEFAULT_FEED_PORT
    host = (parsed.hostname or "").strip()
    port = parsed.port or DEFAULT_FEED_PORT
    return host, int(port)


def valid_feed_host(host: str) -> bool:
    host = (host or "").strip()
    if not host or len(host) > 253:
        return False
    try:
        ipaddress.ip_address(host)
        return True
    except ValueError:
        pass
    if host.lower() == "localhost":
        return True
    return bool(_HOST_RE.match(host))


def request_dyzineframe_restart(*, app_root: Optional[Path] = None) -> bool:
    """Write run/supervisor.cmd restart (same as Supervisor Restart). Never raises."""
    root = app_root or Path(__file__).resolve().parent
    run_dir = root / "run"
    try:
        run_dir.mkdir(parents=True, exist_ok=True)
        hold = run_dir / "supervisor.hold"
        hold.unlink(missing_ok=True)
        (run_dir / "supervisor.cmd").write_text("restart\n", encoding="utf-8")
        return True
    except OSError as exc:
        print(f"WARN: OHD setup could not request DF restart: {exc}", flush=True)
        return False


def build_topbox_info_payload(
    *,
    version: str,
    config_path: str,
    display_mode: str,
    asset_number: int,
) -> dict:
    config = ExtendedConfigParser()
    config.read(config_path)
    identity = read_machine_identity(config)
    hostname = socket.gethostname()
    short = hostname.split(".")[0]
    return {
        "version": version,
        "hostname": short,
        "machine_role": identity["machine_role"],
        "bank_number": identity["bank_number"],
        "term_number": identity["term_number"],
        "asset_number": asset_number,
        "display_mode": display_mode,
        "topbox_host": identity["topbox_host"],
    }


def register_ohd_setup_routes(
    *,
    get,
    post,
    request,
    response,
    config_path: str,
    version: str,
    web_port: Callable[[], int],
    display_mode: Callable[[], str],
    asset_number: Callable[[], int],
    reload_config: Callable[[], None],
    modes_status: Optional[Callable[[], dict]] = None,
) -> None:
    @get("/")
    def ohd_root_handler():
        mode = (display_mode() or "").strip().upper()
        if mode in ("OHD", "MINI", "STRIP"):
            response.status = 302
            response.set_header("Location", "/ohd/setup")
            return ""
        response.status = 404
        response.content_type = "text/plain"
        return "Not found\n"

    @get("/Topbox/info.json")
    def topbox_info_handler():
        payload = build_topbox_info_payload(
            version=version,
            config_path=config_path,
            display_mode=display_mode(),
            asset_number=asset_number(),
        )
        response.content_type = "application/json"
        return json.dumps(payload)

    @get("/ohd/api/topbox-scan")
    def ohd_topbox_scan_handler():
        devices, subnets = scan_local_topbox_devices(port=web_port())
        response.content_type = "application/json"
        return json.dumps({"devices": devices, "scanned_subnets": subnets})

    @get("/ohd/api/ota-status")
    def ohd_ota_status_handler():
        response.content_type = "application/json"
        return json.dumps(ohd_ota_status())

    @post("/ohd/api/ota-check")
    def ohd_ota_check_handler():
        response.content_type = "application/json"
        return json.dumps(ohd_check_for_updates())

    @post("/ohd/api/ota-promote")
    def ohd_ota_promote_handler():
        response.content_type = "application/json"
        return json.dumps(ohd_promote_now(restart=True))

    @post("/ohd/api/ota-rollback")
    def ohd_ota_rollback_handler():
        response.content_type = "application/json"
        return json.dumps(ohd_rollback_now(restart=True))

    @get("/ohd/setup")
    def ohd_setup_page_handler():
        config = ExtendedConfigParser()
        config.read(config_path)
        current_source = ""
        if config.has_option("TOPBOX", "PROGRESSIVES_SOURCE"):
            current_source = config.get("TOPBOX", "PROGRESSIVES_SOURCE").strip()
        identity = read_machine_identity(config)
        current_host = identity["topbox_host"]
        feed_host, feed_port = parse_feed_from_source(current_source)
        prog_mode = 1
        mode_time = 10
        if config.has_option("TOPBOX", "PROG_DISPLAY_MODE"):
            try:
                prog_mode = int(config.get("TOPBOX", "PROG_DISPLAY_MODE"))
            except ValueError:
                prog_mode = 1
        if config.has_option("TOPBOX", "MODE_1_DISPLAY_TIME"):
            try:
                mode_time = int(config.get("TOPBOX", "MODE_1_DISPLAY_TIME"))
            except ValueError:
                mode_time = 10
        prog_mode = max(1, min(4, prog_mode))
        mode_time = clamp_mode_1_display_time(mode_time)
        ota = ohd_ota_status()
        response.content_type = "text/html; charset=utf-8"
        return _render_setup_page(
            version=version,
            local_port=web_port(),
            current_source=current_source or "(progressives_source unset)",
            current_host=current_host or "(none)",
            feed_host=feed_host,
            feed_port=feed_port,
            prog_display_mode=prog_mode,
            mode_1_display_time=mode_time,
            ota=ota,
        )

    @get("/ohd/modes")
    def ohd_modes_page_handler():
        response.content_type = "text/html; charset=utf-8"
        return _render_modes_page(version=version)

    @get("/ohd/api/modes-status")
    def ohd_modes_status_handler():
        response.content_type = "application/json"
        if modes_status is None:
            return json.dumps({"error": "modes_status unavailable"})
        try:
            payload = modes_status() or {}
        except Exception as exc:  # noqa: BLE001 — surface to operator UI
            return json.dumps({"error": str(exc)})
        return json.dumps(payload)

    @post("/ohd/setup")
    def ohd_setup_save_handler():
        setup_action = str(request.forms.get("setup_action") or "").strip().lower()
        do_restart = str(request.forms.get("restart") or "").strip().lower() in (
            "1",
            "true",
            "yes",
            "on",
        )

        if setup_action == "modes":
            return _save_progressive_modes(
                request=request,
                config_path=config_path,
                reload_config=reload_config,
                do_restart=do_restart,
            )

        ip = str(request.forms.get("ip") or "").strip()
        hostname = str(request.forms.get("hostname") or "").strip()
        feed_host = str(request.forms.get("feed_host") or "").strip()
        feed_port_raw = str(request.forms.get("feed_port") or "").strip()
        progressives_url = str(request.forms.get("progressives_url") or "").strip()

        host = feed_host or ip
        port = DEFAULT_FEED_PORT
        if feed_port_raw:
            try:
                port = int(feed_port_raw)
            except ValueError:
                return _setup_result("Invalid feed port.", ok=False)
        if port < 1 or port > 65535:
            return _setup_result("Feed port must be 1–65535.", ok=False)

        if not progressives_url and host:
            if not valid_feed_host(host):
                return _setup_result("Invalid feed Pi IP or hostname.", ok=False)
            progressives_url = build_progressives_url(host, port)

        if not progressives_url.startswith("http://") and not progressives_url.startswith(
            "https://"
        ):
            return _setup_result("Invalid progressives URL.", ok=False)

        config = ExtendedConfigParser()
        config.read(config_path)
        config.set("TOPBOX", "PROGRESSIVES_SOURCE", progressives_url)
        if hostname:
            config.set("TOPBOX", "TOPBOX_HOST", hostname)
        elif host:
            config.set("TOPBOX", "TOPBOX_HOST", host)

        from config_ini_persist import write_config_ini

        if not write_config_ini(config, config_path):
            return _setup_result(
                "Could not write config.ini (check ownership / permissions).",
                ok=False,
            )

        reload_config()
        restarted = False
        if do_restart:
            restarted = request_dyzineframe_restart()

        msg = f"Saved feed {progressives_url}"
        if hostname:
            msg += f" (topbox_host={hostname})"
        if do_restart:
            msg += (
                " — Restart requested (supervisor will respawn DyzineFrame)."
                if restarted
                else " — Saved, but could not write supervisor.cmd (restart DF from Supervisor)."
            )
        else:
            msg += " — Restart DyzineFrame to apply."
        return _setup_result(msg, ok=True)


def _save_progressive_modes(
    *,
    request,
    config_path: str,
    reload_config: Callable[[], None],
    do_restart: bool,
) -> str:
    mode_raw = str(request.forms.get("prog_display_mode") or "").strip()
    time_raw = str(request.forms.get("mode_1_display_time") or "").strip()
    try:
        mode = int(mode_raw)
    except ValueError:
        return _setup_result("prog_display_mode must be 1–4.", ok=False)
    if mode < 1 or mode > 4:
        return _setup_result("prog_display_mode must be 1–4.", ok=False)
    try:
        display_time = int(time_raw)
    except ValueError:
        return _setup_result(
            f"mode_1_display_time must be {MODE_1_MIN_SEC}–{MODE_1_MAX_SEC}.",
            ok=False,
        )
    display_time = clamp_mode_1_display_time(display_time)

    config = ExtendedConfigParser()
    config.read(config_path)
    config.set("TOPBOX", "PROG_DISPLAY_MODE", str(mode))
    config.set("TOPBOX", "MODE_1_DISPLAY_TIME", str(display_time))

    from config_ini_persist import write_config_ini

    if not write_config_ini(config, config_path):
        return _setup_result(
            "Could not write config.ini (check ownership / permissions).",
            ok=False,
        )

    reload_config()
    restarted = False
    if do_restart:
        restarted = request_dyzineframe_restart()

    label = PROG_MODE_LABELS.get(mode, str(mode))
    msg = f"Saved progressive mode {label}; base dwell {display_time}s"
    if do_restart:
        msg += (
            " — Restart requested (supervisor will respawn DyzineFrame)."
            if restarted
            else " — Saved, but could not write supervisor.cmd (restart DF from Supervisor)."
        )
    else:
        msg += " — Restart DyzineFrame to apply."
    return _setup_result(msg, ok=True)


def _html_escape(text: str) -> str:
    return (
        text.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
    )


def _setup_result(message: str, *, ok: bool) -> str:
    tone = "ok" if ok else "err"
    return (
        "<!DOCTYPE html>\n<html lang=\"en\"><head><meta charset=\"utf-8\"/>\n"
        "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"/>\n"
        f"<title>OHD Feed Pi</title><style>{_OHD_CSS}</style></head>\n"
        "<body>\n<header><h1>OHD Feed Pi</h1><p>Progressives source</p></header>\n"
        "<main><div class=\"card\">\n"
        f"<p class=\"{tone}\">{_html_escape(message)}</p>\n"
        "<p class=\"hint\"><a href=\"/\">Home</a> · <a href=\"/ohd/setup\">Back to setup</a></p>\n"
        "</div></main>\n</body></html>"
    )


def _render_setup_page(
    *,
    version: str,
    local_port: int,
    current_source: str,
    current_host: str,
    feed_host: str,
    feed_port: int,
    prog_display_mode: int = 1,
    mode_1_display_time: int = 10,
    ota: Optional[dict] = None,
) -> str:
    ota = ota or {}
    pending = "yes — green disc" if ota.get("pending") else "no"
    if ota.get("staging_older_than_live"):
        pending = "Staging older than Live (parked, no disc)"
    win_note = (
        "rclone ← Dropbox SGVI/DyzineFrame/Win11 (not pog-atlas.com:51709)."
        if ota.get("win11")
        else "Updates card is for Win11 OHD PCs (Pi uses EtherGold /config)."
    )
    mode_options = []
    for mid, label in PROG_MODE_LABELS.items():
        selected = " selected" if int(prog_display_mode) == mid else ""
        mode_options.append(
            f'<option value="{mid}"{selected}>{_html_escape(label)}</option>'
        )
    # Format placeholders first, then splice CSS — CSS `{` blocks must not
    # pass through str.format (KeyError on `:root { --bg: ... }`).
    body = _OHD_SETUP_HTML.format(
        version=_html_escape(version),
        local_port=int(local_port),
        current_source=_html_escape(current_source),
        current_host=_html_escape(current_host),
        feed_host=_html_escape(feed_host),
        feed_port=int(feed_port),
        prog_mode_options="\n".join(mode_options),
        mode_1_display_time=int(mode_1_display_time),
        mode_time_min=MODE_1_MIN_SEC,
        mode_time_max=MODE_1_MAX_SEC,
        live_version=_html_escape(ota.get("live_version") or "—"),
        staging_version=_html_escape(ota.get("staging_version") or "—"),
        rollback_version=_html_escape(ota.get("rollback_version") or "—"),
        pending_text=_html_escape(pending),
        win_note=_html_escape(win_note),
        ota_disabled="" if ota.get("win11") else "disabled",
    )
    return body.replace("__CSS__", _OHD_CSS)


def _render_modes_page(*, version: str) -> str:
    body = _OHD_MODES_HTML.format(version=_html_escape(version))
    return body.replace("__CSS__", _OHD_CSS)


_OHD_CSS = """
:root {
  --bg: #0f1419; --panel: #1a222c; --text: #e8eef4; --muted: #8b9aab;
  --accent: #c4a35a; --ok: #3d9a6a; --bad: #c45c5c; --border: #2c3948;
  --font: "Segoe UI", system-ui, sans-serif;
}
* { box-sizing: border-box; }
body {
  margin: 0; font-family: var(--font); background: var(--bg); color: var(--text);
  min-height: 100vh;
}
header {
  padding: 1rem 1.5rem; border-bottom: 1px solid var(--border);
  background: linear-gradient(180deg, #1c2632 0%, var(--bg) 100%);
}
header h1 { margin: 0; font-size: 1.3rem; letter-spacing: 0.04em; color: var(--accent); }
header p { margin: 0.3rem 0 0; color: var(--muted); font-size: 0.88rem; }
main { max-width: 640px; margin: 0 auto; padding: 1rem 1rem 2.5rem; }
main.wide { max-width: 960px; }
.card {
  background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
  padding: 0.9rem 1rem; margin-bottom: 1rem;
}
.card h2 {
  margin: 0 0 0.75rem; font-size: 0.95rem; color: var(--accent);
  letter-spacing: 0.03em; font-weight: 600;
}
label { display: block; font-size: 0.8rem; color: var(--muted); margin-bottom: 0.25rem; }
input[type="text"], input[type="number"], select {
  width: 100%; padding: 0.55rem 0.65rem; margin-bottom: 0.75rem;
  background: #121820; border: 1px solid var(--border); border-radius: 4px;
  color: var(--text); font: inherit;
}
.nav { font-size: 0.85rem; margin: 0.4rem 0 0; }
.nav a { margin-right: 0.85rem; }
.kv { display: grid; grid-template-columns: 11rem 1fr; gap: 0.25rem 0.75rem; font-size: 0.88rem; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; font-family: ui-monospace, Consolas, monospace; }
.idle { color: var(--muted); }
.active-row { color: var(--ok); }
.sched-bar {
  display: inline-block; height: 0.55rem; background: var(--accent);
  border-radius: 2px; vertical-align: middle; margin-left: 0.4rem;
}
.row { display: flex; gap: 0.75rem; }
.row > div { flex: 1; }
.row > div.port { flex: 0 0 7rem; }
button, input[type="submit"] {
  background: var(--accent); color: #1a1408; border: none; border-radius: 4px;
  padding: 0.55rem 1rem; font-weight: 600; cursor: pointer; font: inherit;
}
button.secondary {
  background: transparent; color: var(--accent); border: 1px solid var(--border);
}
.hint { font-size: 0.78rem; color: var(--muted); margin: 0 0 0.75rem; }
.err { color: var(--bad); font-size: 0.9rem; margin: 0 0 0.75rem; }
.ok { color: var(--ok); font-size: 0.95rem; margin: 0 0 0.75rem; }
table { border-collapse: collapse; width: 100%; margin-top: 0.75rem; font-size: 0.88rem; }
th, td { border: 1px solid var(--border); padding: 0.45rem 0.5rem; text-align: left; }
tr.master { background: #13251c; }
a { color: var(--accent); }
.check { display: flex; align-items: center; gap: 0.5rem; margin: 0.5rem 0 0.9rem; color: var(--muted); font-size: 0.85rem; }
.check input { width: auto; margin: 0; }
button.danger {
  background: transparent; color: var(--bad); border: 1px solid var(--bad);
}
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 0.75rem; }
.stamp { font-family: ui-monospace, Consolas, monospace; font-size: 0.85rem; }
"""


_OHD_SETUP_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
  <title>OHD Setup</title>
  <style>__CSS__</style>
</head>
<body>
<header>
  <h1>OHD Setup</h1>
  <p>DyzineFrame {version} · this PC listens on :{local_port}</p>
  <p class="nav"><a href="/">Home</a>
     <a href="/ohd/modes">Modes dashboard</a>
     <a href="/EtherGold/status">Status</a></p>
</header>
<main>
  <div class="card">
    <h2>Updates (Live / Staging / Rollback)</h2>
    <p class="hint">{win_note}</p>
    <p class="stamp">Live {live_version} · Staging {staging_version} · Rollback {rollback_version}</p>
    <p class="hint">Pending: {pending_text}</p>
    <div class="actions">
      <button type="button" class="secondary" id="otaCheck" {ota_disabled}>Check for Updates</button>
      <button type="button" id="otaPromote" {ota_disabled}>Promote Staging</button>
      <button type="button" class="danger" id="otaRollback" {ota_disabled}>Rollback</button>
    </div>
    <p id="otaStatus" class="hint"></p>
  </div>

  <div class="card">
    <h2>Progressive display mode</h2>
    <p class="hint">OHD/MINI slide rotation. Mode 4 skips idle games and dwells
    proportional to activity (weakest active = base seconds). Settings persist in
    <code>config.ini</code> (OTA does not overwrite). <a href="/ohd/modes">Live modes dashboard</a></p>
    <form action="/ohd/setup" method="POST">
      <input type="hidden" name="setup_action" value="modes"/>
      <label for="prog_display_mode">prog_display_mode</label>
      <select id="prog_display_mode" name="prog_display_mode">
        {prog_mode_options}
      </select>
      <label for="mode_1_display_time">mode_1_display_time (base seconds, {mode_time_min}–{mode_time_max})</label>
      <input id="mode_1_display_time" name="mode_1_display_time" type="number"
             min="{mode_time_min}" max="{mode_time_max}" value="{mode_1_display_time}"/>
      <label class="check"><input type="checkbox" name="restart" value="1" checked/>
        Restart DyzineFrame after save</label>
      <input type="submit" value="Save progressive mode"/>
    </form>
  </div>

  <div class="card">
    <h2>Current feed</h2>
    <p class="hint">Topbox host label: <strong>{current_host}</strong></p>
    <p class="hint">{current_source}</p>
  </div>

  <div class="card">
    <h2>Set feed Pi</h2>
    <p class="hint">Enter the listen / Topbox Pi IP (or hostname) that serves
    <code>/EtherGold/progressives.csv</code> on port 31234.</p>
    <form action="/ohd/setup" method="POST">
      <div class="row">
        <div>
          <label for="feed_host">Feed Pi IP / hostname</label>
          <input id="feed_host" name="feed_host" type="text"
                 value="{feed_host}" placeholder="192.168.3.140" autocomplete="off"/>
        </div>
        <div class="port">
          <label for="feed_port">Port</label>
          <input id="feed_port" name="feed_port" type="number" min="1" max="65535"
                 value="{feed_port}"/>
        </div>
      </div>
      <label class="check"><input type="checkbox" name="restart" value="1" checked/>
        Restart DyzineFrame after save</label>
      <input type="submit" value="Save feed Pi"/>
    </form>
  </div>

  <div class="card">
    <h2>Optional LAN scan</h2>
    <p class="hint">Scans this PC’s /24 for EtherGold feeds
    (<code>/health</code> + <code>/EtherGold/progressives.csv</code>) and for
    DyzineFrame <code>/Topbox/info.json</code>. Listen-only Pis show as mode
    <strong>FEED</strong>. You can still type an IP above.</p>
    <button type="button" class="secondary" id="scanBtn">Scan network</button>
    <span id="status" class="hint"></span>
    <form id="pickForm" action="/ohd/setup" method="POST">
      <input type="hidden" name="ip" id="pickIp" value="">
      <input type="hidden" name="hostname" id="pickHost" value="">
      <input type="hidden" name="progressives_url" id="pickUrl" value="">
      <input type="hidden" name="restart" value="1">
      <table id="devTable" style="display:none;">
        <thead>
          <tr><th></th><th>Hostname</th><th>Role</th><th>Asset</th><th>IP</th><th>Mode</th></tr>
        </thead>
        <tbody id="devBody"></tbody>
      </table>
      <button type="submit" id="saveBtn" style="display:none;margin-top:0.75rem;">Use selected Topbox</button>
    </form>
  </div>
</main>
<script>
  async function otaPost(path, label) {{
    const el = document.getElementById('otaStatus');
    el.textContent = label + '…';
    try {{
      const resp = await fetch(path, {{ method: 'POST' }});
      const data = await resp.json();
      const st = data.status || {{}};
      el.textContent = (data.ok ? 'OK. ' : 'FAIL. ') +
        'Live ' + (st.live_version || '—') +
        ' · Staging ' + (st.staging_version || '—') +
        ' · Rollback ' + (st.rollback_version || '—') +
        (data.error ? (' — ' + data.error) : '');
      if (data.output) {{
        console.log(data.output);
      }}
    }} catch (err) {{
      el.textContent = 'FAIL: ' + err;
    }}
  }}
  const otaCheck = document.getElementById('otaCheck');
  const otaPromote = document.getElementById('otaPromote');
  const otaRollback = document.getElementById('otaRollback');
  if (otaCheck && !otaCheck.disabled) {{
    otaCheck.addEventListener('click', () => otaPost('/ohd/api/ota-check', 'Checking SGVI'));
    otaPromote.addEventListener('click', () => otaPost('/ohd/api/ota-promote', 'Promoting'));
    otaRollback.addEventListener('click', () => {{
      if (confirm('Rollback Live to the last snapshot?')) {{
        otaPost('/ohd/api/ota-rollback', 'Rolling back');
      }}
    }});
  }}

  const scanBtn = document.getElementById('scanBtn');
  const statusEl = document.getElementById('status');
  const devTable = document.getElementById('devTable');
  const devBody = document.getElementById('devBody');
  const saveBtn = document.getElementById('saveBtn');
  const pickIp = document.getElementById('pickIp');
  const pickHost = document.getElementById('pickHost');
  const pickUrl = document.getElementById('pickUrl');

  scanBtn.addEventListener('click', async () => {{
    statusEl.textContent = 'Scanning…';
    devBody.innerHTML = '';
    devTable.style.display = 'none';
    saveBtn.style.display = 'none';
    try {{
      const resp = await fetch('/ohd/api/topbox-scan');
      const data = await resp.json();
      const devices = data.devices || [];
      if (!devices.length) {{
        statusEl.textContent = 'No progressive feed hosts on ' + (data.scanned_subnets || []).join(', ');
        return;
      }}
      statusEl.textContent = 'Found ' + devices.length + ' feed host(s)';
      devices.forEach((d, idx) => {{
        const tr = document.createElement('tr');
        if ((d.machine_role || '').toUpperCase() === 'M' || (d.hostname || '').toLowerCase().endsWith('m')) {{
          tr.className = 'master';
        }}
        const mode = d.display_mode || d.source_kind || '';
        tr.innerHTML =
          '<td><input type="radio" name="pick" value="' + idx + '"' + (idx === 0 ? ' checked' : '') + '></td>' +
          '<td>' + (d.hostname || '') + '</td>' +
          '<td>' + (d.machine_role || '—') + '</td>' +
          '<td>' + (d.asset_number != null ? d.asset_number : '—') + '</td>' +
          '<td>' + (d.ip || '') + '</td>' +
          '<td>' + mode + '</td>';
        tr.dataset.ip = d.ip || '';
        tr.dataset.host = d.hostname || '';
        tr.dataset.url = d.progressives_url || '';
        devBody.appendChild(tr);
      }});
      devTable.style.display = '';
      saveBtn.style.display = '';
      syncPick();
    }} catch (err) {{
      statusEl.textContent = 'Scan failed: ' + err;
    }}
  }});

  function syncPick() {{
    const selected = document.querySelector('input[name="pick"]:checked');
    if (!selected) return;
    const tr = selected.closest('tr');
    pickIp.value = tr.dataset.ip || '';
    pickHost.value = tr.dataset.host || '';
    pickUrl.value = tr.dataset.url || '';
  }}

  devBody.addEventListener('change', syncPick);
  document.getElementById('pickForm').addEventListener('submit', syncPick);
</script>
</body>
</html>
"""


_OHD_MODES_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1"/>
  <title>OHD Modes</title>
  <style>__CSS__</style>
</head>
<body>
<header>
  <h1>OHD Modes dashboard</h1>
  <p>DyzineFrame {version} · live progressive schedule variables</p>
  <p class="nav"><a href="/">Home</a>
     <a href="/ohd/setup">Setup</a>
     <a href="/EtherGold/status">Status</a>
     <a href="/EtherGold/mode4-weights.txt">mode4-weights.txt</a></p>
</header>
<main class="wide">
  <div class="card">
    <h2>Config</h2>
    <dl class="kv" id="cfgBlock"></dl>
    <p id="pollHint" class="hint">Polling…</p>
  </div>
  <div class="card">
    <h2>Floor / rank</h2>
    <dl class="kv" id="floorBlock"></dl>
  </div>
  <div class="card">
    <h2>Per-game activity</h2>
    <table>
      <thead>
        <tr>
          <th>Game</th><th>Rank</th><th>Δ¢</th><th>Avg¢</th><th>Score</th>
          <th>Dwell s</th><th>Ratio</th><th>State</th>
        </tr>
      </thead>
      <tbody id="gamesBody"></tbody>
    </table>
  </div>
  <div class="card">
    <h2>Schedule</h2>
    <p class="hint" id="schedMeta"></p>
    <table>
      <thead><tr><th>Game</th><th>Seconds</th><th></th></tr></thead>
      <tbody id="schedBody"></tbody>
    </table>
  </div>
  <div class="card">
    <h2>Mode 4</h2>
    <dl class="kv" id="mode4Block"></dl>
  </div>
</main>
<script>
  function esc(s) {{
    return String(s == null ? '' : s)
      .replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
  }}
  function setKv(el, rows) {{
    el.innerHTML = rows.map(([k,v]) =>
      '<dt>' + esc(k) + '</dt><dd>' + esc(v) + '</dd>').join('');
  }}
  async function refresh() {{
    const hint = document.getElementById('pollHint');
    try {{
      const resp = await fetch('/ohd/api/modes-status');
      const d = await resp.json();
      if (d.error) {{
        hint.textContent = 'Error: ' + d.error;
        return;
      }}
      const c = d.config || {{}};
      setKv(document.getElementById('cfgBlock'), [
        ['prog_display_mode', c.prog_display_mode],
        ['mode_1_display_time', c.mode_1_display_time],
        ['ohd_focus_fraction', c.ohd_focus_fraction],
        ['ohd_rotation_batch', c.ohd_rotation_batch],
        ['activity_avg_min_samples', c.activity_avg_min_samples],
        ['activity_avg_window_sec', c.activity_avg_window_sec],
        ['activity_tiers', c.activity_tiers_obsolete_for_mode4 ? 'obsolete for mode 4' : '—'],
      ]);
      setKv(document.getElementById('floorBlock'), [
        ['floor_games', (d.floor_games || []).join(', ')],
        ['active / idle', (d.active_games || 0) + ' / ' + (d.idle_games || 0)],
        ['rank_metric', d.rank_metric],
        ['leader', d.leader],
        ['leader_score_c', d.leader_score_c],
        ['runner_up_score_c', d.runner_up_score_c],
        ['margin_c', d.margin_c],
      ]);
      const games = d.games || {{}};
      const order = Object.keys(games).sort((a,b) => (games[a].rank||99)-(games[b].rank||99));
      document.getElementById('gamesBody').innerHTML = order.map(id => {{
        const g = games[id];
        const cls = g.active ? 'active-row' : 'idle';
        return '<tr class="' + cls + '"><td>' + esc(id) + ' ' + esc(g.name) +
          '</td><td>' + esc(g.rank) +
          '</td><td>' + esc(g.delta_c) +
          '</td><td>' + esc(g.avg_c) +
          '</td><td>' + esc(g.score_c) +
          '</td><td>' + esc(g.planned_dwell_sec == null ? '—' : g.planned_dwell_sec) +
          '</td><td>' + esc(g.ratio_vs_weakest == null ? '—' : g.ratio_vs_weakest) +
          '</td><td>' + (g.skipped ? 'idle/skip' : 'active') +
          '</td></tr>';
      }}).join('') || '<tr><td colspan="8">No floor games</td></tr>';
      const sched = d.schedule || [];
      const total = d.schedule_total_sec || 0;
      document.getElementById('schedMeta').textContent =
        'Last built cycle ' + total + 's · mode ' + (d.mode || '—') +
        (d.last_build && d.last_build.strategy ? (' · ' + d.last_build.strategy) : '');
      document.getElementById('schedBody').innerHTML = sched.map(row => {{
        const pct = total > 0 ? Math.max(2, Math.round(100 * row.sec / total)) : 0;
        return '<tr><td>' + esc(row.game) + ' ' + esc(row.name) +
          '</td><td>' + esc(row.sec) +
          '</td><td><span class="sched-bar" style="width:' + pct + '%"></span></td></tr>';
      }}).join('') || '<tr><td colspan="3">No schedule yet (wait for a progressive cycle)</td></tr>';
      const m4 = d.mode4 || {{}};
      setKv(document.getElementById('mode4Block'), [
        ['strategy', m4.strategy || '—'],
        ['reason', m4.reason || '—'],
        ['min_active_score_c', m4.min_active_score_c],
        ['active_count', m4.active_count],
        ['clamp_hits', m4.clamp_hits],
        ['dwell_max_mult', m4.dwell_max_mult],
      ]);
      hint.textContent = 'Updated ' + (d.utc || '') + ' · refresh ~3s';
    }} catch (err) {{
      hint.textContent = 'Poll failed: ' + err;
    }}
  }}
  refresh();
  setInterval(refresh, 3000);
</script>
</body>
</html>
"""
