"""Read the Stage Map stage numbers a prose cell cites.

Two readers depend on this grammar and must not drift apart: the coverage
validator proving every stage traces back to a requirement, and the
incremental-scope back-trace resolving which stages an answered clarification
touches. A second copy would let one side accept a citation the other rejects,
which decides whether a re-run narrows or stays full.
"""
from __future__ import annotations

import re
from collections.abc import Iterator

_RANGE = r"(?:[-–]|\bto\b|\bthrough\b)"
_ITEM = rf"\d+(?:\s*{_RANGE}\s*\d+)?"
# `, and` / `, &` is one separator, not a separator followed by a stray word:
# `Stages 1, 2, and 3` is the idiomatic plural and must not orphan stage 3.
_SEP = r"(?:,\s*(?:and|&)?|and|&)"
_LIST_RE = re.compile(
    rf"\bstages?[ \t]*({_ITEM}(?:\s*{_SEP}\s*{_ITEM})*)",
    re.IGNORECASE,
)
_ITEM_RE = re.compile(rf"(\d+)(?:\s*{_RANGE}\s*(\d+))?", re.IGNORECASE)
RANGE_MAX_SPAN = 64


def _stage_citation_items(text: str) -> Iterator[tuple[int, int | None]]:
    """Each `stage`-anchored citation in *text* as `(start, end)`.

    *end* is None for a bare number and the far endpoint for a range. Numbers
    stay anchored to a word-initial `stage`/`stages` token on the same line;
    harvesting bare numbers — or letting the anchor reach across a line break,
    or match the tail of `Substage`/`Backstage` — would let any prose, including
    a row disclaiming every stage, justify any stage.
    """
    for span in _LIST_RE.finditer(text):
        for item in _ITEM_RE.finditer(span.group(1)):
            end = item.group(2)
            yield int(item.group(1)), int(end) if end is not None else None


def cited_stage_numbers(text: str) -> set[int]:
    """Stage numbers *text* cites, in every prose form a planner writes.

    A range reaches every number between its endpoints here, which is what a
    reader asking "could this touch stage 5" needs.
    """
    cited: set[int] = set()
    for start, end in _stage_citation_items(text):
        cited.add(start)
        if end is None:
            continue
        cited.add(end)
        # A reversed or absurdly wide range is a typo, not a citation of
        # everything between its endpoints.
        if 0 <= end - start <= RANGE_MAX_SPAN:
            cited.update(range(start, end))
    return cited


def enumerated_stage_numbers(text: str) -> set[int]:
    """Stage numbers *text* names one by one — a range's interior excluded.

    The two readers of this grammar ask opposite questions, and a range answers
    only one of them. The incremental-scope back-trace asks "could this answer
    reach stage 5", so it must read `Stages 1-8` as reaching it — that is
    `cited_stage_numbers`, and widening there is the safe direction. Coverage
    provenance asks "did the planner confirm stage 5 satisfies this
    requirement", and a range answers that for free: one `Stages 1-64` cell
    stamps every stage in the map without the planner looking at any of them.

    So the interior is dropped here and only hand-written numbers count. A
    range's endpoints ARE hand-written and stay, which keeps `Stages 7-8`
    honest while forcing the wide case to be spelled out. The cost of a
    legitimately broad requirement is typing each number, and that typing is
    the confirmation this check is asking for.
    """
    enumerated: set[int] = set()
    for span in _LIST_RE.finditer(text):
        for item in _ITEM_RE.finditer(span.group(1)):
            enumerated.add(int(item.group(1)))
            if item.group(2) is not None:
                enumerated.add(int(item.group(2)))
    return enumerated
