"""ACP agent registry — maps model prefixes to agent commands.

Fetches agent definitions from the official ACP registry CDN and caches
locally.  User overrides (from Otto config) take priority over the registry.
No hardcoded agent definitions — the CDN is the source of truth.

Registry URL: https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json
"""

from __future__ import annotations

import json
import shutil
import time
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path

from otto.log import get_logger

_log = get_logger("otto.acp.registry")

REGISTRY_URL = "https://cdn.agentclientprotocol.com/registry/v1/latest/registry.json"
CACHE_TTL_SECONDS = 86400  # 1 day
_DEFAULT_CACHE_DIR = Path("~/.otto").expanduser()


@dataclass(frozen=True, slots=True)
class ACPAgentDef:
    """Definition of an ACP agent: how to launch it and what env it needs."""

    command: list[str]
    env_key: str | None = None
    env: dict[str, str] = field(default_factory=dict)


def _parse_prefix(model: str) -> tuple[str, str] | None:
    """Split a model string into (prefix, suffix) or None if no '/'."""
    slash = model.find("/")
    if slash < 0:
        return None
    return model[:slash], model[slash + 1 :]


# ---------------------------------------------------------------------------
# Registry fetch + cache
# ---------------------------------------------------------------------------


def _cache_path(cache_dir: Path) -> Path:
    return cache_dir / "acp_registry.json"


def _load_cache(cache_dir: Path) -> dict | None:
    path = _cache_path(cache_dir)
    if not path.exists():
        return None
    try:
        data = json.loads(path.read_text())
        return data
    except (json.JSONDecodeError, OSError):
        _log.warning("acp registry cache corrupted, ignoring", path=str(path))
        return None


def _cache_age_seconds(cache_dir: Path) -> float:
    path = _cache_path(cache_dir)
    if not path.exists():
        return float("inf")
    return time.time() - path.stat().st_mtime


def _fetch_registry() -> dict:
    """Fetch the live registry JSON from the CDN."""
    _log.info("fetching acp registry", url=REGISTRY_URL)
    request = urllib.request.Request(
        REGISTRY_URL,
        headers={"Accept": "application/json", "User-Agent": "otto"},
    )
    with urllib.request.urlopen(request, timeout=10) as response:
        return json.loads(response.read())


def _save_cache(data: dict, cache_dir: Path) -> None:
    path = _cache_path(cache_dir)
    try:
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(data))
    except OSError:
        _log.warning("failed to write acp registry cache", path=str(path))


def _package_bin_name(package: str) -> str | None:
    """Extract probable binary name from an npm/uvx package string.

    ``@zed-industries/claude-agent-acp@0.20.1`` → ``claude-agent-acp``
    ``@google/gemini-cli@0.32.1``               → ``gemini-cli``
    ``minion-code@0.1.39``                       → ``minion-code``
    """
    name = package.split("/")[-1] if "/" in package else package
    name = name.split("@")[0] if "@" in name else name
    return name or None


def _detect_local_binary(agent_id: str, package: str | None = None) -> str | None:
    """Check if the agent is available as a local binary on PATH.

    Tries the agent ID first (e.g. ``gemini``), then the package name
    without scope/version (e.g. ``claude-agent-acp``).  Returns the
    binary path if found, None otherwise.
    """
    result = shutil.which(agent_id)
    if result:
        return result
    if package:
        bin_name = _package_bin_name(package)
        if bin_name and bin_name != agent_id:
            result = shutil.which(bin_name)
            if result:
                return result
    return None


def _find_npx_cached_binary(package: str) -> str | None:
    """Find a binary in the npx cache directory.

    npx caches packages under ``~/.npm/_npx/<hash>/node_modules/.bin/``.
    Scanning these directories avoids the ~2s npx resolution overhead.
    Returns the absolute path to the binary if found, None otherwise.
    """
    bin_name = _package_bin_name(package)
    if not bin_name:
        return None

    npx_cache = Path.home() / ".npm" / "_npx"
    if not npx_cache.is_dir():
        return None

    try:
        for hash_dir in npx_cache.iterdir():
            candidate = hash_dir / "node_modules" / ".bin" / bin_name
            if candidate.exists():
                path = str(candidate)
                _log.debug("found npx cached binary", binary=bin_name, path=path)
                return path
    except OSError:
        pass
    return None


def _parse_registry_entry(entry: dict) -> ACPAgentDef | None:
    """Parse a single registry JSON entry into an ACPAgentDef.

    The registry supports three distribution formats:
    - ``npx``: ``{"package": "@scope/pkg@ver", "args": [...], "env": {...}}``
    - ``uvx``: ``{"package": "pkg@ver", "args": [...]}``
    - ``binary``: ``{"linux-x86_64": {"archive": "url", "cmd": "./bin"}, ...}``

    For npx/uvx distributions, if the agent binary is detected on PATH
    (via the agent ID), the local binary is used directly to avoid package
    manager startup latency.
    """
    dist = entry.get("distribution")
    if not dist:
        return None

    agent_id = entry.get("id", "")
    env: dict[str, str] = {}

    # Prefer npx (most common in the registry).
    if "npx" in dist:
        npx = dist["npx"]
        package = npx.get("package")
        if not package:
            return None
        extra_args = list(npx.get("args", []))
        env = npx.get("env", {})

        # Prefer locally installed binary over npx for faster startup.
        local = _detect_local_binary(agent_id, package)
        if local:
            _log.debug("using local binary for acp agent", agent_id=agent_id, path=local)
            return ACPAgentDef(command=[local] + extra_args, env=env)

        # Check npx cache to avoid ~2s npx resolution overhead.
        cached = _find_npx_cached_binary(package)
        if cached:
            _log.debug("using npx cached binary for acp agent", agent_id=agent_id, path=cached)
            return ACPAgentDef(command=[cached] + extra_args, env=env)

        command = ["npx", package] + extra_args
        return ACPAgentDef(command=command, env=env)

    # uvx (Python-based agents).
    if "uvx" in dist:
        uvx = dist["uvx"]
        package = uvx.get("package")
        if not package:
            return None
        extra_args = list(uvx.get("args", []))

        # Prefer locally installed binary over uvx for faster startup.
        local = _detect_local_binary(agent_id, package)
        if local:
            _log.debug("using local binary for acp agent", agent_id=agent_id, path=local)
            return ACPAgentDef(command=[local] + extra_args)

        command = ["uvx", package] + extra_args
        return ACPAgentDef(command=command)

    # Binary — platform-specific.  Resolve for current platform.
    if "binary" in dist:
        import platform as _platform

        system = _platform.system().lower()
        machine = _platform.machine().lower()
        # Normalise machine names to registry conventions.
        machine_map = {
            "x86_64": "x86_64",
            "amd64": "x86_64",
            "aarch64": "aarch64",
            "arm64": "aarch64",
        }
        arch = machine_map.get(machine, machine)
        key = f"{system}-{arch}"

        binary = dist["binary"]
        plat = binary.get(key)
        if not plat:
            return None
        cmd = plat.get("cmd")
        if not cmd:
            return None
        return ACPAgentDef(command=[cmd])

    return None


def _parse_registry(data: dict) -> dict[str, ACPAgentDef]:
    """Parse the full registry JSON into a dict of agent ID -> ACPAgentDef."""
    agents: dict[str, ACPAgentDef] = {}
    entries = data if isinstance(data, list) else data.get("agents", data.get("entries", []))
    if isinstance(entries, dict):
        # Some formats use {id: entry} mapping
        entries = [{"id": k, **v} for k, v in entries.items()]
    for entry in entries:
        agent_id = entry.get("id")
        if not agent_id:
            continue
        agent_def = _parse_registry_entry(entry)
        if agent_def is not None:
            agents[agent_id] = agent_def
    return agents


def _ensure_registry(
    cache_dir: Path | None = None,
    *,
    force: bool = False,
) -> dict[str, ACPAgentDef]:
    """Load registry from cache or fetch from CDN if stale/missing."""
    if cache_dir is None:
        cache_dir = _DEFAULT_CACHE_DIR
    cache = _load_cache(cache_dir)
    age = _cache_age_seconds(cache_dir)
    stale = age > CACHE_TTL_SECONDS

    if cache is not None and not stale and not force:
        return _parse_registry(cache)

    # Need to fetch: cache is missing, stale, or force=True
    try:
        data = _fetch_registry()
        _save_cache(data, cache_dir)
        return _parse_registry(data)
    except Exception:
        _log.warning("failed to fetch acp registry from CDN", exc_info=True)
        # Fall back to stale cache if available
        if cache is not None:
            _log.info("using stale acp registry cache")
            return _parse_registry(cache)
        raise ValueError(
            "No ACP registry available: CDN fetch failed and no local cache exists. "
            "Check your internet connection or configure agents manually via acp_agents in config."
        )


# Module-level cache so we don't re-parse on every call within a session.
_registry_cache: dict[str, ACPAgentDef] | None = None


def _get_registry(cache_dir: Path | None = None, force: bool = False) -> dict[str, ACPAgentDef]:
    global _registry_cache
    if _registry_cache is not None and not force:
        return _registry_cache
    _registry_cache = _ensure_registry(cache_dir, force=force)
    return _registry_cache


def _get_all_agents(
    user_overrides: dict[str, dict] | None = None,
    cache_dir: Path | None = None,
) -> dict[str, ACPAgentDef]:
    """Return registry agents merged with user overrides (overrides win)."""
    merged = dict(_get_registry(cache_dir))
    if not user_overrides:
        return merged
    for name, raw in user_overrides.items():
        merged[name] = _validate_override(name, raw)
    return merged


def _validate_override(name: str, raw: dict) -> ACPAgentDef:
    if not isinstance(raw, dict) or "command" not in raw:
        raise ValueError(
            f"Invalid ACP agent override {name!r}: must be a dict with at least a 'command' key"
        )
    command = raw["command"]
    if not isinstance(command, list) or not all(isinstance(c, str) for c in command):
        raise ValueError(
            f"Invalid ACP agent override {name!r}: 'command' must be a list of strings"
        )
    return ACPAgentDef(
        command=list(command),
        env_key=raw.get("env_key"),
        env=raw.get("env", {}),
    )


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


def is_acp_model(model: str, user_overrides: dict[str, dict] | None = None) -> bool:
    """Return True if *model* starts with a known ACP agent prefix + '/'."""
    parts = _parse_prefix(model)
    if parts is None:
        return False
    prefix = parts[0]
    if user_overrides and prefix in user_overrides:
        return True
    try:
        agents = _get_registry()
        if prefix in agents:
            return True
    except ValueError:
        pass
    return False


def resolve_acp_agent(
    model: str,
    user_overrides: dict[str, dict] | None = None,
) -> ACPAgentDef:
    """Return the ACPAgentDef for *model*.  Raises ValueError if not found.

    If the agent prefix is unknown, forces a registry refresh from CDN
    before giving up.
    """
    parts = _parse_prefix(model)
    if parts is None:
        raise ValueError(f"Not an ACP model string (no '/' separator): {model!r}")
    prefix = parts[0]

    # User overrides always win.
    if user_overrides and prefix in user_overrides:
        return _validate_override(prefix, user_overrides[prefix])

    # Try cached registry first.
    try:
        agents = _get_registry()
        if prefix in agents:
            return agents[prefix]
    except ValueError:
        pass

    # Unknown prefix — force a registry refresh.
    _log.info("unknown acp agent prefix, refreshing registry", prefix=prefix)
    try:
        agents = _get_registry(force=True)
        if prefix in agents:
            return agents[prefix]
    except ValueError:
        pass

    raise ValueError(
        f"Unknown ACP agent: {prefix!r}. "
        f"Not found in the ACP registry or user config. "
        f"See https://agentclientprotocol.com/get-started/agents for available agents."
    )


def get_acp_model_suffix(model: str) -> str:
    """Extract the model/variant portion after the prefix.

    Example: ``claude-code/opus`` -> ``opus``, ``codex/`` -> ``""``.
    Raises ValueError if *model* has no '/' separator.
    """
    parts = _parse_prefix(model)
    if parts is None:
        raise ValueError(f"Not an ACP model string (no '/' separator): {model!r}")
    return parts[1]


def get_all_agents(
    user_overrides: dict[str, dict] | None = None,
) -> dict[str, ACPAgentDef]:
    """Return registry agents merged with user overrides (overrides win)."""
    return _get_all_agents(user_overrides)


def reset_cache() -> None:
    """Clear the in-memory registry cache.  Useful for testing."""
    global _registry_cache
    _registry_cache = None
