<?php
/**
 * JJ\'s Post Scheduler schema migration service.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Database;

use ScheduledChanges\Support\WpdbConnection;

use ScheduledChanges\Database\Migrations\CanonicalTargetSchemaMigration;
use ScheduledChanges\Database\Migrations\CompleteCoreSchemaMigration;
use ScheduledChanges\Database\Migrations\InitialSchemaMigration;
use ScheduledChanges\Database\Migrations\LaunchVersionChecksumScopeMigration;
use ScheduledChanges\Database\Migrations\RecordingSessionIdentityColumnsMigration;

/**
 * Applies schema migrations only from explicit lifecycle or CLI entrypoints.
 */
final class Migrator {
	private const LOCK_OPTION   = 'scheduled_changes_migration_lock';
	private const STATUS_OPTION = 'scheduled_changes_migration_status';
	private const LOCK_TTL      = 300;
	private string $lock_token  = '';

	/**
	 * Applies outstanding migrations.
	 */
	public function migrate(): void {
		$this->acquireLock();

		try {
			$current = $this->currentVersion();
			$applied = false;

			foreach ( $this->migrations() as $migration ) {
				if ( $migration->version() <= $current ) {
					continue;
				}

				$migration->up( $this->wpdb() );
				$current = $migration->version();
				$applied = true;

				update_option( 'scheduled_changes_schema_version', (string) $current, false );
				update_option(
					self::STATUS_OPTION,
					array(
						'status'        => 'successful',
						'version'       => $current,
						'completed_at'  => gmdate( 'c' ),
						'error_message' => null,
					),
					false
				);
			}

			if ( ! $applied && $current >= $this->latestVersion() ) {
				update_option(
					self::STATUS_OPTION,
					array(
						'status'        => 'successful',
						'version'       => $current,
						'completed_at'  => gmdate( 'c' ),
						'error_message' => null,
					),
					false
				);
			}
		} catch ( \Throwable $throwable ) {
			update_option(
				self::STATUS_OPTION,
				array(
					'status'        => 'failed',
					'version'       => $this->currentVersion(),
					'completed_at'  => gmdate( 'c' ),
					'error_message' => $throwable->getMessage(),
				),
				false
			);

			throw $throwable;
		} finally {
			$this->releaseLock();
		}
	}

	/**
	 * Current installed schema version.
	 */
	public function currentVersion(): int {
		return (int) get_option( 'scheduled_changes_schema_version', '0' );
	}

	/**
	 * Latest schema version in code.
	 */
	public function latestVersion(): int {
		return (int) SCHEDULED_CHANGES_SCHEMA_VERSION;
	}

	/**
	 * Last migration status.
	 *
	 * @return array<string, mixed>
	 */
	public function status(): array {
		$status = get_option( self::STATUS_OPTION, array() );

		return is_array( $status ) ? $status : array();
	}

	/**
	 * Ordered migrations.
	 *
	 * @return list<Migration>
	 */
	private function migrations(): array {
		return array(
			new InitialSchemaMigration(),
			new LaunchVersionChecksumScopeMigration(),
			new CanonicalTargetSchemaMigration(),
			new RecordingSessionIdentityColumnsMigration(),
			new CompleteCoreSchemaMigration(),
		);
	}

	/**
	 * Prevents concurrent migration runs.
	 */
	private function acquireLock(): void {
		$existing           = get_option( self::LOCK_OPTION, null );
		$existing_timestamp = is_array( $existing )
			? (int) ( $existing['acquired_at'] ?? 0 )
			: (int) $existing;

		if ( $existing_timestamp > ( time() - self::LOCK_TTL ) ) {
			throw new \RuntimeException( 'A JJ\'s Post Scheduler database migration is already running.' );
		}

		$this->lock_token = wp_generate_uuid4();
		$lock             = array(
			'acquired_at' => time(),
			'token'       => $this->lock_token,
		);

		if ( null === $existing && add_option( self::LOCK_OPTION, $lock, '', false ) ) {
			return;
		}

		$wpdb = $this->wpdb();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Atomic compare-and-set locking requires a single conditional options-table update.
		$updated = $wpdb->update(
			$wpdb->options,
			array( 'option_value' => maybe_serialize( $lock ) ),
			array(
				'option_name'  => self::LOCK_OPTION,
				'option_value' => maybe_serialize( $existing ),
			),
			array( '%s' ),
			array( '%s', '%s' )
		);

		if ( 1 !== $updated ) {
			$this->lock_token = '';
			throw new \RuntimeException( 'A JJ\'s Post Scheduler database migration is already running.' );
		}

		wp_cache_delete( self::LOCK_OPTION, 'options' );
	}

	/**
	 * Releases only the lock acquired by this migrator instance.
	 */
	private function releaseLock(): void {
		if ( '' === $this->lock_token ) {
			return;
		}

		$lock = get_option( self::LOCK_OPTION, array() );

		if ( is_array( $lock ) && hash_equals( $this->lock_token, (string) ( $lock['token'] ?? '' ) ) ) {
			delete_option( self::LOCK_OPTION );
		}

		$this->lock_token = '';
	}

	/**
	 * WordPress database connection.
	 */
	private function wpdb(): \wpdb {
		return WpdbConnection::resolve();
	}
}
