"""Tests for the CAT DISTR chapter — DoD: golden + edges + anti-cut.

Self-contained: builds synthetic TableProfiles (no DuckDB) so the suite is fast
and deterministic. Verifies that ``build_cat_distr`` emits the blocks the user
asked for (distinct/total/%-distinct/unique metrics, top-k table and a bar
figure), that EACH categorical column is wrapped in its own keep-together
``Group`` laid out ``side_by_side`` (PPTX: table left / bars right) that starts on
a fresh page/slide (one column per page, chart next to its tables), that the LLM
business description + unit are shown per column when the profile carries an LLM
block, that the long entropy / page-layout explanations are NOT repeated inline
(they live in the glossary — only the clickable terms are kept), that the chapter
renders inside the full document to both PDF and PPTX showing that content, that a
profile with no categorical columns yields ``None`` without raising, and that
long labels / many columns are never cut in either output.
"""

import os
import re
import tempfile

from pypdf import PdfReader
from pptx import Presentation

from datascience.automatic_eda.model import (
    DataTable, Figure, GlossaryCollector, Group, Heading, KVTable, Markdown,
    Note,
)
from datascience.automatic_eda.chapters.cat_distr import (
    CHAPTER_ID, CHAPTER_VERSION, build_cat_distr,
)
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx


def _profile() -> dict:
    return {
        "table": "productos",
        "source": "/data/productos.csv",
        "profiled_at": "2026-06-30T10:00:00+00:00",
        "n_rows": 1000,
        "n_cols": 3,
        "quality_score": 90.0,
        "columns": [
            {"name": "precio", "inferred_type": "numeric", "null_pct": 0.0,
             "null_count": 0,
             "numeric": {"mean": 42.5, "median": 40.0, "min": 1.0,
                         "max": 100.0, "std": 12.3}},
            {"name": "categoria", "inferred_type": "categorical",
             "null_pct": 0.0, "null_count": 0, "distinct_count": 8,
             "categorical": {
                 "top": [
                     {"value": "neumaticos", "count": 500, "pct": 0.5},
                     {"value": "aceite", "count": 300, "pct": 0.3},
                     {"value": "filtros", "count": 120, "pct": 0.12},
                     {"value": "frenos", "count": 80, "pct": 0.08},
                 ],
                 "mode": "neumaticos", "n_distinct": 8, "entropy": 1.6,
                 "imbalance": 6.25, "len_min": 6, "len_mean": 7.5,
                 "len_max": 10}},
            {"name": "uuid", "inferred_type": "categorical",
             "null_pct": 0.0, "null_count": 0, "distinct_count": 1000,
             "categorical": {
                 "top": [{"value": f"id-{i}", "count": 1} for i in range(5)],
                 "mode": "id-0", "n_distinct": 1000, "entropy": 9.97,
                 "imbalance": 1.0}},
        ],
    }


def _pdf_text(path: str) -> str:
    txt = "".join((pg.extract_text() or "") for pg in PdfReader(path).pages)
    return re.sub(r"\s+", " ", txt)


def _pptx_text(path: str) -> str:
    prs = Presentation(path)
    parts = []
    for sl in prs.slides:
        for sh in sl.shapes:
            if sh.has_text_frame:
                parts.append(sh.text_frame.text)
            if sh.has_table:
                tb = sh.table
                for r in range(len(tb.rows)):
                    for c in range(len(tb.columns)):
                        parts.append(tb.cell(r, c).text)
    return re.sub(r"\s+", " ", " ".join(parts))


def _flatten(blocks):
    """Expand keep-together Groups so the per-column heading/table/figure are
    inspectable as a flat block list (the chapter wraps each column in a Group)."""
    out = []
    for b in blocks:
        if getattr(b, "kind", "") == "group":
            out.extend(_flatten(getattr(b, "blocks", []) or []))
        else:
            out.append(b)
    return out


def _column_groups(chapter):
    return [b for b in chapter.blocks if isinstance(b, Group)]


def test_golden_build_cat_distr_emite_bloques_pedidos():
    ch = build_cat_distr(_profile(), {})
    assert ch is not None
    assert ch.id == CHAPTER_ID
    assert ch.version == CHAPTER_VERSION

    # Entropy intro present, but the long explanation is gone (it lives in the
    # glossary now): only the term is named, no log2/normalizada walkthrough.
    headings = [b.text for b in ch.blocks if isinstance(b, Heading)]
    assert any("Entrop" in h for h in headings)
    md = next(b for b in ch.blocks if isinstance(b, Markdown))
    assert "entropía" in md.text.lower()
    assert "log2" not in md.text          # redundant explanation removed.
    assert "máxima diversidad" not in md.text

    # The donut/pie is gone: the intro no longer mentions tarta/donut (the chart
    # is now a bar chart; the long page-layout explanation moved to the glossary).
    assert "donut" not in md.text and "tarta" not in md.text

    # Per-column blocks are wrapped in keep-together Groups: flatten to inspect.
    flat = _flatten(ch.blocks)
    kv = next(b for b in flat if isinstance(b, KVTable))
    labels = [r[0] for r in kv.rows]
    values = " ".join(str(r[1]) for r in kv.rows)
    # Cardinality metrics: distinct count, %-distinct, unique values and total
    # rows are present (grouped onto compact rows so the chart fits the page).
    assert "Distintos · % · únicos" in labels
    assert "Total filas (dataset)" in labels
    assert any("Entropía" in lbl for lbl in labels)
    assert "únicos" in values and "%" in values
    assert "bits" in values and "norm" in values   # entropy + max + normalized.
    # Top-k table + bar figure.
    dt = next(b for b in flat if isinstance(b, DataTable))
    assert dt.header == ["Valor", "Conteo", "%"]
    assert any("neumaticos" in str(cell) for row in dt.rows for cell in row)
    assert any(isinstance(b, Figure) for b in flat)
    # Each per-column Group is laid out side_by_side (table left / bars right).
    assert all(g.layout == "side_by_side" for g in _column_groups(ch))
    # id-like column flagged with a Note that also explains the top-k is dropped.
    idnote = next((b for b in flat
                   if isinstance(b, Note) and "identificador" in b.text), None)
    assert idnote is not None
    assert "No se lista el top" in idnote.text


def test_golden_idlike_omite_topk_y_conserva_grafico():
    # The id-like column (uuid, 100% distinct) must NOT carry a top-k DataTable
    # (it would be a list of unique values), but must still keep its bar Figure
    # and its cardinality table so it stays a full per-column page.
    ch = build_cat_distr(_profile(), {})
    groups = _column_groups(ch)
    uuid_group = next(g for g in groups
                      if any(getattr(b, "text", "") == "uuid" for b in g.blocks))
    kinds = [b.kind for b in uuid_group.blocks]
    assert "data_table" not in kinds      # top-k of unique values dropped.
    assert "kv_table" in kinds            # cardinality kept.
    assert "figure" in kinds              # bar chart kept (chart per column).
    # A non-id-like column keeps its top-k table.
    cat_group = next(g for g in groups
                     if any(getattr(b, "text", "") == "categoria"
                            for b in g.blocks))
    assert "data_table" in [b.kind for b in cat_group.blocks]


def test_golden_una_pagina_por_columna_groups():
    ch = build_cat_distr(_profile(), {})
    groups = _column_groups(ch)
    # Two categorical columns -> two column Groups (numeric column excluded).
    assert len(groups) == 2
    # Each Group carries one column: a heading + its cardinality table + figure.
    for g in groups:
        kinds = [b.kind for b in g.blocks]
        assert kinds[0] == "heading"
        assert "kv_table" in kinds
        assert "figure" in kinds
    # The first column may share the intro page (no forced break); every later
    # column starts on a fresh page/slide so each column gets its own page.
    assert groups[0].page_break_before is False
    assert all(g.page_break_before is True for g in groups[1:])


def test_golden_entropia_clicable_y_definicion_en_glosario():
    # With a glossary collector the intro marks the clickable term and the FULL
    # definition (the long explanation removed from the intro) lands in the
    # glossary, not inline — no data lost, just relocated.
    gc = GlossaryCollector()
    ch = build_cat_distr(_profile(), {"glossary": gc})
    md = next(b for b in ch.blocks if isinstance(b, Markdown))
    assert "[[term:entropia]]entropía[[/term]]" in md.text
    assert gc.has("entropia")
    entry = gc.get("entropia")
    assert entry is not None
    # The definition kept in the glossary still carries the detail removed inline.
    assert "log2" in entry["definition"]
    assert "normalizada" in entry["definition"].lower()


def test_golden_render_pdf_una_pagina_por_columna():
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "eda.pdf")
        res = render_automatic_eda_pdf(_profile(), out, {"title": "EDA"})
        assert res["path"] == out and os.path.exists(out)
        cat_meta = next(c for c in res["chapters"] if c["id"] == CHAPTER_ID)
        # Two categorical columns, each on its own page -> >= 2 pages for the
        # chapter (intro shares the first column's page).
        assert cat_meta["n_pages"] >= 2
        txt = _pdf_text(out)
        assert "Entrop" in txt
        assert "distintos" in txt
        assert "categoria" in txt and "neumaticos" in txt
        assert "barras" in txt          # bar-chart caption rendered as text (PDF).
        assert "identificador" in txt   # id-like note rendered.


def test_golden_render_pptx_muestra_categoricas():
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "eda.pptx")
        res = render_automatic_eda_pptx(_profile(), out, {"title": "EDA"})
        assert res["path"] == out and os.path.exists(out)
        cat_meta = next(c for c in res["chapters"] if c["id"] == CHAPTER_ID)
        assert cat_meta["n_slides"] >= 2  # one slide per categorical column.
        txt = _pptx_text(out)
        assert "Entrop" in txt
        assert "categoria" in txt and "neumaticos" in txt
        assert "distintos" in txt


def _profile_high_card() -> dict:
    """Profile with a high-cardinality NON-id-like categorical column whose top-k
    of long values would split from its donut on a short 16:9 slide unless the
    renderer trims the table — the exact case the adversarial check flagged
    (Ticket / Cabin)."""
    long_vals = [f"Valor largo de categoria numero {i:02d} con texto extra"
                 for i in range(40)]
    top = [{"value": v, "count": 60 - i, "pct": (60 - i) / 5000.0}
           for i, v in enumerate(long_vals)]
    return {
        "table": "t", "source": "t.csv", "n_rows": 5000, "n_cols": 3,
        "quality_score": 80.0,
        "columns": [
            {"name": "precio", "inferred_type": "numeric", "null_pct": 0.0,
             "numeric": {"mean": 1.0, "median": 1.0, "min": 0.0, "max": 2.0,
                         "std": 0.5}},
            # 40 distinct over 5000 rows = 0.8% distinct -> NOT id-like, keeps
            # its (long) top-k table; the tall table must not push the donut off.
            {"name": "alta_card_col", "inferred_type": "categorical",
             "null_pct": 0.0, "distinct_count": 40,
             "categorical": {"top": top, "mode": long_vals[0], "n_distinct": 40,
                             "entropy": 5.2, "imbalance": 1.2, "len_min": 40,
                             "len_mean": 45, "len_max": 50}},
            {"name": "baja_card_col", "inferred_type": "categorical",
             "null_pct": 0.0, "distinct_count": 4,
             "categorical": {
                 "top": [{"value": "norte", "count": 2000, "pct": 0.4},
                         {"value": "sur", "count": 1500, "pct": 0.3},
                         {"value": "este", "count": 1000, "pct": 0.2},
                         {"value": "oeste", "count": 500, "pct": 0.1}],
                 "mode": "norte", "n_distinct": 4, "entropy": 1.8}},
        ],
    }


def test_golden_pptx_una_slide_por_columna_con_su_grafico():
    """Cada columna categórica ocupa EXACTAMENTE UN slide cat_distr que lleva su
    gráfico (picture) en la misma slide — el chart nunca se separa de su columna,
    ni siquiera para una columna de alta cardinalidad. Con layout side_by_side la
    tabla se rasteriza a imagen, así que la comprobación se hace por presencia de
    picture (no por el texto de la tabla)."""
    from pptx.enum.shapes import MSO_SHAPE_TYPE

    prof = _profile_high_card()
    cat_names = ["alta_card_col", "baja_card_col"]
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "eda.pptx")
        res = render_automatic_eda_pptx(prof, out, {"title": "EDA"})
        assert res["path"] == out and os.path.exists(out)
        prs = Presentation(out)

        # Per column: the cat_distr slides whose text mentions it, and whether the
        # owning slide also carries an actual picture shape (its chart).
        slides_with_col = {n: [] for n in cat_names}
        owner_has_chart = {n: False for n in cat_names}
        for i, sl in enumerate(prs.slides):
            texts, has_pic = [], False
            for sh in sl.shapes:
                if sh.has_text_frame:
                    texts.append(sh.text_frame.text)
                if sh.shape_type == MSO_SHAPE_TYPE.PICTURE:
                    has_pic = True
            txt = re.sub(r"\s+", " ", " ".join(texts))
            if "Distribuciones categ" not in txt:   # footer stamp of the chapter.
                continue
            for n in cat_names:
                if n in txt:
                    slides_with_col[n].append(i)
                    if has_pic:
                        owner_has_chart[n] = True

        for n in cat_names:
            # Exactly one slide carries the column (not split across slides).
            assert len(slides_with_col[n]) == 1, (n, slides_with_col[n])
            # That single slide also holds its chart picture.
            assert owner_has_chart[n], (n, "el gráfico no está en el slide de la columna")


def test_golden_pptx_columna_side_by_side_tabla_izq_barra_der():
    """Con layout side_by_side, una columna categórica coloca su tabla de
    cardinalidad (imagen) en la mitad izquierda y su gráfico de barras (imagen) en
    la mitad derecha de la MISMA slide. Verifica que al menos una columna queda en
    dos columnas (tabla-izq / barras-der), evidencia del side_by_side en PPTX."""
    from pptx.enum.shapes import MSO_SHAPE_TYPE
    from pptx.util import Inches

    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "eda.pptx")
        render_automatic_eda_pptx(_profile(), out, {"title": "EDA"})
        prs = Presentation(out)
        centre = int(Inches(13.333 / 2.0))   # half of the 16:9 slide width.
        two_col_slides = 0
        for sl in prs.slides:
            texts, lefts = [], []
            for sh in sl.shapes:
                if sh.has_text_frame:
                    texts.append(sh.text_frame.text)
                if (sh.shape_type == MSO_SHAPE_TYPE.PICTURE
                        and sh.left is not None):
                    lefts.append(sh.left)
            txt = re.sub(r"\s+", " ", " ".join(texts))
            if "Distribuciones categ" not in txt:
                continue
            # One picture starts in the left half, another in the right half.
            if len(lefts) >= 2 and min(lefts) < centre and max(lefts) > centre:
                two_col_slides += 1
        assert two_col_slides >= 1, (
            "ninguna columna quedó con tabla-izq / barras-der (side_by_side)")


def _profile_with_llm() -> dict:
    """The base profile plus an ``llm`` block (as eda_llm_insights would store it
    with run_llm=True): a data dictionary with description/unit per column."""
    prof = _profile()
    prof["llm"] = {
        "dictionary": [
            {"column": "categoria",
             "description": "Familia de producto del recambio",
             "business_meaning": "Agrupa el catálogo por tipo de pieza",
             "unit": "categoría"},
            {"column": "uuid",
             "description": "Identificador único de registro",
             "unit": ""},
        ],
    }
    return prof


def test_llm_descripcion_y_unidad_por_columna():
    # With an LLM dictionary, each categorical column whose name matches shows its
    # business description and unit in a per-column markdown block.
    ch = build_cat_distr(_profile_with_llm(), {})
    groups = _column_groups(ch)
    cat_group = next(g for g in groups
                     if any(getattr(b, "text", "") == "categoria"
                            for b in g.blocks))
    md = " ".join(b.text for b in cat_group.blocks
                  if getattr(b, "kind", "") == "markdown")
    assert "Descripción" in md and "Familia de producto" in md
    assert "Unidad" in md and "categoría" in md


def test_edge_sin_llm_no_anade_descripcion():
    # Without an LLM block the per-column description markdown is simply omitted;
    # the column still renders its cardinality table and bar figure.
    ch = build_cat_distr(_profile(), {})
    for g in _column_groups(ch):
        mds = [b.text for b in g.blocks if getattr(b, "kind", "") == "markdown"]
        assert not any("Descripción" in t for t in mds)


def test_pagina_categorica_clicable_y_definicion_en_glosario():
    # The "how each categorical page is laid out" term is registered + marked
    # clickable in the intro, and its full definition lands in the glossary
    # chapter (canonical baseline catalog), not inline.
    from datascience.automatic_eda.chapters.glosario import build_glosario

    gc = GlossaryCollector()
    ch = build_cat_distr(_profile(), {"glossary": gc})
    md = next(b for b in ch.blocks if isinstance(b, Markdown))
    assert "[[term:pagina_categorica]]" in md.text
    assert gc.has("pagina_categorica")
    glos = build_glosario(_profile(), {"glossary": gc})
    entry = next(b for b in glos.blocks
                 if getattr(b, "kind", "") == "glossary_entry"
                 and b.key == "pagina_categorica")
    assert "barras" in entry.definition
    assert "identificador" in entry.definition


def test_edge_sin_categoricas_devuelve_none():
    only_numeric = {
        "n_rows": 10, "columns": [
            {"name": "x", "inferred_type": "numeric",
             "numeric": {"mean": 1.0}}]}
    assert build_cat_distr(only_numeric, {}) is None
    # None / empty / no-columns never raise and yield None.
    assert build_cat_distr(None, None) is None
    assert build_cat_distr({}, {}) is None
    assert build_cat_distr({"columns": []}, {}) is None


def test_anti_corte_label_largo_y_muchas_columnas():
    long_label = ("Lorem ipsum dolor sit amet consectetur adipiscing elit sed "
                  "do eiusmod tempor incididunt ut labore reprehenderit voluptate")
    cols = []
    for i in range(30):
        cols.append({
            "name": f"cat_{i}", "inferred_type": "categorical",
            "distinct_count": 3,
            "categorical": {
                "top": [{"value": long_label, "count": 60},
                        {"value": "b", "count": 30},
                        {"value": "c", "count": 10}],
                "mode": long_label, "n_distinct": 3, "entropy": 1.2}})
    profile = {"table": "t", "source": "t.csv", "n_rows": 100,
               "n_cols": len(cols), "columns": cols}

    ch = build_cat_distr(profile, {})
    assert ch is not None
    # One Group per column, each forcing its own page (except the first).
    groups = _column_groups(ch)
    assert len(groups) == 30
    assert sum(1 for g in groups if g.page_break_before) == 29
    with tempfile.TemporaryDirectory() as d:
        pdf = os.path.join(d, "anti.pdf")
        res = render_automatic_eda_pdf(profile, pdf, {"write_manifest": False})
        assert res["path"] == pdf
        assert res["n_pages"] > 1       # one page per column, OK.
        txt = _pdf_text(pdf)
        # Long label wrapped (not truncated): every word survives.
        for word in ("Lorem", "incididunt", "reprehenderit", "voluptate"):
            assert word in txt
        # PPTX path must not raise either.
        pptx = os.path.join(d, "anti.pptx")
        res2 = render_automatic_eda_pptx(profile, pptx,
                                         {"write_manifest": False})
        assert res2["path"] == pptx and os.path.exists(pptx)
