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 the native Anthropic Messages API for AI service invocation.
 * <p>
 * This adapter:
 * <ul>
 *   <li>Translates an abstract {@link AiRequestRepresentation} into an Anthropic
 *       Messages API request (POST {@code /v1/messages})</li>
 *   <li>Configures HTTP connection, timeout, and authentication from the provider
 *       configuration using the Anthropic-specific authentication scheme
 *       ({@code x-api-key} header, not {@code Authorization: Bearer})</li>
 *   <li>Extracts the response text by concatenating all {@code text}-type content
 *       blocks from the Anthropic response, returning the result as a raw response
 *       for Application-layer parsing and validation</li>
 *   <li>Classifies technical failures (HTTP errors, timeouts, missing content blocks,
 *       unparseable JSON) according to the existing transient error semantics</li>
 * </ul>
 *
 * <h2>Configuration</h2>
 * <ul>
 *   <li>{@code baseUrl} — the HTTP(S) base URL; defaults to {@code https://api.anthropic.com}
 *       when absent or blank</li>
 *   <li>{@code model} — the Claude model identifier (e.g., {@code claude-3-5-sonnet-20241022})</li>
 *   <li>{@code timeoutSeconds} — connection and read timeout in seconds</li>
 *   <li>{@code apiKey} — the authentication token, resolved from environment variable
 *       {@code ANTHROPIC_API_KEY} or property {@code ai.provider.claude.apiKey};
 *       environment variable takes precedence (resolved by the configuration layer
 *       before this adapter is constructed)</li>
 * </ul>
 *
 * <h2>HTTP request structure</h2>
 * <p>
 * The adapter sends a POST request to {@code {baseUrl}/v1/messages} with:
 * <ul>
 *   <li>Header {@code x-api-key} containing the resolved API key</li>
 *   <li>Header {@code anthropic-version: 2023-06-01}</li>
 *   <li>Header {@code content-type: application/json}</li>
 *   <li>JSON body containing:
 *     <ul>
 *       <li>{@code model} — the configured model name</li>
 *       <li>{@code max_tokens} — fixed at 1024; sufficient for the expected JSON response
 *           without requiring a separate configuration property</li>
 *       <li>{@code system} — the prompt content (if non-blank); Anthropic uses a
 *           top-level field instead of a {@code role=system} message</li>
 *       <li>{@code messages} — an array with exactly one {@code user} message containing
 *           the document text</li>
 *     </ul>
 *   </li>
 * </ul>
 *
 * <h2>Response handling</h2>
 * <ul>
 *   <li><strong>HTTP 200:</strong> All {@code content} blocks with {@code type=="text"}
 *       are concatenated in order; the result is returned as {@link AiInvocationSuccess}
 *       with an {@link AiRawResponse} containing the concatenated text. The Application
 *       layer then parses and validates this text as a NamingProposal JSON object.</li>
 *   <li><strong>No text blocks in HTTP 200 response:</strong> Classified as a technical
 *       failure; the Application layer cannot derive a naming proposal without text.</li>
 *   <li><strong>Unparseable response JSON:</strong> Classified as a technical failure.</li>
 *   <li><strong>HTTP non-200:</strong> Classified as a technical failure.</li>
 * </ul>
 *
 * <h2>Technical error classification</h2>
 * <p>
 * All errors are mapped to {@link AiInvocationTechnicalFailure} and follow the existing
 * transient error semantics. No new error categories are introduced:
 * <ul>
 *   <li>HTTP 4xx (including 401, 403, 429) and 5xx — technical failure</li>
 *   <li>Connection timeout, read timeout — {@code TIMEOUT}</li>
 *   <li>Connection failure — {@code CONNECTION_ERROR}</li>
 *   <li>DNS failure — {@code DNS_ERROR}</li>
 *   <li>IO errors — {@code IO_ERROR}</li>
 *   <li>Interrupted operation — {@code INTERRUPTED}</li>
 *   <li>JSON not parseable — {@code UNPARSEABLE_JSON}</li>
 *   <li>No {@code text}-type content block in response — {@code NO_TEXT_CONTENT}</li>
 * </ul>
 *
 * <h2>Non-goals</h2>
 * <ul>
 *   <li>NamingProposal JSON parsing or validation — the Application layer owns this</li>
 *   <li>Retry logic — this adapter executes a single request only</li>
 *   <li>Shared implementation with the OpenAI-compatible adapter — no common base class</li>
 * </ul>
 */
public class AnthropicClaudeHttpAdapter implements AiInvocationPort {

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

    private static final String MESSAGES_ENDPOINT = "/v1/messages";
    private static final String ANTHROPIC_VERSION_HEADER = "anthropic-version";
    private static final String ANTHROPIC_VERSION_VALUE = "2023-06-01";
    private static final String API_KEY_HEADER = "x-api-key";
    private static final String CONTENT_TYPE = "application/json";
    private static final String DEFAULT_BASE_URL = "https://api.anthropic.com";

    /**
     * Fixed max_tokens value for the Anthropic request.
     * <p>
     * This value is sufficient for the expected NamingProposal JSON response
     * ({@code date}, {@code title}, {@code reasoning}) without requiring a separate
     * configuration property. Anthropic's API requires this field to be present.
     */
    private static final int MAX_TOKENS = 1024;

    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 Claude provider configuration.
     * <p>
     * If {@code config.baseUrl()} is absent or blank, the default Anthropic endpoint
     * {@code https://api.anthropic.com} is used. The HTTP client is initialized with
     * the configured timeout.
     *
     * @param config the provider configuration for the Claude family; must not be null
     * @throws NullPointerException     if config is null
     * @throws IllegalArgumentException if the model is missing or blank
     */
    public AnthropicClaudeHttpAdapter(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 model is missing or blank
     */
    AnthropicClaudeHttpAdapter(ProviderConfiguration config, HttpClient httpClient) {
        Objects.requireNonNull(config, "config must not be null");
        Objects.requireNonNull(httpClient, "httpClient must not be null");
        if (config.model() == null || config.model().isBlank()) {
            throw new IllegalArgumentException("API model must not be null or empty");
        }

        String baseUrlStr = (config.baseUrl() != null && !config.baseUrl().isBlank())
                ? config.baseUrl()
                : DEFAULT_BASE_URL;

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

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

    /**
     * Invokes the Anthropic Claude AI service with the given request.
     * <p>
     * Constructs an Anthropic Messages API request from the request representation,
     * executes it, extracts the text content from the response, and returns either
     * a successful response or a classified technical failure.
     *
     * @param request the AI request with prompt and document text; must not be null
     * @return an {@link AiInvocationResult} encoding either success (with extracted text)
     *         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 extractTextFromResponse(request, response.body());
            } else {
                String reason = "HTTP_" + response.statusCode();
                String message = "Anthropic AI service returned status " + response.statusCode();
                LOG.warn("Claude AI invocation returned non-200 status: {}", response.statusCode());
                return new AiInvocationTechnicalFailure(request, reason, message);
            }
        } catch (java.net.http.HttpTimeoutException e) {
            String message = "HTTP timeout: " + e.getClass().getSimpleName();
            LOG.warn("Claude 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("Claude 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("Claude 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("Claude 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("Claude 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 Claude AI invocation", e);
            return new AiInvocationTechnicalFailure(request, "UNEXPECTED_ERROR", message);
        }
    }

    /**
     * Builds an Anthropic Messages API request from the request representation.
     * <p>
     * Constructs:
     * <ul>
     *   <li>Endpoint URL: {@code {apiBaseUrl}/v1/messages}</li>
     *   <li>Headers: {@code x-api-key}, {@code anthropic-version: 2023-06-01},
     *       {@code content-type: application/json}</li>
     *   <li>Body: JSON with {@code model}, {@code max_tokens}, optional {@code system}
     *       (prompt content), and {@code messages} with a single user message
     *       (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(API_KEY_HEADER, apiKey)
                .header(ANTHROPIC_VERSION_HEADER, ANTHROPIC_VERSION_VALUE)
                .POST(HttpRequest.BodyPublishers.ofString(requestBody))
                .timeout(Duration.ofSeconds(apiTimeoutSeconds))
                .build();
    }

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

    /**
     * Builds the JSON request body for the Anthropic Messages API.
     * <p>
     * The body contains:
     * <ul>
     *   <li>{@code model} — the configured model name</li>
     *   <li>{@code max_tokens} — fixed value sufficient for the expected response</li>
     *   <li>{@code system} — the prompt content as a top-level field (only when non-blank;
     *       Anthropic does not accept {@code role=system} inside the {@code messages} array)</li>
     *   <li>{@code messages} — an array with exactly one user message containing the
     *       document text</li>
     * </ul>
     * <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("max_tokens", MAX_TOKENS);

        // Prompt content goes to the top-level system field (not a role=system message)
        if (request.promptContent() != null && !request.promptContent().isBlank()) {
            body.put("system", request.promptContent());
        }

        JSONObject userMessage = new JSONObject();
        userMessage.put("role", "user");
        userMessage.put("content", request.documentText());
        body.put("messages", new JSONArray().put(userMessage));

        return body.toString();
    }

    /**
     * Extracts the text content from a successful (HTTP 200) Anthropic response.
     * <p>
     * Concatenates all {@code content} blocks with {@code type=="text"} in order.
     * Blocks of other types (e.g., tool use) are ignored.
     * If no {@code text} blocks are present, a technical failure is returned.
     *
     * @param request      the original request (carried through to the result)
     * @param responseBody the raw HTTP response body
     * @return success with the concatenated text, or a technical failure
     */
    private AiInvocationResult extractTextFromResponse(AiRequestRepresentation request, String responseBody) {
        try {
            JSONObject json = new JSONObject(responseBody);
            JSONArray contentArray = json.getJSONArray("content");

            StringBuilder textBuilder = new StringBuilder();
            for (int i = 0; i < contentArray.length(); i++) {
                JSONObject block = contentArray.getJSONObject(i);
                if ("text".equals(block.optString("type"))) {
                    textBuilder.append(block.getString("text"));
                }
            }

            String extractedText = textBuilder.toString();
            if (extractedText.isEmpty()) {
                LOG.warn("Claude AI response contained no text-type content blocks");
                return new AiInvocationTechnicalFailure(request, "NO_TEXT_CONTENT",
                        "Anthropic response contained no text-type content blocks");
            }

            return new AiInvocationSuccess(request, new AiRawResponse(extractedText),
                    extractTokenUsageFromResponse(json));
        } catch (JSONException e) {
            LOG.warn("Claude AI response could not be parsed as JSON: {}", e.getMessage());
            return new AiInvocationTechnicalFailure(request, "UNPARSEABLE_JSON",
                    "Anthropic response body is not valid JSON: " + e.getMessage());
        }
    }

    /**
     * Extrahiert Token-Verbrauchsmetadaten aus der Anthropic-Response.
     *
     * <p>Anthropic Messages API liefert im Top-Level-Feld {@code usage}:
     * {@code input_tokens}, {@code output_tokens},
     * {@code cache_creation_input_tokens}, {@code cache_read_input_tokens}.
     *
     * <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 Anthropic-Response
     * @return befuelltes {@link AiUsageMetadata}; nie {@code null}
     */
    private AiUsageMetadata extractTokenUsageFromResponse(JSONObject root) {
        JSONObject usage = root.optJSONObject("usage");
        if (usage == null) {
            LOG.warn("Anthropic-Response enthielt kein usage-Feld – Token-Daten werden nicht erfasst");
            return AiUsageMetadata.empty();
        }
        Long inputTokens = readTokenField(usage, "input_tokens");
        Long outputTokens = readTokenField(usage, "output_tokens");
        Long cacheCreation = readTokenField(usage, "cache_creation_input_tokens");
        Long cacheRead = readTokenField(usage, "cache_read_input_tokens");
        return new AiUsageMetadata(inputTokens, outputTokens, cacheCreation, cacheRead);
    }

    /**
     * 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("Anthropic-Token-Feld {} ist negativ ({}) – Wert verworfen", fieldName, value);
                return null;
            }
            if (value > 10_000_000L) {
                LOG.warn("Anthropic-Token-Feld {} uebersteigt Maximum (10 Mio.): {} – Wert verworfen", fieldName, value);
                return null;
            }
            return value;
        } catch (JSONException e) {
            LOG.warn("Anthropic-Token-Feld {} ist nicht numerisch – Wert verworfen: {}", fieldName, e.getMessage());
            return null;
        }
    }

    /**
     * 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.
     *
     * @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.
     *
     * @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());
    }
}
