<?php

declare(strict_types=1);

namespace App\Libraries;

use App\Models\VapiConfigModel;
use Throwable;

class VapiService
{
    private string $apiKey = '';
    private string $baseUrl = 'https://api.vapi.ai';
    private ?bool $guzzleAvailable = null;

    public function __construct()
    {
        try {
            $cfg = new VapiConfigModel();
            $this->apiKey = (string) $cfg->getValue('api_key', '');
        } catch (Throwable $e) {
            $this->apiKey = '';
        }
    }

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

    public function setApiKey(string $k): bool
    {
        try {
            $cfg = new VapiConfigModel();
            $result = $cfg->setValue('api_key', trim($k));
            if ($result) {
                $this->apiKey = trim($k);
            }
            return $result;
        } catch (Throwable $e) {
            return false;
        }
    }

    private function isGuzzleAvailable(): bool
    {
        if ($this->guzzleAvailable === null) {
            $this->guzzleAvailable = class_exists(\GuzzleHttp\Client::class);
        }
        return $this->guzzleAvailable;
    }

    private function httpGet(string $url, string $bearerToken = '', array $headers = []): array
    {
        $allHeaders = $headers;
        if ($bearerToken !== '') {
            $allHeaders[] = 'Authorization: Bearer ' . $bearerToken;
        }
        $allHeaders[] = 'Accept: application/json';

        if ($this->isGuzzleAvailable()) {
            return $this->guzzleGet($url, $allHeaders);
        }
        return $this->fgcGet($url, $allHeaders);
    }

    private function guzzleGet(string $url, array $headers): array
    {
        try {
            $client = new \GuzzleHttp\Client([
                'timeout' => 30,
                'verify' => true,
            ]);
            $headerAssoc = [];
            foreach ($headers as $h) {
                $parts = explode(':', $h, 2);
                if (count($parts) === 2) {
                    $headerAssoc[trim($parts[0])] = trim($parts[1]);
                }
            }
            $response = $client->get($url, ['headers' => $headerAssoc]);
            $status = $response->getStatusCode();
            $body = (string) $response->getBody();
            $data = json_decode($body, true);
            if ($status >= 200 && $status < 300) {
                return ['ok' => true, 'status' => $status, 'body' => $body, 'data' => $data];
            }
            return ['ok' => false, 'status' => $status, 'body' => $body, 'data' => $data, 'error' => 'HTTP ' . $status];
        } catch (Throwable $e) {
            $status = 0;
            if (method_exists($e, 'getResponse') && $e->getResponse() !== null) {
                $status = $e->getResponse()->getStatusCode();
                $body = (string) $e->getResponse()->getBody();
                return ['ok' => false, 'status' => $status, 'body' => $body, 'data' => json_decode($body, true), 'error' => $e->getMessage()];
            }
            return ['ok' => false, 'status' => $status, 'body' => '', 'data' => null, 'error' => $e->getMessage()];
        }
    }

    private function fgcGet(string $url, array $headers): array
    {
        try {
            $context = stream_context_create([
                'http' => [
                    'method' => 'GET',
                    'header' => implode("\r\n", $headers),
                    'timeout' => 30,
                    'ignore_errors' => true,
                ],
                'ssl' => [
                    'verify_peer' => true,
                    'verify_peer_name' => true,
                ],
            ]);
            $body = @file_get_contents($url, false, $context);
            $status = 0;
            if (isset($http_response_header) && is_array($http_response_header)) {
                foreach ($http_response_header as $h) {
                    if (preg_match('/^HTTP\/\S+\s+(\d+)/', $h, $m)) {
                        $status = (int) $m[1];
                    }
                }
            }
            if ($body === false) {
                return ['ok' => false, 'status' => $status, 'body' => '', 'data' => null, 'error' => 'file_get_contents failed'];
            }
            $data = json_decode($body, true);
            if ($status >= 200 && $status < 300) {
                return ['ok' => true, 'status' => $status, 'body' => $body, 'data' => $data];
            }
            return ['ok' => false, 'status' => $status, 'body' => $body, 'data' => $data, 'error' => 'HTTP ' . $status];
        } catch (Throwable $e) {
            return ['ok' => false, 'status' => 0, 'body' => '', 'data' => null, 'error' => $e->getMessage()];
        }
    }

    public function testConnection(string $key = ''): array
    {
        $useKey = $key !== '' ? trim($key) : $this->apiKey;
        if ($useKey === '') {
            return ['ok' => false, 'msg' => 'Vapi API key is not configured.'];
        }

        try {
            $resp = $this->httpGet($this->baseUrl . '/assistant', $useKey);
            if ($resp['ok']) {
                $count = is_array($resp['data']) ? count($resp['data']) : 0;
                return [
                    'ok' => true,
                    'msg' => 'Connection successful. Found ' . $count . ' assistant(s).',
                    'data' => ['assistants_count' => $count, 'sample' => is_array($resp['data']) ? array_slice($resp['data'], 0, 3) : null],
                ];
            }
            $errMsg = $resp['error'] ?? 'Unknown error';
            if (!empty($resp['data']['message'])) {
                $errMsg .= ' - ' . $resp['data']['message'];
            } elseif (!empty($resp['data']['error']['message'])) {
                $errMsg .= ' - ' . $resp['data']['error']['message'];
            }
            return ['ok' => false, 'msg' => 'Connection failed: ' . $errMsg, 'data' => ['status' => $resp['status']]];
        } catch (Throwable $e) {
            return ['ok' => false, 'msg' => 'Connection failed: ' . $e->getMessage()];
        }
    }

    public function listAssistants(): array
    {
        if ($this->apiKey === '') {
            return ['ok' => false, 'data' => [], 'msg' => 'Vapi API key is not configured.'];
        }

        try {
            $resp = $this->httpGet($this->baseUrl . '/assistant', $this->apiKey);
            if ($resp['ok']) {
                $data = is_array($resp['data']) ? $resp['data'] : [];
                return ['ok' => true, 'data' => $data];
            }
            $errMsg = $resp['error'] ?? 'Unknown error';
            if (!empty($resp['data']['message'])) {
                $errMsg .= ' - ' . $resp['data']['message'];
            }
            return ['ok' => false, 'data' => [], 'msg' => 'Failed to list assistants: ' . $errMsg];
        } catch (Throwable $e) {
            return ['ok' => false, 'data' => [], 'msg' => 'Failed to list assistants: ' . $e->getMessage()];
        }
    }

    public function listCalls(array $query = []): array
    {
        if ($this->apiKey === '') {
            return ['ok' => false, 'data' => [], 'msg' => 'Vapi API key is not configured.'];
        }

        try {
            $url = $this->baseUrl . '/call';
            if (!empty($query)) {
                $qs = http_build_query($query);
                if ($qs !== '') {
                    $url .= '?' . $qs;
                }
            }
            $resp = $this->httpGet($url, $this->apiKey);
            if ($resp['ok']) {
                $data = is_array($resp['data']) ? $resp['data'] : [];
                return ['ok' => true, 'data' => $data];
            }
            $errMsg = $resp['error'] ?? 'Unknown error';
            if (!empty($resp['data']['message'])) {
                $errMsg .= ' - ' . $resp['data']['message'];
            }
            return ['ok' => false, 'data' => [], 'msg' => 'Failed to list calls: ' . $errMsg];
        } catch (Throwable $e) {
            return ['ok' => false, 'data' => [], 'msg' => 'Failed to list calls: ' . $e->getMessage()];
        }
    }

    public function scoreCallAgainstDocs(
        string $transcript,
        string $callSummary,
        array $scorecardCategories,
        string $basisDocsConcatenatedText,
        ?array $docIdsForRag = null
    ): array {
        $validCategories = [];
        foreach ($scorecardCategories as $cat) {
            if (!is_array($cat)) continue;
            if (!isset($cat['category']) || !isset($cat['points'])) continue;
            $validCategories[] = [
                'category'            => (string) $cat['category'],
                'points'              => (float) $cat['points'],
                'fullCreditBehavior'  => (string) ($cat['fullCreditBehavior'] ?? ''),
            ];
        }

        if ($validCategories === []) {
            return [
                'ok' => false,
                'score_pct' => 0,
                'breakdown' => [],
                'ai_feedback' => '',
                'msg' => 'No valid scorecard categories provided.',
            ];
        }

        $totalMaxPoints = 0.0;
        foreach ($validCategories as $cat) {
            $totalMaxPoints += $cat['points'];
        }
        if ($totalMaxPoints <= 0) {
            return [
                'ok' => false,
                'score_pct' => 0,
                'breakdown' => [],
                'ai_feedback' => '',
                'msg' => 'Total scorecard points must be greater than zero.',
            ];
        }

        try {
            $openAi = new OpenAiService();
            $llmReady = $openAi->isConfigured();
            if ($llmReady) {
                $res = $this->scoreWithLLM($openAi, $transcript, $callSummary, $validCategories, $basisDocsConcatenatedText, $totalMaxPoints, $docIdsForRag);
                $res['breakdown'] = $this->augmentBreakdownWithFallbacks($res['breakdown'] ?? [], $validCategories, $transcript, $docIdsForRag);
                return $res;
            }
            $res = $this->scoreKeywordFallback($transcript, $validCategories, $totalMaxPoints);
            $res['breakdown'] = $this->augmentBreakdownWithFallbacks($res['breakdown'] ?? [], $validCategories, $transcript, $docIdsForRag);
            return $res;
        } catch (Throwable $e) {
            $res = $this->scoreKeywordFallback($transcript, $validCategories, $totalMaxPoints);
            $res['breakdown'] = $this->augmentBreakdownWithFallbacks($res['breakdown'] ?? [], $validCategories, $transcript, $docIdsForRag);
            return $res;
        }
    }

    private function scoreWithLLM(
        OpenAiService $openAi,
        string $transcript,
        string $callSummary,
        array $validCategories,
        string $basisDocsConcatenatedText,
        float $totalMaxPoints,
        ?array $docIdsForRag = null
    ): array {
        $scorecardJson = json_encode($validCategories, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

        $systemPrompt = 'You are an expert call quality auditor. Score a sales/service call strictly against a scorecard, using BASIS DOCUMENTS as the source of truth for what "correct" looks like. '
            . "\n" . 'RULES:'
            . "\n" . '1. Only award points for behavior EVIDENT in the TRANSCRIPT. Do not guess or assume.'
            . "\n" . '2. For partial performance, award proportional points (0 to max for that category).'
            . "\n" . '3. For each category: give a short rationale (1-2 sentences, cite specific transcript lines if possible).'
            . "\n" . '4. For each category, include transcript_quote: the EXACT, verbatim line(s) from the CALL TRANSCRIPT that best support your rationale. If none, set to empty string. Keep under ~350 chars.'
            . "\n" . '5. For each category, include best_answer_from_docs: a concrete, training-doc-aligned 1-4 sentence script/template of what an agent SHOULD say or do for this category based on the BASIS DOCUMENTS. If docs are empty, put best-practice model answer from your general SDR knowledge. Keep concise.'
            . "\n" . '6. Give overall AI feedback (3-5 sentences: strengths, gaps, specific actionable coaching tips).'
            . "\n" . '7. Output ONLY strict valid JSON, no markdown, no extra text, no code fences. Use exactly this shape:'
            . "\n" . '{"score_pct": 0-100 number, "breakdown": [{"category":"string","points_awarded":number,"points_max":number,"rationale":"string","transcript_quote":"string","best_answer_from_docs":"string"}], "ai_feedback":"string"}';

        $basisSnippet = mb_strlen($basisDocsConcatenatedText) > 8000
            ? mb_substr($basisDocsConcatenatedText, 0, 8000) . ' [truncated]'
            : $basisDocsConcatenatedText;
        $transcriptSnippet = mb_strlen($transcript) > 12000
            ? mb_substr($transcript, 0, 12000) . ' [truncated]'
            : $transcript;
        $summarySnippet = mb_strlen($callSummary) > 2000
            ? mb_substr($callSummary, 0, 2000) . ' [truncated]'
            : $callSummary;

        $userPrompt = 'SCORECARD (categories, max points, full-credit behavior description):'
            . "\n" . $scorecardJson
            . "\n\n" . 'BASIS DOCUMENTS (source of truth for correct behavior, best answer, SDR scripts):'
            . "\n" . ($basisSnippet !== '' ? $basisSnippet : '(no basis docs provided — use general SDR best-practice training knowledge)')
            . "\n\n" . 'CALL SUMMARY:'
            . "\n" . ($summarySnippet !== '' ? $summarySnippet : '(no summary)')
            . "\n\n" . 'CALL TRANSCRIPT (transcript_quote must be verbatim from this text or empty):'
            . "\n" . ($transcriptSnippet !== '' ? $transcriptSnippet : '(no transcript)')
            . "\n\n" . 'Return ONLY strict valid JSON with score_pct (0-100), breakdown array (each with transcript_quote + best_answer_from_docs), and ai_feedback string.';

        try {
            $rawText = $openAi->chatCompletions([
                ['role' => 'system', 'content' => $systemPrompt],
                ['role' => 'user',   'content' => $userPrompt],
            ], 0.15);

            $cleaned = trim($rawText);
            if (preg_match('/\{[\s\S]*\}/', $cleaned, $m)) {
                $cleaned = $m[0];
            }
            $parsed = json_decode($cleaned, true);

            if (!is_array($parsed)) {
                return $this->scoreKeywordFallback($transcript, $validCategories, $totalMaxPoints);
            }

            $breakdown = [];
            $totalAwarded = 0.0;
            $catByName = [];
            foreach ($validCategories as $vc) {
                $catByName[$vc['category']] = $vc;
            }

            $rawBreakdown = $parsed['breakdown'] ?? [];
            if (!is_array($rawBreakdown)) $rawBreakdown = [];

            $breakdownByCat = [];
            foreach ($rawBreakdown as $rb) {
                if (!is_array($rb)) continue;
                $catName = (string) ($rb['category'] ?? '');
                if ($catName === '') continue;
                $breakdownByCat[$catName] = $rb;
            }

            foreach ($validCategories as $vc) {
                $name = $vc['category'];
                $maxPts = $vc['points'];
                $awarded = 0.0;
                $rationale = '';
                $quote = '';
                $bestAnswer = '';
                if (isset($breakdownByCat[$name])) {
                    $rb = $breakdownByCat[$name];
                    $awarded = isset($rb['points_awarded']) ? (float) $rb['points_awarded'] : 0.0;
                    $rationale = (string) ($rb['rationale'] ?? '');
                    $quote = (string) ($rb['transcript_quote'] ?? '');
                    $bestAnswer = (string) ($rb['best_answer_from_docs'] ?? '');
                    if (isset($rb['points_max'])) {
                        $maxPts = (float) $rb['points_max'];
                    }
                }
                if ($awarded < 0) $awarded = 0;
                if ($awarded > $maxPts) $awarded = $maxPts;
                if ($rationale === '') {
                    $rationale = 'Scored by LLM auditor.';
                }
                $breakdown[] = [
                    'category'              => $name,
                    'points_awarded'        => $awarded,
                    'points_max'            => $maxPts,
                    'rationale'             => $rationale,
                    'transcript_quote'      => $quote,
                    'best_answer_from_docs' => $bestAnswer,
                ];
                $totalAwarded += $awarded;
            }

            $scorePct = $totalMaxPoints > 0 ? (int) round(($totalAwarded / $totalMaxPoints) * 100) : 0;
            if ($scorePct < 0) $scorePct = 0;
            if ($scorePct > 100) $scorePct = 100;

            $aiFeedback = (string) ($parsed['ai_feedback'] ?? '');
            if (isset($parsed['score_pct']) && is_numeric($parsed['score_pct'])) {
                $llmScorePct = (int) round((float) $parsed['score_pct']);
                if ($llmScorePct >= 0 && $llmScorePct <= 100) {
                    $scorePct = $llmScorePct;
                }
            }

            return [
                'ok' => true,
                'score_pct' => $scorePct,
                'breakdown' => $breakdown,
                'ai_feedback' => $aiFeedback,
            ];
        } catch (Throwable $e) {
            return $this->scoreKeywordFallback($transcript, $validCategories, $totalMaxPoints);
        }
    }

    private function scoreKeywordFallback(string $transcript, array $validCategories, float $totalMaxPoints): array
    {
        $transcriptLower = function_exists('mb_strtolower')
            ? mb_strtolower($transcript, 'UTF-8')
            : strtolower($transcript);

        $tokenize = static function (string $text): array {
            $t = function_exists('mb_strtolower') ? mb_strtolower($text, 'UTF-8') : strtolower($text);
            $t = preg_replace('/[^a-z0-9\s]/u', ' ', $t);
            $t = (string) preg_replace('/\s+/u', ' ', trim((string) $t));
            $words = preg_split('/\s+/u', $t, -1, PREG_SPLIT_NO_EMPTY);
            return is_array($words) ? array_fill_keys($words, true) : [];
        };

        $transcriptTokens = $tokenize($transcript);

        $breakdown = [];
        $totalAwarded = 0.0;
        $BASELINE_PCT = 0.60;

        foreach ($validCategories as $vc) {
            $name = $vc['category'];
            $maxPts = $vc['points'];
            $behavior = $vc['fullCreditBehavior'];

            $behaviorTokens = $tokenize($name . ' ' . $behavior);
            $stopwords = array_fill_keys(['the','a','an','and','or','but','of','to','in','on','for','with','is','are','was','were','be','been','being','have','has','had','do','does','did','will','would','could','should','may','might','must','shall','can','need','dare','ought','used','it','its','this','that','these','those','i','you','he','she','we','they','me','him','her','us','them','my','your','his','our','their','mine','yours','hers','ours','theirs','at','by','from','as','into','through','during','before','after','above','below','between','out','off','over','under','again','further','then','once','here','there','when','where','why','how','all','each','every','both','few','more','most','other','some','such','no','nor','not','only','own','same','so','than','too','very','just','because','if','while','about','up','down','what','which','who','whom'], true);

            $keywordHits = 0;
            $keywordTotal = 0;
            foreach ($behaviorTokens as $word => $_) {
                if (isset($stopwords[$word])) continue;
                if (strlen($word) < 3) continue;
                $keywordTotal++;
                if (isset($transcriptTokens[$word])) {
                    $keywordHits++;
                }
            }

            if ($keywordTotal > 0) {
                $overlapRatio = $keywordHits / $keywordTotal;
                $coverageRatio = $BASELINE_PCT + (0.35 * $overlapRatio);
                if ($coverageRatio > 0.95) $coverageRatio = 0.95;
            } else {
                $coverageRatio = $BASELINE_PCT;
            }

            $awarded = round($maxPts * $coverageRatio, 2);
            if ($awarded < 0) $awarded = 0;
            if ($awarded > $maxPts) $awarded = $maxPts;
            $totalAwarded += $awarded;

            $matchWords = [];
            foreach ($behaviorTokens as $word => $_) {
                if (isset($stopwords[$word]) || strlen($word) < 3) continue;
                if (isset($transcriptTokens[$word])) $matchWords[] = $word;
            }
            $rationale = 'Keyword-overlap fallback (LLM unavailable). '
                . 'Category keywords matched in transcript: ' . $keywordHits . '/' . $keywordTotal
                . ($matchWords !== [] ? ' (' . implode(', ', array_slice($matchWords, 0, 8)) . ')' : '')
                . '. Awarded ~' . (int) round($coverageRatio * 100) . '% of max points.';

            $breakdown[] = [
                'category' => $name,
                'points_awarded' => $awarded,
                'points_max' => $maxPts,
                'rationale' => $rationale,
            ];
        }

        $scorePct = $totalMaxPoints > 0 ? (int) round(($totalAwarded / $totalMaxPoints) * 100) : 0;
        if ($scorePct < 0) $scorePct = 0;
        if ($scorePct > 100) $scorePct = 100;

        $aiFeedback = 'NOTE: This is a heuristic (keyword-based) estimate because the LLM scorer was unavailable. '
            . 'Baseline = 60%, adjusted +0-35% based on overlap between scorecard category/behavior keywords and transcript words. '
            . 'Configure an OpenAI API key for accurate, semantic scoring with specific rationales and actionable coaching feedback.';

        return [
            'ok' => true,
            'score_pct' => $scorePct,
            'breakdown' => $breakdown,
            'ai_feedback' => $aiFeedback,
            'msg' => 'Keyword fallback used (LLM unavailable).',
        ];
    }

    private function augmentBreakdownWithFallbacks(array $breakdown, array $validCategories, string $transcript, ?array $docIdsForRag): array
    {
        $vcByName = [];
        foreach ($validCategories as $vc) {
            $vcByName[(string)($vc['category'] ?? '')] = $vc;
        }
        $merged = [];
        foreach ($breakdown as $row) {
            if (!is_array($row)) continue;
            $name = (string) ($row['category'] ?? '');
            if ($name === '') continue;
            $quote = trim((string) ($row['transcript_quote'] ?? ''));
            $best = trim((string) ($row['best_answer_from_docs'] ?? ''));
            $vc = $vcByName[$name] ?? null;
            $behavior = $vc ? (string)($vc['fullCreditBehavior'] ?? '') : '';
            if ($quote === '' || $best === '') {
                if ($quote === '') {
                    $quote = $this->extractEvidenceLinesFromTranscript($transcript, $name . ' ' . $behavior);
                }
                if ($best === '') {
                    $best = $this->generateBestAnswerFromDocsOrRag($name, $behavior, $docIdsForRag);
                }
            }
            $row['transcript_quote']      = $quote;
            $row['best_answer_from_docs'] = $best;
            $merged[] = $row;
        }
        return $merged;
    }

    private function extractEvidenceLinesFromTranscript(string $transcript, string $categoryQuery): string
    {
        $t = trim($transcript);
        if ($t === '' || trim((string)$categoryQuery) === '') return '';

        $lines = preg_split("/\r\n|\n|\r/", $t);
        if (!is_array($lines) || $lines === []) return '';

        $stopwords = array_fill_keys(['the','a','an','and','or','but','of','to','in','on','for','with','is','are','was','were','be','been','being','have','has','had','do','does','did','will','would','could','should','may','might','must','shall','can','need','dare','ought','used','it','its','this','that','these','those','i','you','he','she','we','they','me','him','her','us','them','my','your','his','our','their','mine','yours','hers','ours','theirs','at','by','from','as','into','through','during','before','after','above','below','between','out','off','over','under','again','further','then','once','here','there','when','where','why','how','all','each','every','both','few','more','most','other','some','such','no','nor','not','only','own','same','so','than','too','very','just','because','if','while','about','up','down','what','which','who','whom','agent','customer','assistant'], true);
        $tokenize = static function (string $text) use ($stopwords): array {
            $norm = function_exists('mb_strtolower') ? mb_strtolower($text, 'UTF-8') : strtolower($text);
            $norm = (string) preg_replace('/[^a-z0-9\s]/u', ' ', $norm);
            $norm = (string) preg_replace('/\s+/u', ' ', trim($norm));
            if ($norm === '') return [];
            $w = preg_split('/\s+/u', $norm, -1, PREG_SPLIT_NO_EMPTY);
            if (!is_array($w)) return [];
            $out = [];
            foreach ($w as $word) {
                if (isset($stopwords[$word])) continue;
                if (strlen($word) < 3) continue;
                $out[] = $word;
            }
            return array_fill_keys($out, true);
        };

        $qTokens = $tokenize((string)$categoryQuery);
        if ($qTokens === []) return '';

        $scored = [];
        $prevLine = null;
        $nextLine = null;
        $n = count($lines);
        for ($i = 0; $i < $n; $i++) {
            $line = (string) $lines[$i];
            if (trim($line) === '') { $prevLine = null; continue; }
            $toks = $tokenize($line);
            if ($toks === []) { $prevLine = $line; continue; }
            $hits = 0;
            foreach (array_keys($qTokens) as $qw) {
                if (isset($toks[$qw])) $hits++;
            }
            if ($hits > 0) {
                $boost = 0;
                $lower = (function_exists('mb_strtolower') ? mb_strtolower($line, 'UTF-8') : strtolower($line));
                if (str_contains($lower, 'agent:') || str_contains($lower, 'assistant:')) $boost += 0.5;
                if (str_contains($lower, 'customer:') || str_contains($lower, 'user:')) $boost += 0.2;
                $ratio = $hits / max(1, count($qTokens));
                $scored[] = [
                    'i'     => $i,
                    'score' => ($ratio + $boost),
                    'line'  => $line,
                    'prev'  => $prevLine,
                ];
            }
            $prevLine = $line;
        }
        if ($scored === []) return '';
        usort($scored, static fn($a, $b) => $b['score'] <=> $a['score']);
        $best = $scored[0];
        $out = [];
        if ($best['prev'] !== null) {
            $p = trim((string)$best['prev']);
            if ($p !== '') $out[] = $p;
        }
        $out[] = trim((string)$best['line']);
        if (($best['i'] + 1) < $n) {
            $nx = trim((string)$lines[$best['i'] + 1]);
            if ($nx !== '') $out[] = $nx;
        }
        $combined = trim(implode("\n", $out));
        if (mb_strlen($combined) > 420) $combined = mb_substr($combined, 0, 417) . '…';
        return $combined;
    }

    private function generateBestAnswerFromDocsOrRag(string $categoryName, string $fullCreditBehavior, ?array $docIdsForRag): string
    {
        $categoryName = trim($categoryName);
        $fullCreditBehavior = trim($fullCreditBehavior);
        if ($categoryName === '') return '';

        $query = $categoryName . ': ' . ($fullCreditBehavior !== '' ? $fullCreditBehavior : 'agent best practice example');
        $ragChunksRaw = null;
        try {
            $openAi = new OpenAiService();
            if ($openAi->isConfigured()) {
                $topK = 4;
                $ragChunksRaw = $openAi->searchSimilarDocuments($query, $topK);
            }
        } catch (Throwable $e) {
            $ragChunksRaw = null;
        }

        $filtered = [];
        $ragTextParts = [];
        $ragUsedDocIds = [];
        if (is_array($ragChunksRaw) && $ragChunksRaw !== []) {
            $allowedIds = null;
            if (is_array($docIdsForRag) && $docIdsForRag !== []) {
                $allowedIds = array_fill_keys(array_values(array_filter(array_map('intval', $docIdsForRag), static fn($v) => $v > 0)), true);
            }
            foreach ($ragChunksRaw as $r) {
                if (!is_array($r)) continue;
                $docId = (int) ($r['document_id'] ?? 0);
                if ($allowedIds !== null && $docId > 0 && !isset($allowedIds[$docId])) continue;
                $chunk = $r['chunk'] ?? $r;
                $text = trim((string)(is_array($chunk) ? ($chunk['chunk_text'] ?? $chunk['text'] ?? '') : $chunk));
                if ($text === '') continue;
                $sim = (float) ($r['similarity'] ?? 0);
                if ($sim < 0.20) continue;
                if (mb_strlen($text) > 420) $text = mb_substr($text, 0, 417) . '…';
                $docTitle = trim((string) ($r['document_title'] ?? ($r['title'] ?? ($docId > 0 ? 'Doc #' . $docId : ''))));
                $filtered[] = [
                    'similarity'    => $sim,
                    'text'          => $text,
                    'document_id'   => $docId,
                    'document_title'=> $docTitle,
                ];
                $ragTextParts[] = ($docTitle !== '' ? '[' . $docTitle . '] ' : '') . $text;
                if ($docId > 0) $ragUsedDocIds[$docId] = true;
                if (count($filtered) >= 4) break;
            }
        }

        $fallbackAnswers = $this->fallbackBestAnswers();
        if ($ragTextParts === [] && isset($fallbackAnswers[$categoryName])) {
            return (string) $fallbackAnswers[$categoryName];
        }

        try {
            $openAi = new OpenAiService();
            if (!$openAi->isConfigured()) {
                if ($ragTextParts !== []) {
                    return "From trained docs: \n" . trim(implode("\n\n", array_slice($ragTextParts, 0, 2)));
                }
                return $fallbackAnswers[$categoryName] ?? '';
            }
            $basis = $ragTextParts !== []
                ? implode("\n\n---\n\n", $ragTextParts)
                : '';
            $sys = 'You are a SDR call-coach that writes a concrete model answer for a scorecard category. '
                . 'Use ONLY the provided "TRAINED DOC SNIPPETS" as the source of truth. If snippets are empty, use general modern SDR best-practice (BDR outbound) knowledge. '
                . 'Return 1-4 sentences, no markdown, keep actionable & script-like when appropriate.';
            $prompt = "CATEGORY: {$categoryName}\nFULL-CREDIT BEHAVIOR: " . ($fullCreditBehavior !== '' ? $fullCreditBehavior : '(none)')
                . "\n\nTRAINED DOC SNIPPETS:\n" . ($basis !== '' ? $basis : '(none — use general trained SDR/BDR knowledge)')
                . "\n\nWrite the BEST ANSWER (what a model agent should say/do for this category, 1-4 sentences, specific, concrete):";
            $resp = $openAi->chatCompletions([
                ['role' => 'system', 'content' => $sys],
                ['role' => 'user',   'content' => $prompt],
            ], 0.25);
            $resp = trim((string)$resp);
            if ($resp === '') {
                return $fallbackAnswers[$categoryName] ?? '';
            }
            return $resp;
        } catch (Throwable $e) {
            if ($ragTextParts !== []) {
                return 'From trained docs: ' . trim(implode(' ', array_slice($ragTextParts, 0, 2)));
            }
            return $fallbackAnswers[$categoryName] ?? '';
        }
    }

    private function fallbackBestAnswers(): array
    {
        return [
            'Research relevance' => 'Before call, open the lead research card, CRM notes, and training docs. Open with: "I was looking at your [Account/Recent news/CRM note], and [specific detail]. That caught my eye because [tie to value prop]." Then reference 1-2 specific account details to show preparation.',
            'Identity and opener' => 'Strong opener: "<smile> Hi [First Name], this is [Agent] with [Company]. How are you today? I\'ll be quick — the reason for my call is [context]." Pause, wait for reply, then transition to permission-based opener: "Do you have 30 seconds to hear why I called specifically?". Speak clearly, warm tone, confident but not pushy.',
            'Observation accuracy' => 'Acknowledge every stated concern explicitly with mirroring: "So if I\'m hearing you right, [paraphrase concern 1] and [paraphrase concern 2] — is that the main thing holding this back?". Confirm before jumping to solutions; this builds trust.',
            'Homeowner-to-business bridge' => 'Use an "I-Agree-Meanwhile" bridge: "I totally get [personal concern / household need]. Meanwhile, here\'s why business-owners in that same situation choose [solution] — because [business payoff tied to their personal values]." Connect individual worries → business outcomes.',
            'Diagnostic question' => 'Ask 2-3 open-ended, pain-uncovering questions: (1) "Today, how are you handling [problem area]?" (2) "When you think about the next 6 months, what\'s the #1 thing you\'d love to see different?" (3) "Walk me through what that would look like from a budget/priority angle." Stay silent, let them speak 80% of the time.',
            'Listening and control' => 'Active listening cues: short "Mhmm", "Got it", paraphrase every 3rd reply. If customer goes off-topic: "That\'s a great point, [First]. I want to address that — and to make sure I don\'t waste your time, let me park that and quickly ask one more question and then we\'ll come right back to it, deal?"',
            'Positioning' => 'Position with Problem-Truth-Plot: "Most [personas] tell us the same thing: they struggle with [Problem]. Here\'s the truth: [data point]. What we do is [Plot/your solution] — which is why [name-drop 1-2 similar customers] see [X% uplift] in [metric]." Tie every feature → customer value.',
            'Objection handling' => 'Feel-Felt-Found + evidence: "I totally get how you feel. Other [personas] in [industry] felt exactly the same way when they first heard about [solution]. What they found after using us for 30 days was [specific metric result]. Would it help if I walked you through exactly how that plays out for a team like yours?"',
            'Booking' => 'Assumptive close with 2-choice-time: "Based on what we covered, [First], what makes the most sense next step for you guys? Would that be Tuesday afternoon 3 PM ET or Thursday morning 10 AM ET?" If yes: confirm duration, meeting link, attendees, and calendar it live on the call.',
            'Compliance and CRM' => 'Record required disclaimers verbatim (TCPA: "This call may be monitored for quality assurance."). After call: push disposition = Assessment booked, duration, lead status, recording URL, notes about next steps to CRM within 5 minutes. Add lead custom fields if new info surfaced.',
        ];
    }
}
