<?php
namespace EasyChat\Library\AiProvider;

require_once __DIR__ . '/openai_compatible_transport.php';
require_once __DIR__ . '/provider_exception.php';

use EasyChat\Library\Ai\OpenAiSseStreamParser;
use EasyChat\Library\SafeRuntime;

 
class OpenAI implements AiProvider {

    private $token = null;
    private $messages = [];  
    private $host = 'https://api.openai.com';
    private $model = 'gpt-4o-mini';  
    private $default_role = 'user';
    private $connector = null;
    private $chunks = 1;
    private $n = 1;
    private $repetition_penalty = 0;  
    private $top_p = 0.9;
    private $temperature = 1.0;
    private $keySeeker = null;
    private $tools = [];  
    private $lastCurlError = '';
    private $lastHttpStatus = 0;
    private $transport;
    private $lastGrounding = null;
    private $lastProviderError = null;
    private $proxy = [
        'ip' => null,
        'port' => null,
        'login' => null,
        'password' => null,
    ];

    public function __construct($token, $data = []) {

        $this->token = $token;
        $this->messages = [];
        $this->model = isset($data['model']) ? $data['model'] : 'gpt-4o-mini';
        $this->chunks = isset($data['chunks']) ? $data['chunks'] : 1;

        if (isset($data['temperature']))
            $this->temperature = (float) $data['temperature'];

        if (isset($data['role']))
            $this->default_role = $data['role'];

        if (isset($data['host']))
            $this->host = $data['host'];

        if (isset($data['top_p']))
            $this->top_p = (float) $data['top_p'];

        if (isset($data['repetition_penalty']))
            $this->repetition_penalty = $data['repetition_penalty'];

        if (isset($data['proxy']))
            $this->proxy = $data['proxy'];

        $trustedResolve = isset($data['trusted_curl_resolve']) && is_array($data['trusted_curl_resolve'])
            ? $data['trusted_curl_resolve']
            : [];
        $this->transport = new OpenAiCompatibleTransport($this->proxy, $trustedResolve);

        return $this;
    }

    public function setChunks($chunks = 1) {
        $this->chunks = $chunks;
    }

    public function setModel($model) {
        $this->model = $model;
    }

    public function setContext($context) {
        if (empty($context))
            return null;

        $this->messages = array_merge(
            [[
                "role" => "system",
                "content" => $context
            ]],
            $this->messages
        );
    }

    public function getHistory() {
        return $this->messages;
    }
    public function clearHistory() {
        $this->messages = [];
    }
    public function setHistory($messages) {
        $this->messages = $messages;
    }

     
     
     

    






    public function setTools(array $toolsConfig) {
        $this->tools = [];
        foreach ($toolsConfig as $tool) {
            if (is_string($tool)) {
                $config = $this->toolConfig($tool);
                if (is_array($config)) {
                    $this->tools[] = $config;
                }
            } elseif (is_array($tool) && !empty($tool['name'])) {
                $this->tools[] = $tool;
            }
        }
        return $this;
    }

    public function getTools() {
        return $this->tools;
    }

    private function toolConfig($tool) {
        switch ((string)$tool) {
            case 'search_pages':
                return require __DIR__ . DIRECTORY_SEPARATOR . 'tools' . DIRECTORY_SEPARATOR . 'search_pages.php';
            case 'search_products':
                return require __DIR__ . DIRECTORY_SEPARATOR . 'tools' . DIRECTORY_SEPARATOR . 'search_products.php';
            default:
                return null;
        }
    }

    



    private function buildToolsPayload() {
        if (empty($this->tools)) {
            return [];
        }
        $tools = [];
        foreach ($this->tools as $tool) {
            $tools[] = [
                'type'     => 'function',
                'function' => [
                    'name'        => $tool['name'],
                    'description' => isset($tool['description']) ? $tool['description'] : '',
                    'parameters'  => $this->buildJsonSchemaParameters(isset($tool['parameters']) ? $tool['parameters'] : []),
                ],
            ];
        }
        return $tools;
    }

    



    private function buildJsonSchemaParameters(array $parameters) {
        $properties = [];
        $required   = [];

        foreach ($parameters as $name => $param) {
            $prop = ['type' => isset($param['type']) ? $param['type'] : 'string'];

            if (isset($param['description'])) $prop['description'] = $param['description'];
            if (isset($param['enum']))        $prop['enum']        = $param['enum'];
            if (isset($param['items']))       $prop['items']       = $param['items'];
            if (isset($param['properties']))  $prop['properties']  = $param['properties'];

            $properties[$name] = $prop;

            if (!empty($param['required'])) {
                $required[] = $name;
            }
        }

        $schema = [
            'type'       => 'object',
            'properties' => $properties,
        ];
        if (!empty($required)) {
            $schema['required'] = $required;
        }
        return $schema;
    }

     
     
     

    



    private function buildCurlOptions($url, $body, array $headers, array $extraOptions = []) {
        return $this->transport->buildOptions($url, $body, $headers, $extraOptions);
    }

    private function isEasyTokensUrl($url) {
        return OpenAiCompatibleTransport::isEasyTokensUrl($url);
    }

    



    private function execCurl(array $options) {
        $raw = $this->transport->execute($options);
        $this->lastCurlError = $this->transport->getLastCurlError();
        $this->lastHttpStatus = $this->transport->getLastHttpStatus();
        $this->connector = null;
        return $raw;
    }

    public function getLastTransportError() {
        return $this->lastCurlError;
    }

    public function getLastHttpStatus() {
        return $this->lastHttpStatus;
    }

     
     
     

    public function get($url, $headers, $data) {
        $options = $this->buildCurlOptions($url, $data, $headers);
        if (empty($data)) {
            unset($options[CURLOPT_POSTFIELDS]);
        }
        $result = $this->execCurl($options);
        return json_decode($result, true);
    }

     
     
     

    public function getToken() {
        $api_key   = preg_split('/[\n\r]+/', $this->token);
        $nTokens   = count($api_key);
        $keySeeker = $this->keySeeker !== null ? $this->keySeeker : rand(0, $nTokens - 1);
        return trim((string) $api_key[$keySeeker]);
    }

    public function setKeySeeker($keySeeker) {
        $this->keySeeker = (int) $keySeeker;
    }

     
     
     

    public function query($prompt, $options = []) {

        $temperature = isset($options['temperature']) && (float) $options['temperature'] ? (float) $options['temperature'] : $this->temperature;

        list($apiAddres, $data) = $this->buildChatPayload($prompt, $temperature, $options);

        $postData = json_encode($data);

        $headers = [
            "Content-Type: application/json",
            "Content-Length: " . strlen($postData),
            "Authorization: Bearer " . $this->getToken(),
        ];
        $headers = $this->appendIdempotencyHeader($headers, $options, $apiAddres);

        $extra_options = [];
        if (isset($options['timeout']) && (int)$options['timeout'] > 0) {
            $extra_options[CURLOPT_TIMEOUT] = max(1, min(120, (int)$options['timeout']));
        }
        if (isset($options['connect_timeout']) && (int)$options['connect_timeout'] > 0) {
            $extra_options[CURLOPT_CONNECTTIMEOUT] = max(1, min(20, (int)$options['connect_timeout']));
        }
        if (isset($options['first_byte_timeout']) && (int)$options['first_byte_timeout'] > 0) {
            $extra_options[CURLOPT_LOW_SPEED_LIMIT] = 1;
            $extra_options[CURLOPT_LOW_SPEED_TIME] = max(5, min(60, (int)$options['first_byte_timeout']));
        }

        $result = $this->execCurl(
            $this->buildCurlOptions($apiAddres, $postData, $headers, $extra_options)
        );

        $response = json_decode($result, true);
        $this->assertTransportResult($result, $response);
        $this->captureResponseMetadata($response);
        $this->throwProviderError($response);

        if (isset($response["choices"][0]["message"]["content"])) {
            $response["choices"][0]["text"] = $response["choices"][0]["message"]["content"];
            $result = json_encode($response);
        }

        return $result;
    }

    public function stream($messages, $options = []) {
        if (is_array($messages)) {
            $this->setHistory(array_unique(array_merge($this->messages, $messages), SORT_REGULAR));
            $prompt = '';
        } else {
            $prompt = (string)$messages;
        }

        $raw = $this->streamChat($prompt, $options, function ($delta) {
            echo 'data: ' . json_encode([
                'choices' => [[
                    'text'  => $delta,
                    'delta' => ['content' => $delta],
                ]],
            ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . "\n\n";
            SafeRuntime::call(function () {
                ob_flush();
                return true;
            }, false);
            flush();
        });

        echo "data: [DONE]\n\n";
        return [
            'response_text' => $this->extractTextFromResponse($raw),
            'response_log'  => $raw,
        ];
    }

    



    public function streamChat($prompt, array $options = [], ?callable $on_delta = null) {
        $temperature = isset($options['temperature']) && (float) $options['temperature'] ? (float) $options['temperature'] : $this->temperature;
        list($apiAddres, $data) = $this->buildChatPayload($prompt, $temperature, $options);
        $data['stream'] = true;

        $postData = json_encode($data);
        $parser = new OpenAiSseStreamParser();

        $extra_options = [
            CURLOPT_TIMEOUT        => isset($options['stream_timeout']) ? max(10, min(180, (int)$options['stream_timeout'])) : 120,
            CURLOPT_CONNECTTIMEOUT => isset($options['connect_timeout']) ? max(1, min(20, (int)$options['connect_timeout'])) : 10,
             
             
            CURLOPT_LOW_SPEED_LIMIT => 1,
            CURLOPT_LOW_SPEED_TIME  => isset($options['first_byte_timeout']) ? max(10, min(60, (int)$options['first_byte_timeout'])) : 35,
            CURLOPT_WRITEFUNCTION  => function ($ch, $chunk) use ($parser, $on_delta) {
                $parser->feed($chunk, $on_delta);
                 
                 
                 
                return strlen($chunk);
            },
        ];

        $headers = $this->appendIdempotencyHeader([
            "Content-Type: application/json",
            "Content-Length: " . strlen($postData),
            "Authorization: Bearer " . $this->getToken(),
        ], $options, $apiAddres);

        $transport_result = $this->execCurl($this->buildCurlOptions(
            $apiAddres,
            $postData,
            $headers,
            $extra_options
        ));

        $parser->finish($on_delta);
        $response = $parser->toOpenAiResponse();
        $decoded = json_decode($response, true);
        $this->assertTransportResult($transport_result, $decoded);
        $this->captureResponseMetadata($decoded);
        $this->throwProviderError($decoded);
        return $response;
    }

    public function getCurlOptions($request, $options = []) {

        if (isset($options['keySeeker']))
            $this->setKeySeeker($options['keySeeker']);

        $temperature = isset($options['temperature']) && (float) $options['temperature'] ? (float) $options['temperature'] : (float) $this->temperature;

        list($apiAddres, $data) = $this->buildChatPayload($request, $temperature, $options);

        $postData = json_encode($data);

        $headers = [
            "Content-Type: application/json",
            "Content-Length: " . strlen($postData),
            "Authorization: Bearer " . $this->getToken(),
        ];
        $headers = $this->appendIdempotencyHeader($headers, $options, $apiAddres);

        return $this->buildCurlOptions($apiAddres, $postData, $headers);
    }

    private function appendIdempotencyHeader(array $headers, array $options, string $url): array {
        if (!$this->isEasyTokensUrl($url)) {
            return $headers;
        }
        $key = trim((string)($options['idempotency_key'] ?? ''));
        if ($key === '' || preg_match('/^[A-Za-z0-9._:-]{8,128}$/', $key) !== 1) {
            return $headers;
        }
        $headers[] = 'Idempotency-Key: ' . $key;
        return $headers;
    }

    







    protected function buildChatPayload($prompt, $temperature, array $options) {
        $messages = $this->messages;

        $is_chat = (strpos($this->model, 'davinci') === false && $this->model != 'gpt-3.5-turbo-instruct');

        if ($is_chat) {
            if ($prompt !== '' && $prompt !== null) {
                $messages[] = [
                    'role'    => $this->default_role,
                    'content' => $prompt,
                ];
            }

            foreach ($messages as $index => $message) {
                $messages[$index] = $this->img2text($message);
            }

            $apiAddres = $this->host . "/v1/chat/completions";
            $data = [
                "model"       => $this->model,
                "temperature" => (float) min(1.99, $temperature),
                "messages"    => $messages,
                "top_p"       => $this->top_p,
                "n"           => $this->n,
            ];

            $tools_payload = $this->buildToolsPayload();
            if (!empty($tools_payload)) {
                $data['tools'] = $tools_payload;
            }

            $web_payload = $this->normalizeWebPayload($options['web'] ?? null);
            if ($web_payload !== null) {
                $data['web'] = $web_payload;
            }
            $data = $this->extendChatPayload($data, $options);
        } else {
            $apiAddres = $this->host . "/v1/completions";
            $data = [
                "model"       => $this->model,
                "prompt"      => $prompt,
                "temperature" => (float) min(1.99, $temperature),
                "best_of"     => $this->n,
            ];
        }

        if (isset($options['max_tokens']) && (int) $options['max_tokens']) {
            $data["max_tokens"] = (int) $options['max_tokens'];
        }

        return [$apiAddres, $data];
    }

    protected function extendChatPayload(array $data, array $options) {
        return $data;
    }

    private function normalizeWebPayload($value) {
        if ($value === null) {
            return null;
        }
        if (is_bool($value)) {
            return $value;
        }
        if (is_string($value)) {
            $value = strtolower(trim($value));
            return in_array($value, ['never', 'auto', 'always'], true) ? $value : null;
        }
        if (!is_array($value)) {
            return null;
        }

        $payload = [];
        foreach (['enabled', 'mode', 'provider', 'scope', 'freshness'] as $key) {
            if (isset($value[$key]) && is_scalar($value[$key])) {
                $payload[$key] = (string)$value[$key];
            }
        }
        foreach (['max_sources', 'max_results'] as $key) {
            if (isset($value[$key]) && is_numeric($value[$key])) {
                $payload[$key] = max(1, min(10, (int)$value[$key]));
            }
        }
        foreach (['return_sources', 'debug'] as $key) {
            if (isset($value[$key])) {
                $payload[$key] = (bool)$value[$key];
            }
        }
        foreach (['urls', 'include_domains', 'exclude_domains'] as $key) {
            if (empty($value[$key]) || !is_array($value[$key])) {
                continue;
            }
            $items = [];
            foreach ($value[$key] as $item) {
                if (!is_scalar($item)) {
                    continue;
                }
                $item = trim((string)$item);
                if ($item === '' || in_array($item, $items, true)) {
                    continue;
                }
                $items[] = $item;
                if (count($items) >= 10) {
                    break;
                }
            }
            if ($items) {
                $payload[$key] = $items;
            }
        }

        return $payload ?: null;
    }

     
     
     

    



    public function parseToolCalls($response) {
        $resp = is_array($response) ? $response : json_decode((string)$response, true);
        $calls = isset($resp['choices'][0]['message']['tool_calls'])
            ? $resp['choices'][0]['message']['tool_calls']
            : [];

        $result = [];
        foreach ($calls as $call) {
            if (isset($call['type']) && $call['type'] !== 'function') {
                continue;
            }
            $name = isset($call['function']['name']) ? $call['function']['name'] : '';
            $args = isset($call['function']['arguments']) ? $call['function']['arguments'] : '{}';
             
            $args_decoded = is_string($args) ? json_decode($args, true) : $args;
            if (!is_array($args_decoded)) {
                $args_decoded = [];
            }
            $result[] = [
                'id'        => isset($call['id']) ? $call['id'] : '',
                'name'      => $name,
                'arguments' => $args_decoded,
            ];
        }
        return $result;
    }

    



    public function appendAssistantToolCalls($response) {
        $resp = is_array($response) ? $response : json_decode((string)$response, true);
        $msg  = isset($resp['choices'][0]['message']) ? $resp['choices'][0]['message'] : null;
        if (!$msg || empty($msg['tool_calls'])) {
            return;
        }
        $this->messages[] = [
            'role'       => 'assistant',
            'content'    => isset($msg['content']) ? $msg['content'] : null,
            'tool_calls' => $msg['tool_calls'],
        ];
    }

    



    public function appendToolResult($call_id, $name, $result) {
        $this->messages[] = [
            'role'         => 'tool',
            'tool_call_id' => (string)$call_id,
            'name'         => (string)$name,
            'content'      => is_string($result) ? $result : json_encode($result, JSON_UNESCAPED_UNICODE),
        ];
    }

    


    public function runWithTools($prompt, $executor, array $options = [], $max_iterations = 5) {
        $response = $this->query($prompt, $options);
        $iter     = 0;

        while ($iter++ < $max_iterations) {
            $calls = $this->parseToolCalls($response);
            if (empty($calls)) {
                return $response;  
            }

            $this->appendAssistantToolCalls($response);

            foreach ($calls as $call) {
                if ($executor && method_exists($executor, 'execute')) {
                    $result = $executor->has($call['name'])
                        ? $executor->execute($call['name'], $call['arguments'])
                        : ['error' => 'Unknown tool: ' . $call['name']];
                } else {
                    $result = ['error' => 'No tool executor configured'];
                }
                $this->appendToolResult($call['id'], $call['name'], $result);
            }

             
            $response = $this->query('', $options);
        }

        return $this->toolCallLoopFallbackResponse();
    }

    







    public function runWithToolsStream($prompt, $executor, array $options = [], ?callable $on_delta = null, $max_iterations = 5) {
        $response = $this->streamChat($prompt, $options, null);
        $iter = 0;

        while ($iter++ < $max_iterations) {
            $calls = $this->parseToolCalls($response);
            if (empty($calls)) {
                $text = $this->extractTextFromResponse($response);
                if ($text !== '' && $on_delta) {
                    $on_delta($text);
                }
                return $response;
            }

            $this->appendAssistantToolCalls($response);

            foreach ($calls as $call) {
                if ($executor && method_exists($executor, 'execute')) {
                    $result = $executor->has($call['name'])
                        ? $executor->execute($call['name'], $call['arguments'])
                        : ['error' => 'Unknown tool: ' . $call['name']];
                } else {
                    $result = ['error' => 'No tool executor configured'];
                }
                $this->appendToolResult($call['id'], $call['name'], $result);
            }

            $response = $this->streamChat('', $options, null);
        }

        return $this->toolCallLoopFallbackResponse();
    }

    private function extractTextFromResponse($response) {
        $json = is_array($response) ? $response : json_decode((string)$response, true);
        if (!is_array($json)) {
            return '';
        }
        if (isset($json['choices'][0]['text'])) {
            return trim((string)$json['choices'][0]['text']);
        }
        if (isset($json['choices'][0]['message']['content'])) {
            return trim((string)$json['choices'][0]['message']['content']);
        }
        return '';
    }

    private function toolCallLoopFallbackResponse() {
        return json_encode([
            'choices' => [[
                'text' => '',
                'finish_reason' => 'tool_calls_exhausted',
            ]],
        ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
    }

     
     
     

    public function getEmbedding($text, $embeddingModel = 'text-embedding-3-small', $dimensions = 1536, array $options = []) {
        $embeddings = $this->getEmbeddings([$text], $embeddingModel, $dimensions, $options);
        return isset($embeddings[0]) ? $embeddings[0] : null;
    }

    public function getEmbeddings(array $texts, $embeddingModel = 'text-embedding-3-small', $dimensions = 1536, array $options = []) {
        if (empty($texts) || count($texts) > 64) {
            throw new \InvalidArgumentException('Embedding batch must contain 1 to 64 texts.');
        }
        $normalized = [];
        foreach ($texts as $text) {
            $text = trim((string)$text);
            if ($text === '') {
                throw new \InvalidArgumentException('Embedding text must not be empty.');
            }
            $normalized[] = $text;
        }
        $apiAddress = $this->host . "/v1/embeddings";
        $data = [
            'model' => $embeddingModel,
            'input' => $normalized,
        ];
        if ($dimensions !== null) {
            $data['dimensions'] = (int)$dimensions;
        }
        $postData = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        $headers = $this->appendIdempotencyHeader([
            'Content-Type: application/json',
            'Content-Length: ' . strlen($postData),
            'Authorization: Bearer ' . $this->getToken(),
        ], $options, $apiAddress);
        $extraOptions = [];
        if (isset($options['timeout']) && (int)$options['timeout'] > 0) {
            $extraOptions[CURLOPT_TIMEOUT] = max(1, min(180, (int)$options['timeout']));
        }
        if (isset($options['connect_timeout']) && (int)$options['connect_timeout'] > 0) {
            $extraOptions[CURLOPT_CONNECTTIMEOUT] = max(1, min(20, (int)$options['connect_timeout']));
        }
        $raw = $this->execCurl($this->buildCurlOptions($apiAddress, $postData, $headers, $extraOptions));
        $response = json_decode((string)$raw, true);
        $this->assertTransportResult($raw, $response);
        $this->throwProviderError($response);
        if (!is_array($response) || !isset($response['data']) || !is_array($response['data'])) {
            throw new ProviderException('Сервис embeddings вернул некорректный ответ.', 'invalid_provider_response', $this->lastHttpStatus);
        }
        usort($response['data'], function ($left, $right) {
            return (int)($left['index'] ?? 0) <=> (int)($right['index'] ?? 0);
        });
        $embeddings = [];
        foreach ($response['data'] as $item) {
            if (!isset($item['embedding']) || !is_array($item['embedding'])) {
                throw new ProviderException('Сервис embeddings вернул неполный ответ.', 'invalid_provider_response', $this->lastHttpStatus);
            }
            $embeddings[] = $item['embedding'];
        }
        if (count($embeddings) !== count($normalized)) {
            throw new ProviderException('Сервис embeddings вернул неожиданное число векторов.', 'invalid_provider_response', $this->lastHttpStatus);
        }
        return $embeddings;
    }

    public function supportsKnowledgeGrounding() {
        return false;
    }

    public function supportsGroundingSources() {
        return false;
    }

    public function getLastGrounding() {
        return $this->lastGrounding;
    }

    public function getLastProviderError() {
        return $this->lastProviderError;
    }

    protected function captureResponseMetadata($response) {
        $this->lastGrounding = null;
        if (is_array($response) && isset($response['easytokens']['grounding']) && is_array($response['easytokens']['grounding'])) {
            $this->lastGrounding = $response['easytokens']['grounding'];
        }
    }

    protected function throwProviderError($response) {
        $this->lastProviderError = null;
        if (!is_array($response) || empty($response['error']) || !is_array($response['error'])) {
            return;
        }
        $code = (string)($response['error']['code'] ?? 'provider_error');
        $message = (string)($response['error']['message'] ?? 'Сервис ИИ временно недоступен.');
        $this->lastProviderError = [
            'code' => $code,
            'http_status' => $this->lastHttpStatus,
        ];
        throw new ProviderException($message, $code, $this->lastHttpStatus);
    }

    



    private function assertTransportResult($raw, $response) {
        if ($raw === false || $this->lastCurlError !== '') {
            $timed_out = stripos($this->lastCurlError, 'timed out') !== false
                || stripos($this->lastCurlError, 'timeout') !== false;
            throw new ProviderException(
                $timed_out ? 'Сервис ИИ не успел ответить.' : 'Не удалось подключиться к сервису ИИ.',
                $timed_out ? 'provider_timeout' : 'provider_unavailable',
                503
            );
        }
        if ($this->lastHttpStatus >= 400) {
            $this->throwProviderError($response);
            $code = $this->lastHttpStatus === 429 ? 'rate_limited' : (
                $this->lastHttpStatus >= 500 ? 'provider_unavailable' : 'provider_error'
            );
            throw new ProviderException('Сервис ИИ вернул ошибку.', $code, $this->lastHttpStatus);
        }
    }

    public function parallel() {
    }

     
     
     

    private function img2text($message) {
        if ((!in_array($this->model, ['gpt-4o', 'gpt-4-turbo']) && strpos($this->model, 'vision') === false) || !isset($message['content']) || !is_string($message['content']))
            return $message;

        preg_match("/\[img=(.*?)\]/is", $message['content'], $finded);
        if ($finded) {
            list($substring, $image_url) = $finded;
            $image_url = trim(trim(trim($image_url, '"'), "'"));
            $text = str_replace($substring, '', $message['content']);
            $message['content'] = [
                [
                    'type' => 'text',
                    'text' => (string) $text,
                ],
                [
                    'type'      => 'image_url',
                    'image_url' => ['url' => (string) $image_url],
                ],
            ];
        }
        return $message;
    }
}
