"""Impure EDA helper: donut figure of the most common categories (`eda` group).

Builds a matplotlib donut (pie with a central hole) of the ``top_k`` most
frequent categories of a categorical column, folding everything else into a
single "Otros (N categorías)" slice. Returns a ready-to-rasterize
``matplotlib.figure.Figure``; it never shows nor saves it.

Impure because it touches matplotlib's rendering machinery. It uses the headless
Agg backend and the object-oriented ``Figure`` API (no ``pyplot``) so it leaks no
global state and is safe to call repeatedly from a report renderer.
"""

import matplotlib

matplotlib.use("Agg")

from matplotlib.figure import Figure  # noqa: E402


# Gray reserved for the aggregated "Otros" slice.
_OTHER_COLOR = "#9e9e9e"
# Muted gray for secondary text (title fallback, center annotation, no-data).
_MUTED_TEXT = "#5f6b7a"
# Pleasant, colour-blind-friendly qualitative palette for the explicit slices.
_PALETTE = [
    "#4C72B0",
    "#DD8452",
    "#55A868",
    "#C44E52",
    "#8172B3",
    "#937860",
    "#DA8BC3",
    "#8C8C8C",
    "#CCB974",
    "#64B5CD",
]


def _truncate(text, width: int = 20) -> str:
    """Truncate ``text`` to ``width`` chars, appending an ellipsis if cut."""
    s = "" if text is None else str(text)
    if len(s) <= width:
        return s
    if width <= 1:
        return s[:width]
    return s[: width - 1] + "…"


def categorical_top_pie_figure(
    top: list,
    n_distinct: int = 0,
    title: str = "",
    top_k: int = 6,
    n_rows=None,
) -> "matplotlib.figure.Figure":
    """Build a donut figure of the most common categories of a column.

    Renders the ``top_k`` most frequent categories as explicit donut slices and
    aggregates every remaining category into a single gray "Otros (N
    categorías)" slice. Category names are not painted on the wedges; they are
    listed in a lateral legend (truncated value + count) to avoid overlap on
    narrow (mobile) figures.

    The function is fully defensive: empty input, missing/``None`` values or
    counts never raise. When there is nothing valid to draw it still returns a
    ``Figure`` carrying a centered "sin datos categóricos" message.

    Args:
        top: List of ``{value, count, pct}`` dicts, already sorted by ``count``
            descending (the ``top`` block of ``summarize_categorical``). May be
            empty or carry incomplete/``None`` entries; non-dict items, items
            without a positive numeric ``count`` and ``None`` counts are skipped.
        n_distinct: Total number of distinct categories in the column. Used to
            label the aggregated slice as "Otros (n_distinct - top_k)" (floored
            at 0). Ignored when it does not exceed the number of shown slices.
        title: Figure title (the column name). Truncated when too long.
        top_k: Maximum number of explicit slices. Default 6. The "Otros" slice
            does not count against this limit.
        n_rows: Optional total row count of the dataset. When given and the sum
            of shown counts is below ``n_rows``, the "Otros" slice uses
            ``n_rows - sum_shown`` as its count so the wedge angles are exact
            with respect to the real total. When omitted, "Otros" uses the sum
            of the counts that fall outside the shown ``top_k`` (only when
            ``top`` carries more than ``top_k`` items).

    Returns:
        A ``matplotlib.figure.Figure`` with a single donut Axes plus a lateral
        legend. The caller is responsible for rasterizing/closing it.
    """
    fig = Figure(figsize=(6.4, 4.0), dpi=150)
    ax = fig.add_subplot(111)

    safe_title = _truncate(title, 48)

    # --- Defensive parse: keep only well-formed {value, count} with count > 0.
    cleaned = []
    if isinstance(top, list):
        for item in top:
            if not isinstance(item, dict):
                continue
            count = item.get("count")
            if count is None:
                continue
            try:
                count = float(count)
            except (TypeError, ValueError):
                continue
            if count <= 0:
                continue
            cleaned.append((item.get("value"), count))

    if not cleaned:
        ax.axis("off")
        ax.text(
            0.5,
            0.5,
            "sin datos categóricos",
            ha="center",
            va="center",
            fontsize=12,
            color=_MUTED_TEXT,
            transform=ax.transAxes,
        )
        if safe_title:
            ax.set_title(safe_title, fontsize=12, loc="center", pad=8)
        fig.tight_layout()
        return fig

    # --- Split into shown slices and the aggregated remainder.
    shown = cleaned[: max(int(top_k), 0)]
    if not shown:  # top_k <= 0 — show at least the largest category.
        shown = cleaned[:1]

    sum_shown = sum(c for _, c in shown)
    overflow_count = sum(c for _, c in cleaned[len(shown):])

    # How many categories are folded into "Otros".
    try:
        nd = int(n_distinct)
    except (TypeError, ValueError):
        nd = 0
    others_categories = max(nd - len(shown), 0)
    # If n_distinct is unknown/too small, fall back to the overflow we actually
    # have in `top` beyond the shown slices.
    overflow_items = len(cleaned) - len(shown)
    if others_categories == 0 and overflow_items > 0:
        others_categories = overflow_items

    # Count attributed to the "Otros" slice for exact angles.
    others_count = 0.0
    if n_rows is not None:
        try:
            total_rows = float(n_rows)
        except (TypeError, ValueError):
            total_rows = None
        if total_rows is not None and total_rows > sum_shown:
            others_count = total_rows - sum_shown
    if others_count <= 0:
        others_count = overflow_count

    labels = [v for v, _ in shown]
    values = [c for _, c in shown]
    colors = [_PALETTE[i % len(_PALETTE)] for i in range(len(shown))]

    has_others = others_count > 0 and others_categories > 0
    if has_others:
        values.append(others_count)
        labels.append("Otros")
        colors.append(_OTHER_COLOR)

    total = sum(values)

    def _autopct(pct: float) -> str:
        # Hide tiny labels to avoid crowding the wedges.
        return f"{pct:.0f}%" if pct >= 5 else ""

    wedges, _texts, autotexts = ax.pie(
        values,
        colors=colors,
        startangle=90,
        counterclock=False,
        wedgeprops={"width": 0.42, "edgecolor": "white", "linewidth": 1.0},
        autopct=_autopct,
        pctdistance=0.79,
        textprops={"fontsize": 8},
    )
    for at in autotexts:
        at.set_color("white")
        at.set_fontweight("bold")
    ax.set_aspect("equal")

    # --- Lateral legend: truncated value + count (+ "(N categorías)" for Otros).
    legend_labels = []
    for idx, (lab, val) in enumerate(zip(labels, values)):
        if has_others and idx == len(labels) - 1:
            legend_labels.append(
                f"Otros ({others_categories} categorías) — {int(round(val))}"
            )
        else:
            legend_labels.append(f"{_truncate(lab, 20)} — {int(round(val))}")

    ax.legend(
        wedges,
        legend_labels,
        title="Categorías",
        loc="center left",
        bbox_to_anchor=(1.02, 0.5),
        fontsize=8,
        title_fontsize=9,
        frameon=False,
    )

    if safe_title:
        ax.set_title(safe_title, fontsize=13, loc="left", pad=10)

    # Center annotation: total count covered by the donut.
    ax.text(
        0,
        0,
        f"n={int(round(total))}",
        ha="center",
        va="center",
        fontsize=11,
        color=_MUTED_TEXT,
        fontweight="bold",
    )

    # Leave room on the right for the legend (avoid clipping it).
    fig.subplots_adjust(left=0.02, right=0.62, top=0.88, bottom=0.06)
    return fig
