"""Shared workspace state — viewer state, selection, project context.

State is persisted to ~/.recoil-workspace/state.json. Both the MCP server
and the FastAPI server read/write this file. Atomic writes via tempfile +
os.replace() (same pattern as ExecutionStore).

State schema:
{
    "project": "tartarus",
    "selection": ["EP001_SH03", "EP001_SH04"],
    "viewer": {
        "shot_id": "EP001_SH03",
        "take_index": 2,
        "file_path": "output/previs/ep_001/shot_003_take2.png",
        "media_type": "image",
        "context": "Reviewing framing"
    },
    "browse_tab_active": true,
    "updated_at": "2026-04-12T01:30:00Z"
}
"""

import fcntl
import json
import logging
import os
import tempfile
import threading
import time as _time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from recoil.pipeline._lib.sanctioned_fallbacks import (
    FallbackRecord,
    fire_sanctioned_fallback,
    register_sanctioned_fallback,
)


# Tenet 6: corrupt workspace state.json quarantine + return-defaults is
# named + observable per the registry. The quarantine renames the corrupt
# file so the operator can inspect it; defaults are documented + canonical.
register_sanctioned_fallback(
    FallbackRecord(
        name="workspace_state_corrupt_default",
        justification=(
            "workspace state.json fails to parse (corrupt JSON, OS error). "
            "The corrupt file is quarantined as state.corrupt.<ns>.json and "
            "fresh defaults are returned so the workspace stays usable; the "
            "operator inspects the quarantined file separately."
        ),
        quality_neutrality_argument=(
            "The substitution (fresh _fresh_default()) flows ONLY into the "
            "workspace UI state (filters, selections, viewer prefs). It is "
            "user-facing UI state, never read by any generation pipeline. "
            "The corrupt file is preserved (renamed, not deleted) so no data "
            "loss occurs — only the in-memory state is reset."
        ),
        expected_substitution=(
            "_fresh_default() (canonical default workspace state) in place "
            "of the parsed corrupt JSON"
        ),
        introduced_in="Phase E.debug-R4",
    )
)

log = logging.getLogger(__name__)

_DEFAULT_STATE_DIR = Path.home() / ".recoil-workspace"
_STATE_DIR = _DEFAULT_STATE_DIR
_STATE_PATH = _STATE_DIR / "state.json"
_LOCK = threading.Lock()  # intra-process
_FLOCK_PATH = _STATE_DIR / ".state.lock"  # inter-process

_DEFAULT_STATE = {
    "project": None,
    "selection": [],
    "viewer": {
        "shot_id": None,
        "take_index": None,
        "file_path": None,
        "media_type": None,
        "context": None,
    },
    "browse_tab_active": True,
    "updated_at": None,
}


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


def _ensure_dir() -> None:
    _STATE_DIR.mkdir(parents=True, exist_ok=True)


def _state_lock_path() -> Path:
    """Return the lock path for the current state location.

    Older tests patch ``_STATE_DIR``/``_STATE_PATH`` directly without also
    patching ``_FLOCK_PATH``. Treat the original home-directory lock as the
    default sentinel so patched state dirs remain self-contained, while still
    honoring an explicit ``_FLOCK_PATH`` patch.
    """
    default_lock = _DEFAULT_STATE_DIR / ".state.lock"
    if _FLOCK_PATH != default_lock:
        return _FLOCK_PATH
    return _STATE_DIR / ".state.lock"


def _fresh_default() -> dict:
    """Return a deep copy of the default state (safe to mutate)."""
    d = dict(_DEFAULT_STATE)
    d["viewer"] = dict(_DEFAULT_STATE["viewer"])
    d["selection"] = list(_DEFAULT_STATE["selection"])
    return d


def read_state() -> dict:
    """Read the current workspace state. Returns default state if file missing."""
    if not _STATE_PATH.is_file():
        return _fresh_default()
    try:
        data = json.loads(_STATE_PATH.read_text(encoding="utf-8"))
        # Deep merge: start with defaults, then overlay saved data
        merged = _fresh_default()
        merged.update(data)
        # Deep merge viewer separately
        default_viewer = dict(_DEFAULT_STATE["viewer"])
        saved_viewer = data.get("viewer", {})
        if isinstance(saved_viewer, dict):
            default_viewer.update(saved_viewer)
        merged["viewer"] = default_viewer
        return merged
    except FileNotFoundError:
        return _fresh_default()
    except (json.JSONDecodeError, OSError) as e:
        # Quarantine the corrupt file with a nanosecond timestamp suffix,
        # then fire the sanctioned fallback. Renaming preserves the bad
        # file for the operator to inspect rather than silently
        # overwriting it on the next write.
        quarantined = _STATE_PATH.with_suffix(
            f".corrupt.{_time.time_ns()}.json"
        )
        try:
            _STATE_PATH.rename(quarantined)
        except OSError as rn_err:
            fire_sanctioned_fallback(
                "workspace_state_corrupt_default",
                state_path=str(_STATE_PATH),
                quarantine_failed=True,
                rename_error=str(rn_err),
                source_error=str(e),
            )
            return _fresh_default()
        fire_sanctioned_fallback(
            "workspace_state_corrupt_default",
            state_path=str(_STATE_PATH),
            quarantined_to=str(quarantined),
            source_error=str(e),
        )
        return _fresh_default()


def write_state(state: dict) -> None:
    """Atomically write workspace state to disk.

    Uses fcntl.flock for cross-process safety (BUG-5 fix).
    """
    _ensure_dir()
    state["updated_at"] = _now_iso()
    with _LOCK:
        lock_fd = os.open(str(_state_lock_path()), os.O_CREAT | os.O_RDWR)
        try:
            fcntl.flock(lock_fd, fcntl.LOCK_EX)
            fd, tmp = tempfile.mkstemp(
                dir=str(_STATE_DIR), prefix=".state_", suffix=".json"
            )
            try:
                with os.fdopen(fd, "w", encoding="utf-8") as f:
                    json.dump(state, f, indent=2)
                os.replace(tmp, str(_STATE_PATH))
            except Exception:
                try:
                    os.unlink(tmp)
                except OSError:
                    pass
                raise
        finally:
            fcntl.flock(lock_fd, fcntl.LOCK_UN)
            os.close(lock_fd)


def get_project() -> Optional[str]:
    """Get the current project name."""
    return read_state().get("project")


def set_project(project: str) -> None:
    """Set the active project."""
    state = read_state()
    state["project"] = project
    state["selection"] = []
    state["viewer"] = dict(_DEFAULT_STATE["viewer"])
    write_state(state)


def get_selection() -> list[str]:
    """Get the list of currently selected shot IDs."""
    return read_state().get("selection", [])


def set_selection(shot_ids: list[str]) -> None:
    """Set the selection to a list of shot IDs."""
    state = read_state()
    state["selection"] = shot_ids
    write_state(state)


def get_viewer_state() -> dict:
    """Get the current viewer state."""
    return read_state().get("viewer", dict(_DEFAULT_STATE["viewer"]))


def set_viewer_state(
    shot_id: Optional[str] = None,
    take_index: Optional[int] = None,
    file_path: Optional[str] = None,
    media_type: Optional[str] = None,
    context: Optional[str] = None,
) -> None:
    """Update the viewer state."""
    state = read_state()
    viewer = state.get("viewer", {})
    # RISK-7 fix: when file_path changes, reset stale fields
    if file_path is not None and file_path != viewer.get("file_path"):
        viewer["shot_id"] = None
        viewer["take_index"] = None
        viewer["context"] = None
    if shot_id is not None:
        viewer["shot_id"] = shot_id
    if take_index is not None:
        viewer["take_index"] = take_index
    if file_path is not None:
        viewer["file_path"] = file_path
        # Auto-detect media type from extension
        ext = Path(file_path).suffix.lower()
        if ext in (".mp4", ".mov", ".webm"):
            viewer["media_type"] = "video"
        elif ext in (".png", ".jpg", ".jpeg", ".webp"):
            viewer["media_type"] = "image"
    if media_type is not None:
        viewer["media_type"] = media_type
    if context is not None:
        viewer["context"] = context
    state["viewer"] = viewer
    write_state(state)


def detect_dropbox_conflicts(shots_dir: Path) -> list[str]:
    """Check for Dropbox conflicted copy files in the shots directory.

    Returns list of conflict file paths (empty if clean).
    """
    conflicts = []
    if not shots_dir.is_dir():
        return conflicts
    for path in shots_dir.iterdir():
        if "conflicted copy" in path.name.lower():
            conflicts.append(str(path))
    return conflicts
