"""Versionierte Schema-Migrationen.

Der Vorgaenger hatte eine ``_ensure_schema()``-Funktion, die bei jedem Start
rund 35 idempotente ``ALTER TABLE``-Blocks durchlief, plus Sentinel-Zeilen in
der ``settings``-Tabelle als Marker fuer Einmal-Migrationen. Das funktioniert,
ist aber nicht nachvollziehbar: welche Migration lief wann, in welcher
Reihenfolge, mit welchem Inhalt?

Hier: eine ``schema_migrations``-Tabelle mit ``(component, version)``, einer
Pruefsumme des Migrationsinhalts und Zeitstempel. Jede Migration laeuft genau
einmal, in einer eigenen Transaktion, in Versionsreihenfolge.

``component`` trennt die Namensraeume: ``core`` bringt Benutzer/Gruppen/
Sitzungen/Audit mit, jede Anwendung ihre Fachtabellen. Dadurch koennen beide
Anwendungen dieselben Core-Migrationen nutzen, ohne dass sich Versionsnummern
in die Quere kommen.
"""

from __future__ import annotations

import hashlib
import logging
import sqlite3
from collections.abc import Callable, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timezone

from .db import Database

logger = logging.getLogger(__name__)

MigrationFn = Callable[[sqlite3.Connection], None]


@dataclass(frozen=True, slots=True)
class Migration:
    """Eine Schemaaenderung.

    ``sql`` wird als Skript ausgefuehrt, danach ggf. ``fn`` fuer Datenmigrationen,
    die sich in reinem SQL nicht sinnvoll ausdruecken lassen.

    ``allow_integrity_error``: fuer nachtraeglich eingefuehrte Unique-Indizes.
    Trifft so ein Index auf bereits vorhandene, verletzende Altdaten, darf der
    Anwendungsstart nicht crashen -- er loggt und laeuft weiter. Genau dieses
    Muster hat sich im Vorgaengerprojekt bewaehrt.
    """

    version: int
    name: str
    sql: str = ""
    fn: MigrationFn | None = None
    allow_integrity_error: bool = False
    tags: tuple[str, ...] = field(default=())

    @property
    def checksum(self) -> str:
        payload = f"{self.version}:{self.name}:{self.sql}".encode("utf-8")
        if self.fn is not None:
            payload += f":fn={self.fn.__name__}".encode("utf-8")
        return hashlib.sha256(payload).hexdigest()[:16]


def split_statements(script: str) -> list[str]:
    """Zerlegt ein SQL-Skript in einzelne Anweisungen.

    Warum nicht ``executescript``: das setzt in Pythons sqlite3-Treiber
    **implizit ein COMMIT** ab, bevor es laeuft. Innerhalb einer laufenden
    Transaktion zerreisst das genau die Atomizitaet, die eine Migration
    braucht. Also: selbst zerlegen und Anweisung fuer Anweisung ausfuehren.

    Der Splitter beruecksichtigt Zeichenketten, Zeilen- und Blockkommentare;
    ``BEGIN ... END``-Bloecke (Trigger) werden zusammengehalten.
    """
    statements: list[str] = []
    current: list[str] = []
    index = 0
    length = len(script)
    depth = 0  # BEGIN/END-Tiefe fuer Trigger

    while index < length:
        char = script[index]
        nxt = script[index + 1] if index + 1 < length else ""

        if char == "-" and nxt == "-":
            end = script.find("\n", index)
            index = length if end == -1 else end + 1
            continue
        if char == "/" and nxt == "*":
            end = script.find("*/", index + 2)
            index = length if end == -1 else end + 2
            continue
        if char in "'\"":
            quote = char
            current.append(char)
            index += 1
            while index < length:
                current.append(script[index])
                if script[index] == quote:
                    if index + 1 < length and script[index + 1] == quote:
                        current.append(script[index + 1])
                        index += 2
                        continue
                    index += 1
                    break
                index += 1
            continue

        if char == ";" and depth == 0:
            statement = "".join(current).strip()
            if statement:
                statements.append(statement)
            current = []
            index += 1
            continue

        current.append(char)
        upper_tail = "".join(current[-6:]).upper()
        if upper_tail.endswith("BEGIN"):
            depth += 1
        elif upper_tail.endswith("END") and depth > 0:
            depth -= 1
        index += 1

    tail = "".join(current).strip()
    if tail:
        statements.append(tail)
    return statements


def run_script(conn: sqlite3.Connection, script: str) -> None:
    for statement in split_statements(script):
        conn.execute(statement)


_BOOTSTRAP = """
CREATE TABLE IF NOT EXISTS schema_migrations (
    component  TEXT    NOT NULL,
    version    INTEGER NOT NULL,
    name       TEXT    NOT NULL,
    checksum   TEXT    NOT NULL,
    applied_at TEXT    NOT NULL,
    PRIMARY KEY (component, version)
);
"""


class MigrationRunner:
    """Fuehrt Migrationen mehrerer Komponenten gegen eine Datenbank aus."""

    def __init__(self, database: Database) -> None:
        self.database = database

    def applied(self, conn: sqlite3.Connection, component: str) -> dict[int, dict[str, str]]:
        rows = Database.all(
            conn,
            "SELECT version, name, checksum, applied_at FROM schema_migrations WHERE component=?",
            (component,),
        )
        return {int(row["version"]): row for row in rows}

    def run(self, component: str, migrations: Sequence[Migration]) -> list[str]:
        """Wendet fehlende Migrationen an. Rueckgabe: Namen der ausgefuehrten."""
        ordered = sorted(migrations, key=lambda m: m.version)
        versions = [m.version for m in ordered]
        if len(set(versions)) != len(versions):
            duplicates = sorted({v for v in versions if versions.count(v) > 1})
            raise ValueError(f"Doppelte Migrationsversionen in {component}: {duplicates}")

        executed: list[str] = []
        with self.database.session() as conn:
            run_script(conn, _BOOTSTRAP)
            known = self.applied(conn, component)

            for migration in ordered:
                record = known.get(migration.version)
                if record is not None:
                    if record["checksum"] != migration.checksum:
                        logger.warning(
                            "Migration %s/%s (%s) wurde nachtraeglich geaendert "
                            "(Pruefsumme %s != %s). Bereits angewendete Migrationen werden "
                            "nicht erneut ausgefuehrt -- neue Aenderungen brauchen eine neue "
                            "Versionsnummer.",
                            component,
                            migration.version,
                            migration.name,
                            record["checksum"],
                            migration.checksum,
                        )
                    continue

                try:
                    with self.database.transaction(conn):
                        if migration.sql.strip():
                            run_script(conn, migration.sql)
                        if migration.fn is not None:
                            migration.fn(conn)
                        conn.execute(
                            "INSERT INTO schema_migrations "
                            "(component, version, name, checksum, applied_at) VALUES (?,?,?,?,?)",
                            (
                                component,
                                migration.version,
                                migration.name,
                                migration.checksum,
                                datetime.now(timezone.utc).isoformat(timespec="seconds"),
                            ),
                        )
                except sqlite3.IntegrityError:
                    if not migration.allow_integrity_error:
                        raise
                    logger.error(
                        "Migration %s/%s (%s) konnte wegen bestehender Altdaten nicht "
                        "angewendet werden. Der Start laeuft weiter; die Daten muessen "
                        "manuell bereinigt werden.",
                        component,
                        migration.version,
                        migration.name,
                    )
                    continue
                executed.append(f"{component}/{migration.version} {migration.name}")
                logger.info("Migration angewendet: %s/%s %s", component, migration.version, migration.name)
        return executed

    def status(self, components: Sequence[str]) -> list[dict[str, object]]:
        """Uebersicht fuer die Diagnoseseite."""
        with self.database.session() as conn:
            run_script(conn, _BOOTSTRAP)
            return Database.all(
                conn,
                "SELECT component, version, name, checksum, applied_at FROM schema_migrations "
                f"WHERE component IN ({','.join('?' * len(components))}) "
                "ORDER BY component, version",
                tuple(components),
            )
