<?php
/**
 * Vector storage service.
 *
 * Handles storing and searching vector embeddings in MySQL.
 *
 * @package Inqyra
 */

// If this file is called directly, abort.
if (!defined('WPINC')) {
    die;
}

/**
 * Vector store service class.
 */
class Inqyra_Vector_Store {

    /**
     * Vector dimension (must match embedding model).
     */
    const VECTOR_DIM = 384;

    /**
     * Store an embedding for a chunk.
     *
     * @param int    $chunk_id   The chunk ID.
     * @param array  $vector     The embedding vector.
     * @param string $model_name The model name used to create the embedding.
     * @return bool Whether storage was successful.
     */
    public function store_embedding($chunk_id, $vector, $model_name = 'unknown') {
        global $wpdb;

        $dim = count($vector);

        // Validate dimension matches expected
        if ($dim !== self::VECTOR_DIM) {
            $this->log_error("Unexpected vector dimension: {$dim} (expected " . self::VECTOR_DIM . ")");
            // Still allow storage, just log warning
        }

        // Pack the vector as binary
        $blob = pack('f*', ...$vector);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Direct update for performance-critical embedding storage.
        $result = $wpdb->update(
            $wpdb->prefix . 'inqyra_knowledge_chunks',
            [
                'embedding' => $blob,
                'embedding_model' => $model_name,
                'needs_embedding' => 0,
                'updated_at' => current_time('mysql'),
            ],
            ['id' => $chunk_id],
            ['%s', '%s', '%d', '%s'],
            ['%d']
        );

        return $result !== false;
    }

    /**
     * Get embedding for a chunk.
     *
     * @param int $chunk_id The chunk ID.
     * @return array|null The embedding vector or null.
     */
    public function get_embedding($chunk_id) {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Single embedding lookup by primary key.
        $blob = $wpdb->get_var($wpdb->prepare(
            'SELECT embedding FROM %i WHERE id = %d',
            $wpdb->prefix . 'inqyra_knowledge_chunks',
            $chunk_id
        ));

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

        return $this->unpack_embedding($blob);
    }

    /**
     * Search for similar chunks using vector similarity.
     *
     * @param int   $agent_id     The agent ID.
     * @param array $query_vector The query embedding vector.
     * @param int   $top_k        Maximum number of results.
     * @param float $min_score    Minimum similarity score (0-1).
     * @param array $content_types Optional content types to filter by.
     * @return array Array of results with id, content, score, etc.
     */
    public function search_similar($agent_id, $query_vector, $top_k = 5, $min_score = 0.3, $content_types = null) {
        global $wpdb;

        // Build content type filter
        $type_filter = '';
        $prepare_params = array($agent_id);
        if (!empty($content_types)) {
            $placeholders = implode(',', array_fill(0, count($content_types), '%s'));
            $type_filter = " AND content_type IN ($placeholders)";
            $prepare_params = array_merge($prepare_params, $content_types);
        }

        // Phase 1: Load only IDs and embeddings (minimal memory — avoids loading all content text)
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $type_filter contains only placeholders, merged into single prepare. Bulk vector search not cacheable.
        $chunks = $wpdb->get_results($wpdb->prepare(
            'SELECT id, embedding
             FROM %i
             WHERE agent_id = %d
             AND embedding IS NOT NULL
             ' . $type_filter,
            $wpdb->prefix . 'inqyra_knowledge_chunks',
            ...$prepare_params
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

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

        // Phase 2: Calculate similarity scores on IDs + embeddings only
        $scored = [];
        foreach ($chunks as $chunk) {
            $embedding = $this->unpack_embedding($chunk->embedding);
            if ($embedding === null) {
                continue;
            }

            $score = $this->cosine_similarity($query_vector, $embedding);

            if ($score >= $min_score) {
                $scored[] = [
                    'id' => (int) $chunk->id,
                    'score' => $score,
                ];
            }
        }

        // Free memory from embeddings before loading content
        unset($chunks);

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

        // Sort by score descending and take top K
        usort($scored, function($a, $b) {
            return $b['score'] <=> $a['score'];
        });
        $scored = array_slice($scored, 0, $top_k);

        // Phase 3: Fetch full content only for top-K results
        $top_ids = array_column($scored, 'id');
        $id_placeholders = implode(',', array_fill(0, count($top_ids), '%d'));
        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $id_placeholders are generated %d placeholders for top-K IDs.
        $full_chunks = $wpdb->get_results($wpdb->prepare(
            'SELECT id, content, title, content_type, source_url, source_type, source_id
             FROM %i
             WHERE id IN (' . $id_placeholders . ')',
            $wpdb->prefix . 'inqyra_knowledge_chunks',
            ...$top_ids
        ));
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, PluginCheck.Security.DirectDB.UnescapedDBParameter

        // Index full chunks by ID for quick lookup
        $chunk_map = [];
        foreach ($full_chunks as $chunk) {
            $chunk_map[(int) $chunk->id] = $chunk;
        }

        // Build final results in score order
        $results = [];
        foreach ($scored as $item) {
            $chunk = $chunk_map[$item['id']] ?? null;
            if (!$chunk) {
                continue;
            }

            $results[] = [
                'id' => $item['id'],
                'content' => $chunk->content,
                'title' => $chunk->title,
                'content_type' => $chunk->content_type,
                'source_url' => $chunk->source_url,
                'source_type' => $chunk->source_type,
                'source_id' => (int) $chunk->source_id,
                'score' => $item['score'],
                'match_type' => 'vector',
            ];
        }

        return $results;
    }

    /**
     * Search for similar chunks with caching.
     *
     * Uses WordPress transients to cache results for frequently searched queries.
     *
     * @param int    $agent_id      The agent ID.
     * @param string $query_text    The original query text (for cache key).
     * @param array  $query_vector  The query embedding vector.
     * @param int    $top_k         Maximum number of results.
     * @param float  $min_score     Minimum similarity score.
     * @param array  $content_types Optional content types to filter by.
     * @param int    $cache_ttl     Cache TTL in seconds (0 to disable).
     * @return array Array of results.
     */
    public function search_similar_cached($agent_id, $query_text, $query_vector, $top_k = 5, $min_score = 0.3, $content_types = null, $cache_ttl = 300) {
        if ($cache_ttl <= 0) {
            return $this->search_similar($agent_id, $query_vector, $top_k, $min_score, $content_types);
        }

        $type_key = $content_types ? implode(',', $content_types) : 'all';
        $cache_key = 'inqyra_vector_' . md5($agent_id . ':' . $query_text . ':' . $top_k . ':' . $min_score . ':' . $type_key);
        $cached = get_transient($cache_key);

        if ($cached !== false) {
            return $cached;
        }

        $results = $this->search_similar($agent_id, $query_vector, $top_k, $min_score, $content_types);
        set_transient($cache_key, $results, $cache_ttl);

        return $results;
    }

    /**
     * Calculate cosine similarity between two vectors.
     *
     * @param array $a First vector.
     * @param array $b Second vector.
     * @return float Similarity score (0-1).
     */
    public function cosine_similarity($a, $b) {
        $dot_product = 0.0;
        $norm_a = 0.0;
        $norm_b = 0.0;

        $len = min(count($a), count($b));

        for ($i = 0; $i < $len; $i++) {
            $dot_product += $a[$i] * $b[$i];
            $norm_a += $a[$i] * $a[$i];
            $norm_b += $b[$i] * $b[$i];
        }

        $denominator = sqrt($norm_a) * sqrt($norm_b);

        if ($denominator == 0) {
            return 0.0;
        }

        return $dot_product / $denominator;
    }

    /**
     * Unpack an embedding from binary BLOB.
     *
     * @param string $blob The binary embedding data.
     * @return array|null The embedding vector or null.
     */
    private function unpack_embedding($blob) {
        if (empty($blob)) {
            return null;
        }

        $embedding = unpack('f*', $blob);
        if ($embedding === false) {
            return null;
        }

        return array_values($embedding);
    }

    /**
     * Delete embeddings for a specific agent.
     *
     * @param int $agent_id The agent ID.
     * @return int Number of affected rows.
     */
    public function delete_agent_embeddings($agent_id) {
        global $wpdb;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk reset of embeddings for agent.
        return $wpdb->update(
            $wpdb->prefix . 'inqyra_knowledge_chunks',
            [
                'embedding' => null,
                'embedding_model' => null,
                'needs_embedding' => 1,
            ],
            ['agent_id' => $agent_id]
        );
    }

    /**
     * Mark all chunks for re-embedding.
     *
     * @param int|null $agent_id Optional agent ID to filter by.
     * @return int Number of affected rows.
     */
    public function mark_for_reembedding($agent_id = null) {
        global $wpdb;

        if ($agent_id) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Bulk mark for re-embedding by agent.
            return $wpdb->update(
                $wpdb->prefix . 'inqyra_knowledge_chunks',
                ['needs_embedding' => 1],
                ['agent_id' => $agent_id]
            );
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix. Bulk mark all chunks for re-embedding.
        return $wpdb->query(
            $wpdb->prepare('UPDATE %i SET needs_embedding = 1', $wpdb->prefix . 'inqyra_knowledge_chunks')
        );
    }

    /**
     * Get statistics about embeddings.
     *
     * @param int|null $agent_id Optional agent ID to filter by.
     * @return array Statistics array.
     */
    public function get_statistics($agent_id = null) {
        global $wpdb;

        $table = $wpdb->prefix . 'inqyra_knowledge_chunks';
        $has_agent = ($agent_id !== null && $agent_id > 0);

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix. Stats queries must be real-time.
        if ($has_agent) {
            $total = (int) $wpdb->get_var($wpdb->prepare(
                'SELECT COUNT(*) FROM %i WHERE agent_id = %d',
                $table,
                $agent_id
            ));
            $with_embedding = (int) $wpdb->get_var($wpdb->prepare(
                'SELECT COUNT(*) FROM %i WHERE agent_id = %d AND embedding IS NOT NULL',
                $table,
                $agent_id
            ));
            $pending = (int) $wpdb->get_var($wpdb->prepare(
                'SELECT COUNT(*) FROM %i WHERE agent_id = %d AND needs_embedding = 1',
                $table,
                $agent_id
            ));
        } else {
            $total = (int) $wpdb->get_var($wpdb->prepare(
                'SELECT COUNT(*) FROM %i',
                $table
            ));
            $with_embedding = (int) $wpdb->get_var($wpdb->prepare(
                'SELECT COUNT(*) FROM %i WHERE embedding IS NOT NULL',
                $table
            ));
            $pending = (int) $wpdb->get_var($wpdb->prepare(
                'SELECT COUNT(*) FROM %i WHERE needs_embedding = 1',
                $table
            ));
        }
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter

        return [
            'total_chunks' => $total,
            'with_embedding' => $with_embedding,
            'pending_embedding' => $pending,
            'completion_percentage' => $total > 0 ? round(($with_embedding / $total) * 100, 1) : 0,
        ];
    }

    /**
     * Log an error message.
     *
     * @param string $message The error message.
     */
    private function log_error($message) {
        if (defined('WP_DEBUG') && WP_DEBUG) {
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Gated behind WP_DEBUG.
            error_log('[Inqyra VectorStore] ' . $message);
        }
    }
}
