"""接口2 服务层：模型单例 + 性别贴图映射 + 「照片→N 张发际线预览图」管线。

把 head3d 的 extract_hairline 步骤包成单例复用（避免每请求重建模型），再按性别
对每张贴图调 render.render_hairline_overlay 生成预览图。
"""
from __future__ import annotations
import glob
import os

import cv2
import numpy as np

from . import constants as C
from . import comfyui
from .face_landmarks import FaceLandmarker
from .face_parsing import FaceParser
from .hairline_2d import (
    smooth_hairline, sample_hairline_clamped,
)
from .lift_3d import lift_hairline_to_3d, build_middle_row, assemble_full
from .render import load_ext_mesh, load_texture_rgba, render_hairline_overlay, build_overlay_layer
from .mask import build_inpaint_mask, compose_comfy_rgba, mask_from_curve
from .marker_detect import detect_marker_hairline, path_to_curve_mask

import base64
import io
import logging

logger = logging.getLogger("hair.worker")

# 接口2 女性发型 key → change_hair hair_id（chang_*）映射：换发型+Flux-2 整帧重绘用。
# 与接口12 final 的 5 型一一对应。female 6/7（bigflower/clasicalflower）无对应 LoRA，
# 走与男性一致的原生生发（ComfyUI add_hair）管线，故不在本表。
_FEMALE_KEY_TO_CHANG = {
    "ellipse": "chang_tuoyuan",   # 椭圆
    "flower": "chang_huaban",     # 花瓣
    "heart": "chang_xinxing",     # 心形
    "straight": "chang_zhixian",  # 直线
    "wave": "chang_bolang",       # 波浪
}

# 发际线贴图显式顺序表：决定 hair_style 序号（1-indexed）。
# 不再依赖文件名字母序——字母序会因新增/重命名文件而错位，破坏现有前端/客户端取值。
# key 须与 _gender_key 派生结果一致（已去空格）：如 "inverse_arc"（源 man_ inverse_arc.png）、
# "Softpetal"（源 man_Soft petal.png，大写 S 保留）。表外未知 key 兜底排到末尾。
_HAIRSTYLE_ORDER = {
    "female": ["ellipse", "flower", "heart", "straight", "wave",
               "bigflower", "clasicalflower"],  # 1..7
    "male": ["ellipse", "inverse_arc", "m", "straight", "heart", "Softpetal"],  # 1..6
}

_REPO = os.path.dirname(os.path.dirname(__file__))
_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture")
_BLACK_TEXTURE_DIR = os.path.join(_REPO, "hairline_texture_black")

# 三接口（接口2女重绘 / 接口2男 / 接口3）统一的 ComfyUI 重绘 prompt。
# 关键：ComfyUI 单卡显存装不下 Flux(7.7G)+qwen CLIP(3.9G) 同驻，靠缓存 CLIP 文本条件避免重载。
# prompt 不同会使缓存失效 → 重载 CLIP 并挤出 Flux（每次 +4s）。三接口用同一字符串即可全程命中。
# 与 app.py 接口2/接口3 的默认 prompt 保持一致；可用 REDRAW_PROMPT 覆盖。
_REDRAW_PROMPT = os.getenv("REDRAW_PROMPT", "填充遮罩区域的头发，皮肤加一点磨皮，再加一点美颜")

# 接口2 女重绘整条管线（swapHair + ComfyUI）送模型前限边。真实照片常达 1257x1495：
# 全分辨率 ComfyUI 重绘要 13~21s 且激活显存把模型挤出。女性路径含 swapHair(SD WebUI ~5.3s
# 固定地板) + ComfyUI 两段串行。1024 档画质更好但部分大图会踩 12s 线，
# 默认压到 896 兜底（ComfyUI ~4s，女性总耗时 9~11s）；追画质可设 REDRAW_MAX_SIDE=1024。
_REDRAW_MAX_SIDE = int(os.getenv("REDRAW_MAX_SIDE", "896"))

def _call_local_redraw(image_png_bytes, mask_png_bytes, timeout=300.0):
    """直接调 ComfyUI 重绘（替代原 local_test HTTP 服务）。

    传 final 图 + 纯红遮罩 PNG，返回重绘后的 PNG bytes。
    失败抛异常（调用方负责 try/except 跳过）。
    """
    from .redraw import run_redraw
    img = cv2.imdecode(np.frombuffer(image_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
    scale = 1.0
    orig_w = orig_h = 0
    if img is not None:
        orig_h, orig_w = img.shape[:2]
        m = max(orig_h, orig_w)
        if _REDRAW_MAX_SIDE > 0 and m > _REDRAW_MAX_SIDE:
            scale = _REDRAW_MAX_SIDE / float(m)
            nw, nh = max(1, round(orig_w * scale)), max(1, round(orig_h * scale))
            msk = cv2.imdecode(np.frombuffer(mask_png_bytes, np.uint8), cv2.IMREAD_UNCHANGED)
            img_s = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_AREA)
            msk_s = cv2.resize(msk, (nw, nh), interpolation=cv2.INTER_NEAREST)
            image_png_bytes = cv2.imencode(".png", img_s)[1].tobytes()
            mask_png_bytes = cv2.imencode(".png", msk_s)[1].tobytes()
            logger.info("接口2女 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
                        orig_w, orig_h, nw, nh, _REDRAW_MAX_SIDE)
    # front=True：接口2 时延敏感，插到 ComfyUI 队列最前，避免排在接口3/5 的批量任务后面
    out = run_redraw(image_png_bytes, mask_png_bytes, timeout=timeout,
                     prompt=_REDRAW_PROMPT, front=True)
    if scale < 1.0 and out:
        out = _upscale_png_to(out, orig_w, orig_h)
    return out

# 发际线贴图档位：middle=默认(hairline_texture/)，high/low 各自独立文件夹。
_TEXTURE_DIRS = {
    "middle": _TEXTURE_DIR,
    "high": os.path.join(_REPO, "hairline_texture_high"),
    "low": os.path.join(_REPO, "hairline_texture_low"),
}

# torch 2.7.1+cu128 已支持 RTX 5090 (sm_120)，SegFormer 走 GPU（~0.05s/张）
_SEG_DEVICE = os.getenv("SEG_DEVICE", "cuda")

_landmarker = None
_parser = None
_texture_maps: dict = {}  # {level: {gender: [(key, path)]}}，按档位缓存


def get_landmarker() -> FaceLandmarker:
    global _landmarker
    if _landmarker is None:
        _landmarker = FaceLandmarker(static_image_mode=True)
    return _landmarker


def get_parser() -> FaceParser:
    global _parser
    if _parser is None:
        _parser = FaceParser(device=_SEG_DEVICE)
    return _parser


def _gender_key(stem: str):
    """文件名 stem → (gender, key)；非 girl_/man_ 前缀返回 (None, None)。"""
    if stem.startswith("girl_"):
        return "female", stem[5:].replace(" ", "").strip()
    if stem.startswith("man_"):
        return "male", stem[4:].replace(" ", "").strip()
    return None, None


def get_texture_map(level: str = "middle") -> dict:
    """扫描指定档位贴图目录建 {gender: [(key, path)]}，按显式顺序表排序、按档位缓存。

    level：middle(默认) / high / low，分别对应 hairline_texture[/_high|/_low]。
    文件名规范化去空格（如 `man_ inverse_arc.png` → key `inverse_arc`）。

    排序依据 _HAIRSTYLE_ORDER：表内 key 按表序、表外未知 key 兜底排到末尾（再按字母序），
    保证新增/重命名文件不会打乱现有 hair_style 序号。
    """
    if level not in _TEXTURE_DIRS:
        raise ValueError(f"hairline_level 必须是 middle/high/low，收到 {level!r}")
    cached = _texture_maps.get(level)
    if cached is not None:
        return cached
    mapping: dict[str, list] = {"female": [], "male": []}
    for path in sorted(glob.glob(os.path.join(_TEXTURE_DIRS[level], "*.png"))):
        stem = os.path.splitext(os.path.basename(path))[0]
        gender, key = _gender_key(stem)
        if gender:
            mapping[gender].append((key, path))
    for g in mapping:
        order = _HAIRSTYLE_ORDER.get(g, [])
        idx = {k: i for i, k in enumerate(order)}
        mapping[g].sort(key=lambda kp: (idx.get(kp[0], len(idx)), kp[0]))
    _texture_maps[level] = mapping
    return mapping


def extract_502(image_bgr: np.ndarray):
    """照片(BGR) → (points502 MP序, valid17)。无人脸返回 (None, None)。"""
    ctx = extract_context(image_bgr)
    if ctx is None:
        return None, None
    return ctx["points"], ctx["valid"]


def extract_context(image_bgr: np.ndarray):
    """照片(BGR) → {landmarks, parse_map, points, valid}。无人脸返回 None。

    发际线几何检测固定用 `sample_hairline_clamped`（射线检测 + 头部轮廓钳制）：
    短发/剃光头照片（如 man_test.jpg）中间锚点检测失效时，纯射线检测的固定 fallback
    偏移会把点顶到头部轮廓外面的背景，产生"发际线贴到头部外面"的视觉 bug；钳制兜底后
    fallback 点不会再跑出头部轮廓，正常长发照片结果与旧行为一致。
    """
    rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
    landmarks = get_landmarker().detect(rgb)
    if landmarks is None:
        return None
    parse_map = get_parser().parse(rgb)
    hairline_2d, valid = sample_hairline_clamped(landmarks, parse_map)
    hairline_2d = smooth_hairline(hairline_2d, valid)
    hairline_3d = lift_hairline_to_3d(landmarks, hairline_2d)
    middle_3d = build_middle_row(landmarks, hairline_3d)
    points = assemble_full(landmarks, middle_3d, hairline_3d)
    return {"landmarks": landmarks, "parse_map": parse_map, "points": points, "valid": valid}


def _black_texture_path(white_path: str) -> str:
    """白贴图路径 → 同名黑贴图路径（hairline_texture_black/）。"""
    return os.path.join(_BLACK_TEXTURE_DIR, os.path.basename(white_path))


def generate_previews(image_bgr: np.ndarray, gender: str):
    """生成该性别全部发际线预览图（仅预览，不生发）。

    Returns: list[dict] {"hairline_type", "image_bgr", "order"}；无人脸返回 None。
    """
    if gender not in ("male", "female"):
        raise ValueError(f"gender 必须是 male/female，收到 {gender!r}")
    ctx = extract_context(image_bgr)
    if ctx is None:
        return None
    uv, ext_faces = load_ext_mesh()
    results = []
    for order, (key, path) in enumerate(get_texture_map()[gender], start=1):
        preview = render_hairline_overlay(image_bgr, ctx["points"], ext_faces, uv,
                                          load_texture_rgba(path))
        results.append({"hairline_type": key, "image_bgr": preview, "order": order})
    return results


def generate_grow_results(image_bgr: np.ndarray, gender: str, use_mask: bool = True,
                         prompt: str = None, hair_styles: list[int] | None = None,
                         workflow_path: str | None = None):
    """指定发际线类型：发际线透明叠图(白线 RGBA) + 生发图(ComfyUI)。

    hair_styles（1-indexed 列表）：指定生成哪几张发际线（按贴图排序）。female: 1..7，male: 1..6。
      为 None 时生成全部（兼容旧调用）。
    use_mask（默认 True）：是否启用 inpaint 遮罩，用于测试对比（同接口3）。
      False 时用**干净原图 + 空遮罩**送 ComfyUI（不烧黑色模板线）。
    prompt（默认 None）：ComfyUI 提示词，非 None 时替换工作流节点60文本。
    workflow_path（默认 None）：ComfyUI 工作流 JSON 路径，None 用默认 add_hair.json。
    Returns: list[dict] {"hairline_type","order","overlay"((H,W,4) RGBA 透明层),
                        "grown_png"(bytes 或 None)}。
    无人脸返回 None。某张 ComfyUI 失败时该项 grown_png=None，不抛异常。
    """
    if gender not in ("male", "female"):
        raise ValueError(f"gender 必须是 male/female，收到 {gender!r}")
    ctx = extract_context(image_bgr)
    if ctx is None:
        return None
    uv, ext_faces = load_ext_mesh()

    textures = get_texture_map()[gender]  # [(key, path), ...] 已排序
    if hair_styles is not None:
        items = [(s, textures[s - 1]) for s in hair_styles]
    else:
        items = list(enumerate(textures, start=1))

    # 禁用遮罩：干净原图 + 空遮罩，与模板无关 → 只跑一次 ComfyUI，下面 N 项复用
    shared_grown = None
    if not use_mask:
        try:
            h, w = image_bgr.shape[:2]
            img_s, msk_s, gsc = _prep_comfy_input(image_bgr, np.zeros((h, w), np.uint8))
            buf = io.BytesIO()
            compose_comfy_rgba(img_s, msk_s).save(buf, format="PNG", compress_level=1)
            # front=True：接口2 时延敏感，插到 ComfyUI 队列最前
            shared_grown = comfyui.run(buf.getvalue(), prompt=prompt,
                                       workflow_path=workflow_path, front=True)
            if gsc < 1.0 and shared_grown:
                shared_grown = _upscale_png_to(shared_grown, w, h)
        except Exception as e:  # noqa: BLE001
            logger.warning("接口2 生发图失败(无遮罩)：%s", e)

    results = []
    h, w = image_bgr.shape[:2]
    for order, (key, white_path) in items:
        white = load_texture_rgba(white_path)
        overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)

        if not use_mask:
            grown_png = shared_grown
        else:
            grown_png = None
            try:
                black = load_texture_rgba(_black_texture_path(white_path))
                marked, mask = build_inpaint_mask(
                    image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
                m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
                buf = io.BytesIO()
                compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
                # front=True：接口2 时延敏感，插到 ComfyUI 队列最前
                grown_png = comfyui.run(buf.getvalue(), prompt=prompt,
                                        workflow_path=workflow_path, front=True)
                if gsc < 1.0 and grown_png:
                    grown_png = _upscale_png_to(grown_png, w, h)
            except Exception as e:  # noqa: BLE001  单张失败不拖垮整请求
                logger.warning("接口2 生发图失败 type=%s：%s", key, e)

        results.append({"hairline_type": key, "order": order,
                        "overlay": overlay, "grown_png": grown_png})
    return results


def generate_grow_results_swap(image_bgr: np.ndarray, hair_styles: list[int] | None,
                               redraw_defaults: dict,
                               redraw_max_side: int | None = None,
                               unet_name: str | None = None,
                               prompt: str | None = None):
    """接口2 女性专用：发际线透明叠图（同 generate_grow_results）+ 生发图。

    生发图来源按发型分两路：
    - 换发型（chang_* 表内，1..5）：female key → change_hair 的 chang_* hair_id，调
      face_analysis.hairline_grow.generate_hairline_redraw（= 接口12 final 管线，参数用
      redraw_defaults）拿到 ④ final（接缝融合基底）+ ⑤-② 纯红遮罩 PNG，再**后端直接调
      ComfyUI**（0716add-hair-api.json 工作流）完成发际线带重绘。
    - 原生生发（表外，6/7 bigflower/clasicalflower 等）：无对应 change_hair LoRA，改走与男性
      一致的原生生发（ComfyUI add_hair），由 _grow_native_one 完成（黑模板 + inpaint 遮罩）。

    overlay 仍是发际线曲线透明层（与 generate_grow_results 完全一致）。
    prompt 仅用于原生生发分支（换发型分支的提示词由 redraw 流程内部固定）。
    Returns: list[dict] {"hairline_type","order","overlay","grown_png"(jpg bytes 或 None)}；
             无人脸返回 None。单个发型换发型/重绘失败时 grown_png=None，不抛异常。
    """
    from face_analysis.hairline_grow import generate_hairline_redraw, NoFaceError
    from face_analysis.head_mask import SEGFORMER_HAIR

    ctx = extract_context(image_bgr)
    if ctx is None:
        return None
    uv, ext_faces = load_ext_mesh()

    # 复用 extract_context 已算好的 SegFormer parse_map，避免 generate_hairline_redraw 内部重复分割
    hair_mask_reuse = (ctx["parse_map"] == SEGFORMER_HAIR)

    textures = get_texture_map()["female"]  # [(key, path), ...] 已排序
    if hair_styles is not None:
        items = [(s, textures[s - 1]) for s in hair_styles]
    else:
        items = list(enumerate(textures, start=1))

    results = []
    h, w = image_bgr.shape[:2]

    # 重绘管线（swapHair + ComfyUI）统一降分辨率：真实照片 swap(SD WebUI)~5s、blend、ComfyUI
    # 均随分辨率线性下降。overlay 预览仍用全分辨率；grown_png 最后放大回原尺寸。
    redraw_img = image_bgr
    hair_mask_redraw = hair_mask_reuse
    if _REDRAW_MAX_SIDE > 0 and max(h, w) > _REDRAW_MAX_SIDE:
        redraw_img, _rs = _downscale_max_side(image_bgr, _REDRAW_MAX_SIDE)
        _nh, _nw = redraw_img.shape[:2]
        if hair_mask_redraw is not None:
            hair_mask_redraw = cv2.resize(hair_mask_reuse.astype(np.uint8), (_nw, _nh),
                                          interpolation=cv2.INTER_NEAREST).astype(bool)
        logger.info("接口2女 管线降分辨率: %dx%d → %dx%d (max_side=%d)",
                    w, h, _nw, _nh, _REDRAW_MAX_SIDE)

    for order, (key, white_path) in items:
        white = load_texture_rgba(white_path)
        overlay = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)

        grown_png = None
        chang_id = _FEMALE_KEY_TO_CHANG.get(key)
        if chang_id is None:
            # 无对应 change_hair LoRA（如 bigflower/clasicalflower）→ 走与男性一致的原生生发
            logger.info("接口2 女 key=%s 无 chang_id，走原生生发(ComfyUI add_hair)", key)
            grown_png = _grow_native_one(image_bgr, ctx, white_path,
                                         prompt=prompt, unet_name=unet_name)
        else:
            try:
                import time as _t
                _ts0 = _t.perf_counter()
                data = generate_hairline_redraw(redraw_img, chang_id, hair_mask=hair_mask_redraw, **redraw_defaults)
                _ts1 = _t.perf_counter()
                steps = data.get("steps") or {}
                # ④ final（接缝融合基底）+ ⑤-② 纯红遮罩 PNG
                final_b64 = steps.get("final_base64") or ""
                mask_b64 = steps.get("redraw_band_mask_base64") or ""
                if not final_b64 or not mask_b64:
                    logger.warning("接口2 换发型：type=%s final/遮罩缺失（final=%d mask=%d）",
                                   key, len(final_b64), len(mask_b64))
                else:
                    # 去掉 data URI 前缀
                    if final_b64.startswith("data:"):
                        final_b64 = final_b64.split(",", 1)[1]
                    if mask_b64.startswith("data:"):
                        mask_b64 = mask_b64.split(",", 1)[1]
                    final_bytes = base64.b64decode(final_b64)
                    mask_bytes = base64.b64decode(mask_b64)
                    # 后端直接调 ComfyUI 重绘，返回重绘后的 PNG
                    _tr0 = _t.perf_counter()
                    grown_png = _call_local_redraw(final_bytes, mask_bytes)
                    _tr1 = _t.perf_counter()
                    _tm = data.get("timings_ms") or {}
                    logger.info("接口2女 分段计时 type=%s: swapHair管线=%.2fs (mask=%dms swap=%dms blend=%dms), ComfyUI重绘=%.2fs",
                                key, _ts1 - _ts0,
                                _tm.get("mask", 0), _tm.get("swap", 0), _tm.get("blend", 0),
                                _tr1 - _tr0)
                    if grown_png is None:
                        logger.warning("接口2 换发型：type=%s 重绘结果为空", key)
                    elif redraw_img is not image_bgr:
                        # 管线在降分辨率图上跑，结果放大回原尺寸
                        grown_png = _upscale_png_to(grown_png, w, h)
            except NoFaceError:
                logger.warning("接口2 换发型：type=%s 未检出人脸", key)
            except Exception as e:  # noqa: BLE001  单张失败不拖垮整请求
                logger.warning("接口2 换发型图失败 type=%s：%s", key, e)

        results.append({"hairline_type": key, "order": order,
                        "overlay": overlay, "grown_png": grown_png})
    return results


def _grow_from_texture(image_bgr: np.ndarray, ctx: dict, white_path: str | None,
                       use_mask: bool, prompt: str | None):
    """对单个发际线做生发(ComfyUI)。黑模板固定取 hairline_texture_black/（middle），
    与 hairline_level 无关（high/low 贴图与 middle 同名，basename 映射即落回 middle 黑模板）。
    use_mask=False 时用干净原图+空遮罩（与贴图无关，white_path 可为 None）。
    失败返回 None，不抛异常。
    """
    try:
        if use_mask:
            black = load_texture_rgba(_black_texture_path(white_path))
            marked, mask = build_inpaint_mask(
                image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
        else:
            h, w = image_bgr.shape[:2]
            marked, mask = image_bgr, np.zeros((h, w), np.uint8)
        buf = io.BytesIO()
        compose_comfy_rgba(marked, mask).save(buf, format="PNG", compress_level=1)
        return comfyui.run(buf.getvalue(), prompt=prompt)
    except Exception as e:  # noqa: BLE001  单张失败不拖垮整请求
        logger.warning("接口5 生发图失败：%s", e)
        return None


def _grow_native_one(image_bgr: np.ndarray, ctx: dict, white_path: str,
                     prompt: str | None = None, unet_name: str | None = None):
    """对单个发际线做原生生发（ComfyUI add_hair），与男性 generate_grow_results 一致。

    接口2 女性新发型（bigflower/clasicalflower 等）无对应 change_hair LoRA，改走此路径：
    黑模板 → build_inpaint_mask → 限边（_prep_comfy_input）→ comfyui.run(front=True)。
    失败返回 None，不抛异常。结果按限边前原图尺寸放大回原尺寸（仅展示对齐）。
    """
    try:
        h, w = image_bgr.shape[:2]
        black = load_texture_rgba(_black_texture_path(white_path))
        marked, mask = build_inpaint_mask(
            image_bgr, ctx["landmarks"], ctx["parse_map"], ctx["points"], black)
        m_s, msk_s, gsc = _prep_comfy_input(marked, mask)
        buf = io.BytesIO()
        compose_comfy_rgba(m_s, msk_s).save(buf, format="PNG", compress_level=1)
        # front=True：接口2 时延敏感，插到 ComfyUI 队列最前
        grown_png = comfyui.run(buf.getvalue(), prompt=prompt, front=True, unet_name=unet_name)
        if gsc < 1.0 and grown_png:
            grown_png = _upscale_png_to(grown_png, w, h)
        return grown_png
    except Exception as e:  # noqa: BLE001  单张失败不拖垮整请求
        logger.warning("接口2 女原生生发图失败：%s", e)
        return None


def generate_hairline_pngs(image_bgr: np.ndarray, gender: str,
                           hair_styles: list[int], use_mask: bool = True,
                           prompt: str | None = None,
                           generate_grow_image: bool = True):
    """接口5：对选中发型返回 middle/high/low 三档发际线透明叠图 + 生发图(同接口2)。

    入参同接口2：先选 gender，再多选 hair_styles（必填，1-indexed 按贴图排序）。
    每个选中发型返回三档叠图（middle/high/low，RGBA 透明层只含发际线曲线）与一张生发图；
    三档贴图同名，生发黑模板固定取自 hairline_texture_black/（middle），故生发目标固定 middle 档。
    use_mask/prompt：同接口2 的生发参数。
    generate_grow_image（默认 True）：是否生成生发图（ComfyUI，最耗时）。False 时跳过生发，
        各发型 grown_png 恒为 None，可大幅降低耗时（仅留三档发际线叠图与中心点）。
    Returns: {"images":[{hairline_type,order,overlays:{middle,high,low}((H,W,4) RGBA 透明层),grown_png}],
              "best_centers":{"middle":(x,y),"high":(x,y),"low":(x,y)}}；无人脸 None。
              best_centers 取首个选中发型三档各自的发际线中点。
    """
    if gender not in ("male", "female"):
        raise ValueError(f"gender 必须是 male/female，收到 {gender!r}")
    if not hair_styles:
        raise ValueError("hair_styles 必填且不能为空")
    ctx = extract_context(image_bgr)
    if ctx is None:
        return None
    h, w = image_bgr.shape[:2]
    uv, ext_faces = load_ext_mesh()
    lm = ctx["landmarks"]
    # 面部中轴 x = 眉心(9/151 中点)
    face_cx = float((lm[9, 0] + lm[151, 0]) / 2 * w)

    # 三档贴图表（同性别、同 key 顺序，因三个文件夹同名）
    tex_by_level = {lv: get_texture_map(lv)[gender] for lv in _TEXTURE_DIRS}

    # use_mask=False：干净原图+空遮罩与贴图无关，只跑一次 ComfyUI，选中项复用
    # generate_grow_image=False：完全跳过生发（最耗时），grown_png 恒为 None
    shared_grown = None
    if generate_grow_image and not use_mask:
        shared_grown = _grow_from_texture(image_bgr, ctx, None, use_mask=False, prompt=prompt)

    def _center_of(overlay):
        """从某档发际线透明叠图取面部中轴处的发际线中点 (x,y)，无像素返回 None。"""
        ys, xs = np.where(overlay[:, :, 3] > 40)
        if not xs.size:
            return None
        near = np.abs(xs - face_cx) <= max(2, int(w * 0.02))
        col_ys = ys[near] if near.any() else ys[np.argsort(np.abs(xs - face_cx))[:20]]
        return (int(round(face_cx)), int(round(float(col_ys.mean()))))

    images, best_centers = [], None
    for s in hair_styles:  # s = 1-indexed 发型序号
        key, mid_path = tex_by_level["middle"][s - 1]
        overlays = {}
        for lv in _TEXTURE_DIRS:
            white = load_texture_rgba(tex_by_level[lv][s - 1][1])
            overlays[lv] = build_overlay_layer(h, w, ctx["points"], ext_faces, uv, white)
        # 生发：固定 middle 黑模板（generate_grow_image=False 时跳过，恒 None）
        if not generate_grow_image:
            grown_png = None
        elif not use_mask:
            grown_png = shared_grown
        else:
            grown_png = _grow_from_texture(image_bgr, ctx, mid_path, use_mask=True, prompt=prompt)
        images.append({"hairline_type": key, "order": s,
                       "overlays": overlays, "grown_png": grown_png})
        # best_centers：首个选中发型三档（middle/high/low）发际线中点
        if best_centers is None:
            best_centers = {lv: _center_of(overlays[lv]) for lv in _TEXTURE_DIRS}
    return {"images": images, "best_centers": best_centers}


# 接口3 送 ComfyUI 前限边，降低峰值显存，避免与接口2 切换时把 Flux 挤出。
# 统一 prompt 后 Flux 不再被 CLIP 挤出，接口3 可用较高分辨率。可用 GROW_B_MAX_SIDE 覆盖。
_GROW_B_MAX_SIDE = int(os.getenv("GROW_B_MAX_SIDE", "1024"))

def _downscale_max_side(img_bgr: np.ndarray, max_side: int) -> tuple[np.ndarray, float]:
    """长边超过 max_side 时等比例缩小；返回 (图, scale)，scale=新/旧。"""
    h, w = img_bgr.shape[:2]
    m = max(h, w)
    if max_side <= 0 or m <= max_side:
        return img_bgr, 1.0
    scale = max_side / float(m)
    nw = max(1, int(round(w * scale)))
    nh = max(1, int(round(h * scale)))
    out = cv2.resize(img_bgr, (nw, nh), interpolation=cv2.INTER_AREA)
    return out, scale


def _upscale_png_to(png_bytes: bytes, out_w: int, out_h: int) -> bytes:
    """把 Comfy 输出 PNG 双线性拉回原图尺寸（仅展示对齐，不增加推理细节）。"""
    arr = np.frombuffer(png_bytes, np.uint8)
    img = cv2.imdecode(arr, cv2.IMREAD_UNCHANGED)
    if img is None:
        return png_bytes
    if img.shape[1] == out_w and img.shape[0] == out_h:
        return png_bytes
    resized = cv2.resize(img, (out_w, out_h), interpolation=cv2.INTER_LINEAR)
    ok, buf = cv2.imencode(".png", resized)
    return buf.tobytes() if ok else png_bytes


def _prep_comfy_input(img_bgr: np.ndarray, mask: np.ndarray) -> tuple[np.ndarray, np.ndarray, float]:
    """单段 ComfyUI 生发（接口2男 / 接口3）送图前限边到 GROW_B_MAX_SIDE。
    返回 (缩后图, 缩后遮罩, scale)；scale<1 时调用方需把结果放大回原尺寸。"""
    h, w = img_bgr.shape[:2]
    if _GROW_B_MAX_SIDE <= 0 or max(h, w) <= _GROW_B_MAX_SIDE:
        return img_bgr, mask, 1.0
    out, scale = _downscale_max_side(img_bgr, _GROW_B_MAX_SIDE)
    nh, nw = out.shape[:2]
    msk = cv2.resize(mask, (nw, nh), interpolation=cv2.INTER_NEAREST)
    logger.info("接口2男/接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
                w, h, nw, nh, _GROW_B_MAX_SIDE)
    return out, msk, scale


def generate_grow_b(marked_bgr: np.ndarray, use_mask: bool = True, prompt: str = None):
    """接口3：检测医生手绘发际线 → 遮罩 → 送 ComfyUI 生发（仅需划线图一张）。

    检测路径只用来**建遮罩**；ComfyUI 输入图用 **marked 原图**（含医生手绘线，
    工作流提示词会清除黑线再生发）。

    进 Comfy 前若长边 > GROW_B_MAX_SIDE（默认 896）会先等比例缩小，降低峰值显存；
    输出再拉回原图尺寸。

    use_mask（默认 True）：是否启用自动检测的遮罩，用于测试对比。
      - True：检测手绘线 → 建遮罩 → alpha=255−mask（透明区=重绘区，节点44 画黄色参考区）。
      - False：跳过检测，直接送划线图，alpha 全 255（空遮罩，节点26 mask 为空），
        模型仅凭医生黑线参考生发。无需改工作流，唯一变量是遮罩。
    Returns: {"grown_png": bytes 或 None, "status": "ok"|"no_face"|"no_line"}。
    """
    orig_h, orig_w = marked_bgr.shape[:2]
    marked_bgr, _scale = _downscale_max_side(marked_bgr, _GROW_B_MAX_SIDE)
    if _scale < 1.0:
        logger.info(
            "接口3 缩图送 Comfy: %dx%d → %dx%d (max_side=%d)",
            orig_w, orig_h, marked_bgr.shape[1], marked_bgr.shape[0], _GROW_B_MAX_SIDE,
        )

    h, w = marked_bgr.shape[:2]
    if use_mask:
        rgb = cv2.cvtColor(marked_bgr, cv2.COLOR_BGR2RGB)
        landmarks = get_landmarker().detect(rgb)
        if landmarks is None:
            return {"grown_png": None, "status": "no_face"}
        parse_map = get_parser().parse(rgb)
        path = detect_marker_hairline(marked_bgr, landmarks, parse_map)
        if path is None:
            return {"grown_png": None, "status": "no_line"}
        line_w = max(2, int(w * 0.006))
        curve_mask = path_to_curve_mask(path, h, w, thickness=max(3, line_w))
        mask = mask_from_curve(curve_mask, landmarks, parse_map)
    else:
        mask = np.zeros((h, w), np.uint8)   # 空遮罩：alpha 全 255，跳过检测

    buf = io.BytesIO()
    compose_comfy_rgba(marked_bgr, mask).save(buf, format="PNG", compress_level=1)   # marked + 遮罩
    grown_png = comfyui.run(buf.getvalue(), prompt=prompt)
    if _scale < 1.0 and grown_png:
        grown_png = _upscale_png_to(grown_png, orig_w, orig_h)
    return {"grown_png": grown_png, "status": "ok"}


if __name__ == "__main__":
    import sys
    g = sys.argv[2] if len(sys.argv) > 2 else "female"
    img = cv2.imread(sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg")
    os.makedirs("tests/output", exist_ok=True)
    print("texture map:", {k: [kp[0] for kp in v] for k, v in get_texture_map().items()})
    res = generate_previews(img, g)
    if res is None:
        print("无人脸")
        sys.exit(1)
    for r in res:
        out = f"tests/output/preview_{g}_{r['hairline_type']}.png"
        cv2.imwrite(out, r["image_bgr"])
        print(f"  order={r['order']} type={r['hairline_type']} -> {out}")
