#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
auto_text.py: 自动化 读-消-写 模块
核心思路：不手绘字符 JSON，完全自动化——
  1) OCR 自动识别模板文字位置
  2) 布局逻辑自动匹配 标签-内容 对
  3) 程序采样字体属性 (颜色/字号)
  4) Inpaint 擦除旧值 + 仿真重绘

用法：
  # 分析布局 (首次运行，生成缓存 JSON)
  python3 auto_text.py analyze <模板图片> [--out <layout.json>]

  # 替换文字 (读 消 写)
  python3 auto_text.py process <模板图片> <输出图片> --fields '<json>' [--layout <layout.json>]

依赖: easyocr numpy opencv-python pillow
"""

import json
import os
import re
import sys

import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageFont, ImageFilter

# ==================== 配置区 ======================

FONT_DIR = "/usr/share/fonts/truetype/dejavu"
FONT_TEXT = os.path.join(FONT_DIR, "DejaVuSans-Bold.ttf")
FONT_MRZ = "/data/port/fonts/DejaVuSansMono.ttf"

# 标签关键词 → 字段 key (词序: 长词在前避免误匹配)
LABEL_KEYS = [
    ("passport no", "passport_no"),
    ("date of expiry", "expiry_date"),
    ("date of birth", "dob"),
    ("date of issue", "issue_date"),
    ("place of birth", "birth_place"),
    ("given name", "given_name"),
    ("nationality", "nationality"),
    ("authority", "authority"),
    ("surname", "surname"),
    ("expiry date", "expiry_date"),
    ("birth date", "dob"),
    ("issue date", "issue_date"),
    ("sex", "sex"),
    ("country code", "country_code"),
    ("country", "country_code"),
    ("passport", "passport_no"),
    ("type", "type"),
]

# 字段里不要当作值的词
STOP_WORDS = {
    "date", "of", "the", "union", "myanmar", "repub", "republic",
    "passport", "type", "code", "country", "sex", "nationality",
    "surname", "given", "name", "place", "birth", "issue", "issued",
    "expiry", "expires", "authority", "mina", "personal", "number", "no",
}

# 值块高度阈值 (标题通常 <= 14，值 >= 15~20) —— 依据 M001 实测
VALUE_H_MIN = 15

# 左列 / 右列 值区 x 分界 (720 宽模板)
COL_SPLIT_X = 385

# 每字段重绘时的覆盖块 (若自动分析得出，按需覆盖)
FIELD_OVERRIDES = {
    "sex":    {"align": "mm"},   # 居中
}


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

def _find_font(size):
    for fp in (FONT_TEXT,):
        try:
            return ImageFont.truetype(fp, size)
        except Exception:
            continue
    return ImageFont.load_default()


def _find_mrz_font(size):
    for fp in (FONT_MRZ,):
        try:
            return ImageFont.truetype(fp, size)
        except Exception:
            continue
    return _find_font(size)


def _load_reader():
    import easyocr
    return easyocr.Reader(["en"], gpu=False, verbose=False)


# ==================== OCR 分析 ===================

def ocr_blocks(reader, img, img_path=None):
    """EasyOCR 识别 → 归一化文本块列表"""
    rgb = img if img.ndim == 3 else img
    results = reader.readtext(rgb) if isinstance(rgb, np.ndarray) else reader.readtext(img_path)
    blocks = []
    for (bbox, text, conf) in results:
        xs = [p[0] for p in bbox]
        ys = [p[1] for p in bbox]
        x = int(min(xs)); y = int(min(ys))
        w = int(max(xs) - x); h = int(max(ys) - y)
        blocks.append({
            "text": re.sub(r"\s+", " ", text).strip(),
            "conf": float(conf),
            "bbox": (x, y, w, h),
            "x_center": x + w / 2,
            "x_left": x,
            "y_center": y + h / 2,
            "y_min": y,
            "y_max": y + h,
        })
    blocks.sort(key=lambda b: (b["y_min"], b["x_left"]))
    return blocks


def _norm(text):
    return re.sub(r"[\s.<>_|,]", "", (text or "")).upper()


def _is_value(b, title_keys):
    """启发式判断一个块是不是『值』而非标题/噪音"""
    txt = b["text"]
    if len(txt) < 1:
        return False
    if b["bbox"][3] < VALUE_H_MIN:
        return False
    if txt in STOP_WORDS:
        return False
    norm = _norm(txt)
    if norm and norm in {_norm(t) for t in title_keys}:
        return False
    # 置信度过低且纯符号 → 丢弃
    if b["conf"] < 0.3 and not re.search(r"[A-Z0-9]{2,}", txt):
        return False
    return True


def _cluster_rows(blocks, tol=14):
    """把块按 y 聚类成『行』，返回行列表 [{y_center, blocks:[...]}] (升序)"""
    rows = []
    for b in sorted(blocks, key=lambda x: x["y_center"]):
        if rows and abs(b["y_center"] - rows[-1]["y_center"]) <= tol:
            rows[-1]["blocks"].append(b)
            ys = [x["y_center"] for x in rows[-1]["blocks"]]
            rows[-1]["y_center"] = sum(ys) / len(ys)
        else:
            rows.append({"y_center": b["y_center"], "blocks": [b]})
    return rows


def analyze_layout(template_path):
    """全自动分析模板，返回字段布局 dict：
       字段名 → {bbox, text, color, font_size, align}
    """
    img = cv2.imread(template_path)
    if img is None:
        raise RuntimeError(f"无法读取: {template_path}")
    H, W = img.shape[:2]
    reader = _load_reader()
    blocks = ocr_blocks(reader, img)

    # 1) 识别标题关键词
    all_norm = {_norm(b["text"]) for b in blocks}
    title_keys = set()
    for word, _ in LABEL_KEYS:
        w = _norm(word)
        if any(w in n for n in all_norm):
            title_keys.add(w)

    # 2) 划分值块（排除顶部国家名标题与 MRZ 区）
    left_col, right_col, top_row = [], [], []
    sex_cand = None
    for b in blocks:
        b_cent = b["y_center"]
        if b_cent < 700 or b_cent >= 1020:
            continue                      # 标题头 / MRZ 区
        if not _is_value(b, title_keys):
            continue
        norm = _norm(b["text"])
        # 顶部分区：Type / Country / Passport No
        if 715 <= b_cent <= 762:
            top_row.append(b)
            continue
        if 762 <= b_cent < 1020:
            if re.fullmatch(r"[MFS]", norm) and 860 <= b_cent <= 930:
                sex_cand = b
                continue
            if b["x_center"] < COL_SPLIT_X:
                left_col.append(b)
            else:
                right_col.append(b)

    # 3) 顶部：Type / Country code / Passport No
    layout = {}
    top_order = ["type", "country_code", "passport_no"]
    top_row.sort(key=lambda b: b["x_left"])
    for key, b in zip(top_order, top_row):
        layout[key] = _snapshot(img, b, key)

    # 4) 左列：以字段行锚点网格匹配真实块（右列 birth_place 动态校准），未命中则占位
    left_col.sort(key=lambda b: b["y_center"])
    right_col.sort(key=lambda b: b["y_center"])
    left_keys = ["surname", "given_name", "nationality", "dob", "issue_date", "expiry_date"]
    right_keys = ["birth_place", "authority"]

    # 字段行锚点 (720 宽缅甸护照 PVA 布局，实测值行中心 = app.py ROW_* 中线)
    ROW_ANCHORS = [("surname", 765), ("given_name", 784), ("nationality", 822),
                   ("dob", 865), ("issue_date", 948), ("expiry_date", 994)]
    # 用右列 birth_place 行校准左列锚点 (SEX/birth_place 行中心期望 ~907)
    offset = 0.0
    if right_col:
        bp = next((b for b in right_col if "birth_place" in (_norm(b["text"]) and "BIRTH" or "")
                   and b["y_center"] < 920), None)
    if right_col and any(b for b in right_col if _norm(b["text"]) in ("MATMAN", "YANGON", "MANDALAY", "TAUNGGYI", "BAGO", "PATHEIN", "SITTWE", "MYITKYINA", "MAWLAMYINE", "MONYWA", "MEIKTILA", "KYAINGTONG") or b["y_center"] < 920):
        offset = right_col[0]["y_center"] - 907

    used = set()
    for key, ref in ROW_ANCHORS:
        ref += offset
        hit = None
        for b in left_col:
            if id(b) in used:
                continue
            if abs(b["y_center"] - ref) <= 20:
                if hit is None or abs(b["y_center"] - ref) < abs(hit["y_center"] - ref):
                    hit = b
        if hit is not None:
            used.add(id(hit))
            layout[key] = _snapshot(img, hit, key)
        else:
            bbox = (240, int(ref) - 11, 130, 22)
            layout[key] = {"bbox": list(bbox), "text": "",
                           "color": _sample_color(img, bbox), "font_size": 20, "align": "lm"}

    # 5) 右列：Place of birth / Authority
    for key, b in zip(right_keys, right_col):
        layout[key] = _snapshot(img, b, key)

    # 6) Sex：优先 sex_cand；否则由 birth_place 行左端点推断
    if sex_cand is not None:
        bbox = sex_cand["bbox"]
        bbox = (bbox[0], bbox[1], max(bbox[2], 20), bbox[3])
        layout["sex"] = _snapshot(img, {**sex_cand, "bbox": bbox}, "sex", align="mm")
    elif "birth_place" in layout:
        bp = layout["birth_place"]["bbox"]
        bbox = (bp[0] - 175, bp[1], 40, bp[3])
        layout["sex"] = {"bbox": bbox, "text": "",
                         "color": _sample_color(img, bbox), "font_size": 18, "align": "mm"}
    # sex 对齐修正为居中
    if "sex" in layout:
        layout["sex"]["align"] = "mm"
        layout["sex"]["font_size"] = max(int(layout["sex"]["bbox"][3] * 0.95), 16)

    # 7) MRZ 两行 (扫描底部)
    mrz_rows = [b for b in blocks if b["y_min"] > 1020 and b["bbox"][2] > 200]
    mrz_rows.sort(key=lambda x: x["y_min"])
    for i, key in enumerate(("mrz1", "mrz2")):
        if i < len(mrz_rows):
            b = mrz_rows[i]
            layout[key] = _snapshot(img, b, key, align="mrz")

    return {"size": [W, H], "fields": layout}


def _sample_color(img_bgr, bbox):
    """从值区采样文字颜色：分离背景(mode)与文字(离背景最远的像素簇)。"""
    x, y, w, h = bbox
    roi = img_bgr[y:y + h, x:x + w]
    if roi.size == 0:
        return (35, 35, 37)
    gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY).astype(np.int16)
    hist, edges = np.histogram(gray, bins=16, range=(0, 255))
    bg = edges[hist.argmax()]                      # 背景色 (最频繁区)
    diff = np.abs(gray - bg)
    thr = 25
    dark = (gray < bg - thr)
    light = (gray > bg + thr)
    mask = dark if dark.sum() >= light.sum() else light
    if mask.sum() >= 5:
        px = roi[mask]
        # 文字色 = 与之差最大的那 1/3 (取像素簇)
        vals = roi[mask]
        sel = vals[:: max(1, len(vals) // 30)][:30]
        r = int(np.median(sel[:, 2])); g = int(np.median(sel[:, 1])); b_ = int(np.median(sel[:, 0]))
        return (r, g, b_)
    # 无文字像素：退化为深色中位数（保守深灰）
    dark_all = gray < np.median(gray) - 10
    if dark_all.sum() >= 3:
        px = roi[dark_all]
        return (int(np.median(px[:, 2])), int(np.median(px[:, 1])), int(np.median(px[:, 0])))
    return (60, 60, 62)


def _snapshot(img_bgr, block, key, align=None):
    """采样块属性 → 布局条目"""
    x, y, w, h = block["bbox"]
    font_size = max(int(h * 0.95), 8)
    color = _sample_color(img_bgr, (x, y, w, h))
    if align is None:
        align = "lm"
        if key in ("type", "country_code"):
            align = "mm"
        elif key == "passport_no":
            align = "rm"
        elif key == "mrz1":
            align = "mrz"
    return {
        "bbox": list(block["bbox"]),
        "text": block["text"],
        "color": list(color),
        "font_size": font_size,
        "align": align,
    }


# ==================== 擦除 =======================

def _erase_text(img_bgr, bbox, text_color, tol=42):
    """按颜色精确擦除文字：只把区域内接近文字色的像素替换为本行背景中位数。
    不向上扩展、不整块 mask，保留模板底纹（避免灰块/擦伤标签）。"""
    x, y, w, h = bbox
    H, W = img_bgr.shape[:2]
    x0 = max(0, x); y0 = max(0, y)
    x1 = min(W, x + w); y1 = min(H, y + h)
    if x0 >= x1 or y0 >= y1:
        return img_bgr
    tc = np.array(text_color, dtype=np.int16)
    roi = img_bgr[y0:y1, x0:x1].astype(np.int16)
    dist = np.abs(roi - tc).sum(axis=2)
    is_text = dist < tol
    for yy in range(roi.shape[0]):
        row = roi[yy]
        m = is_text[yy]
        if m.any():
            nb = ~m
            bgc = np.median(row[nb], axis=0) if nb.sum() > 2 else np.median(row, axis=0)
            row[m] = bgc
    img_bgr[y0:y1, x0:x1] = roi.astype(np.uint8)
    return img_bgr


def _inpaint_roi(img_bgr, x, y, w, h, pad=3):
    mask = np.zeros((img_bgr.shape[0], img_bgr.shape[1]), np.uint8)
    x0 = max(0, x - pad); y0 = max(0, y - pad)
    x1 = min(img_bgr.shape[1], x + w + pad); y1 = min(img_bgr.shape[0], y + h + pad)
    mask[y0:y1, x0:x1] = 255
    try:
        return cv2.inpaint(img_bgr, mask, 2, cv2.INPAINT_TELEA)
    except Exception:
        return img_bgr


def _composite_text(clean_bgr, text, bbox, color, font_size, align, out_rgb=True):
    """在 clean_bgr 上绘制文字，返回合成后的图像"""
    x, y, w, h = bbox
    clean_pil = Image.fromarray(cv2.cvtColor(clean_bgr, cv2.COLOR_BGR2RGB))
    layer = Image.new("RGBA", clean_pil.size, (0, 0, 0, 0))
    draw = ImageDraw.Draw(layer)
    if align == "mrz":
        font = _find_mrz_font(font_size)
    else:
        font = _find_font(font_size)
    tw = draw.textlength(text, font=font)
    th = font_size
    cy = y + h // 2
    if align == "rm":
        px = x + w - 1
    elif align == "mm" or align == "mrz":
        px = x + w // 2 - tw / 2
    else:
        px = x + 1
    draw.text((px, cy - th / 2 + 2), text, font=font, fill=(*color, 255))
    # 噪声 + 模糊，模拟打印质感 (只扰动 RGB，不改 alpha)
    arr = np.array(layer)
    rgb = arr[..., :3].astype(np.int16)
    noise = np.random.normal(0, 12, rgb.shape).astype(np.int16)
    arr[..., :3] = np.clip(rgb + noise, 0, 255).astype(np.uint8)
    layer_noisy = Image.fromarray(arr).filter(ImageFilter.GaussianBlur(radius=0.5))
    return Image.alpha_composite(clean_pil.convert("RGBA"), layer_noisy)


# ==================== 渲染 =======================

def render(template_path, output_path, data, layout=None):
    """以模板为底，擦除旧值 → 重绘新值 + MRZ"""
    img_bgr = cv2.imread(template_path)
    if img_bgr is None:
        raise RuntimeError(f"无法读取: {template_path}")
    H, W = img_bgr.shape[:2]

    if layout is None:
        layout = analyze_layout(template_path)
    fields = layout.get("fields", {})

    # 收集要写入的字段
    to_write = {}
    for key in ["type", "country_code", "passport_no", "surname", "given_name",
                "nationality", "dob", "sex", "issue_date", "expiry_date",
                "birth_place", "authority"]:
        val = (data.get(key, "") or "").strip().upper()
        if val:
            to_write[key] = val

    # 逐字段擦除 → 重绘
    out = img_bgr.copy()
    for key, val in to_write.items():
        spec = fields.get(key)
        if not spec:
            continue
        bbox = tuple(spec["bbox"])
        color = tuple(spec.get("color", (35, 35, 37)))
        out = _erase_text(out, bbox, color)
        fs = spec.get("font_size", 16)
        align = spec.get("align", "lm")
        pil_rgba = _composite_text(out, val, bbox, color, fs, align)
        out = cv2.cvtColor(np.array(pil_rgba.convert("RGB")), cv2.COLOR_RGB2BGR)

    # MRZ (强制重算，除非字段没提供且无数据)
    mrz_lines = None
    try:
        from mrz import build_mrz as _bmrz
        MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN",
                  "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"]

        def _yymmdd(s):
            from datetime import datetime
            s = (s or "").strip().upper()
            for fmt in ("%d %b %Y", "%d %B %Y", "%Y-%m-%d", "%d/%m/%Y"):
                try:
                    return datetime.strptime(s, fmt).strftime("%y%m%d")
                except Exception:
                    pass
            return "300101"

        birth = _yymmdd(data.get("dob"))
        expir = _yymmdd(data.get("expiry_date"))
        mrz_lines = _bmrz(
            (data.get("passport_no", "") or "").upper().replace(" ", "").replace("<", ""),
            (data.get("surname", "") or "").upper(),
            (data.get("given_name", "") or "").upper(),
            birth, expir,
            (data.get("sex", "") or "F").upper()[:1] or "F",
            (data.get("country_code", "") or "MMR").upper()[:3],
            (data.get("type", "") or "PV").upper(),
            (data.get("country_code", "") or "MMR").upper()[:3],
        )
    except Exception:
        mrz_lines = None

    if mrz_lines:
        for i, line in enumerate(mrz_lines):
            key = f"mrz{i + 1}"
            spec = fields.get(key)
            if not spec:
                continue
            bbox = tuple(spec["bbox"])
            x, y, w, h = bbox
            color = tuple(spec.get("color", (50, 50, 50)))
            out = _erase_text(out, bbox, color)
            # OCR-B 等宽 44 字符需恰好填满 bbox 宽度 (OCR-B advance≈0.73em)
            fs = int(round(w / (44 * 0.73)))
            pil_rgba = _composite_text(out, line, bbox, color, fs, "mrz")
            out = cv2.cvtColor(np.array(pil_rgba.convert("RGB")), cv2.COLOR_RGB2BGR)

    os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)
    cv2.imwrite(output_path, out)
    return output_path


# ==================== CLI ========================

def cli():
    import argparse
    ap = argparse.ArgumentParser(description="auto_text 自动化 读-消-写")
    sub = ap.add_subparsers(dest="cmd")

    p1 = sub.add_parser("analyze", help="自动分析模板布局")
    p1.add_argument("template")
    p1.add_argument("--out", default="")

    p2 = sub.add_parser("process", help="自动化替换文字")
    p2.add_argument("template")
    p2.add_argument("output")
    p2.add_argument("--fields", default="{}")
    p2.add_argument("--layout", default="")

    args = ap.parse_args()

    if args.cmd == "analyze":
        layout = analyze_layout(args.template)
        out = args.out or os.path.splitext(args.template)[0] + "_layout.json"
        with open(out, "w", encoding="utf-8") as f:
            json.dump(layout, f, ensure_ascii=False, indent=2)
        print(f"[analyze] 布局已保存: {out}")
        for k, v in layout["fields"].items():
            print(f"  {k:14s} bbox=({v['bbox'][0]},{v['bbox'][1]},{v['bbox'][2]},{v['bbox'][3]}) "
                  f"color={v['color']} fs={v['font_size']} text={v['text']!r}")
    elif args.cmd == "process":
        try:
            data = json.loads(args.fields) if isinstance(args.fields, str) else args.fields
        except Exception:
            data = {}
        layout = None
        if args.layout and os.path.exists(args.layout):
            with open(args.layout, encoding="utf-8") as f:
                layout = json.load(f)
        render(args.template, args.output, data, layout)
        print(f"[process] 已生成: {args.output}")
    else:
        ap.print_help()


if __name__ == "__main__":
    cli()