#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AI 头脑风暴 — Flask 网站
=========================
  - 主页: AI 头脑风暴（多风格）
  - 工具区: 地址校验工具（元宝AI + DeepSeek 双引擎）
  - 并发: Waitress + 10 线程

启动:
  python app.py                  # 开发 / 生产 (自动选)
  python app.py --dev            # 强制开发模式

访问:
  http://localhost:8765
"""

import io, json, os, re, sys, time, urllib.parse, tempfile, hashlib, shutil
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
from concurrent.futures import ThreadPoolExecutor, as_completed

import cloudscraper, pandas as pd, requests
from flask import Flask, request, jsonify, render_template, render_template_string, send_file, send_from_directory
from werkzeug.utils import secure_filename

from chat_routes import chat_bp, inject_chat_config
from video_routes import video_bp

# ============================================================
# 路径 & 配置
# ============================================================
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
TEMPLATE_DIR = os.path.join(SCRIPT_DIR, "templates")
CONFIG_FILE = os.path.join(SCRIPT_DIR, "engine_config.json")
KEYS_FILE = os.path.join(SCRIPT_DIR, "keys.txt")
PORT = 8765

app = Flask(__name__, template_folder=TEMPLATE_DIR, static_folder=SCRIPT_DIR, static_url_path="")
app.config['TEMPLATES_AUTO_RELOAD'] = True
app.jinja_env.auto_reload = True


@app.after_request
def disable_cache(response):
    response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
    response.headers["Pragma"] = "no-cache"
    response.headers["Expires"] = "0"
    # 打印请求日志（时间 方法 路径 → 状态码）
    ts = time.strftime("%H:%M:%S")
    print(f"[{ts}] {request.method} {request.path} → {response.status_code}", flush=True)
    return response


# ============================================================
# 加载引擎配置
# ============================================================
def load_config():
    default = {
        "primary_engine": "deepseek",
        "models": {"yuanbao": "hy3-preview", "deepseek": "deepseek-chat"}
    }
    if not os.path.exists(CONFIG_FILE):
        return default
    try:
        with open(CONFIG_FILE, "r", encoding="utf-8") as f:
            cfg = json.load(f)
        for k in default:
            cfg.setdefault(k, default[k])
        return cfg
    except Exception:
        return default


config = load_config()
YUANBAO_MODEL = config.get("models", {}).get("yuanbao", "hy3-preview")
DEEPSEEK_MODEL = config.get("models", {}).get("deepseek", "deepseek-chat")
DOUBAO_MODEL = config.get("models", {}).get("doubao", "doubao-seed-1-6-flash-250828")
PPT_DOWNLOAD_DIR = config.get("ppt_download_dir", None) or os.path.join(os.path.expanduser("~"), "Downloads")
# 文件名 → 实际保存目录的映射（支持前端自定义路径）
_PPT_SAVE_DIR_MAP = {}
PRIMARY_ENGINE = config.get("primary_engine", "yuanbao")


# ============================================================
# 读取 API Keys
# ============================================================
def read_keys():
    try:
        with open(KEYS_FILE, "r", encoding="utf-8") as f:
            content = f.read()
    except FileNotFoundError:
        return None, None, None, f"密钥文件不存在: {KEYS_FILE}"
    except Exception as e:
        return None, None, None, f"读取密钥文件失败: {e}"

    yuanbao_key = None
    deepseek_key = None
    doubao_key = None
    lines = content.splitlines()
    in_deepseek_section = False
    in_yuanbao_section = False
    in_huoshansection = False

    for line in lines:
        line = line.strip()
        if "元宝" in line or "yuanbao" in line.lower():
            in_yuanbao_section = True
            in_deepseek_section = False
            in_huoshansection = False
            continue
        if line.lower() == "deepseek":
            in_deepseek_section = True
            in_yuanbao_section = False
            in_huoshansection = False
            continue
        if "火山方舟" in line:
            in_huoshansection = True
            in_yuanbao_section = False
            in_deepseek_section = False
            continue
        if in_yuanbao_section and line.startswith("sk-") and not yuanbao_key:
            yuanbao_key = line
            continue
        if in_deepseek_section and line.startswith("sk-") and not deepseek_key:
            deepseek_key = line
            continue
        if in_huoshansection and line.startswith("ark-") and not doubao_key:
            doubao_key = line
            continue

    if not deepseek_key:
        for line in lines:
            line = line.strip()
            if line.startswith("sk-") and line != yuanbao_key and not deepseek_key:
                deepseek_key = line
                break

    # 如果没有在火山方舟段找到，全文件搜索 ark- key
    if not doubao_key:
        for line in lines:
            line = line.strip()
            if line.startswith("ark-") and not doubao_key:
                doubao_key = line
                break

    errors = []
    if not yuanbao_key:
        errors.append("未找到元宝API Key")
    if not deepseek_key:
        errors.append("未找到DeepSeek API Key")
    if not doubao_key:
        errors.append("未找到火山方舟(豆包)API Key")
    return yuanbao_key, deepseek_key, doubao_key, "; ".join(errors) if errors else None


YUANBAO_API_KEY, DEEPSEEK_API_KEY, DOUBAO_API_KEY, keys_error = read_keys()
YUANBAO_API_URL = "https://tokenhub.tencentmaas.com/v1/chat/completions"
DEEPSEEK_API_URL = "https://api.deepseek.com/v1/chat/completions"
DOUBAO_API_URL = "https://ark.cn-beijing.volces.com/api/v3/chat/completions"

# 注册聊天机器人蓝图
app.register_blueprint(chat_bp)
inject_chat_config({
    "YUANBAO_API_URL": YUANBAO_API_URL,
    "YUANBAO_API_KEY": YUANBAO_API_KEY,
    "YUANBAO_MODEL": YUANBAO_MODEL,
    "DEEPSEEK_API_URL": DEEPSEEK_API_URL,
    "DEEPSEEK_API_KEY": DEEPSEEK_API_KEY,
    "DEEPSEEK_MODEL": DEEPSEEK_MODEL,
})

# 注册视频生成蓝图
app.register_blueprint(video_bp)


# ============================================================
# 引擎调用 — 元宝AI
# ============================================================
HUNYUAN_PROMPT = """你是一位中国地理地址校验专家。请分析以下中文地址，判断其各个组成部分是否真实存在，并给出整体结论。

地址：{address}

请严格按以下格式输出，不要添加额外说明：

===地址分解===
（列出地址的每个层级，每行一条，格式：层级名称：具体内容（真实）或 层级名称：具体内容（虚构）或 层级名称：具体内容（不确定））

===整体结论===
真实存在 / 部分真实存在 / 不存在

===分析说明===
（简要说明判断依据，150字以内）

判断规则：
- "真实存在"：地址的所有层级都真实存在
- "部分真实存在"：地址部分层级真实存在，部分虚构或不存在
- "不存在"：地址核心层级本身虚构或完全无法对应真实地理信息
"""


def call_hunyuan(address):
    payload = json.dumps({
        "model": YUANBAO_MODEL,
        "messages": [{"role": "user", "content": HUNYUAN_PROMPT.format(address=address)}],
        "stream": False, "max_tokens": 2048, "temperature": 0.1
    }, ensure_ascii=False).encode("utf-8")

    req = Request(YUANBAO_API_URL, data=payload, headers={
        "Content-Type": "application/json",
        "Authorization": f"Bearer {YUANBAO_API_KEY}"
    }, method="POST")

    try:
        with urlopen(req, timeout=60) as resp:
            data = json.loads(resp.read().decode("utf-8"))
        content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
        if not content:
            return {"success": False, "error": "API返回内容为空"}
        return {"success": True, "raw": content}
    except HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")
        return {"success": False, "error": f"HTTP {e.code}: {err_body[:400]}"}
    except URLError as e:
        return {"success": False, "error": f"网络连接失败: {e.reason}"}
    except Exception as e:
        return {"success": False, "error": f"未知错误: {e}"}


def parse_hunyuan(raw_text):
    parts = []
    m_parts = re.search(r'===地址分解===\s*(.*?)(?:\s*===整体结论===|$)', raw_text, re.DOTALL)
    if m_parts:
        for line in m_parts.group(1).strip().split('\n'):
            line = line.strip()
            if not line:
                continue
            m = re.match(r'(.+?)[：:]\s*(.+?)[（(](真实|虚构|不确定)[）)]\s*$', line)
            if m:
                parts.append({"level": m.group(1).strip(), "name": m.group(2).strip(), "status": m.group(3)})
                continue
            m2 = re.match(r'(.+?)[（(](真实|虚构|不确定)[）)]\s*$', line)
            if m2:
                parts.append({"level": "", "name": m2.group(1).strip(), "status": m2.group(2)})

    overall = "未知"
    m_overall = re.search(r'===整体结论===\s*(.*?)(?:\s*===分析说明===|$)', raw_text, re.DOTALL)
    if m_overall:
        c = m_overall.group(1).strip()
        if "真实存在" in c and "部分" not in c and "不" not in c:
            overall = "真实存在"
        elif "部分真实" in c:
            overall = "部分真实存在"
        elif "不存在" in c:
            overall = "不存在"

    analysis = ""
    m_analysis = re.search(r'===分析说明===\s*(.*)', raw_text, re.DOTALL)
    if m_analysis:
        analysis = m_analysis.group(1).strip()

    return {"parts": parts, "overall": overall, "analysis": analysis}


# ============================================================
# 引擎调用 — DeepSeek
# ============================================================
SYSTEM_DEEPSEEK = """你是一个中文地址校验专家，服务于金融反洗钱(AML)合规场景。

你的任务：
1. 判断给定地址是否真实、完整、规范
2. 将地址标准化（补齐省市区、统一格式）
3. 返回 JSON 格式结果

输出必须是纯 JSON，不要有任何额外文字:
{
  "address_raw": "原始输入",
  "address_standard": "标准化后的完整地址",
  "is_valid": true/false,
  "completeness": "完整/部分缺失/严重缺失",
  "confidence": 0.0-1.0,
  "issues": ["问题1", "问题2"],
  "parsed": { "province": "省", "city": "市", "district": "区/县", "street": "街道/路", "detail": "详细地址" },
  "risk_flags": ["风险标记"],
  "suggestion": "修正建议或备注"
}

风险标记规则:
- 地址不存在或明显虚构 → "疑似虚构地址"
- 省市区不匹配 → "行政区划不匹配"
- 缺少关键信息（无门牌号等）→ "地址信息不完整"
- 地址指向敏感区域 → "敏感区域"
"""


def call_deepseek(address):
    payload = json.dumps({
        "model": DEEPSEEK_MODEL,
        "messages": [
            {"role": "system", "content": SYSTEM_DEEPSEEK},
            {"role": "user", "content": address}
        ],
        "stream": False, "max_tokens": 2048, "temperature": 0.1
    }, ensure_ascii=False).encode("utf-8")

    req = Request(DEEPSEEK_API_URL, data=payload, headers={
        "Content-Type": "application/json",
        "Authorization": f"Bearer {DEEPSEEK_API_KEY}"
    }, method="POST")

    try:
        with urlopen(req, timeout=60) as resp:
            data = json.loads(resp.read().decode("utf-8"))
        content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
        if not content:
            return {"success": False, "error": "API返回内容为空"}
        return {"success": True, "raw": content}
    except HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")
        return {"success": False, "error": f"HTTP {e.code}: {err_body[:400]}"}
    except URLError as e:
        return {"success": False, "error": f"网络连接失败: {e.reason}"}
    except Exception as e:
        return {"success": False, "error": f"未知错误: {e}"}


def parse_deepseek(raw_text):
    text = raw_text.strip()
    if text.startswith("```"):
        text = re.sub(r"^```(?:json)?\s*", "", text)
        text = re.sub(r"\s*```$", "", text)
    try:
        result = json.loads(text)
    except json.JSONDecodeError:
        return {"parts": [], "overall": "解析失败", "analysis": f"DeepSeek返回内容未能解析为JSON", "detail": {}}

    is_valid = result.get("is_valid", False)
    completeness = result.get("completeness", "")
    overall = "真实存在" if is_valid else ("部分真实存在" if completeness == "部分缺失" else "不存在")

    parts = []
    parsed = result.get("parsed", {})
    for key, label in [("province","省/直辖市"),("city","城市"),("district","区/县"),("street","街道/路")]:
        if parsed.get(key):
            parts.append({"level": label, "name": parsed[key], "status": "真实"})
    if parsed.get("detail"):
        parts.append({"level": "详细地址", "name": parsed["detail"], "status": "真实"})

    analysis_parts = []
    issues = result.get("issues", [])
    if issues:
        analysis_parts.append("问题: " + "; ".join(issues))
    flags = result.get("risk_flags", [])
    if flags:
        analysis_parts.append("风险: " + "; ".join(f for f in flags if f))
    if result.get("suggestion"):
        analysis_parts.append(f"建议: {result['suggestion']}")
    if result.get("confidence"):
        analysis_parts.append(f"置信度: {result['confidence']:.0%}")
    analysis = " | ".join(analysis_parts) if analysis_parts else "DeepSeek分析完成"

    return {
        "parts": parts,
        "overall": overall,
        "analysis": analysis,
        "detail": {
            "address_standard": result.get("address_standard", ""),
            "completeness": result.get("completeness", ""),
            "confidence": result.get("confidence", 0),
            "risk_flags": result.get("risk_flags", []),
            "issues": result.get("issues", []),
            "suggestion": result.get("suggestion", ""),
        }
    }


# ============================================================
# 引擎调用 — 翻译（双引擎通用）
# ============================================================

def call_engine_for_translate(engine, prompt, system_prompt=None):
    if engine == "deepseek":
        url = DEEPSEEK_API_URL
        key = DEEPSEEK_API_KEY
        messages = [{"role": "user", "content": prompt}]
        if system_prompt:
            messages.insert(0, {"role": "system", "content": system_prompt})
        payload = json.dumps({
            "model": DEEPSEEK_MODEL,
            "messages": messages,
            "stream": False, "max_tokens": 4096, "temperature": 0.1
        }, ensure_ascii=False).encode("utf-8")
    else:
        url = YUANBAO_API_URL
        key = YUANBAO_API_KEY
        payload = json.dumps({
            "model": YUANBAO_MODEL,
            "messages": [{"role": "user", "content": prompt}],
            "stream": False, "max_tokens": 4096, "temperature": 0.1
        }, ensure_ascii=False).encode("utf-8")

    req = Request(url, data=payload, headers={
        "Content-Type": "application/json",
        "Authorization": f"Bearer {key}"
    }, method="POST")

    try:
        with urlopen(req, timeout=60) as resp:
            data = json.loads(resp.read().decode("utf-8"))
        content = data.get("choices", [{}])[0].get("message", {}).get("content", "")
        if not content:
            return {"success": False, "error": "API返回内容为空"}
        return {"success": True, "raw": content}
    except HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")
        return {"success": False, "error": f"HTTP {e.code}: {err_body[:400]}"}
    except URLError as e:
        return {"success": False, "error": f"网络连接失败: {e.reason}"}
    except Exception as e:
        return {"success": False, "error": f"未知错误: {e}"}


LANGUAGE_MAP = {
    "英语": "English", "法语": "French", "德语": "German",
    "日语": "Japanese", "俄语": "Russian", "西班牙语": "Spanish",
    "葡萄牙语": "Portuguese", "中文": "Chinese",
}


def _extract_json(raw):
    """从 LLM 响应中尽可能提取 JSON 对象，支持各种异常格式。"""
    s = raw.strip()

    # 清理代码块标记
    s = re.sub(r"^```(?:json)?\s*", "", s)
    s = re.sub(r"\s*```$", "", s)
    s = s.strip()

    # 尝试直接解析
    try:
        return json.loads(s)
    except json.JSONDecodeError:
        pass

    # 尝试提取最外层大括号内容
    m = re.search(r'\{.*\}', s, re.DOTALL)
    if m:
        candidate = m.group(0)
        try:
            return json.loads(candidate)
        except json.JSONDecodeError:
            pass
        # 尝试修复常见问题：多余的逗号
        candidate = re.sub(r',\s*}', '}', candidate)
        candidate = re.sub(r',\s*]', ']', candidate)
        try:
            return json.loads(candidate)
        except json.JSONDecodeError:
            pass
        # 尝试修复引号被转义的问题
        candidate = candidate.replace('\\"', '"')
        candidate = candidate.replace("\\'", "'")
        try:
            return json.loads(candidate)
        except json.JSONDecodeError:
            pass

    # 尝试按行解析：语言: 文本 格式
    lines = [l.strip() for l in s.split("\n") if l.strip()]
    result = {"translations": []}
    for line in lines:
        for lang_name in LANGUAGE_MAP:
            if lang_name in line:
                # 提取语言名后的内容
                idx = line.index(lang_name) + len(lang_name)
                text_part = line[idx:].lstrip("：:）（)")
                if text_part:
                    result["translations"].append({"language": lang_name, "text": text_part})
                    break

    if result["translations"]:
        return result

    return None


@app.route("/api/translate", methods=["POST"])
def api_translate():
    body = request.get_json(silent=True) or {}
    text = (body.get("text") or "").strip()
    if not text:
        return jsonify({"error": "请输入要翻译的文本"}), 400
    if len(text) > 2000:
        return jsonify({"error": "文本过长（最多2000字符）"}), 400

    engine = body.get("engine", "deepseek")
    if engine not in ("yuanbao", "deepseek"):
        engine = "yuanbao"

    target_languages = body.get("target_languages", [])
    if not isinstance(target_languages, list):
        target_languages = [target_languages] if target_languages else []

    # 自动检测源语言
    has_chinese = bool(re.search(r'[\u4e00-\u9fff]', text))

    # 默认目标语言
    if not target_languages:
        if has_chinese:
            target_languages = ["英语"]
        else:
            target_languages = ["中文"]

    # 去重
    seen = set()
    unique_langs = []
    for lang in target_languages:
        if lang not in seen:
            seen.add(lang)
            unique_langs.append(lang)
    target_languages = unique_langs

    # 校验 API Key
    if engine == "deepseek" and not DEEPSEEK_API_KEY:
        return jsonify({"error": "DeepSeek API Key 未配置", "engine": engine}), 400
    if engine == "yuanbao" and not YUANBAO_API_KEY:
        return jsonify({"error": "元宝API Key 未配置", "engine": engine}), 400

    # 构建翻译 prompt — 严格 JSON 格式
    lang_str = "、".join(target_languages)
    example_pairs = [f'{{"language": "{lang}", "text": ""}}' for lang in target_languages]
    example_json = "[" + ", ".join(example_pairs) + "]"

    lang_hints = " / ".join([f"{i+1}.{lang}" for i, lang in enumerate(target_languages)])

    prompt = f"""请将以下文本翻译成指定的目标语言。

原文：{text}

目标语言：{lang_str}

请严格按照 JSON 格式输出，不要加任何额外文字、不要加代码块标记：

{{"translations": {example_json}}}"""

    if engine == "deepseek":
        prompt = f"你是一个翻译助手，只输出JSON。\n\n{prompt}"

    t0 = time.time()
    result = call_engine_for_translate(engine, prompt)
    elapsed = round(time.time() - t0, 2)

    if not result["success"]:
        return jsonify({"error": result["error"], "engine": engine}), 500

    raw = result["raw"].strip()

    # 尝试解析 JSON
    parsed = _extract_json(raw)
    if parsed is None:
        # 最后兜底：直接按语言顺序把每行翻译当作结果
        lines = [l for l in raw.split("\n") if l.strip()]
        translations = []
        for i, lang in enumerate(target_languages):
            if i < len(lines):
                translations.append({"language": lang, "text": lines[i].strip()})
        if translations:
            return jsonify({
                "success": True,
                "engine": engine,
                "source_text": text,
                "is_chinese_source": has_chinese,
                "translations": translations,
                "elapsed": elapsed,
            })
        return jsonify({"error": "翻译结果解析失败", "raw": raw[:500], "engine": engine, "elapsed": elapsed}), 500

    translations = parsed.get("translations", [])
    if not translations:
        # 有时 LLM 直接以语言名为 key 平铺
        for lang in target_languages:
            if lang in parsed:
                translations.append({"language": lang, "text": parsed[lang]})
        if not translations:
            return jsonify({"error": "翻译结果格式异常", "raw": raw[:500], "engine": engine, "elapsed": elapsed}), 500

    return jsonify({
        "success": True,
        "engine": engine,
        "source_text": text,
        "is_chinese_source": has_chinese,
        "translations": translations,
        "elapsed": elapsed,
    })


# ============================================================
# Flask 路由 — 页面
# ============================================================

@app.route("/")
def index():
    return render_template("index.html",
        yuanbao_ready=bool(YUANBAO_API_KEY),
        deepseek_ready=bool(DEEPSEEK_API_KEY),
        primary_engine=PRIMARY_ENGINE,
        yuanbao_model=YUANBAO_MODEL,
        deepseek_model=DEEPSEEK_MODEL,
        keys_status="正常" if (YUANBAO_API_KEY or DEEPSEEK_API_KEY) else "未配置",
    )


@app.route("/game/pacman")
def game_pacman():
    return render_template("pacman.html")


@app.route("/readme")
def readme():
    readme_path = os.path.join(SCRIPT_DIR, "README.md")
    if not os.path.exists(readme_path):
        return "README.md 文件不存在", 404
    with open(readme_path, "r", encoding="utf-8") as f:
        content = f.read()
    html_body = []
    in_table = False; in_code = False; buf = []; rows = []
    for line in content.split("\n"):
        if line.startswith("```"):
            if in_code:
                html_body.append(f'<pre><code>{"".join(buf)}</code></pre>'); buf = []; in_code = False
            else: in_code = True
            continue
        if in_code: buf.append(line + "\n"); continue
        if line.startswith("|") and line.endswith("|"):
            cells = [c.strip() for c in line.split("|")[1:-1]]
            if not in_table: in_table = True; rows = [cells]
            elif all(c.startswith("-") for c in "".join(cells) if c): continue
            else: rows.append(cells)
            continue
        if in_table and rows:
            html_body.append("<table><thead><tr>" + "".join(f"<th>{c}</th>" for c in rows[0]) + "</tr></thead><tbody>")
            for r in rows[1:]: html_body.append("<tr>" + "".join(f"<td>{c}</td>" for c in r) + "</tr>")
            html_body.append("</tbody></table>"); rows = []; in_table = False
        if not line.strip(): html_body.append("<br>"); continue
        if line.strip() == "---": html_body.append("<hr>"); continue
        t = line
        t = t.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
        import re as _re
        t = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', t)
        t = _re.sub(r'`([^`]+)`', r'<code>\1</code>', t)
        t = _re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'<a href="\2">\1</a>', t)
        if line.startswith("### "): html_body.append(f"<h3>{t[4:]}</h3>")
        elif line.startswith("## "): html_body.append(f"<h2>{t[3:]}</h2>")
        elif line.startswith("# "): html_body.append(f"<h1>{t[2:]}</h1>")
        elif line.startswith("> "): html_body.append(f"<blockquote>{t[2:]}</blockquote>")
        else: html_body.append(f"<p>{t}</p>")
    if in_table and rows:
        html_body.append("<table><thead><tr>" + "".join(f"<th>{c}</th>" for c in rows[0]) + "</tr></thead><tbody>")
        for r in rows[1:]: html_body.append("<tr>" + "".join(f"<td>{c}</td>" for c in r) + "</tr>")
        html_body.append("</tbody></table>")
    body = "\n".join(html_body)
    return render_template_string(f"""<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>AI 头脑风暴 - 项目文档</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#f5f7fa;color:#1a1a2e;font-family:-apple-system,"Microsoft YaHei","PingFang SC","Helvetica Neue",sans-serif;line-height:1.8;font-size:15px}}
.navbar{{position:sticky;top:0;z-index:100;background:rgba(255,255,255,0.92);backdrop-filter:blur(16px);border-bottom:1px solid #e8e8e8;padding:0 24px}}
.nav-inner{{max-width:900px;margin:0 auto;display:flex;align-items:center;height:56px;gap:12px}}
.nav-logo{{text-decoration:none;color:#1677ff;font-weight:700;font-size:18px;display:flex;align-items:center;gap:8px}}
.nav-back{{margin-left:auto;text-decoration:none;color:#555;font-size:14px;padding:6px 14px;border:1px solid #e8e8e8;border-radius:6px}}
.nav-back:hover{{border-color:#1677ff;color:#1677ff}}
.readme-container{{max-width:900px;margin:40px auto;padding:40px 48px;background:#fff;border-radius:12px;box-shadow:0 2px 12px rgba(0,0,0,0.06)}}
.readme-container h1{{font-size:28px;margin:0 0 8px;color:#1a1a2e}}
.readme-container h2{{font-size:22px;margin:32px 0 12px;padding-bottom:8px;border-bottom:2px solid #e8e8e8;color:#1677ff}}
.readme-container h3{{font-size:18px;margin:24px 0 8px;color:#1a1a2e}}
.readme-container p{{margin:10px 0}}
.readme-container code{{background:#e6f4ff;padding:2px 6px;border-radius:4px;font-size:13px}}
.readme-container pre{{background:#f0f2f5;padding:16px;border-radius:8px;overflow-x:auto;margin:12px 0}}
.readme-container pre code{{background:none;padding:0}}
.readme-container blockquote{{border-left:4px solid #1677ff;padding:8px 16px;margin:12px 0;background:#f5f5f5;border-radius:0 8px 8px 0}}
.readme-container table{{width:100%;border-collapse:collapse;margin:12px 0}}
.readme-container th,.readme-container td{{padding:10px 14px;border:1px solid #e8e8e8;text-align:left}}
.readme-container th{{background:#f5f7fa;font-weight:600}}
.readme-container a{{color:#1677ff;text-decoration:none}}
.readme-container hr{{border:none;border-top:1px solid #e8e8e8;margin:24px 0}}
</style></head><body>
<nav class="navbar"><div class="nav-inner">
<a class="nav-logo" href="/">🧠 AI 头脑风暴</a><span style="font-size:13px;color:#999;">/ 项目文档</span>
<a class="nav-back" href="/">← 返回首页</a>
</div></nav>
<div class="readme-container">{body}</div>
</body></html>""")


@app.route("/tool/translate")
def tool_translate():
    return render_template("tool_translate.html", yuanbao_ready=bool(YUANBAO_API_KEY), deepseek_ready=bool(DEEPSEEK_API_KEY), yuanbao_model=YUANBAO_MODEL, deepseek_model=DEEPSEEK_MODEL)


@app.route("/tool/scraper")
def tool_scraper():
    return render_template("tool_scraper.html", yuanbao_ready=bool(YUANBAO_API_KEY), deepseek_ready=bool(DEEPSEEK_API_KEY), yuanbao_model=YUANBAO_MODEL, deepseek_model=DEEPSEEK_MODEL)


@app.route("/tool/lang")
def tool_lang():
    return render_template("tool_lang.html", yuanbao_ready=bool(YUANBAO_API_KEY), deepseek_ready=bool(DEEPSEEK_API_KEY), yuanbao_model=YUANBAO_MODEL, deepseek_model=DEEPSEEK_MODEL)


@app.route("/tool/video")
def tool_video():
    return render_template("tool_video.html", yuanbao_ready=bool(YUANBAO_API_KEY), deepseek_ready=bool(DEEPSEEK_API_KEY), yuanbao_model=YUANBAO_MODEL, deepseek_model=DEEPSEEK_MODEL)


@app.route("/tool/chat")
def tool_chat():
    return render_template("tool_chat.html", yuanbao_ready=bool(YUANBAO_API_KEY), deepseek_ready=bool(DEEPSEEK_API_KEY), yuanbao_model=YUANBAO_MODEL, deepseek_model=DEEPSEEK_MODEL)


@app.route("/tool/ppt-doubao")
def tool_ppt_doubao():
    return render_template("tool_ppt_doubao.html",
        yuanbao_ready=bool(YUANBAO_API_KEY),
        deepseek_ready=bool(DEEPSEEK_API_KEY),
        doubao_ready=bool(DOUBAO_API_KEY),
        yuanbao_model=YUANBAO_MODEL,
        deepseek_model=DEEPSEEK_MODEL,
        doubao_model=DOUBAO_MODEL,
    )


@app.route("/tool/address-check")
def address_check_tool():
    return render_template("address_checker.html",
        yuanbao_ready=bool(YUANBAO_API_KEY),
        deepseek_ready=bool(DEEPSEEK_API_KEY),
        primary_engine=PRIMARY_ENGINE,
        yuanbao_model=YUANBAO_MODEL,
        deepseek_model=DEEPSEEK_MODEL,
    )


# ============================================================
# Flask 路由 — API
# ============================================================

@app.route("/api/check", methods=["POST"])
def api_check():
    body = request.get_json(silent=True) or {}
    address = (body.get("address") or "").strip()
    if not address:
        return jsonify({"error": "请提供地址"}), 400
    if len(address) > 500:
        return jsonify({"error": "地址过长"}), 400

    result = {"address": address, "primary_engine": PRIMARY_ENGINE}

    # 元宝
    if YUANBAO_API_KEY:
        t0 = time.time()
        raw = call_hunyuan(address)
        elapsed = round(time.time() - t0, 2)
        if raw["success"]:
            parsed = parse_hunyuan(raw["raw"])
            result["yuanbao"] = {"success": True, **parsed, "elapsed": elapsed}
        else:
            result["yuanbao"] = {"success": False, "error": raw["error"], "elapsed": elapsed}
    else:
        result["yuanbao"] = {"success": False, "error": "元宝API Key未配置"}

    # DeepSeek
    if DEEPSEEK_API_KEY:
        t0 = time.time()
        raw = call_deepseek(address)
        elapsed = round(time.time() - t0, 2)
        if raw["success"]:
            parsed = parse_deepseek(raw["raw"])
            result["deepseek"] = {"success": True, **parsed, "elapsed": elapsed}
        else:
            result["deepseek"] = {"success": False, "error": raw["error"], "elapsed": elapsed}
    else:
        result["deepseek"] = {"success": False, "error": "DeepSeek API Key未配置"}

    result["success"] = True
    return jsonify(result)


@app.route("/api/status")
def api_status():
    yb_ok = bool(YUANBAO_API_KEY)
    ds_ok = bool(DEEPSEEK_API_KEY)
    info = []
    if yb_ok:
        info.append(f"元宝: {YUANBAO_API_KEY[:8]}...{YUANBAO_API_KEY[-4:]}")
    else:
        info.append("元宝: 未配置")
    if ds_ok:
        info.append(f"DeepSeek: {DEEPSEEK_API_KEY[:8]}...{DEEPSEEK_API_KEY[-4:]}")
    else:
        info.append("DeepSeek: 未配置")

    return jsonify({
        "ok": yb_ok or ds_ok,
        "yuanbao_ready": yb_ok,
        "deepseek_ready": ds_ok,
        "keys_info": " | ".join(info),
        "models": {"yuanbao": YUANBAO_MODEL, "deepseek": DEEPSEEK_MODEL},
        "primary_engine": PRIMARY_ENGINE,
    })


@app.route("/api/config", methods=["GET", "POST"])
def api_config():
    global PRIMARY_ENGINE, config, YUANBAO_MODEL, DEEPSEEK_MODEL

    if request.method == "GET":
        return jsonify({
            "primary_engine": PRIMARY_ENGINE,
            "models": {"yuanbao": YUANBAO_MODEL, "deepseek": DEEPSEEK_MODEL},
        })

    body = request.get_json(silent=True) or {}
    new_primary = body.get("primary_engine", PRIMARY_ENGINE)
    if new_primary not in ("yuanbao", "deepseek"):
        return jsonify({"error": "primary_engine 必须是 yuanbao 或 deepseek"}), 400

    PRIMARY_ENGINE = new_primary
    config["primary_engine"] = new_primary

    models = body.get("models", {})
    if models:
        config["models"] = models
        YUANBAO_MODEL = models.get("yuanbao", YUANBAO_MODEL)
        DEEPSEEK_MODEL = models.get("deepseek", DEEPSEEK_MODEL)

    try:
        with open(CONFIG_FILE, "w", encoding="utf-8") as f:
            json.dump(config, f, ensure_ascii=False, indent=2)
    except Exception as e:
        return jsonify({"error": f"写入配置失败: {e}"}), 500

    return jsonify({"success": True, "primary_engine": PRIMARY_ENGINE})


# ============================================================
# 网页爬虫
# ============================================================

class _ScrapeParser:
    """快速 HTML 解析器，提取纯文本、链接、图片。"""
    def __init__(self):
        self.text_parts = []
        self.links = []
        self.images = []
        self.title = ""
        self._skip = False
        self._in_title = False
        self._link_text = ""
        self._collect_link = False

    def feed(self, html: str):
        i = 0
        while i < len(html):
            if html[i] == '<':
                end = html.find('>', i)
                if end == -1:
                    break
                tag_content = html[i+1:end].strip()
                # 处理结束标签
                if tag_content.startswith('/'):
                    tag_name = tag_content[1:].split()[0].lower()
                    self._close_tag(tag_name)
                # 处理自闭合和开始标签
                elif tag_content.endswith('/') or tag_content.startswith('!'):
                    tag_name = tag_content.lstrip('!').split()[0].lower()
                    if tag_name in ('meta', 'link', 'br', 'hr', 'img', 'input'):
                        self._handle_self_closing(tag_content)
                else:
                    tag_name = tag_content.split()[0].lower()
                    attrs = self._parse_attrs(tag_content)
                    self._open_tag(tag_name, attrs)
                i = end + 1
            else:
                # 纯文本
                next_tag = html.find('<', i)
                chunk = html[i:next_tag] if next_tag != -1 else html[i:]
                if self._in_title:
                    self.title += chunk
                elif not self._skip:
                    self.text_parts.append(chunk)
                    if self._collect_link:
                        self._link_text += chunk
                i = next_tag if next_tag != -1 else len(html)

    def _parse_attrs(self, tag_str: str) -> dict:
        attrs = {}
        # 找到第一个空格后的属性部分
        sp = tag_str.find(' ')
        if sp == -1:
            return attrs
        rest = tag_str[sp+1:]
        # 简单属性解析
        for pair in re.finditer(r'''([\w-]+)\s*=\s*"([^"]*)"|([\w-]+)\s*=\s*'([^']*)'|([\w-]+)(?=\s|/|>)''', rest):
            if pair.group(1):
                attrs[pair.group(1).lower()] = pair.group(2)
            elif pair.group(3):
                attrs[pair.group(3).lower()] = pair.group(4)
            elif pair.group(5):
                attrs[pair.group(5).lower()] = True
        return attrs

    def _open_tag(self, tag: str, attrs: dict):
        if tag in ('script', 'style'):
            self._skip = True
        elif tag == 'title':
            self._in_title = True
        elif tag == 'a':
            self._collect_link = True
            self._link_text = ""
            href = attrs.get('href', '')
            if href and not href.startswith('#') and not href.startswith('javascript:'):
                self.links.append({'text': '', 'href': href})
        elif tag == 'img':
            src = attrs.get('src', '')
            if src:
                self.images.append({'alt': attrs.get('alt', ''), 'src': src})
        elif tag in ('br', 'p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'tr', 'th', 'td', 'blockquote', 'section', 'article', 'header', 'footer', 'nav', 'pre'):
            self.text_parts.append('\n')

    def _close_tag(self, tag: str):
        if tag in ('script', 'style'):
            self._skip = False
        elif tag == 'title':
            self._in_title = False
        elif tag == 'a' and self._collect_link:
            if self.links:
                self.links[-1]['text'] = self._link_text.strip()[:100]
            self._collect_link = False
            self._link_text = ""
        elif tag in ('p', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'li', 'tr', 'th', 'td', 'blockquote', 'section', 'article', 'pre'):
            self.text_parts.append('\n')

    def _handle_self_closing(self, tag_str: str):
        tag_name = tag_str.split()[0].lower()
        if tag_name == 'img':
            attrs = self._parse_attrs(tag_str)
            src = attrs.get('src', '')
            if src:
                self.images.append({'alt': attrs.get('alt', ''), 'src': src})

    def get_text(self) -> str:
        text = ''.join(self.text_parts)
        # 合并多余空行
        text = re.sub(r'\n{3,}', '\n\n', text)
        text = re.sub(r'[ \t]+', ' ', text)
        return text.strip()


@app.route("/api/scrape", methods=["POST"])
def api_scrape():
    body = request.get_json(silent=True) or {}
    url = (body.get("url") or "").strip()
    if not url:
        return jsonify({"error": "请输入 URL"}), 400

    # 自动补全协议
    if not url.startswith("http://") and not url.startswith("https://"):
        url = "https://" + url

    # 基本 URL 校验
    if not re.match(r'^https?://[^\s/$.?#].[^\s]*$', url):
        return jsonify({"error": "URL 格式不正确"}), 400

    selector = (body.get("selector") or "").strip()

    t0 = time.time()

    try:
        req = Request(url, headers={
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                          "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        })
        with urlopen(req, timeout=30) as resp:
            html_bytes = resp.read()
            # 探测编码
            content_type = resp.headers.get("Content-Type", "")
            charset = None
            if "charset=" in content_type:
                charset = content_type.split("charset=")[-1].split(";")[0].strip()
            if not charset:
                # 从 HTML 中检测 meta charset
                m = re.search(rb'<meta\s+[^>]*charset=["\']?([^"\'>\s]+)', html_bytes[:2048], re.IGNORECASE)
                if m:
                    charset = m.group(1).decode("ascii", errors="ignore")
            if not charset:
                charset = "utf-8"
            try:
                html_text = html_bytes.decode(charset)
            except (UnicodeDecodeError, LookupError):
                html_text = html_bytes.decode("utf-8", errors="replace")
    except HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")[:200]
        return jsonify({"error": f"HTTP {e.code}: {err_body}"}), 400
    except URLError as e:
        return jsonify({"error": f"网络连接失败: {e.reason}"}), 400
    except Exception as e:
        return jsonify({"error": f"请求失败: {e}"}), 400

    # 如果指定了 CSS 选择器，用简单方法截取（不支持复杂选择器）
    if selector:
        # 简单实现：在 HTML 中找 `id="xxx"` 或 `class="xxx"`
        pattern = None
        if selector.startswith("#"):
            id_val = selector[1:]
            pattern = re.compile(
                r'<([a-z0-9]+)[^>]*?\s+id=["\']' + re.escape(id_val) + r'["\'][^>]*?>.*?</\1>',
                re.IGNORECASE | re.DOTALL
            )
        elif selector.startswith("."):
            cls_val = selector[1:]
            pattern = re.compile(
                r'<([a-z0-9]+)[^>]*?\s+class=["\'][^"\']*?\b' + re.escape(cls_val) + r'\b[^"\']*?["\'][^>]*?>.*?</\1>',
                re.IGNORECASE | re.DOTALL
            )
        if pattern:
            m = pattern.search(html_text)
            if m:
                html_text = m.group(0)

    # 解析 HTML
    parser = _ScrapeParser()
    parser.feed(html_text)
    content = parser.get_text()
    raw_length = len(html_text)

    elapsed = round(time.time() - t0, 2)

    return jsonify({
        "success": True,
        "url": url,
        "title": parser.title.strip()[:200] or "",
        "content": content[:10000] if content else "",
        "links": parser.links[:100],
        "images": parser.images[:50],
        "raw_length": raw_length,
        "elapsed": elapsed,
    })


# ============================================================
# 网页爬虫 — 点击选择模式
# ============================================================

def _resolve_url(base: str, href: str) -> str:
    """将相对 URL 解析为绝对 URL。"""
    if not href or href.startswith("javascript:") or href.startswith("#") or href.startswith("data:"):
        return href
    if href.startswith("http://") or href.startswith("https://"):
        return href
    if href.startswith("//"):
        return "https:" + href
    # 相对于 base
    from urllib.parse import urljoin
    return urljoin(base, href)


def _sanitize_for_preview(html: str, base_url: str) -> str:
    """清理 HTML：移除脚本/事件处理器、将 URL 转为绝对路径。"""
    # 移除 script 标签及其内容
    html = re.sub(r'<script[^>]*?>.*?</script>', '', html, flags=re.IGNORECASE | re.DOTALL)
    # 移除事件处理器属性
    html = re.sub(r'\s+on\w+\s*=\s*["\'][^"\']*["\']', '', html, flags=re.IGNORECASE)
    # 处理 href
    html = re.sub(
        r'(<a\s[^>]*?href\s*=\s*["\'])([^"\']+)(["\'])',
        lambda m: m.group(1) + _resolve_url(base_url, m.group(2)) + m.group(3),
        html, flags=re.IGNORECASE
    )
    # 处理 src (img, source, video, audio, iframe, embed)
    html = re.sub(
        r'(<(?:img|source|video|audio|iframe|embed)\s[^>]*?src\s*=\s*["\'])([^"\']+)(["\'])',
        lambda m: m.group(1) + _resolve_url(base_url, m.group(2)) + m.group(3),
        html, flags=re.IGNORECASE
    )
    # 处理 link href (stylesheet 等)
    html = re.sub(
        r'(<link\s[^>]*?href\s*=\s*["\'])([^"\']+)(["\'])',
        lambda m: m.group(1) + _resolve_url(base_url, m.group(2)) + m.group(3),
        html, flags=re.IGNORECASE
    )
    # 处理 form action
    html = re.sub(
        r'(<form\s[^>]*?action\s*=\s*["\'])([^"\']+)(["\'])',
        lambda m: m.group(1) + _resolve_url(base_url, m.group(2)) + m.group(3),
        html, flags=re.IGNORECASE
    )
    # 处理 meta refresh/content
    html = re.sub(
        r'(url\s*=\s*["\']?)([^"\' >]+)',
        lambda m: m.group(1) + _resolve_url(base_url, m.group(2)) if not m.group(2).startswith("http") else m.group(0),
        html, flags=re.IGNORECASE
    )
    return html


# 注入到 srcdoc iframe 中的点击跟踪脚本
CLICK_TRACKER_SCRIPT = """
<script>
(function(){
  if (window._scrapeInjected) return;
  window._scrapeInjected = true;

  var currentEl = null;
  var highlightStyle = '3px solid #1677ff';

  function getCssPath(el) {
    var path = [];
    while (el && el !== document.body && el !== document.documentElement) {
      var sel = el.tagName.toLowerCase();
      if (el.id) {
        sel = '#' + CSS.escape(el.id);
        path.unshift(sel);
        break;
      }
      if (el.className && typeof el.className === 'string') {
        var cls = el.className.trim().split(/\\s+/).filter(function(c) {
          return c && !c.startsWith('scrape-');
        });
        if (cls.length > 0) {
          sel += '.' + cls.map(function(c) { return CSS.escape(c); }).join('.');
        }
      }
      var parent = el.parentElement;
      if (parent) {
        var siblings = Array.from(parent.children).filter(function(s) {
          return s.tagName === el.tagName;
        });
        if (siblings.length > 1) {
          var idx = siblings.indexOf(el) + 1;
          sel += ':nth-of-type(' + idx + ')';
        }
      }
      path.unshift(sel);
      el = el.parentElement;
      if (path.length > 15) break;
    }
    return path.join(' > ');
  }

  function getElementInfo(el) {
    var tag = el.tagName.toLowerCase();
    var info = {
      selector: getCssPath(el),
      tag: tag,
      text: (el.textContent || '').trim().substring(0, 200),
      hasLink: tag === 'a' || !!el.closest('a'),
      hasImage: tag === 'img' || !!el.querySelector('img'),
    };
    if (tag === 'a' && el.href) info.href = el.href;
    if (tag === 'img' && el.src) info.imgSrc = el.src;
    var aTag = tag === 'a' ? el : el.closest('a');
    if (aTag && aTag.href) info.linkHref = aTag.href;
    return info;
  }

  function clearHighlight() {
    if (currentEl) {
      currentEl.style.outline = '';
      currentEl.style.outlineOffset = '';
      currentEl.style.backgroundColor = '';
    }
    currentEl = null;
  }

  document.addEventListener('mouseover', function(e) {
    clearHighlight();
    currentEl = e.target;
    currentEl.style.outline = highlightStyle;
    currentEl.style.outlineOffset = '2px';
    currentEl.style.backgroundColor = 'rgba(22,119,255,0.06)';
  }, true);

  document.addEventListener('mouseout', function(e) {
    if (e.target === currentEl) {
      clearHighlight();
    }
  }, true);

  document.addEventListener('click', function(e) {
    e.preventDefault();
    e.stopPropagation();
    var info = getElementInfo(e.target);
    info.type = 'scrape_click';
    window.parent.postMessage(info, '*');
  }, true);

  // 禁用所有默认交互
  document.querySelectorAll('a, button, input, textarea, select, [role="button"]').forEach(function(el) {
    el.addEventListener('click', function(e) { e.preventDefault(); }, true);
  });
})();
</script>
"""


@app.route("/api/scrape/load", methods=["POST"])
def api_scrape_load():
    """加载网页并返回可在 iframe 中预览的 HTML。"""
    body = request.get_json(silent=True) or {}
    url = (body.get("url") or "").strip()
    if not url:
        return jsonify({"error": "请输入 URL"}), 400
    if not url.startswith("http://") and not url.startswith("https://"):
        url = "https://" + url
    if not re.match(r'^https?://[^\s/$.?#].[^\s]*$', url):
        return jsonify({"error": "URL 格式不正确"}), 400

    t0 = time.time()

    try:
        req = Request(url, headers={
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                          "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
            "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
            "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        })
        with urlopen(req, timeout=30) as resp:
            html_bytes = resp.read()
            content_type = resp.headers.get("Content-Type", "")
            charset = None
            if "charset=" in content_type:
                charset = content_type.split("charset=")[-1].split(";")[0].strip()
            if not charset:
                m = re.search(rb'<meta\s+[^>]*charset=["\']?([^"\'>\s]+)', html_bytes[:2048], re.IGNORECASE)
                if m:
                    charset = m.group(1).decode("ascii", errors="ignore")
            if not charset:
                charset = "utf-8"
            try:
                html_text = html_bytes.decode(charset)
            except (UnicodeDecodeError, LookupError):
                html_text = html_bytes.decode("utf-8", errors="replace")
    except HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")[:200]
        return jsonify({"error": f"HTTP {e.code}: {err_body}"}), 400
    except URLError as e:
        return jsonify({"error": f"网络连接失败: {e.reason}"}), 400
    except Exception as e:
        return jsonify({"error": f"请求失败: {e}"}), 400

    # 限制 HTML 大小
    if len(html_text) > 2 * 1024 * 1024:
        html_text = html_text[:2 * 1024 * 1024]

    # 清理并处理 URL
    safe_html = _sanitize_for_preview(html_text, url)

    # 提取标题
    title_m = re.search(r'<title[^>]*>([^<]+)</title>', safe_html, re.IGNORECASE | re.DOTALL)
    title = title_m.group(1).strip() if title_m else url

    # 注入点击跟踪脚本（放在 </body> 前）
    safe_html = safe_html.replace("</body>", CLICK_TRACKER_SCRIPT + "\n</body>")
    if "</body>" not in safe_html:
        safe_html += CLICK_TRACKER_SCRIPT + "\n</body></html>"

    elapsed = round(time.time() - t0, 2)

    return jsonify({
        "success": True,
        "url": url,
        "title": title[:200],
        "html": safe_html,
        "size": len(safe_html),
        "elapsed": elapsed,
    })


@app.route("/api/scrape/extract-element", methods=["POST"])
def api_scrape_extract_element():
    """根据 CSS 选择器和提取类型，从缓存页面中提取内容。"""
    body = request.get_json(silent=True) or {}
    url = (body.get("url") or "").strip()
    selector = (body.get("selector") or "").strip()
    extract_type = body.get("extract_type", "text")  # text | link | image

    if not url or not selector:
        return jsonify({"error": "参数不完整"}), 400

    t0 = time.time()

    # 重新获取页面（保持简单，每次都重新抓取）
    try:
        req = Request(url, headers={
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
                          "(KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
        })
        with urlopen(req, timeout=30) as resp:
            html_bytes = resp.read()
            content_type = resp.headers.get("Content-Type", "")
            charset = None
            if "charset=" in content_type:
                charset = content_type.split("charset=")[-1].split(";")[0].strip()
            if not charset:
                m = re.search(rb'<meta\s+[^>]*charset=["\']?([^"\'>\s]+)', html_bytes[:2048], re.IGNORECASE)
                if m:
                    charset = m.group(1).decode("ascii", errors="ignore")
            if not charset:
                charset = "utf-8"
            try:
                html_text = html_bytes.decode(charset)
            except (UnicodeDecodeError, LookupError):
                html_text = html_bytes.decode("utf-8", errors="replace")
    except Exception as e:
        return jsonify({"error": f"重新获取页面失败: {e}"}), 400

    # 使用正则 / 简单 CSS 选择器匹配来提取内容
    # 支持 #id 和 tag.class 以及 tag:nth-of-type(n) 组合
    result_data = None

    if extract_type == "text":
        result_data = _extract_text_by_selector(html_text, selector)
    elif extract_type == "link":
        result_data = _extract_links_by_selector(html_text, selector, url)
    elif extract_type == "image":
        result_data = _extract_images_by_selector(html_text, selector, url)
    else:
        return jsonify({"error": f"不支持的提取类型: {extract_type}"}), 400

    elapsed = round(time.time() - t0, 2)

    return jsonify({
        "success": True,
        "selector": selector,
        "extract_type": extract_type,
        "url": url,
        "data": result_data,
        "elapsed": elapsed,
    })


def _build_css_matcher(selector: str):
    """将简单 CSS 选择器转为用于搜索的正则/处理函数。
    支持的格式: tag, #id, .class, tag.class, tag:nth-of-type(n),
    以及链式组合如 div > p.class 或 div p。
    """
    parts = selector.split(" > ")
    if len(parts) == 1:
        parts = selector.split()
    return parts


def _match_element(html_after: str, selector_parts, base_url: str):
    """递归匹配 CSS 选择器部分，返回匹配的元素内容和属性。"""
    # 简化实现：逐级搜索
    current_html = html_after
    for i, part in enumerate(selector_parts):
        part = part.strip()
        is_last = (i == len(selector_parts) - 1)
        tag = re.match(r'^([a-z0-9]+)', part, re.IGNORECASE)
        tag_name = tag.group(1).lower() if tag else r'[a-z0-9]+'
        has_id = re.search(r'#([a-zA-Z0-9_-]+)', part)
        has_class = re.findall(r'\.([a-zA-Z0-9_-]+)', part)
        has_nth = re.search(r':nth-of-type\((\d+)\)', part)
        nth = int(has_nth.group(1)) if has_nth else 0

        # 构建属性条件
        attr_conditions = []
        if has_id:
            attr_conditions.append(f'id=["\']{re.escape(has_id.group(1))}["\']')
        for cls in has_class:
            attr_conditions.append(f'class=["\'][^"\']*\\b{re.escape(cls)}\\b[^"\']*["\']')

        attr_str = ''.join(f'[^>]*?{cond}' for cond in attr_conditions)

        if not is_last:
            # 只找开头标签，不包括内容
            pattern = re.compile(
                rf'<{tag_name}{attr_str}[^>]*?>',
                re.IGNORECASE | re.DOTALL
            )
        else:
            # 最后一个，提取完整元素
            pattern = re.compile(
                rf'<({tag_name})({attr_str})[^>]*?>.*?</\1>',
                re.IGNORECASE | re.DOTALL
            )

        matches = list(pattern.finditer(current_html))
        if not matches:
            return None

        if nth > 0 and nth <= len(matches):
            m = matches[nth - 1]
        else:
            m = matches[0]

        if is_last:
            return {
                "full": m.group(0),
                "inner": re.sub(r'^<[^>]+?>', '', re.sub(r'</[^>]+?>$', '', m.group(0))),
            }
        else:
            # 继续在匹配的开标签之后搜索
            tag_end = m.end()
            current_html = current_html[tag_end:]

    return None


def _extract_text_by_selector(html: str, selector: str) -> dict:
    parts = _build_css_matcher(selector)
    result = _match_element(html, parts, "")
    if not result:
        # 兜底：直接把选择器当正则搜索
        pattern = re.compile(rf'<([a-z0-9]+)[^>]*?\s+id=["\']{re.escape(selector.lstrip("#"))}["\'][^>]*?>.*?</\1>', re.IGNORECASE | re.DOTALL)
        m = pattern.search(html)
        if m:
            inner = re.sub(r'<[^>]+?>', '', m.group(0))
            inner = re.sub(r'\s+', ' ', inner).strip()
            return {"text": inner[:5000], "length": len(inner)}
        return {"text": "", "length": 0}

    # 提取纯文本
    text = re.sub(r'<[^>]+?>', '', result["full"])
    text = re.sub(r'\s+', ' ', text).strip()
    return {"text": text[:5000], "length": len(text)}


def _extract_links_by_selector(html: str, selector: str, base_url: str) -> dict:
    parts = _build_css_matcher(selector)
    result = _match_element(html, parts, base_url)
    if not result:
        return {"links": [], "count": 0}

    # 从匹配结果中提取所有链接
    links = []
    for m in re.finditer(r'<a\s[^>]*?href=["\']([^"\']+)["\'][^>]*?>.*?</a>', result["full"], re.IGNORECASE | re.DOTALL):
        href = _resolve_url(base_url, m.group(1))
        text = re.sub(r'<[^>]+?>', '', m.group(0))
        text = re.sub(r'\s+', ' ', text).strip()[:200]
        links.append({"href": href, "text": text})
    return {"links": links, "count": len(links)}


def _extract_images_by_selector(html: str, selector: str, base_url: str) -> dict:
    parts = _build_css_matcher(selector)
    result = _match_element(html, parts, base_url)
    if not result:
        return {"images": [], "count": 0}

    images = []
    for m in re.finditer(r'<img\s[^>]*?src=["\']([^"\']+)["\'][^>]*?>', result["full"], re.IGNORECASE):
        src = _resolve_url(base_url, m.group(1))
        alt_m = re.search(r'alt=["\']([^"\']*)["\']', m.group(0))
        alt = alt_m.group(1) if alt_m else ""
        images.append({"src": src, "alt": alt})
    return {"images": images, "count": len(images)}


# ============================================================
# 自然语言爬虫 — LLM 驱动（cloudscraper 版）
# ============================================================

def _nl_scraper_fetch(url: str) -> str:
    scraper = cloudscraper.create_scraper()
    headers = {"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
               "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Referer": "https://www.google.com/"}
    resp = scraper.get(url, headers=headers, timeout=30)
    resp.encoding = resp.apparent_encoding
    return resp.text


def _nl_scraper_trim(html: str, max_chars: int = 50000) -> str:
    if len(html) <= max_chars: return html
    half = max_chars // 2
    return html[:half] + "\n\n... [中间内容已截断] ...\n\n" + html[-half:]


def _nl_scraper_call_llm(html: str, prompt: str, url: str, engine: str) -> list:
    sys_msg = "你是一个数据提取助手。从 HTML 页面中提取用户指定的结构化数据。\n只输出 JSON 数组，每条记录一个对象，字段名用英文。\n找不到的字段用空字符串。不要输出任何解释。"
    user_msg = f"网页 URL: {url}\n\n提取需求: {prompt}\n\nHTML 内容:\n{html}\n\n请提取数据并以 JSON 数组格式返回。"
    if engine == "deepseek":
        api_url, api_key, model = DEEPSEEK_API_URL, DEEPSEEK_API_KEY, DEEPSEEK_MODEL
        messages = [{"role": "system", "content": sys_msg}, {"role": "user", "content": user_msg}]
    else:
        api_url, api_key, model = YUANBAO_API_URL, YUANBAO_API_KEY, YUANBAO_MODEL
        messages = [{"role": "user", "content": f"{sys_msg}\n\n{user_msg}"}]
    payload = json.dumps({"model": model, "messages": messages, "temperature": 0.05, "max_tokens": 8192}, ensure_ascii=False).encode("utf-8")
    req = Request(api_url, data=payload, headers={"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}, method="POST")
    with urlopen(req, timeout=120) as resp:
        data = json.loads(resp.read().decode("utf-8"))
    content = data["choices"][0]["message"]["content"].strip()
    if content.startswith("```"):
        lines = content.split("\n"); content = "\n".join(lines[1:-1]).strip()
    result = json.loads(content)
    if isinstance(result, dict):
        for val in result.values():
            if isinstance(val, list) and len(val) > 0: return val
        return [result]
    return result if isinstance(result, list) else []


@app.route("/api/nl-scrape", methods=["POST"])
def api_nl_scrape():
    body = request.get_json(silent=True) or {}
    url = (body.get("url") or "").strip()
    prompt = (body.get("prompt") or "").strip()
    engine = body.get("engine", "deepseek")
    if engine not in ("deepseek", "yuanbao"): engine = "deepseek"
    if not url: return jsonify({"error": "请输入 URL"}), 400
    if not prompt: return jsonify({"error": "请输入提取指令"}), 400
    if not url.startswith("http"): url = "https://" + url
    api_key = DEEPSEEK_API_KEY if engine == "deepseek" else YUANBAO_API_KEY
    if not api_key: return jsonify({"error": f"{engine} API Key 未配置"}), 400
    t_total = time.time()
    try:
        html = _nl_scraper_fetch(url)
    except Exception as e: return jsonify({"error": f"抓取失败: {e}"}), 400
    steps_fetch = round(time.time() - t_total, 2)
    try:
        records = _nl_scraper_call_llm(_nl_scraper_trim(html), prompt, url, engine)
    except Exception as e: return jsonify({"error": f"提取失败: {e}"}), 500
    steps_extract = round(time.time() - t_total - steps_fetch, 2)
    if not records:
        return jsonify({"success": True, "data": [], "count": 0, "engine": engine, "elapsed": round(time.time() - t_total, 2), "steps": {"fetch": steps_fetch, "extract": steps_extract}})
    df = pd.DataFrame(records)
    ts = int(time.time())
    xlsx_name = f"nl_scrape_{ts}.xlsx"
    xlsx_path = os.path.join(tempfile.gettempdir(), xlsx_name)
    df.to_excel(xlsx_path, index=False, engine="openpyxl")
    steps_xlsx = round(time.time() - t_total - steps_fetch - steps_extract, 2)
    return jsonify({"success": True, "data": records, "count": len(records), "columns": list(df.columns), "engine": engine, "elapsed": round(time.time() - t_total, 2), "steps": {"fetch": steps_fetch, "extract": steps_extract, "xlsx": steps_xlsx}, "download_url": xlsx_name})


@app.route("/downloads/<path:filename>")
def download_nl_scrape(filename):
    safe_name = os.path.basename(filename)
    filepath = os.path.join(tempfile.gettempdir(), safe_name)
    if not os.path.exists(filepath): return jsonify({"error": "文件不存在或已过期"}), 404
    return send_file(filepath, as_attachment=True, download_name=safe_name)


# ============================================================
# AI PPT 生成（增强版）
# ============================================================
from pptx import Presentation
from pptx.util import Inches, Pt
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import MSO_SHAPE


def _hex_to_rgb(hex_str, fallback=None):
    """#RRGGBB → RGBColor，非十六进制颜色返回 fallback 或 #cccccc"""
    if not hex_str or not isinstance(hex_str, str) or not hex_str.startswith("#"):
        c = fallback or "#cccccc"
        h = c.lstrip("#")
        if len(h) == 3: h = "".join(c*2 for c in h)
        return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
    h = hex_str.lstrip("#")
    if len(h) == 3: h = "".join(c*2 for c in h)  # #fff → #ffffff
    try:
        return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))
    except ValueError:
        return _hex_to_rgb(fallback or "#cccccc")


def _add_bg(slide, color):
    bg = slide.background; fill = bg.fill; fill.solid(); fill.fore_color.rgb = _hex_to_rgb(color)


def _add_shape_bg(slide, color, left, top, width, height):
    shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height)
    shape.fill.solid(); shape.fill.fore_color.rgb = _hex_to_rgb(color); shape.line.fill.background()
    return shape


def _add_textbox(slide, left, top, width, height, text, font_size=18, color="#333", bold=False, alignment=PP_ALIGN.LEFT):
    txBox = slide.shapes.add_textbox(Inches(left), Inches(top), Inches(width), Inches(height))
    tf = txBox.text_frame; tf.word_wrap = True
    p = tf.paragraphs[0]; p.text = text; p.font.size = Pt(font_size)
    p.font.color.rgb = _hex_to_rgb(color); p.font.bold = bold; p.alignment = alignment
    return txBox, tf


def _add_bullet_text(tf, text, font_size=16, color="#333", bold=False, space_after=8, level=0):
    p = tf.add_paragraph(); p.text = text; p.font.size = Pt(font_size)
    p.font.color.rgb = _hex_to_rgb(color); p.font.bold = bold; p.space_after = Pt(space_after); p.level = level
    return p


def _add_circle(slide, color, left, top, size):
    shape = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(left), Inches(top), Inches(size), Inches(size))
    shape.fill.solid(); shape.fill.fore_color.rgb = _hex_to_rgb(color); shape.line.fill.background()
    return shape


# 图片缓存：内存 + 磁盘两级缓存，避免重复下载
_IMAGE_MEM_CACHE = {}       # keyword -> cache_file_path
_IMAGE_CACHE_DIR = os.path.join(tempfile.gettempdir(), "ppt_img_cache")
os.makedirs(_IMAGE_CACHE_DIR, exist_ok=True)


def _fetch_image(keyword: str, output_path: str) -> bool:
    """根据关键词下载配图，带内存+磁盘两级缓存"""
    if not keyword:
        return False

    cache_key = hashlib.md5(keyword.encode()).hexdigest()[:16]
    cache_file = os.path.join(_IMAGE_CACHE_DIR, f"{cache_key}.jpg")

    # 1. 内存缓存命中
    if keyword in _IMAGE_MEM_CACHE:
        try:
            shutil.copy(_IMAGE_MEM_CACHE[keyword], output_path)
            return True
        except Exception:
            pass

    # 2. 磁盘缓存命中
    if os.path.exists(cache_file) and os.path.getsize(cache_file) > 1000:
        try:
            shutil.copy(cache_file, output_path)
            _IMAGE_MEM_CACHE[keyword] = cache_file
            return True
        except Exception:
            pass

    # 3. 下载
    seed = hashlib.md5(keyword.encode()).hexdigest()[:8]
    urls = [
        f"https://picsum.photos/seed/{seed}/800/600",
        f"https://picsum.photos/800/600?random={abs(hash(keyword)) % 1000}",
    ]
    for url in urls:
        try:
            resp = requests.get(url, timeout=10, headers={"User-Agent": "Mozilla/5.0"})
            if resp.status_code == 200 and len(resp.content) > 1000:
                # 写入目标路径
                with open(output_path, "wb") as f:
                    f.write(resp.content)
                # 同时写入磁盘缓存
                with open(cache_file, "wb") as f:
                    f.write(resp.content)
                _IMAGE_MEM_CACHE[keyword] = cache_file
                return True
        except Exception:
            continue
    return False


def _add_rounded_rect(slide, color, left, top, width, height):
    """添加圆角矩形"""
    shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Inches(left), Inches(top), Inches(width), Inches(height))
    shape.fill.solid(); shape.fill.fore_color.rgb = _hex_to_rgb(color); shape.line.fill.background()
    return shape


def _add_image_panel(slide, left, top, width, height, icon="📷", label="", bg_color="#e8e8e8"):
    """用形状模拟图片区域的装饰面板"""
    shape = _add_rounded_rect(slide, bg_color, left, top, width, height)
    # 内部装饰线
    inner = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(left+0.15), Inches(top+0.15), Inches(width-0.3), Inches(height-0.3))
    inner.fill.solid(); inner.fill.fore_color.rgb = _hex_to_rgb("#ffffff")
    inner.line.color.rgb = _hex_to_rgb("#d0d0d0"); inner.line.width = Pt(0.5)
    # 图标
    _add_textbox(slide, left+width*0.3, top+height*0.25, width*0.4, height*0.4, icon,
                 font_size=int(height*18), color="#bbbbbb", alignment=PP_ALIGN.CENTER)
    if label:
        _add_textbox(slide, left+0.1, top+height-0.45, width-0.2, 0.3, label,
                     font_size=9, color="#999999", alignment=PP_ALIGN.CENTER)
    return shape


def _add_bar_chart(slide, left, top, width, height, data, bar_color="#1677ff", bg_color="#f0f0f0"):
    """用矩形模拟简单的柱状图"""
    if not data: return
    n = len(data)
    max_val = max(data) if max(data) > 0 else 1
    bar_w = width / (n * 2.5)
    gap = bar_w * 0.8
    start_x = left + (width - (bar_w * n + gap * (n-1))) / 2
    for i, val in enumerate(data):
        bar_h = (val / max_val) * height * 0.8
        if bar_h < 0.1: bar_h = 0.1
        x = start_x + i * (bar_w + gap)
        y = top + height - bar_h
        _add_rounded_rect(slide, bar_color, x, y, bar_w, bar_h)


def _render_elements(slide, elements, ac, img_path, has_img):
    """根据 LLM 提供的元素数组渲染幻灯片（精细布局模式）"""
    if not elements: return False
    for el in elements:
        typ = el.get("type", "text")
        x = el.get("x", 0.5); y = el.get("y", 0.5)
        w = el.get("w", 5); h = el.get("h", 0.5)
        content = el.get("content", "")
        style = el.get("style", {})
        fs = style.get("font_size", 16)
        color = style.get("color", "#333333")
        bold = style.get("bold", False)
        align_map = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}
        align = align_map.get(style.get("align", "left"), PP_ALIGN.LEFT)
        bg = style.get("bg_color", "")
        children = el.get("children", [])

        if typ == "shape":
            shape_type = style.get("shape_type", "rect")
            if shape_type == "circle":
                _add_circle(slide, bg or ac, x, y, w)
            elif shape_type == "round_rect":
                _add_rounded_rect(slide, bg or ac, x, y, w, h)
            else:
                _add_shape_bg(slide, bg or ac, Inches(x), Inches(y), Inches(w), Inches(h))

        elif typ == "divider":
            _add_shape_bg(slide, color, Inches(x), Inches(y), Inches(w), Inches(h))

        elif typ == "image":
            if has_img:
                try:
                    slide.shapes.add_picture(img_path, Inches(x), Inches(y), Inches(w), Inches(h))
                except Exception:
                    _add_image_panel(slide, x, y, w, h, "📷", content, "#e8e8e8")
            else:
                _add_image_panel(slide, x, y, w, h, "📷", content, "#e8e8e8")

        elif typ == "list":
            _, tf = _add_textbox(slide, x, y, w, h, "", fs, color)
            for i, item in enumerate(content if isinstance(content, list) else [content]):
                if isinstance(item, dict):
                    _add_bullet_text(tf, f"▸  {item.get('main','')}", fs, color, True, 3)
                    for s in item.get("sub", []):
                        _add_bullet_text(tf, f"    {s}", max(12, fs-2), "#666", space_after=2, level=1)
                else:
                    prefix = style.get("bullet_char", "  ")
                    _add_bullet_text(tf, f"{prefix}{item}", fs, color, space_after=6)

        else:  # text
            _add_textbox(slide, x, y, w, h, content, fs, color, bold, align)

    return True


def _create_ppt(path, slides):
    """专业版 PPT 生成 - 支持精细布局元素"""
    prs = Presentation(); prs.slide_width = Inches(13.333); prs.slide_height = Inches(7.5)
    ICONS = {"趋势":"📈","发展":"🚀","技术":"💻","AI":"🤖","数据":"📊","市场":"🏪","用户":"👥","产品":"📱",
             "创新":"💡","战略":"🎯","风险":"⚠️","机会":"🌟","增长":"📈","成本":"💰","效率":"⚡","团队":"🤝",
             "目标":"🎯","成果":"🏆","方案":"📋","建议":"💪","时间":"⏰","质量":"⭐","安全":"🔒","环保":"🌿","全球":"🌍"}
    def _ic(t): return next((v for k, v in ICONS.items() if k in t), "📌")

    # === 优化：并行下载所有配图 ===
    img_tasks = {}
    with ThreadPoolExecutor(max_workers=5) as executor:
        for idx, sd in enumerate(slides):
            img = sd.get("image_topic", "")
            if img:
                img_path = os.path.join(tempfile.gettempdir(), f"ppt_img_{idx}.jpg")
                img_tasks[idx] = (img_path, executor.submit(_fetch_image, img, img_path))
            else:
                img_tasks[idx] = (None, None)

        # 收集下载结果（这里会等待所有图片下载完成）
        img_results = {}
        for idx, (img_path, future) in img_tasks.items():
            if future is not None:
                try:
                    img_results[idx] = (img_path, future.result())
                except Exception:
                    img_results[idx] = (img_path, False)
            else:
                img_results[idx] = (None, False)

        # 记录本次生成的所有图片文件，后续精确清理
        _own_images = [p for p, _ in img_results.values() if p is not None]

        # === 第二步：渲染所有幻灯片（图片已准备好）===
        for idx, sd in enumerate(slides):
            st = sd.get("type", "content"); title = sd.get("title","")
            pts = sd.get("content",[]); sub = sd.get("subtitle",""); hl = sd.get("highlight","")
            ac = sd.get("accent_color","#1677ff"); bg = sd.get("bg_color","#0f0c29")
            if isinstance(pts, str): pts = [pts]
            slide = prs.slides.add_slide(prs.slide_layouts[6])

            # 使用预下载的配图（来自并行下载阶段）
            img_path, has_img = img_results.get(idx, (None, False))

            # 如果 LLM 提供了精细布局元素，使用元素渲染模式
            elements = sd.get("elements")
            if elements and _render_elements(slide, elements, ac, img_path, has_img):
                continue  # 跳过默认布局

            def _add_photo_or_panel(left, top, w, h, icon, label, fallback_bg="#e8e8e8"):
                if has_img:
                    try:
                        slide.shapes.add_picture(img_path, Inches(left), Inches(top), Inches(w), Inches(h))
                        return
                    except Exception:
                        pass
                _add_image_panel(slide, left, top, w, h, icon, label, fallback_bg)

            if st in ("cover","title"):
                _add_bg(slide, bg)
                _add_circle(slide, "#ffffff", 10.5, -1.5, 4.5)
                slide.shapes[-1].fill.fore_color.brightness = 0.95
                _add_circle(slide, ac, 9.0, 4.5, 2.0)
                slide.shapes[-1].fill.fore_color.brightness = 0.3
                # 封面配图（真实图片优先）
                _add_photo_or_panel(9.5, 4.0, 3.0, 2.5, _ic(title), sd.get("image_topic",""), bg)
                _add_textbox(slide, 1.5, 2.0, 10, 1.5, title, 46, "#fff", True, PP_ALIGN.LEFT)
                if sub: _add_textbox(slide, 1.5, 3.6, 10, 0.6, sub, 22, "#b0b0ff")
                if pts: _add_textbox(slide, 1.5, 4.5, 8, 0.5, pts[0], 16, "#8888cc")
                _add_shape_bg(slide, ac, Inches(0), Inches(7.3), Inches(13.333), Inches(0.2))
            elif st == "agenda":
                _add_bg(slide, "#f8f9fc")
                _add_shape_bg(slide, ac, Inches(0), Inches(0), Inches(13.333), Inches(0.08))
                _add_textbox(slide, 1, 0.3, 11.333, 0.7, title, 32, "#1a1a2e", True)
                y = 1.3
                for i, item in enumerate(pts):
                    bc = "#ffffff" if i % 2 == 0 else "#f0f5ff"
                    _add_shape_bg(slide, bc, Inches(0.6), Inches(y), Inches(12.133), Inches(0.75))
                    _add_shape_bg(slide, ac, Inches(0.6), Inches(y), Inches(0.06), Inches(0.75))
                    _add_textbox(slide, 1.0, y + 0.15, 11.333, 0.5, f"{i+1}.  {item}", 18, "#333")
                    y += 0.82
            elif st == "section":
                _add_bg(slide, bg)
                _add_circle(slide, "#ffffff", 8.5, 3.5, 6)
                slide.shapes[-1].fill.fore_color.brightness = 0.85
                _add_textbox(slide, 1.5, 2.5, 10, 1.2, title, 38, "#fff", True, PP_ALIGN.CENTER)
                if sub: _add_textbox(slide, 1.5, 4.0, 10, 0.6, sub, 18, "#d0d0ff", alignment=PP_ALIGN.CENTER)
            elif st == "comparison":
                _add_bg(slide, "#f8f9fc")
                _add_textbox(slide, 0.8, 0.3, 11.733, 0.7, f"⚖️  {title}", 28, "#1a1a2e", True)
                _add_shape_bg(slide, ac, Inches(0.8), Inches(1.0), Inches(11.733), Inches(0.04))
                lt = sd.get("left_title","A"); rt = sd.get("right_title","B"); rc = sd.get("right_accent","#fa8c16")
                for xx, cc, ct, ci in [(0.6, ac, lt, sd.get("left",[])), (6.8, rc, rt, sd.get("right",[]))]:
                    _add_shape_bg(slide, "#fff", Inches(xx), Inches(1.4), Inches(5.8), Inches(5.2))
                    _add_shape_bg(slide, cc, Inches(xx), Inches(1.4), Inches(5.8), Inches(0.06))
                    _add_textbox(slide, xx+0.3, 1.5, 5.2, 0.5, ct, 22, cc, True)
                    _, tf = _add_textbox(slide, xx+0.3, 2.1, 5.2, 4.3, "", 15, "#333")
                    for item in ci: _add_bullet_text(tf, f"✅  {item}", 15, "#333", space_after=8)
                if hl:
                    _add_shape_bg(slide, "#fffbe6", Inches(0.6), Inches(6.7), Inches(12.133), Inches(0.5))
                    _add_textbox(slide, 0.9, 6.75, 11.533, 0.4, f"📌  {hl}", 14, "#d48806")
            elif st == "data":
                _add_bg(slide, bg)
                _add_circle(slide, ac, -1, -1, 3)
                _add_textbox(slide, 1, 0.4, 11.333, 0.7, title, 28, "#fff", True)
                if hl: _add_textbox(slide, 1, 2.2, 11.333, 1.5, hl, 56, sd.get("highlight_color","#ffd700"), True, PP_ALIGN.CENTER)
                y = 4.2
                for item in pts:
                    _add_shape_bg(slide, "#242444", Inches(1), Inches(y), Inches(11.333), Inches(0.55))
                    _add_textbox(slide, 1.3, y+0.08, 10.733, 0.4, f"{_ic(item)}  {item}", 16, "#ccc")
                    y += 0.62
            elif st == "quote":
                _add_bg(slide, bg)
                _add_shape_bg(slide, ac, Inches(0), Inches(0), Inches(13.333), Inches(0.06))
                _add_textbox(slide, 1.5, 1.5, 1, 1, '"', 72, ac, True)
                _add_textbox(slide, 3, 2.0, 8.733, 2.5, title, 28, "#fff")
                if sub:
                    _add_shape_bg(slide, "#2a2a4a", Inches(3), Inches(4.8), Inches(7), Inches(0.04))
                    _add_textbox(slide, 3, 5.0, 8.733, 0.5, f"— {sub}", 18, "#888", alignment=PP_ALIGN.RIGHT)
            elif st in ("ending","thank_you"):
                _add_bg(slide, bg)
                _add_circle(slide, ac, 9, 5, 3.5)
                slide.shapes[-1].fill.fore_color.brightness = 0.3
                _add_shape_bg(slide, ac, Inches(0), Inches(0), Inches(13.333), Inches(0.06))
                _add_textbox(slide, 1.5, 2.5, 10.333, 1.2, title, 42, "#fff", True, PP_ALIGN.CENTER)
                if sub: _add_textbox(slide, 1.5, 3.8, 10.333, 0.6, sub, 20, "#b0b0ff", alignment=PP_ALIGN.CENTER)
            else:  # content
                _add_bg(slide, "#f8f9fc")
                _add_shape_bg(slide, sd.get("header_bg","#fff"), Inches(0), Inches(0), Inches(13.333), Inches(1.1))
                _add_shape_bg(slide, ac, Inches(0), Inches(1.1), Inches(13.333), Inches(0.05))
                _add_textbox(slide, 0.8, 0.2, 7, 0.7, f"{_ic(title)}  {title}", 28, sd.get("title_color","#1a1a2e"), True)
                if sub: _add_textbox(slide, 0.8, 0.75, 7, 0.3, sub, 14, "#999")
                # 右侧配图（真实图片优先）
                _add_photo_or_panel(8.5, 1.5, 4.3, 3.2, _ic(title), sd.get("image_topic",""), "#eef3ff")
                # 内容卡片
                _add_shape_bg(slide, "#fff", Inches(0.5), Inches(1.5), Inches(7.8), Inches(5.5))
                _, tf = _add_textbox(slide, 0.9, 1.8, 7.7, 5.0, "", 16, "#333")
                for item in pts:
                    if isinstance(item, dict):
                        _add_bullet_text(tf, f"▸  {item.get('main','')}", 18, ac, True, space_after=3)
                        for s in item.get("sub",[]): _add_bullet_text(tf, f"    {s}", 14, "#666", space_after=2, level=1)
                    else:
                        t = str(item)
                        if "：" in t[:25] or ":" in t[:25]:
                            sep = "：" if "：" in t[:25] else ":"
                            ps = t.split(sep, 1)
                            _add_bullet_text(tf, f"▸  {ps[0]}", 17, ac, True, space_after=2)
                            _add_bullet_text(tf, f"    {ps[1]}", 15, "#555", space_after=8, level=1)
                        else:
                            _add_bullet_text(tf, f"  {t}", 16, "#444", space_after=7)
                if hl:
                    _add_shape_bg(slide, "#fffbe6", Inches(0.5), Inches(6.8), Inches(12.333), Inches(0.5))
                    _add_textbox(slide, 0.8, 6.85, 11.733, 0.4, f"💡  {hl}", 14, "#d48806")

    # 保存 PPT
    prs.save(path)

    # 精确清理：只删除本次生成的文件，不扫描整个 temp 目录
    for _f in _own_images:
        try:
            if os.path.exists(_f):
                os.remove(_f)
        except Exception:
            pass


# ============================================================
# 豆包 API PPT 生成（支持文件上传 + 文本输入）
# ============================================================
ALLOWED_EXTENSIONS = {"txt", "pdf", "doc", "docx", "md", "csv", "xlsx", "pptx"}
UPLOAD_FOLDER = os.path.join(tempfile.gettempdir(), "doubao_ppt_uploads")
os.makedirs(UPLOAD_FOLDER, exist_ok=True)


def _allowed_file(filename):
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS


def _read_uploaded_file(filepath):
    """读取上传文件内容为文本，支持 txt/pdf/docx"""
    ext = filepath.rsplit(".", 1)[-1].lower() if "." in filepath else ""
    try:
        if ext == "txt":
            with open(filepath, "r", encoding="utf-8", errors="replace") as f:
                return f.read()
        elif ext == "md":
            with open(filepath, "r", encoding="utf-8", errors="replace") as f:
                return f.read()
        elif ext == "pdf":
            try:
                import pdfplumber
                text_parts = []
                with pdfplumber.open(filepath) as pdf:
                    for page in pdf.pages:
                        t = page.extract_text()
                        if t:
                            text_parts.append(t)
                return "\n".join(text_parts) if text_parts else None
            except ImportError:
                # fallback: 尝试用 PyPDF2
                try:
                    from PyPDF2 import PdfReader
                    reader = PdfReader(filepath)
                    return "\n".join(p.extract_text() or "" for p in reader.pages)
                except ImportError:
                    return "[PDF文件内容无法自动提取，请安装 pdfplumber 或 PyPDF2]"
            except Exception:
                return "[PDF文件读取失败]"
        elif ext in ("docx", "doc"):
            try:
                from docx import Document
                doc = Document(filepath)
                return "\n".join(p.text for p in doc.paragraphs if p.text.strip())
            except ImportError:
                return "[Word文件内容无法自动提取，请安装 python-docx]"
            except Exception:
                return "[Word文件读取失败]"
        elif ext == "csv":
            try:
                df = pd.read_csv(filepath)
                return df.to_string()
            except Exception:
                return "[CSV文件读取失败]"
        elif ext == "xlsx":
            try:
                df = pd.read_excel(filepath)
                return df.to_string()
            except Exception:
                return "[Excel文件读取失败]"
        elif ext == "pptx":
            # 从已有pptx提取文本
            try:
                prs = Presentation(filepath)
                texts = []
                for slide in prs.slides:
                    for shape in slide.shapes:
                        if hasattr(shape, "text") and shape.text.strip():
                            texts.append(shape.text)
                return "\n".join(texts)
            except Exception:
                return "[PPT文件读取失败]"
        return None
    except Exception as e:
        return f"[文件读取错误: {e}]"


@app.route("/api/generate-ppt-doubao", methods=["POST"])
def api_generate_ppt_doubao():
    """豆包API PPT生成 - 支持文件上传 + 文本输入"""
    t0 = time.time()

    # 处理表单数据（支持 multipart 和 JSON 两种方式）
    topic = ""
    file_text = ""
    uploaded_filename = ""

    if request.content_type and "multipart/form-data" in request.content_type:
        topic = (request.form.get("topic") or "").strip()
        save_dir = request.form.get("save_dir", "").strip()
        # 处理文件上传
        if "file" in request.files:
            f = request.files["file"]
            if f and f.filename and _allowed_file(f.filename):
                safe_name = secure_filename(f.filename)
                save_path = os.path.join(UPLOAD_FOLDER, f"{int(time.time())}_{safe_name}")
                f.save(save_path)
                uploaded_filename = f.filename
                file_text = _read_uploaded_file(save_path) or ""
                # 读取后清理临时文件
                try:
                    os.remove(save_path)
                except Exception:
                    pass
    else:
        body = request.get_json(silent=True) or {}
        topic = (body.get("topic") or "").strip()
        save_dir = (body.get("save_dir") or "").strip()

    # 确定最终保存目录：前端自定义路径 > PPT_DOWNLOAD_DIR
    # 先清理空白和引号
    save_dir = save_dir.strip("\"' ")
    if save_dir and os.path.isabs(save_dir):
        final_save_dir = save_dir
    else:
        final_save_dir = PPT_DOWNLOAD_DIR

    if not topic:
        return jsonify({"error": "请输入 PPT 主题或要求"}), 400

    if not DOUBAO_API_KEY:
        return jsonify({"error": "火山方舟(豆包) API Key 未配置，请检查 keys.txt 中是否有 ark- 开头的 key"}), 400

    # 构建系统提示词
    sys_msg = (
        "你是一位顶级的 PPT 设计专家兼内容策划师。根据用户提供的主题和要求，生成一份专业的幻灯片大纲。\n"
        "请严格输出 JSON 数组，不要加任何额外文字和代码块标记。\n\n"
        "=== 基础字段 ===\n"
        "每张幻灯片必须包含：type, title, content(要点数组), bg_color, accent_color\n"
        "可选：subtitle(副标题), highlight(底部金句), image_topic(配图主题)\n\n"
        "=== 幻灯片类型 ===\n"
        '1. "cover" — 封面\n'
        '2. "agenda" — 目录\n'
        '3. "content" — 正文（可带 image_topic）\n'
        '4. "comparison" — 对比\n'
        '5. "data" — 数据\n'
        '6. "quote" — 引用\n'
        '7. "section" — 章节过渡页\n'
        '8. "ending" — 结尾\n\n'
        "=== 精细布局模式（高阶用法）===\n"
        "每张幻灯片可以额外提供一个 elements 数组，精确控制每个元素的位置。\n"
        "如果提供 elements，则忽略上述基础渲染，完全按 elements 绘制。\n\n"
        "elements 数组中每个元素格式：\n"
        "{\n"
        '  "type": "text" | "shape" | "divider" | "image" | "list",\n'
        '  "x": 左侧距离(英寸), "y": 顶部距离(英寸),\n'
        '  "w": 宽度(英寸), "h": 高度(英寸),\n'
        '  "content": "文字内容",\n'
        '  "style": {\n'
        '    "font_size": 数字,\n'
        '    "color": "#RRGGBB",\n'
        '    "bold": true/false,\n'
        '    "align": "left"/"center"/"right",\n'
        '    "bg_color": "#RRGGBB",\n'
        '    "shape_type": "rect"/"circle"/"round_rect"(仅type=shape时)\n'
        "  },\n"
        '  "children": [{"main":"标题","sub":["详情"]}] (仅type=list时)\n'
        "}\n\n"
        "=== 设计要求 ===\n"
        "1. 第一张用 cover，最后一张用 ending\n"
        "2. 中间混合多种类型，总共 10-15 页\n"
        "3. 每页 3-5 个要点，内容充实专业\n"
        "4. 配色选一套统一风格：科技蓝(#0f0c29/#1677ff) 商务绿(#0a1628/#00a870) 轻奢金(#1a1200/#d4a017) 学术红(#1a0a0a/#c43a31) 极光紫(#0d0020/#722ed1)\n"
        '5. 尽量提供 image_topic 字段\n'
        '6. 至少 4 页尝试使用 elements 精细布局\n'
    )

    user_msg = f"PPT 主题/要求：{topic}"
    if file_text:
        # 截取文件内容前 50000 字符
        truncated = file_text[:50000]
        if len(file_text) > 50000:
            truncated += "\n\n[...内容已截断，仅保留前50000字符]"
        user_msg += f"\n\n=== 参考文件内容（{uploaded_filename}）===\n{truncated}"

    try:
        payload = json.dumps({
            "model": DOUBAO_MODEL,
            "messages": [
                {"role": "system", "content": sys_msg},
                {"role": "user", "content": user_msg}
            ],
            "temperature": 0.3,
            "max_tokens": 16384
        }, ensure_ascii=False).encode("utf-8")

        req = Request(DOUBAO_API_URL, data=payload, headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {DOUBAO_API_KEY}"
        }, method="POST")

        with urlopen(req, timeout=180) as resp:
            data = json.loads(resp.read().decode("utf-8"))

        raw = data["choices"][0]["message"]["content"].strip()
    except HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")[:500]
        hint = ""
        if "InvalidEndpointOrModel" in err_body or "does not exist" in err_body:
            hint = "请检查 engine_config.json 中的 doubao 模型配置，需在火山方舟控制台创建端点后填写 endpoint ID"
        if "ModelNotOpen" in err_body:
            hint = "账户 2129375634 未开通该模型。请前往 https://console.volcengine.com/ark 的「模型广场」找到该模型并点击「开通」，或在「模型推理」中创建接入点"
        return jsonify({"error": f"豆包API调用失败 (HTTP {e.code}): {err_body}. {hint}"}), 500
    except URLError as e:
        return jsonify({"error": f"豆包API网络错误: {e.reason}"}), 500
    except Exception as e:
        return jsonify({"error": f"豆包API调用异常: {str(e)}"}), 500

    t1 = round(time.time() - t0, 2)

    # 解析 LLM 返回的 JSON
    if raw.startswith("```"):
        raw = "\n".join(raw.split("\n")[1:-1]).strip()
    try:
        slides = json.loads(raw)
    except json.JSONDecodeError:
        return jsonify({"error": "豆包API返回格式异常，无法解析JSON", "raw": raw[:500]}), 500

    if isinstance(slides, dict):
        slides = [slides]
    if not slides:
        return jsonify({"error": "生成大纲失败"}), 500

    # 保证保存目录存在
    os.makedirs(final_save_dir, exist_ok=True)

    ts = int(time.time())
    fn = f"doubao_ppt_{ts}.pptx"
    fp = os.path.join(final_save_dir, fn)
    _create_ppt(fp, slides)
    # 记录文件保存路径，供下载路由使用
    _PPT_SAVE_DIR_MAP[fn] = final_save_dir

    t2 = round(time.time() - t0 - t1, 2)
    return jsonify({
        "success": True,
        "slides": len(slides),
        "topic": topic,
        "engine": "doubao",
        "model": DOUBAO_MODEL,
        "elapsed": round(time.time() - t0, 2),
        "steps": {"generate": t1, "pptx": t2},
        "download_url": fn,
        "save_dir": final_save_dir,
        "has_file": bool(file_text),
    })


@app.route("/api/download-doubao-ppt/<path:filename>")
def download_doubao_ppt(filename):
    """下载豆包生成的 PPT 文件（优先使用自定义路径，否则用 PPT_DOWNLOAD_DIR）"""
    safe_name = os.path.basename(filename)
    # 优先从文件名映射中查找
    saved_in = _PPT_SAVE_DIR_MAP.pop(safe_name, None) or PPT_DOWNLOAD_DIR
    filepath = os.path.join(saved_in, safe_name)
    if not os.path.exists(filepath):
        return jsonify({"error": f"文件不存在: {safe_name}"}), 404
    return send_file(filepath, as_attachment=True, download_name=safe_name)


@app.route("/api/ppt-doubao-dir")
def get_ppt_doubao_dir():
    """返回当前 PPT 下载路径"""
    return jsonify({"download_dir": PPT_DOWNLOAD_DIR})


# ============================================================
# CSV 智能查询（自然语言 → pandas 安全查询）
# ============================================================
import uuid as _uuid

_CSV_STORE = {}  # id -> {df, filename, columns, rows}


def _clean_nan(obj):
    """递归替换数据中的 NaN/Infinity 为 None，确保 JSON 合法"""
    if isinstance(obj, dict):
        return {k: _clean_nan(v) for k, v in obj.items()}
    elif isinstance(obj, list):
        return [_clean_nan(v) for v in obj]
    elif isinstance(obj, float):
        import math
        if math.isnan(obj) or math.isinf(obj):
            return None
        return obj
    return obj


def _detect_encoding(filepath):
    """检测 CSV 文件编码，低置信度时回退到常见中文编码"""
    import chardet
    with open(filepath, "rb") as f:
        raw = f.read(10000)
    result = chardet.detect(raw)
    enc = result.get("encoding", "") or ""
    confidence = result.get("confidence", 0) or 0
    # 置信度太低或检测出非中文编码 → 按中文 CSV 常见编码依次尝试
    if confidence < 0.5 or enc.lower() in ("koi8-u", "koi8-r", "iso-8859-1", "mac-roman"):
        # 常见中文编码：优先 GBK（中文 Windows 默认）
        for fallback in ("gbk", "utf-8", "gb18030", "utf-16"):
            try:
                pd.read_csv(filepath, encoding=fallback, nrows=1)
                return fallback
            except Exception:
                continue
    return enc if enc else "utf-8"


def _safe_query(question, csv_info):
    """调用 LLM 生成安全的查询指令，然后执行"""
    # 构建系统提示
    col_desc = "\n".join(f"  - {c['name']} ({c['type']}) — 示例值: {', '.join(str(v) for v in c['sample'][:3])}"
                         for c in csv_info["columns"])
    sys_msg = (
        "你是一个数据分析助手。根据用户的问题和CSV结构，生成结构化查询指令。\n\n"
        "CSV 结构：\n" + col_desc + f"\n共 {csv_info['rows']} 行\n\n"
        "请严格输出 JSON，不要添加任何额外文字和代码块标记，格式如下：\n"
        "{\n"
        '  "explanation": "对查询的简短中文说明",\n'
        '  "operations": [\n'
        "    { \"op\": \"操作名\", \"params\": { \"column\": \"列名\", ... } }\n"
        "  ]\n"
        "}\n\n"
        "可用操作：\n"
        '1. filter: 过滤行。params: column(列名), op(= / != / > / < / >= / <= / contains / in), value(值)\n'
        '2. sort: 排序。params: column(列名), order(asc/desc)\n'
        '3. group_by: 分组。params: column(列名)\n'
        '4. agg: 聚合。params: column(列名), func(sum/mean/count/min/max)\n'
        '5. limit: 限制行数。params: value(数字)\n'
        '6. select: 选择列。params: columns(列名数组)\n'
        '7. calc: 计算新列。params: expression(如"利润=收入-成本"), columns(涉及列名数组)\n\n'
        "规则：\n"
        "- 列名必须与CSV中的完全一致\n"
        "- filter 的 value 如果是字符串，用双引号包裹\n"
        "- 需要先 filter 再 group_by/agg，最后 sort + limit\n"
        "- 如果无法回答，将 operations 设为空数组并在 explanation 中说明\n"
    )
    user_msg = f"用户问题：{question}"

    try:
        payload = json.dumps({
            "model": DEEPSEEK_MODEL,
            "messages": [
                {"role": "system", "content": sys_msg},
                {"role": "user", "content": user_msg}
            ],
            "temperature": 0.1,
            "max_tokens": 4096,
        }, ensure_ascii=False).encode("utf-8")

        req = Request(DEEPSEEK_API_URL, data=payload, headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {DEEPSEEK_API_KEY}"
        }, method="POST")

        with urlopen(req, timeout=60) as resp:
            data = json.loads(resp.read().decode("utf-8"))
        raw = data["choices"][0]["message"]["content"].strip()
        if raw.startswith("```"):
            raw = "\n".join(raw.split("\n")[1:-1]).strip()
        plan = json.loads(raw)
    except Exception as e:
        return {"error": f"AI 分析失败: {str(e)}"}

    operations = plan.get("operations", [])
    if not operations:
        return {"error": plan.get("explanation", "无法理解该查询")}

    # 安全执行 pandas 操作
    df = _CSV_STORE.get(csv_info["id"], {}).get("df")
    if df is None:
        return {"error": "数据已过期，请重新上传"}

    try:
        result = df.copy()

        for op in operations:
            op_name = op.get("op")
            params = op.get("params", {})

            if op_name == "filter":
                col, op_sym, val = params["column"], params["op"], params.get("value")
                if op_sym == "=":
                    result = result[result[col] == val]
                elif op_sym == "!=":
                    result = result[result[col] != val]
                elif op_sym == ">":
                    result = result[pd.to_numeric(result[col], errors="coerce") > float(val)]
                elif op_sym == "<":
                    result = result[pd.to_numeric(result[col], errors="coerce") < float(val)]
                elif op_sym == ">=":
                    result = result[pd.to_numeric(result[col], errors="coerce") >= float(val)]
                elif op_sym == "<=":
                    result = result[pd.to_numeric(result[col], errors="coerce") <= float(val)]
                elif op_sym == "contains":
                    result = result[result[col].astype(str).str.contains(str(val), case=False, na=False)]
                elif op_sym == "in":
                    vals = val if isinstance(val, list) else [str(val)]
                    result = result[result[col].astype(str).isin([str(v) for v in vals])]

            elif op_name == "sort":
                col, order = params["column"], params.get("order", "asc")
                ascending = order == "asc"
                result = result.sort_values(by=col, ascending=ascending)

            elif op_name == "group_by":
                col = params["column"]
                result = result.groupby(col, as_index=False)

            elif op_name == "agg":
                col, func = params["column"], params.get("func", "count")
                if isinstance(result, pd.core.groupby.DataFrameGroupBy):
                    # 分组后聚合：直接应用 agg 函数
                    result = result[col].agg(func).reset_index()
                    # 清理 reset_index 带来的多余索引列
                    if "index" in result.columns and result.columns[0] == "index":
                        result = result.drop(columns=["index"])
                else:
                    # 无分组，整列统计单个值
                    val = getattr(result[col], func)()
                    result = pd.DataFrame({col: [val]})

            elif op_name == "limit":
                result = result.head(int(params["value"]))

            elif op_name == "select":
                result = result[params["columns"]]

            elif op_name == "calc":
                expr = params["expression"]
                result = result.copy()
                result.eval(expr, inplace=True)

        # 修复 group_by 后续未匹配 agg 的情况
        if isinstance(result, pd.core.groupby.DataFrameGroupBy):
            result = result.size().reset_index(name="count")

        total_rows = len(result)
        limit = 100
        result = result.head(limit)

        return {
            "explanation": plan.get("explanation", ""),
            "summary": f"返回 {len(result)} 条结果" + (f"（共 {total_rows} 条）" if total_rows > limit else ""),
            "results": _clean_nan(result.to_dict(orient="records")) if len(result) > 0 else [],
            "total_rows": total_rows,
            "limit": limit,
        }

    except Exception as e:
        return {"error": f"查询执行失败: {str(e)}"}


@app.route("/tool/csv-query")
def tool_csv_query():
    return render_template("tool_csv_query.html",
        yuanbao_ready=bool(YUANBAO_API_KEY), deepseek_ready=bool(DEEPSEEK_API_KEY))


@app.route("/api/csv-upload", methods=["POST"])
def api_csv_upload():
    """上传 CSV 文件并返回预览"""
    if "file" not in request.files:
        return jsonify({"error": "请选择 CSV 文件"}), 400
    f = request.files["file"]
    if not f or not f.filename:
        return jsonify({"error": "文件无效"}), 400
    if not f.filename.lower().endswith(".csv"):
        return jsonify({"error": "仅支持 .csv 格式"}), 400

    try:
        # 保存到临时文件
        tmp = os.path.join(tempfile.gettempdir(), f"csv_{int(time.time())}_{secure_filename(f.filename)}")
        f.save(tmp)

        # 检测编码
        try:
            import chardet
            enc = _detect_encoding(tmp)
        except ImportError:
            enc = "utf-8"

        # 尝试读取（使用检测到的编码）
        read_ok = False
        for try_enc in [enc, "gbk", "utf-8", "gb18030"]:
            try:
                df = pd.read_csv(tmp, encoding=try_enc)
                # 验证：检查列名是否包含乱码（中文CSV的列名不应含高位ASCII乱码字符）
                garbled = sum(1 for c in df.columns if any(ord(ch) > 127 and ch not in "，。、；：？！""''（）【】《》—…·" for ch in str(c)[:4]))
                if garbled > len(df.columns) * 0.3:
                    continue  # 超过30%的列名有乱码，换编码重试
                read_ok = True
                break
            except UnicodeDecodeError:
                continue

        if not read_ok:
            df = pd.read_csv(tmp, encoding="gbk")  # 最终保底
        os.remove(tmp)

        if df.empty:
            return jsonify({"error": "CSV 文件为空"}), 400

        # 构建列信息
        columns = []
        for col in df.columns:
            dtype = str(df[col].dtype)
            if "int" in dtype:
                dtype_label = "数字(int)"
            elif "float" in dtype:
                dtype_label = "数字(float)"
            elif "datetime" in dtype:
                dtype_label = "日期"
            else:
                dtype_label = "文本"
            sample = df[col].dropna().head(3).tolist()
            columns.append({"name": str(col), "type": dtype_label, "sample": sample})

        # 存储到内存
        cid = _uuid.uuid4().hex[:12]
        _CSV_STORE[cid] = {"df": df, "filename": f.filename, "columns": columns, "rows": len(df)}

        # 清理旧数据（只保留最近5个）
        while len(_CSV_STORE) > 5:
            _CSV_STORE.pop(next(iter(_CSV_STORE)))

        return jsonify({
            "id": cid,
            "filename": f.filename,
            "rows": len(df),
            "cols": len(columns),
            "columns": columns,
            "sample": _clean_nan(df.head(10).to_dict(orient="records")),
        })

    except Exception as e:
        return jsonify({"error": f"读取 CSV 失败: {str(e)}"}), 500


@app.route("/api/csv-query", methods=["POST"])
def api_csv_query():
    """自然语言查询 CSV 数据"""
    body = request.get_json(silent=True) or {}
    cid = (body.get("id") or "").strip()
    question = (body.get("question") or "").strip()

    if not cid or cid not in _CSV_STORE:
        return jsonify({"error": "请先上传 CSV 文件"}), 400
    if not question:
        return jsonify({"error": "请输入查询问题"}), 400

    csv_info = _CSV_STORE[cid]
    csv_info["id"] = cid

    columns_info = []
    for c in csv_info["columns"]:
        columns_info.append({
            "name": c["name"],
            "type": c["type"],
            "sample": c["sample"],
        })

    info = {
        "id": cid,
        "filename": csv_info["filename"],
        "rows": csv_info["rows"],
        "columns": columns_info,
    }

    result = _safe_query(question, info)
    return jsonify(result)


# ============================================================
# AI 文生图（DeepSeek 优化提示词 + 免费图生 API）
# ============================================================
@app.route("/tool/image")
def tool_image():
    return render_template("tool_image.html",
        yuanbao_ready=bool(YUANBAO_API_KEY), deepseek_ready=bool(DEEPSEEK_API_KEY))


@app.route("/api/generate-image", methods=["POST"])
def api_generate_image():
    """文生图：DeepSeek 优化提示词 → 调用图片生成"""
    body = request.get_json(silent=True) or {}
    prompt = (body.get("prompt") or "").strip()
    style = (body.get("style") or "").strip()
    size = (body.get("size") or "1024x1536").strip()

    if not prompt:
        return jsonify({"error": "请输入图片描述"}), 400
    if not DEEPSEEK_API_KEY:
        return jsonify({"error": "DeepSeek API Key 未配置"}), 400

    t0 = time.time()

    # Step 1: DeepSeek 优化提示词（中译英 + 增强细节）
    sys_msg = (
        "你是一个专业的 AI 图片提示词工程师。把用户的中文描述改写成优质的英文提示词。\n"
        "要求：\n"
        "1. 用英文输出，详细描述画面主体、背景、光线、色彩、构图\n"
        "2. 保留用户原有的核心元素，适当添加专业摄影/绘画术语\n"
        "3. 如果用户指定了风格，强调该风格特征\n"
        '4. 只输出优化后的提示词，不要任何额外文字、引号、解释\n'
    )
    if style:
        sys_msg += f"5. 用户指定的风格是：{style}，请在提示词中重点体现\n"
    user_msg = f"原始描述：{prompt}"

    try:
        payload = json.dumps({
            "model": DEEPSEEK_MODEL,
            "messages": [
                {"role": "system", "content": sys_msg},
                {"role": "user", "content": user_msg}
            ],
            "temperature": 0.3,
            "max_tokens": 1024,
        }, ensure_ascii=False).encode("utf-8")

        req = Request(DEEPSEEK_API_URL, data=payload, headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {DEEPSEEK_API_KEY}"
        }, method="POST")

        with urlopen(req, timeout=60) as resp:
            data = json.loads(resp.read().decode("utf-8"))
        enhanced = data["choices"][0]["message"]["content"].strip()
    except Exception as e:
        return jsonify({"error": f"DeepSeek 优化失败: {str(e)}"}), 500

    t1 = round(time.time() - t0, 2)

    # Step 2: 调用火山方舟豆包文生图 API（Seedream 4.5）
    img_dir = os.path.join(SCRIPT_DIR, "generated_images")
    os.makedirs(img_dir, exist_ok=True)
    img_filename = f"ai_img_{int(time.time())}.png"
    img_local_path = os.path.join(img_dir, img_filename)
    img_downloaded = False
    last_error = ""

    # Seedream 建议使用英文提示词，且对风格提示更敏感
    seedream_prompt = enhanced
    if style:
        seedream_prompt = f"{enhanced}, {style} style"

    try:
        # Seedream 要求图片像素数 >= 3686400（至少 1920x1920）
        w, h = [int(x) for x in size.split("x")]
        if w * h < 3686400:
            # 自动放大到最小尺寸
            ratio = w / h
            if ratio >= 1:
                w = max(w, 1920)
                h = int(w / ratio)
            else:
                h = max(h, 1920)
                w = int(h * ratio)
            # 确保像素足够
            while w * h < 3686400:
                w += 64
                h = int(w / ratio)
            size = f"{w}x{h}"

        payload = json.dumps({
            "model": "doubao-seedream-4-5-251128",
            "prompt": seedream_prompt,
            "size": size,
            "n": 1,
        }, ensure_ascii=False).encode("utf-8")

        req = Request("https://ark.cn-beijing.volces.com/api/v3/images/generations",
            data=payload,
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {DOUBAO_API_KEY}"
            }, method="POST")

        with urlopen(req, timeout=120) as resp:
            body = json.loads(resp.read().decode("utf-8"))

        # 从响应中提取图片 URL
        img_url_from_api = body.get("data", [{}])[0].get("url", "")
        if not img_url_from_api:
            raise Exception("API 未返回图片 URL")

        # 下载图片到本地
        img_req = Request(img_url_from_api, headers={"User-Agent": "Mozilla/5.0"})
        with urlopen(img_req, timeout=60) as img_resp:
            img_data = img_resp.read()
            if len(img_data) > 1000:
                with open(img_local_path, "wb") as f:
                    f.write(img_data)
                img_downloaded = True
            else:
                last_error = "图片数据不完整"
    except HTTPError as e:
        err_body = e.read().decode("utf-8", errors="replace")[:300]
        if "ModelNotOpen" in err_body:
            last_error = "文生图模型未开通，请在火山方舟控制台开通 doubao-seedream-4-5-251128"
        else:
            last_error = f"火山方舟 API 错误: {err_body}"
    except Exception as e:
        last_error = str(e)[:120]

    # 火山方舟失败时回退到 pollinations（带重试）
    if not img_downloaded and DOUBAO_API_KEY:
        from urllib.parse import quote as _q
        enhanced_enc = _q(enhanced)
        w, h = size.split("x")
        for attempt in range(3):
            for url in [
                f"https://image.pollinations.ai/prompt/{enhanced_enc}?width={w}&height={h}&nofeed=true",
                f"https://image.pollinations.ai/prompt/{_q(enhanced[:150])}?width={w}&height={h}&nofeed=true",
            ]:
                try:
                    svc_req = Request(url, headers={"User-Agent": "Mozilla/5.0"})
                    with urlopen(svc_req, timeout=60) as svc_resp:
                        ct = svc_resp.headers.get("Content-Type", "")
                        data = svc_resp.read()
                        if "image" in ct and len(data) > 1000:
                            with open(img_local_path, "wb") as f:
                                f.write(data)
                            img_downloaded = True
                            break
                except Exception:
                    continue
            if img_downloaded:
                break
            import time as _t
            _t.sleep(3 + attempt * 2)

    if not img_downloaded:
        # 如果都失败了，返回带提示词的结果（用户可自行用提示词在其他工具生成）
        return jsonify({
            "error": last_error or "图片生成失败",
            "enhanced_prompt": enhanced,
        }), 503

    t2 = round(time.time() - t0, 2)
    img_url = f"/generated_images/{img_filename}"

    return jsonify({
        "success": True,
        "image_url": img_url,
        "enhanced_prompt": enhanced,
        "engine": "doubao-seedream-4-5-251128",
        "size": size,
        "time": round(t2, 1),
        "steps": {"optimize": t1, "total": t2},
    })


# ============================================================
# 启动
# ============================================================
def get_local_ip():
    try:
        import socket
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        s.settimeout(2)
        s.connect(("8.8.8.8", 80))
        ip = s.getsockname()[0]
        s.close()
        return ip
    except Exception:
        return None


def print_banner(mode="dev"):
    if sys.platform == "win32":
        try:
            sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
        except Exception:
            pass

    out = []
    out.append("=" * 56)
    out.append("      AI 头脑风暴 — Flask 网站")
    out.append("=" * 56)
    out.append(f"  模式:   {mode.upper()}")
    out.append(f"  端口:   {PORT}")
    out.append(f"  主页:   http://localhost:{PORT}")
    local_ip = get_local_ip()
    if local_ip:
        out.append(f"  局域网:  http://{local_ip}:{PORT}")
    out.append(f"  线程:   10 (Waitress)")
    out.append("")
    if YUANBAO_API_KEY:
        out.append(f"  元宝API:     {YUANBAO_API_KEY[:8]}...{YUANBAO_API_KEY[-4:]} | {YUANBAO_MODEL}")
    else:
        out.append(f"  元宝API:     未配置")
    if DEEPSEEK_API_KEY:
        out.append(f"  DeepSeek:    {DEEPSEEK_API_KEY[:8]}...{DEEPSEEK_API_KEY[-4:]} | {DEEPSEEK_MODEL}")
    else:
        out.append(f"  DeepSeek:    未配置")
    if DOUBAO_API_KEY:
        out.append(f"  豆包(火山方舟): {DOUBAO_API_KEY[:8]}...{DOUBAO_API_KEY[-4:]} | {DOUBAO_MODEL}")
    else:
        out.append(f"  豆包(火山方舟): 未配置")
    out.append(f"  主引擎:  {PRIMARY_ENGINE}")
    out.append("")
    out.append("  路由:")
    out.append(f"    GET  /                     → 主页")
    out.append(f"    GET  /game/pacman          → 吃豆人游戏")
    out.append(f"    GET  /readme               → 项目文档")
    out.append(f"    GET  /tool/translate       → AI 翻译")
    out.append(f"    GET  /tool/scraper         → 智能爬虫")
    out.append(f"    GET  /tool/lang            → 语言陪练")
    out.append(f"    GET  /tool/video           → AI 视频")
    out.append(f"    GET  /tool/chat            → AI 聊天")
    out.append(f"    GET  /tool/address-check   → 地址校验工具")
    out.append(f"    POST /api/check            → 双引擎校验地址")
    out.append(f"    GET  /api/status           → API状态")
    out.append(f"    GET  /api/config           → 查看引擎配置")
    out.append(f"    POST /api/config           → 修改引擎配置")
    out.append(f"    POST /api/translate         → AI 翻译（多语言）")
    out.append(f"    POST /api/nl-scrape         → 自然语言爬虫（LLM驱动）")
    out.append(f"    POST /api/chat               → AI 聊天")
    out.append(f"    POST /api/language/check     → 语言陪练（语法检查）")
    out.append(f"    POST /api/video/generate     → AI 视频生成（提交）")
    out.append(f"    GET  /api/video/status/<id>  → AI 视频生成（查询）")
    out.append(f"    GET  /tool/ppt-doubao         → AI PPT（豆包版·可上传文档）")
    out.append(f"    POST /api/generate-ppt-doubao → AI 生成 PPT（豆包API·支持文件上传）")
    out.append(f"    GET  /api/download-doubao-ppt/<file> → 下载豆包PPT")
    out.append(f"    GET  /tool/csv-query          → CSV 智能查询（自然语言）")
    out.append(f"    POST /api/csv-upload           → CSV 上传与预览")
    out.append(f"    POST /api/csv-query            → CSV 自然语言查询")
    out.append(f"    GET  /tool/image               → AI 文生图（DeepSeek）")
    out.append(f"    POST /api/generate-image        → AI 生成图片")
    out.append("=" * 56)
    sys.stdout.write("\n".join(out) + "\n\n")
    sys.stdout.flush()


if __name__ == "__main__":
    import sys

    # Flask debug 模式下的 reloader 子进程: 跳过 banner 和 waitress 检测
    is_reloader_child = os.environ.get("WERKZEUG_RUN_MAIN") == "true"

    dev_mode = "--dev" in sys.argv

    if not dev_mode and not is_reloader_child:
        try:
            from waitress import serve
            print_banner("prod")
            sys.stdout.write("[启动] 生产模式 (Waitress + 10 线程)\n\n")
            sys.stdout.flush()
            serve(app, host="0.0.0.0", port=PORT, threads=20, connection_limit=100)
            sys.exit(0)
        except ImportError:
            print("[提示] Waitress 未安装，降级到 Flask 开发服务器\n")
            dev_mode = True

    if dev_mode or is_reloader_child:
        if not is_reloader_child:
            print_banner("dev")
            print("[启动] 开发模式 (Flask 内置服务器, threaded=True)\n")
        app.run(host="0.0.0.0", port=PORT, debug=True, threaded=True, use_reloader=False)
