#!/usr/bin/env python3
"""Fail-closed Hermes tool surface for live no-post Leo OOS trials."""

from __future__ import annotations

import hashlib
import json
import os
import signal
import subprocess
import sys
import uuid
from pathlib import Path
from typing import Any

STRUCTURED_READ_TOOL_NAME = "teleo-kb"
BRIDGE_TIMEOUT_SECONDS = 45
BRIDGE_TERMINATE_GRACE_SECONDS = 5
KNOWN_RUNNER_ADDED_TOOLS = frozenset({"process"})
PROPOSAL_STATUSES = ("pending_review", "approved", "applied", "canceled", "rejected", "all")

_FIELD_SCHEMAS = {
    "query": {
        "type": "string",
        "minLength": 1,
        "maxLength": 4096,
        "description": "Natural-language lookup text.",
    },
    "claim_id": {
        "type": "string",
        "description": "Canonical claim UUID.",
    },
    "proposal_id": {
        "type": "string",
        "description": "Proposal UUID.",
    },
    "status": {
        "type": "string",
        "enum": list(PROPOSAL_STATUSES),
        "description": "Proposal status filter.",
    },
    "limit": {
        "type": "integer",
        "minimum": 1,
        "maximum": 100,
        "description": "Maximum rows to return.",
    },
    "context_limit": {
        "type": "integer",
        "minimum": 1,
        "maximum": 100,
        "description": "Maximum identity/context rows.",
    },
    "format": {
        "type": "string",
        "enum": ["markdown", "json"],
        "description": "Response format.",
    },
}
_ACTION_SPECS = {
    "search": {"required": ("query",), "optional": ("limit", "context_limit", "format")},
    "context": {"required": ("query",), "optional": ("limit", "context_limit", "format")},
    "show": {"required": ("claim_id",), "optional": ("format",)},
    "evidence": {"required": ("claim_id",), "optional": ("limit", "format")},
    "edges": {"required": ("claim_id",), "optional": ("limit", "format")},
    "list-proposals": {"required": (), "optional": ("status", "limit", "format")},
    "search-proposals": {"required": ("query",), "optional": ("status", "limit", "format")},
    "show-proposal": {"required": ("proposal_id",), "optional": ("format",)},
    "status": {"required": (), "optional": ("format",)},
    "decision-matrix-status": {"required": (), "optional": ("format",)},
}
READ_ONLY_BRIDGE_COMMANDS = frozenset(_ACTION_SPECS)


STRUCTURED_READ_TOOL_SCHEMA = {
    "name": STRUCTURED_READ_TOOL_NAME,
    "description": (
        "Read Leo's canonical Teleo knowledge base through a bounded, read-only interface. "
        "This tool cannot stage, approve, apply, or mutate knowledge."
    ),
    "parameters": {
        "type": "object",
        "properties": {
            "action": {
                "type": "string",
                "enum": sorted(_ACTION_SPECS),
                "description": (
                    "Read operation. query is required for search, context, and search-proposals; claim_id is "
                    "required for show, evidence, and edges; proposal_id is required for show-proposal."
                ),
            },
            **_FIELD_SCHEMAS,
        },
        "required": ["action"],
        "additionalProperties": False,
    },
}


class ReadOnlyGuardError(RuntimeError):
    """The requested command cannot be proven read-only."""


def _uuid_argument(value: str) -> str:
    try:
        return str(uuid.UUID(value))
    except ValueError as exc:
        raise ReadOnlyGuardError("expected a UUID") from exc


def _required_text(tool_args: dict[str, Any], field: str, *, max_length: int = 4096) -> str:
    value = tool_args.get(field)
    if not isinstance(value, str) or not value.strip():
        raise ReadOnlyGuardError(f"{field} is required")
    normalized = value.strip()
    if len(normalized) > max_length:
        raise ReadOnlyGuardError(f"{field} is too long")
    return normalized


def _bounded_integer(tool_args: dict[str, Any], field: str) -> int:
    value = tool_args.get(field)
    if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 100:
        raise ReadOnlyGuardError(f"{field} must be an integer between 1 and 100")
    return value


def _enum_text(tool_args: dict[str, Any], field: str, choices: tuple[str, ...]) -> str:
    value = tool_args.get(field)
    if not isinstance(value, str) or value not in choices:
        raise ReadOnlyGuardError(f"{field} is not an allowed value")
    return value


def structured_read_argv(tool_args: dict[str, Any]) -> list[str]:
    """Compile typed arguments into bridge argv without an executable."""

    if not isinstance(tool_args, dict):
        raise ReadOnlyGuardError("tool arguments must be an object")
    action = tool_args.get("action")
    if not isinstance(action, str) or action not in _ACTION_SPECS:
        raise ReadOnlyGuardError("action must name an available read-only KB operation")
    spec = _ACTION_SPECS[action]
    allowed_fields = {"action", *spec["required"], *spec["optional"]}
    unexpected = sorted(set(tool_args) - allowed_fields)
    if unexpected:
        raise ReadOnlyGuardError(f"fields are not valid for {action}: {', '.join(unexpected)}")
    for field in spec["required"]:
        _required_text(tool_args, field, max_length=64 if field.endswith("_id") else 4096)

    argv = [action]
    if action in {"search", "context"}:
        argv.append(_required_text(tool_args, "query"))
    elif action in {"show", "evidence", "edges"}:
        argv.append(_uuid_argument(_required_text(tool_args, "claim_id", max_length=64)))
    elif action == "search-proposals":
        argv.append(_required_text(tool_args, "query"))
    elif action == "show-proposal":
        argv.append(_uuid_argument(_required_text(tool_args, "proposal_id", max_length=64)))

    if "status" in tool_args:
        argv.extend(["--status", _enum_text(tool_args, "status", PROPOSAL_STATUSES)])
    if "limit" in tool_args:
        argv.extend(["--limit", str(_bounded_integer(tool_args, "limit"))])
    if "context_limit" in tool_args:
        argv.extend(["--context-limit", str(_bounded_integer(tool_args, "context_limit"))])
    if "format" in tool_args:
        argv.extend(["--format", _enum_text(tool_args, "format", ("markdown", "json"))])
    return argv


def _canonical_sha256(value: Any) -> str:
    encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
    return hashlib.sha256(encoded).hexdigest()


def _verified_kb_tool(temp_profile: Path, expected_sha256: str) -> Path:
    candidate = temp_profile / "bin" / "kb_tool.py"
    if (
        temp_profile.is_symlink()
        or (temp_profile / "bin").is_symlink()
        or candidate.is_symlink()
        or not candidate.is_file()
    ):
        raise ReadOnlyGuardError("temporary-profile KB tool must be a regular non-symlink file")
    resolved = candidate.resolve(strict=True)
    if resolved.parent != (temp_profile / "bin").resolve(strict=True):
        raise ReadOnlyGuardError("temporary-profile KB tool escaped the profile bin directory")
    observed_sha256 = hashlib.sha256(resolved.read_bytes()).hexdigest()
    if observed_sha256 != expected_sha256:
        raise ReadOnlyGuardError("temporary-profile KB tool hash does not match the frozen source")
    return resolved


def _terminate_process_group(proc: subprocess.Popen[str]) -> None:
    try:
        os.killpg(proc.pid, signal.SIGTERM)
    except OSError:
        pass
    try:
        proc.communicate(timeout=BRIDGE_TERMINATE_GRACE_SECONDS)
        return
    except subprocess.TimeoutExpired:
        pass
    try:
        os.killpg(proc.pid, signal.SIGKILL)
    except OSError:
        pass
    try:
        proc.communicate(timeout=BRIDGE_TERMINATE_GRACE_SECONDS)
    except (OSError, subprocess.TimeoutExpired):
        pass


def restricted_structured_read_handler(
    kb_tool: Path,
    temp_profile: Path,
    tool_args: dict[str, Any],
    *,
    allow_kb_reads: bool,
    expected_kb_tool_sha256: str,
    **_kwargs: Any,
) -> str:
    """Execute one typed read without exposing a shell command surface."""

    if not allow_kb_reads:
        return json.dumps(
            {
                "output": "",
                "exit_code": 126,
                "error": "database tools are disabled for the no-DB ablation",
                "error_category": "kb_reads_disabled",
            }
        )
    try:
        exact_kb_tool = _verified_kb_tool(temp_profile, expected_kb_tool_sha256)
        if kb_tool != exact_kb_tool:
            raise ReadOnlyGuardError("configured KB tool is not the frozen temporary-profile tool")
        argv = [str(Path(sys.executable).resolve(strict=True)), str(exact_kb_tool), "--local"]
        argv.extend(structured_read_argv(tool_args))
    except (OSError, ReadOnlyGuardError) as exc:
        return json.dumps(
            {
                "output": "",
                "exit_code": 126,
                "error": str(exc),
                "error_category": "invalid_read_arguments",
            }
        )
    child_environment = {
        "HOME": str(temp_profile),
        "HERMES_HOME": str(temp_profile),
        "PATH": f"{temp_profile / 'bin'}:{os.environ.get('PATH', '')}",
        "TELEO_KB_MODE": "local",
    }
    try:
        proc = subprocess.Popen(
            argv,
            cwd=temp_profile,
            env=child_environment,
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            start_new_session=True,
        )
    except OSError as exc:
        return json.dumps(
            {
                "output": "",
                "exit_code": 126,
                "error": f"bridge launch failed: {type(exc).__name__}",
                "error_category": "bridge_launch",
            }
        )
    try:
        stdout, stderr = proc.communicate(timeout=BRIDGE_TIMEOUT_SECONDS)
    except subprocess.TimeoutExpired:
        _terminate_process_group(proc)
        return json.dumps(
            {
                "output": "",
                "exit_code": 124,
                "error": f"bridge command exceeded {BRIDGE_TIMEOUT_SECONDS}s",
                "error_category": "bridge_timeout",
            }
        )
    return json.dumps(
        {
            "output": stdout,
            "exit_code": proc.returncode,
            "error": stderr or None,
            "error_category": None if proc.returncode == 0 and not stderr else "bridge_exit",
        }
    )


def install_read_only_tool_surface(
    temp_profile: Path,
    *,
    allow_kb_reads: bool,
    expected_kb_tool_sha256: str,
) -> dict[str, Any]:
    """Replace Hermes' registry with skills plus one structured KB read tool."""

    import tools.skills_tool  # noqa: F401 - registers the two skill readers
    import toolsets
    from hermes_cli import tools_config
    from tools.registry import registry

    toolset_name = "leo-oos-kb-readonly" if allow_kb_reads else "leo-oos-no-db-ablation"
    allowed_tools = ["skills_list", "skill_view", STRUCTURED_READ_TOOL_NAME]
    toolsets.TOOLSETS[toolset_name] = {
        "description": "Fail-closed Leo OOS tool surface",
        "tools": allowed_tools,
        "includes": [],
    }
    tools_config._get_platform_tools = lambda *_args, **_kwargs: {toolset_name}
    missing = sorted(name for name in ("skills_list", "skill_view") if name not in registry._tools)
    if missing:
        raise ReadOnlyGuardError(f"required tools are not registered: {missing}")
    allowed_entries = {name: registry._tools[name] for name in ("skills_list", "skill_view")}
    registry._tools.clear()
    registry._tools.update(allowed_entries)
    kb_tool = _verified_kb_tool(temp_profile, expected_kb_tool_sha256)
    registry.register(
        name=STRUCTURED_READ_TOOL_NAME,
        toolset=toolset_name,
        schema=STRUCTURED_READ_TOOL_SCHEMA,
        handler=lambda tool_args, **kwargs: restricted_structured_read_handler(
            kb_tool,
            temp_profile,
            tool_args,
            allow_kb_reads=allow_kb_reads,
            expected_kb_tool_sha256=expected_kb_tool_sha256,
            **kwargs,
        ),
        description=STRUCTURED_READ_TOOL_SCHEMA["description"],
    )
    definitions = registry.get_definitions({STRUCTURED_READ_TOOL_NAME}, quiet=True)
    if len(definitions) != 1:
        raise ReadOnlyGuardError("structured KB read tool definition is not model-visible")
    definition_sha256 = _canonical_sha256(definitions[0])
    return {
        "mode": "read_only_kb" if allow_kb_reads else "no_db_ablation",
        "allowed_tools": allowed_tools,
        "actual_registry_tools": sorted(registry._tools),
        "read_only_bridge_commands": sorted(READ_ONLY_BRIDGE_COMMANDS) if allow_kb_reads else [],
        "send_message_tool_enabled": "send_message" in registry._tools,
        "shell_tool_enabled": "terminal" in registry._tools,
        "structured_read_tool_name": STRUCTURED_READ_TOOL_NAME,
        "structured_read_tool_definition_sha256": definition_sha256,
        "structured_read_tool_restricted_to_frozen_kb_tool": True,
        "kb_tool_sha256": expected_kb_tool_sha256,
        "bridge_timeout_seconds": BRIDGE_TIMEOUT_SECONDS,
        "mutating_bridge_commands_exposed": False,
        "provider_credentials_forwarded_to_kb_tool": False,
    }


def reassert_read_only_tool_surface(
    temp_profile: Path,
    *,
    expected_kb_tool_sha256: str,
    expected_definition_sha256: str,
) -> dict[str, Any]:
    """Remove the runner's known additions before the first model turn."""

    from tools.registry import registry

    _verified_kb_tool(temp_profile, expected_kb_tool_sha256)
    exact_tools = {"skills_list", "skill_view", STRUCTURED_READ_TOOL_NAME}
    initial_tools = set(registry._tools)
    missing = sorted(exact_tools - initial_tools)
    unexpected = sorted(initial_tools - exact_tools)
    unsupported = sorted(set(unexpected) - KNOWN_RUNNER_ADDED_TOOLS)
    if missing or unsupported:
        raise ReadOnlyGuardError(
            f"post-runner registry mismatch; missing={missing}; unsupported additions={unsupported}"
        )
    retained_entries = {name: registry._tools[name] for name in exact_tools}
    registry._tools.clear()
    registry._tools.update(retained_entries)
    definitions = registry.get_definitions({STRUCTURED_READ_TOOL_NAME}, quiet=True)
    if len(definitions) != 1:
        raise ReadOnlyGuardError("structured KB read tool definition disappeared after runner initialization")
    definition_sha256 = _canonical_sha256(definitions[0])
    if definition_sha256 != expected_definition_sha256:
        raise ReadOnlyGuardError("structured KB read tool schema changed after runner initialization")
    final_tools = sorted(registry._tools)
    expected_final_tools = sorted(exact_tools)
    return {
        "initial_registry_tools": sorted(initial_tools),
        "removed_known_runner_tools": unexpected,
        "actual_registry_tools": final_tools,
        "structured_read_tool_definition_sha256": definition_sha256,
        "final_surface_matches_preexecution": final_tools == expected_final_tools,
    }
