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

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.net.ConnectException;
import java.net.UnknownHostException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;

import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import de.gecheckt.pdf.umbenenner.adapter.out.bootstrap.validation.InvalidStartConfigurationException;
import de.gecheckt.pdf.umbenenner.adapter.out.configuration.MultiProviderConfigurationValidator;
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.port.out.AiInvocationResult;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationSuccess;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationTechnicalFailure;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;
import de.gecheckt.pdf.umbenenner.domain.model.PromptIdentifier;

/**
 * Unit tests for {@link AnthropicClaudeHttpAdapter}.
 * <p>
 * Tests inject a mock {@link HttpClient} via the package-private constructor
 * to exercise the adapter path without requiring network access.
 * Configuration is supplied via {@link ProviderConfiguration}.
 * <p>
 * Covered scenarios:
 * <ul>
 *   <li>Correct HTTP request structure (URL, method, headers, body)</li>
 *   <li>API key resolution (env var vs. properties value)</li>
 *   <li>Configuration validation for missing API key</li>
 *   <li>Single and multiple text-block extraction from Anthropic response</li>
 *   <li>Ignoring non-text content blocks</li>
 *   <li>Technical failure when no text blocks are present</li>
 *   <li>HTTP 4xx (401, 429) and 5xx (500) mapped to technical failure</li>
 *   <li>Timeout mapped to technical failure</li>
 *   <li>Unparseable JSON response mapped to technical failure</li>
 * </ul>
 */
@ExtendWith(MockitoExtension.class)
@DisplayName("AnthropicClaudeHttpAdapter")
class AnthropicClaudeHttpAdapterTest {

    private static final String API_BASE_URL = "https://api.anthropic.com";
    private static final String API_MODEL = "claude-3-5-sonnet-20241022";
    private static final String API_KEY = "sk-ant-test-key-12345";
    private static final int TIMEOUT_SECONDS = 60;

    @Mock
    private HttpClient httpClient;

    private ProviderConfiguration testConfiguration;
    private AnthropicClaudeHttpAdapter adapter;

    @BeforeEach
    void setUp() {
        testConfiguration = new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
        adapter = new AnthropicClaudeHttpAdapter(testConfiguration, httpClient);
    }

    // =========================================================================
    // Pflicht-Testfall 1: claudeAdapterBuildsCorrectRequest
    // =========================================================================

    /**
     * Verifies that the adapter constructs the correct HTTP request:
     * URL with {@code /v1/messages} path, method POST, all three required headers
     * ({@code x-api-key}, {@code anthropic-version}, {@code content-type}), and
     * a body with {@code model}, {@code max_tokens > 0}, and {@code messages} containing
     * exactly one user message with the document text.
     */
    @Test
    @DisplayName("claudeAdapterBuildsCorrectRequest: correct URL, method, headers, and body")
    void claudeAdapterBuildsCorrectRequest() throws Exception {
        HttpResponse<String> httpResponse = mockHttpResponse(200, buildAnthropicSuccessResponse(
                "{\"date\":\"2024-01-15\",\"title\":\"Testititel\",\"reasoning\":\"Test\"}"));
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiRequestRepresentation request = createTestRequest("System-Prompt", "Dokumenttext");
        adapter.invoke(request);

        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());
        HttpRequest capturedRequest = requestCaptor.getValue();

        // URL must point to /v1/messages
        assertThat(capturedRequest.uri().toString())
                .as("URL must be based on configured baseUrl")
                .startsWith(API_BASE_URL)
                .endsWith("/v1/messages");

        // Method must be POST
        assertThat(capturedRequest.method()).isEqualTo("POST");

        // All three required headers must be present
        assertThat(capturedRequest.headers().firstValue("x-api-key"))
                .as("x-api-key header must be present")
                .isPresent();
        assertThat(capturedRequest.headers().firstValue("anthropic-version"))
                .as("anthropic-version header must be present")
                .isPresent()
                .hasValue("2023-06-01");
        assertThat(capturedRequest.headers().firstValue("content-type"))
                .as("content-type header must be present")
                .isPresent();

        // Body must contain model, max_tokens > 0, and messages with one user message
        String sentBody = adapter.getLastBuiltJsonBodyForTesting();
        JSONObject body = new JSONObject(sentBody);
        assertThat(body.getString("model"))
                .as("model must match configuration")
                .isEqualTo(API_MODEL);
        assertThat(body.getInt("max_tokens"))
                .as("max_tokens must be positive")
                .isGreaterThan(0);
        assertThat(body.getJSONArray("messages").length())
                .as("messages must contain exactly one entry")
                .isEqualTo(1);
        assertThat(body.getJSONArray("messages").getJSONObject(0).getString("role"))
                .as("the single message must be a user message")
                .isEqualTo("user");
        assertThat(body.getJSONArray("messages").getJSONObject(0).getString("content"))
                .as("user message content must be the document text")
                .isEqualTo("Dokumenttext");
    }

    // =========================================================================
    // Pflicht-Testfall 2: claudeAdapterUsesEnvVarApiKey
    // =========================================================================

    /**
     * Verifies that when the {@code ANTHROPIC_API_KEY} environment variable is the source
     * of the resolved API key (represented in ProviderConfiguration after env-var precedence
     * was applied by the configuration layer), the adapter uses that key in the
     * {@code x-api-key} header.
     */
    @Test
    @DisplayName("claudeAdapterUsesEnvVarApiKey: env var value reaches x-api-key header")
    void claudeAdapterUsesEnvVarApiKey() throws Exception {
        String envVarValue = "sk-ant-from-env-variable";
        // Env var takes precedence: the configuration layer resolves this into apiKey
        ProviderConfiguration configWithEnvKey = new ProviderConfiguration(
                API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, envVarValue);
        AnthropicClaudeHttpAdapter adapterWithEnvKey =
                new AnthropicClaudeHttpAdapter(configWithEnvKey, httpClient);

        HttpResponse<String> httpResponse = mockHttpResponse(200,
                buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        adapterWithEnvKey.invoke(createTestRequest("prompt", "doc"));

        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());
        assertThat(requestCaptor.getValue().headers().firstValue("x-api-key"))
                .as("x-api-key header must contain the env var value")
                .hasValue(envVarValue);
    }

    // =========================================================================
    // Pflicht-Testfall 3: claudeAdapterFallsBackToPropertiesApiKey
    // =========================================================================

    /**
     * Verifies that when no environment variable is set, the API key from the
     * properties configuration is used in the {@code x-api-key} header.
     */
    @Test
    @DisplayName("claudeAdapterFallsBackToPropertiesApiKey: properties key reaches x-api-key header")
    void claudeAdapterFallsBackToPropertiesApiKey() throws Exception {
        String propertiesKey = "sk-ant-from-properties";
        ProviderConfiguration configWithPropertiesKey = new ProviderConfiguration(
                API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, propertiesKey);
        AnthropicClaudeHttpAdapter adapterWithPropertiesKey =
                new AnthropicClaudeHttpAdapter(configWithPropertiesKey, httpClient);

        HttpResponse<String> httpResponse = mockHttpResponse(200,
                buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        adapterWithPropertiesKey.invoke(createTestRequest("prompt", "doc"));

        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());
        assertThat(requestCaptor.getValue().headers().firstValue("x-api-key"))
                .as("x-api-key header must contain the properties value")
                .hasValue(propertiesKey);
    }

    // =========================================================================
    // Pflicht-Testfall 4: claudeAdapterFailsValidationWhenBothKeysMissing
    // =========================================================================

    /**
     * Verifies that when both the environment variable and the properties API key for the
     * Claude provider are empty, the {@link MultiProviderConfigurationValidator} rejects the
     * configuration with an {@link InvalidStartConfigurationException}.
     * <p>
     * This confirms that the adapter is protected by startup validation (from AP-001)
     * and will never be constructed with a truly missing API key in production.
     */
    @Test
    @DisplayName("claudeAdapterFailsValidationWhenBothKeysMissing: validator rejects empty API key for Claude")
    void claudeAdapterFailsValidationWhenBothKeysMissing() {
        // Simulate both env var and properties key being absent (empty resolved key)
        ProviderConfiguration claudeConfigWithoutKey = new ProviderConfiguration(
                API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, "");
        ProviderConfiguration inactiveOpenAiConfig = new ProviderConfiguration(
                "unused-model", 0, null, null);
        MultiProviderConfiguration config = new MultiProviderConfiguration(
                AiProviderFamily.CLAUDE, inactiveOpenAiConfig, claudeConfigWithoutKey);

        MultiProviderConfigurationValidator validator = new MultiProviderConfigurationValidator();

        assertThatThrownBy(() -> validator.validate(config))
                .as("Validator must reject Claude configuration with empty API key")
                .isInstanceOf(InvalidStartConfigurationException.class);
    }

    // =========================================================================
    // Pflicht-Testfall 5: claudeAdapterParsesSingleTextBlock
    // =========================================================================

    /**
     * Verifies that a response with a single text block is correctly extracted.
     */
    @Test
    @DisplayName("claudeAdapterParsesSingleTextBlock: single text block becomes raw response")
    void claudeAdapterParsesSingleTextBlock() throws Exception {
        String blockText = "{\"date\":\"2024-01-15\",\"title\":\"Rechnung\",\"reasoning\":\"Test\"}";
        String responseBody = buildAnthropicSuccessResponse(blockText);
        HttpResponse<String> httpResponse = mockHttpResponse(200, responseBody);
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));

        assertThat(result).isInstanceOf(AiInvocationSuccess.class);
        AiInvocationSuccess success = (AiInvocationSuccess) result;
        assertThat(success.rawResponse().content())
                .as("Raw response must equal the text block content")
                .isEqualTo(blockText);
    }

    // =========================================================================
    // Pflicht-Testfall 6: claudeAdapterConcatenatesMultipleTextBlocks
    // =========================================================================

    /**
     * Verifies that multiple text blocks are concatenated in order.
     */
    @Test
    @DisplayName("claudeAdapterConcatenatesMultipleTextBlocks: text blocks are concatenated in order")
    void claudeAdapterConcatenatesMultipleTextBlocks() throws Exception {
        String part1 = "Erster Teil der Antwort. ";
        String part2 = "Zweiter Teil der Antwort.";

        // Build the response using JSONObject to ensure correct escaping
        JSONObject block1 = new JSONObject();
        block1.put("type", "text");
        block1.put("text", part1);
        JSONObject block2 = new JSONObject();
        block2.put("type", "text");
        block2.put("text", part2);
        JSONObject responseJson = new JSONObject();
        responseJson.put("id", "msg_test");
        responseJson.put("type", "message");
        responseJson.put("role", "assistant");
        responseJson.put("content", new JSONArray().put(block1).put(block2));
        responseJson.put("stop_reason", "end_turn");

        HttpResponse<String> httpResponse = mockHttpResponse(200, responseJson.toString());
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));

        assertThat(result).isInstanceOf(AiInvocationSuccess.class);
        assertThat(((AiInvocationSuccess) result).rawResponse().content())
                .as("Multiple text blocks must be concatenated in order")
                .isEqualTo(part1 + part2);
    }

    // =========================================================================
    // Pflicht-Testfall 7: claudeAdapterIgnoresNonTextBlocks
    // =========================================================================

    /**
     * Verifies that non-text content blocks (e.g., tool_use) are ignored and only
     * the text blocks contribute to the raw response.
     */
    @Test
    @DisplayName("claudeAdapterIgnoresNonTextBlocks: only text-type blocks contribute to response")
    void claudeAdapterIgnoresNonTextBlocks() throws Exception {
        String textContent = "Nur dieser Text zaehlt als Antwort.";

        // Build response with a tool_use block before and a tool_result-like block after the text block
        JSONObject toolUseBlock = new JSONObject();
        toolUseBlock.put("type", "tool_use");
        toolUseBlock.put("id", "tool_1");
        toolUseBlock.put("name", "get_weather");
        toolUseBlock.put("input", new JSONObject());

        JSONObject textBlock = new JSONObject();
        textBlock.put("type", "text");
        textBlock.put("text", textContent);

        JSONObject ignoredBlock = new JSONObject();
        ignoredBlock.put("type", "tool_result");
        ignoredBlock.put("content", "irrelevant");

        JSONObject responseJson = new JSONObject();
        responseJson.put("id", "msg_test");
        responseJson.put("type", "message");
        responseJson.put("role", "assistant");
        responseJson.put("content", new JSONArray().put(toolUseBlock).put(textBlock).put(ignoredBlock));
        responseJson.put("stop_reason", "end_turn");

        HttpResponse<String> httpResponse = mockHttpResponse(200, responseJson.toString());
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));

        assertThat(result).isInstanceOf(AiInvocationSuccess.class);
        assertThat(((AiInvocationSuccess) result).rawResponse().content())
                .as("Only text-type blocks must contribute to the raw response")
                .isEqualTo(textContent);
    }

    // =========================================================================
    // Pflicht-Testfall 8: claudeAdapterFailsOnEmptyTextContent
    // =========================================================================

    /**
     * Verifies that a response with no text-type content blocks results in a
     * technical failure.
     */
    @Test
    @DisplayName("claudeAdapterFailsOnEmptyTextContent: no text blocks yields technical failure")
    void claudeAdapterFailsOnEmptyTextContent() throws Exception {
        String noTextBlockResponse = "{"
                + "\"id\":\"msg_test\","
                + "\"type\":\"message\","
                + "\"role\":\"assistant\","
                + "\"content\":["
                + "{\"type\":\"tool_use\",\"id\":\"tool_1\",\"name\":\"unused\",\"input\":{}}"
                + "],"
                + "\"stop_reason\":\"tool_use\""
                + "}";

        HttpResponse<String> httpResponse = mockHttpResponse(200, noTextBlockResponse);
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));

        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        assertThat(((AiInvocationTechnicalFailure) result).failureReason())
                .isEqualTo("NO_TEXT_CONTENT");
    }

    // =========================================================================
    // Pflicht-Testfall 9: claudeAdapterMapsHttp401AsTechnical
    // =========================================================================

    /**
     * Verifies that HTTP 401 (Unauthorized) is classified as a technical failure.
     */
    @Test
    @DisplayName("claudeAdapterMapsHttp401AsTechnical: HTTP 401 yields technical failure")
    void claudeAdapterMapsHttp401AsTechnical() throws Exception {
        HttpResponse<String> httpResponse = mockHttpResponse(401, null);
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));

        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_401");
    }

    // =========================================================================
    // Pflicht-Testfall 10: claudeAdapterMapsHttp429AsTechnical
    // =========================================================================

    /**
     * Verifies that HTTP 429 (Rate Limit Exceeded) is classified as a technical failure.
     */
    @Test
    @DisplayName("claudeAdapterMapsHttp429AsTechnical: HTTP 429 yields technical failure")
    void claudeAdapterMapsHttp429AsTechnical() throws Exception {
        HttpResponse<String> httpResponse = mockHttpResponse(429, null);
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));

        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_429");
    }

    // =========================================================================
    // Pflicht-Testfall 11: claudeAdapterMapsHttp500AsTechnical
    // =========================================================================

    /**
     * Verifies that HTTP 500 (Internal Server Error) is classified as a technical failure.
     */
    @Test
    @DisplayName("claudeAdapterMapsHttp500AsTechnical: HTTP 500 yields technical failure")
    void claudeAdapterMapsHttp500AsTechnical() throws Exception {
        HttpResponse<String> httpResponse = mockHttpResponse(500, null);
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));

        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_500");
    }

    // =========================================================================
    // Pflicht-Testfall 12: claudeAdapterMapsTimeoutAsTechnical
    // =========================================================================

    /**
     * Verifies that a simulated HTTP timeout results in a technical failure with
     * reason {@code TIMEOUT}.
     */
    @Test
    @DisplayName("claudeAdapterMapsTimeoutAsTechnical: timeout yields TIMEOUT technical failure")
    void claudeAdapterMapsTimeoutAsTechnical() throws Exception {
        when(httpClient.send(any(HttpRequest.class), any()))
                .thenThrow(new HttpTimeoutException("Connection timed out"));

        AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));

        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("TIMEOUT");
    }

    // =========================================================================
    // Pflicht-Testfall 13: claudeAdapterMapsUnparseableJsonAsTechnical
    // =========================================================================

    /**
     * Verifies that a non-JSON response body (e.g., an HTML error page or plain text)
     * returned with HTTP 200 results in a technical failure.
     */
    @Test
    @DisplayName("claudeAdapterMapsUnparseableJsonAsTechnical: non-JSON body yields technical failure")
    void claudeAdapterMapsUnparseableJsonAsTechnical() throws Exception {
        HttpResponse<String> httpResponse = mockHttpResponse(200,
                "<html><body>Service unavailable</body></html>");
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiInvocationResult result = adapter.invoke(createTestRequest("prompt", "doc"));

        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("UNPARSEABLE_JSON");
    }

    // =========================================================================
    // Additional behavioral tests
    // =========================================================================

    @Test
    @DisplayName("should use configured model in request body")
    void testConfiguredModelIsUsedInRequestBody() throws Exception {
        HttpResponse<String> httpResponse = mockHttpResponse(200,
                buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        adapter.invoke(createTestRequest("prompt", "doc"));

        String sentBody = adapter.getLastBuiltJsonBodyForTesting();
        assertThat(new JSONObject(sentBody).getString("model")).isEqualTo(API_MODEL);
    }

    @Test
    @DisplayName("should use configured timeout in request")
    void testConfiguredTimeoutIsUsedInRequest() throws Exception {
        HttpResponse<String> httpResponse = mockHttpResponse(200,
                buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        adapter.invoke(createTestRequest("prompt", "doc"));

        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());
        assertThat(requestCaptor.getValue().timeout())
                .isPresent()
                .get()
                .isEqualTo(Duration.ofSeconds(TIMEOUT_SECONDS));
    }

    @Test
    @DisplayName("should place prompt content in system field and document text in user message")
    void testPromptContentGoesToSystemFieldDocumentTextToUserMessage() throws Exception {
        HttpResponse<String> httpResponse = mockHttpResponse(200,
                buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        String promptContent = "Du bist ein Assistent zur Dokumentenbenennung.";
        String documentText = "Rechnungstext des Dokuments.";
        adapter.invoke(createTestRequest(promptContent, documentText));

        String sentBody = adapter.getLastBuiltJsonBodyForTesting();
        JSONObject body = new JSONObject(sentBody);

        assertThat(body.getString("system"))
                .as("Prompt content must be placed in the top-level system field")
                .isEqualTo(promptContent);
        assertThat(body.getJSONArray("messages").getJSONObject(0).getString("content"))
                .as("Document text must be placed in the user message content")
                .isEqualTo(documentText);
    }

    @Test
    @DisplayName("should map CONNECTION_ERROR when ConnectException is thrown")
    void testConnectionExceptionIsMappedToConnectionError() throws Exception {
        when(httpClient.send(any(HttpRequest.class), any()))
                .thenThrow(new ConnectException("Connection refused"));

        AiInvocationResult result = adapter.invoke(createTestRequest("p", "d"));

        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("CONNECTION_ERROR");
    }

    @Test
    @DisplayName("should map DNS_ERROR when UnknownHostException is thrown")
    void testUnknownHostExceptionIsMappedToDnsError() throws Exception {
        when(httpClient.send(any(HttpRequest.class), any()))
                .thenThrow(new UnknownHostException("api.anthropic.com"));

        AiInvocationResult result = adapter.invoke(createTestRequest("p", "d"));

        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("DNS_ERROR");
    }

    @Test
    @DisplayName("should throw NullPointerException when request is null")
    void testNullRequestThrowsException() {
        assertThatThrownBy(() -> adapter.invoke(null))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("request must not be null");
    }

    @Test
    @DisplayName("should throw NullPointerException when configuration is null")
    void testNullConfigurationThrowsException() {
        assertThatThrownBy(() -> new AnthropicClaudeHttpAdapter(null, httpClient))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("config must not be null");
    }

    @Test
    @DisplayName("should throw IllegalArgumentException when API model is blank")
    void testBlankApiModelThrowsException() {
        ProviderConfiguration invalidConfig = new ProviderConfiguration(
                "  ", TIMEOUT_SECONDS, API_BASE_URL, API_KEY);

        assertThatThrownBy(() -> new AnthropicClaudeHttpAdapter(invalidConfig, httpClient))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("API model must not be null or empty");
    }

    @Test
    @DisplayName("should use default base URL when baseUrl is null")
    void testDefaultBaseUrlUsedWhenNull() throws Exception {
        ProviderConfiguration configWithoutBaseUrl = new ProviderConfiguration(
                API_MODEL, TIMEOUT_SECONDS, null, API_KEY);
        AnthropicClaudeHttpAdapter adapterWithDefault =
                new AnthropicClaudeHttpAdapter(configWithoutBaseUrl, httpClient);

        HttpResponse<String> httpResponse = mockHttpResponse(200,
                buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        adapterWithDefault.invoke(createTestRequest("p", "d"));

        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());
        assertThat(requestCaptor.getValue().uri().toString())
                .as("Default base URL https://api.anthropic.com must be used when baseUrl is null")
                .startsWith("https://api.anthropic.com");
    }

    /**
     * Verifies that a custom, non-default base URL is used in the request.
     * <p>
     * This test uses a URL that differs from the default {@code https://api.anthropic.com},
     * ensuring the conditional that selects between the configured URL and the default
     * is correctly evaluated. If the conditional were negated, the request would be sent
     * to the default URL instead of the custom one.
     */
    @Test
    @DisplayName("should use custom non-default base URL when provided")
    void customNonDefaultBaseUrlIsUsedInRequest() throws Exception {
        String customBaseUrl = "http://internal.proxy.example.com:8080";
        ProviderConfiguration configWithCustomUrl = new ProviderConfiguration(
                API_MODEL, TIMEOUT_SECONDS, customBaseUrl, API_KEY);
        AnthropicClaudeHttpAdapter adapterWithCustomUrl =
                new AnthropicClaudeHttpAdapter(configWithCustomUrl, httpClient);

        HttpResponse<String> httpResponse = mockHttpResponse(200,
                buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        adapterWithCustomUrl.invoke(createTestRequest("p", "d"));

        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());
        assertThat(requestCaptor.getValue().uri().toString())
                .as("Custom non-default base URL must be used, not the default api.anthropic.com")
                .startsWith("http://internal.proxy.example.com:8080");
    }

    /**
     * Verifies that a port value of 0 in the base URL is not included in the endpoint URI.
     * <p>
     * {@link java.net.URI#getPort()} returns {@code 0} when the URL explicitly specifies
     * port 0. The endpoint builder must only include the port when it is greater than 0,
     * not when it is equal to 0 or negative.
     */
    @Test
    @DisplayName("should not include port 0 in the endpoint URI")
    void buildEndpointUri_doesNotIncludePortZero() throws Exception {
        ProviderConfiguration configWithPortZero = new ProviderConfiguration(
                API_MODEL, TIMEOUT_SECONDS, "http://example.com:0", API_KEY);
        AnthropicClaudeHttpAdapter adapterWithPortZero =
                new AnthropicClaudeHttpAdapter(configWithPortZero, httpClient);

        HttpResponse<String> httpResponse = mockHttpResponse(200,
                buildAnthropicSuccessResponse("{\"title\":\"T\",\"reasoning\":\"R\"}"));
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        adapterWithPortZero.invoke(createTestRequest("p", "d"));

        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());
        assertThat(requestCaptor.getValue().uri().toString())
                .as("Port 0 must not appear in the endpoint URI")
                .doesNotContain(":0");
    }

    // =========================================================================
    // Helper methods
    // =========================================================================

    /**
     * Builds a minimal valid Anthropic Messages API response body with a single text block.
     */
    private static String buildAnthropicSuccessResponse(String textContent) {
        // Escape the textContent for embedding in JSON string
        String escaped = textContent
                .replace("\\", "\\\\")
                .replace("\"", "\\\"");
        return "{"
                + "\"id\":\"msg_test\","
                + "\"type\":\"message\","
                + "\"role\":\"assistant\","
                + "\"content\":[{\"type\":\"text\",\"text\":\"" + escaped + "\"}],"
                + "\"stop_reason\":\"end_turn\""
                + "}";
    }

    @SuppressWarnings("unchecked")
    private HttpResponse<String> mockHttpResponse(int statusCode, String body) {
        HttpResponse<String> response = (HttpResponse<String>) mock(HttpResponse.class);
        when(response.statusCode()).thenReturn(statusCode);
        if (body != null) {
            when(response.body()).thenReturn(body);
        }
        return response;
    }

    private AiRequestRepresentation createTestRequest(String promptContent, String documentText) {
        return new AiRequestRepresentation(
                new PromptIdentifier("test-v1"),
                promptContent,
                documentText,
                documentText.length()
        );
    }
}
