"""Class-P reference projectors — federation architecture + coupling, from machine artifacts.

These project a family registry (no authoring): the federation dependency graph
(``architecture``, graph mode) and a project's declared coupling (``federation``).
Registered on import so ``fedres generate`` runs them when a project's
``source/project.toml`` declares ``[projectors.architecture]`` / ``[projectors.federation]``.
Deterministic (sorted) so they satisfy the freshness gate.

The registry *shape* is behind the ``RegistryAdapter`` seam (PAPER-01): an adapter
maps an arbitrary registry file to the normalized ``RegistryEntry`` view the
projector bodies consume. The default ``mgf`` adapter reads the MGF-shaped
``MGF_LIBRARIES.json`` (top-level ``libraries`` array, ``repo``/``lang``/``deps``
keys, ``mgf.standard`` package-data fallback) — so nothing changes for
mgf-standard — while a second family **registers** its own adapter
(``register_registry_adapter``) and selects it via
``[projectors.*] registry_adapter = "<name>"`` instead of forking this module.
"""

from __future__ import annotations

import json
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any

from . import canonical
from ._schemas import validate_payload
from .generate import GenerateError, register_projector

if TYPE_CHECKING:
    from .provider import DiskProvider


# --- the RegistryAdapter seam (PAPER-01) ------------------------------------


@dataclass(frozen=True)
class RegistryEntry:
    """One repo in a family registry, normalized for the reference projectors.

    ``deps`` maps sibling/third-party dependency name -> pin spec (any string).
    Only deps whose name is itself a registry ``repo`` become graph edges.
    """

    repo: str
    lang: str = "python"
    deps: Mapping[str, Any] = field(default_factory=dict)


#: An adapter loads a family's registry and normalizes it: (provider, cfg) -> entries.
RegistryAdapter = Callable[["DiskProvider", dict[str, Any]], Sequence[RegistryEntry]]

#: Registered adapters: name -> adapter. ``mgf`` is the default (registered below).
REGISTRY_ADAPTERS: dict[str, RegistryAdapter] = {}


def register_registry_adapter(name: str, fn: RegistryAdapter) -> None:
    """Register a family registry adapter under ``name`` (selected via
    ``[projectors.*] registry_adapter = "<name>"`` in ``source/project.toml``)."""
    REGISTRY_ADAPTERS[name] = fn


def _registry_entries(provider: DiskProvider, cfg: dict[str, Any]) -> Sequence[RegistryEntry]:
    name = str(cfg.get("registry_adapter", "mgf"))
    adapter = REGISTRY_ADAPTERS.get(name)
    if adapter is None:
        raise GenerateError(
            f"projector: unknown registry_adapter {name!r} — registered: "
            f"{sorted(REGISTRY_ADAPTERS)} (register one via "
            "mgf.fedres.projectors.register_registry_adapter)"
        )
    return adapter(provider, cfg)


def _load_libraries(provider: DiskProvider, cfg: dict[str, Any]) -> dict[str, Any]:
    """Load the MGF registry for a Class-P projection (the ``mgf`` adapter's loader).

    With an explicit ``libraries_json`` the projector reads that repo-local file —
    mgf-standard projects its OWN freshly-generated ``MGF_LIBRARIES.json`` (which leads
    the released package). Without it, the registry is read from the **mgf.standard
    package data** (``mgf/standard/_libraries.json``, force-included into the wheel) —
    so any fleet repo can project the graph in a single-repo CI checkout (no sibling
    mgf-standard), which is what lets its ``fedres check`` freshness-gate run in CI.
    """
    rel = cfg.get("libraries_json")
    if rel:
        path = provider.project_root / str(rel)
        if not path.is_file():
            raise GenerateError(f"projector: {rel} not found at {path}")
        text, src = path.read_text(encoding="utf-8"), str(rel)
    else:
        from importlib.resources import files

        try:
            text = (files("mgf.standard") / "_libraries.json").read_text(encoding="utf-8")
        except (ModuleNotFoundError, FileNotFoundError, OSError) as exc:
            raise GenerateError(
                "projector: the mgf.standard registry is unavailable — install "
                "mgf-common[standards], or set [projectors.*].libraries_json to a repo-local path"
            ) from exc
        src = "mgf.standard package data (_libraries.json)"
    try:
        data: dict[str, Any] = json.loads(text)
    except json.JSONDecodeError as exc:
        raise GenerateError(f"{src}: invalid JSON: {exc}") from exc
    return data


def mgf_registry_adapter(provider: DiskProvider, cfg: dict[str, Any]) -> Sequence[RegistryEntry]:
    """The default adapter: the MGF registry shape (``MGF_LIBRARIES.json``)."""
    libs = _load_libraries(provider, cfg).get("libraries", [])
    return [
        RegistryEntry(
            repo=lib["repo"],
            lang=str(lib.get("lang", "python")),
            deps=lib.get("deps", {}),
        )
        for lib in libs
    ]


# --- the reference projectors (shape-agnostic bodies) ------------------------


def _reachable(entries: Sequence[RegistryEntry], project_id: str) -> set[str]:
    """``project_id`` plus every registry repo reachable from it through deps.

    The transitive closure, not just direct deps: a project's architecture is what it
    is actually built on. Cycle-safe (visited set), and an unknown ``project_id``
    (a repo not in the registry) yields just itself.
    """
    by_repo = {e.repo: e for e in entries}
    seen: set[str] = set()
    stack = [project_id]
    while stack:
        repo = stack.pop()
        if repo in seen:
            continue
        seen.add(repo)
        entry = by_repo.get(repo)
        if entry is None:
            continue
        stack.extend(dep for dep in entry.deps if dep in by_repo and dep not in seen)
    return seen


def architecture_projector(provider: DiskProvider, project_id: str, cfg: dict[str, Any]) -> bytes:
    """Project this project's dependency graph (nodes = repos, edges = sibling deps).

    **Scope** (``[projectors.architecture].scope``):

    - ``"project"`` (default) — this project and its transitive sibling deps. A
      project's architecture is what IT is built on, so an unrelated member joining
      the roster leaves this file byte-identical.
    - ``"federation"`` — the whole roster. For the registry's OWNER (mgf-standard),
      which projects from its own repo-local ``MGF_LIBRARIES.json`` and for which the
      full graph IS the subject.

    Why the default is project-scoped (the 2026-07-17 coupling): the whole-federation
    graph is ONE global fact, and emitting it into every member byte-pinned a copy of
    the roster into 35 repos whose freshness gate then read the roster from the
    *installed* mgf-standard — a floating dependency. So publishing the registry (say,
    a new leaf member) turned every one of those repos' `fedres check` RED, through no
    change of their own, and no registry release could avoid it. Scoping the projection
    to the project makes the churn proportional to the change: a leaf nobody depends on
    touches nobody, and a repo's file moves only when its own architecture really does.
    """
    entries = _registry_entries(provider, cfg)
    scope = str(cfg.get("scope", "project"))
    if scope not in ("project", "federation"):
        raise GenerateError(
            f"architecture projector: unknown scope {scope!r} (expected 'project' or 'federation')"
        )
    if scope == "project":
        keep = _reachable(entries, project_id)
        entries = [e for e in entries if e.repo in keep]
    repos = {entry.repo for entry in entries}
    nodes = sorted(
        ({"id": entry.repo, "label": entry.repo, "layer": entry.lang} for entry in entries),
        key=lambda n: n["id"],
    )
    edges = sorted(
        (
            {"from": entry.repo, "to": dep, "kind": "depends_on"}
            for entry in entries
            for dep in entry.deps
            if dep in repos
        ),
        key=lambda e: (e["from"], e["to"]),
    )
    layers = sorted({n["layer"] for n in nodes})
    payload: dict[str, Any] = {
        "schema_version": "0.1",
        "kind": "architecture",
        "project": project_id,
        "mode": "graph",
        "graph": {"layers": layers, "nodes": nodes, "edges": edges},
    }
    errors = validate_payload(payload)
    if errors:
        raise GenerateError(f"architecture projector produced an invalid document: {errors}")
    return canonical.dumpb(payload)


def federation_projector(provider: DiskProvider, project_id: str, cfg: dict[str, Any]) -> bytes:
    """Project this project's coupling: what it consumes from sibling libraries."""
    entries = _registry_entries(provider, cfg)
    repos = {entry.repo for entry in entries}
    entry = next((e for e in entries if e.repo == project_id), None)
    consumes: list[dict[str, Any]] = []
    if entry:
        for dep, spec in sorted(entry.deps.items()):
            if dep in repos:
                consumes.append(
                    {
                        "from": dep,
                        "what": dep,
                        "why": "declared dependency",
                        "pinned_at": str(spec),
                        "on_upstream_change": "notify",
                    }
                )
    payload: dict[str, Any] = {
        "schema_version": "0.1",
        "kind": "federation",
        "project": project_id,
        "consumes": consumes,
        "roadmap_external_deps": [],
    }
    errors = validate_payload(payload)
    if errors:
        raise GenerateError(f"federation projector produced an invalid document: {errors}")
    return canonical.dumpb(payload)


register_registry_adapter("mgf", mgf_registry_adapter)
register_projector("architecture", architecture_projector)
register_projector("federation", federation_projector)
