"""Baseline: semi-global alignment DP with OCR confusion matrix + chunk rules."""

import unicodedata
from harness import evaluate

# --- Confusion costs (lower = more similar) ---
# Default substitution cost
DEFAULT_SUB_COST = 1.0

# Single-char confusion groups: substitution within a group costs 0
CONFUSION_GROUPS = [
    "li1I|!",      # vertical strokes
    "oO0",         # round shapes
    "sS5",         # S-shapes
    "zZ2",         # Z-shapes
    "ce",          # partial loops
    "mn",          # humps (after case fold, helps with partial matches)
    "uv",          # u/v confusion
]

# Build substitution cost lookup
_sub_cost_cache = {}
for group in CONFUSION_GROUPS:
    lower_group = set(group.lower())
    for a in lower_group:
        for b in lower_group:
            if a != b:
                _sub_cost_cache[(a, b)] = 0.0


def sub_cost(a, b):
    """Cost of substituting character a with character b."""
    if a == b:
        return 0.0
    return _sub_cost_cache.get((a, b), DEFAULT_SUB_COST)


# Multi-char chunk rules: (pattern_a, pattern_b, cost)
# These allow 2↔1 or 2↔2 alignments
CHUNK_RULES = [
    ("rn", "m", 0.0),
    ("m", "rn", 0.0),
    ("nn", "m", 0.0),
    ("m", "nn", 0.0),
    ("ri", "n", 0.0),
    ("n", "ri", 0.0),
    ("cl", "d", 0.0),
    ("d", "cl", 0.0),
    ("vv", "w", 0.0),
    ("w", "vv", 0.0),
    ("ii", "u", 0.0),
    ("u", "ii", 0.0),
]

# Index chunk rules by (len_a, len_b) for fast lookup
_chunk_by_size = {}
for pa, pb, cost in CHUNK_RULES:
    key = (len(pa), len(pb))
    if key not in _chunk_by_size:
        _chunk_by_size[key] = []
    _chunk_by_size[key].append((pa, pb, cost))

# Gap (insertion/deletion) cost
GAP_COST = 1.0

# Whitespace gap cost (free - OCR inserts/removes spaces)
SPACE_GAP_COST = 0.0


def normalize(text):
    """Normalize text before alignment."""
    text = unicodedata.normalize("NFKC", text)
    text = text.lower()
    # Collapse whitespace to single space
    text = " ".join(text.split())
    return text


def semiglobal_align(query, candidate):
    """Semi-global alignment: query must be fully aligned, candidate can have
    free gaps at start/end (for substring-like matching).

    Returns a normalized similarity score in [0, 1].
    """
    q = normalize(query)
    c = normalize(candidate)

    n = len(q)
    m = len(c)

    if n == 0:
        return 1.0 if m == 0 else 0.0

    # DP table: dp[i][j] = min cost to align q[:i] with c[:j]
    # Semi-global: free gaps at start/end of candidate (c)
    INF = float("inf")
    dp = [[INF] * (m + 1) for _ in range(n + 1)]

    # Base cases
    dp[0][0] = 0.0
    # Free start gaps in candidate (semi-global: can start anywhere in candidate)
    for j in range(m + 1):
        dp[0][j] = 0.0
    # Gaps in query cost normally (query must be fully consumed)
    for i in range(1, n + 1):
        gap_c = SPACE_GAP_COST if q[i - 1] == " " else GAP_COST
        dp[i][0] = dp[i - 1][0] + gap_c

    # Fill DP
    for i in range(1, n + 1):
        for j in range(1, m + 1):
            # Standard 1↔1 substitution
            dp[i][j] = dp[i - 1][j - 1] + sub_cost(q[i - 1], c[j - 1])

            # Deletion from query (gap in candidate)
            gap_q = SPACE_GAP_COST if q[i - 1] == " " else GAP_COST
            dp[i][j] = min(dp[i][j], dp[i - 1][j] + gap_q)

            # Insertion into query (gap in query = skip candidate char)
            gap_c = SPACE_GAP_COST if c[j - 1] == " " else GAP_COST
            dp[i][j] = min(dp[i][j], dp[i][j - 1] + gap_c)

            # Multi-char chunk rules
            for (la, lb), rules in _chunk_by_size.items():
                if i >= la and j >= lb:
                    q_chunk = q[i - la : i]
                    c_chunk = c[j - lb : j]
                    for pa, pb, chunk_cost in rules:
                        if q_chunk == pa and c_chunk == pb:
                            dp[i][j] = min(dp[i][j], dp[i - la][j - lb] + chunk_cost)

    # Semi-global: find best alignment ending anywhere in candidate
    # (free end gaps in candidate)
    best_cost = min(dp[n][j] for j in range(m + 1))

    # Normalize: max possible cost is roughly len(query) * GAP_COST
    max_cost = sum(SPACE_GAP_COST if ch == " " else GAP_COST for ch in q)
    if max_cost == 0:
        return 1.0

    similarity = max(0.0, 1.0 - best_cost / max_cost)
    return similarity


def score(query, candidate):
    return semiglobal_align(query, candidate)


if __name__ == "__main__":
    evaluate(score, name="08 - Semi-global Alignment DP + Confusion Matrix + Chunks")
