"""
CompletenessAuditor — was the whole table captured, or did part of it get
cut off at an edge? The deterministic text-layer audit behind step 3 of the
Camelot correspondence flow (`quber.core.extractors.camelot.correspondence`).

Camelot reads the PDF *text layer*, not pixels, so the cell values it emits
are the document's real characters. This audit looks for one failure:
truncation at an edge, where the chunk began below the table's top or ended
above its bottom. Camelot can also drop an interior row's values during
cell assignment. This audit ignores missing figures deep inside the
captured span, so it does not catch that.

The audit reads the same text layer Camelot does (via PyMuPDF). For the
table's region it compares the *value-like* numeric figures present in the
text layer against those present in the assembled markdown. A formatted
figure in the extracted columns that never made it into the extraction
counts as a drop only when it sits within EDGE_BAND_PTS of the extracted
span's top or bottom edge. The table was then truncated at that edge.
Judging truncation by eye over-flags by misreading digits off a rasterized
image. This audit does not have that false-positive source, because it
compares real text-layer tokens against real output tokens rather than
re-reading numbers.

"Value-like" is deliberately narrow: numbers carrying a thousands
separator, a decimal, a percent, or five-plus digits. Parentheses or a
currency sign alone do not qualify, so "(84)" is not value-like. Bare
small integers (years like 2024, days like 31, footnote markers) are
excluded so a caption or footnote inside the rough region does not read
as a dropped row. The audit itself never edits the table; it only flags.

When the audit reports a gap, the correspondence orchestrator first extends
the table with the next leftover chunk and re-audits, until no leftover
chunk remains. It then runs the text-layer fill in this module
(`round_off_grid`), which writes the missing figures into the grid, and
re-audits once more. The table is marked incomplete and the gap reported
only if figures are still missing after the fill (the report-don't-drop
rule).
"""

from __future__ import annotations

import re
from pathlib import Path
from typing import List, Literal, Optional, Protocol, Sequence, Tuple, runtime_checkable

from loguru import logger
from pydantic import BaseModel, Field

from quber.settings import get_settings

# A token counts as a real reported figure only if it carries a thousands
# separator, a decimal, a percent, or five-plus digits. Parentheses and a
# currency sign are optional wrappers and do not qualify a token on their
# own. This excludes bare 1-4 digit integers — years, days, footnote markers, small
# ordinals — which routinely appear in captions and footnotes that the
# detector's rough box can include, and which would otherwise read as
# dropped rows.
VALUE_TOKEN_RE = re.compile(
    r"""
    \(?\$?                                   # optional open paren / currency
    (?:
        \d{1,3}(?:,\d{3})+(?:\.\d+)?         # thousands-grouped: 1,234 or 1,234.56
      | \d+\.\d+                             # decimal: 12.3
      | \d+(?:\.\d+)?%                       # percentage: 5% or 12.3%
      | \d{5,}                               # long integer: 12345+
    )
    \)?%?                                    # optional close paren / percent
    """,
    re.VERBOSE,
)

# A missing value-like figure counts as a truncation only if it sits within
# this many points of the extracted span's top or bottom edge. A dropped row is
# immediately adjacent to the captured data — the widest real gap observed is a
# subheader-separated summary row (~28 pt). Figures farther out belong to a
# neighbor table. Figures deep inside the span are ignored, because this audit
# checks edge truncation only. This edge band is a geometry guard that does not
# depend on the detector's rough box cleanly separating two stacked,
# near-identical tables — exactly the case (Visa p1) where the box-based region
# bleeds.
EDGE_BAND_PTS = 40.0
# A token must sit within the extracted columns (plus this horizontal pad)
# to count; keeps page-margin figures and off-column footnotes out.
COLUMN_PAD_PTS = 8.0
# Used by the fill step (round_off_grid, and its markdown variant round_off):
# the minimum horizontal gap that separates two table columns, and the
# vertical tolerance for grouping tokens into the same row. Both in PDF
# points. Box repair also groups rows with ROW_TOL_PTS (text_rows).
COLUMN_GAP_PTS = 12.0
ROW_TOL_PTS = 4.0
# Used by box repair: rows of a single table sit within this vertical gap;
# a larger gap separates two tables into different bands.
BAND_GAP_PTS = 18.0


class MissingFigure(BaseModel):
    """A value-like figure the completeness check found in the text layer
    within the table's edge band but absent from the extraction. Carries
    enough geometry for the fill step to place it back into the grid.
    """

    value: str = Field(description="Display form as it appears in the text layer, e.g. '240,083'")
    x: float = Field(description="Token center x in PDF points")
    y: float = Field(description="Token center y in PDF points (top-left frame)")
    edge: Literal["top", "bottom"] = Field(
        description="Which edge of the captured span this figure sits past"
    )
    column: int = Field(
        default=-1, description="Column index assigned by round_off (0 is label); -1 if unplaced"
    )
    row_label: str = Field(
        default="", description="Label of the row this figure was placed into; set by round_off"
    )


class CompletenessVerdict(BaseModel):
    complete: bool = Field(
        description=(
            "True if every value-like figure adjacent to the extracted data (in its "
            "column span) is present in the extraction. False if a formatted figure sits "
            "just past the top or bottom edge but is absent from the extraction — the "
            "table was cut off at that edge."
        ),
    )
    gap: str = Field(
        default="",
        description=(
            "When complete is False, which edge was truncated and an example of the "
            "missing figure(s) (e.g. 'the table continues below the last extracted row "
            "(e.g. 240,083)'). Empty when complete."
        ),
    )
    reason: str = Field(
        default="",
        description="One short sentence summarizing the value-token comparison.",
    )
    missing_figures: List[MissingFigure] = Field(
        default_factory=list,
        description="Figures found past an edge but absent from the extraction; the fill step's input. Empty when complete.",
    )


def normalize_value(token: str) -> str:
    """Collapse a value token to a comparable key: drop currency, commas,
    parentheses and surrounding whitespace; keep digits, decimal point and
    percent. So '$240,083', '240,083' and '(240,083)' all key to '240083'.
    """
    return re.sub(r"[\s,$()]", "", token)


def value_tokens(text: str) -> List[str]:
    """Every value-like figure in `text`, normalized for comparison."""
    return [normalize_value(m.group(0)) for m in VALUE_TOKEN_RE.finditer(text)]


def page_blocks(source: Path, page: int) -> List[str]:
    """The PDF text layer of one 1-indexed page as its text blocks in reading
    order, via PyMuPDF. A block is what the layout keeps together: a paragraph,
    a multi-line title, one panel of a slide. Where the page prints columns,
    a line-by-line reading interleaves them; blocks do not."""
    import fitz  # PyMuPDF

    doc = fitz.open(str(source))
    try:
        pg = doc[page - 1]
        return [str(b[4]).strip() for b in pg.get_text("blocks", sort=True) if str(b[4]).strip()]
    finally:
        doc.close()


def page_words(source: Path, page: int) -> Tuple[float, float, List[Tuple[float, float, float, float, str]]]:
    """Read the PDF text layer for one 1-indexed page via PyMuPDF.

    Returns the page width/height in points and the word boxes as
    (x0, y0, x1, y1, text) in PyMuPDF's top-left-origin point frame. This
    is the same text layer Camelot parses, so the figures match exactly.
    """
    import fitz  # PyMuPDF

    doc = fitz.open(str(source))
    try:
        pg = doc[page - 1]
        rect = pg.rect
        words: List[Tuple[float, float, float, float, str]] = [
            (float(w[0]), float(w[1]), float(w[2]), float(w[3]), str(w[4])) for w in pg.get_text("words")
        ]
        return float(rect.width), float(rect.height), words
    finally:
        doc.close()


def extracted_span_pts(
    extracted_bboxes: Sequence[Optional[Tuple[float, float, float, float]]],
    page_h: float,
) -> Optional[Tuple[float, float, float, float]]:
    """Union of the matched Camelot chunk bboxes, converted to PyMuPDF's
    top-left point frame: (x0, top, x1, bottom). Camelot bboxes are PDF
    points with a bottom-left origin, so y flips against the page height.
    Returns None if no chunk carried geometry.
    """
    xs0: List[float] = []
    xs1: List[float] = []
    tops: List[float] = []
    bottoms: List[float] = []
    for bb in extracted_bboxes:
        if bb is None:
            continue
        x1, y1, x2, y2 = bb
        xs0.append(min(x1, x2))
        xs1.append(max(x1, x2))
        tops.append(page_h - max(y1, y2))
        bottoms.append(page_h - min(y1, y2))
    if not xs0:
        return None
    return (min(xs0), min(tops), max(xs1), max(bottoms))


def completeness_verdict(
    page_w: float,
    page_h: float,
    words: Sequence[Tuple[float, float, float, float, str]],
    region_bbox: Optional[Tuple[float, float, float, float]],
    extracted_bboxes: Sequence[Optional[Tuple[float, float, float, float]]],
    assembled_markdown: str,
) -> CompletenessVerdict:
    """Deterministic edge-truncation check by text-layer value comparison.

    `region_bbox` is the detector's neighbor-bounded box (normalized 0..1,
    top-left). `extracted_bboxes` are the matched Camelot chunk bboxes (PDF
    points, bottom-left). All geometry is reduced to PyMuPDF's top-left
    point frame. A value-like figure is considered when it sits inside the
    region and within the extracted chunks' x-span plus COLUMN_PAD_PTS, and
    is absent from `assembled_markdown`. It is reported only if it sits
    within EDGE_BAND_PTS of the extracted span's top or bottom edge, and is
    classified top or bottom by the nearer edge. Figures deeper inside the
    span or farther past an edge are ignored. When no chunk carries a bbox,
    every missing figure in the region's own column span is reported with
    the edge unclear.
    """
    md_values = set(value_tokens(assembled_markdown))

    # Region in points (top-left frame). Default to the whole page.
    if region_bbox is not None:
        rx0 = min(region_bbox[0], region_bbox[2]) * page_w
        rx1 = max(region_bbox[0], region_bbox[2]) * page_w
        r_top = min(region_bbox[1], region_bbox[3]) * page_h
        r_bot = max(region_bbox[1], region_bbox[3]) * page_h
    else:
        rx0, rx1, r_top, r_bot = 0.0, page_w, 0.0, page_h

    span = extracted_span_pts(extracted_bboxes, page_h)
    if span is not None:
        col_x0 = span[0] - COLUMN_PAD_PTS
        col_x1 = span[2] + COLUMN_PAD_PTS
        ext_top, ext_bot = span[1], span[3]
    else:
        # No chunk geometry: fall back to the region's own column span and
        # forgo top/bottom classification.
        col_x0, col_x1 = rx0, rx1
        ext_top = ext_bot = None

    top_missing: List[str] = []
    bottom_missing: List[str] = []
    span_unknown_missing: List[str] = []
    figures: List[MissingFigure] = []

    for x0, y0, x1, y1, text in words:
        cx = (x0 + x1) / 2.0
        cy = (y0 + y1) / 2.0
        if not (rx0 <= cx <= rx1 and r_top <= cy <= r_bot):
            continue
        if not (col_x0 <= cx <= col_x1):
            continue
        for m in VALUE_TOKEN_RE.finditer(text):
            orig = m.group(0)
            v = normalize_value(orig)
            if v in md_values:
                continue
            if ext_top is None or ext_bot is None:
                span_unknown_missing.append(v)
                continue
            d_top = abs(cy - ext_top)
            d_bot = abs(cy - ext_bot)
            if d_top > EDGE_BAND_PTS and d_bot > EDGE_BAND_PTS:
                # Deep inside the span (out of scope: this audit checks edges
                # only) or well beyond an edge (a neighbor table's value).
                continue
            edge: Literal["top", "bottom"] = "top" if d_top <= d_bot else "bottom"
            (top_missing if edge == "top" else bottom_missing).append(v)
            figures.append(MissingFigure(value=orig, x=cx, y=cy, edge=edge))

    if not (top_missing or bottom_missing or span_unknown_missing):
        return CompletenessVerdict(
            complete=True,
            reason="all value-like figures in the table's column region are present in the extraction",
        )

    def sample(vals: List[str]) -> str:
        return ", ".join(dict.fromkeys(vals))  # de-dup, preserve order; small lists

    parts: List[str] = []
    if top_missing:
        parts.append(f"rows above the first extracted row are missing (e.g. {sample(top_missing)})")
    if bottom_missing:
        parts.append(f"the table continues below the last extracted row (e.g. {sample(bottom_missing)})")
    if span_unknown_missing:
        parts.append(
            f"value-like figures in the region are absent from the extraction, edge unclear "
            f"(no chunk geometry) (e.g. {sample(span_unknown_missing)})"
        )
    total = len(top_missing) + len(bottom_missing) + len(span_unknown_missing)
    return CompletenessVerdict(
        complete=False,
        gap="; ".join(parts),
        reason=f"{total} value-like figure(s) in the table region are absent from the extraction",
        missing_figures=figures,
    )


def infer_columns(
    words: Sequence[Tuple[float, float, float, float, str]],
    span: Tuple[float, float, float, float],
) -> List[float]:
    """Numeric-column center x's (PDF points) inferred from the value-like
    tokens inside the captured span. The fill step slots a restored row's
    figures into the column whose center is nearest each figure's x.
    """
    xs = sorted(
        (x0 + x1) / 2.0
        for (x0, y0, x1, y1, text) in words
        if span[1] <= (y0 + y1) / 2.0 <= span[3] and VALUE_TOKEN_RE.search(text)
    )
    if not xs:
        return []
    clusters: List[List[float]] = [[xs[0]]]
    for x in xs[1:]:
        if x - clusters[-1][-1] > COLUMN_GAP_PTS:
            clusters.append([x])
        else:
            clusters[-1].append(x)
    return [sum(c) / len(c) for c in clusters]


def row_label_at(
    words: Sequence[Tuple[float, float, float, float, str]],
    y: float,
    first_col_x: float,
) -> str:
    """Label for a restored row: the non-figure words at row `y` that sit
    left of the first numeric column, in reading order. Lone currency and
    paren glyphs are dropped so the label is just the row's text.
    """
    parts: List[Tuple[float, str]] = []
    for x0, y0, x1, y1, text in words:
        cy = (y0 + y1) / 2.0
        if abs(cy - y) > ROW_TOL_PTS or (x0 + x1) / 2.0 >= first_col_x:
            continue
        if VALUE_TOKEN_RE.search(text) or text.strip() in {"$", "(", ")", ""}:
            continue
        parts.append((x0, text))
    return " ".join(t for _, t in sorted(parts))


def round_off(
    assembled_markdown: str,
    words: Sequence[Tuple[float, float, float, float, str]],
    span: Tuple[float, float, float, float],
    missing_figures: Sequence[MissingFigure],
) -> Tuple[str, List[MissingFigure]]:
    """Rebuild truncated edge rows from the text layer and merge them into the
    markdown. Returns (new_markdown, placed_figures).

    Deterministic and value-preserving: the inserted figures are the literal
    text-layer tokens (the same source Camelot reads), never invented. Each
    placed figure has its column index and row label set so the caller can
    record the amendment. A bottom-edge row is appended; a top-edge row is
    inserted at the top. Returns the markdown unchanged with an empty list if
    no columns can be inferred or there is nothing to place.
    """
    cols = infer_columns(words, span)
    if not cols or not missing_figures:
        return assembled_markdown, []

    rows: List[Tuple[float, str, List[MissingFigure]]] = []
    for mf in sorted(missing_figures, key=lambda m: m.y):
        if rows and abs(rows[-1][0] - mf.y) <= ROW_TOL_PTS:
            rows[-1][2].append(mf)
        else:
            rows.append((mf.y, mf.edge, [mf]))

    placed: List[MissingFigure] = []
    top_lines: List[Tuple[float, str]] = []
    bottom_lines: List[Tuple[float, str]] = []
    for y, edge, figs in rows:
        label = row_label_at(words, y, cols[0] - COLUMN_GAP_PTS)
        cells = [""] * len(cols)
        for mf in figs:
            ci = min(range(len(cols)), key=lambda i: abs(cols[i] - mf.x))
            cells[ci] = mf.value
            mf.column = ci + 1  # column 0 is the label column in the rebuilt row
            mf.row_label = label
            placed.append(mf)
        row_md = "| " + " | ".join([label] + cells) + " |"
        (top_lines if edge == "top" else bottom_lines).append((y, row_md))

    lines = assembled_markdown.splitlines()
    for _, row_md in sorted(bottom_lines):
        lines.append(row_md)
    for _, row_md in sorted(top_lines, reverse=True):
        lines.insert(0, row_md)
    return "\n".join(lines), placed


def numeric_grid_columns(cells: Sequence[Sequence[str]]) -> List[int]:
    """Grid column indices that hold value-like figures in the data rows
    (row 0 is treated as the header). Ordered left to right; these are the
    columns a restored row's figures get slotted into.
    """
    if not cells:
        return []
    width = max(len(r) for r in cells)
    out: List[int] = []
    for j in range(width):
        if any(j < len(row) and VALUE_TOKEN_RE.search(row[j]) for row in cells[1:]):
            out.append(j)
    return out


def normalize_label(text: str) -> str:
    """Collapse whitespace and case for matching a restored row to an
    existing grid row by its label cell.
    """
    return re.sub(r"\s+", " ", text).strip().lower()


def round_off_grid(
    cells: Sequence[Sequence[str]],
    words: Sequence[Tuple[float, float, float, float, str]],
    span: Tuple[float, float, float, float],
    missing_figures: Sequence[MissingFigure],
) -> Tuple[List[List[str]], List[MissingFigure]]:
    """Round off a truncated table by inserting the missing edge figures
    into a structured cell grid (not a markdown string). Returns
    (new_cells, placed_figures).

    Clean composition: a figure is placed into the grid column whose
    inferred x matches the figure's x (text-layer numeric columns map
    left-to-right onto the grid's numeric columns). A restored row is filled
    in place into the first grid row whose label cell matches and which has
    at least one empty numeric column, so the column count is preserved.
    Otherwise a new row is inserted, at the bottom for a bottom-edge
    truncation and after the header for a top-edge one. A figure whose
    mapped cell is already full goes to the nearest empty numeric column of
    that row. If none is empty the figure is left unplaced and the re-audit
    still reports it. Values are the literal text-layer tokens, never
    invented; a non-empty Camelot cell is never overwritten.
    """
    col_x = infer_columns(words, span)
    num_cols = numeric_grid_columns(cells)
    if not col_x or not num_cols or not missing_figures:
        return [list(r) for r in cells], []

    new_cells: List[List[str]] = [list(r) for r in cells]
    width = max((len(r) for r in new_cells), default=0)
    for r in new_cells:
        r.extend([""] * (width - len(r)))

    def grid_col_for_x(x: float) -> int:
        k = min(range(len(col_x)), key=lambda i: abs(col_x[i] - x))
        return num_cols[k] if k < len(num_cols) else num_cols[-1]

    # group missing figures into rows by y
    rows: List[Tuple[float, str, List[MissingFigure]]] = []
    for mf in sorted(missing_figures, key=lambda m: m.y):
        if rows and abs(rows[-1][0] - mf.y) <= ROW_TOL_PTS:
            rows[-1][2].append(mf)
        else:
            rows.append((mf.y, mf.edge, [mf]))

    placed: List[MissingFigure] = []
    for _, edge, figs in rows:
        label = row_label_at(words, figs[0].y, col_x[0] - COLUMN_GAP_PTS)

        # Which grid columns this row's figures want, by x.
        want = {min(grid_col_for_x(mf.x), width - 1): mf for mf in figs}

        # Fill in place only when an existing row carries the same label AND
        # has at least one empty numeric column. A label can repeat across
        # sections (e.g. "Dec 31, 2024" under both "3 Months Ended" and
        # "12 Months Ended"); a same-label row with no empty numeric column
        # is the wrong row, so we fall through and insert a new one rather
        # than dropping the figures.
        target = None
        if label:
            for ri, r in enumerate(new_cells):
                has_room = any(c < len(r) and not r[c].strip() for c in num_cols)
                if r and normalize_label(r[0]) == normalize_label(label) and has_room:
                    target = ri
                    break
        if target is None:
            row = [""] * max(width, 1)
            row[0] = label
            target = len(new_cells) if edge == "bottom" else min(1, len(new_cells))
            new_cells.insert(target, row)

        for gc, mf in want.items():
            if new_cells[target][gc].strip():
                # mapped cell already holds a Camelot value; fall back to the
                # nearest empty numeric column so nothing is overwritten.
                empties = [j for j in num_cols if j < width and not new_cells[target][j].strip()]
                if not empties:
                    continue
                gc = min(empties, key=lambda j: abs(j - gc))
            new_cells[target][gc] = mf.value
            mf.column = gc
            mf.row_label = label
            placed.append(mf)

    return new_cells, placed


def text_rows(
    words: Sequence[Tuple[float, float, float, float, str]],
    tol: float = ROW_TOL_PTS,
) -> List[List[Tuple[float, float, float, float, str]]]:
    """Group words into visual rows by y-center (words within `tol` points)."""
    items = sorted(((w[1] + w[3]) / 2.0, w) for w in words)
    rows: List[List[Tuple[float, float, float, float, str]]] = []
    row_cy: Optional[float] = None
    for cy, w in items:
        if row_cy is None or cy - row_cy > tol:
            rows.append([])
            row_cy = cy
        rows[-1].append(w)
    return rows


def tabular_bands(
    words: Sequence[Tuple[float, float, float, float, str]],
) -> List[Tuple[float, float, float, float]]:
    """Vertical bands of contiguous *tabular* rows, each (top, bottom, x_left,
    x_right) in points. A tabular row carries two-or-more value-like figures
    (data rows of a real table); prose lines and lone labels are excluded.
    Contiguous tabular rows (vertical gap <= BAND_GAP_PTS) merge into one
    band. Used to relocate a detector box onto the text that is actually
    there, instead of trusting a box that landed in whitespace.
    """
    spans: List[Tuple[float, float, float, float]] = []
    for row in text_rows(words):
        if sum(1 for w in row if VALUE_TOKEN_RE.search(w[4])) < 2:
            continue
        ys = [w[1] for w in row] + [w[3] for w in row]
        xs = [w[0] for w in row] + [w[2] for w in row]
        spans.append((min(ys), max(ys), min(xs), max(xs)))
    if not spans:
        return []
    spans.sort()
    bands: List[List[float]] = [list(spans[0])]
    for top, bot, xl, xr in spans[1:]:
        if top - bands[-1][1] <= BAND_GAP_PTS:
            bands[-1][1] = max(bands[-1][1], bot)
            bands[-1][2] = min(bands[-1][2], xl)
            bands[-1][3] = max(bands[-1][3], xr)
        else:
            bands.append([top, bot, xl, xr])
    return [(b[0], b[1], b[2], b[3]) for b in bands]


def repair_box(
    box_norm: Tuple[float, float, float, float],
    bands: Sequence[Tuple[float, float, float, float]],
    page_w: float,
    page_h: float,
    near_pad_frac: float = 0.05,
) -> Optional[Tuple[float, float, float, float]]:
    """Snap a detector box (normalized 0..1, top-left) onto the tabular text
    it actually covers. Returns the repaired normalized box, or None when no
    tabular band overlaps the box or sits within `near_pad_frac` of the page
    height of it. On None the caller keeps the detector's box unchanged, so
    the detection is never dropped here.

    Picks the band with the most vertical overlap; if the box overlaps none
    (it landed just off the table), takes the nearest band within the pad.
    The repaired box spans the band's real text extent, which fixes both a
    too-narrow box (clipped row labels) and a vertically-offset box.
    """
    bt = min(box_norm[1], box_norm[3]) * page_h
    bb = max(box_norm[1], box_norm[3]) * page_h
    best: Optional[Tuple[float, float, float, float]] = None
    best_ov = 0.0
    for band in bands:
        ov = max(0.0, min(bb, band[1]) - max(bt, band[0]))
        if ov > best_ov:
            best_ov, best = ov, band
    if best is None:
        pad = near_pad_frac * page_h
        near = [(max(band[0] - bb, bt - band[1], 0.0), band) for band in bands]
        near = [(gap, band) for gap, band in near if gap <= pad]
        if not near:
            return None
        best = min(near, key=lambda g: g[0])[1]
    top, bot, xl, xr = best
    return (xl / page_w, top / page_h, xr / page_w, bot / page_h)


@runtime_checkable
class CompletenessAuditor(Protocol):
    async def audit(
        self,
        *,
        source: Path,
        page: int,
        region_bbox: Optional[Tuple[float, float, float, float]],
        extracted_bboxes: Sequence[Optional[Tuple[float, float, float, float]]],
        assembled_markdown: str,
    ) -> CompletenessVerdict: ...


class TextLayerCompleteness:
    """Deterministic CompletenessAuditor backed by the PDF text layer.

    Reads the page's words via PyMuPDF in a worker thread (blocking IO),
    then compares value-like figures in the table's column region against
    the assembled markdown. No model, no network, no image.
    """

    async def audit(
        self,
        *,
        source: Path,
        page: int,
        region_bbox: Optional[Tuple[float, float, float, float]],
        extracted_bboxes: Sequence[Optional[Tuple[float, float, float, float]]],
        assembled_markdown: str,
    ) -> CompletenessVerdict:
        import asyncio

        try:
            page_w, page_h, words = await asyncio.to_thread(page_words, Path(source), page)
        except Exception as exc:
            logger.error(
                "completeness: could not read text layer; defaulting to complete=true page={} source={} exc={}",
                page,
                Path(source).name,
                exc,
            )
            return CompletenessVerdict(complete=True, reason="default complete: text layer unreadable")
        return completeness_verdict(
            page_w=page_w,
            page_h=page_h,
            words=words,
            region_bbox=region_bbox,
            extracted_bboxes=extracted_bboxes,
            assembled_markdown=assembled_markdown,
        )


class MockCompleteness:
    """Returns a canned verdict. For tests."""

    def __init__(self, verdict: Optional[CompletenessVerdict] = None) -> None:
        self.verdict = verdict or CompletenessVerdict(complete=True, reason="mock")

    async def audit(
        self,
        *,
        source: Path,
        page: int,
        region_bbox: Optional[Tuple[float, float, float, float]],
        extracted_bboxes: Sequence[Optional[Tuple[float, float, float, float]]],
        assembled_markdown: str,
    ) -> CompletenessVerdict:
        _ = (source, page, region_bbox, extracted_bboxes, assembled_markdown)
        return self.verdict


CompletenessBackend = Literal["text", "mock"]


def get_completeness(backend: Optional[CompletenessBackend] = None) -> CompletenessAuditor:
    selected = backend or get_settings().llm.completeness_backend
    if selected == "text":
        return TextLayerCompleteness()
    if selected == "mock":
        return MockCompleteness()
    raise ValueError(f"Unknown QUBER_COMPLETENESS_BACKEND: {selected!r}. Expected text|mock.")
