<?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;

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

    /**
     * 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,
            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 = '';
        try {
            while (!feof($fp)) {
                $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] ?? [];
                $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 {
            fclose($fp);
        }

        return $full;
    }

}
