<?php
/**
 * Template resolver.
 *
 * Implements the §6.5 whitelist template-loader spec: given a
 * validated {@see TemplateKey}, walk the canonical override chain
 * (child theme override → parent theme override → plugin default)
 * and return the absolute filesystem path of the first existing
 * file that passes the realpath-based traversal guard.
 *
 * Search order (§6.5):
 *
 *   1. `{stylesheet_dir}/clb/templates/{key}.php` — child theme override
 *   2. `{template_dir}/clb/templates/{key}.php`   — parent theme override
 *   3. `{pluginTemplateDir}/{key}.php`            — plugin default
 *
 * Each candidate is subjected to a realpath-based traversal guard:
 * the resolved file MUST live inside the realpath of the step's
 * base directory. This defends against symlink-based escapes from
 * the template root, even though the §6.4 grammar already rejects
 * traversal-bearing keys before the resolver is invoked.
 *
 * The class follows the {@see \CommerceLinkBuilder\Bootstrap\EnvironmentChecker}
 * pattern of accepting constructor-injected callables for every
 * external boundary so unit tests can drive the resolver
 * deterministically without touching the WordPress globals or the
 * real filesystem:
 *
 *   - `stylesheetDirectoryProvider` — defaults to
 *     `get_stylesheet_directory()` when that function exists.
 *   - `templateDirectoryProvider`   — defaults to
 *     `get_template_directory()` when that function exists.
 *   - `fileExistsProbe`             — defaults to `is_file()`.
 *
 * Provider callables that return null, the empty string, or a
 * whitespace-only string cause that step to be skipped (the theme
 * directory is simply not available); this is not an error.
 *
 * The plugin template directory is required and validated for
 * absolute-path-ness at construction so a misconfigured caller
 * fails loudly at wiring time, not at the first failed render.
 *
 * @package CommerceLinkBuilder\Application\Render\Template
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Render\Template;

use InvalidArgumentException;

/**
 * §6.5 whitelist template resolver.
 *
 * See the file-level docblock for the search order, the realpath
 * traversal guard, and the constructor-injected callable seams.
 */
final class TemplateResolver {

	/**
	 * Absolute filesystem path of the plugin's templates/ directory.
	 */
	private string $pluginTemplateDir;

	/**
	 * Provider that returns the active stylesheet (child theme)
	 * directory, or null when the function is not available.
	 *
	 * @var callable(): ?string
	 */
	private $stylesheetDirectoryProvider;

	/**
	 * Provider that returns the active template (parent theme)
	 * directory, or null when the function is not available.
	 *
	 * @var callable(): ?string
	 */
	private $templateDirectoryProvider;

	/**
	 * File-existence probe. Defaults to {@see is_file()}; tests
	 * inject a closure backed by a path map.
	 *
	 * @var callable(string): bool
	 */
	private $fileExistsProbe;

	/**
	 * Construct a resolver wired to the supplied plugin template
	 * directory and (optionally overridden) external boundaries.
	 *
	 * @param string                        $pluginTemplateDir           Absolute path to the
	 *                                                                   plugin's templates/ directory.
	 * @param (callable(): ?string)|null    $stylesheetDirectoryProvider Override stylesheet-dir lookup.
	 * @param (callable(): ?string)|null    $templateDirectoryProvider   Override template-dir lookup.
	 * @param (callable(string): bool)|null $fileExistsProbe             Override file-existence probe.
	 *
	 * @throws InvalidArgumentException When `$pluginTemplateDir` is empty
	 *                                  or not an absolute path.
	 */
	public function __construct(
		string $pluginTemplateDir,
		?callable $stylesheetDirectoryProvider = null,
		?callable $templateDirectoryProvider = null,
		?callable $fileExistsProbe = null
	) {
		$trimmed = trim( $pluginTemplateDir );
		if ( $trimmed === '' ) {
			$message = 'TemplateResolver pluginTemplateDir must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$isAbsolute = str_starts_with( $trimmed, DIRECTORY_SEPARATOR )
			|| str_starts_with( $trimmed, '/' )
			|| preg_match( '#^[A-Za-z]:[\\\\/]#', $trimmed ) === 1;
		if ( ! $isAbsolute ) {
			$message = sprintf(
				'TemplateResolver pluginTemplateDir must be an absolute path; got "%s".',
				$trimmed
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->pluginTemplateDir = rtrim( $trimmed, '/\\' );

		$this->stylesheetDirectoryProvider = $stylesheetDirectoryProvider ?? static function (): ?string {
			if ( function_exists( 'get_stylesheet_directory' ) ) {
				$value = get_stylesheet_directory();
				return is_string( $value ) ? $value : null;
			}
			return null;
		};

		$this->templateDirectoryProvider = $templateDirectoryProvider ?? static function (): ?string {
			if ( function_exists( 'get_template_directory' ) ) {
				$value = get_template_directory();
				return is_string( $value ) ? $value : null;
			}
			return null;
		};

		$this->fileExistsProbe = $fileExistsProbe ?? static fn ( string $path ): bool => is_file( $path );
	}

	/**
	 * Resolve `$key` to an absolute filesystem path.
	 *
	 * @param TemplateKey $key Validated §6.4 template key.
	 *
	 * @return string Absolute filesystem path (post-realpath).
	 *
	 * @throws TemplateResolutionException When no candidate file is
	 *                                     found in any of the §6.5
	 *                                     search locations.
	 */
	public function resolve( TemplateKey $key ): string {
		$searched = array();
		foreach ( $this->candidates( $key ) as $candidate ) {
			$searched[] = $candidate['path'];

			if ( ! ( $this->fileExistsProbe )( $candidate['path'] ) ) {
				continue;
			}

			$realCandidate = realpath( $candidate['path'] );
			if ( $realCandidate === false ) {
				continue;
			}

			$realBase = realpath( $candidate['base'] );
			if ( $realBase === false ) {
				continue;
			}

			if ( ! str_starts_with( $realCandidate, $realBase . DIRECTORY_SEPARATOR ) ) {
				continue;
			}

			return $realCandidate;
		}

		// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
		throw TemplateResolutionException::notFound( $key->value(), $searched );
	}

	/**
	 * Resolve `$key` to an absolute filesystem path, returning null
	 * instead of throwing when no candidate file is found.
	 *
	 * @param TemplateKey $key Validated §6.4 template key.
	 */
	public function tryResolve( TemplateKey $key ): ?string {
		try {
			return $this->resolve( $key );
		} catch ( TemplateResolutionException $unused ) {
			return null;
		}
	}

	/**
	 * Un-resolved plugin-default candidate path for `$key`.
	 *
	 * @param TemplateKey $key Validated §6.4 template key.
	 */
	public function pluginTemplatePath( TemplateKey $key ): string {
		return $this->pluginTemplateDir . DIRECTORY_SEPARATOR . $key->value() . '.php';
	}

	/**
	 * Ordered list of candidate paths {@see self::resolve()} would
	 * probe for `$key`, including only steps whose provider
	 * supplied a non-empty base directory.
	 *
	 * @param TemplateKey $key Validated §6.4 template key.
	 *
	 * @return list<string>
	 */
	public function searchedPaths( TemplateKey $key ): array {
		$paths = array();
		foreach ( $this->candidates( $key ) as $candidate ) {
			$paths[] = $candidate['path'];
		}
		return $paths;
	}

	/**
	 * Build the ordered list of candidate `(base, path)` pairs the
	 * §6.5 search order produces for `$key`, skipping steps whose
	 * provider did not supply a usable base directory.
	 *
	 * @param TemplateKey $key Validated §6.4 template key.
	 *
	 * @return list<array{base: string, path: string}>
	 */
	private function candidates( TemplateKey $key ): array {
		$candidates = array();

		$stylesheetDir = $this->normalizeProvidedDirectory( ( $this->stylesheetDirectoryProvider )() );
		if ( $stylesheetDir !== null ) {
			$base         = $stylesheetDir . DIRECTORY_SEPARATOR . 'clb' . DIRECTORY_SEPARATOR . 'templates';
			$candidates[] = array(
				'base' => $base,
				'path' => $base . DIRECTORY_SEPARATOR . $key->value() . '.php',
			);
		}

		$templateDir = $this->normalizeProvidedDirectory( ( $this->templateDirectoryProvider )() );
		if ( $templateDir !== null ) {
			$base         = $templateDir . DIRECTORY_SEPARATOR . 'clb' . DIRECTORY_SEPARATOR . 'templates';
			$candidates[] = array(
				'base' => $base,
				'path' => $base . DIRECTORY_SEPARATOR . $key->value() . '.php',
			);
		}

		$candidates[] = array(
			'base' => $this->pluginTemplateDir,
			'path' => $this->pluginTemplatePath( $key ),
		);

		return $candidates;
	}

	/**
	 * Normalize a provider-supplied directory string. Returns null
	 * when the provider returned null, the empty string, or a
	 * whitespace-only string; otherwise returns the trimmed value
	 * with trailing separators removed.
	 *
	 * @param string|null $value Raw provider output.
	 */
	private function normalizeProvidedDirectory( ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			return null;
		}

		return rtrim( $trimmed, '/\\' );
	}
}
