"""Tests for the CORRELACION chapter — DoD: golden + edges + error/anti-cut.

Self-contained: builds a synthetic TableProfile carrying a ``correlations`` block
shaped exactly like ``association_matrix`` output (no DuckDB), so the suite is
fast and deterministic. Verifies that the chapter emits the association-matrix
figure plus separate top-positive / top-negative tables with the right pairs,
that it returns None when the profile has no pairs, that a None/empty profile
does not raise, and that a wide matrix with long labels renders to PDF *and* PPTX
without cutting anything.
"""

import os
import re
import tempfile

from pypdf import PdfReader

from datascience.automatic_eda.chapters.correlacion import (
    CHAPTER_VERSION,
    build_correlacion,
)
from datascience.automatic_eda.model import DataTable, Figure
from datascience.render_automatic_eda_pdf import render_automatic_eda_pdf
from datascience.render_automatic_eda_pptx import render_automatic_eda_pptx


def _pair(a, b, value, method, padj, sig, p=0.0001):
    return {
        "a": a, "b": b, "a_type": "numeric", "b_type": "numeric",
        "method": method, "value": value, "extra": {"mi": abs(value) * 0.5},
        "p_value": p, "p_value_adjusted": padj, "significant": sig,
    }


def _profile() -> dict:
    """Synthetic wine-like profile with signed and unsigned associations."""
    pairs = [
        _pair("alcohol", "quality", 0.48, "pearson/spearman", 0.0005, True),
        _pair("density", "alcohol", -0.78, "pearson/spearman", 0.0001, True),
        _pair("ph", "fixed_acidity", -0.68, "pearson/spearman", 0.0002, True),
        _pair("sulphates", "quality", 0.25, "pearson/spearman", 0.03, True),
        # Unsigned mixed-type metrics: only ever positive, never in the neg table.
        {"a": "region", "b": "type", "a_type": "categorical",
         "b_type": "categorical", "method": "cramers_v", "value": 0.55,
         "extra": {"mi": 0.3}, "p_value": 0.001, "p_value_adjusted": 0.004,
         "significant": True},
    ]
    return {
        "table": "wine",
        "source": "/data/wine.csv",
        "n_rows": 1599,
        "n_cols": 12,
        "correlations": {
            "pairs": pairs,
            "strong": [p for p in pairs if abs(p["value"]) >= 0.5],
            "methods_legend": {
                "pearson": "num-num lineal (Pearson r), [-1, 1]",
                "cramers_v": "cat-cat simétrica (Cramér's V), [0, 1]",
            },
            "multiple_testing": {"method": "bh", "alpha": 0.05,
                                 "n_tests": 5, "n_rejected": 5},
        },
    }


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 test_golden_chapter_tiene_matriz_y_top_positivos_y_negativos():
    ch = build_correlacion(_profile(), {})
    assert ch is not None
    assert ch.id == "correlacion"
    assert ch.version == CHAPTER_VERSION
    kinds = [b.kind for b in ch.blocks]
    assert "figure" in kinds  # association matrix heatmap.
    figs = [b for b in ch.blocks if isinstance(b, Figure)]
    assert figs and figs[0].make is not None  # lazy figure.

    tables = [b for b in ch.blocks if isinstance(b, DataTable)]
    assert len(tables) >= 2  # top positive + top negative.
    flat = " ".join(str(c) for t in tables for r in t.rows for c in r)
    # Strongest positive present and signed +, strongest negative present and -.
    assert "alcohol" in flat and "quality" in flat
    assert "+0.48" in flat
    assert "density" in flat and "-0.78" in flat


def test_golden_render_pdf_y_pptx_muestran_lo_exigido():
    prof = _profile()
    with tempfile.TemporaryDirectory() as d:
        pdf = os.path.join(d, "corr.pdf")
        pptx = os.path.join(d, "corr.pptx")
        rp = render_automatic_eda_pdf(prof, pdf, {"title": "EDA — wine"})
        rx = render_automatic_eda_pptx(prof, pptx, {"title": "EDA — wine"})
        assert rp["path"] == pdf and rp["n_pages"] >= 1
        assert rx["path"] == pptx and rx["n_slides"] >= 1
        assert "correlacion" in [c["id"] for c in rp["chapters"]]
        assert "correlacion" in [c["id"] for c in rx["chapters"]]
        txt = _pdf_text(pdf)
        # The requirement: matrix + top positive/negative pairs, all visible.
        assert "Correlaci" in txt  # chapter title (accents may vary in extract).
        assert "density" in txt and "alcohol" in txt and "quality" in txt
        assert "0.78" in txt and "0.48" in txt
        # Both signs surfaced as separate sections.
        assert "positiv" in txt.lower() and "negativ" in txt.lower()


def test_edge_sin_pares_devuelve_none():
    # No correlations key, empty pairs, and wrong types all yield None, not error.
    assert build_correlacion({"table": "x"}, {}) is None
    assert build_correlacion({"correlations": {}}, {}) is None
    assert build_correlacion({"correlations": {"pairs": []}}, {}) is None
    assert build_correlacion({"correlations": {"pairs": "nope"}}, {}) is None
    assert build_correlacion(None, None) is None
    assert build_correlacion({}, {}) is None


def test_edge_solo_positivos_emite_nota_sin_tabla_negativa():
    prof = {
        "correlations": {
            "pairs": [
                _pair("a", "b", 0.6, "pearson/spearman", 0.001, True),
                {"a": "c", "b": "d", "a_type": "categorical",
                 "b_type": "categorical", "method": "cramers_v", "value": 0.7,
                 "extra": {"mi": 0.4}, "p_value": 0.001,
                 "p_value_adjusted": 0.003, "significant": True},
            ],
        },
    }
    ch = build_correlacion(prof, {})
    assert ch is not None
    tables = [b for b in ch.blocks if isinstance(b, DataTable)]
    assert len(tables) == 1  # only the positive table.
    notes = " ".join(b.text for b in ch.blocks if b.kind == "note")
    assert "negativas" in notes  # honest "no negative correlations" note.


def test_anticorte_matriz_ancha_y_etiquetas_largas_no_se_cortan():
    # 20 numeric vars with long names -> matrix trimmed to top-N + both renderers
    # must lay the chapter out without raising and keep a long label intact.
    long_a = "concentracion_de_dioxido_de_azufre_libre"
    long_b = "concentracion_de_dioxido_de_azufre_total"
    pairs = [_pair(long_a, long_b, -0.72, "pearson/spearman", 0.0001, True)]
    for i in range(20):
        pairs.append(_pair(f"variable_numerica_larga_{i:02d}",
                           f"variable_numerica_larga_{(i + 1) % 20:02d}",
                           0.55 - i * 0.02, "pearson/spearman", 0.01, True))
    prof = {"correlations": {"pairs": pairs,
                             "multiple_testing": {"method": "bh", "alpha": 0.05,
                                                  "n_tests": len(pairs),
                                                  "n_rejected": len(pairs)}}}
    ch = build_correlacion(prof, {})
    assert ch is not None
    # A "showing top-N most connected" note appears when the matrix is trimmed.
    notes = " ".join(b.text for b in ch.blocks if b.kind == "note")
    assert "más conectadas" in notes
    # Anti-cut guarantee at the block level: the long pair reaches the renderer
    # whole (the block never truncates); the renderer then wraps the cell inside
    # its column. Both long labels are present, intact, in a table cell.
    tables = [b for b in ch.blocks if isinstance(b, DataTable)]
    cells = [str(c) for t in tables for r in t.rows for c in r]
    assert any(long_a in c and long_b in c for c in cells)
    with tempfile.TemporaryDirectory() as d:
        pdf = os.path.join(d, "wide.pdf")
        pptx = os.path.join(d, "wide.pptx")
        rp = render_automatic_eda_pdf(prof, pdf, {"write_manifest": False})
        rx = render_automatic_eda_pptx(prof, pptx, {"write_manifest": False})
        # Both renderers lay the wide chapter out without raising and produce a
        # non-empty document (nothing dropped, just wrapped/scaled to fit).
        assert rp["path"] == pdf and os.path.exists(pdf) and rp["n_pages"] >= 1
        assert rx["path"] == pptx and os.path.exists(pptx) and rx["n_slides"] >= 1
        # A short, unbreakable fragment of the long label survives the wrap.
        assert "azufre" in _pdf_text(pdf)


def _raw_numeric_for_profile(n: int = 80) -> dict:
    """Row-aligned raw numeric sample matching the signed pairs of _profile().

    Builds columns with a clear, deterministic shape so the relationship-type
    classifier has something unambiguous to label:
      - density vs alcohol: strong negative linear (the top-negative pair).
      - alcohol vs quality: positive linear.
      - ph, fixed_acidity, sulphates: filler columns for the remaining pairs.
    """
    import math as _m

    alcohol = [8.0 + 0.05 * i for i in range(n)]
    density = [1.0 - 0.002 * a for a in alcohol]           # neg linear vs alcohol
    quality = [3.0 + 0.4 * a + (0.1 if i % 2 else -0.1)    # pos linear vs alcohol
               for i, a in enumerate(alcohol)]
    ph = [3.0 + 0.3 * _m.sin(i / 5.0) for i in range(n)]
    fixed_acidity = [7.0 - 0.5 * p for p in ph]            # neg linear vs ph
    sulphates = [0.5 + 0.01 * (i % 7) for i in range(n)]
    return {
        "alcohol": alcohol, "density": density, "quality": quality,
        "ph": ph, "fixed_acidity": fixed_acidity, "sulphates": sulphates,
    }


def test_golden_scatters_de_pares_num_num_con_tipo_de_relacion():
    """Con ctx['raw_numeric'], el capítulo añade scatters (Figure dentro de Group)
    de los pares num-num más fuertes, cada uno con su etiqueta de tipo en texto."""
    from datascience.automatic_eda.model import Group

    ctx = {"raw_numeric": _raw_numeric_for_profile()}
    ch = build_correlacion(_profile(), ctx)
    assert ch is not None
    groups = [b for b in ch.blocks if isinstance(b, Group)]
    assert groups, "debe emitir al menos un Group con scatter"
    # Cada Group lleva su figura (lazy) y una nota de texto con el tipo.
    for g in groups:
        gkinds = [b.kind for b in g.blocks]
        assert "figure" in gkinds and "markdown" in gkinds
    # La sección y la etiqueta de tipo aparecen como texto plano (extraíble).
    headings = " ".join(b.text for b in ch.blocks if b.kind == "heading")
    assert "Relaciones más fuertes" in headings
    body = " ".join(b.text for g in groups for b in g.blocks
                    if b.kind == "markdown")
    assert any(t in body for t in
               ("lineal", "polinómica", "monótona", "sin forma"))
    # El par num-num más fuerte (density ↔ alcohol) tiene scatter; el par cat-cat
    # (region ↔ type) NO — no es numérico.
    assert "density" in body or "alcohol" in body
    assert "region" not in body and "type" not in body


def test_golden_pdf_muestra_scatters_con_etiqueta_de_tipo():
    """En el PDF, el capítulo Correlación incluye los scatters y su etiqueta de
    tipo en texto seleccionable (pdftotext la encuentra)."""
    prof = _profile()
    ctx = {"raw_numeric": _raw_numeric_for_profile()}
    with tempfile.TemporaryDirectory() as d:
        pdf = os.path.join(d, "corr_scatter.pdf")
        rp = render_automatic_eda_pdf(prof, pdf, {"title": "EDA — wine",
                                                  "ctx": ctx})
        assert rp["path"] == pdf and rp["n_pages"] >= 1
        txt = _pdf_text(pdf)
        assert "Relaciones" in txt and "scatter" in txt.lower()
        # Alguna etiqueta de tipo de relación, en texto.
        assert any(t in txt for t in
                   ("lineal", "polin", "monóton", "monoton", "sin forma"))


def test_edge_sin_raw_numeric_omite_scatters_sin_lanzar():
    """profile lite / ctx None: sin raw_numeric el capítulo omite los scatters
    pero sigue emitiendo matriz + tablas (no lanza)."""
    from datascience.automatic_eda.model import Group

    for ctx in (None, {}, {"raw_numeric": None}, {"raw_numeric": {}}):
        ch = build_correlacion(_profile(), ctx)
        assert ch is not None
        assert not [b for b in ch.blocks if isinstance(b, Group)]
        # La matriz y al menos una tabla top siguen presentes.
        assert any(b.kind == "figure" for b in ch.blocks)
        assert any(b.kind == "data_table" for b in ch.blocks)


def test_edge_par_sin_columna_cruda_se_omite_sin_lanzar():
    """Si un par seleccionado no tiene su columna en raw_numeric, se omite ese
    par (no lanza); los demás scatters se construyen igual."""
    from datascience.automatic_eda.model import Group

    raw = _raw_numeric_for_profile()
    raw.pop("density", None)   # rompe el par density ↔ alcohol
    ch = build_correlacion(_profile(), {"raw_numeric": raw})
    assert ch is not None
    groups = [b for b in ch.blocks if isinstance(b, Group)]
    body = " ".join(b.text for g in groups for b in g.blocks
                    if b.kind == "markdown")
    # density desaparece de los scatters; otros pares (p.ej. ph↔fixed_acidity,
    # alcohol↔quality) pueden seguir presentes sin error.
    assert "density" not in body


def test_glosario_engancha_metodos_y_fdr():
    """Mejora 4b: los métodos de correlación (Pearson, Spearman, Cramér's V,
    razón de correlación) y la corrección por comparaciones múltiples (FDR) 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()
    ch = build_correlacion(_profile(), {"glossary": g})
    assert ch is not None
    keys = {t["key"] for t in g.terms()}
    assert {"pearson", "spearman", "cramers_v", "correlation_ratio", "fdr"} <= keys
    body = " ".join(b.text for b in ch.blocks if b.kind == "markdown")
    for k in ("pearson", "spearman", "cramers_v", "correlation_ratio", "fdr"):
        assert f"[[term:{k}]]" in body, k

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