<?php
/**
 * Product grid renderer.
 *
 * Application-layer service that converts a §8.3
 * {@see \CommerceLinkBuilder\Application\Render\ViewModel\ProductGridViewModel}
 * into a rendered HTML string by:
 *
 *   1. Translating the view-model's §6.4 template key into a
 *      validated {@see \CommerceLinkBuilder\Application\Render\Template\TemplateKey}.
 *      A grammar / whitelist failure surfaces as the same
 *      {@see \CommerceLinkBuilder\Application\Render\Template\TemplateResolutionException}
 *      "no template found" the resolver itself raises.
 *
 *   2. Resolving the template file via the constructor-injected
 *      {@see \CommerceLinkBuilder\Application\Render\Template\TemplateResolver}.
 *      Per §13.3 graceful-degradation, when the requested grid
 *      template cannot be resolved the renderer attempts the
 *      canonical `'grid-default'` key as a last-chance fallback.
 *      If `'grid-default'` is also missing (or the input view
 *      model already pointed at `'grid-default'`), the original
 *      {@see TemplateResolutionException} is propagated. Note
 *      that there is no separate `grid-fallback-card` key in the
 *      §6.4 whitelist — a grid-level fallback is the
 *      `'grid-default'` template rendering whatever §8.4
 *      {@see \CommerceLinkBuilder\Application\Render\ViewModel\FallbackCardViewModel}
 *      cells the per-item factory composed.
 *
 *   3. Serializing the view-model into the §8.3 read-only array
 *      shape via the constructor-injected
 *      {@see ViewModelArrayDumper}. The dumper recursively
 *      serializes each item into a §8.2 / §8.4 array shape
 *      WITHOUT a `_render_meta` key — the per-item `_render_meta`
 *      block does NOT propagate to inner cards because the grid
 *      template iterates the items list and emits the inner-card
 *      markup inline. A single synthetic `_render_meta` block is
 *      attached at the top level only, carrying the
 *      {@see \CommerceLinkBuilder\Domain\Render\RenderContext}
 *      axes so the grid template (and any inline per-cell render
 *      blocks it chooses to emit) can branch on the surface
 *      without reading WordPress globals.
 *
 *   4. Invoking the constructor-injected `$includeStrategy`
 *      closure with the resolved absolute path and the dumped
 *      array. The strategy is responsible for output buffering
 *      and the `require` itself; tests inject a deterministic
 *      strategy that captures both arguments.
 *
 * The renderer follows the
 * {@see \CommerceLinkBuilder\Bootstrap\EnvironmentChecker}
 * constructor-injected-callable pattern so every external
 * boundary (the resolver, the array dumper, the include
 * strategy) is replaceable from tests without subclassing or
 * monkey-patching. The shape mirrors
 * {@see ProductCardRenderer} verbatim; the only difference is
 * the input view-model union and the §13.3 fallback target.
 *
 * @package CommerceLinkBuilder\Application\Render
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Render;

use CommerceLinkBuilder\Application\Render\Template\TemplateKey;
use CommerceLinkBuilder\Application\Render\Template\TemplateResolutionException;
use CommerceLinkBuilder\Application\Render\Template\TemplateResolver;
use CommerceLinkBuilder\Application\Render\ViewModel\ProductGridViewModel;
use CommerceLinkBuilder\Domain\Render\RenderContext;
use RuntimeException;
use Throwable;

/**
 * Renders a §8.3 ProductGridViewModel into an HTML string.
 *
 * See the file-level docblock for the resolution / degradation
 * sequence and the constructor-injected callable seams.
 */
final class ProductGridRenderer {

	/**
	 * §13.3 grid-level fallback template key used when the
	 * requested grid template cannot be resolved. There is no
	 * dedicated `grid-fallback-card` key in the §6.4 whitelist;
	 * the grid-level fallback is the canonical `grid-default`
	 * template rendering the §8.4 fallback cells the per-item
	 * factory already produced.
	 */
	private const FALLBACK_TEMPLATE_KEY = TemplateKey::KEY_GRID_DEFAULT;

	/**
	 * Whitelist resolver wired at construction.
	 */
	private TemplateResolver $templateResolver;

	/**
	 * Stateless §8.3 view-model serializer.
	 */
	private ViewModelArrayDumper $arrayDumper;

	/**
	 * Closure responsible for `require`-ing a resolved template
	 * file with output buffering and returning the captured HTML
	 * string.
	 *
	 * @var callable(string, array<string, mixed>): string
	 */
	private $includeStrategy;

	/**
	 * Construct a renderer wired to the supplied resolver, dumper
	 * and (optionally overridden) include strategy.
	 *
	 * The default include strategy buffers output, `require`-s
	 * the resolved template inside a hygienic loader that exposes
	 * the dumped array as `$viewModel` and nothing else, and
	 * returns the captured HTML string. Tests inject a
	 * deterministic closure to bypass the filesystem entirely.
	 *
	 * @param TemplateResolver                                      $templateResolver Whitelist resolver.
	 * @param ViewModelArrayDumper                                  $arrayDumper      §8.3 serializer.
	 * @param (callable(string, array<string, mixed>): string)|null $includeStrategy  Override include strategy.
	 */
	public function __construct(
		TemplateResolver $templateResolver,
		ViewModelArrayDumper $arrayDumper,
		?callable $includeStrategy = null
	) {
		$this->templateResolver = $templateResolver;
		$this->arrayDumper      = $arrayDumper;
		$this->includeStrategy  = $includeStrategy ?? self::defaultIncludeStrategy();
	}

	/**
	 * Render `$viewModel` to an HTML string.
	 *
	 * @param ProductGridViewModel $viewModel View model to render.
	 * @param RenderContext        $context   Active render context.
	 *
	 * @throws TemplateResolutionException When the template key is
	 *                                     invalid, the resolver
	 *                                     cannot locate the file
	 *                                     and the §13.3 fallback
	 *                                     attempt also fails, or
	 *                                     when the include
	 *                                     strategy fails to
	 *                                     capture output (the
	 *                                     resulting RuntimeException
	 *                                     is propagated unmodified).
	 */
	public function render(
		ProductGridViewModel $viewModel,
		RenderContext $context
	): string {
		$templateKeyValue = $viewModel->template();

		try {
			$templateKey = TemplateKey::fromString( $templateKeyValue );
		} catch ( Throwable $e ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw TemplateResolutionException::notFound( $templateKeyValue, array() );
		}

		$resolvedPath = $this->resolveWithFallback( $templateKey );

		$array                 = $this->arrayDumper->dump( $viewModel );
		$array['_render_meta'] = array(
			'surface'           => $context->surface(),
			'locale'            => $context->locale(),
			'request_id'        => $context->requestId(),
			'is_amp'            => $context->isAmp(),
			'is_feed'           => $context->isFeed(),
			'tracking_enabled'  => $context->trackingEnabled(),
			'lazy_load_enabled' => $context->lazyLoadEnabled(),
			'debug'             => $context->debug(),
		);

		return ( $this->includeStrategy )( $resolvedPath, $array );
	}

	/**
	 * Resolve `$templateKey` against the resolver, attempting the
	 * §13.3 grid-level fallback (`grid-default`) when the original
	 * key cannot be located.
	 *
	 * When the input key is already `grid-default`, no secondary
	 * attempt is made: the original exception is re-thrown so the
	 * operator can fix the wiring.
	 *
	 * @param TemplateKey $templateKey Validated key for the input view model.
	 *
	 * @throws TemplateResolutionException When neither the original
	 *                                     key nor the fallback key
	 *                                     can be resolved.
	 */
	private function resolveWithFallback( TemplateKey $templateKey ): string {
		try {
			return $this->templateResolver->resolve( $templateKey );
		} catch ( TemplateResolutionException $original ) {
			if ( $templateKey->value() === self::FALLBACK_TEMPLATE_KEY ) {
				throw $original;
			}

			try {
				$fallbackKey = TemplateKey::fromString( self::FALLBACK_TEMPLATE_KEY );
				return $this->templateResolver->resolve( $fallbackKey );
			} catch ( Throwable $unused ) {
				throw $original;
			}
		}
	}

	/**
	 * Build the default include strategy: buffer output, require
	 * the resolved file with `$viewModel` as the only template-
	 * visible local, and return the captured string.
	 *
	 * The closure delegates the actual `require` to
	 * {@see self::safeInclude()} so the renderer's own locals
	 * cannot leak into the template scope. On failure the buffer
	 * is drained and the original Throwable is rethrown wrapped
	 * in a {@see RuntimeException} (with the prior preserved).
	 *
	 * @return callable(string, array<string, mixed>): string
	 */
	private static function defaultIncludeStrategy(): callable {
		return static function ( string $resolvedPath, array $viewModel ): string {
			$level = ob_get_level();
			ob_start();

			try {
				self::safeInclude( $resolvedPath, $viewModel );
				$captured = ob_get_clean();
			} catch ( Throwable $e ) {
				while ( ob_get_level() > $level ) {
					ob_end_clean();
				}
				$message = sprintf(
					'ProductGridRenderer: include strategy failed for "%s": %s',
					$resolvedPath,
					$e->getMessage()
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new RuntimeException( $message, 0, $e );
			}

			if ( $captured === false ) {
				$message = sprintf(
					'ProductGridRenderer: failed to capture output buffer for "%s".',
					$resolvedPath
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new RuntimeException( $message );
			}

			return $captured;
		};
	}

	/**
	 * Hygienic loader for the default include strategy.
	 *
	 * Parameter names use a `$__clb` prefix so the template file
	 * cannot accidentally collide with renderer-internal locals.
	 * Inside this method, only `$viewModel` and the prefixed
	 * parameters are in scope; the template file therefore sees a
	 * single visible local — `$viewModel` — that mirrors the §6.5
	 * receiving signature.
	 *
	 * @param string               $__clbResolvedPath Absolute template path.
	 * @param array<string, mixed> $__clbViewModel    Read-only view-model array.
	 */
	private static function safeInclude( string $__clbResolvedPath, array $__clbViewModel ): void {
		$viewModel = $__clbViewModel;
		require $__clbResolvedPath;
	}
}
