<?php
/**
 * Sync settings value object.
 *
 * Immutable Phase-8 value object that carries the §13.7 Sync
 * tab settings of `specifications/admin-ui-spec.md`. The fields
 * mirror the tab's UI controls one-to-one:
 *
 *   - `enabled` is the master sync toggle. When false, scheduled
 *     and on-demand sync jobs are both suppressed regardless of
 *     `scheduledSyncEnabled`.
 *
 *   - `scheduledSyncEnabled` is the periodic-sync toggle that
 *     drives the WP-Cron handler. Independent of `enabled` so
 *     an admin can stage "ready but not yet on" intermediate
 *     states.
 *
 *   - `freshTtlSeconds` / `staleTtlSeconds` / `expiredTtlSeconds`
 *     are the three product-snapshot TTL boundaries the §13.7
 *     UI exposes. The boundaries MUST be ordered
 *     `fresh <= stale <= expired`; the constructor enforces the
 *     ordering rather than silently swapping values.
 *
 *   - `maxConcurrentJobs` (1..16) and `batchSize` (1..200) bound
 *     the sync runner's parallelism and per-job throughput. The
 *     ranges match the §13.7 input-validation rules; the
 *     constructor rejects out-of-range values.
 *
 *   - `retryPolicyPreset` is a closed set:
 *     `'none' | 'fast' | 'balanced' | 'patient'` (matching the
 *     §13.7 "Retry Policy Preset" control).
 *
 * Instances are immutable; per-field withers return fresh
 * instances. Persistence shape is canonicalized by
 * {@see self::toArray()} / {@see self::fromArray()}.
 *
 * @package Affilun\Application\Settings
 */

declare(strict_types=1);

namespace Affilun\Application\Settings;

use InvalidArgumentException;

/**
 * Immutable §13.7 Sync tab settings bundle.
 *
 * See the file-level docblock for the field-by-field mapping
 * to the §13.7 admin-ui controls, the TTL ordering rule, and
 * the closed set that gates `retryPolicyPreset`.
 */
final class SyncSettings {

	/**
	 * Closed set of allowed `retryPolicyPreset` values.
	 */
	private const RETRY_POLICY_PRESETS = array( 'none', 'fast', 'balanced', 'patient' );

	/**
	 * Lower bound on every TTL field.
	 */
	private const MIN_TTL_SECONDS = 60;

	/**
	 * Master sync toggle.
	 */
	private bool $enabled;

	/**
	 * Periodic-sync toggle.
	 */
	private bool $scheduledSyncEnabled;

	/**
	 * Fresh TTL in seconds (>= 60).
	 */
	private int $freshTtlSeconds;

	/**
	 * Stale TTL in seconds (>= freshTtlSeconds).
	 */
	private int $staleTtlSeconds;

	/**
	 * Expired TTL in seconds (>= staleTtlSeconds).
	 */
	private int $expiredTtlSeconds;

	/**
	 * Maximum concurrent sync jobs (1..16).
	 */
	private int $maxConcurrentJobs;

	/**
	 * Batch size per sync job (1..200).
	 */
	private int $batchSize;

	/**
	 * Retry policy preset (closed set:
	 * `'none' | 'fast' | 'balanced' | 'patient'`).
	 */
	private string $retryPolicyPreset;

	/**
	 * Construct a sync-settings snapshot with explicit values
	 * for every field.
	 *
	 * @param bool   $enabled              Master sync toggle.
	 * @param bool   $scheduledSyncEnabled Periodic-sync toggle.
	 * @param int    $freshTtlSeconds      Fresh TTL in seconds.
	 * @param int    $staleTtlSeconds      Stale TTL in seconds.
	 * @param int    $expiredTtlSeconds    Expired TTL in seconds.
	 * @param int    $maxConcurrentJobs    Maximum concurrent jobs.
	 * @param int    $batchSize            Batch size per job.
	 * @param string $retryPolicyPreset    Retry policy preset.
	 *
	 * @throws InvalidArgumentException When any field violates
	 *                                  the per-field rules
	 *                                  summarized in the
	 *                                  file-level docblock.
	 */
	public function __construct(
		bool $enabled,
		bool $scheduledSyncEnabled,
		int $freshTtlSeconds,
		int $staleTtlSeconds,
		int $expiredTtlSeconds,
		int $maxConcurrentJobs,
		int $batchSize,
		string $retryPolicyPreset
	) {
		self::validateTtl( 'freshTtlSeconds', $freshTtlSeconds );
		self::validateTtl( 'staleTtlSeconds', $staleTtlSeconds );
		self::validateTtl( 'expiredTtlSeconds', $expiredTtlSeconds );
		self::validateTtlOrdering( $freshTtlSeconds, $staleTtlSeconds, $expiredTtlSeconds );
		self::validateRange( 'maxConcurrentJobs', $maxConcurrentJobs, 1, 16 );
		self::validateRange( 'batchSize', $batchSize, 1, 200 );

		$this->enabled              = $enabled;
		$this->scheduledSyncEnabled = $scheduledSyncEnabled;
		$this->freshTtlSeconds      = $freshTtlSeconds;
		$this->staleTtlSeconds      = $staleTtlSeconds;
		$this->expiredTtlSeconds    = $expiredTtlSeconds;
		$this->maxConcurrentJobs    = $maxConcurrentJobs;
		$this->batchSize            = $batchSize;
		$this->retryPolicyPreset    = self::normalizeRetryPolicyPreset( $retryPolicyPreset );
	}

	/**
	 * Construct a sync-settings snapshot with the canonical
	 * §13.7 baseline values: sync enabled, TTLs at 1d / 7d /
	 * 30d, two concurrent jobs, batch size 20, balanced retry.
	 */
	public static function defaults(): self {
		return new self(
			true,
			true,
			86400,
			86400 * 7,
			86400 * 30,
			2,
			20,
			'balanced'
		);
	}

	/**
	 * Master sync toggle.
	 */
	public function enabled(): bool {
		return $this->enabled;
	}

	/**
	 * Periodic-sync toggle.
	 */
	public function scheduledSyncEnabled(): bool {
		return $this->scheduledSyncEnabled;
	}

	/**
	 * Fresh TTL in seconds.
	 */
	public function freshTtlSeconds(): int {
		return $this->freshTtlSeconds;
	}

	/**
	 * Stale TTL in seconds.
	 */
	public function staleTtlSeconds(): int {
		return $this->staleTtlSeconds;
	}

	/**
	 * Expired TTL in seconds.
	 */
	public function expiredTtlSeconds(): int {
		return $this->expiredTtlSeconds;
	}

	/**
	 * Maximum concurrent sync jobs.
	 */
	public function maxConcurrentJobs(): int {
		return $this->maxConcurrentJobs;
	}

	/**
	 * Batch size per sync job.
	 */
	public function batchSize(): int {
		return $this->batchSize;
	}

	/**
	 * Retry policy preset.
	 */
	public function retryPolicyPreset(): string {
		return $this->retryPolicyPreset;
	}

	/**
	 * Produce a copy with `enabled` replaced.
	 *
	 * @param bool $enabled New toggle.
	 */
	public function withEnabled( bool $enabled ): self {
		$clone          = clone $this;
		$clone->enabled = $enabled;
		return $clone;
	}

	/**
	 * Produce a copy with `scheduledSyncEnabled` replaced.
	 *
	 * @param bool $scheduledSyncEnabled New toggle.
	 */
	public function withScheduledSyncEnabled( bool $scheduledSyncEnabled ): self {
		$clone                       = clone $this;
		$clone->scheduledSyncEnabled = $scheduledSyncEnabled;
		return $clone;
	}

	/**
	 * Produce a copy with `freshTtlSeconds` replaced.
	 *
	 * The new value must remain consistent with the existing
	 * `staleTtlSeconds` and `expiredTtlSeconds` ordering;
	 * violations raise {@see InvalidArgumentException}.
	 *
	 * @param int $freshTtlSeconds New fresh TTL.
	 *
	 * @throws InvalidArgumentException When the new value
	 *                                  violates the lower
	 *                                  bound or ordering rule.
	 */
	public function withFreshTtlSeconds( int $freshTtlSeconds ): self {
		self::validateTtl( 'freshTtlSeconds', $freshTtlSeconds );
		self::validateTtlOrdering( $freshTtlSeconds, $this->staleTtlSeconds, $this->expiredTtlSeconds );

		$clone                  = clone $this;
		$clone->freshTtlSeconds = $freshTtlSeconds;
		return $clone;
	}

	/**
	 * Produce a copy with `staleTtlSeconds` replaced.
	 *
	 * @param int $staleTtlSeconds New stale TTL.
	 *
	 * @throws InvalidArgumentException When the new value
	 *                                  violates the lower
	 *                                  bound or ordering rule.
	 */
	public function withStaleTtlSeconds( int $staleTtlSeconds ): self {
		self::validateTtl( 'staleTtlSeconds', $staleTtlSeconds );
		self::validateTtlOrdering( $this->freshTtlSeconds, $staleTtlSeconds, $this->expiredTtlSeconds );

		$clone                  = clone $this;
		$clone->staleTtlSeconds = $staleTtlSeconds;
		return $clone;
	}

	/**
	 * Produce a copy with `expiredTtlSeconds` replaced.
	 *
	 * @param int $expiredTtlSeconds New expired TTL.
	 *
	 * @throws InvalidArgumentException When the new value
	 *                                  violates the lower
	 *                                  bound or ordering rule.
	 */
	public function withExpiredTtlSeconds( int $expiredTtlSeconds ): self {
		self::validateTtl( 'expiredTtlSeconds', $expiredTtlSeconds );
		self::validateTtlOrdering( $this->freshTtlSeconds, $this->staleTtlSeconds, $expiredTtlSeconds );

		$clone                    = clone $this;
		$clone->expiredTtlSeconds = $expiredTtlSeconds;
		return $clone;
	}

	/**
	 * Produce a copy with `maxConcurrentJobs` replaced.
	 *
	 * @param int $maxConcurrentJobs New value.
	 *
	 * @throws InvalidArgumentException When the value is
	 *                                  outside the 1..16 range.
	 */
	public function withMaxConcurrentJobs( int $maxConcurrentJobs ): self {
		self::validateRange( 'maxConcurrentJobs', $maxConcurrentJobs, 1, 16 );

		$clone                    = clone $this;
		$clone->maxConcurrentJobs = $maxConcurrentJobs;
		return $clone;
	}

	/**
	 * Produce a copy with `batchSize` replaced.
	 *
	 * @param int $batchSize New value.
	 *
	 * @throws InvalidArgumentException When the value is
	 *                                  outside the 1..200 range.
	 */
	public function withBatchSize( int $batchSize ): self {
		self::validateRange( 'batchSize', $batchSize, 1, 200 );

		$clone            = clone $this;
		$clone->batchSize = $batchSize;
		return $clone;
	}

	/**
	 * Produce a copy with `retryPolicyPreset` replaced.
	 *
	 * @param string $retryPolicyPreset New preset.
	 *
	 * @throws InvalidArgumentException When the preset is off
	 *                                  the closed set.
	 */
	public function withRetryPolicyPreset( string $retryPolicyPreset ): self {
		$clone                    = clone $this;
		$clone->retryPolicyPreset = self::normalizeRetryPolicyPreset( $retryPolicyPreset );
		return $clone;
	}

	/**
	 * Report whether this snapshot equals the supplied one.
	 *
	 * Equality is structural: every field must match under
	 * strict comparison.
	 *
	 * @param self $other Other snapshot to compare against.
	 */
	public function equals( self $other ): bool {
		return $this->enabled === $other->enabled
			&& $this->scheduledSyncEnabled === $other->scheduledSyncEnabled
			&& $this->freshTtlSeconds === $other->freshTtlSeconds
			&& $this->staleTtlSeconds === $other->staleTtlSeconds
			&& $this->expiredTtlSeconds === $other->expiredTtlSeconds
			&& $this->maxConcurrentJobs === $other->maxConcurrentJobs
			&& $this->batchSize === $other->batchSize
			&& $this->retryPolicyPreset === $other->retryPolicyPreset;
	}

	/**
	 * Serialize this snapshot for OptionStore persistence.
	 *
	 * @return array<string, mixed>
	 */
	public function toArray(): array {
		return array(
			'enabled'                => $this->enabled,
			'scheduled_sync_enabled' => $this->scheduledSyncEnabled,
			'fresh_ttl_seconds'      => $this->freshTtlSeconds,
			'stale_ttl_seconds'      => $this->staleTtlSeconds,
			'expired_ttl_seconds'    => $this->expiredTtlSeconds,
			'max_concurrent_jobs'    => $this->maxConcurrentJobs,
			'batch_size'             => $this->batchSize,
			'retry_policy_preset'    => $this->retryPolicyPreset,
		);
	}

	/**
	 * Construct a snapshot from a {@see self::toArray()} payload.
	 *
	 * @param array<string, mixed> $data Persisted payload.
	 *
	 * @throws InvalidArgumentException When `$data` is malformed.
	 */
	public static function fromArray( array $data ): self {
		return new self(
			self::requireBool( $data, 'enabled' ),
			self::requireBool( $data, 'scheduled_sync_enabled' ),
			self::requireInt( $data, 'fresh_ttl_seconds' ),
			self::requireInt( $data, 'stale_ttl_seconds' ),
			self::requireInt( $data, 'expired_ttl_seconds' ),
			self::requireInt( $data, 'max_concurrent_jobs' ),
			self::requireInt( $data, 'batch_size' ),
			self::requireString( $data, 'retry_policy_preset' )
		);
	}

	/**
	 * Validate a single TTL value against the lower bound.
	 *
	 * @param string $field Field name for the error message.
	 * @param int    $value Raw value.
	 *
	 * @throws InvalidArgumentException When `$value` is below
	 *                                  the lower bound.
	 */
	private static function validateTtl( string $field, int $value ): void {
		if ( $value < self::MIN_TTL_SECONDS ) {
			$message = sprintf(
				'SyncSettings %s must be >= %d.',
				$field,
				self::MIN_TTL_SECONDS
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}

	/**
	 * Validate the TTL ordering rule
	 * `fresh <= stale <= expired`.
	 *
	 * @param int $fresh   Fresh TTL.
	 * @param int $stale   Stale TTL.
	 * @param int $expired Expired TTL.
	 *
	 * @throws InvalidArgumentException When the ordering is
	 *                                  violated.
	 */
	private static function validateTtlOrdering( int $fresh, int $stale, int $expired ): void {
		if ( $fresh > $stale ) {
			$message = 'SyncSettings freshTtlSeconds must be <= staleTtlSeconds.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $stale > $expired ) {
			$message = 'SyncSettings staleTtlSeconds must be <= expiredTtlSeconds.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}

	/**
	 * Validate an integer field against an inclusive range.
	 *
	 * @param string $field Field name for the error message.
	 * @param int    $value Raw value.
	 * @param int    $min   Inclusive lower bound.
	 * @param int    $max   Inclusive upper bound.
	 *
	 * @throws InvalidArgumentException When `$value` is outside
	 *                                  the `[$min, $max]` range.
	 */
	private static function validateRange( string $field, int $value, int $min, int $max ): void {
		if ( $value < $min || $value > $max ) {
			$message = sprintf(
				'SyncSettings %s must be in [%d, %d].',
				$field,
				$min,
				$max
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}

	/**
	 * Validate and normalize the `retryPolicyPreset` field at
	 * construction.
	 *
	 * @param string $retryPolicyPreset Raw preset value.
	 *
	 * @throws InvalidArgumentException When off the closed set.
	 */
	private static function normalizeRetryPolicyPreset( string $retryPolicyPreset ): string {
		if ( ! in_array( $retryPolicyPreset, self::RETRY_POLICY_PRESETS, true ) ) {
			$message = sprintf(
				'SyncSettings retryPolicyPreset must be one of %s.',
				implode( ', ', self::RETRY_POLICY_PRESETS )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $retryPolicyPreset;
	}

	/**
	 * Read a required bool field from a {@see self::fromArray()}
	 * payload.
	 *
	 * @param array<string, mixed> $data Persisted payload.
	 * @param string               $key  Field name.
	 *
	 * @throws InvalidArgumentException When the field is missing
	 *                                  or not a bool.
	 */
	private static function requireBool( array $data, string $key ): bool {
		if ( ! array_key_exists( $key, $data ) || ! is_bool( $data[ $key ] ) ) {
			$message = sprintf(
				'SyncSettings payload must carry a bool `%s`.',
				$key
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $data[ $key ];
	}

	/**
	 * Read a required int field from a {@see self::fromArray()}
	 * payload.
	 *
	 * @param array<string, mixed> $data Persisted payload.
	 * @param string               $key  Field name.
	 *
	 * @throws InvalidArgumentException When the field is missing
	 *                                  or not an int.
	 */
	private static function requireInt( array $data, string $key ): int {
		if ( ! array_key_exists( $key, $data ) || ! is_int( $data[ $key ] ) ) {
			$message = sprintf(
				'SyncSettings payload must carry an int `%s`.',
				$key
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $data[ $key ];
	}

	/**
	 * Read a required string field from a {@see self::fromArray()}
	 * payload.
	 *
	 * @param array<string, mixed> $data Persisted payload.
	 * @param string               $key  Field name.
	 *
	 * @throws InvalidArgumentException When the field is missing
	 *                                  or not a string.
	 */
	private static function requireString( array $data, string $key ): string {
		if ( ! array_key_exists( $key, $data ) || ! is_string( $data[ $key ] ) ) {
			$message = sprintf(
				'SyncSettings payload must carry a string `%s`.',
				$key
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $data[ $key ];
	}
}
