#!/usr/bin/env python3
"""v3.9.0 spec lint: verify finalizer suffix table contract + formatter allowlist + refusal-list guard.

Per spec v3.9.0 §3.8 rules 1-7:
  Rule 1 — marker syntax: 3 new v3.9.0 markers present in finalizer suffix table
  Rule 2 — preprint composition order: PREPRINT before triangulation token
  Rule 3 — v3.7.3 legacy compat: k=1 k_max=1 S2 row → CONTAMINATED-UNMATCHED (not COVERAGE-NOISE)
  Rule 4 — no *-BLOCK tokens in v3.9.0 finalizer subsection (v3.10 policy-layer scope only)
  Rule 5 — formatter pass-through allowlist set-equality (R-L3-2-E lint)
  Rule 6 — refusal-list-unchanged guard
  Rule 7 — CI integration (done in spec-consistency.yml)

Usage:
    python scripts/check_v3_9_0_triangulation.py
    python scripts/check_v3_9_0_triangulation.py --formatter-path PATH --orchestrator-path PATH
        (for test fixtures only)

Exit codes:
    0 — all checks pass
    1 — one or more checks failed
    2 — invocation error (e.g., file missing)
"""
from __future__ import annotations

import argparse
import re
import sys
from pathlib import Path


REPO_ROOT = Path(__file__).resolve().parents[1]
DEFAULT_FORMATTER = REPO_ROOT / "academic-paper/agents/formatter_agent.md"
DEFAULT_ORCHESTRATOR = REPO_ROOT / "academic-pipeline/agents/pipeline_orchestrator_agent.md"

V3_9_0_SECTION_HEADER = "## Cite-Time Provenance Finalizer — v3.9.0 extension"

# Three new v3.9.0 markers that must appear in the finalizer suffix table.
EXPECTED_NEW_V3_9_0_SUFFIXES = {
    "CONTAMINATED-COVERAGE-NOISE",
    "CONTAMINATED-PARTIAL-UNMATCH",
    "CONTAMINATED-TRIANGULATION-UNMATCHED",
}

# Canonical 9-suffix allowlist per spec v3.9.0 §3.8 rule 5.
EXPECTED_ALLOWLIST_TOKENS = {
    # v3.7.3 legacy (3)
    "CONTAMINATED-PREPRINT",
    "CONTAMINATED-UNMATCHED",
    "CONTAMINATED-PREPRINT+UNMATCHED",
    # v3.9.0 new (6)
    "CONTAMINATED-COVERAGE-NOISE",
    "CONTAMINATED-PARTIAL-UNMATCH",
    "CONTAMINATED-TRIANGULATION-UNMATCHED",
    "CONTAMINATED-PREPRINT+COVERAGE-NOISE",
    "CONTAMINATED-PREPRINT+PARTIAL-UNMATCH",
    "CONTAMINATED-PREPRINT+TRIANGULATION-UNMATCHED",
}


# ---------------------------------------------------------------------------
# Orchestrator helpers (rules 1-4)
# ---------------------------------------------------------------------------

def extract_v3_9_0_finalizer_subsection(orchestrator_text: str) -> str:
    """Return the v3.9.0 finalizer extension subsection (## header until next ## or EOF)."""
    lines = orchestrator_text.splitlines()
    in_section = False
    collected: list[str] = []
    for line in lines:
        if line.startswith(V3_9_0_SECTION_HEADER):
            in_section = True
            collected.append(line)
            continue
        if in_section:
            if line.startswith("## "):
                break
            collected.append(line)
    return "\n".join(collected)


def check_marker_syntax(subsection_text: str) -> list[str]:
    """Rule 1: all 3 new v3.9.0 markers appear as backtick-quoted suffixes in subsection."""
    tokens = set(re.findall(r"`(CONTAMINATED-[A-Z+\-]+)`", subsection_text))
    missing = EXPECTED_NEW_V3_9_0_SUFFIXES - tokens
    if missing:
        return [
            f"rule 1 (marker syntax): missing in v3.9.0 finalizer subsection: {sorted(missing)}"
        ]
    return []


def check_preprint_composition_order(subsection_text: str) -> list[str]:
    """Rule 2: any CONTAMINATED-PREPRINT+X composition has PREPRINT before X (not X+PREPRINT)."""
    tokens = re.findall(r"`(CONTAMINATED-[A-Z+\-]+)`", subsection_text)
    failures = []
    for tok in tokens:
        # Strip CONTAMINATED- prefix, then check composition ordering.
        body = tok[len("CONTAMINATED-"):]
        if "PREPRINT" in body and "+" in body:
            parts = body.split("+")
            if parts[0] != "PREPRINT":
                failures.append(
                    f"rule 2 (preprint composition order): {tok} has non-leading PREPRINT"
                )
    return failures


def check_legacy_compat(subsection_text: str) -> list[str]:
    """Rule 3: k=1 k_max=1 + present_field=semantic_scholar_unmatched preserves
    v3.7.3 legacy CONTAMINATED-UNMATCHED suffix (NOT CONTAMINATED-COVERAGE-NOISE).

    Both the preprint=false and preprint=true legacy rows must be guarded:
    - preprint=false: suffix = CONTAMINATED-UNMATCHED, NOT CONTAMINATED-COVERAGE-NOISE
    - preprint=true: suffix = CONTAMINATED-PREPRINT+UNMATCHED, NOT
      CONTAMINATED-PREPRINT+COVERAGE-NOISE

    Additionally, both rows must be PRESENT. Deleting either row silently passes
    without this guard (absence-is-a-pass is a systematic false-negative per
    v3.9.0 spec §3.6 coverage-noise invariant).
    """
    failures = []
    seen_bare_legacy = False
    seen_preprint_legacy = False
    for line in subsection_text.splitlines():
        if "semantic_scholar_unmatched" not in line or "| 1 | 1 |" not in line:
            continue
        # Detect preprint variant: the table row has "| true |" in the preprint column.
        # Rows with "false / absent" or no explicit "true" are the bare (non-preprint) row.
        is_preprint = "| true |" in line
        if is_preprint:
            seen_preprint_legacy = True
            # Preprint legacy row must carry CONTAMINATED-PREPRINT+UNMATCHED.
            if "`CONTAMINATED-PREPRINT+UNMATCHED`" not in line:
                failures.append(
                    f"rule 3 (legacy compat — preprint variant): k=1 k_max=1 "
                    f"semantic_scholar_unmatched preprint=true row does NOT preserve "
                    f"`CONTAMINATED-PREPRINT+UNMATCHED` legacy suffix"
                )
            if "`CONTAMINATED-PREPRINT+COVERAGE-NOISE`" in line:
                failures.append(
                    f"rule 3 (legacy compat — preprint drift): preprint legacy row contains "
                    f"`CONTAMINATED-PREPRINT+COVERAGE-NOISE` (should be "
                    f"`CONTAMINATED-PREPRINT+UNMATCHED`)"
                )
        else:
            seen_bare_legacy = True
            # Bare (preprint=false / absent) legacy row must carry CONTAMINATED-UNMATCHED.
            if "`CONTAMINATED-UNMATCHED`" not in line:
                failures.append(
                    f"rule 3 (legacy compat — bare variant): k=1 k_max=1 "
                    f"semantic_scholar_unmatched preprint=false row does NOT preserve "
                    f"`CONTAMINATED-UNMATCHED` legacy suffix"
                )
            if "`CONTAMINATED-COVERAGE-NOISE`" in line:
                failures.append(
                    f"rule 3 (legacy compat — bare drift): bare legacy row contains "
                    f"`CONTAMINATED-COVERAGE-NOISE` (should be `CONTAMINATED-UNMATCHED`)"
                )
    if not seen_bare_legacy:
        failures.append(
            "rule 3 (legacy compat): bare k=1 k_max=1 semantic_scholar_unmatched legacy row is "
            "MISSING from the v3.9.0 finalizer subsection "
            "(CONTAMINATED-UNMATCHED legacy coverage required)"
        )
    if not seen_preprint_legacy:
        failures.append(
            "rule 3 (legacy compat): preprint k=1 k_max=1 semantic_scholar_unmatched legacy row is "
            "MISSING from the v3.9.0 finalizer subsection "
            "(CONTAMINATED-PREPRINT+UNMATCHED legacy coverage required)"
        )
    return failures


def check_no_high_block(subsection_text: str) -> list[str]:
    """Rule 4: no backtick-quoted *-BLOCK tokens in v3.9.0 subsection (those are v3.10 scope)."""
    block_tokens = re.findall(r"`([A-Z]+(?:-[A-Z]+)*-BLOCK)`", subsection_text)
    if block_tokens:
        return [
            f"rule 4 (no HIGH-BLOCK): backtick-quoted *-BLOCK token in v3.9.0 subsection: "
            f"{block_tokens}"
        ]
    return []


# ---------------------------------------------------------------------------
# Formatter helpers (rules 5-6)
# ---------------------------------------------------------------------------

def extract_allowlist_tokens(formatter_text: str) -> set[str]:
    """Parse the pass-through allowlist sentence (anchored at 'DO NOT trigger refusal').

    Strategy: locate the sentence containing 'DO NOT trigger refusal' (the canonical
    anchor phrase from v3.7.3 + v3.9.0 specs). Walk backward to find the opening '(' of
    the enclosing parenthetical, then forward to ')'. Extract backtick-quoted
    CONTAMINATED-* tokens from that parenthetical only.

    This avoids substring collisions because tokens are parsed from backtick-delimited
    code spans, not from arbitrary text. CONTAMINATED-PREPRINT and
    CONTAMINATED-PREPRINT+UNMATCHED are extracted as distinct tokens.
    """
    anchor = "DO NOT trigger refusal"
    idx = formatter_text.find(anchor)
    if idx < 0:
        return set()
    paren_start = formatter_text.rfind("(", 0, idx)
    paren_end = formatter_text.find(")", paren_start) if paren_start >= 0 else -1
    if paren_start < 0 or paren_end < 0:
        return set()
    parenthetical = formatter_text[paren_start:paren_end]
    pattern = re.compile(r"`(CONTAMINATED-[A-Z+\-]+)`")
    return set(pattern.findall(parenthetical))


def extract_refusal_rule_tokens(formatter_text: str) -> set[str]:
    """Parse the formatter refusal rules block (numbered list before the allowlist).

    Find numbered list lines (^N. ...) that appear BEFORE the allowlist anchor.
    Extract any backtick-quoted CONTAMINATED-* token in those rule bodies — there
    must be none, per R-L3-2-E.
    """
    anchor_idx = formatter_text.find("DO NOT trigger refusal")
    if anchor_idx < 0:
        return set()
    pre = formatter_text[:anchor_idx]
    rule_pattern = re.compile(r"^\d+\.\s.*$", re.MULTILINE)
    rule_lines = rule_pattern.findall(pre)
    if not rule_lines:
        return set()
    # Scan the last 15 numbered list lines (safely covers rules 1-10).
    contam_pattern = re.compile(r"`(CONTAMINATED-[A-Z+\-]+)`")
    found = set()
    for line in rule_lines[-15:]:
        found |= set(contam_pattern.findall(line))
    return found


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def main() -> int:
    parser = argparse.ArgumentParser(description="v3.9.0 triangulation spec lint")
    parser.add_argument(
        "--formatter-path",
        default=str(DEFAULT_FORMATTER),
        help="Path to formatter_agent.md (for test fixtures)",
    )
    parser.add_argument(
        "--orchestrator-path",
        default=str(DEFAULT_ORCHESTRATOR),
        help="Path to pipeline_orchestrator_agent.md (for test fixtures)",
    )
    args = parser.parse_args()

    formatter_path = Path(args.formatter_path)
    orchestrator_path = Path(args.orchestrator_path)

    if not formatter_path.exists():
        print(f"ERROR: formatter not found: {formatter_path}", file=sys.stderr)
        return 2
    if not orchestrator_path.exists():
        print(f"ERROR: orchestrator not found: {orchestrator_path}", file=sys.stderr)
        return 2

    formatter_text = formatter_path.read_text(encoding="utf-8")
    orchestrator_text = orchestrator_path.read_text(encoding="utf-8")
    subsection = extract_v3_9_0_finalizer_subsection(orchestrator_text)

    failures: list[str] = []

    # Rules 1-4: finalizer/orchestrator side
    failures += check_marker_syntax(subsection)
    failures += check_preprint_composition_order(subsection)
    failures += check_legacy_compat(subsection)
    failures += check_no_high_block(subsection)

    # Rules 5-6: formatter side
    allowlist = extract_allowlist_tokens(formatter_text)
    missing = EXPECTED_ALLOWLIST_TOKENS - allowlist
    extra = allowlist - EXPECTED_ALLOWLIST_TOKENS
    if missing:
        failures.append(f"rule 5 (allowlist missing tokens): {sorted(missing)}")
    if extra:
        failures.append(f"rule 5 (allowlist extra tokens): {sorted(extra)}")

    in_refusal = extract_refusal_rule_tokens(formatter_text)
    if in_refusal:
        failures.append(
            f"rule 6 (R-L3-2-E violation — CONTAMINATED-* in refusal rules): {sorted(in_refusal)}"
        )

    if failures:
        print("v3.9.0 triangulation lint FAILED:", file=sys.stderr)
        for f in failures:
            print(f"  - {f}", file=sys.stderr)
        return 1

    print("v3.9.0 triangulation lint OK")
    return 0


if __name__ == "__main__":
    sys.exit(main())
