<?php
/**
 * Activation / deactivation / DB schema management.
 *
 * Creates:
 *  - wp_findastra_vectors  — one row per indexed product (packed float32 BLOB)
 *  - wp_findastra_searches — one row per shopper query (analytics)
 *
 * Idempotent via dbDelta. On version bump, also re-runs to apply schema diffs.
 *
 * @package FindAstra
 */

defined( 'ABSPATH' ) || exit;

final class FindAstra_Activator {

	public const TABLE                  = 'findastra_vectors';
	public const SEARCH_TABLE           = 'findastra_searches';
	public const DB_VERSION_OPTION      = 'findastra_db_version';
	public const DB_VERSION             = '4';
	public const ACTIVATION_FLAG_OPTION = 'findastra_just_activated';
	public const FULLTEXT_INDEX_NAME    = 'findastra_ft';
	public const FULLTEXT_STATUS_OPTION = 'findastra_fulltext_status';

	public static function activate(): void {
		self::ensure_schema();
		self::seed_default_options();
		add_option( self::ACTIVATION_FLAG_OPTION, '1', '', false );
	}

	public static function deactivate(): void {
		// Stop background indexing: once deactivated the FindAstra_Background_Indexer
		// hook handler is no longer attached, so any queued chunk would otherwise
		// fail silently in Action Scheduler. uninstall() handles the rest of cleanup.
		if ( function_exists( 'as_unschedule_all_actions' ) ) {
			as_unschedule_all_actions( 'findastra_index_chunk', array(), 'findastra' );
		}
		$state = get_option( 'findastra_bg_index', array() );
		if ( is_array( $state ) && 'running' === ( $state['status'] ?? '' ) ) {
			$state['status'] = 'cancelled';
			update_option( 'findastra_bg_index', $state, false );
		}
	}

	/**
	 * Plugin uninstall cleanup. Called by Freemius via the `after_uninstall`
	 * action when a buyer deletes the plugin from the Plugins screen.
	 * Touches ONLY plugin-owned objects:
	 *   - tables: wp_findastra_vectors, wp_findastra_searches
	 *   - options: findastra_settings, findastra_db_version,
	 *              findastra_onboarding_done, findastra_just_activated
	 *
	 * Freemius-owned options (fs_active_plugins, fs_accounts, etc.) are
	 * deliberately NOT touched — they belong to the SDK and other
	 * Freemius-using plugins on the same site rely on them.
	 */
	public static function uninstall(): void {
		global $wpdb;

		$tables = array(
			$wpdb->prefix . self::TABLE,
			$wpdb->prefix . self::SEARCH_TABLE,
		);
		foreach ( $tables as $table ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
			$wpdb->query( "DROP TABLE IF EXISTS {$table}" );
		}

		$options = array(
			'findastra_settings',
			self::DB_VERSION_OPTION,
			'findastra_onboarding_done',
			self::ACTIVATION_FLAG_OPTION,
			self::FULLTEXT_STATUS_OPTION,
			'findastra_bg_index', // FindAstra_Background_Indexer::PROGRESS_OPTION
		);
		foreach ( $options as $opt ) {
			delete_option( $opt );
		}

		// Drop any queued background-index actions so Action Scheduler doesn't
		// keep firing a hook whose handler no longer exists after removal.
		if ( function_exists( 'as_unschedule_all_actions' ) ) {
			as_unschedule_all_actions( 'findastra_index_chunk', array(), 'findastra' );
		}
	}

	public static function ensure_schema(): void {
		global $wpdb;

		$vectors  = $wpdb->prefix . self::TABLE;
		$searches = $wpdb->prefix . self::SEARCH_TABLE;
		$charset  = $wpdb->get_charset_collate();

		// v0.10.0: `language` column + composite PK (product_id, language) so a
		// WPML / Polylang translated product can have one embedding per locale.
		// Legacy rows that come over from v0.9.x get `language=''` via the
		// column default and are treated as "site default language" at search
		// time. The composite PK accepts (X, '') as a valid unique key, so the
		// migration is non-destructive — see ensure_language_primary_key().
		$vectors_sql = "CREATE TABLE {$vectors} (
			product_id BIGINT(20) UNSIGNED NOT NULL,
			language VARCHAR(8) NOT NULL DEFAULT '',
			dims SMALLINT UNSIGNED NOT NULL,
			provider VARCHAR(32) NOT NULL,
			model VARCHAR(128) NOT NULL,
			vector LONGBLOB NOT NULL,
			text_hash CHAR(64) NOT NULL,
			indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
			PRIMARY KEY  (product_id, language),
			KEY provider_model (provider, model),
			KEY indexed_at (indexed_at),
			KEY language (language)
		) {$charset};";

		$searches_sql = "CREATE TABLE {$searches} (
			id BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT,
			query VARCHAR(200) NOT NULL,
			result_count SMALLINT UNSIGNED NOT NULL DEFAULT 0,
			provider VARCHAR(32) NOT NULL DEFAULT '',
			query_time_ms INT UNSIGNED NOT NULL DEFAULT 0,
			searched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
			PRIMARY KEY  (id),
			KEY searched_at (searched_at),
			KEY result_count (result_count),
			KEY query_lookup (query(50))
		) {$charset};";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		// On v0.9.x → v0.10.x upgrades the vectors table already exists with PK
		// on product_id alone. dbDelta adds the `language` column for us but
		// then naively attempts to re-add the new composite PRIMARY KEY without
		// dropping the old single-column one — that logs a noisy "Multiple
		// primary key defined" error. Suppress just for that call; we fix the
		// PK explicitly below.
		$suppress = $wpdb->suppress_errors();
		dbDelta( $vectors_sql );
		$wpdb->suppress_errors( $suppress );
		dbDelta( $searches_sql );

		// dbDelta refuses to reshape an existing PRIMARY KEY. Promote the v0.9.x
		// vectors table from PK(product_id) to PK(product_id, language). All
		// legacy rows have language='' so the composite is unique by
		// construction. Idempotent — checks current PK shape first.
		self::ensure_language_primary_key();

		self::ensure_fulltext_index();

		update_option( self::DB_VERSION_OPTION, self::DB_VERSION, false );
	}

	/**
	 * Ensure the vectors table's PRIMARY KEY is the composite (product_id, language).
	 * Fresh v0.10.0 installs already get the right shape from dbDelta(); this
	 * method's job is to migrate existing v0.9.x installs whose PK is still on
	 * product_id alone. All legacy rows have language='' (from the column's
	 * default), so the new composite is unique by construction.
	 *
	 * Idempotent: re-running once the migration has been applied is a no-op.
	 */
	public static function ensure_language_primary_key(): bool {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE;

		// Schema-introspection read against INFORMATION_SCHEMA; not cacheable and
		// has no $wpdb wrapper.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$cols = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.STATISTICS
				 WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = 'PRIMARY'
				 ORDER BY SEQ_IN_INDEX",
				$table
			)
		);
		if ( is_array( $cols ) && count( $cols ) === 2 && $cols[0] === 'product_id' && $cols[1] === 'language' ) {
			return true; // already migrated
		}

		$suppress = $wpdb->suppress_errors();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
		$ok = $wpdb->query( "ALTER TABLE {$table} DROP PRIMARY KEY, ADD PRIMARY KEY (product_id, language)" );
		$wpdb->suppress_errors( $suppress );

		return false !== $ok;
	}

	/**
	 * Ensure the FULLTEXT index on wp_posts(post_title, post_content) used by
	 * the hybrid retrieval path exists. Idempotent; safe to call on every
	 * upgrade. Failures are swallowed and recorded in the status option so
	 * Search_Engine knows to skip the hybrid path on this host.
	 *
	 * Reasons it might fail: MyISAM table on an old MySQL build, InnoDB
	 * without FULLTEXT support (MySQL < 5.6 — we don't claim to support
	 * those), DB user without ALTER privilege, replication setups that
	 * forbid online ALTER. All survivable; the plugin just falls back to
	 * the full-cosine path that was working before.
	 */
	public static function ensure_fulltext_index(): bool {
		global $wpdb;

		// Schema-introspection read against INFORMATION_SCHEMA; not cacheable and
		// has no $wpdb wrapper.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$existing = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s LIMIT 1",
				$wpdb->posts,
				self::FULLTEXT_INDEX_NAME
			)
		);
		if ( '1' === (string) $existing ) {
			update_option( self::FULLTEXT_STATUS_OPTION, 'installed', false );
			return true;
		}

		$suppress = $wpdb->suppress_errors();
		// One-time FULLTEXT index DDL on the trusted {$wpdb->posts} table; the index name is a
		// class constant and there is no user input. The schema-change, no-caching and
		// not-prepared sniffs are intentional for this DDL statement.
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
		$ok = $wpdb->query( "ALTER TABLE {$wpdb->posts} ADD FULLTEXT INDEX " . self::FULLTEXT_INDEX_NAME . " (post_title, post_content)" );
		$wpdb->suppress_errors( $suppress );

		if ( false === $ok ) {
			update_option( self::FULLTEXT_STATUS_OPTION, 'unavailable', false );
			return false;
		}
		update_option( self::FULLTEXT_STATUS_OPTION, 'installed', false );
		return true;
	}

	public static function maybe_upgrade(): void {
		if ( get_option( self::DB_VERSION_OPTION ) !== self::DB_VERSION ) {
			self::ensure_schema();
		}
	}

	private static function seed_default_options(): void {
		if ( false === get_option( 'findastra_settings' ) ) {
			add_option(
				'findastra_settings',
				array(
					'provider'   => 'local',
					'model'      => 'Xenova/bge-small-en-v1.5',
					'dims'       => 384,
					'openai_key' => '',
					'hf_token'   => '',
				),
				'',
				false
			);
		}
	}
}
