#!/usr/bin/env python3
from __future__ import annotations

import datetime
import hashlib
import json
import os
import re
import sys
import tempfile
import unicodedata
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "script/project-manifest.json"
RUN_DIR = ROOT / "work/story/run-001"
LOG_DIR = ROOT / "log/run-001"
CANDIDATE = RUN_DIR / "candidate.txt"
CLOSE = RUN_DIR / "producer-close.json"
SEMANTIC_REVIEW = RUN_DIR / "semantic-review.json"
MIN_WORDS = 9240
MAX_WORDS = 13860
RATE_WPM = 231
AUTHORITY_KEYS = (
    "story_brief", "characters", "outline", "ledger",
    "identity_registry", "reveal_ledger",
)
PRIOR_PROJECTS = (
    ROOT.parent / "001-Mui-Chi-Giau-Trong-Mua-Mua",
    ROOT.parent / "002-Mai-Kinh-Giu-Mot-Mua-Chim",
)
REQUIRED_GATES = (
    "global_coherence", "comprehension", "dialogue", "tts_hygiene",
    "continuity", "retention", "character_agency", "consent",
    "climax", "ending", "originality_semantic",
)


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for chunk in iter(lambda: handle.read(8 * 1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def atomic_bytes(path: Path, data: bytes) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
    try:
        with os.fdopen(fd, "wb") as handle:
            handle.write(data)
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(name, path)
        directory_fd = os.open(path.parent, os.O_DIRECTORY)
        try:
            os.fsync(directory_fd)
        finally:
            os.close(directory_fd)
    finally:
        if os.path.exists(name):
            os.unlink(name)


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


def resolve_active(manifest: dict, key: str) -> Path:
    value = (manifest.get("active_paths") or {}).get(key)
    if not isinstance(value, str) or not value:
        raise RuntimeError(f"active_paths.{key} is missing")
    path = Path(value)
    if not path.is_absolute():
        path = ROOT / path
    resolved = path.resolve()
    try:
        resolved.relative_to(ROOT.resolve())
    except ValueError as exc:
        raise RuntimeError(f"active path escapes project: {key}") from exc
    return resolved


def canonical_mapping_hash(value: dict[str, str]) -> str:
    raw = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
    return hashlib.sha256(raw).hexdigest()


def normalized_tokens(text: str) -> list[str]:
    folded = unicodedata.normalize("NFC", text).casefold()
    return re.findall(r"\w+", folded, flags=re.UNICODE)


def longest_exact_run(first: list[str], second: list[str]) -> tuple[int, str]:
    positions: dict[str, list[int]] = {}
    for index, token in enumerate(second):
        positions.setdefault(token, []).append(index)
    previous: dict[int, int] = {}
    best = 0
    best_end = 0
    for first_index, token in enumerate(first):
        current: dict[int, int] = {}
        for second_index in positions.get(token, []):
            length = previous.get(second_index - 1, 0) + 1
            current[second_index] = length
            if length > best:
                best = length
                best_end = first_index + 1
        previous = current
    return best, " ".join(first[max(0, best_end - best):best_end])


def prior_prose(project: Path) -> Path:
    for relative in (
        "story/spoken-narration.txt", "story/story-canon.txt",
        "story/story-package.txt",
    ):
        path = project / relative
        if path.is_file():
            return path
    raise RuntimeError(f"no readable prior prose artifact: {project}")


def main() -> int:
    manifest = json.loads(MANIFEST.read_text(encoding="utf-8"))
    run = manifest.get("story_run") or {}
    if run.get("run_id") != "run-001":
        raise RuntimeError("active story run is not run-001")
    if Path(str(run.get("candidate_path"))) != Path("work/story/run-001/candidate.txt"):
        raise RuntimeError("candidate path drift")
    if not CANDIDATE.is_file() or not CLOSE.is_file() or not SEMANTIC_REVIEW.is_file():
        raise RuntimeError("candidate, producer-close, or semantic review is missing")

    raw = CANDIDATE.read_bytes()
    text = raw.decode("utf-8", errors="strict")
    candidate_hash = hashlib.sha256(raw).hexdigest()
    word_count = len(text.split())
    close = json.loads(CLOSE.read_text(encoding="utf-8"))

    authority_paths = {key: resolve_active(manifest, key) for key in AUTHORITY_KEYS}
    authority_snapshot = {str(path): sha256(path) for path in authority_paths.values()}
    expected_snapshot = run.get("authority_snapshot") or {}
    close_snapshot = {}
    for key, item in (close.get("authority_snapshot") or {}).items():
        if isinstance(item, dict) and item.get("path") and item.get("sha256"):
            close_snapshot[str(Path(item["path"]).resolve())] = item["sha256"]
    close_checks = {
        "completed_verified": close.get("status") == "completed" and close.get("verified") is True,
        "writer_closed": close.get("writer_closed") is True,
        "run_id": close.get("run_id") == "run-001",
        "absolute_candidate_path": close.get("candidate_path") == str(CANDIDATE.resolve()),
        "candidate_hash": close.get("candidate_sha256") == candidate_hash,
        "byte_count": close.get("byte_count") == len(raw),
        "word_count": close.get("word_count") == word_count,
        "word_method": str(close.get("word_count_method", "")).startswith("len(text.split())"),
        "authority_unchanged": authority_snapshot == expected_snapshot,
        "authority_snapshot": close_snapshot == authority_snapshot,
    }

    stripped_lines = text.splitlines()
    hygiene_checks = {
        "strict_utf8": True,
        "nfc": unicodedata.is_normalized("NFC", text),
        "no_bom": not raw.startswith(b"\xef\xbb\xbf"),
        "no_crlf": b"\r" not in raw,
        "no_tabs": "\t" not in text,
        "no_trailing_spaces": not any(line.endswith((" ", "\t")) for line in stripped_lines),
        "no_replacement_character": "\ufffd" not in text,
        "no_markdown_headings": not any(re.match(r"^\s*#{1,6}\s+", line) for line in stripped_lines),
        "no_named_sections": not any(re.match(r"^\s*(?:chương|phần|cảnh|hồi)\s+(?:\d+|[ivxlc]+)", line, re.I) for line in stripped_lines),
        "no_stage_directions": not any(re.match(r"^\s*[\[(](?:nhạc|sfx|hiệu ứng|chuyển cảnh|pause)\b", line, re.I) for line in stripped_lines),
        "no_channel_cta": not re.search(r"(?:đăng ký|subscribe)\s+kênh|hãy\s+(?:like|chia sẻ)", text, re.I),
        "blank_runs": "\n\n\n" not in text,
        "word_range": MIN_WORDS <= word_count <= MAX_WORDS,
    }

    full_names = (
        "Tạ Minh Yên", "Trình Hạo Dương", "Phùng Tử Kiến",
        "La Tuyết Kỳ", "Mã Khải Phong",
    )
    allowed_forms = full_names + (
        "Minh Yên", "Hạo Dương", "Tử Kiến", "Tuyết Kỳ", "Khải Phong",
    )
    masked = text
    for name in sorted(allowed_forms, key=len, reverse=True):
        masked = masked.replace(name, " " * len(name))
    forbidden_short = ("Yên", "Dương", "Kiến", "Kỳ", "Phong")
    short_hits = {
        token: len(re.findall(rf"(?<!\w){re.escape(token)}(?!\w)", masked))
        for token in forbidden_short
    }
    identity_checks = {
        "all_canonical_names_present": all(name in text for name in full_names),
        "no_forbidden_short_names": not any(short_hits.values()),
    }

    marker_groups = {
        "route_and_deadline": ("tuyến 27", "ba mươi ngày"),
        "mailbox": ("hộp thư", "ghế cuối"),
        "request_stop_trial": ("điểm dừng", "yêu cầu"),
        "dark_data_midpoint": ("dữ liệu", "quẹt vé", "giờ chạy"),
        "consent_boundary": ("đồng thuận", "xin phép"),
        "real_loss": ("đóng quyền", "rút khỏi", "mất quyền"),
        "active_climax": ("bỏ phiếu", "chuyến", "kiểm chứng"),
        "ending_mechanism": ("sáu tháng", "hội đồng", "Bạn muốn xuống ở đâu"),
    }
    marker_checks = {
        name: all(term.casefold() in text.casefold() for term in terms)
        for name, terms in marker_groups.items()
    }

    candidate_tokens = normalized_tokens(text)
    candidate_paragraphs = {
        re.sub(r"\s+", " ", paragraph.strip()).casefold()
        for paragraph in text.split("\n\n") if len(paragraph.split()) >= 12
    }
    originality_findings = []
    originality_ok = True
    for project in PRIOR_PROJECTS:
        source = prior_prose(project)
        source_text = source.read_text(encoding="utf-8")
        source_tokens = normalized_tokens(source_text)
        source_paragraphs = {
            re.sub(r"\s+", " ", paragraph.strip()).casefold()
            for paragraph in source_text.split("\n\n") if len(paragraph.split()) >= 12
        }
        shared = {}
        for size in range(12, 17):
            candidate_ngrams = {tuple(candidate_tokens[index:index + size]) for index in range(max(0, len(candidate_tokens) - size + 1))}
            source_ngrams = {tuple(source_tokens[index:index + size]) for index in range(max(0, len(source_tokens) - size + 1))}
            shared[str(size)] = len(candidate_ngrams & source_ngrams)
        longest, sample = longest_exact_run(candidate_tokens, source_tokens)
        duplicate_paragraphs = sorted(candidate_paragraphs & source_paragraphs)
        passed = not duplicate_paragraphs and all(value == 0 for value in shared.values()) and longest < 12
        originality_ok = originality_ok and passed
        originality_findings.append({
            "project": project.name, "source_path": str(source), "source_sha256": sha256(source),
            "duplicate_paragraph_count": len(duplicate_paragraphs),
            "shared_12_to_16_grams": shared, "longest_exact_token_run": longest,
            "longest_exact_token_sample": sample, "verified": passed,
        })

    semantic = json.loads(SEMANTIC_REVIEW.read_text(encoding="utf-8"))
    gates = semantic.get("gates") or {}
    findings = semantic.get("findings") or []
    semantic_checks = {
        "candidate_current": semantic.get("candidate_sha256") == candidate_hash,
        "reviewer": semantic.get("reviewer") == "Robin",
        "all_required_gates": all(name in gates for name in REQUIRED_GATES),
        "all_gates_verified": all(isinstance(gates.get(name), dict) and gates[name].get("verified") is True for name in REQUIRED_GATES),
        "no_blocking_findings": not any(str(item.get("severity", "")).upper() in {"BLOCKER", "HIGH"} for item in findings if isinstance(item, dict)),
    }

    checks = {
        "producer_close": all(close_checks.values()),
        "candidate_hygiene": all(hygiene_checks.values()),
        "identity": all(identity_checks.values()),
        "semantic_markers": all(marker_checks.values()),
        "originality": originality_ok,
        "semantic_review": all(semantic_checks.values()),
    }
    LOG_DIR.mkdir(parents=True, exist_ok=True)
    atomic_json(LOG_DIR / "candidate-validation.json", {
        "status": "completed" if checks["producer_close"] and checks["candidate_hygiene"] and checks["identity"] else "failed",
        "verified": checks["producer_close"] and checks["candidate_hygiene"] and checks["identity"],
        "candidate_path": str(CANDIDATE), "candidate_sha256": candidate_hash,
        "byte_count": len(raw), "word_count": word_count,
        "predicted_duration_minutes": word_count / RATE_WPM, "rate_wpm": RATE_WPM,
        "producer_close_checks": close_checks, "hygiene_checks": hygiene_checks,
        "identity_checks": identity_checks, "forbidden_short_name_hits": short_hits,
    })
    atomic_json(LOG_DIR / "semantic-markers.json", {
        "status": "completed" if all(marker_checks.values()) else "failed",
        "verified": all(marker_checks.values()), "candidate_sha256": candidate_hash,
        "checks": marker_checks,
    })
    atomic_json(LOG_DIR / "originality-validation.json", {
        "status": "completed" if originality_ok else "failed", "verified": originality_ok,
        "candidate_sha256": candidate_hash, "findings": originality_findings,
        "rule": "zero duplicate paragraphs and zero shared 12-16 grams; longest exact run under 12 tokens",
    })
    atomic_json(LOG_DIR / "semantic-review.json", {
        **semantic, "review_source_path": str(SEMANTIC_REVIEW),
        "review_source_sha256": sha256(SEMANTIC_REVIEW), "checks": semantic_checks,
        "status": "completed" if all(semantic_checks.values()) else "failed",
        "verified": all(semantic_checks.values()),
    })

    if not all(checks.values()):
        raise RuntimeError(f"promotion blocked: {[name for name, value in checks.items() if not value]}")

    canon = resolve_active(manifest, "story_canon")
    narration = resolve_active(manifest, "spoken_narration")
    promotion = resolve_active(manifest, "promotion_receipt")
    for path in (canon, narration, promotion):
        if path.exists():
            raise RuntimeError(f"promotion output already exists: {path}")
    atomic_bytes(canon, raw)
    atomic_bytes(narration, raw)
    canon_hash = sha256(canon)
    narration_hash = sha256(narration)
    if canon_hash != candidate_hash or narration_hash != candidate_hash:
        raise RuntimeError("promoted bytes differ from candidate")
    completed_at = datetime.datetime.now(datetime.timezone.utc).isoformat()
    receipt = {
        "status": "completed", "verified": True, "project_id": manifest.get("project_id"),
        "run_id": "run-001", "candidate_path": str(CANDIDATE), "candidate_sha256": candidate_hash,
        "canon_path": str(canon), "canon_sha256": canon_hash,
        "spoken_narration_path": str(narration), "spoken_narration_sha256": narration_hash,
        "word_count": word_count, "predicted_duration_minutes": word_count / RATE_WPM,
        "duration_baseline_wpm": RATE_WPM, "producer_close_sha256": sha256(CLOSE),
        "semantic_review_sha256": sha256(SEMANTIC_REVIEW),
        "gate_receipts": {
            name: {"path": str(LOG_DIR / name), "sha256": sha256(LOG_DIR / name)}
            for name in ("candidate-validation.json", "semantic-markers.json", "originality-validation.json", "semantic-review.json")
        },
        "completed_at": completed_at,
    }
    atomic_json(promotion, receipt)
    manifest["canon_sha256"] = canon_hash
    manifest["story_duration_seconds"] = word_count / RATE_WPM * 60
    manifest["status"] = "story_promoted"
    manifest["story_run"]["status"] = "promoted"
    manifest["story_run"]["candidate_sha256"] = candidate_hash
    manifest["story_run"]["word_count"] = word_count
    manifest["steps"]["story"] = "completed"
    manifest["updated_at"] = completed_at
    atomic_json(MANIFEST, manifest)
    print(json.dumps({
        "status": "completed", "verified": True, "word_count": word_count,
        "predicted_duration_minutes": word_count / RATE_WPM,
        "candidate_sha256": candidate_hash, "promotion_receipt": str(promotion),
    }, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except Exception as exc:
        print(json.dumps({"status": "failed", "verified": False, "error": f"{type(exc).__name__}: {exc}"}, ensure_ascii=False), file=sys.stderr)
        sys.exit(1)
