#!/usr/bin/env python3
"""Generate a bounded native-size GFX CJK font and reproducible footprint data.

The ordered source set is GB2312 level 1 followed by level 2. Required UI and
validation characters are inserted first, so reducing the count never removes
product strings. The source font is not copied into the repository.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import re
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont, __version__ as pillow_version

DEFAULT_FONT = Path("/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc")
DEFAULT_GLYPH_COUNT = 5000
REQUIRED = (
    "穿戴式智能体终端随身查看控制对话云端审批最终结果帮助按键设置亮度"
    "振动主屏幕文字大小主题表盘电池网络网关固件时间日期星期一二三四五六日"
    "空闲运行完成失败离线等待确认发送取消工具活动简体繁體覆蓋混合名称检查"
    "测试依赖不可用长会话响应第二段不丢失项目内存磁盘概览系统设备显示语音"
    "。，、！？：；（）【】《》“”‘’—…·"
)


def gb2312_han_rows(first_high: int, last_high: int) -> list[str]:
    result: list[str] = []
    seen: set[str] = set()
    for high in range(first_high, last_high + 1):
        for low in range(0xA1, 0xFF):
            try:
                value = bytes((high, low)).decode("gb2312")
            except UnicodeDecodeError:
                continue
            if len(value) == 1 and "\u3400" <= value <= "\u9fff" and value not in seen:
                seen.add(value)
                result.append(value)
    return result


def gb2312_level1_han() -> list[str]:
    return gb2312_han_rows(0xB0, 0xD7)


def gb2312_level2_han() -> list[str]:
    return gb2312_han_rows(0xD8, 0xF7)


def gb2312_han() -> list[str]:
    return gb2312_level1_han() + gb2312_level2_han()


def characters(count: int) -> list[str]:
    ordered: list[str] = []
    seen: set[str] = set()
    source = REQUIRED + "".join(gb2312_level1_han()) + "".join(gb2312_level2_han())
    for value in source:
        if value not in seen and not value.isspace():
            seen.add(value)
            ordered.append(value)
        if len(ordered) == count:
            break
    if len(ordered) != count:
        raise SystemExit(f"requested {count} glyphs, only {len(ordered)} are available")
    return sorted(ordered, key=ord)


def raster(font: ImageFont.FreeTypeFont, value: str) -> tuple[bytes, tuple[int, int, int, int, int]]:
    left, top, right, bottom = font.getbbox(value, anchor="ls")
    width, height = max(0, right - left), max(0, bottom - top)
    advance = max(1, min(255, round(font.getlength(value))))
    if not width or not height:
        return b"", (0, 0, advance, left, top)
    image = Image.new("L", (width, height), 0)
    ImageDraw.Draw(image).text((-left, -top), value, font=font, fill=255, anchor="ls")
    bits = bytearray()
    byte = used = 0
    pixels = image.get_flattened_data() if hasattr(image, "get_flattened_data") else image.getdata()
    for pixel in pixels:
        byte = (byte << 1) | (pixel >= 96)
        used += 1
        if used == 8:
            bits.append(byte)
            byte = used = 0
    if used:
        bits.append(byte << (8 - used))
    return bytes(bits), (width, height, advance, left, top)


def encode(font_path: Path, face: int, size: int, count: int):
    font = ImageFont.truetype(str(font_path), size=size, index=face)
    chars = characters(count)
    bitmaps = bytearray()
    glyphs = []
    for value in chars:
        bitmap, metric = raster(font, value)
        glyphs.append((len(bitmaps), *metric, ord(value)))
        bitmaps.extend(bitmap)
    return font, chars, bytes(bitmaps), glyphs


def write_cpp(path: Path, font_path: Path, face: int, size: int, count: int,
              name: str = "", explicit: str = "") -> dict:
    if explicit:
        chars = sorted(set(explicit), key=ord)
        count = len(chars)
        font = ImageFont.truetype(str(font_path), size=size, index=face)
        bitmaps = bytearray(); glyphs = []
        for value in chars:
            bitmap, metric = raster(font, value)
            glyphs.append((len(bitmaps), *metric, ord(value)))
            bitmaps.extend(bitmap)
        bitmaps = bytes(bitmaps)
    else:
        font, chars, bitmaps, glyphs = encode(font_path, face, size, count)
    symbol = name or f"CjkSc{size}"
    if name:
        stem = "".join(("_" + c.lower() if c.isupper() else c) for c in symbol).lstrip("_")
        stem = re.sub(r"(?<=\D)(?=\d)", "_", stem)
    else:
        stem = f"cjk_sc_{size}"
    path.parent.mkdir(parents=True, exist_ok=True)
    rows = []
    for offset in range(0, len(bitmaps), 16):
        rows.append("    " + ", ".join(f"0x{v:02x}" for v in bitmaps[offset:offset + 16]) + ",")
    glyph_rows = [
        f"    {{0x{code:04x}, {off}, {w}, {h}, {advance}, {xoff}, {yoff}}},"
        for off, w, h, advance, xoff, yoff, code in glyphs
    ]
    ascent, descent = font.getmetrics()
    y_advance = ascent + descent
    source_hash = hashlib.sha256(font_path.read_bytes()).hexdigest()
    content = f'''// Generated by scripts/generate-cjk-subset.py; do not edit.
// Source: Noto Sans CJK SC (SIL OFL 1.1), face={face}, size={size}, glyphs={count}
// Source SHA256: {source_hash}
#include "cjk_subset_font.h"

namespace wearable {{ namespace fonts {{
namespace {{
const uint8_t {stem}_bitmaps[] PROGMEM = {{
{chr(10).join(rows)}
}};
const SubsetGlyph {stem}_glyphs[] PROGMEM = {{
{chr(10).join(glyph_rows)}
}};
}}
const SubsetFont {symbol}({stem}_bitmaps,{stem}_glyphs,{count},{ascent},{descent},{y_advance});
}} }} // namespace wearable::fonts
'''
    path.write_text(content)
    return {
        "size_px": size,
        "glyph_count": count,
        "bitmap_bytes": len(bitmaps),
        "glyph_table_bytes": 16 * count,
        "estimated_binary_bytes": len(bitmaps) + 16 * count + 24,
        "source_sha256": source_hash,
        "pillow": pillow_version,
    }


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--font", type=Path, default=DEFAULT_FONT)
    parser.add_argument("--face", type=int, default=2, help="TTC face; 2 is Noto Sans CJK SC")
    parser.add_argument("--size", type=int, default=28)
    parser.add_argument("--count", type=int, default=DEFAULT_GLYPH_COUNT)
    parser.add_argument("--name", default="")
    parser.add_argument("--characters", default="")
    parser.add_argument("--output", type=Path)
    parser.add_argument("--report", type=Path)
    args = parser.parse_args()
    if not args.font.is_file():
        raise SystemExit(f"font not found: {args.font}")
    report = []
    if args.output:
        report.append(write_cpp(args.output, args.font, args.face, args.size, args.count,
                                args.name, args.characters))
    else:
        for size in (16, 24, 28):
            for count in (3000, 3500, 5000):
                _, _, bitmaps, _ = encode(args.font, args.face, size, count)
                report.append({
                    "size_px": size, "glyph_count": count, "bitmap_bytes": len(bitmaps),
                    "glyph_table_bytes": 16 * count,
                    "estimated_binary_bytes": len(bitmaps) + 16 * count + 24,
                })
    text = json.dumps(report, indent=2, ensure_ascii=False) + "\n"
    if args.report:
        args.report.parent.mkdir(parents=True, exist_ok=True)
        args.report.write_text(text)
    print(text, end="")


if __name__ == "__main__":
    main()
