#!/usr/bin/env python3
"""Build the stroke-order table from Make Me a Hanzi.

Each character carries two sets of geometry:

* ``strokes`` — one closed outline per stroke.  Filling it is what makes the
  stroke look written rather than drawn: real brush shapes, with the entry,
  the swell and the tapered exit.
* ``medians`` — the writing path through each stroke, in order and with
  direction.  It drives the animation.

Both are needed.  Outlines alone can only pop a whole stroke into place, which
hides the direction the brush travelled; medians alone give a pencil line of
constant width, which is not what a character looks like.

Source:
    data/raw/graphics.txt
    https://raw.githubusercontent.com/skishore/makemeahanzi/master/graphics.txt

Licensing: graphics.txt is derived from Arphic PL UKai and Arphic PL KaitiM GB
and is distributed under the Arphic Public License.  The licence text ships in
the App bundle as ``ARPHICPL.TXT``, unaltered and under its original name, and
is disclosed in the terms of service.

Arphic Public License section 2 requires that modifications be documented and
made freely available to third parties under the same licence.  Modifications
made by this project, all on 2026-08-14: selecting only the glyphs the
character database needs; flipping the coordinate system (see below);
normalising the path syntax to absolute M/L/Q/C/Z; converting medians from
point lists to that same syntax; and storing the result raw-deflate compressed
in SQLite.  No outline is redrawn, reshaped or re-ordered.  This script and a
description of the changes are published at
https://aa-prime-studio.github.io/ink/stroke-data — that is the designated
place required by section 2(b).  The modified data stays under the same
licence.

Coordinates: the source is in font space — 1024 wide, y pointing **up**, with
the baseline at 0 (values run about -124..900).  Everything is flipped to
screen space here (y' = 900 - y) so the app never has to think about it.
"""
import json
import os
import re
import sqlite3
import sys
import zlib

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
RAW = os.path.join(ROOT, "data", "raw", "graphics.txt")
DB = os.path.join(ROOT, "db", "chars.sqlite")

SCHEMA = """
DROP TABLE IF EXISTS strokes;
-- 註解必須寫在括號裡面。SQLite 的 sqlite_master 只從 CREATE 這個字開始存，
-- 放在 CREATE 前面的註解不會跟著資料庫走 —— 那就達不到 Arphic 2(a) 的要求。
CREATE TABLE strokes (
    -- ARPHIC PUBLIC LICENSE section 2(a) notice.
    -- Derived from Make Me a Hanzi (graphics.txt), itself derived from Arphic
    -- PL UKai and Arphic PL KaitiM GB, (C) 1999 Arphic Technology Co., Ltd.
    -- Licensed under the Arphic Public License; full text ships as ARPHICPL.TXT.
    -- Modifications, all made on 2026-08-14: selected only the needed glyphs,
    -- flipped the coordinate system to screen space, normalised paths to
    -- absolute commands, stored raw-deflate. No outline redrawn or re-ordered.
    -- Source: https://aa-prime-studio.github.io/ink/stroke-data
    char  TEXT PRIMARY KEY,
    count INTEGER NOT NULL,
    data  BLOB NOT NULL       -- raw deflate of {"o": [outlines], "m": [medians]}
);
"""

# 字型空間的基線位移。Make Me a Hanzi 的 SVG 用
# transform="translate(0, 900) scale(1, -1)"，這裡直接把它折進座標。
BASELINE = 900


def flip(value):
    """字型空間（y 往上）→ 螢幕空間（y 往下）。"""
    return BASELINE - value


TOKEN = re.compile(r"[A-Za-z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
ARITY = {"M": 2, "L": 2, "H": 1, "V": 1, "Q": 4, "C": 6, "S": 4, "Z": 0}


def number(value):
    """Compact, stable number formatting for the SQLite payload."""
    if abs(value) < 0.0000005:
        value = 0.0
    return f"{value:.6f}".rstrip("0").rstrip(".")


def normalise_path(source):
    """Convert the SVG subset in Make Me a Hanzi to absolute commands."""
    tokens = TOKEN.findall(source)
    if not tokens:
        raise ValueError("empty path")
    out = []
    i = 0
    command = None
    x = y = start_x = start_y = 0.0
    last_cubic_control = None

    def is_command(token):
        return len(token) == 1 and token.isalpha()

    while i < len(tokens):
        if is_command(tokens[i]):
            command = tokens[i]
            i += 1
        if command is None:
            raise ValueError("coordinates before command")
        upper = command.upper()
        if upper not in ARITY:
            raise ValueError(f"unsupported SVG command {command}")
        if upper == "Z":
            out.append("Z")
            x, y = start_x, start_y
            last_cubic_control = None
            command = None
            continue

        arity = ARITY[upper]
        first_group = True
        while i < len(tokens) and not is_command(tokens[i]):
            if i + arity > len(tokens) or any(is_command(t) for t in tokens[i:i + arity]):
                raise ValueError(f"incomplete {command} command")
            values = [float(v) for v in tokens[i:i + arity]]
            i += arity
            relative = command.islower()

            if upper == "M":
                nx = values[0] + (x if relative else 0)
                ny = values[1] + (y if relative else 0)
                emitted = "M" if first_group else "L"
                out.append(f"{emitted} {number(nx)} {number(flip(ny))}")
                x, y = nx, ny
                if first_group:
                    start_x, start_y = x, y
                last_cubic_control = None
            elif upper == "L":
                nx = values[0] + (x if relative else 0)
                ny = values[1] + (y if relative else 0)
                out.append(f"L {number(nx)} {number(flip(ny))}")
                x, y = nx, ny
                last_cubic_control = None
            elif upper == "H":
                x = values[0] + (x if relative else 0)
                out.append(f"L {number(x)} {number(flip(y))}")
                last_cubic_control = None
            elif upper == "V":
                y = values[0] + (y if relative else 0)
                out.append(f"L {number(x)} {number(flip(y))}")
                last_cubic_control = None
            elif upper == "Q":
                cx = values[0] + (x if relative else 0)
                cy = values[1] + (y if relative else 0)
                nx = values[2] + (x if relative else 0)
                ny = values[3] + (y if relative else 0)
                out.append(f"Q {number(cx)} {number(flip(cy))} {number(nx)} {number(flip(ny))}")
                x, y = nx, ny
                last_cubic_control = None
            elif upper == "C":
                c1x = values[0] + (x if relative else 0)
                c1y = values[1] + (y if relative else 0)
                c2x = values[2] + (x if relative else 0)
                c2y = values[3] + (y if relative else 0)
                nx = values[4] + (x if relative else 0)
                ny = values[5] + (y if relative else 0)
                out.append(f"C {number(c1x)} {number(flip(c1y))} {number(c2x)} {number(flip(c2y))} {number(nx)} {number(flip(ny))}")
                x, y = nx, ny
                last_cubic_control = (c2x, c2y)
            elif upper == "S":
                if last_cubic_control is None:
                    c1x, c1y = x, y
                else:
                    c1x = 2 * x - last_cubic_control[0]
                    c1y = 2 * y - last_cubic_control[1]
                c2x = values[0] + (x if relative else 0)
                c2y = values[1] + (y if relative else 0)
                nx = values[2] + (x if relative else 0)
                ny = values[3] + (y if relative else 0)
                out.append(f"C {number(c1x)} {number(flip(c1y))} {number(c2x)} {number(flip(c2y))} {number(nx)} {number(flip(ny))}")
                x, y = nx, ny
                last_cubic_control = (c2x, c2y)
            first_group = False

        if first_group:
            raise ValueError(f"missing coordinates for {command}")

    return " ".join(out)


def median_path(points):
    """中線是一串點，轉成跟輪廓同一套的絕對指令，讓 app 只需要一個解析器。"""
    if len(points) < 2:
        raise ValueError("median has fewer than two points")
    head = f"M {number(points[0][0])} {number(flip(points[0][1]))}"
    rest = " ".join(f"L {number(x)} {number(flip(y))}" for x, y in points[1:])
    return f"{head} {rest}"


def load_graphics(needed):
    """只留字庫用得到的字。整份 29 MB，全載進記憶體沒必要。"""
    out = {}
    with open(RAW, encoding="utf-8") as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            record = json.loads(line)
            char = record.get("character")
            if char in needed:
                out[char] = record
    return out


def main():
    if not os.path.exists(RAW):
        sys.exit(f"缺少筆順原檔：{RAW}\n下載方式見本檔案開頭的說明。")
    if not os.path.exists(DB):
        sys.exit(f"缺少字庫：{DB}（先跑 scripts/build.py）")

    db = sqlite3.connect(DB)
    needed = set()
    for trad, simp in db.execute("select char_trad, char_simp from characters"):
        needed.update(trad + simp)

    graphics = load_graphics(needed)
    db.executescript(SCHEMA)
    raw_total = packed_total = 0
    found = set()
    errors = []

    for glyph in sorted(needed):
        record = graphics.get(glyph)
        if record is None:
            continue
        try:
            outlines = [normalise_path(d) for d in record["strokes"]]
            medians = [median_path(points) for points in record["medians"]]
            # 兩者必須一一對應，否則動畫會拿錯中線去揭錯的筆畫
            if len(outlines) != len(medians):
                raise ValueError(
                    f"{len(outlines)} outlines vs {len(medians)} medians")
            if not outlines:
                raise ValueError("no strokes")
        except (KeyError, ValueError) as error:
            errors.append(f"{glyph}: {error}")
            continue

        payload = json.dumps({"o": outlines, "m": medians},
                             separators=(",", ":")).encode()
        compressor = zlib.compressobj(9, zlib.DEFLATED, -15)
        packed = compressor.compress(payload) + compressor.flush()
        db.execute("INSERT OR REPLACE INTO strokes VALUES (?,?,?)",
                   (glyph, len(outlines), packed))
        raw_total += len(payload)
        packed_total += len(packed)
        found.add(glyph)

    db.commit()
    missing = sorted(needed - found)
    print(f"寫入 {len(found)}/{len(needed)} 個字的筆順 "
          f"({len(found) * 100 // len(needed)}%)：輪廓 + 中線，"
          f"來源 Make Me a Hanzi（Arphic Public License）")
    print(f"原始 {raw_total / 1024 / 1024:.1f} MB → 壓縮後 "
          f"{packed_total / 1024 / 1024:.1f} MB")
    if errors:
        print("資料有問題，已跳過：" + "；".join(errors))
    if missing:
        print(f"查無筆順（UI 會隱藏）：{''.join(missing)}")
    db.close()


if __name__ == "__main__":
    main()
