<?php
/**
 * Embeddings model class.
 *
 * Creates a table to store per-post chunk embeddings and metadata.
 *
 * @class EmbeddingsModel
 * @package KimaAI\Models
 * @since   1.3.1
 */

declare(strict_types=1);

namespace KimaAI\Models;

defined('ABSPATH') || exit;

/**
 * Class EmbeddingsModel
 */
class EmbeddingsModel {
  use HasSchema;

  /**
   * The table slug (prefixed automatically).
   *
   * @var string
   */
  public const TABLE = 'kimaai_embeddings';

  /**
   * The table primary key.
   *
   * @var string
   */
  public const PRIMARY_KEY = 'id';

  /**
   * The table columns.
   *
   * @var array<string,string>
   */
  public const COLUMNS = [
    'id' => 'BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT',
    'env_id' => 'VARCHAR(32) NOT NULL',
    'vector_provider' => 'VARCHAR(32) NOT NULL',
    'collection' => 'VARCHAR(191) NULL',
    'namespace' => 'VARCHAR(191) NOT NULL',
    'post_id' => 'BIGINT(20) UNSIGNED NOT NULL',
    'chunk_index' => 'INT(10) UNSIGNED NOT NULL',
    'lang' => "VARCHAR(10) NOT NULL DEFAULT 'en'",
    'heading_path' => 'TEXT NULL',
    'start_char' => 'INT(10) UNSIGNED NULL',
    'end_char' => 'INT(10) UNSIGNED NULL',
    'vector_id' => 'VARCHAR(191) NOT NULL',
    'model' => 'VARCHAR(64) NOT NULL',
    'dim' => 'INT(10) UNSIGNED NULL',
    'content_hash' => 'CHAR(40) NOT NULL',
    'vector' => 'LONGTEXT NULL',
    'token_count' => 'INT(10) UNSIGNED NULL',
    'status' => "VARCHAR(20) NOT NULL DEFAULT 'queued'",
    'error' => 'TEXT NULL',
    'created_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP',
    'updated_at' => 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP',
    'upserted_at' => 'DATETIME NULL',
  ];

  /**
   * Indexes.
   *
   * @var string[]
   */
  public const INDEXES = [
    'UNIQUE KEY uniq_vector_id (vector_id)',
    'UNIQUE KEY uniq_post_chunk_ns (env_id, namespace, post_id, chunk_index)',
    'KEY idx_post (post_id)',
    'KEY idx_env_ns (env_id, namespace)',
    'KEY idx_status (status)',
    'KEY idx_updated (updated_at)',
    'KEY namespace (namespace)',
  ];

  /**
   * Convenience: get fully-qualified table name.
   */
  public function table(): string {
    global $wpdb;
    return $wpdb->prefix . self::TABLE;
  }

  /**
   * Encode 'vector' to JSON (if array) or pass-through (if string/null).
   *
   * @param mixed $value
   */
  public function encodeVector($value): ?string {
    if (is_array($value)) {
      return wp_json_encode(array_values($value));
    }
    if (is_string($value)) {
      return $value;
    }
    return null;
  }

  /**
   * Optional helper to insert a row.
   * Pass 'vector' as either a string (JSON) or an array (will be json-encoded).
   *
   * @param array<string,mixed> $data
   * @return int Insert ID or 0 on failure
   */
  public function insert(array $data): int {
    global $wpdb;

    $table = $this->table();
    $vector_value = array_key_exists('vector', $data) ? $this->encodeVector($data['vector']) : null;

    $payload = [
      'env_id' => (string) ($data['env_id'] ?? ''),
      'vector_provider' => (string) ($data['vector_provider'] ?? ''),
      'post_id' => (int) ($data['post_id'] ?? 0),
      'chunk_index' => (int) ($data['chunk_index'] ?? 0),
      'lang' => (string) ($data['lang'] ?? 'en'),
      'heading_path' => $data['heading_path'] ?? null,
      'start_char' => isset($data['start_char']) ? (int) $data['start_char'] : null,
      'end_char' => isset($data['end_char']) ? (int) $data['end_char'] : null,
      'token_count' => isset($data['token_count']) ? (int) $data['token_count'] : null,
      'model' => (string) ($data['model'] ?? ''),
      'dim' => isset($data['dim']) ? (int) $data['dim'] : null,
      'vector_id' => (string) ($data['vector_id'] ?? ''),
      'namespace' => (string) ($data['namespace'] ?? ''),
      'collection' => (string) ($data['collection'] ?? ''),
      'content_hash' => (string) ($data['content_hash'] ?? ''),
      'status' => (string) ($data['status'] ?? 'queued'),
      'error' => array_key_exists('error', $data) ? $data['error'] : null,
      'upserted_at' => $data['upserted_at'] ?? null,
      'created_at' => current_time('mysql'),
      'updated_at' => current_time('mysql'),
      'vector' => $vector_value,
    ];

    // Build formats dynamically
    $fmts = [];
    foreach ($payload as $k => $value) {
      $fmts[] = is_int($value) ? '%d' : '%s';
    }

    // phpcs:ignore
    $ok = $wpdb->insert($table, $payload, $fmts);

    return $ok ? (int) $wpdb->insert_id : 0;
  }

  /**
   * Get a row by logical unique key (post_id, chunk_index, namespace).
   *
   * @return array<string,mixed>|null
   */
  public function getByKey(string $env_id, int $post_id, int $chunk_index, string $ns = ''): ?array {
    global $wpdb;

    $table = $this->table();

    // phpcs:ignore
    $row = $wpdb->get_row(
      $wpdb->prepare(
        // phpcs:ignore
        "SELECT * FROM {$table} WHERE env_id=%s AND post_id=%d AND chunk_index=%d AND namespace=%s LIMIT 1",
        $env_id,
        $post_id,
        $chunk_index,
        $ns
      ),
      ARRAY_A
    );

    return $row ?: null;
  }

  /**
   * Get a row by vector_id.
   *
   * @return array<string,mixed>|null
   */
  public function getByVectorId(string $vector_id): ?array {
    global $wpdb;

    $table = $this->table();

    if ($vector_id === '') {
      return null;
    }

    // phpcs:ignore
    $row = $wpdb->get_row(
      // phpcs:ignore
      $wpdb->prepare("SELECT * FROM {$table} WHERE vector_id=%s LIMIT 1", $vector_id),
      ARRAY_A
    );

    return $row ?: null;
  }

  /**
   * Insert or update by the logical unique key (post_id, chunk_index, namespace).
   * Uses INSERT ... ON DUPLICATE KEY UPDATE so we don't lose created_at.
   *
   * @param array<string,mixed> $data
   * @throws \InvalidArgumentException If a required key is missing.
   * @return int Rows affected (0=unchanged, 1=insert, 2=updated)
   */
  public function upsertByKey(array $data): int {
    global $wpdb;

    $table = $this->table();

    foreach (['env_id', 'post_id', 'chunk_index', 'namespace'] as $req) {
      if (!array_key_exists($req, $data)) {
        throw new \InvalidArgumentException(sprintf('Missing required key "%s" for upsertByKey', esc_html($req)));
      }
    }

    $cols = [
      'env_id' => '%s',
      'vector_provider' => '%s',
      'collection' => '%s',
      'post_id' => '%d',
      'chunk_index' => '%d',
      'lang' => '%s',
      'heading_path' => '%s',
      'start_char' => '%d',
      'end_char' => '%d',
      'token_count' => '%d',
      'model' => '%s',
      'dim' => '%d',
      'vector_id' => '%s',
      'namespace' => '%s',
      'content_hash' => '%s',
      'status' => '%s',
      'error' => '%s',
      'upserted_at' => '%s',
      'created_at' => '%s',
      'updated_at' => '%s',
      'vector' => '%s',
    ];

    $now = current_time('mysql');

    $insert = [
      'env_id' => (string) ($data['env_id'] ?? ''),
      'vector_provider' => (string) ($data['vector_provider'] ?? ''),
      'collection' => (string) ($data['collection'] ?? ''),
      'post_id' => (int) ($data['post_id'] ?? 0),
      'chunk_index' => (int) ($data['chunk_index'] ?? 0),
      'lang' => (string) ($data['lang'] ?? 'en'),
      'heading_path' => $data['heading_path'] ?? null,
      'start_char' => isset($data['start_char']) ? (int) $data['start_char'] : null,
      'end_char' => isset($data['end_char']) ? (int) $data['end_char'] : null,
      'token_count' => isset($data['token_count']) ? (int) $data['token_count'] : null,
      'model' => (string) ($data['model'] ?? ''),
      'dim' => isset($data['dim']) ? (int) $data['dim'] : null,
      'vector_id' => (string) ($data['vector_id'] ?? ''),
      'namespace' => (string) ($data['namespace'] ?? ''),
      'content_hash' => (string) ($data['content_hash'] ?? ''),
      'status' => (string) ($data['status'] ?? 'queued'),
      'error' => array_key_exists('error', $data) ? $data['error'] : null,
      'upserted_at' => $data['upserted_at'] ?? null,
      'created_at' => $data['created_at'] ?? $now,
      'updated_at' => $data['updated_at'] ?? $now,
      'vector' => array_key_exists('vector', $data) ? $this->encodeVector($data['vector']) : null,
    ];

    $insert_cols = array_keys($insert);
    $placeholders = [];
    $values = [];

    foreach ($insert_cols as $col) {
      $placeholders[] = $cols[$col];
      $values[] = $insert[$col];
    }

    $update_parts = [];
    foreach ($insert_cols as $col) {
      if ($col === 'created_at') {
        $update_parts[] = "`{$col}` = `{$col}`";
      }
      else {
        $update_parts[] = "`{$col}` = VALUES(`{$col}`)";
      }
    }

    $sql = sprintf(
      'INSERT INTO %s (%s) VALUES (%s) ON DUPLICATE KEY UPDATE %s',
      $table,
      implode(',', array_map(fn($col) => "`{$col}`", $insert_cols)),
      implode(',', $placeholders),
      implode(', ', $update_parts)
    );

    // phpcs:ignore
    $prepared = $wpdb->prepare($sql, $values);

    // phpcs:ignore
    $wpdb->query($prepared);

    return (int) $wpdb->rows_affected;
  }

  /**
   * Update a row by vector_id (only the provided columns; no unintended NULLs).
   *
   * @param string $vector_id
   * @param array<string,mixed> $data
   * @return int Number of rows affected
   */
  public function updateByVectorId(string $vector_id, array $data): int {
    global $wpdb;

    if ($vector_id === '') {
      return 0;
    }

    $table = $this->table();

    if (array_key_exists('vector', $data)) {
      $data['vector'] = $this->encodeVector($data['vector']);
    }

    if (!array_key_exists('updated_at', $data)) {
      $data['updated_at'] = current_time('mysql');
    }

    $allowed = [
      'env_id',
      'vector_provider',
      'collection',
      'post_id',
      'chunk_index',
      'lang',
      'heading_path',
      'start_char',
      'end_char',
      'token_count',
      'model',
      'dim',
      'namespace',
      'content_hash',
      'status',
      'error',
      'upserted_at',
      'updated_at',
      'vector',
    ];

    $filtered = array_intersect_key($data, array_flip($allowed));
    if (!$filtered) {
      return 0;
    }

    $formats = [];
    foreach ($filtered as $value) {
      $formats[] = is_int($value) ? '%d' : '%s';
    }

    // phpcs:ignore
    $ok = $wpdb->update($table, $filtered, ['vector_id' => $vector_id], $formats, ['%s']);

    return ($ok === false) ? 0 : (int) $ok;
  }

  /**
   * List embeddings with pagination / search / sort.
   * DB-only: does not enrich with post title/url/slug.
   *
   * @param array<string,mixed> $args
   * @throws \InvalidArgumentException If env_id is missing.
   * @return array{items: array<int,array<string,mixed>>, total: int}
   */
  public function listPaginated(array $args = []): array {
    global $wpdb;

    $table = $this->table();
    $page = max(1, (int) ($args['page'] ?? 1));
    $per_page = max(1, min(200, (int) ($args['per_page'] ?? 20)));
    $search = trim((string) ($args['search'] ?? ''));
    $namespace = isset($args['namespace']) && $args['namespace'] !== '' ? (string) $args['namespace'] : null;
    $post_id = isset($args['post_id']) && $args['post_id'] ? (int) $args['post_id'] : null;

    $env_id = (string) ($args['env_id'] ?? '');
    if ($env_id === '') {
      throw new \InvalidArgumentException('env_id is required for listPaginated');
    }

    $orderby = (string) ($args['orderby'] ?? 'updated_at');
    $allowed = ['post_id', 'chunk_index', 'namespace', 'status', 'model', 'dim', 'token_count', 'updated_at', 'vector_id'];
    if (!in_array($orderby, $allowed, true)) {
      $orderby = 'updated_at';
    }

    $order = strtolower((string) ($args['order'] ?? 'desc'));
    if (!in_array($order, ['asc', 'desc'], true)) {
      $order = 'desc';
    }

    $wheres = [];
    $params = [];

    $wheres[] = 'e.env_id = %s';
    $params[] = $env_id;

    if ($namespace !== null) {
      $wheres[] = 'e.namespace = %s';
      $params[] = $namespace;
    }

    if ($post_id !== null) {
      $wheres[] = 'e.post_id = %d';
      $params[] = $post_id;
    }

    if ($search !== '') {
      $like = '%' . $wpdb->esc_like($search) . '%';
      $wheres[] = '(e.vector_id LIKE %s OR e.namespace LIKE %s OR e.model LIKE %s'
      . ' OR e.heading_path LIKE %s OR CAST(e.post_id AS CHAR) LIKE %s)';
      array_push($params, $like, $like, $like, $like, $like);
    }

    $where_sql = $wheres ? ('WHERE ' . implode(' AND ', $wheres)) : '';

    // phpcs:ignore
    $total = (int) $wpdb->get_var(
      $wpdb->prepare(
        // phpcs:ignore
        "SELECT COUNT(*) FROM {$table} e {$where_sql}",
        ...$params
      )
    );

    $offset = ($page - 1) * $per_page;

    $select = 'e.id, e.vector_id, e.post_id, e.chunk_index, e.namespace, e.model,'
    . ' e.dim, e.token_count, e.status, e.heading_path, e.content_hash,'
    . ' e.upserted_at, e.updated_at, e.created_at, e.lang';

    // phpcs:disable
    $prepared_list = $wpdb->prepare(
      "SELECT {$select}
        FROM {$table} e
        {$where_sql}
        ORDER BY e.{$orderby} {$order}
        LIMIT %d OFFSET %d",
      ...array_merge($params, [$per_page, $offset])
    );
    // phpcs:enable
    // phpcs:ignore
    $items = $wpdb->get_results($prepared_list, ARRAY_A) ?: [];

    return [
      'items' => $items,
      'total' => $total,
    ];
  }

  /**
   * Delete rows by vector_id list.
   *
   * @param array<int,string> $ids
   * @return int Rows affected
   */
  public function deleteByVectorIds(array $ids): int {
    global $wpdb;

    $ids = array_values(array_filter(array_map('trim', $ids)));
    if (!$ids) {
      return 0;
    }

    $table = $this->table();
    $placeholders = implode(',', array_fill(0, count($ids), '%s'));
    // phpcs:ignore
    $prepared_delete = $wpdb->prepare("DELETE FROM {$table} WHERE vector_id IN ({$placeholders})", ...$ids);
    // phpcs:ignore
    $ok = $wpdb->query($prepared_delete);

    return (int) ($ok ?: 0);
  }

  /**
   * Return per-post sync status for a given env_id.
   *
   * @param string $env_id
   * @param int[] $post_ids
   * @return array<int,array{synced:bool,synced_at:?string}>
   */
  public function getPostsStatusForEnv(string $env_id, array $post_ids): array {
    global $wpdb;

    $post_ids = array_values(array_filter(array_map('intval', $post_ids)));
    if (!$post_ids) {
      return [];
    }

    $table = $this->table();
    $placeholders = implode(',', array_fill(0, count($post_ids), '%d'));

    $sql = "
    SELECT post_id,
           MAX(upserted_at) AS synced_at,
           MAX(CASE WHEN status = 'upserted' THEN 1 ELSE 0 END) AS is_synced
    FROM {$table}
    WHERE env_id = %s
      AND post_id IN ({$placeholders})
    GROUP BY post_id
  ";

    $args = array_merge([$env_id], $post_ids);

    // phpcs:ignore
    $rows = $wpdb->get_results(
      // phpcs:ignore
      $wpdb->prepare($sql, ...$args),
      ARRAY_A
    ) ?: [];

    $out = [];
    foreach ($rows as $row) {
      $pid = (int) $row['post_id'];
      $out[$pid] = [
        'synced' => ((int) $row['is_synced']) === 1,
        'synced_at' => $row['synced_at'] ?: null,
      ];
    }

    return $out;
  }

  /**
   * Return true if at least one embedding row exists for the given post.
   *
   * @param int $post_id Post ID to check.
   * @return bool
   */
  public function hasEmbedding(int $post_id): bool {
    global $wpdb;

    $table = $this->table();
    // phpcs:ignore WordPress.DB.DirectDatabaseQuery
    $count = $wpdb->get_var(
      $wpdb->prepare(
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        "SELECT 1 FROM {$table} WHERE post_id = %d LIMIT 1",
        $post_id
      )
    );

    return $count !== null;
  }
}
