"""节点 6.5：产品图生成（product）。

在 prompt_build 之后、compose 之前运行（prompt_build → product → compose）：
  热点提示词 → SPU/颜色选品 → basemap 底图 → 纯印花设计稿 → 模特试穿合成图。
产物写入 output/<country>/product/（底图拷贝 / *_design.png / *_model / *_composite.png / products.json）。

模板选择按 SPU.mark 驱动：
  mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT（图1=模特实拍 / 图2=纯印花设计 / 图3=平铺底图）
  mark!=1 → 旧两图合成模板 composite_prompt（底图 + 印花设计 → 平铺服装图）

配置（config.yaml product 段）：
  enabled                 开关（默认 true）
  db_path                 SPU/SKU 数据库（默认 db/spu_sku.db，相对路径按运行根解析）
  basemap_dir             底图目录（默认 basemap）
  material_library_dir    模特图库（默认 material_library）
  model_category          模特品类子目录（T-shirt）；为空/无图时取 material_library 第一个有图子目录
  brief_index             用第几个 safe 简报的提示词（0=第一个）
  spu_code / sku_code     指定款号/颜色编码（留空自动选第一个有本地底图的）
  spu_tasks               [{"spu": "DG004", "skus": "DG004-BL01,..."}] 多款号批量选品（优先于 spu_code）
  spu_count               款号数量上限（0=不限；取任务清单前 N 个）
  spu_per_color           true=每颜色一个 SPU 块；false=单 SPU 下挂所有颜色 SKU 变体
  backend                 图像后端：openai(真生图,需 key) / mock(占位) / 留空=跳过生成仅存底图

缺底图/模特图时跳过对应步骤并提示，不中断流水线；多款号逐个处理，单个失败不影响其它。
"""
import json
import random
import shutil
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional

from graph.paths import project_root, runtime_root
from graph.product import (
    find_basemap,
    find_first_model_folder,
    first_available_sku,
    list_colors,
    list_spus,
)
from graph.validate import with_fallback


_USED_LOCK = threading.Lock()   # used_designs.json 并发写锁
_MODEL_LOCK = threading.Lock()   # 同款共用模特缓存并发锁
_MODEL_CACHE: Dict[str, Any] = {}   # 同款共用模特：spu_code → model 路径


def _next_img_idx(prod_dir: Path, prefix: str) -> int:
    """货号续号：扫 prod_dir 已有 {prefix}{数字}* 文件，返回下一个起始序号（不覆盖旧产物）。"""
    import re
    max_n = -1
    try:
        if prod_dir.exists():
            for f in prod_dir.iterdir():
                m = re.match(rf"{re.escape(prefix)}(\d+)", f.stem)
                if m:
                    max_n = max(max_n, int(m.group(1)))
    except Exception:  # noqa: BLE001
        pass
    return max_n + 1

MODEL_WEAR_PROMPT = (
    "你是一个专业的电商AI视觉合成工具，执行“高保真印花与色彩移植/印花替换”:把图2的印花设计印到图3的衣服底图上，"
    "并让图1的模特穿上这件带有图2印花设计的图3底图衣服。\n"
    "【图片角色,按提交顺序,不要弄反】\n"
    "第一张图(图1)=模特实拍图(基底,要被替换衣服图案和颜色的目标区域,保留原有背景/人物/光影)；\n"
    "第二张图(图2)=纯印花设计稿(要印上去的图案内容,忽略其背景环境与无关元素,保留图案原始线条与色号)；\n"
    "第三张图(图3)=平铺衣服底图(只提取衣服本身的底色与面料材质;忽略平铺图的背景、桌面、环境、场景阴影等一切与衣服无关的元素,只保留衣服面料的颜色、质感和纹理)。\n"
    "最终效果为图1的模特穿着一件“颜色为图3底色、印有图2图案”的衣服。\n"
    "【执行规则】\n"
    "1.底色锁定:从图3平铺衣服中提取衣服底色与面料属性,该底色在最终合成中必须100%保持不变,"
    "严禁偏色、混入图1原衣服颜色或图2背景色。\n"
    "2.印花提取与叠加:从图2中精准提取纯印花图案主体,保留原始线条、色号、比例关系;"
    "将印花叠加到图3底色衣服上,形成“图3底色+图2印花”的合成面料。\n"
    "3.印花尺寸适配:印花的整体尺寸必须与衣服(图3)的面料面积成合理比例——"
    "居中印在胸/背/衣身的主体区域,占衣身面积约30%-45%,四周保留自然留白与衣摆、领口、肩线余量;"
    "严禁印花过大(撑满整件衣服、溢出领口袖口下摆)或过小(占比低于20%)。\n"
    "4.主体识别与遮罩:识别图1模特的服装穿着区域,忽略皮肤、头发、背景、配饰;"
    "将该区域视为“空白画布”,用上述合成面料(图3底色+图2印花)完整覆盖。图1原有衣服颜色与图案全部清除。\n"
    "5.精准贴合:合成面料严格跟随图1衣服的立体结构——有褶皱、身体扭转时印花相应变形;"
    "印花与新底色须“沉入”褶皱中,保留布料原有明暗纹理与物理属性,杜绝“贴纸感”与“平面涂色感”。\n"
    "6.光影融合:提取图1的环境光方向,调整合成面料的亮度/对比度与环境光匹配;"
    "图3底色在阴影区须自然变暗,在高光区须有布料反光;印花色彩受环境光影响产生相应明暗变化,但色号本身不偏移。\n"
    "7.纯净输出:仅输出一张最终合成图;严禁文字/水印/额外装饰;"
    "图1原本的背景、人物、构图及光影结构100%不变,仅替换图1衣服上的印花图案与衣服底色。"
)


def _resolve_sku(db_path, basemap_root, spu_code: str, sku_code: str, colors=None) -> Optional[str]:
    """选定 SKU：显式指定优先；否则第一个有本地底图的；再无则第一个颜色（便于模板导出）。"""
    if sku_code:
        return sku_code
    s = first_available_sku(db_path, basemap_root, spu_code)
    if s:
        return s
    if colors:
        return colors[0]["sku_code"]
    return None


def _template_out_path(prod_dir: Path, chosen_sku: str) -> Path:
    """模板输出路径：默认 {sku}_已填写.xlsx；若文件被其它程序占用（如已打开），自动换名加序号，避免导出失败。"""
    base = prod_dir / f"{chosen_sku}_已填写.xlsx"
    try:
        with open(base, "ab"):
            pass
        return base
    except OSError:
        pass
    for i in range(2, 100):
        cand = prod_dir / f"{chosen_sku}_已填写_{i}.xlsx"
        if not cand.exists():
            return cand
    return prod_dir / f"{chosen_sku}_已填写_{int(time.time())}.xlsx"


def _retry_image(fn, *args, attempts: int = 3, backoff=(5, 20, 40), **kwargs):
    """图像合成带退避重试（网关超载/超时常见）：成功返回 out_path；全部失败返回 None。"""
    import time as _t
    last = None
    for i in range(attempts):
        try:
            return fn(*args, **kwargs)
        except Exception as e:  # noqa: BLE001
            last = e
            if i < attempts - 1:
                _t.sleep(backoff[i])
    print(f"[product] 图像合成重试 {attempts} 次均失败: {last}")
    return None


def _process_spu(
    db_path, basemap_root, material_root, category, prod_dir, brief, ib,
    spu, sku_code, pcfg, errors, shared_design=None, title_backend=None, country="",
    img_code="", model_img=None,
) -> Optional[Dict[str, Any]]:
    """处理单个款号：选色 → 底图 → 设计稿 → (mark==1) 模特 → 合成 → 模板导出。
    shared_design: compose 节点生成的纯印花设计稿路径（图2）；为 None 时回退本节点 generate。
    img_code: 货号（前缀+3位计数）；本产品所有图片文件归入 prod_dir/{img_code}/ 子文件夹
              （按货号命名，包含该货号对应的所有图片）。
    返回 result dict；内部异常已兜底，不中断。
    """
    # 输出目录 = 货号子文件夹（product/DG000/…），该货号所有图片都放这里
    prod_dir = prod_dir / img_code
    prod_dir.mkdir(parents=True, exist_ok=True)
    colors = list_colors(db_path, spu["code"])
    valid_codes = {c["sku_code"] for c in colors}
    if sku_code:
        sku_codes = [s.strip() for s in sku_code.split(",") if s.strip() and s.strip() in valid_codes]
        if not sku_codes:
            print(f"[product] 颜色 {sku_code!r} 均不在款号 {spu['code']} 下，可选: {[c['sku_code'] for c in colors]}")
            return None
    else:
        first = _resolve_sku(db_path, basemap_root, spu["code"], "", colors)
        if first is None:
            print(f"[product] 款号 {spu['code']} 无颜色数据，可选: {[c['sku_code'] for c in colors]}")
            return None
        sku_codes = [first]

    # 模板模式自动判定（UI 不再选择）：单色=每色一SPU；多色=单SPU多色；CLI --single-spu 显式覆盖
    spu_per_color = pcfg.get("spu_per_color")
    if spu_per_color is None:
        spu_per_color = len(sku_codes) <= 1
    else:
        spu_per_color = bool(spu_per_color)

    chosen_sku = sku_codes[0]   # 生图用第一个颜色
    tag = f"[product/{img_code or chosen_sku}]"   # 日志前缀用货号（DG000），失败/进度一眼定位
    basemap_img = find_basemap(basemap_root, spu["code"], chosen_sku)
    if basemap_img is None:
        print(f"{tag}  底图缺失（{basemap_root}/{spu['code']}/{chosen_sku}/），将跳过印花/模特，仅导出模板")

    result: Dict[str, Any] = {
        "spu_code": spu["code"],
        "sku_code": chosen_sku,
        "color": next((c["color"] for c in colors if c["sku_code"] == chosen_sku), ""),
        "topic": brief.get("topic", ""),
        "art_style": brief.get("art_style", ""),
        "color_palette": brief.get("color_palette", ""),
        "basemap": str(basemap_img) if basemap_img else "",
        "composite_prompt": brief.get("composite_prompt", ""),
        "markup_percent": pcfg.get("markup_percent", 0),   # 加价%（后续定价用）
    }

    # 拷贝底图（图3）：命名 = 货号 + SKU code（如 DG003_DG004-BL01_basemap.jpg），
    # 同一 SKU 多个设计（数量 N）时底图文件也各自独立，不覆盖、不混淆
    if basemap_img is not None:
        base_copy = prod_dir / f"{img_code}_{chosen_sku}_basemap{basemap_img.suffix}"
        shutil.copy2(basemap_img, base_copy)
        result["basemap_copy"] = str(base_copy)
        print(f"{tag}  底图: {base_copy}")

    if ib is None:
        print(f"{tag}  未配置 product.backend（openai/mock），跳过印花/模特生成。")
    elif basemap_img is None:
        print(f"{tag}  无底图，跳过印花/模特生成。")
    else:
        # 5) 纯印花设计稿（图2）：直接用 compose 节点生成的共享设计稿（designs/ 已有，不拷贝）
        if shared_design and Path(shared_design).exists():
            design_path = shared_design
            result["design_path"] = design_path
            result["design_from"] = "compose"
            print(f"{tag}  设计稿（来自 compose 节点，designs/ 已有）: {design_path}")
        else:
            design_path = str(prod_dir / f"{img_code}_design.png")
            try:
                from graph.style_rules import sanitize_image_prompt, ensure_rebrand_hint
                prompt = ensure_rebrand_hint(brief, sanitize_image_prompt(brief.get("image_prompt", "")))
                ib.generate(prompt, design_path,
                            brief.get("composite_negative", ""),
                            size="1024x1024")   # 印花设计统一 1024x1024
                result["design_path"] = design_path
                result["design_from"] = "product"
                print(f"{tag}  纯印花设计稿已生成（product 节点）: {design_path}")
            except Exception as e:  # noqa: BLE001
                errors.append({"node": "product", "type": type(e).__name__, "message": f"设计稿生成失败: {e}", "trace": ""})
                print(f"{tag}  设计稿生成失败: {e}")

        # 6) 模板选择按 SPU.mark 决定：
        #    mark==1 → 新三图合成模板 MODEL_WEAR_PROMPT（图1模特 + 图2印花设计 + 图3底图）
        #    mark!=1 → 旧两图合成模板 composite_prompt（底图 + 印花设计）
        #    mark==1 统一只做三合一：无模特图时跳过合成，不再回退两图合成（印花+底图）
        if int(spu.get("mark") or 0) == 1:
            print(f"{tag}  SPU {spu['code']} mark=1 → 使用三图合成模板（图1模特+图2印花+图3底图）")
            if model_img is not None:
                # 任务级模特分配（product_node 预分配：一个 SPU 一个模特，SPU 数>模特数循环兜底）
                model_copy = prod_dir / f"{img_code}_model{model_img.suffix}"
                shutil.copy2(model_img, model_copy)
                result["model_path"] = str(model_copy)
                result["model_folder"] = model_img.parent.name
                print(f"{tag}  模特图（任务级分配，{model_img.parent.name}/）: {model_copy}")
            else:
                print(f"{tag}  material_library 无模特图，mark=1 统一只做三合一，跳过合成")
        else:
            print(f"{tag}  SPU {spu['code']} mark={spu.get('mark')} → 使用两图合成模板 composite_prompt（底图+印花）")

        # 7) 合成：
        #    有模特图 → 三图合成（图1=模特 / 图2=印花设计 / 图3=底图）
        #    mark=1 无模特图 → 跳过合成（统一只做三合一，不做印花+底图两图合成）
        #    mark!=1 无模特图 → 两图合成平铺服装图（图3=底图 + 图2=印花设计）
        if "design_path" not in result:
            print(f"{tag}  无设计稿，跳过合成")
        elif model_img is not None:
            composite_path = str(prod_dir / f"{img_code}_composite.png")
            try:
                # 三图合成：优先用简报的 composite_prompt（模板化三图文案），回退内置 MODEL_WEAR_PROMPT
                wear_prompt = (brief.get("composite_prompt") or "").strip() or MODEL_WEAR_PROMPT
                print(f"{tag}  三图合成提交中（3 参考图 img2img，网关处理约 2-6 分钟，请耐心等待）…")
                t0 = time.time()
                ib.print(wear_prompt, str(model_img), composite_path,
                         brief.get("composite_negative", ""),
                         extra_images=[design_path, str(basemap_img)],  # 图2印花, 图3底图
                         size="1504x2000")   # 三合一统一 1504x2000
                result["composite_path"] = composite_path
                print(f"{tag}  三图模特合成图已生成（耗时 {int(time.time()-t0)}s）: {composite_path}")
            except Exception as e:  # noqa: BLE001
                # 合成失败 → 带退避重试（网关超载/超时常见，重试 3 次）
                print(f"{tag}  三图合成失败，退避重试…: {e}")
                retried = _retry_image(ib.print, wear_prompt, str(model_img), composite_path,
                                       brief.get("composite_negative", ""),
                                       extra_images=[design_path, str(basemap_img)], size="1504x2000")
                if retried is not None:
                    result["composite_path"] = composite_path
                    print(f"{tag}  三图合成重试成功（耗时 {int(time.time()-t0)}s）: {composite_path}")
                else:
                    errors.append({"node": "product", "type": type(e).__name__, "message": f"模特合成失败(重试仍失败): {e}", "trace": ""})
                    print(f"{tag}  三图合成重试仍失败 → 跳过该产品（不生成标题/不写模板）: {e}")
                    return None
        else:
            # mark=1 无模特图 → 统一只做三合一，不做印花+底图两图合成
            if int(spu.get("mark") or 0) == 1:
                print(f"{tag}  mark=1 无模特图，跳过合成（统一只做三合一）")
            else:
                printed_path = str(prod_dir / f"{img_code}_printed.png")
                try:
                    # 两图合成（无模特，mark!=1）：用平铺印图文案（wearable_prompt），回退旧 composite_prompt
                    flat_prompt = (brief.get("wearable_prompt") or "").strip() or brief.get("composite_prompt", "")
                    ib.print(flat_prompt, str(basemap_img), printed_path,
                             brief.get("composite_negative", ""),
                             extra_images=[design_path],  # 图2印花
                             size="1504x2000")   # 合成统一 1504x2000
                    result["printed_path"] = printed_path
                    print(f"{tag}  平铺服装图已生成（无模特，底图+印花）: {printed_path}")
                except Exception as e:  # noqa: BLE001
                    print(f"{tag}  平铺服装图失败，退避重试…: {e}")
                    retried = _retry_image(ib.print, flat_prompt, str(basemap_img), printed_path,
                                           brief.get("composite_negative", ""),
                                           extra_images=[design_path], size="1504x2000")
                    if retried is not None:
                        result["printed_path"] = printed_path
                        print(f"{tag}  平铺服装图重试成功: {printed_path}")
                    else:
                        errors.append({"node": "product", "type": type(e).__name__, "message": f"平铺服装图生成失败(重试仍失败): {e}", "trace": ""})
                        print(f"{tag}  平铺服装图重试仍失败 → 跳过该产品（不生成标题/不写模板）: {e}")
                        return None

        # 7.2) 多色：单 SPU 多色时每个颜色再执行一次三合一（用各自底图），轮播图按颜色路由
        color_composites: List[Dict[str, Any]] = []
        if model_img is not None and result.get("composite_path"):
            # 首色主图始终记录（单色/多色都走模板填充）
            color_composites.append({"sku_code": chosen_sku, "color": result.get("color", ""),
                                     "composite_path": result["composite_path"]})
            for sc in sku_codes[1:]:
                bm = find_basemap(basemap_root, spu["code"], sc)
                if bm is None:
                    print(f"{tag}  颜色 {sc} 无底图，跳过该色三合一")
                    continue
                cp = str(prod_dir / f"{img_code}_{str(sc).split('-')[-1]}_composite.png")
                try:
                    ib.print(MODEL_WEAR_PROMPT, str(model_img), cp,
                             brief.get("composite_negative", ""),
                             extra_images=[design_path, str(bm)],  # 图2印花, 图3该色底图
                             size="1504x2000")   # 三合一统一 1504x2000
                    col = next((c["color"] for c in colors if c["sku_code"] == sc), sc)
                    color_composites.append({"sku_code": sc, "color": col, "composite_path": cp})
                    print(f"{tag}  颜色 {sc}（{col}）三合一已生成: {cp}")
                except Exception as e:  # noqa: BLE001
                    errors.append({"node": "product", "type": type(e).__name__,
                                   "message": f"颜色 {sc} 三合一失败: {e}", "trace": ""})
        result["color_composites"] = color_composites

    # 7.5) 多模态标题生成：合成图/平铺图/设计稿 → 中英双语 SEO 标题（按国家路由模板）
    if title_backend is not None:
        title_img = (result.get("composite_path") or result.get("printed_path")
                     or result.get("design_path"))
        if title_img:
            t = title_backend.generate_title(title_img, country=country)
            if t.get("en_title") or t.get("cn_title") or t.get("ja_title"):
                result["en_title"] = t.get("en_title", "")
                result["cn_title"] = t.get("cn_title", "")
                result["ja_title"] = t.get("ja_title", "")
                print(f"{tag}  标题已生成: EN={t.get('en_title','')[:50]}... "
                      f"CN={t.get('cn_title','')[:30]}... JA={t.get('ja_title','')[:30]}...")

    return result


@with_fallback("product")
def product_node(state: Dict[str, Any]) -> Dict[str, Any]:
    config = state["config"]
    pcfg = config.get("product") or {}
    stats = dict(state.get("stats") or {})
    if not pcfg.get("enabled", True):
        return {"product": [], "stats": stats}

    country = state["country"]
    briefs = state.get("briefs") or []
    output_dir = Path(state["output_dir"])                       # 本次任务产物（时间戳文件夹）
    cache_dir = Path(state.get("cache_dir") or output_dir)       # 缓存/去重（根目录）
    errors = list(state.get("errors") or [])

    # 路径解析：相对路径 → 优先运行根（exe 旁自定义数据），其次数据根（打包=_MEIPASS 内置）
    def _abs(key: str, default: str) -> Path:
        p = Path(pcfg.get(key, default))
        if p.is_absolute():
            return p
        for root in (runtime_root(), project_root()):
            cand = root / p
            if cand.exists():
                return cand
        return project_root() / p

    db_path = _abs("db_path", "db/spu_sku.db")
    basemap_root = _abs("basemap_dir", "basemap")
    material_root = _abs("material_library_dir", "material_library")
    category = pcfg.get("model_category", "T-shirt")
    brief_index = int(pcfg.get("brief_index", 0))
    spu_code = (pcfg.get("spu_code") or "").strip()
    sku_code = (pcfg.get("sku_code") or "").strip()
    spu_tasks = pcfg.get("spu_tasks") or []
    spu_count = int(pcfg.get("spu_count") or 0)

    # 1) 选简报（优先 safe）
    safe = [b for b in briefs if b.get("risk_level") == "safe"] or briefs
    if not safe:
        print("[product] 无可用简报，跳过产品图生成")
        return {"product": [], "stats": stats}
    brief = safe[brief_index] if brief_index < len(safe) else safe[0]

    # 2) 图像后端
    backend_name = (pcfg.get("backend") or "").strip()
    ib = None
    if backend_name:
        from graph.backends import get_image_backend
        ib = get_image_backend(backend_name)
        if ib is not None:
            ib.bind_config(config.get("compose") or {})  # 复用 compose.api_key/model/size

    # 3) 构造款号工作清单：spu_tasks（多款号）优先；否则 spu_code / 自动第一个
    spus = list_spus(db_path)
    worklist: List[tuple] = []   # (spu, skus, brief) —— 每个款号可绑定自己的热点简报
    by_topic = {str(b.get("topic", "")).lower(): b for b in briefs}
    if not spus:
        print(f"[product] db 无 SPU 数据（{db_path}），跳过")
        return {"product": [], "stats": stats}
    if spu_tasks:
        for ti, t in enumerate(spu_tasks):
            code = (t.get("spu") or t.get("spu_code") or "").strip()
            spu = next((s for s in spus if s["code"] == code), None)
            if spu is None:
                print(f"[product] 任务款号 {code} 不在 db，跳过（可选: {[s['code'] for s in spus][:12]}）")
                continue
            tb = None
            tp = (t.get("topic") or "").strip()
            if tp:
                tb = by_topic.get(tp.lower())
                if tb is None:
                    print(f"[product] 任务热点「{tp}」不在简报中，回退按序号分配")
            if tb is None:
                # 未指定热点（完整流水线）：按任务序号取不同简报，避免多个产品用同一个
                idx = min(ti, len(safe) - 1) if safe else brief_index
                tb = safe[idx] if safe else None
            if tb is None:
                print(f"[product] 无可用简报，跳过任务 {code}")
                continue
            worklist.append((spu, (t.get("skus") or "").strip(), tb))
        if not worklist:
            print("[product] 任务清单无有效款号，跳过产品图生成")
            return {"product": [], "stats": stats}
    elif spu_code:
        spu = next((s for s in spus if s["code"] == spu_code), None)
        if spu is None:
            print(f"[product] 款号 {spu_code} 不在 db，可选: {[s['code'] for s in spus][:12]}")
            return {"product": [], "stats": stats}
        worklist.append((spu, sku_code, brief))
    else:
        spu = next((s for s in spus if first_available_sku(db_path, basemap_root, s["code"])), None)
        if spu is None:
            print(f"[product] 没有任何款号存在本地底图（{basemap_root}/<款号>/<SKU.code>/）")
            return {"product": [], "stats": stats}
        worklist.append((spu, sku_code, brief))

    # 4) 不再按 spu_count 截断：worklist 已是扩展后的完整任务（数量 N = 每款设计数），
    #    全部任务进入队列处理（并发 5）。

    prod_dir = output_dir / "product"
    prod_dir.mkdir(parents=True, exist_ok=True)

    # 4.1) 任务级模特分配（material_library-<category>）：
    #      一个 SPU 对应一个模特；SPU（不同款）数 > 模特数 → 从全部模特循环兜底（允许重复）
    model_assign: Dict[str, Any] = {}
    _all_models: List[str] = []
    try:
        _folder, _all_models = find_first_model_folder(material_root, category)
    except Exception:  # noqa: BLE001
        _all_models = []
    if _all_models:
        seen_spu: Dict[str, str] = {}
        for _i, (_spu, _skus, _tb) in enumerate(worklist):
            code = _spu.get("code", "")
            if code not in seen_spu:
                seen_spu[code] = _all_models[_i % len(_all_models)]   # SPU>模特数 → 循环兜底
            model_assign[code] = seen_spu[code]
        print(f"[product] 任务级模特分配：{len(seen_spu)} 个 SPU，模特池 {len(_all_models)} 张"
              f"{'（SPU>模特，循环兜底）' if len(seen_spu) > len(_all_models) else ''}")

    # 5) compose 节点生成的共享设计稿（图2）：每个任务用自己的热点简报设计（tb.design_path），
    #    一个货号对应一个设计（多颜色共用该设计），不再所有产品共用第一个
    designs_dir = output_dir / "designs"
    designs_dir.mkdir(parents=True, exist_ok=True)
    designs_map = {str(d.get("topic", "")).strip().lower(): d.get("path", "")
                   for d in (state.get("designs") or []) if isinstance(d, dict)}
    for _d in (state.get("briefs") or []):
        if isinstance(_d, dict) and _d.get("design_path"):
            designs_map.setdefault(str(_d.get("topic", "")).strip().lower(), _d.get("design_path"))

    def _resolve_design(tb) -> Optional[str]:
        """任务绑定的简报 → 该热点自己的设计稿路径（按货号命名拷贝到 designs/）。"""
        topic = str(tb.get("topic", "")).strip().lower()
        src = designs_map.get(topic) or tb.get("design_path")
        if not src or not Path(src).exists():
            return None
        return src

    # 6) 逐个款号处理（每款号用自己绑定的热点简报）
    title_backend = None
    ls_cfg = config.get("llm_screen") or {}
    if (ls_cfg.get("provider") or "") not in ("", "mock"):
        try:
            from graph.llms import get_backend as _glb
            _tb = _glb(ls_cfg.get("provider"))
            _tb.bind_config(ls_cfg)
            if _tb.has_key:
                title_backend = _tb
        except Exception as e:  # noqa: BLE001
            print(f"[product] 标题后端初始化失败: {e}")

    results: List[Dict[str, Any]] = []
    prefix = str(pcfg.get("code_prefix") or "DG").strip()
    # 并发：默认每个 SPU 一个独立线程（任务数即并发数，提速）；
    # config.product.concurrency 显式配置可覆盖（如限流时设 3-5）；
    # 默认并发上限 5：全量并发（任务数）会压垮图像网关（10 并发 → 全部超时），
    # 超出上限的任务在线程池排队，逐批处理
    concurrency = int(pcfg.get("concurrency") or 0) or min(len(worklist), 5)
    print(f"[product] 并发 {concurrency}（每 SPU 一线程，上限 {concurrency}）处理 {len(worklist)} 个产品任务")

    def _run_one(idx: int, spu, skus, tb):
        """并发执行单个产品：返回 (result or None, img_code)。失败由 _process_spu 内部兜底。"""
        img_code = f"{prefix}{idx:03d}"   # 货号：图片按此命名（DG000_design.png…）
        try:
            # 每个任务用自己的热点设计（designs_map），并拷贝为货号命名（designs/DG000_design.png）
            design_src = _resolve_design(tb)
            design_path = None
            if design_src:
                design_path = str(designs_dir / f"{img_code}_design.png")
                try:
                    shutil.copy2(design_src, design_path)
                except Exception:  # noqa: BLE001
                    design_path = design_src
                tb = dict(tb)
                tb["design_path"] = design_path
            r = _process_spu(db_path, basemap_root, material_root, category, prod_dir,
                             tb, ib, spu, skus, pcfg, errors, design_path, title_backend,
                             country, img_code=img_code,
                             model_img=model_assign.get(spu.get("code", "")))
            if r:
                r["img_code"] = img_code
            return r, img_code
        except Exception as e:  # noqa: BLE001   # 单产品任何异常都不拖垮整体
            print(f"[product/{img_code}] 产品处理异常（跳过该产品）: {e}")
            return None, img_code

    import concurrent.futures
    # 货号自动续号：任务一开始全部按序分配（start_idx 起），不覆盖已生成的产物
    start_idx = _next_img_idx(prod_dir, prefix)
    with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as ex:
        futures = [ex.submit(_run_one, start_idx + i, spu, skus, tb)
                   for i, (spu, skus, tb) in enumerate(worklist)]
        for f in concurrent.futures.as_completed(futures):
            r, img_code = f.result()
            if r:
                results.append(r)
                try:
                    _record_used(cache_dir, r)   # (热点-风格) 去重记录 → 缓存根目录
                except Exception:  # noqa: BLE001
                    pass

    results.sort(key=lambda x: x.get("img_code", ""))   # 按货号排序，模板/清单顺序稳定
    _write_products(prod_dir, results)
    stats["product"] = {
        "spus": [r.get("spu_code") for r in results],
        "skus": [r.get("sku_code") for r in results],
        "topic": brief.get("topic", ""),
        "count": len(results),
        "composite": sum(1 for r in results if r.get("composite_path")),
        "printed": sum(1 for r in results if r.get("printed_path")),
        "templates": sum(1 for r in results if r.get("template_path")),
        "output_dir": str(prod_dir),
    }
    return {"product": results, "stats": stats, "errors": errors}


def _record_used(output_dir: Path, r: Dict[str, Any]):
    """记录已用 (热点-风格-配色)，供后续去重：output/<国家>/used_designs.json。"""
    topic = r.get("topic", "")
    if not topic:
        return
    with _USED_LOCK:   # 并发下 used_designs.json 读写互斥
        p = output_dir / "used_designs.json"
        used = []
        if p.exists():
            try:
                used = json.loads(p.read_text(encoding="utf-8")).get("used", []) or []
            except Exception:
                used = []
        entry = {
            "topic": topic,
            "art_style": r.get("art_style", ""),
            "color_palette": r.get("color_palette", ""),
            "spu_code": r.get("spu_code", ""),
            "sku_code": r.get("sku_code", ""),
            "date": time.strftime("%Y-%m-%d"),
        }
        # 同 (topic, art_style) 已记录则跳过，避免去重记录重复堆积
        if any(str(u.get("topic", "")).strip().lower() == str(entry["topic"]).strip().lower()
               and str(u.get("art_style", "")).strip().lower() == str(entry["art_style"]).strip().lower()
               for u in used):
            return
        used.append(entry)
        p.write_text(json.dumps(
            {"updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "used": used},
            ensure_ascii=False, indent=2), encoding="utf-8")


def _write_products(prod_dir: Path, products: List[Dict[str, Any]]):
    (prod_dir / "products.json").write_text(
        json.dumps({"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S"), "products": products},
                   ensure_ascii=False, indent=2), encoding="utf-8")
