<?php
/**
 * Migration runner — db-version option + lock + idempotency. Plugins register
 * ordered migrations; each runs at most once per site.
 *
 * @package Hakki\WooCore
 */

namespace ErenLabs\Stocktake\Vendor\Hakki\WooCore\Migration;

use ErenLabs\Stocktake\Vendor\Hakki\WooCore\Core;

defined( 'ABSPATH' ) || exit;

final class Runner {

	/** @var Core */
	private $core;

	/**
	 * Ordered migrations: target_version => callable. A callable returning false
	 * aborts (version not advanced) so it can be retried safely.
	 *
	 * @var array<string,callable>
	 */
	private $migrations = array();

	public function __construct( Core $core ) {
		$this->core = $core;
	}

	private function version_option(): string {
		return $this->core->id( 'db_version' );
	}

	private function lock_option(): string {
		return $this->core->id( 'migration_lock' );
	}

	/**
	 * Register a migration step.
	 */
	public function add( string $target_version, callable $callback ): self {
		$this->migrations[ $target_version ] = $callback;
		return $this;
	}

	public function current_version(): string {
		return (string) get_option( $this->version_option(), '0.0.0' );
	}

	/**
	 * Atomically acquire the migration lock. The INSERT is constrained by the
	 * options table's unique key, so exactly one concurrent request wins — the
	 * cache-backed add_option()/get_option() pair cannot guarantee that. A
	 * stale lock (a hard fatal killed a prior run before its finally) is
	 * reclaimed after 5 minutes via a compare-and-swap UPDATE, again with a
	 * single winner.
	 */
	private function acquire_lock(): bool {
		global $wpdb;
		$name = $this->lock_option();
		$now  = time();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- atomicity requires the unique key.
		$inserted = $wpdb->query(
			$wpdb->prepare(
				"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, 'no')",
				$name,
				(string) $now
			)
		);
		if ( $inserted ) {
			wp_cache_delete( $name, 'options' );
			return true;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$seen = (string) $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", $name ) );
		if ( '' !== $seen && ( $now - (int) $seen ) >= 300 ) {
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- CAS reclaim, single winner.
			$claimed = $wpdb->query(
				$wpdb->prepare(
					"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND option_value = %s",
					(string) $now,
					$name,
					$seen
				)
			);
			if ( $claimed ) {
				wp_cache_delete( $name, 'options' );
				return true;
			}
		}
		return false;
	}

	/**
	 * Run all pending migrations under a lock. Idempotent: a second run with no
	 * newer steps is a no-op and leaves db-version unchanged.
	 *
	 * @return bool True when every pending step completed (or none were due);
	 *              false when locked out OR a step returned false (retry later).
	 */
	public function run(): bool {
		if ( ! $this->acquire_lock() ) {
			return false;
		}

		$completed = true;
		try {
			$from = $this->current_version();
			uksort( $this->migrations, 'version_compare' );
			foreach ( $this->migrations as $target => $callback ) {
				if ( version_compare( $target, $from, '<=' ) ) {
					continue;
				}
				$result = call_user_func( $callback, $from, $target );
				if ( false === $result ) {
					$completed = false;
					break; // Leave db-version where it was; retry next load.
				}
				update_option( $this->version_option(), $target, false );
				$from = $target;
			}
		} finally {
			delete_option( $this->lock_option() );
		}
		return $completed;
	}
}
