"""Persistence for the project↔Claude-CLI session_id map (~/.recoil/chat-sessions.json)."""
from __future__ import annotations

import fcntl
import json
import os
import tempfile
import threading
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path

from recoil.core.exceptions import ChatSessionsCorruptError

SCHEMA_VERSION = 1

_DEFAULT_PATH = Path.home() / ".recoil" / "chat-sessions.json"
_SESSIONS_KEY = "sessions"
_SESSION_ID_KEY = "session_id"
_LAST_USED_KEY = "last_used"
_LOCK = threading.Lock()


def _now_iso() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


@dataclass
class ChatSessionsStore:
    path: Path = field(default_factory=lambda: _DEFAULT_PATH)

    def __post_init__(self) -> None:
        self.path = Path(self.path)

    @property
    def _lock_path(self) -> Path:
        return self.path.with_suffix(self.path.suffix + ".lock")

    def _read(self) -> dict:
        try:
            raw = self.path.read_text(encoding="utf-8")
        except FileNotFoundError:
            return {"schema_version": SCHEMA_VERSION, _SESSIONS_KEY: {}}
        except OSError as exc:
            raise ChatSessionsCorruptError(
                str(self.path), f"could not read: {exc}"
            ) from exc
        try:
            doc = json.loads(raw)
        except json.JSONDecodeError as exc:
            raise ChatSessionsCorruptError(
                str(self.path), f"invalid JSON: {exc}"
            ) from exc
        if not isinstance(doc, dict):
            raise ChatSessionsCorruptError(
                str(self.path), f"top-level must be object, got {type(doc).__name__}"
            )
        if doc.get("schema_version") != SCHEMA_VERSION:
            raise ChatSessionsCorruptError(
                str(self.path),
                f"unsupported schema_version: {doc.get('schema_version')!r}",
            )
        sessions = doc.get(_SESSIONS_KEY)
        if not isinstance(sessions, dict):
            raise ChatSessionsCorruptError(
                str(self.path), f"'{_SESSIONS_KEY}' must be an object"
            )
        return doc

    def _write(self, doc: dict) -> None:
        # fcntl.flock guards the whole read-modify-write across processes
        # (FastAPI + MCP server can both touch this file). Mirrors the
        # workspace/state.py BUG-5 fix.
        self.path.parent.mkdir(parents=True, exist_ok=True)
        with _LOCK:
            lock_fd = os.open(str(self._lock_path), os.O_CREAT | os.O_RDWR)
            try:
                fcntl.flock(lock_fd, fcntl.LOCK_EX)
                fd, tmp_path = tempfile.mkstemp(
                    dir=str(self.path.parent),
                    prefix=self.path.name + ".",
                    suffix=".tmp",
                )
                try:
                    with os.fdopen(fd, "w", encoding="utf-8") as f:
                        json.dump(doc, f, indent=2, sort_keys=True)
                        f.flush()
                        os.fsync(f.fileno())
                    os.replace(tmp_path, str(self.path))
                except Exception:
                    try:
                        os.unlink(tmp_path)
                    except OSError:
                        pass
                    raise
            finally:
                fcntl.flock(lock_fd, fcntl.LOCK_UN)
                os.close(lock_fd)

    def get_session(self, project_id: str) -> str | None:
        entry = self._read()[_SESSIONS_KEY].get(project_id)
        if entry is None:
            return None
        return entry.get(_SESSION_ID_KEY)

    def record_session(self, project_id: str, session_id: str) -> None:
        doc = self._read()
        doc[_SESSIONS_KEY][project_id] = {
            _SESSION_ID_KEY: session_id,
            _LAST_USED_KEY: _now_iso(),
        }
        self._write(doc)

    def list_sessions(self) -> dict[str, dict]:
        return dict(self._read()[_SESSIONS_KEY])

    def bump_last_used(self, project_id: str) -> None:
        doc = self._read()
        entry = doc[_SESSIONS_KEY].get(project_id)
        if entry is None:
            return
        entry[_LAST_USED_KEY] = _now_iso()
        self._write(doc)

    def lookup_project_by_session_id(self, session_id: str) -> str | None:
        for project_id, entry in self._read()[_SESSIONS_KEY].items():
            if entry.get(_SESSION_ID_KEY) == session_id:
                return project_id
        return None


__all__ = [
    "ChatSessionsStore",
    "ChatSessionsCorruptError",
    "SCHEMA_VERSION",
]
