"""Kernel tool runner (docs/kernel_spec.md §2–§5).

Registry + executor. Enforces the capability policy on every call (the gate is
injected — rules live in a peripheral), validates arguments against a tool's
JSON schema, runs the tool, and returns a result or a plain-language error the
model can act on.

Spec §5: error feedback replaces repair. The runner never guesses arguments;
it returns named, actionable errors and lets the model retry. The single
permitted coercion is JSON-string -> object for the whole arguments blob.

Boundary: stdlib only (+ core.model types).
"""
from __future__ import annotations

import difflib
import json
import logging
import traceback
from collections.abc import Callable
from dataclasses import dataclass, field

from core.model import ToolCall

_log = logging.getLogger(__name__)

# The canonical 12 always-on kernel tools (spec §3). Five are implemented in
# core/kernel_tools.py; the other seven are peripheral-registered (Phase 3).
KERNEL_TOOL_NAMES: tuple[str, ...] = (
    "read_file",
    "write_file",
    "edit_file",
    "list_directory",
    "shell_execute",
    "search_web",
    "read_webpage",
    "search_memory",
    "append_to_memory",
    "kanban_create_batch",
    "kanban_list_tasks",
    "find_tools",
)

# Capability tags a tool may carry. The injected gate decides what each means
# per surface (spec §4); the runner only routes the tags.
CAP_SHELL = "shell"
CAP_FS_WRITE = "fs_write"

_OUTPUT_CAP_DEFAULT = 20_000

# Peripheral tools often return error strings instead of raising. Mirror the
# legacy agent loop's failure prefixes so SSE/UI mark success=false (spec §5).
_TOOL_FAILURE_PREFIXES = (
    "tool error:",
    "batch rejected:",
    "error:",
    "unknown tool:",
    "items must be",
    "each item must be",
    "kanban disabled:",
    "failed to create",
    "task rejected:",
    "path does not exist:",
    "path is a directory, not a file:",
    "path is not a directory:",
    "file not found:",
    "permission denied:",
    "operation blocked:",
    "blocked:",
    "refused:",
    "read failed:",
    "write failed:",
    "append failed:",
)


def _tool_output_indicates_failure(text: str) -> bool:
    """True when a tool returned a plain-language error string, not a result."""
    lowered = (text or "").strip().lower()
    if not lowered:
        return False
    return any(lowered.startswith(prefix) for prefix in _TOOL_FAILURE_PREFIXES)


# A gate returns None to allow, or a string (the denial message shown to the
# model as the tool result). Signature: (tool_name, arguments_dict) -> str | None.
PolicyGate = Callable[[str, dict], "str | None"]


@dataclass
class ToolSpec:
    name: str
    description: str
    parameters: dict  # JSON schema, object-typed
    fn: Callable[..., str]
    capabilities: frozenset[str] = frozenset()


@dataclass
class ToolResult:
    name: str
    call_id: str
    output: str
    ok: bool  # False => output is an actionable error the model should act on
    recovered_via: str | None = None  # reserved for future use


def _type_ok(value: object, json_type: str) -> bool:
    """Loose primitive type check matching JSON schema scalar types."""
    if json_type == "string":
        return isinstance(value, str)
    if json_type == "integer":
        return isinstance(value, int) and not isinstance(value, bool)
    if json_type == "number":
        return isinstance(value, (int, float)) and not isinstance(value, bool)
    if json_type == "boolean":
        return isinstance(value, bool)
    if json_type == "array":
        return isinstance(value, list)
    if json_type == "object":
        return isinstance(value, dict)
    return True  # unknown/None type: don't block


class ToolRunner:
    """Validate, gate, and execute tool calls. Never raises on tool failure."""

    def __init__(
        self,
        *,
        policy_gate: PolicyGate | None = None,
        output_cap: int = _OUTPUT_CAP_DEFAULT,
        unknown_tool_resolver: Callable[[str], ToolSpec | None] | None = None,
        catalog_names: Callable[[], list[str]] | None = None,
    ) -> None:
        self._policy_gate = policy_gate
        self._output_cap = output_cap
        self._unknown_tool_resolver = unknown_tool_resolver
        self._catalog_names = catalog_names
        self._specs: dict[str, ToolSpec] = {}

    # ── registry ──
    def register(self, spec: ToolSpec) -> None:
        self._specs[spec.name] = spec

    def register_all(self, specs: "list[ToolSpec] | tuple[ToolSpec, ...]") -> None:
        for spec in specs:
            self.register(spec)

    @property
    def names(self) -> list[str]:
        return sorted(self._specs)

    def schemas(self) -> list[dict]:
        """OpenAI function-call schemas for the registered tools."""
        return [
            {
                "type": "function",
                "function": {
                    "name": spec.name,
                    "description": spec.description,
                    "parameters": spec.parameters or {"type": "object", "properties": {}},
                },
            }
            for spec in self._specs.values()
        ]

    # ── execution ──
    def run(self, call: ToolCall) -> ToolResult:
        name = (call.name or "").strip()
        call_id = call.id or ""

        spec = self._specs.get(name)
        recovered_via: str | None = None
        if spec is None:
            if self._unknown_tool_resolver is not None:
                try:
                    spec = self._unknown_tool_resolver(name)
                except Exception:
                    _log.warning(
                        "unknown_tool_resolver raised for %s; treating as miss",
                        name,
                        exc_info=True,
                    )
                    spec = None
            if spec is not None:
                self.register(spec)
                recovered_via = "registry_fallthrough"
            else:
                return ToolResult(
                    name=name,
                    call_id=call_id,
                    ok=False,
                    output=self._unknown_tool_message(name),
                )

        parsed = self._parse_arguments(call.arguments)
        if isinstance(parsed, str):  # error message
            return ToolResult(name=name, call_id=call_id, ok=False, output=parsed)

        validation_error = self._validate(spec, parsed)
        if validation_error is not None:
            return ToolResult(name=name, call_id=call_id, ok=False, output=validation_error)

        gate_error = self._gate(spec, parsed)
        if gate_error is not None:
            return ToolResult(name=name, call_id=call_id, ok=False, output=gate_error)

        result = self._execute(spec, parsed, call_id)
        if recovered_via:
            result.recovered_via = recovered_via
        return result

    # ── steps ──
    def _unknown_tool_message(self, name: str) -> str:
        available = ", ".join(self.names) or "(none registered)"
        shown = f"'{name}'" if name else "(empty)"
        msg = f"Unknown tool {shown}. Available tools: {available}."
        candidates = list(self._specs.keys())
        if self._catalog_names is not None:
            try:
                for catalog_name in self._catalog_names():
                    if catalog_name not in candidates:
                        candidates.append(catalog_name)
            except Exception:
                _log.debug("catalog_names callback failed", exc_info=True)
        if name:
            close = difflib.get_close_matches(name, candidates, n=3, cutoff=0.6)
            if close:
                suggestions = ", ".join(f"`{match}`" for match in close)
                msg += f" Did you mean {suggestions}?"
        msg += " Call one of the available tools, or use find_tools to discover more."
        return msg

    def _parse_arguments(self, raw: str) -> "dict | str":
        """Return an arguments dict, or an actionable error string.

        Coercion exception (spec §5): the whole arguments blob is a JSON string;
        we parse it once. We never guess or repair individual fields.
        """
        if raw is None or (isinstance(raw, str) and raw.strip() == ""):
            return {}
        if isinstance(raw, dict):  # tolerate already-parsed input
            return raw
        try:
            obj = json.loads(raw)
        except (json.JSONDecodeError, TypeError):
            return (
                "Could not parse tool arguments as JSON. Send `arguments` as a "
                "single valid JSON object, e.g. {\"path\": \"/tmp/x\"}."
            )
        if not isinstance(obj, dict):
            return (
                "Tool arguments must be a JSON object (mapping of names to "
                f"values), got {type(obj).__name__}."
            )
        return obj

    def _validate(self, spec: ToolSpec, args: dict) -> str | None:
        schema = spec.parameters or {}
        props = schema.get("properties") or {}
        required = schema.get("required") or []

        missing = [k for k in required if k not in args]
        if missing:
            names = ", ".join(missing)
            return (
                f"Missing required argument(s) for {spec.name}: {names}. "
                f"Required: {', '.join(required)}."
            )

        for key, value in args.items():
            prop = props.get(key)
            if not isinstance(prop, dict):
                continue  # unknown extra arg: ignore (additive forgiveness)
            expected = prop.get("type")
            if expected and not _type_ok(value, expected):
                return (
                    f"Argument '{key}' for {spec.name} must be of type "
                    f"{expected}, got {type(value).__name__}."
                )
        return None

    def _gate(self, spec: ToolSpec, args: dict) -> str | None:
        if self._policy_gate is not None:
            try:
                return self._policy_gate(spec.name, args)
            except Exception:
                _log.warning("policy gate raised for %s; denying (fail-closed)", spec.name, exc_info=True)
                return (
                    f"Tool {spec.name} was blocked: the security policy check "
                    "failed. This tool is unavailable right now."
                )
        # No gate configured: fail-closed for capability-flagged tools, allow
        # read-only ones. Never silently permissive (spec §4).
        if spec.capabilities:
            caps = ", ".join(sorted(spec.capabilities))
            return (
                f"Tool {spec.name} requires capabilities [{caps}] but no policy "
                "is configured to authorize them. This tool is unavailable."
            )
        return None

    def _execute(self, spec: ToolSpec, args: dict, call_id: str) -> ToolResult:
        try:
            raw = spec.fn(**args)
        except TypeError as exc:
            # Almost always an argument-shape mismatch the schema didn't catch.
            return ToolResult(
                name=spec.name,
                call_id=call_id,
                ok=False,
                output=(
                    f"{spec.name} could not run with those arguments: {exc}. "
                    "Check the argument names and types and try again."
                ),
            )
        except Exception as exc:
            tail = traceback.format_exc().strip().splitlines()[-3:]
            return ToolResult(
                name=spec.name,
                call_id=call_id,
                ok=False,
                output=(
                    f"{spec.name} failed: {exc}\n"
                    + "\n".join(tail)
                    + "\nFix the inputs or try a different approach."
                ),
            )
        text = raw if isinstance(raw, str) else str(raw)
        if len(text) > self._output_cap:
            text = text[: self._output_cap] + "\n[truncated]"
        ok = not _tool_output_indicates_failure(text)
        return ToolResult(name=spec.name, call_id=call_id, ok=ok, output=text)
