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

import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Properties;

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

import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;

/**
 * Tests for {@link LegacyConfigurationMigrator}.
 * <p>
 * Covers all mandatory test cases for the legacy-to-multi-provider configuration migration.
 * Temporary files are managed via {@link TempDir} so no test artifacts remain on the file system.
 */
class LegacyConfigurationMigratorTest {

    @TempDir
    Path tempDir;

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

    /** Full legacy configuration containing all four api.* keys plus other required keys. */
    private static String fullLegacyContent() {
        return "source.folder=./source\n"
             + "target.folder=./target\n"
             + "sqlite.file=./db.sqlite\n"
             + "api.baseUrl=https://api.openai.com/v1\n"
             + "api.model=gpt-4o\n"
             + "api.timeoutSeconds=30\n"
             + "max.retries.transient=3\n"
             + "max.pages=10\n"
             + "max.text.characters=5000\n"
             + "prompt.template.file=./prompt.txt\n"
             + "api.key=sk-test-legacy-key\n"
             + "log.level=INFO\n"
             + "log.ai.sensitive=false\n";
    }

    private Path writeLegacyFile(String name, String content) throws IOException {
        Path file = tempDir.resolve(name);
        Files.writeString(file, content, StandardCharsets.UTF_8);
        return file;
    }

    private Properties loadProperties(Path file) throws IOException {
        Properties props = new Properties();
        props.load(Files.newBufferedReader(file, StandardCharsets.UTF_8));
        return props;
    }

    private LegacyConfigurationMigrator defaultMigrator() {
        return new LegacyConfigurationMigrator();
    }

    // =========================================================================
    // Mandatory test case 1
    // =========================================================================

    /**
     * Legacy file with all four {@code api.*} keys is correctly migrated.
     * Values in the migrated file must be identical to the originals; all other keys survive.
     */
    @Test
    void migratesLegacyFileWithAllFlatKeys() throws IOException {
        Path file = writeLegacyFile("app.properties", fullLegacyContent());

        defaultMigrator().migrateIfLegacy(file);

        Properties migrated = loadProperties(file);
        assertEquals("https://api.openai.com/v1", migrated.getProperty("ai.provider.openai-compatible.baseUrl"));
        assertEquals("gpt-4o",                     migrated.getProperty("ai.provider.openai-compatible.model"));
        assertEquals("30",                          migrated.getProperty("ai.provider.openai-compatible.timeoutSeconds"));
        assertEquals("sk-test-legacy-key",          migrated.getProperty("ai.provider.openai-compatible.apiKey"));
        assertEquals("openai-compatible",           migrated.getProperty("ai.provider.active"));

        // Old flat keys must be gone
        assertFalse(migrated.containsKey("api.baseUrl"),       "api.baseUrl must be removed");
        assertFalse(migrated.containsKey("api.model"),         "api.model must be removed");
        assertFalse(migrated.containsKey("api.timeoutSeconds"), "api.timeoutSeconds must be removed");
        assertFalse(migrated.containsKey("api.key"),           "api.key must be removed");
    }

    // =========================================================================
    // Mandatory test case 2
    // =========================================================================

    /**
     * A {@code .bak} backup is created with the exact original content before any changes.
     */
    @Test
    void createsBakBeforeOverwriting() throws IOException {
        String original = fullLegacyContent();
        Path file = writeLegacyFile("app.properties", original);
        Path bakFile = tempDir.resolve("app.properties.bak");

        assertFalse(Files.exists(bakFile), "No .bak should exist before migration");

        defaultMigrator().migrateIfLegacy(file);

        assertTrue(Files.exists(bakFile), ".bak must be created during migration");
        assertEquals(original, Files.readString(bakFile, StandardCharsets.UTF_8),
                ".bak must contain the exact original content");
    }

    // =========================================================================
    // Mandatory test case 3
    // =========================================================================

    /**
     * When {@code .bak} already exists, the new backup is written as {@code .bak.1}.
     * Neither the existing {@code .bak} nor the new {@code .bak.1} is overwritten.
     */
    @Test
    void bakSuffixIsIncrementedIfBakExists() throws IOException {
        String original = fullLegacyContent();
        Path file = writeLegacyFile("app.properties", original);

        // Pre-create .bak with different content
        Path existingBak = tempDir.resolve("app.properties.bak");
        Files.writeString(existingBak, "# existing bak", StandardCharsets.UTF_8);

        defaultMigrator().migrateIfLegacy(file);

        // Existing .bak must be untouched
        assertEquals("# existing bak", Files.readString(existingBak, StandardCharsets.UTF_8),
                "Existing .bak must not be overwritten");

        // New backup must be .bak.1 with original content
        Path newBak = tempDir.resolve("app.properties.bak.1");
        assertTrue(Files.exists(newBak), ".bak.1 must be created when .bak already exists");
        assertEquals(original, Files.readString(newBak, StandardCharsets.UTF_8),
                ".bak.1 must contain the original content");
    }

    // =========================================================================
    // Mandatory test case 4
    // =========================================================================

    /**
     * A file already in the new multi-provider schema triggers no write and no {@code .bak}.
     */
    @Test
    void noOpForAlreadyMigratedFile() throws IOException {
        String newSchema = "ai.provider.active=openai-compatible\n"
                         + "ai.provider.openai-compatible.baseUrl=https://api.openai.com/v1\n"
                         + "ai.provider.openai-compatible.model=gpt-4o\n"
                         + "ai.provider.openai-compatible.timeoutSeconds=30\n"
                         + "ai.provider.openai-compatible.apiKey=sk-key\n";
        Path file = writeLegacyFile("app.properties", newSchema);
        long modifiedBefore = Files.getLastModifiedTime(file).toMillis();

        defaultMigrator().migrateIfLegacy(file);

        // File must not have been rewritten
        assertEquals(modifiedBefore, Files.getLastModifiedTime(file).toMillis(),
                "File modification time must not change for already-migrated files");

        // No .bak should exist
        Path bakFile = tempDir.resolve("app.properties.bak");
        assertFalse(Files.exists(bakFile), "No .bak must be created for already-migrated files");
    }

    // =========================================================================
    // Mandatory test case 5
    // =========================================================================

    /**
     * After migration, the new parser and validator load the file without error.
     */
    @Test
    void reloadAfterMigrationSucceeds() throws IOException {
        Path file = writeLegacyFile("app.properties", fullLegacyContent());

        defaultMigrator().migrateIfLegacy(file);

        // Reload and parse with the new parser+validator — must not throw
        Properties props = loadProperties(file);
        MultiProviderConfiguration config = assertDoesNotThrow(
                () -> new MultiProviderConfigurationParser().parse(props),
                "Migrated file must be parseable by MultiProviderConfigurationParser");
        assertDoesNotThrow(
                () -> new MultiProviderConfigurationValidator().validate(config),
                "Migrated file must pass MultiProviderConfigurationValidator");
    }

    // =========================================================================
    // Mandatory test case 6
    // =========================================================================

    /**
     * When post-migration validation fails, a {@link ConfigurationLoadingException} is thrown
     * and the {@code .bak} backup is preserved with the original content.
     */
    @Test
    void migrationFailureKeepsBak() throws IOException {
        String original = fullLegacyContent();
        Path file = writeLegacyFile("app.properties", original);

        // Validator that always rejects
        MultiProviderConfigurationValidator failingValidator = new MultiProviderConfigurationValidator() {
            @Override
            public void validate(MultiProviderConfiguration config) {
                throw new InvalidStartConfigurationException("Simulated validation failure");
            }
        };

        LegacyConfigurationMigrator migrator = new LegacyConfigurationMigrator(
                new MultiProviderConfigurationParser(), failingValidator);

        assertThrows(ConfigurationLoadingException.class,
                () -> migrator.migrateIfLegacy(file),
                "Migration must throw ConfigurationLoadingException when post-migration validation fails");

        // .bak must be preserved with original content
        Path bakFile = tempDir.resolve("app.properties.bak");
        assertTrue(Files.exists(bakFile), ".bak must be preserved after migration failure");
        assertEquals(original, Files.readString(bakFile, StandardCharsets.UTF_8),
                ".bak content must match the original file content");
    }

    // =========================================================================
    // Mandatory test case 7
    // =========================================================================

    /**
     * A file that contains {@code ai.provider.active} but no legacy {@code api.*} keys
     * is not considered legacy and triggers no migration.
     */
    @Test
    void legacyDetectionRequiresAtLeastOneFlatKey() throws IOException {
        String notLegacy = "ai.provider.active=openai-compatible\n"
                         + "source.folder=./source\n"
                         + "max.pages=10\n";
        Path file = writeLegacyFile("app.properties", notLegacy);

        Properties props = new Properties();
        props.load(Files.newBufferedReader(file, StandardCharsets.UTF_8));

        boolean detected = defaultMigrator().isLegacyForm(props);

        assertFalse(detected, "File with ai.provider.active and no api.* keys must not be detected as legacy");
    }

    // =========================================================================
    // Mandatory test case 8
    // =========================================================================

    /**
     * The four legacy values land in exactly the target keys in the openai-compatible namespace,
     * and {@code ai.provider.active} is set to {@code openai-compatible}.
     */
    @Test
    void legacyValuesEndUpInOpenAiCompatibleNamespace() throws IOException {
        String content = "api.baseUrl=https://legacy.example.com/v1\n"
                       + "api.model=legacy-model\n"
                       + "api.timeoutSeconds=42\n"
                       + "api.key=legacy-key\n"
                       + "source.folder=./src\n";
        Path file = writeLegacyFile("app.properties", content);

        defaultMigrator().migrateIfLegacy(file);

        Properties migrated = loadProperties(file);
        assertEquals("https://legacy.example.com/v1", migrated.getProperty("ai.provider.openai-compatible.baseUrl"),
                "api.baseUrl must map to ai.provider.openai-compatible.baseUrl");
        assertEquals("legacy-model", migrated.getProperty("ai.provider.openai-compatible.model"),
                "api.model must map to ai.provider.openai-compatible.model");
        assertEquals("42", migrated.getProperty("ai.provider.openai-compatible.timeoutSeconds"),
                "api.timeoutSeconds must map to ai.provider.openai-compatible.timeoutSeconds");
        assertEquals("legacy-key", migrated.getProperty("ai.provider.openai-compatible.apiKey"),
                "api.key must map to ai.provider.openai-compatible.apiKey");
        assertEquals("openai-compatible", migrated.getProperty("ai.provider.active"),
                "ai.provider.active must be set to openai-compatible");
    }

    // =========================================================================
    // Mandatory test case 9
    // =========================================================================

    /**
     * Keys unrelated to the legacy api.* set survive the migration with identical values.
     */
    @Test
    void unrelatedKeysSurviveUnchanged() throws IOException {
        String content = "source.folder=./my/source\n"
                       + "target.folder=./my/target\n"
                       + "sqlite.file=./my/db.sqlite\n"
                       + "max.pages=15\n"
                       + "max.text.characters=3000\n"
                       + "log.level=DEBUG\n"
                       + "log.ai.sensitive=false\n"
                       + "api.baseUrl=https://api.openai.com/v1\n"
                       + "api.model=gpt-4o\n"
                       + "api.timeoutSeconds=30\n"
                       + "api.key=sk-unrelated-test\n";
        Path file = writeLegacyFile("app.properties", content);

        defaultMigrator().migrateIfLegacy(file);

        Properties migrated = loadProperties(file);
        assertEquals("./my/source",  migrated.getProperty("source.folder"),       "source.folder must be unchanged");
        assertEquals("./my/target",  migrated.getProperty("target.folder"),       "target.folder must be unchanged");
        assertEquals("./my/db.sqlite", migrated.getProperty("sqlite.file"),       "sqlite.file must be unchanged");
        assertEquals("15",           migrated.getProperty("max.pages"),           "max.pages must be unchanged");
        assertEquals("3000",         migrated.getProperty("max.text.characters"), "max.text.characters must be unchanged");
        assertEquals("DEBUG",        migrated.getProperty("log.level"),           "log.level must be unchanged");
        assertEquals("false",        migrated.getProperty("log.ai.sensitive"),    "log.ai.sensitive must be unchanged");
    }

    // =========================================================================
    // Mandatory test case 10
    // =========================================================================

    /**
     * Migration writes via a temporary {@code .tmp} file followed by a move/rename.
     * After successful migration, no {@code .tmp} file remains, and the original path
     * holds the fully migrated content (never partially overwritten).
     */
    @Test
    void inPlaceWriteIsAtomic() throws IOException {
        Path file = writeLegacyFile("app.properties", fullLegacyContent());
        Path tmpFile = tempDir.resolve("app.properties.tmp");

        defaultMigrator().migrateIfLegacy(file);

        // .tmp must have been cleaned up (moved to target, not left behind)
        assertFalse(Files.exists(tmpFile),
                ".tmp file must not exist after migration (must have been moved to target)");

        // Target must contain migrated content
        Properties migrated = loadProperties(file);
        assertTrue(migrated.containsKey("ai.provider.active"),
                "Migrated file must contain ai.provider.active (complete write confirmed)");
        assertTrue(migrated.containsKey("ai.provider.openai-compatible.model"),
                "Migrated file must contain the new namespaced model key (complete write confirmed)");
    }

    // =========================================================================
    // Tests: isLegacyForm – each individual legacy key triggers detection
    // =========================================================================

    /**
     * A properties set containing only {@code api.baseUrl} (without {@code ai.provider.active})
     * must be detected as legacy.
     */
    @Test
    void isLegacyForm_detectedWhenOnlyBaseUrlPresent() {
        Properties props = new Properties();
        props.setProperty(LegacyConfigurationMigrator.LEGACY_BASE_URL, "https://api.example.com");
        assertTrue(defaultMigrator().isLegacyForm(props),
                "Properties with only api.baseUrl must be detected as legacy");
    }

    /**
     * A properties set containing only {@code api.model} (without {@code ai.provider.active})
     * must be detected as legacy.
     */
    @Test
    void isLegacyForm_detectedWhenOnlyModelPresent() {
        Properties props = new Properties();
        props.setProperty(LegacyConfigurationMigrator.LEGACY_MODEL, "gpt-4o");
        assertTrue(defaultMigrator().isLegacyForm(props),
                "Properties with only api.model must be detected as legacy");
    }

    /**
     * A properties set containing only {@code api.timeoutSeconds} (without {@code ai.provider.active})
     * must be detected as legacy.
     */
    @Test
    void isLegacyForm_detectedWhenOnlyTimeoutPresent() {
        Properties props = new Properties();
        props.setProperty(LegacyConfigurationMigrator.LEGACY_TIMEOUT, "30");
        assertTrue(defaultMigrator().isLegacyForm(props),
                "Properties with only api.timeoutSeconds must be detected as legacy");
    }

    /**
     * A properties set containing only {@code api.key} (without {@code ai.provider.active})
     * must be detected as legacy.
     */
    @Test
    void isLegacyForm_detectedWhenOnlyApiKeyPresent() {
        Properties props = new Properties();
        props.setProperty(LegacyConfigurationMigrator.LEGACY_API_KEY, "sk-test");
        assertTrue(defaultMigrator().isLegacyForm(props),
                "Properties with only api.key must be detected as legacy");
    }

    // =========================================================================
    // Tests: lineDefinesKey / generateMigratedContent – prefix-only match must not fire
    // =========================================================================

    /**
     * A line whose key is a prefix of a legacy key (e.g. {@code api.baseUrlExtra}) must not
     * be treated as defining the legacy key ({@code api.baseUrl}) and must survive migration
     * unchanged while the actual legacy key is correctly replaced.
     */
    @Test
    void generateMigratedContent_doesNotReplacePrefixMatchKey() {
        String content = "api.baseUrlExtra=should-not-change\n"
                + "api.baseUrl=https://real.example.com\n"
                + "api.model=gpt-4o\n"
                + "api.timeoutSeconds=30\n"
                + "api.key=sk-real\n";

        String migrated = defaultMigrator().generateMigratedContent(content);

        assertTrue(migrated.contains("api.baseUrlExtra=should-not-change"),
                "Line with key that is a prefix of a legacy key must not be modified");
        assertTrue(migrated.contains("ai.provider.openai-compatible.baseUrl=https://real.example.com"),
                "The actual legacy key api.baseUrl must be replaced with the namespaced key");
    }

    /**
     * A line that defines a legacy key with no value (key only, no separator)
     * must be recognized as defining that key and be replaced in migration.
     */
    @Test
    void generateMigratedContent_handlesKeyWithoutValue() {
        String content = "api.baseUrl\n"
                + "api.model=gpt-4o\n"
                + "api.timeoutSeconds=30\n"
                + "api.key=sk-test\n";

        String migrated = defaultMigrator().generateMigratedContent(content);

        assertTrue(migrated.contains("ai.provider.openai-compatible.baseUrl"),
                "Key-only line (no value, no separator) must still be recognized and replaced");
        assertFalse(migrated.contains("api.baseUrl\n") || migrated.contains("api.baseUrl\r"),
                "Original key-only line must not survive unchanged");
    }
}
