"""``mgf-fed drift-check`` — verify each repo against the canonical scaffolding policy.

Read-only. Turns the ``status`` matrix's presence dots into a concrete divergence
report: missing files, missing CI steps, byte-drift in shared files, and pyproject
gate-policy violations (incl. the ruff format-stability pin). Feeds the later
``scaffold`` (apply) + the fleet-wide ruff-pin task.
"""

from __future__ import annotations

import re
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from mgf.fed import _pipeline, _woodpecker
from mgf.fed._roster import Member, federation_root, members
from mgf.fed._template import canonical_text, load_policy

_STEP_NAME_RE = re.compile(r"(?m)^\s{2,}-\s+name:\s+(\S+)")

_WOODPECKER = ".woodpecker.yml"

#: `present`-list entries that are PYTHON-PACKAGING artifacts — a member with no Python
#: has no equivalent, so they are required only of `lang == "python"` members. The
#: `policy.present` list is python-shaped (there is no separate zig policy); the default
#: `--shape python` already excuses out-of-shape members the whole scaffolding policy
#: (see `run_drift_check`), but an explicit `--shape zig` / `--shape all` runs the full
#: `check()`, so these must be shape-gated there too or a zig repo is flagged for files
#: it should never carry. PUBLIC_API.json + its generator introspect Python modules;
#: `py_typed` (PEP 561) and the `[pyproject]` checks are python by nature and gated
#: inline. Keep in sync with `policy.present` if a new Python-only file is added.
_PYTHON_ONLY_PRESENT = frozenset({"PUBLIC_API.json", "tools/generate_public_api_json.py"})

#: Drift kinds produced by `gate_floor` / `ungoverned_repos` — a federation gate that is
#: NOT RUNNING, as opposed to a file that merely diverged from the template.
_GATE_FLOOR_KINDS = frozenset(
    {"ci-missing", "ci-command", "ci-coverage", "ci-ungoverned", "ci-ui-gate"}
)


@dataclass(frozen=True, slots=True)
class DriftItem:
    # kinds: missing | exact | ci-generated | ci-step | ci-command | ci-missing |
    #        ci-coverage | ci-ungoverned | ci-unsafe-install | pyproject
    kind: str
    detail: str


@dataclass(frozen=True, slots=True)
class RepoDrift:
    repo: str
    items: tuple[DriftItem, ...]

    @property
    def ok(self) -> bool:
        return not self.items


def _load_pyproject(repo_dir: Path) -> dict[str, Any]:
    p = repo_dir / "pyproject.toml"
    if not p.is_file():
        return {}
    try:
        return tomllib.loads(p.read_text(encoding="utf-8"))
    except tomllib.TOMLDecodeError:
        return {}


def _ruff_specs(pyproject: dict[str, Any]) -> list[str]:
    opt = (pyproject.get("project", {}) or {}).get("optional-dependencies", {}) or {}
    out: list[str] = []
    for group in opt.values():
        out += [s for s in group if isinstance(s, str) and s.lstrip().lower().startswith("ruff")]
    return out


def _check_pyproject(
    pyproject: dict[str, Any], policy: dict[str, Any], repo: str, exempt: dict[str, Any]
) -> list[DriftItem]:
    items: list[DriftItem] = []
    if not pyproject:
        return [DriftItem("pyproject", "missing or unparseable pyproject.toml")]
    if policy.get("build_backend"):
        got = (pyproject.get("build-system", {}) or {}).get("build-backend")
        if got != policy["build_backend"]:
            items.append(
                DriftItem("pyproject", f"build-backend={got!r} (want {policy['build_backend']!r})")
            )
    tool = pyproject.get("tool", {}) or {}
    if policy.get("mypy_strict") and (tool.get("mypy", {}) or {}).get("strict") is not True:
        items.append(DriftItem("pyproject", "[tool.mypy] strict is not true"))
    if policy.get("pytest_filterwarnings_error"):
        fw = ((tool.get("pytest", {}) or {}).get("ini_options", {}) or {}).get("filterwarnings", [])
        if not (fw and str(fw[0]).startswith("error")):
            items.append(
                DriftItem("pyproject", "[tool.pytest] filterwarnings does not start with 'error'")
            )
    if policy.get("standards_extra") and repo not in exempt.get("standards_extra", []):
        opt = (pyproject.get("project", {}) or {}).get("optional-dependencies", {}) or {}
        if "standards" not in opt:
            items.append(DriftItem("pyproject", "no separate [standards] extra"))
    want = policy.get("ruff_spec")
    if want:
        specs = _ruff_specs(pyproject)
        if not specs:
            items.append(DriftItem("pyproject", "no ruff dev pin found"))
        elif want.replace(" ", "") not in {s.replace(" ", "") for s in specs}:
            items.append(
                DriftItem(
                    "pyproject", f"ruff pin {specs[0]!r} != required {want!r} (format-stability)"
                )
            )
    return items


def _required_steps_drift(text: str, policy: dict[str, Any]) -> list[DriftItem]:
    """FALLBACK ci check: every ``woodpecker_required_steps`` name present (regex, zero-dep).

    Used only when generation is off, or when ``render_woodpecker`` cannot run for this
    member (e.g. an unparseable pyproject.toml) so the generation-currency check is
    unavailable. The generation-currency check supersedes this when a render succeeds.
    """
    have = set(_STEP_NAME_RE.findall(text))
    missing = [s for s in policy.get("woodpecker_required_steps", []) if s not in have]
    if missing:
        return [DriftItem("ci-step", f"missing steps: {', '.join(missing)}")]
    return []


def _gitleaks_shape_drift(on_disk: str, want: str) -> str | None:
    """Shape-aware comparison for gitleaks policy files (M2 §3.6; closes PAPER-03).

    The old byte-exact comparison flagged every legitimately-ADDITIVE consumer
    allowlist entry (a repo allowlisting its own fixture files) as drift, training
    maintainers to ignore the check. This compares the parsed TOML **shape**:

    - ``[allowlist]`` keys with LIST values (``paths``/``regexes``/…): every
      template entry must be present in the consumer — a removed/edited standard
      entry still fails; consumer EXTRAS are tolerated. An extra list-valued
      allowlist key is additive too, and tolerated.
    - everything else (``title``, ``[extend]``, rules, allowlist scalars like
      ``description``): must equal the template exactly — additions, removals and
      edits all fail. Comments are not compared (shape, not bytes).

    Returns ``None`` when conformant, else a one-line detail. An unparseable
    consumer file is drift; an unparseable TEMPLATE degrades to the byte-exact
    comparison (never-block, and byte-exact is the stricter original contract).
    """
    try:
        want_data = tomllib.loads(want)
    except tomllib.TOMLDecodeError:  # broken canonical — hold the line byte-exact
        return None if on_disk == want else "differs from the template (byte-exact)"
    try:
        got_data = tomllib.loads(on_disk)
    except tomllib.TOMLDecodeError as exc:
        return f"unparseable TOML ({exc})"

    def _table(data: dict[str, Any]) -> dict[str, Any]:
        allow = data.get("allowlist")
        return allow if isinstance(allow, dict) else {}

    want_allow, got_allow = _table(want_data), _table(got_data)
    for key in sorted(set(want_data) | set(got_data)):
        if key == "allowlist":
            continue
        if want_data.get(key) != got_data.get(key):
            return f"[{key}] differs from the template"
    for key in sorted(set(want_allow) | set(got_allow)):
        w, g = want_allow.get(key), got_allow.get(key)
        if w is None and isinstance(g, list):
            continue  # additive allowlist entry-list — tolerated (PAPER-03)
        if isinstance(w, list) and isinstance(g, list):
            missing = [e for e in w if e not in g]
            if missing:
                shown = ", ".join(str(e) for e in missing[:3])
                return f"allowlist.{key} drops/edits standard entr(y/ies): {shown}"
            continue  # every standard entry present; extras tolerated
        if w != g:
            return f"allowlist.{key} differs from the template"
    return None


def _append_array_entries(text: str, key: str, extras: list[str]) -> str:
    """Append ``extras`` as new items to the TOML array ``key = [ … ]`` in ``text``.

    Formatting follows the template: each extra is emitted as a triple-single-quoted
    literal (``'''…'''`` — the shape gitleaks path/regex entries use, so a
    backslash-bearing regex needs no escaping) on its own line, indented to match the
    array's existing items, inserted immediately before the closing ``]``. Returns
    ``text`` unchanged when ``extras`` is empty or the array is absent (a consumer-only
    allowlist key the template does not define — there is nothing to append to).
    """
    if not extras:
        return text
    # `key = [` up to the line that closes the array. ``body`` consumes whole lines
    # only, so the terminator is the FIRST line that begins (after indent) with ``]``;
    # entry lines begin with ``'''``, never ``]``, so an entry carrying a literal ``]``
    # (e.g. a ``[0-9]`` character class) is never mistaken for the terminator.
    pat = re.compile(
        rf"(?m)^(?P<lead>[ \t]*){re.escape(key)} = \[[ \t]*\n"
        r"(?P<body>(?:.*\n)*?)"
        r"(?P<close>[ \t]*\])"
    )
    m = pat.search(text)
    if m is None:
        return text
    indent = m.group("lead") + "    "  # fallback: array indent + 4 spaces
    for line in m.group("body").splitlines():
        if line.strip():  # adopt the real entry indentation when the array is non-empty
            indent = line[: len(line) - len(line.lstrip())]
            break
    addition = "".join(f"{indent}'''{e}''',\n" for e in extras)
    return text[: m.start("close")] + addition + text[m.start("close") :]


def gitleaks_restore_text(on_disk: str, want: str) -> str:
    """Non-destructive ``.gitleaks.toml`` restore (Finding 1, Option A).

    Return the template ``want`` — so any MISSING standard allowlist entry is re-added
    — but with the consumer's *additive* allowlist entries (list values present in
    ``on_disk`` but not in ``want``) re-appended, so a repo's own fixture carve-outs
    survive a restore that the old skip-or-overwrite guard would have discarded (the
    mgf-fastapi clobber: a file that simultaneously LACKED a new standard entry and
    CARRIED an extra carve-out). ``title``, ``[extend]``, every rule and the allowlist
    scalars follow the template verbatim; only additive entries in a list the template
    already defines are carried over.

    If either side is unparseable, or there are no additive extras, ``want`` is
    returned byte-for-byte (the template is the safe fallback).
    """
    try:
        want_data = tomllib.loads(want)
        got_data = tomllib.loads(on_disk)
    except tomllib.TOMLDecodeError:
        return want

    def _allow(data: dict[str, Any]) -> dict[str, Any]:
        allow = data.get("allowlist")
        return allow if isinstance(allow, dict) else {}

    want_allow, got_allow = _allow(want_data), _allow(got_data)
    out = want
    for key in sorted(want_allow):  # only keys the template defines can be appended to
        w, g = want_allow.get(key), got_allow.get(key)
        if isinstance(w, list) and isinstance(g, list):
            extras = [e for e in g if e not in w]
            if extras:
                out = _append_array_entries(out, key, extras)
    return out


def _iter_steps(text: str) -> list[tuple[str, str]]:
    """Split a ``.woodpecker.yml`` into ``(step-name, step-body)`` blocks (zero-dep)."""
    blocks: list[tuple[str, str]] = []
    name: str | None = None
    buf: list[str] = []
    for line in text.splitlines():
        m = _STEP_NAME_RE.match(line)
        if m:
            if name is not None:
                blocks.append((name, "\n".join(buf)))
            name = m.group(1)
            buf = [line]
        elif name is not None:
            buf.append(line)
    if name is not None:
        blocks.append((name, "\n".join(buf)))
    return blocks


def _ci_install_safety_drift(text: str, pyproject: dict[str, Any]) -> list[DriftItem]:
    """Install-command safety — runs on EVERY ``.woodpecker.yml`` (generated, exempt,
    or generation-off), because neither the byte-render currency check nor the
    step-name fallback inspects what the install COMMANDS actually do. Two hazards
    from the mgf-hrb / mgf-test-supervisor dig (2026-06):

    1. **Dependency-confusion / wrong-version.** A repo that resolves mgf-* deps
       (``extra_index`` on, the default) MUST steer every project/extras
       ``pip install`` at the devpi index — a bare ``pip install -e ".[dev]"``
       resolves the mgf-* deps from public PyPI (stale mirror). ``[tool.mgf-fed]
       extra_index = false`` opts out (cornerstone / editable-pinned repos), as does
       a ``PIP_INDEX_URL`` env anchor that steers every step file-wide.
    2. **vuln-scan pip CVE.** A ``pip-audit`` step that does not ``pip install
       --upgrade pip`` first audits the base image's own outdated pip and reds on
       its CVEs.
    """
    items: list[DriftItem] = []
    extra_index = bool(_woodpecker._mgf_ci(pyproject).get("extra_index", True))
    env_steered = "PIP_INDEX_URL" in text  # an env-var anchor steers every step

    if extra_index and not env_steered:
        bare = [
            s
            for line in text.splitlines()
            if "pip install" in (s := line.strip())
            and (" -e " in s or ".[" in s)  # the project/extras install (not a tool install)
            and "--index-url" not in s
            and "--extra-index-url" not in s
        ]
        if bare:
            items.append(
                DriftItem(
                    "ci-unsafe-install",
                    f"{len(bare)} bare `pip install` of the project/extras without index "
                    f"steering — resolves mgf-* deps from public PyPI "
                    f"(dependency-confusion / stale-version). e.g. `{bare[0][:64]}`",
                )
            )

    for name, block in _iter_steps(text):
        if "pip-audit" in block and "pip install" in block and "upgrade pip" not in block:
            items.append(
                DriftItem(
                    "ci-unsafe-install",
                    f"step {name!r} runs pip-audit without `pip install --upgrade pip` — "
                    f"audits the base image's outdated pip (base-image-pip CVE time-bomb)",
                )
            )
    return items


def gate_floor(member: Member, policy: dict[str, Any]) -> list[DriftItem]:
    """The federation **gate floor** — the checks that hold for EVERY registry member,
    whatever its language and whether or not it is exempt from generation-currency
    (``mgf.fed._pipeline``; PAPER-121/122).

    Three items, each closing a hole a real wave fell into:

    - ``ci-missing`` — no ``.woodpecker.yml`` at all. Every Woodpecker run then errors
      ("could not load config from forge") and NO gate has ever run on the repo
      (mgf-brand). Excused only by an explicit ``[exempt].no_pipeline`` entry.
    - ``ci-command`` — the pipeline exists but does not RUN a required gate command.
      Asserted against exempt pipelines too: exempt means "we don't regenerate your
      file", not "you may silently drop a federation gate". Python members only — the
      default command list (`ruff …`) is a Python gate set, and a Zig pipeline
      legitimately has no ruff.
    - ``ci-coverage`` — the supervisor-under-coverage ↔ ``patch = ["subprocess"]``
      coupling: green suite, red build.

    Read-only; no I/O beyond the two files the caller already found.
    """
    d = member.repo_dir
    wp = d / _WOODPECKER
    if not wp.is_file():
        if member.repo in _pipeline.pipeline_missing_exempt(policy):
            return []
        return [
            DriftItem(
                "ci-missing",
                f"no {_WOODPECKER} — every Woodpecker run errors ('could not load config "
                f"from forge') and NO gate has ever run on this repo. Run `mgf-fed scaffold` "
                f"(or add the repo to [exempt].no_pipeline if that is deliberate).",
            )
        ]
    text = wp.read_text(encoding="utf-8")
    items: list[DriftItem] = []
    if member.lang == "python":
        missing = _pipeline.missing_commands(text, _pipeline.required_commands(policy))
        if missing:
            items.append(
                DriftItem(
                    "ci-command",
                    f"{_WOODPECKER} does not run required gate command(s): "
                    f"{', '.join(repr(c) for c in missing)} — a step NAME is not a gate "
                    f"(exempt from generation-currency ≠ exempt from the gate floor)",
                )
            )
    pyproject = _load_pyproject(d)
    if _pipeline.coverage_patch_gap(text, pyproject):
        items.append(DriftItem("ci-coverage", _pipeline.COVERAGE_PATCH_FIX))
    if _pipeline.ui_gate_gap(text, pyproject):
        items.append(DriftItem("ci-ui-gate", _pipeline.UI_GATE_FIX))
    return items


def check(member: Member, policy: dict[str, Any], root: Path | None = None) -> RepoDrift:
    d = member.repo_dir
    items: list[DriftItem] = []
    # The one policy is python-shaped; a non-python member (a Zig sibling) carries no
    # PUBLIC_API.json / py.typed / pyproject and must not be flagged for their absence.
    # `--shape python` excuses out-of-shape members upstream (run_drift_check); an
    # explicit `--shape zig` / `--shape all` reaches here, so the Python-packaging
    # checks are gated on lang. The language-agnostic scaffolding (gitleaks, hooks,
    # the conformance ledger, SCOPE/DEPS, .woodpecker.yml currency, install-safety)
    # still applies to every shape.
    is_python = member.lang == "python"
    pyproject = _load_pyproject(d)  # loaded once; reused by the install-safety + pyproject checks
    # presence — `.woodpecker.yml` is excluded here and handled by `gate_floor` below,
    # which says WHY its absence is fatal (no gate has ever run) instead of listing it
    # as one more missing file, and which reports it for every member, not just the ones
    # in the current `--shape`.
    for rel in policy.get("present", []):
        if rel == _WOODPECKER or (rel in _PYTHON_ONLY_PRESENT and not is_python):
            continue
        if not (d / rel).is_file():
            items.append(DriftItem("missing", rel))
    if is_python and policy.get("py_typed") and not list(d.glob("src/**/py.typed")):
        items.append(DriftItem("missing", "src/**/py.typed"))
    # shared files: byte-exact — EXCEPT gitleaks policy files, which are compared
    # shape-aware (§3.6: additive consumer allowlists tolerated; removals/edits of
    # standard entries still fail — see _gitleaks_shape_drift).
    for rel, tmpl in (policy.get("exact", {}) or {}).items():
        f = d / rel
        if not f.is_file():
            continue  # covered by the `present` check when the policy lists it
        got = f.read_text(encoding="utf-8")
        want = canonical_text(tmpl, root)
        if Path(rel).name == ".gitleaks.toml":
            detail = _gitleaks_shape_drift(got, want)
            if detail:
                items.append(DriftItem("exact", f"{rel} {detail} (vs template/{tmpl})"))
        elif got != want:
            items.append(DriftItem("exact", f"{rel} differs from template/{tmpl}"))
    # ci pipeline — the gate floor (missing pipeline / required commands / the
    # coverage coupling) runs first and applies exempt-or-not; the shape checks below
    # only apply when there IS a pipeline.
    items += gate_floor(member, policy)
    wp = d / _WOODPECKER
    if wp.is_file():
        on_disk = wp.read_text(encoding="utf-8")
        woodpecker_exempt = member.repo in (policy.get("exempt", {}) or {}).get("woodpecker", [])
        if policy.get("woodpecker_generated") and not woodpecker_exempt:
            # Generation-currency: the on-disk pipeline MUST equal a fresh render of
            # the canonical template. If the render raises (unparseable repo), fall
            # back to the name-list check so an un-renderable repo is still gated.
            try:
                want = _woodpecker.render_woodpecker(member, root)
            except Exception:  # degrade to the name-list fallback
                items += _required_steps_drift(on_disk, policy)
            else:
                if on_disk != want:
                    items.append(
                        DriftItem(
                            "ci-generated",
                            ".woodpecker.yml differs from generated (run `mgf-fed scaffold`)",
                        )
                    )
        else:
            # Generation off, OR a legitimately-bespoke pipeline excused from
            # generation-currency ([exempt].woodpecker, WF-13): hold it to the
            # step-name list instead of a byte-identical render comparison.
            items += _required_steps_drift(on_disk, policy)
        # Install-command safety — ALWAYS, regardless of generated/exempt: the
        # checks above gate names/bytes, never what the install commands DO.
        items += _ci_install_safety_drift(on_disk, pyproject)
    # pyproject policy — python-only: a Zig member has no pyproject.toml, and requiring
    # one (or its ruff/import-linter/coverage sub-checks) of a Zig repo is the false
    # positive this shape-gate exists to prevent.
    if is_python:
        items += _check_pyproject(
            pyproject,
            policy.get("pyproject", {}) or {},
            member.repo,
            policy.get("exempt", {}) or {},
        )
    return RepoDrift(repo=member.repo, items=tuple(items))


def ungoverned_repos(root: Path, policy: dict[str, Any], known: set[str]) -> list[RepoDrift]:
    """Cloned ``mgf-*`` repos that the registry does not list AND that ship no pipeline
    (``ci-ungoverned``) — the hole mgf-brand actually fell through.

    The gate floor in :func:`gate_floor` holds every *registry member* to a pipeline.
    mgf-brand is not a registry member: it carries no publishable package, so
    ``MGF_LIBRARIES.json`` never listed it — and therefore neither drift-check nor any
    other tool we own ever looked at it. Meanwhile it IS activated on the forge, so every
    Woodpecker run errored ("could not load config from forge") and NO gate has ever run
    on it. Being outside the *package* registry is fine; being outside every *gate* is not.

    Deliberately narrow, so this reports the harm and not the shape:

    - only ``mgf-*`` directories that are real checkouts (a ``.git`` entry) — a scratch
      dir or a build output is not a repo. (A path test, not a ``git`` call: the CI image
      has no ``git`` binary, and this is a read-only classification.)
    - only repos with **no** ``.woodpecker.yml`` at all. A non-registry repo that *has* a
      pipeline (mgf-website, mgf-cost-watch) is running gates and is none of drift-check's
      business — today that keeps this check at exactly one finding, fleet-wide: mgf-brand.
    - excusable, like any member, by an explicit ``[exempt].no_pipeline`` entry.
    """
    out: list[RepoDrift] = []
    exempt = _pipeline.pipeline_missing_exempt(policy)
    for d in sorted(root.glob("mgf-*")):
        name = d.name
        if not d.is_dir() or name in known or name in exempt:
            continue
        if not (d / ".git").exists() or (d / _WOODPECKER).is_file():
            continue
        out.append(
            RepoDrift(
                repo=name,
                items=(
                    DriftItem(
                        "ci-ungoverned",
                        f"cloned repo with NO {_WOODPECKER} and NOT in the registry — every "
                        f"Woodpecker run errors ('could not load config from forge') and NO gate "
                        f"has ever run on it, yet nothing we own was looking. Give it a pipeline "
                        f"(`mgf-fed scaffold`), or add it to [exempt].no_pipeline if it is "
                        f"deliberately CI-less (then de-activate it on the forge).",
                    ),
                ),
            )
        )
    return out


def run_drift_check(*, shape: str = "python", root: Path | None = None) -> int:
    policy = load_policy(root)
    root = root or federation_root()
    rows: list[RepoDrift] = []
    for m in members(root):
        if shape == "all" or m.lang == shape:
            rows.append(check(m, policy, root))
            continue
        # Out-of-shape members (a Zig repo under the default `--shape python`) are
        # excused the Python scaffolding policy — but NEVER the gate floor. A member
        # with no pipeline at all runs no gates in ANY language, and under the old
        # shape filter nothing we own would ever have said so (PAPER-122).
        out_of_shape = gate_floor(m, policy)
        if out_of_shape:
            rows.append(RepoDrift(repo=m.repo, items=tuple(out_of_shape)))
    rows += ungoverned_repos(root, policy, {m.repo for m in members(root)})
    clean = [r for r in rows if r.ok]
    drifted = [r for r in rows if not r.ok]
    for r in drifted:
        print(f"✗ {r.repo}  ({len(r.items)})")
        for it in r.items:
            print(f"    [{it.kind}] {it.detail}")
    for r in clean:
        print(f"✓ {r.repo}")
    total = sum(len(r.items) for r in drifted)
    print(f"\n{len(clean)}/{len(rows)} clean · {len(drifted)} with drift · {total} items")
    # The gate floor is called out separately: these are not "your file drifted from the
    # template" findings, they are "a federation gate is NOT RUNNING on this repo".
    floor = [
        (r.repo, it)
        for r in drifted
        for it in r.items
        if it.kind in _GATE_FLOOR_KINDS  # ci-missing | ci-command | ci-coverage
    ]
    if floor:
        print(f"\nGATE FLOOR — {len(floor)} gate(s) not running (PAPER-121/122):")
        for repo, it in floor:
            print(f"    [{it.kind}] {repo}: {it.detail.splitlines()[0]}")
    return 1 if drifted else 0
