<?php
/**
 * Product grid block renderer.
 *
 * Phase-10 slice 10e dynamic render-callback host for the
 * `clb/product-grid` Gutenberg block. Mirrors
 * {@see ProductCardBlockRenderer} verbatim for the single-card
 * surface, but resolves the §4.3 `productIds` attribute (a list
 * of positive ints), bulk-loads via
 * {@see ProductRepository::findByIds()} (Phase-10 slice 10a — N+1
 * avoidance), composes per-cell {@see ProductSnapshot} entries
 * (null when an id resolves to no record so the §13.5 grid
 * fallback path takes over), and drives the §9.2
 * {@see ProductGridViewModelFactory} +
 * {@see ProductGridRenderer} pipeline introduced in slices 10b,
 * 10c, and 10d.
 *
 * §11.4 invalid-state policy:
 *
 *   - Empty `productIds` → admin sees a
 *     `<div class="clb-grid-error">` element carrying the
 *     localized "商品が選択されていません" reason; non-admins see
 *     the empty string.
 *   - Repository / pipeline Throwable → a generic
 *     "レンダリング中にエラーが発生しました" fallback element for
 *     admins; empty string for non-admins. The originating
 *     exception is intentionally swallowed so the public surface
 *     never leaks internals.
 *
 * Known follow-ups:
 *
 *   - The {@see self::buildSnapshotFromRecord()} helper is
 *     duplicated verbatim from {@see ProductCardBlockRenderer}
 *     to keep slice 10e small. A future slice should extract a
 *     shared utility once the duplication has more than two
 *     call sites.
 *
 * @package CommerceLinkBuilder\Presentation\Block
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Block;

use CommerceLinkBuilder\Application\Cache\CacheKeyFactory;
use CommerceLinkBuilder\Application\Cache\RenderCache;
use CommerceLinkBuilder\Application\Product\SnapshotProjector;
use CommerceLinkBuilder\Application\Render\EmbedConfigInput;
use CommerceLinkBuilder\Application\Render\ProductGridRenderer;
use CommerceLinkBuilder\Application\Render\ViewModel\Parts\GridLayout;
use CommerceLinkBuilder\Application\Render\ViewModel\Parts\MetaFlags;
use CommerceLinkBuilder\Application\Render\ViewModel\ProductGridViewModelFactory;
use CommerceLinkBuilder\Application\Render\ViewModelArrayDumper;
use CommerceLinkBuilder\Domain\Product\ProductSnapshot;
use CommerceLinkBuilder\Domain\Render\RenderContext;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductRecord;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductRepository;
use Throwable;

/**
 * Phase-10 slice 10e dynamic render callback host for the
 * `clb/product-grid` block.
 *
 * See the file-level docblock for the §11.2 grid render flow,
 * the slice-10a N+1 avoidance contract, and the §11.4 invalid-
 * state fallback policy.
 */
final class ProductGridBlockRenderer {

	/**
	 * §6.4 default grid template key when the supplied
	 * `template` attribute is absent or violates the §6.4
	 * grammar.
	 */
	private const DEFAULT_TEMPLATE = 'grid-default';

	/**
	 * §13.1 render-cache TTL (seconds) for the grid adhoc path.
	 * Mirrors the §4 render-layer 60-second default; KI-007
	 * wiring keeps this short so a snapshot change observed by
	 * KI-002's per-embed sweep is reflected even on the adhoc
	 * surface within at most one TTL window.
	 */
	private const RENDER_CACHE_TTL_SECONDS = 60;

	/**
	 * `kind` discriminator passed to the §6.3
	 * {@see CacheKeyFactory::renderAdhocKey()} family for grid
	 * renders.
	 */
	private const RENDER_CACHE_KIND = 'grid';

	/**
	 * `sortKey` passed to {@see CacheKeyFactory::productsHash()}
	 * for the grid cache key. Grid renders preserve the manual
	 * authoring order verbatim, so `manual` is the canonical
	 * sort token; the productsHash itself sorts the id list
	 * lexicographically for stability.
	 */
	private const RENDER_CACHE_SORT_KEY = 'manual';

	/**
	 * §6.4 default per-item template key.
	 */
	private const DEFAULT_ITEM_TEMPLATE = 'single-compact-card';

	/**
	 * §6.4 grammar pattern (mirrored from
	 * {@see \CommerceLinkBuilder\Application\Render\Template\TemplateKey}).
	 */
	private const TEMPLATE_PATTERN = '/\A[a-z][a-z0-9-]{1,63}\z/';

	/**
	 * §11.4 admin-warning capability checked on every fallback.
	 */
	private const ADMIN_CAPABILITY = 'manage_options';

	/**
	 * Default column count.
	 */
	private const DEFAULT_COLUMNS = 3;

	/**
	 * Inclusive column-count bounds (mirroring
	 * {@see GridLayout}).
	 */
	private const MIN_COLUMNS = 1;
	private const MAX_COLUMNS = 6;

	/**
	 * Default gap token.
	 */
	private const DEFAULT_GAP = 'medium';

	/**
	 * Closed set of accepted gap tokens.
	 *
	 * @var list<string>
	 */
	private const VALID_GAPS = array( 'none', 'small', 'medium', 'large' );

	/**
	 * Persistence port for the bulk product lookup.
	 */
	private ProductRepository $repository;

	/**
	 * §9.2 grid factory wired at construction.
	 */
	private ProductGridViewModelFactory $gridFactory;

	/**
	 * §6.5 grid renderer wired at construction.
	 */
	private ProductGridRenderer $gridRenderer;

	/**
	 * §11.4 admin-vs-anonymous policy provider.
	 *
	 * @var callable(string): bool
	 */
	private $currentUserCanProvider;

	/**
	 * Optional §4 render-cache layer wired at construction. When
	 * null, the renderer behaves exactly as the Phase 9 MVP and
	 * never reads or writes the cache (KI-007 fallback path).
	 */
	private ?RenderCache $renderCache;

	/**
	 * Optional §6.3 cache key factory wired at construction.
	 * Required (alongside `$renderCache`) to enable the KI-007
	 * cache path; null disables it.
	 */
	private ?CacheKeyFactory $cacheKeys;

	/**
	 * Optional §8.4 view-model dumper wired at construction.
	 * Required to project the {@see \CommerceLinkBuilder\Application\Render\ViewModel\ProductGridViewModel}
	 * into the canonical `view_model` payload stored on cache
	 * writes; without it the renderer can still satisfy cache
	 * reads but skips the cache write step.
	 */
	private ?ViewModelArrayDumper $dumper;

	/**
	 * Construct a grid block renderer with explicit collaborators.
	 *
	 * @param ProductRepository             $repository             Persistence port.
	 * @param ProductGridViewModelFactory   $gridFactory            §9.2 grid factory.
	 * @param ProductGridRenderer           $gridRenderer           §6.5 renderer.
	 * @param (callable(string): bool)|null $currentUserCanProvider §11.4 admin-policy provider.
	 * @param RenderCache|null              $renderCache            Optional §4 render-cache
	 *                                                              layer (KI-007). When null,
	 *                                                              the renderer behaves
	 *                                                              exactly as the Phase 9
	 *                                                              MVP — every request
	 *                                                              rebuilds the view model
	 *                                                              and re-renders the
	 *                                                              template.
	 * @param CacheKeyFactory|null          $cacheKeys              §6.3 key factory.
	 *                                                              Required to enable the
	 *                                                              cache path; ignored
	 *                                                              otherwise.
	 * @param ViewModelArrayDumper|null     $dumper                 §8.4 dumper used to
	 *                                                              project the grid
	 *                                                              view model into the
	 *                                                              canonical
	 *                                                              `view_model`
	 *                                                              payload stored on
	 *                                                              cache writes.
	 */
	public function __construct(
		ProductRepository $repository,
		ProductGridViewModelFactory $gridFactory,
		ProductGridRenderer $gridRenderer,
		?callable $currentUserCanProvider = null,
		?RenderCache $renderCache = null,
		?CacheKeyFactory $cacheKeys = null,
		?ViewModelArrayDumper $dumper = null
	) {
		$this->repository             = $repository;
		$this->gridFactory            = $gridFactory;
		$this->gridRenderer           = $gridRenderer;
		$this->currentUserCanProvider = $currentUserCanProvider
			?? static fn ( string $cap ): bool => function_exists( 'current_user_can' )
				? (bool) current_user_can( $cap )
				: false;
		$this->renderCache            = $renderCache;
		$this->cacheKeys              = $cacheKeys;
		$this->dumper                 = $dumper;
	}

	/**
	 * `render_callback` entry point for the `clb/product-grid`
	 * block.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 * @param string               $content    Inner content (unused).
	 * @param mixed                $block      WP_Block (unused).
	 */
	public function render( array $attributes, string $content = '', mixed $block = null ): string {
		unset( $content, $block );

		$ids = $this->resolveProductIds( $attributes );
		if ( $ids === array() ) {
			return $this->renderFallback(
				'Commerce Link Builder: 商品が選択されていません。'
			);
		}

		try {
			$records = $this->repository->findByIds( $ids );
		} catch ( Throwable $unused ) {
			return $this->renderFallback(
				'Commerce Link Builder: レンダリング中にエラーが発生しました。'
			);
		}

		try {
			return $this->renderInternal( $ids, $records, $attributes );
		} catch ( Throwable $unused ) {
			return $this->renderFallback(
				'Commerce Link Builder: レンダリング中にエラーが発生しました。'
			);
		}
	}

	/**
	 * Drive the §11.2 grid happy-path render.
	 *
	 * Each input id is mapped to either a {@see ProductSnapshot}
	 * (when the bulk load returned a matching record) or null
	 * (which the §9.2 factory translates to a §13.3 fallback
	 * cell). The original input order is preserved so manually
	 * curated grids render exactly as configured.
	 *
	 * @param list<int>           $ids        Positive ids in input order.
	 * @param list<ProductRecord> $records    Bulk-loaded records (subset of `$ids`).
	 * @param array<string,mixed> $attributes Block attributes.
	 */
	private function renderInternal( array $ids, array $records, array $attributes ): string {
		$template     = $this->resolveTemplate( $attributes, 'template', self::DEFAULT_TEMPLATE );
		$itemTemplate = $this->resolveTemplate( $attributes, 'itemTemplate', self::DEFAULT_ITEM_TEMPLATE );
		$columns      = $this->resolveColumns( $attributes );
		$gap          = $this->resolveGap( $attributes );

		// KI-007: try the §4 render cache layer when fully wired.
		$cacheKey = $this->resolveCacheKey( $ids, $attributes, $template );
		if ( $cacheKey !== null && $this->renderCache !== null ) {
			$hit = $this->renderCache->get( $cacheKey );
			if ( $hit !== null && isset( $hit['html'] ) && is_string( $hit['html'] ) ) {
				return $hit['html'];
			}
		}

		$recordsById = array();
		foreach ( $records as $record ) {
			$recordId = $record->id();
			if ( $recordId === null ) {
				continue;
			}
			$recordsById[ $recordId ] = $record;
		}

		$snapshots = array();
		foreach ( $ids as $id ) {
			$record      = $recordsById[ $id ] ?? null;
			$snapshots[] = $record === null ? null : self::buildSnapshotFromRecord( $record );
		}

		$customCssClass  = $this->resolveCustomCssClass( $attributes );
		$showBrand       = $this->resolveBoolAttribute( $attributes, 'showBrand', true );
		$showDescription = $this->resolveBoolAttribute( $attributes, 'showDescription', false );
		$showPrice       = $this->resolveBoolAttribute( $attributes, 'showPrice', true );
		$showProvider    = $this->resolveBoolAttribute( $attributes, 'showProviderBadge', false );

		$meta = MetaFlags::defaults()
			->withShowBrand( $showBrand )
			->withShowDescription( $showDescription )
			->withShowPrice( $showPrice )
			->withShowProviderBadge( $showProvider );

		$config = new EmbedConfigInput(
			$template,
			array(),
			$meta,
			$customCssClass,
			false
		);

		$context = RenderContext::frontend( $template );
		$layout  = new GridLayout( $columns, $gap );

		$affiliateResultsByItem = array_fill( 0, count( $snapshots ), array() );

		$viewModel = $this->gridFactory->build(
			$snapshots,
			$config,
			$context,
			$layout,
			$affiliateResultsByItem,
			$itemTemplate
		);

		$html = $this->gridRenderer->render( $viewModel, $context );

		if ( $cacheKey !== null && $this->renderCache !== null && $this->dumper !== null ) {
			try {
				$this->renderCache->put(
					$cacheKey,
					$this->dumper->dump( $viewModel ),
					$html,
					self::RENDER_CACHE_TTL_SECONDS
				);
			} catch ( Throwable $unused ) {
				// Cache writes are best-effort: a failure must
				// never break the render path. Swallowed.
				unset( $unused );
			}
		}

		return $html;
	}

	/**
	 * Build the §6.3 adhoc render-cache key for the supplied
	 * id list + block attributes, or null when the cache wiring
	 * is incomplete (no `$renderCache` / `$cacheKeys` injected)
	 * or the inputs would be rejected by the key factory.
	 *
	 * @param list<int>           $ids        Resolved product ids in
	 *                                        authoring order.
	 * @param array<string,mixed> $attributes Block attributes used
	 *                                        to derive the §6.3
	 *                                        `configHash`.
	 * @param string              $template   Resolved grid template
	 *                                        key.
	 */
	private function resolveCacheKey(
		array $ids,
		array $attributes,
		string $template
	): ?string {
		if ( $this->cacheKeys === null || $this->renderCache === null ) {
			return null;
		}

		if ( $ids === array() ) {
			return null;
		}

		$idStrings = array();
		foreach ( $ids as $id ) {
			if ( $id < 1 ) {
				return null;
			}
			$idStrings[] = (string) $id;
		}

		try {
			$productsHash = $this->cacheKeys->productsHash(
				$idStrings,
				self::RENDER_CACHE_SORT_KEY
			);
			$configHash   = $this->cacheKeys->configHash(
				CacheKeyFactory::filterRelevantAttrs( $attributes, self::RENDER_CACHE_KIND )
			);
			return $this->cacheKeys->renderAdhocKey(
				self::RENDER_CACHE_KIND,
				$productsHash,
				$template,
				$configHash
			);
		} catch ( Throwable $unused ) {
			unset( $unused );
			return null;
		}
	}

	/**
	 * Resolve the §4.3 `productIds` attribute to a list of
	 * positive ints, dropping invalid entries silently.
	 *
	 * Strings are coerced via {@see (int)} after trimming;
	 * non-numeric strings, floats < 1, and other types are
	 * dropped. Duplicates are preserved (the §11.2 grid render
	 * reflects the manual ordering verbatim and the persistence
	 * layer deduplicates internally for the bulk lookup).
	 *
	 * @param array<string,mixed> $attributes Block attributes.
	 *
	 * @return list<int>
	 */
	private function resolveProductIds( array $attributes ): array {
		if ( ! array_key_exists( 'productIds', $attributes ) ) {
			return array();
		}

		$raw = $attributes['productIds'];
		if ( ! is_array( $raw ) ) {
			return array();
		}

		$result = array();
		foreach ( $raw as $candidate ) {
			$id = $this->coerceProductId( $candidate );
			if ( $id !== null ) {
				$result[] = $id;
			}
		}

		return $result;
	}

	/**
	 * Coerce a single `productIds` entry to a positive int or
	 * null when it cannot be safely interpreted.
	 *
	 * @param mixed $raw Candidate value.
	 */
	private function coerceProductId( mixed $raw ): ?int {
		if ( is_int( $raw ) ) {
			return $raw >= 1 ? $raw : null;
		}

		if ( is_string( $raw ) ) {
			$trimmed = trim( $raw );
			if ( $trimmed === '' ) {
				return null;
			}
			$parsed = filter_var(
				$trimmed,
				FILTER_VALIDATE_INT,
				array( 'options' => array( 'min_range' => 1 ) )
			);
			return $parsed === false ? null : $parsed;
		}

		if ( is_float( $raw ) ) {
			$parsed = filter_var(
				(string) $raw,
				FILTER_VALIDATE_INT,
				array( 'options' => array( 'min_range' => 1 ) )
			);
			return $parsed === false ? null : $parsed;
		}

		return null;
	}

	/**
	 * Resolve a §6.4 template-key attribute, falling back to the
	 * supplied default when absent or grammar-invalid.
	 *
	 * @param array<string,mixed> $attributes  Block attributes.
	 * @param string              $key         Attribute key.
	 * @param string              $defaultKey  Fallback template key.
	 */
	private function resolveTemplate( array $attributes, string $key, string $defaultKey ): string {
		if ( ! array_key_exists( $key, $attributes ) ) {
			return $defaultKey;
		}

		$raw = $attributes[ $key ];
		if ( ! is_string( $raw ) ) {
			return $defaultKey;
		}

		$trimmed = trim( $raw );
		if ( preg_match( self::TEMPLATE_PATTERN, $trimmed ) !== 1 ) {
			return $defaultKey;
		}

		return $trimmed;
	}

	/**
	 * Resolve the `columns` attribute to a value within the
	 * inclusive 1..6 range, falling back to the default on
	 * absence / invalidity.
	 *
	 * @param array<string,mixed> $attributes Block attributes.
	 */
	private function resolveColumns( array $attributes ): int {
		if ( ! array_key_exists( 'columns', $attributes ) ) {
			return self::DEFAULT_COLUMNS;
		}

		$raw = $attributes['columns'];
		if ( is_int( $raw ) ) {
			$value = $raw;
		} elseif ( is_string( $raw ) || is_float( $raw ) ) {
			$parsed = filter_var(
				is_string( $raw ) ? trim( $raw ) : (string) $raw,
				FILTER_VALIDATE_INT
			);
			if ( $parsed === false ) {
				return self::DEFAULT_COLUMNS;
			}
			$value = $parsed;
		} else {
			return self::DEFAULT_COLUMNS;
		}

		if ( $value < self::MIN_COLUMNS || $value > self::MAX_COLUMNS ) {
			return self::DEFAULT_COLUMNS;
		}

		return $value;
	}

	/**
	 * Resolve the `gap` attribute against the closed token set,
	 * falling back to the default on absence / invalidity.
	 *
	 * @param array<string,mixed> $attributes Block attributes.
	 */
	private function resolveGap( array $attributes ): string {
		if ( ! array_key_exists( 'gap', $attributes ) ) {
			return self::DEFAULT_GAP;
		}

		$raw = $attributes['gap'];
		if ( ! is_string( $raw ) ) {
			return self::DEFAULT_GAP;
		}

		$trimmed = trim( $raw );
		if ( ! in_array( $trimmed, self::VALID_GAPS, true ) ) {
			return self::DEFAULT_GAP;
		}

		return $trimmed;
	}

	/**
	 * Resolve the `customCssClass` attribute to a non-empty
	 * trimmed string, or null when absent / empty after
	 * trimming.
	 *
	 * @param array<string,mixed> $attributes Block attributes.
	 */
	private function resolveCustomCssClass( array $attributes ): ?string {
		if ( ! array_key_exists( 'customCssClass', $attributes ) ) {
			return null;
		}

		$raw = $attributes['customCssClass'];
		if ( ! is_string( $raw ) ) {
			return null;
		}

		$trimmed = trim( $raw );
		return $trimmed === '' ? null : $trimmed;
	}

	/**
	 * Resolve a §4.3 boolean attribute with a default fallback.
	 *
	 * @param array<string,mixed> $attributes   Block attributes.
	 * @param string              $key          Attribute key.
	 * @param bool                $defaultValue Default when absent or non-bool-coercible.
	 */
	private function resolveBoolAttribute( array $attributes, string $key, bool $defaultValue ): bool {
		if ( ! array_key_exists( $key, $attributes ) ) {
			return $defaultValue;
		}

		$raw = $attributes[ $key ];
		if ( is_bool( $raw ) ) {
			return $raw;
		}

		if ( is_int( $raw ) || is_float( $raw ) ) {
			return (bool) $raw;
		}

		if ( is_string( $raw ) ) {
			$lower = strtolower( trim( $raw ) );
			if ( $lower === 'true' || $lower === '1' ) {
				return true;
			}
			if ( $lower === 'false' || $lower === '0' || $lower === '' ) {
				return false;
			}
		}

		return $defaultValue;
	}

	/**
	 * Render the §11.4 invalid-state fallback for the grid
	 * surface.
	 *
	 * Administrators see a developer-facing warning element
	 * carrying the localized reason text; non-administrators see
	 * the empty string so the public surface stays silent on
	 * misconfiguration.
	 *
	 * @param string $reason Developer-facing reason text.
	 */
	private function renderFallback( string $reason ): string {
		$canManage = (bool) ( $this->currentUserCanProvider )( self::ADMIN_CAPABILITY );
		if ( ! $canManage ) {
			return '';
		}

		$escaped = function_exists( 'esc_html' )
			? (string) esc_html( $reason )
			: htmlspecialchars( $reason, ENT_QUOTES | ENT_HTML5, 'UTF-8' );

		return sprintf(
			'<div class="clb-grid-error" role="alert">%s</div>',
			$escaped
		);
	}

	/**
	 * Build a {@see ProductSnapshot} from the resolved persisted
	 * record.
	 *
	 * Duplicated verbatim from {@see ProductCardBlockRenderer::buildSnapshotFromRecord()}
	 * to keep slice 10e small. A future slice should extract a
	 * shared utility once the duplication has more than two
	 * call sites.
	 *
	 * @param ProductRecord $record Resolved persisted record.
	 */
	private static function buildSnapshotFromRecord( ProductRecord $record ): ProductSnapshot {
		return SnapshotProjector::project( $record );
	}
}
