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

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

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

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

import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiConfigurationSaveResult;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationValues;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderApiKeyState;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiProviderConfigurationState;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;

/**
 * Unit tests for {@link GuiConfigurationPropertiesWriter}.
 * <p>
 * Tests cover: normalized output content and order, backup rotation schema, backup
 * non-overwrite guarantee, and atomic write behavior.
 */
class GuiConfigurationPropertiesWriterTest {

    private final GuiConfigurationPropertiesWriter writer = new GuiConfigurationPropertiesWriter();

    @TempDir
    Path tempDir;

    // =========================================================================
    // Backup rotation
    // =========================================================================

    @Test
    void backup_createsFirstBakWhenNoneExists() throws IOException {
        Path file = tempDir.resolve("config.properties");
        Files.writeString(file, "existing=content", StandardCharsets.UTF_8);

        writer.createBakBackup(file);

        Path bak = tempDir.resolve("config.properties.bak");
        assertTrue(Files.exists(bak), "First backup must be created as .bak");
        assertEquals("existing=content", Files.readString(bak, StandardCharsets.UTF_8));
    }

    @Test
    void backup_createsNumberedBakWhenBakAlreadyExists() throws IOException {
        Path file = tempDir.resolve("config.properties");
        Files.writeString(file, "new=content", StandardCharsets.UTF_8);
        Path bak = tempDir.resolve("config.properties.bak");
        Files.writeString(bak, "old=content", StandardCharsets.UTF_8);

        writer.createBakBackup(file);

        Path bak1 = tempDir.resolve("config.properties.bak.1");
        assertTrue(Files.exists(bak1), "Second backup must be created as .bak.1");
        assertEquals("new=content", Files.readString(bak1, StandardCharsets.UTF_8));
        // Existing .bak must not be overwritten.
        assertEquals("old=content", Files.readString(bak, StandardCharsets.UTF_8));
    }

    @Test
    void backup_incrementsNumberUntilFreeSlotFound() throws IOException {
        Path file = tempDir.resolve("config.properties");
        Files.writeString(file, "data", StandardCharsets.UTF_8);
        Files.writeString(tempDir.resolve("config.properties.bak"), "bak", StandardCharsets.UTF_8);
        Files.writeString(tempDir.resolve("config.properties.bak.1"), "bak1", StandardCharsets.UTF_8);
        Files.writeString(tempDir.resolve("config.properties.bak.2"), "bak2", StandardCharsets.UTF_8);

        writer.createBakBackup(file);

        Path bak3 = tempDir.resolve("config.properties.bak.3");
        assertTrue(Files.exists(bak3), "Third numbered backup must be created as .bak.3");
        // Previous backups must remain unchanged.
        assertEquals("bak2", Files.readString(tempDir.resolve("config.properties.bak.2"),
                StandardCharsets.UTF_8));
    }

    @Test
    void backup_neverOverwritesExistingBackups() throws IOException {
        Path file = tempDir.resolve("c.properties");
        Files.writeString(file, "current", StandardCharsets.UTF_8);
        Path bak = tempDir.resolve("c.properties.bak");
        Files.writeString(bak, "precious", StandardCharsets.UTF_8);

        writer.createBakBackup(file);

        assertEquals("precious", Files.readString(bak, StandardCharsets.UTF_8),
                "Existing .bak content must not be overwritten");
    }

    // =========================================================================
    // Normalized output content
    // =========================================================================

    @Test
    void write_newFile_createsFileWithNormalizedContent() throws IOException {
        Path target = tempDir.resolve("application.properties");
        GuiConfigurationValues values = buildTestValues("claude", "sk-claude", "sk-openai");

        GuiConfigurationSaveResult result = writer.write(values, target);

        assertEquals(target, result.savedPath());
        assertFalse(result.hasApiKeyPreservationNote());
        assertTrue(Files.exists(target), "Target file must exist after write");

        Properties props = loadProperties(target);
        assertEquals("claude", props.getProperty("ai.provider.active"));
        assertEquals("https://api.anthropic.com", props.getProperty("ai.provider.claude.baseUrl"));
        assertEquals("claude-3-5-sonnet-20241022", props.getProperty("ai.provider.claude.model"));
        assertEquals("60", props.getProperty("ai.provider.claude.timeoutSeconds"));
        assertEquals("sk-claude", props.getProperty("ai.provider.claude.apiKey"));
        assertEquals("https://api.openai.com/v1", props.getProperty("ai.provider.openai-compatible.baseUrl"));
        assertEquals("gpt-4o-mini", props.getProperty("ai.provider.openai-compatible.model"));
        assertEquals("30", props.getProperty("ai.provider.openai-compatible.timeoutSeconds"));
        assertEquals("sk-openai", props.getProperty("ai.provider.openai-compatible.apiKey"));
        assertEquals("./source", props.getProperty("source.folder"));
        assertEquals("./target", props.getProperty("target.folder"));
        assertEquals("./db.sqlite", props.getProperty("sqlite.file"));
        assertEquals("3", props.getProperty("max.retries.transient"));
        assertEquals("10", props.getProperty("max.pages"));
        assertEquals("1000", props.getProperty("max.text.characters"));
        assertEquals("60", props.getProperty("max.title.length"));
        assertEquals("./prompt.txt", props.getProperty("prompt.template.file"));
        assertEquals("false", props.getProperty("log.ai.sensitive"));
        assertEquals("./logs", props.getProperty("log.directory"));
        assertEquals("INFO", props.getProperty("log.level"));
        assertEquals("./app.lock", props.getProperty("runtime.lock.file"));
    }

    @Test
    void write_existingFile_createsBackupBeforeOverwriting() throws IOException {
        Path target = tempDir.resolve("application.properties");
        Files.writeString(target, "old=value", StandardCharsets.UTF_8);
        GuiConfigurationValues values = buildTestValues("claude", "", "");

        writer.write(values, target);

        Path bak = tempDir.resolve("application.properties.bak");
        assertTrue(Files.exists(bak), "Backup must be created when overwriting an existing file");
        assertEquals("old=value", Files.readString(bak, StandardCharsets.UTF_8));
    }

    @Test
    void write_noBackupCreatedForNewFile() throws IOException {
        Path target = tempDir.resolve("new.properties");
        GuiConfigurationValues values = buildTestValues("claude", "", "");

        writer.write(values, target);

        Path bak = tempDir.resolve("new.properties.bak");
        assertFalse(Files.exists(bak), "No backup must be created when writing a new file");
    }

    @Test
    void write_createsParentDirectoriesWhenMissing() throws IOException {
        Path target = tempDir.resolve("nested/dir/config.properties");
        GuiConfigurationValues values = buildTestValues("claude", "", "");

        writer.write(values, target);

        assertTrue(Files.exists(target), "File must be created even when parent directories are missing");
    }

    // =========================================================================
    // Normalized property order
    // =========================================================================

    @Test
    void buildPropertiesContent_includesExpectedSections() {
        GuiConfigurationValues values = buildTestValues("openai-compatible", "sk-a", "sk-b");

        String content = writer.buildPropertiesContent(values);

        // Verify section grouping order.
        int providerActivePos = content.indexOf("ai.provider.active=");
        int claudePos = content.indexOf("ai.provider.claude.baseUrl=");
        int openaiPos = content.indexOf("ai.provider.openai-compatible.baseUrl=");
        int sourceFolderPos = content.indexOf("source.folder=");
        int maxRetriesPos = content.indexOf("max.retries.transient=");
        int logAiPos = content.indexOf("log.ai.sensitive=");
        int lockPos = content.indexOf("runtime.lock.file=");

        assertTrue(providerActivePos < claudePos, "ai.provider.active must appear before claude block");
        assertTrue(claudePos < openaiPos, "Claude block must appear before openai-compatible block");
        assertTrue(openaiPos < sourceFolderPos, "Provider blocks must appear before paths");
        assertTrue(sourceFolderPos < maxRetriesPos, "Paths must appear before processing section");
        assertTrue(maxRetriesPos < logAiPos, "Processing section must appear before logging section");
        assertTrue(logAiPos < lockPos, "Logging section must appear before runtime section");
    }

    @Test
    void buildPropertiesContent_maxTitleLengthAppearsAfterMaxTextCharacters() {
        GuiConfigurationValues values = buildTestValues("claude", "", "");

        String content = writer.buildPropertiesContent(values);

        int maxCharsPos = content.indexOf("max.text.characters=");
        int maxTitleLengthPos = content.indexOf("max.title.length=");
        assertTrue(maxCharsPos >= 0, "max.text.characters must be present");
        assertTrue(maxTitleLengthPos >= 0, "max.title.length must be present");
        assertTrue(maxCharsPos < maxTitleLengthPos,
                "max.title.length must appear after max.text.characters");
    }

    @Test
    void buildPropertiesContent_containsGroupingComments() {
        GuiConfigurationValues values = buildTestValues("claude", "", "");

        String content = writer.buildPropertiesContent(values);

        assertTrue(content.contains("# Pfade"), "Paths section must have a comment");
        assertTrue(content.contains("# Verarbeitung"), "Processing section must have a comment");
        assertTrue(content.contains("# Logging"), "Logging section must have a comment");
        assertTrue(content.contains("# Laufzeit"), "Runtime section must have a comment");
    }

    @Test
    void buildPropertiesContent_emptyValuesProduceParsableOutput() throws IOException {
        GuiConfigurationValues values = buildTestValues("claude", "", "");

        String content = writer.buildPropertiesContent(values);

        Properties props = new Properties();
        props.load(new StringReader(content));
        // Should not throw, and values should be parseable.
        assertEquals("claude", props.getProperty("ai.provider.active"));
    }

    // =========================================================================
    // Threading invariant: writer must not be called from the FX thread
    // =========================================================================

    /**
     * Verifies that {@link GuiConfigurationPropertiesWriter#write} is called from a background
     * worker thread and not from the JavaFX Application Thread, as required by the threading
     * contract documented on the method.
     * <p>
     * The test invokes the writer directly from a named non-FX thread and captures the thread
     * name inside the call to confirm the threading invariant. A thread named anything other than
     * "JavaFX Application Thread" satisfies the invariant.
     *
     * @throws Exception if the background thread fails or times out
     */
    @Test
    void write_isCalledFromWorkerThread_notFromFxApplicationThread() throws Exception {
        Path target = tempDir.resolve("threading-test.properties");
        GuiConfigurationValues values = buildTestValues("claude", "", "");

        java.util.concurrent.atomic.AtomicReference<String> callerThreadName =
                new java.util.concurrent.atomic.AtomicReference<>();
        java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);

        Thread workerThread = new Thread(() -> {
            try {
                callerThreadName.set(Thread.currentThread().getName());
                writer.write(values, target);
            } finally {
                latch.countDown();
            }
        }, "gui-config-writer-test");
        workerThread.setDaemon(true);
        workerThread.start();

        assertTrue(latch.await(10, java.util.concurrent.TimeUnit.SECONDS),
                "Writer thread must complete within timeout");

        String threadName = callerThreadName.get();
        assertFalse(threadName == null || threadName.contains("JavaFX Application Thread"),
                "Writer must be called from a background worker thread, not the FX Application Thread. "
                        + "Actual thread: " + threadName);
        assertEquals("gui-config-writer-test", threadName,
                "Writer must have been called on the expected worker thread");
    }

    // =========================================================================
    // Helpers
    // =========================================================================

    private GuiConfigurationValues buildTestValues(String activeProvider,
                                                   String claudeApiKey,
                                                   String openaiApiKey) {
        Map<AiProviderFamily, GuiProviderConfigurationState> providerConfigurations = new LinkedHashMap<>();
        providerConfigurations.put(AiProviderFamily.CLAUDE, new GuiProviderConfigurationState(
                "https://api.anthropic.com",
                "claude-3-5-sonnet-20241022",
                "60",
                GuiProviderApiKeyState.unresolved(claudeApiKey)));
        providerConfigurations.put(AiProviderFamily.OPENAI_COMPATIBLE, new GuiProviderConfigurationState(
                "https://api.openai.com/v1",
                "gpt-4o-mini",
                "30",
                GuiProviderApiKeyState.unresolved(openaiApiKey)));
        return new GuiConfigurationValues(
                "./source",
                "./target",
                "./db.sqlite",
                "./prompt.txt",
                "./app.lock",
                "./logs",
                "INFO",
                "3",
                "10",
                "1000",
                "60",
                "false",
                activeProvider,
                providerConfigurations);
    }

    private Properties loadProperties(Path path) throws IOException {
        String content = Files.readString(path, StandardCharsets.UTF_8);
        Properties props = new Properties();
        props.load(new StringReader(content));
        return props;
    }
}
