#!/usr/bin/env python3
"""docs/ を api.aicu.ai の LLM でまとめて翻訳する。

    export AICU_API_KEY=aicu_live_...          # llm スコープ
    python3 scripts/translate-docs.py --to ko                 # dry-run（既定）
    python3 scripts/translate-docs.py --to ko --apply         # 実行
    python3 scripts/translate-docs.py --to ko --apply --only pricing.md

書き出し先は Docusaurus の i18n の規約どおり
`i18n/<locale>/docusaurus-plugin-content-docs/current/<同じファイル名>`。

## 設計

**構造を壊した翻訳は採用しない。** 翻訳の失敗は「意味が変」より先に
「コードフェンスが消えた」「表の列が減った」という形で出る。出力を機械で検査し、
落ちたファイルは書き出さずに報告する。人が読んで直す前に、まず落ちたことが分かる。

**変わっていないものは訳さない。** 原文の SHA-256 を `.translate-manifest.json` に
持ち、一致したら飛ばす。毎回全部投げると金と時間が無駄になる。

**モデルは既定で gpt-4o-mini。** 実測（2026-09-23・英→韓・同じ 4 文書）:

    model          忠実さ  自然さ  訳し漏れ  AP/2,500字  秒
    gpt-4o-mini     1.83   1.55    0.26        18      6.9
    gpt-4o          1.84   1.56    0.30       278      9.9
    gpt-5.6-sol     1.87   1.66    0.25       849      9.2
    gpt-5-mini      1.34   0.49    0.69        46      9.6   ← 訳していない（下記）

gpt-5.6-sol が 3 軸とも最良だが、差は +0.04 / +0.11 / -0.01 で、gpt-4o-mini の **47 倍**
かかる。ドキュメント全体（17 ファイル・134,713 字）で 970 AP 対 45,700 AP。
見直しの入る翻訳にこの差を払う理由は見つからなかったので、既定は gpt-4o-mini のまま。
節目の文書だけ --model gpt-5.6-sol で訳し直す使い方を想定している。

**gpt-5-mini の 1.34 / 0.49 を「訳文が下手」と読んではいけない。** ハングルが 0 字、
出力 2,499 字（入力 2,500 字）で、**英語の原文をそのまま返していた**。採点はそれを
見て低く付けている。構造は完璧に保たれるので structure_check では捕まらない。
translated_check を足したのはこれが理由。

採点は /v1/jev に score と noul で聞いたもの。--judge で同じ採点を回せる。
"""
from __future__ import annotations
import argparse, hashlib, json, os, pathlib, re, sys, time, urllib.error, urllib.request

ROOT = pathlib.Path(__file__).resolve().parent.parent
SRC_DIR = ROOT / "docs"
API = "https://api.aicu.ai/v1"
MANIFEST = ROOT / ".translate-manifest.json"
SKIP_DIRS = {"api"}                      # openapi.json からの生成物は訳さない

LANGS = {"ko": "Korean", "zh": "Simplified Chinese", "fr": "French",
         "es": "Spanish", "pt": "Portuguese", "ja": "Japanese", "en": "English"}

# 翻訳先に固有の文字が実際に出ているかを見るための範囲。
# ラテン文字の言語（fr/es/pt）は範囲で区別できないので None にし、同一性だけで見る。
TARGET_SCRIPT = {
    "ko": (0xAC00, 0xD7A3),   # ハングル
    "zh": (0x4E00, 0x9FFF),   # 漢字
    "ja": (0x3040, 0x30FF),   # ひらがな・カタカナ
}

# 実測の単価（AP / 2,500 文字・2026-09-23・英→韓）。モデルを変えると 47 倍動くので、
# 見積もりを 1 つの定数で済ませない。知らないモデルは黙って安く見せず「不明」と言う。
AP_PER_2500 = {
    "gpt-4o-mini": 18,
    "gpt-5-mini": 46,
    "gpt-4o": 278,
    "gpt-5.6-sol": 849,
}

SYSTEM = """You translate technical API documentation into {language}. Rules:

1. Translate prose only. NEVER translate, and reproduce byte-for-byte:
   - YAML frontmatter keys and values
   - anything inside ``` fences
   - URLs, file paths, HTTP methods, HTTP status codes
   - API parameter, field and model names written in `backticks`
2. Keep the Markdown structure identical: same heading levels, same number of table
   columns and separator rows, same link syntax, same fence languages.
3. Keep product names as they are: AICU, AICU API, AP (the credit unit).
4. Output the translated document only. No preamble, no explanation, no code fence
   around the whole document."""


def call(path: str, body: dict, key: str, timeout: int = 240):
    req = urllib.request.Request(API + path, data=json.dumps(body).encode(), method="POST")
    req.add_header("Authorization", "Bearer " + key)
    req.add_header("Content-Type", "application/json")
    t0 = time.time()
    res = urllib.request.urlopen(req, timeout=timeout)
    return json.load(res), dict(res.headers), time.time() - t0


def translated_check(src: str, out: str, locale: str) -> list[str]:
    """そもそも訳されているか。

    2026-09-22 に gpt-5-mini が **英語の原文をそのまま返した**（ハングル 0 文字）。
    構造は完璧に保たれているので structure_check では捕まらない。
    訳さずに返したものを i18n/ に書き出すと、翻訳済みの顔をした英語が並ぶ。
    """
    bad = []

    def strip_fixed(t: str) -> str:
        """コード・URL・frontmatter を落とした「訳されるべき地の文」だけを残す。"""
        t = re.sub(r"^---\n.*?\n---", "", t, flags=re.S)
        t = re.sub(r"```.*?```", "", t, flags=re.S)
        t = re.sub(r"`[^`]*`", "", t)
        t = re.sub(r"https?://\S+", "", t)
        return re.sub(r"\s+", "", t)

    a, b = strip_fixed(src), strip_fixed(out)
    if a and b:
        same = sum(1 for x, y in zip(a, b) if x == y) / max(len(a), len(b))
        if same > 0.9:
            bad.append(f"原文がそのまま返っている（地の文の一致 {same:.0%}）")

    rng = TARGET_SCRIPT.get(locale)
    if rng and not bad:
        lo, hi = rng
        n = sum(1 for c in out if lo <= ord(c) <= hi)
        if n / max(len(out), 1) < 0.03:
            bad.append(f"{LANGS[locale]} の文字がほとんど無い（{n} 字）")
    return bad


def structure_check(src: str, out: str) -> list[str]:
    """構造が保たれているか。落ちた項目名を返す（空なら合格）。"""
    bad = []
    if src.count("```") != out.count("```"):
        bad.append(f"コードフェンス {src.count('```')}→{out.count('```')}")
    if src.count("|---") != out.count("|---"):
        bad.append(f"表 {src.count('|---')}→{out.count('|---')}")
    # 表そのものは残したまま、中の行だけ黙って落とすことがある（2026-09-23・pricing.md で
    # 表を 1 つ丸ごと落とした。finish_reason は stop で、打ち切りではない）。
    # 行数の比は段落の折り返しが変わるだけで動くので使えない。数えるのは構造の要素だけ。
    for label, count in (("表の行", lambda t: sum(1 for l in t.splitlines() if l.lstrip().startswith("|"))),
                         ("箇条書き", lambda t: sum(1 for l in t.splitlines() if re.match(r"\s*[-*] ", l))),
                         ("見出し", lambda t: sum(1 for l in t.splitlines() if l.startswith("#")))):
        a, b = count(src), count(out)
        if a != b:
            bad.append(f"{label} {a}→{b}")
    for level in ("\n## ", "\n### "):
        if src.count(level) != out.count(level):
            bad.append(f"見出し{level.strip()} {src.count(level)}→{out.count(level)}")
    if src.startswith("---") and not out.startswith("---"):
        bad.append("frontmatter 消失")
    # 文末のピリオドやカンマまで URL に含めると、訳文で句読点が変わっただけで
    # 「消失」と誤検出する（2026-09-23 に audio-api.md で実際に踏んだ）。
    for url in set(re.findall(r"https?://[^\s)\"'`<>\]]+", src)):
        url = url.rstrip(".,;:!?、。）」")
        if url not in out:
            bad.append(f"URL 消失 {url}")
            break
    if out.strip().startswith("```"):
        bad.append("全体がコードフェンスで包まれている")
    return bad


def judge(src: str, out: str, key: str):
    """翻訳の質を /v1/jev（TypeSafe の Jev）に採点させる。"""
    q = {
        "faithful": {"type": "score",
                     "instructions": "Does the translation preserve the meaning of the source, including technical details?",
                     "criteria": ["Meaning is distorted", "Mostly faithful", "Faithful throughout"]},
        "natural": {"type": "score",
                    "instructions": "Does the translation read naturally to a developer who speaks that language?",
                    "criteria": ["Stilted", "Readable but awkward", "Natural"]},
        "untranslated": {"type": "noul",
                         "instructions": "Is there source-language prose left untranslated that should have been translated? (Code, URLs and parameter names are correctly left as-is.)"},
    }
    # /v1/jev の state は 20,000 文字まで。長い文書は頭から均等に切って渡す
    # （2026-09-23 に 14,800 字の文書で 400 を踏んだ）。採点は全文でなくても傾向が出る。
    budget = 9000
    state = {"source": src[:budget], "translation": out[:budget]}
    try:
        d, h, _ = call("/jev", {"state": state, "questions": q}, key, 120)
    except urllib.error.HTTPError as e:
        return None, None, None, 0, f"HTTP {e.code}: {e.read()[:80].decode('utf-8', 'replace')}"
    a = d["answers"]
    return (a["faithful"]["score"], a["natural"]["score"], a["untranslated"]["noul"],
            int(h.get("X-AICU-AP-Cost", 0)), None)


def main() -> int:
    p = argparse.ArgumentParser()
    p.add_argument("--to", required=True, choices=sorted(LANGS), help="翻訳先のロケール")
    p.add_argument("--model", default="gpt-4o-mini")
    p.add_argument("--only", help="このファイル名だけ（例: pricing.md）")
    p.add_argument("--apply", action="store_true", help="実際に呼び出して書き出す（既定は dry-run）")
    p.add_argument("--judge", action="store_true", help="翻訳の質も採点する（1 ファイルにつき約 8 AP 追加）")
    p.add_argument("--force", action="store_true", help="原文が変わっていなくても訳し直す")
    a = p.parse_args()

    key = os.environ.get("AICU_API_KEY")
    if not key:
        sys.exit("AICU_API_KEY が未設定です（llm スコープの鍵が要ります）")

    out_dir = ROOT / "i18n" / a.to / "docusaurus-plugin-content-docs" / "current"
    manifest = json.loads(MANIFEST.read_text()) if MANIFEST.exists() else {}
    lang_manifest = manifest.setdefault(a.to, {})

    files = sorted(f for f in SRC_DIR.rglob("*.md")
                   if not any(part in SKIP_DIRS for part in f.relative_to(SRC_DIR).parts))
    if a.only:
        files = [f for f in files if f.name == a.only]
        if not files:
            sys.exit(f"{a.only} が docs/ に見つかりません")

    todo, skipped = [], 0
    for f in files:
        src = f.read_text(encoding="utf-8")
        digest = hashlib.sha256(src.encode()).hexdigest()
        dest = out_dir / f.relative_to(SRC_DIR)
        if not a.force and lang_manifest.get(str(f.relative_to(ROOT))) == digest and dest.exists():
            skipped += 1
            continue
        todo.append((f, src, digest, dest))

    total_chars = sum(len(s) for _, s, _, _ in todo)
    print(f"  翻訳先   : {LANGS[a.to]} ({a.to}) → {out_dir.relative_to(ROOT)}")
    print(f"  モデル   : {a.model}")
    print(f"  対象     : {len(todo)} ファイル / {total_chars:,} 文字（変更なしで飛ばす: {skipped}）")
    rate = AP_PER_2500.get(a.model)
    if rate is None:
        print(f"  概算     : 不明（{a.model} の実測値が無い。--only で 1 ファイル試して"
              f" X-AICU-AP-Cost を見てから全体を回してください）")
    else:
        print(f"  概算     : {total_chars / 2500 * rate:,.0f} AP"
              + ("（--judge で + 約 8 AP/ファイル）" if a.judge else ""))

    if not a.apply:
        print("\n  dry-run です。実行するには --apply を付けてください。")
        return 0
    if not todo:
        print("\n  訳すものがありません。")
        return 0

    out_dir.mkdir(parents=True, exist_ok=True)
    system = SYSTEM.format(language=LANGS[a.to])
    ap_total, failed, wrote = 0, [], 0

    for f, src, digest, dest in todo:
        rel = f.relative_to(ROOT)
        try:
            d, h, dt = call("/chat/completions", {
                "model": a.model, "temperature": 0, "max_tokens": 8000,
                "messages": [{"role": "system", "content": system},
                             {"role": "user", "content": src}]}, key)
        except urllib.error.HTTPError as e:
            failed.append((rel, f"HTTP {e.code}: {e.read()[:120].decode('utf-8', 'replace')}"))
            print(f"  ✗ {rel}  呼び出し失敗")
            continue

        out = d["choices"][0]["message"]["content"]
        ap = int(h.get("X-AICU-AP-Cost", 0))
        ap_total += ap

        bad = translated_check(src, out, a.to) + structure_check(src, out)
        if bad:
            failed.append((rel, " / ".join(bad)))
            print(f"  ✗ {rel}  {ap:>4} AP  構造が壊れた: {' / '.join(bad)}")
            continue

        line = f"  ✓ {rel}  {ap:>4} AP  {dt:>5.1f} s"
        if a.judge:
            fa, na, un, jap, jerr = judge(src, out, key)
            ap_total += jap
            # 採点に失敗しても翻訳は書き出す。採点はあくまで参考値で、採否の門ではない。
            line += f"  採点できず（{jerr}）" if jerr else f"  忠実 {fa:.2f} 自然 {na:.2f} 訳し漏れ {un:.2f}"
        print(line)

        dest.parent.mkdir(parents=True, exist_ok=True)
        dest.write_text(out, encoding="utf-8")
        lang_manifest[str(rel)] = digest
        wrote += 1

    MANIFEST.write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")

    print(f"\n  書き出し : {wrote} ファイル / 合計 {ap_total:,} AP")
    if failed:
        print(f"\n  ❌ {len(failed)} ファイルが通りませんでした（書き出していません）")
        for rel, why in failed:
            print(f"     {rel}: {why}")
        print("\n  構造が壊れた場合は --model を上げるか、そのファイルだけ --only で試してください。")
        return 1
    print("\n  次: pnpm run build でリンク切れが無いことを確かめてから配信してください。")
    return 0


if __name__ == "__main__":
    sys.exit(main())
