"""AutomaticEDA PPTX renderer — 16:9 slides, never cuts content.

Same flow principle as the PDF renderer but onto PowerPoint slides: measure each
block and place it top-to-bottom; when it does not fit in the remaining slide
space, continue on a new slide titled ``<Chapter> (cont.)``. Data tables split by
rows **repeating the header**; figures/images are scaled to fit entirely. Every
slide carries a footer ``<Chapter> · v<version>`` plus the engine version.

dict-no-throw: a failure inside one block is caught and noted; the deck is always
produced with at least one slide. Engine: ``python-pptx`` (added dependency).
"""

from __future__ import annotations

import io
import os

from . import model
from . import text_layout as tl

try:
    from pptx import Presentation
    from pptx.util import Inches, Pt, Emu
    from pptx.dml.color import RGBColor
    from pptx.enum.text import PP_ALIGN
    _PPTX_OK = True
    _PPTX_ERR = ""
except Exception as _e:  # noqa: BLE001 — surfaced as a dict-no-throw note.
    _PPTX_OK = False
    _PPTX_ERR = str(_e)

# 16:9 widescreen, inches.
_W, _H = 13.333, 7.5
_ML, _MR = 0.7, 0.7
_TITLE_TOP, _TITLE_H = 0.28, 0.7
_CONTENT_TOP = 1.12
_FOOTER_H = 0.4
_CONTENT_BOTTOM = _H - _FOOTER_H - 0.15
_USABLE_W = _W - _ML - _MR

_INK = (0x1B, 0x1B, 0x1B)
_ACCENT = (0x2A, 0x6F, 0x97)
_MUTED = (0x8A, 0x8A, 0x8A)
_HEAD_BG = (0xEE, 0xF3, 0xF6)
_WHITE = (0xFF, 0xFF, 0xFF)
_ZEBRA = (0xF6, 0xF8, 0xFA)   # faint grey for even (zebra) data rows.
_LINK = (0x2A, 0x6F, 0x97)    # accent colour for clickable glossary terms.

_FS_TITLE = 26
_FS_H1, _FS_H2, _FS_H3 = 20, 16, 13
_FS_BODY, _FS_CELL, _FS_NOTE = 14, 11, 11
_GAP = 0.12

# Rasterization DPI for every embedded figure/table image. Raised from 150 to 220
# so a viewer can zoom into a slide (or a shared picture) and read crisp detail —
# axis labels, table cells — without pixelation. Kept moderate so the deck size
# stays reasonable. Same value as the PDF renderer.
_RASTER_DPI = 220


class _PptxState:
    def __init__(self, prs, title: str):
        self.prs = prs
        self.title = title
        self.slide = None
        self.y = _CONTENT_TOP
        self.chapter = None
        self.slide_no = 0
        self.chapter_slides = 0
        self.last_heading = ""        # text of the most recent heading.
        # Glossary wiring (mejora 6): runs to link and per-term target slide.
        self.term_runs = []           # [(key, run)]
        self.term_anchor_slide = {}   # key -> Slide (glossary entry)
        # Clickable index (cover → chapter). toc_runs are the cover's index runs;
        # chapter_starts maps a chapter id AND its title to its first slide.
        self.toc_runs = []            # [(target_id, run, src_slide)]
        self.chapter_starts = {}      # id|title -> Slide (chapter first slide)


def _rgb(c):
    return RGBColor(*c)


def _new_slide(st: _PptxState, cont: bool = False) -> None:
    blank = st.prs.slide_layouts[6]
    st.slide = st.prs.slides.add_slide(blank)
    st.y = _CONTENT_TOP
    st.slide_no += 1
    st.chapter_slides += 1
    _draw_title(st, cont)
    _draw_footer(st)


def _draw_title(st: _PptxState, cont: bool) -> None:
    ch = st.chapter
    title = ch.title if ch is not None else st.title
    if cont:
        title = f"{title} (cont.)"
    box = st.slide.shapes.add_textbox(
        Inches(_ML), Inches(_TITLE_TOP), Inches(_USABLE_W), Inches(_TITLE_H))
    tf = box.text_frame
    tf.word_wrap = True
    p = tf.paragraphs[0]
    run = p.add_run()
    run.text = title
    run.font.size = Pt(_FS_TITLE)
    run.font.bold = True
    run.font.color.rgb = _rgb(_INK)


def _draw_footer(st: _PptxState) -> None:
    ch = st.chapter
    left = f"{ch.title} · v{ch.version}" if ch is not None else ""
    right = f"{model.ENGINE_NAME} v{model.ENGINE_VERSION} · {st.slide_no}"
    box = st.slide.shapes.add_textbox(
        Inches(_ML), Inches(_H - _FOOTER_H), Inches(_USABLE_W),
        Inches(_FOOTER_H * 0.7))
    tf = box.text_frame
    tf.word_wrap = False
    p = tf.paragraphs[0]
    r = p.add_run()
    r.text = left
    r.font.size = Pt(9)
    r.font.color.rgb = _rgb(_MUTED)
    # Right-aligned engine stamp on a second textbox.
    box2 = st.slide.shapes.add_textbox(
        Inches(_ML), Inches(_H - _FOOTER_H), Inches(_USABLE_W),
        Inches(_FOOTER_H * 0.7))
    tf2 = box2.text_frame
    p2 = tf2.paragraphs[0]
    p2.alignment = PP_ALIGN.RIGHT
    r2 = p2.add_run()
    r2.text = right
    r2.font.size = Pt(9)
    r2.font.color.rgb = _rgb(_MUTED)


def _remaining(st: _PptxState) -> float:
    return _CONTENT_BOTTOM - st.y


def _ensure(st: _PptxState, height: float) -> None:
    if _remaining(st) < height:
        _new_slide(st, cont=True)


def _add_text(st: _PptxState, lines: list, fs: float, color, bold=False,
              italic=False, indent=0.0, bullet=False, underline=False) -> None:
    lh = tl.line_height_in(fs)
    height = lh * len(lines) + 0.05
    _ensure(st, height)
    box = st.slide.shapes.add_textbox(
        Inches(_ML + indent), Inches(st.y), Inches(_USABLE_W - indent),
        Inches(height))
    tf = box.text_frame
    tf.word_wrap = True
    first = True
    for ln in lines:
        p = tf.paragraphs[0] if first else tf.add_paragraph()
        first = False
        run = p.add_run()
        run.text = ("•  " + ln) if bullet else ln
        run.font.size = Pt(fs)
        run.font.bold = bold
        run.font.italic = italic
        run.font.underline = underline
        run.font.color.rgb = _rgb(color)
    st.y += height


def _add_rich_text(st: _PptxState, rich_lines: list, fs: float, color,
                   indent=0.0, bullet=False) -> None:
    """Add pre-wrapped lines of styled segments as one paragraph per line.

    Each line is a list of ``(text, is_bold)`` or ``(text, is_bold, term_key)``
    segments; every segment becomes its own run so ``**bold**`` spans render with
    native PowerPoint bold (``run.font.bold``) without affecting the measured
    height (one paragraph per pre-wrapped line). A segment carrying a
    ``term_key`` is drawn in the accent colour and its run is recorded in
    ``st.term_runs`` so it later becomes a native hyperlink jumping to the
    glossary slide of that term.
    """
    lh = tl.line_height_in(fs)
    height = lh * len(rich_lines) + 0.05
    _ensure(st, height)
    box = st.slide.shapes.add_textbox(
        Inches(_ML + indent), Inches(st.y), Inches(_USABLE_W - indent),
        Inches(height))
    tf = box.text_frame
    tf.word_wrap = True
    first = True
    for segs in rich_lines:
        p = tf.paragraphs[0] if first else tf.add_paragraph()
        first = False
        if bullet:
            r0 = p.add_run()
            r0.text = "•  "
            r0.font.size = Pt(fs)
            r0.font.color.rgb = _rgb(color)
        for seg in segs:
            if len(seg) == 3:
                seg_text, is_bold, term = seg
            else:
                seg_text, is_bold, term = seg[0], seg[1], None
            if seg_text == "":
                continue
            run = p.add_run()
            run.text = seg_text
            run.font.size = Pt(fs)
            run.font.bold = bool(is_bold)
            run.font.color.rgb = _rgb(_LINK if term else color)
            if term:
                st.term_runs.append((term, run, st.slide))
    st.y += height


def _place_heading(st: _PptxState, block) -> None:
    level = max(1, min(3, int(getattr(block, "level", 1) or 1)))
    fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
    # Optional per-heading emphasis (cover dataset name): a larger font and an
    # underline. ``size_pt`` overrides the per-level size when set.
    size_override = getattr(block, "size_pt", None)
    if isinstance(size_override, (int, float)) and size_override > 0:
        fs = float(size_override)
    underline = bool(getattr(block, "underline", False))
    text = tl.strip_inline_md(getattr(block, "text", ""))
    st.last_heading = text or st.last_heading
    lines = tl.wrap(text, tl.chars_per_line(_USABLE_W, fs))
    _add_text(st, lines, fs, _INK, bold=True, underline=underline)
    st.y += 0.04


def _place_markdown(st: _PptxState, block) -> None:
    raw = str(getattr(block, "text", "") or "")
    md_lines = raw.split("\n")
    i, n = 0, len(md_lines)
    while i < n:
        stripped = md_lines[i].strip()
        if stripped.startswith("|") and stripped.endswith("|"):
            j = i
            tbl = []
            while j < n and md_lines[j].strip().startswith("|") \
                    and md_lines[j].strip().endswith("|"):
                tbl.append(md_lines[j])
                j += 1
            parsed = tl.parse_md_table(tbl)
            if parsed:
                header, rows = parsed
                _place_data_table(st, model.DataTable(header=header, rows=rows))
                i = j
                continue
        if stripped == "":
            st.y += tl.line_height_in(_FS_BODY) * 0.4
            i += 1
            continue
        if stripped.startswith("### "):
            _place_heading(st, model.Heading(stripped[4:], level=3))
            i += 1
            continue
        if stripped.startswith("## "):
            _place_heading(st, model.Heading(stripped[3:], level=2))
            i += 1
            continue
        if stripped.startswith("# "):
            _place_heading(st, model.Heading(stripped[2:], level=1))
            i += 1
            continue
        if stripped.startswith("- ") or stripped.startswith("* "):
            content = stripped[2:]  # keep inline markers for bold rendering.
            rich = tl.wrap_rich_terms(content,
                                      tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY))
            _add_rich_text(st, rich, _FS_BODY, _INK, bullet=True)
            i += 1
            continue
        para = [stripped]  # keep inline markers; wrap_rich_terms renders **bold**.
        j = i + 1
        while j < n:
            nxt = md_lines[j].strip()
            if nxt == "" or nxt.startswith(("|", "#", "- ", "* ")):
                break
            para.append(nxt)
            j += 1
        text = " ".join(para)
        _add_rich_text(st, tl.wrap_rich_terms(
            text, tl.chars_per_line(_USABLE_W, _FS_BODY)), _FS_BODY, _INK)
        i = j
    st.y += _GAP


def _place_kv_table(st: _PptxState, block) -> None:
    title = getattr(block, "title", None)
    if title:
        _place_heading(st, model.Heading(title, level=2))
    rows = getattr(block, "rows", []) or []
    data_rows = []
    for row in rows:
        try:
            label, value = row[0], row[1]
        except Exception:  # noqa: BLE001
            label, value = str(row), ""
        data_rows.append([model._safe_str(label), model._safe_str(value)])
    _place_data_table(st, model.DataTable(header=["Campo", "Valor"],
                                          rows=data_rows), shaded_header=True,
                      key_value=True)


def _col_widths(header, rows):
    ncol = len(header) if header else (len(rows[0]) if rows else 1)
    ncol = max(1, ncol)
    natural = [3] * ncol
    for c in range(ncol):
        if header and c < len(header):
            natural[c] = max(natural[c], len(model._safe_str(header[c])))
        for r in rows:
            if c < len(r):
                natural[c] = max(natural[c], len(model._safe_str(r[c])))
    clamped = [min(max(w, 4), 44) for w in natural]
    total = float(sum(clamped)) or 1.0
    return [_USABLE_W * w / total for w in clamped]


# Minimal legible characters reserved per column when deciding whether a table
# can be shown as a native (selectable) PowerPoint table. Below this width per
# column the cells become unreadable, so the table is rasterized to a zoomable
# high-res image instead. The 16:9 slide is wide, so more columns fit than on A5.
_MIN_LEGIBLE_CHARS = 8
_CELL_PAD = 0.05


def _table_fits_as_text(header: list, rows: list) -> bool:
    """True when the table fits the usable slide width as a readable table.

    A table whose columns cannot each get a minimal legible width within the slide
    usable width (typically many columns, e.g. a 19-column ``df.head``) is flagged
    so it is rendered as one high-resolution image — the viewer zooms in and reads
    every cell — instead of being squeezed unreadable. Narrow tables keep the
    native selectable table."""
    header = header or []
    rows = rows or []
    ncol = len(header) if header else (len(rows[0]) if rows else 1)
    ncol = max(1, ncol)
    cw = tl.avg_char_width_in(_FS_CELL)
    min_needed = ncol * (_MIN_LEGIBLE_CHARS * cw + _CELL_PAD * 2)
    return min_needed <= _USABLE_W


def _table_figure_block(block):
    """Wrap a too-wide table as a lazily-rasterized Figure (cached on the block).

    Drawn once via ``render_table_as_figure`` (header shading + zebra) and embedded
    as one high-res image scaled to fit entirely. The title/note are drawn inside
    the image (self-describing when zoomed/shared), so no separate caption is
    emitted. Reused for measuring and placing so keep-together stays consistent."""
    cached = getattr(block, "_aeda_tablefig", None)
    if cached is not None:
        return cached
    header = list(getattr(block, "header", []) or [])
    rows = list(getattr(block, "rows", []) or [])
    title = getattr(block, "title", None)
    note = getattr(block, "note", None)

    def _make():
        from datascience.render_table_as_figure import render_table_as_figure
        return render_table_as_figure(header, rows, title=title, note=note)

    fig = model.Figure(make=_make, caption=None)
    try:
        block._aeda_tablefig = fig
    except Exception:  # noqa: BLE001 — block may reject attributes; degrade.
        pass
    return fig


def _row_height_in(cells, widths, fs) -> float:
    lh = tl.line_height_in(fs)
    maxlines = 1
    for c, w in enumerate(widths):
        text = model._safe_str(cells[c]) if c < len(cells) else ""
        lines = tl.wrap(text, tl.chars_per_line(w - 0.12, fs))
        maxlines = max(maxlines, len(lines))
    return lh * maxlines + 0.10


def _emit_table(st: _PptxState, header, chunk, widths, fs,
                start_index: int = 0) -> None:
    nrows = len(chunk) + (1 if header else 0)
    ncol = len(widths)
    # Pre-measure total height to size the shape (pptx still auto-grows rows).
    heights = []
    if header:
        heights.append(_row_height_in(header, widths, fs))
    for r in chunk:
        heights.append(_row_height_in(r, widths, fs))
    total_h = sum(heights)
    gtable = st.slide.shapes.add_table(
        nrows, ncol, Inches(_ML), Inches(st.y), Inches(_USABLE_W),
        Inches(total_h)).table
    gtable.first_row = bool(header)
    gtable.horz_banding = False
    for c in range(ncol):
        gtable.columns[c].width = Emu(int(Inches(widths[c])))
    ridx = 0
    if header:
        for c in range(ncol):
            cell = gtable.cell(0, c)
            cell.text = model._safe_str(header[c]) if c < len(header) else ""
            _style_cell(cell, fs, _INK, bold=True, fill=_HEAD_BG)
        ridx = 1
    # Zebra striping: shade even data rows (1-based) using the GLOBAL row index
    # (start_index offset) so the pattern stays coherent across split chunks.
    for k, r in enumerate(chunk):
        fill = _ZEBRA if (start_index + k) % 2 == 1 else _WHITE
        for c in range(ncol):
            cell = gtable.cell(ridx, c)
            cell.text = model._safe_str(r[c]) if c < len(r) else ""
            _style_cell(cell, fs, _INK, bold=False, fill=fill)
        ridx += 1
    st.y += total_h + _GAP


def _style_cell(cell, fs, color, bold, fill) -> None:
    cell.fill.solid()
    cell.fill.fore_color.rgb = _rgb(fill)
    cell.margin_left = Inches(0.05)
    cell.margin_right = Inches(0.05)
    cell.margin_top = Inches(0.02)
    cell.margin_bottom = Inches(0.02)
    for p in cell.text_frame.paragraphs:
        for run in p.runs:
            run.font.size = Pt(fs)
            run.font.bold = bold
            run.font.color.rgb = _rgb(color)


def _place_data_table(st: _PptxState, block, shaded_header=True,
                      key_value=False) -> None:
    header = list(getattr(block, "header", []) or [])
    rows = list(getattr(block, "rows", []) or [])
    # Too many columns to be legible as a native table → render the whole table as
    # one high-res picture, scaled to fit entirely (the viewer zooms to read it).
    # KVTables (rendered here as a 2-column Campo/Valor table) are excluded: they
    # always fit in width and stay as a selectable table.
    if not key_value and not _table_fits_as_text(header, rows):
        figblock = _table_figure_block(block)
        data, _asp = _figure_bytes_cached(figblock)
        if data is None:
            _add_text(st, ["(tabla no disponible)"], _FS_NOTE, _MUTED,
                      italic=True)
            st.y += _GAP
            return
        _place_picture_bytes(st, data, None,
                             max_h_in=getattr(figblock, "height_in", None),
                             force_caption=False)
        return
    title = getattr(block, "title", None)
    if title:
        _place_heading(st, model.Heading(title, level=2))
    fs = _FS_CELL
    widths = _col_widths(header, rows)
    header_h = _row_height_in(header, widths, fs) if header else 0.0

    idx = 0
    n = len(rows)
    if n == 0:
        # Header-only table still rendered (one slide).
        _ensure(st, header_h + 0.2)
        _emit_table(st, header, [], widths, fs)
        return
    while idx < n:
        # Greedily fill the current slide with as many rows as fit.
        if _remaining(st) < header_h + _row_height_in(rows[idx], widths, fs):
            _new_slide(st, cont=True)
        avail = _remaining(st) - header_h
        chunk = []
        used = 0.0
        chunk_start = idx  # global index of the first row in this chunk (zebra).
        while idx < n:
            rh = _row_height_in(rows[idx], widths, fs)
            if used + rh > avail and chunk:
                break
            chunk.append(rows[idx])
            used += rh
            idx += 1
        _emit_table(st, header, chunk, widths, fs, start_index=chunk_start)
    note = getattr(block, "note", None)
    if note:
        _add_text(st, tl.wrap(model._safe_str(note),
                  tl.chars_per_line(_USABLE_W, _FS_NOTE)), _FS_NOTE, _MUTED,
                  italic=True)


def _img_size_px(data: bytes):
    try:
        from PIL import Image
        with Image.open(io.BytesIO(data)) as im:
            return im.size  # (w, h)
    except Exception:  # noqa: BLE001
        return (1200, 800)


def _resolve_png(block):
    fig = getattr(block, "fig", None)
    make = getattr(block, "make", None)
    f = fig
    owned = False
    if f is None and callable(make):
        try:
            f = make()
            owned = True
        except Exception:  # noqa: BLE001
            f = None
    if f is None:
        return None
    try:
        import matplotlib.pyplot as plt
        buf = io.BytesIO()
        f.savefig(buf, format="png", dpi=_RASTER_DPI, bbox_inches="tight")
        buf.seek(0)
        return buf.read()
    except Exception:  # noqa: BLE001
        return None
    finally:
        if owned:
            try:
                import matplotlib.pyplot as plt
                plt.close(f)
            except Exception:  # noqa: BLE001
                pass


def _figure_bytes_cached(block):
    """Rasterize a figure/image to PNG bytes ONCE and cache (bytes, aspect).

    Measuring (keep-together) and drawing must agree on the real aspect ratio —
    ``bbox_inches='tight'`` changes it vs ``figsize``, so we rasterize once and
    reuse the bytes for both. Cached on the block; never raises."""
    cached = getattr(block, "_aeda_png", None)
    if cached is not None:
        return cached
    kind = getattr(block, "kind", "")
    data = None
    if kind == "image":
        path = getattr(block, "path", "")
        if path and os.path.exists(path):
            try:
                with open(path, "rb") as fh:
                    data = fh.read()
            except Exception:  # noqa: BLE001
                data = None
    else:
        data = _resolve_png(block)
    aspect = 0.66
    if data is not None:
        w_px, h_px = _img_size_px(data)
        aspect = (h_px / w_px) if w_px else 0.66
    try:
        block._aeda_png = (data, aspect)
        return block._aeda_png
    except Exception:  # noqa: BLE001 — block may reject attributes; degrade.
        return (data, aspect)


def _place_picture_bytes(st: _PptxState, data: bytes, caption,
                         max_h_in=None, force_caption=True) -> None:
    # Mejora 4 — every figure on a slide carries a visible caption/title. If the
    # block has no caption, fall back to the current section heading, then to a
    # generic label, so no image is ever shown untitled. ``force_caption=False``
    # suppresses that fallback (used for table images, whose title is inside the
    # picture) so no redundant caption is drawn.
    caption = model._safe_str(caption).strip()
    if not caption and force_caption:
        caption = model._safe_str(st.last_heading).strip() or "Figura"
    w_px, h_px = _img_size_px(data)
    aspect = (h_px / w_px) if w_px else 0.66
    # Reserve the caption's REAL (possibly multi-line) height FIRST, then scale
    # the image to (max_h - cap_reserve): a figure never fills the whole slide,
    # so its caption always fits on the SAME slide and no image is untitled.
    # cap_real = what _add_text consumes; cap_reserve adds the post-image gap and
    # a small cushion so the caption never spills to the next slide.
    cap_lines = tl.wrap(caption, tl.chars_per_line(_USABLE_W, _FS_NOTE)) \
        if caption else []
    cap_real = (tl.line_height_in(_FS_NOTE) * len(cap_lines) + 0.05) \
        if cap_lines else 0.0
    cap_reserve = (cap_real + 0.05 + 0.10) if cap_lines else 0.05
    max_h = _CONTENT_BOTTOM - _CONTENT_TOP
    # height_in hint (model.Figure/Image): cap the target height so a figure in a
    # keep-together Group shrinks to leave room for its heading and text.
    if isinstance(max_h_in, (int, float)) and max_h_in > 0:
        max_h = min(max_h, float(max_h_in))
    max_img_h = max(max_h - cap_reserve, 0.6)
    target_w = _USABLE_W
    target_h = target_w * aspect
    if target_h > max_img_h:
        target_h = max_img_h
        target_w = target_h / aspect if aspect else _USABLE_W
    # Keep the image and its caption together on the same slide.
    if _remaining(st) < target_h + cap_reserve:
        _new_slide(st, cont=True)
    left = _ML + (_USABLE_W - target_w) / 2.0
    st.slide.shapes.add_picture(io.BytesIO(data), Inches(left), Inches(st.y),
                                width=Inches(target_w), height=Inches(target_h))
    st.y += target_h + 0.05
    if cap_lines:
        _add_text(st, cap_lines, _FS_NOTE, _MUTED, italic=True)
    st.y += _GAP


def _place_figure(st: _PptxState, block) -> None:
    png, _aspect = _figure_bytes_cached(block)
    if png is None:
        _add_text(st, ["(figura no disponible)"], _FS_NOTE, _MUTED, italic=True)
        st.y += _GAP
        return
    _place_picture_bytes(st, png, getattr(block, "caption", None),
                         max_h_in=getattr(block, "height_in", None))


def _place_image(st: _PptxState, block) -> None:
    data, _aspect = _figure_bytes_cached(block)
    if data is None:
        path = getattr(block, "path", "")
        _add_text(st, [f"(imagen no encontrada: {path})"], _FS_NOTE, _MUTED,
                  italic=True)
        st.y += _GAP
        return
    _place_picture_bytes(st, data, getattr(block, "caption", None),
                         max_h_in=getattr(block, "height_in", None))


def _place_caption(st: _PptxState, block) -> None:
    _add_text(st, tl.wrap(getattr(block, "text", ""),
              tl.chars_per_line(_USABLE_W, _FS_NOTE)), _FS_NOTE, _MUTED,
              italic=True)
    st.y += _GAP


def _place_note(st: _PptxState, block) -> None:
    _place_caption(st, block)


# --------------------------------------------------------------------------- #
# Block measurement (mejora 3 — keep-together). Estimate a block's slide height
# WITHOUT drawing it so a Group can move whole to the next slide before drawing.
# Over-estimating only triggers an earlier slide break, never a content cut.
# --------------------------------------------------------------------------- #
def _measure_heading_text(text: str, level: int, size_pt=None) -> float:
    level = max(1, min(3, int(level or 1)))
    fs = {1: _FS_H1, 2: _FS_H2, 3: _FS_H3}[level]
    if isinstance(size_pt, (int, float)) and size_pt > 0:
        fs = float(size_pt)
    lines = tl.wrap(tl.strip_inline_md(text), tl.chars_per_line(_USABLE_W, fs))
    return tl.line_height_in(fs) * len(lines) + 0.05 + 0.04


def _measure_markdown(block) -> float:
    raw = str(getattr(block, "text", "") or "")
    md_lines = raw.split("\n")
    h = 0.0
    i, n = 0, len(md_lines)
    while i < n:
        stripped = md_lines[i].strip()
        if stripped.startswith("|") and stripped.endswith("|"):
            j = i
            while j < n and md_lines[j].strip().startswith("|") \
                    and md_lines[j].strip().endswith("|"):
                j += 1
            h += (tl.line_height_in(_FS_CELL) + 0.10) * (j - i) + _GAP
            i = j
            continue
        if stripped == "":
            h += tl.line_height_in(_FS_BODY) * 0.4
            i += 1
            continue
        if stripped.startswith("### "):
            h += _measure_heading_text(stripped[4:], 3)
            i += 1
            continue
        if stripped.startswith("## "):
            h += _measure_heading_text(stripped[3:], 2)
            i += 1
            continue
        if stripped.startswith("# "):
            h += _measure_heading_text(stripped[2:], 1)
            i += 1
            continue
        if stripped.startswith("- ") or stripped.startswith("* "):
            lines = tl.wrap_rich_terms(
                stripped[2:], tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY))
            h += tl.line_height_in(_FS_BODY) * len(lines) + 0.05
            i += 1
            continue
        para = [stripped]
        j = i + 1
        while j < n:
            nxt = md_lines[j].strip()
            if nxt == "" or nxt.startswith(("|", "#", "- ", "* ")):
                break
            para.append(nxt)
            j += 1
        lines = tl.wrap_rich_terms(" ".join(para),
                                   tl.chars_per_line(_USABLE_W, _FS_BODY))
        h += tl.line_height_in(_FS_BODY) * len(lines) + 0.05
        i = j
    return h + _GAP


def _measure_figure_like(block) -> float:
    max_h = _CONTENT_BOTTOM - _CONTENT_TOP
    hint = getattr(block, "height_in", None)
    if isinstance(hint, (int, float)) and hint > 0:
        max_h = min(max_h, float(hint))
    # Use the REAL rasterized aspect (cached) so measuring matches drawing — this
    # is what keeps a figure together with its heading instead of splitting.
    _data, aspect = _figure_bytes_cached(block)
    target_h = min(_USABLE_W * aspect, max_h)
    # Caption is always emitted now (mejora 4), so always reserve its line.
    cap_h = tl.line_height_in(_FS_NOTE) + 0.05
    return target_h + 0.05 + cap_h + _GAP


def _measure_kv_table(block) -> float:
    """Faithful KVTable height — matches ``_place_kv_table`` (rendered as a
    Campo/Valor data table with wrapped cells). The previous estimate assumed one
    line per row and ignored the title, so a keep-together Group under-budgeted
    the figure and the chart spilled to the next slide. Keep in sync."""
    h = 0.0
    title = getattr(block, "title", None)
    if title:
        h += _measure_heading_text(title, 2)
    rows = getattr(block, "rows", []) or []
    data_rows = []
    for row in rows:
        try:
            label, value = row[0], row[1]
        except Exception:  # noqa: BLE001
            label, value = str(row), ""
        data_rows.append([model._safe_str(label), model._safe_str(value)])
    header = ["Campo", "Valor"]
    widths = _col_widths(header, data_rows)
    fs = _FS_CELL
    h += _row_height_in(header, widths, fs)
    for r in data_rows:
        h += _row_height_in(r, widths, fs)
    return h + _GAP


def _measure_data_table(block) -> float:
    """Faithful DataTable height — matches ``_place_data_table`` (title heading +
    wrapped header + every wrapped row + optional note). Keep in sync."""
    header = list(getattr(block, "header", []) or [])
    rows = list(getattr(block, "rows", []) or [])
    # Mirror the placer: a too-wide table is drawn as one image, so its
    # keep-together height is the image's, not the (squeezed) table layout's.
    if not _table_fits_as_text(header, rows):
        return _measure_figure_like(_table_figure_block(block))
    h = 0.0
    title = getattr(block, "title", None)
    if title:
        h += _measure_heading_text(title, 2)
    fs = _FS_CELL
    widths = _col_widths(header, rows)
    if header:
        h += _row_height_in(header, widths, fs)
    for r in rows:
        h += _row_height_in(r, widths, fs)
    note = getattr(block, "note", None)
    if note:
        nlines = tl.wrap(model._safe_str(note),
                         tl.chars_per_line(_USABLE_W, _FS_NOTE))
        h += tl.line_height_in(_FS_NOTE) * len(nlines) + 0.05
    return h + _GAP


def _measure_block(st: _PptxState, block) -> float:
    kind = getattr(block, "kind", "")
    try:
        if kind == "heading":
            return _measure_heading_text(getattr(block, "text", ""),
                                         getattr(block, "level", 1),
                                         size_pt=getattr(block, "size_pt", None))
        if kind == "markdown":
            return _measure_markdown(block)
        if kind in ("figure", "image"):
            return _measure_figure_like(block)
        if kind in ("caption", "note"):
            lines = tl.wrap(getattr(block, "text", ""),
                            tl.chars_per_line(_USABLE_W, _FS_NOTE))
            return tl.line_height_in(_FS_NOTE) * len(lines) + 0.05 + _GAP
        if kind == "toc_entry":
            lines = tl.wrap(tl.strip_inline_md(getattr(block, "label", "")),
                            tl.chars_per_line(_USABLE_W - 0.3, _FS_BODY)) or [""]
            return tl.line_height_in(_FS_BODY) * len(lines) + 0.05
        if kind == "kv_table":
            return _measure_kv_table(block)
        if kind == "data_table":
            return _measure_data_table(block)
        if kind == "group":
            return sum(_measure_block(st, b)
                       for b in (getattr(block, "blocks", []) or []))
    except Exception:  # noqa: BLE001 — a measurement never aborts rendering.
        pass
    return tl.line_height_in(_FS_BODY)


def _shrink_group_figures(st: _PptxState, blocks: list, avail_full: float) -> None:
    """Cap each figure's height (via height_in) so the whole group fits a slide.

    The figure shrinks just enough to leave room for its heading, text and
    caption — that is how keep-together puts a chart on the SAME slide as its
    title and description instead of pushing it to the next slide."""
    fig_blocks = [b for b in blocks
                  if getattr(b, "kind", "") in ("figure", "image")]
    if not fig_blocks:
        return
    nonfig_h = sum(_measure_block(st, b) for b in blocks
                   if getattr(b, "kind", "") not in ("figure", "image"))
    fig_overhead = tl.line_height_in(_FS_NOTE) + 0.05 + 0.05 + _GAP
    budget = avail_full - nonfig_h - 0.10 * len(fig_blocks)
    # Low thresholds: a 16:9 slide is short, so a content-heavy column (cardinality
    # table + top-k + chart) only fits if the chart is allowed to shrink small.
    # Prefer a small-but-present chart on the SAME slide over splitting the column
    # across slides (matches the PDF renderer's keep-together philosophy).
    if budget <= 0.6:
        return  # not enough room to keep together; let it flow (degrade).
    per = budget / len(fig_blocks) - fig_overhead
    if per <= 0.35:
        return
    for fb in fig_blocks:
        cur = getattr(fb, "height_in", None)
        fb.height_in = (min(float(cur), per)
                        if isinstance(cur, (int, float)) and cur > 0 else per)


# Minimum height (inches) reserved for a figure inside a keep-together group on
# the short 16:9 slide. When a high-cardinality column's table(s) would otherwise
# leave no room, the data table is trimmed (with an honest note) so the chart
# stays on the SAME slide next to its table instead of spilling to the next one.
_GROUP_MIN_FIG_H = 1.3


def _trim_data_table_to_budget(block, budget: float):
    """Return a copy of a DataTable whose rows fit within ``budget`` inches.

    Keeps the title, header, as many leading rows as fit (at least one) and an
    honest note reporting how many of the original rows are shown. NEVER mutates
    the original block — the same Chapter blocks are rendered by the PDF renderer,
    which keeps the full table (an A5 page fits it)."""
    header = list(getattr(block, "header", []) or [])
    rows = list(getattr(block, "rows", []) or [])
    title = getattr(block, "title", None)
    fs = _FS_CELL
    widths = _col_widths(header, rows)
    fixed = 0.0
    if title:
        fixed += _measure_heading_text(title, 2)
    if header:
        fixed += _row_height_in(header, widths, fs)
    note_h = tl.line_height_in(_FS_NOTE) + 0.05
    avail_rows = budget - fixed - note_h - _GAP
    kept = []
    used = 0.0
    for r in rows:
        rh = _row_height_in(r, widths, fs)
        if used + rh > avail_rows and kept:
            break
        kept.append(r)
        used += rh
    if len(kept) >= len(rows):
        return block  # already fits; keep the original (with its own note).
    note = (f"top {len(kept)} de {len(rows)} categorías mostradas "
            "(recortado para caber en el slide; el PDF muestra más)")
    return model.DataTable(header=header, rows=kept, title=title, note=note)


def _fit_group_blocks(st: _PptxState, blocks: list, avail_full: float) -> list:
    """Return a slide-fitting copy of a keep-together group's blocks.

    On the short 16:9 slide a high-cardinality column's top-k table plus its
    chart can overflow. Reserve ``_GROUP_MIN_FIG_H`` for the (later shrunk) figure
    and trim the data table(s) to what is left, so every column keeps its chart
    next to its table on ONE slide. No-op when the group has no figure+table pair
    (e.g. id-like columns already drop the top-k upstream, or it already fits)."""
    has_fig = any(getattr(b, "kind", "") in ("figure", "image") for b in blocks)
    tbls = [b for b in blocks if getattr(b, "kind", "") == "data_table"]
    if not (has_fig and tbls):
        return blocks
    fixed_h = sum(_measure_block(st, b) for b in blocks
                  if getattr(b, "kind", "") not in ("figure", "image",
                                                    "data_table"))
    tables_h = sum(_measure_block(st, b) for b in tbls)
    budget_tables = avail_full - fixed_h - _GROUP_MIN_FIG_H
    if tables_h <= budget_tables:
        return blocks  # already fits next to a min-height figure; leave intact.
    out = []
    for b in blocks:
        if getattr(b, "kind", "") != "data_table":
            out.append(b)
            continue
        trimmed = _trim_data_table_to_budget(b, max(budget_tables, 0.8))
        out.append(trimmed)
        budget_tables -= _measure_data_table(trimmed)
    return out


def _fit_img(width_col: float, aspect: float, max_h: float):
    """Scale an image to ``width_col`` then clamp to ``max_h`` keeping aspect."""
    w = width_col
    h = w * aspect
    if h > max_h:
        h = max_h
        w = (h / aspect) if aspect else width_col
    return w, h


def _place_group_side_by_side(st: _PptxState, block, avail_full: float) -> bool:
    """Place a Group's table (left ~55%) next to its figure (right ~45%).

    Both the table and the figure are rasterized to high-res images and placed in
    two columns of the SAME slide; any other blocks (e.g. a heading) render full
    width above the pair, the rest below. Returns True on success; returns False
    (so the caller falls back to stacking) when the group has no table+figure pair
    or the pair cannot fit side by side on one slide. Never raises by itself."""
    blocks = getattr(block, "blocks", []) or []
    tbl = next((b for b in blocks
                if getattr(b, "kind", "") in ("data_table", "kv_table")), None)
    fig = next((b for b in blocks
                if getattr(b, "kind", "") in ("figure", "image")), None)
    if tbl is None or fig is None:
        return False
    gap_col = 0.3
    left_w = _USABLE_W * 0.55 - gap_col / 2.0
    right_w = _USABLE_W * 0.45 - gap_col / 2.0
    if left_w <= 1.0 or right_w <= 1.0:
        return False
    tdata, tasp = _figure_bytes_cached(_table_figure_block(tbl))
    fdata, fasp = _figure_bytes_cached(fig)
    if not tdata or not fdata:
        return False
    ti, fi = blocks.index(tbl), blocks.index(fig)
    lo = min(ti, fi)
    lead = list(blocks[:lo])
    rest = [b for b in blocks[lo + 1:] if b is not tbl and b is not fig]
    lead_h = sum(_measure_block(st, b) for b in lead)
    rest_h = sum(_measure_block(st, b) for b in rest)
    col_max_h = avail_full - lead_h - rest_h - _GAP * 2
    if col_max_h < 1.2:
        return False  # not enough vertical room to put the pair side by side.
    tw, th = _fit_img(left_w, tasp, col_max_h)
    fw, fh = _fit_img(right_w, fasp, col_max_h)
    band = max(th, fh)
    needed = lead_h + band + rest_h + _GAP * 2
    if needed > avail_full:
        return False  # taller than a whole slide even side by side → stack.
    if needed > _remaining(st):
        _new_slide(st, cont=True)
    for b in lead:
        _PLACERS.get(getattr(b, "kind", ""), _place_note)(st, b)
    top = st.y
    f_left = _ML + left_w + gap_col
    st.slide.shapes.add_picture(
        io.BytesIO(tdata), Inches(_ML + (left_w - tw) / 2.0),
        Inches(top + (band - th) / 2.0), width=Inches(tw), height=Inches(th))
    st.slide.shapes.add_picture(
        io.BytesIO(fdata), Inches(f_left + (right_w - fw) / 2.0),
        Inches(top + (band - fh) / 2.0), width=Inches(fw), height=Inches(fh))
    st.y = top + band + _GAP
    for b in rest:
        _PLACERS.get(getattr(b, "kind", ""), _place_note)(st, b)
    return True


def _place_group(st: _PptxState, block) -> None:
    """Render a keep-together Group: move it whole to the next slide if needed."""
    blocks = getattr(block, "blocks", []) or []
    if not blocks:
        return
    # Opt-in slide break: start this group on a fresh slide unless the current one
    # is still empty (so a chapter can give each unit its own slide).
    if getattr(block, "page_break_before", False) and st.y > _CONTENT_TOP + 1e-6:
        _new_slide(st, cont=True)
    avail_full = _CONTENT_BOTTOM - _CONTENT_TOP
    # layout="side_by_side": try table-left / figure-right on one slide; on any
    # reason it can't, fall through to the normal stacked keep-together below.
    if str(getattr(block, "layout", "stack")).lower() == "side_by_side":
        try:
            if _place_group_side_by_side(st, block, avail_full):
                return
        except Exception:  # noqa: BLE001 — degrade to stacking, never abort.
            pass
    # Trim oversized tables first (keeps the chart on the same slide), then shrink
    # the figure to share the remaining room.
    blocks = _fit_group_blocks(st, blocks, avail_full)
    _shrink_group_figures(st, blocks, avail_full)
    total = sum(_measure_block(st, b) for b in blocks)
    if total <= avail_full:
        if total > _remaining(st):
            _new_slide(st, cont=True)
    elif st.y > _CONTENT_TOP + 1e-6:
        _new_slide(st, cont=True)
    for b in blocks:
        placer = _PLACERS.get(getattr(b, "kind", ""), _place_note)
        try:
            placer(st, b)
        except Exception:  # noqa: BLE001 — a bad block never aborts the group.
            pass


def _place_glossary_entry(st: _PptxState, block) -> None:
    """Render one glossary term and register its slide as the link target."""
    key = getattr(block, "key", "")
    label = getattr(block, "label", "") or key
    definition = getattr(block, "definition", "")
    _ensure(st, tl.line_height_in(_FS_H3) + tl.line_height_in(_FS_BODY) * 2)
    if key:
        st.term_anchor_slide[key] = st.slide
    _place_heading(st, model.Heading(text=str(label), level=3))
    if definition:
        _add_text(st, tl.wrap(model._safe_str(definition),
                  tl.chars_per_line(_USABLE_W, _FS_BODY)), _FS_BODY, _INK)
    st.y += _GAP


def _place_toc_entry(st: _PptxState, block) -> None:
    """Render one clickable index line and record its run as a link source.

    Drawn as a bulleted line in the accent link colour; the run is recorded in
    ``st.toc_runs`` so it later becomes a native slide-jump to the target chapter's
    first slide. If the target is never resolved the line still shows as plain
    (accent) text — never cut."""
    label = tl.strip_inline_md(getattr(block, "label", "")) or ""
    target_id = getattr(block, "target_id", "") or ""
    fs = _FS_BODY
    lines = tl.wrap(label, tl.chars_per_line(_USABLE_W - 0.3, fs)) or [""]
    lh = tl.line_height_in(fs)
    height = lh * len(lines) + 0.05
    _ensure(st, height)
    box = st.slide.shapes.add_textbox(
        Inches(_ML), Inches(st.y), Inches(_USABLE_W), Inches(height))
    tf = box.text_frame
    tf.word_wrap = True
    first = True
    link_run = None
    for idx, ln in enumerate(lines):
        p = tf.paragraphs[0] if first else tf.add_paragraph()
        first = False
        r0 = p.add_run()
        r0.text = "•  " if idx == 0 else "   "
        r0.font.size = Pt(fs)
        r0.font.color.rgb = _rgb(_LINK)
        run = p.add_run()
        run.text = ln
        run.font.size = Pt(fs)
        run.font.color.rgb = _rgb(_LINK)
        if idx == 0:
            link_run = run
    if target_id and link_run is not None:
        st.toc_runs.append((target_id, link_run, st.slide))
    st.y += height


_PLACERS = {
    "heading": _place_heading,
    "markdown": _place_markdown,
    "kv_table": _place_kv_table,
    "data_table": _place_data_table,
    "figure": _place_figure,
    "image": _place_image,
    "caption": _place_caption,
    "note": _place_note,
    "group": _place_group,
    "glossary_entry": _place_glossary_entry,
    "toc_entry": _place_toc_entry,
}


def render_pptx(chapters: list, out_path: str, meta: dict = None) -> dict:
    """Render a list of Chapters into a 16:9 PPTX deck. Never raises.

    Returns ``{path, n_slides, chapters, note}`` where ``chapters`` is a list of
    ``{id, version, n_slides}`` for the manifest. On a fatal error ``path`` is
    None and ``note`` explains why (e.g. python-pptx not installed).
    """
    meta = meta or {}
    if not _PPTX_OK:
        return {"path": None, "n_slides": 0, "chapters": [],
                "note": f"python-pptx no disponible: {_PPTX_ERR}"}

    chapters = model.as_chapters(chapters)
    notes = []
    try:
        parent = os.path.dirname(os.path.abspath(out_path))
        os.makedirs(parent, exist_ok=True)
    except OSError as e:
        return {"path": None, "n_slides": 0, "chapters": [],
                "note": f"no se pudo crear el directorio destino: {e}"}

    title = meta.get("title") or model.ENGINE_NAME
    chapters_meta = []
    try:
        prs = Presentation()
        prs.slide_width = Inches(_W)
        prs.slide_height = Inches(_H)
        st = _PptxState(prs, title)
        for ch in chapters:
            st.chapter = ch
            st.chapter_slides = 0
            _new_slide(st, cont=False)
            # Record this chapter's first slide as a link target for the cover
            # index (keyed by id AND title, since the cover only knows titles).
            if ch.id:
                st.chapter_starts[ch.id] = st.slide
            if getattr(ch, "title", ""):
                st.chapter_starts.setdefault(ch.title, st.slide)
            for block in ch.blocks:
                placer = _PLACERS.get(getattr(block, "kind", ""), _place_note)
                try:
                    placer(st, block)
                except Exception as e:  # noqa: BLE001
                    notes.append(
                        f"bloque '{getattr(block, 'kind', '?')}' del capítulo "
                        f"'{ch.id}' omitido: {e}")
            chapters_meta.append({"id": ch.id, "version": ch.version,
                                  "n_slides": st.chapter_slides})
        if st.slide_no == 0:
            st.chapter = model.Chapter(id="vacio", title=title,
                                       version=model.ENGINE_VERSION)
            _new_slide(st, cont=False)
            _place_note(st, model.Note(
                "(documento vacío — sin capítulos aplicables)"))
        # Mejora 6 — wire clickable glossary terms to their entry slide (native
        # PowerPoint slide-jump). Delegated registry function; degrades silently.
        n_links = _wire_glossary_links(st, notes)
        prs.save(out_path)
        n_slides = st.slide_no
    except Exception as e:  # noqa: BLE001
        return {"path": None, "n_slides": 0, "chapters": [],
                "note": f"fallo al escribir el PPTX: {e}"}

    note = f"{n_slides} slides"
    if n_links:
        note += f" · {n_links} enlaces internos"
    if notes:
        note += " · " + "; ".join(notes)
    return {"path": out_path, "n_slides": n_slides, "chapters": chapters_meta,
            "note": note}


def _wire_glossary_links(st: _PptxState, notes: list) -> int:
    """Apply native slide-jumps: glossary terms + the cover index.

    Each in-text glossary term run jumps to its glossary entry slide, and each
    cover ``TocEntry`` run jumps to its chapter's first slide. Returns the total
    number of links applied. A run whose target is its own slide is skipped. Never
    raises."""
    if not (st.term_runs and st.term_anchor_slide) and not (
            st.toc_runs and st.chapter_starts):
        return 0
    try:
        from datascience.pptx_link_run_to_slide import pptx_link_run_to_slide
    except Exception as e:  # noqa: BLE001
        notes.append(f"enlaces internos no aplicados: {e}")
        return 0
    linked = 0
    for key, run, src_slide in st.term_runs:
        tgt = st.term_anchor_slide.get(key)
        if tgt is None or tgt is src_slide:
            continue
        try:
            if pptx_link_run_to_slide(run, src_slide, tgt):
                linked += 1
        except Exception:  # noqa: BLE001 — links are best-effort.
            pass
    # Cover index → chapter first slide (clickable, navigable table of contents).
    for target_id, run, src_slide in st.toc_runs:
        tgt = st.chapter_starts.get(target_id)
        if tgt is None or tgt is src_slide:
            continue
        try:
            if pptx_link_run_to_slide(run, src_slide, tgt):
                linked += 1
        except Exception:  # noqa: BLE001 — links are best-effort.
            pass
    return linked
