"""Tribunal pack installer.

Supports installing packs from:
- Registry name (e.g. ``python-strict``)
- Direct GitHub URL (e.g. ``https://github.com/org/my-tribunal-pack``)
- Local path (e.g. ``./local-pack/``)
"""

from __future__ import annotations

import hashlib
import json
import shutil
import subprocess
import tempfile
import urllib.request
from pathlib import Path
from typing import Any

# Path to the built-in registry, relative to this file's package root.
_REGISTRY_PATH = Path(__file__).parent.parent / "tribunal" / "registry.yaml"

# Directory where packs are stored on the user's machine.
_PACKS_DIR = Path.home() / ".tribunal" / "packs"

# Active hooks config file in the installed plugin.
_HOOKS_JSON = Path.home() / ".claude" / "tribunal" / "hooks" / "hooks.json"


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _load_registry() -> dict[str, Any]:
    """Load and parse the built-in registry YAML.

    Falls back to a minimal empty registry when PyYAML is not available so
    that the function never raises an import error in constrained environments.
    """
    if not _REGISTRY_PATH.exists():
        return {"version": 1, "packs": {}}

    raw = _REGISTRY_PATH.read_text(encoding="utf-8")

    try:
        import yaml  # type: ignore[import]

        return yaml.safe_load(raw) or {"version": 1, "packs": {}}
    except ImportError:
        pass

    # Minimal hand-rolled YAML parser — handles the simple ``key: value``
    # block-mapping structure used in registry.yaml without a dependency.
    import re

    registry: dict[str, Any] = {"version": 1, "packs": {}}
    current_pack: str | None = None

    for line in raw.splitlines():
        # version: 1  # noqa: ERA001
        m = re.match(r"^version:\s*(\d+)", line)
        if m:
            registry["version"] = int(m.group(1))
            continue

        # "  python-strict:"  # noqa: ERA001
        m = re.match(r"^  (\S+):\s*$", line)
        if m:
            current_pack = m.group(1)
            registry["packs"][current_pack] = {}
            continue

        # "    key: value"  # noqa: ERA001
        if current_pack:
            m = re.match(r"^    (\w+):\s*(.*)", line)
            if m:
                key, val = m.group(1), m.group(2).strip()
                if val in ("null", "~", ""):
                    val = None  # type: ignore[assignment]
                elif val.startswith('"') and val.endswith('"'):
                    val = val[1:-1]
                registry["packs"][current_pack][key] = val

    return registry


def _resolve_source(source: str) -> tuple[str, str]:
    """Return ``(kind, resolved)`` for *source*.

    Returns:
        ``("local", path_str)`` for local paths.
        ``("github", url_str)`` for GitHub URLs or registry names.
    """
    # Local path: starts with ./ ../ / or exists as a directory
    if source.startswith(("./", "../", "/")) or Path(source).is_dir():
        return ("local", str(Path(source).expanduser().resolve()))

    # Direct GitHub or HTTP URL
    if source.startswith(("https://", "http://", "git@")):
        return ("github", source)

    # Registry lookup
    registry = _load_registry()
    packs: dict[str, Any] = registry.get("packs", {})
    if source in packs:
        url: str | None = packs[source].get("url")
        if not url:
            raise ValueError(f"Registry pack '{source}' has no URL configured.")
        return ("github", url)

    raise ValueError(
        f"Unknown pack source: '{source}'. "
        "Use a registry name, GitHub URL, or local path."
    )


def _pack_name_from_url(url: str) -> str:
    """Derive a filesystem-safe pack name from a GitHub URL."""
    name = url.rstrip("/").split("/")[-1]
    # Strip .git suffix if present
    if name.endswith(".git"):
        name = name[:-4]
    return name


def _validate_pack_dir(pack_dir: Path) -> None:
    """Raise ``ValueError`` if *pack_dir* is not a valid tribunal pack."""
    manifest = pack_dir / "tribunal-pack.json"
    if not manifest.exists():
        raise ValueError(
            f"Invalid pack: missing 'tribunal-pack.json' in {pack_dir}"
        )

    has_hooks = (pack_dir / "hooks").is_dir() and any((pack_dir / "hooks").iterdir())
    has_rules = (pack_dir / "rules").is_dir() and any((pack_dir / "rules").iterdir())
    if not has_hooks and not has_rules:
        raise ValueError(
            f"Invalid pack: must contain at least one of 'hooks/' or 'rules/' directories in {pack_dir}"
        )


def _sha256_dir(directory: Path, exclude_patterns: list[str] | None = None) -> str:
    """Return a deterministic SHA-256 hex-digest over all files in *directory*.

    Parameters
    ----------
    directory:
        The directory to hash.
    exclude_patterns:
        List of file patterns to exclude (e.g., [".installed-hash"]).
    """
    if exclude_patterns is None:
        exclude_patterns = [".installed-hash"]

    h = hashlib.sha256()
    for file in sorted(directory.rglob("*")):
        if file.is_file():
            # Skip excluded patterns
            if any(file.name == pattern or file.match(pattern) for pattern in exclude_patterns):
                continue
            rel = file.relative_to(directory)
            h.update(str(rel).encode())
            h.update(file.read_bytes())
    return h.hexdigest()


def _compute_pack_hash(pack_dir: Path) -> str:
    """Compute a deterministic SHA-256 hash over all files in a pack directory.

    This function produces a deterministic hash over all files in the pack,
    sorted by path, hashing both the relative path and file content.

    Parameters
    ----------
    pack_dir:
        Path to the installed pack directory.

    Returns
    -------
    str
        SHA-256 hex digest of the pack contents.
    """
    return _sha256_dir(pack_dir)


def _get_installed_hash(pack_dir: Path) -> str | None:
    """Read the stored hash for an installed pack.

    Returns the hash from ``.installed-hash`` file if it exists, else None.
    """
    hash_file = pack_dir / ".installed-hash"
    if hash_file.exists():
        return hash_file.read_text(encoding="utf-8").strip()
    return None


def _save_installed_hash(pack_dir: Path, hash_value: str) -> None:
    """Save the computed hash to the pack directory."""
    hash_file = pack_dir / ".installed-hash"
    hash_file.write_text(hash_value, encoding="utf-8")


def _verify_installed_hash(pack_dir: Path) -> tuple[bool, str | None]:
    """Verify the installed pack against its stored hash.

    Returns
    -------
    tuple[bool, str | None]
        (is_valid, stored_or_computed_hash). If no stored hash exists, returns (True, None).
        If hashes match, returns (True, computed_hash). If mismatch, returns (False, computed_hash).
    """
    stored_hash = _get_installed_hash(pack_dir)
    if stored_hash is None:
        return (True, None)

    computed_hash = _compute_pack_hash(pack_dir)
    return (computed_hash == stored_hash, computed_hash)


def _verify_sha256(pack_dir: Path, expected: str | None) -> None:
    """Check pack content SHA-256 against *expected* (if provided)."""
    if not expected:
        return
    actual = _sha256_dir(pack_dir)
    if actual != expected:
        raise ValueError(
            f"SHA-256 mismatch for pack at {pack_dir}.\n"
            f"  expected: {expected}\n"
            f"  actual:   {actual}"
        )


def _hook_entry_key(entry: dict[str, Any]) -> tuple[str, str]:
    """Generate a deduplication key for a hook entry.

    Returns a composite key of (type, command_string) to identify unique hooks.
    """
    hook_type = entry.get("type", "")
    command = entry.get("command", "")
    return (hook_type, command)


def _merge_hooks(pack_dir: Path) -> None:
    """Merge pack's hooks into the active ``hooks.json``.

    Deduplicates hooks by (event_name, command_string) composite key to prevent
    duplicate entries when re-installing a pack.
    """
    pack_hooks_json = pack_dir / "hooks" / "hooks.json"
    if not pack_hooks_json.exists():
        return

    try:
        pack_hooks: dict[str, Any] = json.loads(pack_hooks_json.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as exc:
        print(f"  ⚠ Could not read pack hooks: {exc}")
        return

    if not _HOOKS_JSON.exists():
        print(f"  ⚠ Active hooks.json not found at {_HOOKS_JSON}; skipping merge.")
        return

    try:
        active_hooks: dict[str, Any] = json.loads(_HOOKS_JSON.read_text(encoding="utf-8"))
    except (json.JSONDecodeError, OSError) as exc:
        print(f"  ⚠ Could not read active hooks.json: {exc}")
        return

    merged_hooks = active_hooks.setdefault("hooks", {})
    for event, entries in pack_hooks.get("hooks", {}).items():
        if event not in merged_hooks:
            merged_hooks[event] = []
        if isinstance(entries, list):
            # Deduplicate by (type, command) composite key before merging
            existing_keys = {_hook_entry_key(e) for e in merged_hooks[event]}
            for entry in entries:
                key = _hook_entry_key(entry)
                if key not in existing_keys:
                    merged_hooks[event].append(entry)
                    existing_keys.add(key)

    _HOOKS_JSON.write_text(json.dumps(active_hooks, indent=2), encoding="utf-8")
    print(f"  ✓ Merged hooks into {_HOOKS_JSON}")


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


def list_packs(registry_path: Path | None = None) -> None:
    """Print the built-in registry packs to stdout."""
    from rich.console import Console
    from rich.table import Table

    import launcher.install as _self
    _orig = _self._REGISTRY_PATH
    if registry_path is not None:
        _self._REGISTRY_PATH = registry_path

    try:
        registry = _load_registry()
    finally:
        if registry_path is not None:
            _self._REGISTRY_PATH = _orig

    packs: dict[str, Any] = registry.get("packs", {})
    if not packs:
        print("No packs found in registry.")
        return

    console = Console()
    table = Table(title="Tribunal Pack Registry", show_lines=False)
    table.add_column("Name", style="cyan")
    table.add_column("Version", style="green")
    table.add_column("Description", style="white")
    table.add_column("URL", style="dim")

    for name, info in packs.items():
        table.add_row(
            name,
            info.get("version") or "-",
            info.get("description") or "",
            info.get("url") or "",
        )

    console.print(table)


def install_pack(
    source: str,
    *,
    packs_dir: Path | None = None,
    hooks_json: Path | None = None,
    registry_path: Path | None = None,
) -> Path:
    """Install a tribunal pack from *source*.

    Parameters
    ----------
    source:
        Registry name, GitHub URL, or local path.
    packs_dir:
        Override the target packs directory (default: ``~/.tribunal/packs/``).
    hooks_json:
        Override the active hooks.json path.
    registry_path:
        Override the registry YAML path.

    Returns
    -------
    Path
        The installed pack directory.
    """
    import launcher.install as _self

    # Allow test overrides — capture original values for cleanup
    _orig_reg = _self._REGISTRY_PATH
    _orig_hooks = _self._HOOKS_JSON
    if registry_path is not None:
        _self._REGISTRY_PATH = registry_path

    target_packs_dir = packs_dir if packs_dir is not None else _PACKS_DIR
    active_hooks_json = hooks_json if hooks_json is not None else _HOOKS_JSON

    try:
        print(f"→ Resolving source: {source}")
        kind, resolved = _resolve_source(source)

        if kind == "local":
            pack_path = Path(resolved)
            pack_name = pack_path.name
            print(f"  Using local pack: {pack_path}")
        else:
            # GitHub / URL — clone into a temp dir then copy
            pack_name = _pack_name_from_url(resolved)
            print(f"  Cloning {resolved} ...")
            tmp = tempfile.mkdtemp(prefix="tribunal-pack-")
            try:
                result = subprocess.run(
                    ["git", "clone", "--depth=1", resolved, tmp],
                    capture_output=True,
                    text=True,
                )
                if result.returncode != 0:
                    raise RuntimeError(
                        f"git clone failed:\n{result.stderr.strip()}"
                    )
                pack_path = Path(tmp)
            except Exception:
                shutil.rmtree(tmp, ignore_errors=True)
                raise

        print(f"  Validating pack structure ...")
        _validate_pack_dir(pack_path)

        # Read manifest for sha256
        manifest_path = pack_path / "tribunal-pack.json"
        manifest: dict[str, Any] = json.loads(manifest_path.read_text(encoding="utf-8"))
        expected_sha = manifest.get("sha256")

        if expected_sha:
            print(f"  Verifying SHA-256 ...")
            _verify_sha256(pack_path, expected_sha)
            print(f"  ✓ SHA-256 verified")
        else:
            print(f"  ⚠ No SHA-256 in manifest — skipping integrity check")

        # Copy to packs dir
        dest = target_packs_dir / pack_name
        if dest.exists():
            print(f"  Removing existing pack at {dest} ...")
            # Check for hash mismatch before removing
            is_valid, hash_val = _verify_installed_hash(dest)
            if not is_valid:
                print(f"  ⚠ Warning: Hash mismatch for existing pack (possible tampering or corruption)")
                print(f"    Stored: {_get_installed_hash(dest)}")
                print(f"    Current: {hash_val}")
            shutil.rmtree(dest)
        target_packs_dir.mkdir(parents=True, exist_ok=True)
        shutil.copytree(str(pack_path), str(dest))
        print(f"  ✓ Pack installed to {dest}")

        # Compute and store hash for the installed pack
        computed_hash = _compute_pack_hash(dest)
        _save_installed_hash(dest, computed_hash)
        print(f"  ✓ Pack hash: {computed_hash}")
        print(f"    (Publishers: add 'sha256: {computed_hash}' to registry.yaml)")

        # Merge hooks — temporarily override hooks.json path for _merge_hooks
        if hooks_json is not None:
            _self._HOOKS_JSON = active_hooks_json
        try:
            _merge_hooks(dest)
        finally:
            _self._HOOKS_JSON = _orig_hooks

        # Clean up temp clone dir
        if kind == "github":
            shutil.rmtree(str(pack_path), ignore_errors=True)

        print(f"\n✓ Pack '{pack_name}' installed successfully!")
        return dest

    finally:
        # Always restore module-level overrides regardless of success/failure
        _self._REGISTRY_PATH = _orig_reg
