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.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
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.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.StartConfigurationValidator;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException;
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.in.BatchRunProcessingUseCase;
import de.gecheckt.pdf.umbenenner.application.port.out.ConfigurationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.DocumentPersistenceException;
import de.gecheckt.pdf.umbenenner.application.port.out.PersistenceSchemaInitializationPort;
import de.gecheckt.pdf.umbenenner.application.port.out.RunLockPort;
import de.gecheckt.pdf.umbenenner.domain.model.BatchRunContext;

/**
 * Unit tests for {@link BootstrapRunner}.
 * <p>
 * Tests cover the bootstrap orchestration behavior including success path,
 * invalid configuration handling, unexpected failure handling, and the
 * empty-lock-file-path default behavior.
 */
class BootstrapRunnerTest {

    @TempDir
    Path tempDir;

    @Test
    void run_returnsZeroOnSuccess() throws Exception {
        ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);

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

        int exitCode = runner.run();

        assertEquals(0, exitCode, "Success path should return exit code 0");
    }

    @Test
    void run_returnsOneOnInvalidConfiguration() throws Exception {
        ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);

        StartConfigurationValidator failingValidator = new StartConfigurationValidator() {
            @Override
            public void validate(StartConfiguration config) {
                throw new InvalidStartConfigurationException("Simulated validation failure");
            }
        };

        BootstrapRunner runner = new BootstrapRunner(
                configPath -> mockConfigPort,
                lockFile -> new MockRunLockPort(),
                () -> failingValidator,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                useCase -> new SchedulerBatchCommand(useCase)
        );

        int exitCode = runner.run();

        assertEquals(1, exitCode, "Invalid configuration should return exit code 1");
    }

    @Test
    void run_returnsOneOnConfigurationLoadingFailure() {
        ConfigurationPort failingConfigPort = () -> {
            throw new ConfigurationLoadingException("Simulated configuration loading failure");
        };

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

        int exitCode = runner.run();

        assertEquals(1, exitCode, "Configuration loading failure should return exit code 1");
    }

    @Test
    void run_returnsOneOnUnexpectedException() {
        ConfigurationPort throwingConfigPort = () -> {
            throw new RuntimeException("Simulated unexpected failure");
        };

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

        int exitCode = runner.run();

        assertEquals(1, exitCode, "Unexpected exception should return exit code 1");
    }

    @Test
    void run_returnsOneWhenBatchFails() throws Exception {
        ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
        BatchRunProcessingUseCase failingUseCase = (context) -> BatchRunOutcome.FAILURE;

        BootstrapRunner runner = new BootstrapRunner(
                configPath -> mockConfigPort,
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> failingUseCase,
                useCase -> new SchedulerBatchCommand(useCase)
        );

        int exitCode = runner.run();

        assertEquals(1, exitCode, "Batch failure outcome should return exit code 1");
    }

    @Test
    void run_returnsOneWhenLockUnavailable() throws Exception {
        // Controlled early termination when another instance is already running
        ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
        BatchRunProcessingUseCase lockUnavailableUseCase = (context) -> BatchRunOutcome.LOCK_UNAVAILABLE;

        BootstrapRunner runner = new BootstrapRunner(
                configPath -> mockConfigPort,
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> lockUnavailableUseCase,
                useCase -> new SchedulerBatchCommand(useCase)
        );

        int exitCode = runner.run();

        assertEquals(1, exitCode, "Lock unavailable (another instance running) should return exit code 1");
    }

    /**
     * Verifies that when runtime.lock.file is absent (empty path),
     * BootstrapRunner applies a default lock path instead of passing Paths.get("")
     * to the lock adapter (which would always see the current directory as "existing"
     * and permanently block lock acquisition).
     */
    @Test
    void run_usesDefaultLockPathWhenRuntimeLockFileNotConfigured() throws Exception {
        // Build a config that has an empty runtimeLockFile (simulates unconfigured property)
        Path sourceDir = Files.createDirectories(tempDir.resolve("source"));
        Path targetDir = Files.createDirectories(tempDir.resolve("target"));
        Path dbFile = Files.createFile(tempDir.resolve("db.sqlite"));
        Path promptFile = Files.createFile(tempDir.resolve("prompt.txt"));

        StartConfiguration configWithEmptyLock = new StartConfiguration(
                sourceDir,
                targetDir,
                dbFile,
                validMultiProviderConfig(),
                3,
                100,
                50000,
                60,
                promptFile,
                Paths.get(""),          // empty – simulates unconfigured runtime.lock.file
                tempDir.resolve("logs"),
                "INFO",
                false
        );

        AtomicReference<Path> capturedLockPath = new AtomicReference<>();

        // ConfigurationPortFactory returns a ConfigurationPort; ConfigurationPort returns StartConfiguration
        ConfigurationPort configPort = () -> configWithEmptyLock;
        BootstrapRunner runner = new BootstrapRunner(
                configPath -> configPort,
                lockFile -> {
                    capturedLockPath.set(lockFile);
                    return new MockRunLockPort();
                },
                StartConfigurationValidator::new,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                useCase -> new SchedulerBatchCommand(useCase)
        );

        int exitCode = runner.run();

        assertEquals(0, exitCode, "Should succeed even when runtime.lock.file is not configured");
        assertNotNull(capturedLockPath.get(), "Lock path should have been set");
        assertFalse(capturedLockPath.get().toString().isBlank(),
                "Lock path passed to adapter must not be blank (default must be applied)");
    }

    /**
     * Exit-code contract: a completed batch run returns exit code 0 even when individual
     * documents ended with content errors or technical document errors.
     * Only hard infrastructure or startup failures must cause exit code 1.
     */
    @Test
    void run_returnsZeroWhenBatchRunCompletesWithDocumentLevelFailures() throws Exception {
        ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
        // BatchRunOutcome.SUCCESS is returned when the batch run completed,
        // regardless of how many individual documents had content or technical errors.
        BatchRunProcessingUseCase useCaseWithDocumentFailures = (context) -> BatchRunOutcome.SUCCESS;

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

        assertEquals(0, runner.run(),
                "Batch completion with document-level failures must return exit code 0");
    }

    @Test
    void run_withDefaultConstructor_usesRealImplementations() {
        BootstrapRunner runner = new BootstrapRunner();
        assertNotNull(runner, "Default constructor should create a valid BootstrapRunner");
    }

    /**
     * Verifies that max.retries.transient = 0 is rejected as invalid startup configuration
     * through the full bootstrap path using the real validator. The value 0 is explicitly
     * prohibited; only integers >= 1 are valid. Invalid startup configuration must prevent
     * the batch from starting and must produce exit code 1.
     */
    @Test
    void run_returnsOneWhenMaxRetriesTransientIsZeroViaRealValidator() throws Exception {
        Path sourceDir = Files.createDirectories(tempDir.resolve("source-mrt"));
        Path targetDir = Files.createDirectories(tempDir.resolve("target-mrt"));
        Path dbFile = Files.createFile(tempDir.resolve("db-mrt.sqlite"));
        Path promptFile = Files.createFile(tempDir.resolve("prompt-mrt.txt"));

        StartConfiguration configWithZeroRetries = new StartConfiguration(
                sourceDir,
                targetDir,
                dbFile,
                validMultiProviderConfig(),
                0,      // max.retries.transient = 0 is invalid (must be >= 1)
                100,
                50000,
                60,
                promptFile,
                tempDir.resolve("lock-mrt.lock"),
                null,
                "INFO",
                false
        );

        BootstrapRunner runner = new BootstrapRunner(
                configPath -> () -> configWithZeroRetries,
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,   // use the real validator
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                SchedulerBatchCommand::new
        );

        assertEquals(1, runner.run(),
                "max.retries.transient = 0 must be rejected as invalid startup configuration "
                + "and must produce exit code 1");
    }

    /**
     * Verifies that an invalid boolean property value for logging configuration
     * (such as a non-boolean log.ai.sensitive value) causes a ConfigurationLoadingException
     * during the loading phase, which must propagate to exit code 1.
     * This ensures that malformed boolean configuration values prevent the batch from starting.
     */
    @Test
    void run_returnsOneWhenConfigurationLoadingFailsDueToInvalidBooleanProperty() {
        BootstrapRunner runner = new BootstrapRunner(
                configPath -> {
                    throw new de.gecheckt.pdf.umbenenner.adapter.out.configuration.ConfigurationLoadingException(
                            "Invalid value for log.ai.sensitive: 'maybe'. "
                            + "Must be either 'true' or 'false' (case-insensitive).");
                },
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                SchedulerBatchCommand::new
        );

        assertEquals(1, runner.run(),
                "Invalid boolean property value for logging configuration must produce exit code 1 "
                + "via ConfigurationLoadingException");
    }

    /**
     * Hard startup failure test — schema initialization failure must be treated as
     * a startup error and result in exit code 1. This verifies that
     * {@link DocumentPersistenceException} thrown from
     * {@link PersistenceSchemaInitializationPort#initializeSchema()} is correctly caught
     * and handled as a bootstrap failure.
     */
    @Test
    void run_returnsOneWhenSchemaInitializationFails() throws Exception {
        ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);

        BootstrapRunner runner = new BootstrapRunner(
                configPath -> mockConfigPort,
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,
                jdbcUrl -> new PersistenceSchemaInitializationPort() {
                    @Override
                    public void initializeSchema() {
                        throw new DocumentPersistenceException("Simulated schema initialization failure");
                    }
                },
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                useCase -> new SchedulerBatchCommand(useCase)
        );

        int exitCode = runner.run();

        assertEquals(1, exitCode, "Schema initialization failure should return exit code 1");
    }

    // =========================================================================
    // Mandatory test case: activeProviderIsLoggedAtRunStart
    // =========================================================================

    /**
     * The active AI provider identifier must be logged at INFO level during the bootstrap phase,
     * after configuration is loaded and validated but before batch processing begins.
     */
    @Test
    void activeProviderIsLoggedAtRunStart() throws Exception {
        ConfigurationPort mockConfigPort = new MockConfigurationPort(tempDir, true);
        BootstrapRunner runner = new BootstrapRunner(
                configPath -> mockConfigPort,
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                SchedulerBatchCommand::new
        );

        List<String> capturedMessages = new ArrayList<>();
        String appenderName = "TestCapture-" + 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) {
                capturedMessages.add(event.getMessage().getFormattedMessage());
            }
        };
        captureAppender.start();
        cfg.addAppender(captureAppender);
        cfg.getRootLogger().addAppender(captureAppender, Level.ALL, null);
        ctx.updateLoggers();
        try {
            runner.run();
        } finally {
            cfg.getRootLogger().removeAppender(appenderName);
            ctx.updateLoggers();
            captureAppender.stop();
        }

        assertTrue(capturedMessages.stream().anyMatch(m -> m.contains("openai-compatible")),
                "Active AI provider identifier must be logged at run start");
    }

    // =========================================================================
    // Mandatory test case: legacyFileEndToEndStillRuns
    // =========================================================================

    /**
     * End-to-end test verifying that a legacy flat-key configuration file is automatically
     * migrated to the multi-provider schema and that the application run completes successfully
     * after migration.
     * <p>
     * Covers the full migration path: detection, backup creation, in-place rewrite,
     * re-validation, and subsequent successful configuration load.
     */
    @Test
    void legacyFileEndToEndStillRuns(@TempDir Path workDir) throws Exception {
        Path sourceDir = Files.createDirectories(workDir.resolve("source"));
        Path targetDir = Files.createDirectories(workDir.resolve("target"));
        Path dbParentDir = Files.createDirectories(workDir.resolve("data"));
        Path promptDir = Files.createDirectories(workDir.resolve("config/prompts"));
        Path promptFile = Files.createFile(promptDir.resolve("template.txt"));
        Files.writeString(promptFile, "Test prompt template.");

        Path configFile = workDir.resolve("application.properties");
        String legacyConfig = String.format(
                "source.folder=%s%n"
                + "target.folder=%s%n"
                + "sqlite.file=%s%n"
                + "api.baseUrl=https://api.example.com%n"
                + "api.model=gpt-4%n"
                + "api.timeoutSeconds=30%n"
                + "api.key=test-legacy-key%n"
                + "max.retries.transient=3%n"
                + "max.pages=10%n"
                + "max.text.characters=5000%n"
                + "prompt.template.file=%s%n",
                sourceDir.toAbsolutePath(),
                targetDir.toAbsolutePath(),
                dbParentDir.resolve("db.sqlite").toAbsolutePath(),
                promptFile.toAbsolutePath()
        );
        Files.writeString(configFile, legacyConfig);

        BootstrapRunner runner = new BootstrapRunner(
                path -> new LegacyConfigurationMigrator().migrateIfLegacy(configFile),
                path -> new PropertiesConfigurationPortAdapter(configFile),
                lockFile -> new MockRunLockPort(),
                StartConfigurationValidator::new,
                jdbcUrl -> new MockSchemaInitializationPort(),
                (config, lock) -> new MockRunBatchProcessingUseCase(true),
                SchedulerBatchCommand::new
        );

        int exitCode = runner.run();

        assertEquals(0, exitCode,
                "Legacy configuration must be migrated and the run must complete successfully");
        assertTrue(Files.exists(workDir.resolve("application.properties.bak")),
                "Backup file must exist after migration");
    }

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

    private static MultiProviderConfiguration validMultiProviderConfig() {
        ProviderConfiguration openAiConfig = new ProviderConfiguration(
                "gpt-4", 30, "https://api.example.com", "test-api-key");
        return new MultiProviderConfiguration(AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null);
    }

    // -------------------------------------------------------------------------
    // Mocks
    // -------------------------------------------------------------------------

    private static class MockConfigurationPort implements ConfigurationPort {
        private final Path tempDir;
        private final boolean shouldSucceed;

        MockConfigurationPort(Path tempDir, boolean shouldSucceed) {
            this.tempDir = tempDir;
            this.shouldSucceed = shouldSucceed;
        }

        @Override
        public StartConfiguration loadConfiguration() {
            if (!shouldSucceed) {
                throw new IllegalStateException("Mock configuration loading failed");
            }

            try {
                Path sourceFolder = Files.createDirectories(tempDir.resolve("source"));
                Path targetFolder = Files.createDirectories(tempDir.resolve("target"));
                Path sqliteFile = tempDir.resolve("db.sqlite");
                if (!Files.exists(sqliteFile)) {
                    Files.createFile(sqliteFile);
                }
                Path promptTemplateFile = tempDir.resolve("prompt.txt");
                if (!Files.exists(promptTemplateFile)) {
                    Files.createFile(promptTemplateFile);
                }

                ProviderConfiguration openAiConfig = new ProviderConfiguration(
                        "gpt-4", 30, "https://api.example.com", "test-api-key");
                MultiProviderConfiguration multiConfig = new MultiProviderConfiguration(
                        AiProviderFamily.OPENAI_COMPATIBLE, openAiConfig, null);

                return new StartConfiguration(
                        sourceFolder,
                        targetFolder,
                        sqliteFile,
                        multiConfig,
                        3,
                        100,
                        50000,
                        60,
                        promptTemplateFile,
                        tempDir.resolve("lock.lock"),
                        tempDir.resolve("logs"),
                        "INFO",
                        false
                );
            } catch (Exception e) {
                throw new RuntimeException("Failed to create mock configuration", e);
            }
        }
    }

    private static class MockRunBatchProcessingUseCase implements BatchRunProcessingUseCase {
        private final boolean shouldSucceed;

        MockRunBatchProcessingUseCase(boolean shouldSucceed) {
            this.shouldSucceed = shouldSucceed;
        }

        @Override
        public BatchRunOutcome execute(BatchRunContext context) {
            return shouldSucceed ? BatchRunOutcome.SUCCESS : BatchRunOutcome.FAILURE;
        }
    }

    private static class MockRunLockPort implements 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 PersistenceSchemaInitializationPort {
        @Override
        public void initializeSchema() {
            // Success by default; can be subclassed to throw exceptions for testing
        }
    }
}
