"""``mgf-fed app-doctor --standards`` — code-level conformance audit.

Read-only audit that walks the consumer project's ``src/`` and
``pyproject.toml`` looking for violations of high-signal rules
from ``docs/standards/``. Complementary to ``mgf-common
catch-up``:

  - ``catch-up`` audits **project setup** (pin staleness,
    four-gate config, CLAUDE.md presence, legacy bootstrap
    call sites).
  - ``doctor --standards`` audits **code-level conformance**
    (py.typed marker, ``__version__`` sync, naive datetime
    usage, etc.).

Both are read-only by design. ``catch-up`` ships a separate
``--apply`` mode for the mechanical fixes it owns; this audit
has no auto-fix path — every finding is a human decision
about how to refactor.

The shipped check set is deliberately small (four checks for
the first cut) so the output stays scannable. Future check
additions land here as new ``_check_*`` functions added to the
``CHECKS`` tuple.

Exit code:
  - ``0``   — every check passed (✓) or only warned (⚠).
  - ``2``   — at least one check FAILED (✗) or the project
              shape couldn't be detected.
"""

from __future__ import annotations

import ast
import os
import re
import shutil
import subprocess
import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

from mgf.common.cli import EXIT_OPERATION_ERROR, EXIT_SUCCESS

from mgf.fed import _pipeline

if TYPE_CHECKING:
    from collections.abc import Iterator


@dataclass(frozen=True)
class Finding:
    """One audit result. Bound to a rule, has a tier + message."""

    rule_id: str
    #: "ok" · "warn" (advisory, the check RAN) · "unknown" (the check could NOT
    #: run) · "fail". `unknown` exists because six checks used to report
    #: "cannot verify …" as `warn`, and `warn` exited 0 — so "I could not check"
    #: and "I checked and it passed" were the same answer to CI.
    tier: str
    message: str


# ---------------------------------------------------------------------------
# Project shape discovery
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class _ProjectShape:
    """Snapshot of the audited project — enough for every check
    to consult without re-parsing pyproject for itself."""

    root: Path
    pyproject: dict[str, object]
    # ``src_dir`` is None for flat (no-``src/``) layouts; ``package_root`` is
    # None when there is no single import package (flat / multi-package /
    # Django apps). Root-level checks (DOC-15, DOC-18) need only ``root``;
    # package-level checks (AP-09/AP-13/DP-12) degrade to N/A when it's None.
    src_dir: Path | None
    package_root: Path | None
    package_name: str
    declared_version: str


def _repo_only_shape(start: Path) -> _ProjectShape | None:
    """A shape for a git repo that has no ``pyproject.toml``.

    Root-level rules (AG-18/AG-18a `.gitignore` policy, DOC-*) are about the
    repository, not the Python package, so they are perfectly checkable here.
    Package-level rules see ``package_root=None`` and report N/A exactly as they
    already do for flat and multi-package layouts.
    """
    cur = start
    while True:
        if (cur / ".git").exists():
            return _ProjectShape(
                root=cur,
                pyproject={},
                src_dir=None,
                package_root=None,
                package_name=cur.name,
                declared_version="",
            )
        if cur.parent == cur:
            return None
        cur = cur.parent


def _discover_project(start: Path) -> _ProjectShape | None:
    """Walk up from ``start`` for a ``pyproject.toml`` with a ``[project]``
    name; that is enough to audit (root-level checks run on every federation
    project). Resolve the import package best-effort — under ``src/`` for the
    src-layout, at the repo root for a flat layout — and leave ``package_root``
    None when there is no single package (flat / multi-package / Django).
    Returns None only when there is no ``pyproject`` with a project name.
    """
    cur = start.resolve()
    while True:
        py = cur / "pyproject.toml"
        if py.is_file():
            break
        if cur.parent == cur:
            # No pyproject anywhere above `start`. That used to end the audit —
            # and 15 MGF-family repos have no pyproject at all (mgf-brand,
            # mgf-website-template, the Zig and asset repos, every sooth* repo),
            # so AG-18(a) was UNENFORCEABLE in exactly the places that violate
            # it. A repo root is enough for the root-level checks; the
            # package-level ones already degrade to N/A on `package_root=None`.
            return _repo_only_shape(start.resolve())
        cur = cur.parent

    try:
        pyproject = tomllib.loads(py.read_text(encoding="utf-8"))
    except (OSError, tomllib.TOMLDecodeError):
        return None

    project = pyproject.get("project") or {}
    if not isinstance(project, dict):
        return None
    package_name = str(project.get("name", "")).strip()
    declared_version = str(project.get("version", "")).strip()
    if not package_name:
        return None

    src = cur / "src"
    if src.is_dir():
        src_dir: Path | None = src
        package_root = _find_package_root(src, package_name)
    else:
        # Flat layout (e.g. a Django project: config/ + apps/ at the repo root).
        src_dir = None
        package_root = _find_package_root(cur, package_name)

    return _ProjectShape(
        root=cur,
        pyproject=pyproject,
        src_dir=src_dir,
        package_root=package_root,
        package_name=package_name,
        declared_version=declared_version,
    )


_INFRA_DIRS = frozenset(
    {"tests", "test", "docs", "doc", "scripts", "build", "dist", "__pycache__", ".venv", ".git"}
)


def _is_package_dir(c: Path) -> bool:
    """A directory is an importable package if it carries ``__init__.py``, a
    ``py.typed`` marker, or any ``.py`` module. The last two cover PEP-420
    namespace leaves (e.g. ``src/mgf/vm/core/``), which have no ``__init__.py``.
    """
    return c.is_dir() and (
        (c / "__init__.py").is_file()
        or (c / "py.typed").is_file()
        or next(c.glob("*.py"), None) is not None
    )


def _find_package_root(src_dir: Path, package_name: str) -> Path | None:
    """Resolve the import package under ``src/`` for flat, namespace, and
    deep-namespace layouts. Hyphens in the distribution name map to the import
    path; namespace packages live under ``src/<ns>/<sub>/…`` and MAY be PEP-420
    (no ``__init__.py``).
    """
    underscore_name = package_name.replace("-", "_")
    candidates = [
        src_dir / underscore_name,
        # First-underscore namespace split — src/<ns>/<sub>/. E.g.
        # mgf-common → src/mgf/common; mgf-test-supervisor → src/mgf/test_supervisor.
        # (Tried BEFORE the full-hyphen path so test-supervisor resolves correctly.)
        *(
            src_dir / parts[0] / "/".join(parts[1:])
            for parts in [underscore_name.split("_", 1)]
            if len(parts) == 2
        ),
        # Full hyphen→path for DEEP namespaces — src/<a>/<b>/<c>/. E.g.
        # mgf-vm-core → src/mgf/vm/core (a PEP-420 namespace, no __init__.py).
        src_dir.joinpath(*package_name.split("-")),
    ]
    for c in candidates:
        if _is_package_dir(c):
            return c
    # Fallback: the distribution name doesn't derive the import package — common
    # for applications (e.g. dist `vm-vmanager-cli` → package `vmanager`). If
    # `src/` holds exactly one importable package, use it.
    pkgs = [d for d in sorted(src_dir.iterdir()) if _is_package_dir(d)]
    if len(pkgs) == 1:
        return pkgs[0]
    # Deeper fallback: descend through single-child bare-namespace dirs to a
    # package, e.g. dist `vm-vmanager-agent` → src/vmanager/agent (vmanager is a
    # bare namespace whose only child is the real package).
    if not pkgs:
        cur = src_dir
        for _ in range(6):  # bounded
            children = [
                d
                for d in sorted(cur.iterdir())
                if d.is_dir() and d.name not in _INFRA_DIRS and not d.name.startswith(".")
            ]
            if len(children) != 1:
                break
            child = children[0]
            if _is_package_dir(child):
                return child
            cur = child
    return None


# ---------------------------------------------------------------------------
# Checks
# ---------------------------------------------------------------------------


def _check_py_typed_marker(shape: _ProjectShape) -> Finding:
    """AP-09 — every typed package MUST ship a ``py.typed`` marker."""
    if shape.package_root is None:
        return Finding(
            "AP-09",
            "ok",
            "no single import package (flat / multi-package layout, e.g. a "
            "Django app) — py.typed check N/A",
        )
    marker = shape.package_root / "py.typed"
    if marker.is_file():
        return Finding(
            "AP-09",
            "ok",
            f"py.typed marker present at {marker.relative_to(shape.root)}",
        )
    return Finding(
        "AP-09",
        "fail",
        f"py.typed marker missing at "
        f"{marker.relative_to(shape.root)} — downstream "
        f"`mypy --strict` will not consume your annotations. "
        f"`touch {marker.relative_to(shape.root)}` and add it to "
        f"the wheel's package data.",
    )


def _check_version_sync(shape: _ProjectShape) -> Finding:
    """AP-13 — ``__version__`` MUST match ``pyproject.toml::project.version``."""
    if shape.package_root is None:
        return Finding(
            "AP-13",
            "ok",
            "no single import package (flat / multi-package layout) — runtime "
            "__version__ check N/A (version lives in pyproject only)",
        )
    if not shape.declared_version:
        return Finding(
            "AP-13",
            "warn",
            "pyproject.toml has no [project] version field — "
            "either using dynamic versioning (no audit possible) "
            "or the field is missing.",
        )
    runtime = _read_dunder_version(shape.package_root)
    if runtime == _DYNAMIC_VERSION:
        return Finding(
            "AP-13",
            "ok",
            f"`{shape.package_name}.__version__` is resolved at runtime via "
            f"importlib.metadata — it tracks the installed distribution "
            f"version, so it matches `pyproject.toml::project.version` "
            f"({shape.declared_version!r}) by construction (verified at "
            f"install, not statically). No drift possible.",
        )
    if runtime is None:
        return Finding(
            "AP-13",
            "warn",
            f"package {shape.package_name} has no `__version__` "
            f"attribute discoverable in `__init__.py` or "
            f"`_version.py`. Pin it to "
            f"`pyproject.toml::project.version` "
            f"({shape.declared_version!r}) so consumers can read "
            f"`<pkg>.__version__`.",
        )
    if runtime != shape.declared_version:
        return Finding(
            "AP-13",
            "fail",
            f"version drift: pyproject says "
            f"{shape.declared_version!r}, runtime "
            f"`{shape.package_name}.__version__` says "
            f"{runtime!r}. They MUST match — bump both together.",
        )
    return Finding(
        "AP-13",
        "ok",
        f"__version__ matches pyproject ({runtime!r})",
    )


def _check_filterwarnings(shape: _ProjectShape) -> Finding:
    """TS-04 — ``filterwarnings = ["error", ...]`` MUST be set in
    pytest config so warnings escalate to errors during tests."""
    if not shape.pyproject:
        # No pyproject at all (an assets / Zig / docs repo). Telling it to
        # configure `[tool.pytest.ini_options]` is noise, and noise from a newly
        # widened gate is how the widening gets reverted.
        return Finding("TS-04", "ok", "no pyproject.toml — pytest config N/A for this repo.")
    tool = shape.pyproject.get("tool")
    pytest_cfg: object = {}
    if isinstance(tool, dict):
        pytest_section = tool.get("pytest")
        if isinstance(pytest_section, dict):
            pytest_cfg = pytest_section.get("ini_options", {})
    fw = pytest_cfg.get("filterwarnings") if isinstance(pytest_cfg, dict) else None
    if not isinstance(fw, list) or not fw:
        return Finding(
            "TS-04",
            "fail",
            "[tool.pytest.ini_options] filterwarnings is not "
            "configured — DeprecationWarnings (including "
            "MgfDeprecationWarning) won't escalate to test "
            'failures. Add `filterwarnings = ["error", ...]` to '
            "pyproject.toml.",
        )
    if "error" not in fw:
        return Finding(
            "TS-04",
            "fail",
            f"[tool.pytest.ini_options] filterwarnings = {fw!r} — "
            f"the first element MUST be 'error' so unexpected "
            f"warnings break the build.",
        )
    return Finding(
        "TS-04",
        "ok",
        f"pytest filterwarnings escalates ({len(fw)} entries; first is 'error')",
    )


def _check_naive_datetime(shape: _ProjectShape) -> Finding:
    """DP-12 — naive ``datetime`` usage is forbidden. Walk
    ``src/`` for ``datetime.now()`` without ``tzinfo`` and
    ``datetime.utcnow()``."""
    scan_root = shape.package_root or shape.src_dir
    if scan_root is None:
        return Finding(
            "DP-12",
            "ok",
            "no single import package or src/ to scan (flat / multi-package "
            "layout) — naive-datetime scan N/A",
        )
    violations: list[str] = []
    for py in _iter_py_files(scan_root):
        try:
            tree = ast.parse(py.read_text(encoding="utf-8"))
        except (OSError, SyntaxError):
            continue
        for node in ast.walk(tree):
            if not isinstance(node, ast.Call):
                continue
            func = node.func
            # Match `datetime.now(...)` / `datetime.utcnow()`.
            if isinstance(func, ast.Attribute):
                attr = func.attr
                base = func.value
                base_name = base.id if isinstance(base, ast.Name) else None
                if base_name == "datetime" and attr == "utcnow":
                    violations.append(
                        f"  {py.relative_to(shape.root)}:{node.lineno}: "
                        f"datetime.utcnow() — use datetime.now(UTC)"
                    )
                if (
                    base_name == "datetime"
                    and attr == "now"
                    and not _has_kwarg(node, "tz")
                    and not node.args
                ):
                    violations.append(
                        f"  {py.relative_to(shape.root)}:{node.lineno}: "
                        f"datetime.now() with no tzinfo — pass UTC explicitly"
                    )
    if not violations:
        return Finding(
            "DP-12",
            "ok",
            "no naive datetime calls in package source",
        )
    return Finding(
        "DP-12",
        "fail",
        "naive datetime calls (DP-12 forbids these — they "
        "produce ambiguous timestamps across DST + timezone "
        "boundaries):\n"
        + "\n".join(violations[:20])
        + (f"\n  ... and {len(violations) - 20} more" if len(violations) > 20 else ""),
    )


def _has_kwarg(call: ast.Call, name: str) -> bool:
    """True iff ``call`` has a keyword argument named ``name``."""
    return any(kw.arg == name for kw in call.keywords)


def _iter_py_files(package_root: Path) -> Iterator[Path]:
    """Walk every ``*.py`` under ``package_root`` (skipping
    ``__pycache__`` and underscore-prefixed test fixtures)."""
    for p in package_root.rglob("*.py"):
        if any(part == "__pycache__" for part in p.parts):
            continue
        yield p


#: Sentinel returned by :func:`_read_dunder_version` when ``__version__`` is
#: resolved at runtime (e.g. ``importlib.metadata.version(...)``) rather than
#: assigned a static string literal. Such a version tracks the *installed*
#: distribution by construction, so it cannot statically drift from pyproject —
#: any string literal alongside it (in an ``except PackageNotFoundError`` branch)
#: is only a not-installed fallback and MUST NOT be read as the version.
_DYNAMIC_VERSION = "\x00dynamic\x00"


def _read_dunder_version(package_root: Path) -> str | None:
    """Find ``__version__`` in the package's ``__init__.py`` or a
    ``_version.py`` sibling.

    Returns the string literal for a static ``__version__ = "..."``; the
    :data:`_DYNAMIC_VERSION` sentinel when ``__version__`` is assigned from a
    call (``importlib.metadata.version(...)`` / ``_pkg_version(...)`` — the
    drift-proof "always the installed version" pattern, whose only literal is
    the not-installed fallback); or ``None`` when absent.
    """
    for candidate in (
        package_root / "__init__.py",
        package_root / "_version.py",
    ):
        if not candidate.is_file():
            continue
        try:
            tree = ast.parse(candidate.read_text(encoding="utf-8"))
        except (OSError, SyntaxError):
            continue
        literal: str | None = None
        dynamic = False
        for node in ast.walk(tree):
            if not (
                isinstance(node, ast.Assign)
                and len(node.targets) == 1
                and isinstance(node.targets[0], ast.Name)
                and node.targets[0].id == "__version__"
            ):
                continue
            if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
                if literal is None:
                    literal = node.value.value
            else:
                # ``__version__ = version("pkg")`` / a computed expression: the
                # value is only known at runtime, so any sibling literal is a
                # fallback, not the audited version.
                dynamic = True
        if dynamic:
            return _DYNAMIC_VERSION
        if literal is not None:
            return literal
    return None


def _check_propagation_headers(shape: _ProjectShape) -> Finding:
    """DOC-18 — every *owned, rule-defining* standards doc MUST carry a
    ``Propagation (DOC-18)`` header so a change's ripple is visible at the
    point of edit. Projects with no owned standards docs (pointer-only
    siblings, applications) are N/A.
    """
    std_dir = shape.root / "docs" / "standards"
    if not std_dir.is_dir():
        return Finding(
            "DOC-18",
            "ok",
            "no docs/standards/ (pointer-only sibling or application) — N/A",
        )
    missing: list[str] = []
    checked = 0
    for md in sorted(std_dir.glob("*.md")):
        if md.name == "README.md":
            continue
        try:
            text = md.read_text(encoding="utf-8")
        except OSError:
            continue
        # Only docs that actually DEFINE rules (carry a Rules box) are bound.
        if "Rules box" not in text:
            continue
        checked += 1
        if "Propagation (DOC-18)" not in text:
            missing.append(md.name)
    if checked == 0:
        return Finding(
            "DOC-18",
            "ok",
            "docs/standards/ is pointer-only (no Rules box) — N/A",
        )
    if missing:
        return Finding(
            "DOC-18",
            "fail",
            f"{len(missing)} rule-defining standards doc(s) missing the "
            f"DOC-18 Propagation header (name the dependents a change ripples "
            f"to — see DOCUMENTATION.md §10.4):\n"
            + "\n".join(f"  docs/standards/{m}" for m in missing),
        )
    return Finding(
        "DOC-18",
        "ok",
        f"all {checked} rule-defining standards docs carry a Propagation header",
    )


# Rule ids in the corpus + the conformance ledger look like ``DP-01`` /
# ``DOC-18`` / ``TS-23`` — a 2+ letter family, a hyphen, a number.
_RULE_ID_RE = re.compile(r"\b[A-Z]{2,}-\d+\b")


def _corpus_must_ids() -> set[str] | None:
    """Return the set of MUST rule ids from the corpus *Index of MUSTs*.

    Resolves the standards README through ``mgf.standard`` (the installed
    corpus extra) and parses the ``## Index of MUSTs`` table — the canonical,
    same-commit-maintained list of every absolute requirement. Returns
    ``None`` when the corpus is not installed, so the caller degrades loudly
    rather than under-counting.
    """
    try:
        from mgf.standard import read_standard
    except (ModuleNotFoundError, ImportError):
        return None
    try:
        readme = read_standard("README")
    except FileNotFoundError:
        return None
    ids: set[str] = set()
    in_index = False
    for line in readme.splitlines():
        if line.startswith("## "):
            in_index = line.strip() == "## Index of MUSTs"
            continue
        if in_index and line.startswith("|"):
            first = line.split("|")[1].strip()
            if _RULE_ID_RE.fullmatch(first):
                ids.add(first)
    return ids


def _check_conformance_ledger_complete(shape: _ProjectShape) -> Finding:
    """DOC-15 / CFM — ``MGF_STANDARDS_CONFORMANCE.md`` MUST account for every
    current MUST in the corpus (each gets a ✅ / 🚫 / ⏳ / N/A row). A ledger
    that omits rule ids is a PARTIAL sync — the exact failure mode the
    federation-sync hardening closes. Out-of-shape MUSTs are still *accounted*
    for (as N/A), so every id must appear regardless of project shape.
    """
    must_ids = _corpus_must_ids()
    if must_ids is None:
        return Finding(
            "DOC-15",
            "unknown",
            "cannot verify conformance-ledger completeness — the standards "
            "corpus is not installed. Install it to enable the check: "
            "pip install -U 'mgf-common[standards]'.",
        )
    if not must_ids:
        return Finding(
            "DOC-15",
            "unknown",
            "the corpus 'Index of MUSTs' resolved empty — cannot verify the "
            "conformance ledger (corpus may be older than the index).",
        )
    candidates = (
        shape.root / "docs" / "inprogress" / "MGF_STANDARDS_CONFORMANCE.md",
        shape.root / "docs" / "MGF_STANDARDS_CONFORMANCE.md",
    )
    ledger = next((p for p in candidates if p.is_file()), None)
    if ledger is None:
        return Finding(
            "DOC-15",
            "fail",
            "MGF_STANDARDS_CONFORMANCE.md not found (DOC-15 / CFM-01 require it "
            "at docs/inprogress/). Every project MUST ship a per-rule "
            f"conformance ledger accounting for all {len(must_ids)} current MUSTs.",
        )
    try:
        text = ledger.read_text(encoding="utf-8")
    except OSError as exc:
        return Finding("DOC-15", "fail", f"could not read {ledger}: {exc}")
    accounted = set(_RULE_ID_RE.findall(text))
    missing = sorted(must_ids - accounted)
    rel = ledger.relative_to(shape.root)
    if missing:
        preview = ", ".join(missing[:15])
        more = f" … (+{len(missing) - 15} more)" if len(missing) > 15 else ""
        return Finding(
            "DOC-15",
            "fail",
            f"conformance ledger is INCOMPLETE — {len(missing)} of "
            f"{len(must_ids)} current MUSTs are unaccounted for in {rel} "
            f"(no row at all). A partial ledger is a partial sync; give each a "
            f"status (✅ / 🚫 / ⏳ / N/A):\n  {preview}{more}",
        )
    return Finding(
        "DOC-15",
        "ok",
        f"conformance ledger accounts for all {len(must_ids)} current MUSTs",
    )


def _check_lesson_learned_present(shape: _ProjectShape) -> Finding:
    """DOC-19 — every project MUST keep a root ``LESSON_LEARNED.md`` (an
    append-only, dated lessons log so the federation stops re-learning the same
    thing). **Presence is the bar** — a seeded file with no real lessons yet is
    conformant.

    Corpus-aware + inert by design (PAPER-99 staging): DOC-19 shipped as a
    SHOULD in standards v2.20 and was promoted to a MUST in v2.21, so this gate
    fires ONLY when the resolved corpus carries ``DOC-19`` in its Index of MUSTs.
    A project pinning an older ``mgf-standard`` is unaffected — the gate
    activates exactly when the project's pinned corpus makes DOC-19 a MUST.
    """
    must_ids = _corpus_must_ids()
    if must_ids is None:
        return Finding(
            "DOC-19",
            "unknown",
            "cannot verify LESSON_LEARNED.md (DOC-19) — the standards corpus is "
            "not installed. Install it: pip install -U 'mgf-common[standards]'.",
        )
    if "DOC-19" not in must_ids:
        return Finding(
            "DOC-19",
            "ok",
            "LESSON_LEARNED.md not required by the pinned corpus (DOC-19 is a "
            "MUST only since standards v2.21) — nothing to check.",
        )
    if (shape.root / "LESSON_LEARNED.md").is_file():
        return Finding("DOC-19", "ok", "LESSON_LEARNED.md present at repo root.")
    return Finding(
        "DOC-19",
        "fail",
        "LESSON_LEARNED.md missing at repo root (DOC-19, MUST since standards "
        "v2.21) — an append-only, dated lessons log. A seeded file is "
        "conformant; create one from the lesson-learned-md-template recipe "
        "(`mgf-fed sync-prompt`).",
    )


def _check_memory_dir(shape: _ProjectShape) -> Finding:
    """AG-17 — every project MUST keep committed in-repo agent memory at
    ``docs/memory/MEMORY.md`` (durable, federation-scoped facts that travel
    with the repo). **Presence is the bar** — a seeded index with no real
    memories yet is conformant.

    Corpus-aware + inert by design: AG-17 shipped in standards v2.22
    (mgf-standard 0.1.54), so this gate fires ONLY when the resolved corpus
    carries ``AG-17`` in its Index of MUSTs. A project pinning an older
    ``mgf-standard`` is unaffected.
    """
    must_ids = _corpus_must_ids()
    if must_ids is None:
        return Finding(
            "AG-17",
            "unknown",
            "cannot verify docs/memory/MEMORY.md (AG-17) — the standards corpus "
            "is not installed. Install it: pip install -U 'mgf-common[standards]'.",
        )
    if "AG-17" not in must_ids:
        return Finding(
            "AG-17",
            "ok",
            "docs/memory/MEMORY.md not required by the pinned corpus (AG-17 is a "
            "MUST only since standards v2.22) — nothing to check.",
        )
    if (shape.root / "docs" / "memory" / "MEMORY.md").is_file():
        return Finding("AG-17", "ok", "docs/memory/MEMORY.md present")
    return Finding(
        "AG-17",
        "fail",
        "docs/memory/MEMORY.md missing (AG-17) — committed in-repo agent "
        "memory; a seeded index is conformant; run `mgf-fed catch-up "
        "--apply` to seed it",
    )


def _check_gitignore_my_stuff(shape: _ProjectShape) -> Finding:
    """AG-18 — every repo's ``.gitignore`` MUST ignore ``my_stuff/`` (the
    local secrets / tokens dir, which MUST never be committed).

    Corpus-aware + inert by design: AG-18 shipped in standards v2.22
    (mgf-standard 0.1.54), so this gate fires ONLY when the resolved corpus
    carries ``AG-18`` in its Index of MUSTs.
    """
    must_ids = _corpus_must_ids()
    if must_ids is None:
        return Finding(
            "AG-18",
            "unknown",
            "cannot verify .gitignore ignores my_stuff/ (AG-18) — the standards "
            "corpus is not installed. Install it: pip install -U "
            "'mgf-common[standards]'.",
        )
    if "AG-18" not in must_ids:
        return Finding(
            "AG-18",
            "ok",
            "my_stuff/ ignore not required by the pinned corpus (AG-18 is a MUST "
            "only since standards v2.22) — nothing to check.",
        )
    try:
        lines = (shape.root / ".gitignore").read_text(encoding="utf-8").splitlines()
    except (OSError, UnicodeDecodeError):
        # UnicodeDecodeError is a ValueError, NOT an OSError: a latin-1
        # `.gitignore` used to escape this handler and crash the whole audit.
        lines = []
    if any(line.strip() == "my_stuff/" for line in lines):
        return Finding("AG-18", "ok", "my_stuff/ is git-ignored (AG-18)")
    return Finding(
        "AG-18",
        "fail",
        "`.gitignore` does not ignore `my_stuff/` (AG-18) — local "
        "secrets/tokens MUST never be committed; run `mgf-fed catch-up "
        "--apply`",
    )


#: The one path AG-18(a) says MUST NOT be ignored: org agent-permission policy
#: that is meant to travel with the repo.
_ORG_POLICY_PATH = ".claude/org/settings.json"


def _ignores_org_policy(lines: list[str]) -> bool:
    """Would these ``.gitignore`` lines withhold ``.claude/org/settings.json``?

    Evaluates the ordered rules against **one fixed path**, which is why this
    can be exact without reimplementing gitignore in general. Two git rules do
    all the work here, and both were learned from real defects on the fleet:

    * **last match wins** — a later rule overrides an earlier one;
    * **a file cannot be re-included once a parent directory is excluded**, so
      ``.claude/*`` + ``!.claude/settings.json`` never reaches ``org/``. Four
      repos were silently withholding policy by exactly that mechanism, and a
      bare ``!.claude/org/settings.json`` "fix" would have been a no-op.

    Deliberately git-free: ``app-doctor`` runs on ``python:3.12-slim``, which
    ships no git, so a ``git check-ignore`` implementation would degrade to
    "cannot verify" in the only place it needs to run. A differential test pins
    this against real ``git check-ignore`` output across every repo on a bench
    that has git.
    """
    # Segment prefixes whose exclusion would trap the file: the dirs, then the
    # file itself. Each is evaluated in .gitignore order, last match winning.
    targets = (".claude", ".claude/org", _ORG_POLICY_PATH)
    excluded = {t: False for t in targets}

    for raw in lines:
        # rstrip only: git does NOT strip LEADING whitespace from a rule, so a
        # tab-indented `\t.claude/org/` is not a rule at all. Stripping it made
        # this check fail repos git does not ignore.
        line = raw.rstrip()
        if not line or line.startswith("#"):
            continue
        negated = line.startswith("!")
        pat = (line[1:] if negated else line).rstrip("/")
        if not pat:
            continue
        for t in targets:
            if _pattern_hits(pat, t):
                excluded[t] = not negated

    # A parent excluded and never re-admitted traps everything beneath it.
    if excluded[".claude"] and not _readmitted(lines, ".claude"):
        return True
    if excluded[".claude/org"]:
        return True
    return excluded[_ORG_POLICY_PATH]


def _readmitted(lines: list[str], path: str) -> bool:
    """Is ``path`` re-included by the LAST rule that mentions it?

    Order matters and the first version ignored it: it scanned every line for
    ``!<path>`` and returned True even when a later rule re-excluded the path,
    so a repo that genuinely withholds org policy was greenlit. Last match
    wins, exactly as git resolves it.
    """
    state = False
    for raw in lines:
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        negated = line.startswith("!")
        pat = (line[1:].strip() if negated else line).rstrip("/")
        if pat.startswith("/"):
            pat = pat[1:]
        if pat == path:
            state = negated
    return state


def _glob_to_regex(pat: str) -> str:
    """Translate a gitignore glob to a regex, with git's wildcard semantics.

    The distinction that matters — and that `fnmatch` gets wrong for this
    purpose — is that in gitignore **``*`` does not cross ``/``**, while
    ``**`` does. Using `fnmatch` here made ``.claude/*`` match
    ``.claude/org/settings.json`` directly, which is not what git does: git
    matches the *directory* ``.claude/org`` and then withholds the file by the
    parent-exclusion rule. Same answer, different route — and the wrong route
    left the parent-exclusion branch below unreachable, so a sabotage of it
    did not fail a single test. Found by sabotaging it.
    """
    import re

    out: list[str] = []
    i = 0
    while i < len(pat):
        c = pat[i]
        if c == "*":
            if pat.startswith("**", i):
                out.append(".*")
                i += 2
                continue
            out.append("[^/]*")
        elif c == "?":
            out.append("[^/]")
        elif c == "[":
            # A character class. `re.escape` made `[` a literal, so
            # `.claude/or[g]/` matched nothing and the org-policy check reported
            # OK for a repo that DOES ignore the file — a false NEGATIVE on the
            # one rule this gate exists to enforce.
            j = i + 1
            if j < len(pat) and pat[j] in "!^":
                j += 1
            if j < len(pat) and pat[j] == "]":
                j += 1
            close = pat.find("]", j)
            if close == -1:  # unterminated: git treats it as a literal '['
                out.append(re.escape(c))
            else:
                body = pat[i + 1 : close]
                if body.startswith("!"):
                    body = "^" + body[1:]
                out.append("[" + body.replace("/", "") + "]")
                i = close + 1
                continue
        else:
            out.append(re.escape(c))
        i += 1
    return "^" + "".join(out) + "$"


def _pattern_hits(pat: str, target: str) -> bool:
    """Does one ``.gitignore`` pattern match ``target`` (a repo-relative path)?

    Covers the shapes that occur on this fleet: exact paths, one-level
    wildcards (``.claude/*``), and basename patterns. Anything unrecognised
    simply does not match, which keeps this check from inventing a violation.
    """
    import re

    if pat == target:
        return True
    if "/" in pat:
        return re.match(_glob_to_regex(pat.lstrip("/")), target) is not None
    # A pattern with no slash matches any path component (basename match).
    rx = _glob_to_regex(pat)
    return any(re.match(rx, seg) for seg in target.split("/"))


def _check_gitignore_org_policy_travels(shape: _ProjectShape) -> Finding:
    """AG-18(a) — ``.claude/org/settings.json`` MUST NOT be ignored.

    It is org agent-permission policy and is meant to **travel with the repo**.
    Enforced as a hard failure rather than staged, because it costs nothing to
    obey and the fleet already obeys it: when this shipped, **0 of 65** MGF-fleet
    repos withheld the file. The *other* half of the same rule — carrying the
    four machine-local/runtime ignore lines — is distributed advisorily through
    ``catch-up``, because requiring it today would fail 61 repos.

    The failure this catches is silent by construction: a withheld policy file
    produces no error, no diff and no red gate. It looks exactly like a repo
    that has no org profile.
    """
    must_ids = _corpus_must_ids()
    if must_ids is None:
        return Finding(
            "AG-18",
            "unknown",
            "cannot verify that `.claude/org/settings.json` is not ignored "
            "(AG-18a) — the standards corpus is not installed. Install it: "
            "pip install -U 'mgf-common[standards]'.",
        )
    if not must_ids:
        return Finding(
            "AG-18",
            "unknown",
            "cannot verify org-profile travel (AG-18a) — the corpus Index of MUSTs "
            "parsed to an EMPTY set, so 'not required' cannot be distinguished from "
            "'could not be read'.",
        )
    if "AG-18" not in must_ids:
        return Finding(
            "AG-18",
            "ok",
            "org-profile travel not required by the pinned corpus (AG-18a is a "
            "MUST only since corpus v3.10.0) — nothing to check.",
        )
    try:
        lines = (shape.root / ".gitignore").read_text(encoding="utf-8").splitlines()
    except OSError as exc:
        # "I could not verify" MUST NOT share an exit code with "I verified".
        # An unreadable .gitignore previously fell through to lines=[] and
        # reported OK — indistinguishable from a repo with no rules at all.
        return Finding(
            "AG-18",
            "unknown",
            f"cannot verify org-profile travel (AG-18a) — `.gitignore` is present "
            f"but unreadable ({type(exc).__name__}).",
        )
    except UnicodeDecodeError:
        return Finding(
            "AG-18",
            "unknown",
            "cannot verify org-profile travel (AG-18a) — `.gitignore` is not UTF-8.",
        )
    if not _ignores_org_policy(lines):
        return Finding(
            "AG-18",
            "ok",
            "`.claude/org/settings.json` is trackable — org policy travels (AG-18a)",
        )
    return Finding(
        "AG-18",
        "fail",
        "`.gitignore` IGNORES `.claude/org/settings.json` (AG-18a) — that file is "
        "org agent-permission policy and MUST travel with the repo, but this repo "
        "withholds it silently. If a `.claude/*` or `.claude/org/` deny is the "
        "cause, note that git cannot re-include a file whose parent directory is "
        "excluded: re-admit the directory first "
        "(`!.claude/org/`, then `.claude/org/*`, then `!.claude/org/settings.json`).",
    )


def _git_available() -> bool:
    """Whether a ``git`` binary is on PATH at all.

    The doctor runs inside slim CI containers that ship no git (only the
    ``commit-trailers`` step apt-installs it). Without git we cannot *determine*
    the hook wiring — and "cannot determine" MUST NOT be reported as "inert",
    which would be a false accusation on every containerised run.
    """
    return shutil.which("git") is not None


def _git_stdout(root: Path, *args: str) -> str:
    """Best-effort ``git -C <root> <args>`` stdout (stripped); ``""`` on any failure."""
    try:
        proc = subprocess.run(
            ["git", "-C", str(root), *args],  # noqa: S607
            capture_output=True,
            text=True,
            check=False,
            timeout=10,
        )
    except (OSError, subprocess.SubprocessError):
        return ""
    return proc.stdout.strip() if proc.returncode == 0 else ""


def _hook_wiring_active(root: Path) -> bool:
    """Whether the committed AG-03 hook can actually FIRE (PAPER-117).

    True when either wiring of ``docs/recipes/standardized-git-config.md`` is
    live: §4a — the repo-local ``core.hooksPath`` points at ``.githooks``; or
    §4b — a ``.git/hooks/prepare-commit-msg`` shim exists (the pre-commit-
    framework path, which cannot set ``core.hooksPath``).
    """
    hooks_path = _git_stdout(root, "config", "--local", "--get", "core.hooksPath")
    if hooks_path.rstrip("/") == ".githooks" or Path(hooks_path).name == ".githooks":
        return True
    # The shim lives in the COMMON git dir (worktree-safe resolution).
    common = _git_stdout(root, "rev-parse", "--git-common-dir")
    if not common:
        return False
    common_dir = Path(common)
    if not common_dir.is_absolute():
        common_dir = root / common_dir
    return (common_dir / "hooks" / "prepare-commit-msg").is_file()


def _check_commit_hook(shape: _ProjectShape) -> Finding:
    """AG-18 — the committed ``.githooks/prepare-commit-msg`` hook MUST be present
    so ``git commit -m`` still carries the AG-03 ``Signed-off-by`` (the
    ``.gitmessage`` template only fires on interactive commits).

    Self-gating + inert by design: enforced ONLY when the pinned corpus's AGENTS
    text references ``prepare-commit-msg`` (the tightened AG-18 — mgf-common
    FEEDBACK PAPER-102). Until AG-18 is tightened fleet-wide this returns ``ok``
    (nothing required), so it never retro-fails a repo whose corpus predates the
    hook requirement.

    PAPER-117: presence alone is not health — a committed hook that neither
    ``core.hooksPath=.githooks`` nor a ``.git/hooks/prepare-commit-msg`` shim
    activates never fires, and the AG-03 signoff silently drops until the CI
    ``commit-trailers`` gate reds the branch. That state WARNs (advisory, never
    FAIL — the PAPER-100 staging discipline).
    """
    try:
        from mgf.standard import read_standard
    except (ModuleNotFoundError, ImportError):
        return Finding(
            "AG-18",
            "unknown",
            "cannot verify the prepare-commit-msg hook (AG-18) — the standards "
            "corpus is not installed. Install it: pip install -U 'mgf-common[standards]'.",
        )
    try:
        agents = read_standard("AGENTS")
    except FileNotFoundError:
        return Finding("AG-18", "unknown", "cannot read the AGENTS corpus to verify AG-18.")
    if "prepare-commit-msg" not in agents:
        return Finding(
            "AG-18",
            "ok",
            "prepare-commit-msg hook not required by the pinned corpus "
            "(the AG-18 hook clause is not in this corpus yet) — nothing to check.",
        )
    if (shape.root / ".githooks" / "prepare-commit-msg").is_file():
        if not _git_available():
            # No git binary (slim CI container): the hook is PRESENT, and its
            # wiring is simply not observable here. Report presence, not a
            # false "inert" — the local wiring is a developer-machine concern
            # anyway; CI's authoritative half is the commit-trailers gate.
            return Finding(
                "AG-18",
                "ok",
                "prepare-commit-msg hook present (AG-18); wiring not checked — no `git` "
                "on PATH (slim container). The authoritative AG-03 gate is CI's "
                "`commit-trailers` step.",
            )
        if _hook_wiring_active(shape.root):
            return Finding("AG-18", "ok", "prepare-commit-msg hook present + active (AG-18)")
        return Finding(
            "AG-18",
            "warn",
            "`.githooks/prepare-commit-msg` is committed but INERT (PAPER-117) — "
            "neither `core.hooksPath=.githooks` is set nor a "
            "`.git/hooks/prepare-commit-msg` shim exists, so `git commit -m` "
            "silently drops the AG-03 Signed-off-by. Wire it: `git config "
            "core.hooksPath .githooks` (standardized-git-config.md §4a), or the "
            "pre-commit path (§4b) on pre-commit-framework repos.",
        )
    return Finding(
        "AG-18",
        "fail",
        "`.githooks/prepare-commit-msg` is missing (AG-18) — `git commit -m` would "
        "drop the AG-03 Signed-off-by; run `mgf-fed catch-up --apply`",
    )


def _runtime_memory_dir(root: Path) -> Path:
    """The harness encodes a project's auto-memory path by replacing ``/`` with
    ``-`` in its working-dir path, under ``<claude-config>/projects/<slug>/memory``.
    Honors ``CLAUDE_CONFIG_DIR`` (the env var Claude Code uses to relocate
    ``~/.claude``), defaulting to ``~/.claude``."""
    base = Path(os.environ.get("CLAUDE_CONFIG_DIR") or (Path.home() / ".claude"))
    slug = str(root.resolve()).replace("/", "-")
    return base / "projects" / slug / "memory"


def _check_memory_store_vcs(shape: _ProjectShape) -> Finding:
    """AG-19 — the machine-local runtime auto-memory store
    (``~/.claude/projects/<slug>/memory/``) MUST be version-controlled and backed
    off-machine: a git repo, carrying the allowlist ``.gitignore`` that keeps the
    parent dir's session ``*.jsonl`` transcripts (which can contain secrets) out,
    pushed to a private ``<project>-memory`` remote.

    Machine-local self-check by design: the store lives OUTSIDE the repo and is
    private to the machine, so server-side CI cannot see it — this local audit is
    AG-19's enforcement. Corpus-aware + inert: AG-19 shipped in standards v2.25,
    so the gate fires ONLY when the resolved corpus carries ``AG-19`` in its Index
    of MUSTs. When no store exists yet (no agent has written memory for this
    project), there is nothing to track and the check passes.
    """
    must_ids = _corpus_must_ids()
    if must_ids is None:
        return Finding(
            "AG-19",
            "unknown",
            "cannot verify the runtime memory store (AG-19) — the standards corpus "
            "is not installed. Install it: pip install -U 'mgf-common[standards]'.",
        )
    if "AG-19" not in must_ids:
        return Finding(
            "AG-19",
            "ok",
            "memory-store version-control not required by the pinned corpus (AG-19 "
            "is a MUST only since standards v2.25) — nothing to check.",
        )
    mem = _runtime_memory_dir(shape.root)
    if not mem.is_dir():
        return Finding(
            "AG-19",
            "ok",
            "no runtime auto-memory store for this project yet "
            "(~/.claude/projects/<slug>/memory/ absent) — nothing to track.",
        )
    problems: list[str] = []
    if not (mem / ".git").is_dir():
        problems.append("not under version control (no .git)")
    # The allowlist .gitignore is the secret-leak guard: the parent dir holds
    # session *.jsonl transcripts that can carry tokens, so the store MUST
    # ignore-all-then-allow-markdown. Its absence is the dangerous failure.
    gi = mem / ".gitignore"
    try:
        gi_lines = (
            [ln.strip() for ln in gi.read_text(encoding="utf-8").splitlines()]
            if gi.is_file()
            else []
        )
    except (OSError, UnicodeDecodeError):
        gi_lines = []
    if not ("*" in gi_lines and "!*.md" in gi_lines):
        problems.append(
            "missing the allowlist .gitignore (the secret-leak guard for session transcripts)"
        )
    # An off-machine remote — read .git/config directly (no subprocess; this audit
    # is pure-filesystem + read-only).
    cfg = mem / ".git" / "config"
    if not (cfg.is_file() and "[remote " in cfg.read_text(encoding="utf-8")):
        problems.append("no off-machine git remote configured")
    if problems:
        return Finding(
            "AG-19",
            "fail",
            "runtime memory store at ~/.claude/projects/<slug>/memory/ is "
            f"non-conformant (AG-19): {'; '.join(problems)}. Bootstrap it with "
            "mgf-standard's `python3 scripts/init_memory_vcs.py` (private "
            "`<project>-memory` remote; allowlist .gitignore).",
        )
    return Finding(
        "AG-19",
        "ok",
        "runtime memory store is git-tracked with the allowlist .gitignore + an "
        "off-machine remote (AG-19)",
    )


def _check_supervisor_coverage_patch(shape: _ProjectShape) -> Finding:
    """TS-23 (CI coupling) — a pipeline that runs the supervisor under ``coverage
    run`` MUST pair it with ``[tool.coverage.run] patch = ["subprocess"]``.

    The TS-23 canonical path runs pytest in CHILD processes, so coverage started at
    the process top sees nothing without the patch key (coverage >= 7.10). The build
    then reds with **every test passing** — "No data was collected" → ``coverage
    combine`` → "No data to combine" → exit 1 — an error message that names neither
    the key nor pyproject. It hit three repos in a single re-render wave.

    A **fail**, not a warn: both halves of the coupling are read from the repo's own
    committed files, so the finding is exact, and the consequence is a certain,
    already-observed build failure — not a matter of taste. It costs a real debugging
    session every time, and this check is the only thing that names the fix.

    N/A (``ok``) when the repo ships no pipeline, or its pipeline does not run the
    supervisor under coverage — most consumers, and never a false alarm.
    """
    wp = shape.root / ".woodpecker.yml"
    if not wp.is_file():
        return Finding("TS-23", "ok", "no .woodpecker.yml — no CI coverage coupling to check.")
    try:
        pipeline = wp.read_text(encoding="utf-8")
    except OSError as exc:
        return Finding("TS-23", "warn", f"could not read {wp}: {exc}")
    if not _pipeline.runs_supervisor_under_coverage(pipeline):
        return Finding(
            "TS-23",
            "ok",
            "CI does not run `coverage run … -m mgf.test_supervisor` — the "
            'subprocess-coverage coupling does not apply (no patch = ["subprocess"] needed).',
        )
    if _pipeline.has_subprocess_patch(shape.pyproject):
        return Finding(
            "TS-23",
            "ok",
            "CI runs the supervisor under coverage AND pyproject carries "
            '[tool.coverage.run] patch = ["subprocess"] — child-process coverage is measured.',
        )
    return Finding(
        "TS-23",
        "fail",
        ".woodpecker.yml runs `coverage run … -m mgf.test_supervisor` but pyproject.toml "
        'has no [tool.coverage.run] patch = ["subprocess"] — this build WILL red with every '
        f"test passing. Fix: {_pipeline.COVERAGE_PATCH_FIX}",
    )


# Order matters — output reads top-down.
CHECKS = (
    _check_py_typed_marker,
    _check_version_sync,
    _check_filterwarnings,
    _check_naive_datetime,
    _check_propagation_headers,
    _check_conformance_ledger_complete,
    _check_lesson_learned_present,
    _check_memory_dir,
    _check_gitignore_my_stuff,
    _check_gitignore_org_policy_travels,
    _check_commit_hook,
    _check_memory_store_vcs,
    _check_supervisor_coverage_patch,
)


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------


def doctor_standards(*, root: Path | None = None) -> int:
    """Run the standards-conformance audit; print + exit code.

    Returns ``0`` on full pass / warnings, ``EXIT_OPERATION_ERROR``
    on any FAIL.
    """
    shape = _discover_project(root or Path.cwd())
    if shape is None:
        print(
            "mgf-fed app-doctor --standards: cannot detect project "
            "shape (need a pyproject.toml with [project] name + a "
            "src/ layout). Run from a project root.",
            file=sys.stderr,
        )
        return EXIT_OPERATION_ERROR

    print(f"mgf-fed app-doctor --standards — auditing {shape.package_name} at {shape.root}\n")

    findings = [check(shape) for check in CHECKS]
    fails = sum(1 for f in findings if f.tier == "fail")
    warns = sum(1 for f in findings if f.tier == "warn")
    unknowns = sum(1 for f in findings if f.tier == "unknown")
    oks = sum(1 for f in findings if f.tier == "ok")

    glyph = {"ok": "✓", "warn": "⚠", "unknown": "?", "fail": "✗"}
    for f in findings:
        print(f"  {glyph.get(f.tier, '·')} {f.rule_id}  {f.message}\n")

    print(
        f"\n{oks} pass · {warns} warn · {unknowns} could-not-verify · "
        f"{fails} fail ({len(findings)} checks total)"
    )
    if fails:
        print(
            "\nAt least one rule failed. Read "
            "`docs/standards/<topic>.md` for the rule rationale "
            "(`mgf-fed standards <topic>`)."
        )
        return EXIT_OPERATION_ERROR
    if unknowns:
        # "I could not verify" MUST NOT share an exit code with "I verified".
        # These six used to be `warn`, and `warn` returned 0 — so a CI run with
        # the standards corpus missing reported a clean audit it never ran.
        print(
            f"\n{unknowns} check(s) COULD NOT RUN — this is not a pass. "
            "Most often the standards corpus is absent: "
            "pip install -U 'mgf-common[standards]'."
        )
        return EXIT_OPERATION_ERROR
    return EXIT_SUCCESS


__all__ = ["doctor_standards"]
