"""POD 热点抓取 Agent —— 桌面 UI（Tkinter 轻量版）。

功能（简洁版）：
- 国家多选（US/GB/JP/AU）
- LLM 后端选择（mock/static/openai_compat/openai/deepseek/qwen/moonshot）
- 种子数量上限（style/related）
- 运行：后台线程调用 graph.agent.run_country，print 输出重定向到日志区
- 结果表格：国家/热点词/类别/风险/分数；双击看完整详情
- 打开产物目录

打包（PyInstaller）：
  pyinstaller -F -w ui_app.py --add-data "config.yaml;." --add-data "configs;configs" --add-data "prompts;prompts"
数据文件（config.yaml/configs/prompts）打进 _MEIPASS；output/ 与 .cache/ 写在 exe 旁运行目录。

自检：python ui_app.py --self-test（无 GUI，跑通 GB mock 核心链路，验证打包环境）。
"""
import contextlib
import io
import json
import os
import queue
import shutil
import sys
import threading
import time
from pathlib import Path

import tkinter as tk
from tkinter import filedialog, ttk, messagebox
import yaml

from graph.agent import run_country

COUNTRIES = ["US", "GB", "JP", "AU", "MX", "DE", "BR", "SA", "PL", "ES", "IT", "CA"]
# LLM 只适配 OpenAI 兼容协议：openai（真调用，可在下方配置自定义模型/URL/Key）/ mock（无 key 演示占位）
PROVIDERS = ["openai", "mock"]


def resource_root() -> Path:
    """数据根：开发=脚本目录；打包=_MEIPASS（config.yaml/configs/prompts 解压处）。"""
    if getattr(sys, "frozen", False):
        return Path(getattr(sys, "_MEIPASS", Path(sys.executable).parent))
    return Path(__file__).resolve().parent


def runtime_root() -> Path:
    """运行根（可写）：打包=exe 旁；开发=脚本目录。output/.cache 写这里。"""
    if getattr(sys, "frozen", False):
        return Path(sys.executable).resolve().parent
    return Path(__file__).resolve().parent


OUTPUT_ROOT = runtime_root() / "output"


def config_root() -> Path:
    """配置生效根：exe 旁的可编辑副本优先（config.yaml 存在时），缺失则用内置默认（_MEIPASS）。

    开发模式 = 脚本目录（即项目根，行为不变）。
    """
    if getattr(sys, "frozen", False):
        run = Path(sys.executable).resolve().parent
        if (run / "config.yaml").exists():
            return run
        return Path(getattr(sys, "_MEIPASS", run))
    return Path(__file__).resolve().parent


def ensure_defaults() -> None:
    """打包后首次运行：把内置默认 config.yaml/configs/prompts 复制到 exe 旁（已存在不覆盖），
    用户可直接编辑这份默认配置；exe 内置的作为兜底。"""
    if not getattr(sys, "frozen", False):
        return
    run = Path(sys.executable).resolve().parent
    data = Path(getattr(sys, "_MEIPASS", run))
    for rel in ("config.yaml", "configs", "prompts"):
        src = data / rel
        dst = run / rel
        if src.exists() and not dst.exists():
            try:
                if src.is_dir():
                    shutil.copytree(src, dst)
                else:
                    shutil.copy2(src, dst)
                print(f"[UI] 已生成默认配置: {dst}")
            except Exception as e:  # noqa: BLE001
                print(f"[UI] 默认配置生成失败 {rel}: {e}")


def load_config() -> dict:
    with open(config_root() / "config.yaml", encoding="utf-8") as f:
        return yaml.safe_load(f)


def load_spus(country: str = None) -> list:
    """从 db 读可选 SPU 列表（可按国家过滤；UI 选品下拉用）。"""
    try:
        from graph.product import list_spus
        pcfg = load_config().get("product") or {}
        dbp = resolve_data_path(pcfg.get("db_path", "db/spu_sku.db"))
        return list_spus(dbp, country) or []
    except Exception:
        return []


def load_colors(spu_code: str) -> list:
    """款号 → 颜色列表（UI 颜色下拉用）。"""
    try:
        from graph.product import list_colors
        pcfg = load_config().get("product") or {}
        dbp = resolve_data_path(pcfg.get("db_path", "db/spu_sku.db"))
        return list_colors(dbp, spu_code) or []
    except Exception:
        return []


def resolve_data_path(rel: str) -> Path:
    """数据文件相对路径解析：exe 旁自定义优先 → _MEIPASS 内置兜底。"""
    p = Path(rel)
    if p.is_absolute():
        return p
    for root in (runtime_root(), resource_root()):
        cand = root / p
        if cand.exists():
            return cand
    return resource_root() / p


# 全局日志文件：UI 启动时创建（logs/<时间戳>.log），所有 print（采集+运行）同时落盘
LOG_FILE = None


class StdoutRedirector(io.TextIOBase):
    """把 print 输出转发到 UI 队列 + 写入全局日志文件（带时间戳，详细记录每步含错误）。"""

    def __init__(self, q: queue.Queue):
        self._q = q

    def write(self, s: str) -> int:
        if s:
            self._q.put(("log", s))
            if LOG_FILE:
                try:
                    LOG_FILE.write(f"[{time.strftime('%H:%M:%S')}] {s}")
                    LOG_FILE.flush()
                except Exception:
                    pass
        return len(s)

    def flush(self):
        if LOG_FILE:
            try:
                LOG_FILE.flush()
            except Exception:
                pass


def apply_openai_cfg(config: dict, oai: dict) -> None:
    """把 UI 的 OpenAI 配置写入 llm_screen + compose。

    - API Key 不在 UI 显示：始终从 config.yaml（llm_screen.api_key / compose.api_key）
      或环境变量（LLM_API_KEY / OPENAI_API_KEY）读取。
    - LLM 与图像的 Base URL 独立（生图模型可能走不同网关）。
    """
    if not oai:
        return
    ls = config.setdefault("llm_screen", {})
    cp = config.setdefault("compose", {})
    if oai.get("llm_base_url"):
        ls["base_url"] = oai["llm_base_url"]
    if oai.get("img_base_url"):
        cp["base_url"] = oai["img_base_url"]
    if oai.get("llm_model"):
        ls["model"] = oai["llm_model"]
    if oai.get("image_model"):
        cp["model"] = oai["image_model"]


def _apply_provider_mode(config: dict, provider: str) -> None:
    """OpenAI 模式开关：openai=全链路真 LLM/真生图；mock=全 mock 演示。

    联动：seed_provider / llm_screen.provider / compose.backend / product.backend。
    API Key 已在 config.yaml 或环境变量配置好（用户已配）。
    """
    on = (provider or "").strip().lower() == "openai"
    config["seed_provider"] = provider or "mock"
    ls = config.setdefault("llm_screen", {})
    ls["provider"] = "openai" if on else "mock"
    cp = config.setdefault("compose", {})
    cp["backend"] = "openai" if on else "mock"
    pp = config.setdefault("product", {})
    pp["backend"] = "openai" if on else "mock"
    print(f"[UI] 模式开关: {'OpenAI（真 LLM + 真生图）' if on else 'Mock（演示）'} | "
          f"llm_screen={ls['provider']} compose={cp['backend']} product={pp['backend']}")


def fetch_keywords(country, provider, max_seeds, log_q, oai=None):
    """后台线程：仅跑「采集+去重过滤」链路（seed→fetch→filter），
    把过滤后的关键词（数量不定）实时回传 UI 显示。"""
    from graph.agent import build_country_config
    from graph.nodes.fetch_node import fetch_node
    from graph.nodes.filter_node import filter_node
    from graph.nodes.seed_node import seed_node

    config = load_config()
    config["seed_provider"] = provider
    cfg = config.setdefault("seed_provider_cfg", {})
    cfg["max_seeds"] = max_seeds
    apply_openai_cfg(config, oai)
    _apply_provider_mode(config, provider)   # OpenAI 模式开关（采集只用 seed，其余为后续统一）

    old_stdout = sys.stdout
    sys.stdout = StdoutRedirector(log_q)
    try:
        cc = build_country_config(config, country, config_root())
        state = {
            "country": country, "config": config, "country_config": cc,
            "prompts_dir": str(config_root() / "prompts" / country),
            "output_dir": str(OUTPUT_ROOT / country),
            "raw_rows": [], "filtered_rows": [], "errors": [], "stats": {},
        }
        log_q.put(("log", f"\n===== 采集热点 {country}（provider={provider}）=====\n"))
        st = seed_node(state)
        state["country_config"] = st.get("country_config") or cc
        state["stats"] = st.get("stats") or {}
        state["errors"] = list(st.get("errors") or [])
        ft = fetch_node(state)
        state["raw_rows"] = ft.get("raw_rows") or []
        state["errors"] += list(ft.get("errors") or [])
        fl = filter_node(state)
        kept = fl.get("filtered_rows") or []

        # 去重（大小写不敏感，保留首次出现顺序）+ 限长
        seen, keywords = set(), []
        for r in kept:
            t = (r.get("topic") or "").strip()
            if not t or t.lower() in seen:
                continue
            seen.add(t.lower())
            keywords.append({
                "topic": t, "source": r.get("source", ""),
                "kind": r.get("kind", ""), "raw_score": r.get("raw_score"),
            })
        log_q.put(("log", f"[{country}] 采集原始 {len(state['raw_rows'])} 条 → 规则过滤保留 {len(kept)} 条 → 去重后 {len(keywords)} 个关键词\n"))
        if keywords:
            # 采集成功 → ① 清除该国旧热点简报缓存（design_briefs.json），
            # 下次「运行」走完整流水线用新采集热点重建简报（fetch 用 collected 缓存，不重复撞 Google）
            out_dir = OUTPUT_ROOT / country
            cache_f = out_dir / "design_briefs.json"
            if cache_f.exists():
                try:
                    cache_f.unlink()
                    log_q.put(("log", f"[UI] 已清除 {country} 旧热点简报缓存，下次运行将使用新采集热点重建\n"))
                except Exception as e:  # noqa: BLE001
                    log_q.put(("log", f"[UI] 清除旧热点缓存失败: {e}\n"))
            # ② 把"过滤后"的关键词写入 collected_keywords.json（运行流水线 fetch 节点优先读取）
            try:
                out_dir.mkdir(parents=True, exist_ok=True)
                (out_dir / "collected_keywords.json").write_text(
                    json.dumps({"country": country,
                                "collected_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
                                "keywords": keywords}, ensure_ascii=False, indent=2),
                    encoding="utf-8")
                log_q.put(("log", f"[UI] 已缓存过滤后热点 {len(keywords)} 条（collected_keywords.json），运行将直接用\n"))
            except Exception as e:  # noqa: BLE001
                log_q.put(("log", f"[UI] 写入热点缓存失败: {e}\n"))
        log_q.put(("fetched", {"country": country, "keywords": keywords}))
    except Exception as e:  # noqa: BLE001
        import traceback
        log_q.put(("log", f"采集失败: {e}\n{traceback.format_exc()}\n"))
        log_q.put(("error", None))
    finally:
        sys.stdout = old_stdout
        try:
            _pack_cache(country, log_q)   # 采集完成 → 自动打包缓存/去重数据
        except Exception as e:  # noqa: BLE001
            log_q.put(("log", f"[UI] 缓存打包失败: {e}\n"))


def run_pipeline(countries, provider, max_seeds, log_q,
                 spu_code="", sku_code="", spu_count=0,
                 spu_tasks=None, oai=None, markup_percent=0.0, code_prefix="DG",
                 template_path=""):
    """后台线程：缓存热点产品流程（选定国家后不再全部重跑种子词/抓取）。

    1) 有缓存热点（output/<国家>/design_briefs.json）→ 直接加载，按 SPU 数量取未用热点，
       每款号一个热点，生成设计稿 → 三图合成 → 多模态标题 → 模板导出。
    2) 无缓存 → 先跑一次完整流水线生成缓存，再继续。
    spu_tasks: [{"spu": "DG004", "skus": "..."}] 多款号批量选品
    spu_count: 款号数量上限（≥1；热点数按它来）
    oai: {"llm_base_url","img_base_url","llm_model","image_model"}（Key 从 config/env 读取）
    markup_percent: 加价百分比（按 SKU 最低价，后续定价用）
    code_prefix: 货号前缀（OSS 上传 key：{国家}/{时间戳}/{前缀+3位计数}_{4位随机}.jpg，000 起最多 999）
    template_path: 用户上传的自定义商品上传模板（xlsx 绝对路径），后续模板导出从此模板解析
    """
    config = load_config()
    config["seed_provider"] = provider
    cfg = config.setdefault("seed_provider_cfg", {})
    cfg["max_seeds"] = max_seeds
    apply_openai_cfg(config, oai)
    # OpenAI 模式开关：provider=openai → 全链路真 LLM/真生图；mock → 全 mock 演示
    _apply_provider_mode(config, provider)
    if spu_code or sku_code or spu_count or spu_tasks or markup_percent or code_prefix or template_path:
        p = config.setdefault("product", {})
        if spu_code:
            p["spu_code"] = spu_code
        if template_path:
            p["template_path"] = template_path          # 自定义模板：template_export 从此解析
        if sku_code:
            p["sku_code"] = sku_code
        if spu_tasks:
            p["spu_tasks"] = spu_tasks
            p.pop("spu_code", None)   # 任务清单优先
        if spu_count:
            p["spu_count"] = spu_count
        if markup_percent:
            p["markup_percent"] = markup_percent
        if code_prefix:
            p["code_prefix"] = code_prefix
    # 任务扩展：每个「集合」（款-颜色集 + 独立数量 count）按自己数量复制 N 份（N=该集合设计数）；
    # 每份 skus 保留颜色集合（多色）→ 每个设计配全部颜色各出一张主图
    if spu_tasks or spu_code:
        base = [dict(t) for t in (spu_tasks or [{"spu": spu_code, "skus": sku_code}])]
        tasks_list = []
        for t in base:
            n = int(t.get("count") or 0) or spu_count or 1   # 每集合独立数量，无则全局
            for _ in range(n):
                tt = dict(t)
                tt.pop("count", None)
                tasks_list.append(tt)
        config.setdefault("product", {})["spu_tasks"] = tasks_list
        if spu_count:
            config.setdefault("product", {})["spu_count"] = spu_count
    old_stdout = sys.stdout
    sys.stdout = StdoutRedirector(log_q)
    results = {}
    task_ts = time.strftime("%Y%m%d%H%M%S")   # 任务开始时间戳（OSS 路径段）
    try:
        from graph.product_batch import load_cached_briefs, run_product_batch
        for c in countries:
            log_q.put(("log", f"\n===== 开始处理 {c}（缓存热点模式）=====\n"))
            # 只要有采集缓存（collected_keywords）或简报缓存 → 缓存模式：
            # run_product_batch 内部简报不足会自动「从采集缓存生成简报」，绝不触发 seed/fetch/Google
            has_cache = bool(load_cached_briefs(runtime_root() / "output" / c)) \
                or (runtime_root() / "output" / c / "collected_keywords.json").exists()
            if has_cache:
                out = run_product_batch(c, config, config_root(), runtime_root(),
                                        list(spu_tasks or []), spu_count, log_q,
                                        task_timestamp=task_ts)
                items = out.get("product") or []
                errs = out.get("errors") or []
                log_q.put(("log", f"[{c}] 缓存热点完成：{len(items)} 个产品（每个热点对应一款）\n"))
            else:
                log_q.put(("log", f"[{c}] 无任何采集缓存 → 走完整流水线（seed+fetch+screen，首次需 Google）…\n"))
                state = run_country(c, config, config_root(), output_root=runtime_root(), task_timestamp=task_ts)
                items = state.get("briefs", []) or []
                errs = state.get("errors") or []
                log_q.put(("log", f"[{c}] 完整流水线完成：简报 {len(items)} 条，兜底错误 {len(errs)}\n"))
            results[c] = items
        ts_dir = runtime_root() / "output" / countries[0] / task_ts
        log_q.put(("log", f"\n✅ 任务完成，产物文件夹：{ts_dir}\n"
                          f"   （缓存/去重记录在 {runtime_root() / 'output' / countries[0]} 根目录，不进任务文件夹）\n"))
        log_q.put(("done", results))
    except Exception as e:  # noqa: BLE001
        import traceback
        log_q.put(("log", f"运行失败: {e}\n{traceback.format_exc()}\n"))
        log_q.put(("error", None))
    finally:
        sys.stdout = old_stdout
        try:
            for c in countries:
                _pack_cache(c, log_q)   # 运行完成 → 自动打包缓存/去重数据
        except Exception as e:  # noqa: BLE001
            log_q.put(("log", f"[UI] 缓存打包失败: {e}\n"))


def _pack_cache(country: str, log_q=None) -> str:
    """运行/采集完成后，把该国缓存+去重数据打包成 zip（output/cache_packs/<国>_<时间戳>.zip），
    内含 design_briefs / used_designs / collected_keywords / products.json + .cache 关键缓存，
    方便直接查看缓存与去重效果。"""
    import zipfile
    src = OUTPUT_ROOT / country
    pack_dir = OUTPUT_ROOT / "cache_packs"
    pack_dir.mkdir(parents=True, exist_ok=True)
    stamp = time.strftime("%Y%m%d_%H%M%S")
    zip_path = pack_dir / f"{country}_{stamp}.zip"
    files = [
        ("design_briefs.json", "简报(热点+风格+提示词)"),
        ("used_designs.json", "已用记录(去重)"),
        ("collected_keywords.json", "采集过滤结果"),
        ("product/products.json", "产品产物清单"),
    ]
    with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
        for rel, _ in files:
            p = src / rel
            if p.exists():
                zf.write(p, f"output/{country}/{rel}")
        # .cache 关键缓存（种子/抓取，各取最新）
        cache_root = runtime_root() / ".cache"
        for sub in ("seeds", "google_trends"):
            d = cache_root / sub
            if not d.exists():
                continue
            picks = sorted(d.glob(f"*{country}*")) or []
            if sub == "google_trends":
                picks = sorted(d.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True)[:10]
            for f in picks:
                try:
                    zf.write(f, f".cache/{sub}/{f.name}")
                except Exception:
                    pass
    if log_q:
        log_q.put(("log", f"[UI] 缓存打包完成: {zip_path}\n"))
    return str(zip_path)


class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title("POD 热点抓取 Agent")
        self.geometry("920x660")
        self._q = queue.Queue()
        self._busy = False
        self._results = {}
        # 运行日志落盘：logs/<时间戳>.log（exe 旁 logs 目录），记录本次打开软件的所有日志
        global LOG_FILE
        try:
            log_dir = runtime_root() / "logs"
            log_dir.mkdir(parents=True, exist_ok=True)
            LOG_FILE = open(log_dir / f"{time.strftime('%Y%m%d_%H%M%S')}.log",
                            "a", encoding="utf-8", buffering=1)
        except Exception as e:  # noqa: BLE001
            LOG_FILE = None
            print(f"[UI] 日志文件创建失败（不影响运行）: {e}")
        self._build_ui()
        self._load_cache()
        self.after(100, self._poll)

    def _default_provider(self) -> str:
        """默认模式：config.yaml llm_screen.provider（用户已配 key → openai 默认）。"""
        try:
            return "openai" if (load_config().get("llm_screen") or {}).get("provider") == "openai" else "mock"
        except Exception:
            return "mock"

    # ---------- UI 构建 ----------
    def _build_ui(self):
        top = ttk.Frame(self, padding=8)
        top.pack(fill="x")
        ttk.Label(top, text="国家:").pack(side="left")
        self.country_var = tk.StringVar(value="US")
        self.country_combo = ttk.Combobox(top, textvariable=self.country_var,
                                          values=COUNTRIES, state="readonly", width=6)
        self.country_combo.pack(side="left", padx=4)
        self.country_combo.bind("<<ComboboxSelected>>", lambda e: self._on_country_selected())
        ttk.Label(top, text="  模式:").pack(side="left", padx=(14, 0))
        self.provider_var = tk.StringVar(value=self._default_provider())
        ttk.Radiobutton(top, text="OpenAI（真 LLM+生图）", value="openai",
                        variable=self.provider_var).pack(side="left", padx=2)
        ttk.Radiobutton(top, text="Mock 演示", value="mock",
                        variable=self.provider_var).pack(side="left", padx=2)
        ttk.Label(top, text="  种子数量:").pack(side="left", padx=(14, 0))
        self.seed_var = tk.StringVar(value="24")
        ttk.Entry(top, textvariable=self.seed_var, width=4).pack(side="left")
        # 右上角：热点状态（缓存时间 / 采集时间）
        self.hotspot_time_var = tk.StringVar(value="")
        ttk.Label(top, textvariable=self.hotspot_time_var, anchor="e",
                  foreground="#185FA5").pack(side="right")

        # —— OpenAI 配置说明（所有配置从 config.yaml 读取：key/URL/模型，前端不配置）——
        oai = ttk.LabelFrame(self, text="OpenAI 配置（全部读取 config.yaml：API Key / Base URL / 模型，不在前端配置）", padding=4)
        oai.pack(fill="x", padx=8, pady=(2, 0))
        ttk.Label(oai, text="LLM / 图像网关与模型在 config.yaml 的 llm_screen / compose 段配置",
                  foreground="#5F5E5A").pack(side="left")

        # —— 商品上传模板（必须上传后才能开始任务；template_export 从该模板解析并填充）——
        tpl = ttk.LabelFrame(self, text="商品上传模板（必须上传 .xlsx 后才能运行）", padding=4)
        tpl.pack(fill="x", padx=8, pady=(2, 0))
        ttk.Button(tpl, text="📁 选择模板…", command=self._choose_template).pack(side="left")
        self.template_path_var = tk.StringVar(value="")
        self.template_name_var = tk.StringVar(value="（未选择）")
        ttk.Label(tpl, textvariable=self.template_name_var, anchor="w",
                  foreground="#993C1D").pack(side="left", padx=(8, 0))

        # —— 选品面板：国家→款号→颜色→添加任务（支持多款号 + 数量上限）——
        sel = ttk.LabelFrame(self, text="选品（选定国家自动加载款号；款号 → 颜色 → 添加任务）", padding=6)
        sel.pack(fill="x", padx=8, pady=(4, 0))

        # 左：款号列表
        lf1 = ttk.Frame(sel)
        lf1.pack(side="left", padx=4)
        ttk.Label(lf1, text="款号（选定后自动加载颜色与最低价）:").pack(anchor="w")
        self.spu_list = tk.Listbox(lf1, selectmode="extended", height=5, width=14, exportselection=False)
        self.spu_list.pack(side="left")
        self.spu_list.bind("<<ListboxSelect>>", self._on_spu_selected)
        self.min_price_var = tk.StringVar(value="")
        ttk.Label(lf1, textvariable=self.min_price_var, anchor="w", foreground="#993C1D").pack(fill="x", pady=(4, 0))

        # 中：颜色多选（点击即多选）+ 添加按钮
        lf2 = ttk.Frame(sel)
        lf2.pack(side="left", padx=(10, 4))
        ttk.Label(lf2, text="当前款号颜色（点击多选，显示色名/最低价）:").pack(anchor="w")
        self.sku_list = tk.Listbox(lf2, selectmode="multiple", height=5, width=34, exportselection=False)
        self.sku_list.pack(side="left")
        b2 = ttk.Frame(lf2)
        b2.pack(side="left", padx=4)
        ttk.Button(b2, text="全选", command=self._select_all_colors).pack(fill="x")
        ttk.Button(b2, text="清空", command=lambda: self.sku_list.selection_clear(0, "end")).pack(fill="x", pady=2)
        ttk.Button(b2, text="＋添加选品", command=self._add_task).pack(fill="x", pady=(4, 0))
        ttk.Label(b2, text="该款SPU/设计数(1-N):", anchor="w").pack(fill="x", pady=(6, 0))
        self.count_var = tk.StringVar(value="10")
        ttk.Entry(b2, textvariable=self.count_var, width=6).pack(fill="x")

        # 右：任务清单
        lf3 = ttk.Frame(sel)
        lf3.pack(side="left", padx=4)
        ttk.Label(lf3, text="选品任务清单:").pack(anchor="w")
        self.task_stat_var = tk.StringVar(value="0 个任务")
        ttk.Label(lf3, textvariable=self.task_stat_var, anchor="w",
                  foreground="#993C1D").pack(anchor="w")
        self.task_list = tk.Listbox(lf3, height=5, width=40, exportselection=False)
        self.task_list.pack(side="left")
        b3 = ttk.Frame(lf3)
        b3.pack(side="left", padx=4)
        ttk.Button(b3, text="删选中", command=self._del_task).pack(fill="x")
        ttk.Button(b3, text="清空", command=self._clear_tasks).pack(fill="x", pady=2)

        # 加价百分比（供后续定价使用）+ 货号前缀
        lf4 = ttk.Frame(sel)
        lf4.pack(side="left", padx=(12, 0))
        ttk.Label(lf4, text="加价%:").pack(anchor="w")
        self.markup_var = tk.StringVar(value="0")
        ttk.Entry(lf4, textvariable=self.markup_var, width=7).pack(anchor="w", pady=(2, 0))
        ttk.Label(lf4, text="按 SKU 最低价加价", anchor="w", foreground="#5F5E5A").pack(anchor="w", pady=(4, 0))

        lf5 = ttk.Frame(sel)
        lf5.pack(side="left", padx=(12, 0))
        ttk.Label(lf5, text="货号前缀:").pack(anchor="w")
        self.code_prefix_var = tk.StringVar(value="DG")
        ttk.Entry(lf5, textvariable=self.code_prefix_var, width=8).pack(anchor="w", pady=(2, 0))
        ttk.Label(lf5, text="前缀+3位计数(000起)\nOSS: 国家/时间戳/货号_4位随机", anchor="w",
                  foreground="#5F5E5A").pack(anchor="w", pady=(4, 0))

        self._sku_map = {}
        self._tasks: list = []
        self._reload_spus()

        bar = ttk.Frame(self, padding=(8, 0))
        bar.pack(fill="x")
        self.fetch_btn = ttk.Button(bar, text="📥 采集热点", command=self._on_fetch)
        self.fetch_btn.pack(side="left")
        self.run_btn = ttk.Button(bar, text="▶ 运行", command=self._on_run)
        self.run_btn.pack(side="left", padx=6)
        ttk.Button(bar, text="打开产物目录", command=self._open_output).pack(side="left", padx=8)
        ttk.Button(bar, text="打开日志目录", command=self._open_logs).pack(side="left")
        ttk.Button(bar, text="清空日志", command=self._clear_log).pack(side="left")

        ttk.Label(self, text="运行日志:").pack(anchor="w", padx=8)
        self.log_text = tk.Text(self, height=8, state="disabled")
        self.log_text.pack(fill="x", padx=8)

        ttk.Label(self, text="设计简报（双击查看完整提示词）:").pack(anchor="w", padx=8)
        cols = ("country", "topic", "category", "risk", "score")
        self.tree = ttk.Treeview(self, columns=cols, show="headings", height=12)
        for col, title, w, anchor in zip(
            cols, ["国家", "热点词", "类别", "风险", "分数"],
            [52, 340, 100, 70, 64],
            ["center", "w", "w", "center", "center"],
        ):
            self.tree.heading(col, text=title)
            self.tree.column(col, width=w, anchor=anchor)
        self.tree.pack(fill="both", expand=True, padx=8, pady=(2, 8))
        self.tree.bind("<Double-1>", self._show_detail)

    def _log(self, s: str):
        self.log_text.config(state="normal")
        self.log_text.insert("end", s)
        self.log_text.see("end")
        self.log_text.config(state="disabled")

    def _reload_spus(self):
        """按当前所选国家加载该国款号到款号列表，并刷新颜色。"""
        country = self.country_var.get()
        self._spu_list = load_spus(country)
        self.spu_list.delete(0, "end")
        for s in self._spu_list:
            self.spu_list.insert("end", s["code"])
        if self._spu_list:
            self.spu_list.selection_set(0)
        self._on_spu_selected()

    def _on_country_selected(self):
        """切换国家：刷新该国款号 + 重新加载该国热点缓存。"""
        self._reload_spus()
        self._load_cache()

    def _on_spu_selected(self, _evt=None):
        sel = self.spu_list.curselection()
        if not sel:
            self.sku_list.delete(0, "end")
            self.min_price_var.set("")
            return
        spu = self._spu_list[sel[0]]["code"] if sel[0] < len(self._spu_list) else None
        if not spu:
            return
        colors = load_colors(spu)
        self._sku_map = {c["sku_code"]: c for c in colors}
        self.sku_list.delete(0, "end")
        for c in colors:
            price = c.get("price")
            price_txt = f"  ¥{price:g}" if price not in (None, "") else ""
            self.sku_list.insert("end", f"{c['sku_code']}  {c.get('color', '')}{price_txt}")
        # 最低价标注（该款号所有颜色 SKU 中的最低价）
        prices = [float(c["price"]) for c in colors if c.get("price") not in (None, "")]
        if prices:
            self.min_price_var.set(f"最低价: ¥{min(prices):g}")
        else:
            self.min_price_var.set("")
        if colors:
            self.sku_list.selection_set(0)   # 默认选中第一个颜色

    def _current_spu(self):
        sel = self.spu_list.curselection()
        if sel and sel[0] < len(self._spu_list):
            return self._spu_list[sel[0]]["code"]
        return ""

    def _add_task(self):
        """把当前选中的【款号】加入任务清单（可多选、可重复新增）。
        每个款号一条「集合」：skus=选中颜色集合（多色用逗号分隔），
        count=当前数量输入框值（每集合独立数量，= 该集合的 SPU/设计数）。"""
        sel_idx = self.spu_list.curselection()
        if not sel_idx:
            messagebox.showwarning("提示", "请先选中至少一个款号（可 Ctrl/Shift 多选）")
            return
        sel_spus = []
        for i in sel_idx:
            if i < len(self._spu_list):
                sel_spus.append(self._spu_list[i]["code"])
        if not sel_spus:
            return
        try:
            per_count = max(int(self.count_var.get() or 0), 1)
        except ValueError:
            per_count = 1
        raw_colors = [self.sku_list.get(i).split("  ")[0] for i in self.sku_list.curselection()]
        if not raw_colors:
            raw_colors = [self._sku_map and next(iter(self._sku_map))]
            if not raw_colors[0]:
                messagebox.showwarning("提示", "当前款号无颜色数据")
                return
        color_codes = [c.split("-")[-1] for c in raw_colors]   # 颜色码（如 BL01）
        for spu in sel_spus:
            colors_of_spu = load_colors(spu) or []
            skus = []
            for cc in color_codes:
                m = next((c["sku_code"] for c in colors_of_spu
                          if c["sku_code"].split("-")[-1] == cc), None)
                skus.append(m or (colors_of_spu[0]["sku_code"] if colors_of_spu else cc))
            # 每款一条「集合」（可重复新增），颜色集合逗号分隔 + 独立数量
            self._tasks.append({"spu": spu, "skus": ",".join(skus), "count": per_count})
        self._refresh_tasks()

    def _upsert_task(self, spu: str, skus: str):
        for t in self._tasks:
            if t["spu"] == spu:
                t["skus"] = skus
                break
        else:
            self._tasks.append({"spu": spu, "skus": skus})
        self._refresh_tasks()

    def _del_task(self):
        sel = self.task_list.curselection()
        if not sel:
            return
        for i in reversed(sel):
            self._tasks.pop(i)
        self._refresh_tasks()

    def _clear_tasks(self):
        self._tasks = []
        self._refresh_tasks()

    def _refresh_tasks(self):
        self.task_list.delete(0, "end")
        for t in self._tasks:
            skus = t["skus"] or "（首色）"
            cnt = t.get("count") or 0
            cnt_txt = f" × 设计数{cnt}" if cnt else ""
            self.task_list.insert("end", f"{t['spu']}  ←  {skus}{cnt_txt}")
        # 显示当前任务统计（集合数 × 各自数量 = 总 SPU/设计数）
        total = sum(int(t.get("count") or 0) for t in self._tasks)
        self.task_stat_var.set(f"{len(self._tasks)} 个集合 · 总设计数 {total}（每集合独立数量）")

    def _select_all_colors(self):
        self.sku_list.selection_set(0, "end")

    def _load_cache(self):
        """前台热点显示（两个独立配置文件）：
        ① collected_keywords.json = 完整采集池（全量，不随用量减少）
        ② used_designs.json = 已用记录 → 已用热点从显示列表移除（用完即消失）
        ③ design_briefs.json 只是运行简报（不决定前台显示），无 collected 时兜底。"""
        c = self.country_var.get()
        self._results = {}
        out_dir = OUTPUT_ROOT / c
        ga = ""
        # ① 完整采集池 → 剔除已用 → 显示"可用热点"
        try:
            cp = out_dir / "collected_keywords.json"
            if cp.exists():
                cdata = json.loads(cp.read_text(encoding="utf-8"))
                kws = cdata.get("keywords") or []
                # 已用 topic 集合（used_designs.json）
                used_topics = set()
                up = out_dir / "used_designs.json"
                if up.exists():
                    try:
                        ud = json.loads(up.read_text(encoding="utf-8")).get("used", []) or []
                        used_topics = {str(u.get("topic", "")).strip().lower() for u in ud}
                    except Exception:
                        pass
                avail = [k for k in kws
                         if str(k.get("topic", "")).strip().lower() not in used_topics]
                self._results[c] = [
                    {"topic": k.get("topic", ""),
                     "source": k.get("source", "collected"),
                     "raw_score": k.get("raw_score")} for k in avail
                ]
                ga = (f"{str(cdata.get('collected_at', ''))}"
                      f"（可用 {len(avail)} / 采集 {len(kws)}，已用 {len(used_topics)}）")
        except Exception:
            pass
        # ② 兜底：无 collected 时读简报
        if not self._results.get(c):
            try:
                p = out_dir / "design_briefs.json"
                if p.exists():
                    data = json.loads(p.read_text(encoding="utf-8"))
                    self._results[c] = data.get("design_briefs", []) or []
                    ga = str(data.get("generated_at", ""))
            except Exception:
                pass
        self._refresh_table()
        total = sum(len(x) for x in self._results.values())
        if total:
            self.hotspot_time_var.set(f"可用热点 {total} 条 · {ga or '时间未知'}")
            self._log(f"[UI] 已加载 {c} 可用热点 {total} 条（{ga or '未知'}）\n")
        else:
            self.hotspot_time_var.set(f"{c} 无热点（点「📥 采集热点」）")

    # ---------- 采集（独立步骤）----------
    def _oai_cfg(self) -> dict:
        """前端不再配置 OpenAI：所有 key/URL/模型从 config.yaml 读取（返回空，不覆盖配置）。"""
        return {}

    def _on_fetch(self):
        """仅采集+去重过滤热点关键词，直接显示在结果列表。"""
        if self._busy:
            return
        try:
            ms = int(self.seed_var.get())
        except ValueError:
            messagebox.showerror("参数错误", "种子数量必须是数字")
            return
        try:
            int(self.count_var.get())
        except ValueError:
            messagebox.showerror("参数错误", "数量上限必须是数字")
            return
        self._clear_log()
        self._results = {}
        self._busy = True
        self.fetch_btn.config(state="disabled", text="采集中…")
        self.run_btn.config(state="disabled")
        threading.Thread(
            target=fetch_keywords,
            args=(self.country_var.get(), self.provider_var.get(), ms, self._q,
                  self._oai_cfg()),
            daemon=True,
        ).start()

    # ---------- 运行 ----------
    def _choose_template(self):
        """选择自定义商品上传模板（.xlsx），后续 template_export 从该模板解析。
        导入时校验「经营站点」是否与当前国家一致（如 JP → 日本站），不一致则导入失败。"""
        path = filedialog.askopenfilename(
            title="选择商品上传模板",
            filetypes=[("Excel 模板", "*.xlsx"), ("Excel 文件", "*.xls")],
        )
        if not path:
            return
        # 站点校验：模板第 1-2 行含「经营站点」+ 站点名（美国站/日本站/英国站/澳洲站…）
        site_ok, site_name = self._check_template_site(path)
        if not site_ok:
            messagebox.showerror(
                "模板导入失败",
                f"模板经营站点「{site_name or '未知'}」与当前国家「{self.country_var.get()}」不一致，请重新导入对应站点模板。",
            )
            self._log(f"[UI] 模板导入失败：站点「{site_name or '未知'}」≠ 国家 {self.country_var.get()}\n")
            return
        self.template_path_var.set(path)
        self.template_name_var.set(f"已选: {Path(path).name}")
        self._log(f"[UI] 商品上传模板已选择: {path}（{site_name}）\n")

    def _check_template_site(self, path: str):
        """读取模板经营站点（A1=经营站点 → A2=站点名），返回 (是否匹配当前国家, 站点名)。"""
        # 国家 → 允许的站点关键词
        country_sites = {
            "US": ("美国站", "美国"),
            "GB": ("英国站", "英国"),
            "JP": ("日本站", "日本"),
            "AU": ("澳大利亚站", "澳洲站", "澳洲"),
            "MX": ("墨西哥站", "墨西哥"),
            "DE": ("德国站", "德国"),
            "BR": ("巴西站", "巴西"),
            "SA": ("沙特站", "沙特阿拉伯站", "沙特阿拉伯", "沙特"),
            "PL": ("波兰站", "波兰"),
            "ES": ("西班牙站", "西班牙"),
            "IT": ("意大利站", "意大利"),
            "CA": ("加拿大站", "加拿大"),
        }
        allowed = country_sites.get(str(self.country_var.get()).upper(), ())
        site_name = ""
        try:
            import openpyxl
            wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
            ws = None
            for _sn in wb.sheetnames:
                if "模版" in _sn or "模板" in _sn:
                    ws = wb[_sn]
                    break
            if ws is None:
                ws = wb.worksheets[0]
            # 定位「经营站点」表头所在列（第 1 行），站点名在下一行同列（如 A1=经营站点 → A2=美国站）
            site_col = None
            for c in range(1, 6):
                v = ws.cell(1, c).value
                if v and "站点" in str(v):
                    site_col = c
                    break
            if site_col is not None:
                v2 = ws.cell(2, site_col).value
                if v2:
                    site_name = str(v2).strip()
            wb.close()
        except Exception:  # noqa: BLE001
            return True, ""   # 读不到站点信息 → 放行（不阻塞）
        if not site_name:
            return True, ""   # 模板无站点信息 → 放行
        if any(k in site_name for k in allowed):
            return True, site_name
        return False, site_name

    def _missing_basemaps(self, tasks):
        """任务开始前校验 basemap/<款号>/<颜色>/ 是否存在底图；返回缺失的 (款号/颜色) 列表。"""
        cfg = {}
        try:
            import yaml as _yaml
            cfg = _yaml.safe_load((config_root() / "config.yaml").read_text(encoding="utf-8"))
        except Exception:  # noqa: BLE001
            pass
        bm_rel = (cfg.get("product") or {}).get("basemap_dir", "basemap")
        bm_root = None
        for r in (runtime_root(), config_root()):
            cand = r / bm_rel
            if cand.exists():
                bm_root = cand
                break
        if bm_root is None:
            bm_root = config_root() / bm_rel
        missing = []
        for t in tasks:
            spu = str(t.get("spu", ""))
            for sc in str(t.get("skus") or "").split(","):
                sc = sc.strip()
                if not sc:
                    continue
                d = bm_root / spu / sc
                if not d.is_dir() or not any(p.is_file() for p in d.iterdir()):
                    missing.append(f"{spu}/{sc}")
        return missing

    def _on_run(self):
        if self._busy:
            return
        if not self.template_path_var.get().strip():
            messagebox.showwarning("提示", "请先上传商品上传模板（📁 选择模板…）后才能开始任务")
            return
        countries = [self.country_var.get()]
        try:
            ms = int(self.seed_var.get())
            spu_count = int(self.count_var.get())
            markup = float(self.markup_var.get())
        except ValueError:
            messagebox.showerror("参数错误", "种子数量 / 款号数量 / 加价% 必须是数字")
            return
        if spu_count < 1:
            messagebox.showerror("参数错误", "款号数量必须 ≥ 1（不支持无上限）")
            return
        if markup < 0:
            messagebox.showerror("参数错误", "加价%不能为负数")
            return

        # 任务清单优先；空则用当前选中款号+颜色（保留旧行为）
        tasks = list(self._tasks)
        if not tasks:
            spu = self._current_spu()
            if not spu:
                messagebox.showwarning("提示", "请先添加选品（款号 → 颜色 → ＋添加选品）")
                return
            sel_colors = [self.sku_list.get(i).split("  ")[0] for i in self.sku_list.curselection()]
            if not sel_colors:
                messagebox.showwarning("提示", "请至少选择一个颜色")
                return
            tasks = [{"spu": spu, "skus": ",".join(sel_colors)}]

        # ① 兜底校验：任务开始前检查所有款号颜色的 basemap 底图是否存在；
        #    存在缺失 → 直接报错提醒，不开始任何任务
        missing = self._missing_basemaps(tasks)
        if missing:
            messagebox.showerror(
                "底图缺失，任务未开始",
                "以下款号颜色缺少底图（basemap/<款号>/<颜色>/ 无图片）：\n\n  "
                + "\n  ".join(missing[:10])
                + ("\n  …" if len(missing) > 10 else "")
                + "\n\n请补齐底图后重新运行（本次未执行任何任务）。",
            )
            self._log("[UI] 底图缺失校验未通过，任务未开始: " + ", ".join(missing[:10]) + "\n")
            return

        self._clear_log()
        self._results = {}
        self._busy = True
        self.run_btn.config(state="disabled", text="运行中…")
        self.fetch_btn.config(state="disabled")
        threading.Thread(
            target=run_pipeline,
            args=(countries, self.provider_var.get(), ms, self._q,
                  "", "", spu_count, tasks, self._oai_cfg(), markup,
                  self.code_prefix_var.get().strip() or "DG",
                  self.template_path_var.get().strip()),
            daemon=True,
        ).start()

    def _poll(self):
        try:
            n = 0
            while True:
                kind, payload = self._q.get_nowait()
                if kind == "log":
                    self.log_text.config(state="normal")
                    self.log_text.insert("end", payload)
                    self.log_text.see("end")
                    self.log_text.config(state="disabled")
                    n += 1
                    if n >= 200:   # 大批量日志时分批刷新，避免 UI 卡顿导致显示延迟
                        self.log_text.update_idletasks()
                        n = 0
                elif kind == "error":
                    self._busy = False
                    self.run_btn.config(state="normal", text="▶ 运行")
                    self.fetch_btn.config(state="normal", text="📥 采集热点")
                elif kind == "fetched":
                    self._results = {payload.get("country"): payload.get("keywords") or []}
                    self._busy = False
                    self.run_btn.config(state="normal", text="▶ 运行")
                    self.fetch_btn.config(state="normal", text="📥 采集热点")
                    self._refresh_table()
                    now = time.strftime("%Y-%m-%d %H:%M:%S")
                    self.hotspot_time_var.set(f"采集成功 {now} · {len(payload.get('keywords') or [])} 条")
                    self._log(f"[UI] 采集完成：{len(payload.get('keywords') or [])} 个关键词，时间 {now}\n")
                    messagebox.showinfo("采集完成",
                                        f"热点采集成功：{len(payload.get('keywords') or [])} 个关键词\n时间：{now}")
                elif kind == "done":
                    self._results = payload or {}
                    self._busy = False
                    self.run_btn.config(state="normal", text="▶ 运行")
                    self.fetch_btn.config(state="normal", text="📥 采集热点")
                    self._refresh_table()
                    self._load_cache()   # 运行完成 → 刷新前台：用掉的热点从列表移除
        except queue.Empty:
            pass
        self.after(100, self._poll)

    # ---------- 结果 ----------
    def _refresh_table(self):
        self.tree.delete(*self.tree.get_children())
        for c, briefs in self._results.items():
            for b in briefs:
                score = b.get("score")
                self.tree.insert("", "end", values=(
                    c, b.get("topic", ""),
                    b.get("design_category") or b.get("kind") or b.get("spu_code") or "(采集)",
                    b.get("risk_level") or "-",
                    round(float(score), 3) if score not in (None, "") else "-"))

    def _show_detail(self, _evt):
        sel = self.tree.selection()
        if not sel:
            return
        country, topic = self.tree.item(sel[0], "values")[0], self.tree.item(sel[0], "values")[1]
        for c, briefs in self._results.items():
            if c != country:
                continue
            for b in briefs:
                if b.get("topic") == topic:
                    self._detail_window(b)
                    return

    def _detail_window(self, b):
        win = tk.Toplevel(self)
        win.title(f"{b.get('topic', '')} 详情")
        win.geometry("780x540")
        txt = tk.Text(win, wrap="word", padx=10, pady=10)
        txt.pack(fill="both", expand=True)
        for zh, k in [
            ("国家", "country"), ("热点词", "topic"), ("类别", "design_category"),
            ("风险", "risk_level"), ("风险原因", "risk_reasons"), ("概念", "concept"),
            ("主体 motif", "motif"), ("艺术风格", "art_style"), ("配色", "color_palette"),
            ("构图", "composition"), ("负向", "negative_prompt"),
            ("image_prompt", "image_prompt"), ("composite_prompt", "composite_prompt"),
        ]:
            v = b.get(k)
            if v in (None, "", []):
                continue
            txt.insert("end", f"【{zh}】\n{json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v}\n\n")
        txt.config(state="disabled")

    # ---------- 工具 ----------
    def _clear_log(self):
        self.log_text.config(state="normal")
        self.log_text.delete("1.0", "end")
        self.log_text.config(state="disabled")

    def _open_output(self):
        os.makedirs(OUTPUT_ROOT, exist_ok=True)
        try:
            os.startfile(OUTPUT_ROOT)  # Windows
        except AttributeError:
            import subprocess
            subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", str(OUTPUT_ROOT)])

    def _open_logs(self):
        log_dir = runtime_root() / "logs"
        os.makedirs(log_dir, exist_ok=True)
        try:
            os.startfile(log_dir)
        except AttributeError:
            import subprocess
            subprocess.Popen(["open" if sys.platform == "darwin" else "xdg-open", str(log_dir)])


def self_test():
    """无 GUI 自检：验证打包环境（依赖/数据文件/核心链路），结果写入运行根 self_test_result.txt。"""
    import traceback
    lines: list = []

    def log(s):
        print(s)
        lines.append(str(s))

    try:
        log("=== POD Agent 自检 ===")
        log(f"数据根: {resource_root()}")
        log(f"运行根: {runtime_root()}")
        log(f"配置根: {config_root()} (exe 旁配置优先)")
        cfg = load_config()
        log(f"config 加载 OK | seed_provider: {cfg.get('seed_provider')} | 国家: {cfg.get('countries')}")
        # 自检不上传图床（避免 mock 占位图污染用户 OSS bucket）
        if "oss" in cfg:
            cfg["oss"]["enabled"] = False
        for c in COUNTRIES:
            p = config_root() / "prompts" / c / "aesthetics.yaml"
            log(f"  prompts/{c}: {'OK' if p.exists() else 'MISSING!'}")
        state = run_country("GB", cfg, config_root(), output_root=runtime_root())
        briefs = state.get("briefs", []) or []
        errs = state.get("errors") or []
        log(f"GB 流水线完成：简报 {len(briefs)} 条，兜底错误 {len(errs)}")
        if briefs:
            log(f"样例: {briefs[0].get('topic')} | {briefs[0].get('risk_level')} | {briefs[0].get('design_category')}")
        out = runtime_root() / "output" / "GB"
        log(f"产物目录: {out} 存在={'是' if out.exists() else '否'}")
        log("自检完成 OK")
    except Exception as e:  # noqa: BLE001
        log(f"自检失败: {e}")
        log(traceback.format_exc())
    finally:
        try:
            (runtime_root() / "self_test_result.txt").write_text(
                "\n".join(lines), encoding="utf-8")
        except Exception:
            pass


def main():
    # windowed 打包（console=False）下 sys.stdout/stderr 为 None，print 会崩；兜底为丢弃流
    if sys.stdout is None:
        sys.stdout = io.StringIO()
    if sys.stderr is None:
        sys.stderr = io.StringIO()
    ensure_defaults()
    if "--self-test" in sys.argv:
        self_test()
        return
    App().mainloop()


if __name__ == "__main__":
    main()
