<?php
/**
 * Preview product card use case.
 *
 * Phase 6 Application-layer UseCase that drives the §10.1
 * `POST /affilun/v1/editor/preview` endpoint defined in
 * `specifications/rest-api-spec.md`. The class composes the
 * Phase-5 rendering pipeline — §9.1
 * {@see \Affilun\Application\Render\ViewModel\ProductCardViewModelFactory},
 * §6.5
 * {@see \Affilun\Application\Render\ProductCardRenderer},
 * §8.2 / §8.4
 * {@see \Affilun\Application\Render\ViewModelArrayDumper}
 * — with the §8.x persisted product lookup
 * ({@see \Affilun\Infrastructure\Persistence\ProductRepository})
 * so the editor can preview a saved product without ever
 * touching an external provider API. The result is projected
 * onto the §10.1 `data` shape via {@see PreviewResult::toArray()}.
 *
 * Phase-6 minimum scope notes:
 *
 *   - `buttonConfigs` is empty in Phase-6 minimum: the preview
 *     surfaces a static card without affiliate-tracked buttons.
 *     Per §10.1 the `attrs.button_labels` field is permissively
 *     ignored by this slice; the §13 affiliate-link pipeline is
 *     wired at the controller boundary in a later slice that
 *     also reaches the §11 button rendering path.
 *
 *   - `primaryImage` / `galleryImages` come from the persisted
 *     product but the Phase-2
 *     {@see \Affilun\Infrastructure\Persistence\ProductRecord}
 *     schema does not carry the gallery JSON in a hydrated form
 *     usable by the §10.4 image-presentation builder; the
 *     preview therefore renders without an image until the
 *     Phase-9 `ProductMetaRepository` wiring lands. The use case
 *     passes `null` / `array()` for these fields so the §13.3
 *     graceful-degradation branch produces a still-usable card.
 *
 *   - Sanitization (§10.1 XSS-responsibility warning):
 *     `specifications/rest-api-spec.md` §10.1 documents the
 *     `html` field as the responsibility of the SERVER side —
 *     consumers may insert it under the assumption that
 *     `wp_kses_post()`-equivalent sanitization has already been
 *     applied. The renderer output is passed through
 *     {@see PreviewResult}, which sanitizes the preview HTML before
 *     it reaches the REST envelope.
 *
 *   - `attrs` follows a permissive subset rule: only the four
 *     §8.2 meta-flag axes (`show_price`, `show_brand`,
 *     `show_description`, `show_provider_badge`) are honored
 *     when present and boolean-typed; unknown keys are silently
 *     ignored. The §10.1 spec explicitly allows this posture so
 *     the editor can ship richer block attributes without having
 *     to round-trip through a spec change for every new key.
 *
 *   - `cacheKey` follows the §10.1 form
 *     `affilun:render:preview:{userId}:{kind}:{productIds}:{template}:v1`.
 *     The preview cache layer itself is not wired in Phase-6
 *     minimum; the use case always returns `cacheHit = false`
 *     so a later slice that introduces the cache only has to
 *     swap the constructor argument at the call boundary.
 *
 * @package Affilun\Application\Editor\UseCase
 */

declare(strict_types=1);

namespace Affilun\Application\Editor\UseCase;

use Affilun\Application\Product\Exception\ProductNotFoundException;
use Affilun\Application\Render\EmbedConfigInput;
use Affilun\Application\Render\ProductCardRenderer;
use Affilun\Application\Render\ViewModel\Parts\MetaFlags;
use Affilun\Application\Product\SnapshotProjector;
use Affilun\Application\Render\ViewModel\ProductCardViewModelFactory;
use Affilun\Application\Render\ViewModelArrayDumper;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Render\RenderContext;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Infrastructure\Persistence\ProductRepository;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Phase 6 UseCase that drives the §10.1
 * `POST /affilun/v1/editor/preview` endpoint.
 *
 * See the file-level docblock for the Phase-6 minimum scope and the
 * §10.1 XSS-responsibility / sanitization deferral.
 */
final class PreviewProductCardUseCase {

	/**
	 * §10.1 single-card preview kind token.
	 */
	private const KIND_SINGLE = 'single';


	/**
	 * Closed set of accepted §10.1 kind tokens.
	 *
	 * @var list<string>
	 */
	private const VALID_KINDS = array(
		self::KIND_SINGLE,
	);

	/**
	 * Persistence port that backs the §10.1 lookup.
	 */
	private ProductRepository $repository;

	/**
	 * §9.1 view-model factory wired at construction.
	 */
	private ProductCardViewModelFactory $viewModelFactory;

	/**
	 * §6.5 renderer wired at construction.
	 */
	private ProductCardRenderer $renderer;

	/**
	 * §8.2 / §8.4 view-model array dumper wired at construction.
	 */
	private ViewModelArrayDumper $arrayDumper;

	/**
	 * Construct the UseCase with explicit collaborators.
	 *
	 * @param ProductRepository           $repository       Persistence port.
	 * @param ProductCardViewModelFactory $viewModelFactory §9.1 factory.
	 * @param ProductCardRenderer         $renderer         §6.5 renderer.
	 * @param ViewModelArrayDumper        $arrayDumper      §8.2 / §8.4 dumper.
	 */
	public function __construct(
		ProductRepository $repository,
		ProductCardViewModelFactory $viewModelFactory,
		ProductCardRenderer $renderer,
		ViewModelArrayDumper $arrayDumper
	) {
		$this->repository       = $repository;
		$this->viewModelFactory = $viewModelFactory;
		$this->renderer         = $renderer;
		$this->arrayDumper      = $arrayDumper;
	}

	/**
	 * Execute the §10.1 preview dispatch.
	 *
	 * Validation summary (each violation raises
	 * {@see InvalidArgumentException}):
	 *
	 *   - `$kind` MUST be a member of {@see self::VALID_KINDS}.
	 *   - `$productIds` MUST be a non-empty list of non-empty
	 *     strings; for `'single'` the list MUST have length 1.
	 *   - `$productIds[0]` MUST parse as a positive integer string.
	 *   - `$template` MUST be non-empty after trimming.
	 *   - `$userId` MUST be `>= 1`.
	 *
	 * Lookup: when validation passes the use case resolves the
	 * persisted product via the repository and raises
	 * {@see ProductNotFoundException::forId()} when no row
	 * matches.
	 *
	 * @param string               $kind       §10.1 preview kind (`'single'` only in Phase-6 minimum).
	 * @param list<string>         $productIds Product id list (length 1 for `'single'`).
	 * @param string               $template   §6.4 template key.
	 * @param array<string, mixed> $attrs      Block attributes (permissive subset).
	 * @param int                  $userId     User id for cache-key composition.
	 *
	 * @throws ProductNotFoundException When the resolved product id
	 *                                  does not match any persisted
	 *                                  row. Per-field validation
	 *                                  failures (raised by the
	 *                                  private `assert*` helpers
	 *                                  and the {@see self::parseProductId()}
	 *                                  helper) surface as
	 *                                  {@see InvalidArgumentException}
	 *                                  but are not listed here
	 *                                  because the literal `throw`
	 *                                  in this method body is the
	 *                                  not-found case.
	 */
	public function execute(
		string $kind,
		array $productIds,
		string $template,
		array $attrs,
		int $userId
	): PreviewResult {
		$this->assertKind( $kind );
		$this->assertProductIds( $kind, $productIds );
		$this->assertTemplate( $template );
		$this->assertUserId( $userId );

		$productId = $this->parseProductId( $productIds[0] );
		$record    = $this->repository->findById( $productId );
		if ( $record === null ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw ProductNotFoundException::forId( $productId );
		}

		$snapshot  = SnapshotProjector::project( $record );
		$config    = self::buildEmbedConfig( $template, $attrs );
		$context   = RenderContext::editor( $template );
		$viewModel = $this->viewModelFactory->build( $snapshot, $config, $context, array() );

		// PreviewResult applies the server-side sanitization required by
		// rest-api-spec.md §10.1 before the HTML reaches the editor.
		$html      = $this->renderer->render( $viewModel, $context );
		$viewArray = $this->arrayDumper->dump( $viewModel );
		$cacheKey  = self::composeCacheKey( $userId, $kind, $productIds, $template );

		// Phase-6 minimum: no preview cache layer wired yet, so the
		// hit flag is always false. See the file-level docblock.
		return new PreviewResult( $viewArray, $html, $cacheKey, false );
	}

	/**
	 * Validate the `$kind` axis against the §10.1 closed set.
	 *
	 * @param string $kind Raw kind token.
	 *
	 * @throws InvalidArgumentException When `$kind` is not a
	 *                                  member of the closed set.
	 */
	private function assertKind( string $kind ): void {
		if ( ! in_array( $kind, self::VALID_KINDS, true ) ) {
			$message = sprintf(
				'PreviewProductCardUseCase: kind must be one of [%s]; got "%s".',
				implode( ', ', self::VALID_KINDS ),
				$kind
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}


	/**
	 * Validate the `$productIds` axis against the §10.1 contract.
	 *
	 * @param string       $kind       Validated kind token.
	 * @param list<string> $productIds Raw product id list.
	 *
	 * @throws InvalidArgumentException When `$productIds` is empty,
	 *                                  carries a non-string or
	 *                                  empty entry, or when the
	 *                                  length does not match
	 *                                  `$kind`.
	 */
	private function assertProductIds( string $kind, array $productIds ): void {
		if ( $productIds === array() ) {
			$message = 'PreviewProductCardUseCase: productIds must be a non-empty list.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$expected = 0;
		foreach ( $productIds as $index => $value ) {
			if ( $index !== $expected ) {
				$message = 'PreviewProductCardUseCase: productIds must be a zero-based contiguous list.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			if ( ! is_string( $value ) || trim( $value ) === '' ) {
				$message = 'PreviewProductCardUseCase: productIds entries must be non-empty strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			++$expected;
		}

		if ( $kind === self::KIND_SINGLE && count( $productIds ) !== 1 ) {
			$message = sprintf(
				'PreviewProductCardUseCase: kind "single" requires exactly one productId; got %d.',
				count( $productIds )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}

	/**
	 * Validate the `$template` axis.
	 *
	 * @param string $template Raw template key.
	 *
	 * @throws InvalidArgumentException When `$template` is empty
	 *                                  after trimming.
	 */
	private function assertTemplate( string $template ): void {
		if ( trim( $template ) === '' ) {
			$message = 'PreviewProductCardUseCase: template must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}

	/**
	 * Validate the `$userId` axis.
	 *
	 * @param int $userId Raw user id.
	 *
	 * @throws InvalidArgumentException When `$userId` is below 1.
	 */
	private function assertUserId( int $userId ): void {
		if ( $userId < 1 ) {
			$message = sprintf(
				'PreviewProductCardUseCase: userId must be >= 1; got %d.',
				$userId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}

	/**
	 * Parse a product-id string into a positive integer.
	 *
	 * @param string $raw Raw product-id string.
	 *
	 * @throws InvalidArgumentException When `$raw` does not parse
	 *                                  as a positive integer.
	 */
	private function parseProductId( string $raw ): int {
		$parsed = filter_var(
			trim( $raw ),
			FILTER_VALIDATE_INT,
			array( 'options' => array( 'min_range' => 1 ) )
		);
		if ( $parsed === false ) {
			$message = 'PreviewProductCardUseCase: productId must be a positive integer string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $parsed;
	}



	/**
	 * Build the §10.1 {@see EmbedConfigInput} from the supplied
	 * template key and the permissive `$attrs` subset.
	 *
	 * Honored `$attrs` keys (when present and boolean-typed):
	 *
	 *   - `show_price`           → {@see MetaFlags::withShowPrice()}
	 *   - `show_brand`           → {@see MetaFlags::withShowBrand()}
	 *   - `show_description`     → {@see MetaFlags::withShowDescription()}
	 *   - `show_provider_badge`  → {@see MetaFlags::withShowProviderBadge()}
	 *
	 * Unknown keys and non-boolean values are silently ignored
	 * per the §10.1 permissive-subset note.
	 *
	 * @param string               $template §6.4 template key.
	 * @param array<string, mixed> $attrs    Block attributes.
	 */
	private static function buildEmbedConfig( string $template, array $attrs ): EmbedConfigInput {
		$meta = MetaFlags::defaults();
		if ( array_key_exists( 'show_price', $attrs ) && is_bool( $attrs['show_price'] ) ) {
			$meta = $meta->withShowPrice( $attrs['show_price'] );
		}
		if ( array_key_exists( 'show_brand', $attrs ) && is_bool( $attrs['show_brand'] ) ) {
			$meta = $meta->withShowBrand( $attrs['show_brand'] );
		}
		if ( array_key_exists( 'show_description', $attrs ) && is_bool( $attrs['show_description'] ) ) {
			$meta = $meta->withShowDescription( $attrs['show_description'] );
		}
		if ( array_key_exists( 'show_provider_badge', $attrs ) && is_bool( $attrs['show_provider_badge'] ) ) {
			$meta = $meta->withShowProviderBadge( $attrs['show_provider_badge'] );
		}

		// Phase-6 minimum: buttonConfigs is empty; preview shows a
		// static card without affiliate-tracked buttons. See the
		// file-level docblock.
		return new EmbedConfigInput(
			$template,
			array(),
			$meta,
			null,
			false
		);
	}

	/**
	 * Compose the §10.1 preview cache key.
	 *
	 * Form: `affilun:render:preview:{userId}:{kind}:{ids}:{template}:v1`
	 * where `{ids}` is the comma-joined `$productIds` list. The
	 * `userId` segment scopes the cache per author so a preview
	 * never leaks across user boundaries.
	 *
	 * @param int          $userId     User id for cache scoping.
	 * @param string       $kind       Validated kind token.
	 * @param list<string> $productIds Validated product-id list.
	 * @param string       $template   Validated template key.
	 */
	private static function composeCacheKey(
		int $userId,
		string $kind,
		array $productIds,
		string $template
	): string {
		return 'affilun:render:preview:'
			. $userId . ':'
			. $kind . ':'
			. implode( ',', $productIds ) . ':'
			. $template . ':v1';
	}
}
