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

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

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 for {@link SqliteSchemaInitializationAdapter}.
 * <p>
 * Verifies that the two-level schema is created correctly, that schema evolution
 * (idempotent addition of AI traceability columns) works, that the idempotent
 * status migration of earlier positive intermediate states to {@code READY_FOR_AI}
 * is correct, and that invalid configuration is rejected.
 */
class SqliteSchemaInitializationAdapterTest {

    @TempDir
    Path tempDir;

    // -------------------------------------------------------------------------
    // Construction
    // -------------------------------------------------------------------------

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

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

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

    // -------------------------------------------------------------------------
    // Schema creation – tables present
    // -------------------------------------------------------------------------

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

        adapter.initializeSchema();

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

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

        Set<String> columns = readColumnNames(jdbcUrl, "document_record");
        assertThat(columns).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 initializeSchema_processingAttemptHasAllMandatoryColumns(@TempDir Path dir) throws SQLException {
        String jdbcUrl = jdbcUrl(dir, "attempt_columns_test.db");
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        Set<String> columns = readColumnNames(jdbcUrl, "processing_attempt");
        assertThat(columns).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"
        );
    }

    // -------------------------------------------------------------------------
    // Idempotency
    // -------------------------------------------------------------------------

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

        // Must not throw on second call
        adapter.initializeSchema();
        adapter.initializeSchema();
    }

    // -------------------------------------------------------------------------
    // Unique constraint: fingerprint in document_record
    // -------------------------------------------------------------------------

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

        String insertSql = """
                INSERT INTO document_record
                    (fingerprint, last_known_source_locator, last_known_source_file_name,
                     overall_status, created_at, updated_at)
                VALUES (?, 'locator', 'file.pdf', 'SUCCESS', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
                """;
        String fp = "a".repeat(64);

        try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
            try (var ps = conn.prepareStatement(insertSql)) {
                ps.setString(1, fp);
                ps.executeUpdate();
            }
            // Second insert with same fingerprint must fail
            try (var ps = conn.prepareStatement(insertSql)) {
                ps.setString(1, fp);
                org.junit.jupiter.api.Assertions.assertThrows(
                        SQLException.class, ps::executeUpdate,
                        "Expected UNIQUE constraint violation on document_record.fingerprint");
            }
        }
    }

    // -------------------------------------------------------------------------
    // Unique constraint: (fingerprint, attempt_number) in processing_attempt
    // -------------------------------------------------------------------------

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

        String fp = "b".repeat(64);

        // Insert master record first (FK)
        try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
            try (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 (?, 'loc', 'f.pdf', 'FAILED_RETRYABLE', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
                    """)) {
                ps.setString(1, fp);
                ps.executeUpdate();
            }

            String attemptSql = """
                    INSERT INTO processing_attempt
                        (fingerprint, run_id, attempt_number, started_at, ended_at, status, retryable)
                    VALUES (?, 'run-1', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z', 'FAILED_RETRYABLE', 1)
                    """;

            try (var ps = conn.prepareStatement(attemptSql)) {
                ps.setString(1, fp);
                ps.executeUpdate();
            }
            // Duplicate (fingerprint, attempt_number) must fail
            try (var ps = conn.prepareStatement(attemptSql)) {
                ps.setString(1, fp);
                org.junit.jupiter.api.Assertions.assertThrows(
                        SQLException.class, ps::executeUpdate,
                        "Expected UNIQUE constraint violation on (fingerprint, attempt_number)");
            }
        }
    }

    // -------------------------------------------------------------------------
    // Skip attempts are storable
    // -------------------------------------------------------------------------

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

        String fp = "c".repeat(64);

        try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
            // Insert master record
            try (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 (?, 'loc', 'f.pdf', 'SUCCESS', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')
                    """)) {
                ps.setString(1, fp);
                ps.executeUpdate();
            }

            // Insert a SKIPPED_ALREADY_PROCESSED attempt (null failure fields, retryable=0)
            try (var ps = conn.prepareStatement("""
                    INSERT INTO processing_attempt
                        (fingerprint, run_id, attempt_number, started_at, ended_at,
                         status, failure_class, failure_message, retryable)
                    VALUES (?, 'run-2', 2, '2026-01-02T00:00:00Z', '2026-01-02T00:00:01Z',
                            'SKIPPED_ALREADY_PROCESSED', NULL, NULL, 0)
                    """)) {
                ps.setString(1, fp);
                int rows = ps.executeUpdate();
                assertThat(rows).isEqualTo(1);
            }
        }
    }

    // -------------------------------------------------------------------------
    // Schema evolution — AI traceability columns
    // -------------------------------------------------------------------------

    @Test
    void initializeSchema_addsAiTraceabilityColumnsToExistingSchema(@TempDir Path dir)
            throws SQLException {
        // Simulate a pre-evolution schema: create the base tables without AI columns
        String jdbcUrl = jdbcUrl(dir, "evolution_test.db");
        try (Connection conn = DriverManager.getConnection(jdbcUrl);
             var stmt = conn.createStatement()) {
            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,
                        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
                    )
                    """);
        }

        // Running initializeSchema on the existing base schema must succeed (evolution)
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        Set<String> columns = readColumnNames(jdbcUrl, "processing_attempt");
        assertThat(columns).contains(
                "model_name", "prompt_identifier", "processed_page_count",
                "sent_character_count", "ai_raw_response", "ai_reasoning",
                "resolved_date", "date_source", "validated_title");
    }

    // -------------------------------------------------------------------------
    // Status migration — earlier positive intermediate state → READY_FOR_AI
    // -------------------------------------------------------------------------

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

        // Insert a document with SUCCESS status and no PROPOSAL_READY attempt
        String fp = "d".repeat(64);
        insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");

        // Run schema initialisation again (migration step runs every time)
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        String status = readOverallStatus(jdbcUrl, fp);
        assertThat(status).isEqualTo("READY_FOR_AI");
    }

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

        String fp = "e".repeat(64);
        insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");

        // Run migration twice — must not corrupt data or throw
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();
        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        String status = readOverallStatus(jdbcUrl, fp);
        assertThat(status).isEqualTo("READY_FOR_AI");
    }

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

        String fp = "f".repeat(64);
        // SUCCESS document that already has a PROPOSAL_READY attempt must NOT be migrated
        insertDocumentRecordWithStatus(jdbcUrl, fp, "SUCCESS");
        insertAttemptWithStatus(jdbcUrl, fp, "PROPOSAL_READY");

        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        String status = readOverallStatus(jdbcUrl, fp);
        assertThat(status).isEqualTo("SUCCESS");
    }

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

        String fpRetryable = "1".repeat(64);
        String fpFinal = "2".repeat(64);
        insertDocumentRecordWithStatus(jdbcUrl, fpRetryable, "FAILED_RETRYABLE");
        insertDocumentRecordWithStatus(jdbcUrl, fpFinal, "FAILED_FINAL");

        new SqliteSchemaInitializationAdapter(jdbcUrl).initializeSchema();

        assertThat(readOverallStatus(jdbcUrl, fpRetryable)).isEqualTo("FAILED_RETRYABLE");
        assertThat(readOverallStatus(jdbcUrl, fpFinal)).isEqualTo("FAILED_FINAL");
    }

    // -------------------------------------------------------------------------
    // Error handling
    // -------------------------------------------------------------------------

    @Test
    void initializeSchema_throwsDocumentPersistenceException_onInvalidUrl() {
        // SQLite is lenient with paths; use a truly invalid JDBC URL format
        SqliteSchemaInitializationAdapter badAdapter =
                new SqliteSchemaInitializationAdapter("not-a-jdbc-url-at-all");

        assertThatThrownBy(badAdapter::initializeSchema)
                .isInstanceOf(DocumentPersistenceException.class);
    }

    // -------------------------------------------------------------------------
    // Helpers
    // -------------------------------------------------------------------------

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

    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 void insertDocumentRecordWithStatus(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 insertAttemptWithStatus(String jdbcUrl, String fingerprint,
            String status) 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', 1, '2026-01-01T00:00:00Z', '2026-01-01T00:01:00Z', ?, 0)
                     """)) {
            ps.setString(1, fingerprint);
            ps.setString(2, status);
            ps.executeUpdate();
        }
    }

    private static String readOverallStatus(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("No document record found for fingerprint: " + fingerprint);
            }
        }
    }
}
