<?php
/**
 * Block service provider.
 *
 * Registers the `affilun/product-card` Gutenberg block and wires it to the
 * server-side product-card renderer. The product grid block has been
 * removed from the editor surface, so this provider now exposes only the
 * card block renderer and a shared registrar.
 *
 * @package Affilun\Presentation\Block
 */

declare(strict_types=1);

namespace Affilun\Presentation\Block;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use Affilun\Application\Affiliate\AffiliateLinkRouter;
use Affilun\Application\Affiliate\OptionStoreAffiliateRoutingRepository;
use Affilun\Application\Cache\CacheKeyFactory;
use Affilun\Application\Cache\CacheServiceProvider;
use Affilun\Application\Cache\RenderCache;
use Affilun\Application\Provider\ProviderRegistry;
use Affilun\Application\Settings\OptionStoreSettingsRepository;
use Affilun\Application\Settings\ProviderCredentials;
use Affilun\Application\Settings\SettingsRepository;
use Affilun\Application\Render\ProductCardRenderer;
use Affilun\Application\Render\RenderServiceProvider;
use Affilun\Application\Render\ViewModel\ProductCardViewModelFactory;
use Affilun\Application\Render\ViewModelArrayDumper;
use Affilun\Bootstrap\HookRegistrar;
use Affilun\Bootstrap\ServiceProvider;
use Affilun\Bootstrap\ServiceProviderRegistry;
use Affilun\Infrastructure\Persistence\OptionStore;
use Affilun\Infrastructure\Persistence\PersistenceServiceProvider;
use Affilun\Infrastructure\Persistence\ProductRepository;
use Affilun\Infrastructure\Persistence\ProductUrlRepository;
use Affilun\Presentation\Rest\ProviderServiceProvider;
use InvalidArgumentException;
use LogicException;

/**
 * Binds the {@see BlockRegistrar} plus the {@see ProductCardBlockRenderer},
 * and subscribes the `init` action that drives block registration through
 * WordPress's `register_block_type_from_metadata()`.
 */
final class BlockServiceProvider implements ServiceProvider {

	/**
	 * Registry key under which the {@see BlockRegistrar} is bound.
	 */
	public const BINDING_BLOCK_REGISTRAR = 'affilun.block.registrar';

	/**
	 * Script handle for the shared block-editor preview helper.
	 */
	private const EDITOR_PREVIEW_SCRIPT_HANDLE = 'affilun-editor-preview';

	/**
	 * Registry key under which the slice-7b
	 * {@see ProductCardBlockRenderer} is bound.
	 */
	public const BINDING_PRODUCT_CARD_BLOCK_RENDERER = 'affilun.block.product_card_renderer';

	/**
	 * Absolute filesystem path of the plugin root directory.
	 */
	private string $pluginRootDir;

	/**
	 * Block-registrar override.
	 */
	private ?BlockRegistrar $registrarOverride;

	/**
	 * Slice-7b block-renderer override.
	 */
	private ?ProductCardBlockRenderer $rendererOverride;

	/**
	 * Cached {@see BlockRegistrar} bound into the registry.
	 */
	private ?BlockRegistrar $resolvedRegistrar = null;

	/**
	 * Construct a block service provider with the supplied plugin
	 * root directory and (optionally overridden) collaborators.
	 *
	 * @param string                        $pluginRootDir        Absolute path to the plugin root directory.
	 * @param BlockRegistrar|null           $registrarOverride    Registrar override.
	 * @param ProductCardBlockRenderer|null $rendererOverride     Card-block-renderer override.
	 *
	 * @throws InvalidArgumentException When `$pluginRootDir` is empty
	 *                                  after trimming or not an
	 *                                  absolute path.
	 */
	public function __construct(
		string $pluginRootDir,
		?BlockRegistrar $registrarOverride = null,
		?ProductCardBlockRenderer $rendererOverride = null
	) {
		$trimmed = trim( $pluginRootDir );
		if ( $trimmed === '' ) {
			$message = 'BlockServiceProvider pluginRootDir must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

		$this->pluginRootDir     = rtrim( $trimmed, '/\\' );
		$this->registrarOverride = $registrarOverride;
		$this->rendererOverride  = $rendererOverride;
	}

	/**
	 * Bind the {@see BlockRegistrar} and card block renderer into the
	 * registry, then append the `affilun/product-card` {@see BlockDefinition}
	 * with its `render_callback` populated.
	 *
	 * @param ServiceProviderRegistry $registry Registry driving this
	 *                                          bootstrap pass.
	 *
	 * @throws LogicException When the persistence or render
	 *                        provider has not registered before
	 *                        this one.
	 */
	public function register( ServiceProviderRegistry $registry ): void {
		$repository   = $this->resolveProductRepository( $registry );
		$cardFactory  = $this->resolveViewModelFactory( $registry );
		$cardRenderer = $this->resolveProductCardRenderer( $registry );
		$arrayDumper  = $this->resolveArrayDumper( $registry );

		// KI-007: optional cache wiring. When the cache provider has
		// been registered upstream the block renderers read from /
		// write to the §4 render cache layer; otherwise they fall
		// back on the Phase 9 MVP "render every request" behavior.
		$renderCache      = $this->resolveOptionalRenderCache( $registry );
		$cacheKeys        = $this->resolveOptionalCacheKeyFactory( $registry );
		$providerRegistry = $this->resolveOptionalProviderRegistry( $registry );
		$affiliateRouter  = $this->resolveAffiliateRouter( $registry );
		$urlRepository    = $this->resolveOptionalProductUrlRepository( $registry );
		$settings         = $this->resolveSettingsRepository( $registry );

		$cardBlockRenderer = $this->rendererOverride ?? new ProductCardBlockRenderer(
			$repository,
			$cardFactory,
			$cardRenderer,
			null,
			$renderCache,
			$cacheKeys,
			$arrayDumper,
			$providerRegistry,
			$affiliateRouter,
			$urlRepository,
			$settings
		);

		$registrar = $this->registrarOverride ?? new BlockRegistrar();

		$this->resolvedRegistrar = $registrar;

		$registry->bind( self::BINDING_BLOCK_REGISTRAR, $registrar );
		$registry->bind( self::BINDING_PRODUCT_CARD_BLOCK_RENDERER, $cardBlockRenderer );

		$registrar->add(
			new BlockDefinition(
				$this->pluginRootDir . DIRECTORY_SEPARATOR . 'blocks' . DIRECTORY_SEPARATOR . 'product-card',
				'affilun/product-card',
				array(
					'render_callback' => array( $cardBlockRenderer, 'render' ),
				)
			)
		);
	}

	/**
	 * Subscribe an `init` action callback that drives the bound
	 * {@see BlockRegistrar::register()}.
	 *
	 * @param HookRegistrar $hooks Shared hook registrar.
	 */
	public function boot( HookRegistrar $hooks ): void {
		$hooks->addAction( 'init', array( $this, 'onInit' ) );
		// `enqueue_block_assets` fires both on the front-end and inside
		// the Gutenberg editor's iframe canvas, so the card CSS reaches
		// the in-editor preview HTML emitted by SelectedProductSummary.
		// `enqueue_block_editor_assets` would only load the stylesheet
		// into the wp-admin host page, not into the iframe where the
		// block's edit() output is actually rendered.
		$hooks->addAction( 'enqueue_block_assets', array( $this, 'onEnqueueScripts' ) );
		$hooks->addAction( 'enqueue_block_editor_assets', array( $this, 'onEnqueueEditorAssets' ) );
	}

	/**
	 * `init` action callback.
	 *
	 * Drives {@see BlockRegistrar::register()} so every
	 * {@see BlockDefinition} appended during register() (and any
	 * appended by subsequent slices' service providers) is
	 * forwarded to WordPress at the canonical block-registration
	 * moment.
	 */
	public function onInit(): void {
		$this->registerEditorPreviewScript();

		if ( $this->resolvedRegistrar === null ) {
			return;
		}

		$this->resolvedRegistrar->register();
	}

	/**
	 * Register the shared no-build editor-preview helper before block
	 * metadata registration reads `index.asset.php` dependencies.
	 */
	private function registerEditorPreviewScript(): void {
		if ( ! function_exists( 'wp_register_script' ) || ! function_exists( 'plugins_url' ) ) {
			return;
		}

		$entry_file = defined( 'AFFILUN_PLUGIN_FILE' )
			? (string) constant( 'AFFILUN_PLUGIN_FILE' )
			: dirname( __DIR__, 3 ) . '/affilun.php';

		$rel_path = 'assets/js/affilun-editor-preview.js';
		$abs_path = dirname( $entry_file ) . '/' . $rel_path;
		$src      = plugins_url( $rel_path, $entry_file );
		$version  = file_exists( $abs_path )
			? (string) filemtime( $abs_path )
			: ( defined( 'AFFILUN_VERSION' ) ? (string) constant( 'AFFILUN_VERSION' ) : '0' );

		wp_register_script(
			self::EDITOR_PREVIEW_SCRIPT_HANDLE,
			$src,
			array( 'wp-api-fetch' ),
			$version,
			false
		);
	}

	/**
	 * `wp_enqueue_scripts` / `enqueue_block_editor_assets` callback
	 * (post-KI design refresh).
	 *
	 * Registers + enqueues the front-end card stylesheet so a post that
	 * embeds an `affilun/product-card` block renders the modern affiliate-card
	 * layout. The same stylesheet is enqueued in the block editor so the
	 * server-rendered preview and the front-end render share visuals.
	 *
	 * The asset URL is computed from the plugin entry file so the
	 * lookup is independent of the active theme. The handle is
	 * versioned by the plugin's own version constant when defined,
	 * otherwise by the file's mtime (the same fallback wp-scripts
	 * uses).
	 */
	public function onEnqueueScripts(): void {
		if ( ! function_exists( 'wp_enqueue_style' ) || ! function_exists( 'plugins_url' ) ) {
			return;
		}

		$entry_file = defined( 'AFFILUN_PLUGIN_FILE' )
			? (string) constant( 'AFFILUN_PLUGIN_FILE' )
			: dirname( __DIR__, 3 ) . '/affilun.php';

		$rel_path = 'assets/css/affilun-card.css';
		$abs_path = dirname( $entry_file ) . '/' . $rel_path;
		$src      = plugins_url( $rel_path, $entry_file );

		$version = file_exists( $abs_path )
			? (string) filemtime( $abs_path )
			: ( defined( 'AFFILUN_VERSION' ) ? (string) constant( 'AFFILUN_VERSION' ) : '0' );

		wp_enqueue_style(
			'affilun-card-style',
			$src,
			array(),
			$version,
			'all'
		);
		$this->enqueueProviderButtonColorStyles();

		$this->enqueueClickTrackingScript( $version );
	}

	/**
	 * Enqueue block-editor UI styles that mirror the branded admin
	 * surfaces.
	 */
	public function onEnqueueEditorAssets(): void {
		if ( ! function_exists( 'wp_enqueue_style' ) || ! function_exists( 'plugins_url' ) ) {
			return;
		}

		$entry_file = defined( 'AFFILUN_PLUGIN_FILE' )
			? (string) constant( 'AFFILUN_PLUGIN_FILE' )
			: dirname( __DIR__, 3 ) . '/affilun.php';

		$rel_path = 'assets/css/affilun-block-editor.css';
		$abs_path = dirname( $entry_file ) . '/' . $rel_path;
		$src      = plugins_url( $rel_path, $entry_file );
		$version  = file_exists( $abs_path )
			? (string) filemtime( $abs_path )
			: ( defined( 'AFFILUN_VERSION' ) ? (string) constant( 'AFFILUN_VERSION' ) : '0' );

		wp_enqueue_style(
			'affilun-block-editor-style',
			$src,
			array(),
			$version,
			'all'
		);
	}

	/**
	 * Enqueue provider button color overrides from plugin settings.
	 */
	private function enqueueProviderButtonColorStyles(): void {
		if ( ! function_exists( 'wp_add_inline_style' ) ) {
			return;
		}

		$colors     = self::loadProviderButtonColors();
		$textColors = self::loadProviderButtonTextColors();
		$css        = '.affilun-card {';
		foreach ( $colors as $providerKey => $color ) {
			$token      = self::providerCssToken( $providerKey );
			$color      = self::sanitizeCssHexColor( $color );
			$preference = $textColors[ $providerKey ] ?? ProviderCredentials::BUTTON_TEXT_COLOR_AUTO;
			$css       .= sprintf(
				'--affilun-color-%s:%s;--affilun-color-%s-text:%s;',
				$token,
				$color,
				$token,
				self::sanitizeCssHexColor( ProviderCredentials::resolveButtonTextColor( $color, $preference ) )
			);
		}
		$css .= '}';

		wp_add_inline_style( 'affilun-card-style', $css );
	}

	/**
	 * Load provider button colors from the persisted settings option.
	 *
	 * @return array<string, string>
	 */
	private static function loadProviderButtonColors(): array {
		$colors = ProviderCredentials::DEFAULT_BUTTON_COLORS;
		if ( ! function_exists( 'get_option' ) ) {
			return $colors;
		}

		$stored = get_option( OptionStoreSettingsRepository::OPTION_KEY, array() );
		if ( ! is_array( $stored ) ) {
			return $colors;
		}

		$providers = $stored['provider_credentials'] ?? null;
		if ( ! is_array( $providers ) ) {
			return $colors;
		}

		foreach ( array_keys( $colors ) as $providerKey ) {
			$entry = $providers[ $providerKey ] ?? null;
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$secrets = $entry['secrets'] ?? null;
			if ( ! is_array( $secrets ) ) {
				continue;
			}
			$value                  = isset( $secrets['button_color'] ) && is_string( $secrets['button_color'] )
				? $secrets['button_color']
				: '';
			$colors[ $providerKey ] = ProviderCredentials::normalizeButtonColor( $providerKey, $value );
		}

		return $colors;
	}

	/**
	 * Load provider button text color preferences from the persisted settings option.
	 *
	 * @return array<string, string>
	 */
	private static function loadProviderButtonTextColors(): array {
		$textColors = array_fill_keys( array_keys( ProviderCredentials::DEFAULT_BUTTON_COLORS ), ProviderCredentials::BUTTON_TEXT_COLOR_AUTO );
		if ( ! function_exists( 'get_option' ) ) {
			return $textColors;
		}

		$stored = get_option( OptionStoreSettingsRepository::OPTION_KEY, array() );
		if ( ! is_array( $stored ) ) {
			return $textColors;
		}

		$providers = $stored['provider_credentials'] ?? null;
		if ( ! is_array( $providers ) ) {
			return $textColors;
		}

		foreach ( array_keys( $textColors ) as $providerKey ) {
			$entry = $providers[ $providerKey ] ?? null;
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$secrets = $entry['secrets'] ?? null;
			if ( ! is_array( $secrets ) ) {
				continue;
			}
			$value                      = isset( $secrets['button_text_color'] ) && is_string( $secrets['button_text_color'] )
				? $secrets['button_text_color']
				: '';
			$textColors[ $providerKey ] = ProviderCredentials::normalizeButtonTextColor( $value );
		}

		return $textColors;
	}

	/**
	 * Convert provider keys to the CSS token names used in affilun-card.css.
	 *
	 * @param string $providerKey Provider key.
	 */
	private static function providerCssToken( string $providerKey ): string {
		$token = $providerKey === 'yahoo_shopping' ? 'yahoo' : str_replace( '_', '-', $providerKey );
		if ( function_exists( 'sanitize_html_class' ) ) {
			return (string) sanitize_html_class( $token );
		}
		return (string) preg_replace( '/[^a-z0-9_-]/i', '', $token );
	}

	/**
	 * Sanitize a CSS hex color declaration value.
	 *
	 * @param string $color Candidate color.
	 */
	private static function sanitizeCssHexColor( string $color ): string {
		if ( function_exists( 'sanitize_hex_color' ) ) {
			$sanitized = sanitize_hex_color( $color );
			if ( is_string( $sanitized ) && $sanitized !== '' ) {
				return $sanitized;
			}
		}

		return preg_match( '/\A#[0-9a-fA-F]{6}\z/', $color ) === 1 ? $color : '#111827';
	}

	/**
	 * Enqueue the privacy-light click tracking helper.
	 *
	 * @param string $version Asset version.
	 */
	private function enqueueClickTrackingScript( string $version ): void {
		if ( ! function_exists( 'wp_register_script' ) || ! function_exists( 'wp_enqueue_script' ) ) {
			return;
		}

		$handle = 'affilun-click-tracking';
		wp_register_script( $handle, '', array(), $version, true );
		wp_enqueue_script( $handle );

		if ( ! function_exists( 'wp_add_inline_script' ) ) {
			return;
		}

		$endpoint = function_exists( 'rest_url' )
			? (string) rest_url( 'affilun/v1/clicks' )
			: '/wp-json/affilun/v1/clicks';

		$encodedEndpoint = function_exists( 'wp_json_encode' )
			? wp_json_encode( $endpoint )
			: '"' . addcslashes( $endpoint, "\\\"\n\r\t/" ) . '"';
		if ( ! is_string( $encodedEndpoint ) ) {
			return;
		}

		$script = 'window.aflClickEndpoint=' . $encodedEndpoint . ';'
			. <<<'JS'
(function () {
  if (!window.aflClickEndpoint || !window.document) {
    return;
  }
  function closestButton(target) {
    while (target && target !== document) {
      if (target.classList && target.classList.contains('affilun-button')) {
        return target;
      }
      target = target.parentNode;
    }
    return null;
  }
  function classToken(button, prefix) {
    var classes = button.className ? String(button.className).split(/\s+/) : [];
    for (var i = 0; i < classes.length; i++) {
      if (classes[i].indexOf(prefix) === 0) {
        return classes[i].slice(prefix.length);
      }
    }
    return 'unknown';
  }
  function pagePostId() {
    var body = document.body;
    var className = body && body.className ? String(body.className) : '';
    var match = className.match(/(?:^|\s)(?:postid|page-id)-(\d+)(?:\s|$)/);
    return match ? match[1] : null;
  }
  function storedVariant(key) {
    if (!window.localStorage) {
      return '';
    }
    try {
      return window.localStorage.getItem(key) || '';
    } catch (ignored) {
      return '';
    }
  }
  function saveVariant(key, value) {
    if (!window.localStorage) {
      return;
    }
    try {
      window.localStorage.setItem(key, value);
    } catch (ignored) {}
  }
  function variantFor(root, productId) {
    var key = 'affilun_ab_' + (productId || root.getAttribute('data-affilun-ab-key') || 'global');
    var variant = storedVariant(key);
    if (variant !== 'a' && variant !== 'b') {
      variant = Math.random() < 0.5 ? 'a' : 'b';
      saveVariant(key, variant);
    }
    root.setAttribute('data-affilun-ab-variant', variant);
    return variant;
  }
  function ensureBadge(card, text) {
    if (!card || !text) {
      return;
    }
    var badge = card.querySelector('.affilun-badge-sale, .affilun-badge--sale');
    if (badge) {
      badge.textContent = text;
      return;
    }
    var badges = card.querySelector('.affilun-card__badges');
    if (!badges) {
      badges = document.createElement('div');
      badges.className = 'affilun-card__badges';
      card.appendChild(badges);
    }
    badge = document.createElement('span');
    badge.className = 'affilun-badge affilun-badge--sale';
    badge.textContent = text;
    badges.appendChild(badge);
  }
  function applyAbTesting() {
    var roots = document.querySelectorAll('[data-affilun-ab-enabled="1"]');
    for (var i = 0; i < roots.length; i++) {
      var root = roots[i];
      if (root.getAttribute('data-affilun-ab-applied') === '1') {
        continue;
      }
      root.setAttribute('data-affilun-ab-applied', '1');
      var cards = root.matches && root.matches('.affilun-card')
              ? [root]
              : Array.prototype.slice.call(root.querySelectorAll('.affilun-card'));
      if (!cards.length) {
        cards = [root];
      }
      for (var j = 0; j < cards.length; j++) {
        var card = cards[j];
        var productId = card.getAttribute ? card.getAttribute('data-affilun-product-id') : null;
        var variant = variantFor(card, productId);
        if (card !== root) {
          card.setAttribute('data-affilun-ab-enabled', '1');
        }
        if (variant !== 'b') {
          continue;
        }
        var label = root.getAttribute('data-affilun-ab-button-label-b') || '';
        var microcopy = root.getAttribute('data-affilun-ab-microcopy-b') || '';
        if (label) {
          var labels = card.querySelectorAll('.affilun-button__label');
          for (var k = 0; k < labels.length; k++) {
            labels[k].textContent = label;
          }
        }
        ensureBadge(card, microcopy);
      }
    }
  }
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', applyAbTesting);
  } else {
    applyAbTesting();
  }
  document.addEventListener('click', function (event) {
    var button = closestButton(event.target);
    if (!button || !button.href || !window.fetch) {
      return;
    }
    var provider = classToken(button, 'affilun-button-');
    var card = button.closest ? button.closest('.affilun-card') : null;
    var productId = card && card.getAttribute ? card.getAttribute('data-affilun-product-id') : null;
    var targetUrl = button.getAttribute ? (button.getAttribute('href') || button.href) : button.href;
    var clickToken = button.getAttribute ? button.getAttribute('data-affilun-click-token') : null;
    if (!clickToken || !targetUrl) {
      return;
    }
    var abRoot = card && card.closest ? card.closest('[data-affilun-ab-enabled="1"]') : null;
    var payload = {
      provider: provider || 'unknown',
      button_key: provider || 'unknown',
      target_url: targetUrl,
      product_id: productId || null,
      click_token: clickToken,
      post_id: pagePostId(),
      variant: abRoot && abRoot.getAttribute ? abRoot.getAttribute('data-affilun-ab-variant') : null
    };
    try {
      window.fetch(window.aflClickEndpoint, {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
        keepalive: true
      });
    } catch (ignored) {}
  }, true);
}());
JS;

		wp_add_inline_script( $handle, $script );
	}

	/**
	 * Resolve affiliate link router.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveAffiliateRouter( ServiceProviderRegistry $registry ): ?AffiliateLinkRouter {
		$key = PersistenceServiceProvider::BINDING_OPTION_STORE;
		if ( ! $registry->has( $key ) ) {
			return null;
		}
		$options = $registry->get( $key );
		if ( ! $options instanceof OptionStore ) {
			return null;
		}
		return AffiliateLinkRouter::fromRepository( new OptionStoreAffiliateRoutingRepository( $options ) );
	}

	/**
	 * Resolve {@see ProductRepository} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see ProductRepository}.
	 */
	private function resolveProductRepository( ServiceProviderRegistry $registry ): ProductRepository {
		$key = PersistenceServiceProvider::BINDING_PRODUCT_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			$message = 'BlockServiceProvider requires PersistenceServiceProvider to register before it'
				. ' (missing ProductRepository binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$repository = $registry->get( $key );
		if ( ! $repository instanceof ProductRepository ) {
			$message = sprintf(
				'BlockServiceProvider expected ProductRepository under "%s"; got %s.',
				$key,
				is_object( $repository ) ? get_class( $repository ) : gettype( $repository )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $repository;
	}

	/**
	 * Resolve {@see ProductCardViewModelFactory} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see ProductCardViewModelFactory}.
	 */
	private function resolveViewModelFactory(
		ServiceProviderRegistry $registry
	): ProductCardViewModelFactory {
		$key = RenderServiceProvider::BINDING_PRODUCT_CARD_VIEW_MODEL_FACTORY;
		if ( ! $registry->has( $key ) ) {
			$message = 'BlockServiceProvider requires RenderServiceProvider to register before it'
				. ' (missing ProductCardViewModelFactory binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$factory = $registry->get( $key );
		if ( ! $factory instanceof ProductCardViewModelFactory ) {
			$message = sprintf(
				'BlockServiceProvider expected ProductCardViewModelFactory under "%s"; got %s.',
				$key,
				is_object( $factory ) ? get_class( $factory ) : gettype( $factory )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $factory;
	}

	/**
	 * Resolve {@see ProductCardRenderer} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see ProductCardRenderer}.
	 */
	private function resolveProductCardRenderer( ServiceProviderRegistry $registry ): ProductCardRenderer {
		$key = RenderServiceProvider::BINDING_PRODUCT_CARD_RENDERER;
		if ( ! $registry->has( $key ) ) {
			$message = 'BlockServiceProvider requires RenderServiceProvider to register before it'
				. ' (missing ProductCardRenderer binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$renderer = $registry->get( $key );
		if ( ! $renderer instanceof ProductCardRenderer ) {
			$message = sprintf(
				'BlockServiceProvider expected ProductCardRenderer under "%s"; got %s.',
				$key,
				is_object( $renderer ) ? get_class( $renderer ) : gettype( $renderer )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $renderer;
	}

	/**
	 * Resolve {@see ViewModelArrayDumper} from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 *
	 * @throws LogicException When the binding is missing or the
	 *                        bound value is not a
	 *                        {@see ViewModelArrayDumper}.
	 */
	private function resolveArrayDumper( ServiceProviderRegistry $registry ): ViewModelArrayDumper {
		$key = RenderServiceProvider::BINDING_VIEW_MODEL_ARRAY_DUMPER;
		if ( ! $registry->has( $key ) ) {
			$message = 'BlockServiceProvider requires RenderServiceProvider to register before it'
				. ' (missing ViewModelArrayDumper binding).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$dumper = $registry->get( $key );
		if ( ! $dumper instanceof ViewModelArrayDumper ) {
			$message = sprintf(
				'BlockServiceProvider expected ViewModelArrayDumper under "%s"; got %s.',
				$key,
				is_object( $dumper ) ? get_class( $dumper ) : gettype( $dumper )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		return $dumper;
	}

	/**
	 * Resolve the optional §4 {@see RenderCache} binding published
	 * by {@see CacheServiceProvider}, returning null when the
	 * cache provider has not been registered upstream so the block
	 * renderers fall back on the Phase 9 MVP "render every request"
	 * behavior (KI-007 fallback path).
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveOptionalRenderCache( ServiceProviderRegistry $registry ): ?RenderCache {
		$key = CacheServiceProvider::BINDING_RENDER_CACHE;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

		$value = $registry->get( $key );
		return $value instanceof RenderCache ? $value : null;
	}

	/**
	 * Resolve optional provider registry from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveOptionalProviderRegistry( ServiceProviderRegistry $registry ): ?ProviderRegistry {
		$key = ProviderServiceProvider::BINDING_PROVIDER_REGISTRY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

		$providerRegistry = $registry->get( $key );
		return $providerRegistry instanceof ProviderRegistry ? $providerRegistry : null;
	}

	/**
	 * Resolve optional product URL repository from the registry.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveOptionalProductUrlRepository( ServiceProviderRegistry $registry ): ?ProductUrlRepository {
		$key = PersistenceServiceProvider::BINDING_PRODUCT_URL_REPOSITORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

		$repository = $registry->get( $key );
		return $repository instanceof ProductUrlRepository ? $repository : null;
	}

	/**
	 * Resolve settings repository from the shared option store.
	 *
	 * Block registration happens before SettingsServiceProvider publishes its
	 * own binding, so the block renderer constructs the same repository shape
	 * directly from PersistenceServiceProvider's OptionStore binding.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveSettingsRepository( ServiceProviderRegistry $registry ): ?SettingsRepository {
		$key = PersistenceServiceProvider::BINDING_OPTION_STORE;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

		$optionStore = $registry->get( $key );
		if ( ! $optionStore instanceof OptionStore ) {
			return null;
		}

		return new OptionStoreSettingsRepository( $optionStore );
	}

	/**
	 * Resolve the optional §6.3 {@see CacheKeyFactory} binding
	 * published by {@see CacheServiceProvider}, returning null
	 * when the cache provider has not been registered upstream.
	 *
	 * @param ServiceProviderRegistry $registry Bootstrap registry.
	 */
	private function resolveOptionalCacheKeyFactory( ServiceProviderRegistry $registry ): ?CacheKeyFactory {
		$key = CacheServiceProvider::BINDING_CACHE_KEY_FACTORY;
		if ( ! $registry->has( $key ) ) {
			return null;
		}

		$value = $registry->get( $key );
		return $value instanceof CacheKeyFactory ? $value : null;
	}
}
