"""Phase 7 `translate` 단계 — 번역 사이드카를 okstra 가 직접 만든다.

비영어 리포트의 번역 워커는 종전에 리드의 수동 절차였다: `agent-prompt
materialize --audience translator` 로 예약을 만들고, `worker-dispatch --workers
translator` 로 띄우고, `report-finalize` 를 두 번에 나눠 도는 순서가 리드가
lazy-read 하는 문서(`prompts/lead/report-writer.md`)에만 있었다. 실측(2026-09-09,
fontsninja-v3-site dev-10628-3 implementation-option-selection, grok 리드): 리드가
`report-finalize` 를 한 번에 돌려 번역 예약이 0건이었고, 디스패처는 0건을 거절할
뿐 만들지 못한다(`dispatch_core._translator_job_from_reservation`). 검증은 권고
한 줄을 남겼고 HTML 은 영어로 남았다.

이 모듈은 그 절차를 `report-finalize` 의 한 단계로 내린다. 예약이 없으면 지시문과
프롬프트를 발행하고(`agent-prompt materialize` 와 같은 코드), CLI 래퍼 디스패처로
워커를 띄운 뒤 사이드카가 생겼는지 본다. 디스패치 기록과 결과 링크는
`dispatch_core` 가 배치 안에서 이미 하므로 여기서 다시 하지 않는다.
"""
from __future__ import annotations

import contextlib
import io
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Mapping

from . import worker_dispatch
from .agent.prompt_cli.cli import main as agent_prompt_main
from .dispatch_core import translator_reservations
from .dispatch_state import (
    DispatchError,
    load_json_object,
    resolve_project_path,
    resolve_required_path,
)
from .final_report_paths import translation_sidecar_path
from .json_boundary import JsonBoundaryError

TRANSLATOR_WORKER_ID = "translator"

# 리드가 종전에 손으로 쓰던 지시문(실측 2026-09-08, fontsninja-nlpvibe dev-10786
# requirements-discovery-002)과 같은 절차다. 명령은 `agents/workers/translator-worker.md`
# 의 Procedure 와 같은 세 개이고, 결과·감사 경로는 프롬프트 앵커가 이름한다.
_INSTRUCTIONS = """## Instructions

Translate the finalized report into the language whose IETF language tag is `{language}` faithfully. This is translation only: no analysis, no source edits.

1. Run `okstra report-translate source --run-manifest {manifest}` and read the complete work list, in chunks if it is long. Copy the `Source digest` it prints.
2. Write one translated block per `## T-NNN` item to the exact Result Path, using the same `## T-NNN` headings. Preserve every identifier, path, command, enum token, link, and qualification character for character.
3. Publish with `okstra report-translate write --run-manifest {manifest} --source-digest <digest from step 1> --translations <Result Path>`, then run `okstra report-translate check-data --run-manifest {manifest}`. Fix the translation until that check passes; do not return on a failing check.
4. Write a completion pointer to the Worker Result Path naming the translations file, the published sidecar, the digest, and the check result. Write the audit sidecar with reading, completeness, and terminology checks.

Do not edit the canonical report, the narrative, the HTML, or any source file. All `.okstra` paths resolve from Project Root.
"""


@dataclass(frozen=True)
class TranslateOutcome:
    """`translate` 단계의 결과. `report_finalize` 가 CompletedProcess 로 옮긴다."""

    returncode: int
    stdout: str = ""
    stderr: str = ""


class TranslateError(Exception):
    """번역 워커를 예약하거나 띄우지 못했다. 메시지가 그 자리를 이름한다."""


def report_language(manifest: Mapping[str, Any], data_path: Path) -> str:
    """이 run 의 사람 리포트 언어. 매니페스트가 정본이고, 없으면 리포트의 meta.

    매니페스트 `reportLanguage` 는 prepare 가 `report_language.resolve_report_language`
    로 정한 값이고, 조립이 `meta.reportLanguage` 로 복사한다. 그 필드가 없는 옛
    run 은 리포트에서 읽는다. 둘 다 없으면 영어다.
    """
    value = str(manifest.get("reportLanguage") or "").strip()
    if value:
        return value
    if data_path.is_file():
        try:
            data = load_json_object(data_path, "final-report data")
        except (DispatchError, JsonBoundaryError):
            return "en"
        meta = data.get("meta")
        if isinstance(meta, Mapping):
            value = str(meta.get("reportLanguage") or "").strip()
    return value or "en"


def translate_report(
    *,
    project_root: Path,
    workspace_root: Path,
    manifest_path: Path,
    manifest: Mapping[str, Any],
    data_path: Path,
) -> TranslateOutcome:
    """비영어 리포트의 번역 사이드카를 있게 만든다. 영어 리포트는 할 일이 없다.

    사이드카가 이미 있으면(앞선 호출, 또는 리드가 손으로 띄운 워커) 그대로 둔다.
    없으면 예약을 보장하고 워커를 띄운 뒤, 사이드카의 존재로만 성공을 판정한다 —
    워커의 종료 코드는 산출물의 존재를 대신하지 못한다.
    """
    lang = report_language(manifest, data_path)
    if lang == "en":
        return TranslateOutcome(0, f"skipped: reportLanguage is {lang!r}")
    sidecar = translation_sidecar_path(data_path, lang)
    if sidecar.is_file():
        return TranslateOutcome(0, f"translation sidecar already present: {sidecar.name}")
    try:
        metadata_path = ensure_translator_reservation(
            project_root, manifest_path, manifest, lang
        )
        dispatch = dispatch_translator(project_root, workspace_root, manifest_path)
    except TranslateError as exc:
        return TranslateOutcome(1, "", str(exc))
    if sidecar.is_file():
        return TranslateOutcome(
            0,
            f"translated into {lang!r}: {sidecar.name} (reservation "
            f"{metadata_path.name})",
            dispatch.stderr,
        )
    return TranslateOutcome(
        1,
        dispatch.stdout,
        f"translator dispatch exited {dispatch.returncode} and left no "
        f"translation sidecar at {sidecar}"
        + (f": {dispatch.stderr.strip()}" if dispatch.stderr.strip() else ""),
    )


def ensure_translator_reservation(
    project_root: Path,
    manifest_path: Path,
    manifest: Mapping[str, Any],
    lang: str,
) -> Path:
    """이 run 의 아직 안 띄운 translator 예약 하나를 돌려준다. 없으면 만든다.

    Returns the invocation metadata path of that reservation.

    예약이 둘이면 디스패처가 고를 수 없고 회수 명령도 없으므로 그대로 거절한다 —
    그 상태는 리드가 다른 id 로 두 번 발행했을 때만 생긴다. 예약 id 는
    `<task-type>-<seq>-translator` 이고, 같은 run 의 앞선 시도가 실패해 그 id 가
    이미 쓰였으면 `-r2`, `-r3` 로 잇는다.
    """
    team_state = load_json_object(
        resolve_required_path(project_root, manifest, "teamStatePath"), "team-state"
    )
    try:
        candidates, seen = translator_reservations(project_root, manifest, team_state)
    except DispatchError as exc:
        raise TranslateError(f"translator reservation lookup failed: {exc}") from exc
    if len(candidates) == 1:
        return resolve_project_path(
            project_root, str(candidates[0].get("metadataPath") or "")
        )
    if len(candidates) > 1:
        raise TranslateError(
            "translator dispatch requires exactly one canonical invocation "
            f"reservation for this run; found {len(candidates)}. Translator "
            "reservations seen: " + "; ".join(seen)
        )
    return _materialize_translator(project_root, manifest_path, manifest, lang, seen)


def dispatch_translator(
    project_root: Path, workspace_root: Path, manifest_path: Path,
) -> TranslateOutcome:
    """`okstra worker-dispatch --workers translator` 와 같은 코드를 프로세스 안에서 돈다.

    디스패처는 결과 JSON 을 stdout 에 찍는다. Phase 7 의 stdout 은 finalize 결과
    JSON 하나여야 하므로 여기서 받아 단계 결과로 넘긴다.
    """
    out, err = io.StringIO(), io.StringIO()
    with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
        try:
            code = worker_dispatch.main([
                "--project-root", str(project_root),
                "--run-manifest", str(manifest_path),
                "--workspace-root", str(workspace_root),
                "--workers", TRANSLATOR_WORKER_ID,
            ])
        except (DispatchError, OSError) as exc:
            raise TranslateError(f"translator dispatch failed: {exc}") from exc
    return TranslateOutcome(code, out.getvalue(), err.getvalue())


def _materialize_translator(
    project_root: Path,
    manifest_path: Path,
    manifest: Mapping[str, Any],
    lang: str,
    seen: list[str],
) -> Path:
    task_type = str(manifest.get("taskType") or "").strip()
    if not task_type:
        raise TranslateError("run manifest has no taskType")
    run_dir = resolve_required_path(project_root, manifest, "runDirectoryPath")
    base_id = f"{task_type}-{_seq(manifest, 'manifests')}-translator"
    used = {entry.split(" ", 1)[0] for entry in seen}
    invocation_id = base_id
    attempt = 1
    while invocation_id in used:
        attempt += 1
        invocation_id = f"{base_id}-r{attempt}"
    prompt_seq = _seq(manifest, "prompts")
    result_seq = _seq(manifest, "workerResults")
    suffix = "" if attempt == 1 else f"-r{attempt}"
    instruction = (
        _instruction_root(project_root, manifest, run_dir)
        / f"translator-instructions-{task_type}-{_seq(manifest, 'state')}{suffix}.md"
    )
    prompt = run_dir / "prompts" / f"translator-worker-prompt-{task_type}-{prompt_seq}{suffix}.md"
    translations = (
        run_dir / "worker-results"
        / f"translator-translations-{task_type}-{result_seq}{suffix}.md"
    )
    worker_result = (
        run_dir / "worker-results" / f"translator-worker-{task_type}-{result_seq}{suffix}.md"
    )
    if not instruction.is_file():
        # 리드가 먼저 써 둔 지시문은 그대로 쓴다. 없을 때만 okstra 의 것을 쓴다.
        instruction.parent.mkdir(parents=True, exist_ok=True)
        instruction.write_text(
            _INSTRUCTIONS.format(
                language=lang,
                manifest=manifest_path.relative_to(project_root).as_posix()
                if manifest_path.is_relative_to(project_root)
                else str(manifest_path),
            ),
            encoding="utf-8",
        )
    out, err = io.StringIO(), io.StringIO()
    with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
        code = agent_prompt_main([
            "materialize",
            "--project-root", str(project_root),
            "--run-manifest", str(manifest_path),
            "--invocation-id", invocation_id,
            "--worker-id", TRANSLATOR_WORKER_ID,
            "--audience", TRANSLATOR_WORKER_ID,
            "--dispatch-kind", TRANSLATOR_WORKER_ID,
            "--assignment-ref", TRANSLATOR_WORKER_ID,
            "--instruction", str(instruction),
            "--prompt", str(prompt),
            "--result", str(translations),
            "--audit-source", str(worker_result),
            "--json",
        ])
    if code != 0:
        raise TranslateError(
            "translator prompt materialization failed: "
            + (err.getvalue().strip() or out.getvalue().strip() or f"exit {code}")
        )
    try:
        payload = json.loads(out.getvalue())
    except json.JSONDecodeError as exc:
        raise TranslateError(
            f"translator materialization printed no JSON: {out.getvalue()[:200]!r}"
        ) from exc
    metadata = str(payload.get("metadataPath") or "")
    if not metadata:
        raise TranslateError("translator materialization named no metadataPath")
    return resolve_project_path(project_root, metadata)


def _instruction_root(
    project_root: Path, manifest: Mapping[str, Any], run_dir: Path,
) -> Path:
    """지시문을 둘 디렉터리. materialize 는 `authorizedPaths.instructionRoots` 밖의
    지시문을 거절하므로 그 목록에서 고른다 — 리드가 쓰던 `state/` 가 있으면 그것,
    없으면 첫 항목, 목록이 없으면 run 의 `state/`."""
    contract = manifest.get("agentContract")
    authorized = contract.get("authorizedPaths") if isinstance(contract, Mapping) else None
    roots = authorized.get("instructionRoots") if isinstance(authorized, Mapping) else None
    candidates = [
        resolve_project_path(project_root, str(root))
        for root in (roots if isinstance(roots, list) else [])
        if str(root).strip()
    ]
    for root in candidates:
        if root.name == "state":
            return root
    return candidates[0] if candidates else run_dir / "state"


def _seq(manifest: Mapping[str, Any], category: str) -> str:
    seqs = manifest.get("runSequencesByCategory")
    if not isinstance(seqs, Mapping):
        raise TranslateError("run manifest has no runSequencesByCategory object")
    value = str(seqs.get(category) or seqs.get("manifests") or "").strip()
    if not value:
        raise TranslateError(f"run manifest has no {category} sequence")
    return value
