"""Versioned data migrations for stored JSON entities.

Each migration is a small class with a stable ``name`` (used as the idempotency
key) and an ``apply(db)`` method that performs the change inside a transaction.
The ``MigrationRunner`` keeps a ``data_migrations`` table that records which
migrations have already executed; subsequent runs are a no-op.

This replaces ad-hoc per-store migrations that were rewriting raw JSON via
``str.replace`` — that approach corrupted nested fields whose values happened
to share a substring with the renamed key, and had no transaction or audit.

Adding a new migration
----------------------

1. Subclass :class:`DataMigration`, give it a unique ``name`` (prefix with the
   next sequence number, e.g. ``"002_..."``) and implement ``apply``.
2. Append it to :data:`ALL_MIGRATIONS` in commit order. Never reorder existing
   entries — the runner records each by name.
3. The first ``ColorStripStore`` (or any other store) construction triggers the
   runner; nothing else has to change.
"""

from __future__ import annotations

import json
import sqlite3
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timezone

from ledgrab.storage.database import Database, is_writes_frozen
from ledgrab.utils import get_logger

logger = get_logger(__name__)


class DataMigration(ABC):
    """One JSON-blob migration step.

    Subclasses must declare a class-level ``name`` and implement ``apply``.
    Implementations must operate on the supplied ``conn`` (an already-open
    transaction). They MUST NOT commit or open nested transactions — the
    :class:`MigrationRunner` owns the transaction so the data UPDATEs and the
    audit-table INSERT are atomic.
    """

    name: str = ""

    @abstractmethod
    def apply(self, conn: sqlite3.Connection) -> int:
        """Perform the migration inside the supplied transaction.

        Return the number of rows changed.
        """


@dataclass(frozen=True)
class MigrationRecord:
    """A row in the ``data_migrations`` audit table."""

    name: str
    applied_at: str
    rows_changed: int


class MigrationRunner:
    """Apply pending data migrations exactly once per database.

    Each call to :meth:`run` opens a single transaction that covers (a) the
    "is this migration already applied?" check, (b) the migration body, and
    (c) the audit INSERT. That guarantees:

    * a partial-failure cannot leave data rewritten but unrecorded;
    * concurrent stores constructing on multiple threads cannot race each
      other into a UNIQUE-constraint crash on the audit table.

    The runner skips silently when writes are frozen so a post-restore boot
    does not mutate the freshly-restored database before the imminent restart.
    """

    _TABLE = "data_migrations"

    def __init__(self, db: Database):
        self._db = db
        self._ensure_table()

    def _ensure_table(self) -> None:
        # CREATE TABLE is idempotent thanks to IF NOT EXISTS; running on every
        # startup is cheap. ``Database.execute`` does NOT honour the
        # ``is_writes_frozen`` guard (DDL is always permitted), so the audit
        # table reliably exists even on a frozen boot.
        self._db.execute(
            f"""
            CREATE TABLE IF NOT EXISTS {self._TABLE} (
                name         TEXT PRIMARY KEY,
                applied_at   TEXT NOT NULL,
                rows_changed INTEGER NOT NULL DEFAULT 0
            )
            """
        )

    def applied_names(self) -> set[str]:
        """Return the set of migration names already recorded as applied."""
        cursor = self._db.execute(f"SELECT name FROM {self._TABLE}")
        return {row["name"] for row in cursor.fetchall()}

    def run(self, migrations: list[DataMigration]) -> list[MigrationRecord]:
        """Apply each migration in *migrations* that hasn't been recorded yet.

        Returns the records for migrations that actually executed this call.
        Returns an empty list when writes are frozen.
        """
        if is_writes_frozen():
            # Frozen-write databases are read-only between a restore and the
            # imminent restart. Surface this clearly because the in-memory
            # state may briefly reflect pre-migration data.
            logger.warning("Data migrations skipped: writes are frozen (restart expected).")
            return []

        # Validate names up-front so an unnamed migration aborts before any
        # transaction work.
        for migration in migrations:
            if not migration.name:
                raise ValueError(
                    f"DataMigration {type(migration).__name__} must declare a non-empty name"
                )

        executed: list[MigrationRecord] = []
        for migration in migrations:
            # Use a separate transaction per migration so a failure rolls back
            # only the failing one and any earlier (already-recorded)
            # migrations keep their audit rows.
            with self._db.transaction() as conn:
                already_applied = conn.execute(
                    f"SELECT 1 FROM {self._TABLE} WHERE name = ?",
                    (migration.name,),
                ).fetchone()
                if already_applied:
                    continue
                logger.info("Applying data migration: %s", migration.name)
                rows_changed = int(migration.apply(conn))
                applied_at = datetime.now(timezone.utc).isoformat()
                # INSERT OR IGNORE defends against a hypothetical concurrent
                # writer that recorded the same name between the SELECT above
                # and this INSERT (the RLock serialises this in practice, but
                # the constraint is the durable contract).
                conn.execute(
                    f"INSERT OR IGNORE INTO {self._TABLE} "
                    f"(name, applied_at, rows_changed) VALUES (?, ?, ?)",
                    (migration.name, applied_at, rows_changed),
                )
            executed.append(
                MigrationRecord(
                    name=migration.name,
                    applied_at=applied_at,
                    rows_changed=rows_changed,
                )
            )
            if rows_changed:
                logger.warning("Migration %s rewrote %d row(s)", migration.name, rows_changed)

        if executed:
            logger.info(
                "Applied %d data migration(s): %s",
                len(executed),
                ", ".join(r.name for r in executed),
            )
        return executed


# ---------------------------------------------------------------------------
# Concrete migrations
# ---------------------------------------------------------------------------


class StaticToSingleColorMigration(DataMigration):
    """Rename the legacy ``source_type='static'`` color-strip kind to ``single_color``.

    The pre-rename rows in ``color_strip_sources`` used ``source_type='static'``;
    the new canonical value is ``single_color``. The previous in-line string
    replace risked rewriting unrelated substrings (e.g. an animation type named
    ``static_wave``). This migration parses the JSON, mutates the single field
    we care about, and writes the canonical serialization back. Runs inside
    the runner's transaction.
    """

    name = "001_color_strip_static_to_single_color"

    def apply(self, conn: sqlite3.Connection) -> int:
        rows_changed = 0
        rows = conn.execute("SELECT id, data FROM [color_strip_sources]").fetchall()
        for row in rows:
            blob = row["data"]
            try:
                parsed = json.loads(blob)
            except json.JSONDecodeError as e:
                # A corrupt blob is a pre-existing problem; do not crash the
                # migration. The store's own load path will surface it.
                logger.warning(
                    "Skipping corrupt color_strip_sources row %s during migration: %s",
                    row["id"],
                    e,
                )
                continue
            if parsed.get("source_type") != "static":
                continue
            parsed["source_type"] = "single_color"
            conn.execute(
                "UPDATE [color_strip_sources] SET data = ? WHERE id = ?",
                (json.dumps(parsed, ensure_ascii=False), row["id"]),
            )
            rows_changed += 1
        return rows_changed


# Master list — ORDER MATTERS. Append new migrations; never reorder.
ALL_MIGRATIONS: list[DataMigration] = [
    StaticToSingleColorMigration(),
]
