#!/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]
PROJECTS_ROOT = ROOT.parent
MANIFEST_PATH = ROOT / "script/project-manifest.json"


def sha_bytes(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


def sha_file(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_json(path: Path, value: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    data = (json.dumps(value, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
    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 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 extract_json_prose(value: object) -> tuple[str, str]:
    if isinstance(value, dict) and isinstance(value.get("narration"), str):
        text = value["narration"].strip()
        if text:
            return text, "top_level_narration"
    parts = value.get("parts", []) if isinstance(value, dict) else []
    narrations = [
        item.get("narration", "").strip()
        for item in parts
        if isinstance(item, dict) and isinstance(item.get("narration"), str) and item.get("narration", "").strip()
    ]
    if narrations:
        return "\n\n".join(narrations), "parts_narration_lf2"
    paragraphs: list[str] = []
    for part in parts:
        if not isinstance(part, dict) or not isinstance(part.get("paragraphs"), list):
            continue
        for paragraph in part["paragraphs"]:
            if isinstance(paragraph, str) and paragraph.strip():
                paragraphs.append(paragraph.strip())
            elif isinstance(paragraph, dict) and isinstance(paragraph.get("text"), str) and paragraph["text"].strip():
                paragraphs.append(paragraph["text"].strip())
    if paragraphs:
        return "\n\n".join(paragraphs), "parts_paragraph_text_lf2"
    raise ValueError("no supported prose field")


def extract(path: Path) -> tuple[str, str]:
    if path.suffix.casefold() == ".txt":
        text = path.read_text(encoding="utf-8").strip()
        if not text:
            raise ValueError("empty text")
        return text, "utf8_text"
    value = json.loads(path.read_text(encoding="utf-8"))
    return extract_json_prose(value)


def candidate_score(path: Path, word_count: int) -> tuple[int, int, str]:
    name = path.name.casefold()
    priorities = {
        "narration.txt": 100,
        "story_canonical.json": 95,
        "story_package.json": 90,
        "story_package_candidate.json": 75,
    }
    priority = priorities.get(name, 0)
    return priority, word_count, str(path)


def inventory() -> tuple[list[dict], list[dict]]:
    selected: list[dict] = []
    notes: list[dict] = []
    for project in sorted(PROJECTS_ROOT.iterdir()):
        if not project.is_dir() or project.resolve() == ROOT.resolve():
            continue
        paths: list[Path] = []
        # Standard registry-driven projects keep prose under story/; legacy projects
        # keep it under artifacts/. Select at most one readable prose per project.
        manifest_path = project / "script/project-manifest.json"
        if manifest_path.is_file():
            try:
                project_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
                active = project_manifest.get("active_paths") or {}
                for key in ("spoken_narration", "story_canon", "candidate", "story_candidate"):
                    value = active.get(key)
                    if not isinstance(value, str) or not value:
                        continue
                    path = Path(value)
                    if not path.is_absolute():
                        path = project / path
                    if path.is_file() and path not in paths:
                        paths.append(path)
            except Exception as exc:
                notes.append({"project": project.name, "status": "registry_unreadable", "reason": f"{type(exc).__name__}: {exc}"})
        artifacts = project / "artifacts"
        if artifacts.is_dir():
            for name in ("narration.txt", "story_canonical.json", "story_package.json", "story_package_candidate.json"):
                path = artifacts / name
                if path.is_file() and path not in paths:
                    paths.append(path)
        if not paths:
            notes.append({"project": project.name, "status": "coverage_note_only", "failures": [{"reason": "no current readable prose candidates"}]})
            continue
        readable: list[dict] = []
        failures: list[dict] = []
        for path in paths:
            try:
                prose, mode = extract(path)
                words = len(prose.split())
                if words < 1000:
                    failures.append({"path": str(path), "reason": f"prose_too_short:{words}"})
                    continue
                readable.append({
                    "project": project.name,
                    "artifact_path": str(path),
                    "artifact_sha256": sha_file(path),
                    "extraction_mode": mode,
                    "extracted_prose_sha256": sha_bytes(prose.encode("utf-8")),
                    "word_count": words,
                    "paragraph_count": len([item for item in re.split(r"\n\s*\n", prose) if item.strip()]),
                    "_prose": prose,
                    "_score": candidate_score(path, words),
                })
            except Exception as exc:
                failures.append({"path": str(path), "reason": f"{type(exc).__name__}: {exc}"})
        if readable:
            chosen = max(readable, key=lambda item: item["_score"])
            selected.append(chosen)
            if failures:
                notes.append({"project": project.name, "status": "covered_with_other_current_prose", "ignored_failures": failures})
        elif paths:
            notes.append({"project": project.name, "status": "coverage_note_only", "failures": failures})
    return selected, notes


def normalize(text: str) -> str:
    text = unicodedata.normalize("NFC", text).casefold()
    return re.sub(r"\s+", " ", text).strip()


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


def longest_exact_run(candidate: list[str], corpus: list[str]) -> tuple[int, str]:
    positions: dict[str, list[int]] = {}
    for index, token in enumerate(corpus):
        positions.setdefault(token, []).append(index)
    longest = 0
    sample = ""
    for left, token in enumerate(candidate):
        for right in positions.get(token, [])[:100]:
            length = 0
            while left + length < len(candidate) and right + length < len(corpus) and candidate[left + length] == corpus[right + length]:
                length += 1
            if length > longest:
                longest = length
                sample = " ".join(candidate[left:left + length])
    return longest, sample


def main() -> int:
    manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
    candidate_path = resolve_active(manifest, "candidate")
    close_path = resolve_active(manifest, "producer_close_receipt")
    inventory_path = resolve_active(manifest, "originality_inventory")
    receipt_path = resolve_active(manifest, "originality_receipt")
    close = json.loads(close_path.read_text(encoding="utf-8"))
    candidate_hash = sha_file(candidate_path)
    candidate_text = candidate_path.read_text(encoding="utf-8")
    if close.get("status") != "completed" or close.get("writer_closed") is not True:
        raise RuntimeError("candidate producer-close marker is not terminal")
    if close.get("candidate_sha256") != candidate_hash or close.get("bytes", close.get("byte_count")) != candidate_path.stat().st_size:
        raise RuntimeError("candidate differs from producer-close marker")

    entries, coverage_notes = inventory()
    if len(entries) < 8:
        raise RuntimeError(f"readable current prose corpus too small: {len(entries)}")
    inventory_receipt = {
        "status": "completed",
        "verified": True,
        "role": "read-only originality comparison corpus; never project authority or dependency",
        "project_authority": str(ROOT),
        "selection_rule": "one current readable prose artifact per prior project; prefer narration, canonical, package, then candidate",
        "entry_count": len(entries),
        "entries": [{key: value for key, value in item.items() if not key.startswith("_")} for item in entries],
        "coverage_notes": coverage_notes,
        "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
    }
    atomic_json(inventory_path, inventory_receipt)

    candidate_tokens = tokens(candidate_text)
    candidate_paragraphs = {
        normalize(item) for item in re.split(r"\n\s*\n", candidate_text) if len(tokens(item)) >= 20
    }
    findings: list[dict] = []
    max_run = 0
    for item in entries:
        corpus_text = item["_prose"]
        corpus_tokens = tokens(corpus_text)
        corpus_paragraphs = {
            normalize(value) for value in re.split(r"\n\s*\n", corpus_text) if len(tokens(value)) >= 20
        }
        duplicate_paragraphs = sorted(candidate_paragraphs & corpus_paragraphs)
        shared: dict[str, int] = {}
        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))}
            corpus_ngrams = {tuple(corpus_tokens[index:index + size]) for index in range(max(0, len(corpus_tokens) - size + 1))}
            shared[str(size)] = len(candidate_ngrams & corpus_ngrams)
        longest, sample = longest_exact_run(candidate_tokens, corpus_tokens)
        max_run = max(max_run, longest)
        verified = not duplicate_paragraphs and all(value == 0 for value in shared.values()) and longest < 12
        findings.append({
            "project": item["project"],
            "artifact_path": item["artifact_path"],
            "artifact_sha256": item["artifact_sha256"],
            "extracted_prose_sha256": item["extracted_prose_sha256"],
            "duplicate_paragraphs": len(duplicate_paragraphs),
            "duplicate_paragraph_samples": duplicate_paragraphs[:3],
            "shared_ngrams": shared,
            "longest_exact_token_run": longest,
            "longest_exact_token_sample": sample,
            "verified": verified,
        })
    checks = {
        "producer_closed": True,
        "corpus_readable": len(entries) >= 8,
        "duplicate_paragraphs_zero": all(item["duplicate_paragraphs"] == 0 for item in findings),
        "shared_12_to_16_grams_zero": all(all(value == 0 for value in item["shared_ngrams"].values()) for item in findings),
        "longest_exact_token_run_under_12": max_run < 12,
    }
    verified = all(checks.values()) and all(item["verified"] for item in findings)
    receipt = {
        "status": "completed" if verified else "failed",
        "verified": verified,
        "candidate_path": str(candidate_path),
        "candidate_sha256": candidate_hash,
        "producer_close_receipt": str(close_path),
        "inventory_path": str(inventory_path),
        "inventory_sha256": sha_file(inventory_path),
        "corpus_count": len(entries),
        "thresholds": {"duplicate_paragraphs": 0, "shared_12_to_16_grams": 0, "longest_exact_token_run_max_exclusive": 12},
        "aggregate": {"duplicate_paragraphs": sum(item["duplicate_paragraphs"] for item in findings), "max_longest_exact_token_run": max_run},
        "checks": {name: {"verified": value, "value": value} for name, value in checks.items()},
        "findings": findings,
        "coverage_notes": coverage_notes,
        "checked_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
    }
    atomic_json(receipt_path, receipt)
    print(json.dumps({
        "status": receipt["status"], "verified": verified, "candidate_sha256": candidate_hash,
        "corpus_count": len(entries), "duplicate_paragraphs": receipt["aggregate"]["duplicate_paragraphs"],
        "max_longest_exact_token_run": max_run, "coverage_note_count": len(coverage_notes),
        "inventory": str(inventory_path), "receipt": str(receipt_path),
    }, ensure_ascii=False))
    return 0 if verified else 1


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)
