<?php
/**
 * Sync freshness / priority policy.
 *
 * Pure-domain policy object that decides, for a given
 * {@see ProductRecord} and a "now" instant supplied by the
 * injected {@see Clock}:
 *
 *   - Whether the snapshot needs to be re-synced
 *     ({@see self::requiresResync()}).
 *
 *   - The freshness state token to attribute to the snapshot
 *     ({@see self::evaluateState()}), per
 *     `specifications/sync-and-cache-strategy.md` §7.1–§7.2 and
 *     §13.1.
 *
 *   - The sync-queue priority appropriate to the snapshot
 *     ({@see self::priorityFor()}), per §13.2.
 *
 * Boundaries (§13.1 "判定基準"):
 *
 *   - `staleAfterSeconds` — the age past which a snapshot ages out
 *     of `fresh` into `stale`. Comparison is inclusive of the
 *     boundary: `age <= staleAfterSeconds` is still `fresh`.
 *
 *   - `expiredAfterSeconds` — the age past which a snapshot ages
 *     out of `stale` into `expired`. Comparison is inclusive:
 *     `age <= expiredAfterSeconds` is still `stale`.
 *
 *   - The two thresholds MUST satisfy
 *     `expiredAfterSeconds > staleAfterSeconds`; the constructor
 *     validates this at construction time so a misconfigured
 *     policy fails loudly rather than silently classifying every
 *     stale-grade record as expired.
 *
 * Special cases:
 *
 *   - A `broken` `syncState` short-circuits the freshness check
 *     and is reported as {@see SyncState::broken()} regardless of
 *     timestamps (§7.2 — broken means "missing minimum required
 *     fields", which is structural, not temporal).
 *
 *   - An `archived` `lifecycleState` short-circuits the freshness
 *     check and is reported as {@see SyncState::archived()}
 *     (§7.2 — archived snapshots are out of the freshness rotation).
 *
 *   - `lastSyncedAt === null` (never successfully synced) is
 *     treated as `expired`-grade per §7.2's "再同期が強く推奨される
 *     状態" intent: a snapshot that has never produced a known-good
 *     fetch should be at least as urgent as one that has aged past
 *     `expiredAfterSeconds`. This documented choice keeps the
 *     never-synced cohort visible to the high-priority sweep
 *     without inventing a separate state token.
 *
 * Phase 9 MVP scope:
 *
 *   - This class consumes the global TTL boundaries supplied at
 *     construction time. Per-provider overrides described in §9.3
 *     are out of scope for slice 9d; a future slice can introduce
 *     a provider-aware variant or compose multiple policies.
 *
 * @package Affilun\Application\Sync
 */

declare(strict_types=1);

namespace Affilun\Application\Sync;

use Affilun\Domain\Clock;
use Affilun\Domain\Sync\SyncPriority;
use Affilun\Domain\Sync\SyncState;
use Affilun\Infrastructure\Persistence\ProductRecord;
use InvalidArgumentException;

/**
 * Decide freshness state and sync priority for a stored
 * {@see ProductRecord}. See the file-level docblock for the
 * §7 / §13 boundary semantics and the documented treatment of
 * `broken` / `archived` / never-synced records.
 */
final class SyncPolicy {

	/**
	 * Inclusive upper bound (in seconds) for the `fresh` band.
	 */
	private int $staleAfterSeconds;

	/**
	 * Inclusive upper bound (in seconds) for the `stale` band;
	 * strictly greater than {@see self::$staleAfterSeconds}.
	 */
	private int $expiredAfterSeconds;

	/**
	 * Clock used to resolve "now" for freshness arithmetic.
	 */
	private Clock $clock;

	/**
	 * Construct the policy with explicit boundaries.
	 *
	 * @param int   $staleAfterSeconds   Inclusive upper bound for
	 *                                   the `fresh` band (must be
	 *                                   > 0).
	 * @param int   $expiredAfterSeconds Inclusive upper bound for
	 *                                   the `stale` band (must be
	 *                                   > `$staleAfterSeconds`).
	 * @param Clock $clock               Clock that resolves "now".
	 *
	 * @throws InvalidArgumentException When either threshold is
	 *                                  non-positive, or when the
	 *                                  ordering rule
	 *                                  `expiredAfter > staleAfter`
	 *                                  is violated.
	 */
	public function __construct(
		int $staleAfterSeconds,
		int $expiredAfterSeconds,
		Clock $clock
	) {
		if ( $staleAfterSeconds <= 0 ) {
			$message = sprintf(
				'SyncPolicy: staleAfterSeconds must be > 0, got %d.',
				$staleAfterSeconds
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $expiredAfterSeconds <= 0 ) {
			$message = sprintf(
				'SyncPolicy: expiredAfterSeconds must be > 0, got %d.',
				$expiredAfterSeconds
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $expiredAfterSeconds <= $staleAfterSeconds ) {
			$message = sprintf(
				'SyncPolicy: expiredAfterSeconds (%d) must be > staleAfterSeconds (%d).',
				$expiredAfterSeconds,
				$staleAfterSeconds
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->staleAfterSeconds   = $staleAfterSeconds;
		$this->expiredAfterSeconds = $expiredAfterSeconds;
		$this->clock               = $clock;
	}

	/**
	 * Inclusive upper bound (in seconds) for the `fresh` band.
	 */
	public function staleAfterSeconds(): int {
		return $this->staleAfterSeconds;
	}

	/**
	 * Inclusive upper bound (in seconds) for the `stale` band.
	 */
	public function expiredAfterSeconds(): int {
		return $this->expiredAfterSeconds;
	}

	/**
	 * Decide the freshness state of the given record.
	 *
	 * Decision order:
	 *
	 *   1. Structural break (`syncState === 'broken'`) → broken.
	 *   2. Lifecycle archive (`lifecycleState === 'archived'`) →
	 *      archived.
	 *   3. Never-synced (`lastSyncedAt === null`) → expired (per
	 *      the file-level docblock).
	 *   4. Otherwise compute `age = now - lastSyncedAt` and bucket
	 *      it by `staleAfterSeconds` / `expiredAfterSeconds`.
	 *
	 * @param ProductRecord $record Record to classify.
	 */
	public function evaluateState( ProductRecord $record ): SyncState {
		if ( $record->syncState() === SyncState::BROKEN ) {
			return SyncState::broken();
		}

		if ( $record->lifecycleState() === 'archived' ) {
			return SyncState::archived();
		}

		$lastSyncedAt = $record->lastSyncedAt();
		if ( $lastSyncedAt === null ) {
			return SyncState::expired();
		}

		$now = $this->clock->now();
		$age = $now->getTimestamp() - $lastSyncedAt->getTimestamp();

		// Defensive: a future-dated lastSyncedAt is treated as
		// fresh rather than as an arithmetic anomaly.
		if ( $age <= 0 ) {
			return SyncState::fresh();
		}

		if ( $age <= $this->staleAfterSeconds ) {
			return SyncState::fresh();
		}

		if ( $age <= $this->expiredAfterSeconds ) {
			return SyncState::stale();
		}

		return SyncState::expired();
	}

	/**
	 * Whether the given record warrants a resync.
	 *
	 * Equivalent to `evaluateState($record)->requiresResync()`.
	 *
	 * @param ProductRecord $record Record under consideration.
	 */
	public function requiresResync( ProductRecord $record ): bool {
		return $this->evaluateState( $record )->requiresResync();
	}

	/**
	 * Decide the sync-queue priority for the given record.
	 *
	 * Mapping (§13.2):
	 *
	 *   - archived → low (out of rotation; low-priority cleanup
	 *     only).
	 *   - broken   → urgent (recovery path; structural failure
	 *     should be addressed promptly).
	 *   - expired  → high  (past the freshness requirement; should
	 *     be refreshed before normal display).
	 *   - stale    → normal (eligible for routine background
	 *     resync).
	 *   - fresh    → low   (already current).
	 *
	 * @param ProductRecord $record Record under consideration.
	 */
	public function priorityFor( ProductRecord $record ): SyncPriority {
		$state = $this->evaluateState( $record );

		if ( $state->isArchived() ) {
			return SyncPriority::low();
		}

		if ( $state->isBroken() ) {
			return SyncPriority::urgent();
		}

		if ( $state->isExpired() ) {
			return SyncPriority::high();
		}

		if ( $state->isStale() ) {
			return SyncPriority::normal();
		}

		return SyncPriority::low();
	}
}
