"""Tests for the AGREGACION chapter — DoD: golden + edges + error/no-cut path.

Self-contained and deterministic: no DuckDB and no LLM. The aggregation results
are passed pre-computed via ``ctx['aggregations']`` (the same shape the push-down
registry functions ``groupby_stats_duckdb`` / ``pivot_table_duckdb`` produce), so
the chapter's rendering logic is exercised without touching disk or the network.
Live push-down + LLM selection are covered separately by the golden script.

Verifies:
- Golden: a profile with categoricals + numerics builds a Chapter with per-group
  stats tables, a pivot table and bar-chart figures, and it renders to PDF AND
  PPTX showing the group keys, values and pivot — nothing cut.
- Edges: a dataset with no low-cardinality categorical returns None; an empty
  profile returns None; a profile that *could* be grouped but has no reachable
  data degrades to an honest note instead of raising.
- No-cut: many groups (30) + a long interpretation paragraph survive intact in
  the rendered PDF (table split by rows, text wrapped whole).
"""

import os
import re
import tempfile

from pptx import Presentation
from pypdf import PdfReader

from datascience.automatic_eda.chapters.agregacion import build_agregacion
from datascience.automatic_eda.model import Chapter
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx


# --------------------------------------------------------------------------- #
# Synthetic fixtures.
# --------------------------------------------------------------------------- #
def _profile() -> dict:
    """A titanic-like profile: 2 categoricals + 2 numeric measures + 1 id."""
    return {
        "table": "titanic",
        "source": "/data/titanic.csv",
        "n_rows": 891,
        "n_cols": 5,
        "key_candidates": ["passenger_id"],
        "columns": [
            {"name": "passenger_id", "inferred_type": "numeric",
             "unique_pct": 1.0, "flags": ["possible_id"],
             "numeric": {"mean": 446.0, "std": 257.0}},
            {"name": "sex", "inferred_type": "categorical", "distinct_count": 2,
             "flags": [], "categorical": {"n_distinct": 2, "imbalance": 0.1,
                                          "top": [{"value": "male", "count": 577}]}},
            {"name": "pclass", "inferred_type": "categorical", "distinct_count": 3,
             "flags": [], "categorical": {"n_distinct": 3, "imbalance": 0.2}},
            {"name": "fare", "inferred_type": "numeric", "flags": [],
             "numeric": {"mean": 32.2, "std": 49.7, "cv": 1.54}},
            {"name": "age", "inferred_type": "numeric", "flags": [],
             "numeric": {"mean": 29.7, "std": 14.5, "cv": 0.49}},
        ],
    }


def _groupby_result(group_by: str, keys_n: list) -> dict:
    """A groupby_stats_duckdb-shaped result for `fare` and `age`."""
    groups = []
    for i, (key, n) in enumerate(keys_n):
        groups.append({
            "key": key, "n": n,
            "stats": {
                "fare": {"mean": 20.0 + i * 15, "median": 10.0 + i * 8,
                         "std": 40.0 + i, "min": 0.0, "max": 512.3},
                "age": {"mean": 28.0 + i, "median": 27.0 + i, "std": 14.0,
                        "min": 0.42, "max": 80.0},
            },
        })
    return {"status": "ok", "group_by": group_by, "measures": ["fare", "age"],
            "aggs": ["count", "mean", "median", "std", "min", "max"],
            "n_groups": len(groups), "truncated": False, "groups": groups}


def _pivot_result() -> dict:
    return {"status": "ok", "index": "sex", "columns": "pclass", "value": "fare",
            "agg": "mean", "row_labels": ["male", "female"],
            "col_labels": ["1", "2", "3"],
            "matrix": [[62.0, 19.0, 12.0], [110.0, 22.0, 15.0]],
            "truncated_rows": False, "truncated_cols": False}


def _ctx_precomputed() -> dict:
    return {
        "aggregations": {
            "groupby": [
                {"group_by": "sex", "measures": ["fare", "age"],
                 "why": "sexo del pasajero",
                 "result": _groupby_result("sex", [("male", 577), ("female", 314)])},
                {"group_by": "pclass", "measures": ["fare", "age"],
                 "why": "clase del billete",
                 "result": _groupby_result(
                     "pclass", [("3", 491), ("1", 216), ("2", 184)])},
            ],
            "pivots": [
                {"index": "sex", "columns": "pclass", "value": "fare",
                 "agg": "mean", "why": "tarifa por sexo y clase",
                 "result": _pivot_result()},
            ],
        },
        "agg_insights": [
            {"title": "Tarifa por sexo",
             "text": "Las mujeres pagaron de media casi el doble que los hombres."},
        ],
    }


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))


# --------------------------------------------------------------------------- #
# Golden: builds a Chapter and renders to both formats.
# --------------------------------------------------------------------------- #
def test_golden_chapter_blocks_present():
    ch = build_agregacion(_profile(), _ctx_precomputed())
    assert isinstance(ch, Chapter)
    assert ch.id == "agregacion"
    kinds = [b.kind for b in ch.blocks]
    assert "heading" in kinds
    assert kinds.count("data_table") >= 3   # 2 group summaries + pivot (+details)
    assert "figure" in kinds                 # at least one bar chart.
    # Headings mention the group keys and the pivot.
    htext = " ".join(b.text for b in ch.blocks if b.kind == "heading")
    assert "sex" in htext and "pclass" in htext and "Pivot" in htext


def test_golden_render_pdf():
    ch = build_agregacion(_profile(), _ctx_precomputed())
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "agg.pdf")
        res = render_automatic_eda_pdf([ch], out, {"write_manifest": False})
        assert res["path"] == out and os.path.exists(out)
        assert res["n_pages"] >= 1
        txt = _pdf_text(out)
        assert "Agregación por grupos" in txt
        assert "male" in txt and "female" in txt        # group + pivot labels.
        assert "Pivot" in txt
        assert "mediana" in txt                           # per-measure detail.
        assert "casi el doble" in txt                     # interpretation kept.


def test_golden_render_pptx():
    ch = build_agregacion(_profile(), _ctx_precomputed())
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "agg.pptx")
        res = render_automatic_eda_pptx([ch], out, {"write_manifest": False})
        assert res["path"] == out and os.path.exists(out)
        assert res["n_slides"] >= 1
        txt = _pptx_text(out)
        assert "male" in txt and "pclass" in txt
        assert "Pivot" in txt or "sex" in txt


# --------------------------------------------------------------------------- #
# Edges.
# --------------------------------------------------------------------------- #
def test_edge_no_categorical_returns_none():
    # Only numerics + an id: nothing to group by -> chapter does not apply.
    prof = {
        "table": "t", "n_rows": 100, "key_candidates": ["id"],
        "columns": [
            {"name": "id", "inferred_type": "numeric", "unique_pct": 1.0,
             "flags": ["possible_id"], "numeric": {"std": 10.0}},
            {"name": "x", "inferred_type": "numeric", "flags": [],
             "numeric": {"mean": 1.0, "std": 2.0}},
        ],
    }
    assert build_agregacion(prof, {}) is None


def test_edge_empty_profile_returns_none():
    assert build_agregacion({}, {}) is None
    assert build_agregacion(None, None) is None


def test_edge_high_cardinality_only_returns_none():
    # The single categorical is id-like (high cardinality) -> not groupable.
    prof = {
        "table": "t", "n_rows": 100, "key_candidates": ["uuid"],
        "columns": [
            {"name": "uuid", "inferred_type": "categorical", "distinct_count": 100,
             "flags": ["high_cardinality", "possible_id"]},
            {"name": "x", "inferred_type": "numeric", "flags": [],
             "numeric": {"mean": 1.0, "std": 2.0}},
        ],
    }
    assert build_agregacion(prof, {}) is None


def test_live_without_data_degrades_to_note():
    # Has a categorical to group by but no db_path / no precomputed results:
    # must NOT raise and must emit an honest note (chapter still applies).
    prof = {
        "table": "t", "n_rows": 100, "key_candidates": [],
        "columns": [
            {"name": "grp", "inferred_type": "categorical", "distinct_count": 3,
             "flags": [], "categorical": {"n_distinct": 3}},
            {"name": "v", "inferred_type": "numeric", "flags": [],
             "numeric": {"mean": 1.0, "std": 2.0}},
        ],
    }
    ch = build_agregacion(prof, {})
    assert isinstance(ch, Chapter)
    notes = [b.text for b in ch.blocks if b.kind == "note"]
    assert any("datos crudos" in n for n in notes)


# --------------------------------------------------------------------------- #
# No-cut: many groups + long text survive intact in the PDF.
# --------------------------------------------------------------------------- #
def test_anti_corte_muchos_grupos_y_texto_largo():
    keys_n = [(f"grupo_{i:02d}", 30 - (i % 5)) for i in range(30)]
    long_text = " ".join(f"palabra{i}" for i in range(120))
    ctx = {
        "aggregations": {
            "groupby": [
                {"group_by": "cat", "measures": ["fare"], "why": "muchos niveles",
                 "result": _groupby_result("cat", keys_n)},
            ],
            "pivots": [],
        },
        "agg_insights": [{"title": "Nota larga", "text": long_text}],
    }
    ch = build_agregacion(_profile(), ctx)
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "big.pdf")
        res = render_automatic_eda_pdf([ch], out, {"write_manifest": False})
        assert res["path"] == out
        assert res["n_pages"] > 1  # 30-row table + figure spill across pages.
        txt = _pdf_text(out)
        # First and last group labels both survive (table not truncated).
        assert "grupo_00" in txt and "grupo_29" in txt
        # First, middle and last words of the long paragraph all present.
        for i in (0, 60, 119):
            assert f"palabra{i}" in txt


def test_glosario_engancha_groupby_y_pivot():
    """Mejora 4b: la agrupación (split-apply-combine) y la tabla dinámica (pivot)
    se registran en el colector compartido y se marcan clicables en el cuerpo.
    Sin colector en ctx, el capítulo degrada y no marca nada."""
    from datascience.automatic_eda.model import GlossaryCollector

    g = GlossaryCollector()
    ctx = dict(_ctx_precomputed())
    ctx["glossary"] = g
    ch = build_agregacion(_profile(), ctx)
    assert ch is not None
    keys = {t["key"] for t in g.terms()}
    assert {"groupby", "pivot_table"} <= keys
    body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
    assert "[[term:groupby]]" in body and "[[term:pivot_table]]" in body

    # Sin colector: degrada limpio (ningún marcador en el cuerpo).
    ch2 = build_agregacion(_profile(), _ctx_precomputed())
    body2 = " ".join(b.text for b in ch2.blocks if b.kind == "markdown")
    assert "[[term:" not in body2
