<?php
namespace WPDRMS\ASP\Priorities;

use WPDRMS\ASP\Priorities\ORM\PriorityGroupsOptions;
use WPDRMS\ASP\Priorities\PrioritiesService;
use WPDRMS\ASP\Utils\Plugin;

if ( ! defined( 'ABSPATH' ) ) {
	die( '-1' );
}

final class PrioritiesMigration {

	public static function run(): void {
		self::migrateFromLegacyOption();
		self::migrateFromLegacyTable();
		self::migrateGateConditions();
		self::migrateBoosts();
		self::mergeOrderedPostGroups();
	}

	/**
	 * Consolidates groups that share the SAME "Applies when" gate and only boost posts (post-`IN`
	 * rule boosts and/or ordered-posts boosts) into a single group with one ordered-posts boost,
	 * ordered by descending priority. This cleans up the many small condition-less groups the
	 * pre-4.29 individual-priority migration produced once a site is on the boosts model.
	 *
	 * Version-gated: only runs when upgrading from 4.29.0 or later. A pre-4.29 upgrade does not need
	 * it (its table import already lands as one ordered-posts group per blog) and must not get it:
	 * deliberate user-made post-rule groups migrated off the legacy option would be flattened into
	 * the ordered list, losing the exact priorities the user chose. The targets here are the many
	 * small per-post groups the 4.29.0 release's migration produced.
	 *
	 * Idempotent: collapsing N groups into one leaves a single group per gate signature, so a second
	 * pass finds no bucket with 2+ members and does nothing. Groups with any tax/cf boost, disabled
	 * groups, and singletons are left exactly as they are.
	 */
	private static function mergeOrderedPostGroups(): void {
		if ( ! Plugin::previousVersion( '4.29.0', '>=' ) ) {
			return;
		}

		$pg     = PriorityGroupsOptions::instance();
		$groups = $pg->load()->getGroups();
		if ( count( $groups ) < 2 ) {
			return;
		}

		// Bucket the mergeable (enabled, purely post-targeting) groups by their gate signature.
		$buckets = array();
		foreach ( $groups as $k => $group ) {
			if ( ! self::isMergeablePostGroup( $group ) ) {
				continue;
			}
			$buckets[ self::gateSignature( $group ) ][] = $k;
		}

		$remove  = array();
		$changed = false;
		foreach ( $buckets as $indexes ) {
			if ( count( $indexes ) < 2 ) {
				continue; // Nothing to merge for this gate.
			}

			// Highest priority wins per post across every boost in the bucket.
			$priority_by_post = array();
			foreach ( $indexes as $idx ) {
				foreach ( self::postPriorityPairs( $groups[ $idx ] ) as $pid => $prio ) {
					if ( ! isset( $priority_by_post[ $pid ] ) || $prio > $priority_by_post[ $pid ] ) {
						$priority_by_post[ $pid ] = $prio;
					}
				}
			}
			if ( empty( $priority_by_post ) ) {
				continue;
			}

			// Order posts by priority DESC, tie-break by post id ASC for a deterministic result.
			uksort(
				$priority_by_post,
				static function ( $a, $b ) use ( $priority_by_post ) {
					return $priority_by_post[ $a ] === $priority_by_post[ $b ]
						? $a <=> $b
						: $priority_by_post[ $b ] <=> $priority_by_post[ $a ];
				}
			);

			$first                      = $indexes[0];
			$groups[ $first ]['boosts'] = array(
				array(
					'kind'     => 'ordered_posts',
					'priority' => max( $priority_by_post ),
					'post_ids' => array_map( 'intval', array_keys( $priority_by_post ) ),
				),
			);
			$groups[ $first ]['rules']  = array(); // Boosts are authoritative now.
			foreach ( array_slice( $indexes, 1 ) as $dup ) {
				$remove[ $dup ] = true;
			}
			$changed = true;
		}

		if ( ! $changed ) {
			return;
		}

		$merged = array();
		foreach ( $groups as $k => $group ) {
			if ( isset( $remove[ $k ] ) ) {
				continue;
			}
			$merged[] = $group;
		}
		$pg->saveGroups( $merged );
		PrioritiesService::resetInstance();
	}

	/**
	 * True when a group is enabled and every one of its boosts targets posts only (an `ordered_posts`
	 * boost, or a `rule` boost whose rules are all `field === 'post'` with operator `IN`). Such groups
	 * can be merged into a single ordered-posts list; any tax/cf boost makes the group ineligible, and
	 * so does a `NOT IN` post rule (it boosts everything EXCEPT the listed posts — flattening its
	 * post_ids into an ordered list would invert it). Multiple post rules AND-ed inside one boost are
	 * an intersection, which a flat union also cannot express, so those stay untouched too — as do
	 * groups carrying a demote boost (priority below the search-time default of 1).
	 *
	 * @param array<string, mixed> $group
	 */
	private static function isMergeablePostGroup( array $group ): bool {
		if ( ! ( $group['enabled'] ?? true ) ) {
			return false;
		}
		$boosts = isset( $group['boosts'] ) && is_array( $group['boosts'] ) ? $group['boosts'] : array();
		if ( empty( $boosts ) ) {
			return false;
		}
		foreach ( $boosts as $boost ) {
			// A demote (priority below the CASE ... ELSE 1 search default) cannot be expressed as
			// an ordered post list — flattening it into one would turn it back into a boost.
			if ( (int) ( $boost['priority'] ?? 0 ) < 1 ) {
				return false;
			}
			$kind = $boost['kind'] ?? 'rule';
			if ( $kind === 'ordered_posts' ) {
				continue;
			}
			if ( $kind !== 'rule' ) {
				return false;
			}
			$rules = isset( $boost['rules'] ) && is_array( $boost['rules'] ) ? $boost['rules'] : array();
			if ( empty( $rules ) ) {
				return false;
			}
			foreach ( $rules as $rule ) {
				if ( ( $rule['field'] ?? '' ) !== 'post' ) {
					return false;
				}
				if ( strtoupper( (string) ( $rule['operator'] ?? 'IN' ) ) !== 'IN' ) {
					return false;
				}
			}
			if ( count( $rules ) > 1 && ( $boost['rule_logic'] ?? 'and' ) !== 'or' ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * A stable signature of a group's gate (blog, condition logic, and the set of conditions,
	 * order-independent), so two groups that apply under the same circumstances bucket together.
	 *
	 * @param array<string, mixed> $group
	 */
	private static function gateSignature( array $group ): string {
		$conditions = isset( $group['conditions'] ) && is_array( $group['conditions'] ) ? $group['conditions'] : array();
		$serialized = array();
		foreach ( $conditions as $condition ) {
			$serialized[] = (string) wp_json_encode( self::canonicalize( $condition ) );
		}
		sort( $serialized );

		return (string) wp_json_encode(
			array(
				'blog_id'    => (int) ( $group['blog_id'] ?? 0 ),
				'logic'      => (string) ( $group['condition_logic'] ?? 'and' ),
				'conditions' => $serialized,
			)
		);
	}

	/**
	 * Recursively key-sorts associative arrays so equal data serialises identically regardless of key
	 * order. List arrays are left in place.
	 *
	 * @param mixed $value
	 * @return mixed
	 */
	private static function canonicalize( $value ) {
		if ( ! is_array( $value ) ) {
			return $value;
		}
		$value = array_map( array( self::class, 'canonicalize' ), $value );
		if ( array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) {
			ksort( $value );
		}
		return $value;
	}

	/**
	 * Flattens a mergeable group's boosts to a `post_id => highest priority` map. Ordered-posts boosts
	 * expand to descending priorities by position (matching the search-time expansion).
	 *
	 * @param array<string, mixed> $group
	 * @return array<int, int>
	 */
	private static function postPriorityPairs( array $group ): array {
		$pairs  = array();
		$boosts = isset( $group['boosts'] ) && is_array( $group['boosts'] ) ? $group['boosts'] : array();
		foreach ( $boosts as $boost ) {
			$kind = $boost['kind'] ?? 'rule';
			if ( $kind === 'ordered_posts' ) {
				$top = (int) ( $boost['priority'] ?? 0 );
				$ids = array_values( array_filter( array_map( 'intval', $boost['post_ids'] ?? array() ) ) );
				foreach ( $ids as $i => $pid ) {
					$prio = $top - $i;
					if ( ! isset( $pairs[ $pid ] ) || $prio > $pairs[ $pid ] ) {
						$pairs[ $pid ] = $prio;
					}
				}
				continue;
			}
			$prio = (int) ( $boost['priority'] ?? 0 );
			foreach ( $boost['rules'] ?? array() as $rule ) {
				if ( ( $rule['field'] ?? '' ) !== 'post' ) {
					continue;
				}
				foreach ( $rule['values']['post_ids'] ?? array() as $pid ) {
					$pid = (int) $pid;
					if ( ! isset( $pairs[ $pid ] ) || $prio > $pairs[ $pid ] ) {
						$pairs[ $pid ] = $prio;
					}
				}
			}
		}
		return $pairs;
	}

	/**
	 * Seeds the `boosts` list from the legacy group-level `priority` + `rules` + `logic`, so groups
	 * keep boosting the same posts under the new "one group → many prioritised boosts" model.
	 *
	 * Idempotent: a group with non-empty `boosts` is skipped; a group with no `rules` is left
	 * boost-less (it boosted nothing before, still does). Legacy fields stay in place as the
	 * search-time fallback.
	 */
	private static function migrateBoosts(): void {
		$pg     = PriorityGroupsOptions::instance();
		$groups = $pg->load()->getGroups();
		if ( empty( $groups ) ) {
			return;
		}

		$changed = false;
		foreach ( $groups as $k => $group ) {
			$existing = isset( $group['boosts'] ) && is_array( $group['boosts'] ) ? $group['boosts'] : array();
			if ( ! empty( $existing ) ) {
				continue;
			}
			$rules = isset( $group['rules'] ) && is_array( $group['rules'] ) ? $group['rules'] : array();
			if ( empty( $rules ) ) {
				continue; // Nothing to boost — leave boost-less.
			}

			$groups[ $k ]['boosts'] = array(
				array(
					'kind'       => 'rule',
					'priority'   => (int) ( $group['priority'] ?? 100 ),
					'rules'      => $rules,
					'rule_logic' => ( ( $group['logic'] ?? 'and' ) === 'or' ) ? 'or' : 'and',
				),
			);
			$changed                = true;
		}

		if ( $changed ) {
			$pg->saveGroups( $groups );
			PrioritiesService::resetInstance();
		}
	}

	/**
	 * Seeds the flexible "Applies when" gate (`conditions`) from the legacy single `instance` +
	 * single `phrase` fields, so pre-existing groups keep their behaviour under the new system.
	 *
	 * Idempotent: only groups that still have an empty gate but carry a legacy instance/phrase are
	 * seeded. A group with a legacy gate is touched exactly once (afterwards its `conditions` is
	 * non-empty); a group with no legacy gate is never modified and keeps an empty (applies-always)
	 * gate. The legacy fields are left in place (the evaluator falls back to them when `conditions`
	 * is empty, so behaviour is preserved either way). `conditions` always being present in the
	 * serialized form is exactly why presence can't be the guard — emptiness + a legacy gate is.
	 */
	private static function migrateGateConditions(): void {
		$pg     = PriorityGroupsOptions::instance();
		$groups = $pg->load()->getGroups();
		if ( empty( $groups ) ) {
			return;
		}

		$changed = false;
		foreach ( $groups as $k => $group ) {
			$existing = isset( $group['conditions'] ) && is_array( $group['conditions'] ) ? $group['conditions'] : array();
			if ( ! empty( $existing ) ) {
				continue; // Already has gate conditions — nothing to migrate.
			}

			$conditions = array();
			$instance   = (int) ( $group['instance'] ?? 0 );
			if ( $instance !== 0 ) {
				$conditions[] = array(
					'type'         => 'instance',
					'instance_ids' => array( $instance ),
				);
			}
			$phrase       = (string) ( $group['phrase'] ?? '' );
			$phrase_logic = (string) ( $group['phrase_logic'] ?? 'disabled' );
			if ( $phrase !== '' && $phrase_logic !== 'disabled' ) {
				$conditions[] = array(
					'type'         => 'phrase',
					'phrase'       => $phrase,
					'phrase_logic' => $phrase_logic,
				);
			}

			if ( empty( $conditions ) ) {
				continue; // No legacy gate to migrate; leave as an "applies always" group.
			}

			$groups[ $k ]['conditions']      = $conditions;
			$groups[ $k ]['condition_logic'] = 'and';
			$changed                         = true;
		}

		if ( $changed ) {
			$pg->saveGroups( $groups );
			PrioritiesService::resetInstance();
		}
	}

	/**
	 * Migrates groups from the legacy per-blog WP option (_asp_priority_groups) to the new
	 * site option (asp_priority_groups), converting old jQuery-admin rule formats in the process.
	 *
	 * On multisite EVERY blog had its own legacy option, while the new storage is one network-wide
	 * list with a per-group `blog_id` scope — so every blog's option is collected (the migration
	 * itself only runs once per network: the `_asp_version` just-updated gate is a site option).
	 * Each blog's legacy option doubles as the idempotency guard there: it is deleted once
	 * converted, so blogs that still carry one are picked up even if the new option already exists
	 * (e.g. networks that upgraded through a version that only migrated the first-loading blog).
	 */
	private static function migrateFromLegacyOption(): void {
		if ( is_multisite() ) {
			self::migrateLegacyOptionAllBlogs();
			return;
		}

		if ( get_site_option( 'asp_priority_groups', false ) !== false ) {
			return;
		}

		$legacy = get_option( '_asp_priority_groups', false );
		if ( ! is_array( $legacy ) || empty( $legacy ) ) {
			return;
		}

		PriorityGroupsOptions::instance()->saveGroups( self::convertLegacyGroups( $legacy, 0 ) );
		delete_option( '_asp_priority_groups' );
	}

	/**
	 * Multisite variant of {@see migrateFromLegacyOption}: collects every blog's legacy option into
	 * the network-wide list, scoping each blog's groups to that blog via `blog_id`.
	 */
	private static function migrateLegacyOptionAllBlogs(): void {
		$migrated = array();
		$site_ids = get_sites(
			array(
				'fields' => 'ids',
				'number' => 0,
			)
		);
		foreach ( $site_ids as $site_id ) {
			$site_id = (int) $site_id;
			$legacy  = get_blog_option( $site_id, '_asp_priority_groups', false );
			if ( ! is_array( $legacy ) || empty( $legacy ) ) {
				continue;
			}
			$migrated = array_merge( $migrated, self::convertLegacyGroups( $legacy, $site_id ) );
			delete_blog_option( $site_id, '_asp_priority_groups' );
		}
		if ( empty( $migrated ) ) {
			return;
		}

		$pg = PriorityGroupsOptions::instance();
		$pg->saveGroups( array_merge( $pg->load()->getGroups(), $migrated ) );
		PrioritiesService::resetInstance();
	}

	/**
	 * Converts one blog's legacy group list to the new format: old jQuery-admin rule shapes are
	 * rewritten and the groups are scoped to the originating blog (0 = all blogs on single site,
	 * where the legacy per-blog option and the network list are the same thing).
	 *
	 * @param array<int, mixed> $legacy  The legacy group list (one blog's _asp_priority_groups).
	 * @param int               $blog_id The blog the groups belong to (0 = all blogs).
	 * @return array<int, array<string, mixed>>
	 */
	private static function convertLegacyGroups( array $legacy, int $blog_id ): array {
		$groups = array();
		foreach ( $legacy as $g ) {
			if ( ! is_array( $g ) ) {
				$g = (array) $g;
			}
			if ( isset( $g['rules'] ) && is_array( $g['rules'] ) ) {
				$g['rules'] = self::migrateLegacyRules( $g['rules'] );
			}
			$g['blog_id'] = $blog_id;
			$groups[]     = $g;
		}
		return $groups;
	}

	/**
	 * Migrates rows from the legacy ajaxsearchpro_priorities table to Priority Groups,
	 * then drops the table.
	 *
	 * The 4.28 table was GLOBAL — created with the base prefix and holding one row per
	 * (post_id, blog_id); the legacy search matched rows against the current blog. So the table is
	 * addressed via `base_prefix` (`prefix` would miss it when a subsite triggers the network-once
	 * migration) and rows are split into one group per blog, scoped via the group `blog_id`.
	 */
	private static function migrateFromLegacyTable(): void {
		global $wpdb;

		$old_table = $wpdb->base_prefix . 'ajaxsearchpro_priorities';
		$prev      = $wpdb->suppress_errors( true );
		$wpdb->get_var( "SELECT COUNT(*) FROM `$old_table`" ); // phpcs:ignore
		$table_missing = ( $wpdb->last_error !== '' );
		$wpdb->suppress_errors( $prev );
		if ( $table_missing ) {
			return;
		}

		$rows = $wpdb->get_results( // phpcs:ignore
			"SELECT post_id, blog_id, priority FROM `$old_table` WHERE priority != 100",
			ARRAY_A
		);

		if ( ! empty( $rows ) ) {
			$by_blog = array();
			foreach ( $rows as $row ) {
				$priority = (int) $row['priority'];
				// 4.28 ranked individual priorities on their own axis with a default of 100, so a
				// value below 100 DEMOTED the post below untouched posts. The unified boosts axis
				// defaults to 1 (CASE ... ELSE 1), so sub-100 tiers are shifted to keep demotes
				// demotes: the old neutral 100 maps onto the new neutral 1. Tiers above 100 keep
				// their exact value (already above the default either way).
				if ( $priority < 100 ) {
					$priority -= 99;
				}
				$by_blog[ (int) $row['blog_id'] ][ $priority ][] = (int) $row['post_id'];
			}
			ksort( $by_blog );

			// Each blog's individual per-post priorities land in a SINGLE condition-less group scoped
			// to that blog. Promote tiers flatten into ONE ordered-posts boost (tiers descending,
			// equal-priority posts by id) — the search expands the list to `top - position`, so the
			// relative order is preserved exactly while lower tiers compress to contiguous values.
			// Demote tiers (below the search-time neutral of 1) cannot be expressed by an ordered
			// list and stay one exact-priority rule boost each. Historic blog_id 0 rows become an
			// all-blogs group.
			$new_groups = array();
			foreach ( $by_blog as $blog_id => $by_priority ) {
				krsort( $by_priority ); // Highest priority tier first.

				$boosts  = array();
				$ordered = array();
				$top     = 0;
				foreach ( $by_priority as $priority => $post_ids ) {
					sort( $post_ids ); // Deterministic order inside an equal-priority tier.
					if ( $priority >= 1 ) {
						if ( empty( $ordered ) ) {
							$top = (int) $priority;
						}
						$ordered = array_merge( $ordered, $post_ids );
						continue;
					}
					$boosts[] = array(
						'kind'       => 'rule',
						'priority'   => (int) $priority,
						'rule_logic' => 'and',
						'rules'      => array(
							array(
								'name'     => 'Posts',
								'field'    => 'post',
								'operator' => 'IN',
								'values'   => array( 'post_ids' => $post_ids ),
								'_values'  => array(),
							),
						),
					);
				}
				if ( ! empty( $ordered ) ) {
					array_unshift(
						$boosts,
						array(
							'kind'     => 'ordered_posts',
							'priority' => $top,
							'post_ids' => $ordered,
						)
					);
				}

				$new_groups[] = array(
					'name'            => 'Migrated individual priorities'
						. ( is_multisite() && $blog_id > 0 ? ' (site ' . $blog_id . ')' : '' ),
					'priority'        => 100,
					'phrase'          => '',
					'phrase_logic'    => 'disabled',
					'instance'        => 0,
					'blog_id'         => (int) $blog_id,
					'logic'           => 'and',
					'enabled'         => true,
					'rules'           => array(),
					'condition_logic' => 'and',
					'conditions'      => array(),
					'boosts'          => $boosts,
				);
			}

			$pg = PriorityGroupsOptions::instance();
			$pg->saveGroups( array_merge( $pg->load()->getGroups(), $new_groups ) );
			PrioritiesService::resetInstance();
		}

		$wpdb->query( "DROP TABLE IF EXISTS `$old_table`" ); // phpcs:ignore
		delete_site_option( '_asp_priority_count' );
	}

	/**
	 * Converts rules from the legacy jQuery-admin format to the new format.
	 *
	 * Old taxonomy rule values: { taxonomy_slug: [ term_id, ... ], ... }
	 * New taxonomy rule values: { taxonomy: 'slug', term_ids: [ int, ... ] }
	 * (one rule per taxonomy — old rules with multiple taxonomies are split)
	 *
	 * Old CF rule values: { meta_key_name: [ 'value' ] }  (two values for BETWEEN)
	 * New CF rule values: { meta_key: 'name', value: 'value' }  (+ value2 for BETWEEN)
	 *
	 * Operator fidelity (matching the 4.28 SQL builder exactly):
	 * - tax rules only ever distinguished 'in' (EXISTS) from everything else (NOT EXISTS), so any
	 *   non-'in' operator becomes NOT IN.
	 * - cf rules supported like / not like / elike ('exact like' ≈ equality) plus the numeric
	 *   comparisons (=, <>, >, >=, <, <=, between), which pass through unchanged —
	 *   {@see \WPDRMS\ASP\Search\SearchPostTypes::buildRulePredicate()} executes them.
	 *
	 * @param array<int, array<string, mixed>> $rules
	 * @return array<int, array<string, mixed>>
	 */
	private static function migrateLegacyRules( array $rules ): array {
		$operator_map = array(
			'in'       => 'IN',
			'not in'   => 'NOT IN',
			'like'     => 'LIKE',
			'not like' => 'NOT LIKE',
			'elike'    => '=',
		);

		$new_rules = array();
		foreach ( $rules as $rule ) {
			$field        = (string) ( $rule['field'] ?? '' );
			$raw_operator = strtolower( (string) ( $rule['operator'] ?? 'in' ) );
			$operator     = $operator_map[ $raw_operator ] ?? strtoupper( $raw_operator );
			$values       = is_array( $rule['values'] ?? null ) ? $rule['values'] : array();

			if ( $field === 'tax' && ! isset( $values['taxonomy'] ) ) {
				foreach ( $values as $taxonomy => $term_ids ) {
					$new_rules[] = array(
						'name'     => (string) ( $rule['name'] ?? '' ),
						'field'    => 'tax',
						'operator' => $raw_operator === 'in' ? 'IN' : 'NOT IN',
						'values'   => array(
							'taxonomy' => (string) $taxonomy,
							'term_ids' => array_values( array_map( 'intval', (array) $term_ids ) ),
						),
						'_values'  => array(),
					);
				}
			} elseif ( $field === 'cf' && ! isset( $values['meta_key'] ) ) {
				$field_name = (string) array_key_first( $values );
				$cf_vals    = array_values( (array) ( $values[ $field_name ] ?? array() ) );
				$cf_values  = array(
					'meta_key' => $field_name,
					'value'    => (string) ( $cf_vals[0] ?? '' ),
				);
				if ( $operator === 'BETWEEN' ) {
					$cf_values['value2'] = (string) ( $cf_vals[1] ?? '' );
				}
				$new_rules[] = array(
					'name'     => (string) ( $rule['name'] ?? '' ),
					'field'    => 'cf',
					'operator' => $operator,
					'values'   => $cf_values,
					'_values'  => array(),
				);
			} else {
				$new_rules[] = $rule;
			}
		}
		return $new_rules;
	}
}
