"""Tests for scripts/pdf_table_weave.py convert_tables_to_figures.

CI-light: pure pikepdf fixtures built in-memory + saved/reopened (matches
tests/test_vector_figures.py's proven ParentTree-persistence pattern), no
vendored ODL/java/verapdf.
"""

import os
import sys

import pikepdf

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

import pdf_table_weave as ptw  # noqa: E402


def _mcid_cell(pdf, mcid, pg):
    return pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/TD"),
        "/K": pikepdf.Array([mcid]), "/Pg": pg,
    }))


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)."""
    return pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name(tag),
        "/K": pikepdf.Array(rows),
    }))


def _plain_table(pdf, pg):
    """A second, untouched /Table — opt-in negative fixture."""
    cell = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/TD"),
        "/K": pikepdf.Array([]), "/Pg": pg,
    }))
    row = _row(pdf, [cell])
    return pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([row]), "/Pg": pg,
    }))


def _build_pdf(path, with_parenttree=True):
    """1-page doc, 2 /Table struct elems (document order matches list index):
    index 0 = a mis-detected chart (cells carry MCIDs 0/1, a /Caption with
    MCID 2 excluded from collection), index 1 = a plain untouched table.
    With with_parenttree=True, page.StructParents=0 and a 3-slot ParentTree
    /Nums array (MCIDs 0,1,2) are wired so the remap path is exercised.
    """
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))

    cell0 = _mcid_cell(pdf, 0, page.obj)
    cell1 = _mcid_cell(pdf, 1, page.obj)
    row = _row(pdf, [cell0, cell1])
    caption = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Caption"),
        "/K": pikepdf.Array([2]), "/Pg": page.obj,
    }))
    t0 = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([caption, row]), "/Pg": page.obj,
    }))
    t1 = _plain_table(pdf, page.obj)

    doc_elem = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Document"),
        "/K": pikepdf.Array([t0, t1]),
    }))
    t0["/P"] = doc_elem
    t1["/P"] = doc_elem

    root_dict = {
        "/Type": pikepdf.Name("/StructTreeRoot"),
        "/K": pikepdf.Array([doc_elem]),
    }
    if with_parenttree:
        nums_arr = pikepdf.Array([None, None, None])
        root_dict["/ParentTree"] = pikepdf.Dictionary({"/Nums": pikepdf.Array([0, nums_arr])})
        root_dict["/ParentTreeNextKey"] = 1
    pdf.Root.StructTreeRoot = pdf.make_indirect(pikepdf.Dictionary(root_dict))
    doc_elem["/P"] = pdf.Root.StructTreeRoot
    if with_parenttree:
        page.StructParents = 0
    pdf.save(path)


def _fig_and_table1(pdf):
    doc = ptw._kids(pdf.Root.StructTreeRoot)[0]
    kids = ptw._kids(doc)
    fig = next(k for k in kids if str(k.get("/S", "")) == "/Figure")
    table1 = next(k for k in kids if str(k.get("/S", "")) == "/Table")
    return fig, table1


def test_basic_conversion_creates_figure_with_mcids(tmp_path):
    p = tmp_path / "basic.pdf"
    _build_pdf(p)
    pdf = pikepdf.open(p)

    result = ptw.convert_tables_to_figures(pdf, [{"index": 0, "alt": "a plot"}])

    assert result["converted"] == 1
    assert result["figures_created"] == 1
    assert result["skipped"] == []

    fig, table1 = _fig_and_table1(pdf)
    assert str(fig["/S"]) == "/Figure"
    assert str(fig["/Alt"]) == "a plot"
    mcids = sorted(int(item) for item in fig["/K"] if not isinstance(item, pikepdf.Dictionary))
    assert mcids == [0, 1]
    # index-1 table (opt-in negative) untouched
    assert str(table1["/S"]) == "/Table"


def test_alt_required_missing_and_blank(tmp_path):
    p = tmp_path / "alt.pdf"
    _build_pdf(p)
    pdf = pikepdf.open(p)

    result = ptw.convert_tables_to_figures(pdf, [
        {"index": 0},
        {"index": 0, "alt": "   "},
    ])

    assert result["converted"] == 0
    assert result["figures_created"] == 0
    assert len(result["skipped"]) == 2
    for s in result["skipped"]:
        assert "alt" in s["reason"]

    doc = ptw._kids(pdf.Root.StructTreeRoot)[0]
    kinds = sorted(str(k["/S"]) for k in ptw._kids(doc))
    assert kinds == ["/Table", "/Table"]  # untouched


def test_caption_preserved_and_not_flattened(tmp_path):
    p = tmp_path / "caption.pdf"
    _build_pdf(p)
    pdf = pikepdf.open(p)

    ptw.convert_tables_to_figures(pdf, [{"index": 0, "alt": "a plot"}])
    fig, _ = _fig_and_table1(pdf)

    captions = [item for item in fig["/K"] if isinstance(item, pikepdf.Dictionary)]
    assert len(captions) == 1
    caption = captions[0]
    assert str(caption["/S"]) == "/Caption"
    assert caption["/P"].objgen == fig.objgen
    # caption's own MCID (2) is not among the figure's direct MCID list
    mcids = [int(item) for item in fig["/K"] if not isinstance(item, pikepdf.Dictionary)]
    assert 2 not in mcids


def test_parenttree_remap_points_moved_mcids_at_figure(tmp_path):
    p = tmp_path / "remap.pdf"
    _build_pdf(p)
    pdf = pikepdf.open(p)

    ptw.convert_tables_to_figures(pdf, [{"index": 0, "alt": "a plot"}])
    fig, _ = _fig_and_table1(pdf)

    page = pdf.pages[0]
    arr = pdf.Root.StructTreeRoot.ParentTree.Nums[1]
    assert arr[0].objgen == fig.objgen
    assert arr[1].objgen == fig.objgen
    # caption's MCID (2) was never in mcid_pairs -> untouched (still Null)
    assert arr[2] is None


def test_optin_negative_second_table_stays_table(tmp_path):
    p = tmp_path / "optin.pdf"
    _build_pdf(p)
    pdf = pikepdf.open(p)

    ptw.convert_tables_to_figures(pdf, [{"index": 0, "alt": "a plot"}])
    _, table1 = _fig_and_table1(pdf)
    assert str(table1["/S"]) == "/Table"


def test_no_structparents_still_converts_without_raising(tmp_path):
    p = tmp_path / "noparenttree.pdf"
    _build_pdf(p, with_parenttree=False)
    pdf = pikepdf.open(p)

    result = ptw.convert_tables_to_figures(pdf, [{"index": 0, "alt": "a plot"}])

    assert result["converted"] == 1
    fig, _ = _fig_and_table1(pdf)
    mcids = sorted(int(item) for item in fig["/K"] if not isinstance(item, pikepdf.Dictionary))
    assert mcids == [0, 1]


def test_out_of_range_index_skipped_not_raised(tmp_path):
    p = tmp_path / "oor.pdf"
    _build_pdf(p)
    pdf = pikepdf.open(p)

    result = ptw.convert_tables_to_figures(pdf, [{"index": 99, "alt": "x"}])

    assert result["converted"] == 0
    assert result["skipped"] == [{"spec": {"index": 99, "alt": "x"}, "reason": "index out of range"}]
    doc = ptw._kids(pdf.Root.StructTreeRoot)[0]
    kinds = sorted(str(k["/S"]) for k in ptw._kids(doc))
    assert kinds == ["/Table", "/Table"]


# ── Blocker: OBJR/annotation ref inside a cell (adversarial review) ────────

def _build_pdf_with_link_objr(path):
    """Single /Table whose one /TD carries an MCID AND a /Link StructElem
    child whose /K is a bare OBJR dict (no /S, no /MCID) — the shape that
    used to hit int(kid) -> TypeError."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    annot = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/Annot"), "/Subtype": pikepdf.Name("/Link"),
        "/Rect": pikepdf.Array([0, 0, 10, 10]),
    }))
    objr = pikepdf.Dictionary({"/Type": pikepdf.Name("/OBJR"), "/Obj": annot})
    link_elem = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Link"),
        "/K": objr, "/Pg": page.obj,
    }))
    cell0 = _mcid_cell(pdf, 0, page.obj)
    cell1 = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/TD"),
        "/K": pikepdf.Array([1, link_elem]), "/Pg": page.obj,
    }))
    row = _row(pdf, [cell0, cell1])
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([row]), "/Pg": page.obj,
    }))
    doc_elem = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Document"),
        "/K": pikepdf.Array([table]),
    }))
    table["/P"] = doc_elem
    root = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructTreeRoot"), "/K": pikepdf.Array([doc_elem]),
    }))
    pdf.Root.StructTreeRoot = root
    doc_elem["/P"] = root
    pdf.save(path)


def test_link_objr_cell_does_not_crash_and_ref_survives_in_figure(tmp_path):
    p = tmp_path / "objr.pdf"
    _build_pdf_with_link_objr(p)
    pdf = pikepdf.open(p)

    result = ptw.convert_tables_to_figures(pdf, [{"index": 0, "alt": "a plot"}])

    assert result["converted"] == 1
    assert result["skipped"] == []
    doc = ptw._kids(pdf.Root.StructTreeRoot)[0]
    fig = ptw._kids(doc)[0]
    assert str(fig["/S"]) == "/Figure"
    objr_items = [item for item in fig["/K"]
                  if isinstance(item, pikepdf.Dictionary) and str(item.get("/Type", "")) == "/OBJR"]
    assert len(objr_items) == 1
    assert str(objr_items[0]["/Obj"]["/Subtype"]) == "/Link"
    bare_ints = sorted(int(item) for item in fig["/K"] if not isinstance(item, pikepdf.Dictionary))
    assert bare_ints == [0, 1]


# ── Multi-page table: off-page MCIDs must be emitted as MCR, not bare int ──

def _build_pdf_multipage(path):
    """One /Table spanning 2 pages: page A has 2 cells (mcid 0, 1), page B
    has 1 cell (mcid 0, reused number — MCIDs are page-relative). Page A is
    the modal/representative page."""
    pdf = pikepdf.new()
    page_a = pdf.add_blank_page(page_size=(200, 200))
    page_b = pdf.add_blank_page(page_size=(200, 200))
    cell_a0 = _mcid_cell(pdf, 0, page_a.obj)
    cell_a1 = _mcid_cell(pdf, 1, page_a.obj)
    cell_b0 = _mcid_cell(pdf, 0, page_b.obj)
    row_a = _row(pdf, [cell_a0, cell_a1])
    row_b = _row(pdf, [cell_b0])
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([row_a, row_b]), "/Pg": page_a.obj,
    }))
    doc_elem = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Document"),
        "/K": pikepdf.Array([table]),
    }))
    table["/P"] = doc_elem
    nums_a = pikepdf.Array([None, None])
    nums_b = pikepdf.Array([None])
    root = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructTreeRoot"), "/K": pikepdf.Array([doc_elem]),
        "/ParentTree": pikepdf.Dictionary({"/Nums": pikepdf.Array([0, nums_a, 1, nums_b])}),
        "/ParentTreeNextKey": 2,
    }))
    pdf.Root.StructTreeRoot = root
    doc_elem["/P"] = root
    page_a.StructParents = 0
    page_b.StructParents = 1
    pdf.save(path)


def test_multipage_table_offpage_mcids_emitted_as_mcr(tmp_path):
    p = tmp_path / "multipage.pdf"
    _build_pdf_multipage(p)
    pdf = pikepdf.open(p)

    result = ptw.convert_tables_to_figures(pdf, [{"index": 0, "alt": "a plot"}])
    assert result["converted"] == 1

    doc = ptw._kids(pdf.Root.StructTreeRoot)[0]
    fig = ptw._kids(doc)[0]
    page_a, page_b = pdf.pages[0], pdf.pages[1]
    assert fig["/Pg"].objgen == page_a.obj.objgen  # 2-vote modal page wins

    bare_ints = sorted(int(item) for item in fig["/K"] if not isinstance(item, pikepdf.Dictionary))
    assert bare_ints == [0, 1]  # page-A MCIDs: same page as figure -> bare int

    mcrs = [item for item in fig["/K"]
            if isinstance(item, pikepdf.Dictionary) and str(item.get("/Type", "")) == "/MCR"]
    assert len(mcrs) == 1
    assert int(mcrs[0]["/MCID"]) == 0
    assert mcrs[0]["/Pg"].objgen == page_b.obj.objgen  # off-page MCID -> explicit /Pg

    nums = pdf.Root.StructTreeRoot.ParentTree.Nums
    arr_a, arr_b = nums[1], nums[3]
    assert arr_a[0].objgen == fig.objgen
    assert arr_a[1].objgen == fig.objgen
    assert arr_b[0].objgen == fig.objgen  # page-B ParentTree entry also remapped


# ── Multiple /Caption children: all preserved, no orphan /Table ────────────

def _build_pdf_two_captions(path):
    """Single /Table with TWO /Caption children — a table can carry more
    than one. ParentTree entries for both caption MCIDs pre-point at the
    captions themselves (the realistic pre-conversion state a tagger
    produces), so a dropped caption's dangling /P->table chain would keep
    the detached /Table reachable via ParentTree after save."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    cell0 = _mcid_cell(pdf, 0, page.obj)
    cell1 = _mcid_cell(pdf, 1, page.obj)
    row = _row(pdf, [cell0, cell1])
    cap1 = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Caption"),
        "/K": pikepdf.Array([2]), "/Pg": page.obj,
    }))
    cap2 = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Caption"),
        "/K": pikepdf.Array([3]), "/Pg": page.obj,
    }))
    table = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Table"),
        "/K": pikepdf.Array([cap1, row, cap2]), "/Pg": page.obj,
    }))
    cap1["/P"] = table
    cap2["/P"] = table
    doc_elem = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Document"),
        "/K": pikepdf.Array([table]),
    }))
    table["/P"] = doc_elem
    nums = pikepdf.Array([None, None, cap1, cap2])
    root = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructTreeRoot"), "/K": pikepdf.Array([doc_elem]),
        "/ParentTree": pikepdf.Dictionary({"/Nums": pikepdf.Array([0, nums])}),
        "/ParentTreeNextKey": 1,
    }))
    pdf.Root.StructTreeRoot = root
    doc_elem["/P"] = root
    page.StructParents = 0
    pdf.save(path)


def test_two_captions_both_preserved_no_orphan_table_after_save(tmp_path):
    p = tmp_path / "twocap.pdf"
    _build_pdf_two_captions(p)
    pdf = pikepdf.open(p)

    result = ptw.convert_tables_to_figures(pdf, [{"index": 0, "alt": "a plot"}])
    assert result["converted"] == 1

    doc = ptw._kids(pdf.Root.StructTreeRoot)[0]
    fig = ptw._kids(doc)[0]
    assert str(fig["/S"]) == "/Figure"
    captions_in_fig = [item for item in fig["/K"]
                       if isinstance(item, pikepdf.Dictionary) and str(item.get("/S", "")) == "/Caption"]
    assert len(captions_in_fig) == 2
    for cap in captions_in_fig:
        assert cap["/P"].objgen == fig.objgen

    out = tmp_path / "twocap_out.pdf"
    pdf.save(out)
    reopened = pikepdf.open(out)
    assert ptw._find_tables(reopened) == []


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

def _build_pdf_row_grouped(path):
    """/Table -> /THead (1 row, MCID 0) + /TBody (1 row, MCID 1) — verifies
    the MCID collector descends into row groups, same as a real
    opendataloader-tagged table."""
    pdf = pikepdf.new()
    page = pdf.add_blank_page(page_size=(200, 200))
    thead = _group(pdf, "/THead", [_row(pdf, [_mcid_cell(pdf, 0, page.obj)])])
    tbody = _group(pdf, "/TBody", [_row(pdf, [_mcid_cell(pdf, 1, 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_elem = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructElem"), "/S": pikepdf.Name("/Document"),
        "/K": pikepdf.Array([table]),
    }))
    table["/P"] = doc_elem
    root = pdf.make_indirect(pikepdf.Dictionary({
        "/Type": pikepdf.Name("/StructTreeRoot"), "/K": pikepdf.Array([doc_elem]),
    }))
    pdf.Root.StructTreeRoot = root
    doc_elem["/P"] = root
    pdf.save(path)


def test_row_grouped_table_collects_mcids_from_inside_groups(tmp_path):
    p = tmp_path / "rowgrouped.pdf"
    _build_pdf_row_grouped(p)
    pdf = pikepdf.open(p)

    result = ptw.convert_tables_to_figures(pdf, [{"index": 0, "alt": "a plot"}])
    assert result["converted"] == 1
    assert result["skipped"] == []

    doc = ptw._kids(pdf.Root.StructTreeRoot)[0]
    fig = ptw._kids(doc)[0]
    assert str(fig["/S"]) == "/Figure"
    mcids = sorted(int(item) for item in fig["/K"] if not isinstance(item, pikepdf.Dictionary))
    assert mcids == [0, 1]
