"""使用 openpyxl / python-pptx 写入 xlsx、pptx。"""

from __future__ import annotations

from pathlib import Path
from typing import Any, Callable

from openpyxl import Workbook, load_workbook
from pptx import Presentation
from pptx.chart.data import CategoryChartData
from pptx.dml.color import RGBColor
from pptx.enum.chart import XL_CHART_TYPE
from pptx.enum.shapes import MSO_SHAPE
from pptx.oxml import parse_xml
from pptx.oxml.ns import qn
from pptx.util import Inches, Pt

MAX_SHEETS = 20
MAX_ROWS_PER_SHEET = 5000
MAX_COLS = 256
MAX_SLIDES = 40
MAX_BULLETS = 24
MAX_PPTX_DECOR_SHAPES = 16
MAX_PPTX_TABLE_ROWS = 24
MAX_PPTX_TABLE_COLS = 16
MAX_PPTX_CHART_SERIES = 6
MAX_PPTX_CHART_CATEGORIES = 32
MAX_PPTX_TEXT_BOXES = 12
MAX_PPTX_IMAGES_PER_SLIDE = 12
MAX_PPTX_SPEAKER_NOTES_CHARS = 16000
MAX_PPTX_TEXT_BOX_LINES = 32
_RASTER_IMG_EXT = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff"}

_PPTX_SHAPE_ALIASES: dict[str, MSO_SHAPE] = {
    "rectangle": MSO_SHAPE.RECTANGLE,
    "rounded_rectangle": MSO_SHAPE.ROUNDED_RECTANGLE,
    "oval": MSO_SHAPE.OVAL,
}

# Returned by pptx_inspect_template so callers can pick richer auto shapes without reading python-pptx docs.
_PPTX_SHAPE_KIND_EXAMPLES: list[str] = [
    "chevron",
    "diamond",
    "pentagon",
    "hexagon",
    "star_5_point",
    "right_arrow",
    "down_arrow",
    "left_brace",
    "cloud",
    "heart",
    "lightning_bolt",
    "sun",
    "moon",
    "arc",
    "can",
    "cube",
    "folded_corner",
    "plaque",
    "curved_up_ribbon",
    "round_1_rectangle",
]

_PPTX_CHART_TYPES: dict[str, XL_CHART_TYPE] = {
    "column_clustered": XL_CHART_TYPE.COLUMN_CLUSTERED,
    "column_stacked": XL_CHART_TYPE.COLUMN_STACKED,
    "column_stacked_100": XL_CHART_TYPE.COLUMN_STACKED_100,
    "line": XL_CHART_TYPE.LINE_MARKERS,
    "line_stacked": XL_CHART_TYPE.LINE_STACKED,
    "line_stacked_100": XL_CHART_TYPE.LINE_STACKED_100,
    "pie": XL_CHART_TYPE.PIE,
    "bar_clustered": XL_CHART_TYPE.BAR_CLUSTERED,
    "bar_stacked": XL_CHART_TYPE.BAR_STACKED,
    "area": XL_CHART_TYPE.AREA,
    "area_stacked": XL_CHART_TYPE.AREA_STACKED,
    "doughnut": XL_CHART_TYPE.DOUGHNUT,
    "radar": XL_CHART_TYPE.RADAR,
}


def _pptx_mso_shape_from_kind(kind: str) -> MSO_SHAPE | None:
    k = kind.strip()
    if not k:
        return None
    low = k.lower()
    if low in _PPTX_SHAPE_ALIASES:
        return _PPTX_SHAPE_ALIASES[low]
    enum_name = k.upper().replace("-", "_")
    return getattr(MSO_SHAPE, enum_name, None)


def _pptx_slide_add_shapes(slide: Any, shapes_spec: Any) -> str | None:
    """Optional accent shapes; new shapes stack above layout placeholders—keep positions in margins."""
    if shapes_spec is None:
        return None
    if not isinstance(shapes_spec, list):
        return "shapes must be a list"
    if len(shapes_spec) > MAX_PPTX_DECOR_SHAPES:
        return f"max {MAX_PPTX_DECOR_SHAPES} shapes per slide"
    for i, s in enumerate(shapes_spec):
        if not isinstance(s, dict):
            return f"shapes[{i}] must be object"
        kind = str(s.get("kind", "rectangle")).strip()
        mso = _pptx_mso_shape_from_kind(kind)
        if mso is None:
            return (
                f"shapes[{i}].kind {kind!r} unknown — use lowercase aliases "
                f"{sorted(_PPTX_SHAPE_ALIASES)} or any **MSO_SHAPE** enum name "
                f"(e.g. {_PPTX_SHAPE_KIND_EXAMPLES[:8]!r}, …); see **pptx_inspect_template** `shape_kind_examples`."
            )
        try:
            left = Inches(float(s["left_in"]))
            top = Inches(float(s["top_in"]))
            w = Inches(float(s["width_in"]))
            h = Inches(float(s["height_in"]))
        except (KeyError, TypeError, ValueError):
            return f"shapes[{i}] needs numeric left_in, top_in, width_in, height_in"
        shape = slide.shapes.add_shape(mso, left, top, w, h)
        fill = s.get("fill_rgb")
        if fill is not None:
            if (
                not isinstance(fill, (list, tuple))
                or len(fill) != 3
                or not all(isinstance(x, (int, float)) for x in fill)
            ):
                return f"shapes[{i}].fill_rgb must be [R,G,B] integers 0–255"
            r, g, b = (max(0, min(255, int(x))) for x in fill)
            shape.fill.solid()
            shape.fill.fore_color.rgb = RGBColor(r, g, b)
        line = s.get("line_rgb")
        if line is not None:
            if (
                not isinstance(line, (list, tuple))
                or len(line) != 3
                or not all(isinstance(x, (int, float)) for x in line)
            ):
                return f"shapes[{i}].line_rgb must be [R,G,B] integers 0–255"
            r, g, b = (max(0, min(255, int(x))) for x in line)
            shape.line.color.rgb = RGBColor(r, g, b)
    return None


def _pptx_slide_add_table(slide: Any, table_spec: Any) -> str | None:
    if table_spec is None:
        return None
    if not isinstance(table_spec, dict):
        return "table must be object"
    data = table_spec.get("data")
    if not isinstance(data, list) or not data:
        return "table.data must be non-empty list of rows"
    if len(data) > MAX_PPTX_TABLE_ROWS:
        return f"table max {MAX_PPTX_TABLE_ROWS} rows"
    norm: list[list[str]] = []
    cols = 0
    for ri, row in enumerate(data):
        if isinstance(row, (list, tuple)):
            cells = [str(c) if c is not None else "" for c in row]
        else:
            cells = [str(row)]
        cols = max(cols, len(cells))
        norm.append(cells)
    if cols > MAX_PPTX_TABLE_COLS:
        return f"table max {MAX_PPTX_TABLE_COLS} columns"
    for ri, row in enumerate(norm):
        while len(row) < cols:
            row.append("")
        norm[ri] = row[:cols]
    try:
        left = Inches(float(table_spec.get("table_left_in", 0.5)))
        top = Inches(float(table_spec.get("table_top_in", 4.0)))
        tw = float(table_spec.get("table_width_in", 9.0))
        th = float(table_spec.get("table_height_in", 2.0))
    except (TypeError, ValueError):
        return "table needs numeric table_left_in, table_top_in, table_width_in, table_height_in"
    rows_n = len(norm)
    gf = slide.shapes.add_table(rows_n, cols, left, top, Inches(tw), Inches(th))
    tbl = gf.table
    for ri in range(rows_n):
        for ci in range(cols):
            tbl.cell(ri, ci).text = norm[ri][ci]
    return None


def _pptx_slide_add_chart(slide: Any, chart_spec: Any) -> str | None:
    if chart_spec is None:
        return None
    if not isinstance(chart_spec, dict):
        return "chart must be object"
    ct_key = str(chart_spec.get("chart_type", "column_clustered")).lower().replace("-", "_")
    xl_type = _PPTX_CHART_TYPES.get(ct_key)
    if xl_type is None:
        return f"chart.chart_type {ct_key!r} unknown; use {sorted(_PPTX_CHART_TYPES)}"
    categories = chart_spec.get("categories") or []
    series_list = chart_spec.get("series") or []
    if not isinstance(categories, list) or not categories:
        return "chart.categories must be non-empty list"
    if len(categories) > MAX_PPTX_CHART_CATEGORIES:
        return f"chart max {MAX_PPTX_CHART_CATEGORIES} categories"
    if not isinstance(series_list, list) or not series_list:
        return "chart.series must be non-empty list of {name, values}"
    if ct_key in ("pie", "doughnut") and len(series_list) != 1:
        return "pie and doughnut charts require exactly one entry in chart.series"
    if len(series_list) > MAX_PPTX_CHART_SERIES:
        return f"chart max {MAX_PPTX_CHART_SERIES} series"
    n = len(categories)
    chart_data = CategoryChartData()
    chart_data.categories = [str(c) for c in categories]
    for j, ser in enumerate(series_list):
        if not isinstance(ser, dict):
            return f"chart.series[{j}] must be object"
        name = str(ser.get("name") or f"Series {j + 1}")
        vals = ser.get("values")
        if not isinstance(vals, (list, tuple)):
            return f"chart.series[{j}].values must be list of numbers"
        if len(vals) != n:
            return f"chart.series[{j!r}].values length must match categories ({n})"
        chart_data.add_series(name, tuple(vals))
    try:
        left = Inches(float(chart_spec.get("chart_left_in", 0.5)))
        top = Inches(float(chart_spec.get("chart_top_in", 1.5)))
        cw = float(chart_spec.get("chart_width_in", 9.0))
        ch = float(chart_spec.get("chart_height_in", 4.5))
    except (TypeError, ValueError):
        return "chart needs numeric chart_left_in, chart_top_in, chart_width_in, chart_height_in"
    gf = slide.shapes.add_chart(xl_type, left, top, Inches(cw), Inches(ch), chart_data)
    chart = gf.chart
    ctitle = chart_spec.get("chart_title")
    if ctitle is not None and str(ctitle).strip():
        chart.has_title = True
        chart.chart_title.text_frame.text = str(ctitle)
    return None


def _pptx_slide_add_text_boxes(slide: Any, spec_list: Any) -> str | None:
    if spec_list is None:
        return None
    if not isinstance(spec_list, list):
        return "text_boxes must be a list"
    if len(spec_list) > MAX_PPTX_TEXT_BOXES:
        return f"max {MAX_PPTX_TEXT_BOXES} text_boxes per slide"
    for i, s in enumerate(spec_list):
        if not isinstance(s, dict):
            return f"text_boxes[{i}] must be object"
        try:
            left = Inches(float(s["left_in"]))
            top = Inches(float(s["top_in"]))
            w = Inches(float(s["width_in"]))
            h = Inches(float(s["height_in"]))
        except (KeyError, TypeError, ValueError):
            return f"text_boxes[{i}] needs left_in, top_in, width_in, height_in (numbers)"
        text = s.get("text")
        lines = s.get("lines")
        if lines is not None:
            if not isinstance(lines, list):
                return f"text_boxes[{i}].lines must be list of strings"
            lines = [str(x) for x in lines[:MAX_PPTX_TEXT_BOX_LINES]]
            body = "\n".join(lines) if lines else ""
        elif text is not None:
            body = str(text)
        else:
            return f"text_boxes[{i}] needs text or lines[]"
        box = slide.shapes.add_textbox(left, top, w, h)
        tf = box.text_frame
        tf.clear()
        tf.word_wrap = True
        parts = body.split("\n")
        if parts:
            tf.text = parts[0]
            for line in parts[1:]:
                p = tf.add_paragraph()
                p.text = line
        fp = s.get("font_pt")
        if fp is not None:
            try:
                pt = float(fp)
            except (TypeError, ValueError):
                return f"text_boxes[{i}].font_pt must be a number"
            for para in tf.paragraphs:
                para.font.size = Pt(pt)
        if s.get("bold") is True:
            for para in tf.paragraphs:
                para.font.bold = True
    return None


def _pptx_picture_err(ip: Path, suf: str) -> dict[str, Any] | None:
    if suf in {".html", ".htm"}:
        return {
            "ok": False,
            "error": "image_path cannot be .html — PowerPoint needs a raster file (.png/.jpg/…).",
            "hint": "Call **viz_html_to_png** with the same html_path (the .html and res/ stay on disk), "
            "then set image_path to the returned png_path.",
        }
    if suf not in _RASTER_IMG_EXT:
        return {
            "ok": False,
            "error": f"unsupported image extension {suf!r}; use {sorted(_RASTER_IMG_EXT)}",
        }
    return None


def _pptx_slide_add_pictures(
    slide: Any,
    prs: Any,
    pics: list[dict[str, Any]],
    policy_check: Callable[[Path], None],
) -> dict[str, Any] | str | None:
    """Return error dict for fatal tool shape, or str for simple error, or None."""
    if not pics:
        return None
    if len(pics) > MAX_PPTX_IMAGES_PER_SLIDE:
        return f"max {MAX_PPTX_IMAGES_PER_SLIDE} images per slide (images[] + image_path)"
    slide_w_in = prs.slide_width / 914400
    for i, pic in enumerate(pics):
        if not isinstance(pic, dict):
            return f"images[{i}] must be object"
        raw = pic.get("image_path")
        if not raw:
            return f"images[{i}].image_path required"
        ip = Path(str(raw)).expanduser().resolve()
        policy_check(ip)
        if not ip.is_file():
            return f"image not found: {ip}"
        suf = ip.suffix.lower()
        pe = _pptx_picture_err(ip, suf)
        if pe is not None:
            return pe
        try:
            left = float(pic.get("image_left_in", 0.5))
            top = float(pic.get("image_top_in", 4.0))
            width = pic.get("image_width_in")
            if width is None:
                width = min(4.0, slide_w_in - left - 0.5)
            else:
                width = float(width)
            height = pic.get("image_height_in")
        except (TypeError, ValueError):
            return f"images[{i}] needs numeric image_left_in, image_top_in, image_width_in"
        kw: dict[str, Any] = {"width": Inches(width)}
        if height is not None:
            kw["height"] = Inches(float(height))
        slide.shapes.add_picture(str(ip), Inches(left), Inches(top), **kw)
    return None


def _pptx_collect_slide_picture_specs(spec: dict[str, Any]) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    for im in spec.get("images") or []:
        if isinstance(im, dict) and im.get("image_path"):
            out.append(dict(im))
    if spec.get("image_path"):
        out.append(
            {
                "image_path": spec["image_path"],
                "image_left_in": spec.get("image_left_in", 0.5),
                "image_top_in": spec.get("image_top_in", 4.0),
                "image_width_in": spec.get("image_width_in"),
                "image_height_in": spec.get("image_height_in"),
            }
        )
    return out


def _pptx_slide_set_speaker_notes(slide: Any, notes_spec: Any) -> str | None:
    if notes_spec is None:
        return None
    text = str(notes_spec).strip() if not isinstance(notes_spec, str) else notes_spec.strip()
    if not text:
        return None
    if len(text) > MAX_PPTX_SPEAKER_NOTES_CHARS:
        return f"speaker_notes max {MAX_PPTX_SPEAKER_NOTES_CHARS} characters"
    ns = slide.notes_slide
    ntf = ns.notes_text_frame
    if ntf is None:
        return "notes master has no notes body placeholder; cannot set speaker_notes"
    ntf.text = text
    return None


def _pptx_slide_set_transition(slide: Any, tr_spec: Any) -> str | None:
    """Slide transition (between-slide), not per-shape animation."""
    if tr_spec is None:
        return None
    if not isinstance(tr_spec, dict):
        return "slide_transition must be object"
    effect = str(tr_spec.get("effect", "fade")).lower().replace("-", "_")
    speed = str(tr_spec.get("speed", "med")).lower()
    if speed not in {"slow", "med", "fast"}:
        return "slide_transition.speed must be slow|med|fast"
    direction = str(tr_spec.get("direction", "l")).lower()
    if direction not in {"l", "r", "u", "d"}:
        return "slide_transition.direction must be l|r|u|d"
    orient = str(tr_spec.get("orient", "horz")).lower()
    if orient not in {"horz", "vert"}:
        return "slide_transition.orient must be horz|vert (for split)"

    if effect == "fade":
        inner = "<p:fade/>"
    elif effect == "cut":
        inner = "<p:cut/>"
    elif effect == "push":
        inner = f'<p:push dir="{direction}"/>'
    elif effect == "wipe":
        inner = f'<p:wipe dir="{direction}"/>'
    elif effect == "split":
        inner = f'<p:split orient="{orient}"/>'
    elif effect in ("random_bar", "randombar"):
        inner = "<p:randomBar/>"
    elif effect == "cover":
        inner = f'<p:cover dir="{direction}"/>'
    elif effect == "uncover":
        inner = f'<p:uncover dir="{direction}"/>'
    else:
        return (
            "slide_transition.effect unknown; use fade|cut|push|wipe|split|cover|uncover|random_bar "
            "(push/wipe/cover/uncover use direction; split uses orient)"
        )

    ns_p = "http://schemas.openxmlformats.org/presentationml/2006/main"
    xml = f'<p:transition xmlns:p="{ns_p}" spd="{speed}">{inner}</p:transition>'
    try:
        trans_el = parse_xml(xml)
    except Exception as e:  # noqa: BLE001
        return f"slide_transition XML error: {e!r}"

    el = slide._element
    q_tr = qn("p:transition")
    for child in list(el):
        if child.tag == q_tr:
            el.remove(child)
    anchor = el.clrMapOvr
    if anchor is None:
        anchor = el.cSld
    anchor.addnext(trans_el)
    return None


def _policy_save(path: Path, policy_check: Callable[[Path], None]) -> None:
    path = path.expanduser().resolve()
    policy_check(path)
    path.parent.mkdir(parents=True, exist_ok=True)


def xlsx_write_workbook(
    path: Path,
    sheets: list[dict[str, Any]],
    *,
    policy_check: Callable[[Path], None],
    append: bool = False,
) -> dict[str, Any]:
    """
    写入或追加 Excel。

    ``sheets`` 每项: ``name`` (str), ``headers`` (list, 可选), ``rows`` (list[list]).
    """
    if not isinstance(sheets, list) or not sheets:
        return {"ok": False, "error": "sheets must be non-empty list"}
    if len(sheets) > MAX_SHEETS:
        return {"ok": False, "error": f"max {MAX_SHEETS} sheets"}

    path = path.expanduser().resolve()
    _policy_save(path, policy_check)

    if append and path.is_file():
        wb = load_workbook(path)
    else:
        wb = Workbook()

    used_default_sheet = False

    for si, spec in enumerate(sheets):
        if not isinstance(spec, dict):
            return {"ok": False, "error": "each sheet must be object"}
        name = spec.get("name") or "Sheet"
        if not isinstance(name, str):
            return {"ok": False, "error": "sheet name must be string"}
        name = name[:31] if len(name) > 31 else name
        headers = spec.get("headers")
        rows = spec.get("rows") or []
        if headers is not None and not isinstance(headers, (list, tuple)):
            return {"ok": False, "error": "headers must be list"}
        if not isinstance(rows, list):
            return {"ok": False, "error": "rows must be list"}
        if len(rows) > MAX_ROWS_PER_SHEET:
            return {"ok": False, "error": f"max {MAX_ROWS_PER_SHEET} rows per sheet"}

        if name in wb.sheetnames:
            ws = wb[name]
            mr = ws.max_row or 0
            if mr:
                ws.delete_rows(1, mr)
        elif (
            not used_default_sheet
            and len(wb.sheetnames) == 1
            and wb.active.title == "Sheet"
        ):
            ws = wb.active
            ws.title = name
            used_default_sheet = True
        else:
            ws = wb.create_sheet(title=name)

        if headers:
            if len(headers) > MAX_COLS:
                return {"ok": False, "error": f"max {MAX_COLS} columns"}
            ws.append(list(headers))
        for row in rows:
            if not isinstance(row, (list, tuple)):
                return {"ok": False, "error": "each row must be list"}
            if len(row) > MAX_COLS:
                return {"ok": False, "error": f"max {MAX_COLS} columns"}
            ws.append(list(row))

    wb.save(path)
    return {"ok": True, "path": str(path), "sheet_names": wb.sheetnames}


def pptx_inspect_template(
    path: Path,
    *,
    policy_check: Callable[[Path], None],
) -> dict[str, Any]:
    """列出模板中的版式与占位符，供 AI 选择 layout_index。"""
    path = path.expanduser().resolve()
    policy_check(path)
    if not path.is_file() or path.suffix.lower() != ".pptx":
        return {"ok": False, "error": "path must be .pptx file"}

    prs = Presentation(str(path))
    layouts: list[dict[str, Any]] = []
    for i, layout in enumerate(prs.slide_layouts):
        phs: list[dict[str, Any]] = []
        try:
            for shp in layout.placeholders:
                try:
                    idx = shp.placeholder_format.idx
                    ph_type = int(shp.placeholder_format.type)
                except Exception:
                    idx, ph_type = -1, -1
                phs.append({"idx": idx, "type": ph_type, "name": getattr(shp, "name", "")})
        except Exception:
            pass
        layouts.append({"index": i, "name": layout.name, "placeholders": phs})

    return {
        "ok": True,
        "path": str(path),
        "slide_width_in": prs.slide_width / 914400 * 1.0,
        "slide_height_in": prs.slide_height / 914400 * 1.0,
        "layout_count": len(layouts),
        "layouts": layouts[:40],
        "shape_kind_examples": list(_PPTX_SHAPE_KIND_EXAMPLES),
        "note": "pptx_compose shapes[].kind accepts these examples or any python-pptx MSO_SHAPE name "
        "(e.g. STAR_10_POINT). Per-shape entrance animations are not supported by python-pptx—use "
        "slide_transition and/or a hand-built template with animation on placeholders.",
    }


def pptx_compose(
    path_out: Path,
    slides: list[dict[str, Any]],
    *,
    policy_check: Callable[[Path], None],
    template_path: Path | None = None,
) -> dict[str, Any]:
    """
    从空白或模板生成 PPTX。

    每项 slide: ``layout_index`` (int, 默认 1), ``title`` (str), ``bullets`` (list[str]),
    可选 ``image_path`` (相对工作区或绝对，须通过策略), ``image_left_in``, ``image_top_in``, ``image_width_in``.

    丰富版式（可选，均为英寸）：

    - ``shapes``: 装饰几何，列表项含 ``kind`` (rectangle|rounded_rectangle|oval)、
      ``left_in`` / ``top_in`` / ``width_in`` / ``height_in``，可选 ``fill_rgb`` / ``line_rgb`` 为 ``[R,G,B]``。
      新图形叠在版式占位符之上，请放在页边或留白区，避免挡住标题正文。
    - ``table``: ``data`` 为二维字符串列表；``table_left_in`` / ``table_top_in`` / ``table_width_in`` / ``table_height_in``。
    - ``chart``: ``chart_type`` 含 column/bar/line/area 的 clustered|stacked|stacked_100、doughnut、radar 等；
      ``categories``、``series``（``name`` + ``values``）；**pie / doughnut** 仅允许 **一个** series。
    - ``images``: 多图列表，每项 ``image_path``、``image_left_in``、``image_top_in``、``image_width_in``、
      可选 ``image_height_in``；与单字段 ``image_path`` 可同时使用，合计不超过每页上限。
    - ``text_boxes``: 自由文本框列表（``left_in``…``height_in`` + ``text`` 或 ``lines``），可选 ``font_pt``、``bold``。
    - ``speaker_notes``: 字符串，写入演讲者备注（受长度上限）。
    - ``slide_transition``: 片间切换（非形状动画）— ``effect`` fade|cut|push|wipe|split|cover|uncover|random_bar，
      ``speed`` slow|med|fast，``direction`` l|r|u|d（push/wipe/cover/uncover），``split`` 用 ``orient`` horz|vert。
    - ``shapes[].kind``：除 rectangle/rounded_rectangle/oval 外，可使用 **pptx_inspect_template** 返回的
      ``shape_kind_examples`` 或任意 **MSO_SHAPE** 枚举名（如 ``CHEVRON``、``STAR_5_POINT``）。
    """
    if not isinstance(slides, list) or not slides:
        return {"ok": False, "error": "slides must be non-empty list"}
    if len(slides) > MAX_SLIDES:
        return {"ok": False, "error": f"max {MAX_SLIDES} slides"}

    path_out = path_out.expanduser().resolve()
    _policy_save(path_out, policy_check)

    if template_path is not None:
        tp = template_path.expanduser().resolve()
        policy_check(tp)
        if not tp.is_file() or tp.suffix.lower() != ".pptx":
            return {"ok": False, "error": "template_path must be .pptx"}
        prs = Presentation(str(tp))
    else:
        prs = Presentation()

    for spec in slides:
        if not isinstance(spec, dict):
            return {"ok": False, "error": "each slide must be object"}
        li = int(spec.get("layout_index", 1))
        if li < 0 or li >= len(prs.slide_layouts):
            return {"ok": False, "error": f"layout_index {li} out of range 0..{len(prs.slide_layouts)-1}"}

        slide_layout = prs.slide_layouts[li]
        slide = prs.slides.add_slide(slide_layout)

        title_text = spec.get("title") or ""
        if isinstance(title_text, str) and slide.shapes.title is not None:
            slide.shapes.title.text = title_text

        bullets = spec.get("bullets") or []
        if isinstance(bullets, str):
            bullets = [bullets]
        if not isinstance(bullets, list):
            return {"ok": False, "error": "bullets must be list or string"}
        bullets = [str(b) for b in bullets[:MAX_BULLETS]]

        title_shape = getattr(slide.shapes, "title", None)
        body_shape = None
        try:
            ph_iter = list(slide.placeholders)
        except Exception:
            ph_iter = []
        for shape in ph_iter:
            if not hasattr(shape, "text_frame"):
                continue
            if title_shape is not None and shape == title_shape:
                continue
            body_shape = shape
            break
        if body_shape is None:
            for shape in slide.shapes:
                if title_shape is not None and shape == title_shape:
                    continue
                if hasattr(shape, "text_frame"):
                    body_shape = shape
                    break

        if body_shape is not None:
            tf = body_shape.text_frame
            tf.clear()
            if bullets:
                tf.text = bullets[0]
                for b in bullets[1:]:
                    p = tf.add_paragraph()
                    p.text = b
                    p.level = 0

        err = _pptx_slide_add_shapes(slide, spec.get("shapes"))
        if err:
            return {"ok": False, "error": err}
        err = _pptx_slide_add_table(slide, spec.get("table"))
        if err:
            return {"ok": False, "error": err}
        err = _pptx_slide_add_chart(slide, spec.get("chart"))
        if err:
            return {"ok": False, "error": err}

        pics = _pptx_collect_slide_picture_specs(spec)
        pic_res = _pptx_slide_add_pictures(slide, prs, pics, policy_check)
        if isinstance(pic_res, dict):
            return pic_res
        if isinstance(pic_res, str):
            return {"ok": False, "error": pic_res}

        err = _pptx_slide_add_text_boxes(slide, spec.get("text_boxes"))
        if err:
            return {"ok": False, "error": err}

        err = _pptx_slide_set_speaker_notes(slide, spec.get("speaker_notes"))
        if err:
            return {"ok": False, "error": err}

        err = _pptx_slide_set_transition(slide, spec.get("slide_transition"))
        if err:
            return {"ok": False, "error": err}

    prs.save(path_out)
    return {"ok": True, "path": str(path_out), "slide_count": len(prs.slides)}
