#!/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. It refuses a data.json that is itself over the
Korean-prose limit: the work list would pair every pointer with target-language
text, and the translator would spend a full pass translating a document into
the language it is already in.

``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 os
import sys
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parents[1]
SCRIPTS_DIR = REPO_ROOT / "scripts"
HOME_LIB = (
    Path(os.environ.get("OKSTRA_HOME", str(Path.home() / ".okstra")))
    / "lib"
    / "python"
)

# Prefer dev sources when present, fall back to install — a stale install
# without this module would otherwise shadow the in-repo copy.
if (SCRIPTS_DIR / "okstra_ctl" / "report_translation.py").is_file():
    if str(SCRIPTS_DIR) in sys.path:
        sys.path.remove(str(SCRIPTS_DIR))
    sys.path.insert(0, str(SCRIPTS_DIR))
    if HOME_LIB.is_dir() and str(HOME_LIB) not in sys.path:
        sys.path.append(str(HOME_LIB))
elif HOME_LIB.is_dir() and str(HOME_LIB) not in sys.path:
    sys.path.insert(0, str(HOME_LIB))

from okstra_ctl.report_translation import (  # noqa: E402
    HANGUL_PROSE_LIMIT,
    extract,
    hangul_share,
    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 line, scalar  # 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:
    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": extract(data),
    }


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"
        )


def _english_source_failure(data_path: Path, data: dict) -> str | None:
    """The Korean-prose message for *data*, or None when it reads as English."""
    share, _ = hangul_share(data)
    if share < HANGUL_PROSE_LIMIT:
        return None
    return (
        f"error: {data_path.name} was authored in Korean "
        f"({share:.0%} of its prose, limit {HANGUL_PROSE_LIMIT:.0%}). "
        "The data.json is the English SSOT every later phase reads; the "
        "report language selects the human HTML's language and is served "
        "by the Phase 7 translator, not by authoring the SSOT in it.\n"
    )


def cmd_extract(args: argparse.Namespace) -> int:
    data_path = Path(args.data).resolve()
    data = _load(data_path)
    # Refuse before building the work list. Extracting from a Korean SSOT
    # produces a translation from the target language into itself: a
    # full-cost artifact whose English column is not English, discovered
    # only later when `check-source` fails the finalize step.
    failure = _english_source_failure(data_path, data)
    if failure is not None:
        sys.stderr.write(failure)
        return 1
    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
    failure = _english_source_failure(data_path, data)
    if failure is not None:
        sys.stderr.write(failure)
        return 1
    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="")
    print(line("String count", len(strings)), end="")
    for index, source in enumerate(strings.values(), 1):
        print(f"\n## T-{index:03d}\n{scalar(source)}")
    return 0


def _translation_blocks(path: Path) -> list[str]:
    text = path.read_text(encoding="utf-8")
    blocks: list[str] = []
    current: list[str] | None = None
    for row in text.splitlines():
        if row.startswith("## T-"):
            if current is not None:
                blocks.append("\n".join(current).strip())
            expected = f"## T-{len(blocks) + 1:03d}"
            if row != expected:
                raise SystemExit(f"error: expected translation heading {expected}")
            current = []
        elif current is not None:
            current.append(row)
    if current is not None:
        blocks.append("\n".join(current).strip())
    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"]
    translated = _translation_blocks(Path(args.translations))
    if len(translated) != len(sources) or any(not value for value in translated):
        raise SystemExit("error: translation blocks must match every T-NNN item")
    lang = expected["lang"]
    sidecar = translation_sidecar_path(data_path, lang)
    if sidecar.is_symlink():
        raise SystemExit("error: translation sidecar path is a symlink")
    strings = dict(zip(sources, translated))
    _, 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


def cmd_check_source(args: argparse.Namespace) -> int:
    data_path = Path(args.data).resolve()
    data = _load(data_path)
    share, length = hangul_share(data)
    failure = _english_source_failure(data_path, data)
    payload = {
        "ok": failure is None,
        "hangulShare": round(share, 4),
        "limit": HANGUL_PROSE_LIMIT,
        "proseChars": length,
    }
    print(json.dumps(payload, ensure_ascii=False))
    if failure is not None:
        sys.stderr.write(failure)
        return 1
    return 0


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="okstra-report-translate.py",
        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)

    source_cmd = sub.add_parser(
        "check-source", help="verify the data.json itself was authored in English"
    )
    source_cmd.add_argument("data", help="path to final-report-<type>-<seq>.data.json")
    source_cmd.set_defaults(func=cmd_check_source)
    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())
