#!/usr/bin/env python3
"""CLI entrypoint for the Phase 7 translation sidecar.

Usage:
  okstra-report-translate.py extract <path-to-final-report.data.json>
  okstra-report-translate.py check <path-to-sidecar.i18n.<lang>.json>

``extract`` writes the translator's work list — every pointer the report holds
a translatable string at, paired with the English text. The translator fills
in the values rather than authoring pointers, so a sidecar cannot cite a path
the document does not have.

``check`` is the translator's own gate before it returns: it resolves every
pointer in the sidecar against the report and reports what is still English.
Without it a bad sidecar surfaces at render time, after the worker is gone.

This script is the canonical single-reference-point. The Node CLI
(``bin/okstra report-translate``) is a thin wrapper that spawns it.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import re
import os
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from okstra_bootstrap import prefer_colocated_modules  # noqa: E402

prefer_colocated_modules(__file__, "okstra_ctl/report_translation.py")

from okstra_ctl.report_translation import (  # noqa: E402
    extract,
    group_translation_pointers,
    overlay,
)
from okstra_ctl.final_report_paths import (  # noqa: E402
    translation_sidecar_path,
    translation_source_path,
)
from okstra_ctl.json_boundary import (  # noqa: E402
    JsonBoundaryError,
    load_owned_object,
    load_owned_object_snapshot,
    write_owned_object_atomic,
)
from okstra_ctl.fixed_text import block, line  # noqa: E402
from okstra_ctl.convergence import (  # noqa: E402
    ConvergenceContractError,
    RunArtifactAuthority,
    canonical_run_report_artifact,
    validated_run_authority,
)


def _load(path: Path) -> dict:
    try:
        return load_owned_object(path, artifact="report translation artifact")
    except JsonBoundaryError as exc:
        raise SystemExit(f"error: {exc}") from exc


def _translation_authority(
    manifest_path: Path,
) -> tuple[RunArtifactAuthority, Path]:
    try:
        authority = validated_run_authority(manifest_path)
        report = canonical_run_report_artifact(authority)
    except (ConvergenceContractError, JsonBoundaryError) as exc:
        raise SystemExit(f"error: {exc}") from exc
    return authority, report


def _report_snapshot(path: Path):
    try:
        return load_owned_object_snapshot(
            path, artifact="report translation source"
        )
    except JsonBoundaryError as exc:
        raise SystemExit(f"error: {exc}") from exc


def _source_payload(
    authority: RunArtifactAuthority, data_path: Path, raw_bytes: bytes, data: dict
) -> dict:
    strings = extract(data)
    return {
        "taskKey": authority.task_key,
        "runManifestPath": authority.manifest_ref,
        "sourceData": data_path.name,
        "sourceDataPath": data_path.relative_to(authority.project_root).as_posix(),
        "sourceDataSha256": hashlib.sha256(raw_bytes).hexdigest(),
        "lang": str((data.get("meta") or {}).get("reportLanguage") or ""),
        "strings": strings,
        "translationGroups": group_translation_pointers(data, strings),
    }


def _validate_source_payload(
    source: dict, expected: dict, supplied_digest: str
) -> None:
    for key in (
        "taskKey", "runManifestPath", "sourceData", "sourceDataPath",
        "sourceDataSha256", "lang", "strings",
    ):
        if source.get(key) != expected.get(key):
            raise SystemExit(
                "error: report changed after translation source publication"
            )
    if supplied_digest != expected["sourceDataSha256"]:
        raise SystemExit(
            "error: report changed after translation source publication"
        )
    if "translationGroups" in source and (
        source["translationGroups"] != expected["translationGroups"]
    ):
        raise SystemExit("error: translation groups do not match report source")


def cmd_extract(args: argparse.Namespace) -> int:
    data_path = Path(args.data).resolve()
    data = _load(data_path)
    strings = extract(data)
    out_path = translation_source_path(data_path)
    lang = str((data.get("meta") or {}).get("reportLanguage") or "")
    write_owned_object_atomic(
        out_path,
        {"lang": lang, "sourceData": data_path.name, "strings": strings},
        artifact="translation source",
    )
    print(
        json.dumps(
            {
                "ok": True,
                "sourcePath": str(out_path),
                "sidecarPath": str(translation_sidecar_path(data_path, lang)) if lang else "",
                "lang": lang,
                "stringCount": len(strings),
                "charCount": sum(len(v) for v in strings.values()),
            },
            ensure_ascii=False,
        )
    )
    return 0


def cmd_source(args: argparse.Namespace) -> int:
    authority, data_path = _translation_authority(Path(args.run_manifest))
    snapshot = _report_snapshot(data_path)
    data = snapshot.value
    payload = _source_payload(authority, data_path, snapshot.raw_bytes, data)
    out_path = translation_source_path(data_path)
    if out_path.is_symlink():
        raise SystemExit("error: translation source path is a symlink")
    write_owned_object_atomic(
        out_path, payload, artifact="translation source"
    )
    strings = payload["strings"]
    print("# Translation work list")
    print(line("Task key", authority.task_key), end="")
    print(line("Report", payload["sourceDataPath"]), end="")
    print(line("Run manifest", authority.manifest_ref), end="")
    print(line("Source digest", payload["sourceDataSha256"]), end="")
    groups = payload["translationGroups"]
    print(line("Field count", len(strings)), end="")
    print(line("String count", len(groups)), end="")
    print(line("Source characters", sum(len(value) for value in strings.values())), end="")
    print(line("Translation characters", sum(len(strings[row[0]]) for row in groups)), end="")
    for index, pointers in enumerate(groups, 1):
        print(f"\n## T-{index:03d}\n{block(strings[pointers[0]])}")
    return 0


def _translation_block(rows: list[str]) -> str:
    # Markdown 의 `\`` 는 백틱 리터럴이고, 이전 `source` 출력이 본문 백틱을 그렇게
    # 내보냈다. 렌더러는 Markdown 이스케이프를 모르므로 여기서 백틱으로 되돌린다.
    return "\n".join(rows).strip().replace("\\`", "`")


_T_HEADING_RE = re.compile(r"^#{1,6}\s+T-\d{3}\b")


def _translation_blocks(path: Path) -> list[str]:
    text = path.read_text(encoding="utf-8")
    blocks: list[str] = []
    current: list[str] | None = None
    for number, row in enumerate(text.splitlines(), start=1):
        # 헤딩 수준이 다른 `### T-NNN` 은 블록 경계로 안 보여 본문에 묻히고, 그러면
        # 블록 수 불일치라는 엉뚱한 메시지가 났다(실측 2026-09-08: id 164개 전부
        # 일치, 틀린 것은 `#` 개수뿐). 경계처럼 생긴 줄은 여기서 이름을 대고 거절한다.
        if _T_HEADING_RE.match(row) and not row.startswith("## T-"):
            raise SystemExit(
                f"error: translation heading must be level 2 — line {number} is "
                f"{row.split()[0]!r}, expected '## T-NNN' ({path.name})"
            )
        if row.startswith("## T-"):
            if current is not None:
                blocks.append(_translation_block(current))
            expected = f"## T-{len(blocks) + 1:03d}"
            if row != expected:
                raise SystemExit(
                    f"error: expected translation heading {expected} at line {number}, "
                    f"found {row!r} ({path.name})"
                )
            current = []
        elif current is not None:
            current.append(row)
    if current is not None:
        blocks.append(_translation_block(current))
    return blocks


def cmd_write(args: argparse.Namespace) -> int:
    authority, data_path = _translation_authority(Path(args.run_manifest))
    snapshot = _report_snapshot(data_path)
    data = snapshot.value
    expected = _source_payload(
        authority, data_path, snapshot.raw_bytes, data
    )
    source_path = translation_source_path(data_path)
    if source_path.is_symlink():
        raise SystemExit("error: translation source path is a symlink")
    source = _load(source_path)
    _validate_source_payload(source, expected, args.source_digest)
    sources = expected["strings"]
    # 발행된 구형 작업 목록은 필드당 한 블록이므로 진행 중 번역의 번호를 보존한다.
    groups = source.get("translationGroups", [[pointer] for pointer in sources])
    translated = _translation_blocks(Path(args.translations))
    if len(translated) != len(groups):
        raise SystemExit(
            f"error: translation blocks must match every T-NNN item — "
            f"{len(translated)} blocks in {Path(args.translations).name}, "
            f"{len(groups)} items in the translation source"
        )
    empty = [f"T-{index + 1:03d}" for index, value in enumerate(translated) if not value]
    if empty:
        raise SystemExit(
            "error: translation blocks must match every T-NNN item — empty: "
            + ", ".join(empty[:10])
        )
    lang = expected["lang"]
    sidecar = translation_sidecar_path(data_path, lang)
    if sidecar.is_symlink():
        raise SystemExit("error: translation sidecar path is a symlink")
    strings = {
        pointer: value
        for pointers, value in zip(groups, translated)
        for pointer in pointers
    }
    _, report = overlay(data, strings)
    if report.unresolved or report.applied != len(strings):
        raise SystemExit("error: translations do not validate against report source")
    write_owned_object_atomic(
        sidecar,
        {
            "taskKey": authority.task_key,
            "runManifestPath": authority.manifest_ref,
            "lang": lang,
            "sourceData": data_path.name,
            "sourceDataPath": expected["sourceDataPath"],
            "sourceDataSha256": expected["sourceDataSha256"],
            "strings": strings,
        },
        artifact="report translation sidecar",
    )
    print("Translation sidecar\n" + line("Status", "ready"), end="")
    return 0


def cmd_check_data(args: argparse.Namespace) -> int:
    authority, data_path = _translation_authority(Path(args.run_manifest))
    snapshot = _report_snapshot(data_path)
    expected = _source_payload(
        authority, data_path, snapshot.raw_bytes, snapshot.value
    )
    sidecar_path = translation_sidecar_path(data_path, expected["lang"])
    if sidecar_path.is_symlink():
        raise SystemExit("error: translation sidecar path is a symlink")
    sidecar = _load(sidecar_path)
    _validate_authority_sidecar(sidecar, expected)
    payload, code = _check_sidecar(sidecar_path)
    print("Translation check\n" + line("Status", "ready" if payload["ok"] else "error")
          + line("Applied strings", payload["applied"])
          + line("Unresolved strings", len(payload["unresolved"])), end="")
    return code


def _validate_authority_sidecar(sidecar: dict, expected: dict) -> None:
    for key in (
        "taskKey", "runManifestPath", "lang", "sourceData", "sourceDataPath",
        "sourceDataSha256",
    ):
        if sidecar.get(key) != expected.get(key):
            raise SystemExit(
                "error: translation sidecar does not match run authority"
            )


def cmd_check(args: argparse.Namespace) -> int:
    sidecar_file = Path(args.sidecar).resolve()
    payload, code = _check_sidecar(sidecar_file)
    print(json.dumps(payload, ensure_ascii=False))
    return code


def _check_sidecar(sidecar_file: Path) -> tuple[dict, int]:
    sidecar = _load(sidecar_file)
    strings = sidecar.get("strings")
    if not isinstance(strings, dict):
        raise SystemExit(f"error: {sidecar_file} has no 'strings' object")
    source_name = str(sidecar.get("sourceData") or "")
    if not source_name:
        raise SystemExit(f"error: {sidecar_file} does not name its sourceData")
    data_path = sidecar_file.parent / source_name
    if not data_path.is_file():
        raise SystemExit(f"error: sourceData not found beside the sidecar: {data_path}")

    _, report = overlay(_load(data_path), strings)
    offered = report.applied + len(report.untranslated)
    payload = {
        "ok": not report.unresolved,
        "applied": report.applied,
        "offered": offered,
        "untranslated": list(report.untranslated),
        "unresolved": list(report.unresolved),
    }
    if report.unresolved:
        # A pointer that resolves nowhere means the sidecar was written against
        # a different report. Rendering it would silently drop those strings.
        sys.stderr.write(
            f"error: {len(report.unresolved)} pointer(s) do not resolve in "
            f"{data_path.name}\n"
        )
        return payload, 1
    return payload, 0


_CLI_EPILOG = r"""A thin spawn shim over `scripts/okstra-report-translate.py` (installed at
`$HOME/.okstra/bin/okstra-report-translate.py`).

The final-report data.json is authored in English and stays the SSOT. When the
resolved report language is not English, Phase 7 dispatches a translator that
writes a sidecar beside the report; `render-views` overlays it so only the
human HTML is localized.

Usage:
  okstra report-translate extract <path-to-final-report.data.json>
  okstra report-translate source --run-manifest <path>
  okstra report-translate write --run-manifest <path> --source-digest <sha256> --translations <markdown>
  okstra report-translate check-data --run-manifest <path>
      Write <stem>.translation-source.json — every pointer the report holds a
      translatable string at, paired with its English text. The translator
      fills in the values, so a sidecar cannot cite a path the report lacks.

  okstra report-translate check <path-to-final-report.i18n.<lang>.json>
      Resolve every pointer in a filled sidecar against the report it names
      and report what is still English. Exits non-zero on a pointer that
      resolves nowhere — the translator's own gate before it returns.
"""


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        epilog=_CLI_EPILOG,
        formatter_class=argparse.RawDescriptionHelpFormatter,
        prog="okstra report-translate",
        description="Build and verify the final-report translation sidecar.",
    )
    sub = parser.add_subparsers(dest="op", required=True)

    extract_cmd = sub.add_parser("extract", help="write the translator's work list")
    extract_cmd.add_argument("data", help="path to final-report-<type>-<seq>.data.json")
    extract_cmd.set_defaults(func=cmd_extract)

    source_view = sub.add_parser("source", help="render a fixed translation work list")
    source_view.add_argument("--run-manifest", required=True)
    source_view.set_defaults(func=cmd_source)

    write_cmd = sub.add_parser("write", help="publish translations from T-NNN blocks")
    write_cmd.add_argument("--run-manifest", required=True)
    write_cmd.add_argument("--source-digest", required=True)
    write_cmd.add_argument("--translations", required=True)
    write_cmd.set_defaults(func=cmd_write)

    check_data = sub.add_parser("check-data", help="verify the derived sidecar")
    check_data.add_argument("--run-manifest", required=True)
    check_data.set_defaults(func=cmd_check_data)

    check_cmd = sub.add_parser("check", help="verify a filled sidecar against its report")
    check_cmd.add_argument("sidecar", help="path to final-report-<type>-<seq>.i18n.<lang>.json")
    check_cmd.set_defaults(func=cmd_check)
    return parser


def main(argv: list[str] | None = None) -> int:
    args = _parser().parse_args(argv)
    return int(args.func(args))


if __name__ == "__main__":
    raise SystemExit(main())
