#!/usr/bin/env python3
"""Durable, single-owner controller for one NestQuest OpenCode tick at a time."""

from __future__ import annotations

import argparse
import fcntl
import json
import os
from pathlib import Path
import re
import selectors
import signal
import sqlite3
import subprocess
import sys
import time
import urllib.request


DEFAULT_PROJECT = Path("/mnt/ai-storage/projects/NestQuest")
DEFAULT_STATE = Path.home() / ".local/state/nestquest-controller"
STATE_RE = re.compile(r"(?m)^ORCHESTRATOR_STATE: (CONTINUE|QUIESCENT|BLOCKED)\s*$")
MAX_TEXT = 64_000
MAX_LINE = 4_000_000
CTXD_PROJECT = "nestquest-house-chaos-coordination"
CTXD_FILE = "OPENCODE-ORCHESTRATOR-PROMPT.MD"


def check_ctxd() -> int:
    """Use OpenCode's configured endpoint, never an unrelated host DB."""
    config = json.loads((Path.home() / ".config/opencode/opencode.json").read_text())
    mcp = config["mcp"]["ctxd"]
    configured_url = mcp["url"].rstrip("/")
    if not configured_url.endswith("/mcp"):
        raise RuntimeError("CTXD MCP URL must end in /mcp")
    endpoint = configured_url.removesuffix("/mcp")
    url = f"{endpoint}/projects/{CTXD_PROJECT}/files/{CTXD_FILE}"
    with urllib.request.urlopen(
        urllib.request.Request(url, headers=mcp["headers"]), timeout=8
    ) as response:
        record = json.load(response)
    if (
        record.get("project_id") != CTXD_PROJECT
        or record.get("file_path") != CTXD_FILE
        or "CONTROLLER_TICK" not in record.get("content", "")
    ):
        raise RuntimeError("CTXD returned the wrong NestQuest prompt")
    return int(record["version"])


def interactive_nestquest_session(
    project: Path,
    proc_root: Path = Path("/proc"),
    database: Path = Path.home() / ".local/share/opencode/opencode.db",
) -> bool:
    """Avoid racing a user-owned OpenCode TUI for the same repository."""
    try:
        db = sqlite3.connect(f"file:{database}?mode=ro", uri=True)
    except sqlite3.Error as error:
        raise RuntimeError("OpenCode session database is unavailable") from error
    try:
        for entry in proc_root.iterdir():
            if not entry.name.isdecimal() or int(entry.name) == os.getpid():
                continue
            try:
                args = (entry / "cmdline").read_bytes().split(b"\0")
                args = [a.decode(errors="replace") for a in args if a]
                if not args or Path(args[0]).name != "opencode":
                    continue
                if len(args) > 1 and args[1] in ("run", "debug"):
                    continue
                session = None
                for flag in ("-s", "--session"):
                    if flag in args and args.index(flag) + 1 < len(args):
                        session = args[args.index(flag) + 1]
                        break
                if session:
                    row = db.execute("SELECT directory FROM session WHERE id=?", (session,)).fetchone()
                    if row and Path(row[0]).resolve() == project.resolve():
                        return True
                elif "--dir" in args and args.index("--dir") + 1 < len(args):
                    if Path(args[args.index("--dir") + 1]).resolve() == project.resolve():
                        return True
                elif (entry / "cwd").resolve() == project.resolve():
                    return True
            except sqlite3.Error as error:
                raise RuntimeError("OpenCode session lookup failed") from error
            except TypeError as error:
                raise RuntimeError("OpenCode session directory is invalid") from error
            except (OSError, PermissionError):
                continue
    finally:
        db.close()
    return False


def parse_event(raw: str) -> tuple[str | None, str | None]:
    try:
        event = json.loads(raw)
    except json.JSONDecodeError:
        return None, None
    if not isinstance(event, dict):
        return None, None
    kind = event.get("type")
    part = event.get("part") or {}
    if kind == "text" and isinstance(part, dict):
        return part.get("text"), None
    if kind == "error":
        error = event.get("error") or {}
        known = {"ProviderAuthError", "UnknownError", "MessageOutputLengthError", "MessageAbortedError", "StructuredOutputError", "ContextOverflowError", "APIError"}
        name = error.get("name") if isinstance(error, dict) else None
        return None, name if name in known else "OpenCode error"
    return None, None


def run_tick(command: list[str], env: dict[str, str], prompt: str, timeout: int) -> tuple[int, str, str | None]:
    process = subprocess.Popen(
        command,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        env=env,
        start_new_session=True,
    )
    assert process.stdin is not None and process.stdout is not None
    try:
        process.stdin.write(prompt.encode())
    except BrokenPipeError:
        pass
    finally:
        process.stdin.close()
    selector = selectors.DefaultSelector()
    selector.register(process.stdout, selectors.EVENT_READ)
    deadline = time.monotonic() + timeout
    buffer = b""
    texts: list[str] = []
    last_error: str | None = None
    diagnostic_lines = 0
    try:
        while selector.get_map():
            if time.monotonic() >= deadline:
                try:
                    os.killpg(process.pid, signal.SIGKILL)
                except ProcessLookupError:
                    pass
                process.wait()
                return 124, "".join(texts)[-MAX_TEXT:], "controller tick exceeded documented timeout"
            for key, _ in selector.select(timeout=1):
                chunk = os.read(key.fd, 65536)
                if not chunk:
                    selector.unregister(key.fileobj)
                    continue
                buffer += chunk
                while b"\n" in buffer:
                    line, buffer = buffer.split(b"\n", 1)
                    text, error = parse_event(line.decode(errors="replace"))
                    if not line.lstrip().startswith(b"{"):
                        diagnostic_lines += 1
                    if text:
                        texts.append(text)
                        if sum(map(len, texts)) > MAX_TEXT:
                            texts = ["".join(texts)[-MAX_TEXT:]]
                    if error:
                        last_error = error
                if len(buffer) > MAX_LINE:
                    buffer = b""  # A tool-output event is not a controller verdict.
        if buffer:
            text, error = parse_event(buffer.decode(errors="replace"))
            if text:
                texts.append(text)
            if error:
                last_error = error
        code = process.wait()
        if code and last_error is None and diagnostic_lines:
            last_error = f"OpenCode emitted {diagnostic_lines} non-JSON diagnostic lines"
        return code, "".join(texts)[-MAX_TEXT:], last_error
    finally:
        selector.close()
        process.stdout.close()


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--project", type=Path, default=DEFAULT_PROJECT)
    parser.add_argument("--state-dir", type=Path, default=DEFAULT_STATE)
    parser.add_argument("--once", action="store_true", help="run only one model turn")
    parser.add_argument("--check", action="store_true", help="validate paths without calling a model")
    parser.add_argument("--check-interactive", action="store_true", help="report an open NestQuest TUI without calling a model")
    parser.add_argument("--tick-timeout", type=int, default=6 * 3600)
    args = parser.parse_args()

    project = args.project.resolve()
    control_home = Path(__file__).resolve().parent.parent
    config_dir = control_home / ".opencode"
    binary = Path(os.environ.get("OPENCODE_BIN", "/home/overseer/.opencode/bin/opencode"))
    if not (project.is_dir() and config_dir.is_dir() and binary.is_file()):
        parser.error("project, agent configuration, or OpenCode binary is missing")
    if args.check:
        try:
            ctxd_version = check_ctxd()
        except (OSError, KeyError, ValueError, RuntimeError) as error:
            print(f"CTXD preflight failed: {type(error).__name__}", file=sys.stderr)
            return 1
        print(f"ready: project={project} configuration={config_dir} binary={binary} ctxd_prompt_version={ctxd_version}")
        return 0
    if args.check_interactive:
        try:
            active = interactive_nestquest_session(project)
        except RuntimeError:
            print("OpenCode session state unavailable", file=sys.stderr)
            return 77
        print("interactive NestQuest session open" if active else "no interactive NestQuest session")
        return 0

    args.state_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
    os.chmod(args.state_dir, 0o700)
    with (args.state_dir / "controller.lock").open("w") as lock:
        try:
            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except BlockingIOError:
            print("another NestQuest controller is running", file=sys.stderr)
            return 75
        command = [str(binary), "run", "--pure", "--dir", str(project), "--agent", "orchestrator", "--format", "json", "--title", "NestQuest controller tick"]
        env = os.environ.copy()
        env["OPENCODE_CONFIG_DIR"] = str(config_dir)
        env["NESTQUEST_CONTROL_HOME"] = str(control_home)
        failures = 0
        while True:
            try:
                interactive = interactive_nestquest_session(project)
            except RuntimeError:
                print("OpenCode session state unavailable; waiting", file=sys.stderr, flush=True)
                if args.once:
                    return 77
                time.sleep(60)
                continue
            if interactive:
                print("interactive NestQuest session still open; waiting", flush=True)
                if args.once:
                    return 75
                time.sleep(60)
                continue
            prompt = (
                "Run one NestQuest controller tick. Read CTXD and Maestro afresh, "
                "reconcile Git and PR state, then execute the next eligible action. "
                "Use NESTQUEST_CONTROL_HOME for role launch scripts. "
                "Return a final ORCHESTRATOR_STATE line as your agent instructions require."
            )
            status, answer, error = run_tick(command, env, prompt, args.tick_timeout)
            states = STATE_RE.findall(answer)
            state = states[-1] if status == 0 and error is None and states else "ERROR"
            if state == "CONTINUE":
                failures = 0
            elif state == "QUIESCENT":
                failures = 0
            elif state == "BLOCKED":
                failures = 0
            else:
                failures += 1
            summary = {"time": int(time.time()), "state": state, "exit_code": status, "error": error, "failure_count": failures, "answer_tail": answer[-4000:]}
            target = args.state_dir / "last-run.json"
            temporary = args.state_dir / "last-run.json.tmp"
            temporary.write_text(json.dumps(summary, indent=2) + "\n")
            os.chmod(temporary, 0o600)
            temporary.replace(target)
            print(f"tick state={state} exit={status} failures={failures}", flush=True)
            if args.once:
                return 0 if state in ("CONTINUE", "QUIESCENT") else 77
            if state == "BLOCKED" or failures >= 3:
                print("controller blocked; inspect last-run.json", file=sys.stderr)
                return 77
            time.sleep(300 if state == "QUIESCENT" else min(120, 10 * 2**failures))


if __name__ == "__main__":
    raise SystemExit(main())
