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

Self-contained: builds synthetic ``numeric`` blocks (no DuckDB) so the suite is
fast and deterministic. Verifies that the chapter emits, per numeric column, a
histogram+boxplot figure plus a stats note; that the mean/median/±σ requirement
and the boxplot are present; that a profile with no numeric column yields None;
that None/empty never raises; and that with many numeric columns and long text
both the PDF and the PPTX render without cutting anything (every column heading
survives in the rendered output).
"""

import os
import re
import tempfile

from pypdf import PdfReader

from datascience.automatic_eda.chapters.num_distr import (
    build_num_distr, CHAPTER_VERSION, _DIST_GLOSS,
)
from datascience.automatic_eda import model
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx


def _numeric_block(mean, median, std, mn, mx, dist="normal-ish",
                   n_outliers=0, nbins=10):
    """A synthetic ``numeric`` sub-block shaped like describe_numeric's output."""
    width = (mx - mn) / nbins if mx > mn else 1.0
    hist = [{"lo": mn + i * width, "hi": mn + (i + 1) * width,
             "count": (i + 1) * 3} for i in range(nbins)]
    p25 = mn + (mx - mn) * 0.25
    p75 = mn + (mx - mn) * 0.75
    return {
        "min": mn, "max": mx, "mean": mean, "median": median, "std": std,
        "p25": p25, "p50": median, "p75": p75, "iqr": p75 - p25,
        "n_outliers": n_outliers, "outlier_pct": 100.0 * n_outliers / 300.0,
        "distribution_type": dist, "histogram": hist,
    }


def _profile(n_numeric=2, extra_categorical=True):
    cols = []
    presets = [
        ("precio", 42.5, 40.0, 12.3, 1.0, 100.0, "right-skewed", 5),
        ("alcohol", 10.4, 10.3, 1.1, 8.0, 14.9, "normal-ish", 0),
        ("sulfatos", 0.66, 0.62, 0.17, 0.33, 2.0, "heavy-tail", 9),
        ("calidad", 5.6, 6.0, 0.8, 3.0, 8.0, "discrete", 0),
    ]
    for i in range(n_numeric):
        name, mean, med, std, mn, mx, dist, no = presets[i % len(presets)]
        if i >= len(presets):
            name = f"{name}_{i}"
        cols.append({"name": name, "inferred_type": "numeric",
                     "numeric": _numeric_block(mean, med, std, mn, mx, dist, no)})
    if extra_categorical:
        cols.append({"name": "categoria", "inferred_type": "categorical",
                     "categorical": {"top": [{"value": "tinto", "count": 200}]}})
    return {"table": "vinos", "n_rows": 300, "n_cols": len(cols),
            "columns": cols}


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 _flatten(blocks):
    """Expand keep-together Groups so the per-column heading/figure/markdown 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 test_golden_chapter_estructura_y_bloques():
    ch = build_num_distr(_profile(n_numeric=2), {})
    assert ch is not None
    assert ch.id == "num_distr"
    assert ch.version == CHAPTER_VERSION
    # Per-column blocks are wrapped in keep-together Groups: flatten to inspect.
    flat = _flatten(ch.blocks)
    kinds = [b.kind for b in flat]
    # Heading + intro Markdown, then per column: Heading + Figure + Markdown.
    assert kinds[0] == "heading"
    assert kinds[1] == "markdown"
    assert kinds.count("figure") == 2          # one figure per numeric column.
    assert kinds.count("heading") == 1 + 2     # chapter title + one per column.
    # Each figure has a lazy maker that produces a real matplotlib figure.
    figs = [b for b in flat if b.kind == "figure"]
    fig = figs[0].make()
    assert fig is not None
    # Two stacked axes: histogram + boxplot share the figure.
    assert len(fig.axes) == 2
    import matplotlib.pyplot as plt
    plt.close(fig)


def test_golden_media_mediana_sigma_y_boxplot_presentes():
    # The short intro names the three reference lines and the Tukey boxplot; the
    # per-column note carries the actual mean/median/σ numbers and the shape.
    ch = build_num_distr(_profile(n_numeric=1, extra_categorical=False), {})
    md_texts = " ".join(b.text for b in _flatten(ch.blocks)
                        if b.kind == "markdown")
    assert "media" in md_texts and "mediana" in md_texts
    assert "±1σ" in md_texts or "σ" in md_texts
    assert "boxplot" in md_texts.lower()
    assert "Tukey" in md_texts
    # The long "how to read it" explanation moved to the glossary: the colour-code
    # / 1,5·IQR walkthrough is no longer inline in the chapter body.
    assert "1,5·IQR" not in md_texts
    assert "línea roja" not in md_texts
    # distribution_type gloss surfaced for the column (right-skewed preset).
    assert _DIST_GLOSS["right-skewed"].split(";")[0][:20] in md_texts


def test_glosario_histograma_boxplot_clicable_y_definicion():
    # With a glossary collector the intro marks the clickable term and the FULL
    # explanation (the long paragraph removed from the body) lands in the glossary.
    from datascience.automatic_eda.chapters.glosario import build_glosario

    gc = model.GlossaryCollector()
    prof = _profile(n_numeric=1, extra_categorical=False)
    ch = build_num_distr(prof, {"glossary": gc})
    intro = next(b for b in ch.blocks if b.kind == "markdown")
    assert "[[term:histograma_boxplot]]" in intro.text
    assert gc.has("histograma_boxplot")
    glos = build_glosario(prof, {"glossary": gc})
    entry = next(b for b in glos.blocks
                 if getattr(b, "kind", "") == "glossary_entry"
                 and b.key == "histograma_boxplot")
    assert "boxplot" in entry.definition.lower()
    assert "1,5·IQR" in entry.definition


def test_llm_descripcion_y_unidad_por_columna():
    # With an LLM dictionary, each numeric column whose name matches shows its
    # business description and unit in a per-column markdown block.
    prof = _profile(n_numeric=2)
    prof["llm"] = {"dictionary": [
        {"column": "precio", "description": "Precio de venta del producto",
         "unit": "EUR"},
        {"column": "alcohol", "business_meaning": "Grado alcohólico",
         "unit": "% vol"},
    ]}
    ch = build_num_distr(prof, {})
    md_all = " ".join(b.text for b in _flatten(ch.blocks)
                      if b.kind == "markdown")
    assert "Precio de venta" in md_all and "EUR" in md_all
    assert "Grado alcohólico" in md_all and "% vol" in md_all


def test_edge_sin_llm_no_anade_descripcion():
    # Without an LLM block the per-column description markdown is simply omitted.
    ch = build_num_distr(_profile(n_numeric=2), {})
    md_all = " ".join(b.text for b in _flatten(ch.blocks)
                      if b.kind == "markdown")
    assert "Descripción" not in md_all


def test_boxplot_stats_se_consumen_del_registry():
    # The chapter must feed build_boxplot_stats (group eda) and the resulting
    # box must carry the Tukey fences for the figure.
    from datascience.build_boxplot_stats import build_boxplot_stats
    box = build_boxplot_stats(
        _numeric_block(42.5, 40.0, 12.3, 1.0, 100.0, "right-skewed", 5))
    assert box
    assert "lower_fence" in box and "upper_fence" in box
    assert box["q1"] is not None and box["q3"] is not None


def test_edge_sin_columnas_numericas_devuelve_none():
    prof = {"columns": [{"name": "c", "inferred_type": "categorical",
                         "categorical": {"top": []}}]}
    assert build_num_distr(prof, {}) is None


def test_edge_profile_none_y_vacio_no_revienta():
    assert build_num_distr(None, None) is None
    assert build_num_distr({}, {}) is None
    assert build_num_distr({"columns": []}, {}) is None


def test_anti_corte_muchas_columnas_pdf_y_pptx():
    # 8 numeric columns + long note text: nothing may be cut. Every column
    # heading must survive in both the PDF text and the PPTX deck.
    ch = build_num_distr(_profile(n_numeric=8), {})
    names = [b.text for b in _flatten(ch.blocks)
             if b.kind == "heading" and b.level == 2]
    assert len(names) == 8
    with tempfile.TemporaryDirectory() as d:
        pdf = os.path.join(d, "num.pdf")
        res_pdf = render_automatic_eda_pdf(_profile(n_numeric=8), pdf,
                                           {"write_manifest": False})
        assert res_pdf["path"] == pdf
        txt = _pdf_text(pdf)
        for name in names:
            assert name in txt, f"columna '{name}' cortada/ausente en el PDF"
        pptx = os.path.join(d, "num.pptx")
        res_pptx = render_automatic_eda_pptx(_profile(n_numeric=8), pptx,
                                             {"write_manifest": False})
        assert res_pptx["path"] == pptx
        assert res_pptx["n_slides"] >= 8  # at least one slide per column figure.


def _hist_legend_texts(numeric, box=None):
    """Build the per-column figure and return its histogram-legend label texts."""
    from datascience.automatic_eda.chapters.num_distr import _make_hist_box
    import matplotlib.pyplot as plt
    fig = _make_hist_box("col", numeric, box or {})
    ax_h = fig.axes[0]  # the histogram is the top axis.
    leg = ax_h.get_legend()
    texts = [t.get_text() for t in leg.get_texts()] if leg else []
    plt.close(fig)
    return texts


def test_golden_leyenda_histograma_reporta_valor_std():
    # The histogram legend must report the numeric value of the standard
    # deviation σ next to mean and median.
    numeric = _numeric_block(42.5, 40.0, 12.3, 1.0, 100.0, "right-skewed", 5)
    texts = _hist_legend_texts(numeric)
    joined = " ".join(texts)
    assert any("σ =" in t for t in texts), f"σ value missing in legend: {texts}"
    assert "12.3" in joined, f"std value 12.3 not in legend: {texts}"
    assert any("media =" in t for t in texts)
    assert any("mediana =" in t for t in texts)


def test_edge_std_en_leyenda_aunque_no_haya_banda():
    # When the ±1σ band cannot be drawn (no mean) but σ is known, the legend
    # still surfaces the σ value via a label-only proxy handle.
    numeric = _numeric_block(42.5, 40.0, 7.5, 1.0, 100.0, "right-skewed", 0)
    numeric["mean"] = None  # forces the band off; σ must still appear.
    texts = _hist_legend_texts(numeric)
    assert any("σ = 7.5" in t for t in texts), f"σ proxy missing: {texts}"


def test_edge_sin_std_no_revienta_la_figura():
    # A numeric block without σ must not raise and simply omits the σ entry.
    import matplotlib.pyplot as plt
    numeric = _numeric_block(42.5, 40.0, 0.0, 1.0, 100.0, "discrete", 0)
    numeric["std"] = None
    texts = _hist_legend_texts(numeric)
    assert not any("σ =" in t for t in texts)
    # mean/median lines still produce their own legend entries.
    assert any("media =" in t for t in texts)


def test_distribution_gloss_cubre_todas_las_etiquetas():
    # Every label detect_distribution_type can emit has a Spanish gloss.
    for label in ("normal-ish", "right-skewed", "left-skewed", "heavy-tail",
                  "lognormal-ish", "multimodal", "discrete", "too_few_samples",
                  "other"):
        assert label in _DIST_GLOSS and _DIST_GLOSS[label]
