"""Persistence store interface and file implementation.

StudioStore is the persistence protocol. FileStudioStore is the file-based implementation.
For Postgres/Supabase, implement the StudioStore protocol.
"""

from __future__ import annotations

import json
import logging
import os
import tempfile
import threading
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Protocol, runtime_checkable

from pydantic import BaseModel, Field

from core.common import JsonObject
from core.common.paths import repo_root
from persistence.models import (
    Document,
    Folder,
    Project,
    Revision,
    RunEvent,
    RunSummary,
    Workspace,
)

logger = logging.getLogger(__name__)

# =============================================================================
# HELPERS
# =============================================================================


def _utcnow_iso() -> str:
    return datetime.now(UTC).isoformat()


def _safe_segment(s: str) -> str:
    return str(s).replace("/", "_").replace("\\", "_").replace(":", "_")


def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None:
    """Atomically write JSON to path using temp file + rename."""
    path.parent.mkdir(parents=True, exist_ok=True)
    data = json.dumps(payload, indent=2, sort_keys=True)
    with tempfile.NamedTemporaryFile(
        "w", delete=False, dir=str(path.parent), prefix=path.name, suffix=".tmp"
    ) as f:
        f.write(data)
        tmp_name = f.name
    os.replace(tmp_name, str(path))


def _read_json(path: Path) -> JsonObject | None:
    if not path.exists():
        return None
    try:
        v = json.loads(path.read_text())
        return v if isinstance(v, dict) else None
    except Exception:
        logger.debug("Failed to read JSON from %s", path, exc_info=True)
        return None


# =============================================================================
# STORE PROTOCOL
# =============================================================================


class PatchIdempotencyRecord(BaseModel):
    """Record stored for patch idempotency checks."""

    new_rev: str
    base_rev: str | None = None


class CommitPatchResult(BaseModel):
    """Result of a patch commit."""

    doc_id: str
    new_rev: str
    head_rev: str


class ThreadRecord(BaseModel):
    """A persisted thread record — mirrors ThreadState for explicit persistence.

    Fields align with ``core.types.state.ThreadState`` so that the
    ``PersistentStateStore`` conversion is a clean round-trip rather
    than relying on ``extra='allow'`` to carry unknown fields.
    """

    model_config = {"extra": "allow"}

    id: str

    # Identity (match ThreadState.agent_id / thread_id)
    agent_id: str = ""
    thread_id: str = ""

    # Conversation messages (serialised ConversationMessage dicts)
    messages: list[dict[str, Any]] = Field(default_factory=list)

    # Working memory and extensible metadata
    working_memory: dict[str, Any] = Field(default_factory=dict)
    metadata: dict[str, Any] = Field(default_factory=dict)

    # Counters
    last_active: str | None = None
    turn_count: int = 0
    total_tokens_used: int = 0
    schema_version: int = 1

    # Persistence bookkeeping
    workspace_id: str | None = None
    updated_at: str | None = None


@runtime_checkable
class StudioStore(Protocol):
    """Persistence interface for all storage backends.

    **All methods accept and return Pydantic models — never raw dicts.**

    Implementations:
    - FileStudioStore (file-based, for dev)
    - PostgresStore (planned, for prod)
    """

    # Documents
    def get_document(self, doc_id: str) -> Document | None: ...
    def upsert_document(self, doc: Document) -> Document: ...
    def list_documents(
        self,
        *,
        workspace_id: str | None = None,
        parent_id: str | None = None,
        include_deleted: bool = False,
    ) -> list[Document]: ...
    def list_all_documents(
        self, *, workspace_id: str, include_deleted: bool = False
    ) -> list[Document]: ...

    # Workspaces
    def get_workspace(self, workspace_id: str) -> Workspace | None: ...
    def upsert_workspace(self, ws: Workspace) -> Workspace: ...
    def list_workspaces(self, *, include_deleted: bool = False) -> list[Workspace]: ...

    # Projects
    def get_project(self, project_id: str) -> Project | None: ...
    def upsert_project(self, project: Project) -> Project: ...
    def list_projects(
        self, *, workspace_id: str, include_deleted: bool = False
    ) -> list[Project]: ...

    # Folders
    def get_folder(self, folder_id: str) -> Folder | None: ...
    def upsert_folder(self, folder: Folder) -> Folder: ...
    def list_folders(
        self,
        *,
        workspace_id: str,
        parent_id: str | None = None,
        include_deleted: bool = False,
    ) -> list[Folder]: ...
    def list_all_folders(
        self, *, workspace_id: str, include_deleted: bool = False
    ) -> list[Folder]: ...

    # Revisions
    def write_revision(self, *, doc_id: str, rev_id: str, content: dict[str, Any]) -> Revision: ...
    def get_revision(self, *, doc_id: str, rev_id: str) -> Revision | None: ...

    # Patch idempotency
    def get_patch_idempotency(
        self, *, doc_id: str, client_patch_id: str
    ) -> PatchIdempotencyRecord | None: ...
    def put_patch_idempotency(
        self, *, doc_id: str, client_patch_id: str, record: PatchIdempotencyRecord
    ) -> PatchIdempotencyRecord: ...
    def commit_patch(
        self,
        *,
        doc_id: str,
        expected_head_rev: str | None,
        base_rev: str | None,
        new_rev: str,
        content: dict[str, Any],
        client_patch_id: str | None,
    ) -> CommitPatchResult: ...

    # Runs
    def upsert_run_summary(self, *, summary: RunSummary) -> RunSummary: ...
    def get_run_summary(self, *, run_id: str) -> RunSummary | None: ...
    def list_run_summaries(
        self,
        *,
        workspace_id: str | None = None,
        doc_id: str | None = None,
        limit: int = 50,
    ) -> list[RunSummary]: ...
    def append_run_event(self, *, event: RunEvent) -> RunEvent: ...
    def list_run_events(
        self, *, run_id: str, after_seq: int | None = None, limit: int = 500
    ) -> list[RunEvent]: ...

    # Threads
    def save_thread(self, thread_id: str, data: ThreadRecord) -> None: ...
    def get_thread(self, thread_id: str) -> ThreadRecord | None: ...
    def list_threads(
        self,
        *,
        workspace_id: str | None = None,
        limit: int = 100,
        offset: int = 0,
    ) -> list[ThreadRecord]: ...
    def delete_thread(self, thread_id: str) -> bool: ...
    def count_threads(self, *, workspace_id: str | None = None) -> int: ...


# =============================================================================
# FILE STORE CONFIG
# =============================================================================


class FileStudioStoreConfig(BaseModel):
    """Configuration for file-backed studio store."""

    model_config = {"frozen": True}

    root: str = ".albus_store/studio"


# =============================================================================
# FILE STORE STATE (internal)
# =============================================================================


class _FileStoreState(BaseModel):
    """Internal path state for FileStudioStore."""

    model_config = {"frozen": True, "arbitrary_types_allowed": True}

    root: Path
    docs: Path
    projects: Path
    workspaces: Path
    folders: Path
    revs: Path
    threads: Path
    idempotency: Path
    runs: Path
    lock: threading.RLock

    @classmethod
    def from_config(cls, config: FileStudioStoreConfig) -> _FileStoreState:
        root = Path(config.root)
        if not root.is_absolute():
            try:
                root = (repo_root() / root).resolve()
            except Exception:
                root = root.resolve()

        state = cls(
            root=root,
            docs=root / "documents",
            projects=root / "projects",
            workspaces=root / "workspaces",
            folders=root / "folders",
            revs=root / "revisions",
            threads=root / "threads",
            idempotency=root / "idempotency",
            runs=root / "runs",
            lock=threading.RLock(),
        )
        for p in (
            state.docs,
            state.projects,
            state.workspaces,
            state.folders,
            state.revs,
            state.threads,
            state.idempotency,
            state.runs,
        ):
            p.mkdir(parents=True, exist_ok=True)
        return state

    def ws_dir(self, workspace_id: str) -> Path:
        return self.workspaces / _safe_segment(workspace_id)

    def ws_docs_dir(self, workspace_id: str) -> Path:
        return self.ws_dir(workspace_id) / "documents"

    def ws_folders_dir(self, workspace_id: str) -> Path:
        return self.ws_dir(workspace_id) / "folders"

    def ws_projects_dir(self, workspace_id: str) -> Path:
        return self.ws_dir(workspace_id) / "projects"

    def ws_revs_dir(self, workspace_id: str) -> Path:
        return self.ws_dir(workspace_id) / "revisions"

    def ws_idempotency_dir(self, workspace_id: str) -> Path:
        return self.ws_dir(workspace_id) / "idempotency"

    def ws_runs_dir(self, workspace_id: str) -> Path:
        return self.ws_dir(workspace_id) / "runs"


# =============================================================================
# FILE STORE IMPLEMENTATION
# =============================================================================


class FileStudioStore:
    """File-based storage implementation.

    Reads/writes JSON files to disk. All public methods return Pydantic models —
    conversion happens at the boundary so callers never see raw dicts.

    Layout:
        root/
            documents/
            workspaces/
            projects/
            folders/
            revisions/
            threads/
            runs/
    """

    def __init__(self, config: FileStudioStoreConfig | None = None):
        self._cfg = config or FileStudioStoreConfig()
        self._state = _FileStoreState.from_config(self._cfg)

    @property
    def has_persistence(self) -> bool:
        """Whether the file store's root directory exists."""
        return self._state.root.exists()

    # -------------------------------------------------------------------------
    # Internal helpers — dict ↔ model boundary
    # -------------------------------------------------------------------------

    @staticmethod
    def _to_document(raw: dict[str, Any]) -> Document:
        return Document.model_validate(raw)

    @staticmethod
    def _to_workspace(raw: dict[str, Any]) -> Workspace:
        return Workspace.model_validate(raw)

    @staticmethod
    def _to_project(raw: dict[str, Any]) -> Project:
        return Project.model_validate(raw)

    @staticmethod
    def _to_folder(raw: dict[str, Any]) -> Folder:
        return Folder.model_validate(raw)

    @staticmethod
    def _to_revision(raw: dict[str, Any]) -> Revision:
        return Revision.model_validate(raw)

    @staticmethod
    def _to_thread(raw: dict[str, Any]) -> ThreadRecord:
        return ThreadRecord.model_validate(raw)

    @staticmethod
    def _dump(model: BaseModel) -> dict[str, Any]:
        """Serialize a model to a JSON-safe dict for storage."""
        return model.model_dump(mode="json")

    # -------------------------------------------------------------------------
    # Documents
    # -------------------------------------------------------------------------

    def _get_document_raw(self, doc_id: str) -> dict[str, Any] | None:
        """Internal: get raw dict for a document (used by merge logic)."""
        path = self._state.docs / f"{_safe_segment(doc_id)}.json"
        doc = _read_json(path)
        if not doc:
            return None
        ws_id = str(doc.get("workspace_id") or "")
        if ws_id:
            ws_path = self._state.ws_docs_dir(ws_id) / f"{_safe_segment(doc_id)}.json"
            ws_doc = _read_json(ws_path)
            if ws_doc:
                return ws_doc
        return doc

    def get_document(self, doc_id: str) -> Document | None:
        with self._state.lock:
            raw = self._get_document_raw(doc_id)
            return self._to_document(raw) if raw else None

    def upsert_document(self, doc: Document) -> Document:
        with self._state.lock:
            doc_id = doc.id
            if not doc_id:
                msg = "document.id is required"
                raise ValueError(msg)

            now = _utcnow_iso()
            incoming = self._dump(doc)
            path = self._state.docs / f"{_safe_segment(doc_id)}.json"
            existing = _read_json(path) or {}
            ws_hint = str(incoming.get("workspace_id") or existing.get("workspace_id") or "")
            existing_ws = {}
            if ws_hint:
                ws_path = self._state.ws_docs_dir(ws_hint) / f"{_safe_segment(doc_id)}.json"
                existing_ws = _read_json(ws_path) or {}

            merged = {
                **existing,
                **existing_ws,
                **incoming,
                "id": doc_id,
                "updated_at": now,
                "created_at": existing.get("created_at")
                or existing_ws.get("created_at")
                or incoming.get("created_at")
                or now,
            }
            _atomic_write_json(path, merged)

            ws_id = str(merged.get("workspace_id") or "")
            if ws_id:
                ws_path = self._state.ws_docs_dir(ws_id) / f"{_safe_segment(doc_id)}.json"
                _atomic_write_json(ws_path, merged)
            return self._to_document(merged)

    def list_documents(
        self,
        *,
        workspace_id: str | None = None,
        parent_id: str | None = None,
        include_deleted: bool = False,
    ) -> list[Document]:
        with self._state.lock:
            by_id: dict[str, JsonObject] = {}
            if workspace_id:
                for path in self._state.ws_docs_dir(workspace_id).glob("*.json"):
                    doc = _read_json(path)
                    if doc and (did := str(doc.get("id") or "")):
                        by_id[did] = doc
            for path in self._state.docs.glob("*.json"):
                doc = _read_json(path)
                if not doc:
                    continue
                did = str(doc.get("id") or "")
                if not did:
                    continue
                if workspace_id is not None and doc.get("workspace_id") != workspace_id:
                    continue
                by_id.setdefault(did, doc)

            out: list[JsonObject] = []
            for doc in by_id.values():
                if not include_deleted and doc.get("deleted_at") is not None:
                    continue
                if parent_id is not None and doc.get("parent_id") != parent_id:
                    continue
                if parent_id is None and doc.get("parent_id") is not None:
                    continue
                out.append(doc)
            out.sort(
                key=lambda d: str(d.get("updated_at") or d.get("created_at") or ""), reverse=True
            )
            return [self._to_document(d) for d in out]

    def list_all_documents(
        self, *, workspace_id: str, include_deleted: bool = False
    ) -> list[Document]:
        with self._state.lock:
            by_id: dict[str, JsonObject] = {}
            for path in self._state.ws_docs_dir(workspace_id).glob("*.json"):
                doc = _read_json(path)
                if doc and (did := str(doc.get("id") or "")):
                    by_id[did] = doc
            for path in self._state.docs.glob("*.json"):
                doc = _read_json(path)
                if doc and doc.get("workspace_id") == workspace_id:
                    did = str(doc.get("id") or "")
                    if did:
                        by_id.setdefault(did, doc)
            out = [d for d in by_id.values() if include_deleted or d.get("deleted_at") is None]
            out.sort(
                key=lambda d: str(d.get("updated_at") or d.get("created_at") or ""), reverse=True
            )
            return [self._to_document(d) for d in out]

    # -------------------------------------------------------------------------
    # Workspaces
    # -------------------------------------------------------------------------

    def get_workspace(self, workspace_id: str) -> Workspace | None:
        with self._state.lock:
            path = self._state.workspaces / f"{_safe_segment(workspace_id)}.json"
            raw = _read_json(path)
            return self._to_workspace(raw) if raw else None

    def upsert_workspace(self, ws: Workspace) -> Workspace:
        with self._state.lock:
            ws_id = ws.id
            if not ws_id:
                msg = "workspace.id is required"
                raise ValueError(msg)
            now = _utcnow_iso()
            incoming = self._dump(ws)
            path = self._state.workspaces / f"{_safe_segment(ws_id)}.json"
            existing = _read_json(path) or {}
            merged = {
                **existing,
                **incoming,
                "id": ws_id,
                "updated_at": now,
                "created_at": existing.get("created_at") or incoming.get("created_at") or now,
            }
            _atomic_write_json(path, merged)
            return self._to_workspace(merged)

    def list_workspaces(self, *, include_deleted: bool = False) -> list[Workspace]:
        with self._state.lock:
            out: list[JsonObject] = []
            for path in self._state.workspaces.glob("*.json"):
                ws = _read_json(path)
                if ws and (include_deleted or ws.get("deleted_at") is None):
                    out.append(ws)
            out.sort(
                key=lambda d: str(d.get("updated_at") or d.get("created_at") or ""), reverse=True
            )
            return [self._to_workspace(d) for d in out]

    # -------------------------------------------------------------------------
    # Projects
    # -------------------------------------------------------------------------

    def get_project(self, project_id: str) -> Project | None:
        with self._state.lock:
            path = self._state.projects / f"{_safe_segment(project_id)}.json"
            proj = _read_json(path)
            if not proj:
                return None
            ws_id = str(proj.get("workspace_id") or "")
            if ws_id:
                ws_path = self._state.ws_projects_dir(ws_id) / f"{_safe_segment(project_id)}.json"
                ws_proj = _read_json(ws_path)
                if ws_proj:
                    return self._to_project(ws_proj)
            return self._to_project(proj)

    def upsert_project(self, project: Project) -> Project:
        with self._state.lock:
            pid = project.id
            ws_id = project.workspace_id
            if not pid:
                msg = "project.id is required"
                raise ValueError(msg)
            if not ws_id:
                msg = "project.workspace_id is required"
                raise ValueError(msg)

            now = _utcnow_iso()
            incoming = self._dump(project)
            path = self._state.projects / f"{_safe_segment(pid)}.json"
            existing = _read_json(path) or {}
            merged = {
                **existing,
                **incoming,
                "id": pid,
                "workspace_id": ws_id,
                "updated_at": now,
                "created_at": existing.get("created_at") or incoming.get("created_at") or now,
            }
            _atomic_write_json(path, merged)
            ws_dir = self._state.ws_projects_dir(ws_id)
            ws_dir.mkdir(parents=True, exist_ok=True)
            _atomic_write_json(ws_dir / f"{_safe_segment(pid)}.json", merged)
            return self._to_project(merged)

    def list_projects(
        self, *, workspace_id: str, include_deleted: bool = False
    ) -> list[Project]:
        with self._state.lock:
            ws_dir = self._state.ws_projects_dir(workspace_id)
            ws_dir.mkdir(parents=True, exist_ok=True)
            out: list[JsonObject] = []
            for path in ws_dir.glob("*.json"):
                p = _read_json(path)
                if p and (include_deleted or p.get("deleted_at") is None):
                    out.append(p)
            out.sort(
                key=lambda d: str(d.get("updated_at") or d.get("created_at") or ""), reverse=True
            )
            return [self._to_project(d) for d in out]

    # -------------------------------------------------------------------------
    # Folders
    # -------------------------------------------------------------------------

    def get_folder(self, folder_id: str) -> Folder | None:
        with self._state.lock:
            path = self._state.folders / f"{_safe_segment(folder_id)}.json"
            folder = _read_json(path)
            if not folder:
                return None
            ws_id = str(folder.get("workspace_id") or "")
            if ws_id:
                ws_path = self._state.ws_folders_dir(ws_id) / f"{_safe_segment(folder_id)}.json"
                ws_folder = _read_json(ws_path)
                if ws_folder:
                    return self._to_folder(ws_folder)
            return self._to_folder(folder)

    def upsert_folder(self, folder: Folder) -> Folder:
        with self._state.lock:
            folder_id = folder.id
            ws_id = folder.workspace_id
            if not folder_id:
                msg = "folder.id is required"
                raise ValueError(msg)
            if not ws_id:
                msg = "folder.workspace_id is required"
                raise ValueError(msg)

            now = _utcnow_iso()
            incoming = self._dump(folder)
            path = self._state.folders / f"{_safe_segment(folder_id)}.json"
            ws_path = self._state.ws_folders_dir(ws_id) / f"{_safe_segment(folder_id)}.json"
            existing_global = _read_json(path) or {}
            existing_ws = _read_json(ws_path) or {}
            merged = {
                **existing_global,
                **existing_ws,
                **incoming,
                "id": folder_id,
                "workspace_id": ws_id,
                "updated_at": now,
                "created_at": existing_global.get("created_at")
                or existing_ws.get("created_at")
                or incoming.get("created_at")
                or now,
            }
            _atomic_write_json(path, merged)
            _atomic_write_json(ws_path, merged)
            return self._to_folder(merged)

    def list_folders(
        self,
        *,
        workspace_id: str,
        parent_id: str | None = None,
        include_deleted: bool = False,
    ) -> list[Folder]:
        with self._state.lock:
            by_id: dict[str, JsonObject] = {}
            for path in self._state.ws_folders_dir(workspace_id).glob("*.json"):
                f = _read_json(path)
                if f and (fid := str(f.get("id") or "")):
                    by_id[fid] = f
            for path in self._state.folders.glob("*.json"):
                f = _read_json(path)
                if f and f.get("workspace_id") == workspace_id:
                    fid = str(f.get("id") or "")
                    if fid:
                        by_id.setdefault(fid, f)

            out: list[JsonObject] = []
            for f in by_id.values():
                if not include_deleted and f.get("deleted_at") is not None:
                    continue
                if parent_id is not None and f.get("parent_id") != parent_id:
                    continue
                if parent_id is None and f.get("parent_id") is not None:
                    continue
                out.append(f)
            out.sort(
                key=lambda d: str(d.get("updated_at") or d.get("created_at") or ""), reverse=True
            )
            return [self._to_folder(d) for d in out]

    def list_all_folders(
        self, *, workspace_id: str, include_deleted: bool = False
    ) -> list[Folder]:
        with self._state.lock:
            by_id: dict[str, JsonObject] = {}
            for path in self._state.ws_folders_dir(workspace_id).glob("*.json"):
                f = _read_json(path)
                if f and (fid := str(f.get("id") or "")):
                    by_id[fid] = f
            for path in self._state.folders.glob("*.json"):
                f = _read_json(path)
                if f and f.get("workspace_id") == workspace_id:
                    fid = str(f.get("id") or "")
                    if fid:
                        by_id.setdefault(fid, f)
            out = [f for f in by_id.values() if include_deleted or f.get("deleted_at") is None]
            out.sort(
                key=lambda d: str(d.get("updated_at") or d.get("created_at") or ""), reverse=True
            )
            return [self._to_folder(d) for d in out]

    # -------------------------------------------------------------------------
    # Revisions
    # -------------------------------------------------------------------------

    def write_revision(
        self, *, doc_id: str, rev_id: str, content: dict[str, Any]
    ) -> Revision:
        with self._state.lock:
            if not doc_id or not rev_id:
                msg = "doc_id and rev_id are required"
                raise ValueError(msg)
            now = _utcnow_iso()
            payload = {"doc_id": doc_id, "rev_id": rev_id, "created_at": now, "content": content}

            base = self._state.revs / _safe_segment(doc_id)
            base.mkdir(parents=True, exist_ok=True)
            _atomic_write_json(base / f"{_safe_segment(rev_id)}.json", payload)

            doc = self.get_document(doc_id)
            ws_id = doc.workspace_id if doc else ""
            if ws_id:
                ws_base = self._state.ws_revs_dir(ws_id) / _safe_segment(doc_id)
                ws_base.mkdir(parents=True, exist_ok=True)
                _atomic_write_json(ws_base / f"{_safe_segment(rev_id)}.json", payload)
            return self._to_revision(payload)

    def get_revision(self, *, doc_id: str, rev_id: str) -> Revision | None:
        with self._state.lock:
            doc = self.get_document(doc_id)
            ws_id = doc.workspace_id if doc else ""
            if ws_id:
                ws_path = (
                    self._state.ws_revs_dir(ws_id)
                    / _safe_segment(doc_id)
                    / f"{_safe_segment(rev_id)}.json"
                )
                v = _read_json(ws_path)
                if v:
                    return self._to_revision(v)
            path = self._state.revs / _safe_segment(doc_id) / f"{_safe_segment(rev_id)}.json"
            raw = _read_json(path)
            return self._to_revision(raw) if raw else None

    # -------------------------------------------------------------------------
    # Patch Idempotency
    # -------------------------------------------------------------------------

    def get_patch_idempotency(
        self, *, doc_id: str, client_patch_id: str
    ) -> PatchIdempotencyRecord | None:
        with self._state.lock:
            doc = self.get_document(doc_id)
            ws_id = doc.workspace_id if doc else ""

            paths: list[Path] = []
            if ws_id:
                paths.append(
                    self._state.ws_idempotency_dir(ws_id) / f"{_safe_segment(doc_id)}.json"
                )
            paths.append(self._state.idempotency / f"{_safe_segment(doc_id)}.json")

            for path in paths:
                payload = _read_json(path)
                if not payload:
                    continue
                rec = (payload.get("records") or {}).get(str(client_patch_id))
                if isinstance(rec, dict):
                    return PatchIdempotencyRecord.model_validate(rec)
            return None

    def put_patch_idempotency(
        self, *, doc_id: str, client_patch_id: str, record: PatchIdempotencyRecord
    ) -> PatchIdempotencyRecord:
        with self._state.lock:
            doc = self.get_document(doc_id)
            ws_id = doc.workspace_id if doc else ""

            target_paths: list[Path] = []
            if ws_id:
                target_paths.append(
                    self._state.ws_idempotency_dir(ws_id) / f"{_safe_segment(doc_id)}.json"
                )
            target_paths.append(self._state.idempotency / f"{_safe_segment(doc_id)}.json")

            payload: dict[str, Any] = {"doc_id": str(doc_id), "records": {}}
            for p in target_paths:
                existing = _read_json(p)
                if existing:
                    payload = existing
                    break

            records = payload.get("records") or {}
            cid = str(client_patch_id)
            existing_rec = records.get(cid)
            if isinstance(existing_rec, dict):
                return PatchIdempotencyRecord.model_validate(existing_rec)
            records[cid] = record.model_dump(mode="json")
            payload["records"] = records

            for p in target_paths:
                _atomic_write_json(p, payload)
            return record

    def commit_patch(
        self,
        *,
        doc_id: str,
        expected_head_rev: str | None,
        base_rev: str | None,
        new_rev: str,
        content: dict[str, Any],
        client_patch_id: str | None,
    ) -> CommitPatchResult:
        """Atomic patch commit with optimistic concurrency."""
        with self._state.lock:
            doc = self.get_document(doc_id)
            if not doc:
                msg = f"document not found: {doc_id}"
                raise ValueError(msg)

            current_head = doc.head_rev
            if expected_head_rev is not None and current_head != expected_head_rev:
                from persistence.errors import ConflictError  # noqa: PLC0415

                raise ConflictError(
                    message="head revision mismatch",
                    doc_id=doc_id,
                    base_rev=base_rev,
                    head_rev=str(current_head) if current_head else None,
                )

            self.write_revision(doc_id=doc_id, rev_id=new_rev, content=content)
            # Update head_rev on the existing document model
            updated_doc = doc.model_copy(update={"head_rev": new_rev})
            self.upsert_document(updated_doc)

            if client_patch_id:
                self.put_patch_idempotency(
                    doc_id=doc_id,
                    client_patch_id=client_patch_id,
                    record=PatchIdempotencyRecord(new_rev=new_rev, base_rev=base_rev),
                )

            return CommitPatchResult(doc_id=doc_id, new_rev=new_rev, head_rev=new_rev)

    # -------------------------------------------------------------------------
    # Runs
    # -------------------------------------------------------------------------

    def upsert_run_summary(self, *, summary: RunSummary) -> RunSummary:
        with self._state.lock:
            data = summary.model_dump(mode="json")
            data["updated_at"] = _utcnow_iso()
            path = self._state.runs / f"{_safe_segment(summary.run_id)}.summary.json"
            _atomic_write_json(path, data)
            if summary.workspace_id:
                ws_path = (
                    self._state.ws_runs_dir(summary.workspace_id)
                    / f"{_safe_segment(summary.run_id)}.summary.json"
                )
                ws_path.parent.mkdir(parents=True, exist_ok=True)
                _atomic_write_json(ws_path, data)
            return RunSummary.model_validate(data)

    def get_run_summary(self, *, run_id: str) -> RunSummary | None:
        with self._state.lock:
            path = self._state.runs / f"{_safe_segment(run_id)}.summary.json"
            data = _read_json(path)
            if not data:
                return None
            try:
                return RunSummary.model_validate(data)
            except Exception:
                return None

    def list_run_summaries(
        self,
        *,
        workspace_id: str | None = None,
        doc_id: str | None = None,
        limit: int = 50,
    ) -> list[RunSummary]:
        with self._state.lock:
            scan_dir = self._state.ws_runs_dir(workspace_id) if workspace_id else self._state.runs
            if not scan_dir.exists():
                return []
            out = []
            for p in scan_dir.glob("*.summary.json"):
                data = _read_json(p)
                if not data:
                    continue
                try:
                    s = RunSummary.model_validate(data)
                except Exception:
                    continue
                if workspace_id and s.workspace_id != workspace_id:
                    continue
                if doc_id and s.doc_id != doc_id:
                    continue
                out.append(s)
            out.sort(key=lambda s: (s.finished_at_ms or 0, s.started_at_ms or 0), reverse=True)
            return out[: max(1, limit)]

    def append_run_event(self, *, event: RunEvent) -> RunEvent:
        with self._state.lock:
            line = json.dumps(event.model_dump(mode="json"), ensure_ascii=False)
            path = self._state.runs / f"{_safe_segment(event.run_id)}.events.jsonl"
            path.parent.mkdir(parents=True, exist_ok=True)
            with path.open("a", encoding="utf-8") as f:
                f.write(line + "\n")
            return event

    def list_run_events(
        self, *, run_id: str, after_seq: int | None = None, limit: int = 500
    ) -> list[RunEvent]:
        with self._state.lock:
            path = self._state.runs / f"{_safe_segment(run_id)}.events.jsonl"
            if not path.exists():
                return []
            out = []
            max_n = max(1, limit)
            with path.open("r", encoding="utf-8") as f:
                for line in f:
                    if not line.strip():
                        continue
                    try:
                        ev = RunEvent.model_validate(json.loads(line))
                    except Exception:
                        continue
                    if after_seq is not None and ev.seq <= after_seq:
                        continue
                    out.append(ev)
                    if len(out) >= max_n:
                        break
            return out

    # -------------------------------------------------------------------------
    # Threads
    # -------------------------------------------------------------------------

    def save_thread(self, thread_id: str, data: ThreadRecord) -> None:
        with self._state.lock:
            path = self._state.threads / f"{_safe_segment(thread_id)}.json"
            raw = data.model_dump(mode="json")
            raw["id"] = thread_id
            raw["updated_at"] = _utcnow_iso()
            _atomic_write_json(path, raw)

    def get_thread(self, thread_id: str) -> ThreadRecord | None:
        with self._state.lock:
            path = self._state.threads / f"{_safe_segment(thread_id)}.json"
            raw = _read_json(path)
            return self._to_thread(raw) if raw else None

    def list_threads(
        self,
        *,
        workspace_id: str | None = None,
        limit: int = 100,
        offset: int = 0,
    ) -> list[ThreadRecord]:
        with self._state.lock:
            if not self._state.threads.exists():
                return []
            threads: list[JsonObject] = []
            for path in self._state.threads.glob("*.json"):
                data = _read_json(path)
                if data is None:
                    continue
                if workspace_id is not None and data.get("workspace_id") != workspace_id:
                    continue
                threads.append(data)
            threads.sort(key=lambda t: t.get("updated_at", ""), reverse=True)
            return [self._to_thread(t) for t in threads[offset : offset + limit]]

    def delete_thread(self, thread_id: str) -> bool:
        with self._state.lock:
            path = self._state.threads / f"{_safe_segment(thread_id)}.json"
            if not path.exists():
                return False
            path.unlink()
            return True

    def count_threads(self, *, workspace_id: str | None = None) -> int:
        with self._state.lock:
            if not self._state.threads.exists():
                return 0
            if workspace_id is None:
                return len(list(self._state.threads.glob("*.json")))
            count = 0
            for path in self._state.threads.glob("*.json"):
                data = _read_json(path)
                if data and data.get("workspace_id") == workspace_id:
                    count += 1
            return count


__all__ = [
    "CommitPatchResult",
    "FileStudioStore",
    "FileStudioStoreConfig",
    "PatchIdempotencyRecord",
    "StudioStore",
    "ThreadRecord",
]
