"""Thin stdlib http.server status page for bullseye."""

import argparse
import hmac
import http.server
import json
import os
import subprocess
import threading
import time

from bullseye.page import render_page
from bullseye.render import render_transcript
from bullseye.status import render_status

# Where does the room live? Is LMCP_HOST set, the local lmcp-tool already points at one and
# we need neither sic nor an SSH key — the case since bullseye runs in its own container next
# to the room. Without it we keep going through sic, because the older layout (bullseye and
# room on different machines) is still a valid way to run this.
#
# Same convention as lurker/bullpen-lurker's ROOM_IS_LOCAL; kept identical on purpose so that
# "which lmcp-tool answers here" has ONE answer in this codebase, not two.
ROOM_IS_LOCAL = bool(os.environ.get("LMCP_HOST"))

# The nick bullseye speaks as. Named rather than hardcoded at the call site so a
# second identity later cannot reintroduce the per-nick lookup bug.
POST_NICK = os.environ.get("BULLSEYE_POST_NICK", "markus")


def _lmcp(*args):
    """argv prefix for talking to the room, local or via sic."""
    return ["lmcp-tool", *args] if ROOM_IS_LOCAL else ["sic", "hertz", "lmcp-tool", *args]


def _post_token():
    """The shared secret required to POST into the room. From BULLSEYE_POST_TOKEN, else the
    first non-empty, non-comment line of ~/.config/bullseye/post-token. Returns None when no
    token is configured — which DISABLES posting (fail-closed): the server must be given a
    secret before it can accept posts, so exposing it beyond the LAN can never silently leave
    the 'speak into the room as markus' endpoint wide open."""
    t = (os.environ.get("BULLSEYE_POST_TOKEN") or "").strip()
    if t:
        return t
    try:
        with open(os.path.expanduser("~/.config/bullseye/post-token")) as f:
            for line in f:
                line = line.strip()
                if line and not line.startswith("#"):
                    return line
    except OSError:
        pass
    return None


def _room_secret(nick=POST_NICK):
    """This nick's room secret, or None.

    PER NICK since 2026-08-08 (`687c6de`): the shared /etc/bullpen/post-secret
    is gone, because one shared secret proved nothing about WHO posted — from
    the deus container one could post as `testdesigner` and get the stamp
    (finding B7). Each speaker now has its own file and each container holds
    only its own.

    This function was the last consumer still reading the old paths, so every
    post from the web UI came back `unauthorized`: the room checked `markus`
    against post-secret.d/markus while bullseye offered a secret from a file
    that no longer exists. The old paths stay as a fallback so a deployment
    that never migrated keeps working — the room decides anyway.

    Distinct from _post_token(): that one authenticates the human to bullseye,
    this one authenticates bullseye's nick to the room. Two different doors.
    """
    candidates = [f"/etc/bullpen/post-secret.d/{nick}",
                  os.path.expanduser(f"~/.config/bullpen/post-secret.d/{nick}"),
                  "/etc/bullpen/post-secret",
                  os.path.expanduser("~/.config/bullpen/post-secret")]
    for path in candidates:
        try:
            with open(path) as f:
                secret = f.read().strip()
            if secret:
                return secret
        except OSError:
            continue
    return None

def _authorized(headers, want):
    """Constant-time `Authorization: Bearer <token>` check against the configured secret."""
    scheme, _, presented = (headers.get("Authorization") or "").partition(" ")
    if scheme.strip().lower() != "bearer":
        return False
    return hmac.compare_digest(presented.strip(), want)


def handle_post(to, body):
    """Handle POST request to send a message to a room.
    
    Args:
        to (str): The room name to send the message to
        body (str): The message body to send
        
    Returns:
        tuple: (status_code, result_dict)
    """
    # Validate: empty body or recipient must NOT post (F1/F6) — and never touch subprocess.
    if not body or not body.strip():
        return (400, {"ok": False, "error": "Body cannot be empty"})
    if not to or not to.strip():
        return (400, {"ok": False, "error": "Recipient (to) cannot be empty"})

    # R4: `markus` is a PRIVILEGED nick, so room_say refuses the post unless it carries the
    # shared secret. Without this the UI posted into a wall for every message.
    secret = _room_secret()
    if not secret:
        return (503, {"ok": False, "error": "no room secret configured"})

    # Post to the room. to/body are SEPARATE argv elements (no shell) -> injection-safe.
    try:
        p = subprocess.run(
            _lmcp("room_say", f"from={POST_NICK}", f"to={to}",
                  f"body={body}", f"secret={secret}"),
            check=True, capture_output=True, text=True, timeout=30,
        )
    except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError):
        # F1: a FAILED post must report failure — otherwise the JS clears the box and the
        # user's message is silently destroyed. F2: bounded by timeout. F4: widened except.
        return (502, {"ok": False, "error": "room_say failed"})

    # A REFUSED post exits 0 with {"ok":false} — trusting the exit status alone reported
    # 200 OK for messages the room never accepted (silent loss, the very thing F1 forbids).
    try:
        result = json.loads(p.stdout or "{}")
    except ValueError:
        return (502, {"ok": False, "error": "room_say returned unparseable output"})
    if not result.get("ok"):
        return (502, {"ok": False, "error": str(result.get("error") or "room_say refused")})
    return (200, {"ok": True})


def build_page():
    """Return (status: int, body: str) — status is always 200."""
    # Room transcript
    try:
        room_proc = subprocess.run(
            _lmcp("room_read", "since=0"),
            capture_output=True, text=True, timeout=30
        )
        room_stdout = room_proc.stdout
    except Exception:
        room_stdout = ""

    messages = []
    for line in room_stdout.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            msg = json.loads(line)
            messages.append(msg)
        except (json.JSONDecodeError, ValueError):
            continue

    # Doctor data
    doctor_json = {}
    try:
        doctor_proc = subprocess.run(
            ["bullpen-doctor", "--json"],
            capture_output=True, text=True, timeout=30
        )
        if doctor_proc.returncode == 0:
            doctor_json = json.loads(doctor_proc.stdout)
    except Exception:
        pass

    try:
        body = render_page(messages, doctor_json)
    except Exception:
        body = "<html><body><h1>bullseye:</h1><p>Status page unavailable</p></body></html>"

    return 200, body


def since_payload(after_id):
    """Return dict for incremental AJAX append: rows_html, status_html, last_id."""
    # Room transcript
    try:
        room_proc = subprocess.run(
            _lmcp("room_read", f"since={after_id}"),
            capture_output=True, text=True, timeout=30
        )
        room_stdout = room_proc.stdout
    except Exception:
        room_stdout = ""

    messages = []
    for line in room_stdout.splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            msg = json.loads(line)
            messages.append(msg)
        except (json.JSONDecodeError, ValueError):
            continue

    # Sort messages by id
    messages.sort(key=lambda m: m.get("id", 0))
    
    # Find max id in source data
    last_id = max((msg.get("id", 0) for msg in messages), default=0)
    
    # Filter messages with id > after_id
    filtered_messages = [msg for msg in messages if msg.get("id", 0) > after_id]
    
    # Render rows_html — tolerate a malformed room line (missing keys / non-int id) so one bad
    # message can't crash the handler and silently freeze the whole incremental feed.
    try:
        rows_html = render_transcript(filtered_messages)
    except Exception:
        rows_html = ""
    
    # Doctor data
    doctor_json = {}
    try:
        doctor_proc = subprocess.run(
            ["bullpen-doctor", "--json"],
            capture_output=True, text=True, timeout=30
        )
        if doctor_proc.returncode == 0:
            doctor_json = json.loads(doctor_proc.stdout)
    except Exception:
        pass
    
    # Render status_html — doctor failure leaves doctor_json empty and render_status would
    # KeyError('ts'); unlike build_page there was no wrapper, so the handler crashed and the
    # browser's .catch swallowed it -> transcript froze with zero symptoms. Degrade to "".
    try:
        status_html = render_status(doctor_json)
    except Exception:
        status_html = ""
    
    return {
        "rows_html": rows_html,
        "status_html": status_html,
        "last_id": last_id
    }


def thinking_payload():
    """Live reasoning side-file for the currently-active run (THINKING REVEAL #2). The files
    live on hertz (where the worker runs), so read them via sic like the room. Returns
    {id, text} for the most-recently-written thinking file if still fresh (<1min), else {}.

    FIXED 2026-08-08 — this used to read FILES over `sic hertz`, and three separate things
    were broken at once, each fatal on its own:

      1. /var/lib/bullpen/thinking existed in NONE of the nine containers, nor on hertz. The
         note that used to stand here claimed the files lived in the `workers` container.
         They lived nowhere.
      2. Only @researcher ever wrote them, so the reveal was a window onto ONE worker.
      3. `sic hertz` from `bullseye` fails with `Permission denied (publickey)` — the
         container holds no key, by design (ORCHESTRATION §8.2).

    And `except Exception: return {}` swallowed all of it, so an empty display looked healthy.

    Now it goes through the room, the one place every container reaches without credentials —
    but NOT through room.jsonl: that log is append-only and backed up hourly, and a stream of
    intermediate thoughts has no business in the society's permanent record. `think_read`
    serves a transient per-nick buffer inside the room container instead."""
    try:
        p = subprocess.run(_lmcp("think_read"), capture_output=True, text=True, timeout=15)
        roh = (p.stdout or "").strip()
        if not roh:
            return {}
        d = json.loads(roh)
        if not isinstance(d, dict) or not d.get("text"):
            return {}
        return {"id": d.get("id") or d.get("nick") or "", "text": d["text"],
                "nick": d.get("nick"), "age_s": d.get("age_s")}
    except Exception:
        return {}


# THINKING REVEAL #3 (idea #155): a shared background poller feeds BOTH the snapshot endpoint
# and the new SSE stream below, so N open bullseye tabs cost ONE remote sic-to-hertz poll, not
# N of them (today's client-side setInterval means every open tab polls independently) — strictly
# fewer SSH round-trips than the status quo, not just parity. Cache version bumps only on an
# actual content change, so an idle SSE connection doesn't wake up for no-op ticks.
_THINK_LOCK = threading.Lock()
_THINK_CACHE = {"id": None, "text": None, "version": 0}
_THINK_POLL_INTERVAL = 1.5   # matches the client's existing "active" poll cadence (page.py)


def _update_thinking_cache(data):
    """One poll tick's worth of cache-update logic, pulled out of the loop below so it's
    unit-testable without a thread/sleep in the way. Bumps version only on an actual change.
    Returns True if it bumped (test hook), False if this tick was a no-op repeat."""
    with _THINK_LOCK:
        if data.get("id") != _THINK_CACHE["id"] or data.get("text") != _THINK_CACHE["text"]:
            _THINK_CACHE["id"] = data.get("id")
            _THINK_CACHE["text"] = data.get("text")
            _THINK_CACHE["version"] += 1
            return True
        return False


def _thinking_poll_loop():
    while True:
        try:
            data = thinking_payload()
        except Exception:
            data = {}
        _update_thinking_cache(data)
        time.sleep(_THINK_POLL_INTERVAL)


def start_thinking_poller():
    """Call once at server startup. Daemon thread: never blocks process shutdown."""
    t = threading.Thread(target=_thinking_poll_loop, daemon=True)
    t.start()
    return t


def _thinking_snapshot():
    with _THINK_LOCK:
        if _THINK_CACHE["id"] is None:
            return {}
        return {"id": _THINK_CACHE["id"], "text": _THINK_CACHE["text"]}


class Handler(http.server.BaseHTTPRequestHandler):
    """HTTP request handler that serves the bullseye status page."""

    def do_GET(self):
        # token auto-provision: LAN/VPN clients fetch the post token so the dock just works with
        # no manual entry. caddy gates THIS path by real client IP (private ranges only) and the
        # app binds loopback, so caddy is the sole path in — genuinely-external clients get a 403
        # at the edge and must enter the token by hand. Token never appears in the served HTML.
        if self.path.rstrip("/") == "/bp-token":
            tok = _post_token()
            if not tok:
                self.send_response(404); self.end_headers(); return
            payload = json.dumps({"token": tok}).encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
            return
        if self.path.rstrip("/") == "/api/thinking":
            data = _thinking_snapshot()
            payload = json.dumps(data).encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
            return
        if self.path.rstrip("/") == "/api/thinking/stream":
            self._serve_thinking_sse()
            return
        # incremental-append endpoint (slice 3.6): GET /api/since/<id> -> JSON new rows + status
        if self.path.startswith("/api/since/"):
            try:
                after = int(self.path.rsplit("/", 1)[-1])
            except ValueError:
                after = 0
            try:
                data = since_payload(after)
            except Exception:
                data = {"rows_html": "", "status_html": "", "last_id": 0}
            payload = json.dumps(data).encode("utf-8")
            self.send_response(200)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
            return
        status, body = build_page()
        self.send_response(status)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.end_headers()
        self.wfile.write(body.encode("utf-8"))

    def _serve_thinking_sse(self):
        """SSE stream of the shared thinking cache (idea #155). Pushes a new frame only when the
        background poller (start_thinking_poller) sees a content change, plus a periodic
        heartbeat comment so intermediate proxies/browsers don't time the idle connection out.
        Exits cleanly on client disconnect (broken pipe / reset) — no thread leak, since a
        ThreadingHTTPServer connection thread simply ends when this method returns."""
        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Connection", "keep-alive")
        self.end_headers()
        last_seen = -1
        last_heartbeat = time.monotonic()
        try:
            while True:
                with _THINK_LOCK:
                    version = _THINK_CACHE["version"]
                    data = {"id": _THINK_CACHE["id"], "text": _THINK_CACHE["text"]}
                now = time.monotonic()
                if version != last_seen:
                    last_seen = version
                    self.wfile.write(f"data: {json.dumps(data)}\n\n".encode("utf-8"))
                    self.wfile.flush()
                    last_heartbeat = now
                elif now - last_heartbeat > 15:
                    self.wfile.write(b": keep-alive\n\n")
                    self.wfile.flush()
                    last_heartbeat = now
                time.sleep(0.3)
        except (BrokenPipeError, ConnectionResetError):
            pass

    def do_POST(self):
        # slice 4: POST /post {to, body} -> post to the room as markus via handle_post
        if self.path.rstrip("/") == "/post":
            # AUTH FIRST: /post speaks into the room as markus, so it needs a shared secret
            # before any body is read. Fail-closed — an unconfigured token disables posting
            # entirely (503), so the server can't be exposed beyond the LAN with posting open;
            # a configured-but-absent/wrong Bearer credential is 401.
            want = _post_token()
            if not want:
                self.send_response(503); self.end_headers(); return
            if not _authorized(self.headers, want):
                self.send_response(401)
                self.send_header("WWW-Authenticate", "Bearer")
                self.end_headers(); return
            # F5: require JSON content-type — kills the text/plain CSRF-JSON vector for free.
            ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
            if ctype != "application/json":
                self.send_response(415); self.end_headers(); return
            try:
                length = int(self.headers.get("Content-Length", 0) or 0)
            except ValueError:
                length = 0
            if length > 65536:                       # F3: cap the read; don't trust the claim
                self.send_response(413); self.end_headers(); return
            try:
                raw = self.rfile.read(length) if length > 0 else b""
                data = json.loads(raw.decode("utf-8")) if raw else {}
                to = str(data.get("to", ""))
                body = str(data.get("body", ""))
            except Exception:
                to, body = "", ""
            status, result = handle_post(to, body)
            payload = json.dumps(result).encode("utf-8")
            self.send_response(status)
            self.send_header("Content-Type", "application/json; charset=utf-8")
            self.send_header("Content-Length", str(len(payload)))
            self.end_headers()
            self.wfile.write(payload)
            return
        self.send_response(404)
        self.end_headers()


def build_parser():
    """Return an ArgumentParser with --port defaulting to 8088."""
    parser = argparse.ArgumentParser()
    parser.add_argument("--port", type=int, default=8088, help="Port to listen on")
    return parser


def main():
    parser = build_parser()
    args = parser.parse_args()
    # bind loopback only: caddy (same host) is the sole reachable path, so its client-IP gate on
    # /bp-token is authoritative and the token endpoint can't be hit directly across the LAN.
    server = http.server.ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
    start_thinking_poller()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        server.shutdown()


if __name__ == "__main__":
    main()
