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

Builds a horizontal bar chart of the ``top_k`` most frequent categories of a
categorical column, folding everything else into a single gray
"Otros (N categorías)" bar. The most frequent category sits at the top, each bar
labelled with its count (and percentage) at the end. Returns a ready-to-rasterize
``matplotlib.figure.Figure``; it never shows nor saves it.

This is the "magnitude" twin of ``categorical_top_pie_figure``: identical input
contract (same ``top``/``n_distinct``/``title``/``top_k``/``n_rows`` signature) so
it can be swapped in directly, but it communicates comparable magnitudes via bars
instead of proportions via wedges.

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" bar.
_OTHER_COLOR = "#9e9e9e"
# Muted gray for secondary text (title fallback, no-data message).
_MUTED_TEXT = "#5f6b7a"
# Soft red for the error fallback message.
_ERROR_TEXT = "#b00020"
# Pleasant, colour-blind-friendly qualitative palette for the explicit bars.
_PALETTE = [
    "#4C72B0",
    "#DD8452",
    "#55A868",
    "#C44E52",
    "#8172B3",
    "#937860",
    "#DA8BC3",
    "#8C8C8C",
    "#CCB974",
    "#64B5CD",
]


def _truncate(text, width: int = 22) -> 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 _message_figure(message: str, color: str = _MUTED_TEXT, title: str = "") -> "Figure":
    """Return a fallback ``Figure`` carrying a single centered message."""
    fig = Figure(figsize=(6.4, 4.0), dpi=150)
    ax = fig.add_subplot(111)
    ax.axis("off")
    ax.text(
        0.5,
        0.5,
        message,
        ha="center",
        va="center",
        fontsize=12,
        color=color,
        wrap=True,
        transform=ax.transAxes,
    )
    if title:
        ax.set_title(_truncate(title, 48), fontsize=12, loc="center", pad=8)
    fig.tight_layout()
    return fig


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

    Renders the ``top_k`` most frequent categories as explicit horizontal bars,
    largest at the top, and aggregates every remaining category into a single
    gray "Otros (N categorías)" bar at the bottom. Each bar is annotated with its
    count and percentage of the total at the end of the bar; the category names
    are truncated Y tick labels.

    The function shares the exact input contract of
    ``categorical_top_pie_figure`` (the donut twin) so it is a drop-in swap. It 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, and any unexpected error is caught
    and turned into a fallback ``Figure`` carrying the error text.

    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 bar as "Otros (n_distinct - top_k)" (floored at
            0). Ignored when it does not exceed the number of shown bars.
        title: Figure title (the column name). Truncated when too long.
        top_k: Maximum number of explicit bars. Default 6. The "Otros" bar 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" bar uses
            ``n_rows - sum_shown`` as its count so it is 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 horizontal-bar Axes. The
        caller is responsible for rasterizing/closing it.
    """
    try:
        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:
            return _message_figure("sin datos categóricos", title=title)

        # --- Split into shown bars 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 bars.
        overflow_items = len(cleaned) - len(shown)
        if others_categories == 0 and overflow_items > 0:
            others_categories = overflow_items

        # Count attributed to the "Otros" bar.
        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

        # --- Build the display order (top to bottom): largest .. smallest, Otros.
        display_labels = [_truncate(v, 22) for v, _ in shown]
        display_values = [c for _, c in shown]
        display_colors = [_PALETTE[i % len(_PALETTE)] for i in range(len(shown))]

        has_others = others_count > 0 and others_categories > 0
        if has_others:
            display_labels.append(f"Otros ({others_categories} categorías)")
            display_values.append(others_count)
            display_colors.append(_OTHER_COLOR)

        total = sum(display_values) or 1.0

        # barh draws bottom-up, so reverse the display order before plotting to
        # land the largest category on top and "Otros" at the bottom.
        labels = list(reversed(display_labels))
        values = list(reversed(display_values))
        colors = list(reversed(display_colors))
        y_pos = range(len(values))

        # Height scales with the number of bars so dense reports stay readable.
        n_bars = len(values)
        height = max(2.4, min(0.4 * n_bars + 1.2, 14.0))
        fig = Figure(figsize=(6.4, height), dpi=150)
        ax = fig.add_subplot(111)

        ax.barh(list(y_pos), values, color=colors, edgecolor="white")
        ax.set_yticks(list(y_pos))
        ax.set_yticklabels(labels, fontsize=8)
        ax.set_xlabel("conteo", fontsize=9)

        max_val = max(values) if values else 1.0
        ax.set_xlim(0, max_val * 1.18 if max_val > 0 else 1.0)

        # Annotate each bar with its count and percentage at the end of the bar.
        for y, val in zip(y_pos, values):
            pct = val / total * 100.0
            ax.text(
                val + max_val * 0.012,
                y,
                f"{int(round(val))} ({pct:.0f}%)",
                va="center",
                ha="left",
                fontsize=7,
                color="#202020",
            )

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

        fig.tight_layout()
        return fig
    except Exception as exc:  # noqa: BLE001 — never raise from a figure builder.
        return _message_figure(
            f"error al dibujar barras: {exc}", color=_ERROR_TEXT
        )
