"""Canonical dependency parsing and live-state resolution for the allocator (#758).

The work allocator previously inferred dependency state from two lowercase
substrings (``"blocked on #"`` / ``"downstream of #"``). The repository's
canonical declaration form is a ``Depends:`` field inside the issue body's
linkage line, for example::

    * Parent: #631 · Depends: #633, #634 · Related: #630, #434

That form matched neither substring, so dependency-blocked issues were emitted
as eligible candidates. This module replaces substring inference with:

1. structured parsing of ``Depends:`` declarations into issue references, and
2. resolution of each reference against **live issue state**, never body text.

Both halves fail closed: a reference whose state cannot be established makes
the owning candidate ineligible rather than assignable.

No issue number is special-cased here (#758 AC4/AC14); the parser is driven
entirely by the declaration syntax.
"""

from __future__ import annotations

import re
from typing import Callable, Iterable

# Live issue states, as reported by Gitea.
DEP_STATE_OPEN = "open"
DEP_STATE_CLOSED = "closed"

# "Depends:" / "Depends on:" introduces the declaration. "Dependencies" does
# not match: after "depend" it continues with "e", not "s".
_DEPENDS_KEYWORD = re.compile(r"depends(?:\s+on)?\s*:?\s*", re.IGNORECASE)

# Immediately after the keyword, consume only the contiguous run of issue
# references. Anchoring the run this way means the declaration ends naturally
# at the next separator ("·", newline) or sibling field ("Related:"), without
# needing to enumerate separators.
_DEP_RUN = re.compile(
    r"\s*(#\d+(?:\s*(?:,|and|&)\s*#\d+)*)",
    re.IGNORECASE,
)

# Legacy marker retained so previously-recognized bodies keep working.
_LEGACY_BLOCKED = re.compile(r"blocked\s+on\s+#(\d+)", re.IGNORECASE)

_ISSUE_REF = re.compile(r"#(\d+)")


def parse_dependency_refs(body: str | None) -> tuple[int, ...]:
    """Extract declared dependency issue numbers from an issue *body*.

    Recognizes the canonical ``Depends: #N, #N`` field (including the
    ``Depends on`` spelling) plus the legacy ``blocked on #N`` marker.
    Returns references in first-seen order with duplicates removed. Malformed
    or absent declarations yield an empty tuple rather than raising.
    """
    if not body:
        return ()

    refs: list[int] = []

    def _add(value: str) -> None:
        number = int(value)
        if number > 0 and number not in refs:
            refs.append(number)

    for match in _DEPENDS_KEYWORD.finditer(body):
        run = _DEP_RUN.match(body, match.end())
        if not run:
            continue
        for ref in _ISSUE_REF.findall(run.group(1)):
            _add(ref)

    for ref in _LEGACY_BLOCKED.findall(body):
        _add(ref)

    return tuple(refs)


def resolve_dependency_state(
    refs: Iterable[int],
    state_lookup: Callable[[int], str | None],
    *,
    subject: str = "candidate",
) -> dict:
    """Resolve declared *refs* against live issue state.

    *state_lookup* maps an issue number to its live state string, or to
    ``None`` when that evidence could not be obtained. A reference is:

    * **met** when live state is ``closed``;
    * **unmet** when live state is any other live value (``open``, etc.);
    * **unavailable** when state is ``None`` or the lookup raises.

    Unmet *and* unavailable both mark the candidate ineligible (#758 AC6/AC7):
    allocation must never assume a dependency is satisfied.
    """
    unmet: list[int] = []
    unavailable: list[int] = []
    met: list[int] = []

    for ref in refs:
        try:
            number = int(ref)
        except (TypeError, ValueError):
            continue
        try:
            state = state_lookup(number)
        except Exception:  # noqa: BLE001 — unavailable evidence fails closed
            state = None
        normalized = (str(state).strip().lower() if state is not None else "") or None
        if normalized is None:
            unavailable.append(number)
        elif normalized == DEP_STATE_CLOSED:
            met.append(number)
        else:
            unmet.append(number)

    reason: str | None = None
    if unmet and unavailable:
        reason = (
            f"{subject} has unresolved dependencies "
            f"{_fmt(unmet)} and unverifiable dependencies {_fmt(unavailable)} "
            "(fail closed)"
        )
    elif unmet:
        reason = (
            f"{subject} depends on unresolved issue(s) {_fmt(unmet)}; "
            "they are not closed"
        )
    elif unavailable:
        reason = (
            f"{subject} dependency evidence unavailable for {_fmt(unavailable)} "
            "(fail closed)"
        )

    return {
        "refs": tuple(int(x) for x in refs),
        "met": tuple(met),
        "unmet": tuple(unmet),
        "unavailable": tuple(unavailable),
        "dependency_unmet": bool(unmet or unavailable),
        "reason": reason,
    }


def _fmt(numbers: Iterable[int]) -> str:
    return ", ".join(f"#{n}" for n in numbers)
