<?php
/**
 * affilun Cloud-backed product search-keywords extractor.
 *
 * @package Affilun\Application\Product
 */

declare(strict_types=1);

namespace Affilun\Application\Product;

use Affilun\Application\Cloud\CloudProductKeywordsClient;
use Affilun\Application\Settings\SettingsRepository;
use Affilun\Domain\Product\ProductSnapshot;
use Throwable;

/**
 * Normalizes saved-product keywords with affilun Cloud and falls back locally.
 */
final class CloudProductSearchKeywordsExtractor implements ProductSearchKeywordsExtractor {

	/**
	 * @param SettingsRepository             $settings Settings repository.
	 * @param ProductSearchKeywordsExtractor $fallback Fallback extractor.
	 * @param CloudProductKeywordsClient     $client   Cloud client.
	 */
	public function __construct(
		private SettingsRepository $settings,
		private ProductSearchKeywordsExtractor $fallback,
		private CloudProductKeywordsClient $client = new CloudProductKeywordsClient()
	) {}

	/**
	 * Extract normalized search keywords.
	 *
	 * @param ProductSnapshot $snapshot Product snapshot being saved.
	 */
	public function extract( ProductSnapshot $snapshot ): SearchKeywordsExtractionResult {
		$fallback = $this->fallback->extract( $snapshot );

		try {
			$cloud = $this->settings->load()->cloud();
			if ( ! $cloud->isConfigured() ) {
				return $fallback;
			}
			$keywords = $this->client->normalize(
				$cloud,
				self::payloadFromSnapshot( $snapshot ),
				time(),
				bin2hex( random_bytes( 12 ) )
			);
		} catch ( Throwable $unused ) {
			unset( $unused );
			return $fallback;
		}

		$keywords = trim( $keywords );
		if ( $keywords === '' ) {
			return $fallback;
		}

		return new SearchKeywordsExtractionResult( $keywords, 'cloud' );
	}

	/**
	 * Build Cloud product payload.
	 *
	 * @param ProductSnapshot $snapshot Product snapshot.
	 *
	 * @return array<string,mixed>
	 */
	private static function payloadFromSnapshot( ProductSnapshot $snapshot ): array {
		$payload    = array(
			'provider'         => self::boundedString( $snapshot->provider(), 80 ),
			'provider_item_id' => self::boundedString( $snapshot->providerProductId()->value(), 255 ),
			'title'            => self::boundedString( $snapshot->title(), 500 ),
			'brand'            => self::boundedNullableString( $snapshot->brand(), 255 ),
			'description'      => self::boundedNullableString( $snapshot->description(), 4000 ),
			'detail_url'       => self::boundedNullableString( $snapshot->detailUrl(), 2048 ),
			'affiliate_url'    => self::boundedNullableString( $snapshot->affiliateUrl(), 2048 ),
			'rating'           => self::boundedNullableString( $snapshot->rating(), 32 ),
			'review_count'     => $snapshot->reviewCount(),
			'categories'       => self::boundedStringList( $snapshot->categories(), 20, 255 ),
		);
		$attributes = self::boundedAttributeMap( $snapshot->attributes(), 40, 120, 512 );
		if ( $attributes !== array() ) {
			$payload['attributes'] = $attributes;
		}
		return $payload;
	}

	/**
	 * Bound text for Cloud schema limits.
	 *
	 * @param string $value Text.
	 * @param int    $max   Maximum character length.
	 */
	private static function boundedString( string $value, int $max ): string {
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $value, 0, $max );
		}
		return substr( $value, 0, $max );
	}

	/**
	 * Bound nullable text.
	 *
	 * @param string|null $value Text.
	 * @param int         $max   Maximum character length.
	 */
	private static function boundedNullableString( ?string $value, int $max ): ?string {
		if ( $value === null ) {
			return null;
		}
		return self::boundedString( $value, $max );
	}

	/**
	 * @param array<int,string> $values Raw string list.
	 * @param int               $countLimit Maximum number of values.
	 * @param int               $itemLimit  Maximum character length per value.
	 *
	 * @return array<int,string>
	 */
	private static function boundedStringList( array $values, int $countLimit, int $itemLimit ): array {
		$result = array();
		foreach ( array_slice( $values, 0, $countLimit ) as $value ) {
			$result[] = self::boundedString( $value, $itemLimit );
		}
		return $result;
	}

	/**
	 * @param array<string, scalar|null> $attributes Raw attributes.
	 * @param int                        $countLimit Maximum number of attributes.
	 * @param int                        $keyLimit   Maximum character length per key.
	 * @param int                        $valueLimit Maximum character length per string value.
	 *
	 * @return array<string, scalar|null>
	 */
	private static function boundedAttributeMap( array $attributes, int $countLimit, int $keyLimit, int $valueLimit ): array {
		$result = array();
		foreach ( $attributes as $key => $value ) {
			if ( count( $result ) >= $countLimit ) {
				break;
			}
			$key = self::boundedString( $key, $keyLimit );
			if ( $key === '' ) {
				continue;
			}
			if ( is_string( $value ) ) {
				$result[ $key ] = self::boundedString( $value, $valueLimit );
			} else {
				$result[ $key ] = $value;
			}
		}
		return $result;
	}
}
