#!/usr/bin/env python3
"""Deterministic verifier for transcript-cleanup passes.

Checks a cleaned transcript against its source for the failure modes a
manual read misses: dropped citations, drifted paragraph counts, over- or
under-trimming, unformatted numbers, stray em/en dashes, and non-canonical
proper-noun spellings. No LLM. No network. Stdlib only.

See standards/transcript-cleanup.md for the cleanup rules this checks
against, and standards/glossaries/ for the course-glossary format used by
--glossary.

Usage:
    python scripts/verify_transcript_clean.py --src DIR_OR_FILE --out DIR_OR_FILE
    python scripts/verify_transcript_clean.py --src DIR_OR_FILE --out DIR_OR_FILE --glossary standards/glossaries/law-contracts.md
    python scripts/verify_transcript_clean.py --src DIR_OR_FILE --out DIR_OR_FILE --json

When --src/--out are directories, files are paired by identical basename.
Exit 1 if any pair has an ERROR, else 0. Warnings never fail the run.
"""

from __future__ import annotations

import argparse
import difflib
import json
import re
import sys
from pathlib import Path

WORD_RE = re.compile(r"[A-Za-z]+")
CASE_RE = re.compile(r"\b[A-Z][\w.'-]* v\.? [A-Z][\w.'-]*")
SECTION_RE = re.compile(r"(?:§\s*|\bSection\s+)[\d.\-]+")
DOLLAR_WORD_RE = re.compile(r"\b(\d[\d,]*)\s+dollars\b", re.IGNORECASE)
DOLLAR_SIGN_RE = re.compile(r"\$\d")
BARE_INT_RE = re.compile(r"(?<![\d,.\-])\d{4,}(?![\d,.\-])")
DASH_RE = re.compile("[\u2014\u2013]")  # em dash, en dash (unicode escapes, not literal chars)


def split_paragraphs(text: str) -> list[str]:
    """Split text into paragraphs: blocks separated by one or more blank lines."""
    blocks = re.split(r"\n\s*\n", text.strip())
    return [b.strip() for b in blocks if b.strip()]


def word_count(text: str) -> int:
    return len(WORD_RE.findall(text))


def find_citations(text: str) -> list[str]:
    """Extract case-style names (X v. Y) and section refs (either "Section N" or "§ N")."""
    return CASE_RE.findall(text) + SECTION_RE.findall(text)


def parse_glossary(path: Path) -> list[tuple[str, str]]:
    """Read the '## Canonical spellings' fenced block of 'variant => canonical' lines."""
    text = path.read_text(encoding="utf-8")
    match = re.search(r"## Canonical spellings\s*\n```[^\n]*\n(.*?)```", text, re.DOTALL)
    if not match:
        return []
    pairs = []
    for line in match.group(1).splitlines():
        line = line.strip()
        if not line or "=>" not in line:
            continue
        variant, canonical = line.split("=>", 1)
        pairs.append((variant.strip(), canonical.strip()))
    return pairs


def check_citations(src_text: str, out_text: str) -> list[str]:
    """Flag src citations missing from out with no close respelling match."""
    errors = []
    out_citations = find_citations(out_text)
    for citation in find_citations(src_text):
        if citation in out_citations:
            continue
        if difflib.get_close_matches(citation, out_citations, n=1, cutoff=0.85):
            continue
        errors.append(f"dropped citation or proper noun: {citation!r} not found in output")
    return errors


def check_citations_by_paragraph(src_paras: list[str], out_paras: list[str]) -> list[str]:
    """Flag citations missing from their own paragraph (same membership/respelling logic
    as check_citations, scoped per paragraph so a drop in one paragraph is not masked by
    a surviving mention elsewhere in the file). At most one error per (citation, paragraph)."""
    errors = []
    seen = set()
    for i, (src_para, out_para) in enumerate(zip(src_paras, out_paras), start=1):
        out_citations = find_citations(out_para)
        for citation in find_citations(src_para):
            if citation in out_citations:
                continue
            if difflib.get_close_matches(citation, out_citations, n=1, cutoff=0.85):
                continue
            if (citation, i) in seen:
                continue
            seen.add((citation, i))
            errors.append(f"dropped citation or proper noun: {citation!r} not found in output paragraph {i}")
    return errors


def check_numbers(out_text: str) -> list[str]:
    """Flag unformatted currency, unformatted bare integers, and mixed currency style."""
    warnings = []
    currency_spans = []
    for match in DOLLAR_WORD_RE.finditer(out_text):
        currency_spans.append(match.span(1))
        warnings.append(f"unformatted currency {match.group(0)!r}, format as $ with thousands separators")

    def in_currency_span(pos: int) -> bool:
        return any(start <= pos < end for start, end in currency_spans)

    for match in BARE_INT_RE.finditer(out_text):
        if in_currency_span(match.start()):
            continue
        num = match.group(0)
        if len(num) == 4 and 1000 <= int(num) <= 2099:
            continue  # plausible year, not a formatting defect
        warnings.append(f"unformatted number {num!r} (4+ digits, no thousands separator)")

    if DOLLAR_WORD_RE.search(out_text) and DOLLAR_SIGN_RE.search(out_text):
        warnings.append("mixed currency style: both 'N dollars' and '$N' formats appear in this file")
    return warnings


def check_glossary(out_text: str, glossary_pairs: list[tuple[str, str]]) -> list[str]:
    warnings = []
    for variant, canonical in glossary_pairs:
        if variant != canonical and re.search(re.escape(variant), out_text):
            warnings.append(f"non-canonical spelling {variant!r} found, should be {canonical!r}")
    return warnings


def check_transcript_pair(
    src_text: str,
    out_text: str,
    glossary_pairs: list[tuple[str, str]],
    min_ratio: float,
    max_ratio: float,
) -> dict:
    errors: list[str] = []
    warnings: list[str] = []

    src_paras = split_paragraphs(src_text)
    out_paras = split_paragraphs(out_text)
    if len(src_paras) != len(out_paras):
        errors.append(f"paragraph count mismatch: src has {len(src_paras)}, out has {len(out_paras)}")

    src_words = word_count(src_text)
    out_words = word_count(out_text)
    ratio = out_words / src_words if src_words else (1.0 if out_words == 0 else float("inf"))
    if ratio < min_ratio or ratio > max_ratio:
        errors.append(
            f"word count ratio {ratio:.3f} outside [{min_ratio}, {max_ratio}] "
            f"(src={src_words} words, out={out_words} words)"
        )

    if len(src_paras) == len(out_paras):
        errors.extend(check_citations_by_paragraph(src_paras, out_paras))
    else:
        errors.extend(check_citations(src_text, out_text))

    dash_hits = len(DASH_RE.findall(out_text))
    if dash_hits:
        errors.append(f"em dash or en dash found in output ({dash_hits} occurrence(s))")

    warnings.extend(check_numbers(out_text))
    warnings.extend(check_glossary(out_text, glossary_pairs))

    return {"errors": errors, "warnings": warnings}


def collect_files(path: Path) -> dict[str, Path]:
    if path.is_dir():
        return {p.name: p for p in sorted(path.iterdir()) if p.is_file()}
    return {path.name: path}


def run(src: str, out: str, glossary: str | None, min_ratio: float, max_ratio: float) -> dict:
    glossary_pairs = parse_glossary(Path(glossary)) if glossary else []
    src_path, out_path = Path(src), Path(out)

    if src_path.is_file() and out_path.is_file():
        # Two files: pair them directly, regardless of basename.
        pairs = [(out_path.name, src_path, out_path)]
    else:
        # Directory (or mixed) mode: pair by identical basename.
        src_files = collect_files(src_path)
        out_files = collect_files(out_path)
        pairs = [(name, src_files.get(name), out_files.get(name))
                 for name in sorted(set(src_files) | set(out_files))]

    results = []
    for name, src_file, out_file in pairs:
        if src_file is None:
            results.append({"name": name, "errors": [f"no source file paired with {name!r}"], "warnings": []})
        elif out_file is None:
            results.append({"name": name, "errors": [f"no output file paired with {name!r}"], "warnings": []})
        else:
            src_text = src_file.read_text(encoding="utf-8")
            out_text = out_file.read_text(encoding="utf-8")
            check = check_transcript_pair(src_text, out_text, glossary_pairs, min_ratio, max_ratio)
            results.append({"name": name, **check})

    error_count = sum(len(r["errors"]) for r in results)
    warning_count = sum(len(r["warnings"]) for r in results)
    return {"files": results, "error_count": error_count, "warning_count": warning_count}


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Deterministic transcript-cleanup verifier.")
    parser.add_argument("--src", required=True, help="Source transcript file or directory")
    parser.add_argument("--out", required=True, help="Cleaned transcript file or directory")
    parser.add_argument("--glossary", help="Path to a course glossary (see standards/glossaries/)")
    parser.add_argument("--min-ratio", type=float, default=0.88, help="Minimum out/src word ratio (default 0.88)")
    parser.add_argument("--max-ratio", type=float, default=1.12, help="Maximum out/src word ratio (default 1.12)")
    parser.add_argument("--json", action="store_true", help="Print a machine-readable JSON report")
    args = parser.parse_args(argv)

    report = run(args.src, args.out, args.glossary, args.min_ratio, args.max_ratio)

    if args.json:
        print(json.dumps(report, indent=2))
    else:
        for file_result in report["files"]:
            status = "FAIL" if file_result["errors"] else "OK"
            print(f"[{status}] {file_result['name']}")
            for error in file_result["errors"]:
                print(f"  ERROR: {error}")
            for warning in file_result["warnings"]:
                print(f"  WARNING: {warning}")
        print(
            f"\n{len(report['files'])} file(s) checked, "
            f"{report['error_count']} error(s), {report['warning_count']} warning(s)"
        )

    return 1 if report["error_count"] else 0


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