"""The universal `tool_call` gate (#575): every tool through `rstack-agents guard`.

Built-in shadowing (guarded_tools.py) can only cover tools the adapter knows by
name. Tau's `tool_call` hook fires for EVERY tool the engine executes, so this
handler is the first thing in the adapter that can see an MCP server's tools or
another extension's tools — the G-07 residue #552 recorded as "outside the guard
until upstream Tau ships a universal pre-tool hook (T-01, Jeo)".

It does NOT replace the shadows. Both run, deliberately:

  * A handler that raises is swallowed by Tau's dispatch, which records the error
    and returns None — and `Agent._before_tool_call` reads "no result" as consent.
    A hook is therefore not a mechanism you can rest the whole gate on.
  * Running both can only ever be MORE restrictive: the effective policy is
    "blocked if either gate blocks". `rstack-agents guard` is a pure classifier —
    it reads tasks.json/approvals.json and writes only its verdict to stdout, so
    invoking it twice for one call cannot consume an approval or move any state
    (verified by tracing every transitive import of src/commands/guard.js).

Registration is conditional on the capability probe (capability.py): on a Tau
that discards the block, this handler is not installed at all, because a gate
whose verdict is thrown away is worse than an absent one — it looks like
enforcement.

owner: RStack developed by Richardson Gunde
"""
from __future__ import annotations

import os

from tau.hooks import ToolCallEventResult

from .gates import _GATE_TOOL_MAP, _run_gate
from .guard import _run_guard
from .guarded_tools import _GUARDED_BUILTINS
from .observe import _observe_bg


def _guard_payload(tool_name: str, params: dict) -> tuple[str, dict]:
    """Map one Tau tool call onto the guard CLI's `{tool_name, tool_input}`.

    A known built-in is translated into the guard's Claude-Code vocabulary using
    the SAME table the shadows use, including the field rename that matters
    (Tau's terminal carries `cmd`; the guard expects `command`) — a live smoke
    test once caught exactly that mismatch silently allowing everything.

    Anything else — an MCP tool, another extension's tool — is passed through
    under its own name with its own params. The guard classifies what it can and
    the call is at worst no less inspected than it is today, where it is not
    inspected at all.
    """
    mapping = _GUARDED_BUILTINS.get(tool_name)
    if mapping is None:
        return tool_name, dict(params or {})
    guard_tool_name, guard_field, tau_param_field, _factory = mapping
    return guard_tool_name, {guard_field: (params or {}).get(tau_param_field, "")}


def _make_tool_call_gate(enabled_gates_getter):
    """Build the `tool_call` handler. Tau calls it as `(event, ctx)`."""

    async def _rstack_tool_call_gate(event, ctx):
        # Tau's own shape: ToolCallEvent carries `input`, not `params`, and no
        # cwd — that comes from the context (tau/hooks/engine.py:170-177).
        tool_name = getattr(event, "tool_name", "") or ""
        params = getattr(event, "input", None) or {}
        cwd = str(getattr(ctx, "cwd", None) or os.getcwd())

        try:
            guard_tool_name, guard_input = _guard_payload(tool_name, params)

            # Record the INTENT even when the call is later blocked, so a denied
            # call is still visible in the Hub feed. Fire-and-forget (#253).
            _observe_bg(
                {"tool_name": tool_name, "tool_input": guard_input, "hook_event_name": "PreToolUse"},
                cwd,
            )

            allowed, reason = await _run_guard(guard_tool_name, guard_input, cwd)
            if not allowed:
                return _blocked(reason, guard_tool_name)

            gate_mapping = _GATE_TOOL_MAP.get(tool_name)
            enabled_gates = enabled_gates_getter()
            if enabled_gates and gate_mapping is not None:
                gate_tool_name, gate_field, gate_tau_field = gate_mapping
                gate_input = {gate_field: params.get(gate_tau_field, "")}
                for gate_name in enabled_gates:
                    ok, gate_reason = await _run_gate(gate_name, gate_tool_name, gate_input, cwd)
                    if not ok:
                        return _blocked(gate_reason, guard_tool_name)
        except Exception as exc:  # noqa: BLE001 - see below
            # Upstream catches Exception around handlers, records the error, and
            # returns None — which reads as consent. So an unexpected failure
            # HERE must become an explicit block rather than being allowed to
            # escape into that silence (#371). BaseException is deliberately not
            # caught: CancelledError must keep propagating and already aborts the
            # call, which is the fail-closed direction anyway.
            return _blocked(
                f"RStack guard hook failed ({type(exc).__name__}: {exc}) — failing closed so "
                "enforcement is not silently skipped.",
                tool_name,
            )

        # Allow. Tau skips any return value that is not a ToolCallEventResult,
        # so None is the allow signal (tau/agent/service.py:335-337).
        return None

    return _rstack_tool_call_gate


def _blocked(reason: str, guard_tool_name: str) -> ToolCallEventResult:
    """A denial Tau will honor, carrying the guard's own explanation.

    `reason=None` would degrade to Tau's generic "blocked by an extension" text,
    losing the #371 remediation hint. Metadata keys are namespaced: Tau spreads
    the handler's dict OVER its own `blocked`/`blocked_by`, so reusing those two
    names would overwrite upstream's meaning.
    """
    return ToolCallEventResult(
        block=True,
        reason=reason or "RStack guard blocked this tool call.",
        metadata={"rstack_blocked_by": "rstack-guard", "rstack_guard_tool": guard_tool_name},
    )
