"""Generate synthetic PDFs that reproduce the Set-of-Mark over-merge defect.

Modeled on the real offending table — VISA_991_Q126.pdf p11, table 10 "Visa
Non-GAAP Financial Measures (unaudited)": two (here also three) vertically
stacked Non-GAAP reconciliations, one per period, that the page-scale grid
locator fuses into a single region.

The shape that triggers the fusion, read off the real table:

  * each period block carries a CENTERED spanning super-header over the numeric
    columns ("Three Months Ended December 31, 2025"), with a rule under it — it
    reads as an in-table section divider, not a new-table title, so the locator
    does not break the region there;
  * a WIDE, dense multi-column body (Operating Expenses, Non-operating Income,
    Income Tax Provision, Effective Tax Rate, Net Income, Diluted EPS) with
    multi-line column headers, identical between blocks;
  * tight vertical stacking with only a thin blank gap (the seam), a shared
    "(in millions ...)" caption, and shared footnotes.

Earlier attempts used a left-aligned single-line period title (read as a title
-> locator split) or a single shared header (fused, but the lower block lost its
header on re-extraction). This repeated-header-with-centered-super-header shape
both fuses AND keeps each block's own header, so each sub-region re-extracts
cleanly — matching the real case.

Run: uv run python experiments/que262/generate_synthetic_stacks.py
Verify the fusion with verify_overmerge.py.
"""

from __future__ import annotations

import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Tuple

import fitz

PAGE_W, PAGE_H = 612.0, 792.0  # US Letter, points
LEFT = 72.0
RIGHT = 540.0
DATA_LEFT = 232.0  # numeric columns start here; the label column is LEFT..DATA_LEFT

VISA_BLUE = (0.05, 0.15, 0.55)
BLACK = (0.0, 0.0, 0.0)
GREY = (0.30, 0.30, 0.30)

BODY = "helv"
BOLD = "hebo"

# Right edges of the six numeric columns, spread across DATA_LEFT..RIGHT.
COL_X: List[float] = [286.0, 337.0, 388.0, 438.0, 489.0, 540.0]

# Two-line headers for the six numeric columns (identical on every block).
HEADERS: List[Tuple[str, str]] = [
    ("Operating", "Expenses"),
    ("Non-operating", "Income (Exp.)"),
    ("Income Tax", "Provision"),
    ("Effective", "Tax Rate"),
    ("Net", "Income"),
    ("Diluted", "EPS*"),
]


@dataclass
class Block:
    """One period reconciliation: its period super-header and its rows.

    `rows` are [label, c1..c6]; every block repeats the same column headers.
    """

    period: str
    rows: List[List[str]]


@dataclass
class Doc:
    filename: str
    running_header: str
    section: str
    intro: List[str]
    unit_caption: str
    blocks: List[Block]
    footnotes: List[str] = field(default_factory=list)


def text_w(s: str, font: str, size: float) -> float:
    return fitz.get_text_length(s, fontname=font, fontsize=size)


def left(page: fitz.Page, x: float, y: float, s: str, font: str, size: float, color) -> None:
    page.insert_text((x, y), s, fontname=font, fontsize=size, color=color)


def right(page: fitz.Page, x: float, y: float, s: str, font: str, size: float, color) -> None:
    page.insert_text((x - text_w(s, font, size), y), s, fontname=font, fontsize=size, color=color)


def center(page: fitz.Page, cx: float, y: float, s: str, font: str, size: float, color) -> None:
    page.insert_text((cx - text_w(s, font, size) / 2.0, y), s, fontname=font, fontsize=size, color=color)


def norm(box: Tuple[float, float, float, float]) -> Tuple[float, float, float, float]:
    x1, y1, x2, y2 = box
    return (x1 / PAGE_W, y1 / PAGE_H, x2 / PAGE_W, y2 / PAGE_H)


def draw_block(page: fitz.Page, block: Block, y: float) -> Tuple[float, Tuple[float, float, float, float]]:
    """Render one period reconciliation; return (y_below, tight_box).

    The tight box runs from the period super-header through the last data row,
    excluding the shared footnotes below — mirroring a detector's per-table box.
    """
    box_top = y - 8.0
    # Centered spanning period super-header over the numeric columns, with a rule.
    center(page, (DATA_LEFT + RIGHT) / 2.0, y, block.period, BOLD, 8.5, BLACK)
    y += 3
    page.draw_line((DATA_LEFT, y), (RIGHT, y), color=BLACK, width=0.5)
    y += 11

    # Two-line column headers, right-aligned at each column edge.
    for i, (top, bot) in enumerate(HEADERS):
        right(page, COL_X[i], y, top, BOLD, 6.5, BLACK)
        right(page, COL_X[i], y + 8.5, bot, BOLD, 6.5, BLACK)
    y += 22
    page.draw_line((LEFT, y), (RIGHT, y), color=GREY, width=0.4)
    y += 12

    for row in block.rows:
        left(page, LEFT, y, row[0], BODY, 7.5, BLACK)
        for i, cell in enumerate(row[1:]):
            right(page, COL_X[i], y, cell, BODY, 7.5, BLACK)
        y += 11

    return y, (LEFT, box_top, RIGHT, y - 8.0)


def wrap(s: str, font: str, size: float, width: float) -> List[str]:
    out: List[str] = []
    cur = ""
    for w in s.split():
        trial = f"{cur} {w}".strip()
        if text_w(trial, font, size) <= width:
            cur = trial
        else:
            out.append(cur)
            cur = w
    if cur:
        out.append(cur)
    return out


def render(doc: Doc, out_dir: Path) -> Tuple[Path, int]:
    pdf = fitz.open()
    page = pdf.new_page(width=PAGE_W, height=PAGE_H)

    left(page, LEFT, 46, doc.running_header, BODY, 8.0, GREY)
    page.draw_line((LEFT, 52), (RIGHT, 52), color=GREY, width=0.4)

    y = 84
    left(page, LEFT, y, doc.section, BOLD, 12.0, VISA_BLUE)
    y += 20
    for para in doc.intro:
        for line in wrap(para, BODY, 9.0, RIGHT - LEFT):
            left(page, LEFT, y, line, BODY, 9.0, BLACK)
            y += 12.0
        y += 6
    left(page, LEFT, y, doc.unit_caption, BODY, 8.5, GREY)
    y += 16

    tight_boxes: List[Tuple[float, float, float, float]] = []
    region_top = y - 8.0
    for block in doc.blocks:
        y, box = draw_block(page, block, y)
        tight_boxes.append(box)
        y += 9  # thin blank-gap seam between stacked reconciliations

    region_bottom = y - 9
    y += 4
    for note in doc.footnotes:
        for line in wrap(note, BODY, 7.5, RIGHT - LEFT):
            left(page, LEFT, y, line, BODY, 7.5, GREY)
            y += 10.0

    left(page, RIGHT, PAGE_H - 40, "11", BODY, 8.0, GREY)
    out_path = out_dir / doc.filename
    pdf.save(str(out_path))
    pdf.close()

    sidecar = {
        "page": 1,
        "count": len(doc.blocks),
        "region": list(norm((LEFT, region_top, RIGHT, region_bottom))),
        "boundaries": [list(norm(b)) for b in tight_boxes],
    }
    out_path.with_suffix(".boxes.json").write_text(json.dumps(sidecar, indent=2))
    return out_path, len(doc.blocks)


def recon_rows(gaap: List[str], non_gaap: List[str]) -> List[List[str]]:
    """A GAAP row, a few adjustment rows, and a Non-GAAP row, six columns each."""
    return [
        ["GAAP", *gaap],
        ["Amortization of acquired intangible assets", "(8)", "—", "12", "", "36", "0.02"],
        ["Acquisition and integration costs", "(14)", "—", "4", "", "10", "0.01"],
        ["Litigation provision", "(708)", "—", "95", "", "613", "0.30"],
        ["(Gains) losses on equity investments", "—", "41", "(9)", "", "32", "0.02"],
        ["Non-GAAP", *non_gaap],
    ]


TWO_STACK = Doc(
    filename="synthetic_2stack.pdf",
    running_header="ACME HOLDINGS INC.   ·   NON-GAAP FINANCIAL MEASURES (UNAUDITED)   ·   Q1 FY2026",
    section="Non-GAAP Financial Measures (unaudited) — continued",
    intro=[
        "The following tables reconcile our GAAP financial measures to our non-GAAP financial "
        "measures for the comparable three-month periods presented below."
    ],
    unit_caption="(in millions, except percentages and per share data)",
    blocks=[
        Block(
            "Three Months Ended December 31, 2025",
            recon_rows(
                ["$ 4,164", "$ (54)", "$ 1,081", "17.4%", "$ 5,853", "$ 2.90"],
                ["$ 3,434", "$ (13)", "$ 1,183", "18.4%", "$ 6,544", "$ 3.25"],
            ),
        ),
        Block(
            "Three Months Ended December 31, 2024",
            recon_rows(
                ["$ 3,276", "$ (34)", "$ 1,081", "17.5%", "$ 5,119", "$ 2.51"],
                ["$ 2,907", "$ 7", "$ 1,177", "18.6%", "$ 5,463", "$ 2.75"],
            ),
        ),
    ],
    footnotes=[
        "* Determined by applying applicable tax rates.",
        "(1) Figures in the table may not recalculate exactly due to rounding.",
    ],
)


THREE_STACK = Doc(
    filename="synthetic_3stack.pdf",
    running_header="ACME HOLDINGS INC.   ·   NON-GAAP FINANCIAL MEASURES (UNAUDITED)   ·   FY2025",
    section="Non-GAAP Financial Measures (unaudited) — continued",
    intro=[
        "The following tables reconcile our GAAP financial measures to our non-GAAP financial "
        "measures for each of the three most recent quarters presented below."
    ],
    unit_caption="(in millions, except percentages and per share data)",
    blocks=[
        Block(
            "Three Months Ended September 30, 2025",
            recon_rows(
                ["$ 4,576", "$ 75", "$ 1,133", "18.2%", "$ 5,090", "$ 2.51"],
                ["$ 3,840", "$ 18", "$ 1,240", "18.9%", "$ 5,792", "$ 2.86"],
            ),
        ),
        Block(
            "Three Months Ended June 30, 2025",
            recon_rows(
                ["$ 3,995", "$ 156", "$ 1,061", "16.8%", "$ 5,272", "$ 2.60"],
                ["$ 3,259", "$ 99", "$ 1,168", "17.5%", "$ 5,974", "$ 2.95"],
            ),
        ),
        Block(
            "Three Months Ended March 31, 2025",
            recon_rows(
                ["$ 4,159", "$ 3", "$ 861", "15.8%", "$ 4,577", "$ 2.26"],
                ["$ 3,423", "$ (54)", "$ 968", "16.6%", "$ 5,279", "$ 2.61"],
            ),
        ),
    ],
    footnotes=[
        "* Determined by applying applicable tax rates.",
        "(1) Figures in the table may not recalculate exactly due to rounding.",
    ],
)


def main() -> None:
    out_dir = Path(__file__).parent
    for doc in (TWO_STACK, THREE_STACK):
        path, n = render(doc, out_dir)
        print(f"wrote {path}  ({n} stacked reconciliations, repeated headers)")


if __name__ == "__main__":
    main()
