<?php
/**
 * Sync freshness state value object.
 *
 * Immutable value object that represents the freshness state of a
 * product snapshot relative to its provider source, per
 * `specifications/sync-and-cache-strategy.md` §7.1–§7.3 and
 * ADR-0006. The type unifies the closed set of state tokens
 * (`fresh`, `stale`, `expired`, `broken`, `archived`) so the sync
 * scheduler, render pipeline, and admin diagnostics can branch on
 * a single validated value without re-deriving freshness from
 * sibling fields such as `last_synced_at` or `last_sync_error_code`.
 *
 * State semantics from §7.2 (summarized):
 *
 *   - `fresh` — sufficiently recent; safe for normal display; no
 *     resync required.
 *   - `stale` — beginning to age; still displayable; eligible for
 *     background resync.
 *   - `expired` — past the freshness requirement for normal use;
 *     should not be displayed without re-fetch in normal flow;
 *     high-priority resync target; constrained fallback only.
 *   - `broken` — missing minimum required fields for display;
 *     fallback card or hidden; structural integrity failure.
 *   - `archived` — operationally retired / soft-deleted; out of
 *     band relative to the freshness axis but tracked here so the
 *     state machine has a single typed surface.
 *
 * Per §7.2 the canonical "displayable in normal flow" set is
 * {fresh, stale}. `expired` is conditionally displayable as a
 * constrained fallback, but {@see self::isDisplayable()} is
 * intentionally restrictive and returns false for `expired` so
 * call sites that need fallback rendering must opt in explicitly
 * rather than receiving it implicitly via a permissive predicate.
 *
 * @package CommerceLinkBuilder\Domain\Sync
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Domain\Sync;

use InvalidArgumentException;

/**
 * Immutable freshness state token from `sync-and-cache-strategy.md`
 * §7.1. See the file-level docblock for the mapping to §7.2 state
 * semantics and the rationale for the conservative
 * {@see self::isDisplayable()} predicate.
 */
final class SyncState {

	/**
	 * Snapshot is recent enough for normal display with no resync
	 * required (§7.2 `fresh`).
	 */
	public const FRESH = 'fresh';

	/**
	 * Snapshot is beginning to age but is still displayable; a
	 * background resync should be scheduled (§7.2 `stale`).
	 */
	public const STALE = 'stale';

	/**
	 * Snapshot has aged past the normal freshness requirement and
	 * should not be rendered without a re-fetch in the normal flow
	 * (§7.2 `expired`). High-priority resync target.
	 */
	public const EXPIRED = 'expired';

	/**
	 * Snapshot is missing minimum required fields for display, or
	 * has failed normalization in a way that cannot be recovered
	 * from the stored data alone (§7.2 `broken`).
	 */
	public const BROKEN = 'broken';

	/**
	 * Snapshot is operationally retired / soft-deleted and is out
	 * of the freshness rotation (§7.2 `archived`). Tracked on the
	 * same axis only because the storage layer carries it on the
	 * same column; semantically it is an orthogonal status.
	 */
	public const ARCHIVED = 'archived';

	/**
	 * Closed set of accepted state tokens, in §7.1 declaration
	 * order. Maintained as a class-level constant so the validator
	 * and the test suite can iterate the same source of truth.
	 *
	 * @var list<string>
	 */
	private const VALID_STATES = array(
		self::FRESH,
		self::STALE,
		self::EXPIRED,
		self::BROKEN,
		self::ARCHIVED,
	);

	/**
	 * One of the {@see self::FRESH} / {@see self::STALE} /
	 * {@see self::EXPIRED} / {@see self::BROKEN} /
	 * {@see self::ARCHIVED} tokens.
	 */
	private string $value;

	/**
	 * Private constructor — instances are obtained via the static
	 * named constructors {@see self::fresh()} / {@see self::stale()}
	 * / {@see self::expired()} / {@see self::broken()} /
	 * {@see self::archived()} or via {@see self::fromString()} for
	 * deserialization paths.
	 *
	 * @param string $value Pre-validated state token.
	 */
	private function __construct( string $value ) {
		$this->value = $value;
	}

	/**
	 * Construct a `fresh` state instance.
	 */
	public static function fresh(): self {
		return new self( self::FRESH );
	}

	/**
	 * Construct a `stale` state instance.
	 */
	public static function stale(): self {
		return new self( self::STALE );
	}

	/**
	 * Construct an `expired` state instance.
	 */
	public static function expired(): self {
		return new self( self::EXPIRED );
	}

	/**
	 * Construct a `broken` state instance.
	 */
	public static function broken(): self {
		return new self( self::BROKEN );
	}

	/**
	 * Construct an `archived` state instance.
	 */
	public static function archived(): self {
		return new self( self::ARCHIVED );
	}

	/**
	 * Construct a state instance from a raw string token,
	 * validating against the closed §7.1 set.
	 *
	 * The token is trimmed first so a carelessly-decoded payload
	 * (trailing newline, padded space) is normalized to the same
	 * canonical form a freshly-constructed state would carry.
	 *
	 * @param string $value Raw state token as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$value` is not a
	 *                                  member of the §7.1 closed
	 *                                  set.
	 */
	public static function fromString( string $value ): self {
		$trimmed = trim( $value );

		if ( ! in_array( $trimmed, self::VALID_STATES, true ) ) {
			$message = sprintf(
				'SyncState must be one of [%s]; got "%s".',
				implode( ', ', self::VALID_STATES ),
				$value
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return new self( $trimmed );
	}

	/**
	 * Closed set of accepted state tokens, in §7.1 declaration
	 * order. Exposed for tests and for diagnostics surfaces that
	 * need to enumerate the valid tokens without duplicating the
	 * list.
	 *
	 * @return list<string>
	 */
	public static function validStates(): array {
		return self::VALID_STATES;
	}

	/**
	 * Canonical state token.
	 */
	public function value(): string {
		return $this->value;
	}

	/**
	 * Value-object equality: two states are equal iff their tokens
	 * match exactly.
	 *
	 * @param SyncState $other Other state to compare against.
	 */
	public function equals( SyncState $other ): bool {
		return $this->value === $other->value;
	}

	/**
	 * Whether this is the `fresh` state.
	 */
	public function isFresh(): bool {
		return self::FRESH === $this->value;
	}

	/**
	 * Whether this is the `stale` state.
	 */
	public function isStale(): bool {
		return self::STALE === $this->value;
	}

	/**
	 * Whether this is the `expired` state.
	 */
	public function isExpired(): bool {
		return self::EXPIRED === $this->value;
	}

	/**
	 * Whether this is the `broken` state.
	 */
	public function isBroken(): bool {
		return self::BROKEN === $this->value;
	}

	/**
	 * Whether this is the `archived` state.
	 */
	public function isArchived(): bool {
		return self::ARCHIVED === $this->value;
	}

	/**
	 * Whether the snapshot is displayable in the normal rendering
	 * flow.
	 *
	 * Per §7.2, `fresh` and `stale` are unambiguously displayable.
	 * `expired` is conditionally displayable as a constrained
	 * fallback; this predicate is intentionally restrictive and
	 * returns false for `expired` so call sites that need fallback
	 * rendering must opt in explicitly via {@see self::isExpired()}
	 * rather than receiving it implicitly here. `broken` and
	 * `archived` are never displayable.
	 */
	public function isDisplayable(): bool {
		return self::FRESH === $this->value || self::STALE === $this->value;
	}

	/**
	 * Whether the snapshot warrants a resync.
	 *
	 * Returns true for `stale`, `expired`, and `broken` — the three
	 * states where re-fetching the upstream snapshot can plausibly
	 * recover a usable result. Returns false for `fresh` (already
	 * current) and `archived` (operationally retired; resyncing is
	 * out of scope until reactivation).
	 */
	public function requiresResync(): bool {
		return self::STALE === $this->value
			|| self::EXPIRED === $this->value
			|| self::BROKEN === $this->value;
	}
}
