"""Unit tests for the deterministic text-layer completeness check (QUE-230).

The end-to-end pipeline runs an LLM detector whose table boxes vary run to
run, so an e2e assertion on a single document is not a stable regression
guard. These tests instead drive ``completeness_verdict`` directly with the
exact geometries captured from real documents (page 612x792 pt), so the
truncation logic is verified independently of the detector.

Geometries are taken from:
- Visa Q1FY25 p4 "Processed Transactions": the "12 Months Ended / 240,083"
  summary row dropped below the captured rows -> must flag bottom edge.
- TMUS Q2'25 p24 Non-GAAP reconciliation: the "Net income margin" row's
  eight quarterly percentages dropped at the bottom edge (~3.5 pt out) ->
  must flag bottom edge.
- Visa Q1FY25 p1: two near-identical vertically stacked quarter tables; the
  neighbor's values sit ~63 pt from this table's edge -> must NOT flag
  (the edge-band guard; this is the case that previously false-flagged).
"""

from __future__ import annotations

from quber.agents.completeness import (
    MissingFigure,
    completeness_verdict,
    infer_columns,
    round_off,
    value_tokens,
)

PAGE_W = 612.0
PAGE_H = 792.0


def test_value_tokens_keeps_figures_and_drops_years_days_markers():
    toks = value_tokens("Asia 240,083 growth 12.3% in 2024 on day 31 ref (1) sub 1,234.50 share 5%")
    # Keeps thousands-grouped, decimals, percentages; drops bare year (2024),
    # day (31) and footnote marker ((1)).
    assert toks == ["240083", "12.3%", "1234.50", "5%"]


def test_bottom_edge_drop_visa_p4_is_flagged():
    # Camelot bbox (PDF points, bottom-left); span maps to TL y 528.7..650.4.
    extracted_bboxes = [(48.3, 141.6, 283.0, 263.3)]
    region_bbox = (40.0 / PAGE_W, 515.0 / PAGE_H, 290.0 / PAGE_W, 690.0 / PAGE_H)
    markdown = (
        "| Period | (millions) | Growth |\n| Dec 31, 2024 | 63,797 | 11% |\n| Dec 31, 2023 | 57,472 | 9% |\n"
    )
    words = [
        (160.0, 530.0, 195.0, 540.0, "63,797"),  # present in markdown -> ignored
        (160.0, 673.0, 185.0, 683.0, "240,083"),  # dropped summary row, ~28 pt below
    ]
    v = completeness_verdict(PAGE_W, PAGE_H, words, region_bbox, extracted_bboxes, markdown)
    assert v.complete is False
    assert "below the last extracted row" in v.gap
    assert "240083" in v.gap


def test_bottom_edge_drop_tmus_p24_is_flagged():
    # Reconciliation table; "Net income margin" row's percentages at y~392.9,
    # ~3.5 pt below the captured span bottom (TL y 389.36).
    extracted_bboxes = [(24.37, 402.64, 580.74, 606.22)]
    region_bbox = (20.0 / PAGE_W, 180.0 / PAGE_H, 585.0 / PAGE_W, 410.0 / PAGE_H)
    markdown = "| Net income | 2,374 | 2,925 |\n| Net income margin | | |\n"
    words = [
        (230.0, 388.0, 258.0, 398.0, "14.7"),
        (275.0, 388.0, 303.0, 398.0, "17.8"),
    ]
    v = completeness_verdict(PAGE_W, PAGE_H, words, region_bbox, extracted_bboxes, markdown)
    assert v.complete is False
    assert "below the last extracted row" in v.gap


def test_stacked_neighbor_values_are_not_flagged_visa_p1():
    # December table captured (TL y 263.99..478.78); the September neighbor's
    # first value sits at y 541.7 -> 63 pt below this table's bottom edge,
    # beyond the 40 pt band, so it must not read as a truncation.
    extracted_bboxes = [(32.15, 313.2, 520.6, 528.0)]
    region_bbox = (30.0 / PAGE_W, 250.0 / PAGE_H, 525.0 / PAGE_W, 620.0 / PAGE_H)
    markdown = "| Asia Pacific | $603 | (0.5%) | 0.8% |\n| Canada | 118 | 6.9% | 10.5% |\n"
    words = [
        (165.0, 537.0, 185.0, 547.0, "0.4%"),  # September neighbor, 63 pt away
        (340.0, 537.0, 372.0, 547.0, "11,935"),  # September neighbor, far in x too
    ]
    v = completeness_verdict(PAGE_W, PAGE_H, words, region_bbox, extracted_bboxes, markdown)
    assert v.complete is True


def test_present_values_within_span_are_not_flagged():
    extracted_bboxes = [(48.3, 141.6, 283.0, 263.3)]
    region_bbox = (40.0 / PAGE_W, 515.0 / PAGE_H, 290.0 / PAGE_W, 690.0 / PAGE_H)
    markdown = "| Dec 31, 2024 | 63,797 | 11% |\n"
    words = [(160.0, 530.0, 195.0, 540.0, "63,797")]  # present and inside the span
    v = completeness_verdict(PAGE_W, PAGE_H, words, region_bbox, extracted_bboxes, markdown)
    assert v.complete is True


# --- text-layer fill (round_off) ---------------------------------------------
# A small two-column table captured at TL y 100..150 with a third row dropped
# just below at y 160 (10 pt past the edge). Columns sit at x~150 and x~250.

CAPTURED_WORDS = [
    (60.0, 105.0, 95.0, 115.0, "Alpha"),
    (140.0, 105.0, 165.0, 115.0, "10.0"),
    (240.0, 105.0, 265.0, 115.0, "20.0"),
    (60.0, 125.0, 92.0, 135.0, "Beta"),
    (140.0, 125.0, 165.0, 135.0, "11.0"),
    (240.0, 125.0, 265.0, 135.0, "21.0"),
]
DROPPED_BOTTOM = [
    (58.0, 155.0, 100.0, 165.0, "Gamma"),
    (140.0, 155.0, 165.0, 165.0, "12.0"),
    (240.0, 155.0, 265.0, 165.0, "22.0"),
]
CAPTURED_SPAN = (50.0, 100.0, 300.0, 150.0)  # TL pts
CAPTURED_MD = "|  | Col1 | Col2 |\n| Alpha | 10.0 | 20.0 |\n| Beta | 11.0 | 21.0 |\n"


def test_infer_columns_finds_two_numeric_columns():
    cols = infer_columns(CAPTURED_WORDS, CAPTURED_SPAN)
    assert len(cols) == 2
    assert abs(cols[0] - 152.5) < 5 and abs(cols[1] - 252.5) < 5


def test_round_off_appends_bottom_row_with_values_in_columns():
    words = CAPTURED_WORDS + DROPPED_BOTTOM
    figs = [
        MissingFigure(value="12.0", x=152.5, y=160.0, edge="bottom"),
        MissingFigure(value="22.0", x=252.5, y=160.0, edge="bottom"),
    ]
    new_md, placed = round_off(CAPTURED_MD, words, CAPTURED_SPAN, figs)
    assert "| Gamma | 12.0 | 22.0 |" in new_md
    assert {p.value for p in placed} == {"12.0", "22.0"}
    assert all(p.row_label == "Gamma" for p in placed)
    assert {p.column for p in placed} == {1, 2}  # column 0 is the label


def test_round_off_then_reaudit_is_complete():
    # full cycle: detect the gap, fill it, re-audit -> complete
    words = CAPTURED_WORDS + DROPPED_BOTTOM
    extracted_bboxes = [(50.0, PAGE_H - 150.0, 300.0, PAGE_H - 100.0)]  # span -> Camelot BL
    region = (40.0 / PAGE_W, 90.0 / PAGE_H, 310.0 / PAGE_W, 175.0 / PAGE_H)
    before = completeness_verdict(PAGE_W, PAGE_H, words, region, extracted_bboxes, CAPTURED_MD)
    assert before.complete is False and before.missing_figures
    new_md, placed = round_off(CAPTURED_MD, words, CAPTURED_SPAN, before.missing_figures)
    assert placed
    after = completeness_verdict(PAGE_W, PAGE_H, words, region, extracted_bboxes, new_md)
    assert after.complete is True


def test_round_off_inserts_top_row_at_top():
    # symmetric: a row dropped just above the captured span (top edge)
    captured = [
        (60.0, 205.0, 95.0, 215.0, "Alpha"),
        (140.0, 205.0, 165.0, 215.0, "10.0"),
        (240.0, 205.0, 265.0, 215.0, "20.0"),
    ]
    dropped_top = [
        (58.0, 185.0, 100.0, 195.0, "Header"),
        (140.0, 185.0, 165.0, 195.0, "9.0"),
        (240.0, 185.0, 265.0, 195.0, "19.0"),
    ]
    span = (50.0, 200.0, 300.0, 220.0)
    md = "| Alpha | 10.0 | 20.0 |\n"
    figs = [
        MissingFigure(value="9.0", x=152.5, y=190.0, edge="top"),
        MissingFigure(value="19.0", x=252.5, y=190.0, edge="top"),
    ]
    new_md, placed = round_off(md, captured + dropped_top, span, figs)
    assert new_md.splitlines()[0] == "| Header | 9.0 | 19.0 |"
    assert len(placed) == 2


# --- structured-grid fill (round_off_grid) -----------------------------------
# Clean composition: fill the EXISTING row's empty cell in place, no duplicate
# row, column count preserved. Mirrors BHE p5 "Total assets" (June value
# dropped). Columns at x~152 (Dec) and x~252 (June); the Total-assets row is
# captured with the Dec value present and the June cell empty.

from quber.agents.completeness import numeric_grid_columns, round_off_grid  # noqa: E402

GRID = [
    ["", "Dec 31, 2024", "June 30, 2025"],
    ["Total current assets", "1,504,229", "1,396,990"],
    ["Total assets", "2,139,464", ""],
]
GRID_WORDS = [
    (140.0, 105.0, 165.0, 115.0, "1,504,229"),
    (240.0, 105.0, 265.0, 115.0, "1,396,990"),
    (50.0, 143.0, 75.0, 153.0, "Total"),
    (78.0, 143.0, 110.0, 153.0, "assets"),
    (140.0, 143.0, 165.0, 153.0, "2,139,464"),
    (240.0, 143.0, 265.0, 153.0, "2,030,845"),  # June value, in text layer only
]
GRID_SPAN = (50.0, 100.0, 300.0, 150.0)


def test_numeric_grid_columns_identifies_value_columns():
    assert numeric_grid_columns(GRID) == [1, 2]


def test_round_off_grid_fills_existing_row_in_place_no_duplicate():
    figs = [MissingFigure(value="2,030,845", x=252.0, y=148.0, edge="bottom")]
    new_cells, placed = round_off_grid(GRID, GRID_WORDS, GRID_SPAN, figs)
    # no new row added; the existing Total-assets row's empty June cell is filled
    assert len(new_cells) == len(GRID)
    assert new_cells[2] == ["Total assets", "2,139,464", "2,030,845"]
    assert len(placed) == 1 and placed[0].column == 2 and placed[0].row_label == "Total assets"


def test_round_off_grid_does_not_overwrite_existing_camelot_value():
    # the Dec cell already holds 2,139,464; a figure mapped there must not clobber it
    figs = [MissingFigure(value="9,999,999", x=152.0, y=148.0, edge="bottom")]
    new_cells, _ = round_off_grid(GRID, GRID_WORDS, GRID_SPAN, figs)
    assert new_cells[2][1] == "2,139,464"  # untouched
    # it falls back to the nearest empty numeric column (the June cell)
    assert new_cells[2][2] == "9,999,999"


def test_round_off_grid_appends_new_row_when_no_label_match():
    figs = [
        MissingFigure(value="100", x=152.0, y=170.0, edge="bottom"),
        MissingFigure(value="200", x=252.0, y=170.0, edge="bottom"),
    ]
    words = GRID_WORDS + [(50.0, 167.0, 80.0, 177.0, "Goodwill")]
    new_cells, _ = round_off_grid(GRID, words, GRID_SPAN, figs)
    assert len(new_cells) == len(GRID) + 1
    assert new_cells[-1] == ["Goodwill", "100", "200"]
    assert all(len(r) == 3 for r in new_cells)  # uniform column count


def test_round_off_grid_duplicate_label_appends_not_fills_full_row():
    # "Dec 31, 2024" appears as a full 3-months row; the dropped 240,083
    # belongs to a separate 12-months "Dec 31, 2024" row. The fill must not
    # match the already-full row (no empty target cell) — it appends a new
    # row instead of dropping the figure. (Regression: Visa p4.)
    grid = [
        ["Period", "(millions)", "Growth"],
        ["3 Months Ended", "", ""],
        ["Dec 31, 2024", "63,797", "11%"],
    ]
    words = [
        (140.0, 140.0, 165.0, 150.0, "63,797"),  # captured, in span
        (240.0, 140.0, 265.0, 150.0, "11%"),
        (50.0, 175.0, 70.0, 185.0, "Dec"),  # dropped 12-months row label, below span
        (73.0, 175.0, 95.0, 185.0, "31,"),
        (98.0, 175.0, 122.0, 185.0, "2024"),
        (140.0, 175.0, 165.0, 185.0, "240,083"),
    ]
    span = (50.0, 100.0, 300.0, 150.0)
    figs = [MissingFigure(value="240,083", x=152.0, y=180.0, edge="bottom")]
    new_cells, placed = round_off_grid(grid, words, span, figs)
    assert len(new_cells) == len(grid) + 1  # appended, not filled into the 3-months row
    assert grid[2] == ["Dec 31, 2024", "63,797", "11%"]  # original row untouched
    assert new_cells[-1][0] == "Dec 31, 2024" and new_cells[-1][1] == "240,083"
    assert len(placed) == 1


# --- detector box repair (text-layer validation) -----------------------------
from quber.agents.completeness import repair_box, tabular_bands  # noqa: E402

# one 2-column data band at y 105..135 (x 60..265), plus a prose line below
BAND_WORDS = [
    (60.0, 105.0, 95.0, 115.0, "Alpha"),
    (140.0, 105.0, 165.0, 115.0, "10.0"),
    (240.0, 105.0, 265.0, 115.0, "20.0"),
    (60.0, 125.0, 92.0, 135.0, "Beta"),
    (140.0, 125.0, 165.0, 135.0, "11.0"),
    (240.0, 125.0, 265.0, 135.0, "21.0"),
    (60.0, 155.0, 300.0, 165.0, "due to higher gross additions"),  # prose -> excluded
]


def test_tabular_bands_finds_data_band_excludes_prose():
    bands = tabular_bands(BAND_WORDS)
    assert len(bands) == 1
    top, bot, xl, xr = bands[0]
    assert (top, bot, xl, xr) == (105.0, 135.0, 60.0, 265.0)


def test_repair_box_snaps_overlapping_box_to_band():
    bands = tabular_bands(BAND_WORDS)
    box = (0.20, 103.0 / PAGE_H, 0.50, 143.0 / PAGE_H)  # overlaps band, narrow x
    rep = repair_box(box, bands, PAGE_W, PAGE_H)
    assert rep is not None
    assert abs(rep[0] - 60.0 / PAGE_W) < 1e-6 and abs(rep[2] - 265.0 / PAGE_W) < 1e-6  # x widened to labels


def test_repair_box_pulls_offset_box_up_to_nearby_band():
    # box sits just below the band (p22-like footer offset), within near pad
    bands = tabular_bands(BAND_WORDS)
    box = (0.10, 160.0 / PAGE_H, 0.90, 175.0 / PAGE_H)
    rep = repair_box(box, bands, PAGE_W, PAGE_H)
    assert rep is not None
    assert abs(rep[1] - 105.0 / PAGE_H) < 1e-6 and abs(rep[3] - 135.0 / PAGE_H) < 1e-6


def test_repair_box_returns_none_for_phantom_far_from_any_band():
    # No band near -> None; the caller leaves the box unchanged (never drops).
    bands = tabular_bands(BAND_WORDS)
    box = (0.10, 400.0 / PAGE_H, 0.90, 450.0 / PAGE_H)  # whitespace, no band near
    assert repair_box(box, bands, PAGE_W, PAGE_H) is None
