<?php

/**
 * Internal (WordPress DB) vector environment. Stores embeddings as packed
 * float32 blobs in a dedicated table and runs cosine similarity in PHP.
 * No external service, no API key: knowledge works out of the box.
 *
 * Brute force is intentional: at ~1536 dims a query costs about 50ms per
 * 1,000 chunks, which is instant for the typical "RAG my site" knowledge
 * base (hundreds to a few thousand chunks) and acceptable to ~20k. Rows are
 * scanned in batches to keep memory flat. A native MariaDB VECTOR tier can
 * slot in later behind the same env type.
 */
class MeowPro_MWAI_Addons_Internal {
  private $core = null;
  private $wpdb = null;
  private $env = null;
  private $table = null;
  private $maxSelect = 10;
  private $db_checked = false;
  // Rows per scan batch: ~6 KB per 1536-dim vector, so ~1.5 MB of blobs per batch.
  public const BATCH_SIZE = 250;

  public function __construct() {
    global $mwai_core, $wpdb;
    $this->core = $mwai_core;
    $this->wpdb = $wpdb;
    $this->table = $wpdb->prefix . 'mwai_local_vectors';
    add_filter( 'mwai_embeddings_list_vectors', [ $this, 'list_vectors' ], 10, 2 );
    add_filter( 'mwai_embeddings_add_vector', [ $this, 'add_vector' ], 10, 3 );
    add_filter( 'mwai_embeddings_get_vector', [ $this, 'get_vector' ], 10, 4 );
    add_filter( 'mwai_embeddings_query_vectors', [ $this, 'query_vectors' ], 10, 4 );
    add_filter( 'mwai_embeddings_delete_vectors', [ $this, 'delete_vectors' ], 10, 2 );
  }

  public function init_settings( $envId = null ) {
    $envId = $envId ?? $this->core->get_option( 'embeddings_env' );
    $this->env = $this->core->get_embeddings_env( $envId );
    if ( empty( $this->env ) || $this->env['type'] !== 'internal' ) {
      return false;
    }
    $this->maxSelect = isset( $this->env['max_select'] ) ? (int) $this->env['max_select'] : 10;
    return true;
  }

  private function check_db() {
    if ( $this->db_checked ) {
      return true;
    }
    $charset_collate = $this->wpdb->get_charset_collate();
    $this->wpdb->query( "CREATE TABLE IF NOT EXISTS {$this->table} (
      id VARCHAR(64) NOT NULL,
      envId VARCHAR(64) NOT NULL,
      dimensions INT(11) NOT NULL,
      norm FLOAT NOT NULL,
      embedding LONGBLOB NOT NULL,
      created DATETIME NOT NULL,
      PRIMARY KEY (id),
      KEY envId (envId)
    ) {$charset_collate};" );
    $this->db_checked = true;
    return true;
  }

  private function get_uuid() {
    $data = openssl_random_pseudo_bytes( 16 );
    $data[6] = chr( ord( $data[6] ) & 0x0f | 0x40 );
    $data[8] = chr( ord( $data[8] ) & 0x3f | 0x80 );
    return vsprintf( '%s%s-%s-%s-%s-%s%s%s', str_split( bin2hex( $data ), 4 ) );
  }

  private static function norm( $values ) {
    $sum = 0.0;
    foreach ( $values as $v ) {
      $sum += $v * $v;
    }
    return sqrt( $sum );
  }

  public function add_vector( $success, $vector, $options, $unused = null ) {
    if ( $success ) {
      return $success;
    }
    $envId = $options['envId'];
    if ( !$this->init_settings( $envId ) ) {
      return false;
    }
    if ( empty( $vector['embedding'] ) || !is_array( $vector['embedding'] ) ) {
      throw new Exception( 'Internal vector environment: no embedding provided.' );
    }
    $this->check_db();
    $values = array_map( 'floatval', $vector['embedding'] );
    $norm = self::norm( $values );
    if ( $norm == 0.0 ) {
      throw new Exception( 'Internal vector environment: the embedding is a zero vector.' );
    }
    $id = $this->get_uuid();
    $done = $this->wpdb->insert( $this->table, [
      'id' => $id,
      'envId' => $envId,
      'dimensions' => count( $values ),
      'norm' => $norm,
      // Packed little-endian float32; 4 bytes per dimension.
      'embedding' => pack( 'g*', ...$values ),
      'created' => current_time( 'mysql' ),
    ], [ '%s', '%s', '%d', '%f', '%s', '%s' ] );
    if ( $done === false ) {
      throw new Exception( 'Internal vector environment: insert failed (' . $this->wpdb->last_error . ').' );
    }
    return $id;
  }

  public function query_vectors( $vectors, $vector, $options ) {
    if ( !empty( $vectors ) ) {
      return $vectors;
    }
    $envId = $options['envId'];
    if ( !$this->init_settings( $envId ) ) {
      return $vectors;
    }
    $this->check_db();
    $query = array_map( 'floatval', $vector );
    $queryNorm = self::norm( $query );
    if ( $queryNorm == 0.0 ) {
      return [];
    }
    $dims = count( $query );

    $matches = [];
    $offset = 0;
    while ( true ) {
      $rows = $this->wpdb->get_results( $this->wpdb->prepare(
        "SELECT id, norm, embedding FROM {$this->table}
         WHERE envId = %s AND dimensions = %d
         ORDER BY id LIMIT %d OFFSET %d",
        $envId,
        $dims,
        self::BATCH_SIZE,
        $offset
      ), ARRAY_A );
      if ( empty( $rows ) ) {
        break;
      }
      foreach ( $rows as $row ) {
        // add_vector() rejects zero-norm embeddings, but a row could still arrive
        // another way (import, direct DB edit). Skip it rather than divide by zero,
        // which yields INF and would rank the bad row above every real match.
        $rowNorm = (float) $row['norm'];
        if ( $rowNorm <= 0.0 ) {
          continue;
        }
        $stored = unpack( 'g*', $row['embedding'] );
        $dot = 0.0;
        // unpack() is 1-indexed; $query is 0-indexed.
        foreach ( $stored as $i => $v ) {
          $dot += $v * $query[ $i - 1 ];
        }
        $score = $dot / ( $queryNorm * $rowNorm );
        $matches[] = [ 'id' => $row['id'], 'score' => $score ];
      }
      // Release wpdb's cached result set; blobs add up quickly otherwise.
      $this->wpdb->flush();
      $offset += self::BATCH_SIZE;
    }

    usort( $matches, function ( $a, $b ) {
      return $b['score'] <=> $a['score'];
    } );
    return array_slice( $matches, 0, $this->maxSelect );
  }

  public function get_vector( $vector, $vectorId, $envId, $options ) {
    if ( !empty( $vector ) ) {
      return $vector;
    }
    if ( !$this->init_settings( $envId ) ) {
      return $vector;
    }
    $this->check_db();
    $row = $this->wpdb->get_row( $this->wpdb->prepare(
      "SELECT id, embedding FROM {$this->table} WHERE id = %s",
      $vectorId
    ), ARRAY_A );
    if ( empty( $row ) ) {
      return null;
    }
    // Content, title and type live in the main vectors table (same database),
    // so there is nothing more to recover from here than the raw vector.
    return [
      'id' => $row['id'],
      'type' => 'manual',
      'title' => '',
      'content' => '',
      'model' => '',
      'values' => array_values( unpack( 'g*', $row['embedding'] ) ),
    ];
  }

  public function delete_vectors( $success, $options ) {
    $envId = $options['envId'] ?? null;
    if ( !$this->init_settings( $envId ) ) {
      return $success;
    }
    $this->check_db();
    if ( !empty( $options['deleteAll'] ) ) {
      $this->wpdb->query( $this->wpdb->prepare(
        "DELETE FROM {$this->table} WHERE envId = %s",
        $envId
      ) );
      return true;
    }
    $ids = $options['ids'] ?? [];
    if ( empty( $ids ) ) {
      return true;
    }
    $placeholders = implode( ',', array_fill( 0, count( $ids ), '%s' ) );
    $this->wpdb->query( $this->wpdb->prepare(
      "DELETE FROM {$this->table} WHERE id IN ({$placeholders})",
      ...$ids
    ) );
    return true;
  }

  public function list_vectors( $vectors, $options ) {
    if ( !empty( $vectors ) ) {
      return $vectors;
    }
    $envId = $options['envId'];
    if ( !$this->init_settings( $envId ) ) {
      return $vectors;
    }
    $this->check_db();
    $limit = isset( $options['limit'] ) ? (int) $options['limit'] : 100;
    $offset = isset( $options['offset'] ) ? (int) $options['offset'] : 0;
    $ids = $this->wpdb->get_col( $this->wpdb->prepare(
      "SELECT id FROM {$this->table} WHERE envId = %s ORDER BY created LIMIT %d OFFSET %d",
      $envId,
      $limit,
      $offset
    ) );
    return $ids;
  }
}
