"""Extract numeric features from a natural_pdf Page for structure classification.

Reimplements the numeric computation from to_llm_sections.py, calling the same
page APIs but returning numbers instead of rendered text.
"""

from __future__ import annotations

import logging
import re
from collections import defaultdict
from typing import Any, Dict, List, Tuple

import numpy as np

logger = logging.getLogger(__name__)


# ── Helpers ────────────────────────────────────────────────────────────────


def _cluster_values(
    values: List[float], tolerance: float = 3.0
) -> List[Tuple[float, List[int]]]:
    """Cluster sorted numeric values by tolerance gap.

    Returns list of (center, [indices]) tuples sorted by center.
    Identical to to_llm_sections._cluster_values.
    """
    if not values:
        return []

    indexed = sorted(enumerate(values), key=lambda x: x[1])
    clusters: List[Tuple[List[int], List[float]]] = []
    current_indices = [indexed[0][0]]
    current_values = [indexed[0][1]]

    for i in range(1, len(indexed)):
        idx, val = indexed[i]
        if val - current_values[-1] > tolerance:
            clusters.append((current_indices, current_values))
            current_indices = [idx]
            current_values = [val]
        else:
            current_indices.append(idx)
            current_values.append(val)

    clusters.append((current_indices, current_values))

    result = []
    for indices, vals in clusters:
        center = sum(vals) / len(vals)
        result.append((center, indices))

    return sorted(result, key=lambda x: x[0])


def _cluster_peaks(
    pcts: np.ndarray, threshold: float = 15.0, gap: int = 5
) -> List[Tuple[int, float, int]]:
    """Find peaks in a 1D density profile.

    Groups adjacent pixels above threshold into clusters.
    Returns (center_pixel, max_density, width_in_pixels) per cluster.
    Identical to to_llm_sections.render_pixel_histogram._cluster_peaks.
    """
    peaks = [(i, float(pcts[i])) for i in range(len(pcts)) if pcts[i] > threshold]
    if not peaks:
        return []
    clusters = [[peaks[0]]]
    for p in peaks[1:]:
        if p[0] - clusters[-1][-1][0] <= gap:
            clusters[-1].append(p)
        else:
            clusters.append([p])
    return [
        (
            sum(p[0] for p in c) // len(c),
            max(p[1] for p in c),
            c[-1][0] - c[0][0] + 1,
        )
        for c in clusters
    ]


# ── Garble rate ────────────────────────────────────────────────────────────

_SCRIPT_TO_LANG: Dict[str, str] = {"LATIN": "en", "CYRILLIC": "ru", "ARABIC": "ar"}
_SPELLCHECK_LANGS = {"en", "es", "fr", "de", "pt", "ru", "ar", "eu", "lv", "nl"}
_spellchecker_cache: Dict[str, Any] = {}
_WORD_RE = re.compile(r"^[a-zA-Z]+(?:['''\-][a-zA-Z]+)*$")


def _get_spellchecker(lang: str) -> Any:
    if lang not in _spellchecker_cache:
        from spellchecker import SpellChecker

        _spellchecker_cache[lang] = SpellChecker(language=lang)
    return _spellchecker_cache[lang]


def _detect_script(text: str) -> str:
    import unicodedata

    script_counts: Dict[str, int] = {}
    for ch in text:
        if not ch.isalpha():
            continue
        try:
            name = unicodedata.name(ch, "")
        except ValueError:
            continue
        script = name.split(" ", 1)[0] if name else "UNKNOWN"
        if script in ("CJK",):
            script = "Han"
        script_counts[script] = script_counts.get(script, 0) + 1
    if not script_counts:
        return "Latin"
    return max(script_counts, key=script_counts.get)


def _compute_garble_features(text_elements: list) -> Tuple[float, int, int]:
    """Compute garble rate from text elements.

    Returns (garble_rate, misspelled_count, alpha_word_count).
    Returns (0.0, 0, 0) on failure.
    """
    try:
        from spellchecker import SpellChecker  # noqa: F401
    except ImportError:
        return (0.0, 0, 0)

    combined = " ".join(getattr(el, "text", "") or "" for el in text_elements)
    if not combined.strip():
        return (0.0, 0, 0)

    script = _detect_script(combined)
    lang = _SCRIPT_TO_LANG.get(script.upper(), None)

    if script.upper() == "LATIN" and lang == "en":
        try:
            from langdetect import detect as _detect_lang

            detected = _detect_lang(combined)
            if detected in _SPELLCHECK_LANGS:
                lang = detected
        except Exception:
            pass

    if lang is None or lang not in _SPELLCHECK_LANGS:
        return (0.0, 0, 0)

    words: List[str] = []
    for el in text_elements:
        for token in (getattr(el, "text", "") or "").split():
            clean = token.strip(".,;:!?()[]{}\"'`~#*&@/\\<>-_=+")
            if not clean or len(clean) < 3:
                continue
            if clean == clean.upper():
                continue
            if "-" in clean:
                parts = [p for p in clean.split("-") if len(p) >= 3 and p.isalpha()]
                words.extend(p.lower() for p in parts if p != p.upper())
            elif clean.endswith("'s") or clean.endswith("\u2019s"):
                stem = clean[:-2]
                if len(stem) >= 3 and stem.isalpha() and stem != stem.upper():
                    words.append(stem.lower())
            elif _WORD_RE.match(clean):
                words.append(clean.lower())

    if not words:
        return (0.0, 0, 0)

    spell = _get_spellchecker(lang)
    misspelled = spell.unknown(words)
    rate = len(misspelled) / len(words)
    return (rate, len(misspelled), len(words))


# ── Feature extraction ─────────────────────────────────────────────────────


def extract_features(page) -> Dict[str, float]:
    """Extract ~62 numeric features from a natural_pdf Page object.

    Each feature group is wrapped in try/except so a single failure
    defaults that group to 0.0 without crashing the pipeline.
    """
    f: Dict[str, float] = {}

    # Pre-fetch page data (each API called once)
    try:
        text_elements = list(page.find_all("text"))
    except Exception:
        text_elements = []

    try:
        all_lines = list(page.lines)
    except Exception:
        all_lines = []

    try:
        rects = list(page.find_all("rect"))
    except Exception:
        rects = []

    try:
        images = list(page.images)
    except Exception:
        images = []

    page_w = float(getattr(page, "width", 0) or 0)
    page_h = float(getattr(page, "height", 0) or 0)
    page_area = page_w * page_h if page_w > 0 and page_h > 0 else 1.0

    # ── Group A: Text Layer (7) ────────────────────────────────────────
    try:
        total = len(text_elements)
        native_count = sum(
            1
            for el in text_elements
            if getattr(el, "source", "pdf") in ("native", "pdf")
        )
        ocr_count = sum(
            1 for el in text_elements if getattr(el, "source", "pdf") == "ocr"
        )
        total_chars = sum(len(getattr(el, "text", "") or "") for el in text_elements)

        f["text_total_count"] = float(total)
        f["text_native_count"] = float(native_count)
        f["text_ocr_count"] = float(ocr_count)
        f["text_native_ratio"] = native_count / max(total, 1)
        f["text_total_chars"] = float(total_chars)
        f["text_mean_element_len"] = total_chars / max(total, 1)
        f["text_has_any"] = 1.0 if total > 0 else 0.0
    except Exception as e:
        logger.warning("Feature group A (text layer) failed: %s", e)
        for k in [
            "text_total_count",
            "text_native_count",
            "text_ocr_count",
            "text_native_ratio",
            "text_total_chars",
            "text_mean_element_len",
            "text_has_any",
        ]:
            f.setdefault(k, 0.0)

    # ── Group B: Garble Rate (3) ───────────────────────────────────────
    try:
        gr, mc, awc = _compute_garble_features(text_elements)
        f["garble_rate"] = gr
        f["garble_misspelled_count"] = float(mc)
        f["garble_alpha_word_count"] = float(awc)
    except Exception as e:
        logger.warning("Feature group B (garble rate) failed: %s", e)
        for k in ["garble_rate", "garble_misspelled_count", "garble_alpha_word_count"]:
            f.setdefault(k, 0.0)

    # ── Group C: Vector Elements (8) ───────────────────────────────────
    try:
        h_lines = [l for l in all_lines if l.is_horizontal]
        v_lines = [l for l in all_lines if l.is_vertical]

        # Rect classification
        small_rects = []
        small_squares = []
        degenerate = 0
        for r in rects:
            rw, rh = float(r.width), float(r.height)
            if rw < 1 or rh < 1:
                degenerate += 1
            elif max(rw, rh) < 20:
                small_rects.append(r)
                if abs(rw - rh) < 3.0:
                    small_squares.append(r)

        large_count = len(rects) - len(small_rects) - degenerate

        # Nesting: count large rects fully inside another large rect
        large_rects = [
            r
            for r in rects
            if float(r.width) >= 1
            and float(r.height) >= 1
            and max(float(r.width), float(r.height)) >= 20
        ]
        nested = 0
        tol = 2.0
        for i, child in enumerate(large_rects):
            for j, parent in enumerate(large_rects):
                if i == j:
                    continue
                if (
                    child.x0 >= parent.x0 - tol
                    and child.top >= parent.top - tol
                    and child.x1 <= parent.x1 + tol
                    and child.bottom <= parent.bottom + tol
                    and (parent.width * parent.height) > (child.width * child.height)
                ):
                    nested += 1
                    break

        f["line_h_count"] = float(len(h_lines))
        f["line_v_count"] = float(len(v_lines))
        f["rect_total_count"] = float(len(rects))
        f["rect_small_count"] = float(len(small_rects))
        f["rect_small_square_count"] = float(len(small_squares))
        f["rect_large_count"] = float(large_count)
        f["rect_degenerate_count"] = float(degenerate)
        f["rect_nested_count"] = float(nested)
    except Exception as e:
        logger.warning("Feature group C (vector elements) failed: %s", e)
        for k in [
            "line_h_count",
            "line_v_count",
            "rect_total_count",
            "rect_small_count",
            "rect_small_square_count",
            "rect_large_count",
            "rect_degenerate_count",
            "rect_nested_count",
        ]:
            f.setdefault(k, 0.0)

    # ── Group D: Image Features (4) ────────────────────────────────────
    try:
        img_areas = [float(img.width) * float(img.height) for img in images]
        total_img_area = sum(img_areas)
        max_img_area = max(img_areas) if img_areas else 0.0

        f["image_count"] = float(len(images))
        f["image_coverage_pct"] = total_img_area / page_area * 100
        f["image_max_area_pct"] = max_img_area / page_area * 100
        f["image_has_large"] = 1.0 if max_img_area / page_area > 0.25 else 0.0
    except Exception as e:
        logger.warning("Feature group D (images) failed: %s", e)
        for k in [
            "image_count",
            "image_coverage_pct",
            "image_max_area_pct",
            "image_has_large",
        ]:
            f.setdefault(k, 0.0)

    # ── Group E: Style Tiers (8) ───────────────────────────────────────
    try:
        tiers: Dict[Tuple, list] = defaultdict(list)
        for el in text_elements:
            size = round(float(getattr(el, "size", 0) or 0) * 2) / 2
            bold = bool(getattr(el, "bold", False))
            italic = bool(getattr(el, "italic", False))
            tiers[(size, bold, italic)].append(el)

        tier_counts = [len(v) for v in tiers.values()]
        sizes = [k[0] for k in tiers.keys()]
        bold_count = sum(1 for el in text_elements if getattr(el, "bold", False))
        italic_count = sum(1 for el in text_elements if getattr(el, "italic", False))
        total_el = max(len(text_elements), 1)
        max_tier = max(tier_counts) if tier_counts else 0
        bold_tiers = sum(1 for k in tiers if k[1])

        f["style_tier_count"] = float(len(tiers))
        f["style_max_size"] = max(sizes) if sizes else 0.0
        f["style_min_size"] = min(sizes) if sizes else 0.0
        f["style_size_range"] = (max(sizes) - min(sizes)) if sizes else 0.0
        f["style_bold_ratio"] = bold_count / total_el
        f["style_italic_ratio"] = italic_count / total_el
        f["style_dominant_tier_ratio"] = max_tier / total_el
        f["style_bold_tier_count"] = float(bold_tiers)
    except Exception as e:
        logger.warning("Feature group E (style tiers) failed: %s", e)
        for k in [
            "style_tier_count",
            "style_max_size",
            "style_min_size",
            "style_size_range",
            "style_bold_ratio",
            "style_italic_ratio",
            "style_dominant_tier_ratio",
            "style_bold_tier_count",
        ]:
            f.setdefault(k, 0.0)

    # ── Group F: Line Geometry (8) ─────────────────────────────────────
    try:
        h_lines_list = [l for l in all_lines if l.is_horizontal]
        v_lines_list = [l for l in all_lines if l.is_vertical]

        # Horizontal line clusters
        if h_lines_list:
            h_y = [float(l.top) for l in h_lines_list]
            h_clusters = _cluster_values(h_y, tolerance=2.0)
            h_x0 = min(float(l.x0) for l in h_lines_list)
            h_x1 = max(float(l.x1) for l in h_lines_list)
            h_span = (h_x1 - h_x0) / page_w if page_w > 0 else 0.0

            h_positions = [c[0] for c in h_clusters]
            h_regular = 0.0
            h_avg_gap = 0.0
            if len(h_positions) >= 3:
                gaps = [
                    h_positions[i + 1] - h_positions[i]
                    for i in range(len(h_positions) - 1)
                ]
                h_avg_gap = sum(gaps) / len(gaps)
                if h_avg_gap > 0:
                    cv = (max(gaps) - min(gaps)) / h_avg_gap
                    if cv < 0.3:
                        h_regular = 1.0
        else:
            h_clusters = []
            h_span = 0.0
            h_regular = 0.0
            h_avg_gap = 0.0

        # Vertical line clusters
        if v_lines_list:
            v_x = [float(l.x0) for l in v_lines_list]
            v_clusters = _cluster_values(v_x, tolerance=2.0)
            v_top = min(float(l.top) for l in v_lines_list)
            v_bot = max(float(l.bottom) for l in v_lines_list)
            v_span = (v_bot - v_top) / page_h if page_h > 0 else 0.0

            v_positions = [c[0] for c in v_clusters]
            v_avg_gap = 0.0
            if len(v_positions) >= 2:
                gaps = [
                    v_positions[i + 1] - v_positions[i]
                    for i in range(len(v_positions) - 1)
                ]
                v_avg_gap = sum(gaps) / len(gaps)
        else:
            v_clusters = []
            v_span = 0.0
            v_avg_gap = 0.0

        h_n = len(h_clusters)
        v_n = len(v_clusters)

        f["line_h_cluster_count"] = float(h_n)
        f["line_v_cluster_count"] = float(v_n)
        f["line_h_span_ratio"] = h_span
        f["line_v_span_ratio"] = v_span
        f["line_h_regular_spacing"] = h_regular
        f["line_h_avg_gap"] = h_avg_gap
        f["line_v_avg_gap"] = v_avg_gap
        f["line_grid_score"] = (
            min(h_n, v_n) / max(max(h_n, v_n), 1) if (h_n + v_n) > 0 else 0.0
        )
    except Exception as e:
        logger.warning("Feature group F (line geometry) failed: %s", e)
        for k in [
            "line_h_cluster_count",
            "line_v_cluster_count",
            "line_h_span_ratio",
            "line_v_span_ratio",
            "line_h_regular_spacing",
            "line_h_avg_gap",
            "line_v_avg_gap",
            "line_grid_score",
        ]:
            f.setdefault(k, 0.0)

    # ── Group G: Alignment / Layout (8) ────────────────────────────────
    try:
        total_text = max(len(text_elements), 1)
        min_count = max(2, int(total_text * 3.0 / 100))

        # X-clusters (columns)
        x_positions = [float(el.x0) for el in text_elements]
        x_clusters = _cluster_values(x_positions, tolerance=3.0) if x_positions else []
        x_significant = [(c, idx) for c, idx in x_clusters if len(idx) >= min_count]

        # Largest x-cluster ratio
        x_max_cluster = (
            max(len(idx) for _, idx in x_significant) if x_significant else 0
        )

        # Multi-column signal: >= 3 significant clusters with balanced sizes
        multi_col = 0.0
        if len(x_significant) >= 3:
            sorted_sizes = sorted(
                [len(idx) for _, idx in x_significant], reverse=True
            )
            if len(sorted_sizes) >= 2:
                if sorted_sizes[1] / total_text >= 0.10:
                    multi_col = 1.0

        # Y-clusters (rows)
        y_positions = [float(el.top) for el in text_elements]
        y_clusters = _cluster_values(y_positions, tolerance=3.0) if y_positions else []
        y_significant = [(c, idx) for c, idx in y_clusters if len(idx) >= 2]

        y_regular = 0.0
        y_avg_gap = 0.0
        y_gap_cv = 0.0
        if len(y_significant) >= 3:
            centers = [c for c, _ in y_significant]
            gaps = [centers[i + 1] - centers[i] for i in range(len(centers) - 1)]
            y_avg_gap = sum(gaps) / len(gaps) if gaps else 0.0
            if y_avg_gap > 0:
                cv = (max(gaps) - min(gaps)) / y_avg_gap
                y_gap_cv = min(cv, 2.0)
                if cv < 0.3:
                    y_regular = 1.0

        f["align_x_cluster_count"] = float(len(x_significant))
        f["align_y_cluster_count"] = float(len(y_significant))
        f["align_y_regular_spacing"] = y_regular
        f["align_y_avg_gap"] = y_avg_gap
        f["align_y_gap_cv"] = y_gap_cv
        f["align_x_max_cluster_ratio"] = x_max_cluster / total_text
        f["align_multi_column"] = multi_col
        f["align_text_density"] = total_text / page_area * 10000
    except Exception as e:
        logger.warning("Feature group G (alignment) failed: %s", e)
        for k in [
            "align_x_cluster_count",
            "align_y_cluster_count",
            "align_y_regular_spacing",
            "align_y_avg_gap",
            "align_y_gap_cv",
            "align_x_max_cluster_ratio",
            "align_multi_column",
            "align_text_density",
        ]:
            f.setdefault(k, 0.0)

    # ── Group H: Pixel Histogram (8) ───────────────────────────────────
    try:
        pimg = page._page.to_image(resolution=72)
        arr = np.array(pimg.original.convert("L"))
        dark = (arr < 128).astype(np.int32)

        x_pct = dark.sum(axis=0) / arr.shape[0] * 100
        y_pct = dark.sum(axis=1) / arr.shape[1] * 100

        x_cl = _cluster_peaks(x_pct)
        y_cl = _cluster_peaks(y_pct)

        f["pixel_x_peak_count"] = float(len(x_cl))
        f["pixel_y_peak_count"] = float(len(y_cl))
        f["pixel_x_max_density"] = max((c[1] for c in x_cl), default=0.0)
        f["pixel_y_max_density"] = max((c[1] for c in y_cl), default=0.0)
        f["pixel_x_mean_width"] = (
            sum(c[2] for c in x_cl) / len(x_cl) if x_cl else 0.0
        )
        f["pixel_y_mean_width"] = (
            sum(c[2] for c in y_cl) / len(y_cl) if y_cl else 0.0
        )
        f["pixel_dark_ratio"] = float(dark.sum()) / max(dark.size, 1)
        f["pixel_grid_score"] = float(min(len(x_cl), len(y_cl)))
    except Exception as e:
        logger.warning("Feature group H (pixel histogram) failed: %s", e)
        for k in [
            "pixel_x_peak_count",
            "pixel_y_peak_count",
            "pixel_x_max_density",
            "pixel_y_max_density",
            "pixel_x_mean_width",
            "pixel_y_mean_width",
            "pixel_dark_ratio",
            "pixel_grid_score",
        ]:
            f.setdefault(k, 0.0)

    # ── Group I: Zebra Stripe Features (4) ─────────────────────────────
    try:
        WHITE_FILLS = {"#ffffff", "white", "none", "transparent", "(1, 1, 1)", "(1.0, 1.0, 1.0)"}

        # Find all rects with fill, group by color
        fill_groups: Dict[str, list] = defaultdict(list)
        for r in rects:
            fill = getattr(r, "fill", None)
            if not fill:
                continue
            normalized = str(fill).lower().strip()
            if normalized in WHITE_FILLS:
                continue
            fill_groups[str(fill)].append(r)

        # Wide filled rects (potential stripe bands)
        wide_filled = []
        for r_list in fill_groups.values():
            for r in r_list:
                rw, rh = float(r.width), float(r.height)
                if page_w > 0 and rw > 0.8 * page_w and 5 < rh < 40:
                    wide_filled.append(r)

        # Largest same-color group of wide rects
        wide_by_color: Dict[str, list] = defaultdict(list)
        for r in wide_filled:
            wide_by_color[str(getattr(r, "fill", ""))].append(r)
        max_group_size = max((len(v) for v in wide_by_color.values()), default=0)

        # Y-spacing regularity of largest group
        stripe_regular = 0.0
        if max_group_size >= 3:
            largest_group = max(wide_by_color.values(), key=len)
            tops = sorted(float(r.top) for r in largest_group)
            if len(tops) >= 3:
                gaps = [tops[i + 1] - tops[i] for i in range(len(tops) - 1)]
                avg = sum(gaps) / len(gaps)
                if avg > 0:
                    cv = (max(gaps) - min(gaps)) / avg
                    if cv < 0.3:
                        stripe_regular = 1.0

        f["stripe_wide_filled_rect_count"] = float(len(wide_filled))
        f["stripe_fill_color_count"] = float(len(fill_groups))
        f["stripe_max_same_color_group"] = float(max_group_size)
        f["stripe_y_regular"] = stripe_regular
    except Exception as e:
        logger.warning("Feature group I (zebra stripes) failed: %s", e)
        for k in [
            "stripe_wide_filled_rect_count",
            "stripe_fill_color_count",
            "stripe_max_same_color_group",
            "stripe_y_regular",
        ]:
            f.setdefault(k, 0.0)

    # ── Group J: Composite Hints (4) ───────────────────────────────────
    try:
        h_count = f.get("line_h_count", 0.0)
        v_count = f.get("line_v_count", 0.0)
        word_count = f.get("text_total_count", 0.0)

        # Rects with text inside
        rects_with_text = 0
        rects_short = 0
        rects_long = 0
        for r in rects:
            if float(r.width) < 15 or float(r.height) < 8:
                continue
            try:
                text = (r.extract_text() or "").strip()
            except Exception:
                text = ""
            if text:
                rects_with_text += 1
                if len(text) <= 15:
                    rects_short += 1
                elif len(text) > 30:
                    rects_long += 1

        # Borderless table signal
        borderless = 0.0
        if h_count == 0 and v_count == 0 and word_count > 20:
            x_sig = f.get("align_x_cluster_count", 0.0)
            if x_sig >= 3:
                borderless = 1.0

        f["hint_rects_with_text_count"] = float(rects_with_text)
        f["hint_rects_short_text"] = float(rects_short)
        f["hint_rects_long_text"] = float(rects_long)
        f["hint_borderless_table_signal"] = borderless
    except Exception as e:
        logger.warning("Feature group J (hints) failed: %s", e)
        for k in [
            "hint_rects_with_text_count",
            "hint_rects_short_text",
            "hint_rects_long_text",
            "hint_borderless_table_signal",
        ]:
            f.setdefault(k, 0.0)

    # Ensure all features are present
    for name in FEATURE_NAMES:
        f.setdefault(name, 0.0)

    return f


# ── Canonical feature list ─────────────────────────────────────────────────

FEATURE_NAMES: List[str] = [
    # A: Text Layer
    "text_total_count",
    "text_native_count",
    "text_ocr_count",
    "text_native_ratio",
    "text_total_chars",
    "text_mean_element_len",
    "text_has_any",
    # B: Garble Rate
    "garble_rate",
    "garble_misspelled_count",
    "garble_alpha_word_count",
    # C: Vector Elements
    "line_h_count",
    "line_v_count",
    "rect_total_count",
    "rect_small_count",
    "rect_small_square_count",
    "rect_large_count",
    "rect_degenerate_count",
    "rect_nested_count",
    # D: Images
    "image_count",
    "image_coverage_pct",
    "image_max_area_pct",
    "image_has_large",
    # E: Style Tiers
    "style_tier_count",
    "style_max_size",
    "style_min_size",
    "style_size_range",
    "style_bold_ratio",
    "style_italic_ratio",
    "style_dominant_tier_ratio",
    "style_bold_tier_count",
    # F: Line Geometry
    "line_h_cluster_count",
    "line_v_cluster_count",
    "line_h_span_ratio",
    "line_v_span_ratio",
    "line_h_regular_spacing",
    "line_h_avg_gap",
    "line_v_avg_gap",
    "line_grid_score",
    # G: Alignment / Layout
    "align_x_cluster_count",
    "align_y_cluster_count",
    "align_y_regular_spacing",
    "align_y_avg_gap",
    "align_y_gap_cv",
    "align_x_max_cluster_ratio",
    "align_multi_column",
    "align_text_density",
    # H: Pixel Histogram
    "pixel_x_peak_count",
    "pixel_y_peak_count",
    "pixel_x_max_density",
    "pixel_y_max_density",
    "pixel_x_mean_width",
    "pixel_y_mean_width",
    "pixel_dark_ratio",
    "pixel_grid_score",
    # I: Zebra Stripes
    "stripe_wide_filled_rect_count",
    "stripe_fill_color_count",
    "stripe_max_same_color_group",
    "stripe_y_regular",
    # J: Composite Hints
    "hint_rects_with_text_count",
    "hint_rects_short_text",
    "hint_rects_long_text",
    "hint_borderless_table_signal",
]

assert len(FEATURE_NAMES) == 62, f"Expected 62 features, got {len(FEATURE_NAMES)}"
