<?php
/**
 * Stale-product finder.
 *
 * Phase-9-MVP surface that walks the persisted product catalogue
 * and returns the records whose freshness state warrants
 * follow-up sync work, per
 * `specifications/sync-and-cache-strategy.md` §13.
 *
 * Strategy:
 *
 *   - The finder pulls a paginated batch of candidates via
 *     {@see ProductRepository::findPaginated()} and classifies
 *     each candidate in-memory through the injected
 *     {@see SyncPolicy}. This is intentionally simple: a future
 *     slice can replace the in-memory filter with a dedicated
 *     repository query that pushes the predicate into SQL.
 *
 *   - The repository is paginated by `last_synced_at asc`, which
 *     biases the bounded scan toward the oldest snapshots first. This
 *     is still not the same as the §13 priority ordering, so the finder
 *     caps the total number of rows it will pull
 *     (`maxScanRows`) so a very large catalogue cannot turn a
 *     single call into an unbounded scan. Callers that want
 *     "every stale row" should iterate themselves over a
 *     dedicated query once that exists.
 *
 *   - Result ordering is computed in-memory after filtering:
 *     primarily by {@see SyncPriority} weight (descending —
 *     urgent first), secondarily by `lastSyncedAt` (ascending,
 *     `null` first — never-synced records bubble to the top of
 *     their priority bucket per the §13.1 "古い順" guidance).
 *     Ties beyond that fall back to the record id (ascending)
 *     so the order is stable across calls.
 *
 *   - `findStale` returns rows in either `stale` or `expired`
 *     state. `findExpired` and `findBroken` are narrower
 *     surfaces over the same scan.
 *
 * @package Affilun\Application\Sync
 */

declare(strict_types=1);

namespace Affilun\Application\Sync;

use Affilun\Domain\Sync\SyncState;
use Affilun\Infrastructure\Persistence\ProductListQuery;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Infrastructure\Persistence\ProductRepository;
use InvalidArgumentException;

/**
 * Surfaces records that need re-sync. See the file-level
 * docblock for the Phase-9-MVP scan / cap strategy and the
 * priority + `lastSyncedAt` ordering rule.
 */
final class StaleProductFinder {

	/**
	 * Default cap on the total number of candidate rows the
	 * finder will pull from the repository in a single call.
	 */
	public const DEFAULT_MAX_SCAN_ROWS = 500;

	/**
	 * Page size used when walking the repository.
	 */
	private const SCAN_PAGE_SIZE = 50;

	/**
	 * Repository the finder scans.
	 */
	private ProductRepository $products;

	/**
	 * Policy used to classify each candidate record.
	 */
	private SyncPolicy $policy;

	/**
	 * Maximum total rows the finder will pull before stopping.
	 */
	private int $maxScanRows;

	/**
	 * Construct the finder with explicit dependencies.
	 *
	 * @param ProductRepository $products    Source of candidate
	 *                                       rows.
	 * @param SyncPolicy        $policy      Classifier used to
	 *                                       decide which records
	 *                                       are stale / expired /
	 *                                       broken.
	 * @param int               $maxScanRows Cap on candidate
	 *                                       rows pulled per call
	 *                                       (must be > 0).
	 *
	 * @throws InvalidArgumentException When `$maxScanRows` is
	 *                                  non-positive.
	 */
	public function __construct(
		ProductRepository $products,
		SyncPolicy $policy,
		int $maxScanRows = self::DEFAULT_MAX_SCAN_ROWS
	) {
		if ( $maxScanRows <= 0 ) {
			$message = sprintf(
				'StaleProductFinder: maxScanRows must be > 0, got %d.',
				$maxScanRows
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->products    = $products;
		$this->policy      = $policy;
		$this->maxScanRows = $maxScanRows;
	}

	/**
	 * Records currently in `stale` or `expired` state, ordered
	 * by priority desc / `lastSyncedAt` asc (nulls first).
	 *
	 * @param int $limit Maximum number of records to return
	 *                   (must be > 0).
	 *
	 * @return list<ProductRecord>
	 *
	 * @throws InvalidArgumentException When `$limit` is
	 *                                  non-positive.
	 */
	public function findStale( int $limit ): array {
		return $this->collect(
			$limit,
			static function ( SyncState $state ): bool {
				return $state->isStale() || $state->isExpired();
			}
		);
	}

	/**
	 * Records currently in `expired` state, ordered as in
	 * {@see self::findStale()}.
	 *
	 * @param int $limit Maximum number of records to return
	 *                   (must be > 0).
	 *
	 * @return list<ProductRecord>
	 *
	 * @throws InvalidArgumentException When `$limit` is
	 *                                  non-positive.
	 */
	public function findExpired( int $limit ): array {
		return $this->collect(
			$limit,
			static function ( SyncState $state ): bool {
				return $state->isExpired();
			}
		);
	}

	/**
	 * Records currently in `broken` state, ordered as in
	 * {@see self::findStale()}.
	 *
	 * @param int $limit Maximum number of records to return
	 *                   (must be > 0).
	 *
	 * @return list<ProductRecord>
	 *
	 * @throws InvalidArgumentException When `$limit` is
	 *                                  non-positive.
	 */
	public function findBroken( int $limit ): array {
		return $this->collect(
			$limit,
			static function ( SyncState $state ): bool {
				return $state->isBroken();
			}
		);
	}

	/**
	 * Cap on the candidate rows the finder pulls per call.
	 */
	public function maxScanRows(): int {
		return $this->maxScanRows;
	}

	/**
	 * Walk the repository up to {@see self::$maxScanRows}, keep
	 * the records whose state passes `$accept`, then sort and
	 * truncate to `$limit`.
	 *
	 * @param int                       $limit  Maximum records to return.
	 * @param callable(SyncState): bool $accept State predicate.
	 *
	 * @return list<ProductRecord>
	 *
	 * @throws InvalidArgumentException When `$limit` is non-positive.
	 */
	private function collect( int $limit, callable $accept ): array {
		if ( $limit <= 0 ) {
			$message = sprintf(
				'StaleProductFinder: limit must be > 0, got %d.',
				$limit
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$matches = array();
		$page    = 1;
		$scanned = 0;

		while ( $scanned < $this->maxScanRows ) {
			$remaining = $this->maxScanRows - $scanned;
			$perPage   = $remaining < self::SCAN_PAGE_SIZE ? $remaining : self::SCAN_PAGE_SIZE;

			$query = new ProductListQuery(
				$page,
				$perPage,
				null,
				null,
				null,
				null,
				null,
				ProductListQuery::SORT_KEY_LAST_SYNCED_AT,
				ProductListQuery::SORT_ORDER_ASC
			);

			$result = $this->products->findPaginated( $query );
			$items  = $result->items();

			foreach ( $items as $record ) {
				++$scanned;
				$state = $this->policy->evaluateState( $record );
				if ( $accept( $state ) ) {
					$matches[] = $record;
				}
			}

			if ( count( $items ) < $perPage ) {
				break;
			}

			++$page;
		}

		usort(
			$matches,
			function ( ProductRecord $a, ProductRecord $b ): int {
				$priorityA = $this->policy->priorityFor( $a )->weight();
				$priorityB = $this->policy->priorityFor( $b )->weight();

				if ( $priorityA !== $priorityB ) {
					return $priorityB <=> $priorityA;
				}

				$lastA = $a->lastSyncedAt();
				$lastB = $b->lastSyncedAt();

				if ( $lastA === null && $lastB !== null ) {
					return -1;
				}
				if ( $lastA !== null && $lastB === null ) {
					return 1;
				}
				if ( $lastA !== null && $lastB !== null ) {
					$cmp = $lastA->getTimestamp() <=> $lastB->getTimestamp();
					if ( $cmp !== 0 ) {
						return $cmp;
					}
				}

				$idA = $a->id();
				$idB = $b->id();
				if ( $idA === null && $idB === null ) {
					return 0;
				}
				if ( $idA === null ) {
					return -1;
				}
				if ( $idB === null ) {
					return 1;
				}
				return $idA <=> $idB;
			}
		);

		return array_slice( $matches, 0, $limit );
	}
}
