"""Text-based fallback personnel extractor.

PP-Structure (Phase 3) is the primary path for personnel rows because it
preserves the table grid. But PP-Structure can fail in two ways on real
sprint scans:

1. The table is not detected at all (low-quality scan, watermark, atypical
   layout) — `extract_personnel` returns an empty list.
2. The table IS detected but the column mapping is too sparse, so each row
   collapses to a single ``nama`` cell with all other fields ``None``. This
   is what was observed on a real Polres Cimahi sprint where the OCR
   produced 24 rows with only ``nama`` populated.

This module provides a regex/heuristic fallback that operates directly on
the flat OCR text. It is deliberately conservative: a row must have BOTH a
recognizable Polri rank AND an 8-digit NRP to be emitted, so we never
generate the kind of "name-only" rows that motivated the fallback in the
first place.
"""

from __future__ import annotations

import re

from ocr_sprint.data.master_pangkat import (
    PANGKAT_VARIANTS,
    is_valid_pangkat,
    normalize_pangkat,
)
from ocr_sprint.schemas.personnel import PersonnelEntry

# Build a single alternation of all known rank tokens (longest first so multi-
# word ranks like "KOMBES POL" win over the single-word "KOMBES").
_RANK_TOKENS: tuple[str, ...] = tuple(
    sorted(
        {variant for variants in PANGKAT_VARIANTS.values() for variant in variants},
        key=lambda v: -len(v),
    )
)
_RANK_ALT = "|".join(re.escape(tok) for tok in _RANK_TOKENS)
# A rank token followed (within a few characters) by an 8-digit NRP.
# We allow common separators: '/', '-', '.', ',', ':' or whitespace.
# The trailing ``\b`` plus proximity to the 8-digit NRP is the
# specificity signal — we deliberately do *not* require a leading
# ``\b`` because real Polri sprint OCR routinely mashes the rank into
# the trailing characters of the previous cell (observed on Polres
# Banjar: "...CPHR., CBA, CI" runs straight into "AKP" giving
# "CIAKP 84011113"). Requiring a leading boundary loses that row
# entirely. The longest-first alternation order ensures multi-token
# ranks ("KOMBES POL") still win over short overlaps ("KBP").
_RE_RANK_NRP_LINE = re.compile(
    rf"(?P<rank>{_RANK_ALT})\b[\s/.\-,:]*?(?P<nrp>\d{{8}})\b",
    re.IGNORECASE,
)
# A bare row number marker like "1." or "12)". OCR often puts it on its own
# line in tabular layouts.
_RE_ROW_NUMBER = re.compile(r"^\s*(\d{1,3})\s*[.)]\s*$")
# Lines that should never be interpreted as a personnel name. These are
# section headers, OCR garbage anchors, and column header tokens. We match
# them with a *word-boundary* regex (built from this list) rather than a
# bare ``startswith`` check, because short tokens like ``"NO"`` and
# ``"KET"`` would otherwise reject perfectly valid Indonesian names
# (e.g. ``"NOVA SARI"``, ``"NOOR HIDAYAT"``, ``"KETUT WARDANA"`` — the
# latter being an extremely common Balinese birth-order name).
_NAME_BLOCKLIST_TOKENS: tuple[str, ...] = (
    "PADA TANGGAL",  # multi-word entries first so they win the alternation
    "SURAT PERINTAH",
    "DASAR",
    "PERIHAL",
    "PERTIMBANGAN",
    "DIPERINTAHKAN",
    "KEPADA",
    "UNTUK",
    "TEMBUSAN",
    "DIKELUARKAN",
    "SELESAI",
    "DAFTAR",
    "LAMPIRAN",
    "NOMOR",
    "TANGGAL",
    "KEPOLISIAN",
    "DAERAH",
    "RESOR",
    "SEKTOR",
    "MABES",
    "NRP",
    "NIP",
    "PANGKAT",
    "JABATAN",
    "NAMA",
    "KETERANGAN",
    "KET",
    "NO",
)
_RE_NAME_BLOCKLIST = re.compile(
    r"^(?:" + "|".join(re.escape(tok) for tok in _NAME_BLOCKLIST_TOKENS) + r")\b",
    re.IGNORECASE,
)
# A name should look like a name: mostly letters, common punctuation, and
# at least one alphabetic character. Pure-numeric or pure-symbol lines are
# rejected.
_RE_NAME_OK = re.compile(r"[A-Za-z]")


def _is_plausible_name(line: str) -> bool:
    """Return True iff ``line`` could plausibly be a personnel name."""
    stripped = line.strip()
    if not stripped or not _RE_NAME_OK.search(stripped):
        return False
    if _RE_NAME_BLOCKLIST.match(stripped):
        return False
    if _RE_ROW_NUMBER.match(stripped):
        return False
    if _RE_RANK_NRP_LINE.search(stripped):
        return False
    # Reject lines that are nothing but a row number with extra punctuation
    # ("1 .", "2)") which the bare-number regex above might miss.
    return not re.fullmatch(r"[\s\d.)(\-]+", stripped)


def _following_jabatan(lines: list[str], idx: int) -> str | None:
    """Collect 1-3 follow-up lines after the rank+NRP line as the jabatan.

    Stops at the next rank+NRP line, the next bare row-number line, or any
    blocked prefix (section header / column header).
    """
    parts: list[str] = []
    for fwd in range(idx + 1, min(idx + 4, len(lines))):
        candidate = lines[fwd].strip()
        if not candidate:
            if parts:
                break
            continue
        if _RE_RANK_NRP_LINE.search(candidate):
            break
        if _RE_ROW_NUMBER.match(candidate):
            break
        if _RE_NAME_BLOCKLIST.match(candidate):
            break
        parts.append(candidate)
    if not parts:
        return None
    joined = " ".join(parts)
    return " ".join(joined.split()) or None


def extract_personnel_from_text(raw_text: str) -> list[PersonnelEntry]:
    """Best-effort personnel extraction from a flat OCR text stream.

    Strategy:

    **Pass 1** — same-line rank+NRP (original strategy):
    1. Iterate every line. Skip lines that don't contain both a known rank
       and an 8-digit NRP (those are the only signal we trust).
    2. For each rank+NRP line, look back for the most recent plausible name
       line, and forward 1-3 lines for jabatan content.
    3. Emit a ``PersonnelEntry`` only when we have at least pangkat + nrp.

    **Pass 2** — separate-line rank and NRP (for tabular sprint formats):
    If pass 1 produces no results, scan for lines containing a standalone
    rank token, then look up to 2 lines forward for a standalone NRP.
    This handles sprint formats where OCR renders each column on its own
    line (e.g. Polres Banjar layout).

    **Pass 3** — rank-only (for sprint formats *without* an NRP column):
    Some sprint templates (panitia, undangan, etc.) list only nama +
    pangkat + jabatan, no NRP. If pass 1 and pass 2 both yield nothing,
    fall back to a rank-only scan: every standalone rank line (or
    two-line rank like "KOMBES" + "POL" produced by narrow-column OCR)
    becomes a row, with name assembled from preceding lines and jabatan
    from following lines. ``nrp`` stays ``None``. False-positive risk
    is higher (stray rank tokens in body text), so this only fires when
    nothing else matched.

    The fallback is intentionally rate-limited: the first matching rank
    token on a line wins (no greedy multi-match per line), and a name line
    can only be consumed once (so a stray ranked text inside a paragraph
    doesn't turn into multiple bogus entries).
    """
    lines = raw_text.splitlines()

    # ── Pass 1: rank+NRP on the same line ────────────────────────────
    rows = _extract_same_line(lines)
    if rows:
        return rows

    # ── Pass 2: rank and NRP on separate lines ───────────────────────
    rows = _extract_separate_lines(lines)
    if rows:
        return rows

    # ── Pass 3: rank-only (no NRP column) ────────────────────────────
    return _extract_rank_only(lines)


# Regex for a line that is *only* a rank token (possibly with punctuation).
_RE_RANK_ONLY = re.compile(
    rf"^\s*(?P<rank>{_RANK_ALT})\s*[/.\-,:]*\s*$",
    re.IGNORECASE,
)
# Regex for a line that contains a standalone 8-digit NRP.
_RE_NRP_ONLY = re.compile(r"(?<!\d)(?P<nrp>\d{8})(?!\d)")


# Strip a leading row number marker like "1 ", "1.", "12)" from a name
# prefix taken from the same OCR line as a rank+NRP match. Unlike
# _RE_ROW_NUMBER (which matches a *whole* line), this is a prefix strip
# for embedded same-line cases like "1 CUCU JUHANA, A.K.S. KOMPOL ...".
_RE_LEADING_ROW_NUMBER = re.compile(r"^\s*\d{1,3}\s*[.):]?\s+")


def _extract_same_line(lines: list[str]) -> list[PersonnelEntry]:
    """Pass 1: rank+NRP pairs found anywhere in the joined text.

    Uses ``finditer`` over the full ``\\n``-joined OCR text rather than
    ``re.search`` per line so that multiple rank+NRP pairs on the same
    OCR line still produce separate rows. This is required for sprint
    scans where Paddle merges several table rows into one OCR line
    (observed on Polres Banjar where row 2's "...CBA.AKP 77020049 KASAT
    RESKRIM" was being swallowed into row 1's jabatan because per-line
    ``search`` only returns the first match).

    For each match we resolve nama from text *before* the match (the
    same-line prefix takes precedence; otherwise look back through the
    preceding lines bounded by the previous match) and jabatan from text
    *after* the match (same-line suffix plus up to ~3 follow-up lines,
    bounded by the next match).
    """
    if not lines:
        return []
    full_text = "\n".join(lines)

    line_starts: list[int] = []
    pos = 0
    for line in lines:
        line_starts.append(pos)
        pos += len(line) + 1  # +1 for the joining "\n"

    def offset_to_line(offset: int) -> int:
        lo, hi = 0, len(line_starts)
        while lo < hi:
            mid = (lo + hi) // 2
            if line_starts[mid] <= offset:
                lo = mid + 1
            else:
                hi = mid
        return max(0, lo - 1)

    matches = list(_RE_RANK_NRP_LINE.finditer(full_text))
    rows: list[PersonnelEntry] = []
    consumed_lines: set[int] = set()

    for i, m in enumerate(matches):
        pangkat = normalize_pangkat(m.group("rank"))
        if not pangkat or not is_valid_pangkat(pangkat):
            continue
        nrp = m.group("nrp")
        ml = offset_to_line(m.start())
        prev_ml = (
            offset_to_line(matches[i - 1].start()) if i > 0 else -1
        )
        next_ml = (
            offset_to_line(matches[i + 1].start())
            if i + 1 < len(matches)
            else len(lines)
        )

        line_text = lines[ml]
        line_off = line_starts[ml]

        # Same-line prefix: text on this line *before* the rank token.
        # If the previous match was on this same line, only consider the
        # text after that previous match's NRP (otherwise we'd reuse the
        # earlier row's tail as this row's name).
        prefix_start_local = 0
        if prev_ml == ml and i > 0:
            prefix_start_local = max(0, matches[i - 1].end() - line_off)
        prefix = line_text[prefix_start_local : m.start() - line_off]

        # Same-line suffix: text on this line *after* the NRP, capped at
        # the next match's start if it's on this same line.
        suffix_end_local = len(line_text)
        if next_ml == ml and i + 1 < len(matches):
            suffix_end_local = matches[i + 1].start() - line_off
        suffix = line_text[m.end() - line_off : suffix_end_local]

        # ── Resolve nama ────────────────────────────────────────────
        nama: str | None = None
        prefix_clean = _RE_LEADING_ROW_NUMBER.sub("", prefix).strip()
        if prefix_clean and _is_plausible_name(prefix_clean):
            nama = prefix_clean
        elif prev_ml < ml:
            for back in range(ml - 1, prev_ml, -1):
                if back in consumed_lines or back < 0:
                    continue
                candidate = lines[back].strip()
                if _is_plausible_name(candidate):
                    nama = candidate
                    consumed_lines.add(back)
                    break

        # ── Resolve jabatan ─────────────────────────────────────────
        jabatan_parts: list[str] = []
        suffix_clean = suffix.strip()
        if suffix_clean:
            jabatan_parts.append(suffix_clean)
        if next_ml > ml:
            max_fwd = min(ml + 4, next_ml, len(lines))
            for fwd in range(ml + 1, max_fwd):
                candidate = lines[fwd].strip()
                if not candidate:
                    if jabatan_parts:
                        break
                    continue
                if _RE_NAME_BLOCKLIST.match(candidate):
                    break
                if _RE_ROW_NUMBER.match(candidate):
                    break
                jabatan_parts.append(candidate)
        jabatan = (
            " ".join(" ".join(jabatan_parts).split())
            if jabatan_parts
            else None
        )

        rows.append(
            PersonnelEntry(
                no=None,
                pangkat=pangkat,
                nrp=nrp,
                nama=nama,
                jabatan_dinas=jabatan,
                jabatan_sprint=None,
                keterangan=None,
            )
        )
    return rows


def _extract_separate_lines(lines: list[str]) -> list[PersonnelEntry]:
    """Pass 2: rank and NRP on separate nearby lines.

    Handles tabular sprint formats where OCR outputs each column as its
    own line, e.g.:
        1
        CUCU JUHANA, A.K.S.
        KOMPOL
        70100418
        KABAGOPS
    """
    consumed_names: set[int] = set()
    consumed_nrps: set[int] = set()
    rows: list[PersonnelEntry] = []

    for idx, raw_line in enumerate(lines):
        line = raw_line.strip()
        rank_match = _RE_RANK_ONLY.match(line)
        if not rank_match:
            # Also try: line starts with a rank token (may have trailing text)
            for tok in _RANK_TOKENS:
                if line.upper().startswith(tok) and len(line) - len(tok) < 5:
                    rank_match = re.match(
                        rf"^\s*(?P<rank>{re.escape(tok)})\s*[/.\-,:]*",
                        line,
                        re.IGNORECASE,
                    )
                    if rank_match:
                        break
        if not rank_match:
            continue

        pangkat = normalize_pangkat(rank_match.group("rank"))
        if not pangkat or not is_valid_pangkat(pangkat):
            continue

        # Look forward up to 2 lines for NRP
        nrp: str | None = None
        nrp_idx: int | None = None
        for fwd in range(idx + 1, min(idx + 3, len(lines))):
            if fwd in consumed_nrps:
                continue
            nrp_match = _RE_NRP_ONLY.search(lines[fwd].strip())
            if nrp_match:
                nrp = nrp_match.group("nrp")
                nrp_idx = fwd
                break

        if not nrp:
            continue
        assert nrp_idx is not None
        consumed_nrps.add(nrp_idx)

        # Look back for name
        nama: str | None = None
        for back in range(idx - 1, max(idx - 6, -1), -1):
            if back in consumed_names:
                continue
            candidate = lines[back].strip()
            if _is_plausible_name(candidate):
                nama = candidate
                consumed_names.add(back)
                break

        # Look forward after NRP for jabatan
        jabatan = _following_jabatan(lines, nrp_idx)
        rows.append(
            PersonnelEntry(
                no=None,
                pangkat=pangkat,
                nrp=nrp,
                nama=nama,
                jabatan_dinas=jabatan,
                jabatan_sprint=None,
                keterangan=None,
            )
        )
    return rows


# Bare row-number markers used by sprint formats without NRP (the dot
# is often missing in narrow-column OCR, e.g. just "1" on its own line).
_RE_BARE_ROW_NUMBER = re.compile(r"^\s*\d{1,3}\s*[.):]?\s*$")


def _try_match_rank_at(lines: list[str], idx: int) -> tuple[str, int] | None:
    """Try to match a standalone rank starting at ``lines[idx]``.

    Returns ``(rank_text, lines_consumed)`` on success. Handles narrow-
    column OCR that splits a multi-token rank across two lines (e.g.
    ``"KOMBES"`` + ``"POL"`` or ``"PENATA"`` + ``"TK I"``).

    The two-line concatenation is tried *first* so that more-specific
    multi-token ranks ("PENATA TK I") win over their less-specific
    single-line prefix ("PENATA"). Without this preference, "TK I"
    would leak into the jabatan column.
    """
    if idx >= len(lines):
        return None
    line = lines[idx].strip()
    if idx + 1 < len(lines):
        combined = (line + " " + lines[idx + 1].strip()).strip()
        m2 = _RE_RANK_ONLY.match(combined)
        if m2:
            return m2.group("rank"), 2
    m = _RE_RANK_ONLY.match(line)
    if m:
        return m.group("rank"), 1
    return None


def _extract_rank_only(lines: list[str]) -> list[PersonnelEntry]:
    """Pass 3: rank-only fallback for sprint formats without an NRP column.

    Each standalone rank line (single line or two-line concatenation) is
    treated as the pivot of a personnel row. ``nama`` is assembled from
    the preceding contiguous plausible-name lines (typical OCR splits a
    long name across 2-3 short lines because of narrow columns); jabatan
    is collected from following lines until the next rank or row marker.

    ``nrp`` is always ``None`` for rows produced by this pass.
    """
    rows: list[PersonnelEntry] = []
    consumed_lines: set[int] = set()
    i = 0
    while i < len(lines):
        match = _try_match_rank_at(lines, i)
        if not match:
            i += 1
            continue
        rank_text, rank_span = match
        pangkat = normalize_pangkat(rank_text)
        if not pangkat or not is_valid_pangkat(pangkat):
            i += 1
            continue

        # ── Look back for name lines (assemble up to 4 contiguous lines) ──
        name_lines: list[str] = []
        for back in range(i - 1, max(i - 6, -1), -1):
            if back in consumed_lines:
                break
            candidate = lines[back].strip()
            if not candidate:
                if name_lines:
                    break
                continue
            if _RE_BARE_ROW_NUMBER.match(candidate):
                break
            if _RE_NAME_BLOCKLIST.match(candidate):
                break
            if _try_match_rank_at(lines, back) is not None:
                break
            if not _is_plausible_name(candidate):
                break
            name_lines.insert(0, candidate)
            consumed_lines.add(back)
        nama = " ".join(" ".join(name_lines).split()) if name_lines else None

        # ── Look forward for jabatan (stop at next rank / row marker) ─────
        jabatan_parts: list[str] = []
        fwd = i + rank_span
        steps = 0
        while fwd < len(lines) and steps < 8:
            candidate = lines[fwd].strip()
            if not candidate:
                if jabatan_parts:
                    break
                fwd += 1
                steps += 1
                continue
            if _RE_BARE_ROW_NUMBER.match(candidate):
                break
            if _try_match_rank_at(lines, fwd) is not None:
                break
            if _RE_NAME_BLOCKLIST.match(candidate):
                break
            jabatan_parts.append(candidate)
            fwd += 1
            steps += 1
        jabatan = " ".join(" ".join(jabatan_parts).split()) if jabatan_parts else None

        rows.append(
            PersonnelEntry(
                no=None,
                pangkat=pangkat,
                nrp=None,
                nama=nama,
                jabatan_dinas=jabatan,
                jabatan_sprint=None,
                keterangan=None,
            )
        )
        i += rank_span
    return rows


# ── Column-aware Pass 3 (uses OCR bounding boxes) ───────────────────────


def _box_x_left(box: tuple[tuple[float, float], ...]) -> float:
    return min(p[0] for p in box)


def _box_x_right(box: tuple[tuple[float, float], ...]) -> float:
    return max(p[0] for p in box)


def _box_x_center(box: tuple[tuple[float, float], ...]) -> float:
    return (_box_x_left(box) + _box_x_right(box)) / 2


def _box_y_top(box: tuple[tuple[float, float], ...]) -> float:
    return min(p[1] for p in box)


def _box_y_bottom(box: tuple[tuple[float, float], ...]) -> float:
    return max(p[1] for p in box)


def _box_y_center(box: tuple[tuple[float, float], ...]) -> float:
    return (_box_y_top(box) + _box_y_bottom(box)) / 2


def _box_height(box: tuple[tuple[float, float], ...]) -> float:
    return _box_y_bottom(box) - _box_y_top(box)


def extract_personnel_from_ocr_lines(ocr_lines: list) -> list[PersonnelEntry]:
    """Column-aware Pass 3 for sprint formats without an NRP column.

    Each ``ocr_line`` must expose ``text`` (str) and ``box`` (a tuple of
    4 ``(x, y)`` corner points). We use the geometry to:

    1. Detect rank lines (single-line or vertically-stacked two-line).
    2. Estimate the PANGKAT column X-center from those rank lines.
    3. For each rank, gather **only** lines in the NAMA column (X left
       of PANGKAT) within the row's Y span as the name fragments, and
       **only** lines in the JABATAN column (X right of PANGKAT) for
       jabatan. This prevents column-bleed that flat-text Pass 3
       suffers from on dense tables.

    Returns ``[]`` if no rank lines are detected (caller can fall back
    to the text-only Pass 3).
    """
    if not ocr_lines:
        return []

    # Sort by (y_top, x_left) for vertical-stacking rank detection.
    indexed = sorted(
        range(len(ocr_lines)),
        key=lambda i: (_box_y_top(ocr_lines[i].box), _box_x_left(ocr_lines[i].box)),
    )

    # Pass 1: find rank anchors.
    # An anchor is one or two stacked OCR lines whose combined text matches
    # _RE_RANK_ONLY and normalises to a known pangkat. Two-line stacks must
    # X-overlap so we don't accidentally merge cells from different columns.
    used: set[int] = set()
    anchors: list[dict] = []
    for pos, idx in enumerate(indexed):
        if idx in used:
            continue
        ln = ocr_lines[idx]
        text = ln.text.strip()

        rank_text: str | None = None
        member_idxs: list[int] = [idx]

        # Try two-line stack first (so PENATA TK I beats PENATA).
        for j_pos in range(pos + 1, min(pos + 5, len(indexed))):
            j_idx = indexed[j_pos]
            if j_idx in used:
                continue
            other = ocr_lines[j_idx]
            x_overlap = (
                min(_box_x_right(ln.box), _box_x_right(other.box))
                - max(_box_x_left(ln.box), _box_x_left(other.box))
            )
            if x_overlap <= 0:
                continue
            y_gap = _box_y_top(other.box) - _box_y_bottom(ln.box)
            if y_gap > _box_height(ln.box) * 1.5:
                break
            combined = (text + " " + other.text.strip()).strip()
            m2 = _RE_RANK_ONLY.match(combined)
            if m2:
                rank_text = m2.group("rank")
                member_idxs.append(j_idx)
                break

        if rank_text is None:
            m1 = _RE_RANK_ONLY.match(text)
            if m1:
                rank_text = m1.group("rank")

        if rank_text is None:
            continue
        pangkat = normalize_pangkat(rank_text)
        if not pangkat or not is_valid_pangkat(pangkat):
            continue

        anchors.append(
            {
                "member_idxs": member_idxs,
                "pangkat": pangkat,
                "x_center": _box_x_center(ln.box),
                "y_top": min(_box_y_top(ocr_lines[m].box) for m in member_idxs),
                "y_bottom": max(_box_y_bottom(ocr_lines[m].box) for m in member_idxs),
            }
        )
        used.update(member_idxs)

    if not anchors:
        return []

    # Sort anchors by Y so we can compute row spans.
    anchors.sort(key=lambda a: a["y_top"])

    # Estimate PANGKAT column X-center as the median of rank anchor X-centers.
    xs_sorted = sorted(a["x_center"] for a in anchors)
    pangkat_x = xs_sorted[len(xs_sorted) // 2]

    # X tolerance: half the median rank-line width. Lines with x_center
    # within ±tolerance of pangkat_x are *in* the PANGKAT column and
    # excluded from both NAMA and JABATAN buckets.
    rank_widths = [
        _box_x_right(ocr_lines[a["member_idxs"][0]].box)
        - _box_x_left(ocr_lines[a["member_idxs"][0]].box)
        for a in anchors
    ]
    rank_widths.sort()
    median_rank_width = rank_widths[len(rank_widths) // 2] if rank_widths else 50.0
    column_margin = max(median_rank_width * 0.5, 5.0)

    # Try to split the JABATAN side into STRUKTURAL (jabatan_dinas) and
    # DALAM SPRIN (jabatan_sprint) by clustering jabatan-side X-centers.
    # This is a 2-cluster k-means-style split: collect all X-centers of
    # lines to the right of PANGKAT, find the largest X-gap among them,
    # and use that gap as the column boundary. KET is typically the
    # right-most narrow column we let bleed into jabatan_sprint since
    # it's commonly empty.
    jabatan_xs: list[float] = []
    for ln in ocr_lines:
        x = _box_x_center(ln.box)
        if x > pangkat_x + column_margin and ln.text.strip():
            jabatan_xs.append(x)
    jabatan_split_x: float | None = None
    if len(jabatan_xs) >= 4:
        jabatan_xs.sort()
        max_gap = 0.0
        max_gap_x: float | None = None
        for k in range(1, len(jabatan_xs)):
            gap = jabatan_xs[k] - jabatan_xs[k - 1]
            if gap > max_gap:
                max_gap = gap
                max_gap_x = (jabatan_xs[k] + jabatan_xs[k - 1]) / 2
        # Only use the split if the gap is meaningfully larger than a
        # within-column gap (heuristic: > 1.5× median rank width).
        if max_gap_x is not None and max_gap > median_rank_width * 1.5:
            jabatan_split_x = max_gap_x

    # Pre-compute each anchor's y_center for midpoint row dividers.
    anchor_y_centers = [(a["y_top"] + a["y_bottom"]) / 2 for a in anchors]

    rows: list[PersonnelEntry] = []
    for i, anchor in enumerate(anchors):
        # Row Y span: midpoint between this anchor and its neighbours.
        # Using the midpoint (rather than the previous anchor's
        # y_bottom) prevents row N's tail content (e.g. last name
        # fragment "M.H.") from leaking into row N+1's nama bucket
        # when rank lines don't extend to the full visual row height.
        y_lo = (
            (anchor_y_centers[i - 1] + anchor_y_centers[i]) / 2
            if i > 0
            else float("-inf")
        )
        y_hi = (
            (anchor_y_centers[i] + anchor_y_centers[i + 1]) / 2
            if i + 1 < len(anchors)
            else float("inf")
        )

        nama_pieces: list[tuple[float, str]] = []
        struktural_pieces: list[tuple[float, str]] = []
        sprint_pieces: list[tuple[float, str]] = []
        for j, ln in enumerate(ocr_lines):
            if j in anchor["member_idxs"]:
                continue
            text = ln.text.strip()
            if not text:
                continue
            x = _box_x_center(ln.box)
            y = _box_y_center(ln.box)
            if not (y_lo <= y <= y_hi):
                continue
            if x < pangkat_x - column_margin:
                # NAMA side
                if _RE_NAME_BLOCKLIST.match(text):
                    continue
                if _RE_BARE_ROW_NUMBER.match(text):
                    continue
                if not _is_plausible_name(text):
                    continue
                nama_pieces.append((y, text))
            elif x > pangkat_x + column_margin:
                # JABATAN side — split into STRUKTURAL vs DALAM SPRIN
                # using the geometric column boundary detected above.
                if _RE_NAME_BLOCKLIST.match(text):
                    continue
                if jabatan_split_x is not None and x > jabatan_split_x:
                    sprint_pieces.append((y, text))
                else:
                    struktural_pieces.append((y, text))
            # else: in PANGKAT column or column margin — skip

        nama_pieces.sort(key=lambda p: p[0])
        struktural_pieces.sort(key=lambda p: p[0])
        sprint_pieces.sort(key=lambda p: p[0])

        # Strip leading row number from the first nama piece (e.g. "1 F. GUNTUR"
        # collapses to "F. GUNTUR" if the row marker happens to share a box).
        if nama_pieces:
            head = _RE_LEADING_ROW_NUMBER.sub("", nama_pieces[0][1]).strip()
            nama_pieces[0] = (nama_pieces[0][0], head)

        def _join(pieces: list[tuple[float, str]]) -> str | None:
            text = " ".join(t for _, t in pieces if t).strip()
            text = " ".join(text.split())
            return text or None

        rows.append(
            PersonnelEntry(
                no=None,
                pangkat=anchor["pangkat"],
                nrp=None,
                nama=_join(nama_pieces),
                jabatan_dinas=_join(struktural_pieces),
                jabatan_sprint=_join(sprint_pieces),
                keterangan=None,
            )
        )
    return rows


def is_low_quality(rows: list[PersonnelEntry]) -> bool:
    """Heuristic: did PP-Structure produce useless rows?

    A row is useful when it has at least pangkat OR nrp. If most rows have
    only ``nama`` (or worse, nothing) the table extraction failed and the
    caller should retry with the text-based fallback.
    """
    if not rows:
        return True
    useful = sum(1 for r in rows if r.pangkat or r.nrp)
    # Require at least 30% of rows to carry rank/NRP signal. Below that we
    # assume the column mapper degraded to "everything is nama" and prefer
    # a fresh attempt.
    return useful / max(1, len(rows)) < 0.3
