"""Correlation chapter — association matrix plus top positive/negative pairs.

Builds the CORRELACION chapter of an AutomaticEDA document from a TableProfile.
It renders exactly what the user asked for:

1. A correlation/association **matrix** (heatmap) reconstructed from the evaluated
   pairs, signed for numeric-numeric pairs (Pearson/Spearman, ``[-1, 1]``) and as
   magnitude for the mixed-type metrics (Cramér's V, correlation ratio, mutual
   information, ``[0, 1]``). Labels are ordered by total connectivity so strong
   associations cluster together instead of being scattered alphabetically.
2. The **TOP positive** pairs and the **TOP negative** pairs as two separate
   tables. Only numeric-numeric metrics carry a sign, so negative pairs are by
   construction Pearson/Spearman; positive pairs may use any method.
3. The methods legend and the multiple-testing (FDR) summary, so the reader sees
   how many pairs survive the correction.
4. A spuriousness caveat when the profile flags level-based correlations on
   non-stationary series (Granger–Newbold).

All data comes from ``profile['correlations']`` — the output of the ``eda`` group
function ``association_matrix`` (optionally enriched by ``profile_table``). The
chapter never recomputes any statistic; it only lays the existing values out as
format-independent blocks. The renderers paginate tables (repeating the header)
and scale the heatmap to fit entirely, so nothing is ever cut.

Contract: build_<id>(profile, ctx) -> Chapter | None ; CHAPTER_VERSION = "x.y.z".
"""

from __future__ import annotations

import math

from .. import model

CHAPTER_VERSION = "1.1.0"
CHAPTER_ID = "correlacion"
CHAPTER_TITLE = "Correlación"

# Methods whose value carries a sign (direction). Everything else is a magnitude
# in [0, 1] and therefore only ever contributes to the positive side.
_SIGNED_METHODS = ("pearson", "spearman")

# Cap the heatmap to the most-connected variables so it stays legible on a phone
# screen / a slide. The renderer would scale a bigger matrix to fit, but the
# cells become unreadable; we instead show the top-N and say so.
_MAX_MATRIX_LABELS = 16

# How many pairs to show in each of the top-positive / top-negative tables.
_TOP_N = 10

# How many of the strongest numeric-numeric pairs to draw as scatter plots on
# each sign (positive / negative). A scatter per pair carries a fitted line/curve
# and a relationship-type label; keeping the count small keeps the chapter
# readable on a phone / a slide. Only signed (Pearson/Spearman) pairs qualify —
# Cramér's V / correlation ratio pairs are not numeric-numeric, so no scatter.
_SCATTER_TOP_N = 3

# Glossary terms this chapter explains. Each is registered in the shared
# collector (ctx['glossary']) and marked clickable on its first appearance in the
# body — the canonical two-step pattern (see ``cat_distr`` for the reference
# implementation): ``glossary.add(key, label, definition)`` + the inline span
# ``[[term:KEY]]texto visible[[/term]]`` in a Markdown block. Mapping key ->
# (label, definition). ``fdr`` is only registered when the FDR summary is present.
_TERM_DEFS = {
    "pearson": (
        "Pearson (coeficiente r)",
        "Coeficiente de correlación lineal de Pearson (r) entre dos variables "
        "numéricas. Va de −1 (relación lineal inversa perfecta) a +1 (directa "
        "perfecta); 0 indica ausencia de relación lineal. Sólo capta relaciones "
        "lineales, por eso lleva signo."),
    "spearman": (
        "Spearman (correlación de rangos)",
        "Correlación de rangos de Spearman: el coeficiente de Pearson calculado "
        "sobre los puestos (rangos) de los valores en vez de sus magnitudes. Mide "
        "relaciones monótonas (no necesariamente lineales), va de −1 a +1 y es "
        "robusta frente a valores atípicos."),
    "cramers_v": (
        "Cramér's V",
        "Medida de asociación entre dos variables categóricas, derivada del "
        "estadístico chi-cuadrado y normalizada al rango 0–1 (0 = independientes, "
        "1 = asociación total). No tiene signo: sólo mide la intensidad."),
    "correlation_ratio": (
        "Razón de correlación (η)",
        "Razón de correlación (eta) entre una variable numérica y una "
        "categórica: la fracción de la varianza de la numérica explicada por los "
        "grupos de la categórica. Va de 0 (los grupos no explican nada) a 1 (la "
        "explican toda); no tiene signo."),
    "fdr": (
        "Comparaciones múltiples (FDR)",
        "Al evaluar muchos pares a la vez, algunos parecen significativos por "
        "puro azar. La corrección por tasa de falsos descubrimientos (FDR, "
        "Benjamini-Hochberg) ajusta los p-valores para controlar la proporción "
        "esperada de falsos positivos entre los pares declarados significativos."),
}


def _term(mark: bool, key: str, text: str) -> str:
    """Wrap ``text`` as a clickable glossary span when ``mark`` is True.

    The visible text is identical with or without the marker (the renderers strip
    the marker), so wrapping never changes line layout — it only adds the link.
    """
    return f"[[term:{key}]]{text}[[/term]]" if mark else text


def _is_num(v) -> bool:
    """True for a real, finite int/float (not bool, not NaN/inf)."""
    return (
        isinstance(v, (int, float))
        and not isinstance(v, bool)
        and not (isinstance(v, float) and (math.isnan(v) or math.isinf(v)))
    )


def _fmt_val(value, decimals: int = 2) -> str:
    """Format an association value compactly, signed, with a fixed width feel."""
    if not _is_num(value):
        return "—"
    text = f"{float(value):+.{decimals}f}"
    # Strip a trailing -0.00 / +0.00 into a clean 0.00 for readability.
    if text in ("+0.00", "-0.00"):
        return "0.00"
    return text


def _fmt_p(value) -> str:
    """Format an adjusted p-value; tiny values collapse to a '<' threshold."""
    if not _is_num(value):
        return "—"
    p = float(value)
    if p < 0.001:
        return "<0.001"
    return f"{p:.3f}"


def _is_signed(pair: dict) -> bool:
    """True if the pair's method reports a directional (signed) value."""
    method = str(pair.get("method") or "").lower()
    return any(m in method for m in _SIGNED_METHODS)


def _significant(pair: dict) -> bool:
    """True if the pair is significant after FDR (or has no test to correct)."""
    if pair.get("significant") is True:
        return True
    # Pairs without an applicable test (p_value None) are not penalised: they are
    # admitted on magnitude alone upstream, so treat missing as "not rejected".
    return pair.get("p_value") is None and pair.get("significant") is None


def _label(pair: dict) -> str:
    """Human label for a pair, e.g. 'alcohol ↔ density'."""
    return f"{model._safe_str(pair.get('a'))} ↔ {model._safe_str(pair.get('b'))}"


def _split_top(pairs: list, top_n: int = _TOP_N):
    """Split evaluated pairs into ranked top-positive and top-negative lists.

    Positive: any pair with a positive value, ranked by value descending.
    Negative: only signed (numeric-numeric) pairs with a negative value, ranked
    by value ascending (most negative first). Non-finite values are dropped.
    """
    positive = []
    negative = []
    for pair in pairs:
        if not isinstance(pair, dict):
            continue
        value = pair.get("value")
        if not _is_num(value):
            continue
        if value > 0:
            positive.append(pair)
        elif value < 0 and _is_signed(pair):
            negative.append(pair)
    positive.sort(key=lambda p: float(p.get("value", 0.0)), reverse=True)
    negative.sort(key=lambda p: float(p.get("value", 0.0)))
    return positive[:top_n], negative[:top_n]


def _top_table(pairs: list, title: str):
    """Build a DataTable for a list of pairs, or None if there are none."""
    if not pairs:
        return None
    header = ["Par", "Método", "Valor", "p (FDR)", "Sig."]
    rows = []
    for pair in pairs:
        method = model._safe_str(pair.get("method")) or "—"
        rows.append([
            _label(pair),
            method,
            _fmt_val(pair.get("value")),
            _fmt_p(pair.get("p_value_adjusted")),
            "sí" if _significant(pair) else "no",
        ])
    return model.DataTable(header=header, rows=rows, title=title)


def _ordered_labels(pairs: list):
    """Pick and order the matrix labels by total connectivity (descending).

    Returns the list of variable names to place on the axes, capped at
    ``_MAX_MATRIX_LABELS`` (the most-connected ones), plus a boolean saying
    whether the cap trimmed anything.
    """
    strength = {}
    for pair in pairs:
        if not isinstance(pair, dict):
            continue
        value = pair.get("value")
        if not _is_num(value):
            continue
        mag = abs(float(value))
        for key in ("a", "b"):
            name = pair.get(key)
            if name is None:
                continue
            strength[name] = strength.get(name, 0.0) + mag
    if not strength:
        return [], False
    ordered = sorted(strength, key=lambda n: strength[n], reverse=True)
    trimmed = len(ordered) > _MAX_MATRIX_LABELS
    return ordered[:_MAX_MATRIX_LABELS], trimmed


def _matrix_figure(pairs: list, labels: list):
    """Return a Figure (lazy) with the signed association heatmap, or None.

    The matplotlib figure is built lazily inside ``make`` so importing this
    module never requires matplotlib and a malformed plot degrades to nothing
    instead of aborting the chapter.
    """
    if len(labels) < 2:
        return None

    index = {name: i for i, name in enumerate(labels)}

    def make():
        import numpy as np
        from matplotlib.figure import Figure

        n = len(labels)
        grid = np.full((n, n), np.nan, dtype=float)
        for i in range(n):
            grid[i, i] = 1.0
        for pair in pairs:
            if not isinstance(pair, dict):
                continue
            a = pair.get("a")
            b = pair.get("b")
            value = pair.get("value")
            if a not in index or b not in index or not _is_num(value):
                continue
            v = float(value)
            # Mixed-type magnitudes are non-negative; keep them as-is on [0, 1].
            ia, ib = index[a], index[b]
            grid[ia, ib] = v
            grid[ib, ia] = v

        import matplotlib

        masked = np.ma.masked_invalid(grid)
        fig = Figure(figsize=(6.2, 5.6))
        ax = fig.add_subplot(111)
        cmap = matplotlib.colormaps["RdBu_r"].copy()
        cmap.set_bad(color="#eeeeee")
        im = ax.imshow(masked, cmap=cmap, vmin=-1.0, vmax=1.0, aspect="auto")
        ax.set_xticks(range(n))
        ax.set_yticks(range(n))
        short = [str(s)[:14] for s in labels]
        ax.set_xticks(range(n))
        ax.set_xticklabels(short, rotation=90, fontsize=7)
        ax.set_yticklabels(short, fontsize=7)
        # Annotate cells only when the matrix is small enough to stay legible.
        if n <= 8:
            for i in range(n):
                for j in range(n):
                    cell = grid[i, j]
                    if _is_num(cell):
                        ax.text(j, i, f"{cell:+.2f}".replace("+", "") if cell < 0
                                else f"{cell:.2f}",
                                ha="center", va="center", fontsize=6,
                                color="#222222")
        fig.colorbar(im, ax=ax, fraction=0.046, pad=0.04,
                     label="asociación (signo en num-num)")
        fig.tight_layout()
        return fig

    return model.Figure(make=make,
                        caption="Matriz de asociación. Azul = positiva, rojo = "
                                "negativa (sólo num-num lleva signo); gris = par "
                                "no evaluado.")


def _methods_block(corr: dict):
    """Build a KVTable with the legend of the methods actually present."""
    legend = corr.get("methods_legend")
    if not isinstance(legend, dict) or not legend:
        return None
    rows = [(model._safe_str(k), model._safe_str(v)) for k, v in legend.items()]
    return model.KVTable(rows=rows, title="Métodos de asociación")


def _fdr_text(corr: dict, mark_term: bool = False) -> str | None:
    """One-line summary of the multiple-testing (FDR) correction, or None."""
    mt = corr.get("multiple_testing")
    if not isinstance(mt, dict) or not mt:
        return None
    method = model._safe_str(mt.get("method")).upper() or "FDR"
    alpha = mt.get("alpha")
    n_tests = mt.get("n_tests")
    n_rej = mt.get("n_rejected")
    multi = _term(mark_term, "fdr", "comparaciones múltiples")
    parts = [f"Corrección por {multi} ({method}"]
    if _is_num(alpha):
        parts[0] += f", α={float(alpha):g}"
    parts[0] += ")."
    if _is_num(n_tests):
        rej = n_rej if _is_num(n_rej) else "—"
        parts.append(
            f"De {int(n_tests)} pares con test, {rej} siguen siendo "
            f"significativos tras la corrección.")
    return " ".join(parts)


def _is_seq(values) -> bool:
    """True for a non-empty list/tuple of values (a raw numeric column)."""
    return isinstance(values, (list, tuple)) and len(values) > 0


def _select_scatter_pairs(pairs: list, top_n: int = _SCATTER_TOP_N):
    """Pick the strongest numeric-numeric pairs to draw as scatters.

    Only signed (Pearson/Spearman) pairs are numeric-numeric and thus eligible
    for a scatter with a fitted curve. Returns up to ``top_n`` of the strongest
    positive pairs followed by up to ``top_n`` of the strongest negative ones,
    each ranked by magnitude. Mixed-type metrics (Cramér's V, correlation ratio,
    mutual information) are excluded — they have no x/y scatter interpretation.
    """
    positive = []
    negative = []
    for pair in pairs:
        if not isinstance(pair, dict) or not _is_signed(pair):
            continue
        value = pair.get("value")
        if not _is_num(value):
            continue
        if value > 0:
            positive.append(pair)
        elif value < 0:
            negative.append(pair)
    positive.sort(key=lambda p: abs(float(p.get("value", 0.0))), reverse=True)
    negative.sort(key=lambda p: abs(float(p.get("value", 0.0))), reverse=True)
    return positive[:top_n] + negative[:top_n]


def _classification_note(a: str, b: str, cls: dict) -> str:
    """Human-readable sentence describing the relationship of a pair.

    Plain text (not baked into the figure image) so the type label is selectable
    in the PDF / extractable by pdftotext, and sits right next to its scatter
    inside the keep-together Group.
    """
    tipo = model._safe_str(cls.get("tipo")) or "sin forma clara"
    bits = []
    pearson = cls.get("pearson")
    spearman = cls.get("spearman")
    r2_lin = cls.get("r2_linear")
    r2_poly = None
    for key in ("r2_poly2", "r2_poly3"):
        v = cls.get(key)
        if _is_num(v) and (r2_poly is None or float(v) > r2_poly):
            r2_poly = float(v)
    if _is_num(pearson):
        bits.append(f"Pearson r={float(pearson):+.2f}")
    if _is_num(spearman):
        bits.append(f"Spearman ρ={float(spearman):+.2f}")
    if _is_num(r2_lin):
        bits.append(f"R² lineal={float(r2_lin):.2f}")
    if r2_poly is not None:
        bits.append(f"R² polinómico={r2_poly:.2f}")
    metrics = "; ".join(bits)
    text = (f"Relación **{tipo}** entre «{a}» y «{b}»."
            + (f" {metrics}." if metrics else ""))
    return text


def _scatter_blocks(pairs: list, raw_numeric):
    """Build keep-together scatter Groups for the strongest num-num pairs.

    Returns a list of blocks (a Heading plus one Group per pair), or an empty
    list when there is no raw numeric data (e.g. the lite profile drops
    ``ctx['raw_numeric']`` to skip live recomputation) or the relationship
    helpers are unavailable. Never raises: any failure degrades to no scatters,
    leaving the matrix + tables intact.
    """
    if not isinstance(raw_numeric, dict) or not raw_numeric:
        return []
    selected = _select_scatter_pairs(pairs)
    if not selected:
        return []

    # The relationship helpers live in the datascience package. Import lazily so
    # the chapter still builds (matrix + tables) when they are absent.
    try:
        from datascience.classify_relationship_type import (
            classify_relationship_type,
        )
        from datascience.relationship_scatter_figure import (
            relationship_scatter_figure,
        )
    except Exception:  # noqa: BLE001 — degrade, never break the chapter.
        return []

    groups = []
    for pair in selected:
        a = pair.get("a")
        b = pair.get("b")
        xs = raw_numeric.get(a)
        ys = raw_numeric.get(b)
        # Edge: a selected pair has no raw column (aggregated profile, renamed
        # column, …) — skip just that pair, keep the rest.
        if not _is_seq(xs) or not _is_seq(ys):
            continue
        try:
            cls = classify_relationship_type(list(xs), list(ys)) or {}
        except Exception:  # noqa: BLE001
            continue
        a_lbl = model._safe_str(a)
        b_lbl = model._safe_str(b)

        def _make(xs=xs, ys=ys, a_lbl=a_lbl, b_lbl=b_lbl, cls=cls):
            return relationship_scatter_figure(
                list(xs), list(ys), x_label=a_lbl, y_label=b_lbl,
                classification=cls)

        groups.append(model.Group(blocks=[
            model.Heading(text=f"{a_lbl} ↔ {b_lbl}", level=2),
            model.Figure(
                make=_make,
                caption=(f"Dispersión de «{a_lbl}» frente a «{b_lbl}» con la "
                         "curva de ajuste del mejor modelo.")),
            model.Markdown(text=_classification_note(a_lbl, b_lbl, cls)),
        ]))

    if not groups:
        return []
    intro = model.Markdown(text=(
        "Para los pares numéricos más fuertes (positivos y negativos) se dibuja "
        "la nube de puntos con su ajuste y se clasifica el **tipo de relación**: "
        "**lineal** (una recta basta), **polinómica** (curva de grado 2/3 que "
        "mejora claramente el ajuste lineal), **monótona no-lineal** (crece o "
        "decrece siempre pero no en línea recta; Spearman ≫ Pearson) o "
        "**débil/sin forma**."))
    return [model.Heading(text="Relaciones más fuertes (scatter)", level=2),
            intro] + groups


def build_correlacion(profile: dict, ctx: dict):
    """Build the Correlation Chapter, or None if there are no pairs to show.

    Reads ``profile['correlations']`` (the ``association_matrix`` output). Returns
    ``None`` when the dataset has fewer than two associable columns (no evaluated
    pairs), so the chapter is omitted instead of showing an empty section. Never
    raises: every access is defensive.

    ctx keys consumed: none specific (presentation metadata is inherited from the
    document). The chapter reads everything it needs from the profile.
    """
    profile = profile or {}
    ctx = ctx or {}

    corr = profile.get("correlations")
    if not isinstance(corr, dict):
        return None
    pairs = corr.get("pairs")
    if not isinstance(pairs, list) or not pairs:
        return None

    blocks: list = []

    # Register the always-present method terms in the shared glossary and mark
    # their first appearance clickable (the FDR term is registered lazily below,
    # only when the FDR summary is actually emitted). Degrades silently when no
    # collector is in ctx (standalone render) — mark_term stays False.
    glossary = ctx.get("glossary")
    gloss = glossary if isinstance(glossary, model.GlossaryCollector) else None
    mark_term = gloss is not None
    if gloss is not None:
        for key in ("pearson", "spearman", "cramers_v", "correlation_ratio"):
            label, definition = _TERM_DEFS[key]
            gloss.add(key, label, definition)

    # Intro: what this chapter shows and how to read the sign. Build the marked
    # method names as locals first (avoids backslash-in-f-string for "Cramér's V").
    t_pearson = _term(mark_term, "pearson", "Pearson")
    t_spearman = _term(mark_term, "spearman", "Spearman")
    t_cramers = _term(mark_term, "cramers_v", "Cramér's V")
    t_corr_ratio = _term(mark_term, "correlation_ratio", "razón de correlación")
    blocks.append(model.Markdown(text=(
        "Asociación entre columnas. Cada par se evalúa con la métrica adecuada "
        f"a sus tipos: {t_pearson}/{t_spearman} (numéricas), {t_cramers} "
        f"(categóricas), {t_corr_ratio} (num-categórica) e información mutua. "
        "Sólo las correlaciones **num-num** llevan **signo** (dirección): por "
        "eso los pares **negativos** son siempre num-num.")))

    # 1) Association matrix (heatmap).
    labels, trimmed = _ordered_labels(pairs)
    fig = _matrix_figure(pairs, labels)
    if fig is not None:
        blocks.append(model.Heading(text="Matriz de asociación", level=2))
        blocks.append(fig)
        if trimmed:
            blocks.append(model.Note(text=(
                f"Se muestran las {len(labels)} variables más conectadas de la "
                "matriz para mantenerla legible; el resto de pares siguen en las "
                "tablas de abajo.")))

    # 2) Top positive / top negative pairs.
    positive, negative = _split_top(pairs, _TOP_N)
    pos_table = _top_table(positive, f"Top {len(positive)} positivas")
    neg_table = _top_table(negative, f"Top {len(negative)} negativas")
    if pos_table is not None:
        blocks.append(model.Heading(text="Pares más correlacionados (positivos)",
                                    level=2))
        blocks.append(pos_table)
    if neg_table is not None:
        blocks.append(model.Heading(text="Pares más correlacionados (negativos)",
                                    level=2))
        blocks.append(neg_table)
    elif pos_table is not None:
        # No signed-negative pairs at all: say so honestly rather than omit.
        blocks.append(model.Note(text=(
            "No se han hallado correlaciones negativas significativas entre "
            "columnas numéricas.")))

    # 2.5) Scatter plots of the strongest numeric-numeric pairs, each with its
    # fitted curve and a relationship-type label (lineal / polinómica / monótona
    # / débil). Needs the raw numeric sample (ctx['raw_numeric'], row-aligned);
    # when it is absent (aggregated/lite profile) the scatters are simply omitted
    # and the matrix + tables above stand on their own.
    raw_numeric = None
    if isinstance(ctx, dict):
        raw_numeric = ctx.get("raw_numeric") or profile.get("raw_numeric")
    else:
        raw_numeric = profile.get("raw_numeric")
    blocks.extend(_scatter_blocks(pairs, raw_numeric))

    # 3) Spuriousness caveat for level-based correlations (Granger–Newbold).
    caveat = corr.get("levels_caveat")
    if isinstance(caveat, str) and caveat.strip():
        blocks.append(model.Note(text=caveat.strip()))
    elif corr.get("levels_possible_spurious"):
        blocks.append(model.Note(text=(
            "Aviso: algunas correlaciones se calcularon sobre niveles de series "
            "no estacionarias y pueden ser espurias (Granger–Newbold). Compáralas "
            "sobre los retornos/diferencias antes de interpretarlas.")))

    # 4) FDR summary + methods legend. Register the FDR term only when its
    # summary is emitted, so the glossary never lists an unreferenced entry.
    fdr_text = _fdr_text(corr, mark_term=mark_term)
    if fdr_text:
        if gloss is not None:
            label, definition = _TERM_DEFS["fdr"]
            gloss.add("fdr", label, definition)
        blocks.append(model.Markdown(text=fdr_text))
    methods = _methods_block(corr)
    if methods is not None:
        blocks.append(model.Heading(text="Métodos y leyenda", level=2))
        blocks.append(methods)

    if not blocks:
        return None
    return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
                         version=CHAPTER_VERSION, blocks=blocks)
