package de.gecheckt.pdf.umbenenner.bootstrap;

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.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

import org.apache.logging.log4j.Level;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.core.LogEvent;
import org.apache.logging.log4j.core.LoggerContext;
import org.apache.logging.log4j.core.appender.AbstractAppender;
import org.apache.logging.log4j.core.config.Configuration;
import org.apache.logging.log4j.core.config.Property;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

import de.gecheckt.pdf.umbenenner.adapter.in.cli.SchedulerBatchCommand;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiAdapter;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.GuiStartupContext;
import de.gecheckt.pdf.umbenenner.adapter.in.gui.editor.GuiConfigurationEditorState;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.LegacyConfigurationMigrator;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.PropertiesConfigurationPortAdapter;
import de.gecheckt.pdf.umbenenner.application.config.provider.AiProviderFamily;
import de.gecheckt.pdf.umbenenner.application.config.provider.MultiProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.config.startup.StartConfiguration;
import de.gecheckt.pdf.umbenenner.application.port.in.BatchRunOutcome;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupArguments;
import de.gecheckt.pdf.umbenenner.bootstrap.startup.StartupMode;

/**
 * Tests for the {@code --config} path semantics enforced by {@link BootstrapRunner}.
 * <p>
 * Covers the distinct behavior for GUI and headless startup modes when the path
 * supplied via {@code --config} refers to a file that does or does not exist:
 * <ul>
 *   <li><strong>Headless, file missing:</strong> hard startup failure (exit code 1),
 *       no migration or loading attempted, error is logged.</li>
 *   <li><strong>Headless, file exists:</strong> proceeds normally with the supplied path.</li>
 *   <li><strong>Headless, no {@code --config}:</strong> default path is used unchanged.</li>
 *   <li><strong>GUI, file missing:</strong> error is logged, startup notice is forwarded
 *       to the adapter, GUI starts normally (exit code 0 on normal shutdown).</li>
 *   <li><strong>GUI, file exists:</strong> path is confirmed, GUI starts normally.</li>
 * </ul>
 */
class BootstrapRunnerConfigPathSemanticsTest {

    @TempDir
    Path tempDir;

    // =========================================================================
    // Headless: non-existent --config path
    // =========================================================================

    @Test
    void runHeadless_withNonExistentConfigPath_returnsExitCode1() {
        String nonExistentPath = tempDir.resolve("does-not-exist.properties").toString();
        AtomicBoolean configPortCalled = new AtomicBoolean(false);

        BootstrapRunner runner = new BootstrapRunner(
                configPath -> {
                    configPortCalled.set(true);
                    return buildValidConfigPort();
                },
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                SchedulerBatchCommand::new
        );

        int exitCode = runner.run(new StartupArguments(StartupMode.HEADLESS, Optional.of(nonExistentPath)));

        assertEquals(1, exitCode,
                "Headless start with non-existent --config path must return exit code 1");
        assertFalse(configPortCalled.get(),
                "ConfigurationPort must not be called when --config file does not exist");
    }

    @Test
    void runHeadless_withNonExistentConfigPath_logsError() {
        String nonExistentPath = tempDir.resolve("missing.properties").toString();
        List<LogEvent> capturedEvents = new ArrayList<>();
        String appenderName = "TestCapture-MissingConfig-" + UUID.randomUUID();

        LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
        Configuration cfg = ctx.getConfiguration();
        AbstractAppender captureAppender = new AbstractAppender(
                appenderName, null, null, false, Property.EMPTY_ARRAY) {
            @Override
            public void append(LogEvent event) {
                capturedEvents.add(event.toImmutable());
            }
        };
        captureAppender.start();
        cfg.addAppender(captureAppender);
        cfg.getRootLogger().addAppender(captureAppender, Level.ALL, null);
        ctx.updateLoggers();

        BootstrapRunner runner = new BootstrapRunner(
                configPath -> buildValidConfigPort(),
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                SchedulerBatchCommand::new
        );

        try {
            runner.run(new StartupArguments(StartupMode.HEADLESS, Optional.of(nonExistentPath)));
        } finally {
            cfg.getRootLogger().removeAppender(appenderName);
            ctx.updateLoggers();
            captureAppender.stop();
        }

        assertTrue(
                capturedEvents.stream().anyMatch(e ->
                        e.getLevel() == Level.ERROR
                        && e.getMessage().getFormattedMessage().contains("not found")),
                "An ERROR-level log mentioning the missing file must be produced");
    }

    @Test
    void runHeadless_withExistingConfigPath_proceedsNormally(@TempDir Path workDir) throws Exception {
        Path existingConfigFile = Files.createFile(workDir.resolve("custom.properties"));
        AtomicReference<Path> receivedPath = new AtomicReference<>();

        BootstrapRunner runner = new BootstrapRunner(
                configPath -> {
                    receivedPath.set(configPath);
                    return buildValidConfigPort();
                },
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                SchedulerBatchCommand::new
        );

        int exitCode = runner.run(
                new StartupArguments(StartupMode.HEADLESS, Optional.of(existingConfigFile.toString())));

        assertEquals(0, exitCode,
                "Headless start with existing --config path must proceed normally");
        assertEquals(existingConfigFile, receivedPath.get(),
                "The existing config path must be forwarded to the ConfigurationPortFactory");
    }

    @Test
    void runHeadless_withoutConfigPath_usesDefaultBehavior() {
        AtomicReference<Path> receivedPath = new AtomicReference<>();

        BootstrapRunner runner = new BootstrapRunner(
                configPath -> {
                    receivedPath.set(configPath);
                    return buildValidConfigPort();
                },
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                SchedulerBatchCommand::new
        );

        runner.run(new StartupArguments(StartupMode.HEADLESS, Optional.empty()));

        assertEquals(java.nio.file.Paths.get("config/application.properties"), receivedPath.get(),
                "Headless without --config must use the default configuration path");
    }

    // =========================================================================
    // GUI: non-existent --config path
    // =========================================================================

    @Test
    void runGui_withNonExistentConfigPath_startsGuiAndReturnsZeroOnNormalShutdown() {
        String nonExistentPath = tempDir.resolve("missing.properties").toString();
        AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
        AtomicReference<GuiConfigurationEditorState> receivedState = new AtomicReference<>();

        BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
            @Override
            public void start(GuiStartupContext startupContext) {
                receivedNotice.set(startupContext.startupNotice());
                receivedState.set(startupContext.initialState());
                // normal shutdown: returns without throwing
            }
        });

        int exitCode = runner.run(new StartupArguments(StartupMode.GUI, Optional.of(nonExistentPath)));

        assertEquals(0, exitCode,
                "GUI with non-existent --config path must still return exit code 0 on normal shutdown");
        assertTrue(receivedNotice.get().isPresent(),
                "A startup notice must be forwarded to the GUI adapter when the config file is missing");
        assertFalse(receivedState.get().hasLoadedFileSnapshot(),
                "GUI must start without a loaded configuration when the supplied file is missing");
    }

    @Test
    void runGui_withNonExistentConfigPath_logsError() {
        String nonExistentPath = tempDir.resolve("missing-for-gui.properties").toString();
        List<LogEvent> capturedEvents = new ArrayList<>();
        String appenderName = "TestCapture-GuiMissingConfig-" + UUID.randomUUID();

        LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
        Configuration cfg = ctx.getConfiguration();
        AbstractAppender captureAppender = new AbstractAppender(
                appenderName, null, null, false, Property.EMPTY_ARRAY) {
            @Override
            public void append(LogEvent event) {
                capturedEvents.add(event.toImmutable());
            }
        };
        captureAppender.start();
        cfg.addAppender(captureAppender);
        cfg.getRootLogger().addAppender(captureAppender, Level.ALL, null);
        ctx.updateLoggers();

        BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
            @Override
            public void start(GuiStartupContext startupContext) {
                // normal termination
            }
        });

        try {
            runner.run(new StartupArguments(StartupMode.GUI, Optional.of(nonExistentPath)));
        } finally {
            cfg.getRootLogger().removeAppender(appenderName);
            ctx.updateLoggers();
            captureAppender.stop();
        }

        assertTrue(
                capturedEvents.stream().anyMatch(e ->
                        e.getLevel() == Level.ERROR
                        && e.getMessage().getFormattedMessage().contains("not found")),
                "An ERROR-level log mentioning the missing file must be produced for GUI path too");
    }

    @Test
    void runGui_withNonExistentConfigPath_startupNoticeContainsPathInfo() {
        String nonExistentPath = tempDir.resolve("absent.properties").toString();
        AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();

        BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
            @Override
            public void start(GuiStartupContext startupContext) {
                receivedNotice.set(startupContext.startupNotice());
            }
        });

        runner.run(new StartupArguments(StartupMode.GUI, Optional.of(nonExistentPath)));

        assertTrue(receivedNotice.get().isPresent(), "Startup notice must be present");
        assertTrue(receivedNotice.get().get().contains("absent.properties"),
                "Startup notice must contain the missing file path for user orientation");
    }

    @Test
    void runGui_withExistingConfigPath_startsGuiWithEmptyNotice(@TempDir Path workDir) throws Exception {
        Path existingConfigFile = Files.createFile(workDir.resolve("real.properties"));
        AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
        AtomicReference<GuiConfigurationEditorState> receivedState = new AtomicReference<>();

        BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
            @Override
            public void start(GuiStartupContext startupContext) {
                receivedNotice.set(startupContext.startupNotice());
                receivedState.set(startupContext.initialState());
            }
        });

        int exitCode = runner.run(
                new StartupArguments(StartupMode.GUI, Optional.of(existingConfigFile.toString())));

        assertEquals(0, exitCode,
                "GUI with existing --config path must return exit code 0 on normal shutdown");
        assertFalse(receivedNotice.get().isPresent(),
                "No startup notice must be forwarded when the config file exists");
        assertTrue(receivedState.get().hasLoadedFileSnapshot(),
                "GUI must receive a loaded editor state when the supplied file exists");
        assertEquals(existingConfigFile.toString(), receivedState.get().configurationPathText(),
                "The loaded editor state must point to the supplied configuration file");
    }

    @Test
    void runGui_withLegacyConfigPath_loadsMigratedStateAndKeepsMigrationNote(@TempDir Path workDir) throws Exception {
        Path legacyConfigFile = workDir.resolve("legacy.properties");
        Files.createDirectories(workDir.resolve("source"));
        Files.createDirectories(workDir.resolve("target"));
        Files.createDirectories(workDir.resolve("logs"));
        Files.createFile(workDir.resolve("db.sqlite"));
        Files.createFile(workDir.resolve("prompt.txt"));
        Files.createFile(workDir.resolve("lock.pid"));
        Files.writeString(legacyConfigFile, """
                source.folder=%s
                target.folder=%s
                sqlite.file=%s
                api.baseUrl=https://api.openai.com/v1
                api.model=gpt-4o-mini
                api.timeoutSeconds=30
                api.key=test-api-key
                max.retries.transient=3
                max.pages=10
                max.text.characters=5000
                prompt.template.file=%s
                runtime.lock.file=%s
                log.directory=%s
                log.level=INFO
                log.ai.sensitive=false
                """.formatted(
                workDir.resolve("source"),
                workDir.resolve("target"),
                workDir.resolve("db.sqlite"),
                workDir.resolve("prompt.txt"),
                workDir.resolve("lock.pid"),
                workDir.resolve("logs")));

        AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();
        AtomicReference<GuiConfigurationEditorState> receivedState = new AtomicReference<>();

        BootstrapRunner runner = new BootstrapRunner(
                path -> new LegacyConfigurationMigrator().migrateIfLegacy(path),
                PropertiesConfigurationPortAdapter::new,
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                SchedulerBatchCommand::new,
                () -> new GuiAdapter() {
                    @Override
                    public void start(GuiStartupContext startupContext) {
                        receivedNotice.set(startupContext.startupNotice());
                        receivedState.set(startupContext.initialState());
                    }
                }
        );

        int exitCode = runner.run(new StartupArguments(StartupMode.GUI, Optional.of(legacyConfigFile.toString())));

        assertEquals(0, exitCode, "GUI with legacy config path must still return exit code 0 on normal shutdown");
        assertFalse(receivedNotice.get().isPresent(),
                "No startup notice is needed when a legacy config can be migrated and loaded");
        assertTrue(receivedState.get().hasLoadedFileSnapshot(),
                "The migrated editor state must carry the loaded file snapshot");
        assertTrue(receivedState.get().hasPendingMigrationMessage(),
                "The migrated editor state must retain the pending migration message");
        assertEquals(legacyConfigFile.toString(), receivedState.get().configurationPathText(),
                "The migrated editor state must report the full loaded path");
    }

    @Test
    void runGui_withoutConfigPath_startsGuiWithEmptyNotice() {
        AtomicReference<Optional<String>> receivedNotice = new AtomicReference<>();

        BootstrapRunner runner = runnerWithGuiFactory(() -> new GuiAdapter() {
            @Override
            public void start(GuiStartupContext startupContext) {
                receivedNotice.set(startupContext.startupNotice());
            }
        });

        int exitCode = runner.run(new StartupArguments(StartupMode.GUI, Optional.empty()));

        assertEquals(0, exitCode, "GUI without --config must return exit code 0 on normal shutdown");
        assertFalse(receivedNotice.get().isPresent(),
                "No startup notice must be forwarded when no --config is supplied");
    }

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

    /**
     * Creates a {@link BootstrapRunner} wired with a controllable GUI adapter factory
     * and stub factories for all headless-path dependencies.
     * <p>
     * The config port factory throws {@link de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException}
     * so that {@code initializeApplicationRunContext()} can handle it gracefully (storing
     * the error in {@code applicationContextError}) without crashing the GUI launch sequence.
     * Tests that verify the absence of a startup notice or the presence of a loaded editor
     * state are not affected by the context-init failure.
     * <p>
     * Run-lock and schema-init factories still throw hard errors because they must never be
     * reached from the GUI path in test scenarios without a valid application run context.
     */
    private BootstrapRunner runnerWithGuiFactory(BootstrapRunner.GuiAdapterFactory guiAdapterFactory) {
        return new BootstrapRunner(
                path -> { /* no-op migration */ },
                configPath -> { throw new de.gecheckt.pdf.umbenenner.adapter.out.configuration
                        .ConfigurationLoadingException("Stub: no config port in GUI test context"); },
                lockFile -> { throw new AssertionError("RunLockPort must not be called in GUI mode"); },
                () -> { throw new AssertionError("Validator must not be called in GUI mode"); },
                jdbcUrl -> { throw new AssertionError("SchemaInitPort must not be called in GUI mode"); },
                (config, lock) -> { throw new AssertionError("UseCaseFactory must not be called in GUI mode"); },
                useCase -> { throw new AssertionError("CommandFactory must not be called in GUI mode"); },
                guiAdapterFactory
        );
    }

    /**
     * Builds a minimal valid {@link ConfigurationPort} backed by real temp-dir paths.
     */
    private ConfigurationPort buildValidConfigPort() {
        try {
            Files.createDirectories(tempDir.resolve("source"));
            Files.createDirectories(tempDir.resolve("target"));
            Path sqliteFile = tempDir.resolve("db.sqlite");
            if (!Files.exists(sqliteFile)) {
                Files.createFile(sqliteFile);
            }
            Path promptFile = tempDir.resolve("prompt.txt");
            if (!Files.exists(promptFile)) {
                Files.createFile(promptFile);
            }
            ProviderConfiguration providerConfig = new ProviderConfiguration(
                    "gpt-4", 30, "https://api.example.com", "test-key");
            MultiProviderConfiguration multiConfig = new MultiProviderConfiguration(
                    AiProviderFamily.OPENAI_COMPATIBLE, providerConfig, null);
            StartConfiguration config = new StartConfiguration(
                    tempDir.resolve("source"),
                    tempDir.resolve("target"),
                    sqliteFile,
                    multiConfig,
                    3, 100, 50000, 60,
                    promptFile,
                    tempDir.resolve("lock.lock"),
                    tempDir.resolve("logs"),
                    "INFO",
                    false
            );
            return () -> config;
        } catch (Exception e) {
            throw new RuntimeException("Failed to build valid ConfigurationPort for test", e);
        }
    }

    private static class MockRunLockPort implements de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort {
        @Override public void acquire() {
            // intentionally empty
        }
        @Override public void release() {
            // intentionally empty
        }
        @Override
        public java.util.Optional<de.gecheckt.pdf.umbenenner.application.port.out.RunLockHandle> tryAcquire() {
            return java.util.Optional.empty();
        }
    }

    private static class MockSchemaInitializationPort
            implements de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort {
        @Override public void initializeSchema() {
            // intentionally empty
        }
    }

    private static class MockRunBatchProcessingUseCase
            implements de.gecheckt.pdf.umbenenner.application.port.in.BatchRunProcessingUseCase {
        private final boolean success;
        MockRunBatchProcessingUseCase(boolean success) { this.success = success; }
        @Override
        public BatchRunOutcome execute(de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext context) {
            return success ? BatchRunOutcome.SUCCESS : BatchRunOutcome.FAILURE;
        }
    }
}
