<?php
/**
 * Product card block renderer.
 *
 * Phase-7 slice 7b dynamic render callback for the
 * `affilun/product-card` Gutenberg block. The renderer reuses the
 * Phase-5 rendering pipeline — §9.1
 * {@see \Affilun\Application\Render\ViewModel\ProductCardViewModelFactory},
 * §6.5
 * {@see \Affilun\Application\Render\ProductCardRenderer}
 * — wired against the §8.x persisted product lookup
 * ({@see \Affilun\Infrastructure\Persistence\ProductRepository})
 * so the block's PHP render is consistent with the editor preview
 * surface and any other downstream consumer that drives the same
 * pipeline.
 *
 * The class is exposed as a stable, named callable rather than a
 * closure: the {@see \Affilun\Presentation\Block\BlockServiceProvider}
 * binds the renderer into the registry and attaches
 * `array($renderer, 'render')` to the §6.4 block-definition's
 * `render_callback` slot via {@see \Affilun\Presentation\Block\BlockDefinition::withArgs()}.
 *
 * Render flow per `specifications/gutenberg-block-spec.md` §11.2:
 *
 *   1. Validate attributes — only `productId` is strictly required;
 *      `template` defaults to `'single-default-card'` when absent
 *      or grammar-invalid; the §4.3 `show*` / link-control flags
 *      and `buttonLabels` defaults are applied permissively.
 *
 *   2. Resolve the product via {@see ProductRepository::findById()}.
 *      Missing record triggers the §11.4 fallback policy (admin
 *      warning vs anonymous empty string).
 *
 *   3. Build a {@see \Affilun\Domain\Product\ProductSnapshot}
 *      from the persisted {@see \Affilun\Infrastructure\Persistence\ProductRecord}
 *      using the verbatim §10.1 mapping pattern from
 *      {@see \Affilun\Application\Editor\UseCase\PreviewProductCardUseCase}.
 *
 *   4. Build an {@see EmbedConfigInput} from the validated
 *      attributes; chain {@see MetaFlags} per-axis withers from
 *      the {@see MetaFlags::defaults()} baseline.
 *
 *   5. Build a {@see RenderContext::frontend()} keyed by the
 *      validated template (Phase-7 minimum: site default locale,
 *      no request-id correlation).
 *
 *   6. Build the §8.2 ViewModel via the factory; pass an empty
 *      `affiliateResults` list because the Phase-7 minimum
 *      {@see EmbedConfigInput::buttonConfigs()} is empty.
 *
 *   7. Render to HTML via the §6.5 renderer. Any
 *      {@see \Throwable} raised by the pipeline is caught and
 *      mapped to the §11.4 invalid-state fallback so a
 *      misconfigured server never emits a PHP fatal into the
 *      front-end stream.
 *
 * Phase-7 minimum scope notes:
 *
 *   - **No `buttonLabels` rendering.** The block attribute is
 *     accepted and validated but the Phase-7 minimum carries an
 *     empty `buttonConfigs` list through the factory. Phase 9
 *     wires real {@see \Affilun\Application\Render\Builder\ButtonConfigInput}
 *     entries from `buttonLabels` plus the active provider list
 *     and the §13 affiliate-link pipeline.
 *
 *   - **`showDisclaimer` is accepted but not honored.** The
 *     §10.8 {@see \Affilun\Application\Render\Builder\DisclaimerBuilder}
 *     runs unconditionally inside the §9.1 factory; suppressing
 *     it requires a dedicated {@see EmbedConfigInput} axis that
 *     does not yet exist in the Phase-5 input shape. The
 *     attribute is preserved on the block but ignored at render
 *     time.
 *
 *   - **No `get_block_wrapper_attributes()` wrapping.** With
 *     `apiVersion: 3` plus a `render_callback`, WordPress core
 *     does not auto-wrap the callback's return value with the
 *     `align` / `className` / `anchor` core supports unless the
 *     callback explicitly emits a wrapper element produced by
 *     `get_block_wrapper_attributes()`. The Phase-7 minimum
 *     returns the §6.5 renderer's output verbatim because the
 *     templates already emit a §8.2 `<article class="{classes.root}">`
 *     root with the canonical BEM tokens; the wrapper-attribute
 *     wiring is deferred to a later slice that also reaches the
 *     §11.4 inspector-controls work.
 *
 *   - **No locale plumbing.** Phase-7 minimum constructs the
 *     {@see RenderContext} with the site default locale (null);
 *     a later slice will derive the locale from the active
 *     post or the global locale filter chain.
 *
 * Render-callback signature:
 *
 *   - WordPress invokes the callback as
 *     `render_callback($attributes, $content, $block)`. The
 *     {@see self::render()} method matches that signature so the
 *     callback is forward-compatible with WordPress's calling
 *     convention even though the Phase-7 minimum only consults
 *     `$attributes`.
 *
 * §11.4 invalid-state fallback policy:
 *
 *   - When the caller is an administrator (the injected
 *     `currentUserCanProvider` returns true for `manage_options`),
 *     the renderer emits a `<div class="affilun-card affilun-card--admin-warning" role="alert">`
 *     element carrying a localized human-readable reason so the
 *     misconfiguration is visible during authoring.
 *
 *   - For non-administrators the renderer emits the empty string
 *     so the §11.4 "render nothing" posture is preserved on the
 *     public surface.
 *
 *   - The reason text is always developer-facing Japanese
 *     copy (matching the §11.4 spec example phrasings); no
 *     internal exception details are surfaced.
 *
 * @package Affilun\Presentation\Block
 */

declare(strict_types=1);

namespace Affilun\Presentation\Block;

use Affilun\Application\Analytics\ClickToken;
use Affilun\Application\Cache\CacheKeyFactory;
use Affilun\Application\Cache\RenderCache;
use Affilun\Application\Product\SnapshotProjector;
use Affilun\Application\Affiliate\AffiliateLinkRouter;
use Affilun\Domain\Affiliate\AffiliateContext;
use Affilun\Application\Provider\ProviderRegistry;
use Affilun\Application\Render\EmbedConfigInput;
use Affilun\Application\Render\ProductCardRenderer;
use Affilun\Application\Render\ViewModel\Parts\BadgeView;
use Affilun\Application\Render\ViewModel\Parts\ButtonView;
use Affilun\Application\Render\ViewModel\Parts\MetaFlags;
use Affilun\Application\Render\ViewModel\ProductCardViewModel;
use Affilun\Application\Render\ViewModel\ProductCardViewModelFactory;
use Affilun\Application\Render\ViewModelArrayDumper;
use Affilun\Application\Settings\OptionStoreSettingsRepository;
use Affilun\Application\Settings\RenderingDefaults;
use Affilun\Application\Settings\SettingsRepository;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Render\RenderContext;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Infrastructure\Persistence\ProductRepository;
use Affilun\Infrastructure\Persistence\ProductUrlRepository;
use Affilun\Infrastructure\Provider\Mercari\MercariProvider;
use Affilun\Infrastructure\Provider\YahooShopping\YahooShoppingAffiliateLinkBuilder;
use Affilun\Presentation\Card\ProductCardVisualSettings;
use Throwable;

/**
 * Phase-7 slice 7b dynamic render callback host for the
 * `affilun/product-card` block.
 *
 * See the file-level docblock for the §11.2 render flow, the
 * Phase-7 minimum scope notes (no `buttonLabels`,
 * `showDisclaimer` accepted-but-ignored, no
 * `get_block_wrapper_attributes()` wrap), and the §11.4
 * invalid-state fallback policy.
 */
final class ProductCardBlockRenderer {

	/**
	 * §13.1 render-cache TTL (seconds) for the post-body adhoc
	 * Gutenberg 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
	 * single-card renders.
	 */
	private const RENDER_CACHE_KIND = 'single';

	/**
	 * `sortKey` passed to {@see CacheKeyFactory::productsHash()}
	 * for the single-card cache key. The single-card path always
	 * holds exactly one productId so sort order is irrelevant;
	 * `manual` is the canonical neutral choice.
	 */
	private const RENDER_CACHE_SORT_KEY = 'manual';

	/**
	 * §6.4 grammar pattern (mirrored from
	 * {@see \Affilun\Application\Render\Template\TemplateKey})
	 * so a malformed `template` attribute can be silently demoted
	 * to {@see self::DEFAULT_TEMPLATE} without raising.
	 */
	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';

	/**
	 * Persistence port for the §11.2 step-2 product lookup.
	 */
	private ProductRepository $repository;

	/**
	 * Optional URL repository for provider-specific product pages.
	 */
	private ?ProductUrlRepository $productUrlRepository;

	/**
	 * Optional settings repository used for provider button colors.
	 */
	private ?SettingsRepository $settingsRepository;

	/**
	 * §9.1 view-model factory wired at construction.
	 */
	private ProductCardViewModelFactory $viewModelFactory;

	/**
	 * §6.5 renderer wired at construction.
	 */
	private ProductCardRenderer $renderer;

	/**
	 * §11.4 admin-vs-anonymous policy provider.
	 *
	 * Receives a capability slug and returns true when the active
	 * user holds it. The default forwards to
	 * {@see current_user_can()} when the WordPress global is
	 * loaded, otherwise denies (so pure-unit tests default to
	 * the anonymous branch).
	 *
	 * @var callable(string): bool
	 */
	private $currentUserCanProvider;

	/**
	 * Current global rendering defaults loaded from settings.
	 */
	private RenderingDefaults $renderingDefaults;

	/**
	 * 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 provider registry used to build 0.4.0 multi-provider
	 * search buttons for enabled providers.
	 */
	private ?ProviderRegistry $providerRegistry;

	/**
	 * Optional affiliate link router.
	 */
	private ?AffiliateLinkRouter $affiliateRouter;

	/**
	 * Optional §8.4 view-model dumper wired at construction.
	 * Required to project the {@see ProductCardViewModel} 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 block renderer with explicit collaborators.
	 *
	 * @param ProductRepository             $repository             Persistence port.
	 * @param ProductCardViewModelFactory   $viewModelFactory       §9.1 factory.
	 * @param ProductCardRenderer           $renderer               §6.5 renderer.
	 * @param (callable(string): bool)|null $currentUserCanProvider §11.4 admin-policy provider.
	 * @param RenderCache|null              $renderCache            Optional §4 render-cache
	 *                                                              layer. When wired
	 *                                                              together with
	 *                                                              `$cacheKeys` and
	 *                                                              `$dumper`, the
	 *                                                              post-body adhoc
	 *                                                              render path reads
	 *                                                              from / writes to
	 *                                                              this 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
	 *                                                              ProductCardViewModel
	 *                                                              into the canonical
	 *                                                              `view_model`
	 *                                                              payload stored on
	 *                                                              cache writes.
	 * @param ProviderRegistry|null         $providerRegistry       Optional enabled-provider registry
	 *                                                              used to append 0.4.0
	 *                                                              multi-provider search buttons.
	 * @param AffiliateLinkRouter|null      $affiliateRouter        Optional affiliate link router.
	 * @param ProductUrlRepository|null     $productUrlRepository   Optional provider URL repository.
	 * @param SettingsRepository|null       $settingsRepository     Optional settings repository.
	 */
	public function __construct(
		ProductRepository $repository,
		ProductCardViewModelFactory $viewModelFactory,
		ProductCardRenderer $renderer,
		?callable $currentUserCanProvider = null,
		?RenderCache $renderCache = null,
		?CacheKeyFactory $cacheKeys = null,
		?ViewModelArrayDumper $dumper = null,
		?ProviderRegistry $providerRegistry = null,
		?AffiliateLinkRouter $affiliateRouter = null,
		?ProductUrlRepository $productUrlRepository = null,
		?SettingsRepository $settingsRepository = null
	) {
		$this->repository             = $repository;
		$this->viewModelFactory       = $viewModelFactory;
		$this->renderer               = $renderer;
		$this->currentUserCanProvider = $currentUserCanProvider
			?? static fn ( string $cap ): bool => function_exists( 'current_user_can' )
				? (bool) current_user_can( $cap )
				: false;
		$this->settingsRepository     = $settingsRepository;
		$this->renderingDefaults      = $settingsRepository !== null
			? self::loadRenderingDefaultsFromRepository( $settingsRepository )
			: self::loadRenderingDefaults();
		$this->renderCache            = $renderCache;
		$this->cacheKeys              = $cacheKeys;
		$this->dumper                 = $dumper;
		$this->providerRegistry       = $providerRegistry;
		$this->affiliateRouter        = $affiliateRouter;
		$this->productUrlRepository   = $productUrlRepository;
	}

	/**
	 * `render_callback` entry point for the `affilun/product-card`
	 * block.
	 *
	 * Signature matches WordPress's calling convention
	 * `render_callback($attributes, $content, $block)`. The
	 * Phase-7 minimum only consults `$attributes`; the second and
	 * third arguments are accepted for forward compatibility but
	 * unused.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 * @param string               $content    Inner block content (unused).
	 * @param mixed                $block      WP_Block instance (unused).
	 */
	public function render( array $attributes, string $content = '', mixed $block = null ): string {
		unset( $content, $block );

		$productId = $this->resolveProductId( $attributes );
		if ( $productId === null ) {
			return $this->renderFallback(
				'affilun: 商品が選択されていません。'
			);
		}

		try {
			$record = $this->repository->findById( $productId );
		} catch ( Throwable $unused ) {
			return $this->renderFallback(
				'affilun: レンダリング中にエラーが発生しました。'
			);
		}

		if ( $record === null ) {
			return $this->renderFallback(
				sprintf(
					'affilun: 指定された商品が見つかりません (id: %d)。',
					$productId
				)
			);
		}

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

	/**
	 * Drive the §11.2 happy-path render.
	 *
	 * @param ProductRecord        $record     Resolved persisted record.
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function renderInternal( ProductRecord $record, array $attributes ): string {
		$template        = $this->resolveTemplate( $attributes );
		$customCssClass  = $this->composeCustomRootClass( $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 );

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

		$snapshot = self::buildSnapshotFromRecord( $record );
		$snapshot = $this->applyProviderAffiliateLink( $snapshot, $attributes );

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

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

		$context   = RenderContext::frontend( $template );
		$viewModel = $this->viewModelFactory->build( $snapshot, $config, $context, array() );
		if ( $viewModel instanceof ProductCardViewModel ) {
			$viewModel = $this->appendProviderSearchButtons(
				$viewModel,
				$snapshot,
				$record->searchKeywords(),
				$attributes,
				$this->loadProviderUrls( $record->id() )
			);
			$viewModel = $this->appendCustomButtons( $viewModel, $snapshot, $attributes );
			$viewModel = $this->appendMicrocopyBadge( $viewModel, $attributes );
		}

		$html = $this->renderer->render( $viewModel, $context );
		$html = $this->appendProviderButtonColorStyles( $html );
		$html = $this->appendPersistedProductIdAttribute( $html, $record->id() );
		$html = $this->appendClickTokenAttributes( $html, $record->id() );
		$html = $this->appendAbTestingAttributes( $html, $attributes, $record->id() );

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

	/**
	 * Stamp the persisted product id onto the card root for click tracking.
	 *
	 * @param string   $html      Rendered card HTML.
	 * @param int|null $productId Persisted product id.
	 */
	private function appendPersistedProductIdAttribute( string $html, ?int $productId ): string {
		if ( $productId === null || $productId <= 0 ) {
			return $html;
		}

		$result = preg_replace_callback(
			'/<([a-z][a-z0-9-]*)\b([^>]*\bclass=("|\')[^"\']*\baffilun-card\b[^"\']*\3[^>]*)>/i',
			static function ( array $matches ) use ( $productId ): string {
				$tag   = (string) $matches[1];
				$attrs = (string) $matches[2];
				$value = 'data-affilun-product-id="' . self::escapeAttribute( (string) $productId ) . '"';
				if ( preg_match( '/\sdata-affilun-product-id=("|\')[^"\']*\1/i', $attrs ) === 1 ) {
					$attrs = preg_replace( '/\sdata-affilun-product-id=("|\')[^"\']*\1/i', ' ' . $value, $attrs, 1 );
					$attrs = is_string( $attrs ) ? $attrs : (string) $matches[2];
					return '<' . $tag . $attrs . '>';
				}
				return '<' . $tag . $attrs . ' ' . $value . '>';
			},
			$html,
			1
		);

		return is_string( $result ) ? $result : $html;
	}

	/**
	 * Apply the configured provider's affiliate link builder to the snapshot.
	 *
	 * @param ProductSnapshot      $snapshot   Persisted product snapshot.
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function applyProviderAffiliateLink( ProductSnapshot $snapshot, array $attributes ): ProductSnapshot {
		if ( $this->providerRegistry === null ) {
			return $snapshot;
		}
		if ( ! $this->resolveBoolAttribute( $attributes, 'trackingEnabled', true ) ) {
			return $snapshot;
		}

		$provider = $this->providerRegistry->all()[ $snapshot->provider() ] ?? null;
		if ( $provider === null ) {
			return $snapshot;
		}

		try {
			$affiliate = $provider->buildAffiliateLink( $snapshot, AffiliateContext::empty() );
			if ( $affiliate->trackingApplied() ) {
				return $snapshot->withAffiliateUrl( $affiliate->url() );
			}
		} catch ( Throwable $unused ) {
			unset( $unused );
		}

		return $snapshot;
	}

	/**
	 * Append buttons for every enabled provider to the card view model.
	 *
	 * @param ProductCardViewModel $viewModel      Source view model.
	 * @param ProductSnapshot      $snapshot       Persisted product snapshot.
	 * @param string|null          $searchKeywords Persisted cross-provider search keywords.
	 * @param array<string, mixed> $attributes     Block attributes.
	 * @param array<string,string> $providerUrls   Stored provider URLs keyed by provider.
	 */
	private function appendProviderSearchButtons(
		ProductCardViewModel $viewModel,
		ProductSnapshot $snapshot,
		?string $searchKeywords,
		array $attributes,
		array $providerUrls
	): ProductCardViewModel {
		if ( $this->providerRegistry === null ) {
			return $viewModel;
		}

		$query   = $searchKeywords !== null && trim( $searchKeywords ) !== ''
			? trim( $searchKeywords )
			: self::deriveSearchQuery( $snapshot );
		$buttons = $viewModel->buttons();

		foreach ( $this->providerRegistry->all() as $providerKey => $provider ) {
			$label = $this->resolveProviderButtonLabel( $providerKey, $provider->label(), $attributes );
			if ( array_key_exists( $providerKey, $providerUrls ) ) {
				$url = $providerUrls[ $providerKey ];
			} elseif ( $providerKey === $snapshot->provider() ) {
				$url = $snapshot->affiliateUrl() ?? $snapshot->detailUrl();
				if ( $provider instanceof MercariProvider ) {
					$url = $provider->buildAffiliateUrl( $url );
				}
			} else {
				$url = $provider instanceof MercariProvider
					? $provider->buildSearchUrl( $query )
					: self::buildProviderSearchUrl( $providerKey, $query );
			}

			if ( $url === null ) {
				continue;
			}
			$url = $this->routeProviderUrl( $providerKey, $url, $snapshot, $attributes );

			try {
				$buttons[] = new ButtonView(
					$providerKey,
					$label,
					$url,
					ButtonView::TARGET_BLANK,
					'nofollow sponsored noopener noreferrer',
					'affilun-button affilun-button-' . $providerKey,
					sprintf( '%s を %s で見る', $snapshot->title(), $label )
				);
			} catch ( Throwable $unused ) {
				unset( $unused );
			}
		}

		return $viewModel->withButtons( $buttons );
	}

	/**
	 * Load stored provider URLs keyed by provider.
	 *
	 * @param int|null $productId Persisted product id.
	 *
	 * @return array<string,string>
	 */
	private function loadProviderUrls( ?int $productId ): array {
		if ( $productId === null || $this->productUrlRepository === null ) {
			return array();
		}

		try {
			$rows = $this->productUrlRepository->findByProductId( $productId );
		} catch ( Throwable $unused ) {
			unset( $unused );
			return array();
		}

		$urls = array();
		foreach ( $rows as $row ) {
			$provider = $row->providerType();
			if ( ! array_key_exists( $provider, $urls ) ) {
				$urls[ $provider ] = $row->url();
			}
		}

		return $urls;
	}

	/**
	 * Route provider URL when tracking and routing are enabled.
	 *
	 * @param string               $providerKey Provider key.
	 * @param string               $url         URL.
	 * @param ProductSnapshot      $snapshot    Product snapshot.
	 * @param array<string, mixed> $attributes  Block attributes.
	 */
	private function routeProviderUrl(
		string $providerKey,
		string $url,
		ProductSnapshot $snapshot,
		array $attributes
	): string {
		if ( ! $this->resolveBoolAttribute( $attributes, 'trackingEnabled', true ) ) {
			return $url;
		}
		if ( $providerKey === 'yahoo_shopping' && YahooShoppingAffiliateLinkBuilder::isValueCommerceRedirectUrl( $url ) ) {
			return $url;
		}
		if ( $providerKey === 'yahoo_shopping' ) {
			$trackedYahooUrl = $this->buildYahooValueCommerceUrl( $url );
			if ( $trackedYahooUrl !== null ) {
				return $trackedYahooUrl;
			}
		}
		if ( $this->affiliateRouter === null ) {
			return $url;
		}
		unset( $snapshot );
		return $this->affiliateRouter->route( $providerKey, $url, null );
	}

	/**
	 * Build a ValueCommerce-tracked Yahoo URL from a stored provider URL.
	 *
	 * @param string $url Stored Yahoo destination URL.
	 */
	private function buildYahooValueCommerceUrl( string $url ): ?string {
		if ( $this->settingsRepository === null || ! $this->isSafeHttpUrl( $url ) ) {
			return null;
		}

		try {
			$credentials = $this->settingsRepository->load()->provider( 'yahoo_shopping' );
		} catch ( Throwable $unused ) {
			unset( $unused );
			return null;
		}

		if ( $credentials === null ) {
			return null;
		}

		$sid = trim( (string) $credentials->secret( 'affiliate_sid' ) );
		$pid = trim( (string) $credentials->secret( 'affiliate_pid' ) );
		if ( $sid === '' || $pid === '' ) {
			return null;
		}

		return YahooShoppingAffiliateLinkBuilder::buildReferralBaseUrl( $sid, $pid ) . rawurlencode( $url );
	}

	/**
	 * Resolve provider button label override.
	 *
	 * @param string               $providerKey  Provider key.
	 * @param string               $defaultLabel Default provider label.
	 * @param array<string, mixed> $attributes   Block attributes.
	 */
	private function resolveProviderButtonLabel(
		string $providerKey,
		string $defaultLabel,
		array $attributes
	): string {
		if ( ! isset( $attributes['buttonLabels'] ) || ! is_array( $attributes['buttonLabels'] ) ) {
			return $defaultLabel;
		}

		$raw = $attributes['buttonLabels'][ $providerKey ] ?? null;
		if ( ! is_string( $raw ) || trim( $raw ) === '' ) {
			return $defaultLabel;
		}

		return $this->truncateAttributeText( trim( $raw ), 40 );
	}

	/**
	 * Append user-defined custom buttons to the card view model.
	 *
	 * @param ProductCardViewModel $viewModel  Source view model.
	 * @param ProductSnapshot      $snapshot   Persisted product snapshot.
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function appendCustomButtons(
		ProductCardViewModel $viewModel,
		ProductSnapshot $snapshot,
		array $attributes
	): ProductCardViewModel {
		$customButtons = $this->resolveCustomButtons( $attributes );
		if ( $customButtons === array() ) {
			return $viewModel;
		}

		$buttons = $viewModel->buttons();
		foreach ( $customButtons as $button ) {
			try {
				$buttons[] = new ButtonView(
					'custom',
					$button['label'],
					$button['url'],
					$button['target'],
					$button['rel'],
					'affilun-button affilun-button-custom',
					sprintf( '%s を %s で見る', $snapshot->title(), $button['label'] )
				);
			} catch ( Throwable $unused ) {
				unset( $unused );
			}
		}

		return $viewModel->withButtons( $buttons );
	}

	/**
	 * Add provider button color CSS variables to the rendered card root.
	 *
	 * Settings-page previews use the same variables; front-end renders need
	 * them stamped on the card root because provider colors live in settings,
	 * not in per-block attributes.
	 *
	 * @param string $html Rendered card HTML.
	 */
	private function appendProviderButtonColorStyles( string $html ): string {
		$style = $this->providerButtonColorStyle();
		if ( $style === '' ) {
			return $html;
		}

		$result = preg_replace_callback(
			'/<([a-z][a-z0-9-]*)\b([^>]*\bclass=("|\')[^"\']*\baffilun-card\b[^"\']*\3[^>]*)>/i',
			static function ( array $matches ) use ( $style ): string {
				$tag   = (string) $matches[1];
				$attrs = (string) $matches[2];
				if ( preg_match( '/\sstyle=("|\')([^"\']*)\1/i', $attrs, $styleMatch ) === 1 ) {
					$quote        = (string) $styleMatch[1];
					$currentStyle = rtrim( self::stripProviderButtonColorStyles( (string) $styleMatch[2] ) );
					$mergedStyle  = $currentStyle === '' ? $style : rtrim( $currentStyle, ';' ) . ';' . $style;
					$styleAttr    = ' style=' . $quote . self::escapeAttribute( $mergedStyle ) . $quote;
					$attrs        = preg_replace( '/\sstyle=("|\')[^"\']*\1/i', $styleAttr, $attrs, 1 );
					$attrs        = is_string( $attrs ) ? $attrs : (string) $matches[2];
					return '<' . $tag . $attrs . '>';
				}

				return '<' . $tag . $attrs . ' style="' . self::escapeAttribute( $style ) . '">';
			},
			$html,
			1
		);

		return is_string( $result ) ? $result : $html;
	}

	/**
	 * Build inline CSS custom properties for configured provider button colors.
	 */
	private function providerButtonColorStyle(): string {
		if ( $this->settingsRepository === null ) {
			return '';
		}

		try {
			$settings = $this->settingsRepository->load();
		} catch ( Throwable $unused ) {
			unset( $unused );
			return '';
		}

		return ProductCardVisualSettings::providerButtonColorStyle( $settings );
	}

	/**
	 * Remove previously injected provider button color variables.
	 *
	 * @param string $style Inline style declaration.
	 */
	private static function stripProviderButtonColorStyles( string $style ): string {
		$cleaned = preg_replace(
			'/--affilun-color-(?:amazon|rakuten|yahoo|mercari)(?:-text)?:[^;]*;?/',
			'',
			$style
		);

		return trim( is_string( $cleaned ) ? $cleaned : $style );
	}

	/**
	 * Resolve custom button attribute payload.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 *
	 * @return list<array{label: string, url: string, target: string, rel: string}>
	 */
	private function resolveCustomButtons( array $attributes ): array {
		if ( ! isset( $attributes['customButtons'] ) || ! is_array( $attributes['customButtons'] ) ) {
			return array();
		}

		$result = array();
		foreach ( $attributes['customButtons'] as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$label  = isset( $entry['label'] ) && is_string( $entry['label'] ) ? trim( $entry['label'] ) : '';
			$url    = isset( $entry['url'] ) && is_string( $entry['url'] ) ? trim( $entry['url'] ) : '';
			$target = $this->resolveCustomButtonTarget( $entry );
			$rel    = $this->resolveCustomButtonRel( $entry, $target );
			if ( $label === '' || ! $this->isSafeHttpUrl( $url ) ) {
				continue;
			}
			$result[] = array(
				'label'  => $this->truncateAttributeText( $label, 80 ),
				'url'    => $url,
				'target' => $target,
				'rel'    => $rel,
			);
		}

		return $result;
	}

	/**
	 * Resolve a custom button target attribute.
	 *
	 * @param array<string,mixed> $entry Custom button payload.
	 */
	private function resolveCustomButtonTarget( array $entry ): string {
		if ( ! isset( $entry['target'] ) || ! is_string( $entry['target'] ) ) {
			return ButtonView::TARGET_BLANK;
		}

		$target = trim( $entry['target'] );
		return in_array( $target, ButtonView::validTargets(), true ) ? $target : ButtonView::TARGET_BLANK;
	}

	/**
	 * Resolve and sanitize custom button rel tokens.
	 *
	 * @param array<string,mixed> $entry  Custom button payload.
	 * @param string              $target Resolved target.
	 */
	private function resolveCustomButtonRel( array $entry, string $target ): string {
		$raw    = isset( $entry['rel'] ) && is_string( $entry['rel'] ) ? $entry['rel'] : 'nofollow';
		$tokens = preg_split( '/\s+/', strtolower( trim( $raw ) ) );
		$result = array();
		if ( is_array( $tokens ) ) {
			foreach ( $tokens as $token ) {
				if ( preg_match( '/\A[a-z0-9_-]+\z/', $token ) === 1 ) {
					$result[] = $token;
				}
			}
		}
		if ( $result === array() ) {
			$result[] = 'nofollow';
		}
		if ( $target === ButtonView::TARGET_BLANK ) {
			$result[] = 'noopener';
			$result[] = 'noreferrer';
		}

		return implode( ' ', array_values( array_unique( $result ) ) );
	}

	/**
	 * Check whether a URL is an absolute HTTP(S) URL.
	 *
	 * @param string $url URL candidate.
	 */
	private function isSafeHttpUrl( string $url ): bool {
		return preg_match( '#\Ahttps?://#i', $url ) === 1;
	}

	/**
	 * Truncate attribute text without requiring mbstring.
	 *
	 * @param string $value  Raw text.
	 * @param int    $length Maximum length.
	 */
	private function truncateAttributeText( string $value, int $length ): string {
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $value, 0, $length, 'UTF-8' );
		}

		return substr( $value, 0, $length );
	}

	/**
	 * Append an active manual microcopy / sale label badge.
	 *
	 * @param ProductCardViewModel $viewModel  Source view model.
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function appendMicrocopyBadge( ProductCardViewModel $viewModel, array $attributes ): ProductCardViewModel {
		$label = $this->resolveActiveMicrocopyLabel( $attributes );
		if ( $label === null ) {
			return $viewModel;
		}

		$badges   = $viewModel->badges();
		$badges[] = new BadgeView( BadgeView::KIND_SALE, $label, 'affilun-badge affilun-badge-sale' );

		return $viewModel->withBadges( $badges );
	}

	/**
	 * Append URL-scoped click-token metadata to rendered button anchors.
	 *
	 * @param string   $html      Rendered HTML.
	 * @param int|null $productId Persisted product id.
	 */
	private function appendClickTokenAttributes( string $html, ?int $productId ): string {
		if ( $productId === null || $productId <= 0 ) {
			return $html;
		}
		$postId = self::currentPostId();

		$result = preg_replace_callback(
			'/<a\b([^>]*)>/i',
			static function ( array $matches ) use ( $productId, $postId ): string {
				$attrs = (string) $matches[1];
				if ( stripos( $attrs, 'affilun-button' ) === false || stripos( $attrs, 'data-affilun-click-token' ) !== false ) {
					return $matches[0];
				}
				if ( preg_match( '/\shref\s*=\s*("|\')([^"\']+)\1/i', $attrs, $hrefMatch ) !== 1 ) {
					return $matches[0];
				}
				$targetUrl = html_entity_decode( $hrefMatch[2], ENT_QUOTES | ENT_HTML5, 'UTF-8' );
				$token     = ClickToken::issue( $productId, null, $targetUrl, $postId );
				return '<a' . $attrs . ' data-affilun-click-token="' . self::escapeAttribute( $token ) . '">';
			},
			$html
		);

		return is_string( $result ) ? $result : $html;
	}

	/**
	 * Current WordPress post id when available.
	 */
	private static function currentPostId(): ?int {
		if ( function_exists( 'get_the_ID' ) ) {
			$postId = (int) get_the_ID();
			return $postId > 0 ? $postId : null;
		}
		return null;
	}

	/**
	 * Append front-end A/B testing metadata to the rendered root element.
	 *
	 * @param string               $html       Rendered HTML.
	 * @param array<string, mixed> $attributes Block attributes.
	 * @param int|null             $productId  Persisted product id.
	 */
	private function appendAbTestingAttributes( string $html, array $attributes, ?int $productId ): string {
		if ( ! $this->resolveBoolAttribute( $attributes, 'abTestingEnabled', false ) ) {
			return $html;
		}

		$buttonLabel = $this->resolveAbTestingText( $attributes, 'abButtonLabelVariant', 40 );
		$microcopy   = $this->resolveAbTestingText( $attributes, 'abMicrocopyVariant', 80 );
		if ( $buttonLabel === null && $microcopy === null ) {
			return $html;
		}

		$attrs = ' data-affilun-ab-enabled="1"';
		if ( $productId !== null && $productId > 0 ) {
			$attrs .= ' data-affilun-ab-key="' . self::escapeAttribute( (string) $productId ) . '"';
		}
		if ( $buttonLabel !== null ) {
			$attrs .= ' data-affilun-ab-button-label-b="' . self::escapeAttribute( $buttonLabel ) . '"';
		}
		if ( $microcopy !== null ) {
			$attrs .= ' data-affilun-ab-microcopy-b="' . self::escapeAttribute( $microcopy ) . '"';
		}

		$result = preg_replace_callback(
			'/<([a-z][a-z0-9-]*)\b/i',
			static fn ( array $matches ): string => '<' . $matches[1] . $attrs,
			$html,
			1
		);

		return is_string( $result ) ? $result : $html;
	}

	/**
	 * Resolve A/B testing text attribute.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 * @param string               $key        Attribute key.
	 * @param int                  $maxLength  Maximum length.
	 */
	private function resolveAbTestingText( array $attributes, string $key, int $maxLength ): ?string {
		if ( ! isset( $attributes[ $key ] ) || ! is_string( $attributes[ $key ] ) ) {
			return null;
		}

		$value = trim( $attributes[ $key ] );
		if ( $value === '' ) {
			return null;
		}
		return $this->truncateAttributeText( $value, $maxLength );
	}

	/**
	 * Escape an HTML attribute without depending on WordPress globals.
	 *
	 * @param string $value Attribute value.
	 */
	private static function escapeAttribute( string $value ): string {
		return htmlspecialchars( $value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8' );
	}

	/**
	 * Resolve active microcopy label when the display window includes now.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function resolveActiveMicrocopyLabel( array $attributes ): ?string {
		if ( ! isset( $attributes['microcopyText'] ) || ! is_string( $attributes['microcopyText'] ) ) {
			return null;
		}

		$label = trim( $attributes['microcopyText'] );
		if ( $label === '' || ! $this->isWithinMicrocopyWindow( $attributes ) ) {
			return null;
		}

		return $this->truncateAttributeText( $label, 80 );
	}

	/**
	 * Check optional microcopy start/end dates.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function isWithinMicrocopyWindow( array $attributes ): bool {
		$now = time();

		$start = isset( $attributes['microcopyStartAt'] ) && is_string( $attributes['microcopyStartAt'] )
			? strtotime( trim( $attributes['microcopyStartAt'] ) )
			: false;
		if ( is_int( $start ) && $now < $start ) {
			return false;
		}

		$end = isset( $attributes['microcopyEndAt'] ) && is_string( $attributes['microcopyEndAt'] )
			? strtotime( trim( $attributes['microcopyEndAt'] ) )
			: false;
		if ( is_int( $end ) && $now > $end ) {
			return false;
		}

		return true;
	}

	/**
	 * Derive the provider-crossing search query.
	 *
	 * The server render path uses the saved title/brand as a deterministic
	 * fallback query. When affilun Cloud extraction is available at save/search
	 * time, the saved keywords already carry the extracted product name/model signal.
	 *
	 * @param ProductSnapshot $snapshot Snapshot to derive from.
	 */
	private static function deriveSearchQuery( ProductSnapshot $snapshot ): string {
		$parts = array();
		if ( $snapshot->brand() !== null ) {
			$parts[] = $snapshot->brand();
		}
		$parts[] = $snapshot->title();

		$query = preg_replace( '/\s+/u', ' ', implode( ' ', $parts ) );
		if ( ! is_string( $query ) ) {
			$query = $snapshot->title();
		}

		return trim( $query );
	}

	/**
	 * Build a provider-native search URL for the supplied query.
	 *
	 * @param string $providerKey Provider key.
	 * @param string $query       Search query.
	 */
	private static function buildProviderSearchUrl( string $providerKey, string $query ): ?string {
		$encoded = rawurlencode( $query );
		switch ( $providerKey ) {
			case 'amazon':
				return 'https://www.amazon.co.jp/s?k=' . $encoded;
			case 'rakuten':
				return 'https://search.rakuten.co.jp/search/mall/' . $encoded . '/';
			case 'yahoo_shopping':
				return 'https://shopping.yahoo.co.jp/search?p=' . $encoded;
			case 'mercari':
				return 'https://jp.mercari.com/search?keyword=' . $encoded;
			default:
				return null;
		}
	}

	/**
	 * Build the §6.3 adhoc render-cache key for the supplied
	 * record + 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 ProductRecord        $record     Persisted product
	 *                                         being rendered.
	 * @param array<string, mixed> $attributes Block attributes
	 *                                         used to derive the
	 *                                         §6.3 `configHash`.
	 * @param string               $template   Resolved template
	 *                                         key.
	 */
	private function resolveCacheKey(
		ProductRecord $record,
		array $attributes,
		string $template
	): ?string {
		if ( $this->cacheKeys === null || $this->renderCache === null ) {
			return null;
		}

		$id = $record->id();
		if ( $id === null || $id < 1 ) {
			return null;
		}

		try {
			$productsHash = $this->cacheKeys->productsHash(
				array( (string) $id ),
				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 `productId` attribute to a positive int, or
	 * null when absent / invalid.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function resolveProductId( array $attributes ): ?int {
		if ( ! array_key_exists( 'productId', $attributes ) ) {
			return null;
		}

		$raw = $attributes['productId'];
		if ( is_int( $raw ) ) {
			return $raw >= 1 ? $raw : null;
		}

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

		return null;
	}

	/**
	 * Resolve the §4.3 `template` attribute, falling back to the
	 * configured rendering default when absent or grammar-invalid.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function resolveTemplate( array $attributes ): string {
		$default = $this->renderingDefaults->defaultTemplate();
		if ( ! array_key_exists( 'template', $attributes ) ) {
			return $default;
		}

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

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

		return $trimmed;
	}

	/**
	 * Compose custom root classes from visual settings + user class.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function composeCustomRootClass( array $attributes ): string {
		$classes = array();
		$variant = $this->resolveTemplateVariant( $attributes );
		if ( $variant !== 'default' ) {
			$classes[] = 'affilun-design-' . $variant;
		}

		$classes[] = 'affilun-color-scheme-' . $this->resolveColorScheme( $attributes );

		$buttonLayout = $this->resolveButtonLayout( $attributes );
		if ( $buttonLayout !== 'wrap' ) {
			$classes[] = 'affilun-buttons-' . $buttonLayout;
		}

		$buttonShape = $this->resolveButtonShape( $attributes );
		if ( $buttonShape !== 'rounded' ) {
			$classes[] = 'affilun-button-shape-' . $buttonShape;
		}

		$buttonTone = $this->resolveButtonTone( $attributes );
		if ( $buttonTone !== 'brand' ) {
			$classes[] = 'affilun-button-tone-' . $buttonTone;
		}

		$custom = $this->resolveCustomCssClass( $attributes );
		if ( $custom !== null ) {
			$classes[] = $custom;
		}

		return implode( ' ', $classes );
	}

	/**
	 * Resolve the design preset attribute.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function resolveTemplateVariant( array $attributes ): string {
		$default = $this->renderingDefaults->defaultDesignPreset();
		if ( ! array_key_exists( 'templateVariant', $attributes ) || ! is_string( $attributes['templateVariant'] ) ) {
			return $default;
		}

		$variant = trim( $attributes['templateVariant'] );
		return in_array( $variant, array( 'default', 'flat', 'outlined', 'soft' ), true ) ? $variant : $default;
	}

	/**
	 * Resolve button layout visual setting.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function resolveButtonLayout( array $attributes ): string {
		$default = $this->renderingDefaults->defaultButtonLayout();
		if ( ! array_key_exists( 'buttonLayout', $attributes ) || ! is_string( $attributes['buttonLayout'] ) ) {
			return $default;
		}

		$layout = trim( $attributes['buttonLayout'] );
		return in_array( $layout, array( 'wrap', 'stacked', 'full' ), true ) ? $layout : $default;
	}

	/**
	 * Resolve button shape visual setting.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function resolveButtonShape( array $attributes ): string {
		$default = $this->renderingDefaults->defaultButtonShape();
		if ( ! array_key_exists( 'buttonShape', $attributes ) || ! is_string( $attributes['buttonShape'] ) ) {
			return $default;
		}

		$shape = trim( $attributes['buttonShape'] );
		return in_array( $shape, array( 'square', 'rounded', 'pill' ), true ) ? $shape : $default;
	}

	/**
	 * Resolve button color theme visual setting.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function resolveButtonTone( array $attributes ): string {
		$default = 'brand';
		if ( ! array_key_exists( 'buttonTone', $attributes ) || ! is_string( $attributes['buttonTone'] ) ) {
			return $default;
		}

		$tone = trim( $attributes['buttonTone'] );
		return in_array( $tone, array( 'brand', 'muted', 'monochrome' ), true ) ? $tone : $default;
	}

	/**
	 * Resolve card color-scheme visual setting.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function resolveColorScheme( array $attributes ): string {
		$default = $this->renderingDefaults->defaultColorScheme();
		if ( ! array_key_exists( 'colorScheme', $attributes ) || ! is_string( $attributes['colorScheme'] ) ) {
			return $default;
		}

		$scheme = trim( $attributes['colorScheme'] );
		return in_array( $scheme, array( 'light', 'dark', 'system' ), true ) ? $scheme : $default;
	}

	/**
	 * Include setting-derived visual defaults in the render-cache config hash.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 *
	 * @return array<string, mixed>
	 */
	private function renderCacheAttributes( array $attributes ): array {
		$cacheAttributes                    = $attributes;
		$cacheAttributes['templateVariant'] = $this->resolveTemplateVariant( $attributes );
		$cacheAttributes['buttonLayout']    = $this->resolveButtonLayout( $attributes );
		$cacheAttributes['buttonShape']     = $this->resolveButtonShape( $attributes );
		$cacheAttributes['buttonTone']      = $this->resolveButtonTone( $attributes );
		$cacheAttributes['colorScheme']     = $this->resolveColorScheme( $attributes );
		return $cacheAttributes;
	}

	/**
	 * Load rendering defaults from the persisted settings option.
	 */
	private static function loadRenderingDefaults(): RenderingDefaults {
		if ( ! function_exists( 'get_option' ) ) {
			return RenderingDefaults::defaults();
		}

		$stored = get_option( OptionStoreSettingsRepository::OPTION_KEY, array() );
		if ( ! is_array( $stored ) || ! isset( $stored['rendering'] ) || ! is_array( $stored['rendering'] ) ) {
			return RenderingDefaults::defaults();
		}

		try {
			return RenderingDefaults::fromArray( $stored['rendering'] );
		} catch ( \Throwable ) {
			return RenderingDefaults::defaults();
		}
	}

	/**
	 * Load rendering defaults through the settings repository.
	 *
	 * @param SettingsRepository $settingsRepository Settings repository.
	 */
	private static function loadRenderingDefaultsFromRepository( SettingsRepository $settingsRepository ): RenderingDefaults {
		try {
			return $settingsRepository->load()->rendering();
		} catch ( Throwable ) {
			return RenderingDefaults::defaults();
		}
	}

	/**
	 * Resolve the §4.3 `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.
	 *
	 * Administrators see a developer-facing warning element so a
	 * misconfiguration is visible during authoring; non-admins see
	 * the empty string so the public surface preserves the §11.4
	 * "render nothing" posture.
	 *
	 * @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="affilun-card affilun-card--admin-warning" role="alert">%s</div>',
			$escaped
		);
	}

	/**
	 * Build a {@see ProductSnapshot} from the resolved persisted
	 * record.
	 *
	 * Phase 2 KI-009: thin delegation to the shared
	 * {@see SnapshotProjector::project()} utility so the
	 * field-mapping logic lives in exactly one place rather than
	 * being duplicated between the card and grid block
	 * renderers. Kept as a private method (rather than inlining
	 * the call site) so the call sites in
	 * {@see self::renderInternal()} stay readable and a future
	 * slice can re-introduce a per-renderer specialization
	 * without disturbing every caller.
	 *
	 * @param ProductRecord $record Resolved persisted record.
	 */
	private static function buildSnapshotFromRecord( ProductRecord $record ): ProductSnapshot {
		return SnapshotProjector::project( $record );
	}
}
