<?php
/**
 * Template resolution exception.
 *
 * Runtime exception raised by {@see TemplateResolver::resolve()}
 * when no template file is found in any of the §6.5 search
 * locations (child theme override, parent theme override, plugin
 * default).
 *
 * The exception carries enough structured context for the
 * diagnostics surface and developer-facing log lines to report
 * exactly what was tried:
 *
 *   - {@see self::key()} returns the requested §6.4 template key
 *     verbatim so the operator can correlate the failure to a
 *     specific render request.
 *
 *   - {@see self::searchedPaths()} returns the ordered list of
 *     candidate filesystem paths the resolver checked. Steps that
 *     were skipped because the corresponding theme directory
 *     provider returned null are not included; only paths that
 *     were actually probed appear here.
 *
 * The canonical "not found" message is produced by
 * {@see self::notFound()} so call sites stay terse and the message
 * format is locked down for tests.
 *
 * @package Affilun\Application\Render\Template
 */

declare(strict_types=1);

namespace Affilun\Application\Render\Template;

use InvalidArgumentException;
use RuntimeException;
use Throwable;

/**
 * Runtime failure raised when the §6.5 whitelist resolver cannot
 * locate a template file for the requested key.
 *
 * See the file-level docblock for the structured-context fields
 * the exception carries and the canonical message format.
 */
final class TemplateResolutionException extends RuntimeException {

	/**
	 * Requested §6.4 template key.
	 */
	private string $key;

	/**
	 * Ordered list of candidate filesystem paths the resolver
	 * checked, in the order they were checked.
	 *
	 * @var list<string>
	 */
	private array $searchedPaths;

	/**
	 * Construct an exception with explicit context.
	 *
	 * @param string         $message       Developer-facing message.
	 * @param string         $key           Requested §6.4 template key.
	 * @param array<mixed>   $searchedPaths Ordered list of probed candidate paths.
	 * @param Throwable|null $previous     Previous exception in the chain.
	 *
	 * @throws InvalidArgumentException When `$searchedPaths` is not a
	 *                                  list of non-empty strings.
	 */
	public function __construct( string $message, string $key, array $searchedPaths, ?Throwable $previous = null ) {
		parent::__construct( $message, 0, $previous );

		$this->key           = $key;
		$this->searchedPaths = self::normalizeSearchedPaths( $searchedPaths );
	}

	/**
	 * Construct the canonical "not found" exception for a key and
	 * the ordered list of paths that were probed.
	 *
	 * @param string       $key           Requested §6.4 template key.
	 * @param array<mixed> $searchedPaths Ordered list of probed candidate paths.
	 */
	public static function notFound( string $key, array $searchedPaths ): self {
		$normalized = self::normalizeSearchedPaths( $searchedPaths );
		$message    = sprintf(
			"TemplateResolver: no template file found for key '%s'. Searched: %s.",
			$key,
			implode( ', ', $normalized )
		);

		return new self( $message, $key, $normalized );
	}

	/**
	 * Requested §6.4 template key.
	 */
	public function key(): string {
		return $this->key;
	}

	/**
	 * Ordered list of candidate filesystem paths the resolver
	 * checked.
	 *
	 * @return list<string>
	 */
	public function searchedPaths(): array {
		return $this->searchedPaths;
	}

	/**
	 * Validate `$searchedPaths` is a list of non-empty strings and
	 * return it as a strict `list<string>`.
	 *
	 * @param array<mixed> $searchedPaths Raw input.
	 *
	 * @return list<string>
	 *
	 * @throws InvalidArgumentException When the input contains a
	 *                                  non-string entry, an empty
	 *                                  string, or has non-list keys.
	 */
	private static function normalizeSearchedPaths( array $searchedPaths ): array {
		$normalized = array();
		$expected   = 0;
		foreach ( $searchedPaths as $index => $path ) {
			if ( $index !== $expected ) {
				$message = 'TemplateResolutionException searchedPaths must be a list (sequential int keys from 0).';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			++$expected;

			if ( ! is_string( $path ) ) {
				$message = 'TemplateResolutionException searchedPaths must contain only strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( trim( $path ) === '' ) {
				$message = 'TemplateResolutionException searchedPaths must not contain empty strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[] = $path;
		}

		return $normalized;
	}
}
