#!/usr/bin/env python3
"""Validate the self-contained HTML view produced by Phase 7 step 1.5
(``scripts/okstra-render-report-views.py``).

Takes the report's Markdown path as its handle and branches on schema.

A schema-v2 report is checked against its ``.data.json`` — the record the
HTML was rendered from — and never against the Markdown sibling, which is a
second rendering of that same record:
  1. the ``*.html`` sibling exists;
  2. the document carries the task type's own template;
  3. every human field the task type requires is present, and no audit field
     leaked into the human main;
  4. each visualization's svg node ids match its fallback ids;
  5. the Response IDs in the HTML match ``clarificationItems[]`` 1:1;
  6. no external URL appears in ``<script src=>`` / ``<link href=>`` /
     ``<img src=>`` — the self-contained guarantee.

A schema-v1 report keeps the Markdown as its source, so its checks read the
§1 Clarification Items table (fail-closed when the heading is present but the
table will not parse), require the HTML sibling only when the report carries
clarification rows or an analysis-review / plan-approval contract, forbid form
controls in the §5.6 / §5.7 / §5.8 deliverable regions, and enforce the same
Response-ID parity and self-contained rules.

Exit codes: 0 on success, 1 on any failure. Failures are printed one
per line to stderr.
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

_VALIDATORS_DIR = Path(__file__).resolve().parent
for _ssot_dir in (_VALIDATORS_DIR.parent / "scripts", _VALIDATORS_DIR.parent / "python"):
    if _ssot_dir.is_dir() and str(_ssot_dir) not in sys.path:
        sys.path.insert(0, str(_ssot_dir))

from okstra_ctl.clarification_items import (  # noqa: E402
    STRUCTURED_REPORT_VERSIONS,
    parse_clarification_items,
    section_1_present_but_unparsed,
)
from okstra_ctl.report_views import (  # noqa: E402
    analysis_review_context,
    plan_approval_context,
)
from okstra_ctl.report_view_artifacts import html_view_path  # noqa: E402
from okstra_ctl.final_report_paths import final_report_data_path  # noqa: E402
from okstra_ctl.report_contract import required_human_fields_for_report  # noqa: E402


_EXTERNAL_URL_RE = re.compile(
    r'<(?:script|link|img|iframe|source|video|audio)\s[^>]*?(?:src|href)\s*=\s*["\']https?://',
    re.IGNORECASE,
)

_RESPONSE_ID_ATTR_RE = re.compile(r'data-response-id="(C-\d+)"')
_ANALYSIS_REVIEW_SECTION_RE = re.compile(
    r'<section id="analysis-review">(?P<body>.*?)</section>', re.DOTALL
)
_ANALYSIS_REVIEW_STATUS_RE = re.compile(
    r'name="analysis-review-status"\s+value="([^"]+)"'
)
_ANALYSIS_REVIEW_SELECTOR_RE = re.compile(
    r'<select id="analysis-review-affected-ids"[^>]*>(?P<body>.*?)</select>',
    re.DOTALL,
)
_OPTION_VALUE_RE = re.compile(r'<option value="([^"]+)">')
_ANALYSIS_REVIEW_STATUSES = ["accepted", "rejected", "revision-requested"]


def _main_body(html_text: str) -> str:
    match = re.search(r"<main[^>]*>(?P<body>.*?)</main>", html_text, re.DOTALL)
    if match is None:
        return html_text
    return match.group("body")


def _load_v2_data(report_path: Path) -> tuple[Path, dict] | None:
    data_path = final_report_data_path(report_path)
    if not data_path.is_file():
        return None
    try:
        data = json.loads(data_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None
    if isinstance(data, dict) and data.get("schemaVersion") in STRUCTURED_REPORT_VERSIONS:
        return data_path, data
    return None


def _validate_v2(report_path: Path, data_path: Path, data: dict) -> list[str]:
    html_path = html_view_path(report_path)
    if not html_path.is_file():
        return [f"missing html artifact: {html_path}"]
    html_text = html_path.read_text(encoding="utf-8")
    main_body = _main_body(html_text)
    failures: list[str] = []

    task_type = data.get("header", {}).get("taskType", "")
    if f'data-task-template="{task_type}"' not in html_text:
        failures.append(f"v2 html task template mismatch for {task_type}")
    actual_fields = set(re.findall(r'data-report-field="([^"]+)"', main_body))
    for field in required_human_fields_for_report(task_type, data):
        if field not in actual_fields:
            failures.append(f"missing human field: {field}")

    if "data-audit-field=" in main_body:
        failures.append("audit field inside human main")
    node_ids = set(re.findall(r'data-node-id="([^"]+)"', html_text))
    fallback_ids = set(re.findall(r'data-fallback-id="([^"]+)"', html_text))
    if node_ids != fallback_ids:
        failures.append(
            f"visualization ID mismatch: svg={sorted(node_ids)}, fallback={sorted(fallback_ids)}"
        )

    data_ids = []
    for row in data.get("clarificationItems", []):
        if not isinstance(row, dict):
            continue
        response_id = row.get("id")
        if isinstance(response_id, str) and re.fullmatch(r"C-\d+", response_id):
            data_ids.append(response_id)
    data_ids.sort()
    html_ids = sorted(set(_RESPONSE_ID_ATTR_RE.findall(html_text)))
    if data_ids != html_ids:
        failures.append(
            f"Response ID mismatch: data.json has {data_ids}, HTML has {html_ids}"
        )
    if _EXTERNAL_URL_RE.search(html_text):
        failures.append("html contains external URL in script/link/img — must be self-contained")
    return failures


def _no_form_sections(html_body: str) -> list[str]:
    """Return a list of strings, each being the rendered chunk of a
    no-form section (5.6 / 5.7 / 5.8) up to the next h2/h3. Used to
    check that no form controls live inside them.
    """
    chunks: list[str] = []
    headings = [
        m for m in re.finditer(
            r'<h([23])[^>]*>([^<]*?)</h[23]>', html_body
        )
    ]
    for i, m in enumerate(headings):
        text = m.group(2).strip()
        if not text.startswith(("5.6", "5.7", "5.8")):
            continue
        start = m.end()
        end = headings[i + 1].start() if i + 1 < len(headings) else len(html_body)
        chunks.append(html_body[start:end])
    return chunks


def _validate_analysis_review_html(
    html_text: str, expected_ids: tuple[str, ...]
) -> list[str]:
    section = _ANALYSIS_REVIEW_SECTION_RE.search(html_text)
    if section is None:
        return ["analysis report html is missing the Analysis Review control"]
    body = section.group("body")
    failures: list[str] = []
    statuses = sorted(_ANALYSIS_REVIEW_STATUS_RE.findall(body))
    if statuses != _ANALYSIS_REVIEW_STATUSES:
        failures.append(
            "Analysis Review controls must be exactly accepted, "
            "revision-requested, and rejected"
        )
    selector = _ANALYSIS_REVIEW_SELECTOR_RE.search(body)
    actual_ids = (
        sorted(_OPTION_VALUE_RE.findall(selector.group("body")))
        if selector is not None
        else []
    )
    if actual_ids != sorted(expected_ids):
        failures.append(
            f"Analysis Review selector mismatch: data.json has "
            f"{sorted(expected_ids)}, HTML has {actual_ids}"
        )
    return failures


def validate(report_path: Path) -> list[str]:
    """Validate the html view of the report named by *report_path*.

    A schema-v2 report is checked against its data.json and the rendered html;
    the full reading copy markdown is a sibling rendering of the same record and is
    never read here, so it does not have to exist. Schema-v1 reports keep the
    markdown as their source and still require it.
    """
    failures: list[str] = []
    v2 = _load_v2_data(report_path)
    if v2 is not None:
        return _validate_v2(report_path, v2[0], v2[1])
    if not report_path.is_file():
        return [f"final-report not found: {report_path}"]

    md = report_path.read_text(encoding="utf-8")
    html_path = html_view_path(report_path)
    # §1 헤딩이 있는데 파싱이 실패하면 md_ids 가 빈 []이 되어 "clarification 없음
    # → skip" 으로 흘러 HTML form parity 게이트가 조용히 열린다. fail-closed.
    if section_1_present_but_unparsed(md):
        return [
            "final-report has a `## 1. Clarification Items` heading but its table "
            "could not be parsed (heading/anchor/format drift) — cannot verify HTML "
            "form parity. Re-render the report so §1 matches the schema."
        ]
    md_ids = _md_response_ids(md)
    review_ctx = analysis_review_context(report_path)

    # (1) sibling artifact exists when clarification rows or a structured
    # analysis-review / plan-approval contract require interactive controls.
    if not html_path.is_file():
        if (
            not md_ids
            and plan_approval_context(report_path) is None
            and review_ctx is None
        ):
            return []
        return [f"missing html artifact: {html_path}"]

    html_text = html_path.read_text(encoding="utf-8")
    html_body = _main_body(html_text)

    # (3) deliverable sections contain no form controls
    for chunk in _no_form_sections(html_body):
        if "<textarea" in chunk or "<input" in chunk or "<select" in chunk:
            failures.append(
                "html §5.6/§5.7/§5.8 deliverable section contains a form control"
            )
            break

    # (4) no external URLs in <script src> / <link href> / etc.
    if _EXTERNAL_URL_RE.search(html_text):
        failures.append("html contains external URL in script/link/img — must be self-contained")

    # (5) Response ID parity: HTML form rows ↔ §1 C-* rows in MD.
    # Bidirectional — catches both "MD has C-* the HTML lost" AND
    # "HTML has stale C-* that the current MD no longer declares".
    # (md_ids computed once at the top.)
    html_ids = sorted(set(_RESPONSE_ID_ATTR_RE.findall(html_text)))
    if md_ids != html_ids:
        failures.append(
            f"Response ID mismatch: MD §1 has {md_ids}, HTML has {html_ids}"
        )

    review_section_present = _ANALYSIS_REVIEW_SECTION_RE.search(html_text) is not None
    if review_ctx is not None:
        failures.extend(
            _validate_analysis_review_html(html_text, review_ctx.selector_ids)
        )
    elif review_section_present:
        failures.append("non-analysis report html must not contain Analysis Review controls")

    return failures


def _md_response_ids(md: str) -> list[str]:
    items = parse_clarification_items(md) or []
    return sorted({it.row_id for it in items if re.fullmatch(r"C-\d+", it.row_id)})


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Validate the self-contained HTML view of an okstra final-report."
    )
    parser.add_argument(
        "report_path",
        type=Path,
        help="Path to the original final-report markdown.",
    )
    args = parser.parse_args(argv)
    failures = validate(args.report_path)
    for f in failures:
        print(f, file=sys.stderr)
    return 1 if failures else 0


if __name__ == "__main__":
    sys.exit(main())
