"""Tests for scripts/pdf_table_weave.py table-header-promotion additions.

CI-light: pure pikepdf fixtures built in-memory, no vendored ODL/java/verapdf.
"""

import os
import sys

import fitz  # PyMuPDF
import pikepdf
import pytest

sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts"))

import pdf_table_weave as ptw


# ── fixtures ─────────────────────────────────────────────────────────────

def _cell(pdf, text=None, pg=None):
    kids = []
    if text is not None:
        p = pdf.make_indirect(pikepdf.Dictionary(
            {"/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/P"),
             "/ActualText": pikepdf.String(text)}))
        kids.append(p)
    d = {
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/TD"),
        "/K": pikepdf.Array(kids) if kids else pikepdf.Array([]),
    }
    if pg is not None:
        d["/Pg"] = pg
    return pdf.make_indirect(pikepdf.Dictionary(d))


def _row(pdf, cells):
    return pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/TR"),
        "/K": pikepdf.Array(cells),
    }))


def _group(pdf, tag, rows):
    """A /THead, /TBody, or /TFoot row-grouping struct elem — the PDF spec's
    standard tagged-table shape (/Table -> /TBody -> /TR -> /TD) that real
    taggers (opendataloader-pdf included) emit routinely."""
    return pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name(tag),
        "/K": pikepdf.Array(rows),
    }))


def _table(pdf, rows):
    return pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array(rows),
    }))


def _doc_with_tables(pdf, tables):
    doc = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Document"),
        "/K": pikepdf.Array(tables),
    }))
    root = pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructTreeRoot"), "/K": doc,
    })
    pdf.Root.StructTreeRoot = root
    return doc


def _link_row(pdf):
    """A /TR whose only child is a /Link struct elem — no /TD/TH cells. The
    real-file junk-row shape (ODL sweeps a /Link into TR0) that must never
    be treated as the header row."""
    link = pdf.make_indirect(pikepdf.Dictionary(
        {"/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Link")}))
    return pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/TR"),
        "/K": pikepdf.Array([link]),
    }))


def _grid(pdf, rows_text, pg=None):
    """A /Table struct elem built from rows_text (list of list of str) —
    uniform column count per row (never ragged on its own)."""
    rows = [_row(pdf, [_cell(pdf, t, pg=pg) for t in row]) for row in rows_text]
    return _table(pdf, rows)


def _headers_ids(cell):
    """The /Headers array (as a list of str) from a cell's /A entry —
    PDF32000-2 14.7.5.4 (Table 349) defines Headers as a member of the
    /Table-owned attribute dictionary, never a top-level key on the
    structure element itself. Asserts the top-level key is absent on every
    call, so every test that reads a promoted cell's headers through this
    helper also re-verifies correct placement."""
    assert "/Headers" not in cell, "/Headers must live in /A, not top-level"
    a = cell["/A"]
    return [str(x) for x in a["/Headers"]]


def _assert_no_headers(cell):
    """No /Headers anywhere on `cell` — neither top-level nor nested in
    /A (which may be absent, a single dict, or an array of dicts)."""
    assert "/Headers" not in cell
    a = cell.get("/A")
    if a is None:
        return
    items = a if isinstance(a, pikepdf.Array) else [a]
    for item in items:
        if hasattr(item, "get"):
            assert "/Headers" not in item


def _two_table_doc():
    pdf = pikepdf.new()
    t0 = _table(pdf, [
        _row(pdf, [_cell(pdf, "Name"), _cell(pdf, "Score")]),
        _row(pdf, [_cell(pdf, "Alice"), _cell(pdf, "9")]),
        _row(pdf, [_cell(pdf, "Bob"), _cell(pdf, "7")]),
    ])
    t1 = _table(pdf, [
        _row(pdf, [_cell(pdf, "apple"), _cell(pdf, "banana"), _cell(pdf, "cherry")]),
    ])
    _doc_with_tables(pdf, [t0, t1])
    return pdf


# ── list_tables ──────────────────────────────────────────────────────────

def test_list_tables_reports_shape():
    pdf = _two_table_doc()
    tables = ptw.list_tables(pdf)
    assert len(tables) == 2
    assert tables[0]["index"] == 0
    assert tables[0]["rows"] == 3
    assert tables[0]["cols"] == 2
    assert tables[0]["has_th"] is False
    assert tables[0]["first_row_text"] == ["Name", "Score"]
    assert tables[1]["index"] == 1
    assert tables[1]["rows"] == 1
    assert tables[1]["cols"] == 3


def test_list_tables_pages_empty_and_page_none_when_no_pg_resolves():
    """_two_table_doc's cells carry no /Pg -> both `page` and `pages` must
    report the unresolved case, never guess."""
    pdf = _two_table_doc()
    tables = ptw.list_tables(pdf)
    assert tables[0]["page"] is None
    assert tables[0]["pages"] == []


def test_list_tables_single_page_table_reports_pages_matching_page():
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    table = _grid(pdf, [["Name", "Score"], ["Alice", "9"]], pg=page.obj)
    _doc_with_tables(pdf, [table])

    tables = ptw.list_tables(pdf)
    assert tables[0]["page"] == 1
    assert tables[0]["pages"] == [1]


def test_list_tables_reports_every_page_a_table_spans_sorted_and_deduped():
    """A table whose header row repeats on page 2 (the real NYU-Torts
    grading-table shape this feature exists for): rows 0-1 sit on page 1,
    rows 2-3 (the repeated header + a data row) sit on page 2. `pages` must
    report both, sorted and deduplicated; `page` stays exactly what it
    always was (the header row's page) and equals pages[0]."""
    pdf = pikepdf.new()
    page1 = pdf.add_blank_page(page_size=(200, 200))
    page2 = pdf.add_blank_page(page_size=(200, 200))
    row0 = _row(pdf, [_cell(pdf, "Name", pg=page1.obj), _cell(pdf, "Score", pg=page1.obj)])
    row1 = _row(pdf, [_cell(pdf, "Alice", pg=page1.obj), _cell(pdf, "9", pg=page1.obj)])
    row2 = _row(pdf, [_cell(pdf, "Name", pg=page2.obj), _cell(pdf, "Score", pg=page2.obj)])
    row3 = _row(pdf, [_cell(pdf, "Bob", pg=page2.obj), _cell(pdf, "7", pg=page2.obj)])
    table = _table(pdf, [row0, row1, row2, row3])
    _doc_with_tables(pdf, [table])

    tables = ptw.list_tables(pdf)
    assert tables[0]["page"] == 1
    assert tables[0]["pages"] == [1, 2]
    assert tables[0]["page"] == tables[0]["pages"][0]


# ── promote_headers ──────────────────────────────────────────────────────

def test_header_row_promotes_row0_with_column_scope():
    pdf = _two_table_doc()
    result = ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    assert result["tables_touched"] == 1
    assert result["cells_promoted"] == 2
    assert result["skipped"] == []

    tables = ptw._find_tables(pdf)
    rows = ptw._kids(tables[0])
    row0 = ptw._kids(rows[0])
    for cell in row0:
        assert str(cell["/S"]) == "/TH"
        assert str(cell["/A"]["/O"]) == "/Table"
        assert str(cell["/A"]["/Scope"]) == "/Column"
    row1 = ptw._kids(rows[1])
    for cell in row1:
        assert str(cell["/S"]) == "/TD"


def test_header_col_promotes_first_cell_of_each_row_with_row_scope():
    pdf = _two_table_doc()
    result = ptw.promote_headers(pdf, [{"index": 0, "header_col": True}])
    assert result["cells_promoted"] == 3  # one per row, no header_row

    tables = ptw._find_tables(pdf)
    rows = ptw._kids(tables[0])
    for row in rows:
        cells = ptw._kids(row)
        assert str(cells[0]["/S"]) == "/TH"
        assert str(cells[0]["/A"]["/Scope"]) == "/Row"
        assert str(cells[1]["/S"]) == "/TD"


def test_header_row_and_col_corner_gets_column_scope_once():
    pdf = _two_table_doc()
    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_row": True, "header_col": True}])
    # row0: 2 cells promoted; header_col: rows 1,2 first cell promoted (row0
    # cell already /TH -> skipped by _promote, not double-counted)
    assert result["cells_promoted"] == 4

    tables = ptw._find_tables(pdf)
    rows = ptw._kids(tables[0])
    corner = ptw._kids(rows[0])[0]
    assert str(corner["/S"]) == "/TH"
    assert str(corner["/A"]["/Scope"]) == "/Column"  # row wins, written once
    row_header = ptw._kids(rows[1])[0]
    assert str(row_header["/S"]) == "/TH"
    assert str(row_header["/A"]["/Scope"]) == "/Row"


def test_opt_in_table_not_in_specs_is_untouched():
    pdf = _two_table_doc()
    result = ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    assert result["tables_touched"] == 1

    tables = ptw._find_tables(pdf)
    # table 1 never listed -> still all /TD
    row0 = ptw._kids(ptw._kids(tables[1])[0])
    for cell in row0:
        assert str(cell["/S"]) == "/TD"


def test_idempotent_second_run_promotes_zero_and_preserves_a():
    pdf = _two_table_doc()
    ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    tables = ptw._find_tables(pdf)
    first_a = ptw._kids(ptw._kids(tables[0])[0])[0]["/A"]

    second = ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    assert second["cells_promoted"] == 0
    assert second["tables_touched"] == 0

    tables = ptw._find_tables(pdf)
    second_a = ptw._kids(ptw._kids(tables[0])[0])[0]["/A"]
    assert first_a.objgen == second_a.objgen  # /A untouched, not rewritten


def test_out_of_range_index_is_skipped_not_raised():
    pdf = _two_table_doc()
    result = ptw.promote_headers(pdf, [{"index": 99, "header_row": True}])
    assert result["tables_touched"] == 0
    assert result["cells_promoted"] == 0
    assert len(result["skipped"]) == 1
    assert result["skipped"][0]["reason"] == "index out of range"


# ── junk-row regression (real Cooper_2018-shaped table: /Link at TR0) ──────

def _junk_row_table_doc():
    pdf = pikepdf.new()
    table = _table(pdf, [
        _link_row(pdf),                                          # TR0: junk
        _row(pdf, [_cell(pdf, "Name"), _cell(pdf, "Score")]),     # TR1: real header
        _row(pdf, [_cell(pdf, "Alice"), _cell(pdf, "9")]),        # TR2: data
    ])
    _doc_with_tables(pdf, [table])
    return pdf


def test_list_tables_resolves_header_past_junk_row():
    pdf = _junk_row_table_doc()
    tables = ptw.list_tables(pdf)
    assert tables[0]["rows"] == 3
    assert tables[0]["cell_rows"] == 2
    assert tables[0]["header_row_index"] == 1
    assert tables[0]["cols"] == 2  # not 1 (the /Link row) or wrong count
    assert tables[0]["first_row_text"] == ["Name", "Score"]


def test_header_row_promotes_second_row_past_junk_row():
    pdf = _junk_row_table_doc()
    result = ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    assert result["cells_promoted"] == 2
    assert result["tables_touched"] == 1

    tables = ptw._find_tables(pdf)
    rows = ptw._kids(tables[0])
    # junk /TR0's /Link child is untouched
    link = ptw._kids(rows[0])[0]
    assert str(link["/S"]) == "/Link"
    assert "/A" not in link
    # real header row (TR1) promoted
    for cell in ptw._kids(rows[1]):
        assert str(cell["/S"]) == "/TH"
        assert str(cell["/A"]["/Scope"]) == "/Column"
    # data row (TR2) untouched
    for cell in ptw._kids(rows[2]):
        assert str(cell["/S"]) == "/TD"


def test_header_row_index_override_targets_explicit_row():
    pdf = pikepdf.new()
    table = _table(pdf, [
        _link_row(pdf),                                          # TR0: junk
        _row(pdf, [_cell(pdf, "Name"), _cell(pdf, "Score")]),     # TR1: NOT the header this time
        _row(pdf, [_cell(pdf, "Alice"), _cell(pdf, "9")]),        # TR2: the real header
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_row": True, "header_row_index": 2}])
    assert result["cells_promoted"] == 2

    tables = ptw._find_tables(pdf)
    rows = ptw._kids(tables[0])
    for cell in ptw._kids(rows[1]):
        assert str(cell["/S"]) == "/TD"  # default-resolved row left alone
    for cell in ptw._kids(rows[2]):
        assert str(cell["/S"]) == "/TH"
        assert str(cell["/A"]["/Scope"]) == "/Column"


# ── ragged-table safety guard (veraPDF 7.2 hazard) ──────────────────────────

def _ragged_table():
    """Rows of 3, 3, 2 cells — cell counts differ -> ragged."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, "A"), _cell(pdf, "B"), _cell(pdf, "C")]),
        _row(pdf, [_cell(pdf, "1"), _cell(pdf, "2"), _cell(pdf, "3")]),
        _row(pdf, [_cell(pdf, "x"), _cell(pdf, "y")]),
    ])
    _doc_with_tables(pdf, [table])
    return pdf, table


def _well_formed_table():
    """Rows of 2, 2 cells — uniform -> not ragged."""
    pdf = pikepdf.new()
    return _table(pdf, [
        _row(pdf, [_cell(pdf, "Name"), _cell(pdf, "Score")]),
        _row(pdf, [_cell(pdf, "Alice"), _cell(pdf, "9")]),
    ]), pdf


def test_list_tables_reports_ragged_flag():
    pdf, _ = _ragged_table()
    tables = ptw.list_tables(pdf)
    assert tables[0]["ragged"] is True

    pdf2 = _two_table_doc()  # both tables here are uniform per-row
    tables2 = ptw.list_tables(pdf2)
    assert tables2[0]["ragged"] is False
    assert tables2[1]["ragged"] is False  # single row, trivially uniform


def test_ragged_table_refused_by_default():
    pdf, _ = _ragged_table()
    result = ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    assert result["cells_promoted"] == 0
    assert result["tables_touched"] == 0
    assert len(result["skipped"]) == 1
    reason = result["skipped"][0]["reason"]
    assert "ragged" in reason and "7.2" in reason

    tables = ptw._find_tables(pdf)
    for row in ptw._kids(tables[0]):
        for cell in ptw._kids(row):
            assert str(cell["/S"]) == "/TD"  # completely untouched


def test_ragged_table_force_true_promotes():
    pdf, _ = _ragged_table()
    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_row": True, "force": True}])
    assert result["tables_touched"] == 1
    assert result["cells_promoted"] == 3  # row 0 has 3 cells

    tables = ptw._find_tables(pdf)
    row0 = ptw._kids(ptw._kids(tables[0])[0])
    for cell in row0:
        assert str(cell["/S"]) == "/TH"


def test_mixed_call_promotes_well_formed_skips_ragged():
    pdf = pikepdf.new()
    ragged = _table(pdf, [
        _row(pdf, [_cell(pdf, "A"), _cell(pdf, "B"), _cell(pdf, "C")]),
        _row(pdf, [_cell(pdf, "x"), _cell(pdf, "y")]),
    ])
    well_formed = _table(pdf, [
        _row(pdf, [_cell(pdf, "Name"), _cell(pdf, "Score")]),
        _row(pdf, [_cell(pdf, "Alice"), _cell(pdf, "9")]),
    ])
    _doc_with_tables(pdf, [ragged, well_formed])

    result = ptw.promote_headers(pdf, [
        {"index": 0, "header_row": True},
        {"index": 1, "header_row": True},
    ])
    assert result["tables_touched"] == 1
    assert result["cells_promoted"] == 2
    assert len(result["skipped"]) == 1
    assert "ragged" in result["skipped"][0]["reason"]

    tables = ptw._find_tables(pdf)
    for cell in ptw._kids(ptw._kids(tables[0])[0]):
        assert str(cell["/S"]) == "/TD"  # ragged table untouched
    for cell in ptw._kids(ptw._kids(tables[1])[0]):
        assert str(cell["/S"]) == "/TH"  # well-formed table promoted


# ── row-group traversal (/THead, /TBody, /TFoot) ────────────────────────────

def _row_grouped_table_doc():
    """/Table -> /THead (1 row) + /TBody (2 rows) — the PDF-spec-standard
    row-grouped shape real taggers emit (opendataloader-pdf included).
    Cells carry /Pg so page resolution is exercised end to end."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    thead = _group(pdf, "/THead", [
        _row(pdf, [_cell(pdf, "Name", pg=page.obj), _cell(pdf, "Score", pg=page.obj)]),
    ])
    tbody = _group(pdf, "/TBody", [
        _row(pdf, [_cell(pdf, "Alice", pg=page.obj), _cell(pdf, "9", pg=page.obj)]),
        _row(pdf, [_cell(pdf, "Bob", pg=page.obj), _cell(pdf, "7", pg=page.obj)]),
    ])
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([thead, tbody]), "/Pg": page.obj,
    }))
    _doc_with_tables(pdf, [table])
    return pdf, page


def test_list_tables_row_grouped_thead_tbody_reports_real_shape():
    pdf, _ = _row_grouped_table_doc()
    tables = ptw.list_tables(pdf)
    assert len(tables) == 1
    t = tables[0]
    # before the fix: rows=2 (THead, TBody misread as rows), cell_rows=0,
    # cols=0, header_row_index=None, page=None, first_row_text=[]
    assert t["rows"] == 3
    assert t["cell_rows"] == 3
    assert t["cols"] == 2
    assert t["header_row_index"] == 0
    assert t["first_row_text"] == ["Name", "Score"]
    assert t["ragged"] is False
    assert t["page"] == 1


def test_promote_headers_thead_row_promoted_to_th_column_scope():
    pdf, _ = _row_grouped_table_doc()
    result = ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    assert result["tables_touched"] == 1
    assert result["cells_promoted"] == 2
    assert result["skipped"] == []

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    for cell in ptw._row_cells(rows[0]):
        assert str(cell["/S"]) == "/TH"
        assert str(cell["/A"]["/Scope"]) == "/Column"
    # /TBody rows untouched
    for row in rows[1:]:
        for cell in ptw._row_cells(row):
            assert str(cell["/S"]) == "/TD"


def test_mixed_group_and_loose_tr_yields_rows_in_document_order():
    """A /THead group plus a loose /TR sibling — both must be picked up,
    in document order (group rows first, then the loose /TR)."""
    pdf = pikepdf.new()
    thead = _group(pdf, "/THead", [_row(pdf, [_cell(pdf, "H1"), _cell(pdf, "H2")])])
    loose_tr = _row(pdf, [_cell(pdf, "D1"), _cell(pdf, "D2")])
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([thead, loose_tr]),
    }))
    _doc_with_tables(pdf, [table])

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    assert len(rows) == 2
    texts = [ptw._cell_text(c) for r in rows for c in ptw._row_cells(r)]
    assert texts == ["H1", "H2", "D1", "D2"]


def test_caption_child_not_counted_as_row():
    pdf = pikepdf.new()
    caption = pdf.make_indirect(pikepdf.Dictionary(
        {"/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Caption")}))
    tbody = _group(pdf, "/TBody", [_row(pdf, [_cell(pdf, "a"), _cell(pdf, "b")])])
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([caption, tbody]),
    }))
    _doc_with_tables(pdf, [table])

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    assert len(rows) == 1
    assert str(rows[0]["/S"]) == "/TR"


# ── _cell_text MCID fallback + first_col_text/caption/truncation ───────────

def _mcid_cell(pdf, mcid, pg=None):
    """A /TD cell whose /K is a bare MCID int — no /ActualText, no /Alt, no
    /P descendant. The real-world shape (verified against real ODL-tagged
    PDFs) that requires the mcid_text.py content-stream fallback."""
    d = {"/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/TD"),
         "/K": pikepdf.Array([pikepdf.Integer(mcid)])}
    if pg is not None:
        d["/Pg"] = pg
    return pdf.make_indirect(pikepdf.Dictionary(d))


def _set_page_mcid_text(pdf, page, mcid_to_text):
    """Write a content stream with one BDC/Tj/EMC run per {mcid: text}
    entry — no /F0 Tf needed, the default (no-font) decode path is
    latin-1, which round-trips plain ASCII test text correctly."""
    parts = [f"/P <</MCID {mcid}>> BDC\nBT ({text}) Tj ET\nEMC\n" for mcid, text in mcid_to_text.items()]
    page.Contents = pdf.make_stream("".join(parts).encode("latin-1"))


def test_cell_text_prefers_actual_text_over_mcid_fallback():
    """Regression: a cell WITH /ActualText must never fall through to the
    (deliberately different) MCID text — this is the most important
    assertion in this section, per the plan's explicit call-out."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    _set_page_mcid_text(pdf, page, {0: "FromContentStream"})
    cell = _cell(pdf, "FromActualText", pg=page.obj)
    # cell's /K carries the /P with /ActualText already (via _cell helper);
    # attach the same MCID too, to prove ActualText wins even when MCID
    # text is also collectible for this cell.
    cell["/K"] = pikepdf.Array(list(cell["/K"]) + [pikepdf.Integer(0)])
    assert ptw._cell_text(cell) == "FromActualText"


def test_cell_text_falls_back_to_mcid_when_no_actual_text_or_alt():
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    _set_page_mcid_text(pdf, page, {5: "RealCellText"})
    cell = _mcid_cell(pdf, 5, pg=page.obj)
    assert ptw._cell_text(cell) == "RealCellText"


def test_cell_text_mcid_fallback_uses_inherited_pg_when_cell_has_none():
    """Real taggers often set /Pg once at the table level, not per cell —
    inherited_pg is how _cell_text still resolves the right page."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    _set_page_mcid_text(pdf, page, {7: "InheritedPageText"})
    cell = _mcid_cell(pdf, 7, pg=None)  # no /Pg on the cell itself
    assert ptw._cell_text(cell) == ""  # no page to resolve against -> no text
    assert ptw._cell_text(cell, inherited_pg=page.obj) == "InheritedPageText"


def test_cell_text_with_no_actual_text_alt_or_mcid_returns_empty_string():
    pdf = pikepdf.new()
    cell = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/TD"),
        "/K": pikepdf.Array([]),
    }))
    assert ptw._cell_text(cell) == ""


def test_list_tables_emits_first_col_text_and_caption():
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    caption = pdf.make_indirect(pikepdf.Dictionary(
        {"/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Caption"),
         "/ActualText": pikepdf.String("Table 3: Enrollment by term")}))
    rows = [
        _row(pdf, [_cell(pdf, "Term", pg=page.obj), _cell(pdf, "Count", pg=page.obj)]),
        _row(pdf, [_cell(pdf, "Fall", pg=page.obj), _cell(pdf, "120", pg=page.obj)]),
        _row(pdf, [_cell(pdf, "Spring", pg=page.obj), _cell(pdf, "95", pg=page.obj)]),
    ]
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([caption] + rows), "/Pg": page.obj,
    }))
    _doc_with_tables(pdf, [table])

    tables = ptw.list_tables(pdf)
    assert tables[0]["first_col_text"] == ["Term", "Fall", "Spring"]
    assert tables[0]["caption"] == "Table 3: Enrollment by term"


def test_list_tables_caption_empty_string_when_absent():
    pdf = _two_table_doc()
    tables = ptw.list_tables(pdf)
    assert tables[0]["caption"] == ""
    assert tables[1]["caption"] == ""


def test_list_tables_first_col_text_capped_at_eight_rows():
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    rows = [_row(pdf, [_cell(pdf, f"r{i}", pg=page.obj), _cell(pdf, "x", pg=page.obj)])
            for i in range(12)]
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array(rows), "/Pg": page.obj,
    }))
    _doc_with_tables(pdf, [table])

    tables = ptw.list_tables(pdf)
    assert tables[0]["first_col_text"] == [f"r{i}" for i in range(8)]


def test_truncate_caps_at_200_chars_and_appends_ellipsis():
    long_text = "x" * 250
    result = ptw._truncate(long_text)
    assert len(result) == 201
    assert result == "x" * 200 + "…"

    short_text = "short"
    assert ptw._truncate(short_text) == short_text


def test_cell_text_truncates_long_actual_text():
    pdf = pikepdf.new()
    cell = _cell(pdf, "y" * 250)
    result = ptw._cell_text(cell)
    assert result == "y" * 200 + "…"


# ── _caption_text: must not descend into a child's /Alt (regression) ───────

def test_caption_text_ignores_child_link_alt_placeholder_uses_mcid_instead():
    """Regression (real-file case, Cooper_2018-shaped, PLOS ONE typesetting):
    /Caption's only struct child is a /Link (an internal cross-reference)
    whose /Alt is the tagger's generic placeholder "Annotation" — not real
    caption content. The real caption text ("Table 1. Demographics of
    students...") lives as MCID text under that same /Link's /K.
    _caption_text must resolve the caption's own MCID text and must NEVER
    fall through to the child /Link's /Alt — descending into a child's
    /Alt is _cell_text's behavior (reasonable for a cell's /P descendant),
    not _caption_text's: a caption's text is its own content, and a wrong-
    but-plausible-looking caption is worse than an empty one for the
    table-vs-figure judgment caption exists to inform."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    _set_page_mcid_text(pdf, page, {0: "Table 1. Demographics of students"})
    link = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Link"),
        "/Alt": pikepdf.String("Annotation"),
        "/K": pikepdf.Array([pikepdf.Integer(0)]),
    }))
    caption = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Caption"),
        "/K": pikepdf.Array([link]),
    }))
    result = ptw._caption_text(caption, inherited_pg=page.obj)
    assert result == "Table 1. Demographics of students"
    assert result != "Annotation"


def test_caption_text_prefers_own_actual_text_over_mcid():
    pdf = pikepdf.new()
    caption = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Caption"),
        "/ActualText": pikepdf.String("Figure 6.18 Residual plots"),
        "/K": pikepdf.Array([]),
    }))
    assert ptw._caption_text(caption) == "Figure 6.18 Residual plots"


def test_caption_text_empty_when_no_actual_text_alt_or_mcid():
    pdf = pikepdf.new()
    caption = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Caption"),
        "/K": pikepdf.Array([]),
    }))
    assert ptw._caption_text(caption) == ""


def test_list_tables_uses_caption_text_not_cell_text_for_caption_field():
    """End-to-end via list_tables(): a table whose /Caption has the same
    child-/Link-placeholder shape must surface real caption text in the
    manifest, not "Annotation"."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    _set_page_mcid_text(pdf, page, {0: "Table 1. Demographics"})
    link = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Link"),
        "/Alt": pikepdf.String("Annotation"),
        "/K": pikepdf.Array([pikepdf.Integer(0)]),
    }))
    caption = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Caption"),
        "/K": pikepdf.Array([link]),
    }))
    rows = [_row(pdf, [_cell(pdf, "a", pg=page.obj), _cell(pdf, "b", pg=page.obj)])]
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([caption] + rows), "/Pg": page.obj,
    }))
    _doc_with_tables(pdf, [table])

    tables = ptw.list_tables(pdf)
    assert tables[0]["caption"] == "Table 1. Demographics"


# ── Hierarchical headers: header_rows / header_cols / band_rows ────────────
# (docs/plan-... N/A — feature spec is in the task prompt / PR description,
# not a separate plan doc.)

def test_hierarchical_keys_absent_behaves_exactly_like_legacy():
    """Regression: the most important assertion in this section. With none
    of header_rows/header_cols/band_rows present, behavior must be
    byte-for-byte identical to before this feature existed — no /ID, no
    /Headers anywhere, same promotion count as the pre-existing
    header_row+header_col test."""
    pdf = _two_table_doc()
    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_row": True, "header_col": True}])
    assert result["cells_promoted"] == 4
    assert result["tables_touched"] == 1

    tables = ptw._find_tables(pdf)
    for row in ptw._kids(tables[0]):
        for cell in ptw._kids(row):
            assert "/ID" not in cell
            _assert_no_headers(cell)


def test_row_main_column_sub_data_cell_gets_both_header_ids():
    """Shape 1 (rubric-like): row 0 spanning main header + column 0 row
    labels. A data cell must carry /Headers referencing BOTH the column's
    header-row cell and the row's header-col cell."""
    pdf = pikepdf.new()
    table = _grid(pdf, [
        ["Category", "Score"],
        ["Row A", "10"],
        ["Row B", "20"],
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_rows": [0], "header_cols": [0]}])
    assert result["tables_touched"] == 1
    # header_rows: (0,0) "Category", (0,1) "Score"; header_cols (rows 1,2
    # only — row 0 is already fully header, "row wins"): (1,0), (2,0)
    assert result["cells_promoted"] == 4
    assert result["skipped"] == []

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    r0 = ptw._row_cells(rows[0])
    r1 = ptw._row_cells(rows[1])
    r2 = ptw._row_cells(rows[2])

    for cell in r0:
        assert str(cell["/S"]) == "/TH"
        assert str(cell["/A"]["/Scope"]) == "/Column"
        assert "/ID" in cell

    assert str(r1[0]["/S"]) == "/TH"
    assert str(r1[0]["/A"]["/Scope"]) == "/Row"
    assert str(r2[0]["/S"]) == "/TH"
    assert str(r2[0]["/A"]["/Scope"]) == "/Row"

    for row in (r1, r2):
        data_cell = row[1]
        assert str(data_cell["/S"]) == "/TD"
        header_ids = _headers_ids(data_cell)
        assert header_ids == [str(r0[1]["/ID"]), str(row[0]["/ID"])]


def test_band_rows_data_cell_references_only_nearest_preceding_band():
    """Band rows are structurally narrower than the data rows they split
    (a single spanning cell, no /ColSpan recorded) -> the table is ragged
    by _is_ragged's definition, so "force": true is required — exactly the
    guard's documented purpose (spanning rows with no recorded span info)."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, "Banner A")]),                          # band 0
        _row(pdf, [_cell(pdf, "r1c0"), _cell(pdf, "r1c1")]),          # data
        _row(pdf, [_cell(pdf, "Banner B")]),                          # band 2
        _row(pdf, [_cell(pdf, "r3c0"), _cell(pdf, "r3c1")]),          # data
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(
        pdf, [{"index": 0, "band_rows": [0, 2], "force": True}])
    assert result["tables_touched"] == 1
    assert result["skipped"] == []

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    band0 = ptw._row_cells(rows[0])[0]
    band2 = ptw._row_cells(rows[2])[0]
    assert str(band0["/S"]) == "/TH"
    assert str(band2["/S"]) == "/TH"
    # lone cell of a narrower band row genuinely spans the table's width ->
    # Scope /Column is asserted
    assert str(band0["/A"]["/Scope"]) == "/Column"
    assert str(band2["/A"]["/Scope"]) == "/Column"

    for cell in ptw._row_cells(rows[1]):
        assert _headers_ids(cell) == [str(band0["/ID"])]
    for cell in ptw._row_cells(rows[3]):
        # must reference band2 (nearest preceding), NEVER band0 too
        assert _headers_ids(cell) == [str(band2["/ID"])]


def test_hierarchical_ragged_guard_refuses_band_rows_without_force():
    """The ragged-table guard applies identically to the hierarchical path:
    a band row narrower than the data rows it splits makes the table
    ragged, and promotion is refused by default (same message shape as the
    legacy guard), leaving every cell completely untouched. The band row
    here carries TWO cells (not one), so it is genuinely ambiguous — which
    cell spans, and by how much, cannot be inferred — and
    _synthesize_colspans (v7.76) never touches it; a single-cell band row
    of this shape is exactly what synthesis now resolves instead, see
    test_hierarchical_single_cell_band_row_synthesizes_colspan_and_promotes
    below."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, "Banner A"), _cell(pdf, "Banner B")]),
        _row(pdf, [_cell(pdf, "r1c0"), _cell(pdf, "r1c1"), _cell(pdf, "r1c2")]),
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(pdf, [{"index": 0, "band_rows": [0]}])
    assert result["tables_touched"] == 0
    assert result["cells_promoted"] == 0
    assert result["spans_synthesized"] == 0
    assert len(result["skipped"]) == 1
    reason = result["skipped"][0]["reason"]
    assert "ragged" in reason and "7.2" in reason

    tables = ptw._find_tables(pdf)
    for row in ptw._table_rows(tables[0]):
        for cell in ptw._row_cells(row):
            assert str(cell["/S"]) == "/TD"
            assert "/ID" not in cell
            _assert_no_headers(cell)


def test_hierarchical_three_level_cell_lists_main_band_and_row_headers():
    """Shape 3 (PLOS-like): main header row 0 + interspersed band row +
    column-0 row sub-headers. A data cell governed by all three axes must
    list all three header IDs in its /Headers array."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, "Category"), _cell(pdf, "Score")]),           # 0: main header
        _row(pdf, [_cell(pdf, "Funny humor (n=1608)")]),                    # 1: band
        _row(pdf, [_cell(pdf, "Row A"), _cell(pdf, "10")]),                 # 2: data
        _row(pdf, [_cell(pdf, "Row B"), _cell(pdf, "20")]),                 # 3: data
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(pdf, [{
        "index": 0, "header_rows": [0], "header_cols": [0],
        "band_rows": [1], "force": True,
    }])
    assert result["tables_touched"] == 1
    assert result["skipped"] == []

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    main_header = ptw._row_cells(rows[0])[1]        # "Score"
    band = ptw._row_cells(rows[1])[0]                # "Funny humor..."
    row_a_header = ptw._row_cells(rows[2])[0]        # "Row A"
    row_b_header = ptw._row_cells(rows[3])[0]        # "Row B"

    for cell in (main_header, band, row_a_header, row_b_header):
        assert str(cell["/S"]) == "/TH"
        assert "/ID" in cell

    data_a = ptw._row_cells(rows[2])[1]              # "10"
    data_b = ptw._row_cells(rows[3])[1]              # "20"
    assert _headers_ids(data_a) == [
        str(main_header["/ID"]), str(row_a_header["/ID"]), str(band["/ID"])]
    assert _headers_ids(data_b) == [
        str(main_header["/ID"]), str(row_b_header["/ID"]), str(band["/ID"])]


def test_hierarchical_spanning_header_row_clamped_for_every_data_column():
    """Real-file case (PMP exam outline): a header_rows row can be a single
    spanning banner cell narrower than the data rows below it. Every data
    column must still reference that one cell, not just column 0 — a naive
    exact-column-index lookup would leave every column past 0 with NO
    reference to the banner at all."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, "Banner")]),                    # 0: 1-cell header row
        _row(pdf, [_cell(pdf, "a"), _cell(pdf, "b")]),         # 1: data, 2 cells
        _row(pdf, [_cell(pdf, "c"), _cell(pdf, "d")]),         # 2: data, 2 cells
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_rows": [0], "force": True}])
    assert result["tables_touched"] == 1

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    banner = ptw._row_cells(rows[0])[0]
    assert str(banner["/S"]) == "/TH"

    for row_idx in (1, 2):
        for cell in ptw._row_cells(rows[row_idx]):
            assert _headers_ids(cell) == [str(banner["/ID"])]


def test_headers_written_into_table_owned_attribute_dict_not_top_level():
    """Regression for a real placement bug: /Headers is a member of the
    /Table-owned attribute dictionary (PDF32000-2 14.7.5.4, Table 349 —
    RowSpan/ColSpan/Headers/Scope/Summary are siblings in ONE dict with
    /O /Table), reached via the structure element's /A entry — never a
    top-level key on the cell dictionary itself. A conforming reader
    resolves Headers by looking in /A; a bare top-level /Headers is
    invisible to it, so this must never regress.

    Also exercises the array-of-attribute-dicts merge: a cell that already
    carries a non-/Table /A (e.g. /Layout, from some other process) must
    keep that dict completely untouched while gaining a SECOND, /Table-
    owned dict for /Headers — never clobbered, never merged into the
    wrong owner."""
    pdf = pikepdf.new()
    layout_attrs = pdf.make_indirect(pikepdf.Dictionary(
        O=pikepdf.Name("/Layout"), Placement=pikepdf.Name("/Block")))
    data_cell = _cell(pdf, "10")
    data_cell["/A"] = layout_attrs
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, "Category"), _cell(pdf, "Score")]),
        _row(pdf, [_cell(pdf, "Row A"), data_cell]),
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_rows": [0], "header_cols": [0]}])
    assert result["tables_touched"] == 1

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    r0 = ptw._row_cells(rows[0])
    r1 = ptw._row_cells(rows[1])
    promoted_data_cell = r1[1]
    assert promoted_data_cell.objgen == data_cell.objgen
    assert str(promoted_data_cell["/S"]) == "/TD"

    # the bug: /Headers must NEVER be a top-level key
    assert "/Headers" not in promoted_data_cell

    a = promoted_data_cell["/A"]
    assert isinstance(a, pikepdf.Array)
    assert len(a) == 2
    owners = {str(item["/O"]) for item in a}
    assert owners == {"/Layout", "/Table"}

    layout_entry = next(item for item in a if str(item["/O"]) == "/Layout")
    assert str(layout_entry["/Placement"]) == "/Block"  # pre-existing, untouched

    table_entry = next(item for item in a if str(item["/O"]) == "/Table")
    header_ids = [str(x) for x in table_entry["/Headers"]]
    assert header_ids == [str(r0[1]["/ID"]), str(r1[0]["/ID"])]


def test_hierarchical_pretagged_th_with_no_id_still_gets_wired():
    """Real-file case: opendataloader-pdf already marks the intended header
    row/column /TH (no /A, no /ID) before this module ever runs. "already
    /TH" must not mean "fully done, skip" here — the cell still needs /ID
    and its governed data cells still need /Headers, or the feature does
    nothing on exactly the files it targets."""
    pdf = pikepdf.new()

    def pretagged_th(text):
        return pdf.make_indirect(pikepdf.Dictionary({
            "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/TH"),
            "/K": pikepdf.Array([pdf.make_indirect(pikepdf.Dictionary(
                {"/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/P"),
                 "/ActualText": pikepdf.String(text)}))]),
        }))

    table = _table(pdf, [
        _row(pdf, [pretagged_th("Category"), pretagged_th("Score")]),
        _row(pdf, [pretagged_th("Row A"), _cell(pdf, "10")]),
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_rows": [0], "header_cols": [0]}])
    assert result["tables_touched"] == 1
    assert result["cells_promoted"] == 0  # no /TD -> /TH transitions, all pre-tagged /TH

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    header_cells = ptw._row_cells(rows[0]) + [ptw._row_cells(rows[1])[0]]
    for cell in header_cells:
        assert "/ID" in cell
        assert "/A" in cell

    data_cell = ptw._row_cells(rows[1])[1]
    assert str(data_cell["/S"]) == "/TD"
    assert len(_headers_ids(data_cell)) == 2


def test_generated_ids_are_document_unique_even_on_collision():
    """/ID must be unique among ALL structure elements in the document
    (PDF32000-2 14.7.2), not just the tables this module touches. Pre-seed
    a collision with the exact id our th_<table>_<row>_<col> scheme would
    generate and verify it is detected and suffixed, not silently reused."""
    pdf = pikepdf.new()
    table = _grid(pdf, [["Category", "Score"], ["Row A", "10"]])
    other = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/P"),
        "/ID": pikepdf.String("th_0_0_0"),
    }))
    _doc_with_tables(pdf, [table, other])

    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_rows": [0], "header_cols": [0]}])
    assert result["tables_touched"] == 1

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    corner = ptw._row_cells(rows[0])[0]
    assert str(corner["/ID"]) != "th_0_0_0"
    assert str(corner["/ID"]) == "th_0_0_0_2"

    all_ids = [str(corner["/ID"]), "th_0_0_0"]
    assert len(all_ids) == len(set(all_ids))


def test_hierarchical_table_absent_from_specs_is_untouched():
    pdf = pikepdf.new()
    t0 = _grid(pdf, [["Category", "Score"], ["Row A", "10"]])
    t1 = _grid(pdf, [["X", "Y"], ["1", "2"]])
    _doc_with_tables(pdf, [t0, t1])

    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_rows": [0], "header_cols": [0]}])
    assert result["tables_touched"] == 1

    tables = ptw._find_tables(pdf)
    for row in ptw._table_rows(tables[1]):
        for cell in ptw._row_cells(row):
            assert str(cell["/S"]) == "/TD"
            assert "/ID" not in cell
            _assert_no_headers(cell)


def test_hierarchical_invalid_type_is_skipped_not_raised():
    pdf = pikepdf.new()
    table = _grid(pdf, [["A", "B"], ["1", "2"]])
    _doc_with_tables(pdf, [table])
    result = ptw.promote_headers(pdf, [{"index": 0, "header_rows": ["0"]}])
    assert result["tables_touched"] == 0
    assert result["cells_promoted"] == 0
    assert len(result["skipped"]) == 1
    assert "must be lists of int" in result["skipped"][0]["reason"]


def test_hierarchical_all_empty_lists_is_skipped():
    pdf = pikepdf.new()
    table = _grid(pdf, [["A", "B"], ["1", "2"]])
    _doc_with_tables(pdf, [table])
    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_rows": [], "header_cols": [], "band_rows": []}])
    assert result["tables_touched"] == 0
    assert result["cells_promoted"] == 0
    assert len(result["skipped"]) == 1
    assert "all empty" in result["skipped"][0]["reason"]


def test_hierarchical_idempotent_second_run():
    pdf = pikepdf.new()
    table = _grid(pdf, [["Category", "Score"], ["Row A", "10"]])
    _doc_with_tables(pdf, [table])
    spec = [{"index": 0, "header_rows": [0], "header_cols": [0]}]

    first = ptw.promote_headers(pdf, spec)
    assert first["cells_promoted"] == 3

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    header_cell = ptw._row_cells(rows[0])[0]
    data_cell = ptw._row_cells(rows[1])[1]
    first_id = str(header_cell["/ID"])
    first_headers = _headers_ids(data_cell)

    second = ptw.promote_headers(pdf, spec)
    assert second["cells_promoted"] == 0
    assert second["tables_touched"] == 0

    tables = ptw._find_tables(pdf)
    rows = ptw._table_rows(tables[0])
    header_cell2 = ptw._row_cells(rows[0])[0]
    data_cell2 = ptw._row_cells(rows[1])[1]
    assert str(header_cell2["/ID"]) == first_id
    assert _headers_ids(data_cell2) == first_headers


# ── page_context ─────────────────────────────────────────────────────────

def _fitz_backed_pdf(path, page_placements):
    """Write a real multi-page PDF via fitz.insert_text (searchable,
    extractable text — unlike the raw-content-stream MCID fixtures above,
    which have no /F0 Tf font selection and are opaque to fitz's own text
    engine). `page_placements` is a list (one per page) of (text, x, y)
    triples to insert on that page."""
    doc = fitz.open()
    for placements in page_placements:
        page = doc.new_page(width=300, height=400)
        for text, x, y in placements:
            page.insert_text((x, y), text, fontsize=11)
    doc.save(str(path))
    doc.close()


def _attach_table_on_page(pdf, page_obj, cell_text):
    """Minimal 1-row 1-cell /Table struct tree whose sole cell carries
    /ActualText == cell_text and /Pg == page_obj, wired as the document's
    only table via _doc_with_tables. cell_text should be text that was
    actually drawn on page_obj (via _fitz_backed_pdf) so fitz.search_for
    can locate it."""
    cell = _cell(pdf, cell_text, pg=page_obj.obj)
    row = _row(pdf, [cell])
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([row]), "/Pg": page_obj.obj,
    }))
    _doc_with_tables(pdf, [table])
    return table


def test_list_tables_one_arg_still_works_page_context_all_empty():
    """Backward compatibility (most important case): list_tables(pdf) with
    no `path` must keep returning records exactly as before, with
    page_context present but all-empty rather than omitted."""
    pdf = _two_table_doc()
    tables = ptw.list_tables(pdf)
    assert len(tables) == 2
    for t in tables:
        assert t["page_context"] == {
            "prev_page": "", "next_page": "", "same_page_before": "", "same_page_after": "",
        }


def test_page_context_none_page_gets_all_empty_key():
    """A table whose page never resolves (page: None) still gets a
    page_context key, all empty — never omitted."""
    pdf = _two_table_doc()  # cells carry no /Pg -> page resolves to None
    tables = ptw.list_tables(pdf)
    assert tables[0]["page"] is None
    assert tables[0]["page_context"] == {
        "prev_page": "", "next_page": "", "same_page_before": "", "same_page_after": "",
    }


def test_page_context_prev_page_empty_on_first_page(tmp_path):
    pdf_path = tmp_path / "doc.pdf"
    _fitz_backed_pdf(pdf_path, [
        [("TABLEMARKER", 40, 100)],
        [("Second page filler text.", 40, 100)],
    ])
    pdf = pikepdf.open(pdf_path)
    page_obj = pdf.pages[0]
    _attach_table_on_page(pdf, page_obj, "TABLEMARKER")

    tables = ptw.list_tables(pdf, path=str(pdf_path))
    assert tables[0]["page"] == 1
    assert tables[0]["page_context"]["prev_page"] == ""
    pdf.close()


def test_page_context_next_page_empty_on_last_page(tmp_path):
    pdf_path = tmp_path / "doc.pdf"
    _fitz_backed_pdf(pdf_path, [
        [("First page filler text.", 40, 100)],
        [("TABLEMARKER", 40, 100)],
    ])
    pdf = pikepdf.open(pdf_path)
    page_obj = pdf.pages[1]
    _attach_table_on_page(pdf, page_obj, "TABLEMARKER")

    tables = ptw.list_tables(pdf, path=str(pdf_path))
    assert tables[0]["page"] == 2
    assert tables[0]["page_context"]["next_page"] == ""
    pdf.close()


def test_page_context_prev_page_surfaces_previous_page_text(tmp_path):
    pdf_path = tmp_path / "doc.pdf"
    _fitz_backed_pdf(pdf_path, [
        [("Roadmap conclusion of the rubric on page one.", 40, 100)],
        [("TABLEMARKER", 40, 100)],
    ])
    pdf = pikepdf.open(pdf_path)
    page_obj = pdf.pages[1]
    _attach_table_on_page(pdf, page_obj, "TABLEMARKER")

    tables = ptw.list_tables(pdf, path=str(pdf_path))
    assert tables[0]["page"] == 2
    assert "Roadmap conclusion" in tables[0]["page_context"]["prev_page"]
    pdf.close()


def test_page_context_same_page_before_and_after_split_around_table(tmp_path):
    pdf_path = tmp_path / "doc.pdf"
    _fitz_backed_pdf(pdf_path, [
        [
            ("Before text region alpha bravo.", 40, 100),
            ("TABLEMARKER", 40, 200),
            ("After text region charlie delta.", 40, 300),
        ],
    ])
    pdf = pikepdf.open(pdf_path)
    page_obj = pdf.pages[0]
    _attach_table_on_page(pdf, page_obj, "TABLEMARKER")

    tables = ptw.list_tables(pdf, path=str(pdf_path))
    ctx = tables[0]["page_context"]
    assert "Before text region" in ctx["same_page_before"]
    assert "After text region" not in ctx["same_page_before"]
    assert "After text region" in ctx["same_page_after"]
    assert "Before text region" not in ctx["same_page_after"]
    pdf.close()


def test_page_context_truncates_prev_page_on_word_boundary(tmp_path):
    long_text = " ".join(["word" + str(i) for i in range(200)])  # well over 400 chars
    pdf_path = tmp_path / "doc.pdf"
    _fitz_backed_pdf(pdf_path, [
        [(long_text, 40, 100)],
        [("TABLEMARKER", 40, 100)],
    ])
    pdf = pikepdf.open(pdf_path)
    page_obj = pdf.pages[1]
    _attach_table_on_page(pdf, page_obj, "TABLEMARKER")

    tables = ptw.list_tables(pdf, path=str(pdf_path))
    prev = tables[0]["page_context"]["prev_page"]
    assert len(prev) <= 400
    assert prev != ""
    # never cut mid-word: prev must consist of whole words taken in order
    # from the front of the source text, never a partial trailing token.
    words = long_text.split(" ")
    prev_words = prev.split(" ")
    assert prev_words == words[:len(prev_words)]
    pdf.close()


def test_normalize_ws_collapses_whitespace_runs():
    assert ptw._normalize_ws("a   b\n\nc\t\td") == "a b c d"
    assert ptw._normalize_ws("  leading and trailing  ") == "leading and trailing"


def test_truncate_head_cuts_on_word_boundary_and_respects_limit():
    text = "alpha bravo charlie delta echo foxtrot golf hotel"
    result = ptw._truncate_head(text, 20)
    assert len(result) <= 20
    assert result == "alpha bravo charlie"  # cut back from char 20 to the last space
    assert ptw._truncate_head("short", 20) == "short"


def test_truncate_tail_cuts_on_word_boundary_and_respects_limit():
    text = "alpha bravo charlie delta echo foxtrot golf hotel"
    result = ptw._truncate_tail(text, 20)
    assert len(result) <= 20
    assert result == "foxtrot golf hotel"  # cut forward from the tail to the next space
    assert ptw._truncate_tail("short", 20) == "short"


# ── grid preview (2D layout in the table manifest) ──────────────────────────

def _cell_with_span(pdf, text, colspan=None, rowspan=None, pg=None):
    """A /TD cell like _cell(), plus a /Table-owned /A carrying /ColSpan
    and/or /RowSpan — the single-attribute-dict-with-/O-/Table shape
    _table_attrs's docstring describes (PDF32000-2 14.7.5.4, Table 349)."""
    kids = [pdf.make_indirect(pikepdf.Dictionary(
        {"/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/P"),
         "/ActualText": pikepdf.String(text)}))]
    d = {
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/TD"),
        "/K": pikepdf.Array(kids),
    }
    if pg is not None:
        d["/Pg"] = pg
    attrs = {"/O": pikepdf.Name("/Table")}
    if colspan is not None:
        attrs["/ColSpan"] = pikepdf.Integer(colspan)
    if rowspan is not None:
        attrs["/RowSpan"] = pikepdf.Integer(rowspan)
    d["/A"] = pdf.make_indirect(pikepdf.Dictionary(attrs))
    return pdf.make_indirect(pikepdf.Dictionary(d))


def test_grid_mirrors_simple_table_tags_and_text():
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, "Name"), _cell(pdf, "Score"), _cell(pdf, "Grade")]),
        _row(pdf, [_cell(pdf, "Alice"), _cell(pdf, "9"), _cell(pdf, "A")]),
    ])
    _doc_with_tables(pdf, [table])

    grid = ptw.list_tables(pdf)[0]["grid"]
    assert grid["truncated_rows"] == 0
    assert grid["truncated_cols"] == 0
    assert grid["rows"] == [
        [{"text": "Name", "tag": "TD"}, {"text": "Score", "tag": "TD"}, {"text": "Grade", "tag": "TD"}],
        [{"text": "Alice", "tag": "TD"}, {"text": "9", "tag": "TD"}, {"text": "A", "tag": "TD"}],
    ]


def test_grid_reports_th_tag_after_promotion():
    pdf = _two_table_doc()
    ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    grid = ptw.list_tables(pdf)[0]["grid"]
    assert [c["tag"] for c in grid["rows"][0]] == ["TH", "TH"]
    assert [c["tag"] for c in grid["rows"][1]] == ["TD", "TD"]


def test_grid_ragged_rows_keep_differing_lengths_not_padded():
    """The whole point of the grid: raggedness (a spanning cell with no
    /ColSpan collapsing a row's cell count) must stay visible, not be
    padded away — see _is_ragged / the veraPDF 7.2 guard this signals."""
    pdf, _ = _ragged_table()  # rows of 3, 3, 2 cells
    grid = ptw.list_tables(pdf)[0]["grid"]
    assert [len(r) for r in grid["rows"]] == [3, 3, 2]
    assert grid["truncated_cols"] == 0  # widest row (3) is within _GRID_MAX_COLS (4)


def test_grid_row_truncation_counts_rows_beyond_eight():
    pdf = pikepdf.new()
    rows = [_row(pdf, [_cell(pdf, f"r{i}"), _cell(pdf, "x")]) for i in range(12)]
    table = _table(pdf, rows)
    _doc_with_tables(pdf, [table])

    grid = ptw.list_tables(pdf)[0]["grid"]
    assert len(grid["rows"]) == 8
    assert grid["truncated_rows"] == 4
    assert [r[0]["text"] for r in grid["rows"]] == [f"r{i}" for i in range(8)]


def test_grid_col_truncation_counts_cols_beyond_four_in_widest_previewed_row():
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, f"c{i}") for i in range(6)]),
        _row(pdf, [_cell(pdf, "a"), _cell(pdf, "b")]),
    ])
    _doc_with_tables(pdf, [table])

    grid = ptw.list_tables(pdf)[0]["grid"]
    assert len(grid["rows"][0]) == 4
    assert [c["text"] for c in grid["rows"][0]] == ["c0", "c1", "c2", "c3"]
    assert len(grid["rows"][1]) == 2
    assert grid["truncated_cols"] == 2  # widest previewed row (6 cells) - 4 shown


def test_grid_row_grouped_thead_tbody_matches_ungrouped_equivalent():
    grouped_pdf, _ = _row_grouped_table_doc()
    grouped_grid = ptw.list_tables(grouped_pdf)[0]["grid"]

    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([
            _row(pdf, [_cell(pdf, "Name", pg=page.obj), _cell(pdf, "Score", pg=page.obj)]),
            _row(pdf, [_cell(pdf, "Alice", pg=page.obj), _cell(pdf, "9", pg=page.obj)]),
            _row(pdf, [_cell(pdf, "Bob", pg=page.obj), _cell(pdf, "7", pg=page.obj)]),
        ]),
        "/Pg": page.obj,
    }))
    _doc_with_tables(pdf, [table])
    ungrouped_grid = ptw.list_tables(pdf)[0]["grid"]

    assert grouped_grid == ungrouped_grid


def test_grid_empty_when_no_cell_bearing_rows():
    pdf = pikepdf.new()
    table = _table(pdf, [_link_row(pdf)])  # only a junk /Link row, no cells
    _doc_with_tables(pdf, [table])

    grid = ptw.list_tables(pdf)[0]["grid"]
    assert grid == {"rows": [], "truncated_rows": 0, "truncated_cols": 0}


def test_grid_colspan_rowspan_surface_when_present_omitted_when_absent_or_one():
    pdf = pikepdf.new()
    spanning = _cell_with_span(pdf, "Category", colspan=2, rowspan=3)
    explicit_one = _cell_with_span(pdf, "One", colspan=1, rowspan=1)  # ==1 -> omitted
    ordinary = _cell(pdf, "Ordinary")  # no /A at all -> omitted
    table = _table(pdf, [_row(pdf, [spanning, explicit_one, ordinary])])
    _doc_with_tables(pdf, [table])

    grid_row = ptw.list_tables(pdf)[0]["grid"]["rows"][0]
    assert grid_row[0] == {"text": "Category", "tag": "TD", "colspan": 2, "rowspan": 3}
    assert grid_row[1] == {"text": "One", "tag": "TD"}
    assert grid_row[2] == {"text": "Ordinary", "tag": "TD"}


def test_grid_cell_text_truncated_to_40_chars_on_word_boundary():
    pdf = pikepdf.new()
    long_text = "alpha bravo charlie delta echo foxtrot golf hotel"
    table = _table(pdf, [_row(pdf, [_cell(pdf, long_text)])])
    _doc_with_tables(pdf, [table])

    cell_text = ptw.list_tables(pdf)[0]["grid"]["rows"][0][0]["text"]
    assert cell_text == ptw._truncate_head(long_text, ptw._GRID_CELL_CHARS)
    assert len(cell_text) <= ptw._GRID_CELL_CHARS


def test_grid_additive_first_row_text_and_first_col_text_unchanged():
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    rows = [
        _row(pdf, [_cell(pdf, "Term", pg=page.obj), _cell(pdf, "Def", pg=page.obj)]),
        _row(pdf, [_cell(pdf, "Fall", pg=page.obj), _cell(pdf, "x", pg=page.obj)]),
        _row(pdf, [_cell(pdf, "Spring", pg=page.obj), _cell(pdf, "y", pg=page.obj)]),
    ]
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array(rows), "/Pg": page.obj,
    }))
    _doc_with_tables(pdf, [table])

    t = ptw.list_tables(pdf)[0]
    assert t["first_row_text"] == ["Term", "Def"]
    assert t["first_col_text"] == ["Term", "Fall", "Spring"]
    assert "grid" in t and t["grid"]["rows"]


# ── span-aware ragged determination (/ColSpan//RowSpan, veraPDF 7.2) ────────

def _cell_with_raw_attr(pdf, text, key, value):
    """A /TD cell like _cell_with_span, but with a single /Table-owned /A
    key set to a raw pikepdf value of the caller's choosing — used to drive
    malformed /ColSpan//RowSpan values (0, negative, non-integer) through
    _cell_span without going through _cell_with_span's int()-only inputs."""
    kids = [pdf.make_indirect(pikepdf.Dictionary(
        {"/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/P"),
         "/ActualText": pikepdf.String(text)}))]
    d = {
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/TD"),
        "/K": pikepdf.Array(kids),
    }
    d["/A"] = pdf.make_indirect(pikepdf.Dictionary({"/O": pikepdf.Name("/Table"), key: value}))
    return pdf.make_indirect(pikepdf.Dictionary(d))


def test_short_row_only_via_colspan_is_not_ragged():
    """Row 0 has 2 raw cells but each carries /ColSpan 2 (effective width
    4); row 1 has 4 ordinary cells (effective width 4). Raw cell counts
    ([2, 4]) would have read as ragged under the old raw-count-only check —
    the whole point of this fix is that a row that is short only because a
    cell spans multiple columns is not really narrower at all."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [
            _cell_with_span(pdf, "Header A/B", colspan=2),
            _cell_with_span(pdf, "Header C/D", colspan=2),
        ]),
        _row(pdf, [_cell(pdf, "a"), _cell(pdf, "b"), _cell(pdf, "c"), _cell(pdf, "d")]),
    ])
    _doc_with_tables(pdf, [table])

    rows = ptw._table_rows(table)
    assert ptw._cell_row_counts(rows) == [2, 4]  # raw counts still differ
    assert ptw._effective_col_counts(rows) == [4, 4]
    assert ptw._is_ragged(rows) is False
    assert ptw.list_tables(pdf)[0]["ragged"] is False


def test_short_row_only_via_inherited_rowspan_is_not_ragged():
    """Row 0's first cell carries /RowSpan 2, so it also occupies a column
    in row 1 — row 1 has only 1 raw cell of its own but inherits row 0's
    spanning column, landing at the same effective width as row 0. Mirrors
    the real Type I/II-errors-table shape (a /RowSpan 2 'Truth' cell whose
    governed row would otherwise misread as narrower)."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [
            _cell_with_span(pdf, "Truth", rowspan=2),
            _cell(pdf, "Predicted"),
        ]),
        _row(pdf, [_cell(pdf, "value")]),
    ])
    _doc_with_tables(pdf, [table])

    rows = ptw._table_rows(table)
    assert ptw._cell_row_counts(rows) == [2, 1]  # raw counts differ
    assert ptw._effective_col_counts(rows) == [2, 2]  # row 1 inherits col 0
    assert ptw._is_ragged(rows) is False
    assert ptw.list_tables(pdf)[0]["ragged"] is False


def test_genuinely_ragged_table_with_no_spans_stays_ragged():
    """A real column-count mismatch with no /ColSpan//RowSpan anywhere is
    still ragged after the fix — the fix narrows false positives, it does
    not stop detecting real raggedness."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, "A"), _cell(pdf, "B"), _cell(pdf, "C")]),
        _row(pdf, [_cell(pdf, "x"), _cell(pdf, "y")]),
    ])
    _doc_with_tables(pdf, [table])

    rows = ptw._table_rows(table)
    assert ptw._effective_col_counts(rows) == [3, 2]
    assert ptw._is_ragged(rows) is True
    assert ptw.list_tables(pdf)[0]["ragged"] is True


def test_promote_headers_still_refuses_genuinely_ragged_table_by_default():
    """Regression: the guard's policy is unchanged — a table with a real
    column-count mismatch and no spans is still refused without force."""
    pdf, _ = _ragged_table()
    result = ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    assert result["cells_promoted"] == 0
    assert result["tables_touched"] == 0
    assert len(result["skipped"]) == 1
    assert "ragged" in result["skipped"][0]["reason"]


def test_promote_headers_still_honors_force_on_genuinely_ragged_table():
    """Regression: force still overrides the guard on a genuinely ragged
    table (no spans to make it not-ragged)."""
    pdf, _ = _ragged_table()
    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_row": True, "force": True}])
    assert result["tables_touched"] == 1
    assert result["cells_promoted"] == 3


def test_span_carrying_table_previously_refused_now_promotes_without_force():
    """A table shaped like test_short_row_only_via_colspan_is_not_ragged
    above — raw cell counts differ (2 vs 4) but /ColSpan makes the real
    column widths equal. Before this fix promote_headers would have
    refused it (raw counts differ -> ragged) unless "force" was passed;
    now the classification is correct and it promotes with no force."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [
            _cell_with_span(pdf, "Header A/B", colspan=2),
            _cell_with_span(pdf, "Header C/D", colspan=2),
        ]),
        _row(pdf, [_cell(pdf, "a"), _cell(pdf, "b"), _cell(pdf, "c"), _cell(pdf, "d")]),
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    assert result["skipped"] == []
    assert result["tables_touched"] == 1
    assert result["cells_promoted"] == 2

    header_row = ptw._row_cells(ptw._table_rows(table)[0])
    for cell in header_row:
        assert str(cell["/S"]) == "/TH"


def test_malformed_span_values_treated_as_one_never_raise():
    """0, a negative int, a non-integer value, and a missing key all fall
    back to 1 for both /ColSpan and /RowSpan — never raise, never go
    negative."""
    pdf = pikepdf.new()
    zero = _cell_with_raw_attr(pdf, "zero", "/ColSpan", pikepdf.Integer(0))
    negative = _cell_with_raw_attr(pdf, "neg", "/ColSpan", pikepdf.Integer(-3))
    non_integer = _cell_with_raw_attr(pdf, "bad", "/ColSpan", pikepdf.String("two"))
    zero_row = _cell_with_raw_attr(pdf, "zero_row", "/RowSpan", pikepdf.Integer(0))
    negative_row = _cell_with_raw_attr(pdf, "neg_row", "/RowSpan", pikepdf.Integer(-1))
    missing = _cell(pdf, "missing")  # no /A at all

    for cell in (zero, negative, non_integer, missing):
        assert ptw._cell_span(cell) == (1, 1)
    for cell in (zero_row, negative_row):
        assert ptw._cell_span(cell) == (1, 1)

    table = _table(pdf, [_row(pdf, [zero, negative, non_integer, missing])])
    _doc_with_tables(pdf, [table])
    rows = ptw._table_rows(table)
    assert ptw._effective_col_counts(rows) == [4]  # 1+1+1+1, no crash
    assert ptw._is_ragged(rows) is False  # single row, trivially uniform


# ── unambiguous /ColSpan synthesis for single-cell short rows (v7.76) ──────

def test_hierarchical_single_cell_banner_row_synthesizes_colspan_and_promotes_without_force():
    """The PMP-exam-outline shape (module/promote_headers docstrings): a
    single-cell banner row narrower than the data rows below it. With
    exactly one cell in the short row, its /ColSpan can only be the
    table's target width — synthesized before the ragged check runs, so
    this table now promotes with no "force" at all."""
    pdf = pikepdf.new()
    banner = _cell(pdf, "DOMAIN I PEOPLE—33%")
    table = _table(pdf, [
        _row(pdf, [banner]),                                               # 0: 1-cell banner
        _row(pdf, [_cell(pdf, "Task 1"), _cell(pdf, "Manage conflict")]),  # 1: data, 2 cells
        _row(pdf, [_cell(pdf, "Task 2"), _cell(pdf, "Lead a team")]),      # 2: data, 2 cells
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(pdf, [{"index": 0, "header_rows": [0]}])
    assert result["skipped"] == []
    assert result["tables_touched"] == 1
    assert result["spans_synthesized"] == 1

    attrs = ptw._read_table_owned_attrs(banner)
    assert attrs is not None
    assert int(attrs["/ColSpan"]) == 2
    assert str(banner["/S"]) == "/TH"


def test_legacy_header_row_path_also_gets_span_synthesis():
    """Span synthesis runs ahead of BOTH ragged checks, not just the
    hierarchical one — the legacy header_row/header_col boolean path gets
    it too."""
    pdf = pikepdf.new()
    banner = _cell(pdf, "Banner")
    table = _table(pdf, [
        _row(pdf, [banner]),
        _row(pdf, [_cell(pdf, "a"), _cell(pdf, "b")]),
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    assert result["skipped"] == []
    assert result["spans_synthesized"] == 1
    assert result["tables_touched"] == 1
    assert int(ptw._read_table_owned_attrs(banner)["/ColSpan"]) == 2
    assert str(banner["/S"]) == "/TH"


def test_synthesize_colspans_skips_short_row_with_two_or_more_cells():
    """The critical safety test: a short row with 2+ cells is genuinely
    ambiguous — which cell spans, and by how much, cannot be inferred from
    row shape alone — so _synthesize_colspans must never touch it, and the
    table must still read as ragged afterward."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, "A"), _cell(pdf, "B")]),
        _row(pdf, [_cell(pdf, "1"), _cell(pdf, "2"), _cell(pdf, "3")]),
    ])
    _doc_with_tables(pdf, [table])
    rows = ptw._table_rows(table)

    synthesized = ptw._synthesize_colspans(pdf, rows)
    assert synthesized == 0
    for cell in ptw._row_cells(rows[0]):
        assert ptw._read_table_owned_attrs(cell) is None
    assert ptw._is_ragged(rows) is True


def test_table_absent_from_specs_gets_no_spans_written():
    """promote_headers never touches a table not named in specs — including
    span synthesis, which only runs for the table currently being
    promoted. Table 0 here has the single-cell-banner shape synthesis
    would otherwise fix, but it is never named in the spec list."""
    pdf = pikepdf.new()
    banner = _cell(pdf, "Banner")
    untouched = _table(pdf, [
        _row(pdf, [banner]),
        _row(pdf, [_cell(pdf, "a"), _cell(pdf, "b")]),
    ])
    well_formed = _table(pdf, [
        _row(pdf, [_cell(pdf, "Name"), _cell(pdf, "Score")]),
        _row(pdf, [_cell(pdf, "Alice"), _cell(pdf, "9")]),
    ])
    _doc_with_tables(pdf, [untouched, well_formed])

    result = ptw.promote_headers(pdf, [{"index": 1, "header_row": True}])
    assert result["spans_synthesized"] == 0
    assert ptw._read_table_owned_attrs(banner) is None
    assert ptw.list_tables(pdf)[0]["ragged"] is True  # table 0 unaffected


def test_existing_correct_colspan_not_rewritten_table_promotes():
    """A lone cell that already carries the correct /ColSpan (e.g. from an
    earlier promote_headers call, or written by the base tagger) is left
    untouched — not re-written, not re-counted — because its effective
    width already reads at the table's target, so it never even reaches
    the "short row" branch. The table (already non-ragged because of it)
    promotes normally."""
    pdf = pikepdf.new()
    banner = _cell_with_span(pdf, "Banner", colspan=2)
    table = _table(pdf, [
        _row(pdf, [banner]),
        _row(pdf, [_cell(pdf, "a"), _cell(pdf, "b")]),
    ])
    _doc_with_tables(pdf, [table])
    rows = ptw._table_rows(table)

    synthesized = ptw._synthesize_colspans(pdf, rows)
    assert synthesized == 0
    assert int(ptw._read_table_owned_attrs(banner)["/ColSpan"]) == 2

    result = ptw.promote_headers(pdf, [{"index": 0, "header_rows": [0]}])
    assert result["skipped"] == []
    assert result["spans_synthesized"] == 0
    assert result["tables_touched"] == 1


def test_existing_conflicting_colspan_left_alone_table_refused():
    """A lone cell whose existing /ColSpan does NOT match the value
    synthesis would compute (2, wrong — needs 3) is left exactly as
    recorded, never silently overwritten. The row stays exactly as ragged
    as it already read, and promotion is refused without force."""
    pdf = pikepdf.new()
    banner = _cell_with_span(pdf, "Banner", colspan=2)  # wrong: table needs 3
    table = _table(pdf, [
        _row(pdf, [banner]),
        _row(pdf, [_cell(pdf, "a"), _cell(pdf, "b"), _cell(pdf, "c")]),
    ])
    _doc_with_tables(pdf, [table])
    rows = ptw._table_rows(table)

    synthesized = ptw._synthesize_colspans(pdf, rows)
    assert synthesized == 0
    assert int(ptw._read_table_owned_attrs(banner)["/ColSpan"]) == 2  # unchanged
    assert ptw._is_ragged(rows) is True

    result = ptw.promote_headers(pdf, [{"index": 0, "header_rows": [0]}])
    assert result["spans_synthesized"] == 0
    assert result["tables_touched"] == 0
    assert len(result["skipped"]) == 1
    assert "ragged" in result["skipped"][0]["reason"]


def test_force_true_still_bypasses_refusal_for_ambiguous_multicell_short_row():
    """A short row with 2+ cells stays ragged (never synthesized), but
    "force": true still overrides the refusal exactly as it did before
    this feature existed."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, "A"), _cell(pdf, "B")]),
        _row(pdf, [_cell(pdf, "1"), _cell(pdf, "2"), _cell(pdf, "3")]),
    ])
    _doc_with_tables(pdf, [table])

    result = ptw.promote_headers(
        pdf, [{"index": 0, "header_rows": [0], "force": True}])
    assert result["spans_synthesized"] == 0
    assert result["tables_touched"] == 1
    assert result["skipped"] == []


def test_spans_synthesized_accumulates_across_multiple_tables_in_one_call():
    """The synthesized count is reported in the return dict, summed across
    every table promoted in this call."""
    pdf = pikepdf.new()
    banner1 = _cell(pdf, "Banner 1")
    t0 = _table(pdf, [
        _row(pdf, [banner1]),
        _row(pdf, [_cell(pdf, "a"), _cell(pdf, "b")]),
    ])
    banner2 = _cell(pdf, "Banner 2")
    t1 = _table(pdf, [
        _row(pdf, [banner2]),
        _row(pdf, [_cell(pdf, "x"), _cell(pdf, "y"), _cell(pdf, "z")]),
    ])
    _doc_with_tables(pdf, [t0, t1])

    result = ptw.promote_headers(pdf, [
        {"index": 0, "header_rows": [0]},
        {"index": 1, "header_rows": [0]},
    ])
    assert result["spans_synthesized"] == 2
    assert result["tables_touched"] == 2
    assert int(ptw._read_table_owned_attrs(banner1)["/ColSpan"]) == 2
    assert int(ptw._read_table_owned_attrs(banner2)["/ColSpan"]) == 3


def test_already_non_ragged_table_gets_zero_spans_synthesized():
    """Regression: a uniform table (no short rows at all) is unaffected —
    synthesis only ever touches a row narrower than the table's widest
    row."""
    pdf = pikepdf.new()
    table = _table(pdf, [
        _row(pdf, [_cell(pdf, "Name"), _cell(pdf, "Score")]),
        _row(pdf, [_cell(pdf, "Alice"), _cell(pdf, "9")]),
    ])
    _doc_with_tables(pdf, [table])
    rows = ptw._table_rows(table)

    synthesized = ptw._synthesize_colspans(pdf, rows)
    assert synthesized == 0
    for row in rows:
        for cell in ptw._row_cells(row):
            assert ptw._read_table_owned_attrs(cell) is None

    result = ptw.promote_headers(pdf, [{"index": 0, "header_row": True}])
    assert result["spans_synthesized"] == 0
    assert result["tables_touched"] == 1
