#!/usr/bin/env python3
import hashlib
import json
import os
import re
import unicodedata
from datetime import datetime, timezone
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "script/project-manifest.json"
PRODUCTION_TERMS = re.compile(
    r"(?i)(?<!\w)(teaser|cold open|catch-up|beat|scene|POV|outline|candidate|canon|narration|prompt|receipt|metadata)(?!\w)"
)


def sha256_bytes(value):
    return hashlib.sha256(value).hexdigest()


def sha256(path):
    return sha256_bytes(path.read_bytes())


def atomic_bytes(path, value):
    path.parent.mkdir(parents=True, exist_ok=True)
    part = path.with_suffix(path.suffix + ".part")
    with open(part, "xb") as handle:
        handle.write(value)
        handle.flush()
        os.fsync(handle.fileno())
    os.replace(part, path)


def atomic_json(path, value):
    atomic_bytes(path, (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8"))


def fail(message):
    raise RuntimeError(message)


def invisible_locations(text):
    rows = []
    line = 1
    column = 1
    for char in text:
        category = unicodedata.category(char)
        if category in {"Cf", "Cs", "Co", "Cn", "Zl", "Zp"} or (category == "Cc" and char != "\n"):
            rows.append({"line": line, "column": column, "codepoint": f"U+{ord(char):04X}"})
        if char == "\n":
            line += 1
            column = 1
        else:
            column += 1
    return rows


def main():
    manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
    if manifest.get("active_run") != "run-002" or manifest.get("status") != "run-002_draft_complete_ready_for_deterministic_validation":
        fail("run-002 draft is not enabled for deterministic validation")
    active = manifest["active_paths"]
    brief_path = ROOT / active["story_brief"]
    authority_receipt_path = ROOT / "work/story/run-002/authority-validation.json"
    draft_progress_rel = manifest.get("draft_progress_path")
    if not isinstance(draft_progress_rel, str) or not draft_progress_rel:
        fail("manifest draft-progress path missing")
    draft_progress_path = ROOT / draft_progress_rel
    candidate_path = ROOT / active["candidate"]
    validation_path = ROOT / active["candidate_validation"]
    if candidate_path.exists() or validation_path.exists():
        fail("candidate and validation outputs must be virgin")
    if not brief_path.exists() or not authority_receipt_path.exists() or not draft_progress_path.exists():
        fail("Brief, authority validation, or draft-progress receipt missing")
    brief = json.loads(brief_path.read_text(encoding="utf-8"))
    authority_receipt = json.loads(authority_receipt_path.read_text(encoding="utf-8"))
    if authority_receipt.get("status") != "PASS" or authority_receipt.get("verified") is not True:
        fail("authority validation is not terminal PASS")
    if authority_receipt.get("authority_revision") != "v2" or manifest.get("authority_revision") != "v2":
        fail("authority revision v2 not terminal/current")
    for key, expected in authority_receipt.get("authority_hashes", {}).items():
        path = ROOT / active[key]
        if sha256(path) != expected:
            fail(f"authority drift before drafting close: {key}")
    draft_progress = json.loads(draft_progress_path.read_text(encoding="utf-8"))
    if draft_progress.get("status") != "all_parts_closed" or draft_progress.get("verified") is not True:
        fail("all four source parts are not terminal closed")
    if draft_progress.get("authority_validation_sha256") != sha256(authority_receipt_path):
        fail("draft progress authority receipt drift")
    if [row.get("part") for row in draft_progress.get("parts", [])] != [1, 2, 3, 4]:
        fail("draft progress part inventory/order drift")

    production = brief.get("draft_production", {})
    parts_spec = production.get("parts", [])
    if len(parts_spec) < 3:
        fail("at least three sequential source parts required")
    expected_numbers = list(range(1, len(parts_spec) + 1))
    if [row.get("part") for row in parts_spec] != expected_numbers:
        fail("source part numbering/order invalid")
    if production.get("assembly_contract") != "exact part bytes in order; each part ends one LF; add one LF between parts; aggregate ends one LF":
        fail("unknown assembly contract")

    part_rows = []
    part_bytes = []
    for row in parts_spec:
        path = ROOT / row["path"]
        if not path.exists():
            fail(f"source part missing: {path}")
        raw = path.read_bytes()
        if raw.startswith(b"\xef\xbb\xbf") or b"\r" in raw:
            fail(f"BOM/CR forbidden in {path}")
        if not raw.endswith(b"\n") or raw.endswith(b"\n\n"):
            fail(f"source part must end with exactly one LF: {path}")
        try:
            text = raw.decode("utf-8")
        except UnicodeDecodeError as exc:
            fail(f"invalid UTF-8 in {path}: {exc}")
        if not unicodedata.is_normalized("NFC", text):
            fail(f"non-NFC source part: {path}")
        if any(line.endswith((" ", "\t")) for line in text.splitlines()):
            fail(f"trailing whitespace in {path}")
        part_rows.append({
            "part": row["part"],
            "path": row["path"],
            "bytes": len(raw),
            "words": len(text.split()),
            "sha256": sha256_bytes(raw),
        })
        part_bytes.append(raw)
        progress_row = draft_progress["parts"][row["part"] - 1]
        if progress_row.get("path") != row["path"] or progress_row.get("sha256") != sha256_bytes(raw):
            fail(f"draft progress source-part drift: {path}")

    candidate = b"\n".join(part_bytes)
    if not candidate.endswith(b"\n") or candidate.endswith(b"\n\n"):
        fail("aggregate final LF contract failed")
    for index in range(len(part_bytes) - 1):
        boundary = part_bytes[index][-1:] + b"\n" + part_bytes[index + 1][:1]
        if boundary[:2] != b"\n\n" or boundary.startswith(b"\n\n\n"):
            fail(f"aggregate blank-line boundary failed after part {index + 1}")
    reconstructed = b"\n".join(part_bytes)
    if reconstructed != candidate:
        fail("aggregate reverse reconstruction failed")

    text = candidate.decode("utf-8")
    minimum, maximum = brief["target_words"]
    word_count = len(text.split())
    hygiene = {
        "utf8": True,
        "nfc": unicodedata.is_normalized("NFC", text),
        "bom": candidate.startswith(b"\xef\xbb\xbf"),
        "cr_count": candidate.count(b"\r"),
        "tab_count": text.count("\t"),
        "invisible_unicode_locations": invisible_locations(text),
        "markdown_heading_lines": [i for i, line in enumerate(text.splitlines(), 1) if re.match(r"^\s*#{1,6}\s+", line)],
        "named_section_lines": [i for i, line in enumerate(text.splitlines(), 1) if re.match(r"(?i)^\s*(chương|phần|cảnh|hồi|chapter|part|scene)\s*(\d+|[IVXLC]+|[:\-])", line)],
        "stage_direction_lines": [i for i, line in enumerate(text.splitlines(), 1) if re.match(r"(?i)^\s*[\[(](nhạc|âm nhạc|sfx|hiệu ứng|chuyển cảnh|giọng|pause|music|sound)\b", line)],
        "cta_lines": [i for i, line in enumerate(text.splitlines(), 1) if re.search(r"(?i)(đăng ký kênh|subscribe|nhấn chuông|hãy like|hãy chia sẻ)", line)],
        "production_term_lines": [i for i, line in enumerate(text.splitlines(), 1) if PRODUCTION_TERMS.search(line)],
    }
    required_timeline_phrases = [
        "Ngày còn mười bốn hôm",
        "Ngày còn mười hai hôm",
        "Sáng ngày còn mười hôm",
        "Ba đêm kết thúc vào ngày còn bảy hôm",
        "Ngày còn năm hôm",
        "Ngày còn một hôm",
        "ngày bàn giao",
    ]
    forbidden_stale_timeline_phrases = [
        "Sáng ngày còn tám hôm",
        "Ba đêm kết thúc vào ngày còn năm hôm",
        "Ngày còn ba hôm",
    ]
    timeline_gate = {
        "required_phrases_present": {value: value in text for value in required_timeline_phrases},
        "stale_phrases_absent": {value: value not in text for value in forbidden_stale_timeline_phrases},
    }
    hard_fail = (
        not all(timeline_gate["required_phrases_present"].values())
        or not all(timeline_gate["stale_phrases_absent"].values())
        or not minimum <= word_count <= maximum
        or not hygiene["nfc"]
        or hygiene["bom"]
        or hygiene["cr_count"]
        or hygiene["tab_count"]
        or hygiene["invisible_unicode_locations"]
        or hygiene["markdown_heading_lines"]
        or hygiene["named_section_lines"]
        or hygiene["stage_direction_lines"]
        or hygiene["cta_lines"]
        or hygiene["production_term_lines"]
    )
    if hard_fail:
        fail(json.dumps({"word_count": word_count, "target": [minimum, maximum], "hygiene": hygiene, "timeline_gate": timeline_gate}, ensure_ascii=False))

    atomic_bytes(candidate_path, candidate)
    if candidate_path.read_bytes() != candidate:
        fail("candidate readback mismatch")
    now = datetime.now(timezone.utc).isoformat()
    validation = {
        "status": "PASS",
        "verified": True,
        "run_id": "run-002",
        "revision": manifest.get("active_version"),
        "candidate_path": active["candidate"],
        "candidate_sha256": sha256(candidate_path),
        "candidate_bytes": candidate_path.stat().st_size,
        "candidate_words": word_count,
        "predicted_duration_minutes_at_231_wpm": word_count / 231,
        "target_words": [minimum, maximum],
        "assembly": {
            "verified": True,
            "contract": production["assembly_contract"],
            "part_count": len(part_rows),
            "parts": part_rows,
            "reverse_reconstruction_verified": True,
            "blank_line_boundaries": len(part_rows) - 1,
            "final_lf": True,
        },
        "authority_validation_path": "work/story/run-002/authority-validation.json",
        "authority_validation_sha256": sha256(authority_receipt_path),
        "authority_hashes": authority_receipt["authority_hashes"],
        "timeline_gate": timeline_gate,
        "narration_hygiene": hygiene,
        "heuristics_not_promoted_to_hard_limits": [
            "sentence length",
            "dialogue turn length",
            "paragraph density",
        ],
        "producer_full_read": "PENDING_SEPARATE_HUMAN_SEMANTIC_STEP",
        "completed_at": now,
    }
    atomic_json(validation_path, validation)
    if sha256(candidate_path) != validation["candidate_sha256"]:
        fail("validation readback hash mismatch")
    print(json.dumps({
        "status": "PASS",
        "candidate_sha256": validation["candidate_sha256"],
        "candidate_words": word_count,
        "duration_minutes": word_count / 231,
        "parts": len(part_rows),
        "producer_full_read": "PENDING",
    }, ensure_ascii=False))


if __name__ == "__main__":
    main()
