<?php
/**
 * Maintenance.
 *
 * @package SureCookie
 * @subpackage SureCookie/Core
 * @since 0.0.1
 */

namespace SureCookie\Core;

use SureCookie\Inc\Database\ConsentLog;
use SureCookie\Inc\Database\Init as DB_Init;
use SureCookie\Inc\Traits\GetInstance;

defined( 'ABSPATH' ) || exit;

/**
 * Update Compatibility
 *
 * @package SureCookie
 */
class Maintenance {
	use GetInstance;

	/**
	 *  Constructor
	 */
	public function __construct() {
		if ( is_admin() ) {
			add_action( 'admin_init', self::class . '::init' );
		} else {
			add_action( 'init', self::class . '::init' );
		}
	}

	/**
	 * Init
	 *
	 * @since 0.0.1
	 * @return void
	 */
	public static function init(): void {
		// DB schema versioning is independent of the plugin version below SURECOOKIE_DB_VERSION can change without a plugin release, so this must run before the saved-version early returns.
		self::maybe_upgrade_db();

		do_action( 'surecookie_update_before' );

		// Get auto saved version number.
		$saved_version = get_option( 'surecookie_saved_version', '' );

		// Update auto saved version number.
		if ( ! $saved_version ) {
			update_option( 'surecookie_saved_version', SURECOOKIE_VERSION );
			return;
		}

		// If equals then return.
		if ( version_compare( strval( $saved_version ), SURECOOKIE_VERSION, '=' ) ) {
			return;
		}

		self::manage_backward();

		// Update auto saved version number.
		update_option( 'surecookie_saved_version', SURECOOKIE_VERSION );

		do_action( 'surecookie_update_after' );

		// Finally flush rewrite rules.
		delete_option( 'rewrite_rules' );
	}

	/**
	 * Manage backward compatibility.
	 *
	 * @since 0.0.1
	 * @return void
	 */
	public static function manage_backward(): void {
		$saved_version = strval( get_option( 'surecookie_saved_version', false ) );

		// If the saved version is already set then manage some backward compatibility.
		if ( ! $saved_version ) {
			return;
		}

		self::migrate_consent_log_unique_key();
	}

	/**
	 * Re-run dbDelta when SURECOOKIE_DB_VERSION changed, so column additions
	 * apply without a deactivate/reactivate cycle.
	 *
	 * @since 1.1.0
	 * @return void
	 */
	public static function maybe_upgrade_db(): void {
		$stored = is_multisite()
			? get_network_option( null, 'surecookie_db_version' )
			: get_option( 'surecookie_db_version' );

		if ( $stored === SURECOOKIE_DB_VERSION ) {
			return;
		}

		try {
			DB_Init::create_db_tables();

			// dbDelta() swallows SQL errors (e.g. a failed ALTER on a locked table) — verify the column landed before storing the version, otherwise the upgrade would never retry.
			if ( self::schema_upgrade_applied() ) {
				self::store_db_version();
			}
		} catch ( \Throwable $e ) {
			error_log( 'SureCookie: schema upgrade failed — ' . $e->getMessage() ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
		}
	}

	/**
	 * Persist the current DB schema version (network-wide on multisite).
	 *
	 * @since 1.1.0
	 * @return void
	 */
	public static function store_db_version(): void {
		if ( is_multisite() ) {
			update_network_option( null, 'surecookie_db_version', SURECOOKIE_DB_VERSION );
		} else {
			update_option( 'surecookie_db_version', SURECOOKIE_DB_VERSION );
		}
	}

	/**
	 * Spot-check the newest column added by the current SURECOOKIE_DB_VERSION.
	 *
	 * @since 1.1.0
	 * @return bool
	 */
	private static function schema_upgrade_applied(): bool {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQLPlaceholders.UnsupportedPlaceholder, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber -- One-shot schema verification; %i is the WP 6.2+ identifier placeholder.
		$column = $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $wpdb->prefix . SURECOOKIE_CONSENT_LOG_DB, 'is_forwarded' ) );

		return ! empty( $column );
	}

	/**
	 * Apply the consent-log UNIQUE KEY migration introduced for issue #457.
	 *
	 * `dbDelta` only runs on activation; WP auto-updates and file-swaps never
	 * trigger it. Without this step, existing installs would run the
	 * `INSERT … ON DUPLICATE KEY UPDATE` in {@see ConsentLog::upsert()}
	 * without the `UNIQUE KEY (session_id, timestamp)` the atomic upsert
	 * relies on — silently degrading to plain `INSERT` and leaving the race
	 * condition in place.
	 *
	 * Flow:
	 *   1. Exit fast if the migration flag is already set (idempotent).
	 *   2. Drop existing duplicate `(session_id, timestamp)` rows — `dbDelta`
	 *      silently skips `ALTER TABLE ADD UNIQUE KEY` on duplicates.
	 *   3. Re-run the table DDL via `DB_Init::create_db_tables()`.
	 *   4. Verify `SHOW INDEX` reports `session_unique`; log a warning
	 *      otherwise so a silently-skipped key is observable.
	 *   5. Store the flag so subsequent loads short-circuit.
	 *
	 * @since 1.0.0
	 * @return void
	 */
	private static function migrate_consent_log_unique_key(): void {
		$flag = 'surecookie_consent_log_unique_key_migrated';
		if ( get_option( $flag ) ) {
			return;
		}

		global $wpdb;
		$table = ConsentLog::get_instance()->get_tablename();

		// Step 1: dedupe pre-existing duplicate rows that would make
		// `dbDelta` silently skip the UNIQUE KEY on ALTER.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnsupportedPlaceholder, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- One-time upgrade dedup; the table name is a server-controlled constant from wpdb prefix + plugin.
		$dedup_sql = $wpdb->prepare(
			'DELETE t1 FROM %i AS t1 INNER JOIN %i AS t2 ON t1.session_id = t2.session_id AND t1.timestamp = t2.timestamp AND t1.id < t2.id',
			$table,
			$table
		);
		if ( is_string( $dedup_sql ) ) {
			// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $dedup_sql is the output of $wpdb->prepare() above.
			$wpdb->query( $dedup_sql );
		}

		// Step 2: apply the new schema (idempotent via dbDelta).
		DB_Init::create_db_tables();

		// Step 3: verify the UNIQUE KEY is actually present. `dbDelta` swallows
		// 1062 errors silently, so a failed migration is otherwise invisible.
		$index_exists = (int) $wpdb->get_var(
			$wpdb->prepare(
				'SELECT COUNT(1) FROM information_schema.STATISTICS
				 WHERE TABLE_SCHEMA = DATABASE()
				   AND TABLE_NAME   = %s
				   AND INDEX_NAME   = %s',
				$table,
				'session_unique'
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnsupportedPlaceholder

		if ( $index_exists < 1 ) {
			return;
		}

		update_option( $flag, time() );
	}
}
