#!/usr/bin/env python3
"""
verdict.py — the ONE right-reason match authority (ARCHITECTURE §4, the day-1 Verdict primitive).

WHY THIS EXISTS: the credit/punt decision was scattered across bench.py (`is_flagged`) and
orchestrator/detect.py:219 (its own `autoscorable` + coverage-incomplete routing) and re-patched
repeatedly. This file unifies it so the drift stops. bench + detect both consume `match` now.

CONTRACT (stable; do not narrow):
    match(findings, expectation, *, judge=None) -> Verdict(outcome, reason)
    outcome in {CREDIT, MISS, PUNT}.
  - `expectation` carries POLARITY — positive (canonical / bench-require: the defect SHOULD be named)
    or negative (anti-canary / discriminator: the detector should stay QUIET on safe code). Polarity is
    a field, not a second function: the SoT names four consumers (bench scoring · conformance admission ·
    resolution verify-gate · prevention re-detect) and three are negative. Locking a positive-only
    signature would force a rebuild for each.
  - `judge` is the punt -> judge boundary, day-1 (ARCHITECTURE §4). Today None = punt stays punt
    (hand-judged downstream). The autoscorer (#61) drops an LLM judge in HERE — a drop-in, not a rewrite.

IMPL STATUS: positive path is the live one (bench scoring + detect run_bench). The negative branch is the
real polarity flip (matched-on-safe = false raise = MISS), not a stub — its consumers (conformance /
resolution / prevention) wire in next. No NotImplementedError: the flip is correct logic today.

Pure stdlib so it path-loads (importlib) from both root (bench.py) and orchestrator/ with no `__init__`.
"""
import re
from dataclasses import dataclass

CREDIT, MISS, PUNT = "credit", "miss", "punt"
POSITIVE, NEGATIVE = "positive", "negative"

# genuinely-generic tokens only — dropping any of these from a phrase symbol's distinctive set must make the
# match STRICTER (fewer false credits), never looser. Security-distinctive words (fetch/unguarded/omission/atomic/
# ssrf/...) are deliberately ABSENT so they stay REQUIRED — that is what stops a sibling finding naming the same
# symbol-for-a-different-reason from matching (advisor: trimming STOP toward false-credit is the failure mode).
STOP = {"the", "a", "an", "of", "to", "in", "on", "is", "are", "not", "no", "and", "or",
        "validation", "check", "checks", "missing", "unscoped", "scope", "scoped",
        "id", "by", "row", "via", "with"}
# canonicals whose `why` explicitly demands hand-judging (generic tokens would false-credit a wrong-reason finding,
# e.g. bare "privilege escalation"). _right_reason returns False for these -> match routes them to PUNT (hand-judge
# is ground truth) unless a judge is wired. NEVER auto-credit these.
PUNT_WHY = ("hand-judged", "not substring", "credit only")


@dataclass(frozen=True)
class Expectation:
    """What a set of findings is matched AGAINST. Carries polarity + the bench-only autoscore source.
    `autoscore_src` is bench-specific (the vuln source text where the symbol must appear contiguous to make a
    miss autoscorable); empty for conformance/resolution/prevention, which do not autoscore."""
    symbol: str = ""          # canonical_symbol — the right-reason anchor
    why: str = ""             # canonical.why — drives the hand-judge PUNT routing
    polarity: str = POSITIVE
    autoscore_src: str = ""   # bench-only: a not-flagged miss is REAL (autoscorable) iff symbol is contiguous here


@dataclass(frozen=True)
class Verdict:
    outcome: str   # CREDIT | MISS | PUNT
    reason: str    # human-readable why, for the registry/report trail


def _right_reason(sym, why, titles):
    """Does a finding name THIS canonical defect (right-reason), not something nearby? sym/why/titles
    pre-lowercased. Three disjoint paths by symbol shape; strict contiguous fallback when in doubt.
    Byte-identical to the original bench.is_flagged 3-path — the single authority now lives here.
      1. why demands hand-judge          -> False (match -> PUNT; hand-judge is ground truth)
      2. code-identifier symbol (1 token) -> contiguous substring (exact, safe)
      3. phrase symbol (multi-word)       -> ALL distinctive tokens present in ONE title (>=2 distinctive required)
    A bare class tag (s1..s11) in a title is never a right-reason catch (class-id matching deliberately absent)."""
    if any(p in why for p in PUNT_WHY):
        return False
    if not sym:
        return False
    if not re.search(r"\s", sym):                       # code-identifier -> exact contiguous
        return any(sym in t for t in titles)
    distinctive = [t for t in re.split(r"[^a-z0-9]+", sym) if t and t not in STOP and len(t) > 2]
    if len(distinctive) < 2:                            # too few distinctive -> strict contiguous fallback
        return any(sym in t for t in titles)
    return any(all(d in t for d in distinctive) for t in titles)


def match(findings, expectation, *, judge=None):
    """The credit/miss/punt decision. See the file docstring for the contract.
    Positive polarity (the live path): right-reason match -> CREDIT; else autoscorable miss (symbol in source,
    not flagged) -> MISS; else PUNT (hand-judge) unless `judge` resolves it. Negative polarity: a right-reason
    match on SAFE code is a false raise -> MISS; staying quiet -> CREDIT."""
    sym = (expectation.symbol or "").lower()
    why = (expectation.why or "").lower()
    titles = [f.lower() for f in findings]
    rr = _right_reason(sym, why, titles)

    if expectation.polarity == NEGATIVE:               # anti-canary / discriminator: credit = stayed quiet
        return Verdict(MISS, "false raise on safe cell") if rr else Verdict(CREDIT, "correctly quiet on safe cell")

    # POSITIVE — canonical / bench-require: the defect SHOULD be named
    if rr:
        return Verdict(CREDIT, "right-reason match")
    autoscorable = bool(sym) and bool(expectation.autoscore_src) and sym in expectation.autoscore_src.lower()
    if autoscorable:
        return Verdict(MISS, "autoscorable miss: symbol present in source, not flagged")
    if judge is not None:                              # punt -> judge boundary (autoscorer #61 drops in here)
        return judge(findings, expectation)
    return Verdict(PUNT, "hand-judge: not autoscorable")
