import os
import sys
import uuid
import time
import shutil
import threading
import random
from datetime import datetime, timedelta
import numpy as np
import cv2
import insightface
from insightface.app import FaceAnalysis
from PIL import Image, ImageDraw, ImageFont
from flask import Flask, render_template, request, send_file, jsonify
from concurrent.futures import ThreadPoolExecutor

sys.path.insert(0, "/passport")
from mrz import build_line1, build_line2
from bisenet import change_background

BASE = "/passport"
FONT_DIR = "/usr/share/fonts/truetype/dejavu"

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 16 * 1024 * 1024
app.config["UPLOAD_FOLDER"] = os.path.join(BASE, "web", "uploads")
app.config["RESULT_FOLDER"] = os.path.join(BASE, "web", "results")
app.config["PAIRED_PIN"] = os.path.join(BASE, "pin")
app.config["PAIRED_POUT"] = os.path.join(BASE, "pout")
app.config["TEMPLATES"] = os.path.join(BASE, "templates")

for d in (app.config["UPLOAD_FOLDER"], app.config["RESULT_FOLDER"],
          app.config["PAIRED_PIN"], app.config["PAIRED_POUT"], app.config["TEMPLATES"]):
    os.makedirs(d, exist_ok=True)

PROVIDERS = ["CPUExecutionProvider"]
ROOT = BASE

print("[Init] Loading FaceAnalysis (buffalo_l)...", flush=True)
face_app = FaceAnalysis(name="buffalo_l", root=ROOT, providers=PROVIDERS)
face_app.prepare(ctx_id=-1, det_size=(640, 640))

_face_det_lock = threading.Lock()

def _detect_faces(img):
    """人脸检测，带超大脸回退：默认 det_size 检不出时缩小尺度重试。
    swap 用到的小尺寸证件照若人脸过大(street 大脸)会检不出，需缩小 det_size。"""
    fs = face_app.get(img)
    if fs:
        return fs
    with _face_det_lock:
        saved = tuple(face_app.det_size)
        try:
            for det in ((512, 512), (448, 448), (320, 320)):
                face_app.prepare(ctx_id=-1, det_size=det)
                fs = face_app.get(img)
                if fs:
                    break
        finally:
            face_app.prepare(ctx_id=-1, det_size=saved)
    return fs

print("[Init] Loading inswapper_128...", flush=True)
swapper = insightface.model_zoo.get_model(
    os.path.join(BASE, "models", "inswapper_128.onnx"), providers=PROVIDERS
)

TEMPLATE_GIRL = os.path.join(BASE, "templates", "mm_girl.jpg")

# 缅甸姓名库
FEMALE_SURNAMES = ["EI", "SU", "KHIN", "MAY", "NU", "THAN", "THIDA", "NILAR", "MOE", "YIN", "HTWE", "WIN", "MYA"]
FEMALE_GIVEN = ["SU MYAT", "THIN", "ZAR", "THU", "YADANAR", "HTAY", "KHINE", "WAI", "EI", "MON", "THIRI", "AYE", "PHYU"]
MALE_SURNAMES = ["AUNG", "KYAW", "WIN", "MYINT", "ZAW", "HTET", "TUN", "PHYO", "MIN", "THEIN", "SAN", "KYI", "YE"]
MALE_GIVEN = ["AUNG", "KYAW", "WIN", "MIN", "HTET", "ZAW", "PHYO", "THEIN", "MYO", "TUN", "NAING", "YE", "HTUN"]
BIRTH_PLACES = ["YANGON", "MANDALAY", "TAUNGGYI", "BAGO", "PATHEIN", "SITTWE",
                "MYITKYINA", "MAWLAMYINE", "MONYWA", "MEIKTILA", "MATMAN", "KYAINGTONG"]
AUTHORITIES = ["MOHA, KYAINGTONG", "MOHA, YANGON", "MOHA, MANDALAY"]

MONTHS_ABBR = ["JAN","FEB","MAR","APR","MAY","JUN","JUL","AUG","SEP","OCT","NOV","DEC"]

executor = ThreadPoolExecutor(max_workers=2)
tasks = {}
tasks_lock = threading.Lock()

print("[Init] Ready!", flush=True)


# ===================== 工具 =====================

def gen_passport_no():
    return "MK" + "".join(str(random.randint(0, 9)) for _ in range(6))

def paired_name():
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    uid = uuid.uuid4().hex[:6]
    return f"{ts}_{uid}"

def gen_random_date(start_year, end_year):
    y = random.randint(start_year, end_year)
    m = random.randint(1, 12)
    d = random.randint(1, 28)
    return datetime(y, m, d)

def fmt_date(dt):
    return f"{dt.day:02d} {MONTHS_ABBR[dt.month-1]} {dt.year}"


# ===================== 随机数据 =====================

def random_passport_data(sex=None):
    sex = (sex or "F").upper()
    if sex not in ("M", "F"):
        sex = "F"
    if sex == "M":
        surname = random.choice(MALE_SURNAMES)
        given = random.choice(MALE_GIVEN)
    else:
        surname = random.choice(FEMALE_SURNAMES)
        given = random.choice(FEMALE_GIVEN)
    dob = gen_random_date(1980, 2005)
    issue = gen_random_date(2020, 2025)
    expiry = issue + timedelta(days=5 * 365)
    return {
        "passport_no": gen_passport_no(),
        "sex": sex,
        "surname": surname,
        "given_name": given,
        "dob": fmt_date(dob),
        "birth_place": random.choice(BIRTH_PLACES),
        "issue_date": fmt_date(issue),
        "expiry_date": fmt_date(expiry),
    }


# ===================== MRZ =====================

def build_mrz(data):
    surname = (data.get("surname", "") or "").upper().replace(" ", "<")
    given = (data.get("given_name", "") or "").upper().replace(" ", "<")
    country = (data.get("country_code", "") or "MMR").upper()[:3]
    dtype = (data.get("type", "") or "PV").upper()

    pas = (data.get("passport_no", "") or "").upper().replace(" ", "").replace("<", "")
    if not pas:
        pas = gen_passport_no()

    def _parse(s):
        for f in ("%d %b %Y", "%d %B %Y", "%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"):
            try:
                return datetime.strptime(s, f)
            except Exception:
                pass
        return None

    dob_dt = _parse(data.get("dob", "") or "") or datetime(2000, 2, 6)
    exp_dt = _parse(data.get("expiry_date", "") or "") or datetime(2030, 11, 23)
    sex = (data.get("sex", "") or "F").upper()[:1]
    if sex not in ("M", "F"):
        sex = "F"

    line1 = build_line1(surname, given, dtype, country)
    line2 = build_line2(pas[:8], dob_dt.strftime("%y%m%d"),
                        exp_dt.strftime("%y%m%d"), sex, country)
    return line1, line2


# ===================== 证件照制作 (ICAO 35×45mm @ 300dpi) =====================
# 35mm × 45mm @ 300dpi → 413 × 531 像素
# 头部标准（ICAO 9303）: 头顶到下巴占图高 70~80%；下巴到图底 ≥ 头部高度
# 实际采用：脸高(下巴-发际) ≈ 0.55H；头顶距图顶 ≈ 0.10H；下巴到图底 ≈ 0.35H

ID_W, ID_H = 413, 531


def smart_crop_selfie(source_path, output_path):
    """智能裁切自拍照：包含完整头发+脸+小部分衣领，按 35:45 比例输出 JPG。
    用于左侧预览框显示原图。
    """
    img = cv2.imread(source_path)
    if img is None:
        return "无法读取源图片"

    faces = face_app.get(img)
    if not faces:
        return "源图片未检测到人脸"
    face = max(faces, key=lambda f: f.det_score)
    x1, y1, x2, y2 = face.bbox.astype(int)
    fw, fh = x2 - x1, y2 - y1
    cx, cy = (x1 + x2) // 2, (y1 + y2) // 2

    # 用 bisenet 找出头发上边界
    hair_top = y1  # fallback
    try:
        mask = parse_face_only(img, face_bbox=(x1, y1, x2, y2))
        # hair/hat label 13, 14
        hair_rows = np.where((mask[13] > 127) | (mask[14] > 127))[0]
        if len(hair_rows) > 0:
            hair_top = int(hair_rows.min())
        # 衣领下边界（cloth=16 标签）
        cloth_rows = np.where(mask[16] > 127)[0]
        cloth_bottom = y2 + int(fh * 1.0)  # fallback
        if len(cloth_rows) > 0:
            cloth_bottom = int(cloth_rows.max())
    except Exception:
        cloth_bottom = y2 + int(fh * 1.0)

    # 目标：35:45 比例。垂直方向 hair_top → cloth_bottom 包含头发+脸+衣领
    # 加上小余量：上下各放一点空隙
    pad_top = int((cloth_bottom - hair_top) * 0.05)
    pad_bot = int((cloth_bottom - hair_top) * 0.05)
    crop_top = max(0, hair_top - pad_top)
    crop_bot = min(img.shape[0], cloth_bottom + pad_bot)
    crop_h = crop_bot - crop_top
    # 35:45 比例 = 0.7778 ; 宽 = 高 * 35/45
    crop_w = int(crop_h * 35 / 45)
    # 水平以人脸中心为基准
    crop_left = max(0, cx - crop_w // 2)
    crop_right = min(img.shape[1], crop_left + crop_w)
    # 如果右边超出，再左移
    if crop_right - crop_left < crop_w:
        crop_left = max(0, crop_right - crop_w)

    crop = img[crop_top:crop_bot, crop_left:crop_right]
    cv2.imwrite(output_path, crop, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
    return ""


def parse_face_only(image_bgr, face_bbox=None):
    """只返回 19 类的 mask（h, 19, h, w）numpy uint8 0/255。"""
    from bisenet import _get_session, KEEP_LABELS, INPUT_SIZE
    h, w = image_bgr.shape[:2]
    img = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
    img = cv2.resize(img, (INPUT_SIZE, INPUT_SIZE), interpolation=cv2.INTER_LINEAR)
    mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
    std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
    x = (img.astype(np.float32) / 255.0 - mean) / std
    x = x.transpose(2, 0, 1)[None, ...].astype(np.float32)
    sess = _get_session()
    outputs = sess.run(None, {"input": x})[0]
    label_map = outputs[0].argmax(axis=0).astype(np.uint8)
    out = np.zeros((19, INPUT_SIZE, INPUT_SIZE), dtype=np.uint8)
    for i in range(19):
        out[i] = (cv2.resize((label_map == i).astype(np.uint8) * 255,
                              (w, h), interpolation=cv2.INTER_LINEAR) > 127).astype(np.uint8) * 255
    return out


_silueta_session = None

def _get_silueta():
    global _silueta_session
    if _silueta_session is None:
        import onnxruntime as ort
        path = os.path.join(BASE, "models", "silueta.onnx")
        if not os.path.exists(path):
            raise FileNotFoundError(f"silueta 模型不存在: {path}")
        _silueta_session = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
    return _silueta_session


def silueta_mask(image_bgr):
    """使用 silueta 模型做人像抠图，返回 0~1 float mask (h, w)。"""
    h, w = image_bgr.shape[:2]
    sess = _get_silueta()
    img_resized = cv2.resize(image_bgr, (320, 320))
    img_rgb = cv2.cvtColor(img_resized, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
    mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
    std = np.array([1.0, 1.0, 1.0], dtype=np.float32)
    img_norm = (img_rgb - mean) / std
    img_t = img_norm.transpose(2, 0, 1)[None, ...].astype(np.float32)
    outs = sess.run(None, {"input.1": img_t})
    mask = outs[0][0, 0]
    mask = (mask - mask.min()) / (mask.max() - mask.min() + 1e-8)
    mask_full = cv2.resize(mask, (w, h))
    return mask_full


_get_box_fn = None  # Hivision U.get_box 懒加载

def _hivision_adjust(bgr, alpha, face_rect, bg_color, head_h, head_top_y, cx):
    """生成标准 35×45mm 证件照 (413×531)。
    缩放: head_h 规一化为 0.70*ID_H (保证脸完整、头比例标准);
    水平: 以脸中心水平居中 (标准证件照脸居中的要求);
    垂直: 头顶固定在 0.10*ID_H, 脸完整置于画布内, 底部留 0.20*ID_H 给颈肩。"""
    bg = np.array(bg_color, dtype=np.float32)
    fg = (alpha[..., None].astype(np.float32) / 255.0)
    bgr_clean = (bgr.astype(np.float32) * fg + bg * (1.0 - fg)).astype(np.uint8)

    h, w = bgr_clean.shape[:2]
    head_h = max(int(head_h), 30)
    scale = (ID_H * 0.70) / head_h
    new_w = max(int(w * scale), 2)
    new_h = max(int(h * scale), 2)
    img_resized = cv2.resize(bgr_clean, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)

    new_head_cx = int(round(cx * scale))
    topm = int(round(head_top_y * scale))
    x1 = new_head_cx - ID_W // 2
    y1 = topm - int(ID_H * 0.10)
    x2 = x1 + ID_W
    y2 = y1 + ID_H

    # 与画布求交
    src_x1 = max(0, x1); src_y1 = max(0, y1)
    src_x2 = min(new_w, x2); src_y2 = min(new_h, y2)
    out = np.full((ID_H, ID_W, 3), bg_color, dtype=np.uint8)
    if src_x2 > src_x1 and src_y2 > src_y1:
        dst_x1 = src_x1 - x1; dst_y1 = src_y1 - y1
        dst_x2 = dst_x1 + (src_x2 - src_x1)
        dst_y2 = dst_y1 + (src_y2 - src_y1)
        out[dst_y1:dst_y2, dst_x1:dst_x2] = img_resized[src_y1:src_y2, src_x1:src_x2]
    return out


def make_id_photo(source_path, output_path, bg_color=(255, 255, 255)):
    """生成标准 35×45mm 证件照 (413×531 像素 @300dpi)。
    流程：
    1) insightface 检测人脸框 + 106 关键点(找 chin/brow 定头部上下)
    2) silueta 模型抠出人像 (alpha)
    3) 缩放: head_h → 0.70*ID_H (保证脸完整、头比例标准)
    4) 水平: 借 Hivision 人像边缘检测, 身体够宽时裁到边缘让肩膀铺满, 否则居中
    5) 垂直: 头顶固定在 0.10*ID_H, 合成到纯色背景输出 413×531
    """
    img = cv2.imread(source_path)
    if img is None:
        return "无法读取源图片"

    faces = _detect_faces(img)
    if not faces:
        return "源图片未检测到人脸"
    face = max(faces, key=lambda f: f.det_score)
    x1, y1, x2, y2 = face.bbox.astype(int)
    fw, fh = x2 - x1, y2 - y1
    face_rect = (x1, y1, fw, fh)
    cx = (x1 + x2) // 2

    chin_y = y2
    brow_y = y1 + int(fh * 0.3)
    if face.landmark_2d_106 is not None:
        lmk = face.landmark_2d_106
        chin_y = int(lmk[0][1]); brow_y = int(lmk[49][1])
    face_h = max(chin_y - brow_y, 50)
    head_top_y = brow_y - int(face_h * 0.5)
    head_h = chin_y - head_top_y

    # 1) silueta 抠图 → alpha
    mask = silueta_mask(img)
    alpha = (np.clip(mask, 0, 1) * 255).astype(np.uint8)

    try:
        out = _hivision_adjust(img, alpha, face_rect, bg_color, head_h, head_top_y, cx)
    except Exception as e:
        print(f"[make_id_photo] _hivision_adjust failed, fallback: {e!r}", flush=True)
        # 回退：老逻辑（silueta + head_scale 居中裁切）
        mask_f = mask[..., None].astype(np.float32)
        bg = np.array(bg_color, dtype=np.float32)
        img_clean = (img.astype(np.float32) * mask_f + bg * (1.0 - mask_f)).astype(np.uint8)
        h, w = img.shape[:2]
        scale = (ID_H * 0.70) / max(head_h, 30)
        new_w = int(w * scale); new_h = int(h * scale)
        img_resized = cv2.resize(img_clean, (new_w, new_h), interpolation=cv2.INTER_LANCZOS4)
        new_head_cx = int(cx * scale); new_head_cy = int((head_top_y + chin_y) // 2 * scale)
        crop_x1 = new_head_cx - ID_W // 2; crop_y1 = new_head_cy - ID_H // 2
        out = np.full((ID_H, ID_W, 3), bg_color, dtype=np.uint8)
        src_x1 = max(0, crop_x1); src_y1 = max(0, crop_y1)
        src_x2 = min(new_w, crop_x1 + ID_W); src_y2 = min(new_h, crop_y1 + ID_H)
        if src_x2 > src_x1 and src_y2 > src_y1:
            out[src_y1 - crop_y1:src_y2 - crop_y1, src_x1 - crop_x1:src_x2 - crop_x1] = \
                img_resized[src_y1:src_y2, src_x1:src_x2]

    cv2.imwrite(output_path, out)
    return ""


# ===================== 换脸 =====================

def crop_main_face_from_template(swapped_path, template_path, out_path=None):
    """从已换脸模板中裁切主头像（左下大图）。
    模板 mm_girl.jpg 有两张人脸：左侧大头像 + 右侧小头像。
    通过对比 swapped 和 template 的人脸位置，定位到左侧大头像区域裁切。"""
    if out_path is None:
        out_path = swapped_path.replace(".png", "_face.jpg")
    img_bgr = cv2.imread(swapped_path)
    if img_bgr is None:
        raise RuntimeError("swapped 模板读取失败")
    faces = face_app.get(img_bgr)
    if not faces:
        raise RuntimeError("swapped 模板未检测到人脸")
    h, w = img_bgr.shape[:2]
    # 主头像在左半边，bbox 左上 x < w * 0.4
    left_faces = [f for f in faces if f.bbox[0] < w * 0.4]
    if not left_faces:
        left_faces = faces
    face = max(left_faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
    x1, y1, x2, y2 = face.bbox.astype(int)
    fw, fh = x2 - x1, y2 - y1
    # 上下左右小扩展，保证头像+肩部+头顶空间
    pad_x = int(fw * 0.3)
    pad_top = int(fh * 0.5)
    pad_bot = int(fh * 0.6)
    nx1 = max(0, x1 - pad_x)
    ny1 = max(0, y1 - pad_top)
    nx2 = min(w, x2 + pad_x)
    ny2 = min(h, y2 + pad_bot)
    crop_bgr = img_bgr[ny1:ny2, nx1:nx2]
    cv2.imwrite(out_path, crop_bgr, [int(cv2.IMWRITE_JPEG_QUALITY), 92])
    return out_path


def swap_faces(source_path, target_path, output_path):
    img_src = cv2.imread(source_path)
    img_dst = cv2.imread(target_path)
    if img_src is None:
        return "无法读取源图片"
    if img_dst is None:
        return "无法读取目标图片"
    faces_src = _detect_faces(img_src)
    faces_dst = _detect_faces(img_dst)
    if not faces_src:
        return "源图片未检测到人脸"
    if not faces_dst:
        return "目标图片未检测到人脸"
    src_face = max(faces_src, key=lambda f: f.det_score)
    result = img_dst.copy()
    for face in faces_dst:
        result = swapper.get(result, face, src_face, paste_back=True)
    cv2.imwrite(output_path, result)
    return ""


# ===================== 护照图片生成 (以换脸后模板为画布, 叠加字段+MRZ) =====================
# mm_girl.jpg 模板布局 (720×1280 竖版):
#   y=   0- 200  深色头部
#   y= 200- 700  上方内容区 (标题/护照号)
#   y= 720- 980  照片+字段区 (左侧大头像+右侧小头像+文字)
#   y= 980-1160  签名+MRZ区
#   y=1160-1280  深色底部 (MRZ 条)


def generate_passport_image(swapped_path, output_path):
    """只换人像：直接输出换脸后的原版模板，保留模板全部原始文字与布局。"""
    shutil.copy2(swapped_path, output_path)
    return output_path


def _clear_text(img, y0, y1, thr=90):
    """去文字：把深色文字像素改为该行背景中位数，保留其余像素(含模板底纹/渐变)，
    避免擦除后出现纯色横条。必须在创建 draw 之前调用（img 会被替换）。"""
    a = np.asarray(img).copy()
    for y in range(y0, y1):
        row = a[y]
        mask = row.max(axis=1) > thr
        if mask.any():
            bg = np.median(row[mask], axis=0).astype(row.dtype)
            row[~mask] = bg
    return Image.fromarray(a)


def replace_passport_fields(base_path, data, output_path):
    """步骤2：以步骤1的结果图(默认模板)为底，更换固定值文字(标题/护照号/6字段/MRZ)。"""
    img = Image.open(base_path).convert("RGB")

    # 擦除三个区域：逐行去文字，保留模板底纹，与底色一致不会出现横条
    for y0, y1 in [(200, 270), (925, 995), (1050, 1110)]:
        img = _clear_text(img, y0, y1)

    W, H = img.size
    draw = ImageDraw.Draw(img)

    def _font(name, size):
        try:
            return ImageFont.truetype(os.path.join(FONT_DIR, name), size)
        except Exception:
            return ImageFont.load_default()

    sf = W / 720
    f_title = _font("DejaVuSans-Bold.ttf", max(14, int(22 * sf)))
    f_pass  = _font("DejaVuSans-Bold.ttf", max(11, int(16 * sf)))
    f_label = _font("DejaVuSans-Bold.ttf", max(9, int(12 * sf)))
    f_val   = _font("DejaVuSans-Bold.ttf", max(11, int(15 * sf)))
    f_mrz   = _font("DejaVuSansMono.ttf", max(11, int(18 * sf)))
    f_tiny  = _font("DejaVuSans-Bold.ttf", max(7, int(9 * sf)))

    surname    = data.get("surname", "") or ""
    given_name = data.get("given_name", "") or ""
    nationality= data.get("nationality", "") or "MYANMAR"
    dob        = data.get("dob", "") or ""
    sex        = data.get("sex", "") or "F"
    issue      = data.get("issue_date", "") or ""
    expiry     = data.get("expiry_date", "") or ""
    birthplace = data.get("birth_place", "") or ""
    authority  = data.get("authority", "") or "MOHA, KYAINGTONG"
    pas        = data.get("passport_no", "") or "MK000000"
    dtype      = (data.get("type", "") or "PV").upper()
    ccode      = (data.get("country_code", "") or "MMR").upper()

    # 模板实际字段行位置 (像素分析得出): 上组 y=930-950, 下组 y=975-989
    field_rows_y = [932, 940, 948, 956, 978, 986]
    L_LBL = (40, 120); L_VAL = (180, 280)
    R_LBL = (420, 500); R_VAL = (580, 640)

    # ── 标题 (居中 y=215, 235) ──
    draw.text((W // 2, 215), "REPUBLIC OF THE UNION OF MYANMAR",
              fill="#111111", font=f_title, anchor="mm")
    draw.text((W // 2, 235), "P A S S P O R T",
              fill="#6B0000", font=f_pass, anchor="mm")

    # ── 护照号 (右对齐 y=235, 255) ──
    draw.text((W - 20, 235), f"Passport No  {pas}", fill="#111111",
              font=f_label, anchor="rt")
    draw.text((W - 20, 255), f"Type  {dtype}    Code  {ccode}",
              fill="#444444", font=f_tiny, anchor="rt")

    # ── 6 字段行 ──
    fields_data = [
        ("Surname / Nom", surname, "Given Name / Prenoms", given_name),
        ("Nationality", nationality, "", ""),
        ("Date of birth", dob, "Sex / Sexe", sex),
        ("Place of birth", birthplace, "", ""),
        ("Date of issue", issue, "Date of expiry", expiry),
        ("Authority / Autorite", authority, "", ""),
    ]
    for i, (l1, v1, l2, v2) in enumerate(fields_data):
        ry = field_rows_y[i]
        draw.text((L_LBL[0], ry), l1, fill="#333333", font=f_label)
        draw.text((L_VAL[0], ry), v1, fill="#000000", font=f_val)
        if l2:
            draw.text((R_LBL[0], ry), l2, fill="#333333", font=f_label)
            draw.text((R_VAL[0], ry), v2, fill="#000000", font=f_val)

    # ── MRZ (两行居中 y=1065, 1100) ──
    line1, line2 = build_mrz(data)
    draw.text((W // 2, 1065), line1, fill="#000000", font=f_mrz, anchor="mm")
    draw.text((W // 2, 1100), line2, fill="#000000", font=f_mrz, anchor="mm")

    img.save(output_path, dpi=(300, 300))
    return output_path


# ===================== 路由 =====================

@app.route("/")
def index():
    return render_template("index.html")


@app.route("/random_passport_data")
def random_data():
    sex = (request.args.get("sex") or request.form.get("sex") or "F")
    return jsonify({"ok": True, **random_passport_data(sex)})


@app.route("/compute_mrz", methods=["POST"])
def compute_mrz():
    data = request.get_json(force=True) or {}
    line1, line2 = build_mrz(data)
    return jsonify({"ok": True, "line1": line1, "line2": line2})


SYNC_DIR = os.path.join(BASE, "web", "synced")
os.makedirs(SYNC_DIR, exist_ok=True)


@app.route("/upload_synced", methods=["POST"])
def upload_synced():
    if "file" not in request.files:
        return jsonify({"ok": False, "error": "no file"}), 400
    f = request.files["file"]
    name = datetime.now().strftime("%Y%m%d_%H%M%S_") + uuid.uuid4().hex[:6] + ".png"
    path = os.path.join(SYNC_DIR, name)
    f.save(path)
    print(f"[SYNC] {path}  size={os.path.getsize(path)}", flush=True)
    return jsonify({"ok": True, "path": path, "url": f"/synced/{name}"})


@app.route("/synced/<name>")
def get_synced(name):
    return send_file(os.path.join(SYNC_DIR, name))


@app.route("/generate_passport", methods=["POST"])
def gen_passport():
    if "source" not in request.files:
        return jsonify({"ok": False, "error": "请上传大头照"}), 400
    sf = request.files["source"]
    if sf.filename == "":
        return jsonify({"ok": False, "error": "请选择源图片"}), 400

    # 保存源图
    src_ext = os.path.splitext(sf.filename)[1] or ".jpg"
    src_name = f"src_{uuid.uuid4().hex}{src_ext}"
    src_path = os.path.join(app.config["UPLOAD_FOLDER"], src_name)
    sf.save(src_path)

    pair_name = paired_name()
    paired_src = os.path.join(app.config["PAIRED_PIN"], f"{pair_name}{src_ext}")
    shutil.copy2(src_path, paired_src)
    print(f"[Paired] 源照 → {paired_src}", flush=True)

    # Step 1: 制作 413×531 证件照（BiSeNet 抠图+换底+几何校正）
    id_name = f"id_{uuid.uuid4().hex}.jpg"
    id_path = os.path.join(app.config["UPLOAD_FOLDER"], id_name)
    err = make_id_photo(src_path, id_path)
    if err:
        return jsonify({"ok": False, "error": f"制证失败: {err}"}), 500

    # Step 2: 证件照脸 → 模板双头像
    swapped_name = f"swapped_{uuid.uuid4().hex}.png"
    swapped_path = os.path.join(app.config["UPLOAD_FOLDER"], swapped_name)
    err = swap_faces(id_path, TEMPLATE_GIRL, swapped_path)
    if err:
        return jsonify({"ok": False, "error": f"换脸失败: {err}"}), 500

    out_name = f"passport_{uuid.uuid4().hex}.png"
    out_path = os.path.join(app.config["RESULT_FOLDER"], out_name)
    task_id = out_name

    with tasks_lock:
        tasks[task_id] = {"status": "processing", "mode": "passport"}

    def do_gen():
        print(f"[Passport] {task_id} started", flush=True)
        try:
            # 只换人像：换脸后的原版模板直接作为结果，模板文字保持不变
            generate_passport_image(swapped_path, out_path)
            print(f"[Passport] {task_id} generated", flush=True)
        except Exception as e:
            import traceback
            print(f"[Passport] {task_id} ERROR: {e}", flush=True)
            traceback.print_exc()
            with tasks_lock:
                tasks[task_id] = {"status": "error", "error": str(e)}
            return
        result_url = f"/result/{out_name}"
        with tasks_lock:
            paired_out = os.path.join(app.config["PAIRED_POUT"], f"{pair_name}.png")
            shutil.copy2(out_path, paired_out)
            print(f"[Paired] 护照 → {paired_out}", flush=True)
            tasks[task_id] = {
                "status": "done",
                "result_url": result_url,
                "mode": "passport",
                "paired_name": pair_name,
            }
        print(f"[Passport] {task_id} done", flush=True)
        for p in [src_path, swapped_path]:
            try: os.remove(p)
            except OSError: pass

    executor.submit(do_gen)
    return jsonify({"ok": True, "task_id": task_id})


@app.route("/replace_fields", methods=["POST"])
def replace_fields():
    """步骤2：以步骤1结果图(默认模板)为基础，更换固定值文字。"""
    base = (request.form.get("base") or "").strip()
    if not base:
        return jsonify({"ok": False, "error": "缺少步骤1结果图"}), 400

    data = {}
    for key in ["type", "country_code", "passport_no", "surname", "given_name",
                "nationality", "dob", "sex", "issue_date", "expiry_date",
                "birth_place", "authority"]:
        data[key] = (request.form.get(key, "") or "").strip()

    if not data["type"]: data["type"] = "PV"
    if not data["country_code"]: data["country_code"] = "MMR"
    if not data["nationality"]: data["nationality"] = "MYANMAR"
    if not data["authority"]: data["authority"] = "MOHA, KYAINGTONG"
    sex = (data.get("sex") or "F").upper()[:1]
    if sex not in ("M", "F"):
        sex = "F"
    data["sex"] = sex

    rnd = random_passport_data(sex=sex)
    for k in ["passport_no", "dob", "birth_place", "issue_date",
              "expiry_date", "surname", "given_name"]:
        if not data[k]:
            data[k] = rnd[k]

    base_path = os.path.join(app.config["RESULT_FOLDER"], os.path.basename(base))
    if not os.path.exists(base_path):
        return jsonify({"ok": False, "error": "找不到步骤1结果图"}), 404

    out_name = f"fields_{uuid.uuid4().hex}.png"
    out_path = os.path.join(app.config["RESULT_FOLDER"], out_name)
    try:
        replace_passport_fields(base_path, data, out_path)
    except Exception as e:
        return jsonify({"ok": False, "error": f"更换固定值失败: {e}"}), 500

    print(f"[Fields] {base} → {out_name}", flush=True)
    return jsonify({"ok": True, "result_url": f"/result/{out_name}", "filename": out_name})


@app.route("/make_id_photo", methods=["POST"])
def make_id_photo_api():
    if "source" not in request.files:
        return jsonify({"ok": False, "error": "请上传大头照"}), 400
    sf = request.files["source"]
    if sf.filename == "":
        return jsonify({"ok": False, "error": "请选择源图片"}), 400
    bg = (request.form.get("bg", "white") or "white").lower()
    bg_map = {"white": (255, 255, 255), "blue": (214, 230, 242),
              "red": (220, 60, 60), "gray": (240, 240, 240)}
    bg_color = bg_map.get(bg, (255, 255, 255))

    src_ext = os.path.splitext(sf.filename)[1] or ".jpg"
    src_name = f"src_{uuid.uuid4().hex}{src_ext}"
    src_path = os.path.join(app.config["UPLOAD_FOLDER"], src_name)
    sf.save(src_path)

    id_name = f"id_{uuid.uuid4().hex}.jpg"
    id_path = os.path.join(app.config["UPLOAD_FOLDER"], id_name)
    err = make_id_photo(src_path, id_path, bg_color=bg_color)
    if err:
        return jsonify({"ok": False, "error": err}), 500
    try: os.remove(src_path)
    except OSError: pass
    return jsonify({"ok": True, "result_url": f"/result/{id_name}",
                    "filename": id_name})


@app.route("/smart_crop_source", methods=["POST"])
def smart_crop_source():
    """智能裁切自拍照：含完整头发+脸+部分衣领，按 35:45 比例输出。
    备用——当前 UI 改用浏览器 contain 直接显示原图。"""
    if "source" not in request.files:
        return jsonify({"ok": False, "error": "请上传大头照"}), 400
    sf = request.files["source"]
    if sf.filename == "":
        return jsonify({"ok": False, "error": "请选择源图片"}), 400

    src_ext = os.path.splitext(sf.filename)[1] or ".jpg"
    src_name = f"src_{uuid.uuid4().hex}{src_ext}"
    src_path = os.path.join(app.config["UPLOAD_FOLDER"], src_name)
    sf.save(src_path)

    out_name = f"crop_{uuid.uuid4().hex}.jpg"
    out_path = os.path.join(app.config["UPLOAD_FOLDER"], out_name)
    err = smart_crop_selfie(src_path, out_path)
    if err:
        shutil.copy2(src_path, out_path)
    try: os.remove(src_path)
    except OSError: pass
    return jsonify({"ok": True, "result_url": f"/result/{out_name}",
                    "filename": out_name})


@app.route("/status/<task_id>")
def task_status(task_id):
    with tasks_lock:
        task = tasks.get(task_id)
    if task is None:
        return jsonify({"ok": False, "error": "任务不存在"}), 404
    return jsonify(task)


@app.route("/result/<filename>")
def download_result(filename):
    for folder in (app.config["RESULT_FOLDER"], app.config["UPLOAD_FOLDER"]):
        path = os.path.join(folder, filename)
        if os.path.exists(path):
            mime = "image/jpeg" if filename.lower().endswith((".jpg", ".jpeg")) else "image/png"
            return send_file(path, mimetype=mime)
    return "文件不存在", 404


def cleanup_old_files(folder, max_age_seconds=3600):
    now = time.time()
    for fname in os.listdir(folder):
        fpath = os.path.join(folder, fname)
        if os.path.isfile(fpath) and (now - os.path.getmtime(fpath) > max_age_seconds):
            try: os.remove(fpath)
            except OSError: pass


@app.route("/cleanup", methods=["POST"])
def cleanup():
    cleanup_old_files(app.config["UPLOAD_FOLDER"], 1800)
    cleanup_old_files(app.config["RESULT_FOLDER"], 1800)
    with tasks_lock:
        tasks.clear()
    return jsonify({"ok": True})


if __name__ == "__main__":
    cleanup_old_files(app.config["UPLOAD_FOLDER"], 3600)
    cleanup_old_files(app.config["RESULT_FOLDER"], 3600)
    app.run(host="0.0.0.0", port=7860, debug=False, threaded=True)
