"""Tests for address-based grounding of the corrected table.

The agent reads the Camelot grid inside a printed coordinate frame and reports
each merge's source cells by address; grounding is a lookup into `cell_grid`
guarded by a text check, so the tests assert exact box equality throughout.
`resolve_corrected_grid` then closes the whole table by order-preserving
alignment. Everything here is pure and deterministic — no vision, no words,
no model.
"""

from __future__ import annotations

from quber.agents.llm_client import CellMerge, FootnoteMark
from quber.core.extractors.base import GroundedCell, MergedCellBox
from quber.core.extractors.camelot.acquire import column_letter, grid_to_addressed_markdown
from quber.core.extractors.set_of_mark.merge_grounding import (
    locate_markers,
    log_ungrounded,
    log_ungrounded_cells,
    parse_address,
    resolve_corrected_grid,
    resolve_merges,
)


def _cells(rows):
    return [[GroundedCell(text=t, box=b) for t, b in row] for row in rows]


# A stacked-header grid the agent flattens: row 1 header fragments, row 2 the
# year band, then data rows with a split symbol column.
GRID = _cells(
    [
        [("Item", (0.10, 0.10, 0.20, 0.12)), ("Three Months Ended", (0.40, 0.10, 0.70, 0.12)), ("", None)],
        [("", None), ("December 31, 2022", (0.42, 0.14, 0.68, 0.16)), ("", None)],
        [
            ("Revenue", (0.10, 0.20, 0.20, 0.22)),
            ("$", (0.40, 0.20, 0.44, 0.22)),
            ("1,637", (0.50, 0.20, 0.60, 0.22)),
        ],
        [
            ("Cost", (0.10, 0.30, 0.20, 0.32)),
            ("$", (0.40, 0.30, 0.44, 0.32)),
            ("1,592", (0.50, 0.30, 0.60, 0.32)),
        ],
    ]
)


# --- the coordinate frame ----------------------------------------------------


def test_column_letters_and_address_parse_roundtrip():
    assert [column_letter(i) for i in (0, 25, 26, 27)] == ["A", "Z", "AA", "AB"]
    assert parse_address("B3") == (2, 1)
    assert parse_address(" aa12 ") == (11, 26)
    assert parse_address("7") is None
    assert parse_address("B0") is None
    assert parse_address("$B3") is None


def test_addressed_markdown_tags_every_nonempty_cell_inline():
    md = grid_to_addressed_markdown([["Item", "2022"], ["Revenue", ""], ["", "1,637"]])
    lines = md.splitlines()
    assert lines[0] == "| [A1] Item | [B1] 2022 |"
    assert lines[2] == "| [A2] Revenue |  |"  # blank cells carry no tag
    assert lines[3] == "|  | [B3] 1,637 |"


# --- resolve_merges: address -> measured box, text-validated ------------------


def test_merge_addresses_resolve_to_exact_camelot_boxes():
    # The flattened header and a rejoined symbol, both by printed address.
    merges = [
        CellMerge(
            result="Three Months Ended December 31, 2022",
            row=0,
            col=1,
            sources=["Three Months Ended", "December 31, 2022"],
            source_cells=["B1", "B2"],
        ),
        CellMerge(result="$ 1,637", row=1, col=1, sources=["$", "1,637"], source_cells=["B3", "C3"]),
    ]
    out = resolve_merges(merges, GRID)
    assert [m.grounded_by for m in out] == ["cell_address", "cell_address"]
    assert out[0].source_boxes == [(0.40, 0.10, 0.70, 0.12), (0.42, 0.14, 0.68, 0.16)]
    assert out[0].box == (0.40, 0.10, 0.70, 0.16)  # union of the two header cells
    assert out[1].source_boxes == [(0.40, 0.20, 0.44, 0.22), (0.50, 0.20, 0.60, 0.22)]


def test_misread_address_is_refused_by_the_text_check():
    # C4 holds "1,592", which is not part of this result: the claim is refused,
    # never absorbed as a plausible box.
    merges = [CellMerge(result="$ 1,637", row=1, col=1, sources=["$", "1,637"], source_cells=["B3", "C4"])]
    out = resolve_merges(merges, GRID)
    assert out[0].grounded_by == "partial"
    assert out[0].source_boxes == [(0.40, 0.20, 0.44, 0.22), None]


def test_unparseable_out_of_bounds_and_blank_addresses_resolve_to_none():
    merges = [
        CellMerge(result="$ 1,637", row=1, col=1, sources=["$"], source_cells=["Z99"]),
        CellMerge(result="$ 1,637", row=1, col=1, sources=["$"], source_cells=["??"]),
        CellMerge(result="$ 1,637", row=1, col=1, sources=["$"], source_cells=["C1"]),  # blank cell
        CellMerge(result="$ 1,637", row=1, col=1, sources=["$", "1,637"]),  # none reported
    ]
    out = resolve_merges(merges, GRID)
    assert [m.grounded_by for m in out] == ["none", "none", "none", "none"]
    assert all(m.box is None for m in out)


def test_cell_merge_drops_blank_pieces_at_the_schema():
    m = CellMerge(
        result="Top-hole Rigs",
        row=9,
        col=0,
        sources=["Top-hole Rigs", "", "  "],
        source_cells=["A9", " "],
    )
    assert m.sources == ["Top-hole Rigs"]
    assert m.source_cells == ["A9"]


def test_ungrounded_merges_are_traced_with_addresses():
    # A debugging trace, not a warning: the affected cell is classified and
    # flagged through the flags record, so the log line asks for no action.
    from loguru import logger

    merges = [
        CellMerge(result="$ 1,637", row=1, col=1, sources=["$", "1,637"], source_cells=["B3", "C4"]),
        CellMerge(result="$ 1,592", row=2, col=1, sources=["$", "1,592"], source_cells=["B4", "C4"]),
    ]
    out = resolve_merges(merges, GRID)
    captured: list[str] = []
    sink_id = logger.add(captured.append, level="DEBUG")
    try:
        log_ungrounded(out, page=7)
    finally:
        logger.remove(sink_id)
    # The misread merge is traced naming the refused address; the clean one is quiet.
    assert len(captured) == 1
    assert "DEBUG" in captured[0]
    assert "page 7" in captured[0] and "'C4'" in captured[0] and "partial" in captured[0]


# --- resolve_corrected_grid: the complete cell-level view ---------------------


def test_corrected_grid_aligns_moved_values_and_merge_addresses():
    # The agent flattened the two header rows into one and kept the data rows.
    md = (
        "| Item | Three Months Ended December 31, 2022 |\n| --- | --- |\n"
        "| Revenue | $ 1,637 |\n| Cost | $ 1,592 |"
    )
    merges = resolve_merges(
        [
            CellMerge(
                result="Three Months Ended December 31, 2022",
                row=0,
                col=1,
                sources=["Three Months Ended", "December 31, 2022"],
                source_cells=["B1", "B2"],
            ),
            CellMerge(result="$ 1,637", row=1, col=1, sources=["$", "1,637"], source_cells=["B3", "C3"]),
            CellMerge(result="$ 1,592", row=2, col=1, sources=["$", "1,592"], source_cells=["B4", "C4"]),
        ],
        GRID,
    )
    grid = resolve_corrected_grid(md, merges, GRID)
    assert grid[0][1].box == (0.40, 0.10, 0.70, 0.16)  # merge union
    assert grid[1][1].box == (0.40, 0.20, 0.60, 0.22)
    assert grid[2][1].box == (0.40, 0.30, 0.60, 0.32)
    # Row labels aligned from the grid itself.
    assert grid[0][0].box == (0.10, 0.10, 0.20, 0.12)
    assert grid[1][0].box == (0.10, 0.20, 0.20, 0.22)
    assert grid[2][0].box == (0.10, 0.30, 0.20, 0.32)


def test_corrected_grid_maps_identical_twin_rows_in_order():
    # Two rows identical except their labels, values identical everywhere:
    # order-preserving alignment maps each to its own row, never crossing.
    grid = _cells(
        [
            [("Basic", (0.1, 0.2, 0.2, 0.22)), ("5.98", (0.5, 0.2, 0.6, 0.22))],
            [("Diluted", (0.1, 0.3, 0.2, 0.32)), ("5.98", (0.5, 0.3, 0.6, 0.32))],
        ]
    )
    md = "| Basic | 5.98 |\n| --- | --- |\n| Diluted | 5.98 |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][1].box == (0.5, 0.2, 0.6, 0.22)
    assert out[1][1].box == (0.5, 0.3, 0.6, 0.32)


def test_corrected_grid_twins_within_a_row_map_left_to_right():
    grid = _cells(
        [
            [
                ("Letters of credit", (0.1, 0.2, 0.3, 0.22)),
                ("47.8", (0.4, 0.2, 0.5, 0.22)),
                ("47.8", (0.6, 0.2, 0.7, 0.22)),
            ]
        ]
    )
    md = "| Letters of credit | 47.8 | 47.8 |\n| --- | --- | --- |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][1].box == (0.4, 0.2, 0.5, 0.22)
    assert out[0][2].box == (0.6, 0.2, 0.7, 0.22)


def test_corrected_grid_survives_dropped_columns_and_rows():
    # Camelot emitted a spurious empty column and a header-fragment row the
    # correction dropped; alignment still finds every kept value's box.
    grid = _cells(
        [
            [("junk", None), ("", None), ("", None)],
            [("Region", (0.1, 0.1, 0.2, 0.12)), ("", None), ("2025", (0.5, 0.1, 0.6, 0.12))],
            [("US", (0.1, 0.2, 0.2, 0.22)), ("", None), ("1,637", (0.5, 0.2, 0.6, 0.22))],
        ]
    )
    md = "| Region | 2025 |\n| --- | --- |\n| US | 1,637 |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][0].box == (0.1, 0.1, 0.2, 0.12)
    assert out[0][1].box == (0.5, 0.1, 0.6, 0.12)
    assert out[1][0].box == (0.1, 0.2, 0.2, 0.22)
    assert out[1][1].box == (0.5, 0.2, 0.6, 0.22)


def test_corrected_grid_recovered_text_is_an_honest_gap():
    # A row the correction recovered from elsewhere matches no grid cell: it
    # keeps box=None and the debug trace names it.
    from loguru import logger

    grid = _cells([[("US", (0.1, 0.2, 0.2, 0.22)), ("1,637", (0.5, 0.2, 0.6, 0.22))]])
    md = "| US | 1,637 |\n| --- | --- |\n| Recovered | 9,999 |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][0].box is not None and out[0][1].box is not None
    assert out[1][0].box is None and out[1][1].box is None

    captured: list[str] = []
    sink_id = logger.add(captured.append, level="DEBUG")
    try:
        log_ungrounded_cells(out, page=4)
    finally:
        logger.remove(sink_id)
    assert len(captured) == 1
    assert "DEBUG" in captured[0]
    assert "'Recovered'" in captured[0] and "'9,999'" in captured[0]


def test_corrected_grid_empty_markdown_returns_empty():
    assert resolve_corrected_grid("", [], GRID) == []


def test_resolved_merge_serializes_with_addresses():
    merges = [CellMerge(result="$ 1,637", row=1, col=1, sources=["$", "1,637"], source_cells=["B3", "C3"])]
    out = resolve_merges(merges, GRID)
    dumped = out[0].model_dump()
    assert dumped["source_cells"] == ["B3", "C3"]
    assert dumped["grounded_by"] == "cell_address"
    assert MergedCellBox.model_validate(dumped) == out[0]


def test_corrected_grid_matches_unreported_cell_joins_by_concatenation():
    # The correction rejoined "$" with its amount and glued a split label, but
    # reported no merge: a run of consecutive grid cells whose concatenation
    # equals the corrected cell matches exactly, and its union is the box.
    grid = _cells(
        [
            [
                ("Class A common stock,", (0.10, 0.2, 0.30, 0.22)),
                ("par value $0.0001", (0.32, 0.2, 0.48, 0.22)),
                ("$", (0.60, 0.2, 0.64, 0.22)),
                ("", None),
                ("168,663", (0.70, 0.2, 0.80, 0.22)),
            ]
        ]
    )
    md = "| Class A common stock, par value $0.0001 | $ 168,663 |\n| --- | --- |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][0].box == (0.10, 0.2, 0.48, 0.22)  # union of the two label cells
    assert out[0][1].box == (0.60, 0.2, 0.80, 0.22)  # union across the blank cell


def test_corrected_grid_tiles_fragments_of_a_glued_value_cell():
    # Camelot packed four printed value columns into ONE grid cell; the
    # corrected cells are fragments of it, so whole-cell matching can never
    # box them. Span tiling gives each fragment the glued cell's box — its
    # true measured home, at the granularity Camelot measured.
    glued = (0.4, 0.2, 0.9, 0.24)
    grid = _cells(
        [
            [("Net income", (0.1, 0.2, 0.3, 0.24)), ("46,706\n 34,238\n 438,841\n 428,302", glued)],
        ]
    )
    md = "| Net income | 46,706 | 34,238 | 438,841 | 428,302 |\n| --- | --- | --- | --- | --- |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][0].box == (0.1, 0.2, 0.3, 0.24)
    assert [out[0][c].box for c in (1, 2, 3, 4)] == [glued] * 4


def test_corrected_grid_tiles_a_label_wrapped_across_grid_rows():
    # Camelot wrapped one printed label across two grid rows and glued the
    # values; the corrected row is one printed row. The tiling window spans
    # the unpaired wrapped-label row, so the label's box is the union of its
    # two lines and each value fragment gets the glued cell's box.
    top = (0.10, 0.20, 0.60, 0.22)
    bottom = (0.10, 0.24, 0.25, 0.26)
    glued = (0.60, 0.24, 0.90, 0.26)
    grid = _cells(
        [
            [("Deduct gain from real estate dispositions of unconsolidated", top), ("", None)],
            [("joint ventures", bottom), ("(135)\n (93)\n (14,880)", glued)],
        ]
    )
    md = (
        "| Deduct gain from real estate dispositions of unconsolidated joint ventures "
        "| (135) | (93) | (14,880) |\n| --- | --- | --- | --- |"
    )
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][0].box == (0.10, 0.20, 0.60, 0.26)  # union of both label lines
    assert [out[0][c].box for c in (1, 2, 3)] == [glued] * 3


def test_corrected_grid_fabricated_placeholder_never_acquires_a_box():
    # A token absent from the grid (an em-dash the agent invented for a
    # printed-blank cell) has no home: it stays box=None while the real
    # fragments around it still tile.
    glued = (0.4, 0.2, 0.9, 0.24)
    grid = _cells([[("Deduct gain", (0.1, 0.2, 0.3, 0.24)), ("(135)\n (93)", glued)]])
    md = "| Deduct gain | — | (135) | (93) |\n| --- | --- | --- | --- |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][1].box is None
    assert out[0][2].box == glued and out[0][3].box == glued


def test_span_tiling_keeps_printed_order_for_repeated_fragments():
    # The same fragment appears in two glued cells; the cursor only moves
    # forward, so each occurrence resolves to its own cell, never crossing.
    box_a = (0.4, 0.2, 0.6, 0.24)
    box_b = (0.7, 0.2, 0.9, 0.24)
    grid = _cells([[("Item", (0.1, 0.2, 0.3, 0.24)), ("12\n 34", box_a), ("12\n 78", box_b)]])
    md = "| Item | 12 | 34 | 12 | 78 |\n| --- | --- | --- | --- | --- |"
    out = resolve_corrected_grid(md, [], grid)
    assert [out[0][c].box for c in (1, 2, 3, 4)] == [box_a, box_a, box_b, box_b]


def test_span_tiling_never_matches_a_single_char():
    # A one-char cell would anchor inside any number containing that digit;
    # it is never span-matched and stays an honest gap.
    grid = _cells([[("Total", (0.1, 0.2, 0.3, 0.24)), ("15,637", (0.4, 0.2, 0.6, 0.24))]])
    md = "| Total | 5 |\n| --- | --- |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][1].box is None


def test_header_stack_closure_boxes_flattened_column_stacks():
    # 'Additional' / 'Paid-In' / 'Capital' stack in one grid column of the
    # header region; the flattened header cell takes the union of the run.
    grid = _cells(
        [
            [("", None), ("Additional", (0.5, 0.10, 0.6, 0.12))],
            [("", None), ("Paid-In", (0.5, 0.13, 0.6, 0.15))],
            [("", None), ("Capital", (0.5, 0.16, 0.6, 0.18))],
            [("Balance", (0.1, 0.2, 0.2, 0.22)), ("$ 3,142,993", (0.5, 0.2, 0.6, 0.22))],
        ]
    )
    md = "| Item | Additional Paid-In Capital |\n| --- | --- |\n| Balance | $ 3,142,993 |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][1].box == (0.5, 0.10, 0.6, 0.18)


def test_header_stack_closure_span_label_serves_every_column_it_spans():
    # 'Common Stock' printed once over Shares and Dollars: both corrected
    # header copies take the one printed label's box.
    label = (0.4, 0.10, 0.7, 0.12)
    grid = _cells(
        [
            [("", None), ("Common Stock", label), ("", None)],
            [("", None), ("Shares", (0.4, 0.13, 0.5, 0.15)), ("Dollars", (0.6, 0.13, 0.7, 0.15))],
            [
                ("Balance", (0.1, 0.2, 0.2, 0.22)),
                ("670,378,701", (0.4, 0.2, 0.5, 0.22)),
                ("$ 6,704", (0.6, 0.2, 0.7, 0.22)),
            ],
        ]
    )
    md = (
        "| Item | Common Stock | Common Stock |\n| --- | --- | --- |\n"
        "| Item | Shares | Dollars |\n| Balance | 670,378,701 | $ 6,704 |"
    )
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][1].box == label and out[0][2].box == label


def test_header_stack_closure_pairs_repeated_bands_in_reading_order():
    # Two identical '% of' stacks in different columns: corrected duplicates
    # pair with candidate stacks left to right, never crossing.
    a1, a2 = (0.4, 0.10, 0.5, 0.12), (0.4, 0.13, 0.5, 0.15)
    b1, b2 = (0.7, 0.10, 0.8, 0.12), (0.7, 0.13, 0.8, 0.15)
    grid = _cells(
        [
            [("", None), ("% of", a1), ("% of", b1)],
            [("", None), ("Revenue", a2), ("Revenue", b2)],
            [
                ("Gross", (0.1, 0.2, 0.2, 0.22)),
                ("12.1%", (0.4, 0.2, 0.5, 0.22)),
                ("7.7%", (0.7, 0.2, 0.8, 0.22)),
            ],
        ]
    )
    md = "| Item | % of Revenue | % of Revenue |\n| --- | --- | --- |\n| Gross | 12.1% | 7.7% |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][1].box == (0.4, 0.10, 0.5, 0.15)
    assert out[0][2].box == (0.7, 0.10, 0.8, 0.15)


def test_header_merge_citing_a_data_region_source_is_refused():
    # The equity-statement case: 'Treasury' is a header label AND a data-row
    # word. A header merge citing the data-region address must be refused even
    # though the text anchors — then the stack closure grounds it correctly.
    header_box = (0.6, 0.10, 0.7, 0.12)
    grid = _cells(
        [
            [("", None), ("Treasury", header_box), ("Total", (0.8, 0.10, 0.9, 0.12))],
            [
                ("Balance", (0.1, 0.2, 0.2, 0.22)),
                ("$ 6,704", (0.6, 0.2, 0.7, 0.22)),
                ("$ 1,614", (0.8, 0.2, 0.9, 0.22)),
            ],
            [
                ("Treasury stock", (0.1, 0.3, 0.3, 0.32)),
                ("(1,791)", (0.6, 0.3, 0.7, 0.32)),
                ("(1,342)", (0.8, 0.3, 0.9, 0.32)),
            ],
        ]
    )
    md = (
        "| Item | Treasury | Total |\n| --- | --- | --- |\n"
        "| Balance | $ 6,704 | $ 1,614 |\n| Treasury stock | (1,791) | (1,342) |"
    )
    merges = [CellMerge(result="Treasury", row=0, col=1, sources=["Treasury"], source_cells=["A3"])]
    merged = resolve_merges(merges, grid, md)
    assert merged[0].grounded_by == "none"  # data-region address refused
    out = resolve_corrected_grid(md, merged, grid)
    assert out[0][1].box == header_box  # closed from the header region instead


def test_unpaired_row_closure_boxes_a_cross_section_rebuild():
    # Two stacked printed sections rebuilt side by side: each corrected row's
    # left cells live in a grid row the alignment never consumed. The pool
    # pass adopts that row, all-or-nothing, in order.
    us = (0.4, 0.2, 0.9, 0.22)
    grid = _cells(
        [
            [("Interest cost", (0.1, 0.2, 0.3, 0.22)), ("1,235\n 1,177", us)],
            [("", None), ("", None)],
            [("Interest cost", (0.1, 0.5, 0.3, 0.52)), ("95\n 91", (0.4, 0.5, 0.9, 0.52))],
        ]
    )
    md = "| Item | 2022 | 2021 | 2022 | 2021 |\n| --- | --- | --- | --- | --- |\n| Interest cost | 1,235 | 1,177 | 95 | 91 |"
    out = resolve_corrected_grid(md, [], grid)
    # right cells aligned to the second section; left cells closed from the
    # unpaired first-section row
    assert out[1][1].box == us and out[1][2].box == us
    assert out[1][3].box == (0.4, 0.5, 0.9, 0.52) and out[1][4].box == (0.4, 0.5, 0.9, 0.52)


def test_unpaired_row_closure_is_all_or_nothing():
    # A pool row that accounts for only part of the corrected row's remainder
    # closes nothing — partial adoption would risk a wrong home.
    grid = _cells(
        [
            [("Interest cost", (0.1, 0.5, 0.3, 0.52)), ("95\n 91", (0.4, 0.5, 0.9, 0.52))],
            [("Other", (0.1, 0.6, 0.3, 0.62)), ("1,235", (0.4, 0.6, 0.9, 0.62))],
        ]
    )
    md = "| Interest cost | 95 | 91 |\n| --- | --- | --- |\n| Other | 1,235 | 9,999 |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[1][1].box is not None  # aligned normally
    assert out[1][2].box is None  # '9,999' has no home; row 0 pool must not partially adopt


def test_mispositioned_merge_report_never_plants_its_box():
    # The merge's sources anchor and its box resolves, but the agent
    # miscounted its output position: (row 1, col 1) holds '1,637', not the
    # merge's result. Planting there would put a correct box on the wrong
    # cell — the seed is dropped, the true cells still box from the grid.
    md = "| Item | 2022 |\n| --- | --- |\n| Revenue | 1,637 |\n| Total | $ 9,999 |"
    grid = _cells(
        [
            [("Item", (0.1, 0.1, 0.2, 0.12)), ("2022", (0.4, 0.1, 0.5, 0.12))],
            [("Revenue", (0.1, 0.2, 0.2, 0.22)), ("1,637", (0.4, 0.2, 0.5, 0.22))],
            [("Total", (0.1, 0.3, 0.2, 0.32)), ("$\n9,999", (0.4, 0.3, 0.5, 0.32))],
        ]
    )
    merges = [CellMerge(result="$ 9,999", row=1, col=1, sources=["$", "9,999"], source_cells=["B3", "B3"])]
    merged = resolve_merges(merges, grid, md)
    assert merged[0].box is not None  # the merge itself resolves
    out = resolve_corrected_grid(md, merged, grid)
    assert out[1][1].box == (0.4, 0.2, 0.5, 0.22)  # '1,637' keeps ITS box
    assert out[2][1].box == (0.4, 0.3, 0.5, 0.32)  # '$ 9,999' boxes from the grid


def test_partially_grounded_merge_never_plants_its_shrunken_union():
    # One source refused: the union covers only the '$' symbol, a box that
    # misses the digits of the value it claims. It must not become the cell's
    # box — the grid matching boxes the full value instead.
    grid = _cells(
        [
            [("Item", (0.1, 0.1, 0.2, 0.12)), ("2022", (0.4, 0.1, 0.5, 0.12))],
            [
                ("Revenue", (0.1, 0.2, 0.2, 0.22)),
                ("$", (0.4, 0.2, 0.44, 0.22)),
                ("7,818", (0.5, 0.2, 0.6, 0.22)),
            ],
        ]
    )
    md = "| Item | 2022 |\n| --- | --- |\n| Revenue | $ 7,818 |"
    # B2 anchors ('$' is in the result); C9 is out of bounds and refused.
    merges = [CellMerge(result="$ 7,818", row=1, col=1, sources=["$", "7,818"], source_cells=["B2", "C9"])]
    merged = resolve_merges(merges, grid, md)
    assert merged[0].grounded_by == "partial"
    assert merged[0].box == (0.4, 0.2, 0.44, 0.22)  # the shrunken union
    out = resolve_corrected_grid(md, merged, grid)
    assert out[1][1].box == (0.4, 0.2, 0.6, 0.22)  # grid run boxes '$'+'7,818'


def test_single_source_merge_grounds_a_replicated_band_label():
    # A band label printed once but serving three output cells: the agent
    # reports each output cell separately, all citing the same single source.
    band = (0.3, 0.14, 0.8, 0.16)
    grid = _cells(
        [
            [("Metric", (0.1, 0.1, 0.2, 0.12)), ("GAAP Results", band), ("", None), ("", None)],
            [
                ("Revenue", (0.1, 0.2, 0.2, 0.22)),
                ("1,637", (0.3, 0.2, 0.4, 0.22)),
                ("(12)", (0.5, 0.2, 0.6, 0.22)),
                ("1,625", (0.7, 0.2, 0.8, 0.22)),
            ],
        ]
    )
    merges = [
        CellMerge(result="GAAP Results", row=1, col=c, sources=["GAAP Results"], source_cells=["B1"])
        for c in (1, 2, 3)
    ]
    out = resolve_merges(merges, grid)
    assert [m.grounded_by for m in out] == ["cell_address"] * 3
    assert all(m.box == band for m in out)


def test_marker_extended_label_closes_by_stripping_a_catalogued_marker():
    # The page prints a superscript marker the grid and text layer lack; the
    # corrected label carries it inline. Stripping is allowed only for markers
    # the correction catalogued in footnote_refs, and the stripped text must
    # still match the grid exactly.
    label = (0.1, 0.2, 0.3, 0.22)
    starred = (0.1, 0.3, 0.3, 0.32)
    grid = _cells(
        [
            [("Item", (0.1, 0.1, 0.2, 0.12)), ("2022", (0.4, 0.1, 0.5, 0.12))],
            [("Transaction and integration costs", label), ("1,637", (0.4, 0.2, 0.5, 0.22))],
            [("Share-based compensation", starred), ("1,592", (0.4, 0.3, 0.5, 0.32))],
        ]
    )
    md = (
        "| Item | 2022 |\n| --- | --- |\n"
        "| Transaction and integration costs(1) | 1,637 |\n"
        "| Share-based compensation* | 1,592 |"
    )
    out = resolve_corrected_grid(md, [], grid, footnote_refs=["1", "*"])
    assert out[1][0].box == label
    assert out[2][0].box == starred


def test_marker_strip_is_grounded_in_the_catalogued_refs_and_the_grid():
    grid = _cells(
        [
            [("Item", (0.1, 0.1, 0.2, 0.12)), ("2022", (0.4, 0.1, 0.5, 0.12))],
            [("Transaction and integration costs", (0.1, 0.2, 0.3, 0.22)), ("1,637", (0.4, 0.2, 0.5, 0.22))],
        ]
    )
    md = "| Item | 2022 |\n| --- | --- |\n| Transaction and integration costs(1) | 1,637 |"
    # No catalogued refs: nothing is ever stripped.
    out = resolve_corrected_grid(md, [], grid)
    assert out[1][0].box is None
    # Catalogued marker, but the stripped text matches no grid cell: no box.
    md2 = "| Item | 2022 |\n| --- | --- |\n| Integration expenses(1) | 1,637 |"
    out2 = resolve_corrected_grid(md2, [], grid, footnote_refs=["1"])
    assert out2[1][0].box is None


def test_parenthesized_value_is_never_consumed_as_a_bare_marker():
    # '(84)' is a negative value, not label + marker: stripping must leave at
    # least two chars of label, so the whole cell can never read as a marker.
    grid = _cells(
        [
            [("Item", (0.1, 0.1, 0.2, 0.12)), ("2022", (0.4, 0.1, 0.5, 0.12))],
            [("Other", (0.1, 0.2, 0.3, 0.22)), ("1,637", (0.4, 0.2, 0.5, 0.22))],
        ]
    )
    md = "| Item | 2022 |\n| --- | --- |\n| Other | 1,637 |\n| (84) | 1,637 |"
    out = resolve_corrected_grid(md, [], grid, footnote_refs=["84"])
    assert out[2][0].box is None


def test_classifier_names_every_kind_of_gap():
    # One table exercising every status: a reconciled value, a band header
    # printed but unlocated, a wrapped label printed but unlocated, a bare
    # symbol, a 'Total' added on an unlabeled totals row, and an 'Item' added
    # over a column printed without a header.
    grid = _cells(
        [
            [("", None), ("2022", (0.4, 0.1, 0.5, 0.12))],
            [("Deferred income", (0.1, 0.2, 0.2, 0.22)), ("1,637 2,291", (0.4, 0.2, 0.6, 0.22))],
            [("taxes payable", (0.1, 0.25, 0.2, 0.27)), ("", None)],
        ]
    )
    md = (
        "| Item | 2022 | 2021 |\n| --- | --- | --- |\n"
        "| Three Months Ended March 31, 2023 | | |\n"
        "| Deferred income taxes payable | 1,637 | 2,291 |\n"
        "| Total | $ | 9,999 |"
    )
    out = resolve_corrected_grid(md, [], grid, region_text="Three Months Ended March 31, 2023")
    assert out[0][1].status == "reconciled"  # '2022' aligns exactly
    # added header label: not printed anywhere, authorized, header row
    assert out[0][0].status == "header_label_added"
    # band header: printed in the region text layer, not located, non-value row
    assert out[1][0].status == "header_printed_unlocated"
    # wrapped label: fragments in the grid, assembled cell unlocated, value row
    assert out[2][0].status == "label_printed_unlocated"
    assert out[3][1].status == "single_character"  # bare '$'
    assert out[3][0].status == "total_label_added"  # 'Total' on a totals row
    assert out[3][2].status == "unverified"  # '9,999' is in no source and no convention
    # empty cells carry no status
    assert out[1][1].status is None


def test_status_codes_come_from_the_registry():
    # The registry is the single source of truth for a front-end lookup:
    # GroundedCell rejects any code not registered there.
    import pytest

    from quber.core.extractors.base import CELL_STATUS_REFERENCE

    assert {s.code for s in CELL_STATUS_REFERENCE} == {
        "reconciled",
        "header_printed_unlocated",
        "label_printed_unlocated",
        "single_character",
        "total_label_added",
        "header_label_added",
        "unverified",
        "defect",
        "footnote_unresolved",
        "footnote_unreferenced",
        "footnote_marker_unplaced",
        "header_text_dropped",
        "value_misread",
        "value_unreconciled",
    }
    assert [s.code for s in CELL_STATUS_REFERENCE if s.inspect] == [
        "total_label_added",
        "header_label_added",
        "unverified",
        "defect",
        "footnote_unresolved",
        "footnote_marker_unplaced",
        "header_text_dropped",
        "value_misread",
        "value_unreconciled",
    ]
    # every catalog case is claimed by exactly one status
    by_case = [s.cases for s in CELL_STATUS_REFERENCE if s.cases]
    assert by_case == ["1", "2 and 3", "7", "5", "6", "4"]
    with pytest.raises(ValueError):
        GroundedCell(text="x", status="mystery")


def test_classifier_never_matches_a_token_across_two_cells_seam():
    # 'NetSales' must not classify as printed just because one cell ends with
    # 'Net' and the next begins with 'Sales' — source cells join on a sentinel.
    grid = _cells(
        [
            [("Revenue Net", (0.1, 0.1, 0.2, 0.12)), ("Sales tax", (0.4, 0.1, 0.5, 0.12))],
            [("Item", (0.1, 0.2, 0.2, 0.22)), ("1,637 2,291", (0.4, 0.2, 0.5, 0.22))],
        ]
    )
    md = "| NetSales | 2022 |\n| --- | --- |\n| Item | 1,637 |"
    out = resolve_corrected_grid(md, [], grid)
    assert out[0][0].status == "unverified"


def test_dropped_header_text_is_returned_for_flagging():
    # 'Coverage Data' is printed in the grid's header region but the corrected
    # table carries it nowhere — the fragment is returned so the caller can
    # record it as a table-level review flag. Absorbed into the table's
    # metadata (units/title), it is not a drop and nothing is returned.
    from quber.core.extractors.set_of_mark.merge_grounding import find_dropped_header_text

    grid = _cells(
        [
            [("", None), ("Coverage Data", (0.4, 0.1, 0.8, 0.12)), ("", None)],
            [
                ("Item", (0.1, 0.14, 0.2, 0.16)),
                ("Before Fees", (0.4, 0.14, 0.6, 0.16)),
                ("After Fees", (0.65, 0.14, 0.8, 0.16)),
            ],
            [
                ("Twelve months", (0.1, 0.2, 0.2, 0.22)),
                ("1.37x", (0.4, 0.2, 0.6, 0.22)),
                ("1.04x", (0.65, 0.2, 0.8, 0.22)),
            ],
        ]
    )
    md = "| Item | Before Fees | After Fees |\n| --- | --- | --- |\n| Twelve months | 1.37x | 1.04x |"
    out = resolve_corrected_grid(md, [], grid)
    assert find_dropped_header_text(out, grid, "", 9) == ["Coverage Data"]
    assert find_dropped_header_text(out, grid, "Coverage Data (in millions)", 9) == []


def test_cell_flags_collect_inspect_statuses_with_document_identity():
    from quber.core.extractors.base import ExtractedTable, cell_flags

    t = ExtractedTable(
        table_id="doc-p9-t1",
        title="Occupancy",
        markdown="| a |",
        page=9,
        source="doc.pdf",
        corrected_grid=[
            [
                GroundedCell(text="Period", status="defect", note="page shows other headers"),
                GroundedCell(text="Total", status="total_label_added", note="blank totals row"),
                GroundedCell(text="76.2 %", box=(0.1, 0.1, 0.2, 0.2), status="reconciled"),
                GroundedCell(text="Coverage", status="header_printed_unlocated", note="printed band"),
            ]
        ],
        dropped_text=["Attachment 1"],
    )
    flags = cell_flags([t])
    # pass-tier statuses (reconciled, confirmed printed-unlocated) are not
    # flags; dropped printed text is a table-level flag with no cell to point at.
    assert [(f.status, f.text) for f in flags] == [
        ("defect", "Period"),
        ("total_label_added", "Total"),
        ("header_text_dropped", "Attachment 1"),
    ]
    assert (flags[2].row, flags[2].col, flags[2].page) == (None, None, 9)
    f = flags[0]
    assert (f.source, f.page, f.table_id, f.title, f.row, f.col) == (
        "doc.pdf",
        9,
        "doc-p9-t1",
        "Occupancy",
        0,
        0,
    )
    assert f.note == "page shows other headers"


def test_camelot_glued_cell_validates_by_piece_containment():
    # Camelot packed two visual rows into one cell ('% of\nApril 30,'): the
    # cell's text is not in the result, but the reported piece is in the cell,
    # so the claim is text-anchored and the cell's box is its home.
    grid = _cells(
        [
            [("% of April 30,", (0.4, 0.1, 0.5, 0.14))],
            [("Total 2022", (0.4, 0.16, 0.5, 0.20))],
        ]
    )
    merges = [
        CellMerge(
            result="Three Months Ended April 30, 2022",
            row=0,
            col=1,
            sources=["April 30,", "2022"],
            source_cells=["A1", "A2"],
        )
    ]
    out = resolve_merges(merges, grid)
    assert out[0].grounded_by == "cell_address"
    assert out[0].source_boxes == [(0.4, 0.1, 0.5, 0.14), (0.4, 0.16, 0.5, 0.20)]


# --- locate_markers ---------------------------------------------------------

MARKER_MARKDOWN = "\n".join(
    [
        "| | Pension | Health Care |",
        "|---|---|---|",
        "| Service cost | 280 | 60 |",
        "| Benefit obligation(3) | 21,400 | 4,100 |",
    ]
)


def test_locate_markers_quote_places_at_the_quoted_cell():
    marks = [FootnoteMark(marker="(3)", cell_text="Benefit obligation(3)")]
    out = locate_markers(marks, ["(3)"], MARKER_MARKDOWN)
    assert [(m.marker, m.row, m.col) for m in out] == [("(3)", 2, 0)]


def test_locate_markers_marker_stripped_quote_still_places():
    # The text layer dropped the superscript: the corrected cell reads
    # 'Service cost' while the agent, reading the image, quotes
    # 'Service cost(2)'. Stripping the marker from both sides closes the gap.
    marks = [FootnoteMark(marker="(2)", cell_text="Service cost(2)")]
    out = locate_markers(marks, ["(2)"], MARKER_MARKDOWN)
    assert [(m.marker, m.row, m.col) for m in out] == [("(2)", 1, 0)]


def test_locate_markers_mid_stack_and_mid_label_quotes_place():
    # The quote IS the cell, so a marker mid-stack ('fees(e)(f)') or
    # mid-label ('...total(e) - corporate') needs no positional rules.
    md = "\n".join(
        [
            "| | 2022 |",
            "|---|---|",
            "| Net write-off rate — principal and fees(e)(f) | 1.3 % |",
            "| 90+ days past billing as a % of total(e) - corporate | 0.5 % |",
        ]
    )
    marks = [
        FootnoteMark(marker="(e)", cell_text="Net write-off rate — principal and fees(e)(f)"),
        FootnoteMark(marker="(e)", cell_text="90+ days past billing as a % of total(e) - corporate"),
        FootnoteMark(marker="(f)", cell_text="Net write-off rate — principal and fees(e)(f)"),
    ]
    out = locate_markers(marks, [], md)
    assert [(m.marker, m.row, m.col) for m in out] == [
        ("(e)", 1, 0),
        ("(e)", 2, 0),
        ("(f)", 1, 0),
    ]


def test_locate_markers_twin_labels_place_on_both():
    md = "\n".join(
        [
            "| | 2022 |",
            "|---|---|",
            "| Segment EBITDA(3) | 280 |",
            "| Segment EBITDA(3) | 12.1 |",
        ]
    )
    marks = [FootnoteMark(marker="(3)", cell_text="Segment EBITDA(3)")]
    out = locate_markers(marks, [], md)
    assert [(m.marker, m.row, m.col) for m in out] == [("(3)", 1, 0), ("(3)", 2, 0)]


def test_locate_markers_unmatched_quote_is_unplaced():
    marks = [FootnoteMark(marker="(2)", cell_text="A label the table does not contain")]
    out = locate_markers(marks, [], MARKER_MARKDOWN)
    assert [(m.marker, m.row, m.col) for m in out] == [("(2)", None, None)]


def test_locate_markers_catalogued_but_unreported_marker_is_kept():
    # A ref with no mark at all: one unplaced entry, never dropped.
    out = locate_markers([], ["(2)"], MARKER_MARKDOWN)
    assert [(m.marker, m.row, m.col) for m in out] == [("(2)", None, None)]


def test_locate_markers_whole_cell_symbol_quote_places():
    md = "\n".join(
        [
            "| | 2022 | Change |",
            "|---|---|---|",
            "| Provisions | 1,514 | # |",
        ]
    )
    marks = [FootnoteMark(marker="#", cell_text="#")]
    out = locate_markers(marks, ["#"], md)
    assert [(m.marker, m.row, m.col) for m in out] == [("#", 1, 2)]


def test_locate_markers_carries_the_kind_judgement():
    marks = [FootnoteMark(marker="Note 16", cell_text="Benefit obligation(3)", kind="section")]
    out = locate_markers(marks, [], MARKER_MARKDOWN)
    assert [(m.marker, m.kind, m.row is not None) for m in out] == [("Note 16", "section", True)]


def test_locate_markers_symbol_legend_places_from_its_own_definition():
    # The definition names the symbol and the table prints it in cells — no
    # agent catalogue needed to connect them. A non-symbol definition marker
    # is never searched this way.
    from quber.agents.llm_client import FootnoteDef

    md = "\n".join(
        [
            "| | Change 2022 vs. 2021 |",
            "|---|---|",
            "| Provisions for credit losses | # |",
            "| Earnings per common share | # % |",
            "| Expenses | 24 |",
        ]
    )
    out = locate_markers(
        [],
        [],
        md,
        [
            FootnoteDef(marker="#", text="Denotes a variance of 100 percent or more"),
            FootnoteDef(marker="2", text="Never cell-searched by its definition alone"),
        ],
    )
    assert [(m.marker, m.row, m.col) for m in out] == [("#", 1, 1), ("#", 2, 1)]


def test_locate_markers_whole_cell_marker_places_itself():
    # A cell whose entire content is a catalogued marker's parenthesized form
    # carries that marker — the table prints '(b)' where the rate would go.
    # An all-digits marker never places this way: a bare '(1)' cell is
    # indistinguishable from a negative value. The bare letter form never
    # matches either.
    md = "\n".join(
        [
            "| | Principal Only | 30+ Days |",
            "|---|---|---|",
            "| Consumer | 0.9 % | 1.0 % |",
            "| Corporate | (b) | (1) |",
            "| b | 0.4 % | (c) |",
        ]
    )
    out = locate_markers([], ["(b)", "(c)", "(1)"], md)
    assert [(m.marker, m.row, m.col) for m in out if m.row is not None] == [
        ("(b)", 2, 1),
        ("(c)", 3, 2),
    ]
    assert [m.marker for m in out if m.row is None] == ["(1)"]


def test_locate_markers_header_text_marker_places_at_table_scope():
    # A marker carried by the table's title or subtitle — a spanning band
    # absorbed as 'Accounts Classified as a TDR (c)' — qualifies the whole
    # table: placed at table scope, not flagged. A marker in neither a cell
    # nor the header text stays unplaced.
    md = "\n".join(
        [
            "| 2021 (Millions) | In Program (d) |",
            "|---|---|",
            "| Consumer | $ 708 |",
        ]
    )
    out = locate_markers(
        [],
        ["(c)", "(x)"],
        md,
        table_text="As of December 31, 2021 Accounts Classified as a TDR (c)",
    )
    by_marker = {m.marker: m for m in out}
    assert by_marker["(c)"].scope == "table" and by_marker["(c)"].row is None
    assert by_marker["(x)"].scope == "cell" and by_marker["(x)"].row is None
