"""``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 _woodpecker
from mgf.fed._roster import Member, members
from mgf.fed._template import canonical_text, load_policy

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


@dataclass(frozen=True, slots=True)
class DriftItem:
    # kinds: missing | exact | ci-generated | ci-step | 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 _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 Codeberg 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 check(member: Member, policy: dict[str, Any], root: Path | None = None) -> RepoDrift:
    d = member.repo_dir
    items: list[DriftItem] = []
    pyproject = _load_pyproject(d)  # loaded once; reused by the install-safety + pyproject checks
    # presence
    for rel in policy.get("present", []):
        if not (d / rel).is_file():
            items.append(DriftItem("missing", rel))
    if policy.get("py_typed") and not list(d.glob("src/**/py.typed")):
        items.append(DriftItem("missing", "src/**/py.typed"))
    # byte-exact shared files
    for rel, tmpl in (policy.get("exact", {}) or {}).items():
        f = d / rel
        if f.is_file() and f.read_text(encoding="utf-8") != canonical_text(tmpl, root):
            items.append(DriftItem("exact", f"{rel} differs from template/{tmpl}"))
    # ci pipeline — the missing-file case is covered by the `present` check above.
    wp = d / ".woodpecker.yml"
    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
    items += _check_pyproject(
        pyproject,
        policy.get("pyproject", {}) or {},
        member.repo,
        policy.get("exempt", {}) or {},
    )
    return RepoDrift(repo=member.repo, items=tuple(items))


def run_drift_check(*, shape: str = "python", root: Path | None = None) -> int:
    policy = load_policy(root)
    rows = [check(m, policy, root) for m in members(root) if shape == "all" or m.lang == shape]
    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")
    return 1 if drifted else 0
