"""False positive test v2: exclude exact matches from candidates.

Simulates: "the correct spelling isn't on this page, but an OCR-garbled
version is. Does our fuzzy matching find the right one without matching junk?"

For each pair:
1. Find source .txt, split into tokens/n-grams
2. REMOVE candidates that are an exact case-insensitive match for the query
3. Score remaining candidates
4. Check: does the OCR error rank #1?
"""

import csv
import sys
import unicodedata
from pathlib import Path

from rapidfuzz import fuzz
from rapidfuzz.distance import JaroWinkler

DATA_DIR = Path(__file__).parent
CSV_PATH = DATA_DIR / "ocr_errors.csv"
OUTPUT_DIR = DATA_DIR / "output"


def load_pairs():
    pairs = []
    with open(CSV_PATH) as f:
        for row in csv.DictReader(f):
            pairs.append((row["ocr_error"], row["correction"]))
    return pairs


def find_source_file(ocr_error):
    for txt_path in OUTPUT_DIR.glob("*.txt"):
        content = txt_path.read_text(errors="replace")
        if ocr_error in content:
            return txt_path
    return None


def get_candidates(text, n_words):
    tokens = text.split()
    if n_words == 1:
        return tokens
    return [" ".join(tokens[i:i+n_words]) for i in range(len(tokens) - n_words + 1)]


# --- Scoring functions ---

def score_jw(query, candidate):
    return JaroWinkler.similarity(query.lower(), candidate.lower())

def score_lev(query, candidate):
    return fuzz.ratio(query.lower(), candidate.lower()) / 100.0

MULTI_RULES = [("rn","m"),("nn","m"),("ri","n"),("cl","d"),("vv","w"),("ii","u")]
SHAPE = {}
for ch in "il1I|!tfj": SHAPE[ch] = "I"
for ch in "oO0cCeQa": SHAPE[ch] = "O"
for ch in "mwMW": SHAPE[ch] = "M"
for ch in "nuvNUV": SHAPE[ch] = "N"
for ch in "bhk6": SHAPE[ch] = "B"
for ch in "pqgy9": SHAPE[ch] = "P"
for ch in "dDPRB": SHAPE[ch] = "D"
for ch in "xXzZK": SHAPE[ch] = "X"
for ch in "sS5$": SHAPE[ch] = "S"
for ch in "-_~=": SHAPE[ch] = "H"
for ch in "234": SHAPE[ch] = "2"
for ch in "78": SHAPE[ch] = "7"
SHAPE["r"] = "R"

def _compress(t):
    t = unicodedata.normalize("NFKC", t).lower()
    for p, r in MULTI_RULES:
        t = t.replace(p, r)
    return "".join(SHAPE.get(c, c) for c in t)

def score_shape(query, candidate):
    return fuzz.ratio(_compress(query), _compress(candidate)) / 100.0

def score_word_jw(query, candidate):
    q_words = query.lower().split()
    c_words = candidate.lower().split()
    if not q_words or not c_words:
        return 0.0
    total = 0.0
    for qw in q_words:
        best = max(JaroWinkler.similarity(qw, cw) for cw in c_words)
        total += best
    return total / len(q_words)

def score_word_shape(query, candidate):
    q_words = query.lower().split()
    c_words = candidate.lower().split()
    if not q_words or not c_words:
        return 0.0
    total = 0.0
    for qw in q_words:
        sq = _compress(qw)
        best = max(fuzz.ratio(sq, _compress(cw)) / 100.0 for cw in c_words)
        total += best
    return total / len(q_words)


ALGORITHMS = {
    "JW": score_jw,
    "Lev": score_lev,
    "Shape": score_shape,
    "WordJW": score_word_jw,
    "WordShape": score_word_shape,
}


def evaluate_fp(algo_name, score_fn):
    pairs = load_pairs()
    rank_1 = 0
    rank_top3 = 0
    total_tested = 0
    false_pos = []  # cases where wrong thing ranks above correct

    for ocr_error, correction in pairs:
        src = find_source_file(ocr_error)
        if src is None:
            continue

        text = src.read_text(errors="replace")
        n_words = len(correction.split())
        all_candidates = get_candidates(text, n_words)

        # Remove exact matches (case-insensitive)
        candidates = [c for c in all_candidates if c.lower() != correction.lower()]

        if not candidates:
            continue

        # Score all candidates
        scored = [(score_fn(correction, c), c) for c in candidates]
        scored.sort(key=lambda x: x[0], reverse=True)

        # Find rank of the correct OCR error
        rank = None
        correct_score = None
        for i, (s, cand) in enumerate(scored):
            if ocr_error in cand or cand in ocr_error:
                rank = i + 1
                correct_score = s
                break

        if rank is None:
            continue

        total_tested += 1
        if rank == 1:
            rank_1 += 1
        if rank <= 3:
            rank_top3 += 1

        if rank != 1 and len(false_pos) < 20:
            # What beat it?
            top_s, top_c = scored[0]
            false_pos.append({
                "query": correction,
                "expected": ocr_error,
                "rank": rank,
                "top_match": top_c[:30],
                "top_score": top_s,
                "correct_score": correct_score,
                "gap": top_s - correct_score,
            })

    print(f"\n{'=' * 70}")
    print(f"  FALSE POSITIVES (exact matches excluded): {algo_name}")
    print(f"{'=' * 70}")
    print(f"  Pairs tested: {total_tested}")
    print(f"  Rank 1: {rank_1}/{total_tested} = {rank_1/total_tested:.1%}")
    print(f"  Top 3:  {rank_top3}/{total_tested} = {rank_top3/total_tested:.1%}")

    if false_pos:
        print(f"\n  Where correct answer was NOT rank 1:")
        print(f"  {'Query':<18} {'Expected':<18} {'Rank':>4} {'Winner':<28} {'Win':>5} {'Corr':>5} {'Gap':>5}")
        print(f"  {'-'*18} {'-'*18} {'-'*4} {'-'*28} {'-'*5} {'-'*5} {'-'*5}")
        for f in false_pos:
            print(f"  {f['query']:<18} {f['expected']:<18} {f['rank']:>4} {f['top_match']:<28} {f['top_score']:>5.2f} {f['correct_score']:>5.2f} {f['gap']:>5.2f}")
    print()


if __name__ == "__main__":
    algo = sys.argv[1] if len(sys.argv) > 1 else "all"
    if algo == "all":
        for name, fn in ALGORITHMS.items():
            evaluate_fp(name, fn)
    elif algo in ALGORITHMS:
        evaluate_fp(algo, ALGORITHMS[algo])
    else:
        print(f"Unknown: {algo}. Options: {list(ALGORITHMS.keys())}")
