"""``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 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

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
    tier: str  # "ok" / "warn" / "fail"
    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 _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:
            return None
        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 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."""
    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


def _read_dunder_version(package_root: Path) -> str | None:
    """Find ``__version__ = "..."`` in the package's ``__init__.py``
    or a ``_version.py`` sibling."""
    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
        for node in ast.walk(tree):
            if (
                isinstance(node, ast.Assign)
                and len(node.targets) == 1
                and isinstance(node.targets[0], ast.Name)
                and node.targets[0].id == "__version__"
                and isinstance(node.value, ast.Constant)
                and isinstance(node.value.value, str)
            ):
                return node.value.value
    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",
            "warn",
            "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",
            "warn",
            "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",
            "warn",
            "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",
            "warn",
            "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",
            "warn",
            "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:
        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`",
    )


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.
    """
    try:
        from mgf.standard import read_standard
    except (ModuleNotFoundError, ImportError):
        return Finding(
            "AG-18",
            "warn",
            "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", "warn", "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():
        return Finding("AG-18", "ok", "prepare-commit-msg hook present (AG-18)")
    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",
            "warn",
            "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"
    gi_lines = (
        [ln.strip() for ln in gi.read_text(encoding="utf-8").splitlines()] if gi.is_file() else []
    )
    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)",
    )


# 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_commit_hook,
    _check_memory_store_vcs,
)


# ---------------------------------------------------------------------------
# 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")
    oks = sum(1 for f in findings if f.tier == "ok")

    glyph = {"ok": "✓", "warn": "⚠", "fail": "✗"}
    for f in findings:
        print(f"  {glyph[f.tier]} {f.rule_id}  {f.message}\n")

    print(f"\n{oks} pass · {warns} warn · {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
    return EXIT_SUCCESS


__all__ = ["doctor_standards"]
