<?php
/**
 * Editor preview result value object.
 *
 * Phase 6 Application-layer immutable VO that carries the §10.1
 * `POST /affilun/v1/editor/preview` response payload produced by the
 * {@see PreviewProductCardUseCase}. The class isolates the
 * `data` shape the §10.1 envelope wraps (the §8.2 / §8.4 view-
 * model array, the rendered HTML string, and the preview-cache
 * descriptor) so the REST controller — added in a subsequent
 * slice — can map a single VO onto the success envelope without
 * re-deriving the field set from the use case's collaborators.
 *
 * Field semantics match `specifications/rest-api-spec.md` §10.1
 * exactly:
 *
 *   - `viewModel` is the read-only associative-array shape the
 *     {@see \Affilun\Application\Render\ViewModelArrayDumper}
 *     produces for a §8.2 ProductCardViewModel or §8.4
 *     FallbackCardViewModel. The §10.1 success envelope exposes
 *     it under the `view_model` key (snake_case at the wire
 *     boundary, camelCase inside the use case).
 *
 *   - `html` is the rendered HTML string the
 *     {@see \Affilun\Application\Render\ProductCardRenderer}
 *     produced. Per §10.1 the sanitization responsibility is
 *     server-side; this VO sanitizes the HTML with `wp_kses_post()`
 *     when WordPress is available and falls back to a conservative
 *     script/style/event-attribute scrub in pure-unit contexts. It
 *     then refuses construction when the sanitized trim leaves it
 *     empty so an upstream rendering failure surfaces here instead
 *     of producing an empty preview card.
 *
 *   - `cacheKey` is the §10.1 preview-cache key string of the
 *     form `affilun:render:preview:{userId}:{...}:v1`. The VO does
 *     not parse the structure; it only enforces non-empty after
 *     trim so a programming error in the key composer surfaces
 *     at the type-system boundary rather than as a malformed
 *     cache key in production logs.
 *
 *   - `cacheHit` is the boolean flag the §10.1 envelope's
 *     `cache.hit` field exposes. Phase-6 minimum returns false
 *     unconditionally because the preview cache layer is not
 *     wired yet; the field is part of the VO contract today so
 *     a later slice that introduces the cache only has to flip
 *     the constructor argument at the call site.
 *
 * Instances are immutable. There are no `with*()` helpers
 * because a preview result represents a single computed
 * outcome; any change in the underlying inputs should be
 * modeled as a fresh use-case invocation.
 *
 * @package Affilun\Application\Editor\UseCase
 */

declare(strict_types=1);

namespace Affilun\Application\Editor\UseCase;

use InvalidArgumentException;

/**
 * Immutable Phase-6 editor preview result.
 *
 * See the file-level docblock for the mapping to
 * `rest-api-spec.md` §10.1 and the rationale for keeping the
 * `cacheHit` axis on the VO surface even though Phase-6 minimum
 * returns false unconditionally.
 */
final class PreviewResult {

	/**
	 * §8.2 / §8.4 view-model array as produced by
	 * {@see \Affilun\Application\Render\ViewModelArrayDumper}.
	 *
	 * @var array<string, mixed>
	 */
	private array $viewModel;

	/**
	 * Rendered HTML string from
	 * {@see \Affilun\Application\Render\ProductCardRenderer}.
	 */
	private string $html;

	/**
	 * §10.1 preview-cache key string.
	 */
	private string $cacheKey;

	/**
	 * Whether the §10.1 preview cache served this result. Phase-6
	 * minimum: always false (no preview cache layer wired yet).
	 */
	private bool $cacheHit;

	/**
	 * Construct a preview result with explicit values for every
	 * field.
	 *
	 * Validation:
	 *
	 *   - `viewModel` array-shape is allowed empty. The field is
	 *     forwarded to the §10.1 envelope verbatim; a fallback
	 *     view model with no buttons can legitimately produce
	 *     an array whose top-level slots are present but empty.
	 *   - `html` is rejected when trimming leaves an empty
	 *     string — an upstream rendering failure must not
	 *     silently produce an empty preview card.
	 *   - `cacheKey` is rejected when trimming leaves an empty
	 *     string — a key-composer programming error must not
	 *     silently produce a malformed cache key.
	 *
	 * @param array<string, mixed> $viewModel §8.2 / §8.4 view-model array.
	 * @param string               $html      Rendered HTML string.
	 * @param string               $cacheKey  §10.1 preview-cache key.
	 * @param bool                 $cacheHit  Whether the cache served this result.
	 *
	 * @throws InvalidArgumentException When `$html` or `$cacheKey`
	 *                                  is the empty string after
	 *                                  trimming.
	 */
	public function __construct(
		array $viewModel,
		string $html,
		string $cacheKey,
		bool $cacheHit
	) {
		$html = self::sanitizeHtml( $html );

		if ( trim( $html ) === '' ) {
			$message = 'PreviewResult html must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( trim( $cacheKey ) === '' ) {
			$message = 'PreviewResult cacheKey must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->viewModel = $viewModel;
		$this->html      = $html;
		$this->cacheKey  = $cacheKey;
		$this->cacheHit  = $cacheHit;
	}

	/**
	 * Sanitize preview HTML for safe insertion in the block editor.
	 *
	 * @param string $html Rendered preview HTML.
	 */
	private static function sanitizeHtml( string $html ): string {
		if ( function_exists( 'wp_kses_post' ) ) {
			return (string) wp_kses_post( $html );
		}

		$sanitized = preg_replace( '#<(script|style)\\b[^>]*>.*?</\\1>#is', '', $html );
		if ( ! is_string( $sanitized ) ) {
			return '';
		}
		$sanitized = preg_replace( '/\\s+on[a-z0-9_-]+\\s*=\\s*("[^"]*"|\'[^\']*\'|[^\\s>]+)/i', '', $sanitized );
		if ( ! is_string( $sanitized ) ) {
			return '';
		}
		$sanitized = preg_replace( '/\\s+(href|src)\\s*=\\s*("|\')\\s*javascript:[^"\']*\\2/i', '', $sanitized );
		return is_string( $sanitized ) ? $sanitized : '';
	}

	/**
	 * §8.2 / §8.4 view-model array.
	 *
	 * @return array<string, mixed>
	 */
	public function viewModel(): array {
		return $this->viewModel;
	}

	/**
	 * Rendered HTML string.
	 */
	public function html(): string {
		return $this->html;
	}

	/**
	 * §10.1 preview-cache key string.
	 */
	public function cacheKey(): string {
		return $this->cacheKey;
	}

	/**
	 * Whether the §10.1 preview cache served this result.
	 */
	public function cacheHit(): bool {
		return $this->cacheHit;
	}

	/**
	 * Project this VO onto the §10.1 `data` array shape the REST
	 * controller wraps in the success envelope.
	 *
	 * The returned array's top-level key set and ordering match
	 * `specifications/rest-api-spec.md` §10.1 verbatim:
	 *
	 *   - `view_model` (snake_case at the wire boundary).
	 *   - `html`.
	 *   - `cache.key`, `cache.hit`.
	 *
	 * @return array{
	 *     view_model: array<string, mixed>,
	 *     html: string,
	 *     cache: array{key: string, hit: bool}
	 * }
	 */
	public function toArray(): array {
		return array(
			'view_model' => $this->viewModel,
			'html'       => $this->html,
			'cache'      => array(
				'key' => $this->cacheKey,
				'hit' => $this->cacheHit,
			),
		);
	}

	/**
	 * Report whether this VO equals the supplied one.
	 *
	 * Equality is structural: the view-model array is compared
	 * with strict `===` (key order and value types matter), the
	 * scalar fields with strict comparison.
	 *
	 * @param self $other Other VO to compare against.
	 */
	public function equals( self $other ): bool {
		if ( $this->html !== $other->html ) {
			return false;
		}

		if ( $this->cacheKey !== $other->cacheKey ) {
			return false;
		}

		if ( $this->cacheHit !== $other->cacheHit ) {
			return false;
		}

		return $this->viewModel === $other->viewModel;
	}
}
