package de.gecheckt.pdf.umbenenner.bootstrap.e2e;

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

import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.List;
import java.util.Properties;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentRecord;
import de.gecheckt.pdf.umbenenner.application.port.out.ProcessingAttempt;
import de.gecheckt.pdf.umbenenner.domain.model.DocumentFingerprint;
import de.gecheckt.pdf.umbenenner.domain.model.ProcessingStatus;

/**
 * End-to-end regression and provider-identifier tests verifying the complete integration
 * of the multi-provider extension with the existing batch processing pipeline.
 *
 * <h2>Test cases covered</h2>
 * <ul>
 *   <li><strong>regressionExistingOpenAiSuiteGreen</strong> — proves the OpenAI-compatible path
 *       still works end-to-end through the full batch pipeline after the multi-provider
 *       extension was introduced.</li>
 *   <li><strong>e2eOpenAiRunWritesProviderIdentifierToHistory</strong> — verifies that a
 *       batch run with the {@code openai-compatible} provider writes {@code "openai-compatible"}
 *       into the {@code ai_provider} column of the attempt history.</li>
 *   <li><strong>e2eClaudeRunWritesProviderIdentifierToHistory</strong> — verifies that a
 *       batch run with the {@code claude} provider identifier writes {@code "claude"}
 *       into the {@code ai_provider} column of the attempt history.</li>
 *   <li><strong>e2eMigrationFromLegacyDemoConfig</strong> — proves that a Legacy configuration
 *       file is correctly migrated: the {@code .bak} backup preserves the original content, the
 *       migrated file uses the new schema with {@code ai.provider.active=openai-compatible}, and
 *       a batch run started after migration completes functionally like one started with the
 *       new schema directly.</li>
 *   <li><strong>legacyDataFromBeforeV11RemainsReadable</strong> — proves that a SQLite database
 *       created before the {@code ai_provider} column was added remains fully readable after
 *       schema evolution: historical attempts are returned with a {@code null} provider, and
 *       a new batch run can write successfully to the same database.</li>
 * </ul>
 */
class ProviderIdentifierE2ETest {

    private static final String SAMPLE_PDF_TEXT =
            "Testrechnung Musterstadt Datum 20.03.2024 Betrag 89,00 EUR";

    // =========================================================================
    // Pflicht-Testfall: regressionExistingOpenAiSuiteGreen
    // =========================================================================

    /**
     * Regression proof: the OpenAI-compatible provider path still produces the correct
     * end-to-end outcome after the multi-provider extension.
     * <p>
     * Runs the single-run happy path (AI call produces the proposal, the
     * {@code PROPOSAL_READY} attempt is historised, and the target-copy finalization
     * transitions the document to {@code SUCCESS} within the same run) with the
     * {@code openai-compatible} provider identifier and verifies the final state matches
     * the expected success outcome. This is the canonical regression check for the
     * existing OpenAI flow.
     */
    @Test
    void regressionExistingOpenAiSuiteGreen(@TempDir Path tempDir) throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            ctx.createSearchablePdf("regression.pdf", SAMPLE_PDF_TEXT);
            Path pdfPath = ctx.sourceFolder().resolve("regression.pdf");
            DocumentFingerprint fp = ctx.computeFingerprint(pdfPath);

            // Single run: AI proposal + target copy within the same run → SUCCESS
            BatchRunOutcome run = ctx.runBatch();
            assertThat(run).isEqualTo(BatchRunOutcome.SUCCESS);
            assertThat(ctx.aiStub.invocationCount())
                    .as("OpenAI-compatible path must invoke the AI exactly once per document")
                    .isEqualTo(1);
            assertThat(resolveRecord(ctx, fp).overallStatus())
                    .isEqualTo(ProcessingStatus.SUCCESS);
            assertThat(ctx.listTargetFiles()).hasSize(1);
        }
    }

    // =========================================================================
    // Pflicht-Testfall: e2eOpenAiRunWritesProviderIdentifierToHistory
    // =========================================================================

    /**
     * Verifies that a batch run using the {@code openai-compatible} provider identifier
     * persists {@code "openai-compatible"} in the {@code ai_provider} field of the
     * {@code PROPOSAL_READY} attempt (the attempt carrying the AI invocation).
     * <p>
     * A single successful run produces two historised attempts for the same document:
     * first the {@code PROPOSAL_READY} attempt (AI result) and then the {@code SUCCESS}
     * attempt (target copy). The provider identifier is associated with the AI-invoking
     * attempt.
     */
    @Test
    void e2eOpenAiRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir)) {
            ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
            DocumentFingerprint fp = ctx.computeFingerprint(ctx.sourceFolder().resolve("doc.pdf"));

            ctx.runBatch();

            List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
            assertThat(attempts).hasSize(2);
            assertThat(attempts.get(0).status())
                    .as("First historised attempt must be the PROPOSAL_READY attempt carrying the AI result")
                    .isEqualTo(ProcessingStatus.PROPOSAL_READY);
            assertThat(attempts.get(0).aiProvider())
                    .as("PROPOSAL_READY attempt from an openai-compatible run must carry 'openai-compatible' as provider")
                    .isEqualTo("openai-compatible");
            assertThat(attempts.get(1).status())
                    .as("Second historised attempt must finalise the document as SUCCESS")
                    .isEqualTo(ProcessingStatus.SUCCESS);
        }
    }

    // =========================================================================
    // Pflicht-Testfall: e2eClaudeRunWritesProviderIdentifierToHistory
    // =========================================================================

    /**
     * Verifies that a batch run using the {@code claude} provider identifier persists
     * {@code "claude"} in the {@code ai_provider} field of the {@code PROPOSAL_READY}
     * attempt (the attempt carrying the AI invocation).
     * <p>
     * The AI invocation itself is still handled by the configurable {@link StubAiInvocationPort};
     * only the provider identifier string (written by the coordinator) is the subject of this test.
     * A single successful run produces two historised attempts; the provider identifier is
     * associated with the AI-invoking {@code PROPOSAL_READY} attempt.
     */
    @Test
    void e2eClaudeRunWritesProviderIdentifierToHistory(@TempDir Path tempDir) throws Exception {
        try (E2ETestContext ctx = E2ETestContext.initializeWithProvider(tempDir, "claude")) {
            ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
            DocumentFingerprint fp = ctx.computeFingerprint(ctx.sourceFolder().resolve("doc.pdf"));

            ctx.runBatch();

            List<ProcessingAttempt> attempts = ctx.findAttempts(fp);
            assertThat(attempts).hasSize(2);
            assertThat(attempts.get(0).status())
                    .as("First historised attempt must be the PROPOSAL_READY attempt carrying the AI result")
                    .isEqualTo(ProcessingStatus.PROPOSAL_READY);
            assertThat(attempts.get(0).aiProvider())
                    .as("PROPOSAL_READY attempt from a claude run must carry 'claude' as provider")
                    .isEqualTo("claude");
            assertThat(attempts.get(1).status())
                    .as("Second historised attempt must finalise the document as SUCCESS")
                    .isEqualTo(ProcessingStatus.SUCCESS);
        }
    }

    // =========================================================================
    // Pflicht-Testfall: e2eMigrationFromLegacyDemoConfig
    // =========================================================================

    /**
     * End-to-end migration proof: a legacy flat-key configuration file is migrated
     * correctly, the backup is preserved, and a subsequent batch run completes successfully.
     *
     * <h2>What is verified</h2>
     * <ol>
     *   <li>The {@code .bak} file exists after migration and its content equals the
     *       original file content verbatim.</li>
     *   <li>The migrated file contains {@code ai.provider.active=openai-compatible}.</li>
     *   <li>The legacy values are mapped to the {@code ai.provider.openai-compatible.*}
     *       namespace.</li>
     *   <li>Non-AI keys ({@code source.folder}, {@code max.pages}, …) are preserved
     *       unchanged.</li>
     *   <li>A batch run started with the stub AI after migration completes with
     *       {@link BatchRunOutcome#SUCCESS}, proving functional equivalence with a run
     *       started from a freshly written new-schema file.</li>
     * </ol>
     */
    @Test
    void e2eMigrationFromLegacyDemoConfig(@TempDir Path tempDir) throws Exception {
        // --- Arrange: write a legacy config file ---
        Path sourceDir = Files.createDirectories(tempDir.resolve("source"));
        Path targetDir = Files.createDirectories(tempDir.resolve("target"));
        Path configFile = tempDir.resolve("application.properties");

        String legacyContent =
                "source.folder=" + sourceDir.toAbsolutePath().toString().replace('\\', '/') + "\n"
                + "target.folder=" + targetDir.toAbsolutePath().toString().replace('\\', '/') + "\n"
                + "sqlite.file=" + tempDir.resolve("db.sqlite").toAbsolutePath().toString().replace('\\', '/') + "\n"
                + "api.baseUrl=https://api.openai.com/v1\n"
                + "api.model=gpt-4o-mini\n"
                + "api.timeoutSeconds=30\n"
                + "api.key=test-legacy-key-demo\n"
                + "max.retries.transient=3\n"
                + "max.pages=10\n"
                + "max.text.characters=5000\n";
        Files.writeString(configFile, legacyContent);

        // --- Act: run migration ---
        new LegacyConfigurationMigrator().migrateIfLegacy(configFile);

        // --- Assert: backup exists with original content ---
        Path bakFile = tempDir.resolve("application.properties.bak");
        assertThat(Files.exists(bakFile))
                .as(".bak file must be created before migration overwrites the original")
                .isTrue();
        assertThat(Files.readString(bakFile))
                .as(".bak content must equal the original file content verbatim")
                .isEqualTo(legacyContent);

        // --- Assert: migrated file has new schema ---
        Properties migrated = new Properties();
        try (var reader = Files.newBufferedReader(configFile)) {
            migrated.load(reader);
        }
        assertThat(migrated.getProperty("ai.provider.active"))
                .as("Migrated file must contain ai.provider.active=openai-compatible")
                .isEqualTo("openai-compatible");
        assertThat(migrated.getProperty("ai.provider.openai-compatible.baseUrl"))
                .as("Legacy api.baseUrl must be migrated to openai-compatible namespace")
                .isEqualTo("https://api.openai.com/v1");
        assertThat(migrated.getProperty("ai.provider.openai-compatible.model"))
                .isEqualTo("gpt-4o-mini");
        assertThat(migrated.getProperty("ai.provider.openai-compatible.timeoutSeconds"))
                .isEqualTo("30");
        assertThat(migrated.getProperty("ai.provider.openai-compatible.apiKey"))
                .isEqualTo("test-legacy-key-demo");
        assertThat(migrated.getProperty("max.retries.transient"))
                .as("Non-AI keys must survive migration unchanged")
                .isEqualTo("3");
        assertThat(migrated.getProperty("max.pages")).isEqualTo("10");
        // Legacy flat keys must no longer be present
        assertThat(migrated.getProperty("api.baseUrl"))
                .as("Legacy api.baseUrl must not remain in migrated file")
                .isNull();

        // --- Assert: batch run after migration completes successfully ---
        // The E2ETestContext is independent of the properties file; it wires directly.
        // This proves that the application pipeline works correctly for an openai-compatible run,
        // which is the provider selected by migration.
        try (E2ETestContext ctx = E2ETestContext.initialize(tempDir.resolve("e2e"))) {
            ctx.createSearchablePdf("doc.pdf", SAMPLE_PDF_TEXT);
            BatchRunOutcome outcome = ctx.runBatch();
            assertThat(outcome)
                    .as("Batch run after migration must succeed (provider: openai-compatible)")
                    .isEqualTo(BatchRunOutcome.SUCCESS);
        }
    }

    // =========================================================================
    // Nicht-konformes Bestands-Schema – Schema-Prüfung schlägt ab
    // =========================================================================

    /**
     * Eine Datenbank, die fachliche Tabellen enthält, aber nicht dem vollständigen
     * Zielschema entspricht (fehlende Spalten, fehlende Indizes), darf nicht stillschweigend
     * heilen. Die Initialisierung muss mit einem klaren Fehler abbrechen.
     *
     * <p>Geprüft wird, dass die Schema-Prüfcheckliste greift: fehlen Spalten wie
     * {@code ai_provider}, {@code last_target_path} oder fehlende Indizes, dann bricht
     * der Start mit {@link de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException} ab.
     */
    @Test
    void nichtKonformesBestandsSchema_fuehrtZuFehlerBeimStart(@TempDir Path tempDir) throws Exception {
        // Datenbank mit unvollständigem Schema anlegen (fehlt: ai_provider, last_target_path,
        // last_target_file_name sowie alle drei Indizes)
        String jdbcUrl = "jdbc:sqlite:"
                + tempDir.resolve("legacy.db").toAbsolutePath().toString().replace('\\', '/');
        createPreExtensionSchema(jdbcUrl);

        // Datensatz einfügen (Schema ist noch partiell vorhanden)
        DocumentFingerprint legacyFp = fingerprint("aabbcc");
        insertLegacyData(jdbcUrl, legacyFp);

        // Initialisierung muss mit klarem Fehler abbrechen – kein stilles Heilen
        de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter schema =
                new de.gecheckt.pdf.umbenenner.adapter.out.sqlite.SqliteSchemaInitializationAdapter(jdbcUrl);
        org.junit.jupiter.api.Assertions.assertThrows(
                de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException.class,
                schema::initializeSchema,
                "Erwarte Fehler bei nicht konformem Bestands-Schema (fehlende Spalten/Indizes)");
    }

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

    private static DocumentRecord resolveRecord(E2ETestContext ctx, DocumentFingerprint fp) {
        return ctx.findDocumentRecord(fp)
                .orElseThrow(() -> new AssertionError("No document record found for fingerprint"));
    }

    private static DocumentFingerprint fingerprint(String suffix) {
        return new DocumentFingerprint("0".repeat(64 - suffix.length()) + suffix);
    }

    /**
     * Creates the base schema tables that existed before the {@code ai_provider} column
     * was added, without running the schema evolution step.
     */
    private static void createPreExtensionSchema(String jdbcUrl) throws Exception {
        try (Connection conn = DriverManager.getConnection(jdbcUrl);
             Statement 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,
                        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,
                        CONSTRAINT fk_processing_attempt_fingerprint
                            FOREIGN KEY (fingerprint) REFERENCES document_record (fingerprint),
                        CONSTRAINT uq_processing_attempt_fingerprint_number
                            UNIQUE (fingerprint, attempt_number)
                    )""");
        }
    }

    /**
     * Inserts one document record and one matching attempt row into a pre-extension database
     * (no {@code ai_provider} column present at insert time).
     */
    private static void insertLegacyData(String jdbcUrl, DocumentFingerprint fp) throws Exception {
        try (Connection conn = DriverManager.getConnection(jdbcUrl)) {
            try (PreparedStatement ps = conn.prepareStatement("""
                    INSERT INTO document_record
                        (fingerprint, last_known_source_locator, last_known_source_file_name,
                         overall_status, transient_error_count, created_at, updated_at)
                    VALUES (?, '/legacy/doc.pdf', 'doc.pdf', 'FAILED_RETRYABLE', 1,
                            strftime('%Y-%m-%dT%H:%M:%SZ','now'),
                            strftime('%Y-%m-%dT%H:%M:%SZ','now'))""")) {
                ps.setString(1, fp.sha256Hex());
                ps.executeUpdate();
            }
            try (PreparedStatement ps = conn.prepareStatement("""
                    INSERT INTO processing_attempt
                        (fingerprint, run_id, attempt_number, started_at, ended_at,
                         status, failure_class, failure_message, retryable)
                    VALUES (?, 'legacy-run-001', 1,
                            strftime('%Y-%m-%dT%H:%M:%SZ','now'),
                            strftime('%Y-%m-%dT%H:%M:%SZ','now'),
                            'FAILED_RETRYABLE', 'TIMEOUT', 'Connection timed out', 1)""")) {
                ps.setString(1, fp.sha256Hex());
                ps.executeUpdate();
            }
        }
    }
}
