"""D-GEN — project a project's authored single-source into gated ``generated/`` JSON.

**Class A (authored):** ``fed-resources/source/<kind>.toml`` is the single source of
truth; the generator projects it to ``generated/<kind>.json`` — deterministically,
validated, canonical — so ``regenerate -> byte-identical`` is a real gate and there
is no second source of truth.

**Class P (projected from machine artifacts):** plugs in via the projector registry
(e.g. an architecture graph from ``MGF_LIBRARIES.json``), added as reference
projectors land.
"""

from __future__ import annotations

import json
import tomllib
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any

from . import canonical
from . import manifest as manifest_mod
from ._schemas import validate_payload

if TYPE_CHECKING:
    from collections.abc import Callable

    from .provider import DiskProvider

CLASS_A_KINDS: tuple[str, ...] = ("status", "roadmap", "focus", "suggestions", "federation")


class GenerateError(Exception):
    """Generation can't proceed, or the source projects to an invalid document (fail-closed)."""


@dataclass(frozen=True)
class GenerateResult:
    project: dict[str, str]
    files: dict[str, bytes]  # relpath -> canonical bytes (state files; NOT the manifest)
    manifest_bytes: bytes
    written: bool


def _load_toml(raw: bytes, what: str) -> dict[str, Any]:
    try:
        return tomllib.loads(raw.decode("utf-8"))
    except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
        raise GenerateError(f"{what}: invalid TOML: {exc}") from exc


def _status_summary(tasks: list[dict[str, Any]]) -> dict[str, Any]:
    """Derive status.summary from the tasks (computed, never authored — can't drift)."""
    by_state: dict[str, int] = {}
    for task in tasks:
        state = str(task.get("state", "unknown"))
        by_state[state] = by_state.get(state, 0) + 1
    return {"total": len(tasks), "by_state": dict(sorted(by_state.items()))}


def _project_class_a(kind: str, raw: bytes, project_id: str) -> bytes:
    data = _load_toml(raw, f"source/{kind}.toml")
    payload: dict[str, Any] = {"schema_version": "0.1", "kind": kind, "project": project_id, **data}
    if kind == "status":
        payload["summary"] = _status_summary(payload.get("tasks", []))
    errors = validate_payload(payload)
    if errors:
        raise GenerateError(f"source/{kind}.toml projects to an invalid {kind}.json: {errors}")
    return canonical.dumpb(payload)


#: Class-P projectors: name -> fn(provider, project_id, cfg) -> canonical bytes.
PROJECTORS: dict[str, Callable[[DiskProvider, str, dict[str, Any]], bytes]] = {}


def register_projector(name: str, fn: Callable[[DiskProvider, str, dict[str, Any]], bytes]) -> None:
    """Register — or REPLACE — the Class-P projector bound to ``name``.

    This is the **family-adapter seam** (CIR-26). The registry is last-write-wins:
    registering a name that is already bound — *including* a built-in slot such as
    ``"architecture"`` / ``"federation"`` — REPLACES it. Overriding a built-in is
    **supported, contracted behavior**, not an accident (pinned by
    ``test_projectors.test_register_projector_replaces_a_builtin_slot``): a second
    family points a slot at its own registry projector by importing a module that
    calls this at import time. The stock CLI drives that via ``$MGF_FEDRES_PROJECTORS``
    (imported after the built-ins register, so the family's calls win) — so a family
    adapter needs no wrapper script. The Sooth family is the first consumer.
    """
    PROJECTORS[name] = fn


def _require_project(provider: DiskProvider) -> dict[str, Any]:
    raw = provider.source_bytes("project.toml")
    if raw is None:
        raise GenerateError(
            "fed-resources/source/project.toml is required (defines [project] id/name/kind)"
        )
    data = _load_toml(raw, "source/project.toml")
    proj = data.get("project")
    if not isinstance(proj, dict) or not all(k in proj for k in ("id", "name", "kind")):
        raise GenerateError("source/project.toml must define [project] with id, name, kind")
    return data


def generate(
    provider: DiskProvider,
    *,
    generated_at: str,
    write: bool = False,
    generator_version: str = "0.1.0",
) -> GenerateResult:
    """Project source/ -> generated/ in memory; optionally write it to the working tree."""
    cfg = _require_project(provider)
    proj = cfg["project"]
    project_id = str(proj["id"])
    project_ref = {"id": proj["id"], "name": proj["name"], "kind": proj["kind"]}

    files: dict[str, bytes] = {}

    # Class A — every recognised source/<kind>.toml (honest-absence: absent -> no file)
    for kind in CLASS_A_KINDS:
        raw = provider.source_bytes(f"{kind}.toml")
        if raw is not None:
            files[f"{kind}.json"] = _project_class_a(kind, raw, project_id)

    # Class P — declared projectors
    projectors_cfg = cfg.get("projectors") or {}
    for name, pcfg in projectors_cfg.items():
        if name not in PROJECTORS:
            raise GenerateError(f"unknown projector {name!r} declared in source/project.toml")
        files[f"{name}.json"] = PROJECTORS[name](provider, project_id, pcfg)

    source_commit = provider.head_commit() or "UNKNOWN"

    # Run-records (generated/runs/**) are integrity-hashed in the manifest but
    # reproduce-exempt (append-only history can't be regenerated). Fold the
    # existing ones in so they don't read as "extra" -> TAMPERED.
    manifest_files = dict(files)
    tags: dict[str, str] = {}
    for relpath in provider.list_generated():
        if relpath.startswith("runs/"):
            data = provider.generated_bytes(relpath)
            if data is not None:
                manifest_files[relpath] = data
                try:
                    tags[relpath] = str(json.loads(data).get("kind", "run_record"))
                except json.JSONDecodeError:
                    tags[relpath] = "run_record"

    manifest = manifest_mod.build_manifest(
        project=project_ref,
        source_commit=source_commit,
        generated_at=generated_at,
        generator={"name": "fedres", "version": generator_version},
        files=manifest_files,
        tags=tags,
    )
    manifest_bytes = canonical.dumpb(manifest)

    written = False
    if write:
        provider.write_generated({**files, "manifest.json": manifest_bytes})
        written = True

    return GenerateResult(
        project=project_ref, files=files, manifest_bytes=manifest_bytes, written=written
    )
