<?php

declare(strict_types=1);

namespace App\Libraries;

use App\Models\ChatbotConfigModel;
use App\Models\DocumentChunkModel;
use App\Models\DocumentModel;
use RuntimeException;

class OpenAiService
{
    private string $apiKey;
    private string $embeddingModel;
    private string $chatModel;

    public static function isRealApiKey(string $v): bool
    {
        $v = trim($v);
        if ($v === '') return false;
        if (stripos($v, 'your-') === 0) return false;
        if (stripos($v, 'xxx') !== false) return false;
        if (stripos($v, 'example') !== false) return false;
        if (stripos($v, 'replace') !== false) return false;
        if (strpos($v, '*') !== false) return false;
        if (strlen($v) < 12) return false;
        return stripos($v, 'sk-') === 0;
    }

    public function __construct()
    {
        $chatbotConfig = new ChatbotConfigModel();

        $isRealKey = [self::class, 'isRealApiKey'];

        $dbKey  = (string) $chatbotConfig->getValue('openai_api_key', '');
        $envKey = (string) getenv('OPENAI_API_KEY');

        if ($isRealKey($dbKey)) {
            $this->apiKey = $dbKey;
        } elseif ($isRealKey($envKey)) {
            $this->apiKey = $envKey;
        } else {
            $this->apiKey = trim($dbKey) !== '' ? $dbKey : $envKey;
        }

        $embEnv    = (string) getenv('OPENAI_EMBEDDING_MODEL');
        $embDb     = (string) $chatbotConfig->getValue('openai_embedding_model', '');
        $this->embeddingModel = ($embDb !== '') ? $embDb : (($embEnv !== '') ? $embEnv : 'text-embedding-3-small');

        $chatEnv    = (string) getenv('OPENAI_CHAT_MODEL');
        $chatDb     = (string) $chatbotConfig->getValue('openai_chat_model', '');
        $chatModelName = (string) $chatbotConfig->getValue('model_name', '');
        $this->chatModel = ($chatModelName !== '') ? $chatModelName : (($chatDb !== '') ? $chatDb : (($chatEnv !== '') ? $chatEnv : 'gpt-4o-mini'));
    }

    public function isConfigured(): bool
    {
        return self::isRealApiKey($this->apiKey);
    }

    public function getApiKey(): string
    {
        return $this->apiKey;
    }

    public function getModels(): array
    {
        return [
            'embedding' => $this->embeddingModel,
            'chat'      => $this->chatModel,
        ];
    }

    public function createEmbedding(string $text): array
    {
        $result = $this->createEmbeddingBatch([$text]);
        if (!isset($result[0]) || !is_array($result[0])) {
            throw new RuntimeException('Embedding batch returned empty result for single text.');
        }
        return $result[0];
    }

    public function createEmbeddingBatch(array $texts): array
    {
        $texts = array_values($texts);
        if ($texts === []) {
            return [];
        }
        foreach ($texts as $i => $t) {
            if (!is_string($t)) {
                $texts[$i] = (string)$t;
            }
            if (strlen($t) > 30000) {
                $texts[$i] = substr($t, 0, 30000);
            }
        }

        $BATCH_SIZE = 64;
        $MAX_INPUT_TOKENS_APPROX = 7500 * $BATCH_SIZE; // rough safety cap (text-embedding-3-small: 8191 tokens/input, API has per-call total cap too)
        $totalChunks = count($texts);
        $final = [];

        for ($offset = 0; $offset < $totalChunks; $offset += $BATCH_SIZE) {
            $batch = array_slice($texts, $offset, $BATCH_SIZE, false);
            $batchLen = count($batch);
            $totalChars = 0;
            foreach ($batch as $b) $totalChars += strlen($b);
            if ($totalChars > $MAX_INPUT_TOKENS_APPROX * 3) {
                $subBatches = array_chunk($batch, (int)ceil($BATCH_SIZE / 2), false);
                foreach ($subBatches as $sb) {
                    $res = $this->createEmbeddingBatch($sb);
                    foreach ($res as $r) $final[] = $r;
                }
                continue;
            }

            $url = 'https://api.openai.com/v1/embeddings';
            $payload = json_encode([
                'model'           => $this->embeddingModel,
                'input'           => $batch,
                'encoding_format' => 'float',
            ]);
            if ($payload === false) {
                for ($i = 0; $i < $batchLen; $i++) $final[] = null;
                continue;
            }

            try {
                $response = $this->curlRequest($url, $payload);
                $data = json_decode($response, true);
                if (!isset($data['data']) || !is_array($data['data'])) {
                    for ($i = 0; $i < $batchLen; $i++) $final[] = null;
                    continue;
                }
                $byIndex = [];
                foreach ($data['data'] as $row) {
                    $idx = isset($row['index']) ? (int)$row['index'] : -1;
                    $emb = isset($row['embedding']) && is_array($row['embedding']) ? $row['embedding'] : null;
                    if ($idx >= 0 && $emb !== null) {
                        $byIndex[$idx] = $emb;
                    }
                }
                for ($i = 0; $i < $batchLen; $i++) {
                    $final[] = $byIndex[$i] ?? null;
                }
            } catch (\Throwable $e) {
                log_message('error', 'createEmbeddingBatch offset=' . $offset . ' size=' . $batchLen . ' failed: ' . $e->getMessage());
                for ($i = 0; $i < $batchLen; $i++) $final[] = null;
            }
        }

        return $final;
    }

    public function cosineSimilarity(array $a, array $b): float
    {
        $dotProduct = 0.0;
        $normA = 0.0;
        $normB = 0.0;

        $count = count($a);
        for ($i = 0; $i < $count; $i++) {
            $dotProduct += $a[$i] * $b[$i];
            $normA += $a[$i] * $a[$i];
            $normB += $b[$i] * $b[$i];
        }

        $normA = sqrt($normA);
        $normB = sqrt($normB);

        if ($normA === 0.0 || $normB === 0.0) {
            return 0.0;
        }

        return $dotProduct / ($normA * $normB);
    }

    public static function normalizeTitleKey(string $s): string
    {
        $s = (string) $s;
        if ($s === '') return '';
        $s = preg_replace('/[\s\-_.]+/u', ' ', $s);
        $s = preg_replace('/[^A-Za-z0-9 ]/u', '', $s);
        $s = (string) preg_replace('/\s{2,}/u', ' ', trim((string) $s));
        return function_exists('mb_strtolower') ? mb_strtolower($s, 'UTF-8') : strtolower($s);
    }

    public static function titleMatchesQuestion(string $question, string $docTitle): bool
    {
        $q = self::normalizeTitleKey($question);
        $t = self::normalizeTitleKey($docTitle);
        if ($q === '' || $t === '') return false;
        if (strpos($q, $t) !== false) return true;
        $tLen = strlen($t);
        if ($tLen < 8) return false;
        $words = preg_split('/\s+/u', $t, -1, PREG_SPLIT_NO_EMPTY);
        if (!is_array($words) || count($words) < 2) return false;
        $matches = 0;
        $required = min(count($words), max(2, (int) ceil(count($words) * 0.55)));
        foreach ($words as $w) {
            if (strlen($w) <= 2) continue;
            if (strpos($q, $w) !== false) $matches++;
            if ($matches >= $required) return true;
        }
        return false;
    }

    public function searchSimilarDocuments(string $query, int $topK = 5, ?int $currentDocId = null, ?string $visibility = null): array
    {
        $queryLower = function_exists('mb_strtolower') ? mb_strtolower(trim($query), 'UTF-8') : strtolower(trim($query));

        $broad = false;
        $keywords = [
            'summarize', 'summary', 'all key', 'knowledge base', 'talking point',
            'objection', 'rebuttal', 'pricing detail', 'key pricing',
            'qualifying question', 'lead question', 'everything', 'all document',
            'all info', 'overview', 'what are the most important', 'list any',
            'provide qualifying', 'overall', 'entire knowledge',
            'focus of the', 'primary focus', 'what is the', 'what are the',
            'cover', 'does the', 'according to the',
        ];
        foreach ($keywords as $kw) {
            if (strpos($queryLower, $kw) !== false) { $broad = true; break; }
        }

        $chunkModel = new DocumentChunkModel();
        $documentModel = new DocumentModel();

        $docVisibilityMap = [];
        $visibleDocRows = null;
        $docRowById = [];
        if ($visibility !== null) {
            $visibleDocRows = $documentModel
                ->where('visibility', $visibility)
                ->findAll();
        } else {
            $visibleDocRows = $documentModel->findAll();
        }
        if (is_array($visibleDocRows)) {
            foreach ($visibleDocRows as $r) {
                $rid = (int) ($r['id'] ?? 0);
                if ($rid <= 0) continue;
                $docVisibilityMap[$rid] = true;
                $docRowById[$rid] = $r;
            }
        }

        $scopedDocIds = [];
        if ($currentDocId !== null && $currentDocId > 0) {
            if (!isset($docVisibilityMap[$currentDocId])) {
                return [];
            }
            $scopedDocIds[$currentDocId] = true;
        }

        $titleMatchedDocIds = [];
        foreach ($docRowById as $rid => $r) {
            if ($scopedDocIds !== [] && !isset($scopedDocIds[$rid])) continue;
            $t = trim((string) ($r['title'] ?? ''));
            if ($t === '') continue;
            if (self::titleMatchesQuestion($query, $t)) {
                $titleMatchedDocIds[$rid] = true;
                $broad = true;
            }
        }

        $effectiveTopK = $broad ? max(50, $topK * 10) : max(24, $topK * 3);

        try {
            $queryEmbedding = $this->createEmbedding($query);
        } catch (\Throwable $e) {
            $queryEmbedding = null;
        }

        $allChunks = $chunkModel->getEmbeddings();

        $chunksByDoc = [];
        $scored = [];
        $embeddingsAvailable = is_array($queryEmbedding) && $queryEmbedding !== [];
        foreach ($allChunks as $chunk) {
            $chunkDocId = (int) ($chunk['document_id'] ?? 0);
            if (!isset($docVisibilityMap[$chunkDocId])) continue;
            if ($scopedDocIds !== [] && !isset($scopedDocIds[$chunkDocId])) continue;

            $chunksByDoc[$chunkDocId][] = $chunk;

            if (!$embeddingsAvailable) continue;
            if (empty($chunk['embedding'])) continue;
            $embedding = json_decode($chunk['embedding'], true);
            if (!is_array($embedding)) continue;

            $similarity = $this->cosineSimilarity($queryEmbedding, $embedding);
            $docBoost = 0.0;
            if ($currentDocId !== null && (int)$chunk['document_id'] === (int)$currentDocId) {
                $docBoost = 0.08;
            }
            if (isset($titleMatchedDocIds[$chunkDocId])) {
                $docBoost += 0.16;
            }
            $scored[] = [
                'chunk'       => $chunk,
                'document_id' => $chunkDocId,
                'similarity'  => $similarity + $docBoost,
            ];
        }

        $docCache = $docRowById;
        $results = [];
        $seenPairs = [];
        $chunksPerDocCap = $broad ? 30 : 12;
        $docChunkCount = [];

        if ($titleMatchedDocIds !== []) {
            ksort($titleMatchedDocIds);
            foreach ($titleMatchedDocIds as $mId => $unused) {
                if (!isset($chunksByDoc[$mId])) continue;
                $docChunks = $chunksByDoc[$mId];
                usort($docChunks, static function ($a, $b) {
                    $ca = (int) ($a['chunk_index'] ?? 0);
                    $cb = (int) ($b['chunk_index'] ?? 0);
                    return $ca <=> $cb;
                });
                $perDocCounter = 0;
                foreach ($docChunks as $mc) {
                    if ($perDocCounter >= $chunksPerDocCap) break;
                    if (count($results) >= $effectiveTopK) break 2;
                    $chunkId = (int)($mc['id'] ?? 0);
                    $chunkIdx = (int)($mc['chunk_index'] ?? 0);
                    $key = $chunkId . '_' . $mId . '_' . $chunkIdx;
                    if (isset($seenPairs[$key])) continue;
                    if (!isset($docCache[$mId])) {
                        $doc = $documentModel->find($mId);
                        if ($doc === null) { $docCache[$mId] = false; break; }
                        $docCache[$mId] = $doc;
                    }
                    if ($docCache[$mId] === false) break;
                    $results[] = [
                        'document'   => $docCache[$mId],
                        'chunk'      => $mc,
                        'similarity' => null,
                        'title_matched' => true,
                    ];
                    $seenPairs[$key] = true;
                    $countSoFar = $docChunkCount[$mId] ?? 0;
                    $docChunkCount[$mId] = $countSoFar + 1;
                    $perDocCounter++;
                }
            }
        }

        if ($scored !== []) {
            usort($scored, static function ($a, $b) {
                if ($a['similarity'] === $b['similarity']) {
                    $ca = (int) ($a['chunk']['chunk_index'] ?? 0);
                    $cb = (int) ($b['chunk']['chunk_index'] ?? 0);
                    return $ca <=> $cb;
                }
                return ($a['similarity'] < $b['similarity']) ? 1 : -1;
            });

            foreach ($scored as $item) {
                if (count($results) >= $effectiveTopK) break;
                $docId = (int) $item['document_id'];
                $countSoFar = $docChunkCount[$docId] ?? 0;
                if ($countSoFar >= $chunksPerDocCap) continue;

                if (!isset($docCache[$docId])) {
                    $doc = $documentModel->find($docId);
                    if ($doc === null) { $docCache[$docId] = false; continue; }
                    $docCache[$docId] = $doc;
                }
                if ($docCache[$docId] === false) continue;

                $chunkId = (int)($item['chunk']['id'] ?? 0);
                $chunkIdx = (int)($item['chunk']['chunk_index'] ?? 0);
                $key = $chunkId . '_' . $docId . '_' . $chunkIdx;
                if (isset($seenPairs[$key])) continue;

                $results[] = [
                    'document'   => $docCache[$docId],
                    'chunk'      => $item['chunk'],
                    'similarity' => $item['similarity'],
                ];
                $docChunkCount[$docId] = $countSoFar + 1;
                $seenPairs[$key] = true;
            }
        }

        if (count($results) < $effectiveTopK) {
            $orderedDocIds = [];
            if ($titleMatchedDocIds !== []) {
                foreach (array_keys($titleMatchedDocIds) as $mId) $orderedDocIds[$mId] = true;
            }
            foreach (array_keys($docVisibilityMap) as $vId) {
                if ($scopedDocIds !== [] && !isset($scopedDocIds[$vId])) continue;
                $orderedDocIds[$vId] = true;
            }
            $orderedDocIds = array_keys($orderedDocIds);

            foreach ($orderedDocIds as $paddedDocId) {
                if (count($results) >= $effectiveTopK) break;
                if (!isset($chunksByDoc[$paddedDocId])) continue;
                $padChunks = $chunksByDoc[$paddedDocId];
                usort($padChunks, static function ($a, $b) {
                    $ca = (int) ($a['chunk_index'] ?? 0);
                    $cb = (int) ($b['chunk_index'] ?? 0);
                    return $ca <=> $cb;
                });
                foreach ($padChunks as $pc) {
                    if (count($results) >= $effectiveTopK) break 2;
                    $chunkDocId = (int) ($pc['document_id'] ?? 0);
                    $chunkId = (int)($pc['id'] ?? 0);
                    $chunkIdx = (int)($pc['chunk_index'] ?? 0);
                    $key = $chunkId . '_' . $chunkDocId . '_' . $chunkIdx;
                    if (isset($seenPairs[$key])) continue;
                    $countSoFar = $docChunkCount[$chunkDocId] ?? 0;
                    if ($countSoFar >= $chunksPerDocCap) continue;
                    if (!isset($docCache[$chunkDocId])) {
                        $doc = $documentModel->find($chunkDocId);
                        if ($doc === null) { $docCache[$chunkDocId] = false; continue; }
                        $docCache[$chunkDocId] = $doc;
                    }
                    if ($docCache[$chunkDocId] === false) continue;
                    $results[] = [
                        'document'   => $docCache[$chunkDocId],
                        'chunk'      => $pc,
                        'similarity' => null,
                        'padded'     => true,
                    ];
                    $docChunkCount[$chunkDocId] = $countSoFar + 1;
                    $seenPairs[$key] = true;
                }
            }
        }

        return $results;
    }

    public function chatCompletions(array $messages, float $temperature = 0.7): string
    {
        $url = 'https://api.openai.com/v1/chat/completions';
        $payload = json_encode([
            'model'       => $this->chatModel,
            'messages'    => $messages,
            'temperature' => $temperature,
        ]);

        $response = $this->curlRequest($url, $payload);
        $data = json_decode($response, true);

        if (!isset($data['choices'][0]['message']['content'])) {
            throw new RuntimeException('Chat completion content not found in API response: ' . json_encode($data));
        }

        return (string) $data['choices'][0]['message']['content'];
    }

    public function generateFollowUpQuestions(string $userQuestion, string $assistantAnswer, array $contextTitlesOrRefs, int $count = 3): array
    {
        if (!$this->isConfigured()) {
            return [];
        }
        $count = max(1, min(6, $count));

        $refs = [];
        foreach (array_values($contextTitlesOrRefs) as $i => $ref) {
            if (is_string($ref)) {
                $refs[] = '- ' . ($i + 1) . '. ' . $ref;
            } elseif (is_array($ref)) {
                $title = (string) ($ref['title'] ?? $ref['document_title'] ?? ($ref['document']['title'] ?? 'Document'));
                $refs[] = '- ' . ($i + 1) . '. ' . $title . (!empty($ref['score']) ? ' (score=' . round((float)$ref['score'], 3) . ')' : '');
            }
        }
        $refsBlock = $refs !== [] ? implode("\n", array_slice($refs, 0, 10)) : '(no context sources)';

        $answerSnippet = mb_substr(trim($assistantAnswer), 0, 1200);
        if (mb_strlen($assistantAnswer) > 1200) $answerSnippet .= ' [truncated]';
        $questionSnippet = mb_substr(trim($userQuestion), 0, 220);

        $systemPrompt = 'You generate follow-up questions for a RAG chatbot over a knowledge base. Rules:'
            . "\n" . '- Produce EXACTLY ' . $count . ' questions. No intro, no outro, no commentary, no markdown.'
            . "\n" . '- Each question must be 5-25 words, natural English, answerable from the SAME document/knowledge base.'
            . "\n" . '- Questions must be a genuine logical next-step after the exchange (not repeats of prior question, not generic, not meta "did that help").'
            . "\n" . '- Prefer: probing the answer detail, asking for next steps/process, asking for examples/scripts/templates/rebuttals, asking objections/pricing/qualifying, asking comparisons.'
            . "\n" . '- If the last answer said "answer not in KB / no context", propose scoped clarifying questions the user should ask instead.'
            . "\n" . '- Output format #1 (preferred): valid JSON array of strings, e.g. ["q1","q2","q3"]'
            . "\n" . '- Output format #2 (fallback): numbered lines like "1. question" / "- question".';

        $userPrompt = 'Prior user question: ' . ($questionSnippet !== '' ? $questionSnippet : '(none)')
            . "\n\n" . 'Assistant answer (first ~1200 chars):'
            . "\n" . $answerSnippet
            . "\n\n" . 'Context sources used for this answer:'
            . "\n" . $refsBlock
            . "\n\n" . 'Return EXACTLY ' . $count . ' diverse follow-up questions.';

        try {
            $text = $this->chatCompletions([
                ['role' => 'system', 'content' => $systemPrompt],
                ['role' => 'user',   'content' => $userPrompt],
            ], 0.25);
        } catch (\Throwable $e) {
            return [];
        }

        $cleaned = trim((string) $text);
        $out = [];

        if (preg_match('/\[[\s\S]*\]/', $cleaned, $m)) {
            $arr = json_decode($m[0], true);
            if (is_array($arr)) {
                foreach ($arr as $q) {
                    $q = is_string($q) ? trim($q, " \t\n\r\0\x0B-*\"'`") : '';
                    if ($q !== '' && mb_strlen($q) >= 4 && mb_strlen($q) <= 180) $out[] = $q;
                }
            }
        }

        if (count($out) < $count) {
            $lines = preg_split('/\r\n|\r|\n/', $cleaned);
            foreach ($lines as $line) {
                $line = trim((string) $line);
                if ($line === '' || $line === '[' || $line === ']') continue;
                $line = preg_replace('/^\s*(\d+\.|[\-\*\+\•])\s*/u', '', $line);
                $line = trim($line, " \t\n\r\0\x0B-*\"'`");
                if ($line === '' || mb_strlen($line) < 4 || mb_strlen($line) > 200) continue;
                if (preg_match('/^(here are|following|questions|follow|sure|certainly|note\:|rules|output)/i', $line)) continue;
                if (in_array($line, $out, true)) continue;
                $out[] = $line;
                if (count($out) >= $count + 2) break;
            }
        }

        $deduped = [];
        $seenQ = [];
        foreach ($out as $q) {
            $norm = preg_replace('/\s+/u', ' ', mb_strtolower(trim($q)));
            if (isset($seenQ[$norm])) continue;
            $seenQ[$norm] = true;
            $deduped[] = $q;
        }
        return array_slice($deduped, 0, $count);
    }

    public function summarizeDocument(string $contentText): string
    {
        $truncated = substr($contentText, 0, 12000);

        $messages = [
            [
                'role'    => 'system',
                'content' => 'You are a document summarization expert. Create a concise, comprehensive summary covering all key points.',
            ],
            [
                'role'    => 'user',
                'content' => "Summarize this document:\n\n" . $truncated,
            ],
        ];

        return $this->chatCompletions($messages, 0.5);
    }

    public function generateQuestions(string $summary, int $count = 3): array
    {
        $messages = [
            [
                'role'    => 'system',
                'content' => 'You are a question generation expert. Output ONLY a valid JSON array of strings. No extra text, no markdown, no explanations.',
            ],
            [
                'role'    => 'user',
                'content' => "Generate {$count} insightful questions that a sales agent might ask about the following document summary. Output ONLY a JSON array like [\"q1?\",\"q2?\",\"q3?\"].\n\nSummary:\n" . $summary,
            ],
        ];

        $response = $this->chatCompletions($messages, 0.7);
        $response = trim($response);

        $jsonStart = strpos($response, '[');
        $jsonEnd = strrpos($response, ']');
        if ($jsonStart !== false && $jsonEnd !== false) {
            $response = substr($response, $jsonStart, $jsonEnd - $jsonStart + 1);
        }

        $decoded = json_decode($response, true);
        if (!is_array($decoded)) {
            $questions = [];
            $lines = explode("\n", trim($response));
            foreach ($lines as $line) {
                $line = trim($line);
                if ($line === '') {
                    continue;
                }
                $line = preg_replace('/^\s*[\d\.\)\-\*]+\s*/', '', $line);
                if ($line !== '') {
                    $questions[] = $line;
                }
            }
            return array_slice($questions, 0, $count);
        }

        $cleaned = [];
        foreach ($decoded as $item) {
            if (is_string($item) && trim($item) !== '') {
                $cleaned[] = $item;
            }
        }
        return array_slice($cleaned, 0, $count);
    }

    public function testKey(?string $overrideApiKey = null, ?string $chatModel = null): array
    {
        $apiKey = (string) ($overrideApiKey ?? $this->apiKey);

        $maskKey = static function (string $k): string {
            $k = trim($k);
            if ($k === '') return '(empty)';
            if (strlen($k) <= 10) return substr($k, 0, 2) . '…' . substr($k, -2);
            return substr($k, 0, 6) . '…' . substr($k, -5);
        };

        $scrub = static function (string $msg) use ($apiKey, $maskKey): string {
            if ($apiKey === '') return $msg;
            $variants = array_unique([
                $apiKey,
                trim($apiKey),
                str_replace(' ', '', $apiKey),
            ]);
            $safe = $maskKey($apiKey);
            foreach ($variants as $raw) {
                if ($raw === '') continue;
                $len = strlen($raw);
                if ($len <= 0) continue;
                if (strlen($msg) === 0) continue;
                if (strpos($msg, $raw) !== false) {
                    $msg = str_replace($raw, $safe, $msg);
                }
                if ($len > 12) {
                    for ($winsize = 8; $winsize <= max(8, min(20, $len)); $winsize++) {
                        $sub = substr($raw, 0, $winsize);
                        if ($sub !== '' && strpos($msg, $sub) !== false && strpos($safe, $sub) === false) {
                            $msg = str_replace($sub, $safe, $msg);
                        }
                        $sub = substr($raw, -$winsize);
                        if ($sub !== '' && strpos($msg, $sub) !== false && strpos($safe, $sub) === false) {
                            $msg = str_replace($sub, $safe, $msg);
                        }
                    }
                }
            }
            return $msg;
        };

        if ($apiKey === '') {
            return [
                'ok'          => false,
                'message'     => 'OpenAI API key is empty. Copy your key from the OpenAI dashboard and paste it into Chatbot Configuration.',
                'models'      => null,
                'used_model_ok' => false,
                'used_model'  => (string) ($chatModel ?? $this->chatModel),
            ];
        }
        $chatModel = $chatModel ?? $this->chatModel;

        try {
            $modelsUrl = 'https://api.openai.com/v1/models';
            $ch = curl_init($modelsUrl);
            if ($ch === false) {
                throw new \RuntimeException('Failed to initialize cURL for /v1/models.');
            }
            curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
            curl_setopt($ch, CURLOPT_HTTPHEADER, [
                'Authorization: Bearer ' . $apiKey,
            ]);
            curl_setopt($ch, CURLOPT_TIMEOUT, 20);
            curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
            curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);

            $modelsResp = curl_exec($ch);
            $httpCode   = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
            $curlErr    = curl_error($ch);
            curl_close($ch);

            if ($modelsResp === false) {
                $netMsg = 'Network error while talking to OpenAI: ' . ($curlErr !== '' ? $curlErr : 'cURL returned empty response.');
                if (stripos($netMsg, 'timed out') !== false || stripos($netMsg, 'timeout') !== false) {
                    $netMsg .= ' This can be caused by a firewall blocking outbound HTTPS, DNS failure, or proxy. Ask your host to allow traffic to api.openai.com:443.';
                } elseif (stripos($netMsg, 'ssl') !== false || stripos($netMsg, 'certificate') !== false) {
                    $netMsg .= ' Your PHP curl.cainfo / openssl.cafile may be missing or out of date. Ask your host to update the CA bundle in php.ini.';
                }
                return [
                    'ok'          => false,
                    'message'     => $netMsg,
                    'models'      => null,
                    'used_model_ok' => false,
                    'used_model'  => $chatModel,
                ];
            }

            $parsed = json_decode((string)$modelsResp, true);
            if (!is_array($parsed)) {
                $snippet = trim(substr((string)$modelsResp, 0, 300));
                if ($httpCode >= 301 && $httpCode <= 308) {
                    return [
                        'ok'          => false,
                        'message'     => 'OpenAI redirected (HTTP ' . $httpCode . ') instead of returning JSON. Check your base URL / network proxy / DNS. Raw start: ' . $snippet,
                        'models'      => null,
                        'used_model_ok' => false,
                        'used_model'  => $chatModel,
                    ];
                }
                return [
                    'ok'          => false,
                    'message'     => 'OpenAI returned a non-JSON response (HTTP ' . $httpCode . '): ' . ($snippet !== '' ? $snippet : '(empty body)'),
                    'models'      => null,
                    'used_model_ok' => false,
                    'used_model'  => $chatModel,
                ];
            }

            if ($httpCode === 401) {
                $msg = (string) ($parsed['error']['message'] ?? 'The API key was rejected (HTTP 401 Invalid authentication).');
                $code = (string) ($parsed['error']['code'] ?? '');
                $msg = $scrub($msg);
                $hint = '';
                if ($code === 'invalid_api_key' || stripos($msg, 'incorrect api key') !== false || stripos($msg, 'rejected') !== false) {
                    $hint = ' Incorrect API key provided: ' . $maskKey($apiKey) . '. You can find your API key at https://platform.openai.com/account/api-keys. Double-check the key starts with "sk-" or "sk-proj-", has no leading/trailing spaces, and has not been revoked. If the key is a Project key, ensure the project has credit and usage limits enabled.';
                } elseif ($code === 'insufficient_quota' || stripos($msg, 'quota') !== false || stripos($msg, 'billing') !== false || stripos($msg, 'credit') !== false) {
                    $hint = ' Your OpenAI account has run out of credits or your free tier is expired. Add a payment method at https://platform.openai.com/settings/billing to restore access.';
                } elseif (stripos($code, 'project') !== false || stripos($msg, 'project') !== false) {
                    $hint = ' The key is scoped to a Project that is disabled, out of credit, or has usage limits set to 0. Visit the project settings in your OpenAI dashboard.';
                } else {
                    $hint = ' Double-check the key starts with "sk-" or "sk-proj-", has no leading/trailing spaces, and has not been revoked. If the key is a Project key, ensure the project has credit and usage limits enabled.';
                }
                return [
                    'ok'          => false,
                    'message'     => rtrim($msg . $hint),
                    'models'      => null,
                    'used_model_ok' => false,
                    'used_model'  => $chatModel,
                ];
            }

            if ($httpCode === 403) {
                $msg = (string) ($parsed['error']['message'] ?? 'Access forbidden (HTTP 403).');
                $msg = $scrub($msg);
                return [
                    'ok'          => false,
                    'message'     => $msg . ' Your API key may be restricted by IP allowlist, organization policy, or Terms-of-Service suspension.',
                    'models'      => null,
                    'used_model_ok' => false,
                    'used_model'  => $chatModel,
                ];
            }

            if ($httpCode === 429) {
                $msg = (string) ($parsed['error']['message'] ?? 'OpenAI rate-limit hit (HTTP 429).');
                $code = (string) ($parsed['error']['code'] ?? '');
                $msg = $scrub($msg);
                if (stripos($msg, 'quota') !== false || $code === 'insufficient_quota' || stripos($code, 'quota') !== false) {
                    $msg .= ' Your account has no remaining credit. Add billing at your OpenAI dashboard: https://platform.openai.com/settings/billing .';
                } elseif (stripos($msg, 'rate') !== false) {
                    $msg .= ' You have hit a temporary requests-per-minute or tokens-per-minute cap. Wait 30 seconds and retry.';
                } else {
                    $msg .= ' Wait a moment and retry. If this persists, your usage limits may be capped in the OpenAI dashboard.';
                }
                return [
                    'ok'          => false,
                    'message'     => $msg,
                    'models'      => null,
                    'used_model_ok' => false,
                    'used_model'  => $chatModel,
                ];
            }

            if ($httpCode >= 500) {
                $msg = (string) ($parsed['error']['message'] ?? 'OpenAI server error (HTTP ' . $httpCode . ').');
                $msg = $scrub($msg);
                return [
                    'ok'          => false,
                    'message'     => $msg . ' This is an OpenAI-side issue. Check https://status.openai.com and retry in a few minutes.',
                    'models'      => null,
                    'used_model_ok' => false,
                    'used_model'  => $chatModel,
                ];
            }

            if ($httpCode < 200 || $httpCode >= 300) {
                $msg = (string) ($parsed['error']['message'] ?? 'Unexpected HTTP ' . $httpCode . ' from OpenAI.');
                $msg = $scrub($msg);
                return [
                    'ok'          => false,
                    'message'     => $msg,
                    'models'      => null,
                    'used_model_ok' => false,
                    'used_model'  => $chatModel,
                ];
            }

            $modelsList = [];
            $hasChatModel = false;
            if (is_array($parsed['data'] ?? null)) {
                foreach ($parsed['data'] as $m) {
                    $id = (string) ($m['id'] ?? '');
                    if ($id === '') continue;
                    $modelsList[] = $id;
                    if (strcasecmp($id, $chatModel) === 0) $hasChatModel = true;
                }
            }

            if (!$hasChatModel && count($modelsList) > 0) {
                $lmodel = strtolower($chatModel);
                foreach ($modelsList as $m) {
                    if (stripos($m, $lmodel) === 0 || strcasecmp($m, $chatModel) === 0) {
                        $hasChatModel = true;
                        break;
                    }
                }
            }

            $usedModelOk = $hasChatModel;
            $message = sprintf(
                'Key is valid (HTTP 200). Found %d models available under this key.',
                count($modelsList)
            );
            if ($usedModelOk) {
                $message .= sprintf(' Selected chat model "%s" is supported by this key.', $chatModel);
            } else {
                $message .= sprintf(
                    ' WARNING: Selected chat model "%s" was not found in the list of available models for this key. The key may lack access to gpt-4o or you are on a tier that only includes legacy models. Using a supported fallback like gpt-4o-mini is recommended.',
                    $chatModel
                );
            }

            return [
                'ok'            => true,
                'message'       => $message,
                'models'        => array_slice($modelsList, 0, 40),
                'used_model_ok' => $usedModelOk,
                'used_model'    => $chatModel,
            ];
        } catch (\Throwable $e) {
            $raw = $e->getMessage();
            $cleaned = $scrub($raw !== '' ? $raw : get_class($e));
            return [
                'ok'          => false,
                'message'     => 'Test failed with exception: ' . $cleaned,
                'models'      => null,
                'used_model_ok' => false,
                'used_model'  => $chatModel,
            ];
        }
    }

    private function curlRequest(string $url, string $payload, int $timeoutSeconds = 0): string
    {
        if ($this->apiKey === '') {
            throw new RuntimeException('OpenAI API key is not configured.');
        }

        $ch = curl_init($url);
        if ($ch === false) {
            throw new RuntimeException('Failed to initialize cURL.');
        }

        if ($timeoutSeconds <= 0) {
            if (stripos($url, '/embeddings') !== false) {
                $timeoutSeconds = 300;
            } else {
                $timeoutSeconds = 120;
            }
        }

        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Content-Type: application/json',
            'Authorization: Bearer ' . $this->apiKey,
        ]);
        curl_setopt($ch, CURLOPT_TIMEOUT, $timeoutSeconds);
        curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 20);
        curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
        curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);

        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        $error = curl_error($ch);
        curl_close($ch);

        if ($response === false) {
            throw new RuntimeException('cURL error: ' . $error);
        }

        if ($httpCode < 200 || $httpCode >= 300) {
            throw new RuntimeException("OpenAI API request failed (HTTP {$httpCode}): {$response}");
        }

        return (string) $response;
    }
}
