<?php
/**
 * HTTP client for the QFQ AI gateway (OpenAI compatible chat completions API,
 * served by a self hosted LiteLLM/vLLM stack).
 *
 * Configuration: SYSTEM_AI_* keys (ext_conf_template.txt / qfq.json).
 * See Documentation-develop/AI_INFRASTRUCTURE.md for the infrastructure side.
 */

namespace IMATHUZH\Qfq\Core\Ai;

use IMATHUZH\Qfq\Core\Store\Store;

class AiService {

    private string $gatewayUrl;
    private string $apiKey;
    private string $modelDefault;
    private int $timeoutSeconds;
    private int $maxTokens;
    private int $maxStreamSeconds;

    /**
     * @throws \CodeException
     * @throws \UserFormException
     */
    public function __construct() {

        if (!self::isEnabled()) {
            throw new \UserFormException(json_encode([
                ERROR_MESSAGE_TO_USER => 'AI features are not available.',
                ERROR_MESSAGE_TO_DEVELOPER => 'AI is disabled or not configured. Check config: ' .
                    SYSTEM_AI_ENABLED . ', ' . SYSTEM_AI_GATEWAY_URL . ', ' . SYSTEM_AI_GATEWAY_API_KEY]));
        }

        $this->gatewayUrl = rtrim((string)Store::getVar(SYSTEM_AI_GATEWAY_URL, STORE_SYSTEM), '/');
        $this->apiKey = (string)Store::getVar(SYSTEM_AI_GATEWAY_API_KEY, STORE_SYSTEM);
        $this->modelDefault = (string)Store::getVar(SYSTEM_AI_MODEL_DEFAULT, STORE_SYSTEM);
        $this->timeoutSeconds = (int)Store::getVar(SYSTEM_AI_TIMEOUT_SECONDS, STORE_SYSTEM);

        if ($this->modelDefault === '') {
            $this->modelDefault = AI_MODEL_DEFAULT;
        }
        if ($this->timeoutSeconds <= 0) {
            $this->timeoutSeconds = AI_TIMEOUT_SECONDS_DEFAULT;
        }
        $this->maxTokens = (int)Store::getVar(SYSTEM_AI_MAX_TOKENS, STORE_SYSTEM);
        if ($this->maxTokens <= 0) {
            $this->maxTokens = AI_MAX_TOKENS_DEFAULT;
        }
        $this->maxStreamSeconds = (int)Store::getVar(SYSTEM_AI_MAX_STREAM_SECONDS, STORE_SYSTEM);
        if ($this->maxStreamSeconds <= 0) {
            $this->maxStreamSeconds = AI_MAX_STREAM_SECONDS_DEFAULT;
        }
    }

    /**
     * AI features are usable if the flag is set and gateway URL + API key are configured.
     *
     * @return bool
     * @throws \CodeException
     * @throws \UserFormException
     */
    public static function isEnabled(): bool {
        return Store::getVar(SYSTEM_AI_ENABLED, STORE_SYSTEM) == '1'
            && Store::getVar(SYSTEM_AI_GATEWAY_URL, STORE_SYSTEM) != ''
            && Store::getVar(SYSTEM_AI_GATEWAY_API_KEY, STORE_SYSTEM) != '';
    }

    /**
     * Streaming chat completion: sends `stream: true` and forwards the model's
     * deltas to $onDelta($type, $text) as they arrive, where $type is 'reasoning' (the
     * reasoning_content channel of thinking models) or 'content' (the answer). Returns the
     * full accumulated raw answer content so the caller can run extractCitedSources() on it.
     *
     * If the browser disconnects mid-stream, the upstream connection is closed (detected via
     * connection_aborted() after $onDelta flushed output), which makes vLLM abort the
     * generation and free the GPU.
     *
     * Uses the PHP http:// stream wrapper (no ext-curl); it yields the SSE body
     * incrementally. If a PHP build ever buffers it, switch to fsockopen().
     *
     * @param string $systemPrompt
     * @param string $userMessage
     * @param string $model Logical gateway model name. Empty: SYSTEM_AI_MODEL_DEFAULT.
     * @param bool $enableThinking
     * @param array $history Prior turns [['role'=>..,'content'=>..], ...].
     * @param callable|null $onDelta function(string $type, string $text): void
     *
     * @return string full raw answer content
     * @throws \UserFormException
     */
    public function chatCompletionStream(string $systemPrompt, string $userMessage, string $model = '', bool $enableThinking = false, array $history = array(), ?callable $onDelta = null): string {

        $messages = array();
        if ($systemPrompt !== '') {
            $messages[] = [AI_GW_ROLE => AI_GW_ROLE_SYSTEM, AI_GW_CONTENT => $systemPrompt];
        }
        foreach ($history as $turn) {
            $role = (($turn[AI_GW_ROLE] ?? '') === AI_GW_ROLE_ASSISTANT) ? AI_GW_ROLE_ASSISTANT : AI_GW_ROLE_USER;
            $content = (string)($turn[AI_GW_CONTENT] ?? '');
            if ($content !== '') {
                $messages[] = [AI_GW_ROLE => $role, AI_GW_CONTENT => $content];
            }
        }
        $messages[] = [AI_GW_ROLE => AI_GW_ROLE_USER, AI_GW_CONTENT => $userMessage];

        $payload = [
            AI_GW_MODEL => $model === '' ? $this->modelDefault : $model,
            AI_GW_MESSAGES => $messages,
            // Cap generated tokens (reasoning + answer) so a thinking/repetition loop cannot run
            // to the context limit. Per request.
            AI_GW_MAX_TOKENS => $this->maxTokens,
            AI_GW_CHAT_TEMPLATE_KWARGS => [AI_GW_ENABLE_THINKING => $enableThinking],
            AI_GW_STREAM => true,
            // Ask the gateway to include token usage in the stream (else it is omitted). vLLM
            // sends a cumulative usage per chunk -> the live token counter; OpenAI-spec gateways
            // send it only in the final chunk -> the counter then shows the total at the end.
            AI_GW_STREAM_OPTIONS => [AI_GW_INCLUDE_USAGE => true],
        ];

        $context = stream_context_create([
            'http' => [
                'method' => 'POST',
                'header' => "Content-Type: application/json\r\n"
                    . "Accept: text/event-stream\r\n"
                    . 'Authorization: Bearer ' . $this->apiKey . "\r\n",
                'content' => json_encode($payload, JSON_INVALID_UTF8_SUBSTITUTE),
                'timeout' => $this->timeoutSeconds,
                'ignore_errors' => true,
            ],
        ]);

        $url = $this->gatewayUrl . AI_GW_PATH_CHAT;
        $fp = @fopen($url, 'rb', false, $context);
        if ($fp === false) {
            $lastError = error_get_last();
            throw new \UserFormException(json_encode([
                ERROR_MESSAGE_TO_USER => 'AI gateway is not reachable.',
                ERROR_MESSAGE_TO_DEVELOPER => ($lastError['message'] ?? 'unknown error') . ' - ' . $url]));
        }

        // $http_response_header is populated after fopen() on the http wrapper.
        $httpCode = 0;
        if (isset($http_response_header[0]) && preg_match('#^HTTP/\S+\s+(\d{3})#', $http_response_header[0], $m)) {
            $httpCode = (int)$m[1];
        }
        if ($httpCode !== 200) {
            $body = (string)stream_get_contents($fp);
            fclose($fp);
            throw new \UserFormException(json_encode([
                ERROR_MESSAGE_TO_USER => 'AI request failed.',
                ERROR_MESSAGE_TO_DEVELOPER => 'Gateway HTTP ' . $httpCode . ': ' . substr($body, 0, 1000)]));
        }

        $full = '';
        // Hard wall-clock deadline. The stream read 'timeout' above is only an IDLE timeout
        $deadline = time() + $this->maxStreamSeconds;
        $timedOut = false;
        $finishReason = '';   // gateway 'finish_reason' of the final chunk ('stop' | 'length' | ...)
        try {
            while (!feof($fp)) {
                if (time() >= $deadline) {
                    $timedOut = true;
                    break;
                }
                $line = fgets($fp);
                if ($line === false) {
                    break;
                }
                $line = trim($line);
                // SSE: only 'data:' lines carry payload; skip comments/blank/event: lines.
                if ($line === '' || strncmp($line, AI_SSE_DATA_PREFIX, strlen(AI_SSE_DATA_PREFIX)) !== 0) {
                    continue;
                }
                $data = trim(substr($line, strlen(AI_SSE_DATA_PREFIX)));
                if ($data === AI_SSE_DONE) {
                    break;
                }
                $event = json_decode($data, true);
                if (!is_array($event)) {
                    continue;
                }
                $delta = $event[AI_GW_CHOICES][0][AI_GW_DELTA] ?? [];
                // finish_reason lives next to (not inside) the delta; the final chunk sets it to
                // 'length' when the max_tokens budget was hit -> used below for clear feedback.
                $fr = $event[AI_GW_CHOICES][0][AI_GW_FINISH_REASON] ?? null;
                if (is_string($fr) && $fr !== '') {
                    $finishReason = $fr;
                }
                $reasoning = $delta[AI_GW_REASONING_CONTENT] ?? null;
                $content = $delta[AI_GW_CONTENT] ?? null;
                if (is_string($reasoning) && $reasoning !== '' && $onDelta !== null) {
                    $onDelta(AI_STREAM_REASONING, $reasoning);
                }
                if (is_string($content) && $content !== '') {
                    $full .= $content;
                    if ($onDelta !== null) {
                        $onDelta(AI_STREAM_CONTENT, $content);
                    }
                }
                // Exact generated-token count if the gateway includes usage in the chunk. vLLM
                // sends it cumulatively per chunk (live); OpenAI-spec gateways only in the final
                // chunk (the client counts deltas live, this then corrects it to the exact value).
                $usage = $event[AI_GW_USAGE] ?? null;
                if (is_array($usage) && $onDelta !== null) {
                    $tokens = $usage[AI_GW_COMPLETION_TOKENS] ?? ($usage[AI_GW_TOTAL_TOKENS] ?? null);
                    if (is_numeric($tokens)) {
                        $onDelta(AI_STREAM_TOKENS, (string)(int)$tokens);
                    }
                }
                // Browser gone (the $onDelta echo+flush above surfaces the disconnect)?
                // Close upstream -> vLLM aborts the generation and frees the GPU.
                if (connection_aborted()) {
                    break;
                }
            }
        } finally {
            // Closing the upstream stream aborts the gateway generation and frees the GPU.
            fclose($fp);
        }

        // Hit the wall-clock deadline without producing any answer (a reasoning loop)
        if ($timedOut && trim($full) === '') {
            throw new \UserFormException(json_encode([
                ERROR_MESSAGE_TO_USER => 'The AI did not return an answer in time. Please try again - '
                    . 'if this happens with translate/improve, disable "thinking".',
                ERROR_MESSAGE_TO_DEVELOPER => 'AI stream exceeded the ' . $this->maxStreamSeconds
                    . 's wall-clock deadline (' . SYSTEM_AI_MAX_STREAM_SECONDS . ') with no content - likely a reasoning loop.']));
        }

        // Token cap hit before any answer was produced (e.g. 'thinking' consumed the whole budget)
        if ($finishReason === AI_GW_FINISH_LENGTH && trim($full) === '') {
            throw new \UserFormException(json_encode([
                ERROR_MESSAGE_TO_USER => 'The AI reached its token limit before returning an answer. '
                    . 'Please try again - disable "thinking" for translate/improve, or raise the AI max tokens limit.',
                ERROR_MESSAGE_TO_DEVELOPER => 'AI stream stopped with finish_reason=length and no content - the '
                    . SYSTEM_AI_MAX_TOKENS . ' budget (' . $this->maxTokens . ') was exhausted, likely by reasoning tokens.']));
        }

        return $full;
    }

}
