<?php
/**
 * Migration runner.
 *
 * Orchestrates the application of {@see Migration} implementations
 * against the host WordPress site. Given an ordered set of migrations
 * registered via {@see self::add()} (or supplied en masse via
 * {@see self::withMigrations()}), the runner:
 *
 *   1. Reads the currently-installed schema version from
 *      {@see SchemaVersionRepository::currentVersion()}.
 *
 *   2. Selects every migration whose {@see Migration::targetVersion()}
 *      is strictly greater than the current version, sorted ascending
 *      by target version.
 *
 *   3. Dispatches each selected migration's {@see Migration::up()} in
 *      order. After each successful `up()`, the runner persists the
 *      migration's target version via
 *      {@see SchemaVersionRepository::record()} so that a mid-pipeline
 *      failure leaves the option pointing at the last completed step
 *      and the next activation resumes from there.
 *
 *   4. Returns a {@see MigrationResult} describing the before / after
 *      versions and the ordered list of applied migrations.
 *
 * Invariants enforced by the runner:
 *
 *   - Migration target versions MUST be unique. Duplicate targets are
 *     a programming error (two migrations trying to install the same
 *     numbered revision) and raise a {@see \LogicException} from
 *     {@see self::add()} rather than being silently resolved by
 *     insertion order.
 *
 *   - Migration target versions MUST be strictly positive. Version 0
 *     is reserved as the "no schema installed yet" sentinel on
 *     {@see SchemaVersion}, so no migration can target it.
 *
 *   - The runner does NOT roll back applied migrations on failure.
 *     WordPress schema changes go through `dbDelta()`, which is not
 *     transactional across storage engines, and mixing reversible and
 *     irreversible steps would hide failures rather than surface them.
 *     A failed migration leaves the site at the previous step; the
 *     operator is expected to fix the root cause and retry.
 *
 * The runner itself performs no WordPress API calls and is fully unit-
 * testable against in-memory collaborators.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use InvalidArgumentException;
use LogicException;
use Throwable;

/**
 * Applies registered {@see Migration} implementations in ascending
 * target-version order, persisting progress after each step.
 *
 * See the file-level docblock for ordering rules, duplicate-version
 * enforcement, and the deliberate no-rollback policy.
 */
final class MigrationRunner {

	/**
	 * Repository used to read and write the current schema version.
	 *
	 * Injected rather than constructed internally so tests can drive
	 * the runner against an {@see InMemoryOptionStore}-backed
	 * repository without touching WordPress globals.
	 */
	private SchemaVersionRepository $versions;

	/**
	 * Registered migrations, keyed by target version.
	 *
	 * Stored in a map (rather than a list) so {@see self::add()} can
	 * detect duplicate target versions in O(1) and so that
	 * {@see self::run()} can sort by version without an auxiliary pass.
	 *
	 * @var array<int, Migration>
	 */
	private array $migrations = array();

	/**
	 * Whether {@see self::run()} has already been invoked on this
	 * instance.
	 *
	 * Tracked purely so repeat calls to `run()` from the same activation
	 * path can be treated as no-ops; registering additional migrations
	 * after a run is still allowed, and a subsequent `run()` will pick
	 * them up.
	 */
	private bool $hasRun = false;

	/**
	 * Construct the runner.
	 *
	 * @param SchemaVersionRepository $versions Repository used to read
	 *                                          and persist the schema
	 *                                          version across calls to
	 *                                          {@see self::run()}.
	 */
	public function __construct( SchemaVersionRepository $versions ) {
		$this->versions = $versions;
	}

	/**
	 * Register a migration with the runner.
	 *
	 * Migrations may be added in any order; {@see self::run()} sorts
	 * them by target version before dispatching. Duplicate target
	 * versions are rejected with a {@see LogicException} so ordering
	 * ambiguity surfaces loudly rather than silently.
	 *
	 * Target versions MUST be strictly positive. Version 0 is the
	 * reserved "no schema installed yet" sentinel on
	 * {@see SchemaVersion} and cannot be the target of a migration.
	 *
	 * @param Migration $migration Migration to register.
	 *
	 * @throws InvalidArgumentException When `$migration` targets version 0.
	 * @throws LogicException           When another migration already
	 *                                  targets the same version.
	 */
	public function add( Migration $migration ): void {
		$version = $migration->targetVersion();

		if ( $version->isZero() ) {
			$message = sprintf(
				'Migration "%s" targets reserved schema version 0; migrations must target a strictly positive version.',
				$migration->description()
			);

			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$key = $version->toInt();

		if ( array_key_exists( $key, $this->migrations ) ) {
			$existing = $this->migrations[ $key ];
			$message  = sprintf(
				'Duplicate migration target version %d: "%s" conflicts with previously-registered "%s".',
				$key,
				$migration->description(),
				$existing->description()
			);

			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$this->migrations[ $key ] = $migration;
	}

	/**
	 * Register multiple migrations at once.
	 *
	 * Sugar over repeated {@see self::add()} calls that returns `$this`
	 * so wiring code can chain the call:
	 *
	 *   `( new MigrationRunner( $repo ) )->withMigrations( [ ... ] )->run();`
	 *
	 * Each element is added in iteration order; duplicate target
	 * versions raise {@see LogicException} exactly as in
	 * {@see self::add()}.
	 *
	 * @param list<Migration> $migrations Migrations to register, in any order.
	 *
	 * @return static
	 */
	public function withMigrations( array $migrations ): self {
		foreach ( $migrations as $migration ) {
			$this->add( $migration );
		}

		return $this;
	}

	/**
	 * Return every migration registered with the runner, sorted by
	 * target version ascending.
	 *
	 * Exposed primarily for tests and for diagnostics surfaces that
	 * want to enumerate the known migration pipeline without triggering
	 * it. Production call sites should invoke {@see self::run()} rather
	 * than iterate this list directly.
	 *
	 * @return list<Migration>
	 */
	public function migrations(): array {
		$ordered = $this->migrations;
		ksort( $ordered, SORT_NUMERIC );
		return array_values( $ordered );
	}

	/**
	 * Highest target version known to the runner, or
	 * {@see SchemaVersion::zero()} when no migrations are registered.
	 *
	 * Useful for diagnostics that want to surface "schema is up to
	 * date" vs. "N migrations pending" without running the pipeline.
	 */
	public function highestKnownVersion(): SchemaVersion {
		if ( $this->migrations === array() ) {
			return SchemaVersion::zero();
		}

		return SchemaVersion::of( max( array_keys( $this->migrations ) ) );
	}

	/**
	 * Report whether {@see self::run()} has already been invoked on
	 * this instance.
	 *
	 * Repeated calls to `run()` are safe (the second call will simply
	 * discover no pending migrations), but callers that want to avoid
	 * re-entering the pipeline may probe this flag first.
	 */
	public function hasRun(): bool {
		return $this->hasRun;
	}

	/**
	 * Apply every pending migration in ascending target-version order.
	 *
	 * A migration is considered "pending" when its target version is
	 * strictly greater than the currently-installed schema version as
	 * reported by {@see SchemaVersionRepository::currentVersion()}.
	 *
	 * After each migration's {@see Migration::up()} returns without
	 * throwing, the runner persists the migration's target version via
	 * {@see SchemaVersionRepository::record()}. If `up()` throws, the
	 * runner re-throws the original {@see Throwable} without touching
	 * the schema version, so the site remains at the last successfully-
	 * applied step and the next invocation resumes from there.
	 *
	 * Returns a {@see MigrationResult} describing the before / after
	 * versions and the ordered list of applied migrations — including
	 * the no-op case where every registered migration's target version
	 * is already satisfied.
	 *
	 * @throws Throwable Re-thrown verbatim from a failing migration's
	 *                   {@see Migration::up()}. Callers that need to
	 *                   distinguish migration failures from other
	 *                   exceptions should catch at this boundary.
	 */
	public function run(): MigrationResult {
		$this->hasRun = true;

		$start   = $this->versions->currentVersion();
		$current = $start;
		$applied = array();

		foreach ( $this->migrations() as $migration ) {
			if ( ! $migration->targetVersion()->isGreaterThan( $current ) ) {
				continue;
			}

			$migration->up();

			$this->versions->record( $migration->targetVersion() );
			$current   = $migration->targetVersion();
			$applied[] = $migration;
		}

		return new MigrationResult( $start, $current, $applied );
	}
}
