"""Applying schema changes, in order, exactly once.

A deliberate ~100 lines rather than Alembic. Alembic without the SQLAlchemy ORM
is awkward, and pulling in an ORM to issue DDL for two tables is a large
dependency for a small job — this platform's small, auditable surface has been
worth keeping.

The trade is real and worth naming: no autogeneration, no downgrade path, and
this code is ours to maintain. Migrations here are hand-written and forward-only,
which for an application that ships to installations we do not babysit is
arguably the safer shape anyway — a `down` nobody tested is not a rollback plan.

The file discovery, ordering and checksum logic below is pure and separately
tested; only `apply` touches a database.
"""

from __future__ import annotations

import hashlib
import re
from dataclasses import dataclass
from pathlib import Path

MIGRATIONS_DIR = Path(__file__).parent / "migrations"

#: 0001_name.sql — the number orders them, the name says what it does.
FILENAME = re.compile(r"^(\d{4})_([a-z0-9_]+)\.sql$")


class MigrationError(RuntimeError):
    """A migration cannot be applied safely."""


@dataclass(frozen=True, slots=True)
class Migration:
    version: str
    name: str
    sql: str

    @property
    def checksum(self) -> str:
        return hashlib.sha256(self.sql.encode("utf-8")).hexdigest()

    @property
    def label(self) -> str:
        return f"{self.version}_{self.name}"


def discover(directory: Path | None = None) -> list[Migration]:
    """Read the migrations on disk, in the order they must run.

    Sorted by the numeric prefix rather than by filename, so 0002 and 0010 order
    correctly the day there are ten of them — string sort puts 0010 before 0002
    only when someone drops the zero padding, and someone eventually does.

    A file that does not match the naming rule is an error, not something to
    skip: a migration silently ignored because of a typo is a schema change that
    never happened on one installation and did on every other.
    """
    directory = directory or MIGRATIONS_DIR

    if not directory.is_dir():
        raise MigrationError(f"No migrations directory at {directory}.")

    migrations: list[Migration] = []
    seen: dict[str, str] = {}

    for path in sorted(directory.iterdir()):
        if path.is_dir() or path.name.startswith("."):
            continue

        match = FILENAME.match(path.name)

        if not match:
            raise MigrationError(
                f"'{path.name}' is not a migration filename. Expected 0001_some_name.sql."
            )

        version, name = match.group(1), match.group(2)

        if version in seen:
            # Two files claiming the same position have no defined order between
            # them, and whichever ran first would differ by filesystem.
            raise MigrationError(f"Two migrations share version {version}: {seen[version]}, {path.name}.")

        seen[version] = path.name
        migrations.append(Migration(version=version, name=name, sql=path.read_text(encoding="utf-8")))

    return sorted(migrations, key=lambda m: int(m.version))


def pending(all_migrations: list[Migration], applied: dict[str, str]) -> list[Migration]:
    """What still needs to run, refusing if history has been rewritten.

    ``applied`` maps version to the checksum recorded when it ran. A mismatch
    means somebody edited a migration that has already been applied somewhere —
    which produces two installations with the same version number and different
    schemas, and no way to tell them apart afterwards. Editing is the mistake;
    the fix is a new migration.
    """
    for migration in all_migrations:
        recorded = applied.get(migration.version)

        if recorded is not None and recorded != migration.checksum:
            raise MigrationError(
                f"Migration {migration.label} has changed since it was applied. "
                "Add a new migration instead of editing one that has already run."
            )

    return [m for m in all_migrations if m.version not in applied]


SCHEMA_MIGRATIONS = """
CREATE TABLE IF NOT EXISTS schema_migrations (
    version    TEXT PRIMARY KEY,
    name       TEXT NOT NULL,
    checksum   TEXT NOT NULL,
    applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""


class Migrator:
    """Runs pending migrations against a live database."""

    def __init__(self, connect, directory: Path | None = None) -> None:
        # A callable rather than a connection: migrations run at startup, when
        # the database may not be up yet, and holding a connection from before
        # that point is how a service fails to start for a reason nobody can see.
        self._connect = connect
        self._directory = directory

    def apply(self) -> list[str]:
        """Bring the schema up to date. Returns what it ran."""
        migrations = discover(self._directory)
        ran: list[str] = []

        with self._connect() as connection:
            with connection.cursor() as cursor:
                cursor.execute(SCHEMA_MIGRATIONS)
                cursor.execute("SELECT version, checksum FROM schema_migrations")
                applied = {row[0]: row[1] for row in cursor.fetchall()}

            for migration in pending(migrations, applied):
                # One transaction per migration. A single transaction around all
                # of them looks safer and is worse: PostgreSQL rolls back the
                # whole batch on any failure, so a long-running migration that
                # succeeded is undone by a typo in the next one.
                with connection.transaction(), connection.cursor() as cursor:
                    cursor.execute(migration.sql)
                    cursor.execute(
                        "INSERT INTO schema_migrations (version, name, checksum) VALUES (%s, %s, %s)",
                        (migration.version, migration.name, migration.checksum),
                    )

                ran.append(migration.label)

        return ran
