<?php
/**
 * Daily Amazon discount synchronization.
 *
 * @package Affilun\Application\AmazonDiscount
 */

declare(strict_types=1);

namespace Affilun\Application\AmazonDiscount;

use Affilun\Domain\Affiliate\AffiliateContext;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Provider\Exception\RateLimitExceededException;
use RuntimeException;
use Throwable;

/**
 * Fetches category snapshots from Creators API and replaces local rows.
 */
final class AmazonDiscountSyncService {

	public const DEFAULT_MIN_SAVING_PERCENT = 20;
	public const DEFAULT_PAGES_PER_CATEGORY = 10;
	public const ITEMS_PER_PAGE             = 10;
	public const REQUEST_INTERVAL_SECONDS   = 2;

	/**
	 * @var callable(int):void
	 */
	private $sleep;

	/**
	 * @var list<string>
	 */
	private array $priorityBrands;

	/**
	 * @param AmazonDiscountCatalog     $catalog    Amazon catalog.
	 * @param AmazonDiscountRepository  $repository Snapshot repository.
	 * @param (callable(int):void)|null $sleep      Sleep seam for rate limiting.
	 * @param list<mixed>               $priorityBrands Brands synchronized before categories.
	 */
	public function __construct(
		private AmazonDiscountCatalog $catalog,
		private AmazonDiscountRepository $repository,
		?callable $sleep = null,
		array $priorityBrands = array()
	) {
		$this->sleep          = $sleep ?? static function ( int $seconds ): void {
			sleep( $seconds );
		};
		$this->priorityBrands = AmazonDiscountBrand::normalize( $priorityBrands );
	}

	/**
	 * Run a full physical-category sweep.
	 */
	public function sync(): AmazonDiscountSyncResult {
		$saved       = array();
		$errors      = array();
		$requestMade = false;

		foreach ( $this->priorityBrands as $brand ) {
			$key = AmazonDiscountBrand::storageKey( $brand );
			try {
				$products      = $this->fetchCollection( $key, 'All', $brand, $requestMade );
				$saved[ $key ] = $this->repository->replaceBrand( $brand, $products );
			} catch ( Throwable $error ) {
				$errors[ $key ] = $error->getMessage();
			}
		}

		foreach ( AmazonDiscountCategory::all() as $searchIndex ) {
			try {
				$products              = $this->fetchCollection( $searchIndex, $searchIndex, null, $requestMade );
				$saved[ $searchIndex ] = $this->repository->replaceCategory( $searchIndex, $products );
			} catch ( Throwable $error ) {
				$errors[ $searchIndex ] = $error->getMessage();
			}
		}

		if ( $saved === array() ) {
			throw new RuntimeException( 'Amazon discount sync failed for every category.' );
		}

		return new AmazonDiscountSyncResult( $saved, $errors );
	}

	/**
	 * Fetch all pages for one category or brand collection.
	 *
	 * @param string      $collectionKey Stored collection key.
	 * @param string      $searchIndex   Amazon SearchIndex.
	 * @param string|null $brand         Optional brand filter.
	 * @param bool        $requestMade   Whether an earlier request was made.
	 *
	 * @return list<AmazonDiscountProduct>
	 */
	private function fetchCollection(
		string $collectionKey,
		string $searchIndex,
		?string $brand,
		bool &$requestMade
	): array {
		$products = array();
		$seen     = array();
		for ( $page = 1; $page <= self::DEFAULT_PAGES_PER_CATEGORY; ++$page ) {
			if ( $requestMade ) {
				$this->pause( self::REQUEST_INTERVAL_SECONDS );
			}
			$requestMade = true;
			$result      = $this->searchWithRetry( $searchIndex, $page, $brand );
			$items       = $result->items();
			if ( $items === array() ) {
				break;
			}
			foreach ( $items as $offset => $snapshot ) {
				$asin = $snapshot->providerProductId()->value();
				if ( isset( $seen[ $asin ] ) ) {
					continue;
				}
				$seen[ $asin ] = true;
				$products[]    = $this->toRecord(
					$collectionKey,
					( $page - 1 ) * self::ITEMS_PER_PAGE + $offset + 1,
					$snapshot
				);
			}
			if ( ! $result->hasMore() ) {
				break;
			}
		}
		return $products;
	}

	/**
	 * Search one page with bounded rate-limit retries.
	 *
	 * @param string      $searchIndex Amazon SearchIndex.
	 * @param int         $page        One-based page.
	 * @param string|null $brand  Optional brand filter.
	 */
	private function searchWithRetry(
		string $searchIndex,
		int $page,
		?string $brand
	): \Affilun\Domain\Search\SearchResult {
		$backoffs = array( 2, 4, 8 );
		foreach ( array( 0, 1, 2, 3 ) as $attempt ) {
			try {
				return $brand === null
					? $this->catalog->searchDiscountProducts(
						$searchIndex,
						self::DEFAULT_MIN_SAVING_PERCENT,
						$page,
						self::ITEMS_PER_PAGE
					)
					: $this->catalog->searchBrandDiscountProducts(
						$brand,
						self::DEFAULT_MIN_SAVING_PERCENT,
						$page,
						self::ITEMS_PER_PAGE
					);
			} catch ( RateLimitExceededException $error ) {
				if ( ! isset( $backoffs[ $attempt ] ) ) {
					throw $error;
				}
				$this->pause( $backoffs[ $attempt ] );
			}
		}

		throw new RuntimeException( 'Amazon discount search retry loop exhausted.' );
	}

	/**
	 * Convert a provider snapshot into a stored discount row.
	 *
	 * @param string          $searchIndex Amazon SearchIndex.
	 * @param int             $position    Upstream result position.
	 * @param ProductSnapshot $snapshot    Provider snapshot.
	 */
	private function toRecord(
		string $searchIndex,
		int $position,
		ProductSnapshot $snapshot
	): AmazonDiscountProduct {
		$price     = $snapshot->price();
		$affiliate = $this->catalog->buildAffiliateLink( $snapshot, AffiliateContext::empty() );

		return new AmazonDiscountProduct(
			$searchIndex,
			$position,
			$snapshot->providerProductId()->value(),
			$snapshot->title(),
			$snapshot->brand(),
			$snapshot->detailUrl(),
			$affiliate->url(),
			$snapshot->primaryImage(),
			$price?->amount(),
			$price?->currency(),
			$price?->label(),
			self::savingsPercentage( $snapshot ),
			self::DEFAULT_MIN_SAVING_PERCENT,
			$snapshot->fetchedAt()
		);
	}

	/**
	 * Extract the selected listing saving percentage.
	 *
	 * @param ProductSnapshot $snapshot Provider snapshot.
	 */
	private static function savingsPercentage( ProductSnapshot $snapshot ): ?int {
		$offers = $snapshot->rawMeta()['offersV2'] ?? null;
		if ( ! is_array( $offers ) ) {
			return null;
		}
		$listings = $offers['listings'] ?? null;
		if ( ! is_array( $listings ) ) {
			return null;
		}
		$selectedSaving = null;
		$buyBoxSaving   = null;
		foreach ( $listings as $listing ) {
			if ( ! is_array( $listing ) ) {
				continue;
			}
			$saving = self::listingSavingsPercentage( $listing );
			if ( $saving === null ) {
				continue;
			}
			$selectedSaving = $selectedSaving === null ? $saving : max( $selectedSaving, $saving );
			if ( ! empty( $listing['isBuyBoxWinner'] ) ) {
				$buyBoxSaving = $saving;
			}
		}
		return $buyBoxSaving ?? $selectedSaving;
	}

	/**
	 * Extract a saving percentage from one listing.
	 *
	 * @param array<string,mixed> $listing Creators API listing.
	 */
	private static function listingSavingsPercentage( array $listing ): ?int {
		$value = $listing['dealDetails']['savingsPercentage']
			?? $listing['price']['savings']['percentage']
			?? $listing['price']['savingsPercentage']
			?? $listing['savingsPercentage']
			?? null;
		if ( is_int( $value ) ) {
			return max( 0, min( 100, $value ) );
		}
		if ( is_numeric( $value ) ) {
			return max( 0, min( 100, (int) $value ) );
		}
		return null;
	}

	/**
	 * Pause through the injected sleep seam.
	 *
	 * @param int $seconds Pause duration.
	 */
	private function pause( int $seconds ): void {
		$sleep = $this->sleep;
		$sleep( $seconds );
	}
}
