<?php

namespace Modules\AiCore\Entities\Services;

use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Modules\AiCore\Entities\AiProvider;
use Modules\AiCore\Entities\AiUsageLog;

/**
 * Thin, provider-agnostic LLM client — the single shared engine for the ERP.
 *
 * Lifted from VetAI's AiClient and generalised to take an {@see AiProvider}
 * settings row instead of VetAI's VaiSetting. Every call returns the same
 * NORMALISED result array (consumers rely on this shape):
 *
 *   ['ok'=>bool, 'text'=>string, 'tokens_in'=>int, 'tokens_out'=>int, 'error'=>?string]
 *
 * Adds (over the VetAI original): monthly-budget enforcement, per-call usage
 * logging keyed by module/feature, an optional short-TTL response cache, and
 * Anthropic prompt caching on the system block.
 *
 * Supports: anthropic | openai | openai_compatible | gemini.
 */
class AiClient
{
    /**
     * @param AiProvider $settings  the per-business provider config
     * @param string     $system    system prompt
     * @param string     $user      user prompt
     * @param array      $opts       ['module'=>?, 'feature'=>?, 'cacheable'=>bool]
     */
    public function chat(AiProvider $settings, string $systemPrompt, string $userPrompt, array $opts = []): array
    {
        // Budget guard — refuse before spending tokens.
        if ($settings->budgetExceeded()) {
            $res = $this->fail('budget_exceeded');
            $this->log($settings, $opts, $res);
            return $res;
        }

        $cacheKey = null;
        if (!empty($opts['cacheable']) && config('aicore.cache.enabled')) {
            $cacheKey = 'aicore:chat:' . md5($settings->provider . '|' . $settings->model . '|' . $systemPrompt . '|' . $userPrompt);
            $cached = Cache::get($cacheKey);
            if (is_array($cached)) {
                return $cached;
            }
        }

        // Primary provider attempt.
        $res = $this->dispatch($settings, $systemPrompt, $userPrompt);
        $this->log($settings, $opts, $res);

        // Transparent failover: if the primary errored (anything but an
        // intentional budget stop), retry the business's configured fallback
        // providers in order, so a single upstream outage / rate-limit / timeout
        // never surfaces to the user. Opt out with $opts['no_fallback'] — used by
        // the settings connectivity test, which must target one specific provider.
        if (empty($res['ok'])
            && empty($opts['no_fallback'])
            && ($res['error'] ?? null) !== 'budget_exceeded') {
            foreach (AiProvider::fallbacksFor((int) $settings->business_id, $settings->name) as $fallback) {
                $fbRes = $this->dispatch($fallback, $systemPrompt, $userPrompt);
                $this->log($fallback, array_merge($opts, [
                    'feature' => trim(((string) ($opts['feature'] ?? '')) . ':fallback', ':'),
                ]), $fbRes);
                if (!empty($fbRes['ok'])) {
                    $res = $fbRes;
                    break;
                }
            }
        }

        if ($cacheKey && !empty($res['ok'])) {
            Cache::put($cacheKey, $res, (int) config('aicore.cache.ttl', 300));
        }

        return $res;
    }

    /**
     * Single-provider dispatch — the raw call to one provider, with no budget
     * guard, cache, logging or failover. Returns the normalised result shape.
     */
    private function dispatch(AiProvider $settings, string $systemPrompt, string $userPrompt): array
    {
        try {
            switch ($settings->provider) {
                case 'anthropic':
                    return $this->anthropic($settings, $systemPrompt, $userPrompt);
                case 'openai':
                    return $this->openaiStyle($settings, $systemPrompt, $userPrompt, 'https://api.openai.com/v1');
                case 'openai_compatible':
                    return empty($settings->base_url)
                        ? $this->fail('No base URL configured for the OpenAI-compatible provider.')
                        : $this->openaiStyle($settings, $systemPrompt, $userPrompt, $settings->base_url);
                case 'gemini':
                    return $this->gemini($settings, $systemPrompt, $userPrompt);
                default:
                    return $this->fail('Unknown provider: ' . $settings->provider);
            }
        } catch (\Throwable $e) {
            return $this->fail('Request failed: ' . $e->getMessage());
        }
    }

    /**
     * Streaming chat for OpenAI-style providers (OpenAI / Groq / Bynara / any
     * OpenAI-compatible endpoint). Calls $onDelta($text) for each token chunk as
     * it arrives, then returns the final normalised result (with usage). Providers
     * that don't stream here (anthropic / gemini) — or a stream that yields nothing
     * — transparently fall back to the resilient non-streaming chat() (which keeps
     * the Groq->Bynara failover), emitting the whole answer once.
     */
    public function stream(AiProvider $settings, string $systemPrompt, string $userPrompt, callable $onDelta, array $opts = []): array
    {
        if (!in_array($settings->provider, ['openai', 'openai_compatible'], true)) {
            $res = $this->chat($settings, $systemPrompt, $userPrompt, $opts);
            if (!empty($res['ok'])) { $onDelta($res['text']); }
            return $res;
        }
        if ($settings->budgetExceeded()) {
            $res = $this->fail('budget_exceeded');
            $this->log($settings, $opts, $res);
            return $res;
        }

        $base = $settings->provider === 'openai'
            ? 'https://api.openai.com/v1'
            : (string) $settings->base_url;

        $text = '';
        $tin = 0;
        $tout = 0;
        if ($base !== '') {
            try {
                $req = Http::timeout($this->timeout())->withOptions(['stream' => true]);
                if (!empty($settings->api_key)) {
                    $req = $req->withToken($settings->api_key);
                }
                $resp = $req->post(rtrim($base, '/') . '/chat/completions', [
                    'model'          => $settings->model,
                    'max_tokens'     => (int) $settings->max_tokens,
                    'temperature'    => (float) $settings->temperature,
                    'stream'         => true,
                    'stream_options' => ['include_usage' => true],
                    'messages'       => [
                        ['role' => 'system', 'content' => $systemPrompt],
                        ['role' => 'user',   'content' => $userPrompt],
                    ],
                ]);
                if ($resp->successful()) {
                    $body = $resp->toPsrResponse()->getBody();
                    $buffer = '';
                    while (!$body->eof()) {
                        $buffer .= $body->read(2048);
                        while (($nl = strpos($buffer, "\n")) !== false) {
                            $line = trim(substr($buffer, 0, $nl));
                            $buffer = substr($buffer, $nl + 1);
                            if ($line === '' || strncmp($line, 'data:', 5) !== 0) {
                                continue;
                            }
                            $data = trim(substr($line, 5));
                            if ($data === '[DONE]') {
                                break 2;
                            }
                            $j = json_decode($data, true);
                            if (!is_array($j)) {
                                continue;
                            }
                            $delta = $j['choices'][0]['delta']['content'] ?? '';
                            if ($delta !== '' && $delta !== null) {
                                $text .= $delta;
                                $onDelta($delta);
                            }
                            if (isset($j['usage']) && is_array($j['usage'])) {
                                $tin  = (int) ($j['usage']['prompt_tokens'] ?? $tin);
                                $tout = (int) ($j['usage']['completion_tokens'] ?? $tout);
                            }
                        }
                    }
                }
            } catch (\Throwable $e) {
                // fall through to the non-stream fallback below
            }
        }

        if ($text === '') {
            // Nothing streamed (provider error / empty body) — use the resilient path.
            $res = $this->chat($settings, $systemPrompt, $userPrompt, $opts);
            if (!empty($res['ok'])) { $onDelta($res['text']); }
            return $res;
        }

        $res = ['ok' => true, 'text' => $text, 'tokens_in' => $tin, 'tokens_out' => $tout, 'error' => null];
        $this->log($settings, $opts, $res);
        return $res;
    }

    private function timeout(): int
    {
        return (int) config('aicore.timeout', 90);
    }

    private function anthropic(AiProvider $s, string $system, string $user): array
    {
        // Prompt caching: wrap the (largely static) system prompt in a block
        // with cache_control so repeated system prompts are served from cache.
        $systemPayload = $system;
        if (config('aicore.anthropic_prompt_cache', true) && strlen($system) > 0) {
            $systemPayload = [[
                'type'          => 'text',
                'text'          => $system,
                'cache_control' => ['type' => 'ephemeral'],
            ]];
        }

        $resp = Http::withHeaders([
            'x-api-key'         => $s->api_key,
            'anthropic-version' => '2023-06-01',
            'content-type'      => 'application/json',
        ])->timeout($this->timeout())->post('https://api.anthropic.com/v1/messages', [
            'model'       => $s->model,
            'max_tokens'  => (int) $s->max_tokens,
            'temperature' => (float) $s->temperature,
            'system'      => $systemPayload,
            'messages'    => [['role' => 'user', 'content' => $user]],
        ]);

        if (!$resp->successful()) {
            return $this->fail($this->errMsg($resp->json(), $resp->status()));
        }
        $j = $resp->json();
        $text = '';
        foreach (($j['content'] ?? []) as $block) {
            if (($block['type'] ?? '') === 'text') $text .= $block['text'];
        }
        $usage = $j['usage'] ?? [];
        return [
            'ok'         => $text !== '',
            'text'       => $text ?: '(empty response)',
            // include cached-token counts so budget tracking stays accurate
            'tokens_in'  => (int) ($usage['input_tokens'] ?? 0)
                          + (int) ($usage['cache_read_input_tokens'] ?? 0)
                          + (int) ($usage['cache_creation_input_tokens'] ?? 0),
            'tokens_out' => (int) ($usage['output_tokens'] ?? 0),
            'error'      => $text === '' ? 'Empty response from model' : null,
        ];
    }

    /**
     * OpenAI Chat Completions format — used by OpenAI itself and by every
     * OpenAI-compatible endpoint (DeepSeek, Groq, OpenRouter, local Ollama…).
     */
    private function openaiStyle(AiProvider $s, string $system, string $user, string $baseUrl): array
    {
        $endpoint = rtrim($baseUrl, '/') . '/chat/completions';

        $req = Http::timeout($this->timeout());
        if (!empty($s->api_key)) {
            $req = $req->withToken($s->api_key); // optional — keyless for local Ollama
        }

        $resp = $req->post($endpoint, [
            'model'       => $s->model,
            'max_tokens'  => (int) $s->max_tokens,
            'temperature' => (float) $s->temperature,
            'messages'    => [
                ['role' => 'system', 'content' => $system],
                ['role' => 'user',   'content' => $user],
            ],
        ]);

        if (!$resp->successful()) {
            return $this->fail($this->errMsg($resp->json(), $resp->status()));
        }
        $j = $resp->json();
        $text = $j['choices'][0]['message']['content'] ?? '';
        return [
            'ok'         => $text !== '',
            'text'       => $text ?: '(empty response)',
            'tokens_in'  => (int) ($j['usage']['prompt_tokens'] ?? 0),
            'tokens_out' => (int) ($j['usage']['completion_tokens'] ?? 0),
            'error'      => $text === '' ? 'Empty response from model' : null,
        ];
    }

    private function gemini(AiProvider $s, string $system, string $user): array
    {
        $url = 'https://generativelanguage.googleapis.com/v1beta/models/'
             . rawurlencode($s->model) . ':generateContent?key=' . urlencode($s->api_key);

        $generationConfig = [
            'temperature'     => (float) $s->temperature,
            'maxOutputTokens' => (int) $s->max_tokens,
        ];
        // Gemini 2.5 *flash* models "think" by default — reasoning tokens eat the
        // output budget and truncate the answer. Disable thinking on flash models.
        $model = strtolower((string) $s->model);
        if (str_contains($model, '2.5') && str_contains($model, 'flash')) {
            $generationConfig['thinkingConfig'] = ['thinkingBudget' => 0];
        }

        $resp = Http::timeout($this->timeout())->post($url, [
            'systemInstruction' => ['parts' => [['text' => $system]]],
            'contents'          => [['role' => 'user', 'parts' => [['text' => $user]]]],
            'generationConfig'  => $generationConfig,
        ]);

        if (!$resp->successful()) {
            return $this->fail($this->errMsg($resp->json(), $resp->status()));
        }
        $j = $resp->json();
        $text = '';
        foreach (($j['candidates'][0]['content']['parts'] ?? []) as $part) {
            $text .= $part['text'] ?? '';
        }

        // Surface a truncated answer instead of silently returning a cut-off one.
        $finish = $j['candidates'][0]['finishReason'] ?? '';
        if ($finish === 'MAX_TOKENS' && $text !== '') {
            $text .= "\n\n_[Response truncated — raise “Max Response Tokens” in AI Settings.]_";
        }

        $usage = $j['usageMetadata'] ?? [];
        return [
            'ok'         => $text !== '',
            'text'       => $text ?: '(empty response)',
            'tokens_in'  => (int) ($usage['promptTokenCount'] ?? 0),
            // include reasoning ("thoughts") tokens so the budget guard is accurate
            'tokens_out' => (int) ($usage['candidatesTokenCount'] ?? 0)
                          + (int) ($usage['thoughtsTokenCount'] ?? 0),
            'error'      => $text === '' ? 'Empty / blocked response from model' : null,
        ];
    }

    private function errMsg($json, int $status): string
    {
        $msg = $json['error']['message'] ?? ($json['error']['type'] ?? null);
        if (is_array($msg)) $msg = json_encode($msg);
        return 'API error ' . $status . ($msg ? ': ' . $msg : '');
    }

    private function fail(string $error): array
    {
        return ['ok' => false, 'text' => '', 'tokens_in' => 0, 'tokens_out' => 0, 'error' => $error];
    }

    /** Persist one usage row (best-effort). */
    private function log(AiProvider $settings, array $opts, array $res): void
    {
        AiUsageLog::record((int) $settings->business_id, [
            'module'     => $opts['module']  ?? null,
            'feature'    => $opts['feature'] ?? null,
            'provider'   => $settings->provider,
            'model'      => $settings->model,
            'tokens_in'  => $res['tokens_in']  ?? 0,
            'tokens_out' => $res['tokens_out'] ?? 0,
            'ok'         => $res['ok'] ?? false,
            'error'      => $res['error'] ?? null,
        ]);
    }
}
