#!/usr/bin/env python3
"""WebSim launcher: lets https://www.ai-ministries.com/tools/websim.html build with
YOUR OWN Claude Code account, on your own PC.

Run it, leave it running, then pick "My Claude CLI" in the WebSim AI settings:

    py websim-launcher.py

Nothing but this page on ai-ministries.com (or a localhost copy) can talk to it,
it listens on 127.0.0.1 only, it runs the claude CLI with every tool switched
off (pure text generation), and nothing is stored. No key: the claude CLI you
already signed in to is the account. Standard library only.

Dials:
    WEBSIM_BIN    the CLI command           default: claude
    WEBSIM_PORT   port on 127.0.0.1         default: 8734
"""
from __future__ import annotations

import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

BIN = os.environ.get("WEBSIM_BIN", "claude")
PORT = int(os.environ.get("WEBSIM_PORT", "8734"))
MODELS = ("sonnet", "opus", "haiku")
TIMEOUT = 900


def _no_window() -> dict:
    """Windows: the CLI call must never show a window. CREATE_NO_WINDOW gives the
    child no console at all, and a hidden STARTUPINFO covers anything it or its
    children allocate themselves (AllocConsole honours the inherited show state)."""
    if os.name != "nt":
        return {}
    si = subprocess.STARTUPINFO()
    si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
    si.wShowWindow = subprocess.SW_HIDE
    return {"creationflags": subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP,
            "startupinfo": si}


NO_WINDOW = _no_window()
HOME = "https://websim.ai-ministries.com/"
BUNDLED_PAGE = os.path.join(getattr(sys, "_MEIPASS", os.path.dirname(os.path.abspath(__file__))), "websim.html")
_page_cache = {"at": 0.0, "body": b""}
# The CLI runs in an empty folder of its own so no project CLAUDE.md, settings
# or memory from wherever you started the launcher leaks into the builds.
WORKDIR = tempfile.mkdtemp(prefix="websim-")

ALLOWED_SUFFIX = ("ai-ministries.com",)
ALLOWED_HOSTS = ("localhost", "127.0.0.1")


def say(text: str) -> None:
    """print that survives a windowed exe, where stdout is None"""
    if sys.stdout is not None:
        try:
            sys.stdout.write(text + "\n")
            sys.stdout.flush()
        except (OSError, ValueError):
            pass


def origin_ok(origin: str) -> bool:
    if not origin:
        return False
    host = origin.split("://", 1)[-1].split("/", 1)[0].split(":", 1)[0].lower()
    return host in ALLOWED_HOSTS or any(host == s or host.endswith("." + s) for s in ALLOWED_SUFFIX)


def transcript(messages: list[dict]) -> tuple[str, str]:
    """Split a chat into (system text, prompt text). The prompt carries the whole
    conversation in plain labelled blocks; the CLI reads it on stdin so newlines
    survive on every OS."""
    system = "\n\n".join(m.get("content", "") for m in messages if m.get("role") == "system")
    turns = [m for m in messages if m.get("role") != "system"]
    if len(turns) == 1:
        return system, turns[0].get("content", "")
    parts = []
    for m in turns:
        label = "ASSISTANT (your earlier output)" if m.get("role") == "assistant" else "USER"
        parts.append(f"=== {label} ===\n{m.get('content', '')}")
    parts.append("=== END OF CONVERSATION ===\nAnswer the last USER block now.")
    return system, "\n\n".join(parts)


def run_claude(messages: list[dict], model: str | None) -> dict:
    system, prompt = transcript(messages)
    cmd = [shutil.which(BIN) or BIN, "-p", "--output-format", "json", "--tools", "", "--strict-mcp-config",
           "--permission-mode", "default"]
    if model in MODELS:
        cmd += ["--model", model]
    # the page's system prompt replaces the CLI's own coding-agent prompt, so the
    # model is a plain generator; one line because Windows cuts an argument at a newline
    cmd += ["--system-prompt", " ".join((system or "You are a helpful assistant.").split())]
    try:
        out = subprocess.run(cmd, input=prompt, capture_output=True, text=True,
                             encoding="utf-8", errors="replace", timeout=TIMEOUT, cwd=WORKDIR,
                             **NO_WINDOW)
    except FileNotFoundError:
        return {"error": f"'{BIN}' is not installed or not on PATH. Install Claude Code, "
                         f"sign in, or set WEBSIM_BIN to its command."}
    except subprocess.TimeoutExpired:
        return {"error": f"the CLI took longer than {TIMEOUT} s"}
    if out.returncode != 0:
        return {"error": (out.stderr or out.stdout or "the CLI failed").strip()[-800:]}
    try:
        data = json.loads(out.stdout)
    except ValueError:
        return {"text": out.stdout.strip()}
    if data.get("is_error"):
        return {"error": str(data.get("result", "the CLI reported an error"))[-800:]}
    return {"text": data.get("result", ""), "cost": data.get("total_cost_usd"),
            "stop": data.get("stop_reason"), "model": model or "default"}


def page_bytes() -> bytes:
    """The WebSim page, served from here so the browser needs no permission to
    reach the launcher: the live copy when it can be fetched (cached an hour),
    else the copy bundled with the launcher."""
    if _page_cache["body"] and time.time() - _page_cache["at"] < 3600:
        return _page_cache["body"]
    try:
        with urllib.request.urlopen(HOME + "index.html", timeout=6) as r:
            body = r.read()
        if b"websim.brain" in body:
            _page_cache.update(at=time.time(), body=body)
            return body
    except Exception:
        pass
    try:
        with open(BUNDLED_PAGE, "rb") as f:
            return f.read()
    except OSError:
        return (b"<!doctype html><meta charset=utf-8><p>WebSim launcher is running. Open <a href='"
                + HOME.encode() + b"'>" + HOME.encode() + b"</a>.")


class Handler(BaseHTTPRequestHandler):
    server_version = "websim-launcher/1"

    def _cors(self) -> bool:
        """Send CORS headers when the caller is allowed; say whether it is."""
        origin = self.headers.get("Origin", "")
        if not origin_ok(origin):
            return False
        self.send_header("Access-Control-Allow-Origin", origin)
        self.send_header("Vary", "Origin")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "content-type")
        self.send_header("Access-Control-Max-Age", "600")
        # an https page reaching 127.0.0.1 is a private-network request: Chrome
        # needs this on the preflight or it blocks the call
        self.send_header("Access-Control-Allow-Private-Network", "true")
        return True

    def _json(self, code: int, body: dict) -> None:
        if not origin_ok(self.headers.get("Origin", "")):
            code, body = 403, {"error": "origin not allowed"}
        raw = json.dumps(body).encode("utf-8")
        self.send_response(code)
        self._cors()
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(raw)))
        self.end_headers()
        self.wfile.write(raw)

    def do_OPTIONS(self) -> None:  # noqa: N802
        ok = origin_ok(self.headers.get("Origin", ""))
        self.send_response(204 if ok else 403)
        self._cors()
        self.end_headers()

    def do_GET(self) -> None:  # noqa: N802
        path = self.path.split("?")[0]
        if path in ("/", "/index.html"):
            body = page_bytes()
            self.send_response(200)
            self.send_header("Content-Type", "text/html; charset=utf-8")
            self.send_header("Content-Length", str(len(body)))
            self.send_header("Cache-Control", "no-store")
            self.end_headers()
            self.wfile.write(body)
            return
        if path == "/ping":
            self._json(200, {"ok": True, "bin": BIN, "found": bool(shutil.which(BIN)),
                             "models": list(MODELS)})
        else:
            self._json(404, {"error": "not found"})

    def do_POST(self) -> None:  # noqa: N802
        if self.path.split("?")[0] != "/chat":
            self._json(404, {"error": "not found"})
            return
        try:
            n = int(self.headers.get("Content-Length", "0"))
            body = json.loads(self.rfile.read(n) or b"{}")
            messages = body.get("messages") or []
            if not isinstance(messages, list) or not messages:
                raise ValueError("messages missing")
        except (ValueError, TypeError) as e:
            self._json(400, {"error": f"bad request: {e}"})
            return
        model = body.get("model")
        result = run_claude(messages, model if model in MODELS else None)
        self._json(200 if "text" in result else 502, result)

    def log_message(self, fmt, *args):  # quiet, one line per call
        say("websim-launcher: " + (fmt % args))


class Server(ThreadingHTTPServer):
    # Windows lets a reusable socket bind a port another program already holds
    # and the traffic then goes to that program; refuse, so a taken port errors.
    allow_reuse_address = False


def main() -> None:
    if not shutil.which(BIN):
        say(f"warning: '{BIN}' not found on PATH; set WEBSIM_BIN or install Claude Code")
    try:
        srv = Server(("127.0.0.1", PORT), Handler)
    except OSError as e:
        raise SystemExit(f"port {PORT} is taken by another program ({e}); set WEBSIM_PORT to a free one, "
                         f"and the same address in the WebSim AI settings")
    say(f"WebSim launcher on http://127.0.0.1:{PORT}  (CLI: {BIN})  Ctrl+C stops it")
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()
