"""Render `final-report-<task-type>-<seq>.md` from its JSON SSOT.

The JSON SSOT lives next to the rendered markdown as
``final-report-<task-type>-<seq>.data.json``. Its ``schemaVersion`` selects the
matching final-report schema. Report assembly writes the data.json in
Phase 6; this renderer + the matching Jinja2 template deterministically
produce the canonical AI-facing markdown.

Why this exists: prior to v0.32, report-writer-worker wrote the markdown
directly. Free-form authoring led to silent contract violations — missing
columns in the Execution Status table, omitted §4 phase-continuation
rows, ad-hoc ``## Index`` sections. Routing everything through one
template + schema cuts those failure modes to zero.

Rendering never injects a reader index: the AI-handoff markdown keeps its
compact fixed order and the task-specific HTML provides human navigation.
``_inject_index_and_anchors`` survives as a standalone pass over an already
written markdown file, driven by ``scripts/okstra-inject-report-index.py``.

Phase 7 mutation flow: ``okstra-token-usage.py --substitute-data`` fills
the ``tokenUsage`` and ``executionStatus[].totalTokens`` etc. cells in
data.json, then re-invokes this renderer so the markdown stays in sync.
The markdown is never hand-edited.

As of v0.33+, the renderer itself is the schema-enforcement seam: data.json
is validated against its version-selected schema before any Jinja2 rendering
begins, so schema violations are caught at write-time rather than only by the
post-hoc ``validators/validate-run.py``.
"""
from __future__ import annotations

import json
import os
import re
import sys
from pathlib import Path
from typing import Any

# Vendored jinja2 must be importable. The installer (``src/install.mjs``)
# drops ``okstra_vendor/`` under ``~/.okstra/lib/python/``; in-repo runs
# rely on ``scripts/`` being on PYTHONPATH. Either way the package
# registers ``markupsafe`` and ``jinja2`` aliases in ``sys.modules`` so
# downstream ``from jinja2 import ...`` resolves to the vendored copy.
import okstra_vendor  # noqa: F401 — side effect: sys.modules aliases
from jinja2 import ChainableUndefined, Environment, FileSystemLoader

from okstra_ctl.clarification_items import USER_INPUT_BLOCKS, progress_blocking_ids
from okstra_ctl.final_report_schema import (
    SchemaError,
    load_schema_for_data,
    validate as schema_validate,
)
from okstra_ctl.json_boundary import JsonBoundaryError, load_owned_object
from okstra_ctl.i18n import I18nError, SUPPORTED_LANGS, load_dictionary, make_jinja_global
from okstra_ctl.md_table import UNESCAPED_PIPE_RE, to_cell_text
from okstra_ctl.models import UnknownModelError, resolve_model_metadata
from okstra_ctl.paths import find_asset_root
from okstra_ctl.report_contract import (
    CURRENT_REPORT_SCHEMA_VERSION,
    TASK_TYPE_DATA_PROPERTY,
    markdown_template_for,
)
from okstra_ctl.report_markdown import ReportSections
from okstra_ctl.schema_excerpt import describe_changed, excerpt_contract_skew
from okstra_ctl.seeding import installed_version
from okstra_ctl.usage_cells import format_duration_ms, format_int, format_usd


TEMPLATE_BY_SCHEMA_VERSION = {
    "2.0": ("templates", "reports", "final-report-v2.template.md"),
    "3.0": ("templates", "reports", "final-report-v2.template.md"),
}
DEFAULT_TEMPLATE_REL = TEMPLATE_BY_SCHEMA_VERSION[CURRENT_REPORT_SCHEMA_VERSION]

TASK_DELIVERABLE_TITLES = {
    "requirements-discovery": "Requirements Discovery",
    "improvement-discovery": "Improvement Discovery",
    "error-analysis": "Error Analysis",
    "implementation-option-selection": "Implementation Option Selection",
    "project-analysis": "Project Analysis",
    "feature-analysis": "Feature Analysis",
    "change-impact-analysis": "Change Impact Analysis",
    "implementation-planning": "Implementation Planning",
    "implementation": "Implementation",
    "final-verification": "Final Verification",
    "release-handoff": "Release Handoff",
}

class FinalReportRenderError(RuntimeError):
    """Raised when the data.json cannot be rendered. Wraps jinja2 errors
    and IO errors with a single user-facing message so the CLI / Phase 6
    surface one consistent failure shape.
    """


def _yaml_scalar(value: Any) -> str:
    """Serialize a scalar for the YAML frontmatter block.

    Strings: wrap in double quotes and escape embedded double-quote /
    backslash so the YAML parser at consumer side (Obsidian / okstra
    runtime) does not choke on accidental colons or square brackets in
    the value. Bools / numbers / None: render as-is.
    """
    if value is None:
        return ""
    if isinstance(value, bool):
        return "true" if value else "false"
    if isinstance(value, (int, float)):
        return str(value)
    text = str(value)
    escaped = text.replace("\\", "\\\\").replace('"', '\\"')
    return f'"{escaped}"'


def _yaml_inline_list(values: list[str]) -> str:
    """Render a list of strings as a YAML flow-style sequence on one line."""
    return "[" + ", ".join(_yaml_scalar(v) for v in values) + "]"


def _md_quote(value: str | None, width: int = 2) -> str:
    """Re-mark every continuation line of a multi-line blockquote value.

    The template supplies the first line's ``  > ``; lines 2..n arrive at
    column 0, where they end the enclosing list item and drag the bullets
    that follow into one run-on paragraph.
    """
    text = "" if value is None else str(value)
    pad = " " * width
    lines = text.split("\n")
    return "\n".join(
        [lines[0]] + [f"{pad}>{' ' + line if line else ''}" for line in lines[1:]]
    )


# --- Index / scroll-anchor post-render pass -------------------------------
# After Jinja2 renders the body, every report gets a top-of-report index and
# clickable scroll anchors. An ID is *defined* when it is the leading token of
# a table row's first cell (`| **FU-001**<br>… |` or `| C-001 | …`); that row
# gets an `<a id="…">` anchor. Every other in-body mention of a *uniquely*
# defined ID becomes a `[ID](#anchor)` link. Same ID string defined in two
# sections (e.g. `C-001` in §1 Clarification AND §6.1 Consensus) gets two
# distinct anchors and its bare references are left unlinked — an ambiguous
# reference must not silently jump to the wrong row.
_HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*$")
_FENCE_RE = re.compile(r"^[ \t]*(?:```|~~~)")
_FIRST_CELL_ID_RE = re.compile(r"^[ \t]*\*{0,2}([A-Z]{1,4}-\d{1,})\b")
# A reference token: an ID not glued to a word char / `-` on either side and
# not prefixed by `:` (which would make it a `<worker>:<item-id>` source ref).
# `\d{1,}` (not `{3,}`): short IDs like `RC-4` are valid — the old 3-digit floor
# silently dropped them, so they could never anchor or link.
_REF_ID_RE = re.compile(r"(?<![:\w-])[A-Z]{1,4}-\d{1,}(?![\w-])")


def _slugify(text: str) -> str:
    slug = re.sub(r"[^\w\s-]", "", text.strip().lower())
    slug = re.sub(r"[\s_]+", "-", slug).strip("-")
    return slug or "section"


def _dedupe(base: str, used: set[str]) -> str:
    candidate, suffix = base, 1
    while candidate in used:
        suffix += 1
        candidate = f"{base}-{suffix}"
    used.add(candidate)
    return candidate


def _code_line_mask(lines: list[str]) -> list[bool]:
    """Mark lines the anchor / reference passes must never rewrite: fenced
    code blocks (git status dumps, etc.) and the leading YAML frontmatter
    (a `task-id` that happens to look like an ID must not be linkified)."""
    frontmatter_end = -1
    if lines and lines[0].strip() == "---":
        for k in range(1, len(lines)):
            if lines[k].strip() == "---":
                frontmatter_end = k
                break
    mask, in_fence = [], False
    for i, line in enumerate(lines):
        if i <= frontmatter_end:
            mask.append(True)
        elif _FENCE_RE.match(line):
            mask.append(True)
            in_fence = not in_fence
        else:
            mask.append(in_fence)
    return mask


def _first_cell(line: str) -> str | None:
    if not line.lstrip().startswith("|"):
        return None
    # Split on unescaped pipes only — an `\|` escaped by the `mdcell` filter
    # stays inside its cell instead of truncating it.
    parts = UNESCAPED_PIPE_RE.split(line)
    return parts[1] if len(parts) >= 3 else None


def _scan_structure(lines: list[str], mask: list[bool]) -> tuple[list, list]:
    """Collect (level, text, slug, line_idx) headings and {id, section,
    line} definitions in document order. H1 (the title) is skipped."""
    headings: list = []
    definitions: list = []
    used_slugs: set[str] = set()
    current_section: str | None = None
    for i, line in enumerate(lines):
        if mask[i]:
            continue
        heading = _HEADING_RE.match(line)
        if heading:
            if len(heading.group(1)) < 2:
                continue  # H1 title — not an index entry, no anchor
            text = heading.group(2).strip()
            slug = _dedupe(_slugify(text), used_slugs)
            headings.append((len(heading.group(1)), text, slug, i))
            current_section = text
            continue
        cell = _first_cell(line)
        if cell is None:
            continue
        match = _FIRST_CELL_ID_RE.match(cell)
        if match:
            definitions.append({"id": match.group(1), "section": current_section, "line": i})
    return headings, definitions


def _assign_anchors(definitions: list) -> dict[str, str]:
    """Give every definition a unique anchor; return the id→anchor map for
    the subset of IDs that are defined exactly once (safe to link)."""
    used: set[str] = set()
    counts: dict[str, int] = {}
    for d in definitions:
        counts[d["id"]] = counts.get(d["id"], 0) + 1
    for d in definitions:
        d["anchor"] = _dedupe(d["id"].lower(), used)
    return {d["id"]: d["anchor"] for d in definitions if counts[d["id"]] == 1}


def _inject_anchors(lines: list[str], headings: list, definitions: list) -> None:
    for _level, _text, slug, i in headings:
        if "<a id=" not in lines[i]:
            lines[i] = lines[i].rstrip() + f' <a id="{slug}"></a>'
    for d in definitions:
        i = d["line"]
        pos = lines[i].index(d["id"])
        lines[i] = lines[i][:pos] + f'<a id="{d["anchor"]}"></a>' + lines[i][pos:]


def _maybe_link(match: re.Match, id_to_anchor: dict[str, str]) -> str:
    token = match.group(0)
    anchor = id_to_anchor.get(token)
    if anchor is None:
        return token
    # The definition site is already `<a id="…"></a>**FU-001**`; never wrap it.
    if match.string[: match.start()].rstrip("*").endswith("</a>"):
        return token
    return f"[{token}](#{anchor})"


def _link_references(lines: list[str], mask: list[bool], id_to_anchor: dict[str, str]) -> None:
    if not id_to_anchor:
        return
    for i, line in enumerate(lines):
        if mask[i]:
            continue
        # Split on backticks so inline-code spans (odd segments) are skipped.
        segments = line.split("`")
        for j in range(0, len(segments), 2):
            segments[j] = _REF_ID_RE.sub(lambda m: _maybe_link(m, id_to_anchor), segments[j])
        lines[i] = "`".join(segments)


def _build_index(headings: list, definitions: list, labels: dict) -> list[str]:
    # The section list / ID index use bold labels, not `###` sub-headings, so
    # the HTML view's auto-TOC (report_views._build_toc) doesn't pick them up
    # as navigable headings.
    heading = labels.get("heading", "Index")
    block = [f'## {heading} <a id="report-index"></a>', "", f'**{labels.get("sectionsLabel", "Sections")}**', ""]
    for level, text, slug, _i in headings:
        block.append(f'{"  " * (level - 2)}- [{text}](#{slug})')
    block += ["", f'**{labels.get("idIndexLabel", "ID Index")}**', ""]
    if not definitions:
        block += [labels.get("noIds", "- (no tracked IDs in this report.)"), ""]
        return block
    groups: list = []
    order: dict = {}
    for d in definitions:
        section = d["section"] or "—"
        if section not in order:
            order[section] = len(groups)
            groups.append((section, []))
        groups[order[section]][1].append((d["id"], d["anchor"]))
    for section, items in groups:
        links = ", ".join(f"[{tok}](#{anchor})" for tok, anchor in items)
        block.append(f"- **{section}**: {links}")
    block.append("")
    return block


# --- Prose ventilation post-render pass -----------------------------------
# Split prose paragraphs at sentence boundaries so raw markdown reads one
# sentence per line; the HTML view turns those newlines into <br>
# (report_views._markdown_to_html). Prose only — table cells already carry
# their own <br>, and headings / lists / code / blockquotes must stay intact.
# A leading-whitespace line is a list continuation, not a prose paragraph:
# only column-0 non-block lines are treated as prose (allowlist).
_LIST_LINE_RE = re.compile(r"^\s*(?:[-*]\s|\d+\.\s)")
_SENT_BOUNDARY_RE = re.compile(r"[.?!][ \t]+(?=\S)")
# Trailing tokens whose dot is not a sentence end (compared lowercased with
# the trailing dot stripped).
_ABBREV = {
    "e.g", "i.e", "etc", "cf", "vs", "al", "no", "fig",
    "dr", "mr", "ms", "inc", "ltd", "jr", "sr",
}


def _code_span_mask(text: str) -> list[bool]:
    """Mark every char inside an inline-code span (and the backticks that
    fence it) so sentence detection never fires on a `.` in a path or id."""
    mask = [False] * len(text)
    in_code = False
    for i, ch in enumerate(text):
        if ch == "`":
            in_code = not in_code
            mask[i] = True
        else:
            mask[i] = in_code
    return mask


def _ventilate_text(text: str, *, sep: str) -> str:
    """Insert ``sep`` after each sentence-ending ``.``/``?``/``!`` that is
    followed by whitespace. Skips marks inside inline-code spans, numbers /
    decimals, ellipses, and known abbreviations. ``sep`` is ``"\\n"`` for
    prose lines (raw-markdown ventilation) and ``"<br>"`` for table cells /
    list items, where a bare newline would break the row / list structure.

    Masking code spans (rather than splitting on backticks and ventilating
    each segment) is what lets a boundary sitting right before an inline-code
    span split — the segment approach missed it at the segment edge.
    """
    if not text:
        return text
    mask = _code_span_mask(text)
    out: list[str] = []
    last = 0
    for match in _SENT_BOUNDARY_RE.finditer(text):
        mark = match.start()  # index of the punctuation
        if mask[mark]:
            continue
        prev = text[mark - 1] if mark > 0 else ""
        # A number, a bare-digit, or an ellipsis dot is not a sentence end.
        if not prev or prev.isspace() or prev.isdigit() or prev == ".":
            continue
        tail = re.search(r"[A-Za-z.]+$", text[: mark + 1])
        if tail and tail.group(0).rstrip(".").lower() in _ABBREV:
            continue
        out.append(text[last : mark + 1])  # up to and including the mark
        out.append(sep)
        last = match.end()  # drop the inter-sentence whitespace
    out.append(text[last:])
    return "".join(out)


def _ventilate_table_row(line: str) -> str:
    """Ventilate each cell of a pipe-table row with ``<br>``. A newline
    would truncate the row, so table cells (which already carry ``<br>`` for
    stacked meta) use ``<br>``. The header separator row (``|---|:--:|``) has
    no prose and is left alone."""
    if set(line.strip()) <= set("|-: "):
        return line
    cells = UNESCAPED_PIPE_RE.split(line)
    for k in range(1, len(cells)):  # skip cells[0] (indent / pre-border)
        cells[k] = _ventilate_text(cells[k], sep="<br>")
    return "|".join(cells)


def _ventilate_list_item(line: str) -> str:
    """Ventilate a list item's body with ``<br>`` (a newline would end the
    item), preserving the bullet / number marker and any indent."""
    marker = _LIST_LINE_RE.match(line).group(0)
    return marker + _ventilate_text(line[len(marker):], sep="<br>")


def _ventilate_prose(markdown: str) -> str:
    """Break prose into one sentence per line. Column-0 paragraphs split on a
    newline (clean raw markdown); table cells and list items split on ``<br>``
    (a newline there would break the row / list). Headings, blockquotes, and
    indented continuation lines are left intact. Idempotent: a single-sentence
    span has no internal boundary to split."""
    lines = markdown.split("\n")
    mask = _code_line_mask(lines)
    for i, line in enumerate(lines):
        if mask[i] or not line:  # frontmatter / fenced code / blank
            continue
        if _HEADING_RE.match(line) or line.startswith(">"):
            continue
        if line.lstrip().startswith("|"):
            lines[i] = _ventilate_table_row(line)
        elif _LIST_LINE_RE.match(line):
            lines[i] = _ventilate_list_item(line)
        elif line == line.lstrip():  # column-0 prose (not indented continuation)
            lines[i] = _ventilate_text(line, sep="\n")
    return "\n".join(lines)


def _inject_index_and_anchors(markdown: str, dictionary: dict | None) -> str:
    """Append scroll anchors + a top-of-report index to a rendered report.
    Idempotent: re-running on already-anchored markdown is a no-op for
    headings (anchor already present) and re-derives the same anchors."""
    # Idempotent: a markdown that already carries the index anchor has been
    # processed (or hand-seeded) — re-running must not stack a second index.
    if '<a id="report-index"' in markdown:
        return markdown
    labels = (dictionary or {}).get("index", {})
    lines = markdown.split("\n")
    mask = _code_line_mask(lines)
    headings, definitions = _scan_structure(lines, mask)
    if not headings:
        return markdown
    id_to_anchor = _assign_anchors(definitions)
    _inject_anchors(lines, headings, definitions)
    _link_references(lines, mask, id_to_anchor)
    insert_at = headings[0][3]
    lines = lines[:insert_at] + _build_index(headings, definitions, labels) + lines[insert_at:]
    return "\n".join(lines)


def inject_index_into_file(md_path: Path) -> int:
    """Apply the legacy top-of-report index to an existing Markdown report.

    This remains for schema-v1 and quick compatibility artifacts. Schema-v2
    reports use the fixed AI-handoff structure and never call this seam.
    Idempotent; returns the number of bytes written.
    """
    if not md_path.is_file():
        raise FinalReportRenderError(f"report markdown not found: {md_path}")
    try:
        dictionary = load_dictionary(MARKDOWN_LANG)
    except I18nError as exc:
        raise FinalReportRenderError(str(exc)) from exc
    injected = _inject_index_and_anchors(md_path.read_text(encoding="utf-8"), dictionary)
    tmp = md_path.with_suffix(md_path.suffix + f".tmp.{os.getpid()}")
    tmp.write_text(injected, encoding="utf-8")
    tmp.replace(md_path)
    return len(injected.encode("utf-8"))


def _enforce_schema(data: dict) -> dict | None:
    """렌더 전에 data.json 을 스키마에 대해 검증하는 seam.

    검증에 쓴 스키마를 그대로 돌려준다 — v2 마크다운 렌더러가 필드 순서를
    이 스키마에서 읽기 때문에, 같은 파일을 두 번 찾지 않는다.

    스키마 파일을 찾지 못하는 경우(손상된 설치 환경)는 경고만 출력하고 계속
    진행한다 — validate-run 과 install 경고가 이미 해당 상황을 표면화하므로
    Phase 7 재렌더를 hard-fail 시키는 것은 과도하다.
    """
    try:
        schema = load_schema_for_data(data)
    except SchemaError as exc:
        print(
            f"render-final-report: schema not locatable; skipping schema enforcement ({exc})",
            file=sys.stderr,
        )
        return None
    errors = schema_validate(data, schema)
    if errors:
        raise FinalReportRenderError(
            f"final-report data.json fails schema validation ({len(errors)} error(s)): "
            + "; ".join(errors[:5])
        )
    return schema


# 일반 alias('opus'/'sonnet'/'haiku')가 런타임에 해소되는, okstra 가 아는 최신
# 구체버전 — final-report '표시 전용'. 실행 인자(execution value)는 바꾸지 않으며
# (여전히 'opus' 가 `claude --model` 로 전달됨), 이 매핑은 보고서에서 "어느
# 구체버전 계열로 돌았는지" 가독성만 준다. CLI 가 실제 고른 버전과 다를 수
# 있으나 표시이므로 실행에는 무해하다. 새 버전이 나오면 여기만 갱신한다.
_DISPLAY_CONCRETE_CLAUDE = {
    "fable": "claude-fable-5",
    "opus": "claude-opus-5",
    "sonnet": "claude-sonnet-4-6",
    "haiku": "claude-haiku-4-5",
}


def _model_detail(display: Any) -> str:
    """모델 display alias(예: 'opus')를 'opus (claude-opus-4-7)' 형태로 상세화한다.

    lead·report-writer 헤더 모델은 항상 claude provider 다. 구체 alias('opus-4-7'
    등)는 claude 매핑의 실행 ID 를, 일반 alias('opus' 등)는 표시 전용
    `_DISPLAY_CONCRETE_CLAUDE` 의 권장 구체버전을 병기한다. 어느 쪽에도 없는 값
    (이미 실행 ID 이거나 'default' 등)은 원본을 그대로 돌려준다. 바깥 백틱은
    템플릿이 감싸므로 여기서는 넣지 않는다."""
    text = str(display or "").strip()
    if not text:
        return text
    try:
        meta = resolve_model_metadata(
            provider="claude", raw_value=text,
            default_display=text, default_execution="",
        )
    except UnknownModelError:
        meta = None
    if meta and meta.execution and meta.execution != text:
        return f"{text} ({meta.execution})"
    concrete = _DISPLAY_CONCRETE_CLAUDE.get(text.lower())
    if concrete:
        return f"{text} ({concrete})"
    return text


def _build_environment(template_dir: Path) -> Environment:
    # ChainableUndefined lets optional fields (e.g.
    # ``clarificationCarryIn``, ``ticketCoverage.omit``) silently evaluate
    # to false in `{% if %}` tests instead of raising. Strict presence
    # checking is the schema validator's job, not the renderer's — and
    # the renderer can't tell "the writer forgot this required field"
    # from "this field is intentionally absent on this task-type".
    env = Environment(
        loader=FileSystemLoader(str(template_dir)),
        undefined=ChainableUndefined,
        trim_blocks=True,
        lstrip_blocks=True,
        keep_trailing_newline=True,
    )
    env.filters["format_int"] = format_int
    env.filters["format_usd"] = format_usd
    env.filters["format_duration_ms"] = format_duration_ms
    env.filters["yaml_scalar"] = _yaml_scalar
    env.filters["yaml_inline_list"] = _yaml_inline_list
    env.filters["model_detail"] = _model_detail
    # `mdcell` neutralises the two things in worker prose that can break a
    # markdown table row: a literal `|` (splits the row) and a newline
    # (truncates it, dropping every later column). Table-cell interpolations
    # only — never code blocks / headings / prose, where `\|` and `<br>` would
    # render verbatim.
    env.filters["mdcell"] = to_cell_text
    # `mdquote` re-marks continuation lines of a blockquote nested in a list
    # item; the fenced siblings use Jinja's builtin `indent` for the same
    # reason. Both exist because only the first line of an interpolation
    # inherits the template's leading indent.
    env.filters["mdquote"] = _md_quote
    return env


def _ai_markdown_context(data: dict, schema: dict | None) -> dict:
    context = _with_optional_defaults(data)
    header = data.get("header") if isinstance(data.get("header"), dict) else {}
    task_type = header.get("taskType", "")
    context["aiTaskDeliverableTitle"] = TASK_DELIVERABLE_TITLES.get(
        task_type, task_type
    )
    context["aiTaskProperty"] = TASK_TYPE_DATA_PROPERTY.get(task_type, "")
    context["aiTaskTemplate"] = _markdown_task_template(task_type)
    context["aiBlockingIds"] = progress_blocking_ids(
        data.get("clarificationItems", []),
        USER_INPUT_BLOCKS,
        report_data=data,
    )
    sections = ReportSections(data, schema or {})
    context["md"] = sections.section
    context["md_rest"] = sections.rest
    context["md_has"] = sections.has
    context["md_claim"] = sections.mark_rendered
    context["executionRolesTable"] = render_execution_roles_markdown(data)
    context["executionIdentityVersion"] = data.get("executionIdentityVersion")
    return context


def _markdown_task_template(task_type: str) -> str:
    """The task body this report includes, or '' for an unknown task type.

    An unknown type still renders the shared spine plus the generic sweep, so a
    task type that reaches the renderer before its template exists produces a
    complete report rather than a crash.
    """
    try:
        return markdown_template_for(task_type)
    except ValueError:
        return ""


# The Markdown is the AI handoff sibling of the data.json — same content, same
# audience, so it renders in the SSOT's language and nothing else. The field
# still has to be well-formed here because it decides whether Phase 7 pays for
# a translator, and a malformed one would surface at the very end of the run.
MARKDOWN_LANG = "en"


def validate_report_language(data: dict) -> str:
    """Check `meta.reportLanguage` and return the Markdown's own language.

    The value names the language the *human HTML* renders in; it never
    selects this renderer's dictionary.
    """
    candidate = (data.get("meta") or {}).get("reportLanguage") or "en"
    if candidate == "auto":
        raise FinalReportRenderError(
            "reportLanguage 'auto' must be resolved by the lead before "
            "the renderer runs; report assembly copies the resolved 'en' or "
            "'ko' from the run manifest into data.json.meta.reportLanguage."
        )
    if candidate not in SUPPORTED_LANGS:
        raise FinalReportRenderError(
            f"reportLanguage must be one of {SUPPORTED_LANGS}, got {candidate!r}"
        )
    return MARKDOWN_LANG


# Schema-optional top-level arrays, and the value a template may assume when
# the report omits them. The vendored jinja2 does not resolve a missing
# top-level name to `Undefined`: `| default([])` passes the sentinel straight
# through, `| length` raises on it, and both `x` and `not x` evaluate true. So a
# template cannot test for absence at all, and the value has to arrive filled.
_OPTIONAL_ARRAY_DEFAULTS = ("endStateCoverage",)
_OPTIONAL_ANALYSIS_DEFAULTS = (
    "analysisCommon",
    "projectAnalysis",
    "featureAnalysis",
    "changeImpactAnalysis",
)


def render_execution_roles_markdown(data: dict) -> str:
    roles = data.get("executionRoles") or []
    if data.get("executionIdentityVersion") != 2:
        return "legacy execution roles"
    lines = ["| Role | Label | Status |", "|---|---|---|"]
    for row in roles:
        lines.append(
            f"| {row.get('role', '')} | {row.get('executionLabel', '')} | "
            f"{row.get('status', '')} |"
        )
    return "\n".join(lines)


def render_execution_roles_html(data: dict) -> str:
    return render_execution_roles_markdown(data)


def _with_optional_defaults(data: dict) -> dict:
    """Render context with schema-optional fields filled in.

    Keeps the schema field optional — an omitted `endStateCoverage` stays absent
    in data.json, which is what the run validator reads to tell a legacy brief
    apart from a dropped requirement — while giving the template a value it can
    branch on.
    """
    filled = dict(data)
    for key in _OPTIONAL_ARRAY_DEFAULTS:
        if not isinstance(filled.get(key), list):
            filled[key] = []
    for key in _OPTIONAL_ANALYSIS_DEFAULTS:
        if not isinstance(filled.get(key), dict):
            filled[key] = None
    return filled


def render(
    data: dict,
    *,
    template_path: Path,
) -> str:
    """Render ``data`` through the Jinja2 ``template_path`` and return the
    final markdown as a string. Caller writes it to disk.

    Raises ``FinalReportRenderError`` on any template / IO / data structural failure
    so the surface is one exception type rather than the broad jinja2
    hierarchy.
    """
    if not template_path.is_file():
        raise FinalReportRenderError(f"template not found: {template_path}")

    schema = _enforce_schema(data)

    lang = validate_report_language(data)
    try:
        dictionary = load_dictionary(lang)
    except I18nError as exc:
        raise FinalReportRenderError(str(exc)) from exc

    env = _build_environment(template_path.parent)
    env.globals["t"] = make_jinja_global(dictionary)

    try:
        template = env.get_template(template_path.name)
        rendered = template.render(**_ai_markdown_context(data, schema))
        return _ventilate_prose(rendered)
    except I18nError as exc:
        raise FinalReportRenderError(
            f"i18n lookup failed while rendering {template_path.name}: {exc}"
        ) from exc
    except Exception as exc:  # jinja2.TemplateError, KeyError, etc.
        raise FinalReportRenderError(
            f"render failed for template {template_path.name}: {exc}"
        ) from exc


def find_default_template(start: Path | None = None) -> Path:
    """Locate the bundled final-report template.

    Resolution order:
      1. ``$OKSTRA_HOME/templates/reports/final-report-v2.template.md`` (installed runtime).
      2. ``<repo>/templates/reports/final-report-v2.template.md`` (in-repo dev runs).
         Repo root is detected by walking up from this file until a
         ``templates/reports/final-report-v2.template.md`` exists.

    Raises ``FinalReportRenderError`` if neither path is present.
    """
    root = find_asset_root(DEFAULT_TEMPLATE_REL, start=start)
    if root is not None:
        return root.joinpath(*DEFAULT_TEMPLATE_REL)

    raise FinalReportRenderError(
        "could not locate final-report-v2.template.md. Set OKSTRA_HOME or "
        "run from a checkout that contains templates/reports/."
    )


def find_default_template_for_data(
    data: dict, start: Path | None = None
) -> Path:
    """Locate the Markdown template selected by ``data.schemaVersion``."""
    version = data.get("schemaVersion")
    try:
        relative_path = TEMPLATE_BY_SCHEMA_VERSION[version]
    except KeyError as exc:
        raise FinalReportRenderError(
            f"unsupported final-report schemaVersion: {version}"
        ) from exc

    root = find_asset_root(relative_path, start=start)
    if root is not None:
        return root.joinpath(*relative_path)

    raise FinalReportRenderError(
        f"could not locate {relative_path[-1]}. Set OKSTRA_HOME or run from a "
        "checkout that contains templates/reports/."
    )


def _bundle_excerpt_path(data_path: Path) -> Path | None:
    """The task bundle's schema excerpt, found by walking up from *data_path*."""
    for ancestor in data_path.resolve().parents:
        candidate = ancestor / "instruction-set" / "final-report-schema.json"
        if candidate.is_file():
            return candidate
    return None


def _with_excerpt_drift_hint(
    exc: FinalReportRenderError, data_path: Path, task_type: str
) -> FinalReportRenderError:
    """Name the contract drift behind a schema failure, when that is the cause.

    A long run straddles its own runtime upgrade: the report-writer authors its
    narrative against the excerpt frozen into the bundle at prep time, while
    assembly and the renderer validate against the installed schema. Without this the author sees only
    `additional property ... not allowed` for a field the excerpt told it to
    write, and has no way to tell a real mistake from a stale bundle.

    An older stamp is not itself the cause. When the excerpt still states this
    task-type's contract exactly, the failure came from somewhere else, and
    pointing at the version would send the author to re-prepare a bundle that
    was never wrong. The hint is attached only when a contract member actually
    moved, and it names which.
    """
    if "schema validation" not in str(exc):
        return exc
    excerpt_path = _bundle_excerpt_path(data_path)
    if excerpt_path is None or not task_type:
        return exc
    current = installed_version()
    skew = excerpt_contract_skew(excerpt_path, task_type, current)
    if skew is None:
        return exc
    return FinalReportRenderError(
        f"{exc} — the bundle's schema excerpt ({excerpt_path}) was cut from okstra "
        f"{skew.cut_from} but validation ran on {current}, which states "
        f"{task_type}'s contract differently: {describe_changed(skew.changed)}; "
        "author against the installed schema, not the excerpt, or re-prepare the "
        "bundle."
    )


def render_to_file(
    data_path: Path,
    output_path: Path,
    *,
    template_path: Path | None = None,
) -> int:
    """Read ``data_path`` (JSON), render through Jinja2, write to
    ``output_path``. Returns the number of bytes written.

    The output is written atomically (write-then-rename) so a partial
    write never produces a half-corrupt final-report on disk.
    """
    if not data_path.is_file():
        raise FinalReportRenderError(f"data file not found: {data_path}")
    try:
        data = load_owned_object(data_path, artifact="final report record")
    except JsonBoundaryError as exc:
        raise FinalReportRenderError(f"invalid JSON in {data_path}: {exc}") from exc

    # 템플릿은 data.json 위치가 아니라 이 모듈(설치본은 ~/.okstra/lib/python,
    # repo 는 scripts/) 위치 기준으로 찾는다. data_path 를 start 로 넘기면
    # 프로젝트의 .okstra 트리만 위로 뒤지다 templates/reports 를 못 찾아
    # 설치본에서 항상 'could not locate template' 으로 실패한다(OKSTRA_HOME 을
    # 수동 설정해야 했던 원인). 프로젝트별 override 는 --template 으로 한다.
    resolved_template = template_path or find_default_template_for_data(data)
    try:
        rendered = render(
            data,
            template_path=resolved_template,
        )
    except FinalReportRenderError as exc:
        header = data.get("header") if isinstance(data, dict) else None
        task_type = header.get("taskType") if isinstance(header, dict) else ""
        raise _with_excerpt_drift_hint(exc, data_path, str(task_type or "")) from exc

    output_path.parent.mkdir(parents=True, exist_ok=True)
    tmp = output_path.with_suffix(output_path.suffix + f".tmp.{os.getpid()}")
    tmp.write_text(rendered, encoding="utf-8")
    tmp.replace(output_path)
    return len(rendered.encode("utf-8"))


def snapshot_last_valid(data_path: Path) -> Path | None:
    """Keep the data.json that just rendered, as `<name>.last-valid`.

    The gate blocks in this file are hand-edited between self-fix rounds, and a
    write in the wrong shape destroys the previous round's verdicts. `.okstra`
    is conventionally gitignored, so there is no version history to fall back
    on — recovery has meant scraping the JSON back out of the last rendered
    markdown. This snapshot is that fallback, replaced only after a render that
    passed schema enforcement, so it is always a document that renders.

    Called by the CLI entry point rather than `render_to_file`, so rendering a
    data.json in place (a fixture, a dry run) never writes beside its input.
    Failing to write it must never fail the render: the report is the
    deliverable, the snapshot is a convenience.
    """
    snapshot = data_path.with_name(data_path.name + ".last-valid")
    try:
        tmp = snapshot.with_suffix(snapshot.suffix + f".tmp.{os.getpid()}")
        tmp.write_bytes(data_path.read_bytes())
        tmp.replace(snapshot)
    except OSError as exc:
        print(
            f"render-final-report: could not write {snapshot.name} ({exc})",
            file=sys.stderr,
        )
        return None
    return snapshot
