"""Persistenter Zustand unter dem konfigurierten state-Verzeichnis.

Ein Mechanismus für alles: JSON-Datei + exklusive Dateisperre (fcntl.flock
auf eine eigene .lock-Datei) + atomarer Ersatz (temp + fsync + os.replace).
flock sperrt je offener Dateibeschreibung; jede Transaktion öffnet die
Sperrdatei neu, damit sperrt sie auch Threads desselben Prozesses
gegeneinander und andere Prozesse (z. B. parallele Bridge-Aufrufe).

Beschädigte Zustandsdateien führen zu StateError (fail-closed), nie zu
einem stillen Neuanfang.
"""
from __future__ import annotations

import fcntl
import json
import os
import threading
from contextlib import contextmanager
from pathlib import Path
from typing import Iterator, Optional

from .core import Mode


class StateError(RuntimeError):
    def __init__(self, code: str):
        super().__init__(code)
        self.code = code


def ensure_private_dir(path: Path) -> Path:
    path.mkdir(mode=0o700, parents=True, exist_ok=True)
    if not path.is_dir():
        raise StateError("STATE_DIR_INVALID")
    return path


class LockedJsonFile:
    def __init__(self, path: Path):
        self.path = Path(path)
        self.lock_path = self.path.with_name(self.path.name + ".lock")

    @contextmanager
    def transaction(self) -> Iterator[dict]:
        ensure_private_dir(self.path.parent)
        lock_fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT, 0o600)
        try:
            fcntl.flock(lock_fd, fcntl.LOCK_EX)
            data = self._read()
            yield data
            self._write(data)
        finally:
            try:
                fcntl.flock(lock_fd, fcntl.LOCK_UN)
            finally:
                os.close(lock_fd)

    def read_snapshot(self) -> dict:
        ensure_private_dir(self.path.parent)
        lock_fd = os.open(self.lock_path, os.O_RDWR | os.O_CREAT, 0o600)
        try:
            fcntl.flock(lock_fd, fcntl.LOCK_SH)
            return self._read()
        finally:
            try:
                fcntl.flock(lock_fd, fcntl.LOCK_UN)
            finally:
                os.close(lock_fd)

    def _read(self) -> dict:
        try:
            with open(self.path, "r", encoding="utf-8") as handle:
                raw = handle.read()
        except FileNotFoundError:
            return {}
        except OSError as exc:
            raise StateError("STATE_UNREADABLE") from exc
        if not raw.strip():
            return {}
        try:
            data = json.loads(raw)
        except ValueError as exc:
            raise StateError("STATE_CORRUPT") from exc
        if not isinstance(data, dict):
            raise StateError("STATE_CORRUPT")
        return data

    def _write(self, data: dict) -> None:
        tmp = self.path.with_name(f".{self.path.name}.{os.getpid()}.{threading.get_ident()}.tmp")
        fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
        try:
            with os.fdopen(fd, "w", encoding="utf-8") as handle:
                json.dump(data, handle, sort_keys=True, separators=(",", ":"))
                handle.flush()
                os.fsync(handle.fileno())
            os.replace(tmp, self.path)
        except BaseException:
            try:
                os.unlink(tmp)
            except FileNotFoundError:
                pass
            raise


class IdempotencyStore:
    """Merkt sich verarbeitete Nachrichten (chat_id, message_id)."""

    def __init__(self, path: Path, ttl_seconds: int):
        self._file = LockedJsonFile(path)
        self._ttl = ttl_seconds

    def claim(self, key: str, job_id: str, now: float) -> tuple[bool, Optional[str]]:
        """True, wenn der Schlüssel neu ist und jetzt belegt wurde."""
        with self._file.transaction() as data:
            entries = data.setdefault("entries", {})
            for stale in [k for k, v in entries.items() if v.get("expires", 0) < now]:
                del entries[stale]
            existing = entries.get(key)
            if existing is not None:
                return False, existing.get("job_id")
            entries[key] = {"job_id": job_id, "expires": now + self._ttl}
            return True, None


class ModeStore:
    """Modellmodus je Chat (/auto, /codex, /claude)."""

    def __init__(self, path: Path):
        self._file = LockedJsonFile(path)

    def get(self, chat_id: str) -> Mode:
        data = self._file.read_snapshot()
        value = data.get("modes", {}).get(chat_id, Mode.AUTO.value)
        try:
            return Mode(value)
        except ValueError as exc:
            raise StateError("STATE_CORRUPT") from exc

    def set(self, chat_id: str, mode: Mode) -> None:
        if not isinstance(mode, Mode):
            raise ValueError("INVALID_MODE")
        with self._file.transaction() as data:
            data.setdefault("modes", {})[chat_id] = mode.value


class NonceStore:
    """Verbrauchte Clearance-Nonces (Replay-Schutz), persistent."""

    def __init__(self, path: Path, retention_seconds: int = 3600):
        self._file = LockedJsonFile(path)
        self._retention = retention_seconds

    def consume(self, nonce: str, expires_at: float, now: float) -> bool:
        with self._file.transaction() as data:
            used = data.setdefault("used", {})
            for stale in [n for n, exp in used.items() if exp + self._retention < now]:
                del used[stale]
            if nonce in used:
                return False
            used[nonce] = expires_at
            return True

    def was_used(self, nonce: str) -> bool:
        return nonce in self._file.read_snapshot().get("used", {})
