#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
AI 生成 PPT 相关：大纲、单页重生成、配图、HTML 演示稿、布局拼装、HTML/大纲转 PPTX。
本模块提供 AIPptMixin，由 AIChatSessionViewSet 混入使用；依赖 view 提供 _parse_body、_get_client、_sse_event。
"""

# ---------- 标准库 ----------
import json
import logging
import os
import re
import uuid
from typing import Any

# ---------- Django ----------
from django.conf import settings
from django.http import FileResponse, StreamingHttpResponse

# ---------- DRF ----------
from rest_framework.decorators import action
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response

# ---------- 本包 ----------
from django_base_ai.utils.ai import ChatMessage
from django_base_ai.utils.ai.wanxiang import generate_image as wanxiang_generate_image
from django_base_ai.utils.html_to_pptx import outline_to_pptx, parse_html_to_outline
from django_base_ai.utils.json_response import DetailResponse, ErrorResponse

logger = logging.getLogger(__name__)


# ---------- PPT 大纲与单页 prompt ----------
PPT_OUTLINE_SYSTEM = """你是一个专业的PPT大纲撰写助手。根据用户给出的主题，生成一份结构清晰、适合直接制作成PPT的大纲。
请严格按照以下JSON数组格式输出，不要添加任何其他说明或markdown标记，只输出一个JSON数组：
[
  {"title": "第1页标题", "points": ["要点1", "要点2"]},
  {"title": "第2页标题", "points": ["要点1", "要点2", "要点3"]}
]
要求：title 为每页PPT的标题（简短有力）；points 为该页下的要点列表（每页2-5条，每条一句话）。"""

PPT_PAGE_SYSTEM = """你是PPT大纲助手。根据整体主题和前后页上下文，只生成「当前这一页」的标题与要点。
请只输出一个JSON对象，不要其他说明或markdown，格式：{"title": "本页标题", "points": ["要点1", "要点2"]}。"""

PPT_IMAGE_SYSTEM = """你是PPT配图助手。根据某一页的标题和要点，生成该页适合的配图描述。
请只输出一个JSON对象，不要其他说明或markdown，格式：
{"image_prompt": "英文短句，用于 DALL-E 等文生图（描述画面、风格）", "image_keywords": ["中文关键词1", "关键词2"], "suggestion": "一句话中文配图建议"}
image_prompt 需为英文、简洁、适合作为文生图提示词；image_keywords 为 2-5 个中文关键词，便于在图库搜索。"""

# ---------- HTML 演示稿 prompt 与模板 ----------
PPT_HTML_SYSTEM = """你是一个前端演示文稿专家。根据用户提供的大纲（每页含 title 和 points），生成一份**单文件、零依赖**的 HTML 演示文稿，可直接在浏览器中打开并全屏演示。

## 必须遵守的规范（CRITICAL）

1. **视口适配**：每一页必须刚好一屏，禁止页内滚动。
   - 每个 .slide 使用：width:100vw; height:100vh; height:100dvh; overflow:hidden; scroll-snap-align:start;
   - 根元素：html, body { height:100%; overflow-x:hidden; }；html 上 scroll-snap-type:y mandatory; scroll-behavior:smooth;

2. **版式与字号**：所有字号、间距必须用 clamp() 做响应式，例如：
   - 标题：clamp(1.5rem, 5vw, 4rem)；副标题/正文：clamp(0.75rem, 1.5vw, 1.125rem)；
   - 内边距：--slide-padding: clamp(1rem, 4vw, 4rem)；

3. **结构**：每页一个 <section class="slide">，内部用 <div class="slide-content"> 包内容；每页内容不超过 1 个标题 + 4～6 条要点（多则拆页或精简）。

4. **单文件**：所有 CSS、JS 内联在同一个 HTML 中，不引用外部 CSS/JS 文件；字体可用 CDN（如 Fontshare、Google Fonts）的 link。

5. **交互**：支持键盘左右键/空格翻页、滚轮翻页；可选进度条或右侧导航点；用 Intersection Observer 给当前 slide 加 .visible 类以触发入场动画。

6. **风格**：按用户指定的 style 预设选用配色、字体与入场动画（如 fade+translateY）。可选预设：Bold Signal（深色+高对比）、Dark Botanical（深色+柔和形状）、Swiss Modern（极简线框）、Neon Cyber（霓虹/科技感）、Pastel Geometry（浅色几何）。

7. **无障碍**：支持 prefers-reduced-motion，语义化使用 section/nav；输出完整 <!DOCTYPE html> 且 lang 属性正确。

请**只输出一份完整的 HTML 文档**，从 <!DOCTYPE html> 到 </html>，不要用 markdown 代码块包裹（不要 ```html），不要额外解释。"""

PPT_HTML_ONE_SLIDE_SYSTEM = """你只生成「一页」演示文稿的 HTML 片段，不要整页文档。
输出格式：仅一个 <section class="slide">...</section>，内部包含：
- <div class="slide-content"> 包裹整页内容
- 标题用 <h1 class="slide-title"> 或 <h2 class="slide-title">
- 要点用 <ul class="points-list"><li class="point-item">...</li></ul>
不要输出 <!DOCTYPE>、<html>、<style>、<script>，不要用 markdown 代码块包裹。只输出这一段 section 标签。"""

PPT_HTML_STYLE_PRESETS = [
    {"id": "bold_signal", "name": "Bold Signal", "desc": "深色背景、高对比、自信专业"},
    {"id": "dark_botanical", "name": "Dark Botanical", "desc": "深色、柔和抽象形状、优雅"},
    {"id": "swiss_modern", "name": "Swiss Modern", "desc": "极简、线框、数据感"},
    {"id": "neon_cyber", "name": "Neon Cyber", "desc": "霓虹、科技、未来感"},
    {"id": "pastel_geometry", "name": "Pastel Geometry", "desc": "浅色、几何、友好"},
]

PPT_HTML_DOC_TEMPLATE = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{TITLE}}</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700&display=swap" rel="stylesheet">
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        html { height: 100%; overflow-x: hidden; scroll-snap-type: y mandatory; scroll-behavior: smooth; }
        body { font-family: 'Inter', sans-serif; background: #0c1929; color: #e2e8f0; height: 100%; overflow-x: hidden; line-height: 1.5; }
        .slide { width: 100vw; height: 100vh; height: 100dvh; overflow: hidden; scroll-snap-align: start; display: flex; align-items: center; justify-content: center; gap: clamp(1rem, 4vw, 3rem); padding: clamp(1rem, 4vw, 4rem); background: linear-gradient(135deg, #0c1929 0%, #0f2744 50%, #0c1929 100%); flex-wrap: wrap; }
        .slide-content { flex: 1; min-width: 280px; max-width: 580px; }
        .slide-image { flex: 0 0 auto; width: min(42%, 420px); display: flex; align-items: center; justify-content: center; }
        .slide-image img { max-width: 100%; max-height: min(65vh, 400px); object-fit: contain; border-radius: 8px; }
        .slide-title { font-size: clamp(1.5rem, 5vw, 3.5rem); font-weight: 700; margin-bottom: clamp(1rem, 3vw, 2rem); color: #f0f9ff; }
        .points-list { list-style: none; }
        .point-item { font-size: clamp(0.875rem, 1.8vw, 1.25rem); margin-bottom: clamp(0.4rem, 1vh, 0.75rem); padding-left: 1em; position: relative; color: #cbd5e1; }
        .point-item::before { content: "•"; position: absolute; left: 0; color: #38bdf8; font-weight: bold; }
        @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
    </style>
</head>
<body>
{{SLIDES}}
<script>
(function(){
    document.addEventListener("keydown", function(e) {
        if (e.key === "ArrowRight" || e.key === " ") { e.preventDefault(); window.scrollBy(0, window.innerHeight); }
        if (e.key === "ArrowLeft") { e.preventDefault(); window.scrollBy(0, -window.innerHeight); }
    });
})();
</script>
</body>
</html>"""


# ---------- 布局规范与模板 ----------
PPT_SLIDE_LAYOUT_SCHEMA = {
    "description": "单页 PPT 元素布局，position/size 为相对画布的百分比 0-100",
    "title": {
        "text": "标题文案",
        "position": {"x": 5, "y": 5, "width": 90, "height": 12},
        "font_size": 28,
        "color": "#1a1a1a",
        "align": "left",
    },
    "body": {
        "content": "正文或 points 数组",
        "position": {"x": 5, "y": 18, "width": 90, "height": 55},
        "font_size": 18,
        "color": "#333333",
        "align": "left",
        "line_spacing": 1.2,
    },
    "image": {
        "url": "图片地址，空则占位不显示",
        "position": {"x": 5, "y": 75, "width": 90, "height": 20},
        "fit": "cover",
    },
}

PPT_SLIDE_LAYOUT_TEMPLATES = [
    {
        "id": "title_top_text_left_image_right",
        "name": "标题上、左文右图",
        "title": {"position": {"x": 5, "y": 3, "width": 90, "height": 14}, "font_size": 28, "color": "#1a1a1a", "align": "left"},
        "body": {"position": {"x": 5, "y": 18, "width": 48, "height": 72}, "font_size": 18, "color": "#333333", "align": "left", "line_spacing": 1.2},
        "image": {"position": {"x": 55, "y": 18, "width": 40, "height": 72}, "fit": "cover"},
    },
    {
        "id": "title_top_text_below_image_bottom",
        "name": "标题上、正文中、图片下",
        "title": {"position": {"x": 5, "y": 2, "width": 90, "height": 12}, "font_size": 26, "color": "#1a1a1a", "align": "center"},
        "body": {"position": {"x": 8, "y": 16, "width": 84, "height": 45}, "font_size": 16, "color": "#333333", "align": "left", "line_spacing": 1.25},
        "image": {"position": {"x": 15, "y": 62, "width": 70, "height": 32}, "fit": "contain"},
    },
    {
        "id": "title_center_text_only",
        "name": "仅标题+正文（无图）",
        "title": {"position": {"x": 5, "y": 5, "width": 90, "height": 15}, "font_size": 32, "color": "#1a1a1a", "align": "center"},
        "body": {"position": {"x": 10, "y": 22, "width": 80, "height": 70}, "font_size": 18, "color": "#333333", "align": "left", "line_spacing": 1.2},
        "image": {"position": {"x": 0, "y": 0, "width": 0, "height": 0}, "fit": "cover"},
    },
    {
        "id": "title_left_image_full_right",
        "name": "标题左上、大图占右半",
        "title": {"position": {"x": 5, "y": 5, "width": 45, "height": 12}, "font_size": 24, "color": "#1a1a1a", "align": "left"},
        "body": {"position": {"x": 5, "y": 18, "width": 45, "height": 75}, "font_size": 16, "color": "#333333", "align": "left", "line_spacing": 1.2},
        "image": {"position": {"x": 52, "y": 5, "width": 43, "height": 88}, "fit": "cover"},
    },
]


# ---------- 解析与辅助函数 ----------
def _parse_image_suggestion_from_response(text: str) -> dict[str, Any]:
    raw = (text or "").strip()
    if "```" in raw:
        start = raw.find("```")
        start = raw.find("\n", start) + 1 if raw.find("\n", start) != -1 else start + 3
        end = raw.find("```", start)
        if end != -1:
            raw = raw[start:end]
    try:
        data = json.loads(raw)
        if isinstance(data, dict):
            kw = data.get("image_keywords")
            return {
                "image_prompt": (data.get("image_prompt") or "").strip(),
                "image_keywords": list(kw) if isinstance(kw, list) else [],
                "suggestion": (data.get("suggestion") or "").strip(),
            }
    except json.JSONDecodeError:
        pass
    return {"image_prompt": "", "image_keywords": [], "suggestion": ""}


def _parse_ppt_outline_from_response(text: str) -> list[dict[str, Any]]:
    raw = (text or "").strip()
    if "```" in raw:
        start = raw.find("```")
        start = raw.find("\n", start) + 1 if raw.find("\n", start) != -1 else start + 3
        end = raw.find("```", start)
        if end != -1:
            raw = raw[start:end]
    try:
        data = json.loads(raw)
        if isinstance(data, list):
            result = []
            for x in data:
                if isinstance(x, dict):
                    pts = x.get("points")
                    result.append({
                        "title": (x.get("title") or "").strip(),
                        "points": list(pts) if isinstance(pts, list) else [],
                    })
                else:
                    result.append({"title": str(x), "points": []})
            return result
    except json.JSONDecodeError:
        pass
    return []


def _parse_single_slide_from_response(text: str) -> dict[str, Any]:
    raw = (text or "").strip()
    if "```" in raw:
        start = raw.find("```")
        start = raw.find("\n", start) + 1 if raw.find("\n", start) != -1 else start + 3
        end = raw.find("```", start)
        if end != -1:
            raw = raw[start:end]
    try:
        data = json.loads(raw)
        if isinstance(data, dict):
            pts = data.get("points")
            return {
                "title": (data.get("title") or "").strip(),
                "points": list(pts) if isinstance(pts, list) else [],
            }
    except json.JSONDecodeError:
        pass
    return {"title": "", "points": []}


def _generate_slide_image_url(
    topic: str,
    title: str,
    points: list[str],
    client: Any,
    model: str | None,
    wanxiang_api_key: str | None = None,
    wanxiang_model: str | None = None,
    wanxiang_size: str | None = None,
) -> str | None:
    points_str = "、".join((str(p).strip() for p in (points or [])[:5] if p))
    user_content = f"整体主题：{topic or '无'}\n本页标题：{title}\n本页要点：{points_str}\n请生成适合本页的配图描述与关键词。"
    messages = [
        ChatMessage(role="system", content=PPT_IMAGE_SYSTEM),
        ChatMessage(role="user", content=user_content),
    ]
    try:
        result = client.chat(messages, model=model)
    except Exception as e:
        logger.warning("Generate slide image prompt failed: %s", e)
        return None
    suggestion = _parse_image_suggestion_from_response(result.content or "")
    image_prompt = (suggestion.get("image_prompt") or "").strip() or title
    urls, _ = wanxiang_generate_image(
        prompt=image_prompt,
        api_key=wanxiang_api_key,
        model=wanxiang_model,
        n=1,
        size=wanxiang_size,
    )
    return urls[0] if urls else None


def _inject_slide_image(fragment: str, image_url: str) -> str:
    if not image_url or not fragment.strip():
        return fragment
    safe_url = (image_url or "").replace('"', "&quot;").replace("<", "&lt;").replace(">", "&gt;")
    img_block = f'<div class="slide-image"><img src="{safe_url}" alt=""></div>'
    if "</section>" in fragment:
        return fragment.replace("</section>", img_block + "\n</section>", 1)
    return fragment + img_block


def _parse_single_slide_html_fragment(text: str) -> str:
    raw = (text or "").strip()
    if "```" in raw:
        idx = raw.find("```")
        start = idx + 3
        if raw[start : start + 4].lower() == "html":
            start += 4
        if start < len(raw) and raw[start] == "\n":
            start += 1
        end = raw.find("```", start)
        if end != -1:
            raw = raw[start:end].strip()
    match = re.search(r"<section[^>]*>.*?</section>", raw, re.DOTALL | re.IGNORECASE)
    if match:
        return match.group(0)
    if raw:
        return f'<section class="slide"><div class="slide-content">{raw}</div></section>'
    return '<section class="slide"><div class="slide-content"><h2 class="slide-title">（本页无内容）</h2></div></section>'


def _parse_html_from_response(text: str) -> str:
    raw = (text or "").strip()
    if "```" in raw:
        idx = raw.find("```")
        start = idx + 3
        if raw[start : start + 4].lower() == "html":
            start += 4
        if start < len(raw) and raw[start] == "\n":
            start += 1
        end = raw.find("```", start)
        if end != -1:
            raw = raw[start:end]
    return raw.strip()


def _build_slide_spec(
    title: str,
    points: list[str],
    image_url: str = "",
    template_id: str = "title_top_text_left_image_right",
) -> dict[str, Any]:
    template = next((t for t in PPT_SLIDE_LAYOUT_TEMPLATES if t["id"] == template_id), PPT_SLIDE_LAYOUT_TEMPLATES[0])
    return {
        "title": {"text": title, **{k: v for k, v in template["title"].items() if k != "text"}},
        "body": {"content": "\n".join(points) if isinstance(points, list) else str(points), "points": points if isinstance(points, list) else [], **{k: v for k, v in template["body"].items() if k not in ("content", "points")}},
        "image": {"url": image_url or "", **template["image"]},
        "layout_id": template["id"],
    }


# templates 下 PPT 预览 HTML 子目录（便于用户本地验证）
PPT_TEMPLATES_SUBDIR = "ppt"


def _get_base_dir() -> str:
    base_dir = getattr(settings, "BASE_DIR", None)
    if not base_dir:
        base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
    return os.path.abspath(base_dir)


def _get_slides_dir() -> str:
    media_root = getattr(settings, "MEDIA_ROOT", "") or "media"
    return os.path.join(_get_base_dir(), media_root, "slides")


def _write_ppt_html_to_templates(filename: str, html_content: str) -> str | None:
    """将生成的 PPT HTML 同时写入项目 templates/ppt/ 目录，便于验证测试。返回写入的绝对路径，失败返回 None。"""
    base_dir = _get_base_dir()
    templates_ppt = os.path.join(base_dir, "templates", PPT_TEMPLATES_SUBDIR)
    try:
        os.makedirs(templates_ppt, exist_ok=True)
        path = os.path.join(templates_ppt, filename)
        with open(path, "w", encoding="utf-8") as f:
            f.write(html_content)
        return os.path.abspath(path)
    except OSError as e:
        logger.warning("Write PPT HTML to templates failed: %s", e)
        return None


# ---------- Mixin：依赖 view 提供 _parse_body(request)、_get_client(body)、_sse_event(name, data) ----------
class AIPptMixin:
    """
    AI PPT 流程混入。挂到 AIChatSessionViewSet 使用。
    流程：1 生成大纲 → 2 根据大纲生成每页内容(HTML) → 3 预览 → 4 生成 PPTX → 5 下载 PPT；
    生成 HTML 时同时写入 templates/ppt/ 便于本地验证。
    """

    @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
    def generate_ppt_outline(self, request: Request) -> Response:
        """
        接口1 生成 PPT 大纲。根据主题生成大纲，再传 outline 调 generate_ppt_html 生成每页内容。
        请求体: topic（必填）, slide_count（选填，默认 10）, previous_outline（选填）,
               provider, model, use_settings, api_key, base_url。
        响应: { outline: [{ title, points }, ...], raw, model, usage }。
        """
        body = self._parse_body(request)
        topic = (body.get("topic") or "").strip()
        if not topic:
            return ErrorResponse(msg="topic 不能为空")
        slide_count = 10
        try:
            sc = body.get("slide_count")
            if sc is not None:
                slide_count = max(1, min(int(sc), 30))
        except (TypeError, ValueError):
            pass
        previous_outline = body.get("previous_outline")
        if previous_outline is not None and not isinstance(previous_outline, list):
            previous_outline = None
        client, err = self._get_client(body)
        if err:
            return ErrorResponse(msg=err)
        model = body.get("model")
        system_content = PPT_OUTLINE_SYSTEM + f"\n重要：必须且仅生成 {slide_count} 页的大纲，不要多也不要少，只输出 {slide_count} 个 JSON 对象。"
        if previous_outline:
            prev_str = json.dumps(
                [{"title": x.get("title", ""), "points": x.get("points", [])} for x in previous_outline if isinstance(x, dict)],
                ensure_ascii=False,
            )
            user_content = f"主题：{topic}\n\n用户希望「重新生成」大纲。请在以下已有大纲基础上优化或按主题重新生成（严格保持 {slide_count} 页）：\n{prev_str}"
        else:
            user_content = f"请为以下主题生成一份PPT大纲：\n\n{topic}"
        messages = [
            ChatMessage(role="system", content=system_content),
            ChatMessage(role="user", content=user_content),
        ]
        try:
            result = client.chat(messages, model=model)
        except Exception as e:
            logger.exception("Generate PPT outline failed: %s", e)
            return ErrorResponse(msg=f"生成大纲失败: {e}")
        outline = _parse_ppt_outline_from_response(result.content or "")
        outline = outline[:slide_count]
        return DetailResponse(data={"outline": outline, "raw": result.content or "", "model": result.model or "", "usage": result.usage or {}})

    @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
    def regenerate_ppt_outline_page(self, request: Request) -> Response:
        """
        单页重新生成：只重新生成指定页的 title 与 points。
        请求体: topic（必填）, page_index（必填，从 0 开始）, outline（必填）, provider, model, ...
        响应: { slide: { title, points }, page_index, raw, model, usage }。
        """
        body = self._parse_body(request)
        topic = (body.get("topic") or "").strip()
        if not topic:
            return ErrorResponse(msg="topic 不能为空")
        outline = body.get("outline")
        if not isinstance(outline, list) or len(outline) == 0:
            return ErrorResponse(msg="outline 必填且为非空数组")
        try:
            page_index = int(body.get("page_index", -1))
        except (TypeError, ValueError):
            page_index = -1
        if page_index < 0 or page_index >= len(outline):
            return ErrorResponse(msg="page_index 无效，需在 0 与 outline 长度减 1 之间")
        client, err = self._get_client(body)
        if err:
            return ErrorResponse(msg=err)
        model = body.get("model")
        current = outline[page_index] if isinstance(outline[page_index], dict) else {}
        prev_pages = outline[:page_index]
        next_pages = outline[page_index + 1 :]
        context = f"主题：{topic}。当前为第 {page_index + 1} 页，共 {len(outline)} 页。"
        if prev_pages:
            context += f"\n前一页标题：{prev_pages[-1].get('title', '')}"
        if next_pages:
            context += f"\n后一页标题：{next_pages[0].get('title', '')}"
        user_content = f"{context}\n\n请只重新生成「当前这一页」的标题与要点，与前后页衔接自然。当前页原内容（可参考或替换）：{json.dumps(current, ensure_ascii=False)}"
        messages = [ChatMessage(role="system", content=PPT_PAGE_SYSTEM), ChatMessage(role="user", content=user_content)]
        try:
            result = client.chat(messages, model=model)
        except Exception as e:
            logger.exception("Regenerate PPT page failed: %s", e)
            return ErrorResponse(msg=f"单页重新生成失败: {e}")
        slide = _parse_single_slide_from_response(result.content or "")
        return DetailResponse(data={"slide": slide, "page_index": page_index, "raw": result.content or "", "model": result.model or "", "usage": result.usage or {}})

    @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
    def generate_ppt_slide_image_prompt(self, request: Request) -> Response:
        """
        根据某一页的标题与要点，生成该页配图描述与关键词。
        请求体: topic（选填）, title（必填）, points（选填）, provider, model, ...
        响应: { image_prompt, image_keywords, suggestion, model, usage }。
        """
        body = self._parse_body(request)
        title = (body.get("title") or "").strip()
        if not title:
            return ErrorResponse(msg="title 不能为空")
        topic = (body.get("topic") or "").strip()
        points = body.get("points")
        if not isinstance(points, list):
            points = []
        points_str = "、".join((str(p) for p in points[:5] if p))
        client, err = self._get_client(body)
        if err:
            return ErrorResponse(msg=err)
        model = body.get("model")
        user_content = f"整体主题：{topic or '无'}\n本页标题：{title}\n本页要点：{points_str}\n请生成适合本页的配图描述与关键词。"
        messages = [ChatMessage(role="system", content=PPT_IMAGE_SYSTEM), ChatMessage(role="user", content=user_content)]
        try:
            result = client.chat(messages, model=model)
        except Exception as e:
            logger.exception("Generate PPT image prompt failed: %s", e)
            return ErrorResponse(msg=f"生成配图建议失败: {e}")
        suggestion = _parse_image_suggestion_from_response(result.content or "")
        return DetailResponse(data={**suggestion, "model": result.model or "", "usage": result.usage or {}})

    @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
    def generate_ppt_slide_image(self, request: Request) -> Response:
        """
        根据本页标题与要点生成配图描述并调用通义万相，直接返回图片 URL。
        请求体: topic（选填）, title（必填）, points（选填）, wanxiang_api_key/model/size（选填）。
        响应: { image_url, image_urls, image_prompt, image_keywords, suggestion, error }。
        """
        body = self._parse_body(request)
        title = (body.get("title") or "").strip()
        if not title:
            return ErrorResponse(msg="title 不能为空")
        topic = (body.get("topic") or "").strip()
        points = body.get("points")
        if not isinstance(points, list):
            points = []
        points_str = "、".join((str(p) for p in points[:5] if p))
        client, err = self._get_client(body)
        if err:
            return ErrorResponse(msg=err)
        model = body.get("model")
        user_content = f"整体主题：{topic or '无'}\n本页标题：{title}\n本页要点：{points_str}\n请生成适合本页的配图描述与关键词。"
        messages = [ChatMessage(role="system", content=PPT_IMAGE_SYSTEM), ChatMessage(role="user", content=user_content)]
        try:
            result = client.chat(messages, model=model)
        except Exception as e:
            logger.exception("Generate PPT image prompt failed: %s", e)
            return ErrorResponse(msg=f"生成配图描述失败: {e}")
        suggestion = _parse_image_suggestion_from_response(result.content or "")
        image_prompt = (suggestion.get("image_prompt") or "").strip() or title
        wanxiang_key = (body.get("wanxiang_api_key") or "").strip() or None
        wanxiang_model = (body.get("wanxiang_model") or "").strip() or None
        wanxiang_size = (body.get("wanxiang_size") or "").strip() or None
        urls, img_err = wanxiang_generate_image(prompt=image_prompt, api_key=wanxiang_key, model=wanxiang_model, n=1, size=wanxiang_size)
        return DetailResponse(data={
            "image_url": urls[0] if urls else "",
            "image_urls": urls,
            "image_prompt": image_prompt,
            "image_keywords": suggestion.get("image_keywords", []),
            "suggestion": suggestion.get("suggestion", ""),
            "error": img_err if not urls else None,
        })

    @action(detail=False, methods=["get"], permission_classes=[IsAuthenticated])
    def get_ppt_slide_layout_spec(self, request: Request) -> Response:
        """返回 PPT 单页布局规范与预设模板。响应: { schema, templates }。"""
        return DetailResponse(data={"schema": PPT_SLIDE_LAYOUT_SCHEMA, "templates": PPT_SLIDE_LAYOUT_TEMPLATES})

    @action(detail=False, methods=["get"], permission_classes=[IsAuthenticated])
    def get_ppt_html_styles(self, request: Request) -> Response:
        """返回「AI 生成 HTML 演示文稿」可用的风格预设。响应: { styles }。"""
        return DetailResponse(data={"styles": PPT_HTML_STYLE_PRESETS})

    @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
    def build_ppt_slides(self, request: Request) -> Response:
        """
        根据大纲与每页图片 URL 拼出每页完整布局数据，供前端或导出 PPT 使用。
        请求体: outline（必填）, image_urls（选填）, image_url_by_index（选填）, template_id（选填）。
        响应: { slides: [{ title, body, image, layout_id }, ...] }。
        """
        body = self._parse_body(request)
        outline = body.get("outline")
        if not isinstance(outline, list) or len(outline) == 0:
            return ErrorResponse(msg="outline 必填且为非空数组")
        image_urls = body.get("image_urls")
        if not isinstance(image_urls, list):
            image_urls = []
        url_by_index = body.get("image_url_by_index")
        if not isinstance(url_by_index, dict):
            url_by_index = {}
        template_id = (body.get("template_id") or "title_top_text_left_image_right").strip()
        slides = []
        for i, item in enumerate(outline):
            title = (item.get("title") or "") if isinstance(item, dict) else ""
            points = item.get("points") if isinstance(item, dict) and isinstance(item.get("points"), list) else []
            url = (image_urls[i] if i < len(image_urls) else None) or url_by_index.get(str(i), "") or ""
            if isinstance(url, dict):
                url = url.get("url") or url.get("image_url") or ""
            slides.append(_build_slide_spec(title, points, url, template_id))
        return DetailResponse(data={"slides": slides})

    @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
    def generate_ppt_html(self, request: Request) -> Response:
        """
        接口2 根据大纲生成 PPT 每页内容（HTML 演示稿）。保存到 media/slides/ 并同步写入 templates/ppt/ 便于验证。
        请求体: outline（必填，来自 generate_ppt_outline）, topic（选填）, slide_count, style, stream（0/1）, per_slide, generate_slide_image, also_generate_pptx, template_id, wanxiang_*, ...
        响应: 非流式 { html_url, filename, outline, templates_path?, ... }；流式 SSE：outline → slide → done（含 templates_path）。
        """
        body = self._parse_body(request)
        use_stream = body.get("stream", 0) in (1, "1", True, "true")
        topic = (body.get("topic") or "").strip()
        outline = body.get("outline")
        if not isinstance(outline, list):
            outline = None
        if not outline and not topic:
            return ErrorResponse(msg="topic 与 outline 至少填一项；仅填 topic 时将先生成大纲再生成 HTML")
        slide_count = 10
        try:
            sc = body.get("slide_count")
            if sc is not None:
                slide_count = max(1, min(int(sc), 30))
        except (TypeError, ValueError):
            pass
        style = (body.get("style") or "bold_signal").strip().lower() or "bold_signal"
        if style not in [p["id"] for p in PPT_HTML_STYLE_PRESETS]:
            style = "bold_signal"
        client, err = self._get_client(body)
        if err:
            return ErrorResponse(msg=err)
        model = body.get("model")
        per_slide = body.get("per_slide", True)
        if isinstance(per_slide, str):
            per_slide = str(per_slide).lower() in ("1", "true", "yes")
        generate_slide_image = body.get("generate_slide_image", True)
        if isinstance(generate_slide_image, str):
            generate_slide_image = str(generate_slide_image).lower() in ("1", "true", "yes")
        wanxiang_key = (body.get("wanxiang_api_key") or "").strip() or None
        wanxiang_model_opt = (body.get("wanxiang_model") or "").strip() or None
        wanxiang_size_opt = (body.get("wanxiang_size") or "").strip() or None

        if use_stream and per_slide:
            def _ppt_stream_gen():
                if not outline or len(outline) == 0:
                    if not topic:
                        yield self._sse_event("error", {"message": "topic 与 outline 至少填一项"}).encode("utf-8")
                        return
                    system_content = PPT_OUTLINE_SYSTEM + f"\n重要：必须且仅生成 {slide_count} 页的大纲，只输出 {slide_count} 个 JSON 对象。"
                    user_content = f"请为以下主题生成一份PPT大纲：\n\n{topic}"
                    messages = [ChatMessage(role="system", content=system_content), ChatMessage(role="user", content=user_content)]
                    try:
                        result = client.chat(messages, model=model)
                    except Exception as e:
                        logger.exception("Generate PPT outline (stream) failed: %s", e)
                        yield self._sse_event("error", {"message": f"生成大纲失败: {e}"}).encode("utf-8")
                        return
                    outline = _parse_ppt_outline_from_response(result.content or "")
                    outline = outline[:slide_count]
                if not outline:
                    yield self._sse_event("error", {"message": "未能得到有效大纲，请重试或直接传入 outline"}).encode("utf-8")
                    return
                template_id = (body.get("template_id") or "title_top_text_left_image_right").strip()
                outline_payload = [{"title": (x.get("title") or "").strip(), "points": x.get("points") or [], "image_url": ""} for x in outline if isinstance(x, dict)]
                yield self._sse_event("outline", {"outline": outline_payload}).encode("utf-8")
                fragments = []
                for idx, item in enumerate(outline):
                    if not isinstance(item, dict):
                        continue
                    title = (item.get("title") or "").strip() or f"第 {idx + 1} 页"
                    points = item.get("points")
                    if not isinstance(points, list):
                        points = []
                    points_str = "、".join((str(p).strip() for p in points[:6] if p))
                    user_content = f"主题：{topic or '未指定'}\n风格：{style}\n本页标题：{title}\n本页要点：{points_str}\n请只输出这一页的 <section class=\"slide\">...</section> 片段。"
                    messages = [ChatMessage(role="system", content=PPT_HTML_ONE_SLIDE_SYSTEM), ChatMessage(role="user", content=user_content)]
                    try:
                        result = client.chat(messages, model=model, max_tokens=2048)
                    except Exception as e:
                        logger.exception("Generate PPT HTML slide %s (stream) failed: %s", idx + 1, e)
                        yield self._sse_event("error", {"message": f"生成第 {idx + 1} 页失败: {e}"}).encode("utf-8")
                        continue
                    frag = _parse_single_slide_html_fragment(result.content or "")
                    img_url = ""
                    if generate_slide_image:
                        img_url = _generate_slide_image_url(topic or "", title, points, client, model, wanxiang_api_key=wanxiang_key, wanxiang_model=wanxiang_model_opt, wanxiang_size=wanxiang_size_opt) or ""
                        if img_url:
                            frag = _inject_slide_image(frag, img_url)
                    item["image_url"] = img_url
                    slide_spec = _build_slide_spec(title, points, img_url, template_id)
                    yield self._sse_event("slide", {"index": idx, "title": title, "points": points, "image_url": img_url, "slide_spec": slide_spec}).encode("utf-8")
                    fragments.append(frag)
                doc_title = (topic or "演示文稿").strip()[:80]
                html_content = PPT_HTML_DOC_TEMPLATE.replace("{{TITLE}}", doc_title).replace("{{SLIDES}}", "\n".join(fragments))
                slides_dir = _get_slides_dir()
                try:
                    os.makedirs(slides_dir, exist_ok=True)
                except OSError:
                    yield self._sse_event("error", {"message": "创建演示文稿目录失败"}).encode("utf-8")
                    return
                filename = f"{uuid.uuid4().hex}.html"
                file_path = os.path.join(slides_dir, filename)
                try:
                    with open(file_path, "w", encoding="utf-8") as f:
                        f.write(html_content)
                except OSError:
                    yield self._sse_event("error", {"message": "写入演示文稿文件失败"}).encode("utf-8")
                    return
                templates_path = _write_ppt_html_to_templates(filename, html_content)
                media_root = getattr(settings, "MEDIA_ROOT", "") or "media"
                media_url = (getattr(settings, "MEDIA_URL", "") or "media/").strip().rstrip("/")
                html_url = f"/{media_url}/slides/{filename}".replace("//", "/")
                saved_path_rel = os.path.join(media_root, "slides", filename).replace("\\", "/")
                saved_path_abs = os.path.abspath(file_path)
                data = {"html_url": html_url, "filename": filename, "outline": outline, "slide_count": len(outline), "style": style, "saved_path": saved_path_rel, "saved_path_abs": saved_path_abs}
                if templates_path:
                    data["templates_path"] = templates_path
                also_pptx = body.get("also_generate_pptx", False)
                if isinstance(also_pptx, str):
                    also_pptx = str(also_pptx).lower() in ("1", "true", "yes")
                if also_pptx:
                    pptx_filename = f"{uuid.uuid4().hex}.pptx"
                    pptx_path = os.path.join(slides_dir, pptx_filename)
                    try:
                        outline_to_pptx(outline, pptx_path)
                        data["pptx_url"] = f"/{media_url}/slides/{pptx_filename}".replace("//", "/")
                        data["pptx_filename"] = pptx_filename
                    except Exception as e:
                        logger.exception("Also generate pptx (stream) failed: %s", e)
                yield self._sse_event("done", data).encode("utf-8")

            return StreamingHttpResponse(_ppt_stream_gen(), content_type="text/event-stream; charset=utf-8")

        if not outline or len(outline) == 0:
            system_content = PPT_OUTLINE_SYSTEM + f"\n重要：必须且仅生成 {slide_count} 页的大纲，只输出 {slide_count} 个 JSON 对象。"
            user_content = f"请为以下主题生成一份PPT大纲：\n\n{topic}"
            messages = [ChatMessage(role="system", content=system_content), ChatMessage(role="user", content=user_content)]
            try:
                result = client.chat(messages, model=model)
            except Exception as e:
                logger.exception("Generate PPT outline for HTML failed: %s", e)
                return ErrorResponse(msg=f"生成大纲失败: {e}")
            outline = _parse_ppt_outline_from_response(result.content or "")
            outline = outline[:slide_count]
        if not outline:
            return ErrorResponse(msg="未能得到有效大纲，请重试或直接传入 outline")

        if per_slide:
            fragments = []
            for idx, item in enumerate(outline):
                if not isinstance(item, dict):
                    continue
                title = (item.get("title") or "").strip() or f"第 {idx + 1} 页"
                points = item.get("points")
                if not isinstance(points, list):
                    points = []
                points_str = "、".join((str(p).strip() for p in points[:6] if p))
                user_content = f"主题：{topic or '未指定'}\n风格：{style}\n本页标题：{title}\n本页要点：{points_str}\n请只输出这一页的 <section class=\"slide\">...</section> 片段。"
                messages = [ChatMessage(role="system", content=PPT_HTML_ONE_SLIDE_SYSTEM), ChatMessage(role="user", content=user_content)]
                try:
                    result = client.chat(messages, model=model, max_tokens=2048)
                except Exception as e:
                    logger.exception("Generate PPT HTML slide %s failed: %s", idx + 1, e)
                    return ErrorResponse(msg=f"生成第 {idx + 1} 页失败: {e}")
                frag = _parse_single_slide_html_fragment(result.content or "")
                img_url = ""
                if generate_slide_image:
                    img_url = _generate_slide_image_url(topic or "", title, points, client, model, wanxiang_api_key=wanxiang_key, wanxiang_model=wanxiang_model_opt, wanxiang_size=wanxiang_size_opt) or ""
                    if img_url:
                        frag = _inject_slide_image(frag, img_url)
                item["image_url"] = img_url
                fragments.append(frag)
            doc_title = (topic or "演示文稿").strip()[:80]
            html_content = PPT_HTML_DOC_TEMPLATE.replace("{{TITLE}}", doc_title).replace("{{SLIDES}}", "\n".join(fragments))
        else:
            outline_str = json.dumps([{"title": x.get("title", ""), "points": x.get("points", [])} for x in outline if isinstance(x, dict)], ensure_ascii=False)
            user_content = f"主题：{topic or '未指定'}\n风格预设：{style}\n请根据以下大纲生成完整单文件 HTML 演示文稿（严格遵守视口与版式规范）：\n{outline_str}"
            messages = [ChatMessage(role="system", content=PPT_HTML_SYSTEM), ChatMessage(role="user", content=user_content)]
            max_tokens = 8192
            try:
                mt = body.get("max_tokens")
                if mt is not None:
                    max_tokens = max(1024, min(int(mt), 65536))
            except (TypeError, ValueError):
                pass
            try:
                result = client.chat(messages, model=model, max_tokens=max_tokens)
            except Exception as e:
                logger.exception("Generate PPT HTML failed: %s", e)
                return ErrorResponse(msg=f"生成 HTML 失败: {e}")
            html_raw = _parse_html_from_response(result.content or "")
            if not html_raw or ("<!DOCTYPE" not in html_raw.upper() and "<html" not in html_raw.lower()):
                return ErrorResponse(msg="AI 未返回有效 HTML 文档，请重试")
            html_content = html_raw

        slides_dir = _get_slides_dir()
        try:
            os.makedirs(slides_dir, exist_ok=True)
        except OSError as e:
            logger.exception("Create slides dir failed: %s", e)
            return ErrorResponse(msg="创建演示文稿目录失败")
        filename = f"{uuid.uuid4().hex}.html"
        file_path = os.path.join(slides_dir, filename)
        try:
            with open(file_path, "w", encoding="utf-8") as f:
                f.write(html_content)
        except OSError as e:
            logger.exception("Write PPT HTML failed: %s", e)
            return ErrorResponse(msg="写入演示文稿文件失败")
        templates_path = _write_ppt_html_to_templates(filename, html_content)
        media_root = getattr(settings, "MEDIA_ROOT", "") or "media"
        media_url = (getattr(settings, "MEDIA_URL", "") or "media/").strip().rstrip("/")
        html_url = f"/{media_url}/slides/{filename}".replace("//", "/")
        saved_path_rel = os.path.join(media_root, "slides", filename).replace("\\", "/")
        saved_path_abs = os.path.abspath(file_path)
        data = {"html_url": html_url, "filename": filename, "outline": outline, "slide_count": len(outline), "style": style, "saved_path": saved_path_rel, "saved_path_abs": saved_path_abs}
        if templates_path:
            data["templates_path"] = templates_path
        also_pptx = body.get("also_generate_pptx", False)
        if isinstance(also_pptx, str):
            also_pptx = str(also_pptx).lower() in ("1", "true", "yes")
        if also_pptx:
            pptx_filename = f"{uuid.uuid4().hex}.pptx"
            pptx_path = os.path.join(slides_dir, pptx_filename)
            try:
                outline_to_pptx(outline, pptx_path)
                data["pptx_url"] = f"/{media_url}/slides/{pptx_filename}".replace("//", "/")
                data["pptx_filename"] = pptx_filename
            except Exception as e:
                logger.exception("Also generate pptx failed: %s", e)
        return DetailResponse(data=data)

