"""Tests for the AutomaticEDA engine features added in phase 4a.

Covers, with executable evidence, the six render-engine improvements:

1. Bold no longer overlaps the following text in the PDF (real width measured).
2. Zebra striping on data tables (PDF Rectangle fills + PPTX cell fills).
3. Keep-together: a Group moves whole to the next page/slide (heading never gets
   stranded from its figure).
4. Every PPTX figure carries a visible caption/title (fallback to the heading).
5. Cover is built last but placed first and reflects an aggregated summary.
6. Glossary is the last chapter; the term "entropía" is a real clickable link in
   the PDF (PyMuPDF GOTO annotation) and in the PPTX (native slide-jump run).

Self-contained: synthetic profiles, no DuckDB. Heavy renderer checks (fitz/pptx)
skip cleanly when the optional engine is missing.
"""

import os
import sys

import pytest

_HERE = os.path.dirname(os.path.abspath(__file__))
_FUNCTIONS = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))  # python/functions
if _FUNCTIONS not in sys.path:
    sys.path.insert(0, _FUNCTIONS)

import matplotlib  # noqa: E402

matplotlib.use("Agg")
import matplotlib.colors as mcolors  # noqa: E402
import matplotlib.pyplot as plt  # noqa: E402
from matplotlib.patches import Rectangle  # noqa: E402

from datascience.automatic_eda import model  # noqa: E402
from datascience.automatic_eda import render_pdf_impl as RP  # noqa: E402
from datascience.automatic_eda import render_pptx_impl as RX  # noqa: E402
from datascience.automatic_eda import build_document  # noqa: E402
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf  # noqa: E402
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx  # noqa: E402


class _FakePdf:
    """Stand-in for PdfPages so the placers can call _new_page in unit tests."""

    def savefig(self, fig):  # noqa: D401
        pass


def _small_fig():
    fig = plt.figure(figsize=(4.0, 1.5))
    ax = fig.add_subplot(111)
    ax.plot([0, 1, 2], [1, 3, 2])
    return fig


def _profile_with_cat_and_num():
    """A tiny profile that triggers cat_distr (→ entropía term) and num_distr."""
    return {
        "table": "ventas", "n_rows": 120, "n_cols": 2, "quality_score": 91,
        "duplicate_pct": 1.5, "null_cell_pct": 0.8,
        "columns": [
            {"name": "region", "inferred_type": "categorical",
             "categorical": {
                 "top": [{"value": "norte", "count": 50, "pct": 0.42},
                         {"value": "sur", "count": 40, "pct": 0.33},
                         {"value": "este", "count": 30, "pct": 0.25}],
                 "mode": "norte", "n_distinct": 3, "entropy": 1.55,
                 "imbalance": 0.1}},
            {"name": "importe", "inferred_type": "numeric",
             "numeric": {"mean": 50.0, "median": 48.0, "std": 10.0,
                         "min": 10, "max": 99, "iqr": 15,
                         "histogram": [{"lo": 0, "hi": 50, "count": 40},
                                       {"lo": 50, "hi": 100, "count": 80}]}},
        ],
    }


# --------------------------------------------------------------------------- #
# 1) Bold does not overlap the following text (PDF).
# --------------------------------------------------------------------------- #
def test_pdf_bold_span_does_not_overlap_following_text():
    fig = plt.figure(figsize=(RP._W, RP._H))
    st = RP._PdfState(_FakePdf(), "t")
    st.fig = fig
    st.page = 1
    # A wide bold token immediately followed by normal text on the SAME line.
    rich = [[("PALABRAMUYANCHAENNEGRITA", True, None),
             (" texto normal justo después", False, None)]]
    RP._place_rich_lines(st, rich, RP._FS_BODY, RP._INK)

    renderer = fig.canvas.get_renderer()
    boxes = sorted((t.get_window_extent(renderer) for t in fig.texts),
                   key=lambda b: b.x0)
    assert len(boxes) == 2, "se esperaban dos spans dibujados"
    # The bold span ends before the normal span starts (no overlap). 1px slack.
    assert boxes[0].x1 <= boxes[1].x0 + 1.0, \
        "la negrita se solapa con el texto siguiente"
    plt.close(fig)


# --------------------------------------------------------------------------- #
# 2) Zebra striping.
# --------------------------------------------------------------------------- #
def _facecolor_eq(artist, hexcolor) -> bool:
    want = mcolors.to_rgba(hexcolor)
    got = artist.get_facecolor()
    return all(abs(a - b) < 0.02 for a, b in zip(got[:3], want[:3]))


def test_pdf_table_has_zebra_striping():
    fig = plt.figure(figsize=(RP._W, RP._H))
    st = RP._PdfState(_FakePdf(), "t")
    st.fig = fig
    st.page = 1
    st.chapter = model.Chapter(id="c", title="C", version="1.0.0")
    dt = model.DataTable(header=["A", "B"],
                         rows=[["1", "x"], ["2", "y"], ["3", "z"], ["4", "w"]])
    RP._place_data_table(st, dt)
    zebra = [a for a in fig.findobj(Rectangle) if _facecolor_eq(a, RP._ZEBRA)]
    # 4 data rows → even rows (1-based 2 and 4) shaded = 2 zebra rectangles.
    assert len(zebra) == 2, f"esperadas 2 filas zebra, hay {len(zebra)}"
    plt.close(fig)


def test_pptx_table_has_zebra_striping(tmp_path):
    pptx = pytest.importorskip("pptx")
    from pptx import Presentation
    from pptx.dml.color import RGBColor

    doc = [model.Chapter(id="c", title="Tabla", version="1.0.0", blocks=[
        model.DataTable(header=["A", "B"],
                        rows=[["1", "x"], ["2", "y"], ["3", "z"], ["4", "w"]])])]
    out = str(tmp_path / "zebra.pptx")
    assert render_automatic_eda_pptx(doc, out, {"write_manifest": False})["path"]

    prs = Presentation(out)
    table = None
    for slide in prs.slides:
        for sh in slide.shapes:
            if sh.has_table:
                table = sh.table
                break
    assert table is not None, "no se encontró la tabla en el deck"
    zebra = RGBColor(0xF6, 0xF8, 0xFA)
    white = RGBColor(0xFF, 0xFF, 0xFF)
    # Row 0 = header; data rows follow. Even data rows (table rows 2, 4) shaded.
    assert table.cell(1, 0).fill.fore_color.rgb == white
    assert table.cell(2, 0).fill.fore_color.rgb == zebra
    assert table.cell(4, 0).fill.fore_color.rgb == zebra


# --------------------------------------------------------------------------- #
# 3) Keep-together (Group): heading + figure never split.
# --------------------------------------------------------------------------- #
def test_pdf_group_moves_whole_to_next_page_when_it_does_not_fit():
    fig = plt.figure(figsize=(RP._W, RP._H))
    st = RP._PdfState(_FakePdf(), "t")
    st.fig = fig
    st.page = 1
    st.chapter = model.Chapter(id="c", title="C", version="1.0.0")
    grp = model.Group(blocks=[
        model.Heading(text="Sección con figura", level=2),
        model.Figure(make=_small_fig, caption="cap"),
        model.Markdown(text="Descripción breve de la figura."),
    ])
    # Only ~0.4in left: the group does not fit here but fits on a fresh page.
    st.y = RP._CONTENT_BOTTOM - 0.4
    page_before = st.page
    RP._place_group(st, grp)
    # Exactly one page break: the whole group (heading+figure+text) stays
    # together on the new page — no second break inside it.
    assert st.page == page_before + 1
    plt.close(st.fig)


def test_pdf_group_does_not_break_when_it_fits():
    fig = plt.figure(figsize=(RP._W, RP._H))
    st = RP._PdfState(_FakePdf(), "t")
    st.fig = fig
    st.page = 1
    st.chapter = model.Chapter(id="c", title="C", version="1.0.0")
    grp = model.Group(blocks=[
        model.Heading(text="Cabe entera", level=2),
        model.Figure(make=_small_fig, caption="cap"),
    ])
    st.y = RP._CONTENT_TOP  # empty page → fits, must not break.
    page_before = st.page
    RP._place_group(st, grp)
    assert st.page == page_before
    plt.close(st.fig)


def test_pptx_group_moves_whole_to_next_slide(tmp_path):
    pytest.importorskip("pptx")
    from pptx import Presentation
    from pptx.util import Inches

    prs = Presentation()
    prs.slide_width = Inches(RX._W)
    prs.slide_height = Inches(RX._H)
    st = RX._PptxState(prs, "t")
    st.chapter = model.Chapter(id="c", title="C", version="1.0.0")
    RX._new_slide(st, cont=False)
    grp = model.Group(blocks=[
        model.Heading(text="Sección con figura", level=2),
        model.Figure(make=_small_fig, caption="cap"),
        model.Markdown(text="Descripción breve."),
    ])
    st.y = RX._CONTENT_BOTTOM - 0.4  # does not fit here.
    slide_before = st.slide_no
    RX._place_group(st, grp)
    assert st.slide_no == slide_before + 1  # one jump; group kept together.


# --------------------------------------------------------------------------- #
# 4) Every PPTX figure carries a visible caption/title.
# --------------------------------------------------------------------------- #
def test_pptx_figure_without_caption_gets_heading_title(tmp_path):
    pytest.importorskip("pptx")
    from pptx import Presentation
    from pptx.enum.shapes import MSO_SHAPE_TYPE

    doc = [model.Chapter(id="c", title="Cap", version="1.0.0", blocks=[
        model.Heading(text="Mi sección gráfica", level=2),
        model.Figure(make=_small_fig),  # NO caption provided.
    ])]
    out = str(tmp_path / "cap.pptx")
    assert render_automatic_eda_pptx(doc, out, {"write_manifest": False})["path"]

    prs = Presentation(out)
    for slide in prs.slides:
        has_pic = any(sh.shape_type == MSO_SHAPE_TYPE.PICTURE
                      for sh in slide.shapes)
        if not has_pic:
            continue
        italic = [r.text for sh in slide.shapes if sh.has_text_frame
                  for p in sh.text_frame.paragraphs for r in p.runs
                  if r.font.italic and r.text.strip()]
        assert italic, "la figura no lleva caption visible en su slide"
        assert any("Mi sección gráfica" in t for t in italic), \
            "el caption no cayó al título de la sección"
        return
    pytest.fail("no se encontró ningún slide con imagen")


def test_pptx_no_figure_slide_is_ever_untitled(tmp_path):
    """Invariant: across many figures (incl. tall ones), NO slide with an image
    lacks a visible caption — the caption never spills to the next slide."""
    pytest.importorskip("pptx")
    from pptx import Presentation
    from pptx.enum.shapes import MSO_SHAPE_TYPE

    def _tall_fig():
        fig = plt.figure(figsize=(5.0, 4.6))  # nearly square → fills the slide.
        fig.add_subplot(111).bar([1, 2, 3], [4, 5, 6])
        return fig

    blocks = []
    for i in range(6):
        blocks.append(model.Heading(text=f"Gráfico {i}", level=2))
        blocks.append(model.Figure(
            make=_tall_fig,
            caption=("Una descripción de la figura deliberadamente larga para "
                     "que el caption ocupe más de una línea al envolverse en el "
                     f"ancho del slide — figura número {i} del bloque.")))
    doc = [model.Chapter(id="c", title="Muchas figuras", version="1.0.0",
                         blocks=blocks)]
    out = str(tmp_path / "many.pptx")
    assert render_automatic_eda_pptx(doc, out, {"write_manifest": False})["path"]

    prs = Presentation(out)
    missing = []
    pics = 0
    for i, slide in enumerate(prs.slides):
        if not any(sh.shape_type == MSO_SHAPE_TYPE.PICTURE
                   for sh in slide.shapes):
            continue
        pics += 1
        italic = [r.text for sh in slide.shapes if sh.has_text_frame
                  for p in sh.text_frame.paragraphs for r in p.runs
                  if r.font.italic and r.text.strip()]
        if not italic:
            missing.append(i)
    assert pics >= 6, f"esperadas >=6 figuras, hay {pics}"
    assert not missing, f"slides con imagen sin caption: {missing}"


# --------------------------------------------------------------------------- #
# 5) Cover built last, placed first, with an aggregated summary.
# --------------------------------------------------------------------------- #
def test_cover_first_glossary_last_with_summary():
    chs = build_document(_profile_with_cat_and_num(), ctx={"dataset_name": "v"})
    ids = [c.id for c in chs]
    assert ids[0] == "portada", f"la portada no es la primera: {ids}"
    assert ids[-1] == "glosario", f"el glosario no es el último: {ids}"
    cover = chs[0]
    headings = [b.text for b in cover.blocks if b.kind == "heading"]
    assert any("Resumen" in h for h in headings), \
        "la portada no incluye el resumen agregado"
    # The index ("Índice") is now a clickable list of TocEntry blocks (one per
    # body chapter), not a markdown bullet list. Verify both the heading and that
    # the entries name the body chapters.
    assert any("Índice" in h for h in headings), \
        "la portada no incluye la sección Índice"
    toc_labels = " ".join(
        getattr(b, "label", "") for b in cover.blocks
        if getattr(b, "kind", "") == "toc_entry")
    assert "Distribuciones" in toc_labels, \
        "el índice de portada no menciona los capítulos del cuerpo"


# --------------------------------------------------------------------------- #
# 6) Glossary clickable in PDF (PyMuPDF GOTO) and PPTX (native slide jump).
# --------------------------------------------------------------------------- #
def test_pdf_glossary_term_is_clickable(tmp_path):
    fitz = pytest.importorskip("fitz")
    out = str(tmp_path / "glos.pdf")
    res = render_automatic_eda_pdf(_profile_with_cat_and_num(), out,
                                   {"ctx": {"dataset_name": "v"},
                                    "write_manifest": False})
    assert res["path"] == out and os.path.exists(out)

    doc = fitz.open(out)
    goto = [(pno, l) for pno in range(doc.page_count)
            for l in doc[pno].get_links() if l.get("kind") == fitz.LINK_GOTO]
    doc.close()
    assert goto, "no hay ningún enlace interno (entropía → glosario) en el PDF"
    # Destination must be a real page in the document (the glossary page).
    assert all(0 <= l.get("page", -1) for _p, l in goto)


def test_pptx_glossary_term_is_clickable(tmp_path):
    pytest.importorskip("pptx")
    from pptx import Presentation
    from pptx.oxml.ns import qn

    out = str(tmp_path / "glos.pptx")
    res = render_automatic_eda_pptx(_profile_with_cat_and_num(), out,
                                    {"ctx": {"dataset_name": "v"},
                                     "write_manifest": False})
    assert res["path"] == out and os.path.exists(out)

    prs = Presentation(out)
    found = False
    for slide in prs.slides:
        for sh in slide.shapes:
            if not sh.has_text_frame:
                continue
            for p in sh.text_frame.paragraphs:
                for r in p.runs:
                    rpr = r._r.find(qn("a:rPr"))
                    if rpr is None:
                        continue
                    hl = rpr.find(qn("a:hlinkClick"))
                    if hl is not None and \
                            hl.get("action") == "ppaction://hlinksldjump":
                        found = True
    assert found, "ningún término tiene hyperlink de salto a slide en el PPTX"
