"""Categorical distributions chapter (CAT DISTR).

Third reference chapter for AutomaticEDA. Each categorical column gets **its own
page (PDF) / slide (PPTX)**: every column is wrapped in a keep-together
``model.Group`` with ``page_break_before=True`` (except the first, which may share
the intro's page), so its chart sits next to its tables and no column is split.

Per column the Group is laid out ``side_by_side`` (PPTX: cardinality table LEFT,
chart RIGHT; PDF: stacked) and contains, in order:

1. The column name plus, when the LLM layer ran, its business **description** and
   **unit** (read from ``profile['llm']['dictionary']``, matched by column name).
2. A cardinality key/value table: distinct values, ``% distinct`` (distinct /
   total rows), total dataset rows, singleton values (frequency 1), entropy with
   its theoretical maximum and the normalized ratio, mode, imbalance and
   string-length stats.
3. A short note flagging problematic cardinality (id-like ≈100% distinct, or a
   single dominating category).
4. A ``top-k`` table (value / count / %).
5. A **horizontal bar chart** of the most common categories (top-k + an "Otros"
   bucket), drawn lazily so the renderers scale it to fit entirely.

A short intro names the clickable **[[term:entropia]]entropía[[/term]]** and
**[[term:pagina_categorica]]page-layout[[/term]]** terms — their full
definitions live in the GLOSARIO chapter, so they are NOT repeated inline here
(one click jumps to the glossary entry). The intro also carries the dataset row
total used as a comparison baseline.

Data comes from the ``eda`` group: each ``columns[i]['categorical']`` is the
output of ``summarize_categorical`` (``top[{value,count,pct}]``, ``mode``,
``n_distinct``, ``entropy``, ``imbalance``, ``len_min/mean/max``). The derived
cardinality metrics and the bar figure are delegated to two registry functions
(``categorical_cardinality_block`` and ``categorical_top_bar_figure``); both are
imported lazily and degrade to a minimal inline fallback so this chapter never
raises even if they are unavailable.

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

from __future__ import annotations

import math

from .. import model

CHAPTER_VERSION = "1.3.0"
CHAPTER_ID = "cat_distr"
CHAPTER_TITLE = "Distribuciones categóricas"

# Key under which eda_llm_insights stores its interpretive block in the profile.
LLM_KEY = "llm"

# Second glossary term this chapter names: "how each categorical page is laid
# out". The long paragraph that used to describe it inline in the intro now lives
# in the GLOSARIO chapter (canonical definition in ``glosario._BASELINE_TERMS``);
# the intro only names the clickable term, relocating the explanation, not losing
# it. The chapter only needs to register key+label here.
_TERM_PAGINA_KEY = "pagina_categorica"
_TERM_PAGINA_LABEL = "Cómo se organiza cada página categórica"

# Glossary term this chapter explains. Registered in the shared collector and
# marked clickable on its first appearance (end-to-end glossary example —
# mejora 6). Other chapters hook their own terms the same way (see the contract).
_TERM_ENTROPIA_KEY = "entropia"
_TERM_ENTROPIA_LABEL = "Entropía (de Shannon)"
_TERM_ENTROPIA_DEF = (
    "Medida, en bits, de cómo de repartidos están los valores de una columna "
    "categórica. Vale 0 cuando una sola categoría concentra todas las filas "
    "(máxima previsibilidad) y alcanza su máximo, log2(k) para k categorías "
    "distintas, cuando todas aparecen por igual (máxima diversidad). La entropía "
    "normalizada (entropía dividida por su máximo) la lleva al rango 0–1 para "
    "comparar columnas con distinto número de categorías.")

# Cap the number of categorical columns rendered to keep the document bounded;
# the rest are summarized in a closing note (no silent truncation).
MAX_COLS = 40
# Rows shown in each top-k table and explicit bars in the chart. Kept moderate so
# the whole column — cardinality table + top-k table + bar chart — fits on ONE
# page/slide with the chart next to its tables; the table note still reports
# "top N of M" so nothing is silently hidden. For id-like columns (≈100%
# distinct) the top-k table is dropped entirely (it would be a list of unique
# values — pure noise), which also frees the room the chart needs (see build).
TOP_TABLE_ROWS = 8
CHART_TOP_K = 6
# Truncate very long category labels in tables (the renderer also wraps). Kept
# tight so a column with long id-like values (names, tickets) still fits its page.
LABEL_MAX = 28


def _fmt_int(value) -> str:
    if value is None:
        return "—"
    try:
        return f"{int(value):,}".replace(",", ".")
    except (TypeError, ValueError):
        return str(value)


def _fmt_num(value, decimals: int = 3) -> str:
    if value is None:
        return "—"
    if isinstance(value, bool):
        return str(value)
    if isinstance(value, int):
        return f"{value:,}".replace(",", ".")
    if isinstance(value, float):
        if value != value:  # NaN
            return "NaN"
        if value in (float("inf"), float("-inf")):
            return str(value)
        text = f"{value:.{decimals}f}".rstrip("0").rstrip(".")
        return text if text else "0"
    return str(value)


def _fmt_pct_value(value, decimals: int = 1) -> str:
    """Format an already-in-percent value (0–100). None -> placeholder."""
    if value is None:
        return "—"
    try:
        return f"{float(value):.{decimals}f}%"
    except (TypeError, ValueError):
        return str(value)


def _pct_from_maybe_fraction(value, decimals: int = 1) -> str:
    """Format a percentage that may arrive as a 0–1 fraction or a 0–100 number."""
    if value is None:
        return "—"
    try:
        v = float(value)
    except (TypeError, ValueError):
        return str(value)
    if v <= 1.0:
        v *= 100.0
    return f"{v:.{decimals}f}%"


def _truncate(text: str, limit: int = LABEL_MAX) -> str:
    s = model._safe_str(text)
    if len(s) <= limit:
        return s
    return s[: max(1, limit - 1)].rstrip() + "…"


def _is_categorical(col: dict) -> bool:
    """A column is treated as categorical when it carries a non-empty top list
    and is not a pure numeric column (numeric columns may still expose a top)."""
    if not isinstance(col, dict):
        return False
    cat = col.get("categorical")
    if not (isinstance(cat, dict) and cat.get("top")):
        return False
    if col.get("inferred_type") == "numeric":
        return False
    return True


def _cardinality(cat: dict, n_rows) -> dict:
    """Derive cardinality metrics for a column, via the registry function when
    available, otherwise a minimal inline fallback. Never raises."""
    try:
        from datascience.categorical_cardinality_block import (
            categorical_cardinality_block,
        )

        out = categorical_cardinality_block(cat=cat, n_rows=n_rows)
        if isinstance(out, dict):
            return out
    except Exception:  # noqa: BLE001 — fall back to the inline derivation.
        pass
    return _fallback_cardinality(cat, n_rows)


def _fallback_cardinality(cat: dict, n_rows) -> dict:
    cat = cat or {}
    top = cat.get("top") or []
    n_distinct = cat.get("n_distinct")
    entropy = cat.get("entropy")
    try:
        nr = int(n_rows) if n_rows is not None else None
    except (TypeError, ValueError):
        nr = None
    pct_distinct = None
    if isinstance(n_distinct, (int, float)) and nr:
        pct_distinct = float(n_distinct) / nr * 100.0
    entropy_max = None
    if isinstance(n_distinct, (int, float)):
        entropy_max = math.log2(n_distinct) if n_distinct > 1 else 0.0
    entropy_norm = None
    if isinstance(entropy, (int, float)) and entropy_max:
        entropy_norm = max(0.0, min(1.0, float(entropy) / entropy_max))
    mode_pct = cat.get("mode_pct")
    if mode_pct is None and top and isinstance(top[0], dict):
        mode_pct = top[0].get("pct")
    # Normalize to a 0–100 scale: summarize_categorical emits a 0–1 fraction.
    if isinstance(mode_pct, (int, float)) and not isinstance(mode_pct, bool):
        mode_pct = float(mode_pct) * 100.0 if mode_pct <= 1.0 else float(mode_pct)
    else:
        mode_pct = None
    n_singletons = None
    if top:
        n_singletons = sum(
            1 for t in top if isinstance(t, dict) and t.get("count") == 1)
    return {
        "n_distinct": n_distinct,
        "n_rows": nr,
        "pct_distinct": pct_distinct,
        "entropy": entropy,
        "entropy_max": entropy_max,
        "entropy_norm": entropy_norm,
        "mode": cat.get("mode"),
        "mode_pct": mode_pct,
        "imbalance": cat.get("imbalance"),
        "n_singletons": n_singletons,
        "n_singletons_partial": (
            isinstance(n_distinct, (int, float)) and n_distinct > len(top)),
        "len_min": cat.get("len_min"),
        "len_mean": cat.get("len_mean"),
        "len_max": cat.get("len_max"),
        "id_like": pct_distinct is not None and pct_distinct >= 99.0,
        "dominated": mode_pct is not None and mode_pct >= 90.0,
    }


def _llm_index(profile: dict, ctx: dict) -> dict:
    """Map column name -> its LLM dictionary entry (description/unit/...).

    Reads the ``llm.dictionary`` list that ``eda_llm_insights`` stored in the
    profile (``profile['llm']``; falls back to ``ctx['llm']``). Returns an empty
    dict when ``run_llm`` did not run, so the caller degrades cleanly. Fully
    defensive: never raises on malformed input.
    """
    llm = profile.get(LLM_KEY)
    if not isinstance(llm, dict):
        llm = ctx.get(LLM_KEY)
    if not isinstance(llm, dict):
        return {}
    entries = llm.get("dictionary")
    if not isinstance(entries, (list, tuple)):
        return {}
    index: dict = {}
    for e in entries:
        if not isinstance(e, dict):
            continue
        col = e.get("column")
        if col is None:
            continue
        index[model._safe_str(col)] = e
    return index


def _llm_desc_unit_block(name: str, llm_index: dict):
    """Markdown block with the LLM business description + unit of a column, or
    None when no LLM entry matches the column (clean fallback without LLM)."""
    entry = llm_index.get(model._safe_str(name))
    if not isinstance(entry, dict):
        return None
    raw_desc = entry.get("description") or entry.get("business_meaning")
    desc = " ".join(model._safe_str(raw_desc).split()) if raw_desc else ""
    raw_unit = entry.get("unit")
    unit = " ".join(model._safe_str(raw_unit).split()) if raw_unit else ""
    parts = []
    if desc:
        parts.append(f"**Descripción:** {desc}")
    if unit:
        parts.append(f"**Unidad:** {unit}")
    if not parts:
        return None
    return model.Markdown(text=" · ".join(parts))


def _bar_make(top, n_distinct, title, n_rows):
    """Return a zero-arg callable that builds the bar figure lazily."""

    def make():
        try:
            from datascience.categorical_top_bar_figure import (
                categorical_top_bar_figure,
            )

            return categorical_top_bar_figure(
                top=top, n_distinct=n_distinct or 0, title=title,
                top_k=CHART_TOP_K, n_rows=n_rows)
        except Exception:  # noqa: BLE001 — minimal local fallback figure.
            return _fallback_bar(top, title)

    return make


def _fallback_bar(top, title):
    """Minimal horizontal-bar figure used only if the registry function is
    unavailable. Largest category on top, the rest folded into "Otros"."""
    import matplotlib

    matplotlib.use("Agg")
    from matplotlib.figure import Figure

    fig = Figure(figsize=(5.0, 3.2))
    ax = fig.add_subplot(111)
    items = [t for t in (top or [])
             if isinstance(t, dict) and isinstance(t.get("count"), (int, float))]
    items = sorted(items, key=lambda t: t.get("count") or 0, reverse=True)
    head = items[:CHART_TOP_K]
    rest = items[CHART_TOP_K:]
    labels = [_truncate(t.get("value"), 20) for t in head]
    sizes = [float(t.get("count") or 0) for t in head]
    if rest:
        labels.append(f"Otros ({len(rest)})")
        sizes.append(sum(float(t.get("count") or 0) for t in rest))
    if not sizes or sum(sizes) <= 0:
        ax.text(0.5, 0.5, "sin datos categóricos", ha="center", va="center")
        ax.axis("off")
        return fig
    # barh draws bottom-up, so reverse to put the largest category on top.
    y_pos = range(len(labels))
    ax.barh(list(y_pos), list(reversed(sizes)), color="#4C72B0",
            edgecolor="white")
    ax.set_yticks(list(y_pos))
    ax.set_yticklabels(list(reversed(labels)), fontsize=7)
    ax.set_xlabel("conteo", fontsize=8)
    ax.set_title(_truncate(title, 40))
    fig.tight_layout()
    return fig


def _normalize_card(card: dict) -> dict:
    """Make the cardinality dict robust regardless of the upstream scale.

    ``summarize_categorical`` emits ``mode_pct`` as a 0–1 fraction; bring it to a
    0–100 scale and recompute the ``dominated`` flag here so the chapter is
    correct whether it consumed the registry function or the inline fallback.
    """
    card = dict(card or {})
    mp = card.get("mode_pct")
    if isinstance(mp, (int, float)) and not isinstance(mp, bool):
        mp = float(mp) * 100.0 if mp <= 1.0 else float(mp)
    else:
        mp = None
    card["mode_pct"] = mp
    card["dominated"] = mp is not None and mp >= 90.0
    pd = card.get("pct_distinct")
    card["id_like"] = isinstance(pd, (int, float)) and pd >= 99.0
    return card


def _cardinality_block(card: dict):
    """KVTable with the cardinality / entropy metrics for one column.

    Related metrics are grouped onto a single row each (distinct/%/unique;
    entropy bits/max/normalized; length min/mean/max) so the whole column —
    table + chart — fits one page/slide without dropping any datum; the short
    16:9 PPTX slide does not fit one metric per row plus a chart otherwise."""
    n_singletons = card.get("n_singletons")
    if n_singletons is not None and card.get("n_singletons_partial"):
        singletons = f"≥{_fmt_int(n_singletons)}"
    elif n_singletons is not None:
        singletons = _fmt_int(n_singletons)
    else:
        singletons = "—"

    # Distinct count · % distinct · unique (frequency 1) on one row.
    distinct_combo = (f"{_fmt_int(card.get('n_distinct'))} · "
                      f"{_fmt_pct_value(card.get('pct_distinct'))} · "
                      f"{singletons} únicos")

    # Entropy bits · theoretical max · normalized 0–1 on one row.
    entropy_combo = (f"{_fmt_num(card.get('entropy'))} bits · "
                     f"máx {_fmt_num(card.get('entropy_max'))} · "
                     f"norm {_fmt_num(card.get('entropy_norm'))}")

    mode = card.get("mode")
    mode_pct = card.get("mode_pct")
    mode_str = "—" if mode is None else _truncate(mode, 32)
    if mode is not None and mode_pct is not None:
        mode_str = f"{mode_str} ({_fmt_pct_value(mode_pct)})"

    rows = [
        ("Distintos · % · únicos", distinct_combo),
        ("Total filas (dataset)", _fmt_int(card.get("n_rows"))),
        ("Entropía (bits · máx · norm)", entropy_combo),
        ("Moda", mode_str),
    ]
    imbalance = card.get("imbalance")
    lm = card.get("len_min")
    lmean = card.get("len_mean")
    lmax = card.get("len_max")
    # Imbalance and string length (both secondary) share one closing row.
    extras = []
    if imbalance is not None:
        extras.append(f"desbalance {_fmt_num(imbalance)}")
    if any(v is not None for v in (lm, lmean, lmax)):
        extras.append(
            f"long. {_fmt_num(lm)}/{_fmt_num(lmean)}/{_fmt_num(lmax)}")
    if extras:
        rows.append(("Desbalance · longitud", " · ".join(extras)))
    return model.KVTable(rows=rows, title="Cardinalidad")


def _flag_note(card: dict):
    """Return a Note flagging problematic cardinality, or None."""
    if card.get("id_like"):
        return model.Note(
            "Casi todos los valores son distintos (≈100% distintos): la columna "
            "se comporta como un identificador y aporta poco para agrupar o "
            "comparar categorías. No se lista el top de categorías (serían "
            "valores casi todos únicos).")
    if card.get("dominated"):
        mp = card.get("mode_pct")
        mp_str = _fmt_pct_value(mp) if mp is not None else "muy alta"
        return model.Note(
            f"Una sola categoría domina la columna (moda {mp_str}): la "
            "distribución está muy desbalanceada.")
    return None


def _topk_table(cat: dict):
    """DataTable value / count / % for the top categories."""
    top = cat.get("top") or []
    n_distinct = cat.get("n_distinct")
    header = ["Valor", "Conteo", "%"]
    rows = []
    for t in top[:TOP_TABLE_ROWS]:
        if not isinstance(t, dict):
            continue
        rows.append([
            _truncate(t.get("value")),
            _fmt_int(t.get("count")),
            _pct_from_maybe_fraction(t.get("pct")),
        ])
    if not rows:
        return None
    shown = len(rows)
    if isinstance(n_distinct, (int, float)) and n_distinct > shown:
        note = f"top {shown} de {_fmt_int(n_distinct)} categorías distintas"
    else:
        note = f"{shown} categorías"
    return model.DataTable(header=header, rows=rows, title="Top categorías",
                           note=note)


def _intro_blocks(mark_term: bool = False):
    # The full explanation of entropy AND of how each categorical page is laid out
    # lives in the GLOSARIO chapter; the chapter body keeps only the minimal
    # clickable terms — no descriptive prose — to avoid duplicating the glossary.
    # The dataset row total is not repeated here: each column's cardinality table
    # already carries "Total filas (dataset)".
    entropia = ("[[term:entropia]]entropía[[/term]]" if mark_term
                else "entropía")
    pagina = ("[[term:pagina_categorica]]cómo se organiza cada página[[/term]]"
              if mark_term else "cómo se organiza cada página")
    text = f"Términos: {entropia} · {pagina}."
    return [
        model.Heading(text="Entropía y cardinalidad", level=2),
        model.Markdown(text=text),
    ]


def build_cat_distr(profile: dict, ctx: dict):
    """Build the categorical-distributions Chapter, or None if the dataset has
    no categorical columns."""
    profile = profile or {}
    ctx = ctx or {}
    cols = profile.get("columns") or []
    cat_cols = [c for c in cols if _is_categorical(c)]
    if not cat_cols:
        return None

    n_rows = profile.get("n_rows")
    # Register "entropía" and the "how each categorical page is laid out" term in
    # the shared glossary collector (if present) and mark their first appearance
    # clickable. End-to-end glossary example (mejora 6).
    glossary = ctx.get("glossary")
    mark_term = False
    if isinstance(glossary, model.GlossaryCollector):
        glossary.add(_TERM_ENTROPIA_KEY, _TERM_ENTROPIA_LABEL,
                     _TERM_ENTROPIA_DEF)
        glossary.add(_TERM_PAGINA_KEY, _TERM_PAGINA_LABEL)
        mark_term = True
    blocks = list(_intro_blocks(mark_term=mark_term))

    # Business description + unit per column come from the LLM dictionary
    # (profile['llm']['dictionary'], matched by column name); absent without
    # run_llm, in which case the per-column description block is simply omitted.
    llm_index = _llm_index(profile, ctx)

    rendered = cat_cols[:MAX_COLS]
    for idx, col in enumerate(rendered):
        name = col.get("name") or "(columna)"
        cat = col.get("categorical") or {}
        card = _normalize_card(_cardinality(cat, n_rows))

        # One Group per categorical column: heading + (optional) LLM description +
        # cardinality table + flag note + top-k table + bar figure are kept
        # together and the renderer starts each on a fresh page/slide
        # (page_break_before) so every column gets its own page with its chart next
        # to its tables. The first column may share the intro's page (no forced
        # break) to avoid a near-empty page.
        col_blocks = [model.Heading(text=str(name), level=2)]
        desc_block = _llm_desc_unit_block(name, llm_index)
        if desc_block is not None:
            col_blocks.append(desc_block)
        col_blocks.append(_cardinality_block(card))
        note = _flag_note(card)
        if note is not None:
            col_blocks.append(note)
        # For id-like columns (≈100% distinct) the top-k is a list of unique
        # values — pure noise; skip it (the flag note already explains why) and
        # let the bar chart take that room so the whole column fits one page/slide.
        if not card.get("id_like"):
            topk = _topk_table(cat)
            if topk is not None:
                col_blocks.append(topk)
        col_blocks.append(model.Figure(
            make=_bar_make(cat.get("top") or [], card.get("n_distinct"),
                           str(name), n_rows),
            caption=(f"Categorías más comunes de «{_truncate(name, 32)}» "
                     "(barras: top-k + «Otros»)")))
        # layout="side_by_side": in PPTX the cardinality table goes to the LEFT and
        # the bar chart to the RIGHT of the same slide; the PDF renderer stacks it
        # (the A5 mobile page is too narrow for two readable columns).
        blocks.append(model.Group(blocks=col_blocks, layout="side_by_side",
                                  page_break_before=(idx > 0)))

    if len(cat_cols) > len(rendered):
        omitted = len(cat_cols) - len(rendered)
        blocks.append(model.Note(
            f"Se muestran las primeras {len(rendered)} columnas categóricas; "
            f"quedan {omitted} sin mostrar para mantener acotado el informe."))

    return model.Chapter(id=CHAPTER_ID, title=CHAPTER_TITLE,
                         version=CHAPTER_VERSION, blocks=blocks)
