<?php
/**
 * Cache settings value object.
 *
 * Immutable Phase-8 value object that carries the §13.8 Cache
 * tab settings of `specifications/admin-ui-spec.md`. The fields
 * mirror the tab's UI controls one-to-one:
 *
 *   - `searchCacheEnabled` toggles the provider-search response
 *     cache that the §13.8 "Enable Search Cache" control exposes.
 *
 *   - `searchCacheTtlSeconds` / `lookupCacheTtlSeconds` /
 *     `renderCacheTtlSeconds` / `providerHealthCacheTtlSeconds`
 *     are the per-cache TTL bounds. Each MUST be non-negative;
 *     zero is permitted as the canonical "cache disabled at the
 *     TTL boundary" sentinel.
 *
 *   - `renderCacheEnabled` toggles the §8.2 ProductCardViewModel
 *     render-output cache.
 *
 * 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.8 Cache tab settings bundle.
 *
 * See the file-level docblock for the field-by-field mapping
 * to the §13.8 admin-ui controls and the non-negative TTL rule.
 */
final class CacheSettings {

	/**
	 * Whether the provider-search response cache is enabled.
	 */
	private bool $searchCacheEnabled;

	/**
	 * Search cache TTL in seconds (>= 0).
	 */
	private int $searchCacheTtlSeconds;

	/**
	 * Lookup cache TTL in seconds (>= 0).
	 */
	private int $lookupCacheTtlSeconds;

	/**
	 * Whether the render-output cache is enabled.
	 */
	private bool $renderCacheEnabled;

	/**
	 * Render cache TTL in seconds (>= 0).
	 */
	private int $renderCacheTtlSeconds;

	/**
	 * Provider-health cache TTL in seconds (>= 0).
	 */
	private int $providerHealthCacheTtlSeconds;

	/**
	 * Construct a cache-settings snapshot with explicit values
	 * for every field.
	 *
	 * @param bool $searchCacheEnabled            Whether the search cache is enabled.
	 * @param int  $searchCacheTtlSeconds         Search cache TTL in seconds.
	 * @param int  $lookupCacheTtlSeconds         Lookup cache TTL in seconds.
	 * @param bool $renderCacheEnabled            Whether the render cache is enabled.
	 * @param int  $renderCacheTtlSeconds         Render cache TTL in seconds.
	 * @param int  $providerHealthCacheTtlSeconds Provider-health cache TTL in seconds.
	 *
	 * @throws InvalidArgumentException When any TTL is negative.
	 */
	public function __construct(
		bool $searchCacheEnabled,
		int $searchCacheTtlSeconds,
		int $lookupCacheTtlSeconds,
		bool $renderCacheEnabled,
		int $renderCacheTtlSeconds,
		int $providerHealthCacheTtlSeconds
	) {
		self::validateNonNegative( 'searchCacheTtlSeconds', $searchCacheTtlSeconds );
		self::validateNonNegative( 'lookupCacheTtlSeconds', $lookupCacheTtlSeconds );
		self::validateNonNegative( 'renderCacheTtlSeconds', $renderCacheTtlSeconds );
		self::validateNonNegative( 'providerHealthCacheTtlSeconds', $providerHealthCacheTtlSeconds );

		$this->searchCacheEnabled            = $searchCacheEnabled;
		$this->searchCacheTtlSeconds         = $searchCacheTtlSeconds;
		$this->lookupCacheTtlSeconds         = $lookupCacheTtlSeconds;
		$this->renderCacheEnabled            = $renderCacheEnabled;
		$this->renderCacheTtlSeconds         = $renderCacheTtlSeconds;
		$this->providerHealthCacheTtlSeconds = $providerHealthCacheTtlSeconds;
	}

	/**
	 * Construct a cache-settings snapshot with the canonical
	 * §13.8 baseline values.
	 */
	public static function defaults(): self {
		return new self( true, 3600, 86400, false, 60, 300 );
	}

	/**
	 * Whether the search cache is enabled.
	 */
	public function searchCacheEnabled(): bool {
		return $this->searchCacheEnabled;
	}

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

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

	/**
	 * Whether the render cache is enabled.
	 */
	public function renderCacheEnabled(): bool {
		return $this->renderCacheEnabled;
	}

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

	/**
	 * Provider-health cache TTL in seconds.
	 */
	public function providerHealthCacheTtlSeconds(): int {
		return $this->providerHealthCacheTtlSeconds;
	}

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

	/**
	 * Produce a copy with `searchCacheTtlSeconds` replaced.
	 *
	 * @param int $searchCacheTtlSeconds New TTL.
	 *
	 * @throws InvalidArgumentException When negative.
	 */
	public function withSearchCacheTtlSeconds( int $searchCacheTtlSeconds ): self {
		self::validateNonNegative( 'searchCacheTtlSeconds', $searchCacheTtlSeconds );

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

	/**
	 * Produce a copy with `lookupCacheTtlSeconds` replaced.
	 *
	 * @param int $lookupCacheTtlSeconds New TTL.
	 *
	 * @throws InvalidArgumentException When negative.
	 */
	public function withLookupCacheTtlSeconds( int $lookupCacheTtlSeconds ): self {
		self::validateNonNegative( 'lookupCacheTtlSeconds', $lookupCacheTtlSeconds );

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

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

	/**
	 * Produce a copy with `renderCacheTtlSeconds` replaced.
	 *
	 * @param int $renderCacheTtlSeconds New TTL.
	 *
	 * @throws InvalidArgumentException When negative.
	 */
	public function withRenderCacheTtlSeconds( int $renderCacheTtlSeconds ): self {
		self::validateNonNegative( 'renderCacheTtlSeconds', $renderCacheTtlSeconds );

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

	/**
	 * Produce a copy with `providerHealthCacheTtlSeconds`
	 * replaced.
	 *
	 * @param int $providerHealthCacheTtlSeconds New TTL.
	 *
	 * @throws InvalidArgumentException When negative.
	 */
	public function withProviderHealthCacheTtlSeconds( int $providerHealthCacheTtlSeconds ): self {
		self::validateNonNegative( 'providerHealthCacheTtlSeconds', $providerHealthCacheTtlSeconds );

		$clone                                = clone $this;
		$clone->providerHealthCacheTtlSeconds = $providerHealthCacheTtlSeconds;
		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->searchCacheEnabled === $other->searchCacheEnabled
			&& $this->searchCacheTtlSeconds === $other->searchCacheTtlSeconds
			&& $this->lookupCacheTtlSeconds === $other->lookupCacheTtlSeconds
			&& $this->renderCacheEnabled === $other->renderCacheEnabled
			&& $this->renderCacheTtlSeconds === $other->renderCacheTtlSeconds
			&& $this->providerHealthCacheTtlSeconds === $other->providerHealthCacheTtlSeconds;
	}

	/**
	 * Serialize this snapshot for OptionStore persistence.
	 *
	 * @return array<string, mixed>
	 */
	public function toArray(): array {
		return array(
			'search_cache_enabled'              => $this->searchCacheEnabled,
			'search_cache_ttl_seconds'          => $this->searchCacheTtlSeconds,
			'lookup_cache_ttl_seconds'          => $this->lookupCacheTtlSeconds,
			'render_cache_enabled'              => $this->renderCacheEnabled,
			'render_cache_ttl_seconds'          => $this->renderCacheTtlSeconds,
			'provider_health_cache_ttl_seconds' => $this->providerHealthCacheTtlSeconds,
		);
	}

	/**
	 * 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, 'search_cache_enabled' ),
			self::requireInt( $data, 'search_cache_ttl_seconds' ),
			self::requireInt( $data, 'lookup_cache_ttl_seconds' ),
			self::requireBool( $data, 'render_cache_enabled' ),
			self::requireInt( $data, 'render_cache_ttl_seconds' ),
			self::requireInt( $data, 'provider_health_cache_ttl_seconds' )
		);
	}

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

	/**
	 * 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(
				'CacheSettings 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(
				'CacheSettings 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 ];
	}
}
