"""Font analysis: clustering, CM->LaTeX mapping, heading hierarchy."""
from __future__ import annotations

import re
from collections import Counter
from dataclasses import dataclass, field
from typing import Optional
from ..types import Span


@dataclass
class FontProfile:
    name: str
    size: float
    is_bold: bool
    char_count: int


@dataclass
class FontHierarchy:
    body_font: str
    body_size: float
    title_size: Optional[float] = None
    heading_sizes: list[float] = field(default_factory=list)
    footnote_size: Optional[float] = None
    bold_heading_at_body_size: bool = False  # True if bold body-size text acts as heading

    def heading_level(self, size: float, is_bold: bool, font: str = "") -> int:
        """Return heading level (1=title, 2=H2, 3=H3, ...) or 0 for body text."""
        # Reject non-body font families early (e.g. figure labels in Futura)
        _different_family = False
        if font and self.body_font:
            body_family = _font_family(self.body_font)
            line_family = _font_family(font)
            _different_family = not _families_related(body_family, line_family)
        if self.title_size and abs(size - self.title_size) < 0.5:
            return 1
        for i, hs in enumerate(self.heading_sizes):
            if abs(size - hs) < 0.5 and is_bold:
                if _different_family:
                    return 0  # Different font family — likely figure/chart label
                return i + 2
        # Fallback: bold text larger than body — but only if same font family
        if size > self.body_size * 1.05 and is_bold:
            if _different_family:
                return 0
            return min(len(self.heading_sizes) + 2, 4)
        # Bold text at body size can be heading if flagged
        if self.bold_heading_at_body_size and is_bold and abs(size - self.body_size) < 1.0:
            return len(self.heading_sizes) + 2
        return 0

    def is_footnote(self, size: float) -> bool:
        if self.footnote_size:
            return abs(size - self.footnote_size) < 0.5
        return size < self.body_size * 0.85


_ARXIV_PATTERN = re.compile(r"arXiv:\d|^\d{4}\.\d{4,5}")


def _is_arxiv_metadata_text(text: str) -> bool:
    """Check if text looks like arXiv metadata."""
    return bool(_ARXIV_PATTERN.search(text))


def build_font_hierarchy(spans: list[Span], config=None) -> FontHierarchy:
    """Analyze all spans to determine font hierarchy."""
    size_counts: Counter[float] = Counter()
    bold_size_counts: Counter[float] = Counter()
    font_size_counts: Counter[tuple[str, float]] = Counter()
    size_texts: dict[float, str] = {}
    # Track which sizes appear on first 2 pages (where titles live)
    early_page_sizes: set[float] = set()
    # Track the dominant font family for each size
    size_fonts: dict[float, Counter] = {}

    # Track distinct text strings per size (for repetition detection)
    size_span_texts: dict[float, list[str]] = {}

    for span in spans:
        text = span.text.strip()
        if not text:
            continue
        rounded_size = round(span.size * 2) / 2
        size_counts[rounded_size] += len(text)
        font_size_counts[(span.font, rounded_size)] += len(text)
        if span.is_bold:
            bold_size_counts[rounded_size] += len(text)
        existing = size_texts.get(rounded_size, "")
        if len(existing) < 500:
            size_texts[rounded_size] = existing + " " + text
        # Track font family per size
        if rounded_size not in size_fonts:
            size_fonts[rounded_size] = Counter()
        # Extract base font family
        base_font = _font_family(span.font)
        size_fonts[rounded_size][base_font] += len(text)
        # Track distinct text strings
        if len(text) > 2:
            size_span_texts.setdefault(rounded_size, []).append(text)

    if not size_counts:
        return FontHierarchy(body_font="", body_size=12.0)

    body_size = max(size_counts, key=size_counts.get)
    body_font_key = max(
        ((k, v) for k, v in font_size_counts.items() if k[1] == body_size),
        key=lambda x: x[1],
    )[0]
    body_font = body_font_key[0]
    body_font_family = _font_family(body_font)

    # Filter sizes: exclude arXiv metadata and figure annotations
    valid_larger = []
    for s in sorted(size_counts.keys(), reverse=True):
        if s <= body_size * 1.05:
            continue
        sample = size_texts.get(s, "")
        # Skip arXiv ID stamps
        if _is_arxiv_metadata_text(sample) and size_counts[s] < 200:
            continue
        # Skip sizes with very few characters (drop caps, single chars)
        if size_counts[s] < 3:
            continue
        # Skip sizes that use a completely different font family from body
        # (likely figure/chart annotations) unless they're very prominent
        # or mostly bold (companion heading fonts like LinBiolinum + LinLibertine)
        sf = size_fonts.get(s, Counter())
        dominant_family = sf.most_common(1)[0][0] if sf else ""
        if dominant_family != body_font_family and size_counts[s] < 500:
            bold_fraction = bold_size_counts.get(s, 0) / size_counts[s] if size_counts[s] > 0 else 0
            if bold_fraction < 0.5 and dominant_family not in ("Times", "NimbusRomNo9L"):
                continue
            # Even bold different-family: skip if text is repetitive
            # (same string appearing 2+ times = figure/chart labels)
            span_texts = size_span_texts.get(s, [])
            if span_texts and len(set(span_texts)) == 1 and len(span_texts) >= 2:
                continue
        valid_larger.append(s)

    # Title = largest valid size with enough text to be a title
    _NON_TITLE_WORDS = {"appendix", "appendices", "references", "bibliography",
                        "acknowledgments", "acknowledgements", "supplementary"}
    title_size = None
    for s in valid_larger:
        text = size_texts.get(s, "").strip()
        # Skip if text is just a single common non-title section word
        if text.lower() in _NON_TITLE_WORDS:
            continue
        title_size = s
        break

    # Heading sizes = bold sizes larger than body (excluding title)
    heading_sizes = []
    for s in valid_larger:
        if title_size and abs(s - title_size) < 0.5:
            continue
        bold_chars = bold_size_counts.get(s, 0)
        total_chars = size_counts[s]
        if bold_chars > total_chars * 0.3:
            # Skip heading sizes that only contain a single section word
            text = size_texts.get(s, "").strip().lower()
            if text in _NON_TITLE_WORDS:
                continue
            # Skip different-family sizes with very little text (figure labels)
            sf = size_fonts.get(s, Counter())
            dominant = sf.most_common(1)[0][0] if sf else ""
            if dominant != body_font_family and total_chars < 50:
                continue
            heading_sizes.append(s)
    heading_sizes.sort(reverse=True)

    # Check if bold-at-body-size should act as heading
    # This handles papers where headings are bold at body size (BERT, KAN survey)
    bold_heading_at_body_size = False
    if not heading_sizes:
        # No intermediate heading sizes — check for bold body-size text
        # that could be section headings
        for s in size_counts:
            if abs(s - body_size) < 1.0 and bold_size_counts.get(s, 0) > 0:
                bold_ratio = bold_size_counts[s] / size_counts[s]
                # If bold text is a small fraction of total, it's likely headings
                if bold_ratio < 0.3:
                    bold_heading_at_body_size = True
                    break

    footnote_candidates = [s for s in size_counts if s < body_size * 0.85]
    footnote_size = max(footnote_candidates) if footnote_candidates else None

    return FontHierarchy(
        body_font=body_font,
        body_size=body_size,
        title_size=title_size,
        heading_sizes=heading_sizes,
        footnote_size=footnote_size,
        bold_heading_at_body_size=bold_heading_at_body_size,
    )


# Computer Modern font family prefixes used for math
# Pure math fonts (always math): CMMI (Math Italic), CMSY (Symbols), CMEX (Extensions)
CM_MATH_FONTS = {
    "CMMI", "CMSY", "CMEX",
    "cmmi", "cmsy", "cmex",
    "CMBSY", "cmbsy", "CMMIB", "cmmib",
    "LASY", "lasy", "MSAM", "MSBM", "msam", "msbm",
    "StandardSymbolsPS",
}

# CM text fonts that are math ONLY when body font is non-CM
# (In LaTeX papers, CMR/CMBX/CMTI are body text; in non-LaTeX papers
# they appear only inside equations for text like "softmax", "Attention")
_CM_TEXT_FONTS = {"CMR", "CMBX", "CMTI", "cmr", "cmbx", "cmti"}

# Map CM font characters to LaTeX commands
CM_TO_LATEX: dict[str, str] = {
    # Arithmetic / basic operators
    "\u2212": "-",
    "\u00b7": "\\cdot ",
    "\u00d7": "\\times ",
    "\u00f7": "\\div ",
    "\u00b1": "\\pm ",
    "\u2217": "*",           # asterisk operator (CMSY)
    "\u25e6": "\\circ ",     # circle operator (CMSY)
    "\u2218": "\\circ ",     # ring operator
    # Relations
    "\u2264": "\\leq ",
    "\u2265": "\\geq ",
    "\u2260": "\\neq ",
    "\u2248": "\\approx ",
    "\u2261": "\\equiv ",
    "\u223c": "\\sim ",      # tilde operator (CMSY)
    "\u2243": "\\simeq ",
    "\u2245": "\\cong ",
    "\u221d": "\\propto ",   # proportional to (CMSY)
    # Set theory / logic
    "\u221e": "\\infty ",
    "\u2208": "\\in ",
    "\u2209": "\\notin ",
    "\u2282": "\\subset ",
    "\u2283": "\\supset ",
    "\u222a": "\\cup ",
    "\u2229": "\\cap ",
    "\u2200": "\\forall ",
    "\u2203": "\\exists ",
    "\u2228": "\\lor ",
    "\u2227": "\\land ",
    "\u00ac": "\\neg ",
    # Calculus / big operators
    "\u2207": "\\nabla ",
    "\u2202": "\\partial ",
    "\u221a": "\\sqrt",
    "\u2211": "\\sum ",
    "\u220f": "\\prod ",
    "\u222b": "\\int ",
    # Greek lowercase
    "\u03b1": "\\alpha ",
    "\u03b2": "\\beta ",
    "\u03b3": "\\gamma ",
    "\u03b4": "\\delta ",
    "\u03b5": "\\epsilon ",
    "\u03f5": "\\epsilon ",  # lunate epsilon variant (CMMI)
    "\u03b6": "\\zeta ",
    "\u03b7": "\\eta ",
    "\u03b8": "\\theta ",
    "\u03b9": "\\iota ",
    "\u03ba": "\\kappa ",
    "\u03bb": "\\lambda ",
    "\u03bc": "\\mu ",
    "\u00b5": "\\mu ",       # micro sign used as mu (CMMI/CMMIB)
    "\u03bd": "\\nu ",
    "\u03be": "\\xi ",
    "\u03c0": "\\pi ",
    "\u03c1": "\\rho ",
    "\u03c3": "\\sigma ",
    "\u03c4": "\\tau ",
    "\u03c5": "\\upsilon ",
    "\u03c6": "\\phi ",
    "\u03d5": "\\phi ",      # phi symbol variant (CMMI)
    "\u03c7": "\\chi ",
    "\u03c8": "\\psi ",
    "\u03c9": "\\omega ",
    # Greek uppercase
    "\u0393": "\\Gamma ",
    "\u0394": "\\Delta ",
    "\u0398": "\\Theta ",
    "\u039b": "\\Lambda ",
    "\u039e": "\\Xi ",
    "\u03a0": "\\Pi ",
    "\u03a3": "\\Sigma ",
    "\u03a6": "\\Phi ",
    "\u03a8": "\\Psi ",
    "\u03a9": "\\Omega ",
    # Arrows
    "\u2192": "\\to ",
    "\u2190": "\\leftarrow ",
    "\u21d2": "\\Rightarrow ",
    "\u21d0": "\\Leftarrow ",
    "\u2194": "\\leftrightarrow ",
    # Delimiters and bars
    "\u2225": "\\| ",        # parallel / double bar (CMSY)
    "\u2016": "\\| ",        # double vertical line
    "\u2032": "'",           # prime (CMSY)
    "\u2020": "\\dagger ",   # dagger (CMSY)
    "\u2021": "\\ddagger ",  # double dagger (CMSY)
    # Accents (from CMR fonts)
    "\u00af": "\\bar ",      # macron / overline
    "\u02dc": "\\tilde ",    # small tilde
    "\u02c6": "\\hat ",      # modifier circumflex
    "\u0338": "/",           # combining solidus (negation)
    # Script / special letters
    "\u2113": "\\ell ",      # script ell (CMMI)
    "\u2118": "\\wp ",       # Weierstrass p
    "\u211c": "\\Re ",       # real part
    "\u2111": "\\Im ",       # imaginary part
    # Miscellaneous
    "\u2713": "\\checkmark ", # check mark
    "\u22c5": "\\cdot ",     # dot operator
    "\u2026": "\\ldots ",    # horizontal ellipsis
    "\u22ef": "\\cdots ",    # midline horizontal ellipsis
    "\u22c6": "\\star ",     # star operator
    "\u2606": "\\star ",     # white star
    "\u25a1": "\\square ",   # white square
    "\u25c7": "\\diamond ",  # white diamond
    "\u25b3": "\\triangle ", # white triangle
    "\u2205": "\\emptyset ", # empty set
    "\u2135": "\\aleph ",    # aleph
    "\ufb00": "ff",          # ff ligature (CMR)
    "\ufb01": "fi",          # fi ligature
    "\ufb02": "fl",          # fl ligature
    "\ufb03": "ffi",         # ffi ligature
    "\ufb04": "ffl",         # ffl ligature
    # Additional relations
    "\u226a": "\\ll ",       # much less than
    "\u226b": "\\gg ",       # much greater than
    "\u2286": "\\subseteq ",
    "\u2287": "\\supseteq ",
    "\u228a": "\\subsetneq ",
    "\u2291": "\\sqsubseteq ",
    "\u2292": "\\sqsupseteq ",
    "\u22a2": "\\vdash ",
    "\u22a3": "\\dashv ",
    "\u22a8": "\\models ",
    "\u22a5": "\\perp ",
    "\u2126": "\\Omega ",    # Ohm sign (used as Omega)
    "\u2329": "\\langle ",
    "\u232a": "\\rangle ",
    "\u27e8": "\\langle ",   # mathematical left angle bracket
    "\u27e9": "\\rangle ",   # mathematical right angle bracket
}

SUPERSCRIPT_MAP = {
    "\u2070": "0", "\u00b9": "1", "\u00b2": "2", "\u00b3": "3",
    "\u2074": "4", "\u2075": "5", "\u2076": "6", "\u2077": "7",
    "\u2078": "8", "\u2079": "9", "\u207a": "+", "\u207b": "-",
    "\u207c": "=", "\u207d": "(", "\u207e": ")",
}

SUBSCRIPT_MAP = {
    "\u2080": "0", "\u2081": "1", "\u2082": "2", "\u2083": "3",
    "\u2084": "4", "\u2085": "5", "\u2086": "6", "\u2087": "7",
    "\u2088": "8", "\u2089": "9", "\u208a": "+", "\u208b": "-",
    "\u208c": "=", "\u208d": "(", "\u208e": ")",
}


# Font families that are companions (same designer/project, used together)
_COMPANION_FAMILIES = [
    {"LinLibertineT", "LinBiolinumT"},  # Linux Libertine + Biolinum
    {"NimbusRomNo9L", "NimbusSanL"},     # Nimbus Roman + Sans
]


def _families_related(family_a: str, family_b: str) -> bool:
    """Check if two font families are the same or are known companion families."""
    if family_a == family_b:
        return True
    for group in _COMPANION_FAMILIES:
        if family_a in group and family_b in group:
            return True
    return False


def _font_family(font_name: str) -> str:
    """Extract base font family, normalizing bold/italic/MT variants.

    Examples:
        TimesNewRomanPSMT        → TimesNewRomanPS
        TimesNewRomanPS-BoldMT   → TimesNewRomanPS
        NimbusRomNo9L-Regu       → NimbusRomNo9L
        ArialMT                  → Arial
        Arial-BoldMT             → Arial
        LinLibertineTB           → LinLibertineT
        LinLibertineTBI          → LinLibertineT
        Futura-Medium            → Futura
    """
    base = font_name.split("-")[0] if "-" in font_name else font_name
    if base.endswith("MT") and len(base) > 2:
        base = base[:-2]
    # Strip trailing weight/style suffixes: BI (bold-italic), B (bold), I (italic)
    # Only strip if the remaining base is at least 4 chars to avoid over-stripping
    for suffix in ("BI", "B", "I"):
        if base.endswith(suffix) and len(base) - len(suffix) >= 4:
            candidate = base[:-len(suffix)]
            # Verify the suffix isn't part of the name (e.g. "CMMI" shouldn't strip "I")
            # Check: the char before suffix is lowercase, digit, or uppercase preceded
            # by a lowercase letter (e.g. "LinLibertineT" + "B")
            if candidate:
                last = candidate[-1]
                if last.islower() or last.isdigit():
                    base = candidate
                    break
                # "FooBarT" + "B" → strip if there's a lowercase letter before T
                if last.isupper() and len(candidate) >= 2 and candidate[-2].islower():
                    base = candidate
                    break
    return base


def is_math_font(font_name: str, body_font: str = "") -> bool:
    """Check if a font name is a Computer Modern math font.

    CM text fonts (CMR, CMBX, CMTI) are treated as math only when the
    document's body font is NOT a CM font (i.e., the CM text font is used
    inside equations in a non-LaTeX-typeset paper).
    """
    for prefix in CM_MATH_FONTS:
        if font_name.startswith(prefix):
            return True
    # CM text fonts: math only if body is non-CM
    for prefix in _CM_TEXT_FONTS:
        if font_name.startswith(prefix):
            # If body font is also CM, this is regular text, not math
            if body_font:
                for bp in _CM_TEXT_FONTS | CM_MATH_FONTS:
                    if body_font.startswith(bp):
                        return False
            return True
    return False


def char_to_latex(char: str, font: str) -> str:
    """Convert a single character from a CM font to LaTeX."""
    if char in CM_TO_LATEX:
        return CM_TO_LATEX[char]
    if char in SUPERSCRIPT_MAP:
        return f"^{{{SUPERSCRIPT_MAP[char]}}}"
    if char in SUBSCRIPT_MAP:
        return f"_{{{SUBSCRIPT_MAP[char]}}}"
    # CMEX fonts encode large delimiters (parens, brackets, braces, radicals,
    # summation/product/integral symbols) as ASCII code points.  These have no
    # meaningful text content — drop them entirely to avoid garbled output.
    if font.startswith(("CMEX", "cmex")):
        return ""
    # Control characters from other CM fonts are also delimiter fragments
    if ord(char) < 0x20 and char not in ('\n', '\t'):
        return ""
    # Private-use area characters (U+F8xx) are bracket/paren pieces
    if 0xF800 <= ord(char) <= 0xF8FF:
        return ""
    return char
