"""Cell-box tightening against a PDF with known word positions.

The fixture page mimics the sparse presentation layout where structural
geometry fails: a label column and a right-aligned value column with a wide
whitespace gap, plus a second occurrence of the same value on another line to
exercise nearest-match disambiguation. The PDF is generated with PyMuPDF, so
word rectangles come from the same text layer the tightener reads in
production.
"""

from __future__ import annotations

import fitz
import pytest

from quber.agents.completeness import page_words
from quber.core.extractors.camelot.tighten import tighten_box, tighten_cell_boxes

PAGE_W, PAGE_H = 612, 396


@pytest.fixture(scope="module")
def words(tmp_path_factory):
    doc = fitz.open()
    page = doc.new_page(width=PAGE_W, height=PAGE_H)
    page.insert_text((50, 100), "GAAP Net Loss", fontsize=11)
    page.insert_text((280, 100), "($0.15)", fontsize=11)
    page.insert_text((50, 140), "Per Share", fontsize=11)
    page.insert_text((280, 140), "($0.15)", fontsize=11)
    page.insert_text((50, 180), "Net income", fontsize=11)
    page.insert_text((280, 180), "$15.8", fontsize=11)
    page.insert_text((50, 220), "Q2'26", fontsize=11)
    # A row label whose footnote marker prints superscripted — higher than
    # the label's line, so it sorts before the label in reading order.
    page.insert_text((50, 260), "Net assets", fontsize=11)
    page.insert_text((105, 253), "(9)", fontsize=7)
    # Accounting style: the currency sign far from its right-aligned value,
    # glued into one camelot cell.
    page.insert_text((280, 260), "$", fontsize=11)
    page.insert_text((360, 260), "1,234", fontsize=11)
    path = tmp_path_factory.mktemp("tighten") / "fixture.pdf"
    doc.save(str(path))
    doc.close()
    _, _, extracted = page_words(path, 1)
    return extracted


def word_named(words, text):
    return next(w for w in words if w[4] == text)


# The structural band for the "Per Share" row's value column: from the end of
# the label column to past the value, half a line low — the failing geometry
# observed in production.
BAND = (200.0, 128.0, 320.0, 148.0)


def test_tightens_to_printed_extent(words):
    tight = tighten_box(BAND, "($0.15)", words)
    assert tight is not None
    printed = word_named(words, "($0.15)")
    # Two occurrences print on the page; the band centered on the y=140 line
    # must resolve to that line's occurrence.
    occurrences = [w for w in words if w[4] == "($0.15)"]
    assert len(occurrences) == 2
    target = min(occurrences, key=lambda w: abs((w[1] + w[3]) / 2 - 138))
    assert tight == pytest.approx(target[:4], abs=0.5)
    # And it is dramatically narrower than the structural band.
    assert (tight[2] - tight[0]) < (BAND[2] - BAND[0]) / 2
    del printed


def test_multiword_cell_unions_its_words(words):
    band = (30.0, 168.0, 200.0, 192.0)
    tight = tighten_box(band, "Net income", words)
    assert tight is not None
    # "Net" also prints inside "GAAP Net Loss" on another line; take the one
    # on this row's line.
    first = next(w for w in words if w[4] == "Net" and w[1] > 160)
    second = word_named(words, "income")
    assert tight[0] == pytest.approx(first[0], abs=0.5)
    assert tight[2] == pytest.approx(second[2], abs=0.5)


def test_unmatched_text_returns_none(words):
    assert tighten_box(BAND, "not printed anywhere", words) is None
    assert tighten_box(BAND, "", words) is None


def test_typographic_punctuation_folds(words):
    # Print and extraction disagree on the apostrophe glyph; both sides fold
    # to the same comparison form. (The fixture prints the straight form —
    # the base font cannot encode the curly one — and the cell text carries
    # the curly form, the mirror of the case seen in real filings.)
    band = (30.0, 208.0, 200.0, 232.0)
    tight = tighten_box(band, "Q2’26", words)
    assert tight is not None
    printed = next(w for w in words if "26" in w[4] and w[1] > 200)
    assert tight == pytest.approx(printed[:4], abs=0.5)


def test_grid_round_trips_camelot_frame(words):
    # Camelot's frame is bottom-left origin: the same band, flipped.
    camelot_band = (BAND[0], PAGE_H - BAND[3], BAND[2], PAGE_H - BAND[1])
    cells = [["Per Share", "($0.15)"]]
    boxes = [[None, camelot_band]]
    out = tighten_cell_boxes(cells, boxes, words, PAGE_H)
    assert out[0][0] is None
    got = out[0][1]
    assert got is not None
    printed = min(
        (w for w in words if w[4] == "($0.15)"),
        key=lambda w: abs((w[1] + w[3]) / 2 - 138),
    )
    assert got[0] == pytest.approx(printed[0], abs=0.5)
    assert got[1] == pytest.approx(PAGE_H - printed[3], abs=0.5)
    assert got[2] == pytest.approx(printed[2], abs=0.5)
    assert got[3] == pytest.approx(PAGE_H - printed[1], abs=0.5)


def test_empty_text_and_missing_box_pass_through(words):
    cells = [["", "($0.15)"]]
    boxes = [[(10.0, 10.0, 50.0, 30.0), None]]
    out = tighten_cell_boxes(cells, boxes, words, PAGE_H)
    assert out == boxes


def test_superscript_marker_matches_by_tokens(words):
    band = (30.0, 248.0, 200.0, 272.0)
    tight = tighten_box(band, "Net assets (9)", words)
    assert tight is not None
    label_first = next(w for w in words if w[4] == "Net" and w[1] > 240)
    marker = next(w for w in words if w[4] == "(9)")
    assert tight[0] == pytest.approx(label_first[0], abs=0.5)
    assert tight[1] == pytest.approx(marker[1], abs=0.5)


def test_glued_currency_sign_unions_across_the_gap(words):
    band = (300.0, 248.0, 380.0, 272.0)  # the value's column band; '$' prints left of it
    tight = tighten_box(band, "$ 1,234", words)
    assert tight is not None
    sign = next(w for w in words if w[4] == "$" and w[1] > 240)
    value = next(w for w in words if w[4] == "1,234")
    assert tight[0] == pytest.approx(sign[0], abs=0.5)
    assert tight[2] == pytest.approx(value[2], abs=0.5)


def test_unmatched_cell_clamps_to_contained_ink(words):
    from quber.core.extractors.camelot.tighten import ink_extent

    # A band holding printed words that do not match the cell text at all.
    band = (30.0, 88.0, 320.0, 112.0)
    assert tighten_box(band, "Synthesized Header", words) is None
    clamp = ink_extent(band, words)
    assert clamp is not None
    inside = [
        w
        for w in words
        if band[0] <= (w[0] + w[2]) / 2 <= band[2] and band[1] <= (w[1] + w[3]) / 2 <= band[3]
    ]
    assert clamp[0] == pytest.approx(min(w[0] for w in inside), abs=0.5)
    assert clamp[2] <= band[2] + 0.01
    # An empty band (no ink) stays unclamped.
    assert ink_extent((400.0, 300.0, 500.0, 330.0), words) is None
