#!/usr/bin/env python3
"""llm_runner.py — the single LLM-backend dispatch point (the `adapter` seam; modular-hierarchy refactor 2).

DRY: gate.py:one_roll and semantic_merge._call_llm both shelled out an identical
`claude -p --model M --effort E --dangerously-skip-permissions` with the same env/timeout. That call
lives here ONCE. Backend = claude-cli today; alt backends (the deferred elusive-dice phase) extend this
fn, not the call sites.

Returns the raw CompletedProcess so each caller keeps its OWN error policy: one_roll tolerates a nonzero
exit and returns partial stdout; semantic_merge raises MergeError on nonzero. An error-imposing /
text-returning abstraction would change one of them -> not built here (YAGNI). Loaded by PATH."""
import os, subprocess, time, json, re, shutil, uuid

# backends. claude (default) = `claude -p --model M --effort E` over stdin (the validated sonnet leg).
# codex = `codex exec -m gpt-5.5 -c model_reasoning_effort=E` (gpt-5.5 measurement leg). Selected by the
# SG_LLM_BACKEND env so call sites (gate.py one_roll / semantic_merge) stay UNCHANGED — the single seam.
_CLAUDE_ALIASES = {"sonnet", "opus", "haiku"}

# sandbox-run names containers overdeck-sandbox-<id>.  This one is deliberately
# persistent: security-gate makes many short, stateless calls and pays only podman
# exec latency after provisioning.  Provisioning is an operator action; this
# adapter must never silently create a weaker container or fall back to the host.
_POOL_CONTAINER = "overdeck-sandbox-security-gate-codex"
_CODEX_CRED_SOURCE = "/creds/pools/security-gate-codex/codex"
_CODEX_CRED_DEST = "/sandbox-secrets/codex"


class ContainmentError(RuntimeError):
    """The required agent-sandbox pool cannot safely execute this LLM call."""


def _inspect_pool(env):
    """Return the validated warm agent-sandbox pool inspection record."""
    podman = shutil.which("podman", path=env.get("PATH"))
    if not podman:
        raise ContainmentError("podman is unavailable; refusing host LLM execution")
    try:
        inspected = subprocess.run(
            [podman, "inspect", _POOL_CONTAINER], capture_output=True, text=True,
            timeout=10, env=env,
        )
    except (OSError, subprocess.SubprocessError) as exc:
        raise ContainmentError(f"cannot inspect agent-sandbox pool: {exc}") from exc
    if inspected.returncode:
        raise ContainmentError(
            f"agent-sandbox pool {_POOL_CONTAINER!r} is unavailable; refusing host LLM execution"
        )
    try:
        records = json.loads(inspected.stdout)
        record = records[0]
        running = record["State"]["Running"]
        mounts = record["Mounts"]
    except (ValueError, KeyError, IndexError, TypeError) as exc:
        raise ContainmentError("agent-sandbox pool inspection was malformed") from exc
    if not running:
        raise ContainmentError(f"agent-sandbox pool {_POOL_CONTAINER!r} is not running")
    credential_mount = next(
        (mount for mount in mounts
         if mount.get("Source") == _CODEX_CRED_SOURCE
         and mount.get("Destination") == _CODEX_CRED_DEST),
        None,
    )
    if not credential_mount or credential_mount.get("RW", True):
        raise ContainmentError(
            "agent-sandbox pool lacks the required read-only security-gate credential mount"
        )
    return podman


def _contained_run(command, *, prompt, cwd, timeout, env, runtime):
    """Execute one command in the warm pool with a fresh, private HOME."""
    podman = _inspect_pool(env)
    call_home = f"/sandbox/home/security-gate/{uuid.uuid4().hex}"
    argv = [
        podman, "exec", "--interactive", "--env", f"HOME={call_home}",
        "--env", f"CODEX_HOME={call_home}/.codex",
    ]
    if "CLAUDE_CONFIG_DIR" in env:
        argv.extend(["--env", f"CLAUDE_CONFIG_DIR={env['CLAUDE_CONFIG_DIR']}"])
    if cwd:
        argv.extend(["--workdir", cwd])
    argv.extend([
        _POOL_CONTAINER, "/bin/sh", "-c",
        # This follows agent-credentials.sh's staging contract: copy a read-only
        # mounted credential into the private HOME, keep the shell alive for the
        # child, then remove the entire per-call home.
        'umask 077; trap \'rm -rf "$HOME"\' EXIT HUP INT TERM; '
        'mkdir -p "$HOME" "$CODEX_HOME"; '
        '[ "$1" != codex ] || { cp /sandbox-secrets/codex/auth.json "$CODEX_HOME/auth.json" '
        '&& chmod 600 "$CODEX_HOME/auth.json"; }; runtime="$1"; shift; "$@"',
        "sh", runtime,
        *command,
    ])
    try:
        return subprocess.run(
            argv, input=prompt, capture_output=True, text=True, timeout=timeout, env=env,
        )
    except (OSError, subprocess.SubprocessError) as exc:
        raise ContainmentError(f"agent-sandbox pool execution failed: {exc}") from exc


def _record_codex_telemetry(model, effort, t0, cp):
    """Append one per-call {wall_ms, tokens, rc} row when SG_TELEMETRY is set. Measurement-only,
    default-off, never raises. tokens parsed from codex's `tokens used\\nN` summary (stdout or stderr)."""
    path = os.environ.get("SG_TELEMETRY")
    if not path:
        return
    wall_ms = int((time.monotonic() - t0) * 1000)
    blob = (cp.stdout or "") + "\n" + (cp.stderr or "")
    m = re.search(r"tokens used[\s:]+([\d,]+)", blob)
    rec = {"tag": os.environ.get("SG_TELEMETRY_TAG", ""), "model": model, "effort": effort,
           "wall_ms": wall_ms, "tokens": int(m.group(1).replace(",", "")) if m else None,
           "rc": cp.returncode}
    try:
        with open(path, "a") as f:
            f.write(json.dumps(rec) + "\n")
        if os.environ.get("SG_TELEMETRY_RAW"):
            with open(path + ".raw", "a") as f:
                f.write(f"=== rc={cp.returncode} ===\n--STDOUT--\n{cp.stdout}\n--STDERR--\n{cp.stderr}\n")
    except Exception:
        pass


def run_llm(prompt, model="sonnet", effort="medium", config_dir=None, cwd=None, timeout=600):
    """Run the LLM backend on `prompt`. config_dir -> CLAUDE_CONFIG_DIR (blind catch-test isolation);
    cwd -> working dir (the review bundle lives in /tmp, cwd is the repo). Raises on subprocess failure
    (timeout / ENOENT); the caller decides what a nonzero return code means.

    SG_LLM_BACKEND=codex swaps the leg to codex (gpt-5.5). codex reads the prompt as an ARG (stdin closed,
    else it hangs), needs --skip-git-repo-check, and takes effort via `-c model_reasoning_effort=`. Its model
    is the passed `model` unless that is a claude alias (the gate's default) — then SG_CODEX_MODEL / gpt-5.5.
    codex's final message lands on STDOUT (same field the parser reads); reasoning/hook noise goes to stderr."""
    env = dict(os.environ)
    if config_dir:
        env["CLAUDE_CONFIG_DIR"] = config_dir
    backend = os.environ.get("SG_LLM_BACKEND", "claude")
    if backend == "codex":
        cmodel = model if model not in _CLAUDE_ALIASES else os.environ.get("SG_CODEX_MODEL", "gpt-5.5")
        cdir = cwd or os.getcwd()
        t0 = time.monotonic()
        cp = _contained_run(
            ["codex", "exec", "--skip-git-repo-check", "-C", cdir,
             "-m", cmodel, "-c", f"model_reasoning_effort={effort}", prompt],
            prompt="", cwd=cdir, timeout=timeout, env=env, runtime="codex",
        )
        _record_codex_telemetry(cmodel, effort, t0, cp)
        return cp
    return _contained_run(
        ["claude", "-p", "--model", model, "--effort", effort, "--dangerously-skip-permissions"],
        prompt=prompt, timeout=timeout, cwd=cwd, env=env, runtime="claude",
    )
