"""Engine, sessions, and the per-frame lock that replaces the old
whole-config.json RLock.

Single uvicorn worker (see Dockerfile) -- handlers are sync and run in
the threadpool, so this is ordinary multi-threading in one process: the
same regime the old config.locked() RLock handled, now scoped per frame.
"""

from __future__ import annotations

import os
import threading
from contextlib import contextmanager
from typing import Iterator

from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker

from .models import WIDGET_CONFIG_MODELS, Frame, Widget

DATABASE_URL = os.environ.get("DATABASE_URL", "sqlite:////data/espresso.db")

_is_sqlite = DATABASE_URL.startswith("sqlite")

engine = create_engine(
    DATABASE_URL,
    connect_args={"check_same_thread": False} if _is_sqlite else {},
)

if _is_sqlite:

    @event.listens_for(engine, "connect")
    def _sqlite_pragmas(dbapi_connection, _record):
        cursor = dbapi_connection.cursor()
        cursor.execute("PRAGMA journal_mode=WAL")
        cursor.execute("PRAGMA foreign_keys=ON")
        cursor.execute("PRAGMA busy_timeout=5000")
        cursor.close()


# expire_on_commit=False so a Frame resolved by the require_device
# dependency (which commits its last_seen touch) stays usable in the
# route handler without a re-select per attribute. Freshness inside
# mutation spans is handled explicitly by frame_locked()'s refresh.
SessionLocal = sessionmaker(bind=engine, autoflush=False, expire_on_commit=False)


def get_db() -> Iterator[Session]:
    """FastAPI dependency: one session per request (FastAPI caches the
    dependency, so require_device and the route handler share it)."""
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


# One lock per frame id, created on demand. Guarded by a module lock so
# two threads can't race to create different Lock objects for the same
# frame (which would defeat the whole point).
_frame_locks: dict[int, threading.Lock] = {}
_frame_locks_guard = threading.Lock()


def _get_lock(frame_id: int) -> threading.Lock:
    with _frame_locks_guard:
        lock = _frame_locks.get(frame_id)
        if lock is None:
            lock = threading.Lock()
            _frame_locks[frame_id] = lock
        return lock


@contextmanager
def frame_locked(db: Session, frame_id: int) -> Iterator[Frame]:
    """Serializes a whole read-modify-write span on one frame -- the
    direct successor of the old config.locked(). The refresh() inside the
    lock is what makes it correct: without it the session could hold
    attribute state read *before* another thread's committed write, and
    saving would silently clobber it (the same lost-update race the old
    pattern's 're-read inside the lock' comment guarded against)."""
    with _get_lock(frame_id):
        frame = db.get(Frame, frame_id)
        if frame is None:
            raise LookupError(f"Frame {frame_id} does not exist")
        db.refresh(frame)
        yield frame
        db.commit()


@contextmanager
def widget_locked(db: Session, frame_id: int, widget_id: int) -> Iterator[tuple[Frame, Widget, object]]:
    """Same lock/refresh/commit dance as frame_locked, additionally
    resolving and refreshing the widget's own per-type config row
    (PhotoWidgetConfig/CalendarWidgetConfig/WhiteboardWidgetConfig, see
    models.WIDGET_CONFIG_MODELS). Deliberately still locks at *frame*
    granularity -- the exact same per-frame threading.Lock frame_locked
    uses, not a separate per-widget lock -- simplest, avoids a new class
    of multi-lock deadlock bugs, and this project's actual concurrency
    needs are tiny (a handful of users per household frame).

    threading.Lock is not reentrant: a caller executing several widget
    actions in one pass (e.g. a button press assigned multiple
    (widget, action) pairs, see routers/device.py) MUST call this once
    per action, sequentially, never nested inside an outer
    frame_locked/widget_locked span for the same frame -- nesting would
    deadlock instantly, not just misbehave."""
    with frame_locked(db, frame_id) as frame:
        widget = db.get(Widget, widget_id)
        if widget is None or widget.frame_id != frame_id:
            raise LookupError(f"Widget {widget_id} does not belong to frame {frame_id}")
        config_model = WIDGET_CONFIG_MODELS[widget.widget_type]
        config = db.get(config_model, widget_id)
        if config is None:
            raise LookupError(f"Widget {widget_id} has no {widget.widget_type} config row")
        db.refresh(config)
        yield frame, widget, config
