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

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

ROOT = Path(__file__).resolve().parents[1]
MANIFEST_PATH = ROOT / "script/project-manifest.json"
AUTHORITY_KEYS = ["story_brief", "characters", "ledger", "outline", "identity_registry", "reveal_ledger"]
GATE_KEYS = [
    ("authority", "authority_receipt", False),
    ("candidate", "candidate_receipt", True),
    ("voice_test", "voice_test_receipt", True),
    ("semantic", "semantic_receipt", True),
    ("originality", "originality_receipt", True),
]


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 load(path: Path) -> dict:
    return json.loads(path.read_text(encoding="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
    try:
        path.resolve().relative_to(ROOT.resolve())
    except ValueError as exc:
        raise RuntimeError(f"active path escapes project: {key}") from exc
    return path


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)
    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 main() -> int:
    manifest = load(MANIFEST_PATH)
    paths = {
        key: resolve_active(manifest, key)
        for key in [
            "candidate", "producer_close_receipt", "story_canon", "spoken_narration",
            "story_manifest", "promotion_receipt", *AUTHORITY_KEYS,
            *[key for _, key, _ in GATE_KEYS],
        ]
    }
    required = list(paths.values()) + [MANIFEST_PATH]
    missing = [str(path) for path in required if not path.exists()]
    # Promotion outputs may legitimately not exist yet.
    allowed_missing = {paths["story_canon"], paths["spoken_narration"], paths["promotion_receipt"]}
    blocking_missing = [path for path in missing if Path(path) not in allowed_missing]
    if blocking_missing:
        raise RuntimeError(f"required promotion inputs missing: {blocking_missing}")
    for key in ("story_canon", "spoken_narration", "promotion_receipt"):
        if paths[key].exists():
            raise RuntimeError(f"promotion output already exists and requires audit/invalidation: {paths[key]}")

    candidate_hash = sha256(paths["candidate"])
    close = load(paths["producer_close_receipt"])
    if close.get("status") != "completed" or close.get("writer_closed") is not True:
        raise RuntimeError("candidate writer is not closed")
    if close.get("candidate_sha256") != candidate_hash or close.get("bytes", close.get("byte_count")) != paths["candidate"].stat().st_size:
        raise RuntimeError("candidate differs from producer-close marker")

    reports: dict[str, dict] = {}
    for name, key, candidate_bound in GATE_KEYS:
        report = load(paths[key])
        reports[name] = report
        if report.get("status") != "completed" or report.get("verified") is not True:
            raise RuntimeError(f"promotion gate not terminal/verified: {name}")
        if candidate_bound and report.get("candidate_sha256") != candidate_hash:
            raise RuntimeError(f"promotion gate stale for candidate: {name}")

    authority_hashes = {str(paths[key].relative_to(ROOT)): sha256(paths[key]) for key in AUTHORITY_KEYS}
    if reports["authority"].get("hashes") != authority_hashes:
        raise RuntimeError("authority drift detected after validation")

    data = paths["candidate"].read_bytes()
    text = data.decode("utf-8", errors="strict")
    words = len(text.split())
    run_manifest = load(resolve_active(manifest, "candidate_run_manifest"))
    run_id = run_manifest.get("run_id")
    before = {
        key: {"path": str(path), "exists": path.exists(), "sha256": sha256(path) if path.exists() else None}
        for key, path in {
            "canon": paths["story_canon"], "narration": paths["spoken_narration"],
            "promotion": paths["promotion_receipt"], "story_manifest": paths["story_manifest"],
            "project_manifest": MANIFEST_PATH,
        }.items()
    }

    # Write content first; promotion receipt and pointers follow.
    atomic_bytes(paths["story_canon"], data)
    atomic_bytes(paths["spoken_narration"], data)
    canon_hash = sha256(paths["story_canon"])
    narration_hash = sha256(paths["spoken_narration"])
    if canon_hash != candidate_hash or narration_hash != candidate_hash:
        raise RuntimeError("post-write promotion equality check failed")

    now = datetime.datetime.now(datetime.timezone.utc).isoformat()
    promotion = {
        "status": "completed", "verified": True, "run_id": run_id,
        "project_id": manifest.get("project_id"), "canonical_title": manifest.get("story_title"),
        "candidate_path": str(paths["candidate"]), "candidate_sha256": candidate_hash,
        "producer_close_receipt": str(paths["producer_close_receipt"]),
        "producer_close_sha256": sha256(paths["producer_close_receipt"]),
        "canon_path": str(paths["story_canon"]), "canon_sha256": canon_hash,
        "spoken_narration_path": str(paths["spoken_narration"]),
        "spoken_narration_sha256": narration_hash, "assembly_equality": True,
        "words_whitespace": words, "bytes": len(data), "authority_hashes": authority_hashes,
        "gate_receipts": {
            name: {"path": str(paths[key]), "sha256": sha256(paths[key])}
            for name, key, _ in GATE_KEYS
        },
        "precondition_fingerprints": before, "promoted_at": now,
        "hash_method": "sha256 streaming 8 MiB",
    }
    atomic_json(paths["promotion_receipt"], promotion)

    story_manifest = load(paths["story_manifest"])
    story_manifest.update({
        "status": "promoted", "candidate_path": str(paths["candidate"].relative_to(ROOT)),
        "candidate_sha256": candidate_hash, "canon_path": str(paths["story_canon"].relative_to(ROOT)),
        "canon_sha256": canon_hash,
        "spoken_narration_path": str(paths["spoken_narration"].relative_to(ROOT)),
        "spoken_narration_sha256": narration_hash, "words_whitespace": words,
        "voice_test": {"status": "completed", "verified": True,
            "receipt": str(paths["voice_test_receipt"].relative_to(ROOT)),
            "candidate_sha256": candidate_hash, "wpm": reports["voice_test"]["wpm"]},
        "promotion": {"status": "completed", "verified": True,
            "receipt": str(paths["promotion_receipt"].relative_to(ROOT))},
        "media_gate": "open", "promoted_at": now,
    })
    atomic_json(paths["story_manifest"], story_manifest)

    manifest = load(MANIFEST_PATH)
    manifest["status"] = "story_promoted"
    manifest["canon_sha256"] = canon_hash
    manifest["spoken_narration_sha256"] = narration_hash
    manifest["steps"]["story"] = "completed"
    manifest["steps"]["voice_test"] = "completed"
    manifest["voice_test_run"] = {
        "run_id": paths["voice_test_receipt"].parent.name,
        "candidate_sha256": candidate_hash, "receipt": str(paths["voice_test_receipt"].relative_to(ROOT)),
        "status": "completed", "verified": True, "wpm": reports["voice_test"]["wpm"],
        "predicted_full_minutes": words / reports["voice_test"]["wpm"],
    }
    manifest["updated_at"] = now
    atomic_json(MANIFEST_PATH, manifest)

    if load(paths["promotion_receipt"]).get("spoken_narration_sha256") != narration_hash:
        raise RuntimeError("promotion receipt readback failed")
    if load(paths["story_manifest"]).get("spoken_narration_sha256") != narration_hash:
        raise RuntimeError("story manifest readback failed")
    if load(MANIFEST_PATH).get("spoken_narration_sha256") != narration_hash:
        raise RuntimeError("project manifest readback failed")
    print(json.dumps({
        "status": "completed", "verified": True, "canon_sha256": canon_hash,
        "spoken_narration_sha256": narration_hash, "words_whitespace": words,
        "promotion_receipt": str(paths["promotion_receipt"]),
    }, 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)
