"""AutomaticEDA Markdown serializer — one self-contained file to paste to an LLM.

Same document model as the PDF/PPTX renderers (an ordered list of
:class:`Chapter`, each a list of format-independent blocks) but emitted as plain
**Markdown** instead of a binary. The goal is different from the other two
renderers: a Markdown EDA is meant to be *pasted into an LLM*, so it prioritises
TEXT and DATA over visuals. Tables become Markdown tables (every row dumped, no
pagination — nothing is cut because there are no pages); a ``Figure`` becomes its
caption plus, when possible, the underlying bar/histogram data as a Markdown
table (an LLM cannot see the image); glossary term markers are stripped while
``**bold**`` is kept (it is valid Markdown).

dict-no-throw (the ``eda`` group style): :func:`render_md` never raises. On a
fatal error it returns ``{path: None, ...}`` with a ``note`` explaining why; a
malformed block degrades to a readable note rather than crashing the document.
"""

from __future__ import annotations

import os
import re

from . import model

# Glossary span markers (kept text, dropped markers). We intentionally do NOT use
# ``text_layout.strip_inline_md`` for Markdown blocks because that also removes
# ``**bold**`` — valid Markdown we want to preserve when pasting to an LLM.
_TERM_OPEN_RE = re.compile(r"\[\[term:[A-Za-z0-9_]+\]\]")
_MAX_BAR_ROWS = 100


# --------------------------------------------------------------------------- #
# Small helpers.
# --------------------------------------------------------------------------- #
def _clean_terms(s) -> str:
    """Drop glossary term markers, keeping the visible text (and any **bold**)."""
    s = model._safe_str(s)
    s = _TERM_OPEN_RE.sub("", s)
    return s.replace("[[/term]]", "")


def _cell(v) -> str:
    """Render a value as a safe Markdown table cell.

    Escapes pipes (``|`` -> ``\\|``) so they do not break the column layout and
    folds newlines to ``<br>`` so a multi-line value stays inside one cell. None
    becomes an empty string.
    """
    s = model._safe_str(v)
    s = s.replace("|", "\\|")
    s = s.replace("\r\n", "\n").replace("\r", "\n").replace("\n", "<br>")
    return s


def _slug(text: str) -> str:
    """GitHub-style heading anchor: lowercase, spaces->'-', drop other symbols."""
    s = model._safe_str(text).strip().lower()
    out = []
    for ch in s:
        if ch.isalnum():
            out.append(ch)
        elif ch in " -":
            out.append("-")
        # any other symbol is dropped.
    slug = "".join(out)
    while "--" in slug:
        slug = slug.replace("--", "-")
    return slug.strip("-")


def _fmt_num(v) -> str:
    """Compact number for the figure data tables (ints as ints, else 4 sig figs)."""
    try:
        f = float(v)
    except Exception:  # noqa: BLE001
        return model._safe_str(v)
    if f != f:  # NaN
        return "NaN"
    if f == int(f) and abs(f) < 1e15:
        return str(int(f))
    return f"{f:.4g}"


def _fmt_int(v) -> str:
    try:
        return str(int(v))
    except Exception:  # noqa: BLE001
        return model._safe_str(v)


def _now_iso() -> str:
    from datetime import datetime, timezone
    return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")


# --------------------------------------------------------------------------- #
# Document header (title + metadata blockquote + numbered index).
# --------------------------------------------------------------------------- #
def _meta_block(meta: dict) -> list:
    """Build the metadata lines for the header blockquote (omitting absentees)."""
    ctx = meta.get("ctx") if isinstance(meta.get("ctx"), dict) else {}
    lines: list = []

    def add(label, value) -> None:
        if value is None:
            return
        s = model._safe_str(value).strip()
        if s and s.lower() != "none":
            lines.append(f"**{label}:** {s}")

    add("Dataset", ctx.get("dataset_name") or meta.get("dataset_name"))
    add("Fuente", ctx.get("source_origin") or meta.get("source_origin"))
    add("Almacenamiento", ctx.get("storage") or meta.get("storage"))
    n_rows = ctx.get("n_rows", meta.get("n_rows"))
    n_cols = ctx.get("n_cols", meta.get("n_cols"))
    if n_rows is not None and n_cols is not None:
        lines.append(
            f"**Dimensiones:** {_fmt_int(n_rows)} filas × {_fmt_int(n_cols)} columnas")
    add("Generado", meta.get("generated_at") or _now_iso())
    lines.append(f"**Motor:** {model.ENGINE_NAME} v{model.ENGINE_VERSION}")
    return lines


# --------------------------------------------------------------------------- #
# Per-block serializers. Each returns a Markdown string (no surrounding blanks;
# the caller separates blocks with a blank line).
# --------------------------------------------------------------------------- #
def _md_heading(block) -> str:
    level = int(getattr(block, "level", 1) or 1)
    hashes = "#" * min(level + 2, 6)  # level1 -> ###; '#'/'##' reserved for doc/chapter.
    text = _clean_terms(getattr(block, "text", "")).strip()
    return f"{hashes} {text}"


def _md_markdown(block) -> str:
    # Keep the text verbatim, dropping only glossary markers (keep **bold**).
    return _clean_terms(getattr(block, "text", "")).rstrip("\n")


def _md_kv_table(block) -> str:
    lines: list = []
    title = getattr(block, "title", None)
    if title:
        lines.append(f"**{_clean_terms(title).strip()}**")
        lines.append("")
    lines.append("| Campo | Valor |")
    lines.append("| --- | --- |")
    for row in (getattr(block, "rows", []) or []):
        try:
            label, value = row[0], row[1]
        except Exception:  # noqa: BLE001
            label, value = row, ""
        lines.append(f"| {_cell(label)} | {_cell(value)} |")
    return "\n".join(lines)


def _md_data_table(block) -> str:
    lines: list = []
    title = getattr(block, "title", None)
    if title:
        lines.append(f"**{_clean_terms(title).strip()}**")
        lines.append("")
    header = list(getattr(block, "header", []) or [])
    rows = list(getattr(block, "rows", []) or [])
    if not header:
        ncol = max((len(r) for r in rows), default=1)
        header = [f"col{i + 1}" for i in range(ncol)]
    ncol = len(header)
    lines.append("| " + " | ".join(_cell(h) for h in header) + " |")
    lines.append("| " + " | ".join(["---"] * ncol) + " |")
    for r in rows:  # dump every row — no pagination, nothing cut.
        cells = [_cell(r[c]) if c < len(r) else "" for c in range(ncol)]
        lines.append("| " + " | ".join(cells) + " |")
    note = getattr(block, "note", None)
    if note:
        lines.append("")
        lines.append(f"*{_clean_terms(note).strip()}*")
    return "\n".join(lines)


def _bars_table(bars: list, header: tuple = ("Desde", "Hasta", "Frecuencia")) -> str:
    """Render extracted bar/histogram data as a Markdown table.

    ``header`` is the 3-column header to use. Histogram bars are
    ``(Desde, Hasta, Frecuencia)``; bar/scree charts (means by group, PCA
    explained variance) are *not* bins, so the caller passes a semantically
    correct header (e.g. ``(Inicio, Fin, Valor)``) to avoid the misleading
    "Frecuencia" label — see report 2053, loss #6.
    """
    h0, h1, h2 = header
    lines = [f"| {h0} | {h1} | {h2} |", "| --- | --- | --- |"]
    shown = bars[:_MAX_BAR_ROWS]
    for x0, x1, h in shown:
        lines.append(f"| {_fmt_num(x0)} | {_fmt_num(x1)} | {_fmt_num(h)} |")
    out = "\n".join(lines)
    extra = len(bars) - len(shown)
    if extra > 0:
        out += f"\n\n*… ({extra} filas más)*"
    return out


def _is_histogram_caption(caption: str) -> bool:
    """True when a figure caption describes a histogram (genuine numeric bins).

    Histograms are the only figures whose bars are real ``[Desde, Hasta)`` bins
    with a frequency count. Bar charts (means by group) and the PCA scree plot
    carry per-category / per-component values, not bins — they must not inherit
    the ``Desde/Hasta/Frecuencia`` header.
    """
    c = (caption or "").lower()
    return "histograma" in c or "distribución" in c or "distribucion" in c


def _extract_bars(fig) -> list:
    """Collect (x_from, x_to, height) of the rectangular bars of a matplotlib fig.

    Histogram / bar-chart bars are ``matplotlib.patches.Rectangle`` with positive
    width and height; spines, legends and zero-area artists are skipped. Never
    raises — returns ``[]`` on any problem.
    """
    bars: list = []
    try:
        for ax in fig.get_axes():
            # Collect this axes' positive-area rectangles, then keep only the ones
            # that look like actual histogram/bar bins. Reference shapes that
            # matplotlib also stores in ``ax.patches`` — most notably the ``±1σ``
            # band drawn by ``axvspan`` (a single rectangle far wider than a bin)
            # and a lone Tukey boxplot box — would otherwise show up as fake
            # "bins". A histogram axes has several near-equal-width bars, so we
            # drop any rectangle whose width is more than twice the median width
            # of that axes' rectangles (the σ-band spans many bins; uniform bins
            # all sit at the median width and stay).
            ax_bars: list = []
            for patch in list(getattr(ax, "patches", []) or []):
                try:
                    w = patch.get_width()
                    h = patch.get_height()
                    x = patch.get_x()
                except Exception:  # noqa: BLE001 — not a Rectangle-like patch.
                    continue
                if w and w > 0 and h and h > 0:
                    ax_bars.append((x, x + w, h))
            if len(ax_bars) >= 3:
                widths = sorted(b[1] - b[0] for b in ax_bars)
                median_w = widths[len(widths) // 2]
                if median_w > 0:
                    ax_bars = [b for b in ax_bars
                               if (b[1] - b[0]) <= 2.0 * median_w]
            bars.extend(ax_bars)
    except Exception:  # noqa: BLE001
        return []
    return bars


def _md_figure(block, meta: dict, out_path: str, counter: list) -> str:
    """Serialize a Figure prioritising TEXT + DATA (an LLM cannot see the image).

    Emits the caption, then — if the matplotlib figure has bars — a Markdown table
    of the underlying (Desde, Hasta, Frecuencia) values. Optionally (when
    ``meta['embed_figures']`` is True) also exports a PNG beside the .md and adds
    an image link; off by default so the Markdown stays self-contained.
    """
    caption = model._safe_str(getattr(block, "caption", "")).strip()
    parts = [f"*Figura: {caption}*" if caption else "*Figura*"]
    fig = None
    try:
        import matplotlib
        matplotlib.use("Agg")  # defensive: headless rasterization backend.
        fig = getattr(block, "fig", None)
        make = getattr(block, "make", None)
        if fig is None and callable(make):
            fig = make()
        if fig is not None:
            bars = _extract_bars(fig)
            if bars:
                # A histogram's bars are genuine numeric bins (Desde/Hasta/
                # Frecuencia). Bar charts and the PCA scree plot are not bins —
                # give them a header that does not lie about "Frecuencia".
                header = (("Desde", "Hasta", "Frecuencia")
                          if _is_histogram_caption(caption)
                          else ("Inicio", "Fin", "Valor"))
                parts.append(_bars_table(bars, header))
            if meta.get("embed_figures"):
                png = _embed_png(fig, out_path, counter)
                if png:
                    parts.append(f"![{caption}]({png})")
    except Exception:  # noqa: BLE001 — a bad figure degrades to just its caption.
        pass
    finally:
        if fig is not None:
            try:
                import matplotlib.pyplot as plt
                plt.close(fig)
            except Exception:  # noqa: BLE001
                pass
    return "\n\n".join(parts)


def _embed_png(fig, out_path: str, counter: list) -> str:
    """Export the figure to ``<basename>_figN.png`` beside the .md; return its name."""
    try:
        counter[0] += 1
        base = os.path.splitext(os.path.basename(out_path))[0] or "figura"
        name = f"{base}_fig{counter[0]}.png"
        path = os.path.join(os.path.dirname(os.path.abspath(out_path)), name)
        fig.savefig(path, format="png", dpi=120, bbox_inches="tight")
        return name
    except Exception:  # noqa: BLE001
        return ""


def _md_image(block) -> str:
    path = model._safe_str(getattr(block, "path", ""))
    caption = model._safe_str(getattr(block, "caption", "")).strip()
    out = f"![{caption}]({path})"
    if caption:
        out += f"\n\n*{caption}*"
    return out


def _md_caption(block) -> str:
    return f"*{_clean_terms(getattr(block, 'text', '')).strip()}*"


def _md_note(block) -> str:
    text = _clean_terms(getattr(block, "text", "")).strip()
    lines = text.split("\n")
    return "\n".join((f"> {ln}" if ln.strip() else ">") for ln in lines)


def _md_group(block, meta: dict, out_path: str, counter: list) -> str:
    parts: list = []
    title = getattr(block, "title", None)
    if title:
        parts.append(f"### {_clean_terms(title).strip()}")
    for b in (getattr(block, "blocks", []) or []):
        try:
            seg = _serialize_block(b, meta, out_path, counter)
        except Exception:  # noqa: BLE001
            seg = ""
        if seg:
            parts.append(seg)
    return "\n\n".join(parts)


def _md_glossary_entry(block) -> str:
    label = (model._safe_str(getattr(block, "label", "")).strip()
             or model._safe_str(getattr(block, "key", "")).strip())
    definition = _clean_terms(getattr(block, "definition", "")).strip()
    out = f"### {label}"
    if definition:
        out += f"\n\n{definition}"
    return out


def _serialize_block(block, meta: dict, out_path: str, counter: list) -> str:
    """Dispatch a single block to its Markdown serializer. Unknown -> note."""
    kind = getattr(block, "kind", "")
    if kind == "heading":
        return _md_heading(block)
    if kind == "markdown":
        return _md_markdown(block)
    if kind == "kv_table":
        return _md_kv_table(block)
    if kind == "data_table":
        return _md_data_table(block)
    if kind == "figure":
        return _md_figure(block, meta, out_path, counter)
    if kind == "image":
        return _md_image(block)
    if kind == "caption":
        return _md_caption(block)
    if kind == "note":
        return _md_note(block)
    if kind == "group":
        return _md_group(block, meta, out_path, counter)
    if kind == "glossary_entry":
        return _md_glossary_entry(block)
    # Unknown content -> readable note (mirrors the model's defensive coercion).
    return _md_note(model.Note(text=model._safe_str(block)))


# --------------------------------------------------------------------------- #
# Profile appendix — the data the human-facing chapters drop.
#
# The chapter document (shared with the PDF/PPTX renderers) is designed for human
# reading and intentionally omits raw numbers: the correlation matrix shows only
# the top extremes, the numeric blocks skip skew/kurtosis/extended percentiles,
# the model chapter does not list ``scores_by_k`` or the normality test
# statistics. But the Markdown is meant to be *pasted into an LLM*, so it should
# carry EVERYTHING the engine computed. This appendix serializes the full
# ``profile`` (passed via ``meta['profile']``) as Markdown tables, additively:
# the PDF/PPTX are untouched, the .md simply has more than they do. Each section
# is emitted only when its source data is present, so a ``lite`` profile (no
# models) or a profile without correlations degrades cleanly instead of raising.
# See report 2053 for the six losses this closes.
# --------------------------------------------------------------------------- #
def _pair_types(a_type, b_type) -> str:
    """Short ``num↔cat`` label for an association pair's variable types."""
    def short(t):
        t = model._safe_str(t).lower()
        if t.startswith("num"):
            return "num"
        if t.startswith("cat"):
            return "cat"
        return t or "?"
    return f"{short(a_type)}↔{short(b_type)}"


def _app_correlations(corr: dict) -> str:
    """Loss #1 — every association pair (not just the top extremes).

    Dumps all of ``correlations['pairs']`` as a table (pair · types · method ·
    value · p · p-FDR · significant), ordered by |value| desc so the strongest
    associations lead while nothing is cut. Includes the ``correlation_ratio``
    (num↔cat) and ``cramers_v`` (cat↔cat) pairs the human chapter never shows.
    """
    pairs = list(corr.get("pairs", []) or [])
    if not pairs:
        return ""
    def keyfn(p):
        try:
            return -abs(float(p.get("value")))
        except Exception:  # noqa: BLE001
            return 0.0
    pairs_sorted = sorted(pairs, key=keyfn)
    lines = ["### Matriz de asociación — todos los pares",
             "",
             ("| Par | Tipos | Método | Valor | p-value | p-ajustado (FDR) "
              "| ¿Sig? |"),
             "| --- | --- | --- | --- | --- | --- | --- |"]
    for p in pairs_sorted:
        par = f"{_cell(p.get('a'))} ↔ {_cell(p.get('b'))}"
        types = _pair_types(p.get("a_type"), p.get("b_type"))
        method = _cell(p.get("method"))
        val = _fmt_num(p.get("value"))
        pv = _fmt_num(p.get("p_value")) if p.get("p_value") is not None else ""
        padj = (_fmt_num(p.get("p_value_adjusted"))
                if p.get("p_value_adjusted") is not None else "")
        sig = "sí" if p.get("significant") else "no"
        lines.append(
            f"| {par} | {types} | {method} | {val} | {pv} | {padj} | {sig} |")
    mt = corr.get("multiple_testing") or {}
    n_tests = mt.get("n_tests", corr.get("n_tests"))
    n_rej = mt.get("n_rejected")
    note_bits = [f"{len(pairs)} pares en total"]
    if n_tests is not None and n_rej is not None:
        note_bits.append(
            f"{n_rej} de {n_tests} significativos tras corrección "
            f"{model._safe_str(mt.get('method', 'FDR')).upper()}")
    lines.append("")
    lines.append(f"*{'; '.join(note_bits)}.*")
    return "\n".join(lines)


# Numeric statistics, in serialization order: (profile key, column header).
_NUM_STATS = [
    ("count", "n"), ("mean", "mean"), ("median", "median"), ("mode", "mode"),
    ("std", "std"), ("variance", "variance"), ("cv", "cv"),
    ("skew", "skew"), ("kurtosis", "kurtosis"),
    ("min", "min"), ("p1", "p1"), ("p5", "p5"), ("p25", "p25"), ("p50", "p50"),
    ("p75", "p75"), ("p95", "p95"), ("p99", "p99"), ("iqr", "iqr"),
    ("max", "max"), ("n_outliers", "outliers"),
    ("distribution_type", "distribución"),
]


def _app_numeric_describe(columns: list) -> str:
    """Loss #2 — every numeric statistic for every numeric column.

    One row per numeric column with the full describe: mean/median/mode/std/
    variance/cv, skew & kurtosis (for ALL columns, not only the skewed ones),
    p1/p5/p25/p50/p75/p95/p99, iqr, min/max, outliers and distribution_type.
    """
    rows = []
    for info in (columns or []):
        num = info.get("numeric") if isinstance(info, dict) else None
        if not num:
            continue
        name = _cell(info.get("name"))
        cells = [name]
        for key, _hdr in _NUM_STATS:
            v = num.get("count" if key == "count" else key)
            if key == "count":
                v = num.get("count", info.get("count"))
            if key == "distribution_type":
                cells.append(_cell(v))
            else:
                cells.append(_fmt_num(v) if v is not None else "")
        rows.append(cells)
    if not rows:
        return ""
    header = ["Columna"] + [hdr for _k, hdr in _NUM_STATS]
    lines = ["### Estadísticos numéricos completos (describe)",
             "",
             "| " + " | ".join(header) + " |",
             "| " + " | ".join(["---"] * len(header)) + " |"]
    for cells in rows:
        lines.append("| " + " | ".join(cells) + " |")
    return "\n".join(lines)


def _app_reexpression(columns: list) -> str:
    """Loss #3 — the concrete recommended re-expression per column.

    Names the transform (log1p/sqrt/yeo-johnson/none) instead of a vague
    "consider re-expressing", with the ladder power, reason and alternatives.
    """
    rows = []
    for info in (columns or []):
        rx = info.get("reexpression") if isinstance(info, dict) else None
        if not rx or not isinstance(rx, dict):
            continue
        rec = model._safe_str(rx.get("recommended")).strip()
        if not rec:
            continue
        alts = rx.get("alternatives") or []
        alt_txt = ", ".join(
            model._safe_str(a.get("transform")) for a in alts
            if isinstance(a, dict) and a.get("transform")) or "—"
        rows.append([
            _cell(info.get("name")), _cell(rec),
            _fmt_num(rx.get("ladder_power")) if rx.get("ladder_power") is not None else "",
            _cell(rx.get("reason")), _cell(alt_txt),
        ])
    if not rows:
        return ""
    lines = ["### Re-expresión recomendada (escalera de Tukey)",
             "",
             "| Columna | Recomendada | Potencia | Razón | Alternativas |",
             "| --- | --- | --- | --- | --- |"]
    for r in rows:
        lines.append("| " + " | ".join(r) + " |")
    return "\n".join(lines)


def _app_kmeans_scores(kmeans: dict) -> str:
    """Loss #4 — KMeans silhouette + inertia per k (justifies the chosen k)."""
    scores = list(kmeans.get("scores_by_k", []) or [])
    if not scores:
        return ""
    best_k = kmeans.get("best_k")
    lines = ["#### KMeans — selección de k (`scores_by_k`)",
             "",
             "| k | Silhouette | Inercia | Elegido |",
             "| --- | --- | --- | --- |"]
    for s in scores:
        if not isinstance(s, dict):
            continue
        k = s.get("k")
        chosen = "✓" if best_k is not None and k == best_k else ""
        lines.append(
            f"| {_fmt_num(k)} | {_fmt_num(s.get('silhouette'))} "
            f"| {_fmt_num(s.get('inertia'))} | {chosen} |")
    return "\n".join(lines)


def _app_normality(normality: dict) -> str:
    """Loss #5 — each normality test's statistic next to its p-value."""
    if not isinstance(normality, dict) or not normality:
        return ""
    lines = ["#### Tests de normalidad (estadístico + p-value)",
             "",
             ("| Columna | n | JB stat | JB p | D'Agostino stat | D'Agostino p "
              "| Shapiro stat | Shapiro p | ¿Normal? |"),
             "| --- | --- | --- | --- | --- | --- | --- | --- | --- |"]
    any_row = False
    for col, res in normality.items():
        if not isinstance(res, dict):
            continue
        jb = res.get("jarque_bera") or {}
        da = res.get("dagostino") or {}
        sh = res.get("shapiro") or {}
        is_norm = "sí" if res.get("is_normal") else "no"
        lines.append(
            f"| {_cell(col)} | {_fmt_num(res.get('n')) if res.get('n') is not None else ''} "
            f"| {_fmt_num(jb.get('stat'))} | {_fmt_num(jb.get('p'))} "
            f"| {_fmt_num(da.get('stat'))} | {_fmt_num(da.get('p'))} "
            f"| {_fmt_num(sh.get('stat'))} | {_fmt_num(sh.get('p'))} | {is_norm} |")
        any_row = True
    return "\n".join(lines) if any_row else ""


def _profile_appendix(profile: dict) -> str:
    """Build the full-data appendix from a TableProfile dict (additive).

    Returns a Markdown ``## Apéndice`` section with one sub-table per loss the
    human chapters drop, or ``""`` when the profile carries none of them. Never
    raises: a missing/oddly-shaped section is skipped, not fatal.
    """
    if not isinstance(profile, dict):
        return ""
    sections: list = []
    try:
        corr = profile.get("correlations") or {}
        seg = _app_correlations(corr) if isinstance(corr, dict) else ""
        if seg:
            sections.append(seg)
    except Exception:  # noqa: BLE001
        pass
    try:
        columns = profile.get("columns") or []
        seg = _app_numeric_describe(columns)
        if seg:
            sections.append(seg)
        seg = _app_reexpression(columns)
        if seg:
            sections.append(seg)
    except Exception:  # noqa: BLE001
        pass
    try:
        models = profile.get("models") or {}
        if isinstance(models, dict):
            model_segs = []
            seg = _app_kmeans_scores(models.get("kmeans") or {})
            if seg:
                model_segs.append(seg)
            seg = _app_normality(models.get("normality") or {})
            if seg:
                model_segs.append(seg)
            if model_segs:
                sections.append(
                    "### Modelos — detalle\n\n" + "\n\n".join(model_segs))
    except Exception:  # noqa: BLE001
        pass
    if not sections:
        return ""
    intro = ("Volcado completo de los datos que el motor computó y que los "
             "capítulos (pensados para lectura humana / PDF) resumen. "
             "Pensado para que un LLM reconstruya el análisis entero.")
    return ("## Apéndice — Datos completos del perfil\n\n"
            f"*{intro}*\n\n" + "\n\n".join(sections))


# --------------------------------------------------------------------------- #
# Entry point.
# --------------------------------------------------------------------------- #
def render_md(chapters: list, out_path: str, meta: dict = None) -> dict:
    """Serialize a list of Chapters into a single self-contained Markdown file.

    The output leads with ``# <title>``, a metadata blockquote and a numbered
    ``## Índice`` linking each chapter, then one ``## N. <title>`` section per
    chapter with its blocks. Tables become Markdown tables (every row dumped),
    figures become caption + underlying data table, glossary markers are stripped
    while ``**bold**`` is kept. Designed to be pasted into an LLM.

    Args:
        chapters: a list of ``Chapter`` (dataclasses or dicts); normalized
            defensively with ``model.as_chapters``.
        out_path: filesystem path for the ``.md`` (parent dirs are created).
        meta: optional dict. Recognised keys: ``title``, ``ctx`` (dict with
            ``dataset_name``/``source_origin``/``storage``/``n_rows``/``n_cols``),
            ``generated_at``, ``embed_figures`` (export PNGs beside the .md,
            default False).

    Returns:
        dict (never raises): ``{path: str|None, n_chars: int,
        chapters: list[{id, version}], note: str}``. On a fatal error ``path`` is
        None and ``note`` explains why.
    """
    meta = meta or {}
    chapters = model.as_chapters(chapters)
    title = model._safe_str(meta.get("title")) or model.ENGINE_NAME

    # Edge: nothing to render -> a minimal but valid Markdown document.
    if not chapters:
        content = (f"# {title}\n\n"
                   "*(documento vacío — sin capítulos aplicables)*\n")
        return _write(out_path, content, [], "documento vacío")

    counter = [0]  # document-wide figure counter for unique PNG names.
    notes: list = []
    segments: list = [f"# {title}"]

    meta_lines = _meta_block(meta)
    if meta_lines:
        segments.append("\n".join(f"> {ln}" for ln in meta_lines))

    # Numbered index. The anchor matches the chapter heading emitted below
    # (``## N. <title>``) in GitHub slug style.
    chap_heads = []
    idx_lines = ["## Índice"]
    for i, ch in enumerate(chapters, 1):
        head_text = f"{i}. {model._safe_str(ch.title)}"
        anchor = _slug(head_text)
        chap_heads.append((head_text, anchor))
        idx_lines.append(f"{i}. [{model._safe_str(ch.title)}](#{anchor})")
    segments.append("\n".join(idx_lines))

    chapters_meta = []
    for i, ch in enumerate(chapters, 1):
        segments.append("---")
        head_text, _anchor = chap_heads[i - 1]
        segments.append(f"## {head_text}")

        blocks = list(ch.blocks or [])
        # Omit a leading level-1 Heading that just repeats the chapter title.
        if blocks:
            b0 = blocks[0]
            if (getattr(b0, "kind", "") == "heading"
                    and int(getattr(b0, "level", 1) or 1) == 1
                    and _clean_terms(getattr(b0, "text", "")).strip()
                    == model._safe_str(ch.title).strip()):
                blocks = blocks[1:]

        for block in blocks:
            try:
                seg = _serialize_block(block, meta, out_path, counter)
            except Exception as e:  # noqa: BLE001
                seg = _md_note(model.Note(text=model._safe_str(block)))
                notes.append(
                    f"bloque '{getattr(block, 'kind', '?')}' del capítulo "
                    f"'{ch.id}' degradado: {e}")
            if seg:
                segments.append(seg)
        chapters_meta.append({"id": ch.id, "version": ch.version})

    # Full-data appendix: dump everything the profile holds that the human
    # chapters drop (additive — the .md ends up with more than the PDF/PPTX).
    # Emitted only when a profile is supplied via meta['profile']; never fatal.
    try:
        appendix = _profile_appendix(meta.get("profile"))
    except Exception as e:  # noqa: BLE001
        appendix = ""
        notes.append(f"apéndice de perfil omitido: {e}")
    if appendix:
        segments.append("---")
        segments.append(appendix)

    content = "\n\n".join(segments) + "\n"
    note = f"{len(content)} caracteres"
    if notes:
        note += " · " + "; ".join(notes)
    return _write(out_path, content, chapters_meta, note)


def _write(out_path: str, content: str, chapters_meta: list, note: str) -> dict:
    """Write the Markdown to disk (creating parents). dict-no-throw."""
    try:
        parent = os.path.dirname(os.path.abspath(out_path))
        os.makedirs(parent, exist_ok=True)
        with open(out_path, "w", encoding="utf-8") as fh:
            fh.write(content)
    except Exception as e:  # noqa: BLE001 — never raise from the writer.
        return {"path": None, "n_chars": 0, "chapters": [],
                "note": f"no se pudo escribir el Markdown: {e}"}
    return {"path": out_path, "n_chars": len(content),
            "chapters": chapters_meta, "note": note}
