"""Drift guard: every public name MUST appear in PUBLIC_API.md (RA-10).

The library has two API-contract artifacts:

  - ``PUBLIC_API.md`` — hand-written, human-readable. The
    canonical place where each public name is documented with its
    stability tier (experimental / stable / deprecated) and
    semantic description.

  - ``PUBLIC_API.json`` — generated by
    ``tools/generate_public_api_json.py`` from the runtime
    ``__all__`` of each public module. Pinned in sync with itself
    by ``test_public_api_json_pin``.

The JSON catches "the contract drifted from `__all__`". This test
catches the orthogonal class: "I added a new public name, the
generator picked it up, but I forgot to write the doc row in
`PUBLIC_API.md`".

The check is one-directional: every name in the JSON's ``modules``
mapping MUST appear somewhere in ``PUBLIC_API.md`` as a code-span
(backtick-wrapped). We don't enforce the reverse direction
(every name mentioned in .md exists in __all__) because the .md
legitimately discusses internal helpers, removed names, and
narrative concepts that aren't public API.

Allowlist exists for names where the .md mentions them but only
indirectly (e.g., as a substring of a different name) — vanishingly
small in practice.
"""

from __future__ import annotations

import json
import re
from pathlib import Path

import pytest

_REPO_ROOT = Path(__file__).resolve().parent.parent.parent
_PUBLIC_API_MD = _REPO_ROOT / "PUBLIC_API.md"
_PUBLIC_API_JSON = _REPO_ROOT / "PUBLIC_API.json"

# Names that the JSON exports but PUBLIC_API.md legitimately does
# NOT mention by name. Empty today; if a future export turns up
# here, decide whether the right fix is to document it (preferred)
# or allowlist it (rare — usually only for re-exports of names
# that the .md documents under a different qualified path).
_ALLOWLIST: set[str] = set()


def _public_names() -> dict[str, list[str]]:
    """Authoritative module → names mapping from PUBLIC_API.json."""
    data = json.loads(_PUBLIC_API_JSON.read_text(encoding="utf-8"))
    return data["modules"]


def _md_text() -> str:
    return _PUBLIC_API_MD.read_text(encoding="utf-8")


def _name_appears_in_md(name: str, md: str) -> bool:
    r"""Does ``name`` appear in the .md as a code-span?

    We require backtick wrapping (``\`name\``) so that loose
    occurrences in prose ("the configure helper" — old name) don't
    count as documentation. Backtick wrapping is the
    Markdown-idiomatic way to refer to identifiers.
    """
    # \b doesn't work inside backticks reliably with markdown
    # parsers; build the pattern explicitly. The name may appear:
    #   - alone in backticks: `Name`
    #   - as part of a qualified path: `mgf.common.Name`
    #   - as part of a signature: `Name(arg)`
    # All three count.
    escaped = re.escape(name)
    # Match `...name...` where the surrounding context is
    # backtick-wrapped. Use a non-greedy backtick scan.
    pattern = re.compile(
        rf"`[^`]*\b{escaped}\b[^`]*`",
    )
    return bool(pattern.search(md))


def _missing_names() -> list[tuple[str, str]]:
    """Return (module, name) for every public name absent from the .md."""
    md = _md_text()
    missing: list[tuple[str, str]] = []
    for module, names in _public_names().items():
        for name in names:
            if name in _ALLOWLIST:
                continue
            if not _name_appears_in_md(name, md):
                missing.append((module, name))
    return missing


def test_every_public_name_appears_in_public_api_md() -> None:
    """RA-10: PUBLIC_API.json names that aren't documented in
    PUBLIC_API.md are drift — a new public name landed without a
    corresponding doc row.

    Fix path: add a row to the relevant ``## `mgf.common.X``` section
    of PUBLIC_API.md with the name, its stability tier, and a
    one-line description. Refer to existing rows for the format."""
    missing = _missing_names()
    if missing:
        lines = "\n".join(f"  {mod}.{name}" for mod, name in missing)
        pytest.fail(
            f"RA-10: {len(missing)} public name(s) appear in PUBLIC_API.json "
            f"but are NOT mentioned in PUBLIC_API.md as backtick-wrapped "
            f"identifiers:\n{lines}\n\n"
            f"Add a row to the relevant `## `mgf.common.X`` section of "
            f"PUBLIC_API.md with the name, stability tier, and a one-line "
            f"description. If the omission is intentional (rare — usually "
            f"only for re-exports), add the name to _ALLOWLIST in this test "
            f"with a justification."
        )


def test_allowlist_entries_still_in_public_api() -> None:
    """Allowlisted names that have since been removed from the
    public surface should be removed from the allowlist too —
    otherwise we're carrying dead exceptions."""
    all_public: set[str] = set()
    for names in _public_names().values():
        all_public.update(names)
    stale = _ALLOWLIST - all_public
    if stale:
        pytest.fail(
            f"RA-10: _ALLOWLIST contains {len(stale)} name(s) that are no "
            f"longer in PUBLIC_API.json — they were removed from the public "
            f"surface but the allowlist wasn't pruned: {sorted(stale)}"
        )
