"""Render schema-v2 data directly into a task-specific HTML document."""
from __future__ import annotations

import hashlib
import json
import sys
from pathlib import Path

import okstra_vendor  # noqa: F401  # registers vendored dependency aliases
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape

from ..final_report_paths import final_report_data_path, translation_sidecar_path
from ..final_report_schema import load_schema_for_data, validate
from ..i18n import HTML_DICTIONARY_REL, load_dictionary, make_jinja_global
from ..report_translation import overlay
from ..report_view_artifacts import user_responses_dir_for_report
from ..usage_cells import format_duration_ms
from ..json_boundary import load_owned_object
from .common import anchor_index, worker_finding_links
from .context_links import (
    approved_plan_links,
    brief_end_states,
    carry_in_links,
    end_state_table,
    selected_direction_links,
)
from .filters import (
    code_evidence,
    enum_label,
    enum_legend,
    evidence_refs,
    inline_code,
    paragraphs,
)
from .models import HtmlRunMeta
from .report_index import inject_report_index
from .router import HtmlRenderError, resolve_html_route
from .run_usage import run_usage


def _sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def _templates_root(start: Path | None = None) -> Path:
    if start is not None:
        return start
    here = Path(__file__).resolve()
    for parent in [here, *here.parents]:
        candidate = parent / "templates" / "reports"
        if candidate.is_dir():
            return candidate
    raise HtmlRenderError("could not locate templates/reports")


def _elapsed_text(elapsed_ms: int | None) -> str | None:
    """Render a run duration, or nothing when there is none to render.

    A run whose team-state never recorded timestamps has no measured duration;
    printing "0m" would claim it finished instantly. Measured durations go
    through the same formatter as the per-agent cells below, so the header and
    the run-cost table do not spell one quantity two ways.
    """
    if not elapsed_ms or elapsed_ms < 0:
        return None
    return format_duration_ms(elapsed_ms)


def _report_meta(data: dict, run_meta: HtmlRunMeta) -> dict[str, object]:
    return {
        "createdAt": data.get("header", {}).get("createdAt", ""),
        "taskTitle": data.get("frontmatter", {}).get("title", ""),
        "taskKey": run_meta.task_key,
        "elapsed": _elapsed_text(run_meta.elapsed_ms),
    }


def _localize(data: dict, data_path: Path) -> tuple[dict, str]:
    """이 리포트 언어의 번역 사이드카를 메모리 복사본에 덮어쓴다.

    data.json 은 영어 정본이고 고치지 않는다. 사람이 읽는 HTML 만 독자 언어다.
    사이드카가 빠진 포인터는 영어로 남긴다.

    사이드카 파일 자체가 없으면 요청 언어를 버리고 전부 영어로 렌더한다.
    종전에는 여기서 렌더를 거부했다 — 크롬만 번역된 반쪽 페이지를 내보내지
    않겠다는 이유였고, 그 판단 자체는 옳다. 틀린 것은 남은 선택지였다: 번역
    워커가 못 도는 이유(호스트 승인 게이트, 공급자 부재)는 리포트의 품질과
    무관한데, 거부는 열람본을 통째로 없애고 `validate-run` 이 그 부재를 차단
    실패로 삼아 run 을 `contract-violated` 로 끝냈다 — 분석도 리포트도 끝난
    run 이 그렇게 끝났다(2026-09-08 FontsNinja/app/jobs dev-10784
    error-analysis). 언어를 낮추면 크롬과 본문이 같은 영어라 반쪽 페이지도
    아니고, 독자는 읽을 것을 갖는다. 번역이 나중에 도착하면 다시 렌더해
    덮어쓴다.
    """
    lang = str((data.get("meta") or {}).get("reportLanguage") or "en")
    if lang == "en":
        return data, lang
    sidecar_file = translation_sidecar_path(data_path, lang)
    if not sidecar_file.is_file():
        sys.stdout.write(
            f"no {lang} translation sidecar at {sidecar_file.name}; "
            "rendering the English source\n"
        )
        return data, "en"
    payload = load_owned_object(sidecar_file, artifact="translation sidecar")
    strings = payload.get("strings")
    if not isinstance(strings, dict):
        raise HtmlRenderError(f"translation sidecar has no 'strings' object: {sidecar_file}")
    localized, report = overlay(data, strings)
    sys.stdout.write(
        f"translated {report.applied} string(s) into {lang}"
        f" ({len(report.untranslated)} left in English"
        f", {len(report.unresolved)} unresolved)\n"
    )
    return localized, lang


def _html_path(data_path: Path) -> Path:
    suffix = ".data.json"
    if not data_path.name.endswith(suffix):
        raise HtmlRenderError(f"v2 report path must end with {suffix}: {data_path}")
    return data_path.with_name(data_path.name.removesuffix(suffix) + ".html")


def render_v2_html_view(
    data_path: Path,
    *,
    run_meta: HtmlRunMeta,
    templates_root: Path | None = None,
) -> Path:
    """Render the human HTML from the data.json alone.

    The AI-handoff markdown sibling is a second rendering of this same record,
    never an input here: it used to be read for a `source-md-sha256` stamp that
    no reader ever compared, which made a derived artifact a precondition for
    another derived artifact.
    """
    data = load_owned_object(data_path, artifact="final report record")
    errors = validate(data, load_schema_for_data(data))
    if errors:
        raise HtmlRenderError("invalid v2 final-report data: " + "; ".join(errors[:5]))
    # Validate the SSOT, then localize — the sidecar carries presentation and
    # has no say in whether the report is well-formed.
    data, lang = _localize(data, data_path)
    route = resolve_html_route(run_meta.task_type)
    view = route.view_builder(data)
    root = _templates_root(templates_root)
    env = Environment(loader=FileSystemLoader(str(root)), autoescape=select_autoescape(("html",)), undefined=StrictUndefined)
    env.policies["json.dumps_kwargs"] = {"sort_keys": True, "ensure_ascii": False}
    # Binding the index here is what lets a template cite an id without
    # threading the index through every macro and call site. The record's
    # own rows come first; the brief's end-state rows and the carry-in
    # report's clarifications only fill ids this page has no row for.
    links = {
        row_id: f"#{name}"
        for row_id, name in anchor_index(
            data, view.anchored_fields, view.scoped_anchor_fields
        ).items()
    }
    brief_rows = brief_end_states(data_path)
    for row in brief_rows:
        links.setdefault(row["id"], f"#id-{row['id']}")
    links.update(carry_in_links(data, data_path, exclude=links))
    links.update(selected_direction_links(data, data_path, exclude=links))
    links.update(approved_plan_links(data, data_path, exclude=links))
    # 워커 finding 번호(`F-NNN`)는 그 번호를 출처로 적은 승격 근거 행이 하나뿐일
    # 때만 그 행으로 간다. 이 페이지가 정의한 id 가 우선이다.
    links.update({
        finding: href
        for finding, href in worker_finding_links(data).items()
        if finding not in links
    })
    chrome = load_dictionary(lang, HTML_DICTIONARY_REL)
    translate = make_jinja_global(chrome)
    env.globals["t"] = translate
    env.filters["code_evidence"] = code_evidence
    env.filters["enum_label"] = lambda value, vocabulary: enum_label(value, vocabulary, chrome)
    env.filters["enum_legend"] = lambda vocabulary: enum_legend(vocabulary, chrome)
    # `extra` is a card-scoped link map (`common.scoped_anchor_map`): a
    # direction's own commitment rows outrank the page index for the ids its
    # prose cites, and nothing else on the page can see them.
    env.filters["evidence_refs"] = lambda refs, extra=None: evidence_refs(
        refs, {**links, **(extra or {})}
    )
    env.filters["inline_code"] = lambda value, extra=None: inline_code(
        value, {**links, **(extra or {})}
    )
    env.filters["paragraphs"] = lambda value, extra=None: paragraphs(
        value, {**links, **(extra or {})}
    )
    response_js = (root / "report.js").read_text(encoding="utf-8")
    base_js = (root / "html/assets/base.js").read_text(encoding="utf-8")
    source_data = final_report_data_path(Path(run_meta.source_report)).as_posix()
    user_responses_dir = user_responses_dir_for_report(data_path.resolve())
    context = {
        **view.context,
        "runMeta": run_meta,
        "reportMeta": _report_meta(data, run_meta),
        "taskType": view.task_type,
        "lang": lang,
        "sourceData": source_data,
        "dataSha256": _sha256(data_path),
        "clarificationItems": data.get("clarificationItems", []),
        # 브리프의 최종 상태 행과 이 run 의 `endStateCoverage` 판정을 한 표로.
        # 모든 phase 가 EB/PB/EO 아이디를 인용하지만 문장은 브리프에만 있고,
        # "이 run 이 그 상태를 덮었는가" 는 coverage 표에만 있다.
        "endStates": end_state_table(brief_rows, data),
        # 에이전트 활동 표. 계획 리포트의 결정 카드가 `#id-A-NNN` 으로 가리키고
        # 다른 phase 의 산문도 인용하므로 모든 타입의 페이지가 같은 절을 그린다.
        "agentActivity": [
            row for row in data.get("agentActivity") or [] if isinstance(row, dict)
        ],
        # 합의·이견 근거는 태스크 본문과 같이 기본 화면에 올린다. 근거 대장
        # 감사 모드에만 두면 판정이 사용자에게 안 보인다.
        "crossVerification": data.get("crossVerification") or {},
        # Every task type ends with the same run-cost section, so it is bound
        # here rather than in ten view models that would each rebuild it.
        "runUsage": run_usage(data),
        # 바닥글이 "여기에 두면" 이라고 가리키는 디렉터리. 상대 경로
        # `runs/<type>/user-responses/` 는 task 디렉터리 기준이라는 말이 없어
        # 프로젝트 루트에서 찾으면 없고, implementation 은 stage 아래라 그 경로
        # 자체가 틀렸다(실측 2026-09-09) — 보고서 파일에서 계산한 절대 경로를 찍는다.
        "userResponseDir": user_responses_dir.as_posix(),
        "executionRoles": data.get("executionRoles") or [],
        "css": (root / "html/assets/base.css").read_text(encoding="utf-8"),
        "js": response_js + "\n" + base_js,
    }
    output_path = _html_path(data_path)
    document = env.get_template(route.template_name).render(**context)
    document = inject_report_index(document, label=translate("base.contents"))
    output_path.write_text(document, encoding="utf-8")
    # The footer tells the reader to drop the exported file here, so the
    # directory has to exist before they go looking for it — on every report,
    # not only those with clarification rows: the footer is rendered on all.
    user_responses_dir.mkdir(parents=True, exist_ok=True)
    return output_path
