"""Registry loader + validator — declared truth, fails closed.

Loads ``SECRETS.toml`` from the sibling standards checkouts (mgf-standard =
family ``mgf`` + shared infra; sooth-standard = family ``sooth``), validates
every entry structurally, and refuses to hand back a partial registry: a missing
required field, an unknown kind/surface/state, a duplicate id across families, an
invalid event, or a ``held`` entry marked armed all raise :class:`RegistryError`
(→ CLI exit 2). No secret value is ever read here — only declared metadata.
"""

from __future__ import annotations

import os
import tomllib
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .model import (
    ARMABLE_SURFACES,
    EVENTS,
    FAMILIES,
    KINDS,
    STATES,
    SURFACE_TYPES,
    Entry,
    Policy,
    Surface,
)

if TYPE_CHECKING:
    from collections.abc import Iterable, Iterator, Sequence

#: Sibling files that hold the declared registry, in load order.
DEFAULT_FILES: tuple[tuple[str, str], ...] = (
    ("mgf-standard", "SECRETS.toml"),
    ("sooth-standard", "SECRETS.toml"),
)

#: Top-level fields every entry MUST carry.
_REQUIRED_FIELDS = (
    "family",
    "kind",
    "purpose",
    "surfaces",
    "consumers",
    "policy",
    "rotation",
    "verify",
    "recovery",
    "state",
)

#: Per-surface-type required attribute keys (beyond ``type``).
_SURFACE_REQUIRED: dict[str, tuple[str, ...]] = {
    "woodpecker-org-secret": ("events",),
    "woodpecker-repo-secret": ("repo", "events"),
    "forge-deploy-key": ("repos", "readonly", "title"),
    "forge-pat": (),
    "host-file": ("host", "path", "mode", "owner"),
    "local-file": ("repo", "path", "mode"),
    "server-env": ("host", "path"),
    "store": (),
}


class RegistryError(Exception):
    """A registry that failed to validate. Carries every problem found."""

    def __init__(self, problems: Sequence[str]) -> None:
        self.problems = list(problems)
        super().__init__("; ".join(self.problems))


class Registry:
    """A validated, immutable view over every declared credential."""

    def __init__(self, entries: Iterable[Entry]) -> None:
        self._entries: dict[str, Entry] = {e.id: e for e in entries}

    def __len__(self) -> int:
        return len(self._entries)

    def __iter__(self) -> Iterator[Entry]:
        return iter(self._entries.values())

    @property
    def entries(self) -> tuple[Entry, ...]:
        return tuple(self._entries.values())

    def get(self, entry_id: str) -> Entry | None:
        return self._entries.get(entry_id)

    def select(
        self,
        *,
        family: str | None = None,
        kind: str | None = None,
        state: str | None = None,
    ) -> list[Entry]:
        out = []
        for e in self._entries.values():
            if family and e.family != family:
                continue
            if kind and e.kind != kind:
                continue
            if state and e.state != state:
                continue
            out.append(e)
        return sorted(out, key=lambda e: e.id)

    def with_surface(self, *types: str) -> list[Entry]:
        return sorted(
            (e for e in self._entries.values() if e.has_surface(*types)),
            key=lambda e: e.id,
        )


# --------------------------------------------------------------------------- #
# Loading + validation.
# --------------------------------------------------------------------------- #


def fed_root(explicit: str | Path | None = None) -> Path:
    """The federation checkout root: explicit → ``$MGF_ROOT`` → ``~/PycharmProjects``."""
    if explicit is not None:
        return Path(explicit)
    env = os.environ.get("MGF_ROOT")
    if env:
        return Path(env)
    return Path.home() / "PycharmProjects"


def default_paths(root: str | Path | None = None) -> list[Path]:
    base = fed_root(root)
    return [base / repo / name for repo, name in DEFAULT_FILES]


def load(
    paths: Sequence[str | Path] | None = None,
    *,
    root: str | Path | None = None,
    require_files: bool = True,
) -> Registry:
    """Load + validate the registry, failing closed on any problem.

    Args:
        paths: explicit TOML files to load; defaults to the sibling standards
            files under ``root``.
        root: federation root override (else ``$MGF_ROOT`` / ``~/PycharmProjects``).
        require_files: when using the default paths, raise if not one exists.
    """
    if paths is None:
        candidates = default_paths(root)
        present = [p for p in candidates if Path(p).is_file()]
        if require_files and not present:
            raise RegistryError(
                ["no SECRETS.toml found — looked at " + ", ".join(str(p) for p in candidates)]
            )
        files = present
    else:
        files = [Path(p) for p in paths]

    problems: list[str] = []
    entries: list[Entry] = []
    seen: dict[str, str] = {}  # id -> source file (duplicate detection)

    for path in files:
        p = Path(path)
        if not p.is_file():
            problems.append(f"{p}: file not found")
            continue
        try:
            raw = tomllib.loads(p.read_text(encoding="utf-8"))
        except (OSError, tomllib.TOMLDecodeError) as exc:
            problems.append(f"{p}: cannot parse ({exc})")
            continue
        for entry_id, body in raw.items():
            if not isinstance(body, dict):
                problems.append(f"{p}: entry {entry_id!r} is not a table")
                continue
            if entry_id in seen:
                problems.append(
                    f"duplicate id {entry_id!r} in {p.name} (already defined in {seen[entry_id]})"
                )
                continue
            seen[entry_id] = p.name
            entry, errs = _build_entry(entry_id, body)
            problems.extend(f"{p.name}:{entry_id}: {e}" for e in errs)
            if entry is not None:
                entries.append(entry)

    if problems:
        raise RegistryError(problems)
    return Registry(entries)


def _build_entry(entry_id: str, body: dict[str, Any]) -> tuple[Entry | None, list[str]]:
    errs: list[str] = []
    for field in _REQUIRED_FIELDS:
        if field not in body:
            errs.append(f"missing required field {field!r}")
    if errs:
        return None, errs

    family = body["family"]
    kind = body["kind"]
    state = body["state"]
    if family not in FAMILIES:
        errs.append(f"unknown family {family!r} (want one of {sorted(FAMILIES)})")
    if kind not in KINDS:
        errs.append(f"unknown kind {kind!r}")
    if state not in STATES:
        errs.append(f"unknown state {state!r}")

    policy_raw = body["policy"]
    if (
        not isinstance(policy_raw, dict)
        or "allowed_repos" not in policy_raw
        or "blast_radius" not in policy_raw
    ):
        errs.append("policy must be a table with allowed_repos + blast_radius")
        policy = Policy(allowed_repos="*", blast_radius="")
    else:
        policy = Policy(
            allowed_repos=policy_raw["allowed_repos"],
            blast_radius=str(policy_raw["blast_radius"]),
        )

    rotation = body["rotation"]
    if not isinstance(rotation, dict) or "class" not in rotation or "staged" not in rotation:
        errs.append("rotation must be a table with class + staged")
        rotation = {}
    verify = body["verify"]
    if not isinstance(verify, dict) or "probe" not in verify:
        errs.append("verify must be a table with probe")
        verify = {}

    consumers = body["consumers"]
    if not isinstance(consumers, list):
        errs.append("consumers must be a list")
        consumers = []

    surfaces_raw = body["surfaces"]
    surfaces: list[Surface] = []
    if not isinstance(surfaces_raw, list) or not surfaces_raw:
        errs.append("surfaces must be a non-empty list")
    else:
        for i, s in enumerate(surfaces_raw):
            surf, serrs = _build_surface(i, s, state)
            errs.extend(serrs)
            if surf is not None:
                surfaces.append(surf)

    if errs:
        return None, errs

    entry = Entry(
        id=entry_id,
        family=family,
        kind=kind,
        purpose=str(body["purpose"]),
        surfaces=tuple(surfaces),
        consumers=tuple(str(c) for c in consumers),
        policy=policy,
        rotation=dict(rotation),
        verify=dict(verify),
        recovery=str(body["recovery"]),
        state=state,
    )
    return entry, []


def _build_surface(idx: int, s: Any, state: str) -> tuple[Surface | None, list[str]]:
    errs: list[str] = []
    if not isinstance(s, dict) or "type" not in s:
        return None, [f"surface[{idx}] must be a table with a type"]
    stype = s["type"]
    if stype not in SURFACE_TYPES:
        return None, [f"surface[{idx}] unknown type {stype!r}"]
    for req in _SURFACE_REQUIRED[stype]:
        if req not in s:
            errs.append(f"surface[{idx}] ({stype}) missing required key {req!r}")
    # Event validity for the Woodpecker surfaces.
    if stype in ("woodpecker-org-secret", "woodpecker-repo-secret") and "events" in s:
        evs = s["events"]
        if not isinstance(evs, list) or not evs:
            errs.append(f"surface[{idx}] ({stype}) events must be a non-empty list")
        else:
            bad = [e for e in evs if e not in EVENTS]
            if bad:
                errs.append(f"surface[{idx}] ({stype}) unknown event(s) {bad}")
    # Optional D-7 image restriction on a Woodpecker secret: a list of image globs.
    if stype in ("woodpecker-org-secret", "woodpecker-repo-secret") and "images" in s:
        imgs = s["images"]
        if not isinstance(imgs, list) or not all(isinstance(i, str) for i in imgs):
            errs.append(f"surface[{idx}] ({stype}) images must be a list of image strings")
    # File-mode sanity.
    if stype in ("host-file", "local-file") and "mode" in s:
        mode = str(s["mode"])
        if not (mode.isdigit() and len(mode) in (3, 4)):
            errs.append(f"surface[{idx}] ({stype}) mode {mode!r} is not octal (e.g. 0600)")
    # The held guard: a held entry must not carry an armed-implying flag.
    if state == "held" and stype in ARMABLE_SURFACES and bool(s.get("armed", False)):
        errs.append(
            f"surface[{idx}] ({stype}) is armed=true but the entry state is 'held' — "
            "a held credential may declare where it would live but must never be armed"
        )
    data = {k: v for k, v in s.items() if k != "type"}
    return Surface(type=stype, data=data), errs
