"""Find the statements an answered clarification may have just falsified.

`_common-contract.md` §"Supersession" and `report-writer.md` §"Self-fix rewrite"
both say the same thing: an answer does not merely add a decision, it invalidates
whatever the plan wrote under the opposite assumption, so before editing you must
"grep the constant, the symbol, the path, the requirement ID across the whole
plan body". Both leave that grep to the author's diligence, and it is the step
that gets skipped — in one observed run, 17 of 23 blocked plan items were a
recorded decision whose derivations were never swept.

This does the grep. It is deliberately advisory: it returns candidate locations,
never a verdict about which ones are now false. Deciding that is the author's
job, and a tool that guessed would be trading one silent failure for another.
"""
from __future__ import annotations

import re
from typing import Any, Iterator, Mapping, Sequence

# A backticked span is how both contracts tell an author to write a symbol, a
# path, or a constant, so it is the highest-signal thing to extract. Bare prose
# words are deliberately not extracted: they match everywhere and would bury the
# hits that matter.
_BACKTICKED_RE = re.compile(r"`([^`\n]+)`")
# Ids the plan carries in its own rows (`R-001`, `P-Step-3`, `VC-002`) and the
# ticket ids the brief uses (`DEV-10174`, `PROD-1623`).
_ID_RE = re.compile(r"\b(?:[A-Z][A-Za-z]*-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)\b")
# Below this length a token matches too much to be worth reading: `id`, `db`,
# and `ko` each hit dozens of unrelated rows.
_MIN_TOKEN_LENGTH = 3
_EXCERPT_MAX = 160


def extract_tokens(text: str) -> list[str]:
    """The symbols, paths, and ids an answer names, longest first.

    Longest first so a caller reading the report sees the specific token before
    the general one it contains (`src/domains/font` before `src/domains`).
    """
    found: set[str] = set()
    for raw in _BACKTICKED_RE.findall(text):
        token = raw.strip()
        # A backticked sentence is prose in code font, not a symbol.
        if len(token) >= _MIN_TOKEN_LENGTH and " " not in token:
            found.add(token)
    for token in _ID_RE.findall(text):
        if len(token) >= _MIN_TOKEN_LENGTH:
            found.add(token)
    return sorted(found, key=lambda token: (-len(token), token))


def _string_leaves(node: Any, pointer: str = "") -> Iterator[tuple[str, str]]:
    if isinstance(node, str):
        yield pointer, node
    elif isinstance(node, Mapping):
        for key, value in node.items():
            yield from _string_leaves(value, f"{pointer}/{key}")
    elif isinstance(node, Sequence) and not isinstance(node, (str, bytes)):
        for index, value in enumerate(node):
            yield from _string_leaves(value, f"{pointer}/{index}")


def _excerpt(text: str, token: str) -> str:
    index = text.find(token)
    if index < 0:
        return text[:_EXCERPT_MAX]
    start = max(0, index - _EXCERPT_MAX // 3)
    excerpt = text[start:start + _EXCERPT_MAX]
    return ("…" if start else "") + excerpt + ("…" if len(text) > start + _EXCERPT_MAX else "")


def find_derivations(
    plan: Mapping[str, Any],
    tokens: Sequence[str],
    *,
    exclude_pointer_prefix: str = "",
) -> list[dict[str, str]]:
    """Every string in *plan* that mentions one of *tokens*.

    One hit per (pointer, token): a row naming two affected symbols is two things
    to re-check, not one.
    """
    hits: list[dict[str, str]] = []
    for pointer, text in _string_leaves(plan):
        if exclude_pointer_prefix and pointer.startswith(exclude_pointer_prefix):
            continue
        for token in tokens:
            if token in text:
                hits.append({
                    "token": token,
                    "pointer": pointer,
                    "excerpt": _excerpt(text, token),
                })
    return hits
