"""Agent detection and hook registration logic.

Detects which AI coding agents are installed on the system and
registers/unregisters AvaKill hooks in each agent's configuration.
"""

from __future__ import annotations

import json
import os
import platform
import shutil
import subprocess
import sys
from collections.abc import Callable
from pathlib import Path


def _cursor_installed() -> bool:
    """Check if Cursor is installed."""
    if Path.home().joinpath(".cursor").is_dir():
        return True
    if platform.system() == "Darwin":
        return Path("/Applications/Cursor.app").exists()
    return shutil.which("cursor") is not None


def _windsurf_installed() -> bool:
    """Check if Windsurf is installed."""
    if platform.system() == "Darwin":
        return Path("/Applications/Windsurf.app").exists()
    return (
        shutil.which("windsurf") is not None
        or Path.home().joinpath(".codeium", "windsurf").is_dir()
    )


AGENT_DETECTORS: dict[str, Callable[[], bool]] = {
    "claude-code": lambda: (
        Path.home().joinpath(".claude").is_dir() or shutil.which("claude") is not None
    ),
    "gemini-cli": lambda: (
        Path.home().joinpath(".gemini").is_dir() or shutil.which("gemini") is not None
    ),
    "cursor": _cursor_installed,
    "windsurf": _windsurf_installed,
    "openclaw": lambda: (
        Path.home().joinpath(".openclaw").is_dir() or shutil.which("openclaw") is not None
    ),
    "aider": lambda: shutil.which("aider") is not None,
    "cline": lambda: Path.cwd().joinpath(".vscode", "cline_mcp_settings.json").is_file(),
    "continue": lambda: (
        Path.cwd().joinpath(".continue", "config.json").is_file()
        or Path.home().joinpath(".continue").is_dir()
    ),
    "claude-desktop": lambda: any(
        p.is_dir()
        for p in [
            Path.home() / "Library" / "Application Support" / "Claude",
            Path.home() / ".config" / "claude",
            Path.home() / "AppData" / "Roaming" / "Claude",
        ]
    ),
    "swe-agent": lambda: shutil.which("sweagent") is not None,
    "openai-codex": lambda: (
        Path.home().joinpath(".codex").is_dir() or shutil.which("codex") is not None
    ),
    "kiro": lambda: Path.home().joinpath(".kiro").is_dir() or shutil.which("kiro") is not None,
    "amp": lambda: (
        Path.home().joinpath(".config", "amp").is_dir() or shutil.which("amp") is not None
    ),
}


def detect_agents() -> list[str]:
    """Return list of installed agent names."""
    return [name for name, check in AGENT_DETECTORS.items() if check()]


# ---------------------------------------------------------------------------
# Hook installation
# ---------------------------------------------------------------------------

_HOOK_COMMAND = "avakill-hook-{slug}"

# Agent-specific settings paths and hook shapes.
_AGENT_CONFIG: dict[str, dict[str, object]] = {
    "claude-code": {
        "config_path": Path.home() / ".claude" / "settings.json",
        "event": "PreToolUse",
        "hook_entry": lambda cmd: {
            "matcher": "",
            "hooks": [{"type": "command", "command": cmd}],
        },
    },
    "gemini-cli": {
        "config_path": Path.home() / ".gemini" / "settings.json",
        "event": "BeforeTool",
        "hook_entry": lambda cmd: {
            "matcher": ".*",
            "hooks": [{"type": "command", "command": cmd}],
        },
    },
    "cursor": {
        "config_path": Path.home() / ".cursor" / "hooks.json",
        "events": [
            "beforeShellExecution",
            "beforeMCPExecution",
            "beforeReadFile",
            "preToolUse",
        ],
        "hook_entry": lambda cmd: {"command": cmd},
    },
    "windsurf": {
        "config_path": Path.home() / ".codeium" / "windsurf" / "hooks.json",
        "event": "pre_run_command",
        "hook_entry": lambda cmd: {"command": cmd, "show_output": True},
    },
    "openai-codex": {
        "config_path": Path.home() / ".codex" / "config.toml",
        "event": "before_tool_use",
        "pending_upstream": True,
        "hook_entry": lambda cmd: {"command": cmd},
    },
    "kiro": {
        "config_path": Path.home() / ".kiro" / "agents" / "avakill.json",
        "event": "preToolUse",
        "hook_entry": lambda cmd: {"command": cmd},
    },
    "amp": {
        "config_path": Path.home() / ".config" / "amp" / "settings.json",
        "event": "permissions",
        "hook_entry": lambda cmd: {
            "tool": "*",
            "action": "delegate",
            "to": cmd,
        },
    },
    "openclaw": {
        "config_path": Path.home() / ".openclaw" / "openclaw.json",
        "plugin_install": "openclaw plugins install avakill-openclaw",
        "plugin_uninstall": "openclaw plugins uninstall avakill-openclaw",
        "plugin_check": "avakill-openclaw",
    },
}


def _resolve_config_path(cfg: dict[str, object]) -> Path:
    """Return the config path, calling it if it's a callable (lazy eval)."""
    raw = cfg["config_path"]
    if callable(raw):
        return Path(raw())
    assert isinstance(raw, Path)
    return raw


def _hook_command(agent: str) -> str:
    """Return the absolute path to the console-script for an agent.

    Resolves via ``shutil.which()`` first.  Falls back to looking in the
    same ``bin/`` directory as the running Python interpreter (handles
    virtualenvs where the scripts aren't on ``$PATH``).  If neither
    works, returns the bare command name as a last resort.
    """
    slug = agent.replace("-", "-").replace(" ", "-")
    bare = _HOOK_COMMAND.format(slug=slug)

    # 1. Try PATH
    found = shutil.which(bare)
    if found:
        return found

    # 2. Try sibling of sys.executable (same venv).
    # Use .parent (not .resolve().parent) so symlinked venvs stay in
    # the venv bin/ dir instead of following into e.g. Homebrew Cellar.
    bin_dir = Path(sys.executable).parent
    candidate = bin_dir / bare
    if candidate.is_file():
        return str(candidate)

    # 3. Bare name — will fail at runtime but install_hook warns
    return bare


def _is_avakill_entry(entry: dict[str, object]) -> bool:
    """Check if a hook entry belongs to AvaKill."""
    # Check command field directly.
    cmd = entry.get("command", "")
    if isinstance(cmd, str) and "avakill" in cmd:
        return True
    # Check "to" field (Amp's delegation shape).
    to = entry.get("to", "")
    if isinstance(to, str) and "avakill" in to:
        return True
    # Check nested hooks list (Claude Code / Gemini CLI shape).
    hooks = entry.get("hooks", [])
    if isinstance(hooks, list):
        for h in hooks:
            if isinstance(h, dict) and "avakill" in str(h.get("command", "")):
                return True
    return False


class HookInstallResult:
    """Result of a hook installation with optional warnings."""

    def __init__(self, config_path: Path, command: str) -> None:
        self.config_path = config_path
        self.command = command
        self.warnings: list[str] = []
        self.smoke_test_passed: bool | None = None

    @property
    def path(self) -> Path:
        return self.config_path


def install_hook(agent: str, config_path: Path | None = None) -> HookInstallResult:
    """Register AvaKill hook in an agent's config.

    Args:
        agent: Agent name (e.g. "claude-code").
        config_path: Override the default config path (useful for testing).

    Returns:
        A :class:`HookInstallResult` with the config path, resolved
        command, and any warnings.  The result is truthy and its
        ``.config_path`` / ``.path`` attribute gives the path (backwards
        compatible with code that treats the return as a ``Path``).

    Raises:
        KeyError: If *agent* is not a known agent.
    """
    if agent not in _AGENT_CONFIG:
        raise KeyError(f"unknown agent: {agent!r}")

    cfg = _AGENT_CONFIG[agent]

    if cfg.get("pending_upstream"):
        path = config_path or _resolve_config_path(cfg)
        cmd = _hook_command(agent)
        result = HookInstallResult(config_path=path, command=cmd)
        result.warnings.append(
            "Codex CLI does not yet support pre-execution hooks. "
            "The AvaKill adapter is ready and will activate when upstream "
            "support ships. See https://github.com/openai/codex/issues/2109"
        )

        # Generate exec policy rules if a policy file is available.
        policy_path_env = os.environ.get("AVAKILL_POLICY")
        policy_file: Path | None = None
        if policy_path_env:
            policy_file = Path(policy_path_env)
        else:
            # Auto-discover avakill.yaml / avakill.yml in cwd
            for name in ("avakill.yaml", "avakill.yml"):
                candidate = Path.cwd() / name
                if candidate.is_file():
                    policy_file = candidate
                    break

        if policy_file:
            from avakill.hooks.openai_codex import generate_codex_rules

            rules_path = Path.home() / ".codex" / "rules" / "avakill.rules"
            try:
                generate_codex_rules(policy_file, rules_path)
                result.warnings.append(
                    f"Generated exec policy rules at {rules_path} for shell command protection."
                )
            except Exception as exc:
                result.warnings.append(f"Failed to generate exec policy rules: {exc}")

        return result

    # OpenClaw uses a native plugin system — install via `openclaw plugins install`.
    if cfg.get("plugin_install"):
        path = config_path or _resolve_config_path(cfg)
        install_cmd = str(cfg["plugin_install"])
        result = HookInstallResult(config_path=path, command=install_cmd)

        if not shutil.which("openclaw"):
            result.warnings.append(
                "OpenClaw CLI not found on PATH. "
                "Install it first, then run: avakill hook install --agent openclaw"
            )
            return result

        try:
            proc = subprocess.run(
                install_cmd,
                shell=True,
                capture_output=True,
                text=True,
                timeout=30,
            )
            if proc.returncode != 0:
                stderr = proc.stderr.strip()
                result.warnings.append(
                    f"Plugin install returned exit code {proc.returncode}"
                    + (f": {stderr}" if stderr else "")
                )
            result.smoke_test_passed = proc.returncode == 0
        except (OSError, subprocess.TimeoutExpired) as exc:
            result.warnings.append(f"Plugin install failed: {exc}")
            result.smoke_test_passed = False

        return result

    path = config_path or _resolve_config_path(cfg)
    events: list[str] = cfg.get("events") or [cfg["event"]]  # type: ignore[assignment]
    make_entry: Callable[[str], dict[str, object]] = cfg["hook_entry"]  # type: ignore[assignment]

    cmd = _hook_command(agent)
    result = HookInstallResult(config_path=path, command=cmd)

    # Warn if we couldn't resolve an absolute path
    if "/" not in cmd and "\\" not in cmd:
        result.warnings.append(
            f"Could not find '{cmd}' on PATH or in the active Python environment. "
            f"The hook may fail silently. Try: pip install avakill"
        )

    entry = make_entry(cmd)

    # Load or create config.
    path.parent.mkdir(parents=True, exist_ok=True)
    data = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}

    # Cursor hooks.json requires a version field.
    if "events" in cfg:
        data.setdefault("version", 1)

    # Amp uses a different JSON structure: amp.permissions[] instead of hooks.event[]
    if agent == "amp":
        amp_cfg = data.setdefault("amp", {})
        perms = amp_cfg.setdefault("permissions", [])
        if not any(_is_avakill_entry(e) for e in perms):
            perms.append(entry)
    else:
        # Standard hooks.event[] structure — register for each event.
        hooks = data.setdefault("hooks", {})
        for event in events:
            event_hooks = hooks.setdefault(event, [])
            if not any(_is_avakill_entry(e) for e in event_hooks):
                event_hooks.append(entry)

    path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")

    # Smoke test: verify the hook binary actually runs
    result.smoke_test_passed = _smoke_test(cmd)
    if not result.smoke_test_passed:
        result.warnings.append(
            f"Smoke test failed: '{cmd}' did not execute successfully. "
            f"Hook calls will fail at runtime."
        )

    return result


def _smoke_test(cmd: str) -> bool:
    """Run a minimal invocation of the hook binary to verify it exists.

    Sends an empty stdin — the hook should fail with a parse error (exit 2)
    rather than "command not found" (exit 127).  Any exit code other than
    127 means the binary was found and executed.
    """
    try:
        proc = subprocess.run(
            cmd,
            shell=True,
            input="",
            capture_output=True,
            timeout=5,
        )
        # exit 127 = command not found; anything else = binary exists
        return proc.returncode != 127
    except (OSError, subprocess.TimeoutExpired):
        return False


def uninstall_hook(agent: str, config_path: Path | None = None) -> bool:
    """Remove AvaKill hook from an agent's config.

    Returns:
        ``True`` if a hook was removed, ``False`` otherwise.
    """
    if agent not in _AGENT_CONFIG:
        raise KeyError(f"unknown agent: {agent!r}")

    cfg = _AGENT_CONFIG[agent]

    # OpenClaw uses a native plugin system — uninstall via `openclaw plugins uninstall`.
    if cfg.get("plugin_uninstall"):
        uninstall_cmd = str(cfg["plugin_uninstall"])
        if not shutil.which("openclaw"):
            return False
        try:
            proc = subprocess.run(
                uninstall_cmd,
                shell=True,
                capture_output=True,
                text=True,
                timeout=30,
            )
            return proc.returncode == 0
        except (OSError, subprocess.TimeoutExpired):
            return False

    path = config_path or _resolve_config_path(cfg)
    events: list[str] = cfg.get("events") or [cfg["event"]]  # type: ignore[assignment]

    if not path.exists():
        return False

    data = json.loads(path.read_text(encoding="utf-8"))

    # Amp uses amp.permissions[] instead of hooks.event[]
    if agent == "amp":
        perms = data.get("amp", {}).get("permissions", [])
        original_len = len(perms)
        perms[:] = [e for e in perms if not _is_avakill_entry(e)]
        if len(perms) == original_len:
            return False
        data.setdefault("amp", {})["permissions"] = perms
    else:
        hooks = data.get("hooks", {})
        removed_any = False
        for event in events:
            event_hooks = hooks.get(event, [])
            original_len = len(event_hooks)
            event_hooks[:] = [e for e in event_hooks if not _is_avakill_entry(e)]
            if len(event_hooks) < original_len:
                removed_any = True
            hooks[event] = event_hooks
        if not removed_any:
            return False
        data["hooks"] = hooks

    path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
    return True


def list_installed_hooks() -> dict[str, bool]:
    """Return ``{agent: is_installed}`` for all known agents."""
    result: dict[str, bool] = {}
    for agent, cfg in _AGENT_CONFIG.items():
        # OpenClaw uses a native plugin system — check config for plugin entry.
        if cfg.get("plugin_check"):
            path = _resolve_config_path(cfg)
            installed = False
            if path.exists():
                try:
                    data = json.loads(path.read_text(encoding="utf-8"))
                    plugin_name = str(cfg["plugin_check"])
                    plugins = data.get("plugins", {}).get("entries", {})
                    if plugin_name in plugins:
                        entry = plugins[plugin_name]
                        installed = entry.get("enabled", True) if isinstance(entry, dict) else True
                except (json.JSONDecodeError, OSError):
                    pass
            result[agent] = installed
            continue

        path = _resolve_config_path(cfg)
        installed = False
        if path.exists():
            try:
                data = json.loads(path.read_text(encoding="utf-8"))
                # Amp uses amp.permissions[] instead of hooks.event[]
                if agent == "amp":
                    entries = data.get("amp", {}).get("permissions", [])
                else:
                    events: list[str] = cfg.get("events") or [cfg["event"]]  # type: ignore[assignment]
                    entries = []
                    for ev in events:
                        entries.extend(data.get("hooks", {}).get(ev, []))
                for entry in entries:
                    if _is_avakill_entry(entry):
                        installed = True
                        break
            except (json.JSONDecodeError, OSError):
                pass
        result[agent] = installed
    return result
