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

Self-contained: builds a synthetic TableProfile with a ``models`` block (no
DuckDB, no sklearn, no LLM, no network). The cluster scatter is fed a synthetic
pre-computed ``cluster_projection`` via ``ctx`` and the per-cluster titles via
``ctx['cluster_titles']`` so the suite is fast and deterministic. The live paths
(``project_clusters_2d`` / ``describe_clusters_llm``) are exercised against the
real wine dataset in the work report, not here.

Verifies: the chapter renders to PDF *and* PPTX showing the user-required pieces
(markdown text, PCA scree, cluster scatter, per-cluster LLM micro-analysis,
outlier + normalization explanations); that an inapplicable profile yields None
without raising; and that a long normality table is split without losing any
column (anti-cut).
"""

import os
import re
import tempfile

from pypdf import PdfReader
from pptx import Presentation

from datascience.automatic_eda.chapters.modelos import build_modelos
from datascience.automatic_eda.model import Figure, DataTable, Markdown
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 _models_block(n_norm_cols: int = 4) -> dict:
    feats = ["fixed_acidity", "alcohol", "ph", "sulphates"]
    normality = {}
    for i in range(n_norm_cols):
        normality[f"col_{i}"] = {
            "n": 500,
            "jarque_bera": {"stat": 12.3, "p": 0.002 + i * 0.0001, "normal": False},
            "dagostino": {"stat": 9.1, "p": 0.01, "normal": False},
            "shapiro": {"stat": 0.98, "p": 0.04, "normal": False},
            "is_normal": False,
        }
    return {
        "n_numeric_cols": 4,
        "pca": {
            "n_components": 2, "n_rows_used": 1599, "n_features": 4,
            "explained_variance_ratio": [0.41, 0.22],
            "cumulative": [0.41, 0.63],
            "top_loadings": [
                {"component": 0, "feature": "alcohol", "loading": 0.62},
                {"component": 0, "feature": "fixed_acidity", "loading": -0.48},
                {"component": 1, "feature": "ph", "loading": 0.71},
                {"component": 1, "feature": "sulphates", "loading": 0.33},
            ],
            "projection": [[0.1, 0.2], [0.3, -0.1]],
        },
        "kmeans": {
            "best_k": 3, "silhouette": 0.27,
            "scores_by_k": [{"k": 2, "silhouette": 0.21}, {"k": 3, "silhouette": 0.27}],
            "cluster_sizes": [700, 500, 399],
            "centers": [[0.1, 0.2, 0.3, 0.4]],
            "n_rows_used": 1599, "n_features": 4,
        },
        "outliers": {
            "n_outliers": 80, "outlier_pct": 5.0, "threshold": -0.0123,
            "n_rows_used": 1599,
        },
        "normality": normality,
        "note": "",
        "_feats": feats,
    }


def _cluster_projection() -> dict:
    # 30 points across 3 clusters, aligned points<->labels.
    points, labels = [], []
    centers = [(-2.0, -2.0), (2.0, 0.0), (0.0, 2.5)]
    for cl, (cx, cy) in enumerate(centers):
        for j in range(10):
            points.append([cx + (j - 5) * 0.05, cy + (j - 5) * 0.05])
            labels.append(cl)
    return {
        "points": points, "labels": labels,
        "centers_2d": [list(c) for c in centers],
        "best_k": 3, "silhouette": 0.27,
        "explained_2d": [0.41, 0.22],
        "cluster_sizes": [10, 10, 10],
        "cluster_profiles": [
            {"cluster": 0, "size": 10, "pct": 0.33,
             "centroid_original": {"alcohol": 9.5, "ph": 3.5},
             "distinctive": ["alcohol", "ph"], "centroid_z": {"alcohol": -1.2}},
            {"cluster": 1, "size": 10, "pct": 0.33,
             "centroid_original": {"alcohol": 12.0, "ph": 3.1},
             "distinctive": ["alcohol"], "centroid_z": {"alcohol": 1.4}},
            {"cluster": 2, "size": 10, "pct": 0.33,
             "centroid_original": {"alcohol": 10.5, "ph": 3.8},
             "distinctive": ["ph"], "centroid_z": {"ph": 1.6}},
        ],
        "feature_names": ["alcohol", "ph", "fixed_acidity", "sulphates"],
        "n_used": 1599, "note": "",
    }


def _ctx_full() -> dict:
    return {
        "cluster_projection": _cluster_projection(),
        "cluster_titles": [
            {"cluster": 0, "title": "Vinos suaves de baja graduación",
             "description": "Alcohol bajo y pH alto; perfil ligero."},
            {"cluster": 1, "title": "Vinos potentes",
             "description": "Alta graduación alcohólica."},
            {"cluster": 2, "title": "Vinos de pH elevado",
             "description": "Acidez baja relativa al resto."},
        ],
    }


def _profile() -> dict:
    return {"table": "wine", "n_rows": 1599, "n_cols": 12,
            "models": _models_block()}


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)
    out = []
    for slide in prs.slides:
        for shape in slide.shapes:
            if shape.has_text_frame:
                out.append(shape.text_frame.text)
    return re.sub(r"\s+", " ", " ".join(out))


# --------------------------------------------------------------------------- #
# Golden.
# --------------------------------------------------------------------------- #
def test_golden_build_modelos_bloques_requeridos():
    ch = build_modelos(_profile(), _ctx_full())
    assert ch is not None
    assert ch.id == "modelos" and ch.version
    # Both figures present: scree plot + cluster scatter.
    n_figures = sum(1 for b in ch.blocks if isinstance(b, Figure))
    assert n_figures >= 2
    # Tables present (variance, loadings, sizes, normality).
    assert sum(1 for b in ch.blocks if isinstance(b, DataTable)) >= 3
    # Markdown carries the required explanations.
    md = " ".join(b.text for b in ch.blocks if isinstance(b, Markdown))
    assert "z-score" in md                       # normalization explained
    assert "Isolation Forest" in md              # outlier generation explained
    assert "silhouette" in md                    # kmeans
    # Per-cluster micro-analysis titles present.
    assert "Vinos potentes" in md
    assert "Cluster 1" in md


def test_golden_render_pdf_muestra_lo_exigido():
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "modelos.pdf")
        res = render_automatic_eda_pdf(
            _profile(), out, {"title": "EDA — wine", "ctx": _ctx_full()})
        assert res["path"] == out and os.path.exists(out)
        ids = [c["id"] for c in res["chapters"]]
        assert "modelos" in ids
        txt = _pdf_text(out)
        for needle in ("Modelos no supervisados", "z-score", "PCA",
                       "Segmentación", "Isolation Forest", "Normalidad",
                       "Vinos potentes"):
            assert needle in txt, f"falta en PDF: {needle}"


def test_golden_render_pptx_muestra_lo_exigido():
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "modelos.pptx")
        res = render_automatic_eda_pptx(
            _profile(), out, {"title": "EDA — wine", "ctx": _ctx_full()})
        assert res["path"] == out and os.path.exists(out)
        assert res["n_slides"] >= 1
        txt = _pptx_text(out)
        for needle in ("Modelos no supervisados", "z-score", "Isolation Forest",
                       "Vinos potentes"):
            assert needle in txt, f"falta en PPTX: {needle}"


# --------------------------------------------------------------------------- #
# Edges.
# --------------------------------------------------------------------------- #
def test_edge_profile_none_o_vacio_devuelve_none():
    assert build_modelos(None, {}) is None
    assert build_modelos({}, {}) is None
    assert build_modelos({"n_rows": 5}, None) is None  # no 'models' key


def test_edge_models_insuficiente_devuelve_none():
    prof = {"table": "tiny", "models": {
        "n_numeric_cols": 1,
        "pca": {"n_components": 0, "explained_variance_ratio": [],
                "note": "datos insuficientes"},
        "kmeans": {"best_k": 0, "note": "datos insuficientes"},
        "outliers": {"n_outliers": 0, "note": "datos insuficientes"},
        "normality": None,
        "note": "insuficientes columnas numericas para modelos multivariantes",
    }}
    assert build_modelos(prof, {}) is None


def test_edge_solo_normalidad_si_genera_capitulo():
    # A single numeric column: only normality applies. Chapter must still build.
    prof = {"table": "one", "models": {
        "n_numeric_cols": 1, "pca": None, "kmeans": None, "outliers": None,
        "normality": {"x": {"n": 500, "jarque_bera": {"stat": 1.0, "p": 0.2,
                      "normal": True}, "dagostino": {"stat": 1.0, "p": 0.3,
                      "normal": True}, "shapiro": {"stat": 0.99, "p": 0.4,
                      "normal": True}, "is_normal": True}},
    }}
    ch = build_modelos(prof, {})
    assert ch is not None
    md = " ".join(b.text for b in ch.blocks if isinstance(b, Markdown))
    assert "z-score" in md  # normalization intro still present


def test_edge_kmeans_sin_proyeccion_degrada_sin_romper():
    # kmeans stats present but no cluster_projection / raw_numeric to colour by.
    prof = _profile()
    ch = build_modelos(prof, {})  # no ctx projection
    assert ch is not None
    # No scatter figure for clusters, but a Note explaining the degradation.
    notes = [b.text for b in ch.blocks if b.kind == "note"]
    assert any("ctx['raw_numeric']" in n or "cluster_projection" in n
               for n in notes)
    # PDF still renders fine.
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "deg.pdf")
        res = render_automatic_eda_pdf(prof, out, {"write_manifest": False})
        assert res["path"] == out and os.path.exists(out)


# --------------------------------------------------------------------------- #
# Anti-cut.
# --------------------------------------------------------------------------- #
def test_anticortes_tabla_normalidad_larga_no_corta():
    # 40 numeric columns → the normality DataTable must split across pages,
    # repeating the header, without losing any column name.
    prof = {"table": "wide", "models": _models_block(n_norm_cols=40)}
    with tempfile.TemporaryDirectory() as d:
        out = os.path.join(d, "wide.pdf")
        render_automatic_eda_pdf(prof, out, {"write_manifest": False,
                                             "ctx": _ctx_full()})
        reader = PdfReader(out)
        n_pages = len(reader.pages)
        assert n_pages > 1
        txt = "".join((pg.extract_text() or "") for pg in reader.pages)
        # Every column name survives (wrapped/split, never truncated).
        for i in (0, 19, 39):
            assert f"col_{i}" in txt


def test_glosario_engancha_terminos_modelos():
    """Mejora 4b: PCA, KMeans, silhouette, Isolation Forest y la estandarización
    z-score 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_full())
    ctx["glossary"] = g
    ch = build_modelos(_profile(), ctx)
    assert ch is not None
    keys = {t["key"] for t in g.terms()}
    assert {"zscore", "pca", "kmeans", "silhouette", "isolation_forest"} <= keys
    body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
    for k in ("zscore", "pca", "kmeans", "silhouette", "isolation_forest"):
        assert f"[[term:{k}]]" in body, k

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