"""Repository for the token-unlock dilution-short CALENDAR (`unlock_events`). The calendar used to live in
`unlock_calendar.json` on the single-node RWO surfer-data PVC — the LAST PVC dependency forcing node-pinning
for the Bybit evals/book Jobs. This moves it into the operational DB so the sleeve / evals / Jobs read it
over 5432 (networked, node-agnostic), same rationale as the DuckDB->TimescaleDB warehouse consolidation.

`write_events` is an idempotent upsert keyed on (sym, cliff_day); `read_events` returns the SAME shape the
JSON loader returns today ([{"sym","cliff_day","n","cat"}]). Mirrors `PaperRepo`'s engine/StaticPool
construction so it works on in-memory SQLite (tests) as well as Postgres (prod)."""
from __future__ import annotations

import datetime as dt
from typing import Any

from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool

from fxhnt.adapters.persistence.cockpit_models import CockpitBase, UnlockEventRow


class UnlockCalendarRepo:
    def __init__(self, dsn: str) -> None:
        # StaticPool keeps a single in-memory SQLite connection alive across sessions; only for the
        # in-memory SQLite case (tests). Not used for Postgres. Mirrors PaperRepo.
        kw: dict[str, Any] = {"future": True}
        if dsn == "sqlite://" or (dsn.startswith("sqlite://") and ":memory:" in dsn):
            kw |= {"connect_args": {"check_same_thread": False}, "poolclass": StaticPool}
        self._engine = create_engine(dsn, **kw)

    def migrate(self) -> None:
        """Create `unlock_events` if it does not exist (idempotent). Only this table is created, not the
        whole cockpit metadata, so the repo is self-contained where it is constructed."""
        CockpitBase.metadata.create_all(self._engine, tables=[UnlockEventRow.__table__])

    def write_events(self, events: list[dict[str, Any]], *, at: dt.datetime | None = None) -> None:
        """Idempotent upsert of the calendar keyed on (sym, cliff_day). An EMPTY `events` is a no-op (never
        wipes a populated calendar — an empty refresh is ~always a transient DefiLlama/CoinGecko failure;
        same discipline as the json's `_refresh_calendar`)."""
        if not events:
            return
        ts = at or dt.datetime.now(dt.UTC)
        with Session(self._engine) as s:
            for e in events:
                s.merge(UnlockEventRow(
                    sym=str(e["sym"]), cliff_day=int(e["cliff_day"]),
                    n=float(e["n"]), cat=str(e.get("cat", "")), src_updated_at=ts))
            s.commit()

    def read_events(self) -> list[dict[str, Any]]:
        """The calendar as [{"sym","cliff_day","n","cat"}] — the EXACT shape `load_unlock_events` returns
        from the JSON file today. Empty list when the table is empty."""
        with Session(self._engine) as s:
            rows = s.scalars(select(UnlockEventRow)
                             .order_by(UnlockEventRow.cliff_day.asc(), UnlockEventRow.sym.asc()))
            return [{"sym": r.sym, "cliff_day": int(r.cliff_day), "n": float(r.n), "cat": r.cat}
                    for r in rows]

    def is_empty(self) -> bool:
        """True if no calendar rows exist (drives the loader's DB-first / JSON-fallback decision)."""
        with Session(self._engine) as s:
            return s.scalar(select(UnlockEventRow.sym).limit(1)) is None
