"""使用 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.util import Inches

MAX_SHEETS = 20
MAX_ROWS_PER_SHEET = 5000
MAX_COLS = 256
MAX_SLIDES = 40
MAX_BULLETS = 24
_RASTER_IMG_EXT = {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tif", ".tiff"}


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],
    }


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``.
    """
    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

        img_rel = spec.get("image_path")
        if img_rel:
            ip = Path(str(img_rel)).expanduser().resolve()
            policy_check(ip)
            if not ip.is_file():
                return {"ok": False, "error": f"image not found: {ip}"}
            suf = ip.suffix.lower()
            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)}",
                }
            left = float(spec.get("image_left_in", 0.5))
            top = float(spec.get("image_top_in", 4.0))
            width = spec.get("image_width_in")
            if width is None:
                width = min(4.0, prs.slide_width / 914400 - left - 0.5)
            else:
                width = float(width)
            slide.shapes.add_picture(str(ip), Inches(left), Inches(top), width=Inches(width))

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