"""Raw climate record store — TimescaleDB hypertables (Postgres) behind polars.

The per-cell daily record (the multi-decade archive history and the hourly
recent+forecast bundle) is the *source of truth* for grading. On Postgres it
lives in two tables managed by Alembic (``backend/alembic/versions``):

* ``climate_history`` — a TimescaleDB **hypertable** (durable/LOGGED) of the full
  daily archive, keyed ``(cell_id, date)``. Expensive to refetch (45 years,
  rate-limited upstream), so it is durable, not a throwaway cache.
* ``climate_recent`` — a plain table holding the recent-observations + forward
  forecast bundle (includes *future* dates), fully rewritten each refresh.
* ``climate_sync`` — per-cell freshness (epoch seconds), replacing the parquet
  file mtimes that used to drive the topup cadence, the forecast TTL, and the
  ``recent_stamp`` token embedded in derived-payload validity (see api/payloads.py).

This module is the Postgres backend only. ``climate.py`` keeps the parquet cache
as the backend when ``THERMOGRAPH_DATABASE_URL`` is *not* a Postgres URL (dev,
tests, offline tooling) — the dialect switch mirrors ``data/store.py`` and
``accounts/db.py`` exactly, which is what keeps the test suite Postgres-free.

Every helper is **fail-soft**: a connection or query that fails is swallowed and
returns "absent" (``None`` / ``0.0``). Unlike ``store.py`` (which degrades to
recompute-from-parquet), a miss here degrades to *fetch-from-upstream* — prod has
no parquet fallback, and ``climate.py``'s loaders already treat "no cache" as
"go fetch".
"""
import contextlib
import os
import threading

import polars as pl

# Bump when a new persisted metric column is added to the daily record. A cell
# whose stored rows predate the bump reads back as schema-incomplete (see
# read_history), so climate.py refetches to fill the new column — the Postgres
# analogue of the parquet NEW_COLS presence check.
COLS_VERSION = 1

# The persisted daily columns, in a stable order. ``humid`` is the RAW relative
# humidity the archive returns; absolute humidity + wet-bulb are derived at the
# read boundary in climate._derive_metrics, never stored (so the store matches
# what the parquet write path persisted). ``feels`` IS stored. ``doy`` is not —
# climate._with_doy recomputes it at read time.
COLS = ("date", "tmax", "tmin", "precip", "wind", "gust", "humid", "fmax", "fmin", "feels")

# Explicit polars schema so a frame read back from Postgres has the exact dtypes
# the parquet path produced (pl.Date + Float64), even when the result is empty —
# so grading.py / scoring.py / api/payloads.py are untouched by the backend switch.
_SCHEMA: dict[str, pl.DataType] = {
    "date": pl.Date,
    **{c: pl.Float64 for c in COLS if c != "date"},
}

DATABASE_URL = os.environ.get("THERMOGRAPH_DATABASE_URL", "").strip()
IS_POSTGRES = DATABASE_URL.startswith("postgresql")


def is_postgres() -> bool:
    """True when the climate record is served from Postgres (prod), False when it
    falls back to the parquet cache (dev / tests / offline)."""
    return IS_POSTGRES


def _libpq_dsn(url: str) -> str:
    """A plain libpq URL psycopg accepts: drop the SQLAlchemy driver suffix
    (``postgresql+asyncpg://`` / ``+psycopg://`` -> ``postgresql://``)."""
    return url.replace("+asyncpg", "").replace("+psycopg", "")


_PG_DSN = _libpq_dsn(DATABASE_URL) if IS_POSTGRES else ""

# Bounded pool, replacing the old thread-local connection (one psycopg.connect per
# THREAD, opened lazily, never closed). Starlette's sync threadpool can spin up
# dozens of worker threads per uvicorn worker; with N workers x this module x
# data/store.py all doing the same thing, that had no ceiling and could exhaust
# Postgres max_connections, taking down every module sharing the database at
# once. min_size=1 keeps an idle deploy cheap; max_size=8 is a modest per-process
# ceiling that still lets several concurrent cell fetches proceed without
# serializing on one connection. connect_timeout + statement_timeout bound how
# long a caller (or, worse, the notifier's leader-election flock) can be wedged
# by an unresponsive Postgres.
_POOL_MIN_SIZE = 1
_POOL_MAX_SIZE = 8

_pool_lock = threading.Lock()
_pool_obj = None   # type: "psycopg_pool.ConnectionPool | None"


def _pool():
    """Lazily open (once) the bounded connection pool, or None when Postgres is
    off or the pool can't be opened — callers treat that exactly like the old
    _conn() returning None."""
    global _pool_obj
    if not IS_POSTGRES:
        return None
    if _pool_obj is not None:
        return _pool_obj
    with _pool_lock:
        if _pool_obj is None:
            try:
                import psycopg_pool  # local import: only the Postgres path needs it
                _pool_obj = psycopg_pool.ConnectionPool(
                    _PG_DSN,
                    min_size=_POOL_MIN_SIZE,
                    max_size=_POOL_MAX_SIZE,
                    kwargs={
                        "autocommit": True,
                        "connect_timeout": 5,
                        "options": "-c statement_timeout=30000",
                    },
                    open=True,
                )
            except Exception:  # noqa: BLE001 - the store is optional; callers fetch on miss
                return None
    return _pool_obj


@contextlib.contextmanager
def _conn():
    """Borrow a connection from the bounded pool for the duration of one
    operation, or yield None when Postgres is off or unreachable. Every caller
    frames exactly one operation in a ``with _conn() as conn:`` block, so the
    connection is returned to the pool (or discarded, if broken) the moment that
    operation finishes — replacing the old pattern where a thread opened one raw
    connection and held it open, unclosed, for its entire lifetime."""
    if not IS_POSTGRES:
        yield None
        return
    pool = _pool()
    if pool is None:
        yield None
        return
    with pool.connection() as conn:
        yield conn


# --- freshness (climate_sync) -----------------------------------------------

def _get_sync(conn, cell_id):
    """(history_synced_at, recent_synced_at, cols_version) for a cell, or None."""
    row = conn.execute(
        "SELECT history_synced_at, recent_synced_at, cols_version "
        "FROM climate_sync WHERE cell_id = %s",
        (cell_id,),
    ).fetchone()
    return row


def _bump_sync(conn, cell_id, *, history=None, recent=None, cols_version=None) -> None:
    """Upsert one cell's freshness. A None field leaves the stored value unchanged
    on update (COALESCE), so a recent-bundle write never disturbs history_synced_at
    and vice-versa. On insert, cols_version defaults to 1."""
    conn.execute(
        """
        INSERT INTO climate_sync (cell_id, history_synced_at, recent_synced_at, cols_version)
        VALUES (%(c)s, %(h)s, %(r)s, COALESCE(%(v)s, 1))
        ON CONFLICT (cell_id) DO UPDATE SET
            history_synced_at = COALESCE(%(h)s, climate_sync.history_synced_at),
            recent_synced_at  = COALESCE(%(r)s, climate_sync.recent_synced_at),
            cols_version      = COALESCE(%(v)s, climate_sync.cols_version)
        """,
        {"c": cell_id, "h": history, "r": recent, "v": cols_version},
    )


def history_synced_at(cell_id: str) -> float:
    """Epoch seconds of the cell's last history write/topup; 0.0 if absent."""
    try:
        with _conn() as conn:
            if conn is None:
                return 0.0
            row = _get_sync(conn, cell_id)
            return float(row[0]) if row and row[0] is not None else 0.0
    except Exception:  # noqa: BLE001
        return 0.0


def recent_synced_at(cell_id: str) -> float:
    """Epoch seconds of the cell's last recent-bundle write; 0.0 if absent. Backs
    climate.recent_stamp, so it must advance only on a real rewrite (see write_recent)."""
    try:
        with _conn() as conn:
            if conn is None:
                return 0.0
            row = _get_sync(conn, cell_id)
            return float(row[1]) if row and row[1] is not None else 0.0
    except Exception:  # noqa: BLE001
        return 0.0


def history_max_date(cell_id: str) -> "str | None":
    """The ISO date (YYYY-MM-DD) of the cell's newest archived day, or None when
    there is no cached history (or Postgres is off/unreachable).

    Backs the content-page cache token (api/payloads.content_token), so it must be
    CHEAP — an indexed ``MAX(date)`` over the ``(cell_id, date)`` primary key (see
    the 0002 migration), never a full-history load. Fail-soft: any error reads as
    None, so the caller falls back to a 'none' bucket rather than raising."""
    try:
        with _conn() as conn:
            if conn is None:
                return None
            row = conn.execute(
                "SELECT MAX(date) FROM climate_history WHERE cell_id = %s",
                (cell_id,),
            ).fetchone()
            if not row or row[0] is None:
                return None
            return row[0].isoformat()
    except Exception:  # noqa: BLE001
        return None


def cols_version(cell_id: str) -> int:
    """The stored schema version for a cell's history; 0 if absent."""
    try:
        with _conn() as conn:
            if conn is None:
                return 0
            row = _get_sync(conn, cell_id)
            return int(row[2]) if row and row[2] is not None else 0
    except Exception:  # noqa: BLE001
        return 0


# --- reads ------------------------------------------------------------------

def _read_frame(conn, table: str, cell_id: str) -> pl.DataFrame:
    """A cell's rows from ``table`` as a polars frame with the canonical schema.
    ``table`` is a module constant, never user input."""
    rows = conn.execute(
        f"SELECT {', '.join(COLS)} FROM {table} WHERE cell_id = %s ORDER BY date",
        (cell_id,),
    ).fetchall()
    return pl.DataFrame(rows, schema=_SCHEMA, orient="row")


def read_history(cell_id: str) -> "tuple[pl.DataFrame, float] | None":
    """(history frame, history_synced_at) for a cell, or None when there is no
    schema-complete cached record. A stored cols_version below COLS_VERSION reads
    as absent, so climate.py refetches to add the new column(s)."""
    try:
        with _conn() as conn:
            if conn is None:
                return None
            sync = _get_sync(conn, cell_id)
            if sync is None or sync[0] is None:
                return None
            if (sync[2] or 0) < COLS_VERSION:
                return None
            df = _read_frame(conn, "climate_history", cell_id)
            if df.is_empty():
                return None
            return df, float(sync[0])
    except Exception:  # noqa: BLE001
        return None


def read_history_raw(cell_id: str) -> "pl.DataFrame | None":
    """A cell's history rows regardless of schema version — for the stale-serve
    fallback when every upstream fetch fails (the Postgres analogue of reading the
    parquet file directly, bypassing the NEW_COLS completeness check). None when
    there are no rows."""
    try:
        with _conn() as conn:
            if conn is None:
                return None
            df = _read_frame(conn, "climate_history", cell_id)
            return None if df.is_empty() else df
    except Exception:  # noqa: BLE001
        return None


def read_recent(cell_id: str) -> "tuple[pl.DataFrame, float] | None":
    """(recent+forecast frame, recent_synced_at) for a cell, or None when absent."""
    try:
        with _conn() as conn:
            if conn is None:
                return None
            sync = _get_sync(conn, cell_id)
            if sync is None or sync[1] is None:
                return None
            df = _read_frame(conn, "climate_recent", cell_id)
            if df.is_empty():
                return None
            return df, float(sync[1])
    except Exception:  # noqa: BLE001
        return None


# --- writes -----------------------------------------------------------------

def _copy_rows(cur, table: str, cell_id: str, df: pl.DataFrame) -> None:
    """COPY a cell's frame into ``table`` (text format). NaN was already folded to
    null upstream in climate._finalize_frame, so nulls copy cleanly."""
    with cur.copy(f"COPY {table} (cell_id, {', '.join(COLS)}) FROM STDIN") as copy:
        for row in df.select(COLS).iter_rows():
            copy.write_row((cell_id, *row))


def write_history(cell_id: str, df: pl.DataFrame, synced_at: float) -> None:
    """Upsert a cell's history rows and stamp history_synced_at. Idempotent: a
    re-fetched day supersedes its stored duplicate (ON CONFLICT DO UPDATE), which
    mirrors the parquet unique(subset="date", keep="last") merge. Best-effort."""
    try:
        with _conn() as conn:
            if conn is None:
                return
            with conn.transaction():
                conn.execute(
                    "CREATE TEMP TABLE _hist_stage (LIKE climate_history) ON COMMIT DROP")
                with conn.cursor() as cur:
                    _copy_rows(cur, "_hist_stage", cell_id, df)
                set_cols = ", ".join(f"{c} = EXCLUDED.{c}" for c in COLS if c != "date")
                conn.execute(
                    f"INSERT INTO climate_history (cell_id, {', '.join(COLS)}) "
                    f"SELECT cell_id, {', '.join(COLS)} FROM _hist_stage "
                    f"ON CONFLICT (cell_id, date) DO UPDATE SET {set_cols}")
                _bump_sync(conn, cell_id, history=synced_at, cols_version=COLS_VERSION)
    except Exception:  # noqa: BLE001 - failing to cache must not fail the request
        pass


def touch_history(cell_id: str, synced_at: float) -> None:
    """Bump only history_synced_at — the analogue of ``os.utime`` on the parquet
    file when the archive tail is already current, so the hourly topup timer resets
    without a rewrite. Best-effort."""
    try:
        with _conn() as conn:
            if conn is None:
                return
            _bump_sync(conn, cell_id, history=synced_at)
    except Exception:  # noqa: BLE001
        pass


def write_recent(cell_id: str, df: pl.DataFrame, synced_at: float) -> None:
    """Replace a cell's recent+forecast bundle (delete + COPY in one transaction)
    and stamp recent_synced_at. The window slides and forecast values change each
    refresh, so a full replace is the simplest correct write. Call ONLY on a real
    rewrite — recent_synced_at feeds recent_stamp, which must not advance when a
    stale bundle is served without a refetch. Best-effort."""
    try:
        with _conn() as conn:
            if conn is None:
                return
            with conn.transaction():
                conn.execute("DELETE FROM climate_recent WHERE cell_id = %s", (cell_id,))
                with conn.cursor() as cur:
                    _copy_rows(cur, "climate_recent", cell_id, df)
                _bump_sync(conn, cell_id, recent=synced_at)
    except Exception:  # noqa: BLE001
        pass
