"""Director-note ledger for governable edits.

The ledger stores small immutable note records under an episode-local
``_history/notes`` directory. Rewrites may update only explicit mutable fields.
"""
from __future__ import annotations

import dataclasses
import json
import secrets
import threading
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from recoil.core.atomic_write import atomic_write_json


NOTE_SCHEMA_VERSION = "1.0"

STATUSES = {"proposed", "applied", "failed", "rolled_back", "parked"}
ACTION_TYPES = {"fix", "choose", "revert", "constrain"}
DOMAINS = {
    "board",
    "prompt",
    "cinema",
    "blocking",
    "plan",
    "grouping",
    "script",
    "bible",
    "ref",
    "continuity",
    "video",
    "performance",
}
MECHANISMS = {
    "noop",
    "choose_existing",
    "rollback_note",
    "reroll_board",
    "patch_board_prompt",
    "patch_cinema_prompt",
    "patch_blocking",
    "patch_plan",
    "patch_grouping",
    "patch_script",
    "patch_constraints",
    "reroll_video_take",
    "patch_motion_prompt",
    "patch_plan_timing",
    "patch_ref",
    "edit_bible",
    "park_unsupported",
    "ask_clarify",
}
BLAST_TIERS = {
    "T0_NO_GENERATION",
    "T1_ONE_ARTIFACT",
    "T2_SHOTSET_OR_TAKE",
    "T3_SCENE_OR_SMALL_BATCH",
    "T4_BATCH_OR_EPISODE_DERIVE",
    "T5_SCRIPT_REDERIVE_WITH_SEGMENTATION_RISK",
    "T6_PROJECT_OR_GLOBAL_RULE",
    "T7_UNSUPPORTED",
}
SCOPES = {"shot", "shotset_hash", "scene", "batch", "episode", "global"}


@dataclass
class NoteRecord:
    note_id: str
    schema_version: str
    created_at: str
    author: str
    project_id: str
    episode_id: str
    raw_text: str
    action_type: str
    domain: str
    mechanism: str
    blast_tier: str
    scope: str
    target: dict
    reference_target: dict | None
    classification: dict
    artifacts_edited: list[dict]
    derive_triggered: str | None
    approvals: dict
    spend: dict
    links: dict
    status: str
    rolled_back_by: str | None
    error: str | None

    def __post_init__(self) -> None:
        for field in dataclasses.fields(self):
            setattr(self, field.name, _json_safe(getattr(self, field.name)))


MUTABLE_FIELDS = frozenset(
    {
        "status",
        "rolled_back_by",
        "artifacts_edited",
        "approvals",
        "spend",
        "error",
        "links",
    }
)
IMMUTABLE_FIELDS = frozenset(f.name for f in dataclasses.fields(NoteRecord)) - MUTABLE_FIELDS

_NOTE_FIELD_NAMES = tuple(f.name for f in dataclasses.fields(NoteRecord))
_NOTE_FIELDS = frozenset(_NOTE_FIELD_NAMES)
assert MUTABLE_FIELDS | IMMUTABLE_FIELDS == _NOTE_FIELDS
assert MUTABLE_FIELDS & IMMUTABLE_FIELDS == frozenset()

_ID_LOCK = threading.Lock()
_LAST_ID_STAMP = ""
_LAST_ID_SEQUENCE = -1


def _json_safe(value: Any) -> Any:
    if isinstance(value, Path):
        return str(value)
    if dataclasses.is_dataclass(value):
        return _json_safe(dataclasses.asdict(value))
    if isinstance(value, dict):
        return {str(_json_safe(k)): _json_safe(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_json_safe(v) for v in value]
    if isinstance(value, tuple):
        return [_json_safe(v) for v in value]
    return value


def _to_dict(rec: NoteRecord) -> dict[str, Any]:
    return {field: _json_safe(getattr(rec, field)) for field in _NOTE_FIELD_NAMES}


def _from_dict(data: dict[str, Any]) -> NoteRecord:
    missing = _NOTE_FIELDS - data.keys()
    if missing:
        raise ValueError(next(iter(sorted(missing))))
    known = {field: data[field] for field in _NOTE_FIELDS}
    return NoteRecord(**known)


def _validate_enum(field: str, value: str, allowed: set[str]) -> None:
    if value not in allowed:
        raise ValueError(field)


def _validate_required_fields(rec: NoteRecord) -> None:
    for field in MUTABLE_FIELDS | IMMUTABLE_FIELDS:
        if not hasattr(rec, field):
            raise ValueError(field)


def _validate_utc_iso(field: str, value: Any) -> None:
    if not isinstance(value, str):
        raise ValueError(field)
    candidate = value
    if candidate.endswith("Z"):
        candidate = f"{candidate[:-1]}+00:00"
    try:
        parsed = datetime.fromisoformat(candidate)
    except ValueError as exc:
        raise ValueError(field) from exc
    if parsed.tzinfo is None or parsed.utcoffset() != timezone.utc.utcoffset(parsed):
        raise ValueError(field)


def validate_record(rec: NoteRecord) -> None:
    """Validate the v1 ledger schema, closed enums, and required fields."""
    _validate_required_fields(rec)
    if rec.schema_version != NOTE_SCHEMA_VERSION:
        raise ValueError("schema_version")
    _validate_utc_iso("created_at", rec.created_at)
    _validate_enum("action_type", rec.action_type, ACTION_TYPES)
    _validate_enum("domain", rec.domain, DOMAINS)
    _validate_enum("mechanism", rec.mechanism, MECHANISMS)
    _validate_enum("blast_tier", rec.blast_tier, BLAST_TIERS)
    _validate_enum("scope", rec.scope, SCOPES)
    _validate_enum("status", rec.status, STATUSES)


def new_note_id() -> str:
    """Return a sortable, filesystem-safe note id."""
    global _LAST_ID_SEQUENCE, _LAST_ID_STAMP
    stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
    with _ID_LOCK:
        if stamp <= _LAST_ID_STAMP:
            stamp = _LAST_ID_STAMP
            _LAST_ID_SEQUENCE += 1
        else:
            _LAST_ID_STAMP = stamp
            _LAST_ID_SEQUENCE = 0
        sequence = _LAST_ID_SEQUENCE
    return f"{stamp}-{sequence:012d}-{secrets.token_hex(4)}"


def notes_dir(episode_root: Path) -> Path:
    path = Path(episode_root) / "_history" / "notes"
    path.mkdir(parents=True, exist_ok=True)
    return path


def _note_path(episode_root: Path, note_id: str) -> Path:
    return notes_dir(episode_root) / f"{note_id}.json"


def _check_rewrite_allowed(existing: NoteRecord, replacement: NoteRecord) -> None:
    for field in IMMUTABLE_FIELDS:
        if getattr(existing, field) != getattr(replacement, field):
            raise ValueError(field)

    existing_links = existing.links or {}
    replacement_links = replacement.links or {}
    for key, value in existing_links.items():
        if key not in replacement_links or replacement_links[key] != value:
            raise ValueError("links")


def write_note(rec: NoteRecord, episode_root: Path) -> Path:
    validate_record(rec)
    path = _note_path(episode_root, rec.note_id)
    existing = read_note(episode_root, rec.note_id)
    if existing is not None:
        _check_rewrite_allowed(existing, rec)
    atomic_write_json(path, _to_dict(rec))
    return path


def read_note(episode_root: Path, note_id: str) -> NoteRecord | None:
    path = _note_path(episode_root, note_id)
    if not path.exists():
        return None
    data = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise ValueError("note")
    rec = _from_dict(data)
    validate_record(rec)
    return rec


def list_notes(episode_root: Path) -> list[NoteRecord]:
    directory = notes_dir(episode_root)
    records: list[NoteRecord] = []
    for path in sorted(directory.glob("*.json")):
        rec = read_note(episode_root, path.stem)
        if rec is not None:
            records.append(rec)
    return sorted(records, key=lambda rec: rec.created_at)


__all__ = [
    "ACTION_TYPES",
    "BLAST_TIERS",
    "DOMAINS",
    "IMMUTABLE_FIELDS",
    "MECHANISMS",
    "MUTABLE_FIELDS",
    "NOTE_SCHEMA_VERSION",
    "NoteRecord",
    "SCOPES",
    "STATUSES",
    "list_notes",
    "new_note_id",
    "notes_dir",
    "read_note",
    "validate_record",
    "write_note",
]
