"""Pre-execution enforcement via `rstack-agents guard` (#371).

Exit-code contract: 0 = ALLOW, 2 = BLOCK, anything else = guard UNAVAILABLE →
fail closed by default (RSTACK_GUARD_FAIL_OPEN=1 restores the legacy allow).

owner: RStack developed by Richardson Gunde; Tau adapter contributed by Jeomon
"""
from __future__ import annotations

import asyncio
import json
import os
import sys
from typing import Optional

from .config import _timeout_from_env
from .proc import _communicate_or_kill, _own_process_group_kwargs
from .resolve import _resolve_bin_argv


def _guard_fail_open() -> bool:
    """#371: fail CLOSED by default when the guard cannot RUN; opt back into the
    legacy fail-OPEN with RSTACK_GUARD_FAIL_OPEN=1. Mirrors the JS guard policy
    (src/commands/guard.js guardFailOpen) so enforcement behaves the same way on
    every harness."""
    return os.environ.get("RSTACK_GUARD_FAIL_OPEN") == "1"


# Guard calls resolve a local binary first (see resolve.py), so unlike the
# bridge they rarely absorb a cold `npx` fetch — a tighter bound keeps a hung
# guard from stalling a Tau tool call for long.
_GUARD_TIMEOUT_MS = 15_000


def _guard_timeout_s() -> float:
    """Hard bound on a single guard invocation (RSTACK_GUARD_TIMEOUT_MS, default
    15s) so a hung `npx`/guard process can never stall a Tau tool call."""
    return _timeout_from_env("RSTACK_GUARD_TIMEOUT_MS", _GUARD_TIMEOUT_MS)


def _guard_context() -> str:
    """The agent context this session's tool calls are enforced under (#573).

    Was hardcoded to "builder", which made a Tau validator session enforceable
    only as a builder — and builder context gates just DESTRUCTIVE actions, so an
    ordinary source overwrite passed in a session whose contract is read-only.
    Worse, an explicit `--context` OUTRANKS RSTACK_AGENT_CONTEXT in the CLI's
    precedence (src/commands/guard.js resolveGuardContext), so the hardcoded flag
    actively defeated the documented env knob.

    Resolution mirrors that precedence: the sandbox stamp wins (escalation is
    ONE-WAY — a sandboxed session must not be downgradable by setting the softer
    knob), then the role env, then builder. The role VOCABULARY is not
    re-declared here: an unrecognized value is passed through and guard.js
    normalizes it back to builder, so the two languages cannot drift.

    Sending the resolved role also keeps the argv honest if the child's
    environment is ever allowlist-scrubbed — enforcement then no longer depends
    on RSTACK_VALIDATOR_CONTEXT surviving into the subprocess.
    """
    if os.environ.get("RSTACK_VALIDATOR_CONTEXT") == "1":
        return "validator"
    return (os.environ.get("RSTACK_AGENT_CONTEXT") or "").strip().lower() or "builder"


def _resolve_guard_argv(cwd: str) -> tuple[Optional[list[str]], bool]:
    """Resolve how to invoke `rstack-agents` for the guard (#371) — the shared
    resolver, kept under its historical name for the guard's callers/tests.
    See rstack_sdlc_pkg/resolve.py for the resolution order."""
    return _resolve_bin_argv("rstack-agents", cwd)


def _guard_unavailable(detail: str) -> tuple[bool, str]:
    """Verdict when the guard COULD NOT RUN (spawn failure, timeout, crash, or a
    cold-npx registry miss). Fails closed by default so enforcement is never
    silently skipped; RSTACK_GUARD_FAIL_OPEN=1 restores the legacy allow, with a
    loud warning so the skipped enforcement is never invisible. (#371)"""
    if _guard_fail_open():
        print(
            f"[rstack] WARNING: guard unavailable ({detail}); RSTACK_GUARD_FAIL_OPEN=1 — "
            "allowing this tool call WITHOUT enforcement.",
            file=sys.stderr,
        )
        return True, ""
    return False, (
        f"RStack guard is UNAVAILABLE ({detail}) — failing closed so enforcement is not "
        "silently skipped. Run `rstack-agents doctor` to fix the guard, or set "
        "RSTACK_GUARD_FAIL_OPEN=1 to allow tool calls without enforcement."
    )


async def _run_guard(guard_tool_name: str, tool_input: dict, cwd: str) -> tuple[bool, str]:
    """Classify one pending tool call via `rstack-agents guard`.

    Exit-code contract (#371): 0 = ALLOW, 2 = BLOCK. ANY other outcome — a
    non-0/2 exit (crash, module-load error, cold-`npx` registry miss), a
    timeout, or a spawn failure — means the guard could not decide, so it is
    UNAVAILABLE and fails closed by default (see _guard_unavailable). This fixes
    the old "any exit != 2 = allow" behavior, under which a partial install or an
    offline cold cache would silently disable enforcement with no signal.
    """
    argv_prefix, _needs_network = _resolve_guard_argv(cwd)
    if argv_prefix is None:
        return _guard_unavailable("no rstack-agents binary and no npx on PATH")

    payload = json.dumps({"tool_name": guard_tool_name, "tool_input": tool_input}).encode("utf-8")
    argv = [*argv_prefix, "guard", "--context", _guard_context(), "--project", cwd]
    try:
        proc = await asyncio.create_subprocess_exec(
            *argv,
            stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
            env=os.environ, cwd=cwd,
            **_own_process_group_kwargs(),
        )
    except OSError as exc:
        return _guard_unavailable(f"could not spawn guard: {exc}")

    try:
        # #487 (audit finding, verbatim): "even _run_guard's timeout branch
        # calls proc.kill() without a follow-up await proc.wait() — the
        # child is signaled but never reaped." Routed through the shared
        # helper, which kills AND reaps before re-raising.
        out, err = await _communicate_or_kill(proc, payload, _guard_timeout_s())
    except asyncio.TimeoutError:
        return _guard_unavailable("guard timed out")

    if proc.returncode == 0:
        return True, ""
    if proc.returncode == 2:
        reason = (
            err.decode("utf-8", "replace").strip()
            or out.decode("utf-8", "replace").strip()
            or "RStack guard blocked this tool call."
        )
        return False, reason
    # Any OTHER exit code = the guard ran abnormally (crash / module-load error /
    # cold npx registry miss). Do NOT read it as allow.
    detail = (
        err.decode("utf-8", "replace").strip()
        or out.decode("utf-8", "replace").strip()
        or f"exit {proc.returncode}"
    )
    return _guard_unavailable(detail)
