"""TOC drift detection (#194).

Parses the Project Discussion TOC body for `#N` references and
compares the set against the universe of Issues + Discussions in the
dev repo. The handler (`open_session`) surfaces the result so the
session-start banner can warn about TOC entries that lag actual repo
state.

Pure stdlib so the parsing + comparison logic is exercised under
`:utils`-leaf tests; the GitHub fetches that supply this module's
inputs live in `github/recovery.py`.
"""
from __future__ import annotations

import re

# A `#N` token outside of a URL path or a `<word>#NN` qualified ref
# (e.g. `mcp-dev#15`, which references an Issue in a *different* repo
# and is not in the dev-repo number space drift detection cares about).
# The negative lookbehind for `[\w/]` covers both: alphanumerics rule
# out cross-repo refs, `/` rules out URL paths like
# `https://.../issues/123`.
_ISSUE_REF_RE = re.compile(r"(?<![\w/])#(\d+)\b")

# Range notation in milestone descriptions: `#76–#86` (en-dash, the
# canonical form used in the dev-repo TOC) or `#76-#86` (ASCII hyphen,
# accepted as a fallback). AC3 — every number in `[lo, hi]` counts as
# referenced even if no individual table row exists.
_RANGE_RE = re.compile(r"#(\d+)\s*(?:–|-)\s*#(\d+)")


def parse_toc_references(body: str | None) -> set[int]:
    """Return every issue / discussion number referenced in `body`.

    Treats range notation (`#76–#86`) as expanded into the closed
    integer interval [lo, hi]. Empty / None body yields an empty set.
    Cross-repo references (`mcp-dev#15`) and URL paths
    (`/issues/123`) are filtered out by the lookbehind in
    `_ISSUE_REF_RE`.
    """
    if not body:
        return set()
    refs: set[int] = set()
    for m in _ISSUE_REF_RE.finditer(body):
        refs.add(int(m.group(1)))
    for m in _RANGE_RE.finditer(body):
        lo, hi = int(m.group(1)), int(m.group(2))
        if lo <= hi:
            refs.update(range(lo, hi + 1))
    return refs


def compute_toc_drift(
    toc_body: str | None,
    issue_numbers: list[int],
    discussion_numbers: list[int],
) -> dict:
    """Compose the `toc_drift` payload (#194) returned by `open_session`.

    Inputs:
      - `toc_body` — the active Project Discussion opening post.
      - `issue_numbers` — every issue number in the dev repo
        (open + closed). Empty list permitted; the caller treats a
        fetch failure as "skip drift detection."
      - `discussion_numbers` — every discussion number in the dev repo.

    Output (dict; converted to TocDrift in the model layer):

      ``{
          "stale": bool,
          "missing_issues": list[int],     # sorted ascending
          "missing_discussions": list[int],# sorted ascending
          "highest_issue_in_toc": int | None,
          "highest_issue_in_repo": int | None,
          "suggested_action": str,
      }``
    """
    referenced = parse_toc_references(toc_body)
    issue_set = set(issue_numbers)
    discussion_set = set(discussion_numbers)

    missing_issues = sorted(issue_set - referenced)
    missing_discussions = sorted(discussion_set - referenced)

    highest_issue_in_repo = max(issue_set) if issue_set else None
    issue_refs_in_repo = referenced & issue_set
    highest_issue_in_toc = max(issue_refs_in_repo) if issue_refs_in_repo else None

    stale = bool(missing_issues or missing_discussions)
    if stale:
        parts: list[str] = []
        if missing_issues:
            parts.append(f"{len(missing_issues)} issue(s)")
        if missing_discussions:
            parts.append(f"{len(missing_discussions)} discussion(s)")
        suggested_action = (
            f"open_session detected TOC drift: {' + '.join(parts)} "
            "not in Project Index. Compose a refreshed TOC body and "
            "call update_project_toc to backfill."
        )
    else:
        suggested_action = "TOC is current — no drift detected."

    return {
        "stale": stale,
        "missing_issues": missing_issues,
        "missing_discussions": missing_discussions,
        "highest_issue_in_toc": highest_issue_in_toc,
        "highest_issue_in_repo": highest_issue_in_repo,
        "suggested_action": suggested_action,
    }
