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

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

import java.util.Properties;
import java.util.function.Function;

import org.junit.jupiter.api.Test;

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

/**
 * Tests for the multi-provider configuration parsing and validation pipeline.
 * <p>
 * Covers all mandatory test cases for the new configuration schema as defined
 * in the active work package specification.
 */
class MultiProviderConfigurationTest {

    private static final Function<String, String> NO_ENV = key -> null;

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

    private Properties fullOpenAiProperties() {
        Properties props = new Properties();
        props.setProperty("ai.provider.active", "openai-compatible");
        props.setProperty("ai.provider.openai-compatible.baseUrl", "https://api.openai.com");
        props.setProperty("ai.provider.openai-compatible.model", "gpt-4o");
        props.setProperty("ai.provider.openai-compatible.timeoutSeconds", "30");
        props.setProperty("ai.provider.openai-compatible.apiKey", "sk-openai-test");
        // Claude side intentionally not set (inactive)
        return props;
    }

    private Properties fullClaudeProperties() {
        Properties props = new Properties();
        props.setProperty("ai.provider.active", "claude");
        props.setProperty("ai.provider.claude.baseUrl", "https://api.anthropic.com");
        props.setProperty("ai.provider.claude.model", "claude-3-5-sonnet-20241022");
        props.setProperty("ai.provider.claude.timeoutSeconds", "60");
        props.setProperty("ai.provider.claude.apiKey", "sk-ant-test");
        // OpenAI side intentionally not set (inactive)
        return props;
    }

    private MultiProviderConfiguration parseAndValidate(Properties props,
                                                         Function<String, String> envLookup) {
        MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(envLookup);
        MultiProviderConfiguration config = parser.parse(props);
        new MultiProviderConfigurationValidator().validate(config);
        return config;
    }

    private MultiProviderConfiguration parseAndValidate(Properties props) {
        return parseAndValidate(props, NO_ENV);
    }

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

    /**
     * Full new schema with OpenAI-compatible active, all required values present.
     * Parser and validator must both succeed.
     */
    @Test
    void parsesNewSchemaWithOpenAiCompatibleActive() {
        MultiProviderConfiguration config = parseAndValidate(fullOpenAiProperties());

        assertEquals(AiProviderFamily.OPENAI_COMPATIBLE, config.activeProviderFamily());
        assertEquals("gpt-4o", config.openAiCompatibleConfig().model());
        assertEquals(30, config.openAiCompatibleConfig().timeoutSeconds());
        assertEquals("https://api.openai.com", config.openAiCompatibleConfig().baseUrl());
        assertEquals("sk-openai-test", config.openAiCompatibleConfig().apiKey());
    }

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

    /**
     * Full new schema with Claude active, all required values present.
     * Parser and validator must both succeed.
     */
    @Test
    void parsesNewSchemaWithClaudeActive() {
        MultiProviderConfiguration config = parseAndValidate(fullClaudeProperties());

        assertEquals(AiProviderFamily.CLAUDE, config.activeProviderFamily());
        assertEquals("claude-3-5-sonnet-20241022", config.claudeConfig().model());
        assertEquals(60, config.claudeConfig().timeoutSeconds());
        assertEquals("https://api.anthropic.com", config.claudeConfig().baseUrl());
        assertEquals("sk-ant-test", config.claudeConfig().apiKey());
    }

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

    /**
     * Claude active, {@code ai.provider.claude.baseUrl} absent.
     * The default {@code https://api.anthropic.com} must be applied; validation must pass.
     */
    @Test
    void claudeBaseUrlDefaultsWhenMissing() {
        Properties props = fullClaudeProperties();
        props.remove("ai.provider.claude.baseUrl");

        MultiProviderConfiguration config = parseAndValidate(props);

        assertNotNull(config.claudeConfig().baseUrl(),
                "baseUrl must not be null when Claude default is applied");
        assertEquals(MultiProviderConfigurationParser.CLAUDE_DEFAULT_BASE_URL,
                config.claudeConfig().baseUrl(),
                "Default Claude baseUrl must be https://api.anthropic.com");
    }

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

    /**
     * {@code ai.provider.active} is absent. Parser must throw with a clear message.
     */
    @Test
    void rejectsMissingActiveProvider() {
        Properties props = fullOpenAiProperties();
        props.remove("ai.provider.active");

        MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
        ConfigurationLoadingException ex = assertThrows(
                ConfigurationLoadingException.class,
                () -> parser.parse(props));

        assertTrue(ex.getMessage().contains("ai.provider.active"),
                "Error message must reference the missing property");
    }

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

    /**
     * {@code ai.provider.active=foo} – unrecognised value. Parser must throw.
     */
    @Test
    void rejectsUnknownActiveProvider() {
        Properties props = fullOpenAiProperties();
        props.setProperty("ai.provider.active", "foo");

        MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
        ConfigurationLoadingException ex = assertThrows(
                ConfigurationLoadingException.class,
                () -> parser.parse(props));

        assertTrue(ex.getMessage().contains("foo"),
                "Error message must include the unrecognised value");
    }

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

    /**
     * Active provider has a mandatory field blank (model removed). Validation must fail.
     */
    @Test
    void rejectsMissingMandatoryFieldForActiveProvider() {
        Properties props = fullOpenAiProperties();
        props.remove("ai.provider.openai-compatible.model");

        MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
        MultiProviderConfiguration config = parser.parse(props);

        InvalidStartConfigurationException ex = assertThrows(
                InvalidStartConfigurationException.class,
                () -> new MultiProviderConfigurationValidator().validate(config));

        assertTrue(ex.getMessage().contains("model"),
                "Error message must mention the missing field");
    }

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

    /**
     * Inactive provider has incomplete configuration (Claude fields missing while OpenAI is active).
     * Validation must pass; inactive provider fields are not required.
     */
    @Test
    void acceptsMissingMandatoryFieldForInactiveProvider() {
        // OpenAI active, Claude completely unconfigured
        Properties props = fullOpenAiProperties();
        // No ai.provider.claude.* keys set

        MultiProviderConfiguration config = parseAndValidate(props);

        assertEquals(AiProviderFamily.OPENAI_COMPATIBLE, config.activeProviderFamily(),
                "Active provider must be openai-compatible");
        // Claude config may have null/blank fields – no exception expected
    }

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

    /**
     * Environment variable for the active provider overrides the properties value.
     * <p>
     * Sub-case A: {@code OPENAI_COMPATIBLE_API_KEY} set, OpenAI active.
     * Sub-case B: {@code ANTHROPIC_API_KEY} set, Claude active.
     */
    @Test
    void envVarOverridesPropertiesApiKeyForActiveProvider() {
        // Sub-case A: OpenAI active, OPENAI_COMPATIBLE_API_KEY set
        Properties openAiProps = fullOpenAiProperties();
        openAiProps.setProperty("ai.provider.openai-compatible.apiKey", "properties-key");

        Function<String, String> envWithOpenAiKey = key ->
                MultiProviderConfigurationParser.ENV_OPENAI_API_KEY.equals(key)
                        ? "env-openai-key" : null;

        MultiProviderConfiguration openAiConfig = parseAndValidate(openAiProps, envWithOpenAiKey);
        assertEquals("env-openai-key", openAiConfig.openAiCompatibleConfig().apiKey(),
                "Env var must override properties API key for OpenAI-compatible");

        // Sub-case B: Claude active, ANTHROPIC_API_KEY set
        Properties claudeProps = fullClaudeProperties();
        claudeProps.setProperty("ai.provider.claude.apiKey", "properties-key");

        Function<String, String> envWithClaudeKey = key ->
                MultiProviderConfigurationParser.ENV_CLAUDE_API_KEY.equals(key)
                        ? "env-claude-key" : null;

        MultiProviderConfiguration claudeConfig = parseAndValidate(claudeProps, envWithClaudeKey);
        assertEquals("env-claude-key", claudeConfig.claudeConfig().apiKey(),
                "Env var must override properties API key for Claude");
    }

    // =========================================================================
    // Test: legacy env var PDF_UMBENENNER_API_KEY
    // =========================================================================

    /**
     * {@code PDF_UMBENENNER_API_KEY} is set, {@code OPENAI_COMPATIBLE_API_KEY} is absent.
     * The legacy variable must be accepted as a fallback for the OpenAI-compatible provider.
     */
    @Test
    void legacyEnvVarPdfUmbenennerApiKeyUsedWhenPrimaryAbsent() {
        Properties props = fullOpenAiProperties();
        props.remove("ai.provider.openai-compatible.apiKey");

        Function<String, String> envWithLegacy = key ->
                MultiProviderConfigurationParser.ENV_LEGACY_OPENAI_API_KEY.equals(key)
                        ? "legacy-env-key" : null;

        MultiProviderConfiguration config = parseAndValidate(props, envWithLegacy);
        assertEquals("legacy-env-key", config.openAiCompatibleConfig().apiKey(),
                "Legacy env var PDF_UMBENENNER_API_KEY must be used when OPENAI_COMPATIBLE_API_KEY is absent");
    }

    /**
     * {@code OPENAI_COMPATIBLE_API_KEY} takes precedence over {@code PDF_UMBENENNER_API_KEY}.
     */
    @Test
    void primaryEnvVarTakesPrecedenceOverLegacyEnvVar() {
        Properties props = fullOpenAiProperties();
        props.remove("ai.provider.openai-compatible.apiKey");

        Function<String, String> envBoth = key -> {
            if (MultiProviderConfigurationParser.ENV_OPENAI_API_KEY.equals(key)) return "primary-key";
            if (MultiProviderConfigurationParser.ENV_LEGACY_OPENAI_API_KEY.equals(key)) return "legacy-key";
            return null;
        };

        MultiProviderConfiguration config = parseAndValidate(props, envBoth);
        assertEquals("primary-key", config.openAiCompatibleConfig().apiKey(),
                "OPENAI_COMPATIBLE_API_KEY must take precedence over PDF_UMBENENNER_API_KEY");
    }

    /**
     * Neither env var is set; the properties value is used as final fallback.
     */
    @Test
    void propertiesApiKeyUsedWhenNoEnvVarSet() {
        Properties props = fullOpenAiProperties();
        props.setProperty("ai.provider.openai-compatible.apiKey", "props-only-key");

        MultiProviderConfiguration config = parseAndValidate(props, NO_ENV);
        assertEquals("props-only-key", config.openAiCompatibleConfig().apiKey(),
                "Properties API key must be used when no env var is set");
    }

    // =========================================================================
    // Tests: base URL validation
    // =========================================================================

    /**
     * OpenAI-compatible provider with an invalid (non-URI) base URL must be rejected.
     */
    @Test
    void rejectsInvalidBaseUrlForActiveOpenAiProvider() {
        Properties props = fullOpenAiProperties();
        props.setProperty("ai.provider.openai-compatible.baseUrl", "not a valid url at all ://");

        MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
        MultiProviderConfiguration config = parser.parse(props);

        InvalidStartConfigurationException ex = assertThrows(
                InvalidStartConfigurationException.class,
                () -> new MultiProviderConfigurationValidator().validate(config));

        assertTrue(ex.getMessage().contains("baseUrl"),
                "Error message must reference baseUrl");
    }

    /**
     * Claude provider with an invalid base URL must be rejected when Claude is active.
     */
    @Test
    void rejectsInvalidBaseUrlForActiveClaudeProvider() {
        Properties props = fullClaudeProperties();
        props.setProperty("ai.provider.claude.baseUrl", "ftp://api.anthropic.com");

        MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
        MultiProviderConfiguration config = parser.parse(props);

        InvalidStartConfigurationException ex = assertThrows(
                InvalidStartConfigurationException.class,
                () -> new MultiProviderConfigurationValidator().validate(config));

        assertTrue(ex.getMessage().contains("baseUrl"),
                "Error message must reference baseUrl");
        assertTrue(ex.getMessage().contains("ftp"),
                "Error message must mention the invalid scheme");
    }

    /**
     * A relative URI (no scheme, no host) must be rejected.
     */
    @Test
    void rejectsRelativeUriAsBaseUrl() {
        Properties props = fullOpenAiProperties();
        props.setProperty("ai.provider.openai-compatible.baseUrl", "/v1/chat");

        MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
        MultiProviderConfiguration config = parser.parse(props);

        InvalidStartConfigurationException ex = assertThrows(
                InvalidStartConfigurationException.class,
                () -> new MultiProviderConfigurationValidator().validate(config));

        assertTrue(ex.getMessage().contains("baseUrl"),
                "Error message must reference baseUrl");
    }

    /**
     * A non-http/https scheme (e.g. {@code ftp://}) must be rejected.
     */
    @Test
    void rejectsNonHttpSchemeAsBaseUrl() {
        Properties props = fullOpenAiProperties();
        props.setProperty("ai.provider.openai-compatible.baseUrl", "ftp://api.example.com");

        MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
        MultiProviderConfiguration config = parser.parse(props);

        InvalidStartConfigurationException ex = assertThrows(
                InvalidStartConfigurationException.class,
                () -> new MultiProviderConfigurationValidator().validate(config));

        assertTrue(ex.getMessage().contains("baseUrl"),
                "Error message must reference baseUrl");
        assertTrue(ex.getMessage().contains("ftp"),
                "Error message must mention the invalid scheme");
    }

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

    /**
     * Environment variable is set only for the inactive provider.
     * The active provider must use its own properties value; the inactive provider's
     * env var must not affect the active provider's resolved key.
     */
    @Test
    void envVarOnlyResolvesForActiveProvider() {
        // OpenAI is active with a properties apiKey.
        // ANTHROPIC_API_KEY is set (for the inactive Claude provider).
        // The OpenAI config must use its properties key, not the Anthropic env var.
        Properties props = fullOpenAiProperties();
        props.setProperty("ai.provider.openai-compatible.apiKey", "openai-properties-key");

        Function<String, String> envWithClaudeKeyOnly = key ->
                MultiProviderConfigurationParser.ENV_CLAUDE_API_KEY.equals(key)
                        ? "anthropic-env-key" : null;

        MultiProviderConfiguration config = parseAndValidate(props, envWithClaudeKeyOnly);

        assertEquals("openai-properties-key",
                config.openAiCompatibleConfig().apiKey(),
                "Active provider (OpenAI) must use its own properties key, "
                        + "not the inactive provider's env var");
        // The Anthropic env var IS applied to the Claude config (inactive),
        // but that does not affect the active provider.
        assertEquals("anthropic-env-key",
                config.claudeConfig().apiKey(),
                "Inactive Claude config should still pick up its own env var");
    }

    // =========================================================================
    // Tests: timeout validation
    // =========================================================================

    /**
     * Active provider has timeout set to 0. Validation must fail and mention timeoutSeconds.
     * This verifies that validateTimeoutSeconds is called and that the boundary is strictly
     * positive (i.e. 0 is rejected, not just negative values).
     */
    @Test
    void rejectsZeroTimeoutForActiveProvider() {
        Properties props = fullOpenAiProperties();
        props.setProperty("ai.provider.openai-compatible.timeoutSeconds", "0");

        MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
        MultiProviderConfiguration config = parser.parse(props);

        InvalidStartConfigurationException ex = assertThrows(
                InvalidStartConfigurationException.class,
                () -> new MultiProviderConfigurationValidator().validate(config));

        assertTrue(ex.getMessage().contains("timeoutSeconds"),
                "Error message must reference timeoutSeconds");
    }

    /**
     * Active Claude provider has timeout set to 0. Same invariant for the other provider family.
     */
    @Test
    void rejectsZeroTimeoutForActiveClaudeProvider() {
        Properties props = fullClaudeProperties();
        props.setProperty("ai.provider.claude.timeoutSeconds", "0");

        MultiProviderConfigurationParser parser = new MultiProviderConfigurationParser(NO_ENV);
        MultiProviderConfiguration config = parser.parse(props);

        InvalidStartConfigurationException ex = assertThrows(
                InvalidStartConfigurationException.class,
                () -> new MultiProviderConfigurationValidator().validate(config));

        assertTrue(ex.getMessage().contains("timeoutSeconds"),
                "Error message must reference timeoutSeconds");
    }
}
