<?php
/**
 * Schema version value object.
 *
 * affilun tracks its database schema revision as a simple
 * monotonically-increasing integer, persisted in the WordPress options
 * table under the `affilun_db_schema_version` key. The numbered sequence
 * (1 = initial release, 2 = sync_state, 3 = lifecycle_state, ...) is
 * canonicalized in `specifications/database-schema.md` §13.2.
 *
 * Wrapping that integer in a value object gives us three practical
 * benefits over passing a bare `int` around:
 *
 *   1. Validation is centralized. A schema version is a non-negative
 *      integer; version 0 represents "no schema installed yet" (fresh
 *      environment) and any negative value is a programming error that
 *      we want to surface loudly rather than silently coerce.
 *
 *   2. Comparison is explicit. Migration ordering decisions ("is this
 *      migration newer than the current version?") read more clearly
 *      as `$candidate->isGreaterThan($current)` than as `$a > $b`
 *      against two opaquely-typed integers.
 *
 *   3. Parsing from the options table is normalized in one place.
 *      WordPress may return the stored value as a string, an integer,
 *      or the supplied default when the row is missing; {@see
 *      self::fromStoredValue()} funnels every variant through a single
 *      well-tested path.
 *
 * Instances are immutable. "Bumping" a version returns a new instance
 * via {@see self::next()} rather than mutating the receiver, so call
 * sites cannot accidentally share mutable state.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use InvalidArgumentException;

/**
 * Immutable value object representing a database schema revision.
 *
 * See the file-level docblock for the persistence contract and the
 * rationale for preferring this type over a bare integer.
 */
final class SchemaVersion {

	/**
	 * Canonical sentinel representing "no schema installed yet".
	 *
	 * Fresh WordPress environments do not have the
	 * `affilun_db_schema_version` option populated; the migration runner
	 * treats that state as {@see self::zero()} so every migration whose
	 * target version is ≥ 1 is considered pending.
	 */
	private const ZERO = 0;

	/**
	 * Non-negative integer version number.
	 */
	private int $version;

	/**
	 * Construct a schema version from a validated integer.
	 *
	 * Direct construction is allowed but most call sites should prefer
	 * the named constructors ({@see self::of()}, {@see self::zero()},
	 * {@see self::fromStoredValue()}) because they document intent at
	 * the call site.
	 *
	 * @param int $version Non-negative integer version number.
	 *
	 * @throws InvalidArgumentException When `$version` is negative.
	 */
	public function __construct( int $version ) {
		if ( $version < 0 ) {
			$message = sprintf( 'Schema version must be a non-negative integer, got %d.', $version );

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

		$this->version = $version;
	}

	/**
	 * Named constructor for an explicit non-negative version.
	 *
	 * Reads more clearly at call sites than direct `new SchemaVersion(...)`
	 * construction, especially when the literal value expresses intent
	 * (for example `SchemaVersion::of( 1 )` for the initial release).
	 *
	 * @param int $version Non-negative integer version number.
	 *
	 * @throws InvalidArgumentException When `$version` is negative.
	 */
	public static function of( int $version ): self {
		return new self( $version );
	}

	/**
	 * Return the canonical sentinel for "no schema installed yet".
	 *
	 * Used by the migration runner when the `affilun_db_schema_version`
	 * option is missing, so that every migration whose target version
	 * is ≥ 1 is correctly treated as pending.
	 */
	public static function zero(): self {
		return new self( self::ZERO );
	}

	/**
	 * Normalize a value read from the options table into a SchemaVersion.
	 *
	 * WordPress's `get_option()` may return:
	 *
	 *   - The caller-supplied default (typically `false` or `null`) when
	 *     the row is missing. In that case this method returns
	 *     {@see self::zero()}.
	 *
	 *   - An integer, when the value was written by this plugin.
	 *
	 *   - A numeric string, when another layer (cache, serialization,
	 *     external tooling) has round-tripped the value through a
	 *     string representation.
	 *
	 * Every other input shape is a programming error and raises an
	 * {@see InvalidArgumentException} rather than being silently coerced,
	 * so migration-path bugs surface loudly in tests.
	 *
	 * @param mixed $stored Raw value returned by the option store.
	 *
	 * @throws InvalidArgumentException When `$stored` cannot be
	 *                                  interpreted as a non-negative
	 *                                  integer.
	 */
	public static function fromStoredValue( $stored ): self {
		if ( $stored === false || $stored === null || $stored === '' ) {
			return self::zero();
		}

		if ( is_int( $stored ) ) {
			return new self( $stored );
		}

		if ( is_string( $stored ) && preg_match( '/^-?\d+$/', $stored ) === 1 ) {
			return new self( (int) $stored );
		}

		$rendered = is_scalar( $stored )
			? sprintf( '%s(%s)', gettype( $stored ), (string) $stored )
			: gettype( $stored );

		$message = sprintf(
			'Schema version option must be a non-negative integer or numeric string, got %s.',
			$rendered
		);

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

	/**
	 * Raw integer representation of the version.
	 *
	 * Used when writing the value back to the options table and when
	 * the diagnostics surface needs to report the current schema
	 * revision to operators.
	 */
	public function toInt(): int {
		return $this->version;
	}

	/**
	 * Report whether this version represents a fresh, uninitialized
	 * schema (equivalent to {@see self::zero()}).
	 */
	public function isZero(): bool {
		return $this->version === self::ZERO;
	}

	/**
	 * Report whether this version equals another version by value.
	 *
	 * @param self $other Other version to compare against.
	 */
	public function equals( self $other ): bool {
		return $this->version === $other->version;
	}

	/**
	 * Report whether this version is strictly greater than another.
	 *
	 * Used by the migration runner to decide whether a migration's
	 * target version is newer than the currently-installed schema.
	 *
	 * @param self $other Other version to compare against.
	 */
	public function isGreaterThan( self $other ): bool {
		return $this->version > $other->version;
	}

	/**
	 * Report whether this version is strictly less than another.
	 *
	 * @param self $other Other version to compare against.
	 */
	public function isLessThan( self $other ): bool {
		return $this->version < $other->version;
	}

	/**
	 * Return a new instance one version ahead of this one.
	 *
	 * Convenience for call sites that bump the version in-place after
	 * applying a migration. The receiver is left unchanged because
	 * SchemaVersion instances are immutable.
	 */
	public function next(): self {
		return new self( $this->version + 1 );
	}

	/**
	 * Human-readable representation used in log / diagnostics output.
	 *
	 * The format is deliberately terse (`"v7"`) so it composes cleanly
	 * into longer messages such as `"migrating from v6 to v7"`.
	 */
	public function __toString(): string {
		return 'v' . $this->version;
	}
}
