<?php

namespace Limb_Chatbot\Includes\Migrations;


use Limb_Chatbot\Includes\AI_Providers\Claude\Seeders\AI_Models_Seeder as Claude_Models_Seeder;
use Limb_Chatbot\Includes\AI_Providers\Deep_Seek\Seeders\AI_Models_Seeder as Deep_Seek_Models_Seeder;
use Limb_Chatbot\Includes\AI_Providers\Claude\Claude;
use Limb_Chatbot\Includes\AI_Providers\Deep_Seek\Deep_Seek;
use Limb_Chatbot\Includes\AI_Providers\Gemini\Gemini;
use Limb_Chatbot\Includes\AI_Providers\Gemini\Seeders\AI_Models_Seeder as Gemini_Models_Seeder;
use Limb_Chatbot\Includes\AI_Providers\Grok\Grok;
use Limb_Chatbot\Includes\AI_Providers\Open_Ai\Open_Ai;
use Limb_Chatbot\Includes\AI_Providers\Open_Router\Open_Router;
use Limb_Chatbot\Includes\AI_Providers\Grok\Seeders\AI_Models_Seeder as Grok_Models_Seeder;
use Limb_Chatbot\Includes\AI_Providers\Open_Ai\Seeders\AI_Models_Seeder as Open_Ai_Models_Seeder;
use Limb_Chatbot\Includes\AI_Providers\Open_Router\Seeders\AI_Models_Seeder as Open_Router_Models_Seeder;
use Limb_Chatbot\Includes\Data_Objects\AI_Model;
use Limb_Chatbot\Includes\Services\Channel_Events\Channel_Event_Repository;
use Limb_Chatbot\Includes\Data_Objects\Chatbot;
use Limb_Chatbot\Includes\Data_Objects\Chatbot_Meta;
use Limb_Chatbot\Includes\Data_Objects\Setting;
use Limb_Chatbot\Includes\Data_Schemas\Setting as Setting_Schema;
use Limb_Chatbot\Includes\Services\Notifications\Notification_Service;
use Limb_Chatbot\Includes\Services\Seeder_Service;

/**
 * Migration for version 2.0.0.
 *
 * Adds access roles and type defaults for vectors and actions.
 *
 * @since 2.0.0
 */
class Migration_2_0_0 extends Abstract_Migration {

	/**
	 * Special role allowing universal access.
	 */
	private const ROLE_EVERYONE = 'everyone';
	/**
	 * Default action type.
	 */
	private const ACTION_TYPE_DEFAULT = 1;
	/**
	 * Default action config for migrated rows.
	 */
	private const ACTION_DEFAULT_CONFIG = [
		'show_fields'   => 1,
		'callback_text' => null,
	];

	/**
	 * Models to delete during this migration.
	 *
	 * @since 2.0.0
	 */
	private const DEPRECATED_MODELS = [
		'claude-3-haiku-20240307',
		'gemini-2.5-flash',
		'gemini-2.5-flash-lite',
		'gemini-2.5-pro',
		'imagen-3.0-generate-002',
	];

	/**
	 * @inheritDoc
	 */
	public function get_version(): string {
		return '2.0.0';
	}

	/**
	 * @inheritDoc
	 */
	public function get_description(): string {
		return 'Add ip blocks table, migrate widgets to table, and add type/access_roles/config updates for vectors and actions.';
	}

	/**
	 * @inheritDoc
	 */
	public function up(): bool {
		// Seed missing unified notification setting for older installs.
		$this->seed_unified_notification_settings();

		if ( ! $this->migrate_ip_blocks_table() ) {
			return false;
		}
		if ( ! $this->migrate_widgets_table() ) {
			return false;
		}
		if ( ! $this->seed_default_widgets() ) {
			return false;
		}
		if ( ! $this->migrate_vectors() ) {
			return false;
		}
		if ( ! $this->migrate_actions() ) {
			return false;
		}

		if ( ! $this->create_events_table() ) {
			return false;
		}

		if ( ! $this->create_ratings_table() ) {
			return false;
		}

		if ( ! $this->migrate_chatbot_limits() ) {
			return false;
		}

		if ( ! $this->seed_ai_provider_models() ) {
			return false;
		}

		if ( ! $this->delete_deprecated_models() ) {
			return false;
		}

		if ( ! $this->seed_provider_settings() ) {
			return false;
		}

		if (!$this->migrate_chatbot_settings()){
			return false;
		}



		$this->log( 'Migration 2.0.0 completed successfully.' );

		return true;
	}

	/**
	 * Seed missing `lbaic.plugin.notifications` setting for older installs.
	 *
	 * Also migrates legacy internal notification role slugs (e.g. `admin`)
	 * into native WP role slugs (e.g. `administrator`) and normalizes the
	 * shape to `visibility.roles/custom_emails`.
	 *
	 * @return void
	 * @since 2.0.1
	 */
	private function seed_unified_notification_settings(): void {
		$key = 'lbaic.plugin.notifications';

		if ( ! Setting::exists( $key ) ) {
			Setting::create(
				[
					'key'   => $key,
					'value' => Notification_Service::get_defaults(),
				]
			);
		}
	}

	/**
	 * Create IP blocks table if needed.
	 *
	 * @return bool
	 * @since 2.0.0
	 */
	private function migrate_ip_blocks_table(): bool {
		$ip_blocks_table = $this->wpdb->prefix . 'lbaic_ip_blocks';
		if ( ! $this->table_exists( $ip_blocks_table ) ) {
			$collate = $this->wpdb->get_charset_collate();
			$schema  = "CREATE TABLE {$ip_blocks_table} (
				id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
				ip varchar(45) NOT NULL,
				location varchar(255) NULL,
				reason text NULL,
				type varchar(20) DEFAULT 'manual' NOT NULL,
				created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
				blocked_until datetime NULL,
				chatbot_user_id bigint(20) unsigned NULL,
				PRIMARY KEY (id),
				KEY ip (ip),
				KEY chatbot_user_id (chatbot_user_id),
				KEY blocked_until (blocked_until),
				KEY type (type),
				KEY created_at (created_at)
			) {$collate};";
			$this->db_delta( $schema );
		}

		if ( ! $this->table_exists( $ip_blocks_table ) ) {
			$this->log( "Failed creating ip blocks table {$ip_blocks_table}", 'error' );

			return false;
		}

		return true;
	}

	/**
	 * Create widgets table if needed and migrate existing legacy widgets.
	 *
	 * @return bool
	 */
	private function migrate_widgets_table(): bool {
		$widgets_table = $this->wpdb->prefix . 'lbaic_widgets';
		if ( ! $this->table_exists( $widgets_table ) ) {
			$collate = $this->wpdb->get_charset_collate();
			$schema  = "CREATE TABLE {$widgets_table} (
				id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
				uuid char(36) NOT NULL,
				type varchar(64) NOT NULL,
				published tinyint(1) DEFAULT 1 NOT NULL,
				config longtext NULL,
				locations longtext NULL,
				visibility longtext NULL,
				`order` int(11) DEFAULT 0 NOT NULL,
				created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
				updated_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
				PRIMARY KEY (id),
				UNIQUE KEY uuid (uuid),
				KEY type (type),
				KEY published (published),
				KEY `order` (`order`),
				KEY created_at (created_at),
				KEY updated_at (updated_at)
			) {$collate};";
			$this->db_delta( $schema );
		}

		if ( ! $this->table_exists( $widgets_table ) ) {
			$this->log( "Failed creating widgets table {$widgets_table}", 'error' );

			return false;
		}

		return true;
	}

	/**
	 * Seed default widgets for installs that do not have any widgets yet.
	 *
	 * @return bool
	 * @since 2.0.0
	 */
	private function seed_default_widgets(): bool {
		try {
			( new Seeder_Service( false, true ) )->seed_default_widgets_once();
		} catch ( \Exception $e ) {
			$this->log( 'Failed seeding default widgets: ' . $e->getMessage(), 'error' );
			return false;
		}

		return true;
	}

	/**
	 * Add/access_roles for vectors and backfill existing rows.
	 *
	 * @return bool
	 */
	private function migrate_vectors(): bool {
		$vectors_table = $this->wpdb->prefix . 'lbaic_vectors';
		if ( ! $this->table_exists( $vectors_table ) ) {
			$this->log( "Table {$vectors_table} does not exist, skipping." );

			return true;
		}

		if ( ! $this->column_exists( $vectors_table, 'access_roles' ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$result = $this->wpdb->query( "ALTER TABLE `{$vectors_table}` ADD COLUMN access_roles longtext NULL AFTER config_id" );
			if ( false === $result ) {
				$this->log( 'Failed adding access_roles column: ' . $this->wpdb->last_error, 'error' );

				return false;
			}
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$updated = $this->wpdb->query(
			$this->wpdb->prepare(
				"UPDATE `{$vectors_table}` SET access_roles = %s",
				wp_json_encode( [ self::ROLE_EVERYONE ] )
			)
		);
		if ( false === $updated ) {
			$this->log( 'Failed setting default access_roles for existing vectors: ' . $this->wpdb->last_error,
				'error' );

			return false;
		}

		return true;
	}

	/**
	 * Add type/access_roles/config for actions and backfill existing rows.
	 *
	 * @return bool
	 */
	private function migrate_actions(): bool {
		$actions_table = $this->wpdb->prefix . 'lbaic_actions';
		if ( ! $this->table_exists( $actions_table ) ) {
			$this->log( "Table {$actions_table} does not exist, skipping." );

			return true;
		}

		if ( ! $this->column_exists( $actions_table, 'type' ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$result = $this->wpdb->query( "ALTER TABLE `{$actions_table}` ADD COLUMN `type` tinyint(1) DEFAULT 1 NOT NULL AFTER name" );
			if ( false === $result ) {
				$this->log( 'Failed adding type column to actions: ' . $this->wpdb->last_error, 'error' );

				return false;
			}
		}

		if ( ! $this->column_exists( $actions_table, 'access_roles' ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$result = $this->wpdb->query( "ALTER TABLE `{$actions_table}` ADD COLUMN access_roles longtext NULL AFTER `type`" );
			if ( false === $result ) {
				$this->log( 'Failed adding access_roles column to actions: ' . $this->wpdb->last_error, 'error' );

				return false;
			}
		}
		if ( ! $this->column_exists( $actions_table, 'config' ) ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			$result = $this->wpdb->query( "ALTER TABLE `{$actions_table}` ADD COLUMN config longtext NULL AFTER access_roles" );
			if ( false === $result ) {
				$this->log( 'Failed adding config column to actions: ' . $this->wpdb->last_error, 'error' );

				return false;
			}
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
		$updated = $this->wpdb->query(
			$this->wpdb->prepare(
				"UPDATE `{$actions_table}` SET access_roles = %s, `type` = %d, config = IF(config IS NULL OR config = '', %s, config)",
				wp_json_encode( [ self::ROLE_EVERYONE ] ),
				self::ACTION_TYPE_DEFAULT,
				wp_json_encode( self::ACTION_DEFAULT_CONFIG )
			)
		);
		if ( false === $updated ) {
			$this->log( 'Failed setting default access_roles/type/config for existing actions: ' . $this->wpdb->last_error,
				'error' );

			return false;
		}

		return true;
	}

	private function create_events_table() {
		$table = $this->wpdb->prefix . Channel_Event_Repository::TABLE_NAME;

		if ( $this->table_exists( $table ) ) {
			$this->log( "Table {$table} already exists — skipping." );

			return true;
		}

		$collate = $this->wpdb->get_charset_collate();
		$schema  = "CREATE TABLE {$table} (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			event_type varchar(64) NOT NULL,
			chat_uuid char(36) NULL,
			chatbot_user_uuid char(36) NULL,
			recipient_wp_user_id bigint(20) unsigned NOT NULL,
			payload longtext NOT NULL,
			is_read tinyint(1) DEFAULT 0 NOT NULL,
			created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
			PRIMARY KEY (id),
			KEY event_type (event_type),
			KEY chat_uuid (chat_uuid),
			KEY chatbot_user_uuid (chatbot_user_uuid),
			KEY recipient_wp_user_id (recipient_wp_user_id),
			KEY is_read (is_read),
			KEY created_at (created_at)
		) {$collate};";

		$this->db_delta( $schema );

		if ( ! $this->table_exists( $table ) ) {
			$this->log( "Failed to create table {$table}.", 'error' );

			return false;
		}

		$this->log( "Table {$table} created successfully." );

		return true;
	}

	/**
	 * Create ratings table if needed.
	 *
	 * @return bool
	 * @since 2.0.0
	 */
	private function create_ratings_table(): bool {
		$table = $this->wpdb->prefix . 'lbaic_ratings';

		if ( $this->table_exists( $table ) ) {
			$this->log( "Table {$table} already exists — skipping." );

			return true;
		}

		$collate = $this->wpdb->get_charset_collate();
		$schema  = "CREATE TABLE {$table} (
			id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
			from_type varchar(100) NULL,
			from_id bigint(20) unsigned NULL,
			target_type varchar(100) NULL,
			target_id bigint(20) unsigned NULL,
			rate int(11) NOT NULL,
			notes text NULL,
			metadata longtext NULL,
			chatbot_user_id bigint(20) unsigned NULL,
			created_at datetime DEFAULT '0000-00-00 00:00:00' NOT NULL,
			source_id varchar(191) NULL,
			PRIMARY KEY (id),
			KEY from_type (from_type),
			KEY from_id (from_id),
			KEY target_type (target_type),
			KEY target_id (target_id),
			KEY chatbot_user_id (chatbot_user_id),
			KEY created_at (created_at),
			KEY source_id (source_id)
		) {$collate};";

		$this->db_delta( $schema );

		if ( ! $this->table_exists( $table ) ) {
			$this->log( "Failed to create table {$table}.", 'error' );

			return false;
		}

		$this->log( "Table {$table} created successfully." );

		return true;
	}

	/**
	 * Replace old chatbot usage limits with the new tiered defaults.
	 *
	 * Applies to:
	 *  1. The default (non-post) chatbot stored in wp_options (`lbaic.utilities.chatbot.limits`).
	 *  2. Every published `lbaic_chatbot` CPT post via its `_limits` postmeta.
	 *
	 * The new defaults are sourced from the schema so that a single source of
	 * truth is maintained in Data_Schemas\Setting::chatbot_rules().
	 *
	 * @return bool
	 * @since 2.0.0
	 */
	private function migrate_chatbot_limits(): bool {
		$limits_key    = Setting::SETTING_PREFIX . Chatbot::SETTING_PREFIX . 'limits';
		$chatbot_rules = Setting_Schema::chatbot_rules();

		if ( ! isset( $chatbot_rules[ $limits_key ]['default'] ) ) {
			$this->log( 'Could not resolve default limits from schema — skipping limits migration.', 'error' );

			return false;
		}

		$new_defaults = $chatbot_rules[ $limits_key ]['default'];

		// 1. Update the default chatbot setting in wp_options.
		if ( Setting::exists( $limits_key ) ) {
			Setting::update( [ 'key' => $limits_key ], [ 'value' => $new_defaults ] );
			$this->log( 'Default chatbot limits updated in wp_options.' );
		}

		// Also update preview variant if it exists.
		$preview_key = $limits_key . Setting::PREVIEW_POSTFIX;
		if ( Setting::exists( $preview_key ) ) {
			Setting::update( [ 'key' => $preview_key ], [ 'value' => $new_defaults ] );
			$this->log( 'Default chatbot limits (preview) updated in wp_options.' );
		}

		// 2. Update every chatbot CPT post's _limits meta via Data Objects.
		$chatbots = Chatbot::where( [] );

		if ( $chatbots->is_empty() ) {
			$this->log( 'No chatbot posts found — nothing to migrate for postmeta limits.' );

			return true;
		}

		$updated_count    = 0;
		$preview_meta_key = '_limits' . Setting::PREVIEW_POSTFIX;

		foreach ( $chatbots->get() as $chatbot ) {
			if ( ! $chatbot instanceof Chatbot || empty( $chatbot->get_id() ) ) {
				continue;
			}

			// Update or create the _limits meta.
			$chatbot->update_parameter( 'limits', $new_defaults );
			++$updated_count;

			// Also update _limits.preview meta only if it already exists.
			if ( $chatbot->has_meta( $preview_meta_key ) ) {
				Chatbot_Meta::update(
					[ 'meta_key' => $preview_meta_key, 'chatbot_id' => $chatbot->get_id() ],
					[ 'meta_value' => $new_defaults ]
				);
			}
		}

		$this->log( "Chatbot limits migrated for {$updated_count} chatbot post(s)." );

		return true;
	}

	/**
	 * Seed AI models for all providers.
	 *
	 * Runs each provider's model seeder to create or update AI models
	 * with the latest configurations.
	 *
	 * @return bool True on success, false on failure.
	 * @since 2.0.0
	 */
	private function seed_ai_provider_models(): bool {
		$seeders = [
			'Open AI'     => new Open_Ai_Models_Seeder(),
			'Gemini'      => new Gemini_Models_Seeder(),
			'Claude'      => new Claude_Models_Seeder(),
			'Deep Seek'   => new Deep_Seek_Models_Seeder(),
			'Grok'        => new Grok_Models_Seeder(),
			'Open Router' => new Open_Router_Models_Seeder(),
		];

		foreach ( $seeders as $provider_name => $seeder ) {
			try {
				$this->log( sprintf( 'Seeding %s AI models', $provider_name ) );
				$seeder->run();
				$this->log( sprintf( '%s AI models seeded successfully', $provider_name ) );
			} catch ( \Exception $e ) {
				$this->log( sprintf( 'Error seeding %s models: %s', $provider_name, $e->getMessage() ), 'error' );

				return false;
			}
		}

		return true;
	}

	/**
	 * Seed default settings for all AI providers.
	 *
	 * Iterates over every registered provider, resolves its Data_Schemas\Setting
	 * class, and creates any missing settings with their schema defaults.
	 *
	 * @return bool True on success, false on failure.
	 * @since 2.0.0
	 */
	private function seed_provider_settings(): bool {
		try {
			$this->log( 'Seeding provider settings from data schemas' );

			$provider_rules = [];
			$providers      = [
				new Open_Ai(),
				new Gemini(),
				new Claude(),
				new Deep_Seek(),
				new Grok(),
				new Open_Router(),
			];

			foreach ( $providers as $ai_provider ) {
				$settings = "Limb_Chatbot\\Includes\\AI_Providers\\{$ai_provider::$name}\\Data_Schemas\\Setting";
				if ( class_exists( $settings ) ) {
					$provider_rules = array_merge( $provider_rules, $settings::rules() );
				}
			}

			$settings_created = 0;
			foreach ( $provider_rules as $key => $rule ) {
				if ( isset( $rule['default'] ) && ! Setting::exists( $key ) ) {
					$default_value = $rule['default'];

					if ( $default_value !== null ) {
						$this->maybe_add_setting( $key, $default_value );
						$settings_created ++;
					}
				}
			}

			if ( $settings_created > 0 ) {
				$this->log( sprintf( 'Created %d provider settings', $settings_created ) );
			} else {
				$this->log( 'All provider settings already exist' );
			}

			// Fix orphaned ai_model_id settings pointing to deleted models.
			$this->fix_orphaned_ai_model_settings( $provider_rules );

			$this->log( 'Provider settings seeded successfully' );

			return true;
		} catch ( \Exception $e ) {
			$this->log( sprintf( 'Error seeding provider settings: %s', $e->getMessage() ), 'error' );

			return false;
		}
	}

	/**
	 * Fix ai_model_id settings that reference deleted or non-existent models.
	 *
	 * Checks chatbot.ai_model_id, embedding.ai_model_id, and copilot.ai_model_id
	 * settings for each provider. If the stored model ID no longer exists in the
	 * database, updates the setting to the new default from the schema.
	 *
	 * Also handles `.preview` variants of these settings.
	 *
	 * @param array $provider_rules Provider setting rules with defaults.
	 *
	 * @return void
	 * @since 2.0.0
	 */
	private function fix_orphaned_ai_model_settings( array $provider_rules ): void {
		foreach ( $provider_rules as $key => $rule ) {
			if ( ! str_ends_with( $key, '.ai_model_id' ) ) {
				continue;
			}

			$new_default = $rule['default'] ?? null;

			foreach ( [ $key, $key . Setting::PREVIEW_POSTFIX ] as $setting_key ) {
				if ( ! Setting::exists( $setting_key ) ) {
					continue;
				}

				$setting = Setting::find( $setting_key );

				if ( ! $setting instanceof Setting ) {
					continue;
				}

				$current_value = $setting->get_value();

				if ( ! $current_value || AI_Model::count( [ 'id' => (int) $current_value ] ) ) {
					continue;
				}

				if ( $new_default !== null ) {
					Setting::update( [ 'key' => $setting_key ], [ 'value' => $new_default ] );
					$this->log( sprintf( 'Updated orphaned setting %s from model ID %s to %s', $setting_key, $current_value, $new_default ) );
				}
			}
		}
	}

	/**
	 * Delete deprecated AI models.
	 *
	 * Removes models listed in DEPRECATED_MODELS from the database
	 * so they are no longer available for selection.
	 *
	 * @return bool True on success, false on failure.
	 * @since 2.0.0
	 */
	private function delete_deprecated_models(): bool {
		try {
			foreach ( self::DEPRECATED_MODELS as $model_name ) {
				$model = AI_Model::find_by_name( $model_name );

				if ( $model instanceof AI_Model ) {
					AI_Model::delete( [ 'name' => $model_name ] );
					$this->log( sprintf( 'Deleted deprecated model: %s', $model_name ) );
				} else {
					$this->log( sprintf( 'Deprecated model not found, skipping: %s', $model_name ) );
				}
			}

			return true;
		} catch ( \Exception $e ) {
			$this->log( sprintf( 'Error deleting deprecated models: %s', $e->getMessage() ), 'error' );

			return false;
		}
	}

	/**
	 * Placeholder for any necessary chatbot settings migrations.
	 *
	 * This method can be expanded in the future to handle any specific
	 * transformations needed for chatbot-related settings during this migration.
	 *
	 * @return bool True on success, false on failure.
	 * @throws \Exception
	 * @since 2.0.0
	 */
	private function migrate_chatbot_settings() {
		$chatbot = Chatbot::make();
		$avatar  = $chatbot->get_parameter( 'avatar' );
		if ( $avatar == 2 ) {
			// do nothing
		} else {
			$chatbot->update_parameter( 'avatar', 0 );
		}
		$shadow_dom = $chatbot->get_parameter( 'shadow_dom' );
		if ( $shadow_dom == 1 ) {
			// do nothing
		} else {
			$chatbot->update_parameter( 'shadow_dom', 1 );
		}

		$this->fix_orphaned_chatbot_model_settings();

		return true;
	}

	/**
	 * Fix orphaned ai_model_id references for the global default chatbot setting
	 * and per-chatbot CPT post meta.
	 *
	 * `fix_orphaned_ai_model_settings()` only covers provider-level settings
	 * (e.g. `lbaic.ai_providers.gemini.utilities.chatbot.ai_model_id`).  When a
	 * user selects a model through the default chatbot UI, the choice lands in
	 * `lbaic.utilities.chatbot.ai_model_id` — a completely different key that the
	 * provider-rules loop never visits.  Similarly, per-chatbot CPT posts store
	 * their selection in `_ai_model_id` post meta.  Both are fixed here.
	 *
	 * @return void
	 * @throws \Exception
	 * @since 2.0.0
	 */
	private function fix_orphaned_chatbot_model_settings(): void {
		// Build provider_id → default chatbot model ID map from each provider's schema.
		$provider_default_model_map = $this->build_provider_default_chatbot_model_map();

		// 1. Fix the global default chatbot wp_options setting.
		$model_key    = Setting::SETTING_PREFIX . Chatbot::SETTING_PREFIX . 'ai_model_id';
		$provider_key = Setting::SETTING_PREFIX . Chatbot::SETTING_PREFIX . 'ai_provider_id';

		foreach ( [ $model_key, $model_key . Setting::PREVIEW_POSTFIX ] as $setting_key ) {
			if ( ! Setting::exists( $setting_key ) ) {
				continue;
			}
			$setting = Setting::find( $setting_key );
			if ( ! $setting instanceof Setting ) {
				continue;
			}
			$current_model_id = $setting->get_value();
			if ( ! $current_model_id || AI_Model::count( [ 'id' => (int) $current_model_id ] ) ) {
				continue;
			}

			$provider_setting = Setting::find( $provider_key );
			$provider_id      = $provider_setting instanceof Setting ? $provider_setting->get_value() : null;
			$new_default      = $provider_id ? ( $provider_default_model_map[ $provider_id ] ?? null ) : null;

			if ( $new_default !== null ) {
				Setting::update( [ 'key' => $setting_key ], [ 'value' => $new_default ] );
				$this->log( sprintf(
					'Updated orphaned global chatbot setting %s from model ID %s to %s',
					$setting_key,
					$current_model_id,
					$new_default
				) );
			}
		}

		// 2. Fix per-chatbot CPT post meta (_ai_model_id).
		$chatbots = Chatbot::where( [] );
		if ( $chatbots->is_empty() ) {
			return;
		}

		foreach ( $chatbots->get() as $chatbot ) {
			if ( ! $chatbot instanceof Chatbot || empty( $chatbot->get_id() ) ) {
				continue;
			}

			$ai_model_id = $chatbot->get_ai_model_id();
			if ( ! $ai_model_id || AI_Model::count( [ 'id' => (int) $ai_model_id ] ) ) {
				continue;
			}

			$ai_provider_id = $chatbot->get_ai_provider_id();
			$new_default    = $ai_provider_id ? ( $provider_default_model_map[ $ai_provider_id ] ?? null ) : null;

			if ( $new_default !== null ) {
				Chatbot_Meta::update(
					[ 'meta_key' => '_ai_model_id', 'chatbot_id' => $chatbot->get_id() ],
					[ 'meta_value' => $new_default ]
				);
				$this->log( sprintf(
					'Updated orphaned ai_model_id for chatbot #%d from model ID %s to %s',
					$chatbot->get_id(),
					$ai_model_id,
					$new_default
				) );
			}
		}
	}

	/**
	 * Build a map of provider ID → default chatbot model ID from each provider's schema.
	 *
	 * Each provider's Data_Schemas\Setting::rules() evaluates the default model ID
	 * dynamically (via AI_Model::find_by_name), so this must run after
	 * seed_ai_provider_models() and delete_deprecated_models().
	 *
	 * @return array<string, int> Map of provider_id → model_id.
	 * @since 2.0.0
	 */
	private function build_provider_default_chatbot_model_map(): array {
		$map       = [];
		$providers = [
			new Open_Ai(),
			new Gemini(),
			new Claude(),
			new Deep_Seek(),
			new Grok(),
			new Open_Router(),
		];

		foreach ( $providers as $ai_provider ) {
			$provider_name  = $ai_provider::$name;
			$provider_id    = $ai_provider::$id;
			$settings_class = 'Limb_Chatbot\\Includes\\AI_Providers\\' . $provider_name . '\\Data_Schemas\\Setting';
			if ( ! class_exists( $settings_class ) ) {
				continue;
			}

			$chatbot_model_key = Setting::SETTING_PREFIX . 'ai_providers.' . $provider_id . '.utilities.chatbot.ai_model_id';
			$rules             = $settings_class::rules();

			if ( isset( $rules[ $chatbot_model_key ]['default'] ) ) {
				$map[ $provider_id ] = $rules[ $chatbot_model_key ]['default'];
			}
		}

		return $map;
	}
}

