package de.gecheckt.pdf.umbenenner.adapter.out.sqlite;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.HashSet;
import java.util.Set;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;

/**
 * Tests für {@link SqliteSchemaInitializationAdapter}.
 *
 * <p>Prüft die differenzierte 3-Fall-Strategie (leere DB, bestehende DB ohne
 * Flyway-History, Folgestart), die vollständige Schema-Prüfcheckliste für Fall 2,
 * die Foreign-Key-Aktivierung via DataSource sowie den Konstruktor.
 */
class SqliteSchemaInitializationAdapterTest {

    // -------------------------------------------------------------------------
    // Konstruktor
    // -------------------------------------------------------------------------

    @Test
    void constructor_rejectsNullJdbcUrl() {
        assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(null))
                .isInstanceOf(NullPointerException.class);
    }

    @Test
    void constructor_rejectsBlankJdbcUrl() {
        assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter("   "))
                .isInstanceOf(IllegalArgumentException.class);
    }

    @Test
    void getJdbcUrl_returnsConfiguredUrl() {
        String url = "jdbc:sqlite:/some/path/test.db";
        SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(url);
        assertThat(adapter.getJdbcUrl()).isEqualTo(url);
    }

    // -------------------------------------------------------------------------
    // Fall 1: Leere Datenbank – vollständiges Schema anlegen
    // -------------------------------------------------------------------------

    @Test
    void fall1_leereDb_laegtVollstaendigesSchemaAn(@TempDir Path dir) throws SQLException {
        String jdbcUrl = jdbcUrl(dir, "fall1.db");
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        Set<String> tabellen = readTableNames(jdbcUrl);
        assertThat(tabellen).contains("document_record", "processing_attempt");
    }

    @Test
    void fall1_leereDb_documentRecordHatAlleErwartetenSpalten(@TempDir Path dir) throws SQLException {
        String jdbcUrl = jdbcUrl(dir, "fall1_columns_dr.db");
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        Set<String> spalten = readColumnNames(jdbcUrl, "document_record");
        assertThat(spalten).containsExactlyInAnyOrder(
                "id", "fingerprint", "last_known_source_locator", "last_known_source_file_name",
                "overall_status", "content_error_count", "transient_error_count",
                "last_failure_instant", "last_success_instant", "created_at", "updated_at",
                "last_target_path", "last_target_file_name"
        );
    }

    @Test
    void fall1_leereDb_processingAttemptHatAlleErwartetenSpalten(@TempDir Path dir) throws SQLException {
        String jdbcUrl = jdbcUrl(dir, "fall1_columns_pa.db");
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        Set<String> spalten = readColumnNames(jdbcUrl, "processing_attempt");
        assertThat(spalten).containsExactlyInAnyOrder(
                "id", "fingerprint", "run_id", "attempt_number", "started_at", "ended_at",
                "status", "failure_class", "failure_message", "retryable",
                "model_name", "prompt_identifier", "processed_page_count", "sent_character_count",
                "ai_raw_response", "ai_reasoning", "resolved_date", "date_source",
                "validated_title", "final_target_file_name", "ai_provider",
                // Token- und Preis-Spalten ergaenzt durch V2__token_tracking
                "input_tokens", "output_tokens",
                "cache_creation_input_tokens", "cache_read_input_tokens",
                "price_input_per_token_nano_usd", "price_output_per_token_nano_usd"
        );
    }

    @Test
    void fall1_leereDb_indizesVorhanden(@TempDir Path dir) throws SQLException {
        String jdbcUrl = jdbcUrl(dir, "fall1_indexes.db");
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        Set<String> indizes = readIndexNames(jdbcUrl);
        assertThat(indizes).contains(
                "idx_processing_attempt_fingerprint",
                "idx_processing_attempt_run_id",
                "idx_document_record_overall_status"
        );
    }

    /**
     * "Leer" bedeutet: keine Tabellen vorhanden – NICHT nur Dateigröße 0 Byte.
     * Eine leere SQLite-Datei (0 Byte) muss als leere DB erkannt werden.
     */
    @Test
    void fall1_erkenntLeereDbAuchBeiDateiOhneInhalt(@TempDir Path dir) throws Exception {
        // Leere Datei anlegen (0 Byte)
        Path dbPath = dir.resolve("empty.db");
        Files.createFile(dbPath);
        assertThat(dbPath).exists();

        String jdbcUrl = jdbcUrl(dir, "empty.db");
        // Muss als Fall 1 behandelt werden und erfolgreich durchlaufen
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        Set<String> tabellen = readTableNames(jdbcUrl);
        assertThat(tabellen).contains("document_record", "processing_attempt");
    }

    // -------------------------------------------------------------------------
    // Fall 2: Bestehende DB ohne Flyway-History – Baseline eintragen
    // -------------------------------------------------------------------------

    @Test
    void fall2_bestehendeDbOhneHistory_traegtBaseline_einUndLaeuftErfolgreich(@TempDir Path dir)
            throws SQLException {
        String jdbcUrl = jdbcUrl(dir, "fall2.db");
        // Vollständiges konformes Schema anlegen (wie eine bestehende Produktions-DB)
        erstelleKonformesSchema(jdbcUrl);

        // Adapter muss als Fall 2 erkennen und Baseline eintragen
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        // Flyway-History-Tabelle muss jetzt vorhanden sein
        Set<String> tabellen = readTableNames(jdbcUrl);
        assertThat(tabellen).contains("flyway_schema_history");
        // Fachliche Daten müssen erhalten bleiben
        assertThat(tabellen).contains("document_record", "processing_attempt");
    }

    @Test
    void fall2_bestehendeDbOhneHistory_erstelltDatiertesBackup(@TempDir Path dir)
            throws Exception {
        Path dbPath = dir.resolve("fall2_backup.db");
        String jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath().toString().replace('\\', '/');
        erstelleKonformesSchema(jdbcUrl);

        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        // Backup-Datei muss vorhanden sein
        long backupAnzahl = Files.list(dir)
                .filter(p -> p.getFileName().toString().startsWith("fall2_backup.db.")
                        && p.getFileName().toString().endsWith(".bak"))
                .count();
        assertThat(backupAnzahl).isEqualTo(1);
    }

    @Test
    void fall2_bestehendeDbMitFehlendemElement_brichtMitFehlerAb(@TempDir Path dir) {
        String jdbcUrl = jdbcUrl(dir, "fall2_broken.db");
        // Schema ohne Spalte ai_provider anlegen (nicht konform)
        erstelleSchemaOhneAiProvider(jdbcUrl);

        assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema())
                .isInstanceOf(DocumentPersistenceException.class)
                .hasMessageContaining("ai_provider");
    }

    @Test
    void fall2_bestehendeDbOhneProcessingAttemptTabelle_brichtAb(@TempDir Path dir) {
        String jdbcUrl = jdbcUrl(dir, "fall2_no_attempt.db");
        // Nur document_record anlegen, processing_attempt fehlt
        erstelleNurDocumentRecord(jdbcUrl);

        assertThatThrownBy(() -> new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema())
                .isInstanceOf(DocumentPersistenceException.class)
                .hasMessageContaining("processing_attempt");
    }

    // -------------------------------------------------------------------------
    // Fall 3: Folgestart mit Flyway-History – idempotent
    // -------------------------------------------------------------------------

    @Test
    void fall3_folgestart_laeuftIdempotentOhneException(@TempDir Path dir) {
        String jdbcUrl = jdbcUrl(dir, "fall3.db");
        SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);

        assertThatCode(() -> {
            // Erster Aufruf (Fall 1)
            adapter.initializeSchema();
            // Zweiter Aufruf (Fall 3) – darf nicht werfen
            adapter.initializeSchema();
            // Dritter Aufruf (Fall 3) – ebenfalls idempotent
            adapter.initializeSchema();
        }).doesNotThrowAnyException();
    }

    @Test
    void fall3_folgestart_fachlicheDatenBleiben(@TempDir Path dir) throws SQLException {
        String jdbcUrl = jdbcUrl(dir, "fall3_data.db");
        SqliteSchemaInitializationAdapter adapter = new SqliteSchemaInitializationAdapter(jdbcUrl);
        adapter.initializeSchema();

        // Testdatensatz einfügen
        String fp = "a".repeat(64);
        insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS");

        // Folgestart
        adapter.initializeSchema();

        // Daten müssen erhalten bleiben
        assertThat(leseStatus(jdbcUrl, fp)).isEqualTo("SUCCESS");
    }

    // -------------------------------------------------------------------------
    // PRAGMA foreign_keys – Foreign-Key-Aktivierung via DataSource
    // -------------------------------------------------------------------------

    @Test
    void foreignKeys_sindNachSchemaInitAktiv(@TempDir Path dir) throws Exception {
        String jdbcUrl = jdbcUrl(dir, "fk_test.db");
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        // Neue Verbindung über SQLiteConfig aufbauen (wie der Adapter es tut)
        org.sqlite.SQLiteConfig config = new org.sqlite.SQLiteConfig();
        config.enforceForeignKeys(true);
        org.sqlite.SQLiteDataSource ds = new org.sqlite.SQLiteDataSource(config);
        ds.setUrl(jdbcUrl);

        try (Connection conn = ds.getConnection();
             var stmt = conn.createStatement()) {
            // PRAGMA foreign_keys muss 1 zurückliefern
            ResultSet rs = stmt.executeQuery("PRAGMA foreign_keys");
            assertThat(rs.next()).isTrue();
            assertThat(rs.getInt(1)).isEqualTo(1);
        }
    }

    @Test
    void foreignKeys_verletzungWirdDurchgesetzt(@TempDir Path dir) throws SQLException {
        String jdbcUrl = jdbcUrl(dir, "fk_enforced.db");
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        // Versuch, einen processing_attempt ohne passendem document_record einzufügen
        org.sqlite.SQLiteConfig config = new org.sqlite.SQLiteConfig();
        config.enforceForeignKeys(true);
        org.sqlite.SQLiteDataSource ds = new org.sqlite.SQLiteDataSource(config);
        ds.setUrl(jdbcUrl);

        try (Connection conn = ds.getConnection()) {
            assertThatThrownBy(() -> insertOrphanedProcessingAttempt(conn))
                    .isInstanceOf(SQLException.class);
        }
    }

    private static void insertOrphanedProcessingAttempt(Connection conn) throws SQLException {
        try (var ps = conn.prepareStatement("""
                INSERT INTO processing_attempt
                    (fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
                VALUES ('nichtvorhanden', 'run-1', 1, '2026-01-01T00:00:00Z',
                        '2026-01-01T00:01:00Z', 'FAILED_RETRYABLE', 1)
                """)) {
            ps.executeUpdate();
        }
    }

    // -------------------------------------------------------------------------
    // Eindeutigkeits-Constraints
    // -------------------------------------------------------------------------

    @Test
    void documentRecord_fingerprintUniqueConstraintWirdDurchgesetzt(@TempDir Path dir)
            throws SQLException {
        String jdbcUrl = jdbcUrl(dir, "unique_dr.db");
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        String fp = "b".repeat(64);
        insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS");

        // Zweiter Insert mit gleichem Fingerprint muss fehlschlagen
        assertThatThrownBy(() -> insertiereDocumentRecord(jdbcUrl, fp, "SUCCESS"))
                .isInstanceOf(SQLException.class);
    }

    @Test
    void processingAttempt_fingerprintUndAttemptNumberUniqueConstraintWirdDurchgesetzt(
            @TempDir Path dir) throws SQLException {
        String jdbcUrl = jdbcUrl(dir, "unique_pa.db");
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        String fp = "c".repeat(64);
        insertiereDocumentRecord(jdbcUrl, fp, "FAILED_RETRYABLE");
        insertiereProcessingAttempt(jdbcUrl, fp, 1);

        // Zweiter Insert mit gleicher (fingerprint, attempt_number) muss fehlschlagen
        assertThatThrownBy(() -> insertiereProcessingAttempt(jdbcUrl, fp, 1))
                .isInstanceOf(SQLException.class);
    }

    // -------------------------------------------------------------------------
    // Fehlerfall: ungültige URL
    // -------------------------------------------------------------------------

    @Test
    void initializeSchema_wirftDocumentPersistenceException_beiUngueltigerUrl() {
        SqliteSchemaInitializationAdapter adapter =
                new SqliteSchemaInitializationAdapter("keine-jdbc-url");
        assertThatThrownBy(adapter::initializeSchema)
                .isInstanceOf(DocumentPersistenceException.class);
    }

    // -------------------------------------------------------------------------
    // Hilfsmethoden – Schema-Erstellung für Tests
    // -------------------------------------------------------------------------

    /**
     * Erstellt ein vollständig konformes Schema (entspricht V1-Zielschema) ohne Flyway-History.
     */
    private static void erstelleKonformesSchema(String jdbcUrl) {
        try (Connection conn = DriverManager.getConnection(jdbcUrl);
             var stmt = conn.createStatement()) {
            stmt.execute("PRAGMA foreign_keys = ON");
            stmt.execute("""
                    CREATE TABLE IF NOT EXISTS document_record (
                        id                          INTEGER PRIMARY KEY AUTOINCREMENT,
                        fingerprint                 TEXT    NOT NULL,
                        last_known_source_locator   TEXT    NOT NULL,
                        last_known_source_file_name TEXT    NOT NULL,
                        overall_status              TEXT    NOT NULL,
                        content_error_count         INTEGER NOT NULL DEFAULT 0,
                        transient_error_count       INTEGER NOT NULL DEFAULT 0,
                        last_failure_instant        TEXT,
                        last_success_instant        TEXT,
                        created_at                  TEXT    NOT NULL,
                        updated_at                  TEXT    NOT NULL,
                        last_target_path            TEXT,
                        last_target_file_name       TEXT,
                        CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
                    )
                    """);
            stmt.execute("""
                    CREATE TABLE IF NOT EXISTS processing_attempt (
                        id                     INTEGER PRIMARY KEY AUTOINCREMENT,
                        fingerprint            TEXT    NOT NULL,
                        run_id                 TEXT    NOT NULL,
                        attempt_number         INTEGER NOT NULL,
                        started_at             TEXT    NOT NULL,
                        ended_at               TEXT    NOT NULL,
                        status                 TEXT    NOT NULL,
                        failure_class          TEXT,
                        failure_message        TEXT,
                        retryable              INTEGER NOT NULL DEFAULT 0,
                        model_name             TEXT,
                        prompt_identifier      TEXT,
                        processed_page_count   INTEGER,
                        sent_character_count   INTEGER,
                        ai_raw_response        TEXT,
                        ai_reasoning           TEXT,
                        resolved_date          TEXT,
                        date_source            TEXT,
                        validated_title        TEXT,
                        final_target_file_name TEXT,
                        ai_provider            TEXT,
                        CONSTRAINT fk_processing_attempt_fingerprint
                            FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
                        CONSTRAINT uq_processing_attempt_fingerprint_number
                            UNIQUE (fingerprint, attempt_number)
                    )
                    """);
            stmt.execute("CREATE INDEX IF NOT EXISTS idx_processing_attempt_fingerprint ON processing_attempt (fingerprint)");
            stmt.execute("CREATE INDEX IF NOT EXISTS idx_processing_attempt_run_id ON processing_attempt (run_id)");
            stmt.execute("CREATE INDEX IF NOT EXISTS idx_document_record_overall_status ON document_record (overall_status)");
        } catch (SQLException e) {
            throw new RuntimeException("Testvorbereitungsfehler: Schema konnte nicht erstellt werden", e);
        }
    }

    /**
     * Erstellt ein Schema ohne die Spalte {@code ai_provider} in {@code processing_attempt}.
     */
    private static void erstelleSchemaOhneAiProvider(String jdbcUrl) {
        try (Connection conn = DriverManager.getConnection(jdbcUrl);
             var stmt = conn.createStatement()) {
            stmt.execute("""
                    CREATE TABLE document_record (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        fingerprint TEXT NOT NULL,
                        last_known_source_locator TEXT NOT NULL,
                        last_known_source_file_name TEXT NOT NULL,
                        overall_status TEXT NOT NULL,
                        content_error_count INTEGER NOT NULL DEFAULT 0,
                        transient_error_count INTEGER NOT NULL DEFAULT 0,
                        last_failure_instant TEXT,
                        last_success_instant TEXT,
                        created_at TEXT NOT NULL,
                        updated_at TEXT NOT NULL,
                        last_target_path TEXT,
                        last_target_file_name TEXT,
                        CONSTRAINT uq_document_record_fingerprint UNIQUE (fingerprint)
                    )
                    """);
            // processing_attempt OHNE ai_provider
            stmt.execute("""
                    CREATE TABLE processing_attempt (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        fingerprint TEXT NOT NULL,
                        run_id TEXT NOT NULL,
                        attempt_number INTEGER NOT NULL,
                        started_at TEXT NOT NULL,
                        ended_at TEXT NOT NULL,
                        status TEXT NOT NULL,
                        failure_class TEXT,
                        failure_message TEXT,
                        retryable INTEGER NOT NULL DEFAULT 0,
                        model_name TEXT,
                        prompt_identifier TEXT,
                        processed_page_count INTEGER,
                        sent_character_count INTEGER,
                        ai_raw_response TEXT,
                        ai_reasoning TEXT,
                        resolved_date TEXT,
                        date_source TEXT,
                        validated_title TEXT,
                        final_target_file_name TEXT
                    )
                    """);
        } catch (SQLException e) {
            throw new RuntimeException("Testvorbereitungsfehler", e);
        }
    }

    /**
     * Erstellt nur die Tabelle {@code document_record} (ohne {@code processing_attempt}).
     */
    private static void erstelleNurDocumentRecord(String jdbcUrl) {
        try (Connection conn = DriverManager.getConnection(jdbcUrl);
             var stmt = conn.createStatement()) {
            stmt.execute("""
                    CREATE TABLE document_record (
                        id INTEGER PRIMARY KEY AUTOINCREMENT,
                        fingerprint TEXT NOT NULL,
                        last_known_source_locator TEXT NOT NULL,
                        last_known_source_file_name TEXT NOT NULL,
                        overall_status TEXT NOT NULL,
                        content_error_count INTEGER NOT NULL DEFAULT 0,
                        transient_error_count INTEGER NOT NULL DEFAULT 0,
                        last_failure_instant TEXT,
                        last_success_instant TEXT,
                        created_at TEXT NOT NULL,
                        updated_at TEXT NOT NULL
                    )
                    """);
        } catch (SQLException e) {
            throw new RuntimeException("Testvorbereitungsfehler", e);
        }
    }

    // -------------------------------------------------------------------------
    // Hilfsmethoden – JDBC
    // -------------------------------------------------------------------------

    private static String jdbcUrl(Path dir, String filename) {
        return "jdbc:sqlite:" + dir.resolve(filename).toAbsolutePath().toString().replace('\\', '/');
    }

    private static Set<String> readTableNames(String jdbcUrl) throws SQLException {
        Set<String> tables = new HashSet<>();
        try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
            DatabaseMetaData meta = conn.getMetaData();
            try (ResultSet rs = meta.getTables(null, null, "%", new String[]{"TABLE"})) {
                while (rs.next()) {
                    tables.add(rs.getString("TABLE_NAME").toLowerCase());
                }
            }
        }
        return tables;
    }

    private static Set<String> readColumnNames(String jdbcUrl, String tableName)
            throws SQLException {
        Set<String> columns = new HashSet<>();
        try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
            DatabaseMetaData meta = conn.getMetaData();
            try (ResultSet rs = meta.getColumns(null, null, tableName, "%")) {
                while (rs.next()) {
                    columns.add(rs.getString("COLUMN_NAME").toLowerCase());
                }
            }
        }
        return columns;
    }

    private static Set<String> readIndexNames(String jdbcUrl) throws SQLException {
        Set<String> indexes = new HashSet<>();
        try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
            DatabaseMetaData meta = conn.getMetaData();
            for (String table : new String[]{"document_record", "processing_attempt"}) {
                try (ResultSet rs = meta.getIndexInfo(null, null, table, false, false)) {
                    while (rs.next()) {
                        String name = rs.getString("INDEX_NAME");
                        if (name != null) {
                            indexes.add(name.toLowerCase());
                        }
                    }
                }
            }
        }
        return indexes;
    }

    private static void insertiereDocumentRecord(String jdbcUrl, String fingerprint,
            String status) throws SQLException {
        try (Connection conn = DriverManager.getConnection(jdbcUrl);
             var ps = conn.prepareStatement("""
                     INSERT INTO document_record
                         (fingerprint, last_known_source_locator, last_known_source_file_name,
                          overall_status, created_at, updated_at)
                     VALUES (?, '/src', 'doc.pdf', ?, '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
                     """)) {
            ps.setString(1, fingerprint);
            ps.setString(2, status);
            ps.executeUpdate();
        }
    }

    private static void insertiereProcessingAttempt(String jdbcUrl, String fingerprint,
            int attemptNumber) throws SQLException {
        try (Connection conn = DriverManager.getConnection(jdbcUrl);
             var ps = conn.prepareStatement("""
                     INSERT INTO processing_attempt
                         (fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
                     VALUES (?, 'run-1', ?, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z',
                             'FAILED_RETRYABLE', 1)
                     """)) {
            ps.setString(1, fingerprint);
            ps.setInt(2, attemptNumber);
            ps.executeUpdate();
        }
    }

    private static String leseStatus(String jdbcUrl, String fingerprint) throws SQLException {
        try (Connection conn = DriverManager.getConnection(jdbcUrl);
             var ps = conn.prepareStatement(
                     "SELECT overall_status FROM document_record WHERE fingerprint = ?")) {
            ps.setString(1, fingerprint);
            try (ResultSet rs = ps.executeQuery()) {
                if (rs.next()) {
                    return rs.getString("overall_status");
                }
                throw new IllegalStateException("Kein Eintrag für Fingerprint: " + fingerprint);
            }
        }
    }
}
