"""Atomic, private filesystem persistence for wizard drafts."""

from __future__ import annotations

import os
from contextlib import contextmanager
from collections.abc import Iterator
from pathlib import Path

import fcntl
import re
import stat

from checkin_cli.wizard_models import WizardFlow, WizardSession


class WizardStorage:
    """Read and replace profile-local drafts under a single process lock."""

    def __init__(self, home: Path) -> None:
        self._home = home
        self._drafts = home / "drafts"
        self._lock = home / ".wizard.lock"

_LOCK_FLAGS = os.O_RDWR | os.O_NOFOLLOW | os.O_CLOEXEC
_READ_FLAGS = os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC
_SESSION_ID = re.compile(r"^[a-f0-9]{32}$")
_MAX_DRAFT_BYTES = 256 * 1024


def _verify_lock_inode(path: Path, descriptor: int, *, writable: bool) -> os.stat_result:
    """Verify a private lock inode without following a replacement symlink."""
    try:
        opened = os.fstat(descriptor)
        named = os.stat(path, follow_symlinks=False)
    except OSError as exc:
        raise RuntimeError("wizard lock is unavailable") from exc
    if (
        not stat.S_ISREG(opened.st_mode)
        or opened.st_uid != os.getuid()
        or opened.st_nlink != 1
        or stat.S_IMODE(opened.st_mode) != 0o600
        or (opened.st_dev, opened.st_ino) != (named.st_dev, named.st_ino)
        or not stat.S_ISREG(named.st_mode)
        or named.st_uid != os.getuid()
        or named.st_nlink != 1
        or stat.S_IMODE(named.st_mode) != 0o600
    ):
        raise RuntimeError("wizard lock is unsafe")
    return opened


def _open_lock(path: Path, *, writable: bool) -> int:
    flags = _LOCK_FLAGS if writable else _READ_FLAGS
    if writable:
        path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
        try:
            descriptor = os.open(path, flags, 0o600)
        except FileNotFoundError:
            descriptor = os.open(path, flags | os.O_CREAT | os.O_EXCL, 0o600)
        os.fchmod(descriptor, 0o600)
    else:
        try:
            descriptor = os.open(path, flags)
        except OSError as exc:
            raise RuntimeError("wizard lock is unavailable") from exc
    try:
        _verify_lock_inode(path, descriptor, writable=writable)
    except BaseException:
        os.close(descriptor)
        raise
    return descriptor


def _safe_draft_bytes(path: Path) -> bytes | None:
    if path.is_symlink() or not path.exists():
        return None
    descriptor: int | None = None
    try:
        descriptor = os.open(path, _READ_FLAGS)
        opened = os.fstat(descriptor)
        named = os.stat(path, follow_symlinks=False)
        if (
            not stat.S_ISREG(opened.st_mode)
            or opened.st_uid != os.getuid()
            or opened.st_nlink != 1
            or stat.S_IMODE(opened.st_mode) != 0o600
            or (opened.st_dev, opened.st_ino) != (named.st_dev, named.st_ino)
            or opened.st_size > _MAX_DRAFT_BYTES
        ):
            raise RuntimeError("wizard draft is unsafe")
        chunks: list[bytes] = []
        remaining = _MAX_DRAFT_BYTES
        while remaining:
            chunk = os.read(descriptor, remaining)
            if not chunk:
                break
            chunks.append(chunk)
            remaining -= len(chunk)
        if os.fstat(descriptor).st_size > _MAX_DRAFT_BYTES:
            raise RuntimeError("wizard draft is too large")
        return b"".join(chunks)
    except OSError as exc:
        raise RuntimeError("wizard draft is unavailable") from exc
    finally:
        if descriptor is not None:
            os.close(descriptor)


def _reject_synthetic(value: object) -> None:
    if isinstance(value, dict):
        if value.get("provenance") == "diagnostic_synthetic_v1" or value.get(
            "source_type"
        ) == "diagnostic_synthetic_v1":
            raise ValueError("synthetic diagnostic provenance is not valid in production")
        for child in value.values():
            _reject_synthetic(child)
    elif isinstance(value, (list, tuple)):
        for child in value:
            _reject_synthetic(child)

class WizardStorage(WizardStorage):
    """Wizard persistence operations layered on the validated lock paths."""

    @contextmanager
    def read_locked(self) -> Iterator[None]:
        """Take a physically read-only shared lock on the existing writer inode."""
        descriptor = _open_lock(self._lock, writable=False)
        try:
            fcntl.flock(descriptor, fcntl.LOCK_SH)
            _verify_lock_inode(self._lock, descriptor, writable=False)
            yield
        finally:
            fcntl.flock(descriptor, fcntl.LOCK_UN)
            os.close(descriptor)
    @contextmanager
    def locked(self) -> Iterator[None]:
        """Serialize all start/resume and callback state transitions."""
        self._home.mkdir(parents=True, exist_ok=True, mode=0o700)
        self._home.chmod(0o700)
        descriptor = _open_lock(self._lock, writable=True)
        try:
            fcntl.flock(descriptor, fcntl.LOCK_EX)
            _verify_lock_inode(self._lock, descriptor, writable=True)
            yield
        finally:
            fcntl.flock(descriptor, fcntl.LOCK_UN)
            os.close(descriptor)

    def find_open(
        self,
        flow: WizardFlow,
        owner_id: str,
        topic_id: str,
        kst_day: str,
        supersedes: str | None,
    ) -> WizardSession | None:
        """Return the one unfinished same-day session for this exact boundary."""
        for path in self._drafts.glob("*.json") if self._drafts.exists() else ():
            session = WizardSession.model_validate_json(path.read_text(encoding="utf-8"))
            if (
                session.flow is flow
                and session.owner_id == owner_id
                and session.topic_id == topic_id
                and session.kst_day == kst_day
                and session.supersedes == supersedes
                and session.finalized_event_id is None
            ):
                return session
        return None

    def find_completed_morning(
        self,
        owner_id: str,
        topic_id: str,
        kst_day: str,
    ) -> WizardSession | None:
        """Find the one canonical completed morning record eligible for correction."""
        for path in self._drafts.glob("*.json") if self._drafts.exists() else ():
            session = WizardSession.model_validate_json(path.read_text(encoding="utf-8"))
            if (
                session.flow is WizardFlow.MORNING
                and session.owner_id == owner_id
                and session.topic_id == topic_id
                and session.kst_day == kst_day
                and session.supersedes is None
                and session.finalized_event_id is not None
            ):
                return session
        return None

    def find_completed(
        self,
        flow: WizardFlow,
        owner_id: str,
        topic_id: str,
        kst_day: str,
    ) -> WizardSession | None:
        for path in self._drafts.glob("*.json") if self._drafts.exists() else ():
            session = WizardSession.model_validate_json(path.read_text(encoding="utf-8"))
            if (
                session.flow is flow
                and session.owner_id == owner_id
                and session.topic_id == topic_id
                and session.kst_day == kst_day
                and session.supersedes is None
                and session.finalized_event_id is not None
            ):
                return session
        return None

    def find_latest_finalized(
        self,
        owner_id: str,
        topic_id: str,
        kst_day: str,
    ) -> WizardSession | None:
        candidates: list[tuple[int, WizardSession]] = []
        for path in self._drafts.glob("*.json") if self._drafts.exists() else ():
            session = WizardSession.model_validate_json(path.read_text(encoding="utf-8"))
            if (
                session.owner_id == owner_id
                and session.topic_id == topic_id
                and session.kst_day == kst_day
                and session.finalized_event_id is not None
                and not session.safety_signals
            ):
                candidates.append((path.stat().st_mtime_ns, session))
        return max(candidates, default=(0, None), key=lambda item: item[0])[1]

    def find_finalized_event(self, event_id: str) -> WizardSession | None:
        """Return the immutable session snapshot that produced one canonical event."""
        if not event_id:
            return None
        for path in self._drafts.glob("*.json") if self._drafts.exists() else ():
            session = WizardSession.model_validate_json(path.read_text(encoding="utf-8"))
            if session.finalized_event_id == event_id:
                return session
        return None

    def latest_completed_morning_event(
        self,
        owner_id: str,
        topic_id: str,
        kst_day: str,
        base_event_id: str,
    ) -> str:
        """Follow completed correction links to the latest immutable event id."""
        return self.latest_completed_event(
            WizardFlow.MORNING, owner_id, topic_id, kst_day, base_event_id,
        )

    def latest_completed_event(
        self,
        flow: WizardFlow,
        owner_id: str,
        topic_id: str,
        kst_day: str,
        base_event_id: str,
    ) -> str:
        completed: dict[str, WizardSession] = {}
        for path in self._drafts.glob("*.json") if self._drafts.exists() else ():
            session = WizardSession.model_validate_json(path.read_text(encoding="utf-8"))
            if (
                session.flow is flow
                and session.owner_id == owner_id
                and session.topic_id == topic_id
                and session.kst_day == kst_day
                and session.finalized_event_id is not None
            ):
                completed[session.supersedes or ""] = session
        latest = base_event_id
        visited: set[str] = set()
        while latest in completed and latest not in visited:
            visited.add(latest)
            next_session = completed[latest]
            if next_session.finalized_event_id is None or next_session.finalized_event_id == latest:
                break
            latest = next_session.finalized_event_id
        return latest

    def load(self, session_id: str) -> WizardSession | None:
        """Load one opaque session identifier if it exists."""
        path = self._drafts / f"{session_id}.json"
        if not path.exists():
            return None
        return WizardSession.model_validate_json(path.read_text(encoding="utf-8"))

    def save(self, session: WizardSession) -> None:
        """Atomically replace one private session file."""
        self._drafts.mkdir(parents=True, exist_ok=True, mode=0o700)
        self._drafts.chmod(0o700)
        path = self._drafts / f"{session.session_id}.json"
        temporary = self._drafts / f".{session.session_id}.tmp"
        with temporary.open("w", encoding="utf-8") as handle:
            temporary.chmod(0o600)
            handle.write(session.model_dump_json(exclude_none=True))
            handle.flush()
            os.fsync(handle.fileno())
        os.replace(temporary, path)
        path.chmod(0o600)
