"""FB-02 / FB-03 conformance for a repo's own ``FEEDBACK.md`` papers.

The **outbox** side of feedback (``pending-feedback/``, FB-05) is
:mod:`mgf.fed._feedback`. This is the **inbox** side: the papers already filed in
a repo's ``FEEDBACK.md``, checked against the two rules that make the queue
machine-readable at all —

* **FB-02** — every paper MUST have a metadata *table* carrying ``Status``, drawn
  from ``OPEN`` / ``SHIPPED`` / ``DEFERRED`` / ``WONTFIX`` / ``MOVED``.
* **FB-03** — a ``SHIPPED`` paper MUST carry a ``Shipped in`` row, so a consumer
  can ``git blame`` the close.

**What this deliberately does NOT check: which section a paper sits in.** The
canonical template (mgf-standard ``docs/recipes/feedback-md-template.md``)
prescribes exactly five sections — §1 Process, §2 Open feedback (active), §3
Reviewer's meta-feedback, §4 How consumers submit feedback, §5 Cross-references —
and **no closed/shipped section**. Its §1 Process says the lifecycle is a *status
flip*, not a move: "Status flips through OPEN → SHIPPED / DEFERRED / WONTFIX /
MOVED as the paper resolves." So a SHIPPED paper sitting under "§2 Open feedback
(active)" is the shape the canon asks for, and a tool that relocated those 133
papers would be enforcing a policy the federation never adopted. ``Status`` is the
state; the section is just where papers live.

Fence-aware throughout via :func:`mgf.fed._feedback._fence_spans`: a ``###``
heading inside a ```markdown fence is documentation *about* a paper, not a paper.
Splitting naively on headings cut a paper in half in ``mgf-hrb`` once.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path

from mgf.fed._feedback import _fence_spans
from mgf.fed._roster import federation_root

#: The FB-02 status enum, in lifecycle order.
STATUSES = ("OPEN", "SHIPPED", "DEFERRED", "WONTFIX", "MOVED")

#: A paper heading: an ``###``/``####`` heading bearing a PAPER id. The id is what
#: separates a paper from an ordinary subsection inside one (``#### Notes``).
_PAPER = re.compile(r"^ {0,3}#{3,4}[ \t]+.*?PAPER-\d+", re.MULTILINE)


def _row_re(name: str) -> str:
    """A metadata-table row matcher: ``| Status | OPEN |``.

    Built by concatenation, never ``str.format`` — the pattern carries a ``{0,3}``
    quantifier, which a format call reads as a field name and dies on.
    """
    return r"^ {0,3}\|\s*\**" + re.escape(name) + r"\**\s*\|\s*(?P<val>[^|]*?)\s*\|"


def _bullet_re(name: str) -> str:
    """The bullet form (``- **Status:** OPEN``). FB-02 says *table*, so this exists
    only to tell "wrong shape" apart from "absent" — never to accept it."""
    return r"^ {0,3}[-*]?\s*\**" + re.escape(name) + r"\**\s*:\s*\**\s*(?P<val>[^\n]+)"


@dataclass(frozen=True, slots=True)
class Paper:
    repo: str
    heading: str
    line: int
    text: str


@dataclass(frozen=True, slots=True)
class Violation:
    repo: str
    heading: str
    line: int
    rule: str  # "FB-01" / "FB-02" / "FB-03"
    detail: str

    def __str__(self) -> str:
        return f"{self.repo}:{self.line}  {self.rule}  {self.heading} — {self.detail}"


def _table_field(text: str, name: str) -> str | None:
    m = re.search(_row_re(name), text, re.MULTILINE | re.IGNORECASE)
    return m.group("val").strip() if m else None


def _bullet_field(text: str, name: str) -> str | None:
    m = re.search(_bullet_re(name), text, re.MULTILINE | re.IGNORECASE)
    return m.group("val").strip() if m else None


#: Cell values that carry no information — the seeded placeholder and its cousins.
_PLACEHOLDER = re.compile(
    "^(?:[\\s\u2014\u2013\\-_.?]*|n/?a|tbd|pending|none)$",  # \u2014/\u2013 = em/en dash
    re.IGNORECASE,
)


def _is_placeholder(value: str) -> bool:
    return bool(_PLACEHOLDER.match(value.strip()))


#: Words that negate or hedge the enum member they precede. Matched against the
#: tail of the text before the member, so only an ADJACENT qualifier counts —
#: "SHIPPED, not deferred" must stay SHIPPED.
_QUALIFIER = re.compile(
    r"\b(?:NOT|NEVER|PARTIAL|PARTIALLY|ALMOST|NEARLY|SEMI|HALF|MOSTLY|PENDING)\b[\s\-—:,]*$"
)


def _status_token(raw: str) -> str | None:
    """The enum member named by a Status cell, or None.

    Cells carry decoration — ``✅ SHIPPED (Approach 1) in **0.52.0**`` — so this
    looks for an enum member as a *word*, rather than demanding an exact match.
    ``RESOLVED``/``CLOSED``/``ACCEPTED`` deliberately do NOT map onto SHIPPED: they
    are outside the enum, and silently folding them in is how an out-of-enum state
    survives (F-36/F-37 took the credential plane down for two days that way).
    """
    upper = raw.upper()
    for s in STATUSES:
        m = re.search(rf"\b{s}\b", upper)
        if not m:
            continue
        # A qualifier in front turns the word into its own negation. `PARTIALLY
        # SHIPPED` and `NOT SHIPPED` both matched `\bSHIPPED\b` and were certified
        # as SHIPPED — found on mgf-secrets PAPER-02, whose remaining third is
        # explicitly founder-gated. Reading that as closed is the exact misreport
        # FB-02 exists to stop, so a qualified status is out-of-enum and must be
        # restated as the member that is actually true.
        if _QUALIFIER.search(upper[: m.start()]):
            return None
        return s
    return None


def papers(text: str, repo: str) -> list[Paper]:
    """Every paper in a FEEDBACK.md body, as raw slices (never normalised)."""
    spans = _fence_spans(text)
    hits = [
        m for m in _PAPER.finditer(text) if not any(f.start <= m.start() < f.end for f in spans)
    ]
    out: list[Paper] = []
    for i, m in enumerate(hits):
        end = hits[i + 1].start() if i + 1 < len(hits) else len(text)
        line_end = text.find("\n", m.start())
        heading = text[m.start() : line_end if line_end != -1 else len(text)].strip()
        out.append(
            Paper(
                repo=repo,
                heading=heading.lstrip("# ").strip(),
                line=text.count("\n", 0, m.start()) + 1,
                text=text[m.start() : end],
            )
        )
    return out


#: A paper's id, for the FB-01 uniqueness check. Anchored to the id and its
#: boundary so `PAPER-80-original-concern` is NOT read as a second `PAPER-80` —
#: that suffix form is a legitimate convention in mgf-common, and counting it as
#: a duplicate would make the gate cry wolf on four papers that are fine.
_PAPER_ID = re.compile(r"PAPER-(\d+)(?![\w-])")


def _paper_id(heading: str) -> str | None:
    m = _PAPER_ID.search(heading)
    return f"PAPER-{m.group(1)}" if m else None


#: An entry heading that carries an id which is NOT `PAPER-NN`. mgf-common's
#: FEEDBACK.md holds `ASPIRE-01..06` in exactly the paper shape, and this gate
#: does not judge them — but it must SAY so. Silently skipping them is how
#: `PAPER-53`'s block grew to 871 lines with six other entries inside it, their
#: metadata tables attributed to a paper that does not own them.
_OTHER_ID = re.compile(r"^ {0,3}#{3,4}[ \t]+.*?(?<!PAPER)\b([A-Z][A-Z]{2,7})-\d+", re.MULTILINE)


def skipped_entries(text: str, repo: str) -> list[str]:
    """Entry headings this gate does NOT judge, so a caller can report them.

    "No silent caps": a check that bounds its own coverage has to name what it
    left out, or its clean exit reads as coverage it never had.
    """
    from mgf.fed._feedback import _fence_spans

    spans = _fence_spans(text)
    out = []
    for m in _OTHER_ID.finditer(text):
        if any(f.start <= m.start() < f.end for f in spans):
            continue
        if "PAPER-" in m.group(0):
            continue
        out.append(m.group(1) + "-" + m.group(0).split(m.group(1) + "-")[1].split()[0].strip("`*"))
    return sorted(set(out))


def check_text(text: str, repo: str) -> list[Violation]:
    """FB-01/FB-02/FB-03 violations in one FEEDBACK.md body."""
    found: list[Violation] = []

    # FB-01 — "papers keep their PAPER-NN forever for stable git-blame +
    # cross-reference". Two papers sharing an id breaks every reference to it,
    # and neither one is identifiable afterwards. mgf-standard PAPER-141 filed
    # this as a risk ("nothing detects a duplicate at merge time") and rated it
    # "nothing is broken today"; mgf-common carried two different PAPER-119s
    # while that assessment stood, which is what a detector is for.
    seen: dict[str, Paper] = {}
    for p in papers(text, repo):
        pid = _paper_id(p.heading)
        if pid is None:
            continue
        if pid in seen:
            found.append(
                Violation(
                    p.repo,
                    p.heading,
                    p.line,
                    "FB-01",
                    f"{pid} is already used at line {seen[pid].line} "
                    f"({seen[pid].heading[:60]}) — ids are never reused",
                )
            )
        else:
            seen[pid] = p

    for p in papers(text, repo):
        cell = _table_field(p.text, "Status")
        if cell is None:
            bullet = _bullet_field(p.text, "Status")
            detail = (
                f"Status is in bullet form ({bullet!r}), not the required metadata table"
                if bullet is not None
                else "no Status field at all"
            )
            found.append(Violation(p.repo, p.heading, p.line, "FB-02", detail))
            continue
        status = _status_token(cell)
        if status is None:
            found.append(
                Violation(
                    p.repo,
                    p.heading,
                    p.line,
                    "FB-02",
                    f"Status {cell!r} is outside the enum {'/'.join(STATUSES)}",
                )
            )
            continue
        if status == "SHIPPED":
            shipped = _table_field(p.text, "Shipped in")
            if shipped is None:
                detail = "SHIPPED without a `Shipped in` row (commit SHA + release version)"
            elif _is_placeholder(shipped):
                # The template seeds `| Shipped in | — |`, which is honest while a paper
                # is OPEN and a lie once it says SHIPPED. Treating a present-but-empty
                # cell as satisfied is how a gate passes a repo it never really checked.
                detail = f"SHIPPED but `Shipped in` is still the placeholder {shipped!r}"
            else:
                continue
            found.append(Violation(p.repo, p.heading, p.line, "FB-03", detail))
    return found


def check_file(path: Path, repo: str) -> list[Violation]:
    try:
        text = path.read_text(encoding="utf-8")
    except OSError as exc:
        return [Violation(repo, str(path), 0, "FB-02", f"unreadable: {exc}")]
    return check_text(text, repo)


def _feedback_files(root: Path | None = None) -> list[tuple[Path, str]]:
    base = root if root is not None else federation_root()
    out: list[tuple[Path, str]] = []
    for d in sorted(p for p in base.iterdir() if p.is_dir()):
        f = d / "FEEDBACK.md"
        if f.is_file():
            out.append((f, d.name))
    return out


def check_fleet(root: Path | None = None) -> list[Violation]:
    found: list[Violation] = []
    for path, repo in _feedback_files(root):
        found.extend(check_file(path, repo))
    return found


def run_papers(repo_dir: Path | None, *, all_repos: bool, root: Path | None = None) -> int:
    """``mgf-fed feedback --papers``. 0 = conformant, 1 = violations, 2 = nothing to check.

    Exit 2 for "no FEEDBACK.md here" is not pedantry: a gate that returns 0 when it
    checked nothing reports "I could not verify" with the same exit code as
    "I verified", which is the failure this whole check exists to end.
    """
    if all_repos:
        pairs = _feedback_files(root)
        if not pairs:
            print(f"feedback --papers: no FEEDBACK.md found under {root or federation_root()}")
            return 2
    else:
        d = repo_dir if repo_dir is not None else Path()
        f = d / "FEEDBACK.md"
        if not f.is_file():
            print(f"feedback --papers: no FEEDBACK.md at {f} — nothing checked (not a pass)")
            return 2
        pairs = [(f, d.resolve().name)]

    found: list[Violation] = []
    total = 0
    for path, repo in pairs:
        try:
            text = path.read_text(encoding="utf-8")
        except OSError as exc:
            found.append(Violation(repo, str(path), 0, "FB-02", f"unreadable: {exc}"))
            continue
        total += len(papers(text, repo))
        found.extend(check_text(text, repo))
    scanned = len(pairs)

    skipped: list[str] = []
    for path, repo in pairs:
        try:
            skipped.extend(skipped_entries(path.read_text(encoding="utf-8"), repo))
        except OSError:
            pass
    if skipped:
        shown = ", ".join(sorted(set(skipped))[:8])
        more = f" (+{len(set(skipped)) - 8} more)" if len(set(skipped)) > 8 else ""
        print(
            f"  note: {len(set(skipped))} entr(ies) carry a non-PAPER id and are NOT judged "
            f"by this gate — {shown}{more}"
        )

    if not found:
        print(f"feedback --papers: {total} paper(s) in {scanned} file(s) — FB-01/02/03 clean.")
        return 0

    by_rule: dict[str, int] = {}
    for v in found:
        by_rule[v.rule] = by_rule.get(v.rule, 0) + 1
    for v in found:
        print(f"  {v}")
    summary = ", ".join(f"{k} × {n}" for k, n in sorted(by_rule.items()))
    print(f"\nfeedback --papers: {len(found)} violation(s) in {total} paper(s) — {summary}")
    return 1


__all__ = [
    "STATUSES",
    "Paper",
    "Violation",
    "check_file",
    "check_fleet",
    "check_text",
    "papers",
    "run_papers",
]
