"""Render the self-contained HTML view of an okstra final-report markdown.

Single product, single source of truth:

* ``build_report_view_model(src_md, *, run_meta)`` — deterministic
  server-side Report View Model for human readers. It carries source digest,
  rendered body HTML, response IDs, sidecar hints, and optional approval
  context.
* ``render_report_view_model(model, *, css, js)`` — self-contained HTML
  renderer over that model. Sections §1/§3/§4 user-actionable rows (those
  reachable from §1 ``C-*`` IDs) get embedded ``<form>`` controls. §5.6 /
  §5.7 / §5.8 deliverable sub-sections are explicitly excluded from form
  attachment — they are read-only deliverables.
* ``render_html(src_md, *, run_meta)`` remains a compatibility wrapper around
  the model builder + renderer.

User responses are NEVER merged back into the original report. The HTML
serialises a ``user-response`` markdown sidecar via ``Export user
response`` button (client-side JS, single-reference-point with the
Python ``serialize_user_response`` function below) and the user pastes
it to ``runs/<task-type>/user-responses/user-response-<task-type>-<seq>.md``.

The next-phase lead prompt now consumes the source MD directly — the
former ``*.slim.md`` derived artefact was removed (its strip rules
saved no tokens worth the contract maintenance cost).
"""
from __future__ import annotations

import hashlib
import html
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable, Optional

from .final_report_paths import final_report_data_path
from .json_boundary import JsonBoundaryError, load_owned_object
from .report_html.view_models.implementation_planning import (
    PlanApprovalState,
    plan_approval_state,
    resolve_recommended_option,
)
from .report_view_artifacts import html_view_path, user_responses_dir_for_report


def source_digest(src_md: str) -> str:
    """Stable identifier for a final-report markdown body. The HTML
    view embeds this in its run-meta block so the validator can detect
    a stale html that was generated from an older MD."""
    return hashlib.sha256(src_md.encode("utf-8")).hexdigest()


_HTML_DIGEST_RE = re.compile(r'"source-sha256":"([0-9a-f]{64})"')


def extract_html_digest(html_text: str) -> Optional[str]:
    m = _HTML_DIGEST_RE.search(html_text)
    return m.group(1) if m else None


_LEADING_FRONTMATTER_RE = re.compile(
    r"\A---\s*\n.*?\n---\s*\n?", re.DOTALL
)


def _strip_leading_frontmatter(text: str) -> str:
    """Remove a leading YAML frontmatter block (``---\\n…\\n---\\n``) if
    present. Used by ``render_html`` so the HTML view does not surface
    the Obsidian-side frontmatter as a paragraph; the source MD keeps
    the frontmatter for Obsidian/Templater consumers."""
    return _LEADING_FRONTMATTER_RE.sub("", text, count=1)

from .clarification_items import (
    _CELL_ANCHOR_RE,
    UNRESOLVED_STATUSES,
    _section_1_slice,
    _split_pipe_row,
    parse_clarification_items,
    parse_meta_cell,
    scan_approval_gate,
    section_1_present_but_unparsed,
)
from .md_table import is_separator_row as _is_separator_row


# --------------------------------------------------------------------------- #
# HTML renderer — minimal markdown-to-HTML converter (no external deps)
# --------------------------------------------------------------------------- #

_BLANK_PATTERN = re.compile(r"^\s*$")

_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
# Leading whitespace is captured so fences nested in a list item (e.g.
# §5.7.5's carry sidecar JSON, indented 2 spaces) are recognised and the
# code lines can be dedented before rendering.
_CODEFENCE_PATTERN = re.compile(r"^(\s*)```(.*)$")
_LIST_BULLET_PATTERN = re.compile(r"^(\s*)[-*]\s+(.*)$")
_LIST_NUMBERED_PATTERN = re.compile(r"^(\s*)(\d+)\.\s+(.*)$")
_INLINE_CODE_PATTERN = re.compile(r"`([^`]+)`")
_BOLD_PATTERN = re.compile(r"\*\*([^*]+)\*\*")
_LINK_PATTERN = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")

# Sections whose Response-ID-bearing rows must NOT get form attachment
# (read-only deliverables — see plan §1.4).
_NO_FORM_SECTION_PREFIXES = ("## 5.6", "### 5.6", "## 5.7", "### 5.7", "## 5.8", "### 5.8")


@dataclass(frozen=True)
class RunMeta:
    task_key: str
    task_type: str
    seq: str
    source_report: str  # relative path of the .md the HTML is derived from
    source_data: str = ""
    source_data_sha256: str = ""


@dataclass(frozen=True)
class AnalysisReviewContext:
    selector_ids: tuple[str, ...]


# task-type itself can contain hyphens (``implementation-planning``,
# ``final-verification``, ``release-handoff``), so the filename segment
# between ``final-report-`` and ``-<seq>.md`` is matched greedily; the
# trailing ``-(?P<seq>\d+)\.md$`` anchor pins seq to the last numeric tail.
_REPORT_PATH_RE = re.compile(
    r"runs/(?P<task_type>[^/]+)/reports/final-report-.+-(?P<seq>\d+)\.md$"
)


def _infer_run_meta_from_path(path: Path) -> dict:
    m = _REPORT_PATH_RE.search(path.as_posix())
    return {"task_type": m.group("task_type"), "seq": m.group("seq")} if m else {}


# 리포트 헤더는 이 값들을 인라인 코드로 렌더한다 (`- Task Type: \`x\``). 마커를
# 벗기지 않으면 백틱이 sidecar 파일명과 frontmatter 로 새고, 다음 run 의
# clarification 캐리인이 `user-response-<task-type>-<seq>.md` 매칭에 실패한다.
_INLINE_CODE_VALUE_RE = re.compile(r"^`(?P<value>.+)`$")


def _strip_inline_code(value: str) -> str:
    m = _INLINE_CODE_VALUE_RE.match(value)
    return m.group("value").strip() if m else value


def _infer_run_meta_from_body(text: str) -> dict:
    found: dict[str, str] = {}
    for label, key in (("Task Key", "task_key"), ("Task Type", "task_type")):
        m = re.search(rf"^- {label}:\s*(\S.*?)\s*$", text, re.MULTILINE)
        if m:
            found[key] = _strip_inline_code(m.group(1))
    return found


def infer_run_meta(report_path: Path, *, task_key: Optional[str] = None,
                   task_type: Optional[str] = None, seq: Optional[str] = None,
                   source_report: Optional[str] = None,
                   source_data: Optional[str] = None,
                   source_data_sha256: Optional[str] = None) -> RunMeta:
    """Derive a ``RunMeta`` from a final-report path/body, honouring any
    explicit override.

    Used by the schema-v1 HTML view render script and by the in-session
    user-response writer. Schema-v2 resolves its own ``HtmlRunMeta`` from the
    data.json header instead, so the sidecar match keys (task_type/seq/
    source-report) this produces must agree with that header — the sidecar
    name the v2 HTML advertises is the one the next run's carry-in looks for.
    """
    text = report_path.read_text(encoding="utf-8")
    inferred = {**_infer_run_meta_from_path(report_path), **_infer_run_meta_from_body(text)}
    resolved_source_report = source_report or report_path.name
    data_path = final_report_data_path(report_path)
    inferred_source_data = ""
    inferred_source_data_sha256 = ""
    if data_path.is_file():
        inferred_source_data = final_report_data_path(
            Path(resolved_source_report)
        ).as_posix()
        inferred_source_data_sha256 = hashlib.sha256(data_path.read_bytes()).hexdigest()
    return RunMeta(
        task_key=task_key or inferred.get("task_key") or "unknown",
        task_type=task_type or inferred.get("task_type") or "unknown",
        seq=seq or inferred.get("seq") or "000",
        source_report=resolved_source_report,
        source_data=source_data or inferred_source_data,
        source_data_sha256=source_data_sha256 or inferred_source_data_sha256,
    )


@dataclass(frozen=True)
class ReportViewModel:
    run_meta: RunMeta
    title: str
    body_html: str
    source_digest: str
    response_ids: tuple[str, ...]
    sidecar_name: str
    sidecar_dir: str
    approval_ctx: PlanApprovalContext | None = None
    reader_ctx: ReaderDashboardContext | None = None
    analysis_review_ctx: AnalysisReviewContext | None = None


@dataclass(frozen=True)
class ReaderDashboardContext:
    decision: str
    human_action_required: str
    blocking_items: str
    safe_to_skip: str
    recommended_command: str
    open_clarifications: int
    approval: str
    recommended_option: str


def build_report_view_model(
    src_md: str,
    *,
    run_meta: RunMeta,
    approval_ctx: PlanApprovalContext | None = None,
    reader_ctx: ReaderDashboardContext | None = None,
    analysis_review_ctx: AnalysisReviewContext | None = None,
) -> ReportViewModel:
    digest = source_digest(src_md)
    body_md = _strip_leading_frontmatter(src_md)
    body_html, toc_headings = _markdown_to_html(body_md)
    body_html = _inject_toc(body_html, toc_headings)
    response_ids = tuple(
        sorted({item.row_id for item in (parse_clarification_items(body_md) or [])})
    )
    return ReportViewModel(
        run_meta=run_meta,
        title=f"{run_meta.task_key} — {run_meta.task_type} #{run_meta.seq}",
        body_html=body_html,
        source_digest=digest,
        response_ids=response_ids,
        sidecar_name=f"user-response-{run_meta.task_type}-{run_meta.seq}.md",
        sidecar_dir=f"runs/{run_meta.task_type}/user-responses/",
        approval_ctx=approval_ctx,
        reader_ctx=reader_ctx,
        analysis_review_ctx=analysis_review_ctx,
    )


def _reader_dashboard(ctx: ReaderDashboardContext | None) -> str:
    if ctx is None:
        return ""
    rows = (
        ("Decision", html.escape(ctx.decision)),
        ("Human action required", html.escape(ctx.human_action_required)),
        ("Blocking items", html.escape(ctx.blocking_items)),
        ("Safe to skip", html.escape(ctx.safe_to_skip)),
        ("Open clarifications", str(ctx.open_clarifications)),
        ("Approval", html.escape(ctx.approval)),
        ("Recommended option", html.escape(ctx.recommended_option or "--")),
        ("Recommended command", f"<code>{html.escape(ctx.recommended_command or '--')}</code>"),
    )
    summary = "".join(f"<dt>{label}</dt><dd>{value}</dd>" for label, value in rows)
    return (
        '<section class="reader-dashboard" aria-label="Reader summary" data-reader-section="action">\n'
        '  <div class="reader-dashboard-head">\n'
        "    <h2>Reader Summary</h2>\n"
        '    <div class="reader-mode-controls" aria-label="Reading mode">\n'
        '      <button type="button" data-action="set-reader-mode" data-reader-mode="action" aria-pressed="true">Action</button>\n'
        '      <button type="button" data-action="set-reader-mode" data-reader-mode="audit" aria-pressed="false">Audit</button>\n'
        '      <button type="button" data-action="set-reader-mode" data-reader-mode="full" aria-pressed="false">Full</button>\n'
        "    </div>\n"
        "  </div>\n"
        f'  <dl class="reader-summary-grid">{summary}</dl>\n'
        "</section>\n"
    )


def render_report_view_model(
    model: ReportViewModel,
    *,
    css: str,
    js: str,
) -> str:
    title = html.escape(model.title)
    response_ids_json = "[" + ",".join(
        '"' + html.escape(rid) + '"' for rid in model.response_ids
    ) + "]"
    sidecar_name = html.escape(model.sidecar_name)
    sidecar_dir = html.escape(model.sidecar_dir)
    run_meta = model.run_meta
    source_data_json = (
        f'"source-data":"{html.escape(run_meta.source_data)}",'
        if run_meta.source_data else ""
    )
    source_data_sha256_json = (
        f'"source-data-sha256":"{html.escape(run_meta.source_data_sha256)}",'
        if run_meta.source_data_sha256 else ""
    )
    reentry_only = bool(
        model.approval_ctx and model.approval_ctx.reentry_command
    )
    export_header = (
        '  <button type="button" data-action="export-user-response">Export user response</button>\n'
        if not reentry_only else ""
    )
    export_footer = (
        f'<footer class="report-footer">\n'
        f'  <button type="button" data-action="export-user-response">Export user response</button>\n'
        f'  <p class="user-response-hint">Export 클릭 시 <code>{sidecar_name}</code> 가 다운로드됩니다 — '
        f'<code>{sidecar_dir}</code> 에 저장하면 resume-clarification 이 자동으로 첨부합니다.</p>\n'
        f'  <pre id="user-response-output" aria-live="polite"></pre>\n'
        f'  <button type="button" data-action="copy-user-response">Copy</button>\n'
        f'  <button type="button" data-action="dismiss-user-response" class="user-response-dismiss" aria-label="출력 닫기" title="닫기" hidden>×</button>\n'
        f'</footer>\n'
        if not reentry_only else ""
    )
    return (
        f"<!DOCTYPE html>\n"
        f"<html lang=\"ko\">\n"
        f"<head>\n"
        f"<meta charset=\"utf-8\">\n"
        f"<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\n"
        f"<title>{title}</title>\n"
        f"<style>{css}</style>\n"
        f"</head>\n"
        f"<body>\n"
        f"<header class=\"report-header\">\n"
        f"  <div>{title}</div>\n"
        f"{export_header}"
        f"</header>\n"
        f"<main>{_reader_dashboard(model.reader_ctx)}{model.body_html}</main>\n"
        f"{_plan_approval_section(model.approval_ctx, run_meta) if model.approval_ctx else ''}"
        f"{_analysis_review_section(model.analysis_review_ctx) if model.analysis_review_ctx else ''}"
        f"{export_footer}"
        f"<script id=\"run-meta\" type=\"application/json\">"
        f"{{\"task-key\":\"{html.escape(run_meta.task_key)}\","
        f"\"task-type\":\"{html.escape(run_meta.task_type)}\","
        f"\"seq\":\"{html.escape(run_meta.seq)}\","
        f"\"source-report\":\"{html.escape(run_meta.source_report)}\","
        f"{source_data_json}"
        f"{source_data_sha256_json}"
        f"\"source-sha256\":\"{model.source_digest}\","
        f"\"response-ids\":{response_ids_json}}}"
        f"</script>\n"
        f"<script>{js}</script>\n"
        f"</body>\n"
        f"</html>\n"
    )


def render_html(
    src_md: str,
    *,
    run_meta: RunMeta,
    css: str,
    js: str,
    approval_ctx: PlanApprovalContext | None = None,
    reader_ctx: ReaderDashboardContext | None = None,
    analysis_review_ctx: AnalysisReviewContext | None = None,
) -> str:
    """Return a single self-contained HTML document for ``src_md``.

    ``css`` / ``js`` are inlined verbatim. No external URLs are written
    into the document (validator enforces this — see
    validate-report-views.py).
    """
    model = build_report_view_model(
        src_md,
        run_meta=run_meta,
        approval_ctx=approval_ctx,
        reader_ctx=reader_ctx,
        analysis_review_ctx=analysis_review_ctx,
    )
    return render_report_view_model(model, css=css, js=js)


def _markdown_to_html(
    src_md: str,
) -> tuple[str, list[tuple[int, str, str]]]:
    """Tiny line-based markdown→HTML emitter. Handles only what the
    final-report template uses: headings, paragraphs, pipe tables,
    fenced code blocks, blockquotes, ordered+unordered lists, inline
    code/bold/links. Anything outside that surface is passed through
    as escaped text inside a paragraph — there are no extension points.

    Returns ``(body_html, toc_headings)`` where ``toc_headings`` is a
    list of ``(level, slug, text)`` for every emitted heading in source
    order. Callers use this to build a navigable table of contents.
    """
    lines = src_md.splitlines()
    out: list[str] = []
    headings: list[tuple[int, str, str]] = []
    i = 0
    n = len(lines)
    current_section_path: list[str] = []  # ['## 1. ...', '### 1.1 ...'] etc.

    while i < n:
        line = lines[i]

        m_heading = _HEADING_PATTERN.match(line)
        if m_heading:
            level = len(m_heading.group(1))
            # The final-report renderer appends an explicit scroll anchor to
            # each heading (`## Verdict Card <a id="verdict-card"></a>`). Honor
            # that id as the slug (keeps markdown ↔ HTML anchors consistent and
            # language-independent) and drop it from the displayed text.
            explicit_id, text = _split_heading_anchor(m_heading.group(2))
            slug = explicit_id or _slugify(text)
            current_section_path = _update_section_path(current_section_path, level, line)
            out.append(f'<h{level} id="{slug}">{_inline(text)}</h{level}>')
            headings.append((level, slug, text))
            i += 1
            continue

        m_code = _CODEFENCE_PATTERN.match(line)
        if m_code:
            indent = m_code.group(1)
            lang = m_code.group(2).strip()
            code_lines: list[str] = []
            i += 1
            while i < n and not _CODEFENCE_PATTERN.match(lines[i]):
                raw = lines[i]
                if indent and raw.startswith(indent):
                    raw = raw[len(indent):]
                code_lines.append(raw)
                i += 1
            if i < n:
                i += 1
            cls = f' class="lang-{html.escape(lang)}"' if lang else ""
            code = html.escape("\n".join(code_lines))
            out.append(f"<pre><code{cls}>{code}</code></pre>")
            continue

        if line.lstrip().startswith("|") and i + 1 < n and _is_separator_row(lines[i + 1]):
            table_html, consumed = _emit_table(lines, i, current_section_path)
            out.append(table_html)
            i += consumed
            continue

        if line.startswith(">"):
            quote_lines: list[str] = []
            while i < n and lines[i].startswith(">"):
                quote_lines.append(lines[i][1:].lstrip())
                i += 1
            out.append("<blockquote>" + _inline(" ".join(quote_lines)) + "</blockquote>")
            continue

        if _LIST_BULLET_PATTERN.match(line) or _LIST_NUMBERED_PATTERN.match(line):
            list_html, consumed = _emit_list(lines, i)
            out.append(list_html)
            i += consumed
            continue

        if _BLANK_PATTERN.match(line):
            i += 1
            continue

        # Plain paragraph — collect until next blank line or block element.
        para_lines: list[str] = []
        while i < n and not _BLANK_PATTERN.match(lines[i]):
            ln = lines[i]
            if (
                _HEADING_PATTERN.match(ln)
                or _CODEFENCE_PATTERN.match(ln)
                or ln.lstrip().startswith("|")
                or ln.startswith(">")
            ):
                break
            para_lines.append(ln)
            i += 1
        if para_lines:
            # The renderer's prose ventilation puts one sentence per source
            # line; join with <br> so those breaks survive into the HTML.
            out.append("<p>" + _inline("<br>".join(para_lines)) + "</p>")

    return "\n".join(out), headings


_HEADING_ANCHOR_RE = re.compile(r'\s*<a id="([^"]+)"></a>\s*$')

# The renderer's top-of-report Index section (`## Index`/`## 목차` carrying
# `<a id="report-index">`, followed by bold-labelled bullet lists). The whole
# section — heading through the bullet lists, up to the next h2 — is replaced
# by the auto-built nav.
_INDEX_BLOCK_RE = re.compile(
    r'<h2 id="report-index">.*?(?=<h2|\Z)', re.DOTALL
)


def _split_heading_anchor(text: str) -> tuple[Optional[str], str]:
    """Split a trailing ``<a id="…"></a>`` off a heading's text. Returns
    ``(explicit_id_or_None, display_text)``."""
    match = _HEADING_ANCHOR_RE.search(text)
    if match:
        return match.group(1), text[: match.start()].rstrip()
    return None, text


def _build_toc(headings: list[tuple[int, str, str]]) -> str:
    """Render a ``<nav class="toc">`` block from collected h2/h3 entries.
    h1 (the report title) is omitted; h4+ are too granular for the TOC."""
    items: list[str] = []
    for level, slug, text in headings:
        if level not in (2, 3):
            continue
        if slug == "report-index":  # skip the source Index/목차 heading itself
            continue
        cls = "toc-h2" if level == 2 else "toc-h3"
        items.append(
            f'<li class="{cls}"><a href="#{slug}">{_inline(text)}</a></li>'
        )
    if not items:
        return ""
    return '<nav class="toc" aria-label="Table of contents">' \
        + '<div class="toc-title">목차</div>' \
        + "<ul>" + "".join(items) + "</ul>" \
        + "</nav>"


def _inject_toc(body_html: str, headings: list[tuple[int, str, str]]) -> str:
    """If the source had an ``## Index`` section followed by a bullet
    list (the template's static TOC), replace it with the auto-built
    nav. Otherwise prepend the nav at the top of the body so readers
    still get a navigation aid. If no h2/h3 headings exist the body is
    returned unchanged.
    """
    nav = _build_toc(headings)
    if not nav:
        return body_html
    if _INDEX_BLOCK_RE.search(body_html):
        return _INDEX_BLOCK_RE.sub(nav, body_html, count=1)
    return nav + "\n" + body_html


def _update_section_path(path: list[str], level: int, line: str) -> list[str]:
    while path and _heading_level(path[-1]) >= level:
        path.pop()
    path.append(line.strip())
    return path


def _heading_level(heading_line: str) -> int:
    m = _HEADING_PATTERN.match(heading_line)
    return len(m.group(1)) if m else 0


def _section_forbids_form(path: Iterable[str]) -> bool:
    return any(any(h.startswith(p) for p in _NO_FORM_SECTION_PREFIXES) for h in path)


def _emit_table(lines: list[str], start: int, section_path: list[str]) -> tuple[str, int]:
    header_cells = _split_pipe_row(lines[start])
    rows: list[list[str]] = []
    # `_split_pipe_row` strips the ID-defining scroll anchor (`<a id="c-001">`)
    # out of every first cell, so the lowercase fragment that the ID-Index and
    # in-body references link to (`[C-001](#c-001)`) would have no landing
    # element. Capture each row's stripped anchor id here and re-attach it to
    # the emitted `<tr>` below so those `href="#…"` links actually jump.
    row_anchor_ids: list[Optional[str]] = []
    i = start + 2  # skip header + separator
    while i < len(lines) and lines[i].lstrip().startswith("|"):
        cells = _split_pipe_row(lines[i])
        # A row with more cells than the header means an unescaped `|`
        # inside cell text (the writer should have used `\|`). Merge the
        # overflow back into the last header column instead of spilling
        # extra <td>s outside the table border.
        if len(cells) > len(header_cells):
            keep = len(header_cells) - 1
            cells = cells[:keep] + ["|".join(cells[keep:])]
        rows.append(cells)
        anchor = _CELL_ANCHOR_RE.search(lines[i])
        row_anchor_ids.append(anchor.group(1).lower() if anchor else None)
        i += 1

    # §1 Clarification Items is the only interactive table. Its short columns
    # are collapsed into one stacked meta cell (`**C-101**<br>Ticket: …<br>
    # Kind: …<br>Blocks: …<br>Status: …`) in the markdown; the HTML view
    # re-attaches a form widget to the `User input` column by re-parsing that
    # meta cell (the SAME parser the approval gate uses).
    is_clarification_table = (
        not _section_forbids_form(section_path)
        and any("Clarification Items" in h for h in section_path)
        and "User input" in header_cells
        and any(h.startswith("Statement") for h in header_cells)
    )

    narrow_cols = _narrow_columns(header_cells, rows)
    path_cols = _path_columns(header_cells)
    # `User input` carries the embedded form widget (textarea / select /
    # input) which needs all the horizontal space it can get; its
    # markdown plain text is empty or a short placeholder so the auto
    # detector would otherwise flag it as narrow. Force it wide.
    if "User input" in header_cells:
        narrow_cols.discard(header_cells.index("User input"))

    head = (
        "<thead><tr>"
        + "".join(
            f"<th{_col_class(idx, narrow_cols, path_cols)}>{_inline(c)}</th>"
            for idx, c in enumerate(header_cells)
        )
        + "</tr></thead>"
    )

    statement_col = next(
        (j for j, h in enumerate(header_cells) if h.startswith("Statement")),
        -1,
    )
    expected_form_col = next(
        (j for j, h in enumerate(header_cells) if h.startswith("Expected form")),
        -1,
    )
    user_input_col = (
        header_cells.index("User input") if "User input" in header_cells else -1
    )
    body_rows: list[str] = []
    for row, anchor_id in zip(rows, row_anchor_ids):
        meta = parse_meta_cell(row[0]) if (is_clarification_table and row) else None
        if (
            meta is not None
            and re.fullmatch(r"C-\d+", meta.row_id)
            and user_input_col >= 0
        ):
            statement = (
                row[statement_col] if 0 <= statement_col < len(row) else ""
            )
            expected_form = (
                row[expected_form_col] if 0 <= expected_form_col < len(row) else ""
            )
            cells_html: list[str] = []
            for idx, cell in enumerate(row):
                if idx == user_input_col:
                    cells_html.append(
                        f"<td>{_form_control(meta.row_id, meta.kind, meta.status, cell, statement, expected_form)}</td>"
                    )
                else:
                    cells_html.append(
                        f"<td{_col_class(idx, narrow_cols, path_cols)}>{_inline(cell)}</td>"
                    )
            body_rows.append(
                f'<tr id="{html.escape(meta.row_id.lower())}" '
                f'data-response-id="{html.escape(meta.row_id)}" '
                f'data-kind="{html.escape(meta.kind)}" '
                f'data-status="{html.escape(meta.status)}">'
                + "".join(cells_html)
                + "</tr>"
            )
        else:
            tr_open = (
                f'<tr id="{html.escape(anchor_id)}">' if anchor_id else "<tr>"
            )
            body_rows.append(
                tr_open
                + "".join(
                    f"<td{_col_class(idx, narrow_cols, path_cols)}>{_inline(c)}</td>"
                    for idx, c in enumerate(row)
                )
                + "</tr>"
            )

    body = "<tbody>" + "".join(body_rows) + "</tbody>"
    return f"<table>{head}{body}</table>", i - start


_ENUM_LETTERS = "abcde"
_ENUM_CUE_WORDS: tuple[str, ...] = (
    "권장은", "권장 ", "추천은", "추천 ", "사유:", "사유 ", "근거:",
)


def _parse_enum_options(statement: str) -> list[tuple[str, str]]:
    """Return ``[(letter, text)]`` pairs if ``statement`` looks like a
    ``(a) X (b) Y (c) Z`` enumeration, else ``[]``. The sequence may start
    at any letter — a statement enum starts at ``(a)``, while the
    ``Alternatives`` cell of the Expected-form contract starts at ``(b)``
    (the recommended answer is ``(a)``) — so parsing keys off the first
    ``(letter)`` marker found and walks the sequence from there. Stops at
    the first recommendation cue (``권장``/``추천``/``사유``/``근거``) so the
    "(c) — recommended because ..." tail doesn't leak into the last
    option's text. Requires ≥ 2 options to trigger select rendering.
    """
    if not statement:
        return []
    first = re.search(r"\(([a-z])\) ", statement)
    if not first:
        return []
    letters = [chr(c) for c in range(ord(first.group(1)), ord("z") + 1)]
    out: list[tuple[str, str]] = []
    cursor = 0
    for i, letter in enumerate(letters):
        anchor = f"({letter}) "
        idx = statement.find(anchor, cursor)
        if idx < 0:
            break
        start = idx + len(anchor)
        end_candidates: list[int] = []
        if i + 1 < len(letters):
            n_idx = statement.find(f"({letters[i + 1]}) ", start)
            if n_idx >= 0:
                end_candidates.append(n_idx)
        for cue in _ENUM_CUE_WORDS:
            c_idx = statement.find(cue, start)
            if c_idx >= 0:
                end_candidates.append(c_idx)
        end = min(end_candidates) if end_candidates else len(statement)
        text = statement[start:end].strip(" .,—-")
        if not text:
            break
        out.append((letter, text))
        cursor = end
    return out if len(out) >= 2 else []


_RECOMMENDED_CUE = "Recommended:"
_ALTERNATIVES_CUE = "Alternatives:"
# Authors sometimes append a pick-one answer-space summary like
# "(A / B 중 택1)" to the Expected form. The rendered <select> already
# enforces single choice, so this annotation must never reach an option
# label — strip a trailing parenthetical that contains "택<n>".
_PICK_ONE_ANNOTATION = re.compile(r"\s*\([^()]*택\s*\d+\s*\)\s*$")


def _lettered_option(letter: str, text: str) -> str:
    return f"({letter}) {text}"


def _strip_leading_letter_label(text: str) -> str:
    """Drop a leading ``(a)`` / ``(b)`` … contract label so the caller can
    re-letter the option exactly once (the contract labels the recommended
    answer `(a)` and alternatives from `(b)`)."""
    return re.sub(r"^\([a-z]\)\s*", "", text)


def parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
    """Parse the ``Expected form`` contract format
    (``Recommended: <answer> — <rationale>; Alternatives: <options>``,
    `_common-contract.md` §Clarification request policy) into select
    ``(value, label)`` options. Returns ``[]`` when the cell carries no
    ``Recommended:`` cue — the caller falls back to the statement enum.

    The single parser for this cell. Both the HTML view and
    ``user_response.show_open_rows`` call it; a second implementation is
    exactly how the two option boards drifted apart once already."""
    if not expected_form:
        return []
    expected_form = _PICK_ONE_ANNOTATION.sub("", expected_form)
    rec_idx = expected_form.find(_RECOMMENDED_CUE)
    if rec_idx < 0:
        return []
    rec_body = expected_form[rec_idx + len(_RECOMMENDED_CUE):]
    alt_body = ""
    alt_idx = rec_body.find(_ALTERNATIVES_CUE)
    if alt_idx >= 0:
        alt_body = rec_body[alt_idx + len(_ALTERNATIVES_CUE):]
        rec_body = rec_body[:alt_idx]
    # The rationale follows " — "; the option keeps only the answer part.
    answer = _strip_leading_letter_label(rec_body.split(" — ", 1)[0].strip(" .,;—-"))
    options: list[tuple[str, str]] = []
    next_letter_idx = 0
    if answer:
        letter = _ENUM_LETTERS[next_letter_idx]
        options.append(("recommended", _lettered_option(letter, answer)))
        next_letter_idx += 1
    enum_alts = _parse_enum_options(alt_body)
    if enum_alts:
        for _original_letter, text in enum_alts:
            if next_letter_idx >= len(_ENUM_LETTERS):
                break
            letter = _ENUM_LETTERS[next_letter_idx]
            options.append((letter, _lettered_option(letter, text)))
            next_letter_idx += 1
    else:
        alt_text = _strip_leading_letter_label(alt_body.strip(" .,;—-"))
        if alt_text and next_letter_idx < len(_ENUM_LETTERS):
            letter = _ENUM_LETTERS[next_letter_idx]
            options.append((letter, _lettered_option(letter, alt_text)))
    return options


_OPTION_LABEL_PREFIX = re.compile(r"^(추천:\s*|\([0-9a-z]\)\s*)")
# 옵션 본문이 이보다 짧으면 prefix 매칭을 건너뛰고 정확 일치만 인정 —
# "혼합" 같은 짧은 라벨이 더 긴 다른 응답의 접두어로 오매칭되는 것을 막는다.
_MIN_OPTION_PREFIX_LEN = 6


def _match_option_value(
    current_value: str, opts: list[tuple[str, str]]
) -> str | None:
    """Re-select the option a user previously picked when a carried-in
    ``User input`` cell is rendered read-only. Compares ``current_value``
    against each option's label body (``추천:`` / ``(a)`` prefix stripped).
    Exact match wins over a prefix match; returns ``None`` when nothing
    matches so the caller falls back to the ``기타`` branch."""
    cv = (current_value or "").strip()
    if not cv:
        return None
    normalized_current = _OPTION_LABEL_PREFIX.sub("", cv).strip()
    bodies = [
        (value, _OPTION_LABEL_PREFIX.sub("", label).strip())
        for value, label in opts
    ]
    for value, body in bodies:
        if body and (normalized_current == body or cv == body or cv == value):
            return value
    for value, body in bodies:
        if (
            len(body) >= _MIN_OPTION_PREFIX_LEN
            and (normalized_current.startswith(body) or cv.startswith(body))
        ):
            return value
    return None


def _form_control(
    response_id: str,
    kind: str,
    status: str,
    current_value: str,
    statement: str = "",
    expected_form: str = "",
) -> str:
    rid = html.escape(response_id)
    disabled = "" if status.lower() in ("open", "answered", "") else " disabled"
    safe_value = html.escape(current_value or "")
    kind_lc = kind.lower()
    placeholder = {
        "material": "파일 경로 또는 본문",
        "decision": "선택 또는 짧은 응답",
        "data-point": "값",
    }.get(kind_lc, "응답")

    # decision 의 후보는 Expected form 의 `Recommended: …; Alternatives: …`
    # 계약(_common-contract.md §Clarification request policy)이 1순위,
    # statement 안 (a)(b)(c) 열거가 fallback. 후보가 있으면 select+기타 input.
    if kind_lc == "decision":
        opts = parse_expected_form_options(expected_form)
        if not opts:
            opts = [
                (letter, f"({letter}) {text}")
                for letter, text in _parse_enum_options(statement)
            ]
        if opts:
            matched = _match_option_value(current_value, opts)
            use_other = matched is None and bool((current_value or "").strip())
            select_opts = "".join(
                f'<option value="{html.escape(value)}"'
                f'{" selected" if value == matched else ""}>'
                f"{_inline(label)}</option>"
                for value, label in opts
            )
            other_attr = " selected" if use_other else ""
            other_hidden = "" if use_other else " hidden"
            select_html = (
                f'<select name="{rid}" data-response-id="{rid}" '
                f'data-kind="{html.escape(kind)}"{disabled}>'
                '<option value="">(선택)</option>'
                f"{select_opts}"
                f'<option value="__other__"{other_attr}>기타 (직접 입력)</option>'
                "</select>"
            )
            other_html = (
                f' <textarea data-other-for="{rid}" '
                f'placeholder="기타 응답" rows="2"{disabled}{other_hidden}>'
                f"{safe_value}</textarea>"
            )
            return select_html + other_html

    # 나머지 kind(material/data-point/decision-without-enum)는 모두 자유 입력.
    # 한 줄 넘는 응답을 편하게 적도록 고정 높이 input 대신 textarea 로 렌더한다.
    return (
        f'<textarea name="{rid}" data-response-id="{rid}" '
        f'data-kind="{html.escape(kind)}" rows="2" '
        f'placeholder="{html.escape(placeholder)}"{disabled}>'
        f"{safe_value}</textarea>"
    )


def _emit_list(lines: list[str], start: int) -> tuple[str, int]:
    i = start
    first = lines[start]
    is_numbered = bool(_LIST_NUMBERED_PATTERN.match(first))
    tag = "ol" if is_numbered else "ul"
    items: list[str] = []
    while i < len(lines):
        ln = lines[i]
        m_b = _LIST_BULLET_PATTERN.match(ln) if not is_numbered else None
        m_n = _LIST_NUMBERED_PATTERN.match(ln) if is_numbered else None
        if not (m_b or m_n):
            if _BLANK_PATTERN.match(ln):
                # Blank inside a list — peek; if next line continues the
                # list, swallow the blank, else end.
                if i + 1 < len(lines) and (
                    _LIST_BULLET_PATTERN.match(lines[i + 1])
                    or _LIST_NUMBERED_PATTERN.match(lines[i + 1])
                ):
                    i += 1
                    continue
            break
        text = m_b.group(2) if m_b else m_n.group(3)
        items.append(f"<li>{_inline(text)}</li>")
        i += 1
    return f"<{tag}>" + "".join(items) + f"</{tag}>", i - start


_NARROW_COL_MAX_PLAIN_LEN = 30

_INLINE_MD_STRIP_RE = re.compile(r"[`*_]")

# Header-name whitelist: columns whose width is effectively fixed by
# convention even when one row carries a long body cell (e.g. "출처"
# starts with a short token but the prose body of a single citation
# row may run 60+ chars). Matched as case-insensitive prefix on the
# header's first plain token, so "Ticket ID" / "출처 (brief/source/worker)"
# / "환산 토큰 (input 기준)" all hit the same entry. Keep this list
# tight — every entry is a promise that the column is *always* one of
# a handful of canonical short values across every final-report.
_NARROW_HEADER_PREFIXES: tuple[str, ...] = (
    # English / latin
    "id",
    "ticket id",
    "kind",
    "status",
    "blocks",
    "origin",
    "priority",
    "auto-spawn",
    # merged record-meta column (columns.recordMeta) — "Record" in en, "항목" in ko
    "record",
    # Korean
    "출처",
    "에이전트",
    "역할",
    "모델",
    "상태",
    "항목",
    "처리 토큰",
    "환산 토큰",
    "비용",
)


def _plain_len(raw_cell: str) -> int:
    return len(_INLINE_MD_STRIP_RE.sub("", raw_cell or "").strip())


# Under report.css `td.td-narrow { white-space: nowrap }` a cell's longest
# <br>-separated line dictates the column's intrinsic width. Lines past half
# of `main`'s 120ch budget would crush or push the prose neighbours off-screen,
# so such a column must not be pinned narrow even when its header is
# whitelisted.
_NARROW_WHITELIST_MAX_LINE_LEN = 60
_BR_TAG_RE = re.compile(r"<br\s*/?\s*>", re.IGNORECASE)


def _max_plain_line_len(raw_cell: str) -> int:
    return max(
        (_plain_len(segment) for segment in _BR_TAG_RE.split(raw_cell or "")),
        default=0,
    )


def _matches_narrow_whitelist(header: str) -> bool:
    plain = _INLINE_MD_STRIP_RE.sub("", header or "").strip().lower()
    return any(plain.startswith(p) for p in _NARROW_HEADER_PREFIXES)


def _narrow_columns(
    header_cells: list[str], rows: list[list[str]]
) -> set[int]:
    """Return the set of column indices that should render with the
    fixed-narrow ``td-narrow`` width (5% under report.css).

    A column qualifies when EITHER:
      (a) its header matches `_NARROW_HEADER_PREFIXES` — these are
          ``ID`` / ``출처`` / ``에이전트`` etc. whose values are
          conventionally short (or <br>-stacked into short lines) across
          every final-report — UNLESS some cell carries a single line
          longer than ``_NARROW_WHITELIST_MAX_LINE_LEN``: nowrap would
          let that line dictate the column width and starve the prose
          neighbours; OR
      (b) every cell in the column (header + body) fits within
          ``_NARROW_COL_MAX_PLAIN_LEN`` plain chars — this catches
          ad-hoc short columns that the whitelist did not anticipate.

    Per-column (not per-cell) so the verdict applies uniformly across
    the column even when one neighbouring column holds long-form text.
    """
    narrow: set[int] = set()
    for col, header in enumerate(header_cells):
        column_cells = [header] + [row[col] for row in rows if col < len(row)]
        if _matches_narrow_whitelist(header):
            longest_line = max(_max_plain_line_len(c) for c in column_cells)
            if longest_line <= _NARROW_WHITELIST_MAX_LINE_LEN:
                narrow.add(col)
            continue
        if max(_plain_len(c) for c in column_cells) <= _NARROW_COL_MAX_PLAIN_LEN:
            narrow.add(col)
    return narrow


def _col_class(col_idx: int, narrow_cols: set[int], path_cols: set[int]) -> str:
    if col_idx in narrow_cols:
        return ' class="td-narrow"'
    if col_idx in path_cols:
        return ' class="td-path"'
    return ""


# A repo-relative path has no spaces, so `overflow-wrap: anywhere` puts its
# min-content width at one character and the auto table layout hands the
# column almost nothing while a prose neighbour takes the rest — the path
# then renders one or two characters per line. `td-path` floors the width.
_PATH_HEADER_PREFIXES: tuple[str, ...] = ("path", "파일 경로", "경로")


def _path_columns(header_cells: list[str]) -> set[int]:
    return {
        col
        for col, header in enumerate(header_cells)
        if _INLINE_MD_STRIP_RE.sub("", header or "")
        .strip()
        .lower()
        .startswith(_PATH_HEADER_PREFIXES)
    }


_DANGEROUS_URL_SCHEME_RE = re.compile(
    r"^(?:javascript|data|vbscript):", re.IGNORECASE
)
_URL_CONTROL_CHARS_RE = re.compile(r"[\x00-\x20]")


def _safe_href(url: str) -> str:
    """Neutralise a markdown link target so it cannot become a script
    vector in the offline HTML report.

    The self-contained report is opened locally from disk, so a
    ``javascript:``/``data:``/``vbscript:`` href in any worker-authored
    cell would execute attacker JS on click. ``html.escape`` in
    ``_inline`` only neutralises quotes, not the scheme, so strip such
    schemes here. Browsers strip ASCII whitespace/control chars from an
    href before resolving the scheme (so ``java\\tscript:`` executes); strip
    that same class before matching to close the bypass. Relative paths,
    fragments, ``http(s):`` and ``mailto:`` pass through unchanged."""
    stripped = _URL_CONTROL_CHARS_RE.sub("", url or "")
    return "#" if _DANGEROUS_URL_SCHEME_RE.match(stripped) else url


def _inline(text: str) -> str:
    out = html.escape(text)
    # Restore inline markdown after escaping (we re-process the escaped
    # forms — backticks/asterisks/brackets survive html.escape).
    out = _INLINE_CODE_PATTERN.sub(lambda m: f"<code>{m.group(1)}</code>", out)
    out = _BOLD_PATTERN.sub(lambda m: f"<strong>{m.group(1)}</strong>", out)
    out = _LINK_PATTERN.sub(
        lambda m: f'<a href="{_safe_href(m.group(2))}">{m.group(1)}</a>', out
    )
    # Preserve explicit <br> line breaks used inside compact meta cells (the
    # markdown source intentionally stacks short fields with <br>). html.escape
    # above turned them into &lt;br&gt;; restore the tag.
    out = out.replace("&lt;br&gt;", "<br>").replace("&lt;br/&gt;", "<br>").replace("&lt;br /&gt;", "<br>")
    # `<small>` demotes a cell's technical detail line below its plain-language
    # first line (File Structure `details`). Allowlisted alongside <br>; every
    # other tag stays escaped.
    out = out.replace("&lt;small&gt;", "<small>").replace("&lt;/small&gt;", "</small>")
    return out


def _slugify(text: str) -> str:
    s = text.strip().lower()
    s = re.sub(r"[^a-z0-9ㄱ-힝\-\.\s]", "", s)
    s = re.sub(r"\s+", "-", s)
    return s or "section"


# --------------------------------------------------------------------------- #
# User-response sidecar serialiser (reference for the validator;
# templates/reports/report.js implements the byte-identical client side).
# --------------------------------------------------------------------------- #

@dataclass(frozen=True)
class UserResponseEntry:
    response_id: str
    kind: str
    value: str
    rationale: Optional[str] = None
    disposition: str = "answer"


@dataclass(frozen=True)
class UserPlanDecision:
    """HTML 계획 결정 위젯의 Export 결과.

    ``status`` 가 승인이 아니면 ``reason`` 이 필수다 — 사유 없는 반려는 다음
    run 이 무엇을 고쳐야 하는지 알 수 없어 되돌아올 수밖에 없다.
    ``implementation_option`` 이 빈 문자열이면 라인을 생략한다 (소비 측은
    Recommended Option 폴백)."""
    status: str
    implementation_option: str = ""
    reason: str = ""


@dataclass(frozen=True)
class UserDirectionSelection:
    option_id: str
    option_name: str
    confirmed: bool
    selection_note: str = ""
    constraints: str = ""


@dataclass(frozen=True)
class UserResponseAnalysisReview:
    status: str
    affected_ids: tuple[str, ...] = ()
    reason: str = ""
    additional_evidence: str = ""
    requested_scope_change: str = ""


_ANALYSIS_REVIEW_STATUSES = frozenset({
    "accepted",
    "revision-requested",
    "rejected",
})

PLAN_DECISION_APPROVED = "approved"
@dataclass(frozen=True)
class UserReportAuthoring:
    """사용자가 리드의 최종 리포트 직접 저작을 허가했는지에 대한 답.

    승인이어도 ``reason`` 이 필수다 — 리포트 헤더가 이 사유를 그대로 실어
    나중에 읽는 사람이 이 run 이 왜 report-writer 경로를 벗어났는지 본다."""
    status: str
    reason: str = ""


REPORT_AUTHORING_APPROVED = "approved"
_REPORT_AUTHORING_STATUSES = frozenset({REPORT_AUTHORING_APPROVED, "denied"})

_PLAN_DECISION_STATUSES = frozenset({
    PLAN_DECISION_APPROVED,
    "revision-requested",
    "rejected",
})

_DIRECTION_IDENTITY_WHITESPACE = frozenset(
    "\u0009\u000a\u000b\u000c\u000d"
    "\u001c\u001d\u001e\u001f\u0020\u0085\u00a0\u1680"
    "\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a"
    "\u2028\u2029\u202f\u205f\u3000\ufeff"
)
_DIRECTION_IDENTITY_ERROR = (
    "DIRECTION SELECTION Option-ID and Option-Name must be "
    "non-empty single-line values"
)


def _trim_direction_identity(value: str) -> str:
    start = 0
    end = len(value)
    while start < end and value[start] in _DIRECTION_IDENTITY_WHITESPACE:
        start += 1
    while end > start and value[end - 1] in _DIRECTION_IDENTITY_WHITESPACE:
        end -= 1
    return value[start:end]


def normalize_direction_selection_identity(
    option_id: str, option_name: str,
) -> tuple[str, str]:
    if (
        "\r" in option_id
        or "\n" in option_id
        or "\r" in option_name
        or "\n" in option_name
    ):
        raise ValueError(_DIRECTION_IDENTITY_ERROR)
    normalized_id = _trim_direction_identity(option_id)
    normalized_name = _trim_direction_identity(option_name)
    if not normalized_id or not normalized_name:
        raise ValueError(_DIRECTION_IDENTITY_ERROR)
    return normalized_id, normalized_name


def _quoted_sidecar_field(label: str, value: str) -> str:
    cleaned = value.strip()
    if not cleaned:
        return f"- {label}:\n"
    quoted = "".join(f"  > {line}\n" for line in cleaned.split("\n"))
    return f"- {label}:\n{quoted}"


def _serialize_analysis_review(review: UserResponseAnalysisReview) -> str:
    if review.status not in _ANALYSIS_REVIEW_STATUSES:
        raise ValueError(f"invalid ANALYSIS REVIEW status: {review.status}")
    if review.status in {"revision-requested", "rejected"} and (
        not review.affected_ids or not review.reason.strip()
    ):
        raise ValueError(
            f"ANALYSIS REVIEW {review.status} requires Affected-IDs and Reason"
        )
    return (
        "\n## ANALYSIS REVIEW\n"
        f"- Status: {review.status}\n"
        f"- Affected-IDs: {', '.join(review.affected_ids)}\n"
        f"{_quoted_sidecar_field('Reason', review.reason)}"
        f"{_quoted_sidecar_field('Additional-Evidence', review.additional_evidence)}"
        f"{_quoted_sidecar_field('Requested-Scope-Change', review.requested_scope_change)}"
    )


def _serialize_plan_decision(decision: UserPlanDecision) -> str:
    if decision.status not in _PLAN_DECISION_STATUSES:
        raise ValueError(f"invalid PLAN DECISION status: {decision.status}")
    if decision.status != PLAN_DECISION_APPROVED and not decision.reason.strip():
        raise ValueError(f"PLAN DECISION {decision.status} requires a Reason")
    chunk = f"\n## PLAN DECISION\n- Status: {decision.status}\n"
    if decision.implementation_option:
        chunk += f"- Implementation-Option: {decision.implementation_option.strip()}\n"
    if decision.reason.strip():
        chunk += _quoted_sidecar_field("Reason", decision.reason)
    return chunk


def _serialize_report_authoring(decision: UserReportAuthoring) -> str:
    """The user's answer on letting the lead author the final report itself.

    Same shape as PLAN DECISION because it is the same kind of fact: a decision
    only the user may make, written into the sidecar the user owns. A reason is
    required even on approval — the report carries it forward, so a later reader
    sees why this run left the report-writer path.
    """
    if decision.status not in _REPORT_AUTHORING_STATUSES:
        raise ValueError(f"invalid REPORT AUTHORING status: {decision.status}")
    if not decision.reason.strip():
        raise ValueError("REPORT AUTHORING requires a Reason")
    return (
        "\n## REPORT AUTHORING\n"
        f"- Status: {decision.status}\n"
        f"{_quoted_sidecar_field('Reason', decision.reason)}"
    )


def _serialize_direction_selection(selection: UserDirectionSelection) -> str:
    option_id, option_name = normalize_direction_selection_identity(
        selection.option_id, selection.option_name
    )
    if not selection.confirmed:
        raise ValueError("DIRECTION SELECTION requires Confirmed: true")
    return (
        "\n## DIRECTION SELECTION\n"
        "- Status: selected\n"
        f"- Option-ID: {option_id}\n"
        f"- Option-Name: {option_name}\n"
        "- Confirmed: true\n"
        f"{_quoted_sidecar_field('Selection-Note', selection.selection_note)}"
        f"{_quoted_sidecar_field('Constraints', selection.constraints)}"
    )


def serialize_user_response(
    *,
    run_meta: RunMeta,
    entries: list[UserResponseEntry],
    created_at: str,
    plan_decision: UserPlanDecision | None = None,
    analysis_review: UserResponseAnalysisReview | None = None,
    direction_selection: UserDirectionSelection | None = None,
    report_authoring: UserReportAuthoring | None = None,
) -> str:
    """Return the canonical markdown text the HTML 'Export user
    response' button must produce. Used by validators to confirm that
    pasted sidecar files conform to the schema and that the JS
    serialiser stayed in sync.
    """
    source_data_lines = ""
    if run_meta.source_data:
        source_data_lines += f"source-data: {run_meta.source_data}\n"
    if run_meta.source_data_sha256:
        source_data_lines += (
            f"source-data-sha256: {run_meta.source_data_sha256}\n"
        )
    head = (
        "---\n"
        f"task-key: {run_meta.task_key}\n"
        f"task-type: {run_meta.task_type}\n"
        f"seq: {run_meta.seq}\n"
        f"source-report: {run_meta.source_report}\n"
        f"{source_data_lines}"
        "created-by: user\n"
        f"created-at: {created_at}\n"
        "---\n"
        "\n"
        "# User Response\n"
    )
    has_plan_decision = plan_decision is not None
    has_analysis_review = analysis_review is not None
    has_direction_selection = direction_selection is not None
    body_chunks: list[str] = []
    for e in entries:
        chunk = f"\n## {e.response_id}\n- Kind: {e.kind}\n"
        if e.disposition != "answer":
            chunk += f"- Disposition: {e.disposition}\n"
        value_lines = e.value.strip().split("\n")
        chunk += "- Value:\n" + "".join(f"  > {ln}\n" for ln in value_lines)
        if e.rationale:
            chunk += f"- Rationale: {e.rationale.strip()}\n"
        body_chunks.append(chunk)
    if (
        not entries
        and not has_plan_decision
        and not has_analysis_review
        and not has_direction_selection
    ):
        body_chunks.append("\n_(No user responses recorded.)_\n")
    if plan_decision is not None:
        body_chunks.append(_serialize_plan_decision(plan_decision))
    if analysis_review is not None:
        body_chunks.append(_serialize_analysis_review(analysis_review))
    if direction_selection is not None:
        body_chunks.append(_serialize_direction_selection(direction_selection))
    if report_authoring is not None:
        body_chunks.append(_serialize_report_authoring(report_authoring))
    return head + "".join(body_chunks)


# --------------------------------------------------------------------------- #
# Convenience entrypoint for tests + CLI.
# --------------------------------------------------------------------------- #

def report_has_clarification_items(src_md: str) -> bool:
    """True when the final-report MD has at least one §1``C-*``
    clarification row. This is the single predicate that gates HTML-view
    generation: the self-contained html's only value over the markdown is
    the embedded ``<form>`` widgets for those rows, so a clarification-free
    report does not get an html sibling. The renderer, the CLI, and
    ``validators/validate-report-views.py`` all key off this same function
    so generation and validation never disagree."""
    return any(
        re.fullmatch(r"C-\d+", item.row_id)
        for item in (parse_clarification_items(src_md) or [])
    )


PlanApprovalContext = PlanApprovalState


def _load_report_data(src_md_path: Path) -> dict | None:
    data_path = final_report_data_path(src_md_path)
    if not data_path.is_file():
        return None
    try:
        data = load_owned_object(data_path, artifact="final report record")
    except JsonBoundaryError:
        return None
    return data if isinstance(data, dict) else None


_ANALYSIS_DATA_KEYS = (
    "analysisCommon",
    "projectAnalysis",
    "featureAnalysis",
    "changeImpactAnalysis",
)
_STRUCTURED_ANALYSIS_ID_RE = re.compile(r"^[A-Z]{2}-\d{3}$")


def _collect_structured_analysis_ids(value: object, found: set[str]) -> None:
    if isinstance(value, dict):
        candidate = value.get("id")
        if isinstance(candidate, str) and _STRUCTURED_ANALYSIS_ID_RE.fullmatch(
            candidate
        ):
            found.add(candidate)
        for child in value.values():
            _collect_structured_analysis_ids(child, found)
    elif isinstance(value, list):
        for child in value:
            _collect_structured_analysis_ids(child, found)


def analysis_review_context(src_md_path: Path) -> AnalysisReviewContext | None:
    data = _load_report_data(src_md_path)
    if data is None or not isinstance(data.get("analysisCommon"), dict):
        return None
    found: set[str] = set()
    for key in _ANALYSIS_DATA_KEYS:
        _collect_structured_analysis_ids(data.get(key), found)
    return AnalysisReviewContext(selector_ids=tuple(sorted(found)))


def plan_approval_context(src_md_path: Path) -> PlanApprovalContext | None:
    """implementation-planning 보고서 + sibling data.json 의 optionCandidates 가
    있을 때만 컨텍스트를 만든다. planning 여부는 task-type 문자열이 아니라
    data.json 의 ``implementationPlanning`` 키(SSOT)로 판정한다 — renderer 와
    validator 가 같은 판정을 공유한다. recommendedOption 이 비거나 후보에 없으면
    첫 후보로 폴백한다 — 항상 정확히 한 옵션이 selected 가 되어 브라우저 자동선택분의
    묵시 Export 를 차단한다."""
    data = _load_report_data(src_md_path)
    if data is None:
        return None
    state = plan_approval_state(data)
    if state is None:
        return None
    scan = scan_approval_gate(src_md_path)
    blocker_ids = state.blocker_ids
    reason = state.disabled_reason
    if scan.unreadable_reason:
        blocker_ids = ()
        reason = "§1 승인 게이트를 읽을 수 없어 승인이 비활성화되었습니다 — 보고서를 재렌더하세요."
    elif scan.blockers:
        blocker_ids = tuple(b.row_id for b in scan.blockers)
        reason = f"§1 승인 차단 항목 {len(scan.blockers)}건 미해소"
    return PlanApprovalContext(
        option_names=state.option_names,
        recommended_option=state.recommended_option,
        disabled_reason=reason,
        blocker_ids=blocker_ids,
        show_option_selector=state.show_option_selector,
        reentry_command=state.reentry_command,
    )


def reader_dashboard_context(
    src_md_path: Path,
    src_text: str,
    approval_ctx: PlanApprovalContext | None,
) -> ReaderDashboardContext | None:
    data = _load_report_data(src_md_path)
    if data is None:
        return None
    reader = data.get("readerSummary")
    if not isinstance(reader, dict):
        reader = {}
    verdict = data.get("verdictCard")
    if not isinstance(verdict, dict):
        verdict = {}
    clarifications = parse_clarification_items(src_text) or []
    open_count = sum(
        1 for item in clarifications
        if re.fullmatch(r"C-\d+", item.row_id) and item.status in UNRESOLVED_STATUSES
    )
    recommended_option = ""
    planning = data.get("implementationPlanning")
    if isinstance(planning, dict):
        rec = planning.get("recommendedOption")
        recommended_option = rec.get("name") or "" if isinstance(rec, dict) else ""
        if approval_ctx is not None:
            recommended_option = approval_ctx.recommended_option
    return ReaderDashboardContext(
        decision=_reader_text(reader.get("decision")) or _reader_text(verdict.get("finalConclusion")),
        human_action_required=(
            _reader_text(reader.get("humanActionRequired"))
            or _reader_text(verdict.get("nextStep"))
            or "Review the report sections below."
        ),
        blocking_items=(
            _reader_text(reader.get("blockingItems"))
            or _fallback_blocking_items(open_count, approval_ctx)
        ),
        safe_to_skip=(
            _reader_text(reader.get("safeToSkip"))
            or "Audit sections can wait until a deeper review."
        ),
        recommended_command=(
            _reader_text(reader.get("recommendedCommand"))
            or _reader_text(verdict.get("nextStep"))
        ),
        open_clarifications=open_count,
        approval=_approval_label(data, approval_ctx),
        recommended_option=recommended_option,
    )


def _reader_text(value: object) -> str:
    return value.strip() if isinstance(value, str) else ""


def _fallback_blocking_items(
    open_count: int, approval_ctx: PlanApprovalContext | None
) -> str:
    if approval_ctx is not None and approval_ctx.blocker_ids:
        return ", ".join(approval_ctx.blocker_ids)
    if open_count:
        return f"{open_count} open clarification item(s)."
    return "No open clarification items."


def _approval_label(data: dict, approval_ctx: PlanApprovalContext | None) -> str:
    frontmatter = data.get("frontmatter")
    approved = frontmatter.get("approved") if isinstance(frontmatter, dict) else False
    if approved is True:
        return "approved"
    if approval_ctx is not None:
        return "blocked" if approval_ctx.disabled_reason else "ready"
    verdict = data.get("verdictCard")
    required = verdict.get("approvalRequired") if isinstance(verdict, dict) else False
    return "required" if required else "not required"


def _resolve_recommended_option(rec_name: str, names: tuple[str, ...]) -> str:
    """Compatibility wrapper for the shared v2 approval-state resolver."""
    return resolve_recommended_option(rec_name, names)


def _approval_blocked_guidance(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
    """disabled 사유를 화면에서 바로 따라할 수 있는 단계별 안내로 렌더한다.
    핵심: 이 화면에서 답을 입력하는 것만으로는 승인이 풀리지 않으며,
    Export → 저장 → resume-clarification → 재렌더 라운드트립이 필요하다는 것."""
    if ctx.reentry_command:
        command = (
            f"/okstra-run task-key={run_meta.task_key} "
            "task-type=implementation-option-selection"
        )
        return (
            '<p class="approval-disabled-reason">선택 방향이 무효화되어 이 계획은 '
            "승인할 수 없습니다. 구현 방향 선택 단계에 다시 진입하세요: "
            f"<code>{html.escape(command)}</code></p>"
        )
    if not ctx.blocker_ids:  # unreadable 사유 — 재렌더 외에 따라할 단계가 없다.
        return f'<p class="approval-disabled-reason">{html.escape(ctx.disabled_reason)}</p>'
    sidecar_dir = f"runs/{run_meta.task_type}/user-responses/"
    ids = ", ".join(ctx.blocker_ids)
    steps = [
        f"위 <strong>§1 Clarification Items</strong> 표에서 차단 항목 "
        f"<strong>{html.escape(ids)}</strong> 의 'User input' 칸에 답을 입력합니다.",
        "헤더 또는 맨 아래의 <strong>[Export user response]</strong> 버튼을 눌러 응답 파일을 내려받습니다.",
        f"내려받은 파일을 <code>{html.escape(sidecar_dir)}</code> 에 그대로 저장합니다.",
        f"터미널에서 <code>scripts/okstra.sh --resume-clarification --task-key "
        f"{html.escape(run_meta.task_key)}</code> (Claude Code 에서는 "
        f"<code>/okstra-run resume-clarification task-key={html.escape(run_meta.task_key)}</code>) "
        "을 실행합니다.",
        "명령이 끝나면 <strong>새로 생성된 보고서</strong>를 여세요 — "
        "그 보고서에서 이 체크박스가 활성화됩니다.",
    ]
    lis = "".join(f"<li>{s}</li>" for s in steps)
    return (
        '<div class="approval-disabled-reason">'
        f"<p><strong>{html.escape(ctx.disabled_reason)}</strong>라 아직 승인할 수 없습니다. "
        "이 화면에서 답만 입력해서는 풀리지 않습니다 — 아래 순서로 해소하세요:</p>"
        f'<ol class="approval-steps">{lis}</ol>'
        "</div>"
    )


def _plan_approval_section(ctx: PlanApprovalContext, run_meta: RunMeta) -> str:
    if ctx.reentry_command:
        return (
            '<section id="plan-approval">\n'
            "  <h2>선택 방향 무효화</h2>\n"
            f"  {_approval_blocked_guidance(ctx, run_meta)}\n"
            "</section>\n"
        )
    disabled = " disabled" if ctx.disabled_reason else ""
    opts: list[str] = []
    for name in ctx.option_names:
        is_rec = name == ctx.recommended_option
        label = f"{name} (권장)" if is_rec else name
        attrs = ' data-recommended="true" selected' if is_rec else ""
        opts.append(f'<option value="{html.escape(name)}"{attrs}>{html.escape(label)}</option>')
    reason_html = (
        "\n  " + _approval_blocked_guidance(ctx, run_meta) if ctx.disabled_reason else ""
    )
    return (
        '<section id="plan-approval">\n'
        "  <h2>Plan Decision</h2>\n"
        f'  <label>구현 옵션: <select id="approval-option"{disabled}>{"".join(opts)}</select></label>\n'
        '  <fieldset id="plan-decision"><legend>판정</legend>'
        f'<label><input type="radio" name="plan-decision-status" value="approved"{disabled}> '
        "이 plan 을 승인합니다</label>"
        '<label><input type="radio" name="plan-decision-status" '
        'value="revision-requested"> 고쳐서 다시 가져오게 합니다</label>'
        '<label><input type="radio" name="plan-decision-status" value="rejected"> '
        "이 plan 을 반려합니다</label></fieldset>\n"
        '  <label>사유 — 반려하거나 다시 고치게 할 때는 반드시 적어야 합니다'
        '<textarea id="plan-decision-reason" rows="4"></textarea></label>'
        f"{reason_html}\n"
        "</section>\n"
    )


def _analysis_review_section(ctx: AnalysisReviewContext) -> str:
    options = "".join(
        f'<option value="{html.escape(analysis_id)}">'
        f"{html.escape(analysis_id)}</option>"
        for analysis_id in ctx.selector_ids
    )
    return (
        '<section id="analysis-review">\n'
        "  <h2>Analysis Review</h2>\n"
        '  <fieldset><legend>Decision</legend>\n'
        '    <label><input type="radio" name="analysis-review-status" '
        'value="accepted">Accept</label>\n'
        '    <label><input type="radio" name="analysis-review-status" '
        'value="revision-requested">Request revision</label>\n'
        '    <label><input type="radio" name="analysis-review-status" '
        'value="rejected">Reject</label>\n'
        "  </fieldset>\n"
        '  <label>Affected IDs <select id="analysis-review-affected-ids" '
        f'multiple>{options}</select></label>\n'
        '  <label>Reason <textarea id="analysis-review-reason" rows="3">'
        "</textarea></label>\n"
        '  <label>Additional evidence <textarea id="analysis-review-evidence" '
        'rows="3"></textarea></label>\n'
        '  <label>Requested scope change <textarea '
        'id="analysis-review-scope-change" rows="3"></textarea></label>\n'
        "</section>\n"
    )


def render_html_view(
    src_md_path: Path,
    *,
    run_meta: RunMeta,
    css: str,
    js: str,
) -> Path | None:
    """Write the HTML view artifact for ``src_md_path`` and return its path,
    or return ``None`` when the report needs no interactive view — §1
    clarification rows 도 없고 Plan Approval 위젯 대상도 아닐 때
    (``report_has_clarification_items`` / ``plan_approval_context``).
    Idempotent — overwrites an existing html sibling, and removes a stale
    one when the report no longer needs a view."""
    src_text = src_md_path.read_text(encoding="utf-8")
    html_path = html_view_path(src_md_path)
    # Fail-closed on §1 heading drift: when a `## 1. Clarification Items`
    # heading exists but its strict form does not parse,
    # `report_has_clarification_items` returns False (lenient parse), so a
    # report with real C-rows would silently get no interactive view and the
    # user would lose the only way to answer. `validate-report-views.py`
    # already refuses this case; refuse loudly here too instead of skipping.
    if section_1_present_but_unparsed(src_text):
        raise ValueError(
            "final-report has a `## 1. Clarification Items` heading but its "
            "strict format does not parse (heading/anchor/format drift) — "
            "re-render the report so §1 matches the schema before generating "
            "the HTML view."
        )
    approval_ctx = plan_approval_context(src_md_path)
    analysis_review_ctx = analysis_review_context(src_md_path)
    reader_ctx = reader_dashboard_context(src_md_path, src_text, approval_ctx)
    has_clarifications = report_has_clarification_items(src_text)
    if (
        not has_clarifications
        and approval_ctx is None
        and reader_ctx is None
        and analysis_review_ctx is None
    ):
        if html_path.is_file():
            html_path.unlink()
        return None
    html_text = render_html(
        src_text,
        run_meta=run_meta,
        css=css,
        js=js,
        approval_ctx=approval_ctx,
        reader_ctx=reader_ctx,
        analysis_review_ctx=analysis_review_ctx,
    )
    html_path.write_text(html_text, encoding="utf-8")
    if (
        has_clarifications
        or approval_ctx is not None
        or analysis_review_ctx is not None
    ):
        # 사용자 확인(폼/승인)이 필요한 보고서 — Export 파일의 저장 위치를 미리
        # 만들어 사용자가 디렉토리를 만들 필요가 없게 한다 (reports/ 의 sibling).
        user_responses_dir_for_report(src_md_path).mkdir(parents=True, exist_ok=True)
    return html_path
