<?php
/**
 * Builder-owned RAG chunk storage.
 *
 * @package Antimanual_Builder
 * @since   1.6.0
 */

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

/**
 * Class AMBLD_Rag_Store
 *
 * Custom table holding embedded chunks of builder-side content
 * (ambld_template, ambld_component, registered shortcodes, doc/ARCHITECTURE.md).
 * Independent of the parent Antimanual KB so the builder can index its
 * own corpus without round-tripping through the parent plugin.
 */
class AMBLD_Rag_Store {

	const TABLE = 'ambld_rag_chunks';

	public static function table_name() {
		global $wpdb;
		return $wpdb->prefix . self::TABLE;
	}

	public static function create_table() {
		global $wpdb;
		$table_name      = self::table_name();
		$charset_collate = $wpdb->get_charset_collate();

		// helpfulness / use_count / last_used_at are written by the behavior
		// feedback loop (accept = +1, regenerate = -1 per source per turn) and
		// applied as a bounded rerank multiplier in AMBLD_Rag_Retriever. Schema
		// version is bumped to '2' in class-antimanual-builder.php so existing
		// installs pick them up via dbDelta.
		$sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			source_type varchar(32) NOT NULL,
			source_id varchar(190) NOT NULL,
			source_label varchar(255) NOT NULL DEFAULT '',
			chunk_index smallint(5) unsigned NOT NULL DEFAULT 0,
			content longtext NOT NULL,
			embedding longblob NOT NULL,
			embedding_model varchar(64) NOT NULL DEFAULT '',
			content_hash char(40) NOT NULL DEFAULT '',
			helpfulness int(11) NOT NULL DEFAULT 0,
			use_count int(11) unsigned NOT NULL DEFAULT 0,
			last_used_at datetime NULL DEFAULT NULL,
			updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
			PRIMARY KEY (id),
			KEY source_lookup (source_type, source_id),
			KEY content_hash (content_hash)
		) {$charset_collate};";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );
	}

	/**
	 * Pack a float[] embedding into a compact little-endian binary blob.
	 *
	 * Float32 keeps the table at 1.5KB/chunk for 384-dim and 6KB for 1536-dim
	 * embeddings — small enough to load every row into memory for cosine
	 * scoring on builder-scale corpora (hundreds of templates, not millions).
	 */
	public static function pack( array $vector ) {
		return pack( 'f*', ...array_map( 'floatval', $vector ) );
	}

	public static function unpack( $blob ) {
		if ( ! is_string( $blob ) || '' === $blob ) {
			return array();
		}
		$out = unpack( 'f*', $blob );
		return is_array( $out ) ? array_values( $out ) : array();
	}

	/**
	 * Replace all chunks for a given source.
	 */
	public static function replace_source( $source_type, $source_id, array $chunks, $embedding_model ) {
		global $wpdb;
		$table = self::table_name();
		$wpdb->delete( $table, array( 'source_type' => $source_type, 'source_id' => (string) $source_id ) );

		foreach ( $chunks as $i => $chunk ) {
			if ( empty( $chunk['embedding'] ) || empty( $chunk['content'] ) ) {
				continue;
			}
			$wpdb->insert(
				$table,
				array(
					'source_type'     => $source_type,
					'source_id'       => (string) $source_id,
					'source_label'    => isset( $chunk['label'] ) ? (string) $chunk['label'] : '',
					'chunk_index'     => $i,
					'content'         => (string) $chunk['content'],
					'embedding'       => self::pack( $chunk['embedding'] ),
					'embedding_model' => (string) $embedding_model,
					'content_hash'    => sha1( (string) $chunk['content'] ),
				),
				array( '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s' )
			);
		}
	}

	public static function delete_source( $source_type, $source_id ) {
		global $wpdb;
		$wpdb->delete( self::table_name(), array( 'source_type' => $source_type, 'source_id' => (string) $source_id ) );
	}

	/**
	 * Load all chunks (optionally filtered by source_type) for in-memory scoring.
	 *
	 * @param string[] $types Source types to include. Empty = all.
	 * @return array<int,array{id:int,source_type:string,source_id:string,source_label:string,content:string,embedding:array}>
	 */
	public static function load_all( array $types = array() ) {
		global $wpdb;
		$table = self::table_name();
		// Defensive: if the schema migration hasn't run yet (e.g. an older
		// install loading new code mid-upgrade), don't spam wpdb errors —
		// just return an empty corpus so retrieval gracefully degrades.
		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( $exists !== $table ) {
			return array();
		}
		// Column list is a fixed, code-defined whitelist — not user input.
		$columns = 'id, source_type, source_id, source_label, content, embedding, helpfulness, use_count';
		if ( ! empty( $types ) ) {
			$placeholders = implode( ',', array_fill( 0, count( $types ), '%s' ) );
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			$rows = $wpdb->get_results( $wpdb->prepare( "SELECT {$columns} FROM `{$table}` WHERE source_type IN ($placeholders)", ...$types ), ARRAY_A );
		} else {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			$rows = $wpdb->get_results( "SELECT {$columns} FROM `{$table}`", ARRAY_A );
		}
		if ( ! is_array( $rows ) ) {
			return array();
		}
		foreach ( $rows as &$row ) {
			$row['embedding']   = self::unpack( $row['embedding'] );
			$row['id']          = (int) $row['id'];
			$row['helpfulness'] = isset( $row['helpfulness'] ) ? (int) $row['helpfulness'] : 0;
			$row['use_count']   = isset( $row['use_count'] ) ? (int) $row['use_count'] : 0;
		}
		return $rows;
	}

	/**
	 * Adjust the helpfulness score for every chunk of a given source.
	 *
	 * Bounded: floors at -5, caps at +20. Keeps the rerank multiplier within
	 * a safe band (±0.25..+0.5 inside the retriever).
	 *
	 * @param string $source_type Source type filter.
	 * @param string $source_id   Source id filter.
	 * @param int    $delta       Signed delta to apply.
	 * @return void
	 */
	public static function bump_helpfulness( $source_type, $source_id, $delta ) {
		global $wpdb;
		$delta = (int) $delta;
		if ( 0 === $delta ) {
			return;
		}
		$table = self::table_name();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query( $wpdb->prepare(
			"UPDATE `{$table}` SET helpfulness = LEAST(20, GREATEST(-5, helpfulness + %d)) WHERE source_type = %s AND source_id = %s",
			$delta,
			(string) $source_type,
			(string) $source_id
		) );
	}

	/**
	 * Mark a set of rows as recently used. Increments use_count and writes
	 * `last_used_at = NOW()` so future admin diagnostics can show which seed
	 * corpus items are actually informing generation.
	 *
	 * @param int[] $ids Row ids returned by retrieval.
	 * @return void
	 */
	public static function touch_used( array $ids ) {
		global $wpdb;
		$ids = array_values( array_unique( array_filter( array_map( 'intval', $ids ) ) ) );
		if ( empty( $ids ) ) {
			return;
		}
		$table        = self::table_name();
		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query( $wpdb->prepare(
			"UPDATE `{$table}` SET use_count = use_count + 1, last_used_at = %s WHERE id IN ($placeholders)",
			current_time( 'mysql', true ),
			...$ids
		) );
	}

	/**
	 * Count chunks grouped by source_type — used by the admin status panel.
	 *
	 * @return array<string,int> Map of source_type → row count.
	 */
	public static function counts_by_type() {
		global $wpdb;
		$table = self::table_name();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results( "SELECT source_type, COUNT(*) AS n FROM `{$table}` GROUP BY source_type", ARRAY_A );
		$out  = array();
		if ( is_array( $rows ) ) {
			foreach ( $rows as $row ) {
				$out[ (string) $row['source_type'] ] = (int) $row['n'];
			}
		}
		return $out;
	}

	public static function count_chunks() {
		global $wpdb;
		$table = self::table_name();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM `{$table}`" );
	}

	public static function content_hash_exists( $source_type, $source_id, $content_hash ) {
		global $wpdb;
		$table = self::table_name();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
		return (bool) $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM `{$table}` WHERE source_type = %s AND source_id = %s AND content_hash = %s LIMIT 1", $source_type, (string) $source_id, $content_hash ) );
	}
}
