"""
TableExtractor Protocol and ExtractedTable Pydantic model.

`ExtractedTable` carries the LLM-correction outputs (title, caption,
markdown, footnotes, footnote refs and marks, units, header rows) plus the
Camelot-side provenance (page, bbox, flavor). `subtitle` is always left empty
and exists only so older artifacts still load.

The numeric content lives inside `markdown`. Where its values come from
depends on the path, and the system prompts in `quber.agents.llm_client` set
the rule for each. The deprecated camelot-llm correction may not change any
Camelot value. The set-of-mark correction takes values from Camelot's grid and
the table's text layer, and may recover rows Camelot missed from that text
layer. A table the figure stage reads off a page image (`kind` image_table)
involves no Camelot: its grid, fingerprint and `som_region` come from the page
scan.
"""

from __future__ import annotations

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

from pydantic import BaseModel, Field, field_validator

from quber.agents.classifier import ClassifierResult
from quber.agents.llm_client import FootnoteDef


@runtime_checkable
class TableExtractor(Protocol):
    async def extract_tables(self, source: Path) -> List[ExtractedTable]: ...

    def extract_tables_sync(self, source: Path) -> List[ExtractedTable]: ...


class ExtractedTable(BaseModel):
    table_id: str = Field(
        default="",
        description=(
            "Deterministic address of this table: '<doc>-p<page>-t<n>', where <doc> is the "
            "source file stem, <page> the 1-indexed page, and <n> the locator's reading-order "
            "ordinal on that page. A sub-table produced by splitting a fused region appends "
            "'-s<k>'. The same document yields the same IDs on every run, so external "
            "references (a review comment, a validation report, a UI link) stay valid across "
            "runs. Empty for engines that do not set it."
        ),
    )
    content_fingerprint: str = Field(
        default="",
        description=(
            "First 8 hex characters of sha256 over Camelot's raw value grid. The same "
            "document yields the same fingerprint on every run; a changed value at the same "
            "table_id shows as a different fingerprint. Empty when no grid backed the table."
        ),
    )

    # LLM-correction outputs
    title: str = Field(
        default="", description="The table's printed name, copied off the page; empty when none is printed"
    )
    caption: str = Field(
        default="", description="The sentence that introduces the table, copied off the page; empty when none"
    )
    # Always left empty. Kept so artifacts from earlier runs still load.
    subtitle: str = Field(default="", description="Retained for older artifacts; new runs leave it empty")
    markdown: str = Field(description="Corrected markdown table; numeric values from Camelot")
    footnotes: List[FootnoteDef] = Field(
        default_factory=list,
        description=(
            "Footnotes printed below the table, each a pair of the footnote's own marker "
            "(empty for an unmarked general note) and its text. Read off the table image "
            "by the correction agent; the image's formatting decides what is a marker."
        ),
    )
    footnote_refs: List[str] = Field(
        default_factory=list,
        description=(
            "Footnote reference markers carried on the table's headers or cells, read off the "
            "table image — a superscript or parenthetical number, letter, or symbol that points "
            "to a footnote: e.g. '1', '(1)', a letter 'a', an asterisk '*', a dagger '†' or "
            "double-dagger '‡', or a section sign '§'. Written as it appears. Empty if the table "
            "carries none. Survives superscripts the text layer drops; the demand-driven footnote "
            "lookup keys off this."
        ),
    )
    footnote_marks: List["LocatedMarker"] = Field(
        default_factory=list,
        description=(
            "Where each footnote reference marker sits: one entry per carrying cell, "
            "addressed by row/col in the corrected markdown (header row 0). The agent "
            "QUOTES the carrying cell and a lookup resolves the quote to its cell — "
            "it never counts positions. An entry with row/col None is a marker whose "
            "quote matched no cell (or that was catalogued without a quote) — kept "
            "and flagged, never dropped."
        ),
    )
    units: str = Field(default="", description="Scale/currency unit attribution caption; empty if none")
    header_rows: Optional[int] = Field(
        default=None,
        description=(
            "Leading column-header rows of the corrected markdown, counted off the table "
            "image by the correction review. A stacked header counts every stacked row; 0 "
            "means the fragment starts at data. None on artifacts extracted before this "
            "capture existed."
        ),
    )

    # Camelot-side provenance
    page: int = Field(description="1-indexed page number")
    bbox: Optional[Tuple[float, float, float, float]] = Field(
        default=None, description="Camelot's geometric box on the page (x1, y1, x2, y2)"
    )
    som_region: Optional[Tuple[float, float, float, float]] = Field(
        default=None,
        description=(
            "Set-of-Mark vision boundary box that located this table, normalized 0..1 with the page "
            "top-left as origin (x1, y1, x2, y2). Full-precision floats, never floored or snapped to a "
            "grid cell. None for engines that do not run the Set-of-Mark locator."
        ),
    )
    content_region: Optional[Tuple[float, float, float, float]] = Field(
        default=None,
        description=(
            "The table's actual content extent, normalized 0..1 top-left origin (x1, y1, x2, y2). "
            "Same box as som_region but with the bottom revised down to the last tabular row, located "
            "by matching the corrected markdown's last row back to the page text layer. som_region and "
            "bbox can overrun past the grid (e.g. enclosing footnote lines printed below it); this is "
            "the authoritative end of the table. Equal to som_region when no overrun is found or the "
            "last row could not be located. None for engines without the Set-of-Mark locator."
        ),
    )
    cell_grid: List[List["GroundedCell"]] = Field(
        default_factory=list,
        description=(
            "The Camelot cell grid with per-cell geometry, as extracted before LLM "
            "structure correction. Rows and columns align with Camelot's raw grid, "
            "not the (possibly restructured) `markdown` — values are preserved verbatim "
            "through correction, so the geometry can be remapped onto the corrected "
            "cells by value later. Empty when no Camelot grid backed the table."
        ),
    )
    corrected_grid: List[List["GroundedCell"]] = Field(
        default_factory=list,
        description=(
            "The corrected markdown's grid with per-cell geometry — one GroundedCell per "
            "markdown cell, same row/col addressing (header row 0), boxes normalized 0..1 "
            "top-left. This is the complete cell-level view, fully resolved at the grounding "
            "stage: combined cells carry the box their merge's reported addresses resolved to, "
            "and every other cell its Camelot cell box via order-preserving alignment to the "
            "raw grid — whole-cell matches first, then fragments of glued Camelot cells by "
            "ordered span tiling within the aligned rows. box is None only where a cell's text "
            "has no home in the raw grid; those gaps are warned in the run log at extraction. "
            "Equal to cell_grid when no correction was applied."
        ),
    )
    merged_cells: List["MergedCellBox"] = Field(
        default_factory=list,
        description=(
            "Corrected cells that combine two or more source cells (a rejoined split "
            "symbol, or a flattened multi-row/spanning header), each addressed by its "
            "row/col in the corrected markdown and resolved to one Camelot cell box per "
            "reported source address plus their union. Empty when no cells were combined."
        ),
    )
    dropped_text: List[str] = Field(
        default_factory=list,
        description=(
            "Text printed in the table's header area that appears in no output cell — "
            "usually surrounding page text the correction rightly excluded, but recorded "
            "so an omission is never silent. Each entry becomes a table-level review "
            "flag (status 'header_text_dropped'). Empty when everything printed was carried."
        ),
    )
    flavor: Literal["lattice", "stream", "unknown"] = Field(
        default="unknown", description="Camelot extraction flavor that produced this table"
    )
    kind: Literal["text_table", "chart", "image_table"] = Field(
        default="text_table",
        description=(
            "What this region really is, set by reconciliation against docling. "
            "text_table: a real text-layer table (the default). chart: SoM mistook "
            "a docling-classified picture (bar/line chart, etc.) for a table. "
            "image_table: a real table rendered as an image (no text layer under a "
            "docling-detected table, Camelot empty). Its content is whatever last "
            "read the image: the page-scan workflow's reading where that has run, "
            "and docling's own OCR of it where it has not."
        ),
    )
    source: Optional[str] = Field(default=None, description="Source PDF path")

    # What each pipeline stage did to this table: parse quality, classifier verdict, LLM edits
    camelot_accuracy: float = Field(
        default=0.0,
        ge=0.0,
        le=100.0,
        description="Camelot parsing_report.accuracy (0-100); how cleanly text snapped to detected cells",
    )
    classifier_decision: Optional[ClassifierResult] = Field(
        default=None,
        description="What the TableClassifier decided about this candidate; None if classifier was bypassed",
    )
    llm_corrected: bool = Field(
        default=False,
        description="True if the LLM modified Camelot's raw markdown during the correction pass",
    )

    @field_validator("footnotes", mode="before")
    @classmethod
    def coerce_legacy_footnote_strings(cls, v: object) -> object:
        """Artifacts extracted before footnotes carried their marker stored each
        footnote as a bare string. Load those as unmarked notes — the text is
        intact; only the marker pairing is absent — so old artifacts stay
        readable."""
        if isinstance(v, list):
            return [{"marker": "", "text": item} if isinstance(item, str) else item for item in v]
        return v

    # Audit trail of the detect/match/audit flow. Only the deprecated correspondence engine sets it.
    extraction_record: Optional[ExtractionRecord] = Field(
        default=None,
        description="Record of the detector/correspondence/completeness flow; None if not used",
    )


class ExtractionRecord(BaseModel):
    """Outcome of the extraction attempt for one detected table.

    Produced by `CamelotCorrespondenceExtractor`. Its pipeline has a vision
    detector list every table visible on a page, matches each detected
    table to the Camelot chunk(s) holding its data, and audits the result
    for completeness against the page image.

    This record captures the status of that attempt, the chunks that supplied
    the data, and the audit verdict — including the failure case where a
    table was detected by the vision detector but not extracted by Camelot
    (`detected_not_extracted`); so a table the detector saw is never silently
    dropped from the output.

    None on `ExtractedTable` when the table came through the legacy
    classifier/unifier path, which has no detector to anchor a record to.
    """

    status: Literal["extracted", "incomplete", "detected_not_extracted"] = Field(
        description=(
            "extracted: a Camelot chunk matched the detected table and passed the "
            "completeness audit. incomplete: matched but the completeness audit "
            "found numbers in the image absent from the data, and no chunk could "
            "supply them. detected_not_extracted: the detector confirmed a table on "
            "the page but neither Camelot flavor nor the recovery pass produced it "
            "(reported explicitly, never silently dropped)."
        ),
    )
    detected_ordinal: Optional[int] = Field(
        default=None,
        description="1-based top-to-bottom position of the matched detected table on its page",
    )
    detected_description: str = Field(
        default="",
        description="The detector's description of this table; the content hook correspondence matched on",
    )
    matched_flavor: Optional[Literal["lattice", "stream"]] = Field(
        default=None,
        description="Which Camelot flavor supplied the chunk(s); None for detected_not_extracted",
    )
    source_candidate_ids: List[str] = Field(
        default_factory=list,
        description="Camelot candidate_ids assembled into this table, in page order",
    )
    combined: bool = Field(
        default=False,
        description=(
            "True if a single Camelot chunk spans two or more detected tables "
            "(convergence). The chunk is kept whole and flagged, never split."
        ),
    )
    combined_ordinals: List[int] = Field(
        default_factory=list,
        description="Detected ordinals a combined chunk spans; empty unless combined is True",
    )
    completeness_complete: Optional[bool] = Field(
        default=None,
        description="completeness audit verdict; None if the audit did not run (e.g. detected_not_extracted)",
    )
    completeness_gap: str = Field(
        default="",
        description="If truncated, which edge was cut off (per the completeness audit); empty when complete",
    )
    filled_from_text_layer: bool = Field(
        default=False,
        description=(
            "True if the text-layer fill step inserted one or more figures to round off an "
            "edge truncation. The inserted values are listed in filled_cells; every other cell "
            "remains Camelot-sourced."
        ),
    )
    filled_cells: List[FilledCell] = Field(
        default_factory=list,
        description="Figures inserted by the fill step, with their grid position and text-layer origin; empty unless filled_from_text_layer",
    )


class FilledCell(BaseModel):
    """One figure inserted into a table by the text-layer fill step.

    Records that this value did NOT come from a Camelot chunk: it was read
    directly from the PDF text layer at (x, y) and placed into the grid to
    round off an edge truncation. Camelot remains the source for every
    other cell; this keeps the amendment auditable and never blurs a
    filled value with a Camelot-extracted one (the source field is always
    `text_layer`).
    """

    value: str = Field(description="The literal text-layer figure that was inserted")
    column: int = Field(
        description="0-based column index in the assembled row the value was placed in (0 is the label column)"
    )
    row_label: str = Field(default="", description="Label of the restored row the value was placed into")
    edge: Literal["top", "bottom"] = Field(description="Which edge the truncated row was restored at")
    x: float = Field(description="Text-layer x of the figure (PDF points)")
    y: float = Field(description="Text-layer y of the figure (PDF points)")
    source: Literal["text_layer"] = Field(
        default="text_layer",
        description="Always text_layer; distinguishes a filled cell from a Camelot-sourced one",
    )


class LocatedMarker(BaseModel):
    """One footnote reference marker tied to the cell that carries it.

    `row`/`col` address the carrying cell in the corrected markdown (header
    row 0), resolved by looking up the cell text the agent QUOTED for the
    marker — the model never counts positions. Both None when the marker is
    known to be on the table but its carrying cell is not — the marker is
    kept and flagged for review rather than guessed onto a row.
    """

    marker: str = Field(description="The reference marker as printed: '1', '(1)', 'a', '*', a dagger")
    row: Optional[int] = Field(
        default=None,
        ge=0,
        description="0-based row of the carrying cell in the corrected markdown; None when unplaced",
    )
    col: Optional[int] = Field(
        default=None,
        ge=0,
        description="0-based column of the carrying cell in the corrected markdown; None when unplaced",
    )
    kind: Literal["footnote", "section"] = Field(
        default="footnote",
        description=(
            "The correction agent's judgement of what the marker points at, made from "
            "the page image: 'footnote' — a note printed for this table; 'section' — a "
            "cross-reference to a named part of the document whose content lives "
            "elsewhere. Resolution routes on this: section references resolve to a "
            "heading pointer and are never hunted as footnotes."
        ),
    )
    scope: Literal["cell", "table"] = Field(
        default="cell",
        description=(
            "'cell': the marker is carried by a grid cell at row/col. 'table': the "
            "marker is carried by the table's own title or subtitle text — a spanning "
            "band or title suffix — so it qualifies the whole table and reaches every "
            "record; row/col are None but the marker is placed, not flagged."
        ),
    )


class CellStatus(BaseModel):
    """One entry of the cell-status reference: the machine code a corrected
    cell carries and the human framing a front end shows for it. `inspect`
    marks the codes a consumer surfaces to the user when the table is used.
    `cases` names the residue-catalog cases the code covers (the catalog is
    docs/CELL_STATUS_CASES.pdf), so a status always points back to a
    demonstrated example.
    """

    code: str
    label: str
    description: str
    inspect: bool = False
    cases: str = ""


# The canonical status registry. The classifier may only emit these codes —
# GroundedCell validates against it — so a front end can key a lookup table
# on `code` without ever meeting an unknown value. Full prose glossary:
# set_of_mark/README.md.
CELL_STATUS_REFERENCE: Tuple[CellStatus, ...] = (
    CellStatus(
        code="reconciled",
        label="Matches the page.",
        description=(
            "The cell's text tied back to a printed source and carries that source's measured coordinates."
        ),
    ),
    CellStatus(
        code="header_printed_unlocated",
        label="This header is printed on the page. We could not measure where.",
        description=(
            "Header or band text is printed on the page, but its coordinates "
            "could not be measured. The text is correct; only the geometry is "
            "missing. Expected behavior — a pass, not a defect."
        ),
        cases="1",
    ),
    CellStatus(
        code="label_printed_unlocated",
        label="This row label is printed on the page. We could not measure where.",
        description=(
            "A row label is printed on the page, usually wrapped across printed "
            "lines, but its coordinates could not be measured. Expected "
            "behavior — a pass, not a defect."
        ),
        cases="2 and 3",
    ),
    CellStatus(
        code="single_character",
        label='A one-character cell, such as a bare "$". Not matched to a position by design.',
        description=(
            "One-character text (a bare '$'), excluded from location matching by "
            "design: a bare symbol or digit would anchor inside any unrelated "
            "number. Working as designed — a pass, not a defect."
        ),
        cases="7",
    ),
    CellStatus(
        code="total_label_added",
        label='The page prints no label on the totals row, so we wrote "Total".',
        description=(
            "The totals row is printed with no label; the extraction wrote "
            "'Total'. Flagged for user review because the author could have "
            "intended something else — a review item, NOT a defect."
        ),
        inspect=True,
        cases="5",
    ),
    CellStatus(
        code="header_label_added",
        label="The page prints no header over this column, so we named it.",
        description=(
            "The table is printed with no header over this column; the "
            "extraction wrote a generic column name ('Item', 'Description'). "
            "Flagged for user review because the author could have intended "
            "something else — a review item, NOT a defect."
        ),
        inspect=True,
        cases="6",
    ),
    CellStatus(
        code="unverified",
        label="This value is on the page, but our check of it did not complete. Worth a manual look.",
        description=(
            "The condition could not be confirmed: the text has no printed source "
            "and is not an authorized conventional label, or the inspector could "
            "not tell from the image. An open question for user review — not yet "
            "a defect."
        ),
        inspect=True,
    ),
    CellStatus(
        code="defect",
        label="The page shows something different from what we recorded. See the note.",
        description=(
            "The inspector positively observed the page showing something OTHER "
            "than what the extraction recorded — a different word printed at an "
            "added label's position, or a structure the condition misdescribes. "
            "A confirmed defect; the evidence names what the page shows."
        ),
        inspect=True,
    ),
    CellStatus(
        code="footnote_unresolved",
        label="This cell has a footnote marker, and we could not find the footnote it refers to.",
        description=(
            "A marker is printed on a table cell or a figure's label and no tier "
            "found the note it points at — not the pairs the correction agent "
            "read off the image, not the reading-order scan, not the demand-driven "
            "lookup. The element states a figure the document qualifies somewhere, "
            "and the qualification is missing: read alone it looks unqualified, "
            "which is indistinguishable from a clean reading. An element-level "
            "flag naming the marker, not an output cell."
        ),
        inspect=True,
    ),
    CellStatus(
        code="footnote_unreferenced",
        label="A footnote on this page that no marker points to.",
        description=(
            "A note printed for a table or figure that no marker on it names. "
            "Currently judged within one element type, so a note a table claims "
            "can still read as unreferenced by a figure on the same page and the "
            "reverse — which is why it is catalogued but not surfaced. Becomes "
            "meaningful once every reader's claims are pooled and the leftovers "
            "reported once for the document."
        ),
    ),
    CellStatus(
        code="footnote_marker_unplaced",
        label="This table has a footnote marker we could not tie to a cell.",
        description=(
            "The correction agent read this footnote reference marker off the "
            "table image but could not tie it to a carrying cell, or the "
            "position it reported failed validation. The marker is real; only "
            "its cell is unknown, so its footnote can be attached at table "
            "level but not to a specific row. A table-level flag: it names "
            "the marker, not an output cell."
        ),
        inspect=True,
    ),
    CellStatus(
        code="header_text_dropped",
        label="The page prints header text here that we left out of the table.",
        description=(
            "Text printed in the table's header area appears in no output cell. "
            "Usually surrounding page text (a heading, a note) that the "
            "extraction correctly excluded from the table, but surfaced so an "
            "omission is never silent. A table-level flag: it names the missing "
            "text, not an output cell."
        ),
        inspect=True,
        cases="4",
    ),
    CellStatus(
        code="value_misread",
        label="The page prints a different figure here. Check which is right.",
        description=(
            "A chart value where the page fragment at the value's position "
            "states a different number than the reading. One of the two is "
            "wrong and the position pins exactly where to look, so the value "
            "never passes silently. A figure-value flag: it names the value "
            "and carries the fragment's box."
        ),
        inspect=True,
    ),
    CellStatus(
        code="value_unreconciled",
        label="Read from the chart once, without a second check. Worth a manual look.",
        description=(
            "A chart value present in one reading with no counterpart in the "
            "other. The note states the direction: a scan-only value the local "
            "read did not corroborate, or a locally read value the scan "
            "omitted. Either way the value stands unverified and is surfaced "
            "rather than passed. A figure-value flag."
        ),
        inspect=True,
    ),
)

# The conventional labels the correction prompt authorizes it to add where the
# page prints nothing. Kept in lockstep with the UNLABELED TOTAL ROW / MISSING
# HEADER ROW guidance in the vet-structure prompt: an added label outside this
# set classifies `unverified`, never as an added-label status.
AUTHORIZED_LABELS = frozenset({"total", "item", "description"})

_CELL_STATUS_CODES = frozenset(s.code for s in CELL_STATUS_REFERENCE)


class GroundedCell(BaseModel):
    """One Camelot cell paired with its geometry.

    `text` is the cell value (immutable through the pipeline). `box` is the
    cell's box normalized 0..1 with the page top-left as origin — the same
    frame as `som_region` and `MergedCellBox`, so every box the table carries
    reads in ONE frame — or None when no Camelot box backs the cell.
    """

    text: str = Field(description="Cell value from Camelot's grid")
    box: Optional[Tuple[float, float, float, float]] = Field(
        default=None,
        description="Cell box (x1, y1, x2, y2) normalized 0..1, top-left origin; None if unavailable",
    )
    status: Optional[str] = Field(
        default=None,
        description=(
            "Why the cell does or does not carry a box; set on corrected-grid cells only. "
            "One of the codes in CELL_STATUS_REFERENCE — see that registry (and the "
            "set_of_mark README) for each code's meaning and whether a consumer should "
            "surface it for user inspection."
        ),
    )

    note: Optional[str] = Field(
        default=None,
        description=(
            "Inspection evidence for a flagged cell: the status inspector's one-line "
            "account of what the table image shows at this position. None when the "
            "cell needed no inspection."
        ),
    )

    @field_validator("status")
    @classmethod
    def status_is_a_registered_code(cls, v: Optional[str]) -> Optional[str]:
        if v is not None and v not in _CELL_STATUS_CODES:
            raise ValueError(f"unknown cell status {v!r}; register it in CELL_STATUS_REFERENCE")
        return v


class CellFlag(BaseModel):
    """One item a run surfaces for review — the error-check record.

    Carries full document identity (source, page, table) alongside the item,
    so a flag can be traced to its table without reverse attribution from
    page numbers. `status` is a registered inspect code; `note` is the
    status inspector's one-line account of what the page image shows. Most
    flags point at an output cell; a table-level flag (status `header_text_dropped`,
    printed text that reached no output cell) has no cell to point at, so
    `row` and `col` are None and `text` holds the missing text.
    """

    source: str
    page: int
    table_id: Optional[str] = None
    title: str = ""
    row: Optional[int] = None
    col: Optional[int] = None
    text: str
    status: str
    note: Optional[str] = None


def cell_flags(tables: Sequence["ExtractedTable"]) -> List[CellFlag]:
    """Every corrected cell whose status is registered for inspection, plus
    each table's dropped printed text and each footnote marker not tied to a
    cell, with document identity attached — the run's review queue, ready to
    emit."""
    inspect_codes = frozenset(s.code for s in CELL_STATUS_REFERENCE if s.inspect)
    out: List[CellFlag] = []
    for t in tables:
        for r, row in enumerate(t.corrected_grid or []):
            for c, cell in enumerate(row):
                if cell.status in inspect_codes:
                    out.append(
                        CellFlag(
                            source=t.source or "",
                            page=t.page,
                            table_id=t.table_id,
                            title=t.title or "",
                            row=r,
                            col=c,
                            text=cell.text,
                            status=cell.status or "",
                            note=cell.note,
                        )
                    )
        for fragment in t.dropped_text:
            out.append(
                CellFlag(
                    source=t.source or "",
                    page=t.page,
                    table_id=t.table_id,
                    title=t.title or "",
                    text=fragment,
                    status="header_text_dropped",
                )
            )
        for mark in t.footnote_marks:
            if mark.row is None and mark.scope != "table":
                out.append(
                    CellFlag(
                        source=t.source or "",
                        page=t.page,
                        table_id=t.table_id,
                        title=t.title or "",
                        text=mark.marker,
                        status="footnote_marker_unplaced",
                    )
                )
    return out


class MergedCellBox(BaseModel):
    """A corrected cell built by combining two or more source cells, resolved to
    per-source-cell boxes plus their union.

    The correction agent reads the Camelot grid inside a printed spreadsheet
    coordinate frame and reports each merge's source cells by ADDRESS
    (`source_cells`, e.g. ['B2', 'B3']); `source_boxes` holds one box per
    address, in the same order — the named cell's Camelot-measured box, or None
    where the address could not be resolved (bad label, blank cell, or text
    that does not appear in the result). `box` is the union of the located
    boxes, a coarse envelope over the whole combined cell. All boxes are
    normalized 0..1 with the page top-left as origin — the same frame as
    `som_region`. Every box is Camelot's own measurement; the model only ever
    repeats printed labels.
    """

    result: str = Field(description="The combined cell text, as it appears in the corrected markdown")
    row: int = Field(
        ge=0,
        description=(
            "0-based row of this cell in the corrected markdown table (header row is 0) — the exact "
            "address of the cell, since result text alone repeats (e.g. an identical total in the "
            "Basic and Diluted EPS rows)"
        ),
    )
    col: int = Field(ge=0, description="0-based column of this cell in the corrected markdown table")
    sources: List[str] = Field(default_factory=list, description="The source cell values that were combined")
    source_cells: List[str] = Field(
        default_factory=list,
        description=(
            "The printed coordinate of each combined Camelot cell as the agent read it off the "
            "coordinate frame (e.g. ['B2', 'B3']); resolves into cell_grid by lookup"
        ),
    )
    source_boxes: List[Optional[Tuple[float, float, float, float]]] = Field(
        default_factory=list,
        description="One box per source_cells entry (same order), normalized 0..1 top-left; None where unresolved",
    )
    box: Optional[Tuple[float, float, float, float]] = Field(
        default=None,
        description="Union of the located source boxes (x1, y1, x2, y2) normalized 0..1, top-left origin; None if none located",
    )
    grounded_by: Literal["cell_address", "partial", "none"] = Field(
        default="none",
        description=(
            "cell_address: every reported source address resolved and text-validated; partial: "
            "some resolved (the union box under-covers the combined cell); none: no address "
            "reported or none resolved"
        ),
    )


def table_address(source: str | Path, page: int, ordinal: int) -> str:
    """The deterministic table ID: '<doc>-p<page>-t<ordinal>'.

    Built purely from the table's position, so the same document yields the
    same IDs on every run. A split sub-table appends '-s<k>' to its parent's
    address at the split site.
    """
    return f"{Path(source).stem}-p{page}-t{ordinal}"


def grid_fingerprint(cells: List[List[str]]) -> str:
    """First 8 hex characters of sha256 over the raw value grid."""
    if not cells:
        return ""
    return hashlib.sha256(json.dumps(cells).encode()).hexdigest()[:8]


def grounded_grid(
    cells: List[List[str]],
    cell_boxes: Optional[List[List[Optional[Tuple[float, float, float, float]]]]],
    page_w: float,
    page_h: float,
) -> List[List[GroundedCell]]:
    """Pair a Camelot cell grid with its per-cell geometry into GroundedCells.

    `cell_boxes` arrive in Camelot's frame (PDF points, bottom-left origin) and
    are converted here to normalized 0..1 top-left, so the grid serializes in
    the same frame as every other box on the table. Shaped exactly like
    `cells`. Any position `cell_boxes` does not cover (or a None `cell_boxes`)
    yields a cell with no box, so the result is safe to build from a candidate
    whose geometry was not captured.
    """

    def norm(box: Optional[Tuple[float, float, float, float]]) -> Optional[Tuple[float, float, float, float]]:
        if box is None or page_w <= 0 or page_h <= 0:
            return None
        x1, y1, x2, y2 = box
        return (x1 / page_w, (page_h - y2) / page_h, x2 / page_w, (page_h - y1) / page_h)

    grid: List[List[GroundedCell]] = []
    for i, row in enumerate(cells):
        box_row = cell_boxes[i] if cell_boxes and i < len(cell_boxes) else []
        grid.append(
            [
                GroundedCell(text=txt, box=norm(box_row[j] if j < len(box_row) else None))
                for j, txt in enumerate(row)
            ]
        )
    return grid
