#!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
@Project ：django_base_ai
@File    ：ai_chat.py
@Desc    ：AI 会话 ViewSet（django.mdc：有对应 Model 用 CustomModelViewSet），支持会话 CRUD 与发消息
"""

# ---------- 标准库 ----------
import base64  # 图片转 base64 用于多模态 content
import json  # 请求体解析、SSE 序列化、PPT 大纲 JSON 解析
import logging  # 本模块日志
import os  # 本地附件路径拼接与文件检查
from typing import Any, Iterator  # 类型注解

# ---------- 第三方 ----------
import requests  # 按 file_urls 拉取远程图片

# ---------- Django ----------
from django.conf import settings  # MEDIA_URL/MEDIA_ROOT、AI_DEFAULT_PROVIDER
from django.db.models import Q  # 查询条件（本文件未直接使用，保留供扩展）
from django.core.files.uploadedfile import UploadedFile  # 判断上传文件类型
from django.http import StreamingHttpResponse  # 流式 SSE 响应
# ---------- DRF ----------
from rest_framework import serializers  # SerializerMethodField 等
from rest_framework.decorators import action  # ViewSet 自定义 action
from rest_framework.permissions import IsAuthenticated  # 需登录
from rest_framework.request import Request  # 请求类型
from rest_framework.response import Response  # 响应类型

# ---------- 本包：模型与 AI 工具 ----------
from django_base_ai.system.models import AIChatMessage, AIChatSession  # 会话与消息表
from django_base_ai.utils.ai import ChatMessage, ChatSession, get_client, get_client_from_settings  # 对话与客户端
from django_base_ai.utils.ai.wanxiang import generate_image as wanxiang_generate_image  # 通义万相文生图
from django_base_ai.utils.json_response import DetailResponse, ErrorResponse  # 统一响应格式
from django_base_ai.utils.xss_cleaned import cleaned_string  # 用户输入 XSS 清洗
from django_base_ai.utils.serializers import CustomModelSerializer  # 基类序列化器
from django_base_ai.utils.viewset import CustomModelViewSet  # 基类 ViewSet

# 本模块日志，便于排查 AI 调用、附件、落库等问题
logger = logging.getLogger(__name__)


# 附件：仅支持图片，单文件最大 5MB，用于多模态（视觉）模型
ALLOWED_IMAGE_CONTENT_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
MAX_ATTACHMENT_SIZE = 5 * 1024 * 1024  # 5MB


def _parse_body(request: Request) -> dict[str, Any]:
    """
    解析 POST 请求体为 dict，无效或非 dict 返回空 dict。
    兼容：application/json 的 dict、raw 字符串、multipart 时的 QueryDict（排除 FILES 键）。
    """
    data = getattr(request, "data", None)
    if data is None:
        return {}
    # 原始字符串按 JSON 解析
    if isinstance(data, str):
        try:
            return json.loads(data) if data.strip() else {}
        except json.JSONDecodeError:
            return {}
    if isinstance(data, dict):
        return data
    # multipart/form-data 时 DRF 可能返回 QueryDict，转成普通 dict（键名排除 FILES 里的 file/files）
    if hasattr(data, "keys") and not isinstance(data, dict):
        return {k: data.get(k) for k in data.keys() if k not in getattr(request, "FILES", {})}
    return {}


def _parse_body_and_files(request: Request) -> tuple[dict[str, Any], list[Any]]:
    """
    解析请求体与上传文件。返回 (body, files)。
    body 含 message、session_id、stream、file_urls 等；files 为 FILES 中的文件列表（支持 key 为 files 或 file）。
    """
    body = _parse_body(request)
    files: list[Any] = []
    flist = getattr(request, "FILES", None)
    if flist:
        # 兼容前端传 files[] 或 file[]
        files = list(flist.getlist("files") or flist.getlist("file") or [])
    return body, files


def _uploaded_files_to_image_parts(files: list[Any]) -> list[dict[str, Any]]:
    """
    将上传的图片转为 OpenAI 多模态 content 中的 image_url 部分。
    超出大小或非图片类型跳过并打日志；格式为 data:image/xxx;base64,...
    """
    parts: list[dict[str, Any]] = []
    for f in files:
        if not isinstance(f, UploadedFile):
            continue
        if f.size > MAX_ATTACHMENT_SIZE:
            logger.warning("Attachment %s exceeds max size %s", getattr(f, "name", ""), MAX_ATTACHMENT_SIZE)
            continue
        # 取 MIME 主类型，忽略 charset 等参数
        content_type = (getattr(f, "content_type", "") or "").split(";")[0].strip().lower()
        if content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
            logger.warning("Attachment %s type %s not allowed", getattr(f, "name", ""), content_type)
            continue
        try:
            raw = f.read()
            b64 = base64.b64encode(raw).decode("ascii")
            data_url = f"data:{content_type};base64,{b64}"
            # OpenAI 多模态格式：type=image_url, image_url.url=data URL
            parts.append({"type": "image_url", "image_url": {"url": data_url}})
        except Exception as e:
            logger.warning("Failed to read attachment %s: %s", getattr(f, "name", ""), e)
    return parts


def _file_urls_to_image_parts(request: Request, file_urls: Any) -> list[dict[str, Any]]:
    """
    将「统一上传接口 /base/api/system/file/ 返回的文件 URL」拉取并转为多模态 image_url 部分。
    file_urls 为字符串或列表，每个元素为上传接口返回的 url（可相对路径如 /media/xxx 或绝对 URL）。
    优先从本地 MEDIA_ROOT 读文件，否则用 HTTP GET 拉取。
    """
    parts: list[dict[str, Any]] = []
    if not file_urls:
        return parts
    if isinstance(file_urls, str):
        file_urls = [file_urls]
    if not isinstance(file_urls, list):
        return parts
    media_url = (getattr(settings, "MEDIA_URL", "") or "media/").strip().rstrip("/")
    media_root = getattr(settings, "MEDIA_ROOT", "") or "media"
    for raw_url in file_urls:
        if not raw_url or not isinstance(raw_url, str):
            continue
        url = raw_url.strip()
        if not url:
            continue
        content_type: str | None = None
        raw: bytes | None = None
        # 相对路径或含 MEDIA 的路径：尝试从本地 MEDIA_ROOT 读取，避免走外网
        if url.startswith("/") or url.startswith(media_url):
            path = url.lstrip("/")
            if path.startswith(media_url):
                path = path[len(media_url) :].lstrip("/")
            local_path = os.path.join(media_root, path) if not os.path.isabs(path) else path
            if os.path.isfile(local_path) and os.path.getsize(local_path) <= MAX_ATTACHMENT_SIZE:
                try:
                    with open(local_path, "rb") as f:
                        raw = f.read()
                    ext = os.path.splitext(local_path)[1].lower()
                    content_type = {
                        ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png",
                        ".gif": "image/gif", ".webp": "image/webp",
                    }.get(ext)
                except Exception as e:
                    logger.warning("Read local file %s failed: %s", local_path, e)
        # 非本地或本地读取失败：按绝对 URL 或相对 URL 用 HTTP 拉取
        if raw is None:
            fetch_url = request.build_absolute_uri(url) if url.startswith("/") else url
            try:
                r = requests.get(fetch_url, timeout=15)
                r.raise_for_status()
                if len(r.content) > MAX_ATTACHMENT_SIZE:
                    logger.warning("File %s exceeds max size", fetch_url)
                    continue
                raw = r.content
                content_type = (r.headers.get("Content-Type") or "").split(";")[0].strip().lower()
            except Exception as e:
                logger.warning("Fetch file_url %s failed: %s", fetch_url, e)
                continue
        if not raw or content_type not in ALLOWED_IMAGE_CONTENT_TYPES:
            continue
        try:
            b64 = base64.b64encode(raw).decode("ascii")
            data_url = f"data:{content_type};base64,{b64}"
            parts.append({"type": "image_url", "image_url": {"url": data_url}})
        except Exception as e:
            logger.warning("Encode file_url failed: %s", e)
    return parts


def _user_message_text_for_db(content: Any) -> str:
    """
    从本轮用户消息 content（str 或多模态 list）中取出纯文本，用于落库。
    多模态时取 type=text 的 text；若仅有图片则返回 "[含n张图片]" 占位。
    """
    if isinstance(content, str):
        return content
    if isinstance(content, list):
        for part in content:
            if isinstance(part, dict) and part.get("type") == "text" and part.get("text"):
                return part["text"]
        n = sum(1 for p in content if isinstance(p, dict) and p.get("type") == "image_url")
        return f"[含{n}张图片]" if n else ""
    return ""


def _get_client(body: dict[str, Any]) -> tuple[Any, str | None]:
    """
    根据 body 创建 AI 客户端。返回 (client, error_msg)，成功时 error_msg 为 None。
    use_settings=True 时从 conf 读 api_key/base_url；False 时需 body 传 api_key，可选 base_url/model。
    不传 provider 时使用配置 AI_DEFAULT_PROVIDER，默认 deepseek。
    """
    use_settings = body.get("use_settings", True)
    provider = (
        (body.get("provider") or getattr(settings, "AI_DEFAULT_PROVIDER", None) or "deepseek")
        .strip()
        or "deepseek"
    )
    model = body.get("model")

    if use_settings:
        try:
            return get_client_from_settings(provider), None
        except ValueError as e:
            return None, str(e)
        except Exception as e:
            logger.exception("AI client init failed")
            return None, f"初始化 AI 客户端失败: {e}"

    # 前端自填 key 模式：必须传 api_key
    api_key = (body.get("api_key") or "").strip()
    if not api_key:
        return None, "use_settings 为 false 时需传 api_key"
    base_url = body.get("base_url") or None
    try:
        client = get_client(
            provider, api_key=api_key, base_url=base_url, default_model=model
        )
        return client, None
    except ValueError as e:
        return None, str(e)
    except Exception as e:
        logger.exception("AI client init failed")
        return None, f"初始化 AI 客户端失败: {e}"


def _load_or_create_session(
    request: Request, body: dict[str, Any], client: Any, first_message: str
) -> tuple[AIChatSession | None, str | None]:
    """
    按 session_id 加载已有会话（并校验归属），否则创建新会话。返回 (session, error_msg)。
    有 session_id 时校验：会话存在且归属当前用户；新建时 title 取首条消息前 50 字。
    """
    session_id = body.get("session_id")
    model = body.get("model")
    default_model = getattr(client, "default_model", "") or ""

    if session_id:
        session_obj = AIChatSession.objects.filter(id=session_id).first()
        if not session_obj:
            return None, "会话不存在"
        # 有归属的会话仅创建者可操作
        if session_obj.user_id and request.user and session_obj.user_id != request.user.id:
            return None, "无权限操作该会话"
        return session_obj, None

    # 新建会话：标题用首条消息前 50 字，后续流式结束时可能再更新
    session_obj = AIChatSession(
        user=request.user,
        title=(first_message[:50] if first_message else ""),
        model_name=model or default_model,
    )
    session_obj.creator = str(request.user.id)
    session_obj.modifier = str(request.user.id)
    session_obj.save()
    return session_obj, None


def _content_for_db(content: str) -> str:
    """
    将内容转为 MySQL utf8mb3 可存格式：去掉 4 字节字符（如 emoji），避免 Incorrect string value。
    若表已是 utf8mb4，可不再调用此函数。
    """
    if not content:
        return content
    return "".join(c if ord(c) <= 0xFFFF else "" for c in content)


def _user_content_for_db(text: str) -> str:
    """
    用户上送内容落库前做 XSS 清洗再转 utf8，用于 AIChatMessage 用户消息及会话 title。
    先 cleaned_string(escape_html=True)，再 _content_for_db 去 4 字节字符。
    """
    if not text:
        return text
    return _content_for_db(cleaned_string(text, escape_html=True))


def _session_messages_for_api(session: AIChatSession) -> list[ChatMessage]:
    """
    将会话内历史消息转为 ChatMessage 列表，按 id 升序。
    供调用 client.chat / client.chat_stream 时作为历史上下文。
    """
    return [
        ChatMessage(role=m.role, content=m.content or "")
        for m in AIChatMessage.objects.filter(session=session).order_by("id")
    ]


def _sse_chat_stream(
    request: Request,
    session_obj: AIChatSession,
    message_content: Any,
    messages_for_api: list[ChatMessage],
    client: Any,
    model: str | None,
) -> Iterator[str]:
    """
    流式对话 SSE 生成器。message_content 为 str 或多模态 list（含图片时）。
    每轮 yield 一条 data: {json}\\n\\n；结束时该条含 done、usage、session_id、message_id，并落库用户消息与助手消息。
    """
    stream_fn = getattr(client, "chat_stream", None)
    if not stream_fn:
        yield _sse_data({"error": "当前供应商不支持流式输出"})
        return
    messages = messages_for_api + [ChatMessage(role="user", content=message_content)]
    full_content: list[str] = []  # 累积助手回复文本，done 时一次性落库
    message_id = None
    text_for_db = _user_message_text_for_db(message_content)
    try:
        for chunk in stream_fn(messages, model=model):
            if chunk.get("content"):
                full_content.append(chunk["content"])
            # 流结束：落库用户消息 + 助手消息，并补全 session_id / message_id 供前端
            if chunk.get("done"):
                uid = str(getattr(request.user, "id", None) or "")
                try:
                    AIChatMessage.objects.create(
                        session=session_obj,
                        role="user",
                        content=_user_content_for_db(text_for_db),
                        creator=uid,
                    )
                    full_str = "".join(full_content)
                    msg_obj = AIChatMessage.objects.create(
                        session=session_obj,
                        role="assistant",
                        content=_content_for_db(full_str),
                        model_name=chunk.get("model") or "",
                        usage=chunk.get("usage") or {},
                        creator=uid,
                    )
                    message_id = msg_obj.id
                    if not session_obj.title and text_for_db:
                        session_obj.title = _user_content_for_db(text_for_db)[:50]
                        session_obj.save(update_fields=["title", "update_datetime"])
                except Exception as e:
                    logger.exception("Save AI stream message failed: %s", e)
                chunk["session_id"] = session_obj.id
                chunk["message_id"] = message_id
            yield _sse_data(chunk)
    except Exception as e:
        logger.exception("AI chat stream failed")
        yield _sse_data({"error": str(e), "done": True})


def _sse_data(obj: dict[str, Any]) -> str:
    """格式化为 SSE 单条：data: {json}\\n\\n，ensure_ascii=False 保证中文不转 \\uXXXX。"""
    return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"


def _sse_chat_stream_bytes(
    request: Request,
    session_obj: AIChatSession,
    message_content: Any,
    messages_for_api: list[ChatMessage],
    client: Any,
    model: str | None,
):
    """
    与 _sse_chat_stream 相同，但逐段 yield UTF-8 bytes。
    StreamingHttpResponse 使用迭代器时需 yield bytes，否则中文可能乱码。
    """
    for chunk in _sse_chat_stream(
        request, session_obj, message_content, messages_for_api, client, model
    ):
        yield chunk.encode("utf-8")


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

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

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


def _parse_ppt_outline_from_response(text: str) -> list[dict[str, Any]]:
    """
    从 AI 返回文本中解析出 PPT 大纲 JSON 数组。兼容被 markdown 代码块 ```json ... ``` 包裹的情况。
    返回 [{ title, points }, ...]，非 dict 元素转为 title=str(x), points=[]。
    """
    raw = (text or "").strip()
    # 去掉 ```json 或 ``` 与第一个换行后的内容，到下一个 ``` 为止
    if "```" in raw:
        start = raw.find("```")
        if start != -1:
            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]:
    """
    从 AI 返回中解析单页 { title, points }。同样兼容 ``` 代码块包裹。
    解析失败返回 {"title": "", "points": []}。
    """
    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 _parse_image_suggestion_from_response(text: str) -> dict[str, Any]:
    """
    从 AI 返回中解析配图建议：image_prompt（文生图用）、image_keywords（图库关键词）、suggestion（中文建议）。
    解析失败返回空字符串/空列表。
    """
    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": ""}


# ---------- PPT 单页布局规范：标题、正文、图片的位置/大小/颜色 ----------
# 坐标与宽高均为相对画布宽高的百分比 0–100；前端可按画布尺寸换算为 px
# 颜色为十六进制 #RRGGBB；font_size 为磅 pt
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",
    },
}

# 预设布局模板 id -> 各元素默认 position/size/color，前端可任选其一再覆盖内容
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 _build_slide_spec(
    title: str,
    points: list[str],
    image_url: str = "",
    template_id: str = "title_top_text_left_image_right",
) -> dict[str, Any]:
    """
    根据标题、要点、图片 URL 和模板 id 拼出单页完整布局数据（title/body/image 的 position、font_size、color 等），供前端直接渲染。
    模板未找到时用第一个模板。
    """
    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"],
    }


# 段落处理：operation -> (system_prompt 模板, 中文说明)
# 模板中可用 {{target_lang}}，仅 translate 使用
TEXT_REFINE_OPERATIONS: dict[str, tuple[str, str]] = {
    "continue": (
        "你是一个写作助手。用户会给出一段文字，请在其基础上自然续写一段，风格与篇幅与原段协调，只输出续写内容，不要重复原文。",
        "续写",
    ),
    "rewrite": (
        "你是一个写作助手。用户会给出一段文字，请用不同表述重写该段，保持原意与信息量，语气可略作调整，只输出重写后的段落。",
        "重写",
    ),
    "summarize": (
        "你是一个写作助手。用户会给出一段文字，请将其缩写/概括为更短的段落，保留核心信息，只输出缩写后的内容。",
        "缩写",
    ),
    "expand": (
        "你是一个写作助手。用户会给出一段文字，请在其基础上扩写，增加细节、论据或展开说明，保持主题一致，只输出扩写后的完整段落。",
        "扩写",
    ),
    "polish": (
        "你是一个写作助手。用户会给出一段文字，请在不改变原意的前提下润色表达，使语句更流畅、用词更恰当，只输出润色后的段落。",
        "润色",
    ),
    "proofread": (
        "你是一个写作与校对助手。用户会给出一段文字，请进行校对与润色：纠正错别字、语病、标点，并适当优化表述，只输出校润后的段落。",
        "校润",
    ),
    "translate": (
        "你是一个翻译助手。用户会给出一段文字及目标语言，请将其翻译成目标语言，保持专业、流畅，只输出译文，不要加说明。",
        "翻译",
    ),
}


# 翻译目标语言简写 -> 中文说明（用于 prompt）
TARGET_LANG_NAMES: dict[str, str] = {
    "en": "英语",
    "english": "英语",
    "zh": "中文",
    "zh-cn": "简体中文",
    "zh-tw": "繁体中文",
    "ja": "日语",
    "japanese": "日语",
    "ko": "韩语",
    "korean": "韩语",
    "fr": "法语",
    "de": "德语",
    "es": "西班牙语",
    "ru": "俄语",
}


def _get_text_refine_system_user(operation: str, text: str, target_lang: str) -> tuple[str, str] | None:
    """
    返回 (system, user) 消息内容；不支持的操作返回 None。
    translate 时用 target_lang 映射为中文语言名（如 en->英语）；其他操作用预置中文说明（续写/重写等）。
    """
    if operation not in TEXT_REFINE_OPERATIONS:
        return None
    system, _ = TEXT_REFINE_OPERATIONS[operation]
    if operation == "translate":
        lang_hint = TARGET_LANG_NAMES.get((target_lang or "").strip().lower()) or (target_lang or "英语")
        user = f"请将以下内容翻译成{lang_hint}：\n\n{text}"
    else:
        user = f"请对以下段落进行{TEXT_REFINE_OPERATIONS[operation][1]}：\n\n{text}"
    return system, user


# ---------- Serializer（django.mdc：有对应 Model 时配套 List/Detail 与 Create/Update）----------
class AIChatSessionSerializer(CustomModelSerializer):
    """列表/详情用，只读 id，其余字段可序列化/反序列化。"""

    class Meta:
        model = AIChatSession
        fields = "__all__"
        read_only_fields = ["id"]


class AIChatSessionThumbnailSerializer(CustomModelSerializer):
    """
    用于 list 接口：返回用户发起的所有会话的缩略信息，仅含列表展示所需字段 + attachment_type。
    前端可按 create_datetime / update_datetime 分组（如昨天、7天内、30天内）。
    """
    attachment_type = serializers.SerializerMethodField(read_only=True)

    def get_attachment_type(self, obj) -> str:
        """附件类型：当前消息仅存文本，固定返回 text；若后续落库附件标记可改为 image 等。"""
        return "text"

    class Meta:
        model = AIChatSession
        fields = ["id", "title", "create_datetime", "update_datetime", "model_name", "attachment_type"]
        read_only_fields = ["id", "title", "create_datetime", "update_datetime", "model_name", "attachment_type"]


class AIChatSessionCreateUpdateSerializer(CustomModelSerializer):
    """创建/更新会话时使用，字段全开放。"""

    class Meta:
        model = AIChatSession
        fields = "__all__"


class AIChatSessionViewSet(CustomModelViewSet):
    """
    AI 会话：list/retrieve/create/update/destroy 会话；chat action 发消息并落库 AIChatMessage。
    list 仅返回当前用户发起的会话，使用缩略序列化（id、title、时间、model_name、attachment_type）。
    会话隔离：session_id 为表主键 id，通过「仅返回当前用户会话」+「发消息时校验归属」保证不会串会话。
    """
    queryset = AIChatSession.objects.all()  # 实际 list 时由基类/过滤仅当前用户
    serializer_class = AIChatSessionSerializer  # retrieve 等用
    list_serializer_class = AIChatSessionThumbnailSerializer  # list 用缩略
    create_serializer_class = AIChatSessionCreateUpdateSerializer
    update_serializer_class = AIChatSessionCreateUpdateSerializer
    search_fields = ["title", "model_name"]  # 列表搜索

    @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
    def chat(self, request: Request) -> Response:
        """
        发消息并返回 AI 回复，支持新会话、续写历史、上传附件（图片）。
        请求体: message（必填）, stream, session_id, provider, model 等；
               图片可二选一或同时使用：① multipart 的 files/file；② file_urls 或 attachment_urls（数组，为统一上传接口 /base/api/system/file/ 返回的 url）。
        图片仅支持 jpeg/png/gif/webp，单文件最大 5MB，需模型支持视觉（多模态）。
        stream=0 时响应: { content, usage, model, session_id, message_id }；
        stream=1 时响应: text/event-stream。
        """
        # 1) 解析请求体与上传文件
        body, uploaded_files = _parse_body_and_files(request)
        message = str((body.get("message") or "")).strip()
        if not message:
            return ErrorResponse(msg="message 不能为空")

        # 2) 图片来源：本次上传的 files + 统一上传接口返回的 file_urls（/base/api/system/file/ 返回的 url）
        image_parts = _uploaded_files_to_image_parts(uploaded_files)
        file_urls = body.get("file_urls") or body.get("attachment_urls")
        image_parts = image_parts + _file_urls_to_image_parts(request, file_urls)
        # 构建本轮用户消息 content：纯文本或 文本+图片（多模态 list）
        if image_parts:
            user_message_content: Any = [{"type": "text", "text": message}] + image_parts
        else:
            user_message_content = message

        # 3) 流式/非流式：stream=1 为 SSE 流式
        stream_mode = body.get("stream", 0)
        try:
            stream_mode = int(stream_mode) if stream_mode is not None else 0
        except (TypeError, ValueError):
            stream_mode = 0
        use_stream = stream_mode == 1

        # 4) 创建 AI 客户端（use_settings 或 body 内 api_key）
        client, err = _get_client(body)
        if err:
            return ErrorResponse(msg=err)

        # 5) 按 session_id 加载或新建会话，并校验归属
        session_obj, err = _load_or_create_session(
            request, body, client, message
        )
        if err:
            status = 403 if "无权限" in err else 400
            return ErrorResponse(msg=err, status=status)

        messages_for_api = _session_messages_for_api(session_obj)
        model = body.get("model")

        # 6) 流式：返回 SSE，在生成器内落库
        if use_stream:
            resp = StreamingHttpResponse(
                _sse_chat_stream_bytes(
                    request, session_obj, user_message_content, messages_for_api, client, model
                ),
                content_type="text/event-stream; charset=utf-8",
            )
            resp["Cache-Control"] = "no-cache"
            resp["X-Accel-Buffering"] = "no"
            resp["Connection"] = "keep-alive"
            return resp

        # 7) 非流式：有附件（多模态 list）时直接 client.chat；无附件用 ChatSession.send 走历史轮数控制
        if isinstance(user_message_content, list):
            messages = messages_for_api + [ChatMessage(role="user", content=user_message_content)]
            try:
                result = client.chat(messages, model=model)
            except Exception as e:
                logger.exception("AI chat request failed")
                return ErrorResponse(msg=f"AI 调用失败: {e}")
        else:
            try:
                chat_session = ChatSession(client, max_history_turns=20)
                chat_session._messages = messages_for_api
                result = chat_session.send(user_message_content, model=model)
            except Exception as e:
                logger.exception("AI chat request failed")
                return ErrorResponse(msg=f"AI 调用失败: {e}")

        # 8) 落库用户消息与助手消息，并补全会话 title（若为空）
        text_for_db = _user_message_text_for_db(user_message_content)
        user_id = getattr(request.user, "id", None)
        uid = str(user_id) if user_id else None
        message_id = None
        try:
            AIChatMessage.objects.create(
                session=session_obj,
                role="user",
                content=_user_content_for_db(text_for_db),
                creator=uid,
            )
            msg_obj = AIChatMessage.objects.create(
                session=session_obj,
                role="assistant",
                content=_content_for_db(result.content),
                model_name=result.model or "",
                usage=result.usage or {},
                creator=uid,
            )
            message_id = msg_obj.id
            if not session_obj.title and text_for_db:
                session_obj.title = _user_content_for_db(text_for_db)[:50]
                session_obj.save(update_fields=["title", "update_datetime"])
        except Exception as e:
            logger.exception("Save AI message failed")
            return ErrorResponse(msg="保存会话消息失败，请重试", status=500)

        return DetailResponse(
            data={
                "content": result.content,
                "usage": result.usage or {},
                "model": result.model or "",
                "session_id": session_obj.id,
                "message_id": message_id,
            }
        )

    @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
    def generate_ppt_outline(self, request: Request) -> Response:
        """
        根据主题自动生成 PPT 大纲；支持传入已有大纲进行「重新生成/微调」。
        请求体: topic（必填）, slide_count（选填，默认 10）, previous_outline（选填，已有大纲数组，传则在其基础上重新生成/优化）,
               provider, model, use_settings, api_key, base_url。
        响应: { outline: [{ title, points }, ...], raw, model, usage }。
        """
        body = _parse_body(request)
        topic = (body.get("topic") or "").strip()
        if not topic:
            return ErrorResponse(msg="topic 不能为空")

        # 页数限制 1～30，默认 10
        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 = _get_client(body)
        if err:
            return ErrorResponse(msg=err)

        model = body.get("model")
        # 拼 system：固定大纲格式 + 页数约束；有 previous_outline 时 user 要求在其基础上重新生成
        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, use_settings, api_key, base_url。
        响应: { slide: { title, points }, page_index, raw, model, usage }。
        """
        body = _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 = _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 :]
        # 给 AI 上下文：主题、当前页序号、前后页标题，便于衔接
        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:
        """
        根据某一页的标题与要点，生成该页配图描述与关键词，用于接入文生图 API（如 DALL-E）或图库搜索。
        请求体: topic（选填，整体主题）, title（必填，本页标题）, points（选填，本页要点数组）, provider, model, use_settings, api_key, base_url。
        响应: { image_prompt, image_keywords, suggestion, model, usage }。前端可用 image_prompt 调文生图、用 image_keywords 搜图库。
        """
        body = _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 = _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（选填，不传用 conf 配置）,
               wanxiang_model（选填）, wanxiang_size（选填，如 1024*1024）。
        响应: { image_url, image_urls, image_prompt, image_keywords, suggestion, error }，失败时 error 有说明、image_url 为空。
        """
        body = _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))

        # 1) 生成配图描述（与 generate_ppt_slide_image_prompt 一致）
        client, err = _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  # 无描述时用标题

        # 通义万相文生图：可选 body 内 key/model/size，否则用 conf 配置
        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 }。schema 说明各字段含义；templates 为预设布局，含 id、name 及 title/body/image 的 position/font_size/color。
        坐标与宽高均为画布宽高的百分比 0–100；颜色为 #RRGGBB；font_size 为磅 pt。
        """
        return DetailResponse(
            data={
                "schema": PPT_SLIDE_LAYOUT_SCHEMA,
                "templates": PPT_SLIDE_LAYOUT_TEMPLATES,
            }
        )

    @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
    def build_ppt_slides(self, request: Request) -> Response:
        """
        根据大纲与每页图片 URL 拼出每页完整布局数据（标题、正文、图片的位置/大小/颜色），供前端或导出 PPT 使用。
        请求体: outline（必填，[{ title, points }, ...]）, image_urls（选填，按页序的图片 URL 数组，缺页用空串）,
               image_url_by_index（选填，{ "0": url1, "1": url2 } 按页索引补图）, template_id（选填，预设布局 id，默认 title_top_text_left_image_right）。
        响应: { slides: [{ title: { text, position, font_size, color, ... }, body: { content, points, position, ... }, image: { url, position, ... }, layout_id }, ...] }。
        """
        body = _parse_body(request)
        outline = body.get("outline")
        if not isinstance(outline, list) or len(outline) == 0:
            return ErrorResponse(msg="outline 必填且为非空数组")
        # 每页图片：image_urls 按页序数组，或 image_url_by_index 按 "0"/"1" 补图
        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})  # 供前端渲染或导出 PPT

    @action(detail=False, methods=["post"], permission_classes=[IsAuthenticated])
    def text_refine(self, request: Request) -> Response:
        """
        段落处理：续写、重写、缩写、扩写、润色、校润、翻译。
        请求体: text（必填，待处理段落）, operation（必填，见下表）, target_lang（选填，翻译时目标语言，如 en/zh/ja）,
               provider, model, use_settings, api_key, base_url。
        operation 取值: continue=续写, rewrite=重写, summarize=缩写, expand=扩写, polish=润色, proofread=校润, translate=翻译。
        响应: { content, operation, operation_name, model, usage }。
        """
        body = _parse_body(request)
        text = (body.get("text") or "").strip()
        if not text:
            return ErrorResponse(msg="text 不能为空")
        operation = (body.get("operation") or "").strip().lower()
        if not operation:
            return ErrorResponse(msg="operation 不能为空")
        target_lang = (body.get("target_lang") or "").strip()  # 仅 translate 使用

        pair = _get_text_refine_system_user(operation, text, target_lang)
        if pair is None:
            valid = ", ".join(TEXT_REFINE_OPERATIONS.keys())
            return ErrorResponse(msg=f"operation 不支持，可选: {valid}")

        system_content, user_content = pair
        client, err = _get_client(body)
        if err:
            return ErrorResponse(msg=err)
        model = body.get("model")
        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("Text refine failed: %s", e)
            return ErrorResponse(msg=f"处理失败: {e}")

        _, operation_name = TEXT_REFINE_OPERATIONS[operation]
        return DetailResponse(
            data={
                "content": result.content or "",
                "operation": operation,
                "operation_name": operation_name,
                "model": result.model or "",
                "usage": result.usage or {},
            }
        )
