"""
launcher_server.py – Persistent lightweight HTTP server (port 8001).

Always-on entry point for the frontend. Serves static files from frontend/,
proxies /api/ requests to the FastAPI backend (port 8000), and exposes
/api/launcher/* control endpoints to start/stop/restart the backend.

Designed to run as a background process (via pythonw.exe) so it survives
even when the terminal is closed.
"""

import os
import sys
import re
import atexit
import socket
import subprocess
import json
import time
import threading
import mimetypes
import traceback
import urllib.request
import urllib.error
import signal
import ctypes
from datetime import datetime, timezone
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.parse import urljoin, parse_qs, urlparse

LAUNCHER_PORT = 8001
BACKEND_PORT  = 8000
ROOT_DIR      = os.path.dirname(os.path.abspath(__file__))
FRONTEND_DIR  = os.path.join(ROOT_DIR, "frontend")
LAUNCHER_LOCK_FILE = os.path.join(ROOT_DIR, ".launcher.lock")

UPDATE_BASE_URL      = "https://www.hetphoenixproject.nl/AgentOrchestrator/"
UPDATE_MANIFEST_NAME = "manifest.json"
DEFAULT_APP_VERSION  = "0.0.1"
VERSION_FILE         = os.path.join(ROOT_DIR, "VERSION")
SETTINGS_FILE        = os.path.join(ROOT_DIR, "settings.json")
LAUNCHER_LOG_FILE    = os.path.join(ROOT_DIR, "launcher_debug.txt")

# ── Shared process state ─────────────────────────────────────────────────────

_proc_lock    = threading.Lock()
_proc: subprocess.Popen | None = None
_user_stopped = False          # True when user explicitly stopped via launcher
_launcher_lock_acquired = False
_state_lock = threading.Lock()
_backend_state = "idle"
_backend_state_since = time.monotonic()
_last_backend_error = ""
_last_backend_exit_code: int | None = None
_client_lock = threading.Lock()
_client_sessions: dict[str, dict[str, object]] = {}
_last_client_seen_at = 0.0

CLIENT_STALE_SECONDS = 45
IDLE_BACKEND_STOP_SECONDS = 120

_MIME_MAP = {
    ".html": "text/html",
    ".js":   "application/javascript",
    ".css":  "text/css",
    ".json": "application/json",
    ".svg":  "image/svg+xml",
    ".png":  "image/png",
    ".ico":  "image/x-icon",
}


_launcher_log_handle = None


class _SingleInstanceHTTPServer(ThreadingHTTPServer):
    # On Windows, reusable addresses can permit duplicate listeners on the
    # same port across processes. Force strict single-owner bind semantics.
    allow_reuse_address = False
    daemon_threads = True


class _TeeWriter:
    def __init__(self, *streams):
        self._streams = [s for s in streams if s is not None]

    def write(self, data):
        for stream in self._streams:
            try:
                stream.write(data)
            except Exception:
                pass
        return len(data)

    def flush(self):
        for stream in self._streams:
            try:
                stream.flush()
            except Exception:
                pass


def _configure_launcher_logging() -> None:
    global _launcher_log_handle
    if _launcher_log_handle is not None:
        return
    try:
        _launcher_log_handle = open(LAUNCHER_LOG_FILE, "a", encoding="utf-8", buffering=1)
        stamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        _launcher_log_handle.write(f"\n===== Launcher session {stamp} =====\n")
        sys.stdout = _TeeWriter(sys.__stdout__, _launcher_log_handle)
        sys.stderr = _TeeWriter(sys.__stderr__, _launcher_log_handle)
    except Exception:
        _launcher_log_handle = None


def _log_unhandled_exception(exc_type, exc_value, exc_traceback):
    try:
        text = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
        print(text, end="")
    except Exception:
        pass


_configure_launcher_logging()
sys.excepthook = _log_unhandled_exception


def _set_backend_state(state: str, error: str = "") -> None:
    global _backend_state, _backend_state_since, _last_backend_error
    with _state_lock:
        _backend_state = state
        _backend_state_since = time.monotonic()
        if error:
            _last_backend_error = error
        elif state in {"running", "idle", "stopped"}:
            _last_backend_error = ""


def _get_backend_state_snapshot() -> dict[str, object]:
    with _state_lock:
        return {
            "state": _backend_state,
            "state_age_seconds": round(max(0.0, time.monotonic() - _backend_state_since), 1),
            "last_error": _last_backend_error,
            "last_exit_code": _last_backend_exit_code,
        }


def _prune_stale_clients() -> None:
    now = time.monotonic()
    with _client_lock:
        stale_ids = [
            client_id
            for client_id, meta in _client_sessions.items()
            if now - float(meta.get("last_seen", 0.0)) > CLIENT_STALE_SECONDS
        ]
        for client_id in stale_ids:
            _client_sessions.pop(client_id, None)


def _active_client_count() -> int:
    _prune_stale_clients()
    with _client_lock:
        return len(_client_sessions)


def _record_client_ping(client_id: str, visible: bool = True) -> None:
    global _last_client_seen_at
    client_key = str(client_id or "").strip()
    if not client_key:
        return
    now = time.monotonic()
    with _client_lock:
        _client_sessions[client_key] = {
            "last_seen": now,
            "visible": bool(visible),
        }
    _last_client_seen_at = now


def _remove_client(client_id: str) -> None:
    client_key = str(client_id or "").strip()
    if not client_key:
        return
    with _client_lock:
        _client_sessions.pop(client_key, None)


def _seconds_since_last_client() -> float | None:
    if _last_client_seen_at <= 0:
        return None
    return max(0.0, time.monotonic() - _last_client_seen_at)


def _is_backend_alive() -> bool:
    with _proc_lock:
        return (_proc is not None and _proc.poll() is None) or _is_local_port_open(BACKEND_PORT)


def _find_backend_pid_by_port() -> int | None:
    if sys.platform != "win32":
        return None
    try:
        result = subprocess.run(
            ["netstat", "-ano", "-p", "tcp"],
            capture_output=True,
            text=True,
            timeout=5,
            creationflags=subprocess.CREATE_NO_WINDOW,
        )
        if result.returncode != 0:
            return None
        needle = f":{BACKEND_PORT}"
        for line in result.stdout.splitlines():
            upper = line.upper()
            if "LISTENING" not in upper or needle not in line:
                continue
            parts = line.split()
            if len(parts) < 5:
                continue
            local_address = parts[1]
            state = parts[3].upper()
            pid_text = parts[4]
            if state != "LISTENING" or not local_address.endswith(needle):
                continue
            try:
                return int(pid_text)
            except ValueError:
                continue
    except Exception:
        return None
    return None


def _process_parent_map_windows() -> dict[int, int]:
    if sys.platform != "win32":
        return {}

    class _PROCESSENTRY32W(ctypes.Structure):
        _fields_ = [
            ("dwSize", ctypes.c_ulong),
            ("cntUsage", ctypes.c_ulong),
            ("th32ProcessID", ctypes.c_ulong),
            ("th32DefaultHeapID", ctypes.c_size_t),
            ("th32ModuleID", ctypes.c_ulong),
            ("cntThreads", ctypes.c_ulong),
            ("th32ParentProcessID", ctypes.c_ulong),
            ("pcPriClassBase", ctypes.c_long),
            ("dwFlags", ctypes.c_ulong),
            ("szExeFile", ctypes.c_wchar * 260),
        ]

    TH32CS_SNAPPROCESS = 0x00000002
    snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
    invalid_handle = ctypes.c_void_p(-1).value
    if snapshot == invalid_handle:
        return {}

    parents: dict[int, int] = {}
    entry = _PROCESSENTRY32W()
    entry.dwSize = ctypes.sizeof(_PROCESSENTRY32W)
    try:
        has_entry = ctypes.windll.kernel32.Process32FirstW(snapshot, ctypes.byref(entry))
        while has_entry:
            parents[int(entry.th32ProcessID)] = int(entry.th32ParentProcessID)
            has_entry = ctypes.windll.kernel32.Process32NextW(snapshot, ctypes.byref(entry))
    finally:
        ctypes.windll.kernel32.CloseHandle(snapshot)
    return parents


def _pid_descends_from(parent_pid: int | None, child_pid: int | None) -> bool:
    if not parent_pid or not child_pid:
        return False
    if parent_pid == child_pid:
        return True
    if sys.platform != "win32":
        return False

    parent_map = _process_parent_map_windows()
    current = int(child_pid)
    seen: set[int] = set()
    while current and current not in seen:
        seen.add(current)
        current = parent_map.get(current, 0)
        if not current:
            return False
        if current == parent_pid:
            return True
    return False


def _stop_external_backend_by_port() -> bool:
    pid = _find_backend_pid_by_port()
    if not pid:
        return False
    tracked_pid = None
    with _proc_lock:
        if _proc is not None:
            tracked_pid = _proc.pid
    if tracked_pid == pid or _pid_descends_from(tracked_pid, pid):
        return False
    try:
        os.kill(pid, signal.SIGTERM)
    except Exception:
        try:
            subprocess.run(
                ["taskkill", "/PID", str(pid), "/T", "/F"],
                capture_output=True,
                text=True,
                timeout=10,
                creationflags=subprocess.CREATE_NO_WINDOW,
            )
        except Exception:
            return False
    deadline = time.monotonic() + 8
    while time.monotonic() < deadline:
        if not _is_local_port_open(BACKEND_PORT):
            return True
        time.sleep(0.25)
    return not _is_local_port_open(BACKEND_PORT)


def _is_local_port_open(port: int, timeout: float = 0.5) -> bool:
    try:
        with socket.create_connection(("127.0.0.1", port), timeout=timeout):
            return True
    except OSError:
        return False


def _launcher_status_reachable(timeout: float = 2.0) -> bool:
    try:
        r = urllib.request.urlopen(
            f"http://127.0.0.1:{LAUNCHER_PORT}/api/launcher/status",
            timeout=timeout,
        )
        return r.status == 200
    except (urllib.error.URLError, OSError):
        return False


def _pid_is_running(pid: int | None) -> bool:
    if not pid or pid <= 0:
        return False
    try:
        os.kill(pid, 0)
    except OSError:
        return False
    except Exception:
        return False
    return True


def _read_launcher_lock_pid() -> int | None:
    try:
        if not os.path.isfile(LAUNCHER_LOCK_FILE):
            return None
        text = open(LAUNCHER_LOCK_FILE, encoding="utf-8").read().strip()
        return int(text) if text else None
    except Exception:
        return None


def _release_launcher_lock() -> None:
    global _launcher_lock_acquired
    if not _launcher_lock_acquired:
        return
    try:
        if os.path.isfile(LAUNCHER_LOCK_FILE):
            os.remove(LAUNCHER_LOCK_FILE)
    except OSError:
        pass
    _launcher_lock_acquired = False


def _acquire_launcher_lock() -> bool:
    global _launcher_lock_acquired
    pid_text = str(os.getpid())
    try:
        fd = os.open(LAUNCHER_LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            handle.write(pid_text)
        _launcher_lock_acquired = True
        atexit.register(_release_launcher_lock)
        return True
    except FileExistsError:
        existing_pid = _read_launcher_lock_pid()
        if existing_pid == os.getpid():
            return False
        if existing_pid and _pid_is_running(existing_pid):
            if _launcher_status_reachable(timeout=3.0):
                return False
            deadline = time.monotonic() + 6.0
            while time.monotonic() < deadline:
                if _launcher_status_reachable(timeout=1.0):
                    return False
                if not _pid_is_running(existing_pid):
                    break
                time.sleep(0.25)
            if existing_pid and _pid_is_running(existing_pid):
                return False
        elif _launcher_status_reachable(timeout=1.0):
            return False
        try:
            os.remove(LAUNCHER_LOCK_FILE)
        except OSError:
            return False
        fd = os.open(LAUNCHER_LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
        with os.fdopen(fd, "w", encoding="utf-8") as handle:
            handle.write(pid_text)
        _launcher_lock_acquired = True
        atexit.register(_release_launcher_lock)
        return True


def _python_exe() -> str:
    """Return path to python.exe for running the backend.

    Prefers the .venv Python so that all dependencies are available.
    Falls back to the running interpreter (minus pythonw.exe → python.exe swap).
    """
    # 1. Try venv inside the project root
    venv_python = os.path.join(ROOT_DIR, ".venv", "Scripts", "python.exe")
    if os.path.isfile(venv_python):
        return venv_python

    # 2. Current interpreter, but prefer python.exe over pythonw.exe
    exe = sys.executable
    if exe.lower().endswith("pythonw.exe"):
        alt = os.path.join(os.path.dirname(exe), "python.exe")
        if os.path.isfile(alt):
            return alt
    return exe


def _python_has_modules(python_exe: str, modules: list[str]) -> bool:
    if not os.path.isfile(python_exe):
        return False
    code = (
        "import importlib.util, sys; "
        "mods = sys.argv[1:]; "
        "missing = [m for m in mods if importlib.util.find_spec(m) is None]; "
        "raise SystemExit(0 if not missing else 1)"
    )
    try:
        result = subprocess.run(
            [python_exe, "-c", code, *modules],
            cwd=ROOT_DIR,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            timeout=20,
            creationflags=subprocess.CREATE_NO_WINDOW if sys.platform == "win32" else 0,
        )
        return result.returncode == 0
    except Exception:
        return False


def _ensure_venv():
    """Create the .venv and install requirements.txt if the venv does not exist yet."""
    venv_python = os.path.join(ROOT_DIR, ".venv", "Scripts", "python.exe")
    required_modules = ["fastapi", "uvicorn", "requests", "dateparser", "segno"]
    need_install = True
    if os.path.isfile(venv_python):
        need_install = not _python_has_modules(venv_python, required_modules)
        if not need_install:
            return  # already present and healthy
        print("[Launcher] Existing .venv is missing required packages — repairing environment …")

    if not os.path.isfile(venv_python):
        print("[Launcher] Virtual environment not found — creating .venv …")
    # Find a base Python to create the venv with.
    # If we are already inside a venv (sys.prefix != sys.base_prefix) the base
    # interpreter is at sys.base_prefix; otherwise use the current executable.
    base_python = sys.executable
    if base_python.lower().endswith("pythonw.exe"):
        alt = os.path.join(os.path.dirname(base_python), "python.exe")
        if os.path.isfile(alt):
            base_python = alt

    if not os.path.isfile(venv_python):
        try:
            subprocess.check_call(
                [base_python, "-m", "venv", os.path.join(ROOT_DIR, ".venv")],
                cwd=ROOT_DIR,
            )
        except Exception as e:
            print(f"[Launcher] Failed to create venv: {e}")
            return

    # Install deps
    req_file = os.path.join(ROOT_DIR, "requirements.txt")
    if os.path.isfile(req_file) and os.path.isfile(venv_python):
        print("[Launcher] Installing dependencies into .venv …")
        try:
            subprocess.check_call(
                [venv_python, "-m", "pip", "install", "-r", req_file, "--quiet"],
                cwd=ROOT_DIR,
            )
            print("[Launcher] Dependencies installed.")
        except Exception as e:
            print(f"[Launcher] pip install failed: {e}")


def _build_cmd() -> list:
    return [_python_exe(), "-m", "uvicorn", "backend.main:app",
            "--host", "127.0.0.1", "--port", str(BACKEND_PORT)]


# ── Update-check helpers (no backend needed) ─────────────────────────────────

def _get_local_version() -> str:
    try:
        if os.path.isfile(VERSION_FILE):
            v = open(VERSION_FILE, encoding="utf-8").read().strip()
            if v:
                return v
    except Exception:
        pass
    return DEFAULT_APP_VERSION


def _parse_version_tuple(v: str) -> tuple:
    nums = [int(x) for x in re.findall(r"\d+", str(v or ""))]
    return tuple(nums) if nums else (0,)


def _is_remote_newer(local: str, remote: str) -> bool:
    return _parse_version_tuple(remote) > _parse_version_tuple(local)


def _load_settings_json() -> dict:
    try:
        if os.path.isfile(SETTINGS_FILE):
            return json.loads(open(SETTINGS_FILE, encoding="utf-8").read())
    except Exception:
        pass
    return {}


def _candidate_manifest_urls(manifest_url: str | None = None) -> list[str]:
    explicit = (manifest_url or "").strip()
    if explicit:
        return [explicit]
    settings = _load_settings_json()
    candidates: list[str] = []
    cm = str(settings.get("update_manifest_url") or "").strip()
    if cm:
        candidates.append(cm)
    cb = str(settings.get("update_base_url") or "").strip()
    if cb:
        candidates.append(urljoin(cb, UPDATE_MANIFEST_NAME))
    for base in [
        UPDATE_BASE_URL,
        "http://www.hetphoenixproject.nl/AgentOrchestrator/",
        "https://www.hetphoenixproject.nl/AgentOrchestrator/",
        "http://hetphoenixproject.nl/AgentOrchestrator/",
        "https://hetphoenixproject.nl/AgentOrchestrator/",
    ]:
        candidates.append(urljoin(base, UPDATE_MANIFEST_NAME))
    seen: set[str] = set()
    out: list[str] = []
    for url in candidates:
        n = url.strip()
        if n and n not in seen:
            seen.add(n)
            out.append(n)
    return out


def _fetch_update_manifest(manifest_url: str | None = None) -> dict:
    attempted: list[str] = []
    last_err: Exception | None = None
    for src_url in _candidate_manifest_urls(manifest_url):
        attempted.append(src_url)
        try:
            req = urllib.request.Request(src_url, method="GET")
            resp = urllib.request.urlopen(req, timeout=15)
            raw = json.loads(resp.read().decode("utf-8-sig"))
            latest = str(raw.get("latest_version") or raw.get("version") or "").strip()
            if not latest:
                raise ValueError("Manifest missing version.")
            dl = str(
                raw.get("download_url") or raw.get("package_url")
                or raw.get("zip_url") or raw.get("download") or ""
            ).strip()
            if not dl:
                raise ValueError("Manifest missing download_url.")
            if not dl.lower().startswith(("http://", "https://")):
                dl = urljoin(src_url, dl)
            notes = raw.get("notes") or raw.get("changelog") or ""
            if isinstance(notes, list):
                notes = "\n".join(str(x) for x in notes)
            return {
                "manifest_url": src_url, "attempted_urls": attempted,
                "latest_version": latest, "download_url": dl,
                "sha256": str(raw.get("sha256") or "").strip().lower(),
                "notes": str(notes or "").strip(), "raw": raw,
            }
        except Exception as e:
            last_err = e
            continue
    raise RuntimeError(
        f"Unable to fetch update manifest. Attempted: {attempted}. Last error: {last_err}"
    )


def _ensure_ollama():
    """Start Ollama serve if the Ollama API is not reachable."""
    try:
        r = urllib.request.urlopen("http://localhost:11434/api/tags", timeout=2)
        if r.status == 200:
            return  # already running
    except Exception:
        pass

    # Locate the ollama executable
    import shutil
    ollama_exe = shutil.which("ollama")
    if not ollama_exe:
        # Common Windows install location
        local = os.path.join(os.environ.get("LOCALAPPDATA", ""), "Programs", "Ollama", "ollama.exe")
        if os.path.isfile(local):
            ollama_exe = local
    if not ollama_exe:
        print("[Launcher] Ollama not found on PATH or default install location — skipping auto-start.")
        return

    print("[Launcher] Ollama not running — starting ollama serve …")
    try:
        flags = 0
        if sys.platform == "win32":
            flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
        subprocess.Popen(
            [ollama_exe, "serve"],
            cwd=ROOT_DIR,
            creationflags=flags,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )
    except Exception as e:
        print(f"[Launcher] Failed to start Ollama: {e}")
        return

    # Wait up to 15 seconds for Ollama to become responsive
    deadline = time.monotonic() + 15
    while time.monotonic() < deadline:
        try:
            r = urllib.request.urlopen("http://localhost:11434/api/tags", timeout=2)
            if r.status == 200:
                print("[Launcher] Ollama is now running.")
                return
        except Exception:
            pass
        time.sleep(0.5)
    print("[Launcher] Ollama started but not yet responsive — continuing anyway.")


def _start_backend_proc():
    global _proc, _last_client_seen_at
    tracked_pid, port_pid = _reconcile_tracked_backend()
    if tracked_pid:
        _set_backend_state("running")
        print(f"[Launcher] Backend already tracked (PID {tracked_pid})")
        return
    if port_pid or _is_local_port_open(BACKEND_PORT):
        _set_backend_state("running")
        print(f"[Launcher] Backend already listening on port {BACKEND_PORT}, not starting duplicate.")
        return
    _set_backend_state("starting")
    if _last_client_seen_at <= 0:
        _last_client_seen_at = time.monotonic()
    _ensure_venv()
    _ensure_ollama()
    cmd = _build_cmd()
    # CREATE_NO_WINDOW prevents a visible console; DETACHED_PROCESS keeps
    # the child alive even when the parent console (if any) is closed.
    flags = 0
    if sys.platform == "win32":
        flags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
    try:
        with _proc_lock:
            _proc = subprocess.Popen(
                cmd,
                cwd=ROOT_DIR,
                creationflags=flags,
                stdout=_launcher_log_handle or subprocess.DEVNULL,
                stderr=_launcher_log_handle or subprocess.DEVNULL,
            )
    except Exception as exc:
        _set_backend_state("error", f"Failed to spawn backend: {exc}")
        raise
    print(f"[Launcher] Backend started (PID {_proc.pid})")


def _stop_backend_proc():
    global _proc
    _set_backend_state("stopping")
    with _proc_lock:
        if _proc and _proc.poll() is None:
            _proc.terminate()
            try:
                _proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                _proc.kill()
        _proc = None
    if _is_local_port_open(BACKEND_PORT):
        _stop_external_backend_by_port()
    _set_backend_state("stopped" if _user_stopped else "idle")


def _wait_for_backend(timeout=30, interval=0.5) -> bool:
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        if _is_local_port_open(BACKEND_PORT, timeout=1.0):
            _set_backend_state("running")
            return True
        time.sleep(interval)
    _set_backend_state("error", "Backend did not bind to port 8000 in time.")
    return False


def _reconcile_tracked_backend() -> tuple[int | None, int | None]:
    global _proc
    tracked_pid = None
    tracked_proc = None
    with _proc_lock:
        if _proc is not None and _proc.poll() is None:
            tracked_proc = _proc
            tracked_pid = _proc.pid

    port_pid = _find_backend_pid_by_port()
    if (
        tracked_proc is not None
        and port_pid
        and tracked_pid != port_pid
        and not _pid_descends_from(tracked_pid, port_pid)
    ):
        with _proc_lock:
            if _proc is tracked_proc:
                _proc = None
        tracked_pid = None

    return tracked_pid, port_pid


def _ensure_backend_for_active_clients(trigger: str = "client") -> bool:
    if _user_stopped:
        return False
    state = str(_get_backend_state_snapshot().get("state") or "").lower()
    if state in {"starting", "stopping"}:
        return False
    if _is_backend_alive():
        if state != "running":
            _set_backend_state("running")
        return False
    if _active_client_count() <= 0:
        return False
    _set_backend_state("starting")
    print(f"[Launcher] Active frontend client detected during {trigger}; starting backend.")
    threading.Thread(target=_deferred_backend_start, daemon=True).start()
    return True


# ── HTTP Handler ──────────────────────────────────────────────────────────────

class _LauncherHandler(BaseHTTPRequestHandler):
    """Serves frontend, proxies /api/ to backend, launcher control endpoints."""

    def log_message(self, fmt, *args):
        pass

    def _cors(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
        self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")

    def _json_response(self, obj, status=200):
        body = json.dumps(obj).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
        self.send_header("Pragma", "no-cache")
        self.send_header("Expires", "0")
        self._cors()
        self.end_headers()
        try:
            self.wfile.write(body)
        except ConnectionAbortedError:
            pass

    def _read_json_body(self) -> dict:
        try:
            content_length = int(self.headers.get("Content-Length", 0))
        except ValueError:
            content_length = 0
        if content_length <= 0:
            return {}
        try:
            raw = self.rfile.read(content_length)
            return json.loads(raw.decode("utf-8") or "{}")
        except Exception:
            return {}

    # ── Reverse proxy ────────────────────────────────────────────────────
    def _proxy_to_backend(self, method="GET"):
        url = f"http://127.0.0.1:{BACKEND_PORT}{self.path}"
        timeout_seconds = 240 if self.path.startswith("/api/email-propose-reply") else 30
        try:
            content_length = int(self.headers.get("Content-Length", 0))
            body = self.rfile.read(content_length) if content_length > 0 else None
            req = urllib.request.Request(url, data=body, method=method)
            for h in ("Content-Type", "Authorization", "Accept"):
                v = self.headers.get(h)
                if v:
                    req.add_header(h, v)
            resp = urllib.request.urlopen(req, timeout=timeout_seconds)
            self.send_response(resp.status)
            for key, val in resp.getheaders():
                if key.lower() not in ("transfer-encoding", "connection"):
                    self.send_header(key, val)
            self._cors()
            self.end_headers()
            self.wfile.write(resp.read())
        except urllib.error.HTTPError as e:
            self.send_response(e.code)
            self._cors()
            self.end_headers()
            self.wfile.write(e.read())
        except (urllib.error.URLError, OSError):
            self._json_response({"error": "Backend not reachable"}, 502)

    # ── Routing ──────────────────────────────────────────────────────────
    def do_OPTIONS(self):
        self.send_response(204)
        self._cors()
        self.end_headers()

    def do_GET(self):
        path = self.path.split("?")[0]
        if path == "/api/launcher/status":
            return self._handle_status()
        if path == "/api/version":
            return self._handle_version()
        if path == "/api/update/check":
            return self._handle_update_check()
        if path.startswith("/api/"):
            return self._proxy_to_backend("GET")
        self._serve_frontend(path)

    def do_POST(self):
        path = self.path.split("?")[0]
        if path == "/api/launcher/client/ping":
            return self._handle_client_ping()
        if path == "/api/launcher/client/disconnect":
            return self._handle_client_disconnect()
        if path == "/api/launcher/start":
            return self._handle_start()
        if path == "/api/launcher/stop":
            return self._handle_stop()
        if path == "/api/launcher/shutdown":
            return self._handle_shutdown()
        if path == "/api/launcher/restart":
            return self._handle_restart()
        if path.startswith("/api/"):
            return self._proxy_to_backend("POST")
        self._json_response({"error": "not found"}, 404)

    def do_PUT(self):
        if self.path.split("?")[0].startswith("/api/"):
            return self._proxy_to_backend("PUT")
        self._json_response({"error": "not found"}, 404)

    def do_PATCH(self):
        if self.path.split("?")[0].startswith("/api/"):
            return self._proxy_to_backend("PATCH")
        self._json_response({"error": "not found"}, 404)

    def do_DELETE(self):
        if self.path.split("?")[0].startswith("/api/"):
            return self._proxy_to_backend("DELETE")
        self._json_response({"error": "not found"}, 404)

    # ── Launcher control ─────────────────────────────────────────────────
    def _handle_status(self):
        _prune_stale_clients()
        _ensure_backend_for_active_clients("status")
        tracked_pid, port_pid = _reconcile_tracked_backend()
        snapshot = _get_backend_state_snapshot()
        active_clients = _active_client_count()
        last_client_age = _seconds_since_last_client()
        self._json_response({
            "running": _is_backend_alive(),
            "tracked": bool(tracked_pid),
            "pid": port_pid or tracked_pid,
            "tracked_pid": tracked_pid,
            "listener_pid": port_pid,
            "pid_mismatch": bool(
                tracked_pid
                and port_pid
                and tracked_pid != port_pid
                and not _pid_descends_from(tracked_pid, port_pid)
            ),
            "port_in_use": _is_local_port_open(BACKEND_PORT),
            "user_stopped": _user_stopped,
            "active_clients": active_clients,
            "last_client_age_seconds": round(last_client_age, 1) if last_client_age is not None else None,
            "idle_stop_after_seconds": IDLE_BACKEND_STOP_SECONDS,
            **snapshot,
        })

    def _handle_client_ping(self):
        global _user_stopped
        payload = self._read_json_body()
        client_id = payload.get("client_id")
        visible = payload.get("visible", True)
        _record_client_ping(client_id, visible)
        _ensure_backend_for_active_clients("client-ping")
        return self._handle_status()

    def _handle_client_disconnect(self):
        payload = self._read_json_body()
        _remove_client(payload.get("client_id"))
        reason = str(payload.get("reason", "")).strip()
        # If the client requested stop-on-close, wait a grace period before
        # shutting down.  A page-refresh reconnects within a few seconds,
        # so if a new client appears in time we cancel the shutdown.
        if reason == "close-stop":
            def _deferred_close_stop():
                time.sleep(30)          # grace period
                _prune_stale_clients()
                if _active_client_count() == 0:
                    print("[Launcher] No clients reconnected after close-stop — shutting down.")
                    _stop_backend_proc()
                    _release_launcher_lock()
                    os._exit(0)
                else:
                    print("[Launcher] Client reconnected during grace period — shutdown cancelled.")
            threading.Thread(target=_deferred_close_stop, daemon=True).start()
        return self._handle_status()

    # ── Update check (handled locally, no backend needed) ────────────────
    def _handle_version(self):
        self._json_response({
            "version": _get_local_version(),
            "update_base_url": UPDATE_BASE_URL,
            "manifest_url": urljoin(UPDATE_BASE_URL, UPDATE_MANIFEST_NAME),
            "manifest_candidates": _candidate_manifest_urls(None),
        })

    def _handle_update_check(self):
        qs = parse_qs(urlparse(self.path).query)
        manifest_url = (qs.get("manifest_url") or [None])[0]
        local_version = _get_local_version()
        now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S") + "Z"
        try:
            manifest = _fetch_update_manifest(manifest_url)
            latest = manifest["latest_version"]
            self._json_response({
                "ok": True,
                "current_version": local_version,
                "latest_version": latest,
                "update_available": _is_remote_newer(local_version, latest),
                "download_url": manifest["download_url"],
                "manifest_url": manifest["manifest_url"],
                "attempted_urls": manifest.get("attempted_urls", []),
                "notes": manifest["notes"],
                "checked_at": now_iso,
            })
        except Exception as e:
            err_str = str(e).lower()
            if "nameresolution" in err_str or "getaddrinfo" in err_str:
                error_code, user_msg = "DNS_RESOLUTION_FAILED", (
                    "Could not resolve the update server address. "
                    "Check internet/DNS or verify the update host configuration."
                )
            elif "timed out" in err_str or "timeout" in err_str:
                error_code, user_msg = "UPDATE_SERVER_TIMEOUT", "The update server did not respond in time."
            else:
                error_code, user_msg = "UPDATE_CHECK_FAILED", "Unable to check for updates right now. Please try again later."
            self._json_response({
                "ok": False,
                "current_version": local_version,
                "update_available": False,
                "error_code": error_code,
                "user_message": user_msg,
                "error": str(e),
                "attempted_urls": [],
                "checked_at": now_iso,
            }, status=502)

    def _handle_start(self):
        global _user_stopped
        if _is_backend_alive():
            _set_backend_state("running")
            return self._json_response({"ok": True, "message": "already running"})
        _user_stopped = False
        _start_backend_proc()
        ok = _wait_for_backend(timeout=15)
        self._json_response({"ok": ok, "message": "started" if ok else "start timeout"})

    def _handle_stop(self):
        global _user_stopped
        _user_stopped = True
        _stop_backend_proc()
        self._json_response({"ok": True, "message": "stopped"})

    def _handle_shutdown(self):
        """Stop backend and terminate the launcher process entirely."""
        global _user_stopped
        _user_stopped = True
        self._json_response({"ok": True, "message": "shutting down"})
        def _deferred_full_shutdown():
            _stop_backend_proc()
            _release_launcher_lock()
            print("[Launcher] Full shutdown requested by client — exiting.")
            os._exit(0)
        threading.Thread(target=_deferred_full_shutdown, daemon=True).start()

    def _handle_restart(self):
        global _user_stopped
        _user_stopped = False
        _stop_backend_proc()
        time.sleep(0.5)
        _start_backend_proc()
        ok = _wait_for_backend(timeout=15)
        self._json_response({"ok": ok, "message": "restarted" if ok else "restart timeout"})

    # ── Static file server ───────────────────────────────────────────────
    def _serve_frontend(self, path):
        if path == "/":
            path = "/index.html"
        safe = os.path.normpath(path.lstrip("/"))
        full = os.path.realpath(os.path.join(FRONTEND_DIR, safe))
        if not full.startswith(os.path.realpath(FRONTEND_DIR)):
            self.send_error(403)
            return
        if not os.path.isfile(full):
            full = os.path.join(FRONTEND_DIR, "index.html")
        try:
            with open(full, "rb") as f:
                data = f.read()
            ext = os.path.splitext(full)[1].lower()
            ctype = _MIME_MAP.get(ext, mimetypes.guess_type(full)[0] or "application/octet-stream")
            self.send_response(200)
            self.send_header("Content-Type", ctype)
            self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
            self.send_header("Pragma", "no-cache")
            self.send_header("Expires", "0")
            self._cors()
            self.end_headers()
            self.wfile.write(data)
        except OSError:
            self.send_error(404)


# ── Watchdog (auto-restart crashed backend) ───────────────────────────────────

RESTART_DELAY = 3
MAX_RESTARTS  = 10

def _watchdog():
    """Background thread: restart the backend if it crashes (unless user stopped)."""
    global _proc, _last_backend_exit_code
    consecutive = 0
    while True:
        with _proc_lock:
            p = _proc
        if p is None:
            time.sleep(1)
            continue
        p.wait()
        exit_code = p.returncode
        _last_backend_exit_code = exit_code
        with _proc_lock:
            if _proc is p:
                _proc = None
        if _user_stopped:
            _set_backend_state("stopped")
            time.sleep(1)
            continue
        if _active_client_count() == 0:
            _set_backend_state("idle", f"Backend exited with code {exit_code} while no frontend clients were attached.")
            print(f"[Launcher] Backend exited with code {exit_code}; no active frontend clients, leaving backend stopped.")
            time.sleep(1)
            continue
        consecutive += 1
        if consecutive >= MAX_RESTARTS:
            _set_backend_state("error", f"Backend crashed repeatedly (last exit code {exit_code}).")
            print(f"[Launcher] Backend crashed {MAX_RESTARTS}x, giving up auto-restart.")
            break
        print(f"[Launcher] Backend exited. Restarting in {RESTART_DELAY}s… ({consecutive}/{MAX_RESTARTS})")
        time.sleep(RESTART_DELAY)
        _start_backend_proc()
        if _wait_for_backend(timeout=15):
            consecutive = 0


def _housekeeper():
    while True:
        try:
            _prune_stale_clients()
            last_client_age = _seconds_since_last_client()
            tracked_pid, _ = _reconcile_tracked_backend()
            if (
                tracked_pid
                and not _user_stopped
                and _is_backend_alive()
                and last_client_age is not None
                and _active_client_count() == 0
                and last_client_age >= IDLE_BACKEND_STOP_SECONDS
            ):
                print(
                    f"[Launcher] No frontend heartbeat for {int(last_client_age)}s; stopping idle backend."
                )
                _stop_backend_proc()
                _set_backend_state("idle")
        except Exception as exc:
            print(f"[Launcher] Housekeeper warning: {exc}")
        time.sleep(5)


# ── Main ──────────────────────────────────────────────────────────────────────

def _deferred_backend_start():
    """Start the backend (venv setup, Ollama, process) in a background thread.

    This runs after the HTTP server is already listening so the frontend is
    reachable immediately while dependencies are being installed."""
    try:
        _start_backend_proc()
        _wait_for_backend(timeout=30)
    except Exception as e:
        print(f"[Launcher] Backend start failed: {e}")


def main():
    if not _acquire_launcher_lock():
        print(f"[Launcher] Existing launcher instance detected, exiting duplicate.")
        return

    # Prevent duplicate launcher instances
    try:
        resp = urllib.request.urlopen(f"http://127.0.0.1:{LAUNCHER_PORT}/api/launcher/status", timeout=2)
        if resp.status == 200:
            print(f"[Launcher] Already running on port {LAUNCHER_PORT}, exiting duplicate.")
            _release_launcher_lock()
            return
    except (urllib.error.URLError, OSError):
        pass

    # Graceful shutdown on SIGTERM / Ctrl+C
    def _shutdown(sig, frame):
        print("\n[Launcher] Shutting down…")
        _stop_backend_proc()
        _release_launcher_lock()
        sys.exit(0)
    signal.signal(signal.SIGTERM, _shutdown)
    signal.signal(signal.SIGINT, _shutdown)

    # Start HTTP server FIRST so port 8001 is reachable immediately.
    try:
        srv = _SingleInstanceHTTPServer(("127.0.0.1", LAUNCHER_PORT), _LauncherHandler)
    except OSError as exc:
        print(f"[Launcher] Failed to bind launcher port {LAUNCHER_PORT}: {exc}")
        _release_launcher_lock()
        return
    print(f"[Launcher] Listening on http://127.0.0.1:{LAUNCHER_PORT}")

    # Start backend (venv install, Ollama, etc.) in a background thread so
    # the frontend is available while setup runs.
    boot = threading.Thread(target=_deferred_backend_start, daemon=True)
    boot.start()

    # Start watchdog thread
    wd = threading.Thread(target=_watchdog, daemon=True)
    wd.start()

    hk = threading.Thread(target=_housekeeper, daemon=True)
    hk.start()

    srv.serve_forever()


if __name__ == "__main__":
    try:
        main()
    except Exception:
        _release_launcher_lock()
        raise
