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.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 OpenAiHttpAdapter}.
 * <p>
 * <strong>Test strategy:</strong>
 * Tests inject a mock {@link HttpClient} via the package-private constructor
 * to exercise the real HTTP adapter path without requiring network access.
 * Configuration is supplied via {@link ProviderConfiguration}.
 * <p>
 * <strong>Coverage goals:</strong>
 * <ul>
 *   <li>HTTP 200 responses: {@code choices[0].message.content} is extracted and returned</li>
 *   <li>Two-step parsing: outer envelope parsed first, inner content string returned as-is</li>
 *   <li>HTTP 200 with missing/empty choices is classified as NO_CHOICE_CONTENT failure</li>
 *   <li>HTTP 200 with unparseable envelope JSON is classified as UNPARSEABLE_JSON failure</li>
 *   <li>HTTP non-2xx responses are mapped to technical failure</li>
 *   <li>HTTP timeout exceptions are classified as TIMEOUT</li>
 *   <li>Connection failures are classified as CONNECTION_ERROR</li>
 *   <li>DNS errors are classified as DNS_ERROR</li>
 *   <li>IO errors are classified as IO_ERROR</li>
 *   <li>Interrupted operations are classified as INTERRUPTED</li>
 *   <li>Configured timeout is actually used in the request</li>
 *   <li>Configured base URL is actually used in the endpoint</li>
 *   <li>Configured model name is actually used in the request body</li>
 *   <li>Effective API key is actually used in the Authorization header</li>
 *   <li>Full document text is sent (not truncated)</li>
 *   <li>Null request raises NullPointerException</li>
 *   <li>Adapter reads all values from ProviderConfiguration</li>
 * </ul>
 */
@ExtendWith(MockitoExtension.class)
@DisplayName("OpenAiHttpAdapter")
class OpenAiHttpAdapterTest {

    private static final String API_BASE_URL = "https://api.example.com";
    private static final String API_MODEL = "test-model-v1";
    private static final String API_KEY = "test-key-12345";
    private static final int TIMEOUT_SECONDS = 45;

    @Mock
    private HttpClient httpClient;

    private ProviderConfiguration testConfiguration;
    private OpenAiHttpAdapter adapter;

    @BeforeEach
    void setUp() {
        testConfiguration = new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, API_KEY);
        // Use the package-private constructor with injected mock HttpClient
        adapter = new OpenAiHttpAdapter(testConfiguration, httpClient);
    }

    @Test
    @DisplayName("should extract choices[0].message.content when HTTP 200 is received")
    void testSuccessfulInvocationWith200Response() throws Exception {
        // Arrange
        String innerContent = "{\"title\":\"Bodenanalyseergebnis\",\"reasoning\":\"Found soil analysis\",\"date\":\"2025-02-23\"}";
        String responseBody = "{\"choices\":[{\"message\":{\"content\":" + JSONObject.quote(innerContent) + "}}]}";
        HttpResponse<String> httpResponse = mockHttpResponse(200, responseBody);
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        AiInvocationResult result = adapter.invoke(request);

        // Assert: success, and the raw response content is the extracted inner content, not the full envelope
        assertThat(result).isInstanceOf(AiInvocationSuccess.class);
        AiInvocationSuccess success = (AiInvocationSuccess) result;
        assertThat(success.request()).isEqualTo(request);
        assertThat(success.rawResponse().content()).isEqualTo(innerContent);
    }

    @Test
    @DisplayName("should return technical failure when HTTP 500 is received")
    void testNon200HttpStatusReturnsTechnicalFailure() throws Exception {
        // Arrange
        HttpResponse<String> httpResponse = mockHttpResponse(500, null);
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        AiInvocationResult result = adapter.invoke(request);

        // Assert
        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        AiInvocationTechnicalFailure failure = (AiInvocationTechnicalFailure) result;
        assertThat(failure.failureReason()).isEqualTo("HTTP_500");
        assertThat(failure.failureMessage()).contains("500");
    }

    @Test
    @DisplayName("should return TIMEOUT failure when HttpTimeoutException is thrown")
    void testTimeoutExceptionIsMappedToTimeout() throws Exception {
        // Arrange
        when(httpClient.send(any(HttpRequest.class), any()))
                .thenThrow(new HttpTimeoutException("Request timed out"));

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        AiInvocationResult result = adapter.invoke(request);

        // Assert
        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        AiInvocationTechnicalFailure failure = (AiInvocationTechnicalFailure) result;
        assertThat(failure.failureReason()).isEqualTo("TIMEOUT");
    }

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

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        AiInvocationResult result = adapter.invoke(request);

        // Assert
        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        AiInvocationTechnicalFailure failure = (AiInvocationTechnicalFailure) result;
        assertThat(failure.failureReason()).isEqualTo("CONNECTION_ERROR");
    }

    @Test
    @DisplayName("should return DNS_ERROR when UnknownHostException is thrown")
    void testDnsExceptionIsMappedToDnsError() throws Exception {
        // Arrange
        when(httpClient.send(any(HttpRequest.class), any()))
                .thenThrow(new UnknownHostException("api.example.com"));

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        AiInvocationResult result = adapter.invoke(request);

        // Assert
        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        AiInvocationTechnicalFailure failure = (AiInvocationTechnicalFailure) result;
        assertThat(failure.failureReason()).isEqualTo("DNS_ERROR");
    }

    @Test
    @DisplayName("should return IO_ERROR when IOException is thrown")
    void testIoExceptionIsMappedToIoError() throws Exception {
        // Arrange
        when(httpClient.send(any(HttpRequest.class), any()))
                .thenThrow(new java.io.IOException("Network unreachable"));

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        AiInvocationResult result = adapter.invoke(request);

        // Assert
        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        AiInvocationTechnicalFailure failure = (AiInvocationTechnicalFailure) result;
        assertThat(failure.failureReason()).isEqualTo("IO_ERROR");
    }

    @Test
    @DisplayName("should return INTERRUPTED when InterruptedException is thrown")
    void testInterruptedExceptionIsMappedToInterrupted() throws Exception {
        // Arrange
        when(httpClient.send(any(HttpRequest.class), any()))
                .thenThrow(new InterruptedException("Thread interrupted"));

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        AiInvocationResult result = adapter.invoke(request);

        // Assert
        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        AiInvocationTechnicalFailure failure = (AiInvocationTechnicalFailure) result;
        assertThat(failure.failureReason()).isEqualTo("INTERRUPTED");
    }

    @Test
    @DisplayName("should use configured timeout value in the actual HTTP request")
    void testConfiguredTimeoutIsUsedInRequest() throws Exception {
        // Arrange
        HttpResponse<String> httpResponse = mockHttpResponse(200, "{}");
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        adapter.invoke(request);

        // Assert - verify the actual timeout is set on the HttpRequest itself
        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());

        HttpRequest capturedRequest = requestCaptor.getValue();
        assertThat(capturedRequest.timeout())
                .as("HttpRequest timeout should be present")
                .isPresent()
                .get()
                .isEqualTo(Duration.ofSeconds(TIMEOUT_SECONDS));
    }

    @Test
    @DisplayName("should use configured base URL in the endpoint")
    void testConfiguredBaseUrlIsUsedInEndpoint() throws Exception {
        // Arrange
        HttpResponse<String> httpResponse = mockHttpResponse(200, "{}");
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        adapter.invoke(request);

        // Assert - capture the request and verify URI contains base URL
        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());

        HttpRequest capturedRequest = requestCaptor.getValue();
        assertThat(capturedRequest.uri().toString())
                .startsWith(API_BASE_URL)
                .contains("/v1/chat/completions");
    }

    @Test
    @DisplayName("should use configured model name in the request body")
    void testConfiguredModelIsUsedInRequestBody() throws Exception {
        // Arrange
        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");
        HttpResponse<String> httpResponse = mockHttpResponse(200, "{}");
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        // Act - invoke to trigger actual request building
        adapter.invoke(request);

        // Assert - verify model is in the actual request body that was sent
        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());

        // Get the actual body that was sent in the request via test accessor
        String sentBody = adapter.getLastBuiltJsonBodyForTesting();
        assertThat(sentBody)
                .as("The actual HTTP request body should contain the configured model")
                .isNotNull();

        JSONObject bodyJson = new JSONObject(sentBody);
        assertThat(bodyJson.getString("model"))
                .as("Model in actual request body must match configuration")
                .isEqualTo(API_MODEL);
    }

    @Test
    @DisplayName("should use effective API key in Authorization header")
    void testEffectiveApiKeyIsUsedInAuthorizationHeader() throws Exception {
        // Arrange
        HttpResponse<String> httpResponse = mockHttpResponse(200, "{}");
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        adapter.invoke(request);

        // Assert - verify the Authorization header was set
        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());

        HttpRequest capturedRequest = requestCaptor.getValue();
        assertThat(capturedRequest.headers().map())
                .containsKey("Authorization")
                .doesNotContainValue(null);

        // Verify header contains the API key
        var authHeaders = capturedRequest.headers().allValues("Authorization");
        assertThat(authHeaders).isNotEmpty();
        assertThat(authHeaders.get(0)).startsWith("Bearer ").contains(API_KEY);
    }

    @Test
    @DisplayName("should send full document text without truncation")
    void testFullDocumentTextIsSentWithoutTruncation() throws Exception {
        // Arrange
        String fullDocumentText = "This is a long document text that should be sent in full.";
        int sentCharacterCount = 20;  // Less than full length
        PromptIdentifier promptId = new PromptIdentifier("v1");
        AiRequestRepresentation request = new AiRequestRepresentation(
                promptId,
                "Test prompt",
                fullDocumentText,
                sentCharacterCount
        );

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

        // Act - invoke to trigger actual request building
        adapter.invoke(request);

        // Assert - verify the full document text is in the actual request body sent (not truncated)
        ArgumentCaptor<HttpRequest> requestCaptor = ArgumentCaptor.forClass(HttpRequest.class);
        verify(httpClient).send(requestCaptor.capture(), any());

        // Get the actual body that was sent in the request via test accessor
        String sentBody = adapter.getLastBuiltJsonBodyForTesting();
        assertThat(sentBody)
                .as("The actual HTTP request body should contain the full document text")
                .isNotNull();

        JSONObject bodyJson = new JSONObject(sentBody);
        JSONArray messages = bodyJson.getJSONArray("messages");
        JSONObject userMessage = messages.getJSONObject(1);  // User message is second
        String contentInBody = userMessage.getString("content");

        // Prove the full text is sent in the actual request, not truncated by sentCharacterCount
        assertThat(contentInBody)
                .as("Document text in actual request body must be the full text")
                .isEqualTo(fullDocumentText);
        assertThat(contentInBody)
                .as("Sent text must not be truncated to sentCharacterCount")
                .isNotEqualTo(fullDocumentText.substring(0, sentCharacterCount));
        assertThat(contentInBody.length())
                .as("Text length must match full document, not truncated")
                .isEqualTo(fullDocumentText.length())
                .isGreaterThan(sentCharacterCount);
    }

    @Test
    @DisplayName("should preserve request in success result")
    void testSuccessPreservesRequest() throws Exception {
        // Arrange
        HttpResponse<String> httpResponse = mockHttpResponse(200,
                "{\"choices\":[{\"message\":{\"content\":\"{\\\"title\\\":\\\"Test\\\",\\\"reasoning\\\":\\\"r\\\"}\"}}]}");
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        AiInvocationResult result = adapter.invoke(request);

        // Assert
        assertThat(result).isInstanceOf(AiInvocationSuccess.class);
        AiInvocationSuccess success = (AiInvocationSuccess) result;
        assertThat(success.request()).isSameAs(request);
    }

    @Test
    @DisplayName("should preserve request in failure result")
    void testFailurePreservesRequest() throws Exception {
        // Arrange
        when(httpClient.send(any(HttpRequest.class), any()))
                .thenThrow(new ConnectException("Connection refused"));

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act
        AiInvocationResult result = adapter.invoke(request);

        // Assert
        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        AiInvocationTechnicalFailure failure = (AiInvocationTechnicalFailure) result;
        assertThat(failure.request()).isSameAs(request);
    }

    @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 OpenAiHttpAdapter(null, httpClient))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("config must not be null");
    }

    @Test
    @DisplayName("should throw NullPointerException when HttpClient is null")
    void testNullHttpClientThrowsException() {
        assertThatThrownBy(() -> new OpenAiHttpAdapter(testConfiguration, null))
                .isInstanceOf(NullPointerException.class)
                .hasMessageContaining("httpClient must not be null");
    }

    @Test
    @DisplayName("should throw IllegalArgumentException when API base URL is null")
    void testNullApiBaseUrlThrowsException() {
        ProviderConfiguration invalidConfig = new ProviderConfiguration(
                API_MODEL, TIMEOUT_SECONDS, null, API_KEY);

        assertThatThrownBy(() -> new OpenAiHttpAdapter(invalidConfig, httpClient))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("API base URL must not be null");
    }

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

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

    @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 OpenAiHttpAdapter(invalidConfig, httpClient))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessageContaining("API model must not be null or empty");
    }

    @Test
    @DisplayName("should handle empty API key gracefully")
    void testEmptyApiKeyHandled() throws Exception {
        // Arrange
        OpenAiHttpAdapter adapterWithEmptyKey = new OpenAiHttpAdapter(
                new ProviderConfiguration(API_MODEL, TIMEOUT_SECONDS, API_BASE_URL, ""),
                httpClient);

        String innerContent = "{\"title\":\"Test\",\"reasoning\":\"r\"}";
        HttpResponse<String> httpResponse = mockHttpResponse(200,
                "{\"choices\":[{\"message\":{\"content\":" + JSONObject.quote(innerContent) + "}}]}");
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        // Act - should not throw exception
        AiInvocationResult result = adapterWithEmptyKey.invoke(request);

        // Assert
        assertThat(result).isInstanceOf(AiInvocationSuccess.class);
    }

    // =========================================================================
    // Mandatory AP-003 test cases
    // =========================================================================

    /**
     * Verifies that the adapter reads all values from the new {@link ProviderConfiguration}
     * namespace and uses them correctly in outgoing HTTP requests.
     */
    @Test
    @DisplayName("openAiAdapterReadsValuesFromNewNamespace: all ProviderConfiguration fields are used")
    void openAiAdapterReadsValuesFromNewNamespace() throws Exception {
        // Arrange: ProviderConfiguration with values distinct from setUp defaults
        ProviderConfiguration nsConfig = new ProviderConfiguration(
                "ns-model-v2", 20, "https://provider-ns.example.com", "ns-api-key-abc");
        OpenAiHttpAdapter nsAdapter = new OpenAiHttpAdapter(nsConfig, httpClient);

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

        AiRequestRepresentation request = createTestRequest("prompt", "document");
        nsAdapter.invoke(request);

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

        // Verify baseUrl from ProviderConfiguration
        assertThat(capturedRequest.uri().toString())
                .as("baseUrl must come from ProviderConfiguration")
                .startsWith("https://provider-ns.example.com");

        // Verify apiKey from ProviderConfiguration
        assertThat(capturedRequest.headers().firstValue("Authorization").orElse(""))
                .as("apiKey must come from ProviderConfiguration")
                .contains("ns-api-key-abc");

        // Verify model from ProviderConfiguration
        String body = nsAdapter.getLastBuiltJsonBodyForTesting();
        assertThat(new JSONObject(body).getString("model"))
                .as("model must come from ProviderConfiguration")
                .isEqualTo("ns-model-v2");

        // Verify timeout from ProviderConfiguration
        assertThat(capturedRequest.timeout())
                .as("timeout must come from ProviderConfiguration")
                .isPresent()
                .get()
                .isEqualTo(Duration.ofSeconds(20));
    }

    /**
     * Verifies that adapter behavioral contracts (success mapping, error classification)
     * are preserved. HTTP 200 must extract {@code choices[0].message.content} as the
     * raw response; non-200 and exceptions must produce typed technical failures.
     */
    @Test
    @DisplayName("openAiAdapterBehaviorIsUnchanged: HTTP success and error mapping contracts are preserved")
    void openAiAdapterBehaviorIsUnchanged() throws Exception {
        // Success case: HTTP 200 must produce AiInvocationSuccess with extracted content
        String innerContent = "{\"title\":\"Rechnung\",\"reasoning\":\"r\"}";
        String successBody = "{\"choices\":[{\"message\":{\"content\":" + JSONObject.quote(innerContent) + "}}]}";
        HttpResponse<String> successResponse = mockHttpResponse(200, successBody);
        doReturn(successResponse).when(httpClient).send(any(HttpRequest.class), any());

        AiInvocationResult result = adapter.invoke(createTestRequest("p", "d"));
        assertThat(result).isInstanceOf(AiInvocationSuccess.class);
        assertThat(((AiInvocationSuccess) result).rawResponse().content()).isEqualTo(innerContent);

        // Non-200 case: HTTP 429 must produce AiInvocationTechnicalFailure with HTTP_429 reason
        HttpResponse<String> rateLimitedResponse = mockHttpResponse(429, null);
        doReturn(rateLimitedResponse).when(httpClient).send(any(HttpRequest.class), any());
        result = adapter.invoke(createTestRequest("p", "d"));
        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("HTTP_429");

        // Timeout case: HttpTimeoutException must produce TIMEOUT reason
        when(httpClient.send(any(HttpRequest.class), any()))
                .thenThrow(new HttpTimeoutException("timed out"));
        result = adapter.invoke(createTestRequest("p", "d"));
        assertThat(result).isInstanceOf(AiInvocationTechnicalFailure.class);
        assertThat(((AiInvocationTechnicalFailure) result).failureReason()).isEqualTo("TIMEOUT");
    }

    /**
     * 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);
        OpenAiHttpAdapter adapterWithPortZero = new OpenAiHttpAdapter(configWithPortZero, httpClient);

        HttpResponse<String> httpResponse = mockHttpResponse(200,
                "{\"choices\":[{\"message\":{\"content\":\"{\\\"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");
    }

    @Test
    @DisplayName("should extract inner JSON from choices[0].message.content (two-step parse)")
    void testTwoStepParseExtractsInnerContentString() throws Exception {
        // The OpenAI API wraps the actual AI content as an escaped JSON string inside
        // choices[0].message.content. The adapter must perform two-step parsing:
        // 1) parse the outer envelope to extract choices[0].message.content,
        // 2) return that content string as-is for the Application layer to parse.
        String innerContent = "{\"title\":\"Bodenanalyseergebnis\",\"reasoning\":\"Soil analysis report\",\"date\":\"2025-02-23\"}";
        String envelope = "{\"choices\":[{\"message\":{\"content\":" + JSONObject.quote(innerContent) + "}}]}";

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

        AiRequestRepresentation request = createTestRequest("Test prompt", "Test document");

        AiInvocationResult result = adapter.invoke(request);

        assertThat(result).isInstanceOf(AiInvocationSuccess.class);
        AiInvocationSuccess success = (AiInvocationSuccess) result;
        // The raw response content must be the extracted inner string, not the outer envelope
        assertThat(success.rawResponse().content())
                .as("Adapter must return the inner content string, not the outer OpenAI envelope")
                .isEqualTo(innerContent);
        // Verify the extracted content is parseable as the expected NamingProposal JSON
        JSONObject parsed = new JSONObject(success.rawResponse().content());
        assertThat(parsed.getString("title")).isEqualTo("Bodenanalyseergebnis");
        assertThat(parsed.getString("date")).isEqualTo("2025-02-23");
    }

    @Test
    @DisplayName("should return NO_CHOICE_CONTENT failure when choices array is empty")
    void testEmptyChoicesArrayReturnsFailure() throws Exception {
        HttpResponse<String> httpResponse = mockHttpResponse(200, "{\"choices\":[]}");
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

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

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

    @Test
    @DisplayName("should return UNPARSEABLE_JSON failure when HTTP 200 body is not valid JSON")
    void testUnparseableEnvelopeReturnsFailure() throws Exception {
        HttpResponse<String> httpResponse = mockHttpResponse(200, "not-json-at-all");
        doReturn(httpResponse).when(httpClient).send(any(HttpRequest.class), any());

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

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

    // Helper methods

    /**
     * Creates a mock HttpResponse with the specified status code and optional body.
     */
    @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 prompt, String documentText) {
        return new AiRequestRepresentation(
                new PromptIdentifier("test-v1"),
                prompt,
                documentText,
                documentText.length()
        );
    }
}
