"""Installed setup registry for agentmux.

Tracks setups that have been installed from remote sources (git URLs) or local
paths.  The registry persists metadata in ``~/.agentmux/registry.json`` and
stores each setup's files under ``~/.agentmux/setups/<name>/``.

Registry JSON format::

    {
      "setups": {
        "my-setup": {
          "source_url": "https://github.com/user/my-setup",
          "installed_sha": "abc123",
          "installed_at": "2026-02-23T10:00:00Z",
          "local_path": "/Users/user/.agentmux/setups/my-setup"
        }
      }
    }
"""

import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path

__all__ = [
    "REGISTRY_DIR",
    "REGISTRY_FILE",
    "RegistryEntry",
    "Registry",
]

REGISTRY_DIR: Path = Path.home() / ".agentmux" / "setups"
REGISTRY_FILE: Path = Path.home() / ".agentmux" / "registry.json"

# ISO-8601 format used for ``installed_at`` timestamps.
# Includes microseconds for precision; the legacy format (no microseconds) is
# accepted on read to remain compatible with registries written by older versions.
_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ"
_TIMESTAMP_FORMAT_LEGACY = "%Y-%m-%dT%H:%M:%SZ"


@dataclass
class RegistryEntry:
    """Metadata for a single installed setup.

    Attributes:
        name: Short identifier used as the lookup key (and subdirectory name
            under ``REGISTRY_DIR``).
        source_url: The git URL or local path the setup was installed from.
        installed_sha: The git commit SHA at the time of installation (empty
            string for local-path installs where git is not available).
        installed_at: UTC timestamp of the last install/update operation.
        local_path: Absolute path to the cloned/copied setup directory on
            the local filesystem.
    """

    name: str
    source_url: str
    installed_sha: str
    installed_at: datetime
    local_path: Path

    @classmethod
    def from_dict(cls, name: str, data: dict[str, str]) -> "RegistryEntry":
        """Deserialise a registry entry from its JSON dict representation.

        Args:
            name: The setup name (key in the outer ``"setups"`` dict).
            data: The value dict containing ``source_url``, ``installed_sha``,
                ``installed_at``, and ``local_path``.

        Raises:
            ValueError: If any required key is missing or a value has an
                unexpected type.
        """
        required_keys = {"source_url", "installed_sha", "installed_at", "local_path"}
        missing = required_keys - data.keys()
        if missing:
            raise ValueError(f"Registry entry {name!r} is missing required keys: {sorted(missing)}")
        try:
            try:
                installed_at = datetime.strptime(data["installed_at"], _TIMESTAMP_FORMAT).replace(
                    tzinfo=timezone.utc
                )
            except ValueError:
                installed_at = datetime.strptime(
                    data["installed_at"], _TIMESTAMP_FORMAT_LEGACY
                ).replace(tzinfo=timezone.utc)
        except ValueError as exc:
            raise ValueError(
                f"Registry entry {name!r} has an invalid 'installed_at' timestamp "
                f"{data['installed_at']!r}. Expected ISO-8601 UTC format "
                f"(e.g. '2026-02-23T10:00:00.123456Z' or '2026-02-23T10:00:00Z')."
            ) from exc
        return cls(
            name=name,
            source_url=data["source_url"],
            installed_sha=data["installed_sha"],
            installed_at=installed_at,
            local_path=Path(data["local_path"]),
        )

    def to_dict(self) -> dict[str, str]:
        """Serialise this entry to the JSON dict format used in ``registry.json``."""
        return {
            "source_url": self.source_url,
            "installed_sha": self.installed_sha,
            "installed_at": self.installed_at.strftime(_TIMESTAMP_FORMAT),
            "local_path": str(self.local_path),
        }


@dataclass
class Registry:
    """Read/write interface for the agentmux installed-setup registry.

    The registry is backed by a single JSON file at ``REGISTRY_FILE``.  All
    mutation methods keep the in-memory state and the on-disk file in sync
    automatically.

    Typical usage::

        registry = Registry.load()
        registry.add(entry)          # persists immediately
        entry = registry.get("my-setup")
        registry.remove("my-setup")  # persists immediately

    Attributes:
        _entries: Internal mapping from setup name to ``RegistryEntry``.
        _registry_file: Path to the JSON file (injected for testing).
    """

    _entries: dict[str, RegistryEntry] = field(default_factory=dict)
    _registry_file: Path = field(default_factory=lambda: REGISTRY_FILE)

    # ------------------------------------------------------------------
    # Class-level factory
    # ------------------------------------------------------------------

    @classmethod
    def load(cls, registry_file: Path | None = None) -> "Registry":
        """Read the registry from disk and return a populated ``Registry``.

        If the file does not exist yet, an empty registry is returned (the file
        is only written once ``add()`` or ``save()`` is first called).

        Args:
            registry_file: Override the default path to ``registry.json`` —
                useful for tests that run in isolated ``tmp_path`` directories.

        Raises:
            ValueError: If the JSON is malformed or contains invalid entries.
        """
        resolved_file = registry_file if registry_file is not None else REGISTRY_FILE

        if not resolved_file.exists():
            return cls(_entries={}, _registry_file=resolved_file)

        try:
            raw = json.loads(resolved_file.read_text(encoding="utf-8"))
        except json.JSONDecodeError as exc:
            raise ValueError(f"Failed to parse registry file '{resolved_file}': {exc}") from exc

        if not isinstance(raw, dict) or "setups" not in raw:
            raise ValueError(
                f"Registry file '{resolved_file}' must be a JSON object with a 'setups' key."
            )

        entries: dict[str, RegistryEntry] = {}
        for name, data in raw["setups"].items():
            if not isinstance(data, dict):
                raise ValueError(
                    f"Registry entry {name!r} must be a JSON object, got {type(data).__name__}."
                )
            try:
                entries[name] = RegistryEntry.from_dict(name, data)
            except ValueError as exc:
                raise ValueError(f"Failed to load registry entry {name!r}: {exc}") from exc

        return cls(_entries=entries, _registry_file=resolved_file)

    # ------------------------------------------------------------------
    # Persistence
    # ------------------------------------------------------------------

    def save(self) -> None:
        """Write the current in-memory state to ``registry.json``.

        Creates the parent directory (``~/.agentmux/``) if it does not exist.
        """
        self._registry_file.parent.mkdir(parents=True, exist_ok=True)
        payload: dict[str, object] = {
            "setups": {name: entry.to_dict() for name, entry in self._entries.items()}
        }
        self._registry_file.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")

    # ------------------------------------------------------------------
    # CRUD helpers
    # ------------------------------------------------------------------

    def add(self, entry: RegistryEntry) -> None:
        """Add or replace a registry entry and persist to disk.

        Args:
            entry: The ``RegistryEntry`` to store.  Its ``name`` is used as the
                key; an existing entry with the same name is overwritten.
        """
        self._entries[entry.name] = entry
        self.save()

    def remove(self, name: str) -> None:
        """Remove the entry with *name* from the registry and persist to disk.

        Args:
            name: The setup name to remove.

        Raises:
            KeyError: If no entry with *name* exists.
        """
        if name not in self._entries:
            raise KeyError(f"No installed setup named {name!r} found in the registry.")
        del self._entries[name]
        self.save()

    def get(self, name: str) -> RegistryEntry | None:
        """Return the ``RegistryEntry`` for *name*, or ``None`` if not found."""
        return self._entries.get(name)

    def list_all(self) -> list[RegistryEntry]:
        """Return all registry entries sorted by name."""
        return sorted(self._entries.values(), key=lambda e: e.name)
