"""
Cell text filtering, column/row word assignment, and bold detection.

This module contains:
- _assign_row_words_to_columns(): spatial assignment of OCR words to grid columns
- Cell text noise filtering (_clean_cell_text, _clean_cell_text_lite, etc.)
- Bold detection via stroke-width analysis (_measure_stroke_width, _classify_bold_cells)

Lizenz: Apache 2.0 (kommerziell nutzbar)
DATENSCHUTZ: Alle Verarbeitung erfolgt lokal.
"""

import re
import logging
from typing import Any, Dict, List, Optional

import numpy as np

from ..types import PageRegion, RowGeometry

logger = logging.getLogger(__name__)

try:
    import cv2
except ImportError:
    cv2 = None  # type: ignore[assignment]


# ---------------------------------------------------------------------------
# Column / Row word assignment
# ---------------------------------------------------------------------------

def _assign_row_words_to_columns(
    row: RowGeometry,
    columns: List[PageRegion],
) -> Dict[int, List[Dict]]:
    """Assign each word in a row to exactly one column.

    Uses a two-pass strategy:
    1. Containment: if a word's center falls within a column's horizontal
       bounds (with padding), assign it to that column.
    2. Nearest center: for words not contained by any column, fall back to
       nearest column center distance.

    This prevents long sentences in wide columns (e.g. example) from having
    their rightmost words stolen by an adjacent column.

    Args:
        row: Row with words (relative coordinates).
        columns: Sorted list of columns (absolute coordinates).

    Returns:
        Dict mapping col_index -> list of words assigned to that column.
    """
    result: Dict[int, List[Dict]] = {i: [] for i in range(len(columns))}

    if not row.words or not columns:
        return result

    left_x = row.x  # content ROI left (absolute)

    # Build non-overlapping column assignment ranges using midpoints.
    # For adjacent columns, the boundary is the midpoint between them.
    # This prevents words near column borders from being assigned to
    # the wrong column (e.g. "We" at the start of an example sentence
    # being stolen by the preceding DE column).
    n = len(columns)
    col_ranges_rel = []  # (assign_left, assign_right) per column
    for ci, col in enumerate(columns):
        col_left_rel = col.x - left_x
        col_right_rel = col_left_rel + col.width

        # Left boundary: midpoint to previous column, or 0
        if ci == 0:
            assign_left = 0
        else:
            prev_right = columns[ci - 1].x - left_x + columns[ci - 1].width
            assign_left = (prev_right + col_left_rel) / 2

        # Right boundary: midpoint to next column, or infinity (row width)
        if ci == n - 1:
            assign_right = row.width + 100  # generous for last column
        else:
            next_left = columns[ci + 1].x - left_x
            assign_right = (col_right_rel + next_left) / 2

        col_ranges_rel.append((assign_left, assign_right))

    for w in row.words:
        w_left = w['left']
        w_right = w_left + w['width']
        w_center_x = w_left + w['width'] / 2

        # Primary: overlap-based matching — assign to column with most overlap.
        # This is more robust than center-based for narrow columns (page_ref)
        # where the last character's center may fall into the next column.
        best_col = -1
        best_overlap = 0
        for ci, col in enumerate(columns):
            col_left_rel = col.x - left_x
            col_right_rel = col_left_rel + col.width
            overlap = max(0, min(w_right, col_right_rel) - max(w_left, col_left_rel))
            if overlap > best_overlap:
                best_overlap = overlap
                best_col = ci

        if best_col >= 0 and best_overlap > 0:
            result[best_col].append(w)
        else:
            # Fallback: center-based range matching
            assigned = False
            for ci, (al, ar) in enumerate(col_ranges_rel):
                if al <= w_center_x < ar:
                    result[ci].append(w)
                    assigned = True
                    break

            if not assigned:
                # Last resort: nearest column center
                best_col = 0
                col_left_0 = columns[0].x - left_x
                best_dist = abs(w_center_x - (col_left_0 + columns[0].width / 2))
                for ci in range(1, n):
                    col_left = columns[ci].x - left_x
                    dist = abs(w_center_x - (col_left + columns[ci].width / 2))
                    if dist < best_dist:
                        best_dist = dist
                        best_col = ci
                result[best_col].append(w)

    return result


# ---------------------------------------------------------------------------
# Cell text noise filtering
# ---------------------------------------------------------------------------

# Regex: at least 2 consecutive letters (Latin + umlauts + accents)
_RE_REAL_WORD = re.compile(r'[a-zA-ZäöüÄÖÜßéèêëàâîïôûùç]{2,}')
_RE_ALPHA = re.compile(r'[a-zA-ZäöüÄÖÜßéèêëàâîïôûùç]')

# Common short EN/DE words (2-3 chars).  Tokens at the end of a cell
# that do NOT appear here are treated as trailing OCR noise.
_COMMON_SHORT_WORDS: set = {
    # EN 1-2 letter
    'a', 'i', 'am', 'an', 'as', 'at', 'be', 'by', 'do', 'go', 'he',
    'if', 'in', 'is', 'it', 'me', 'my', 'no', 'of', 'oh', 'ok', 'on',
    'or', 'so', 'to', 'up', 'us', 'we',
    # EN 3 letter
    'ace', 'act', 'add', 'age', 'ago', 'aid', 'aim', 'air', 'all',
    'and', 'ant', 'any', 'ape', 'arc', 'are', 'ark', 'arm', 'art',
    'ask', 'ate', 'axe', 'bad', 'bag', 'ban', 'bar', 'bat', 'bay',
    'bed', 'bee', 'bet', 'big', 'bin', 'bit', 'bow', 'box', 'boy',
    'bud', 'bug', 'bun', 'bus', 'but', 'buy', 'cab', 'can', 'cap',
    'car', 'cat', 'cop', 'cow', 'cry', 'cub', 'cup', 'cut', 'dad',
    'dam', 'day', 'den', 'dew', 'did', 'die', 'dig', 'dim', 'dip',
    'dog', 'dot', 'dry', 'due', 'dug', 'dye', 'ear', 'eat', 'eel',
    'egg', 'elm', 'end', 'era', 'eve', 'ewe', 'eye', 'fan', 'far',
    'fat', 'fax', 'fed', 'fee', 'few', 'fig', 'fin', 'fir', 'fit',
    'fix', 'fly', 'foe', 'fog', 'for', 'fox', 'fry', 'fun', 'fur',
    'gag', 'gap', 'gas', 'get', 'god', 'got', 'gum', 'gun', 'gut',
    'guy', 'gym', 'had', 'ham', 'has', 'hat', 'hay', 'hen', 'her',
    'hid', 'him', 'hip', 'his', 'hit', 'hog', 'hop', 'hot', 'how',
    'hue', 'hug', 'hum', 'hut', 'ice', 'icy', 'ill', 'imp', 'ink',
    'inn', 'ion', 'its', 'ivy', 'jam', 'jar', 'jaw', 'jay', 'jet',
    'jig', 'job', 'jog', 'joy', 'jug', 'key', 'kid', 'kin', 'kit',
    'lab', 'lad', 'lag', 'lap', 'law', 'lay', 'led', 'leg', 'let',
    'lid', 'lie', 'lip', 'lit', 'log', 'lot', 'low', 'mad', 'man',
    'map', 'mat', 'maw', 'may', 'men', 'met', 'mid', 'mix', 'mob',
    'mog', 'mom', 'mop', 'mow', 'mrs', 'mud', 'mug', 'mum', 'nag',
    'nap', 'net', 'new', 'nod', 'nor', 'not', 'now', 'nun', 'nut',
    'oak', 'oar', 'oat', 'odd', 'off', 'oft', 'oil', 'old', 'one',
    'opt', 'orb', 'ore', 'our', 'out', 'owe', 'owl', 'own', 'pad',
    'pal', 'pan', 'pat', 'paw', 'pay', 'pea', 'peg', 'pen', 'per',
    'pet', 'pie', 'pig', 'pin', 'pit', 'ply', 'pod', 'pop', 'pot',
    'pro', 'pry', 'pub', 'pug', 'pun', 'pup', 'put', 'rag', 'ram',
    'ran', 'rap', 'rat', 'raw', 'ray', 'red', 'ref', 'rib', 'rid',
    'rig', 'rim', 'rip', 'rob', 'rod', 'roe', 'rot', 'row', 'rub',
    'rug', 'rum', 'run', 'rut', 'rye', 'sac', 'sad', 'sag', 'sap',
    'sat', 'saw', 'say', 'sea', 'set', 'sew', 'she', 'shy', 'sin',
    'sip', 'sir', 'sis', 'sit', 'six', 'ski', 'sky', 'sly', 'sob',
    'sod', 'son', 'sop', 'sot', 'sow', 'soy', 'spa', 'spy', 'sty',
    'sub', 'sue', 'sum', 'sun', 'sup', 'tab', 'tad', 'tag', 'tan',
    'tap', 'tar', 'tax', 'tea', 'ten', 'the', 'tie', 'tin', 'tip',
    'toe', 'ton', 'too', 'top', 'tow', 'toy', 'try', 'tub', 'tug',
    'two', 'urn', 'use', 'van', 'vat', 'vet', 'via', 'vie', 'vim',
    'vow', 'wag', 'war', 'was', 'wax', 'way', 'web', 'wed', 'wet',
    'who', 'why', 'wig', 'win', 'wit', 'woe', 'wok', 'won', 'woo',
    'wow', 'yam', 'yap', 'yaw', 'yea', 'yes', 'yet', 'yew', 'you',
    'zap', 'zip', 'zoo',
    # DE 2-3 letter
    'ab', 'da', 'du', 'ei', 'er', 'es', 'ja', 'ob', 'um', 'zu',
    'als', 'alt', 'auf', 'aus', 'bei', 'bin', 'bis', 'das', 'dem',
    'den', 'der', 'des', 'die', 'dir', 'ehe', 'ein', 'eng', 'gar',
    'gib', 'gut', 'hat', 'her', 'ich', 'ihm', 'ihr', 'ins', 'ist',
    'mal', 'man', 'mir', 'mit', 'nah', 'neu', 'nie', 'nur', 'nun',
    'ort', 'rad', 'rat', 'rot', 'ruf', 'ruh', 'sei', 'sie', 'tag',
    'tal', 'tat', 'tee', 'tor', 'tun', 'tut', 'uns', 'vom', 'von',
    'vor', 'war', 'was', 'weg', 'wem', 'wen', 'wer', 'wie', 'wir',
    'wut', 'zum', 'zur',
}

# Known abbreviations found in EN/DE textbooks and dictionaries.
# Stored WITHOUT trailing period (the noise filter strips periods).
# These rescue tokens like "sth." / "sb." / "usw." from being deleted.
_KNOWN_ABBREVIATIONS: set = {
    # EN dictionary meta-words
    'sth', 'sb', 'smth', 'smb', 'sbd',
    # EN general
    'etc', 'eg', 'ie', 'esp', 'approx', 'dept', 'govt', 'corp',
    'inc', 'ltd', 'vs', 'cf', 'ibid', 'nb', 'ps', 'asap',
    # EN references / textbook
    'p', 'pp', 'ch', 'chap', 'fig', 'figs', 'no', 'nos', 'nr',
    'vol', 'vols', 'ed', 'eds', 'rev', 'repr', 'trans', 'ff',
    'fn', 'sec', 'par', 'para', 'app', 'abbr', 'ex', 'exs',
    'ans', 'wb', 'tb', 'vocab',
    # EN parts of speech / grammar
    'adj', 'adv', 'prep', 'conj', 'pron', 'det', 'art', 'interj',
    'aux', 'mod', 'inf', 'pt', 'pres', 'pret', 'ger',
    'sg', 'pl', 'sing', 'irreg', 'reg', 'intr', 'intrans',
    'refl', 'pass', 'imper', 'subj', 'ind', 'perf', 'fut',
    'attr', 'pred', 'comp', 'superl', 'pos', 'neg',
    'lit', 'colloq', 'sl', 'dial', 'arch', 'obs', 'fml', 'infml',
    'syn', 'ant', 'opp', 'var', 'orig',
    # EN titles
    'mr', 'mrs', 'ms', 'dr', 'prof', 'st', 'jr', 'sr',
    # EN pronunciation
    'br', 'am', 'brit', 'amer',
    # EN units
    'hr', 'hrs', 'min', 'km', 'cm', 'mm', 'kg', 'mg', 'ml',
    # DE general
    'usw', 'bzw', 'evtl', 'ggf', 'ggfs', 'sog', 'eigtl', 'allg',
    'bes', 'insb', 'insbes', 'bspw', 'ca',
    'od', 'ua', 'sa', 'vgl', 'zb', 'dh', 'zt', 'idr',
    'inkl', 'exkl', 'zzgl', 'abzgl',
    # DE references
    'abs', 'abschn', 'abt', 'anm', 'ausg', 'aufl', 'bd', 'bde',
    'bearb', 'ebd', 'hrsg', 'hg', 'jg', 'jh', 'jhd', 'kap',
    's', 'sp', 'zit', 'zs', 'vlg',
    # DE grammar
    'nom', 'akk', 'dat', 'gen', 'konj', 'subst', 'obj',
    'praet', 'imp', 'part', 'mask', 'fem', 'neutr',
    'trennb', 'untrennb', 'ugs', 'geh', 'pej',
    # DE regional
    'nordd', 'österr', 'schweiz',
    # Linguistic
    'lex', 'morph', 'phon', 'phonet', 'sem', 'synt', 'etym',
    'deriv', 'pref', 'suf', 'suff', 'dim', 'coll',
    'count', 'uncount', 'indef', 'def', 'poss', 'demon',
}


def _is_noise_tail_token(token: str) -> bool:
    """Check if a token at the END of cell text is trailing OCR noise.

    Trailing fragments are very common OCR artifacts from image edges,
    borders, and neighbouring cells.  This is more aggressive than a
    general word filter: any short token that isn't in the dictionary
    of common EN/DE words is considered noise.

    Examples of noise: "Es)", "3", "ee", "B"
    Examples to keep:  "sister.", "cupcakes.", "...", "mice", "[eg]"
    """
    t = token.strip()
    if not t:
        return True

    # Keep ellipsis
    if t in ('...', '…'):
        return False

    # Keep phonetic brackets: [eg], [maus], ["a:mand], serva], etc.
    if t.startswith('[') or t.startswith('["') or t.startswith("['"):
        return False
    if t.endswith(']'):
        return False

    # Keep meaningful punctuation tokens used in textbooks
    # = (definition marker), (= (definition opener), ; (separator)
    if t in ('=', '(=', '=)', ';', ':', '-', '–', '—', '/', '+', '&'):
        return False

    # Pure non-alpha -> noise ("3", ")", "|")
    alpha_chars = _RE_ALPHA.findall(t)
    if not alpha_chars:
        return True

    # Extract only alpha characters for dictionary lookup
    cleaned = ''.join(alpha_chars)

    # Known abbreviations (e.g. "sth.", "usw.", "adj.") — always keep
    if cleaned.lower() in _KNOWN_ABBREVIATIONS:
        return False

    # Strip normal trailing punctuation before checking for internal noise.
    stripped_punct = re.sub(r'[.,;:!?]+$', '', t)  # "cupcakes." -> "cupcakes"
    t_check = stripped_punct if stripped_punct else t

    # Check for legitimate punctuation patterns vs. real noise.
    # Legitimate: "(auf)", "under-", "e.g.", "(on)", "selbst)", "(wir",
    #             "(Salat-)Gurke", "Tanz(veranstaltung)", "(zer)brechen"
    # Noise: "3d", "B|", "x7"
    # Strategy: strip common dictionary punctuation (parens, hyphens, slashes),
    # THEN check if residual contains only alpha characters.
    t_inner = t_check
    # Remove all parentheses, hyphens, slashes, and dots — these are normal
    # in dictionary entries: "(Salat-)Gurke", "Tanz(veranstaltung)",
    # "(zer)brechen", "wir/uns", "e.g."
    t_inner = re.sub(r'[()\-/.,;:!?]', '', t_inner)
    # Now check: does the inner form still have non-alpha noise?
    inner_alpha = ''.join(_RE_ALPHA.findall(t_inner))
    has_internal_noise = (len(t_inner) > len(inner_alpha)) if t_inner else False

    # Long alpha words (4+ chars) without internal noise are likely real
    if len(cleaned) >= 4 and not has_internal_noise:
        return False

    # Short words: check dictionary (uses only alpha chars)
    if cleaned.lower() in _COMMON_SHORT_WORDS and not has_internal_noise:
        return False

    # Default: short or suspicious -> noise
    return True


def _is_garbage_text(text: str) -> bool:
    """Check if entire cell text is OCR garbage from image areas.

    Garbage text = no recognizable dictionary word.  Catches
    "(ci]oeu", "uanoaain." etc.
    """
    words = _RE_REAL_WORD.findall(text)
    if not words:
        # Check if any token is a known abbreviation (e.g. "e.g.")
        alpha_only = ''.join(_RE_ALPHA.findall(text)).lower()
        if alpha_only in _KNOWN_ABBREVIATIONS:
            return False
        return True

    for w in words:
        wl = w.lower()
        # Known short word or abbreviation -> not garbage
        if wl in _COMMON_SHORT_WORDS or wl in _KNOWN_ABBREVIATIONS:
            return False
        # Long word (>= 4 chars): check vowel/consonant ratio.
        # Real EN/DE words have 20-60% vowels.  Garbage like "uanoaain"
        # or "cioeu" has unusual ratios (too many or too few vowels).
        if len(wl) >= 4:
            vowels = sum(1 for c in wl if c in 'aeiouäöü')
            ratio = vowels / len(wl)
            if 0.15 <= ratio <= 0.65:
                return False  # plausible vowel ratio -> real word

    return True


def _clean_cell_text(text: str) -> str:
    """Remove OCR noise from cell text.  Generic filters:

    1. If the entire text has no real alphabetic word (>= 2 letters), clear.
    2. If the entire text is garbage (no dictionary word), clear.
    3. Strip trailing noise tokens from the end of the text.
    """
    stripped = text.strip()
    if not stripped:
        return ''

    # --- Filter 1: No real word at all ---
    if not _RE_REAL_WORD.search(stripped):
        # Exception: dotted abbreviations like "e.g.", "z.B.", "i.e."
        alpha_only = ''.join(_RE_ALPHA.findall(stripped)).lower()
        if alpha_only not in _KNOWN_ABBREVIATIONS:
            return ''

    # --- Filter 2: Entire text is garbage ---
    if _is_garbage_text(stripped):
        return ''

    # --- Filter 3: Strip trailing noise tokens ---
    tokens = stripped.split()
    while tokens and _is_noise_tail_token(tokens[-1]):
        tokens.pop()
    if not tokens:
        return ''

    return ' '.join(tokens)


def _clean_cell_text_lite(text: str) -> str:
    """Simplified noise filter for cell-first OCR (isolated cell crops).

    Since each cell is OCR'd in isolation (no neighbour content visible),
    trailing-noise stripping is unnecessary.  Only 2 filters remain:

    1. No real alphabetic word (>= 2 letters) and not a known abbreviation -> empty.
    2. Entire text is garbage (no dictionary word) -> empty.
    """
    stripped = text.strip()
    if not stripped:
        return ''

    # --- Filter 1: No real word at all ---
    if not _RE_REAL_WORD.search(stripped):
        alpha_only = ''.join(_RE_ALPHA.findall(stripped)).lower()
        if alpha_only not in _KNOWN_ABBREVIATIONS:
            return ''

    # --- Filter 2: Entire text is garbage ---
    if _is_garbage_text(stripped):
        return ''

    return stripped


# ---------------------------------------------------------------------------
# Bold detection via stroke-width analysis (relative / page-level)
# ---------------------------------------------------------------------------

def _measure_stroke_width(gray_crop: np.ndarray) -> float:
    """Measure mean stroke width in a binarised cell crop.

    Returns a DPI-normalised value (mean stroke width as % of crop height),
    or 0.0 if measurement is not possible.
    """
    if gray_crop is None or gray_crop.size == 0:
        return 0.0
    h, w = gray_crop.shape[:2]
    if h < 10 or w < 10:
        return 0.0

    # Binarise: text = white (255), background = black (0)
    _, bw = cv2.threshold(gray_crop, 0, 255, cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)
    if cv2.countNonZero(bw) < 20:
        return 0.0

    # Distance transform: value at each white pixel = distance to nearest black
    dist = cv2.distanceTransform(bw, cv2.DIST_L2, 3)

    # Skeleton via morphological thinning
    kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3))
    thin = bw.copy()
    for _ in range(max(1, min(h, w) // 6)):
        eroded = cv2.erode(thin, kernel)
        if cv2.countNonZero(eroded) < 5:
            break
        thin = eroded

    skeleton_pts = thin > 0
    if not np.any(skeleton_pts):
        return 0.0
    mean_stroke = float(np.mean(dist[skeleton_pts]))
    return mean_stroke / max(h, 1) * 100  # normalised: % of cell height


def _classify_bold_cells(cells: List[Dict[str, Any]], ocr_img: Optional[np.ndarray],
                         img_w: int, img_h: int) -> None:
    """Two-pass bold detection: measure all cells, then compare against median.

    Cells with stroke width > 1.4x the page median are marked as bold.
    This adapts automatically to font, DPI and scan quality.
    Modifies cells in-place (sets 'is_bold' key).
    """
    if ocr_img is None:
        return

    # Pass 1: measure stroke width for every cell with text
    metrics: List[float] = []
    cell_strokes: List[float] = []
    for cell in cells:
        sw = 0.0
        if cell.get('text', '').strip():
            bp = cell['bbox_px']
            y1 = max(0, bp['y'])
            y2 = min(img_h, bp['y'] + bp['h'])
            x1 = max(0, bp['x'])
            x2 = min(img_w, bp['x'] + bp['w'])
            if y2 > y1 and x2 > x1:
                sw = _measure_stroke_width(ocr_img[y1:y2, x1:x2])
        cell_strokes.append(sw)
        if sw > 0:
            metrics.append(sw)

    if len(metrics) < 3:
        # Too few cells to compare — leave all as non-bold
        return

    median_sw = float(np.median(metrics))
    if median_sw <= 0:
        return

    # Pass 2: cells significantly above median -> bold
    for cell, sw in zip(cells, cell_strokes):
        cell['is_bold'] = sw > 0 and (sw / median_sw) > 1.4
