"""Project-local Claude Code hook install / status / uninstall (P10D.14 S2).

Turns the S1 ``claude_hook`` adapter into a usable project-local install
surface. Writes a merge-preserving AgentVeil-managed ``PreToolUse`` entry into a
project ``.claude/settings.json`` without disturbing unrelated settings or
hooks, reports a bounded status, and removes only the managed entry.

Scope for S2:
- Project scope only (``--project``). No user/global Claude config.
- No approval round-trip (S3), no docs/release (S4/S5).
- Status language is scoped strictly to Claude Code project hooks; it makes no
  host-wide protection claim.

The managed hook entry is identified by ``AGENTVEIL_HOOK_MARKER`` appearing in
its command string. We do not add non-schema marker keys to the settings JSON,
so an unrelated tool parsing the file will not choke on AgentVeil fields.
"""

from __future__ import annotations

import json
import shlex
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


# Substring that identifies an AgentVeil-managed hook command. The installed
# command generated by this module invokes this module path, so its presence in
# a command string marks the entry as ours for idempotent upsert and removal.
AGENTVEIL_HOOK_MARKER = "agentveil_mcp_proxy.claude_hook"

# Tool classes the hook intercepts: Claude built-in mutators + any MCP tool.
HOOK_MATCHER = "Write|Edit|MultiEdit|NotebookEdit|Bash|mcp__.*"

# Tool-class labels surfaced in bounded status (no regex internals leaked).
MATCHED_TOOL_CLASSES = ("Write", "Edit", "MultiEdit", "NotebookEdit", "Bash", "mcp__*")


class HookSetupError(RuntimeError):
    """Raised when install/uninstall cannot proceed safely."""


# ----- path helpers ---------------------------------------------------------


def project_claude_dir(project_dir: Path) -> Path:
    return Path(project_dir) / ".claude"


def project_settings_path(project_dir: Path) -> Path:
    return project_claude_dir(project_dir) / "settings.json"


def project_evidence_path(project_dir: Path) -> Path:
    """Project-local, AgentVeil-scoped evidence file."""
    return project_claude_dir(project_dir) / "agentveil" / "evidence.jsonl"


# ----- managed hook entry ---------------------------------------------------


def build_hook_command(*, python: str, evidence_path: Path) -> str:
    """Build the PreToolUse command string that runs the installed module.

    The interpreter path and the evidence path are shell-quoted with
    ``shlex.quote`` so paths containing spaces or quotes do not break the
    command Claude Code runs via the shell. The ``-m <module>`` token stays
    literal so status detection (`-m agentveil_mcp_proxy.claude_hook`) and the
    ``--evidence-path`` marker remain matchable.
    """
    return (
        f"{shlex.quote(python)} -m {AGENTVEIL_HOOK_MARKER} "
        f"--evidence-path {shlex.quote(str(evidence_path))}"
    )


def build_managed_hook_entry(*, python: str, evidence_path: Path) -> dict[str, Any]:
    """Build the AgentVeil-managed PreToolUse group entry."""
    return {
        "matcher": HOOK_MATCHER,
        "hooks": [
            {
                "type": "command",
                "command": build_hook_command(python=python, evidence_path=evidence_path),
            }
        ],
    }


def _entry_is_agentveil_managed(entry: Any) -> bool:
    """True if a PreToolUse group entry is an AgentVeil-managed one."""
    if not isinstance(entry, dict):
        return False
    hooks = entry.get("hooks")
    if not isinstance(hooks, list):
        return False
    for hook in hooks:
        if isinstance(hook, dict) and AGENTVEIL_HOOK_MARKER in str(hook.get("command", "")):
            return True
    return False


def _command_points_to_module(entry: Any) -> bool:
    """True if the managed entry's command invokes this module via ``-m``."""
    if not isinstance(entry, dict):
        return False
    for hook in entry.get("hooks", []) or []:
        if isinstance(hook, dict):
            command = str(hook.get("command", ""))
            if f"-m {AGENTVEIL_HOOK_MARKER}" in command:
                return True
    return False


def _hook_is_agentveil(hook: Any) -> bool:
    """True for a single inner hook object whose command is AgentVeil-managed."""
    return isinstance(hook, dict) and AGENTVEIL_HOOK_MARKER in str(hook.get("command", ""))


def _strip_managed_from_group(group: Any) -> tuple[Any, int]:
    """Remove only AgentVeil inner hooks from one PreToolUse group.

    Returns ``(group_or_None, removed_count)``. Operates at the inner-hook
    object level so a group that mixes a user hook and an AgentVeil hook keeps
    the user hook. If stripping empties the group's ``hooks`` list, the group
    is dropped (returns ``None``); otherwise the group is returned with only
    the non-AgentVeil hooks preserved.
    """
    if not isinstance(group, dict):
        return group, 0
    hooks = group.get("hooks")
    if not isinstance(hooks, list):
        return group, 0
    kept_hooks = [h for h in hooks if not _hook_is_agentveil(h)]
    removed = len(hooks) - len(kept_hooks)
    if removed == 0:
        return group, 0
    if not kept_hooks:
        return None, removed
    new_group = dict(group)
    new_group["hooks"] = kept_hooks
    return new_group, removed


# ----- settings IO (invalid JSON stops writes) -------------------------------


def load_settings(settings_path: Path) -> dict[str, Any]:
    """Load settings JSON. Returns {} if absent.

    Raises HookSetupError on invalid JSON so callers stop WITHOUT rewriting a
    file they could not parse.
    """
    if not settings_path.exists():
        return {}
    try:
        raw = settings_path.read_text(encoding="utf-8")
    except OSError as exc:
        raise HookSetupError(f"cannot read {settings_path}: {exc}") from exc
    if not raw.strip():
        return {}
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise HookSetupError(
            f"existing {settings_path} is not valid JSON; refusing to overwrite ({exc})"
        ) from exc
    if not isinstance(data, dict):
        raise HookSetupError(f"existing {settings_path} must be a JSON object")
    return data


def _write_settings(settings_path: Path, data: dict[str, Any]) -> None:
    settings_path.parent.mkdir(parents=True, exist_ok=True)
    settings_path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")


def _pre_tool_use_list(settings: dict[str, Any]) -> list[Any]:
    hooks = settings.get("hooks")
    if not isinstance(hooks, dict):
        return []
    pre = hooks.get("PreToolUse")
    return pre if isinstance(pre, list) else []


# ----- results --------------------------------------------------------------


@dataclass(frozen=True)
class InstallResult:
    settings_path: Path
    evidence_path: Path
    created_settings: bool
    replaced_existing_managed: bool
    reload_required: bool = True


@dataclass(frozen=True)
class UninstallResult:
    settings_path: Path
    removed_entries: int
    settings_existed: bool
    reload_required: bool


@dataclass
class StatusResult:
    scope: str = "project"
    status: str = "unsafe"          # protected | advisory | unsafe
    state: str = "missing"          # installed | missing | stale | invalid-json
    settings_present: bool = False
    managed_hook_present: bool = False
    hook_command_points_to_module: bool = False
    evidence_path_configured: bool = False
    reload_required: bool = False
    matched_tool_classes: tuple[str, ...] = field(default_factory=lambda: MATCHED_TOOL_CLASSES)
    notes: tuple[str, ...] = ()

    def to_bounded_dict(self) -> dict[str, Any]:
        """Bounded JSON. No absolute paths, prompts, payloads, or commands."""
        return {
            "scope": self.scope,
            "status": self.status,
            "state": self.state,
            "settings_present": self.settings_present,
            "managed_hook_present": self.managed_hook_present,
            "hook_command_points_to_module": self.hook_command_points_to_module,
            "evidence_path_configured": self.evidence_path_configured,
            "reload_required": self.reload_required,
            "matched_tool_classes": list(self.matched_tool_classes),
            "notes": list(self.notes),
        }


# ----- install / uninstall / status -----------------------------------------


def install_hook(
    project_dir: Path,
    *,
    python: str | None = None,
    evidence_path: Path | None = None,
) -> InstallResult:
    """Install/upsert the AgentVeil PreToolUse hook in project settings.

    Idempotent and merge-preserving: preserves unrelated settings and hooks, removes
    any prior AgentVeil-managed entries, and appends exactly one fresh managed
    entry. Fails closed (raises) on invalid existing JSON without rewriting.
    """
    project_dir = Path(project_dir)
    settings_path = project_settings_path(project_dir)
    resolved_python = python or sys.executable
    resolved_evidence = evidence_path or project_evidence_path(project_dir)

    created = not settings_path.exists()
    settings = load_settings(settings_path)  # raises on invalid JSON

    hooks = settings.get("hooks")
    if not isinstance(hooks, dict):
        hooks = {}
    pre = hooks.get("PreToolUse")
    if not isinstance(pre, list):
        pre = []

    # Strip any prior AgentVeil inner hooks (idempotent upsert) at the
    # hook-object level, preserving user hooks that share a group.
    cleaned: list[Any] = []
    removed_total = 0
    for group in pre:
        new_group, removed = _strip_managed_from_group(group)
        removed_total += removed
        if new_group is not None:
            cleaned.append(new_group)
    replaced = removed_total > 0

    cleaned.append(build_managed_hook_entry(python=resolved_python, evidence_path=resolved_evidence))
    hooks["PreToolUse"] = cleaned
    settings["hooks"] = hooks

    _write_settings(settings_path, settings)

    return InstallResult(
        settings_path=settings_path,
        evidence_path=resolved_evidence,
        created_settings=created,
        replaced_existing_managed=replaced,
    )


def uninstall_hook(project_dir: Path) -> UninstallResult:
    """Remove only AgentVeil-managed PreToolUse entries. Idempotent."""
    project_dir = Path(project_dir)
    settings_path = project_settings_path(project_dir)

    if not settings_path.exists():
        return UninstallResult(
            settings_path=settings_path,
            removed_entries=0,
            settings_existed=False,
            reload_required=False,
        )

    settings = load_settings(settings_path)  # raises on invalid JSON
    hooks = settings.get("hooks")
    if not isinstance(hooks, dict):
        return UninstallResult(settings_path, 0, True, reload_required=False)

    pre = hooks.get("PreToolUse")
    if not isinstance(pre, list):
        return UninstallResult(settings_path, 0, True, reload_required=False)

    # Remove only AgentVeil inner hooks; preserve user hooks in mixed groups.
    cleaned: list[Any] = []
    removed = 0
    for group in pre:
        new_group, removed_here = _strip_managed_from_group(group)
        removed += removed_here
        if new_group is not None:
            cleaned.append(new_group)

    if removed == 0:
        return UninstallResult(settings_path, 0, True, reload_required=False)

    # Clean only containers we may have generated, and only when empty.
    if cleaned:
        hooks["PreToolUse"] = cleaned
    else:
        hooks.pop("PreToolUse", None)
    if not hooks:
        settings.pop("hooks", None)

    _write_settings(settings_path, settings)
    return UninstallResult(
        settings_path=settings_path,
        removed_entries=removed,
        settings_existed=True,
        reload_required=True,
    )


def status_hook(project_dir: Path, *, evidence_path: Path | None = None) -> StatusResult:
    """Return bounded project-hook status.

    Status semantics (scoped to Claude Code project hooks only):
    - ``protected``: managed hook present, command points to the installed
      module, AND local evidence proves the hook has actually fired
      (project evidence file exists and is non-empty). Config correctness
      alone is NOT enough to claim protected — we cannot observe whether the
      running Claude Code process reloaded the config, so protected requires
      real run evidence.
    - ``advisory``: managed hook present and command points to the module, but
      no firing evidence yet (reload/restart likely required) OR the command
      is a stale form that does not invoke the installed module.
    - ``unsafe``: no settings, no managed hook, or invalid JSON.

    This deliberately does not return ``protected`` for a freshly installed
    hook with ``reload_required=true`` — that would overclaim protection before
    the hook has run.
    """
    project_dir = Path(project_dir)
    settings_path = project_settings_path(project_dir)
    result = StatusResult()

    if not settings_path.exists():
        result.settings_present = False
        result.state = "missing"
        result.status = "unsafe"
        result.notes = ("no project .claude/settings.json; hook not installed",)
        return result

    result.settings_present = True
    try:
        settings = load_settings(settings_path)
    except HookSetupError:
        result.state = "invalid-json"
        result.status = "unsafe"
        result.notes = ("project settings.json is not valid JSON",)
        return result

    pre = _pre_tool_use_list(settings)
    managed = [e for e in pre if _entry_is_agentveil_managed(e)]
    if not managed:
        result.managed_hook_present = False
        result.state = "missing"
        result.status = "unsafe"
        result.notes = ("no AgentVeil-managed PreToolUse hook found",)
        return result

    result.managed_hook_present = True
    points_to_module = any(_command_points_to_module(e) for e in managed)
    result.hook_command_points_to_module = points_to_module
    result.evidence_path_configured = any(
        "--evidence-path" in str(h.get("command", ""))
        for e in managed
        for h in (e.get("hooks") or [])
        if isinstance(h, dict)
    )

    if not points_to_module:
        result.state = "stale"
        result.status = "advisory"
        result.reload_required = True
        result.notes = (
            "managed hook present but command does not invoke the installed module; reinstall",
        )
        return result

    # Config is correct. Only claim protected with real firing evidence that
    # post-dates the current install. A leftover evidence file from a previous
    # install (settings.json is rewritten on each install) must NOT count, or
    # a reinstall would falsely report protected before the new hook has run.
    result.state = "installed"
    ev_path = Path(evidence_path) if evidence_path is not None else project_evidence_path(project_dir)
    try:
        settings_mtime = settings_path.stat().st_mtime
        fired = (
            ev_path.exists()
            and ev_path.stat().st_size > 0
            and ev_path.stat().st_mtime > settings_mtime
        )
    except OSError:
        fired = False

    if fired:
        result.status = "protected"
        result.reload_required = False
        result.notes = (
            "managed hook installed and local evidence shows it has fired",
        )
    else:
        result.status = "advisory"
        result.reload_required = True
        result.notes = (
            "managed hook installed but not yet proven to fire; restart Claude Code "
            "and run a tool to confirm (no evidence recorded yet)",
        )
    return result


# ----- project .mcp.json route (written by `connect claude_code`) ------------

# mcpServers key that `connect claude_code` writes into the project .mcp.json.
AGENTVEIL_MCP_SERVER_NAME = "agentveil-mcp-proxy"


def project_mcp_config_path(project_dir: Path) -> Path:
    return Path(project_dir) / ".mcp.json"


def load_mcp_config(path: Path) -> dict[str, Any]:
    """Load a project .mcp.json. Returns {} if absent. Refuses bad JSON."""
    if not path.exists():
        return {}
    try:
        raw = path.read_text(encoding="utf-8")
    except OSError as exc:
        raise HookSetupError(f"cannot read {path}: {exc}") from exc
    if not raw.strip():
        return {}
    try:
        data = json.loads(raw)
    except json.JSONDecodeError as exc:
        raise HookSetupError(
            f"existing {path} is not valid JSON; refusing to overwrite ({exc})"
        ) from exc
    if not isinstance(data, dict):
        raise HookSetupError(f"existing {path} must be a JSON object")
    return data


def mcp_route_present(project_dir: Path) -> bool:
    """True if the AgentVeil MCP route is configured in the project .mcp.json."""
    try:
        data = load_mcp_config(project_mcp_config_path(project_dir))
    except HookSetupError:
        return False
    servers = data.get("mcpServers")
    return isinstance(servers, dict) and AGENTVEIL_MCP_SERVER_NAME in servers


@dataclass(frozen=True)
class RemoveMcpRouteResult:
    config_path: Path
    removed: bool
    config_existed: bool


def remove_mcp_route(project_dir: Path) -> RemoveMcpRouteResult:
    """Remove only the AgentVeil mcpServers entry from project .mcp.json.

    Preserves unrelated MCP servers and unrelated keys. Idempotent. Invalid
    JSON raises without rewriting.
    """
    path = project_mcp_config_path(project_dir)
    if not path.exists():
        return RemoveMcpRouteResult(config_path=path, removed=False, config_existed=False)

    data = load_mcp_config(path)  # raises on invalid JSON
    servers = data.get("mcpServers")
    if not isinstance(servers, dict) or AGENTVEIL_MCP_SERVER_NAME not in servers:
        return RemoveMcpRouteResult(config_path=path, removed=False, config_existed=True)

    servers.pop(AGENTVEIL_MCP_SERVER_NAME, None)
    if servers:
        data["mcpServers"] = servers
    else:
        data.pop("mcpServers", None)
    path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
    return RemoveMcpRouteResult(config_path=path, removed=True, config_existed=True)


# ----- aggregated connector status ------------------------------------------


def connector_status(
    project_dir: Path,
    *,
    evidence_path: Path | None = None,
    proxy_route_present: bool = False,
) -> dict[str, Any]:
    """Bounded one-command connector status.

    Aggregates the project-local hook status, the .mcp.json route, and a
    caller-supplied proxy-route flag into a single product-facing summary. No
    absolute paths, prompts, secrets, or payloads.
    """
    hook = status_hook(project_dir, evidence_path=evidence_path)
    route_present = mcp_route_present(project_dir)

    if hook.state == "invalid-json":
        hook_state = "stale"
    elif not hook.managed_hook_present:
        hook_state = "missing"
    elif hook.state == "stale":
        hook_state = "stale"
    elif hook.status == "protected":
        hook_state = "fired"
    else:
        hook_state = "installed"

    configured = hook.managed_hook_present and route_present and proxy_route_present
    if not configured or hook.state == "invalid-json":
        status = "unsafe"
    elif hook.status == "protected":
        status = "protected"
    else:
        status = "advisory"

    if not hook.managed_hook_present or not route_present or not proxy_route_present:
        restart_required: bool | None = None  # setup incomplete; reload not the issue yet
        next_step = "run `agentveil-mcp-proxy setup claude-code --yes`"
    elif hook.status == "protected":
        restart_required = False
        next_step = "connector active; nothing to do"
    else:
        restart_required = True
        next_step = "restart Claude Code, then run `agentveil-mcp-proxy setup status`"

    return {
        "scope": "project",
        "status": status,
        "hook": hook_state,
        "mcp_route": "configured" if route_present else "missing",
        "proxy_route": "configured" if proxy_route_present else "missing",
        "restart_required": restart_required,
        "matched_tool_classes": list(MATCHED_TOOL_CLASSES),
        "next_step": next_step,
    }


__all__ = [
    "AGENTVEIL_HOOK_MARKER",
    "AGENTVEIL_MCP_SERVER_NAME",
    "HOOK_MATCHER",
    "MATCHED_TOOL_CLASSES",
    "HookSetupError",
    "InstallResult",
    "RemoveMcpRouteResult",
    "StatusResult",
    "UninstallResult",
    "build_hook_command",
    "build_managed_hook_entry",
    "connector_status",
    "install_hook",
    "load_mcp_config",
    "load_settings",
    "mcp_route_present",
    "project_evidence_path",
    "project_mcp_config_path",
    "project_settings_path",
    "remove_mcp_route",
    "status_hook",
    "uninstall_hook",
]
