<?php
/**
 * Product card block renderer.
 *
 * Phase-7 slice 7b dynamic render callback for the
 * `clb/product-card` Gutenberg block. The renderer reuses the
 * Phase-5 rendering pipeline — §9.1
 * {@see \CommerceLinkBuilder\Application\Render\ViewModel\ProductCardViewModelFactory},
 * §6.5
 * {@see \CommerceLinkBuilder\Application\Render\ProductCardRenderer}
 * — wired against the §8.x persisted product lookup
 * ({@see \CommerceLinkBuilder\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 \CommerceLinkBuilder\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 \CommerceLinkBuilder\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 \CommerceLinkBuilder\Domain\Product\ProductSnapshot}
 *      from the persisted {@see \CommerceLinkBuilder\Infrastructure\Persistence\ProductRecord}
 *      using the verbatim §10.1 mapping pattern from
 *      {@see \CommerceLinkBuilder\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 \CommerceLinkBuilder\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 \CommerceLinkBuilder\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="clb-card clb-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 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\ProductCardRenderer;
use CommerceLinkBuilder\Application\Render\ViewModel\Parts\MetaFlags;
use CommerceLinkBuilder\Application\Render\ViewModel\ProductCardViewModelFactory;
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-7 slice 7b dynamic render callback host for the
 * `clb/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 {

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

	/**
	 * §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 \CommerceLinkBuilder\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;

	/**
	 * §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;

	/**
	 * 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 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.
	 */
	public function __construct(
		ProductRepository $repository,
		ProductCardViewModelFactory $viewModelFactory,
		ProductCardRenderer $renderer,
		?callable $currentUserCanProvider = null,
		?RenderCache $renderCache = null,
		?CacheKeyFactory $cacheKeys = null,
		?ViewModelArrayDumper $dumper = 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->renderCache            = $renderCache;
		$this->cacheKeys              = $cacheKeys;
		$this->dumper                 = $dumper;
	}

	/**
	 * `render_callback` entry point for the `clb/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(
				'Commerce Link Builder: 商品が選択されていません。'
			);
		}

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

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

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

	/**
	 * 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->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 );

		// KI-007: try the §4 render cache layer when fully wired.
		$cacheKey = $this->resolveCacheKey( $record, $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'];
			}
		}

		$snapshot = self::buildSnapshotFromRecord( $record );

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

		$html = $this->renderer->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
	 * 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
	 * {@see self::DEFAULT_TEMPLATE} when absent or grammar-invalid.
	 *
	 * @param array<string, mixed> $attributes Block attributes.
	 */
	private function resolveTemplate( array $attributes ): string {
		if ( ! array_key_exists( 'template', $attributes ) ) {
			return self::DEFAULT_TEMPLATE;
		}

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

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

		return $trimmed;
	}

	/**
	 * 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="clb-card clb-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 );
	}
}
