<?php
/**
 * Scheduled link-health state.
 *
 * @package Affilun\Application\LinkHealth
 */

declare(strict_types=1);

namespace Affilun\Application\LinkHealth;

use DateTimeImmutable;
use Exception;

/**
 * Cursor and retry state persisted between scheduled link-health ticks.
 */
final class ScheduledLinkHealthState {

	/**
	 * @param int                    $nextPage        Next product-list cursor page.
	 * @param int                    $attempts        Consecutive failed attempts.
	 * @param DateTimeImmutable|null $nextAvailableAt Earliest retry instant.
	 * @param string|null            $lastError       Last failure summary.
	 */
	private function __construct(
		private int $nextPage,
		private int $attempts,
		private ?DateTimeImmutable $nextAvailableAt,
		private ?string $lastError
	) {}

	/**
	 * Default state.
	 */
	public static function defaults(): self {
		return new self( 1, 0, null, null );
	}

	/**
	 * Reconstitute state from option-store data.
	 *
	 * @param array<string,mixed> $data Stored data.
	 */
	public static function fromArray( array $data ): self {
		$nextPage = isset( $data['next_page'] ) && is_numeric( $data['next_page'] )
			? max( 1, (int) $data['next_page'] )
			: 1;
		$attempts = isset( $data['attempts'] ) && is_numeric( $data['attempts'] )
			? max( 0, (int) $data['attempts'] )
			: 0;

		$nextAvailableAt = null;
		$rawNext         = is_scalar( $data['next_available_at'] ?? null )
			? (string) $data['next_available_at']
			: '';
		if ( $rawNext !== '' ) {
			try {
				$nextAvailableAt = new DateTimeImmutable( $rawNext );
			} catch ( Exception $unused ) {
				unset( $unused );
				$nextAvailableAt = null;
			}
		}

		$lastError = is_scalar( $data['last_error'] ?? null ) ? (string) $data['last_error'] : null;
		return new self( $nextPage, $attempts, $nextAvailableAt, $lastError );
	}

	/**
	 * Serialize for option storage.
	 *
	 * @return array<string,mixed>
	 */
	public function toArray(): array {
		return array(
			'next_page'         => $this->nextPage,
			'attempts'          => $this->attempts,
			'next_available_at' => $this->nextAvailableAt?->format( DATE_ATOM ),
			'last_error'        => $this->lastError,
		);
	}

	/** Next product-list cursor page. */
	public function nextPage(): int {
		return $this->nextPage;
	}

	/** Consecutive failed attempts. */
	public function attempts(): int {
		return $this->attempts;
	}

	/** Earliest retry instant. */
	public function nextAvailableAt(): ?DateTimeImmutable {
		return $this->nextAvailableAt;
	}

	/** Last failure summary. */
	public function lastError(): ?string {
		return $this->lastError;
	}

	/**
	 * Whether a tick may run now.
	 *
	 * @param DateTimeImmutable $now Current instant.
	 */
	public function canRun( DateTimeImmutable $now ): bool {
		return $this->nextAvailableAt === null
			|| $this->nextAvailableAt->getTimestamp() <= $now->getTimestamp();
	}

	/**
	 * Return state after a successful tick.
	 *
	 * @param int $nextPage Next cursor page.
	 */
	public function recordSuccess( int $nextPage ): self {
		return new self( max( 1, $nextPage ), 0, null, null );
	}

	/**
	 * Return state after a failed tick.
	 *
	 * @param int               $attempts        Consecutive failed attempts to store.
	 * @param DateTimeImmutable $nextAvailableAt Earliest retry instant.
	 * @param string            $lastError       Last failure summary.
	 */
	public function recordRetry( int $attempts, DateTimeImmutable $nextAvailableAt, string $lastError ): self {
		return new self(
			$this->nextPage,
			max( 0, $attempts ),
			$nextAvailableAt,
			substr( $lastError, 0, 300 )
		);
	}
}
