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

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Objects;

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import de.gecheckt.pdf.umbenenner.application.config.provider.ProviderConfiguration;
import de.gecheckt.pdf.umbenenner.application.dto.AiUsageMetadata;
import de.gecheckt.pdf.umbenenner.application.port.out.AiInvocationPort;
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.AiRawResponse;
import de.gecheckt.pdf.umbenenner.domain.model.AiRequestRepresentation;

/**
 * Adapter implementing OpenAI-compatible HTTP communication for AI service invocation.
 * <p>
 * This adapter:
 * <ul>
 *   <li>Translates an abstract {@link AiRequestRepresentation} into an OpenAI Chat
 *       Completions API request</li>
 *   <li>Configures HTTP connection, timeout, and authentication from the provider configuration</li>
 *   <li>Executes the HTTP request against the configured AI endpoint</li>
 *   <li>Distinguishes between successful HTTP responses (200) and technical failures
 *       (timeout, unreachable, connection error, etc.)</li>
 *   <li>Returns the raw response body as-is for Application-layer parsing and validation</li>
 *   <li>Classifies and encodes technical failures for retry decision-making</li>
 * </ul>
 * <p>
 * <strong>Configuration:</strong>
 * <ul>
 *   <li>{@code baseUrl} — the HTTP(S) base URL of the AI service endpoint</li>
 *   <li>{@code model} — the model identifier requested from the AI service</li>
 *   <li>{@code timeoutSeconds} — connection and read timeout in seconds</li>
 *   <li>{@code apiKey} — the authentication token (resolved from environment variable
 *       {@code OPENAI_COMPATIBLE_API_KEY} or property {@code ai.provider.openai-compatible.apiKey},
 *       environment variable takes precedence)</li>
 * </ul>
 * <p>
 * <strong>HTTP request structure:</strong>
 * The adapter sends a POST request to the endpoint {@code {baseUrl}/v1/chat/completions}
 * with:
 * <ul>
 *   <li>Authorization header containing the API key</li>
 *   <li>Content-Type application/json</li>
 *   <li>JSON body containing:
 *       <ul>
 *         <li>{@code model} — the configured model name</li>
 *         <li>{@code messages} — array with system role (prompt) and user role (document text)</li>
 *         <li>Optional fields like {@code temperature} for determinism (if desired)</li>
 *       </ul>
 *   </li>
 * </ul>
 * <p>
 * <strong>Response handling:</strong>
 * <ul>
 *   <li><strong>HTTP 200:</strong> Extracts {@code choices[0].message.content} from the
 *       OpenAI Chat Completions response envelope and returns it as the raw response content
 *       via {@link AiInvocationSuccess}. The Application layer then parses and validates
 *       this content as a NamingProposal JSON object. If the envelope cannot be parsed or
 *       the expected content field is absent, a technical failure is returned.</li>
 *   <li><strong>HTTP non-200:</strong> Treated as a technical failure. The response body may
 *       contain an error message, but this is logged for debugging; the client treats it as
 *       a transient communication failure.</li>
 * </ul>
 * <p>
 * <strong>Technical error classification:</strong>
 * The following are classified as {@link AiInvocationTechnicalFailure}:
 * <ul>
 *   <li>Connection timeout</li>
 *   <li>Read timeout</li>
 *   <li>Endpoint unreachable (connection refused, DNS failure, etc.)</li>
 *   <li>Interrupted IO during HTTP communication</li>
 *   <li>HTTP response with non-2xx status code</li>
 *   <li>HTTP 200 response body that cannot be parsed as JSON ({@code UNPARSEABLE_JSON})</li>
 *   <li>HTTP 200 response with no {@code choices} or no {@code message.content}
 *       ({@code NO_CHOICE_CONTENT})</li>
 *   <li>Any other transport-level exception</li>
 * </ul>
 * <p>
 * <strong>Non-goals:</strong>
 * <ul>
 *   <li>Response body parsing or validation — the Application layer owns this</li>
 *   <li>Retry logic — this adapter executes a single request only</li>
 *   <li>Functional error classification (invalid title, unparseable date) — above adapter scope</li>
 * </ul>
 */
public class OpenAiHttpAdapter implements AiInvocationPort {
    private static final String NO_CHOICE_CONTENT_SENTINEL = "NO_CHOICE_CONTENT";
    private static final String JSON_KEY_CONTENT = "content";



    private static final Logger LOG = LogManager.getLogger(OpenAiHttpAdapter.class);

    private static final String CHAT_COMPLETIONS_ENDPOINT = "/v1/chat/completions";
    private static final String CONTENT_TYPE = "application/json";
    private static final String AUTHORIZATION_HEADER = "Authorization";
    private static final String BEARER_PREFIX = "Bearer ";

    private final HttpClient httpClient;
    private final URI apiBaseUrl;
    private final String apiModel;
    private final String apiKey;
    private final int apiTimeoutSeconds;

    // Test-only field to capture the last built JSON body for assertion
    private volatile String lastBuiltJsonBody;

    /**
     * Creates an adapter from the OpenAI-compatible provider configuration.
     * <p>
     * The adapter initializes an HTTP client with the configured timeout and parses
     * the endpoint URI from the configured base URL string.
     *
     * @param config the provider configuration for the OpenAI-compatible family; must not be null
     * @throws NullPointerException if config is null
     * @throws IllegalArgumentException if the base URL or model is missing/blank
     */
    public OpenAiHttpAdapter(ProviderConfiguration config) {
        this(config, HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(config.timeoutSeconds()))
                .build());
    }

    /**
     * Creates an adapter with a custom HTTP client (primarily for testing).
     * <p>
     * This constructor allows tests to inject a mock or configurable HTTP client
     * while keeping configuration validation consistent with the production constructor.
     * <p>
     * <strong>For testing only:</strong> This is package-private to remain internal to the adapter.
     *
     * @param config the provider configuration; must not be null
     * @param httpClient the HTTP client to use; must not be null
     * @throws NullPointerException if config or httpClient is null
     * @throws IllegalArgumentException if the base URL or model is missing/blank
     */
    OpenAiHttpAdapter(ProviderConfiguration config, HttpClient httpClient) {
        Objects.requireNonNull(config, "config must not be null");
        Objects.requireNonNull(httpClient, "httpClient must not be null");
        if (config.baseUrl() == null || config.baseUrl().isBlank()) {
            throw new IllegalArgumentException("API base URL must not be null");
        }
        if (config.model() == null || config.model().isBlank()) {
            throw new IllegalArgumentException("API model must not be null or empty");
        }

        this.apiBaseUrl = URI.create(config.baseUrl());
        this.apiModel = config.model();
        this.apiKey = config.apiKey() != null ? config.apiKey() : "";
        this.apiTimeoutSeconds = config.timeoutSeconds();
        this.httpClient = httpClient;

        LOG.debug("OpenAiHttpAdapter initialized with base URL: {}, model: {}, timeout: {}s",
                apiBaseUrl, apiModel, apiTimeoutSeconds);
    }

    /**
     * Invokes the AI service with the given request.
     * <p>
     * Constructs an OpenAI Chat Completions API request from the request representation,
     * executes it against the configured endpoint, and returns either a successful
     * response or a classified technical failure.
     * <p>
     * The request representation contains:
     * <ul>
     *   <li>Prompt content and identifier (for audit)</li>
     *   <li>Document text prepared by the Application layer</li>
     *   <li>Character count metadata (for audit, not used to truncate content)</li>
     * </ul>
     * <p>
     * These are formatted as system and user messages for the Chat Completions API.
     *
     * @param request the AI request with prompt and document text; must not be null
     * @return an {@link AiInvocationResult} encoding either success (with raw response body)
     *         or a technical failure with classified reason
     * @throws NullPointerException if request is null
     */
    @Override
    public AiInvocationResult invoke(AiRequestRepresentation request) {
        Objects.requireNonNull(request, "request must not be null");

        try {
            HttpRequest httpRequest = buildRequest(request);
            HttpResponse<String> response = executeRequest(httpRequest);

            if (response.statusCode() == 200) {
                return extractContentFromResponse(request, response.body());
            } else {
                String reason = "HTTP_" + response.statusCode();
                String message = "AI service returned status " + response.statusCode();
                LOG.warn("AI invocation returned non-200 status: {}", response.statusCode());
                return new AiInvocationTechnicalFailure(request, reason, message);
            }
        } catch (java.net.http.HttpTimeoutException e) {
            String message = "HTTP timeout after " + e.getClass().getSimpleName();
            LOG.warn("AI invocation timeout: {}", message);
            return new AiInvocationTechnicalFailure(request, "TIMEOUT", message);
        } catch (java.net.ConnectException e) {
            String message = "Failed to connect to endpoint: " + e.getMessage();
            LOG.warn("AI invocation connection error: {}", message);
            return new AiInvocationTechnicalFailure(request, "CONNECTION_ERROR", message);
        } catch (java.net.UnknownHostException e) {
            String message = "Endpoint hostname not resolvable: " + e.getMessage();
            LOG.warn("AI invocation DNS error: {}", message);
            return new AiInvocationTechnicalFailure(request, "DNS_ERROR", message);
        } catch (java.io.IOException e) {
            String message = "IO error during AI invocation: " + e.getMessage();
            LOG.warn("AI invocation IO error: {}", message);
            return new AiInvocationTechnicalFailure(request, "IO_ERROR", message);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            String message = "AI invocation interrupted: " + e.getMessage();
            LOG.warn("AI invocation interrupted: {}", message);
            return new AiInvocationTechnicalFailure(request, "INTERRUPTED", message);
        } catch (Exception e) {
            String message = "Unexpected error during AI invocation: " + e.getClass().getSimpleName()
                    + " - " + e.getMessage();
            LOG.error("Unexpected error in AI invocation", e);
            return new AiInvocationTechnicalFailure(request, "UNEXPECTED_ERROR", message);
        }
    }

    /**
     * Extracts the NamingProposal content from a successful (HTTP 200) OpenAI response.
     * <p>
     * The OpenAI Chat Completions response wraps the actual AI content inside an envelope:
     * {@code choices[0].message.content}. This content string is the NamingProposal JSON
     * that the Application layer expects. This method performs the two-step extraction:
     * first parses the outer envelope, then returns the inner content string.
     * <p>
     * If the envelope JSON cannot be parsed, or if {@code choices} is absent or empty,
     * or if {@code message.content} is absent or blank, a technical failure is returned.
     *
     * @param request      the original request (carried through to the result)
     * @param responseBody the raw HTTP response body (the OpenAI envelope)
     * @return success with the extracted content string, or a technical failure
     */
    private AiInvocationResult extractContentFromResponse(AiRequestRepresentation request, String responseBody) {
        try {
            JSONObject json = new JSONObject(responseBody);
            JSONArray choices = json.optJSONArray("choices");
            if (choices == null || choices.isEmpty()) {
                LOG.warn("OpenAI response contained no choices");
                return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
                        "OpenAI response contained no choices");
            }
            JSONObject firstChoice = choices.getJSONObject(0);
            JSONObject message = firstChoice.optJSONObject("message");
            if (message == null) {
                LOG.warn("OpenAI response choice contained no message");
                return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
                        "OpenAI response choice contained no message");
            }
            String content = message.optString(JSON_KEY_CONTENT, null);
            if (content == null || content.isBlank()) {
                LOG.warn("OpenAI response message.content is absent or blank");
                return new AiInvocationTechnicalFailure(request, NO_CHOICE_CONTENT_SENTINEL,
                        "OpenAI response message.content is absent or blank");
            }
            return new AiInvocationSuccess(request, new AiRawResponse(content),
                    extractTokenUsageFromResponse(json));
        } catch (JSONException e) {
            LOG.warn("OpenAI response could not be parsed as JSON: {}", e.getMessage());
            return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
                    "OpenAI response body is not valid JSON: " + e.getMessage());
        }
    }

    /**
     * Extrahiert Token-Verbrauchsmetadaten aus der OpenAI-Response.
     *
     * <p>Mapping: {@code usage.prompt_tokens -> inputTokens},
     * {@code usage.completion_tokens -> outputTokens}. Cache-Felder sind in der
     * OpenAI-kompatiblen Schnittstelle nicht standardisiert und bleiben immer
     * {@code null}.
     *
     * <p>Validierung: nicht-numerische, negative oder ueber 10 Mio. liegende
     * Werte werden auf {@code null} gesetzt und mit WARN-Log markiert.
     *
     * @param root die geparste OpenAI-Response (Envelope)
     * @return befuelltes {@link AiUsageMetadata}; nie {@code null}
     */
    private AiUsageMetadata extractTokenUsageFromResponse(JSONObject root) {
        JSONObject usage = root.optJSONObject("usage");
        if (usage == null) {
            LOG.warn("OpenAI-Response enthielt kein usage-Feld – Token-Daten werden nicht erfasst");
            return AiUsageMetadata.empty();
        }
        Long inputTokens = readTokenField(usage, "prompt_tokens");
        Long outputTokens = readTokenField(usage, "completion_tokens");
        return new AiUsageMetadata(inputTokens, outputTokens, null, null);
    }

    /**
     * Liest und validiert einen einzelnen Token-Wert aus einem JSON-Objekt.
     *
     * <p>Akzeptiert nicht-vorhandene Felder ({@code null}-Rueckgabe ohne Log).
     * Verwirft nicht-numerische, negative oder ueber 10 Mio. liegende Werte
     * mit WARN-Log und gibt {@code null} zurueck.
     *
     * @param usage    das JSON-Objekt mit den Token-Feldern
     * @param fieldName Name des Feldes
     * @return validierter Token-Wert oder {@code null}
     */
    private Long readTokenField(JSONObject usage, String fieldName) {
        if (!usage.has(fieldName) || usage.isNull(fieldName)) {
            return null;
        }
        try {
            long value = usage.getLong(fieldName);
            if (value < 0L) {
                LOG.warn("OpenAI-Token-Feld {} ist negativ ({}) – Wert verworfen", fieldName, value);
                return null;
            }
            if (value > 10_000_000L) {
                LOG.warn("OpenAI-Token-Feld {} uebersteigt Maximum (10 Mio.): {} – Wert verworfen", fieldName, value);
                return null;
            }
            return value;
        } catch (JSONException e) {
            LOG.warn("OpenAI-Token-Feld {} ist nicht numerisch – Wert verworfen: {}", fieldName, e.getMessage());
            return null;
        }
    }

    /**
     * Builds an OpenAI Chat Completions API request from the request representation.
     * <p>
     * Constructs:
     * <ul>
     *   <li>Endpoint URL: {@code {apiBaseUrl}/v1/chat/completions}</li>
     *   <li>Headers: Authorization with Bearer token, Content-Type application/json</li>
     *   <li>Body: JSON with model, messages (system = prompt, user = document text)</li>
     *   <li>Timeout: configured timeout from provider configuration</li>
     * </ul>
     *
     * @param request the request representation with prompt and document text
     * @return an {@link HttpRequest} ready to send
     */
    private HttpRequest buildRequest(AiRequestRepresentation request) {
        URI endpoint = buildEndpointUri();

        String requestBody = buildJsonRequestBody(request);
        // Capture for test inspection (test-only field)
        this.lastBuiltJsonBody = requestBody;

        return HttpRequest.newBuilder(endpoint)
                .header("Content-Type", CONTENT_TYPE)
                .header(AUTHORIZATION_HEADER, BEARER_PREFIX + apiKey)
                .POST(HttpRequest.BodyPublishers.ofString(requestBody))
                .timeout(Duration.ofSeconds(apiTimeoutSeconds))
                .build();
    }

    /**
     * Composes the endpoint URI from the configured base URL.
     * <p>
     * Resolves {@code {apiBaseUrl}/v1/chat/completions}.
     *
     * @return the complete endpoint URI
     */
    private URI buildEndpointUri() {
        String endpointPath = apiBaseUrl.getPath().replaceAll("/$", "") + CHAT_COMPLETIONS_ENDPOINT;
        return URI.create(apiBaseUrl.getScheme() + "://" +
                apiBaseUrl.getHost() +
                (apiBaseUrl.getPort() > 0 ? ":" + apiBaseUrl.getPort() : "") +
                endpointPath);
    }

    /**
     * Builds the JSON request body for the OpenAI Chat Completions API.
     * <p>
     * The body contains:
     * <ul>
     *   <li>{@code model} — the configured model name</li>
     *   <li>{@code messages} — array with system and user roles</li>
     *   <li>{@code temperature} — 0.0 for deterministic output</li>
     * </ul>
     * <p>
     * The prompt is sent as system message; the document text as user message.
     * <p>
     * <strong>Text limiting:</strong> The document text is already limited to the maximum
     * characters by the Application layer before creating the request representation.
     * The {@link AiRequestRepresentation#sentCharacterCount()} is recorded as audit metadata
     * but is <strong>not</strong> used to truncate content in the adapter. The full
     * document text is sent to the AI service.
     * <p>
     * <strong>Package-private for testing:</strong> This method is accessible to tests
     * in the same package to verify the actual JSON body structure and content.
     *
     * @param request the request with prompt and document text
     * @return JSON string ready to send in HTTP body
     */
    String buildJsonRequestBody(AiRequestRepresentation request) {
        JSONObject body = new JSONObject();
        body.put("model", apiModel);
        body.put("temperature", 0.0);

        JSONObject systemMessage = new JSONObject();
        systemMessage.put("role", "system");
        systemMessage.put(JSON_KEY_CONTENT, request.promptContent());

        JSONObject userMessage = new JSONObject();
        userMessage.put("role", "user");
        userMessage.put(JSON_KEY_CONTENT, request.documentText());

        body.put("messages", new org.json.JSONArray()
                .put(systemMessage)
                .put(userMessage));

        return body.toString();
    }

    /**
     * Package-private accessor for the last constructed JSON body.
     * <p>
     * <strong>For testing only:</strong> Allows tests to verify the actual
     * JSON body sent in HTTP requests without exposing the BodyPublisher internals.
     * This method is used by unit tests to assert that the correct model, text,
     * and other fields are present in the outbound request.
     *
     * @return the last JSON body string constructed by {@link #buildRequest(AiRequestRepresentation)},
     *         or null if no request has been built yet
     */
    String getLastBuiltJsonBodyForTesting() {
        return lastBuiltJsonBody;
    }

    /**
     * Executes the HTTP request and returns the response.
     * <p>
     * Uses the HTTP client configured with the startup timeout to send the request
     * and receive the full response body.
     *
     * @param httpRequest the HTTP request to execute
     * @return the HTTP response with status code and body
     * @throws java.net.http.HttpTimeoutException if the request times out
     * @throws java.net.ConnectException if connection fails
     * @throws java.io.IOException on other IO errors
     * @throws InterruptedException if the request is interrupted
     */
    private HttpResponse<String> executeRequest(HttpRequest httpRequest)
            throws java.io.IOException, InterruptedException {
        return httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString());
    }
}
