<?php
/**
 * Centralized cache-key factory.
 *
 * Single source of truth for the cache-key forms enumerated in
 * `specifications/sync-and-cache-strategy.md` §6.3 (provider
 * search / single-item lookup / health, Render Cache embed and
 * adhoc variants, preview-scoped key, template resolution) and
 * `specifications/rendering-and-template-spec.md` §22.2 (the
 * `productsHash` / `configHash` derivations and the canonical
 * list of `relevantBlockAttrs` that participate in
 * `configHash`).
 *
 * The class is intentionally instance-based with a no-argument
 * constructor so it can be wired through the DI service
 * provider in later Phase 9 slices and substituted in tests
 * without monkey-patching static state. All methods are pure:
 * the same inputs always produce the same key.
 *
 * Validation here is conservative — every helper throws
 * {@see \InvalidArgumentException} on malformed inputs rather
 * than silently producing a key that would either collide with
 * unrelated entries or break the colon-separated parse used by
 * downstream observability tooling.
 *
 * @package Affilun\Application\Cache
 */

declare(strict_types=1);

namespace Affilun\Application\Cache;

use InvalidArgumentException;

/**
 * Builds the project's canonical cache keys and supporting
 * hashes. See the file-level docblock for spec citations.
 */
final class CacheKeyFactory {

	/**
	 * Cache-key version segment (single point of change for a
	 * future bump per §6.5 version strategy).
	 */
	private const VERSION = 'v1';

	/**
	 * Provider whitelist used by provider-scoped keys.
	 *
	 * `all` is the cross-provider channel used by the unified
	 * search aggregator.
	 *
	 * @var list<string>
	 */
	private const PROVIDERS = array( 'amazon', 'rakuten', 'yahoo_shopping', 'all' );

	/**
	 * Render-cache `kind` whitelist (§22.2).
	 *
	 * @var list<string>
	 */
	private const KINDS = array( 'single', 'grid' );

	/**
	 * Sort-key whitelist used in `productsHash` per §22.2.
	 *
	 * @var list<string>
	 */
	private const SORT_KEYS = array( 'manual', 'price_asc', 'price_desc', 'recent' );

	/**
	 * `configHash` attribute whitelist — common keys (apply to
	 * both `single` and `grid`). Mirrors
	 * `rendering-and-template-spec.md` §22.2 "共通".
	 *
	 * @var list<string>
	 */
	private const RELEVANT_ATTR_KEYS_COMMON = array(
		'template',
		'showPrice',
		'showBrand',
		'showDescription',
		'showProviderBadge',
		'showDisclaimer',
		'openInNewTab',
		'nofollow',
		'sponsored',
		'trackingEnabled',
		'buttonLabels',
		'align',
		'templateVariant',
		'buttonLayout',
		'buttonShape',
		'buttonTone',
		'colorScheme',
	);

	/**
	 * `configHash` attribute whitelist — grid-only keys.
	 * Mirrors `rendering-and-template-spec.md` §22.2 "grid 固有".
	 *
	 * @var list<string>
	 */
	private const RELEVANT_ATTR_KEYS_GRID = array(
		'columns',
		'gap',
		'sortMode',
	);

	/**
	 * Public no-arg constructor. The factory is dependency-free
	 * by design; it is registered as an instance so DI wiring
	 * can substitute it (e.g. for keyed-fixture tests) without
	 * reaching into static state.
	 */
	public function __construct() {
	}

	/**
	 * Build a provider-search cache key:
	 * `affilun:provider:{provider}:search:{queryHash}:v1`.
	 *
	 * @param string $provider  One of {@see self::PROVIDERS}.
	 * @param string $queryHash 64-char lowercase hex (sha256).
	 *
	 * @throws InvalidArgumentException When inputs are invalid.
	 */
	public function searchKey( string $provider, string $queryHash ): string {
		$provider = $this->assertProvider( $provider );
		$this->assertSha256Hex( $queryHash, 'queryHash' );

		return 'affilun:provider:' . $provider . ':search:' . $queryHash . ':' . self::VERSION;
	}

	/**
	 * Convenience wrapper for the cross-provider search channel
	 * (`provider = "all"`).
	 *
	 * @param string $queryHash 64-char lowercase hex (sha256).
	 *
	 * @throws InvalidArgumentException When inputs are invalid.
	 */
	public function crossProviderSearchKey( string $queryHash ): string {
		return $this->searchKey( 'all', $queryHash );
	}

	/**
	 * Build a single-item lookup cache key:
	 * `affilun:provider:{provider}:item:{providerItemHash}:v1`.
	 *
	 * Provider item ids are case-sensitive and may contain characters that
	 * conflict with the colon-separated cache-key form (Rakuten itemCode,
	 * for example, is `shop:item`). The key therefore stores a deterministic
	 * sha256 digest of the trimmed id rather than the raw id.
	 *
	 * @param string $provider       One of {@see self::PROVIDERS}.
	 * @param string $providerItemId Sanitized item id token.
	 *
	 * @throws InvalidArgumentException When inputs are invalid.
	 */
	public function lookupKey( string $provider, string $providerItemId ): string {
		$provider       = $this->assertProvider( $provider );
		$providerItemId = $this->assertProviderItemId( $providerItemId );

		return 'affilun:provider:' . $provider . ':item:' . hash( 'sha256', $providerItemId ) . ':' . self::VERSION;
	}

	/**
	 * Build a provider health cache key:
	 * `affilun:provider:{provider}:health:v1`.
	 *
	 * @param string $provider One of {@see self::PROVIDERS}.
	 *
	 * @throws InvalidArgumentException When inputs are invalid.
	 */
	public function providerHealthKey( string $provider ): string {
		$provider = $this->assertProvider( $provider );

		return 'affilun:provider:' . $provider . ':health:' . self::VERSION;
	}

	/**
	 * Build an embed-backed render-cache key:
	 * `affilun:render:{kind}:embed:{embedId}:{template}:{configHash}:v1`.
	 *
	 * @param string $kind       `single` or `grid`.
	 * @param int    $embedId    Positive embed id.
	 * @param string $template   Non-empty trimmed template key.
	 * @param string $configHash 64-char lowercase hex (sha256).
	 *
	 * @throws InvalidArgumentException When inputs are invalid.
	 */
	public function renderEmbedKey(
		string $kind,
		int $embedId,
		string $template,
		string $configHash
	): string {
		$kind     = $this->assertKind( $kind );
		$template = $this->assertTemplate( $template );
		$this->assertSha256Hex( $configHash, 'configHash' );
		if ( $embedId < 1 ) {
			$message = sprintf(
				'CacheKeyFactory: embedId must be >= 1; got %d.',
				$embedId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return 'affilun:render:' . $kind . ':embed:'
			. $embedId . ':'
			. $template . ':'
			. $configHash . ':' . self::VERSION;
	}

	/**
	 * Build an ad-hoc render-cache key:
	 * `affilun:render:{kind}:adhoc:{productsHash}:{template}:{configHash}:v1`.
	 *
	 * @param string $kind         `single` or `grid`.
	 * @param string $productsHash 64-char lowercase hex (sha256).
	 * @param string $template     Non-empty trimmed template key.
	 * @param string $configHash   64-char lowercase hex (sha256).
	 *
	 * @throws InvalidArgumentException When inputs are invalid.
	 */
	public function renderAdhocKey(
		string $kind,
		string $productsHash,
		string $template,
		string $configHash
	): string {
		$kind     = $this->assertKind( $kind );
		$template = $this->assertTemplate( $template );
		$this->assertSha256Hex( $productsHash, 'productsHash' );
		$this->assertSha256Hex( $configHash, 'configHash' );

		return 'affilun:render:' . $kind . ':adhoc:'
			. $productsHash . ':'
			. $template . ':'
			. $configHash . ':' . self::VERSION;
	}

	/**
	 * Build the §10.1 / §6.3 preview cache key:
	 * `affilun:render:preview:{userId}:{kind}:{idsToken}:{template}:v1`.
	 *
	 * The `idsToken` parameter is intentionally untyped beyond
	 * "non-empty trimmed string": callers may pass either the
	 * raw comma-joined product-id list (the existing form
	 * produced by `PreviewProductCardUseCase::composeCacheKey()`)
	 * or a hash of it. The byte-level form is preserved so the
	 * Phase-6 preview cache continues to hit the same key.
	 *
	 * `$userId >= 0` is permitted (0 keeps the surface
	 * conservative; the higher-level use case still enforces
	 * `>= 1` for authenticated previews).
	 *
	 * @param int    $userId   Non-negative scoping id.
	 * @param string $kind     `single` or `grid`.
	 * @param string $idsToken Comma-joined ids or a hash thereof.
	 * @param string $template Non-empty trimmed template key.
	 *
	 * @throws InvalidArgumentException When inputs are invalid.
	 */
	public function previewKey(
		int $userId,
		string $kind,
		string $idsToken,
		string $template
	): string {
		if ( $userId < 0 ) {
			$message = sprintf(
				'CacheKeyFactory: userId must be >= 0; got %d.',
				$userId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
		$kind     = $this->assertKind( $kind );
		$template = $this->assertTemplate( $template );
		if ( trim( $idsToken ) === '' ) {
			$message = 'CacheKeyFactory: idsToken must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return 'affilun:render:preview:'
			. $userId . ':'
			. $kind . ':'
			. $idsToken . ':'
			. $template . ':' . self::VERSION;
	}

	/**
	 * Build a template-resolution cache key:
	 * `affilun:template:{templateKey}:{themeHash}:v1`.
	 *
	 * @param string $templateKey Non-empty trimmed template key.
	 * @param string $themeHash   Non-empty trimmed theme hash.
	 *
	 * @throws InvalidArgumentException When inputs are invalid.
	 */
	public function templateKey( string $templateKey, string $themeHash ): string {
		if ( trim( $templateKey ) === '' ) {
			$message = 'CacheKeyFactory: templateKey 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( $themeHash ) === '' ) {
			$message = 'CacheKeyFactory: themeHash must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return 'affilun:template:' . $templateKey . ':' . $themeHash . ':' . self::VERSION;
	}

	/**
	 * Compute `productsHash` per §22.2:
	 * `sha256( canonical_join(productIds) + "|" + sortKey )`.
	 *
	 * `productIds` is normalized (each entry trimmed, rejected
	 * if empty), sorted ascending lexicographically, and
	 * comma-joined. `sortKey` must be one of
	 * {@see self::SORT_KEYS}.
	 *
	 * @param list<string> $productIds Non-empty list of ids.
	 * @param string       $sortKey    Validated sort token.
	 *
	 * @throws InvalidArgumentException When inputs are invalid.
	 */
	public function productsHash( array $productIds, string $sortKey ): string {
		if ( $productIds === array() ) {
			$message = 'CacheKeyFactory: productIds must be a non-empty list.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
		if ( ! in_array( $sortKey, self::SORT_KEYS, true ) ) {
			$message = sprintf(
				'CacheKeyFactory: sortKey must be one of [%s]; got "%s".',
				implode( ', ', self::SORT_KEYS ),
				$sortKey
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$normalized = array();
		foreach ( $productIds as $index => $value ) {
			if ( ! is_string( $value ) ) {
				$message = sprintf(
					'CacheKeyFactory: productIds[%d] must be a string; got %s.',
					(int) $index,
					gettype( $value )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			$trimmed = trim( $value );
			if ( $trimmed === '' ) {
				$message = 'CacheKeyFactory: productIds entries must be non-empty trimmed strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			$normalized[] = $trimmed;
		}

		sort( $normalized, SORT_STRING );
		$canonical = implode( ',', $normalized );

		return hash( 'sha256', $canonical . '|' . $sortKey );
	}

	/**
	 * Compute `configHash` per §22.2: sha256 of a canonical
	 * JSON serialization of the (recursively key-sorted) input
	 * array.
	 *
	 * The factory does not enforce that `$relevantBlockAttrs`
	 * only contains whitelisted keys — that is the caller's
	 * responsibility, because some callers intentionally hash
	 * the canonical superset for forward compatibility. Use
	 * {@see self::filterRelevantAttrs()} to drop disallowed
	 * keys before hashing when strict adherence is desired.
	 *
	 * @param array<string,mixed> $relevantBlockAttrs Attribute
	 *                                                map.
	 *
	 * @throws InvalidArgumentException When JSON encoding fails.
	 */
	public function configHash( array $relevantBlockAttrs ): string {
		return $this->canonicalJsonSha256( $relevantBlockAttrs, 'configHash' );
	}

	/**
	 * Compute the `queryHash` used by provider-search keys:
	 * sha256 of a canonical JSON serialization of the
	 * descriptor.
	 *
	 * @param array<string,mixed> $queryDescriptor Normalized
	 *                                             descriptor.
	 *
	 * @throws InvalidArgumentException When JSON encoding fails.
	 */
	public function queryHash( array $queryDescriptor ): string {
		return $this->canonicalJsonSha256( $queryDescriptor, 'queryHash' );
	}

	/**
	 * Drop attribute keys that are not part of the §22.2
	 * whitelist for the given `kind`.
	 *
	 * Filtering is intentionally opt-in (see
	 * {@see self::configHash()}): some callers want to hash the
	 * canonical superset for forward compatibility with
	 * unrecognized attributes added in later releases. Callers
	 * that want strict §22.2 adherence should run their
	 * attribute map through this filter before hashing.
	 *
	 * @param array<string,mixed> $attrs Attribute map.
	 * @param string              $kind  `single` or `grid`.
	 *
	 * @return array<string,mixed>
	 *
	 * @throws InvalidArgumentException When `$kind` is invalid.
	 */
	public static function filterRelevantAttrs( array $attrs, string $kind ): array {
		if ( ! in_array( $kind, self::KINDS, true ) ) {
			$message = sprintf(
				'CacheKeyFactory: kind must be one of [%s]; got "%s".',
				implode( ', ', self::KINDS ),
				$kind
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$allowed = self::RELEVANT_ATTR_KEYS_COMMON;
		if ( $kind === 'grid' ) {
			$allowed = array_merge( $allowed, self::RELEVANT_ATTR_KEYS_GRID );
		}

		$filtered = array();
		foreach ( $attrs as $key => $value ) {
			if ( is_string( $key ) && in_array( $key, $allowed, true ) ) {
				$filtered[ $key ] = $value;
			}
		}

		return $filtered;
	}

	/**
	 * Canonical-JSON sha256 helper used by both
	 * {@see self::configHash()} and {@see self::queryHash()}.
	 *
	 * @param array<string,mixed>|array<int,mixed> $payload Input payload.
	 * @param string                               $context Diagnostic label.
	 *
	 * @throws InvalidArgumentException When JSON encoding fails.
	 */
	private function canonicalJsonSha256( array $payload, string $context ): string {
		$canonical = $this->canonicalize( $payload );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Canonical bytes required; wp_json_encode adds slash-escaping that breaks determinism across hosts.
		$json = json_encode(
			$canonical,
			JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
		);
		if ( $json === false ) {
			$message = sprintf(
				'CacheKeyFactory: %s payload could not be JSON-encoded canonically.',
				$context
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return hash( 'sha256', $json );
	}

	/**
	 * Recursively sort associative-array keys to make JSON
	 * encoding canonical. List arrays (zero-based contiguous
	 * integer keys) preserve their order, since list ordering
	 * is itself semantic input.
	 *
	 * @param mixed $value Arbitrary scalar / array tree.
	 *
	 * @return mixed
	 */
	private function canonicalize( $value ) {
		if ( ! is_array( $value ) ) {
			return $value;
		}

		$isList = array_keys( $value ) === range( 0, count( $value ) - 1 );

		if ( $isList ) {
			$out = array();
			foreach ( $value as $entry ) {
				$out[] = $this->canonicalize( $entry );
			}
			return $out;
		}

		ksort( $value, SORT_STRING );
		$out = array();
		foreach ( $value as $key => $entry ) {
			$out[ (string) $key ] = $this->canonicalize( $entry );
		}
		return $out;
	}

	/**
	 * Validate a provider token and return the lowercased form.
	 *
	 * @param string $provider Raw provider token.
	 *
	 * @throws InvalidArgumentException When the token is unknown.
	 */
	private function assertProvider( string $provider ): string {
		$normalized = strtolower( trim( $provider ) );
		if ( ! in_array( $normalized, self::PROVIDERS, true ) ) {
			$message = sprintf(
				'CacheKeyFactory: provider must be one of [%s]; got "%s".',
				implode( ', ', self::PROVIDERS ),
				$provider
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
		return $normalized;
	}

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

	/**
	 * Validate a non-empty trimmed template key and return it
	 * unchanged (case preserved).
	 *
	 * @param string $template Raw template key.
	 *
	 * @throws InvalidArgumentException When empty after trim.
	 */
	private function assertTemplate( string $template ): string {
		if ( trim( $template ) === '' ) {
			$message = 'CacheKeyFactory: template must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
		return $template;
	}

	/**
	 * Sanitize a provider item id: trim, reject empty, reject whitespace and
	 * control characters. Delimiters such as `:` are accepted because callers
	 * hash the returned id before inserting it into a cache key.
	 *
	 * Case is preserved because some provider id schemes (e.g.
	 * Amazon ASINs) are case-sensitive.
	 *
	 * @param string $providerItemId Raw item id.
	 *
	 * @throws InvalidArgumentException When the id is invalid.
	 */
	private function assertProviderItemId( string $providerItemId ): string {
		$trimmed = trim( $providerItemId );
		if ( $trimmed === '' ) {
			$message = 'CacheKeyFactory: providerItemId must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
		if ( preg_match( '/[\s\x00-\x1f\x7f]/', $trimmed ) === 1 ) {
			$message = sprintf(
				'CacheKeyFactory: providerItemId must not contain whitespace or control characters; got "%s".',
				$providerItemId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
		return $trimmed;
	}

	/**
	 * Validate that a string is a 64-char lowercase hex digest
	 * (sha256 output shape).
	 *
	 * @param string $hash  Candidate digest.
	 * @param string $label Diagnostic label for error messages.
	 *
	 * @throws InvalidArgumentException When the shape is wrong.
	 */
	private function assertSha256Hex( string $hash, string $label ): void {
		if ( preg_match( '/^[0-9a-f]{64}$/', $hash ) !== 1 ) {
			$message = sprintf(
				'CacheKeyFactory: %s must be a 64-char lowercase hex sha256 digest; got "%s".',
				$label,
				$hash
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}
}
