<?php

namespace ZenoChatbot\Services\Chat;

if (!defined('ABSPATH')) {
    exit;
}

use ZenoChatbot\Services\Chat\Contracts\SemanticMatcherInterface;
use ZenoChatbot\Services\Chat\Contracts\EmbeddingProviderInterface;
use ZenoChatbot\Services\Chat\Embedding\VectorMath;
use ZenoChatbot\Services\Chat\Embedding\OpenAIEmbeddingProvider;
use ZenoChatbot\Services\Chat\Embedding\GeminiEmbeddingProvider;
use ZenoChatbot\Helper\Settings;
use ZenoChatbot\Model\SavedReply;

/**
 * Semantic Saved Reply Matcher
 *
 * Uses embedding vectors and cosine similarity to find semantically
 * similar saved replies, even when the exact wording differs.
 *
 * Example: "What's the cost?" matches "How much does it cost?"
 */
class SemanticSavedReplyMatcher implements SemanticMatcherInterface
{
    /**
     * Transient prefix for cached user-message embeddings.
     */
    protected const EMBEDDING_CACHE_PREFIX = 'zeno_msg_emb_';

    /**
     * Embeddings for identical input are deterministic, so a long TTL is safe;
     * the key includes provider + model, so switching providers misses cleanly.
     */
    protected const EMBEDDING_CACHE_TTL = WEEK_IN_SECONDS;

    protected EmbeddingProviderInterface $embeddingProvider;
    protected float $similarityThreshold;

    /**
     * @param EmbeddingProviderInterface|null $embeddingProvider Provider for generating embeddings
     * @param float|null $similarityThreshold Minimum similarity score (0-1) to consider a match (reads from settings if null)
     */
    public function __construct(
        ?EmbeddingProviderInterface $embeddingProvider = null,
        ?float $similarityThreshold = null
    ) {
        $this->embeddingProvider = $embeddingProvider ?? $this->createProviderFromSettings();
        $this->similarityThreshold = $similarityThreshold ?? $this->getThresholdFromSettings();
    }

    /**
     * Get similarity threshold from settings
     */
    protected function getThresholdFromSettings(): float
    {
        return (float) Settings::get('saved_replies.semantic_threshold', 0.75);
    }

    /**
     * Create embedding provider based on LLM settings
     */
    protected function createProviderFromSettings(): EmbeddingProviderInterface
    {
        $llmSettings = Settings::getLLMSettings(true);
        $provider = $llmSettings['provider'] ?? 'openai';

        if (strtolower($provider) === 'gemini') {
            return new GeminiEmbeddingProvider();
        }

        return new OpenAIEmbeddingProvider();
    }

    /**
     * Match user message against saved replies using semantic similarity
     *
     * @param string $message User's message
     * @param array $replies Array of SavedReply objects with embeddings
     * @return array|null Matched reply data with similarity score, or null
     */
    public function match(string $message, array $replies): ?array
    {
        if (empty($message) || empty($replies)) {
            return null;
        }

        // Filter replies that have embeddings
        $repliesWithEmbeddings = $this->filterRepliesWithEmbeddings($replies);

        if (empty($repliesWithEmbeddings)) {
            return null;
        }

        try {
            // Generate embedding for user message (cached across requests)
            $messageEmbedding = $this->getMessageEmbedding($message);

            if (empty($messageEmbedding)) {
                return null;
            }

            // Find best match using cosine similarity
            $bestMatch = null;
            $bestSimilarity = 0.0;

            foreach ($repliesWithEmbeddings as $reply) {
                $similarity = VectorMath::cosineSimilarity(
                    $messageEmbedding,
                    $reply['embedding']
                );

                if ($similarity >= $this->similarityThreshold && $similarity > $bestSimilarity) {
                    $bestSimilarity = $similarity;
                    $bestMatch = $reply['reply'];
                }
            }

            if ($bestMatch) {
                return $this->formatMatch($bestMatch, $bestSimilarity);
            }

        } catch (\Exception $e) {
        }

        return null;
    }

    /**
     * Get the embedding for a user message, with a persistent cache.
     *
     * Repeated messages ("pricing?", "hi") previously paid a blocking
     * embedding-API HTTP call on every occurrence. Vectors are stored as
     * packed little-endian floats (~8 KB for 1536 dims vs ~30 KB as JSON).
     *
     * @throws \Exception If the provider call fails (handled by match()).
     */
    protected function getMessageEmbedding(string $message): array
    {
        $model = method_exists($this->embeddingProvider, 'getModel')
            ? $this->embeddingProvider->getModel()
            : '';
        $cacheKey = static::EMBEDDING_CACHE_PREFIX
            . md5(get_class($this->embeddingProvider) . '|' . $model . '|' . $message);

        $cached = get_transient($cacheKey);
        if (is_string($cached) && $cached !== '') {
            $decoded = base64_decode($cached, true);
            $vector = $decoded !== false ? unpack('g*', $decoded) : false;
            if (!empty($vector)) {
                return array_values($vector);
            }
        }

        $embedding = $this->embeddingProvider->embed($message);

        if (!empty($embedding)) {
            set_transient($cacheKey, base64_encode(pack('g*', ...$embedding)), static::EMBEDDING_CACHE_TTL);
        }

        return $embedding;
    }

    /**
     * Filter and prepare replies that have valid embeddings
     *
     * Match candidates are loaded without their embedding column (large JSON
     * vectors), so embeddings are fetched here in a single query — only when
     * the semantic path actually runs.
     *
     * @param array $replies Grouped replies array
     * @return array Array of ['reply' => SavedReply, 'embedding' => array]
     */
    protected function filterRepliesWithEmbeddings(array $replies): array
    {
        // Flatten all match types
        $allReplies = array_merge(
            $replies['exact'] ?? [],
            $replies['keyword'] ?? [],
            $replies['contains'] ?? [],
            $replies['semantic'] ?? []
        );

        $candidatesById = [];
        foreach ($allReplies as $reply) {
            if (!empty($reply->id)) {
                $candidatesById[(int) $reply->id] = $reply;
            }
        }

        if (empty($candidatesById)) {
            return [];
        }

        $rows = SavedReply::query()
            ->select(['id', 'embedding'])
            ->whereIn('id', array_keys($candidatesById))
            ->whereNotNull('embedding')
            ->get();

        $result = [];
        foreach ($rows as $row) {
            // Decode embedding if stored as JSON string
            $embedding = $row->embedding;
            if (is_string($embedding)) {
                $embedding = json_decode($embedding, true);
            }

            if (!empty($embedding) && is_array($embedding)) {
                $result[] = [
                    'reply' => $candidatesById[(int) $row->id],
                    'embedding' => $embedding,
                ];
            }
        }

        return $result;
    }

    /**
     * Format matched reply into standard array
     */
    protected function formatMatch($reply, float $similarity): array
    {
        return [
            'id' => $reply->id,
            'answer' => $reply->answer,
            'match_type' => 'semantic',
            'question' => $reply->question,
            'similarity' => round($similarity, 4),
        ];
    }

    /**
     * Get the current similarity threshold
     */
    public function getSimilarityThreshold(): float
    {
        return $this->similarityThreshold;
    }

    /**
     * Set the similarity threshold
     */
    public function setSimilarityThreshold(float $threshold): self
    {
        $this->similarityThreshold = max(0.0, min(1.0, $threshold));
        return $this;
    }
}
