<?php
/**
 * HTTP client for the QFQ RAG retrieval service (POST <url>/retrieve), which
 * embeds a query, runs the pgvector ANN search and applies per-user ACL, and
 * returns the most relevant source chunks. QFQ injects those into the chatbot
 * prompt (see AiHandler chat mode).
 *
 * Config: SYSTEM_AI_RAG_URL (the retrieval endpoint) + SYSTEM_AI_RAG_API_KEY (optional - for
 * when RAG runs on a different gateway VM than the LLM; empty reuses SYSTEM_AI_GATEWAY_API_KEY).
 * The retrieval service validates the key against its LiteLLM. On a single-VM setup one
 * per-instance key thus gates BOTH the LLM and RAG: removing it from LiteLLM revokes all AI
 * access for the project at once.
 * See Documentation-develop/AI_INFRASTRUCTURE.md for the service side.
 */

namespace IMATHUZH\Qfq\Core\Ai;

use IMATHUZH\Qfq\Core\Store\Store;

class RagService {

    private string $url;
    private string $apiKey;
    private int $timeoutSeconds;

    /**
     * @throws \CodeException
     * @throws \UserFormException
     */
    public function __construct() {
        if (!self::isEnabled()) {
            throw new \UserFormException(json_encode([
                ERROR_MESSAGE_TO_USER => 'The chatbot is not available.',
                ERROR_MESSAGE_TO_DEVELOPER => 'RAG is not configured. Check config: ' .
                    SYSTEM_AI_RAG_URL . ' and the gateway (' . SYSTEM_AI_GATEWAY_API_KEY . ')']));
        }
        $this->url = rtrim((string)Store::getVar(SYSTEM_AI_RAG_URL, STORE_SYSTEM), '/');
        // Auth key: the retrieval service validates it against its own LiteLLM. Use a separate
        // key (aiRagApiKey) when RAG runs on a different gateway VM than the LLM; falls back to
        // the LLM gateway key when empty (single-VM setup).
        $ragKey = (string)Store::getVar(SYSTEM_AI_RAG_API_KEY, STORE_SYSTEM);
        $this->apiKey = $ragKey !== '' ? $ragKey : (string)Store::getVar(SYSTEM_AI_GATEWAY_API_KEY, STORE_SYSTEM);
        $this->timeoutSeconds = (int)Store::getVar(SYSTEM_AI_TIMEOUT_SECONDS, STORE_SYSTEM);
        if ($this->timeoutSeconds <= 0) {
            $this->timeoutSeconds = AI_TIMEOUT_SECONDS_DEFAULT;
        }
    }

    /**
     * @return bool
     * @throws \CodeException
     * @throws \UserFormException
     */
    public static function isEnabled(): bool {
        // RAG rides on the gateway key, so it needs the gateway configured + the RAG URL.
        return AiService::isEnabled() && Store::getVar(SYSTEM_AI_RAG_URL, STORE_SYSTEM) != '';
    }

    /**
     * Parse the aiRagAcl grammar into a per-source map [source => ['mode'=>, 'login'=>]].
     *
     * Grammar: either a single bare default spec, or a comma list of '<source>:<spec>'
     * entries (a bare spec among them sets the default for unmapped sources). A spec is
     *   user | user:<login> | public | off | all
     * The mode keywords (user/public/off/all) are reserved, so a source must not be
     * named like one. Logins (incl. evaluated {{feUser:..}}) are taken verbatim.
     *
     * @param string $aclValue   the (already brace-evaluated) aiRagAcl value
     * @param array $sources     source names from aiRagSource
     * @return array             source => ['mode' => string, 'login' => string]
     * @throws \UserFormException on an unknown mode or a source not in $sources
     */
    public static function parseAcl(string $aclValue, array $sources): array {
        $aclValue = trim($aclValue);
        if ($aclValue === '') {
            $aclValue = AI_RAG_ACL_USER;
        }
        $modes = [AI_RAG_ACL_USER, AI_RAG_ACL_PUBLIC, AI_RAG_ACL_OFF, AI_RAG_ACL_ALL];

        $default = [AI_ACL_MODE => AI_RAG_ACL_USER, AI_ACL_LOGIN => ''];
        $perSource = array();
        foreach (array_filter(array_map('trim', explode(',', $aclValue))) as $entry) {
            $head = explode(':', $entry, 2)[0];
            if (in_array($head, $modes, true)) {
                $default = self::parseAclSpec($entry);                 // bare default spec
            } else {
                $parts = explode(':', $entry, 2);                      // <source>:<spec>
                $src = $parts[0];
                if (!in_array($src, $sources, true)) {
                    throw new \UserFormException("'" . FE_AI_RAG_ACL . "': unknown source '" . $src
                        . "' (not listed in '" . FE_AI_RAG_SOURCE . "')");
                }
                $perSource[$src] = self::parseAclSpec($parts[1] ?? '');
            }
        }

        $map = array();
        foreach ($sources as $src) {
            $map[$src] = $perSource[$src] ?? $default;
        }
        return $map;
    }

    /**
     * Parse a single ACL spec '<mode>[:<login>]' into ['mode'=>, 'login'=>].
     *
     * @param string $spec
     * @return array
     * @throws \UserFormException on an unknown mode
     */
    private static function parseAclSpec(string $spec): array {
        $parts = explode(':', trim($spec), 2);
        $mode = trim($parts[0]);
        if (!in_array($mode, [AI_RAG_ACL_USER, AI_RAG_ACL_PUBLIC, AI_RAG_ACL_OFF, AI_RAG_ACL_ALL], true)) {
            throw new \UserFormException("'" . FE_AI_RAG_ACL . "': unknown mode '" . $mode . "'");
        }
        $login = ($mode === AI_RAG_ACL_USER && isset($parts[1])) ? trim($parts[1]) : '';
        return [AI_ACL_MODE => $mode, AI_ACL_LOGIN => $login];
    }

    /**
     * Validate a RAG config at form render time: the ACL grammar must parse (valid modes,
     * sources referenced in the map must be listed in $sources) and any source using the
     * 'off' mode needs an explicit project scope. Throws with a clear message otherwise.
     *
     * @param array $sources    source names (from aiRagSource)
     * @param string $aclValue  raw aiRagAcl value
     * @param array $projects   project ids (from aiRagProject)
     * @return void
     * @throws \UserFormException
     */
    public static function validateAcl(array $sources, string $aclValue, array $projects): void {
        $map = self::parseAcl($aclValue, $sources);
        foreach ($map as $src => $spec) {
            if ($spec[AI_ACL_MODE] === AI_RAG_ACL_OFF && empty($projects)) {
                throw new \UserFormException("'" . FE_AI_RAG_ACL . "=off' (source '" . $src
                    . "') requires '" . FE_AI_RAG_PROJECT . "' (refusing unscoped no-ACL retrieval)");
            }
        }
    }

    /**
     * Retrieve the top-k context chunks for $query, already ACL-filtered by the service.
     *
     * @param string $query
     * @param string $source     indexed source name, e.g. 'redmine'
     * @param string $acl        AI_RAG_ACL_USER | AI_RAG_ACL_PUBLIC | AI_RAG_ACL_OFF
     * @param string $userLogin  required for acl 'user' (the logged-in fe_user login)
     * @param array $projects    optional project ids to restrict to
     * @param int $k
     *
     * @return array list of chunks: each [title,url,external_id,content,similarity,...]
     * @throws \UserFormException
     */
    public function retrieve(string $query, string $source, string $acl, string $userLogin = '',
                             array $projects = array(), int $k = AI_RAG_DEFAULT_K): array {

        $payload = [
            AI_RAG_REQ_QUERY => $query,
            AI_RAG_SOURCE => $source,
            AI_RAG_REQ_ACL => $acl,
            AI_RAG_REQ_K => $k,
        ];
        if ($userLogin !== '') {
            $payload[AI_RAG_REQ_USER_LOGIN] = $userLogin;
        }
        if (!empty($projects)) {
            $payload[AI_RAG_PROJECTS] = array_values($projects);
        }

        $answer = $this->post('/retrieve', $payload);

        if (!isset($answer[AI_RAG_RESP_CHUNKS]) || !is_array($answer[AI_RAG_RESP_CHUNKS])) {
            throw new \UserFormException(json_encode([
                ERROR_MESSAGE_TO_USER => 'The chatbot search failed.',
                ERROR_MESSAGE_TO_DEVELOPER => 'Retrieval service answer missing "chunks": ' .
                    substr(json_encode($answer), 0, 1000)]));
        }
        return $answer[AI_RAG_RESP_CHUNKS];
    }

    /**
     * POST $payload as JSON to the retrieval service, return the decoded answer.
     * PHP streams (no ext-curl), system trust store for TLS - same approach as AiService.
     *
     * @param string $path
     * @param array $payload
     *
     * @return array
     * @throws \UserFormException
     */
    private function post(string $path, array $payload): array {
        $url = $this->url . $path;

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

        $body = @file_get_contents($url, false, $context);
        if ($body === false) {
            $lastError = error_get_last();
            throw new \UserFormException(json_encode([
                ERROR_MESSAGE_TO_USER => 'The chatbot search is not reachable.',
                ERROR_MESSAGE_TO_DEVELOPER => ($lastError['message'] ?? 'unknown error') . ' - ' . $url]));
        }

        $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) {
            throw new \UserFormException(json_encode([
                ERROR_MESSAGE_TO_USER => 'The chatbot search failed.',
                ERROR_MESSAGE_TO_DEVELOPER => 'Retrieval HTTP ' . $httpCode . ': ' . substr($body, 0, 1000)]));
        }

        $answer = json_decode($body, true);
        if (!is_array($answer)) {
            throw new \UserFormException(json_encode([
                ERROR_MESSAGE_TO_USER => 'The chatbot search failed.',
                ERROR_MESSAGE_TO_DEVELOPER => 'Retrieval answer is no valid JSON: ' . substr($body, 0, 1000)]));
        }
        return $answer;
    }
}
