<?php
/**
 * Product card renderer.
 *
 * Application-layer service that converts a §8.2
 * {@see \Affilun\Application\Render\ViewModel\ProductCardViewModel}
 * or §8.4
 * {@see \Affilun\Application\Render\ViewModel\FallbackCardViewModel}
 * into a rendered HTML string by:
 *
 *   1. Translating the view-model's §6.4 template key into a
 *      validated {@see \Affilun\Application\Render\Template\TemplateKey}.
 *      A grammar / whitelist failure here surfaces as the same
 *      {@see \Affilun\Application\Render\Template\TemplateResolutionException}
 *      "no template found" the resolver itself raises, because
 *      from the renderer's perspective an unknown key is
 *      indistinguishable from a missing file: §6.5 templates
 *      are sourced exclusively from the resolver whitelist.
 *
 *   2. Resolving the template file via the constructor-injected
 *      {@see \Affilun\Application\Render\Template\TemplateResolver}.
 *      Per §13.3 graceful-degradation, when a
 *      {@see ProductCardViewModel} resolution fails, the renderer
 *      attempts to resolve `single-fallback-card` instead so a
 *      missing site-specific override still produces a usable
 *      card. When the input is already a
 *      {@see FallbackCardViewModel}, no further degradation is
 *      attempted: the fallback IS the last resort, and silently
 *      swallowing a missing fallback file would hide a wiring
 *      error.
 *
 *   3. Serializing the view-model into the §8.2 / §8.4 read-only
 *      array shape via the constructor-injected
 *      {@see ViewModelArrayDumper}, then attaching a synthetic
 *      `_render_meta` top-level key carrying the
 *      {@see \Affilun\Domain\Render\RenderContext}
 *      axes (surface, locale, request id, AMP / feed flags,
 *      tracking / lazy-load toggles, debug flag) so templates
 *      that opt-in 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 and returns a
 *      fixed HTML string, so the renderer can be exercised
 *      end-to-end without touching the filesystem or PHP's
 *      `include` machinery.
 *
 * The renderer follows the
 * {@see \Affilun\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.
 *
 * @package Affilun\Application\Render
 */

declare(strict_types=1);

namespace Affilun\Application\Render;

use Affilun\Application\Render\Template\TemplateKey;
use Affilun\Application\Render\Template\TemplateResolutionException;
use Affilun\Application\Render\Template\TemplateResolver;
use Affilun\Application\Render\ViewModel\FallbackCardViewModel;
use Affilun\Application\Render\ViewModel\ProductCardViewModel;
use Affilun\Domain\Render\RenderContext;
use RuntimeException;
use Throwable;

/**
 * Renders a §8.2 ProductCardViewModel or §8.4
 * FallbackCardViewModel into an HTML string.
 *
 * See the file-level docblock for the resolution / degradation
 * sequence and the constructor-injected callable seams.
 */
final class ProductCardRenderer {

	/**
	 * §8.4 fallback template key used by the §13.3
	 * graceful-degradation branch when the requested
	 * {@see ProductCardViewModel} template cannot be resolved.
	 */
	private const FALLBACK_TEMPLATE_KEY = 'single-fallback-card';

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

	/**
	 * Stateless §8.2 / §8.4 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.2 / §8.4 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 ProductCardViewModel|FallbackCardViewModel $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, a
	 *                                     fallback view model's
	 *                                     own template cannot be
	 *                                     resolved, or when the
	 *                                     include strategy fails
	 *                                     to capture output (the
	 *                                     resulting RuntimeException
	 *                                     is propagated unmodified).
	 */
	public function render(
		ProductCardViewModel|FallbackCardViewModel $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, $viewModel );

		$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 );
	}

	/**
	 * Convenience wrapper for callers that already know they are
	 * rendering the §8.4 fallback shape.
	 *
	 * Equivalent to {@see self::render()}; exposed as a separate
	 * method so explicit error-path call sites read more clearly
	 * at the call boundary.
	 *
	 * @param FallbackCardViewModel $viewModel Fallback view model.
	 * @param RenderContext         $context   Active render context.
	 *
	 * @throws TemplateResolutionException When the fallback template
	 *                                     cannot be resolved or when
	 *                                     the include strategy fails
	 *                                     to capture output (any
	 *                                     RuntimeException raised by
	 *                                     the strategy is propagated
	 *                                     unmodified).
	 */
	public function renderFallback( FallbackCardViewModel $viewModel, RenderContext $context ): string {
		return $this->render( $viewModel, $context );
	}

	/**
	 * Resolve `$templateKey` against the resolver, attempting the
	 * §13.3 fallback when the input is a {@see ProductCardViewModel}
	 * and the original key cannot be located.
	 *
	 * Fallback view models bypass the secondary attempt entirely:
	 * a missing fallback template surfaces as the original
	 * exception so the operator can fix the wiring.
	 *
	 * @param TemplateKey                                $templateKey Validated key for the input view model.
	 * @param ProductCardViewModel|FallbackCardViewModel $viewModel   View model under render.
	 *
	 * @throws TemplateResolutionException When neither the original
	 *                                     key nor the fallback key
	 *                                     can be resolved.
	 */
	private function resolveWithFallback(
		TemplateKey $templateKey,
		ProductCardViewModel|FallbackCardViewModel $viewModel
	): string {
		try {
			return $this->templateResolver->resolve( $templateKey );
		} catch ( TemplateResolutionException $original ) {
			if ( $viewModel instanceof FallbackCardViewModel ) {
				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(
					'ProductCardRenderer: 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(
					'ProductCardRenderer: 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 `$__afl` 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               $__aflResolvedPath Absolute template path.
	 * @param array<string, mixed> $__aflViewModel    Read-only view-model array.
	 */
	private static function safeInclude( string $__aflResolvedPath, array $__aflViewModel ): void {
		$viewModel = $__aflViewModel;
		require $__aflResolvedPath;
	}
}
