<?php
/**
 * Template key value object.
 *
 * Immutable value object that wraps a validated §6.4 template key
 * string. The type encapsulates two concerns the
 * {@see TemplateResolver} would otherwise have to enforce inline at
 * its resolution boundary:
 *
 *   - The §6.4 grammar `^[a-z][a-z0-9-]{1,63}$` (lowercase ASCII,
 *     digits, hyphens; must start with a letter; capped at 64
 *     characters) so a typo in a template key can never reach the
 *     filesystem-level resolution step.
 *
 *   - The §6.4 whitelist of canonical built-in card template keys
 *     (`single-default-card`, `single-compact-card`,
 *     `single-minimal-cta`, `single-fallback-card`) so themes attempting
 *     to override an unknown key surface a developer-facing error rather
 *     than a silent miss.
 *
 * Path-traversal guards (`/`, `\`, `.`, `..`, non-ASCII bytes) are
 * applied before the grammar check so the most security-critical
 * rejection path runs first. This matches §6.5's "reject before
 * resolve" posture.
 *
 * Holding the validated key inside a dedicated VO (instead of
 * passing a raw string to the resolver) gives the resolver
 * type-safe inputs: a method that takes `TemplateKey` cannot be
 * called with an unvalidated string by accident.
 *
 * @package Affilun\Application\Render\Template
 */

declare(strict_types=1);

namespace Affilun\Application\Render\Template;

use InvalidArgumentException;

/**
 * Immutable §6.4 template-key value.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §6.4 and §6.5 and the
 * per-rule validation order.
 */
final class TemplateKey {

	/**
	 * §6.4 single-product kind discriminator.
	 */
	public const KIND_SINGLE = 'single';



	/**
	 * §6.4 standard single-product card key.
	 */
	public const KEY_SINGLE_DEFAULT_CARD = 'single-default-card';

	/**
	 * §6.4 compact single-product card key.
	 */
	public const KEY_SINGLE_COMPACT_CARD = 'single-compact-card';

	/**
	 * §6.4 minimal CTA single-product card key.
	 */
	public const KEY_SINGLE_MINIMAL_CTA = 'single-minimal-cta';

	/**
	 * §6.4 fallback single-product card key.
	 */
	public const KEY_SINGLE_FALLBACK_CARD = 'single-fallback-card';



	/**
	 * Closed list of template keys accepted by the rendering layer.
	 * Canonical §6.4 whitelist in declaration order.
	 *
	 * @var list<string>
	 */
	private const VALID_KEYS = array(
		self::KEY_SINGLE_DEFAULT_CARD,
		self::KEY_SINGLE_COMPACT_CARD,
		self::KEY_SINGLE_MINIMAL_CTA,
		self::KEY_SINGLE_FALLBACK_CARD,
	);

	/**
	 * Validated §6.4 template key string.
	 */
	private string $value;

	/**
	 * Private constructor — callers use {@see self::fromString()}.
	 *
	 * @param string $value Pre-validated §6.4 template key string.
	 */
	private function __construct( string $value ) {
		$this->value = $value;
	}

	/**
	 * Construct a TemplateKey from a raw string after applying the
	 * §6.4 / §6.5 validation pipeline.
	 *
	 * Validation order:
	 *
	 *   1. Trim and reject empty.
	 *   2. Reject path-traversal bytes (`/`, `\`, `.`, `..`, any
	 *      non-ASCII byte) — the most security-critical rejection
	 *      runs first per §6.5.
	 *   3. Reject anything not matching `^[a-z][a-z0-9-]{1,63}$`.
	 *   4. Reject anything not in the §6.4 whitelist.
	 *
	 * @param string $key Raw template key string.
	 *
	 * @throws InvalidArgumentException When `$key` violates any of
	 *                                  the rules above. The
	 *                                  message names the offending
	 *                                  input verbatim and the rule
	 *                                  it violated.
	 */
	public static function fromString( string $key ): self {
		$trimmed = trim( $key );
		if ( $trimmed === '' ) {
			$message = '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 ( self::containsTraversalByte( $trimmed ) ) {
			$message = sprintf(
				'TemplateKey "%s" violates the path-traversal rule (no "/", "\\", ".", or non-ASCII).',
				$trimmed
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( preg_match( '/\A[a-z][a-z0-9-]{1,63}\z/', $trimmed ) !== 1 ) {
			$message = sprintf(
				'TemplateKey "%s" violates the §6.4 grammar rule /^[a-z][a-z0-9-]{1,63}$/.',
				$trimmed
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! in_array( $trimmed, self::VALID_KEYS, true ) ) {
			$message = sprintf(
				'TemplateKey "%s" violates the §6.4 whitelist rule (not one of the registered built-in template keys).',
				$trimmed
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return new self( $trimmed );
	}

	/**
	 * §6.4 whitelist accessor returned in declaration order.
	 *
	 * @return list<string>
	 */
	public static function validKeys(): array {
		return self::VALID_KEYS;
	}

	/**
	 * Validated key string.
	 */
	public function value(): string {
		return $this->value;
	}

	/**
	 * Kind discriminator for card templates.
	 */
	public function kind(): string {
		return self::KIND_SINGLE;
	}

	/**
	 * Whether this key is a single-product card key.
	 */
	public function isSingle(): bool {
		return $this->kind() === self::KIND_SINGLE;
	}


	/**
	 * Structural equality check.
	 *
	 * @param self $other Other key to compare against.
	 */
	public function equals( self $other ): bool {
		return $this->value === $other->value;
	}

	/**
	 * Test whether the supplied (already-trimmed) string contains
	 * a §6.5 path-traversal byte.
	 *
	 * The rule rejects:
	 *
	 *   - `/` and `\`             (directory separators)
	 *   - any `.` character       (covers `.`, `..`, `foo.php`, leading/trailing dots)
	 *   - any non-ASCII byte      (anything with the high bit set)
	 *
	 * @param string $value Trimmed candidate key.
	 */
	private static function containsTraversalByte( string $value ): bool {
		if ( strpbrk( $value, '/\\.' ) !== false ) {
			return true;
		}

		$length = strlen( $value );
		for ( $i = 0; $i < $length; $i++ ) {
			if ( ord( $value[ $i ] ) > 127 ) {
				return true;
			}
		}

		return false;
	}
}
