<?php
/**
 * Products REST controller.
 *
 * Phase 6b Presentation-layer controller that drives the four
 * §8.1-§8.4 product endpoints documented in
 * `specifications/rest-api-spec.md`:
 *
 *   - §8.1 `GET /affilun/v1/products/search` — Provider-backed search
 *     dispatch (single-provider or `provider=all` cross-provider).
 *     Validates `provider`, `keywords`, `page`, `per_page`, then
 *     drives {@see SearchProductsUseCase} and renders the §8.1
 *     success envelope (with optional `meta.warnings` populated by
 *     {@see SearchResultBundle::providerWarnings()}).
 *
 *   - §8.2 `GET /affilun/v1/products` — Saved-product listing.
 *     Validates `page` / `per_page` / closed-set filters, then
 *     drives {@see ListProductsUseCase} and renders the §4.2
 *     `data.items` + `data.pagination` envelope.
 *
 *   - §8.3 `GET /affilun/v1/products/{id}` — Saved-product detail
 *     read. Validates the path id, then drives
 *     {@see GetProductUseCase} and renders §6.2 ProductDetail.
 *     {@see ProductNotFoundException} maps to 404.
 *
 *   - §8.4 `POST /affilun/v1/products` — Saved-product upsert from a
 *     normalized snapshot. Validates `provider` /
 *     `provider_item_id` / `snapshot.title` /
 *     `snapshot.detail_url`, builds a {@see ProductSnapshot}, then
 *     drives {@see SaveProductUseCase} and renders §8.4 with
 *     `data.created` / 201 (insert) or 200 (update).
 *
 * Synthetic envelope key:
 *
 *   - Every method returns `Envelope::toArray() + array('_http_status' => int)`.
 *     The slice-6b-2B route registration layer extracts and strips
 *     the {@see self::META_HTTP_STATUS} key when stamping the
 *     `WP_REST_Response` HTTP status. The key is never serialized
 *     onto the wire response — the registration layer is the sole
 *     consumer.
 *
 * Permission resolution flow:
 *
 *   1. Each method names a stable route token (one of
 *      {@see self::ROUTE_TOKEN_SEARCH} / `LIST` / `READ` / `WRITE`).
 *   2. {@see CapabilityPolicy::requiredForRead()} (or
 *      `requiredForWrite()`) yields the OR-list of required caps.
 *   3. The controller probes each required cap via the injected
 *      `$capabilityProvider` callable, populates a
 *      {@see RequestContext} caps map with the held tokens, and
 *      delegates to {@see CapabilityPolicy::isAuthorized()}.
 *   4. A failed authorization check short-circuits to a 403
 *      `affilun_forbidden` error envelope; the §8.x success path runs
 *      otherwise.
 *
 * Exception mapping order in {@see self::mapException()}:
 *
 *   1. {@see ProductNotFoundException} (Application layer) → 404
 *      `affilun_product_not_found`.
 *   2. {@see ProviderException} (any §10.2 subclass) → routed
 *      through {@see ProviderExceptionMapper::map()} which yields
 *      the §15.2 envelope and HTTP status pair.
 *   3. {@see \InvalidArgumentException} → 422
 *      `affilun_validation_failed` carrying the exception message in
 *      `details.exception` (developer-facing diagnosis hook for
 *      VO-construction failures the request validator did not
 *      catch).
 *   4. Catch-all {@see \Throwable} → 500 `affilun_internal_error` with
 *      the literal message `'Internal server error.'`. The
 *      original exception's class and message are NOT forwarded so
 *      the §10.1 "no internal detail leakage" posture is preserved.
 *
 * Phase-6 deferrals (documented in code, never silently dropped):
 *
 *   - §8.1 `is_saved` / saved-id resolution: the controller passes
 *     `null` to {@see SearchResultItemMapper::map()} for every
 *     snapshot. Probing the local `affilun_products` table for a
 *     `(provider, provider_item_id)` hit is a future enhancement;
 *     the §6.3 `is_saved` field therefore reads `false` on every
 *     search-result item in this slice.
 *
 *   - §8.4 `If-Match` conflict handling: the controller does NOT
 *     consult the `If-Match` header. The §8.4 contract permits
 *     this — "`If-Match` 未指定時は常に upsert を優先し、409 は
 *     発生させない" is the canonical posture; this slice extends
 *     the same posture to `If-Match` clients pending a future
 *     optimistic-locking slice.
 *
 *   - §6.3 `provider_meta` enrichment: empty array via
 *     {@see SearchResultItemMapper::map()} per its Phase-6
 *     contract; per-provider extraction lands in Phase 9+.
 *
 *   - §6.1 / §6.2 image-hint resolution: the controller passes
 *     `null` `ImageHints` to both summary and detail mappers, so
 *     `image_primary_url` reads `null` and `images.gallery` reads
 *     `[]`. Wiring `ProductMetaRepository` is a future slice.
 *
 *   - §6.2 `usage` enrichment: the controller passes `null`
 *     `UsageHints`, so `usage.embed_count` reads `0` and
 *     `usage.post_ids` reads `[]`. Wiring lands in Phase 9.
 *
 * @package Affilun\Presentation\Rest\Controller
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Controller;

use Affilun\Application\Product\Exception\ProductNotFoundException;
use Affilun\Application\Product\ProductUrlDraft;
use Affilun\Application\Product\UseCase\GetProductUseCase;
use Affilun\Application\Product\UseCase\ListProductsUseCase;
use Affilun\Application\Product\UseCase\SaveProductUseCase;
use Affilun\Application\Product\UseCase\SearchProductsUseCase;
use Affilun\Domain\Pricing\AvailabilityInfo;
use Affilun\Domain\Pricing\PriceInfo;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Provider\Exception\ProviderException;
use Affilun\Domain\Provider\ProviderProductId;
use Affilun\Domain\Search\SearchQuery;
use Affilun\Infrastructure\Persistence\ProductListQuery;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Presentation\Rest\Mapper\ImageHints;
use Affilun\Presentation\Rest\Mapper\ProductDetailMapper;
use Affilun\Presentation\Rest\Mapper\ProductSummaryMapper;
use Affilun\Presentation\Rest\Mapper\SearchResultItemMapper;
use Affilun\Presentation\Rest\Permission\CapabilityPolicy;
use Affilun\Presentation\Rest\RequestContext;
use Affilun\Presentation\Rest\Response\ErrorEnvelope;
use Affilun\Presentation\Rest\Response\ProviderExceptionMapper;
use Affilun\Presentation\Rest\Response\SuccessEnvelope;
use Affilun\Presentation\Rest\Validation\RequestValidator;
use Affilun\Presentation\Rest\Validation\ValidationResult;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use Throwable;

/**
 * REST controller for the §8.1-§8.4 product endpoints.
 *
 * See the file-level docblock for the synthetic
 * {@see self::META_HTTP_STATUS} envelope key, the permission
 * resolution flow, the exception mapping order, and the Phase-6
 * deferrals.
 *
 * ### Nonce verification (§3.1)
 *
 * Per spec §3.1, all admin / block-editor REST calls authenticate via
 * the WordPress logged-in cookie plus the canonical `wp_rest` nonce
 * header (`X-WP-Nonce`), validated automatically by the WordPress REST
 * framework. The REST `permission_callback` checks capabilities at
 * route level, and this controller repeats the same authorization
 * (§3.2 / §3.3) via the injected `CapabilityPolicy` + capability provider; nonce
 * remains the WP framework's responsibility.
 */
final class ProductsController {

	/**
	 * Stable route token for §8.1 `GET /affilun/v1/products/search`.
	 */
	public const ROUTE_TOKEN_SEARCH = 'products.search';

	/**
	 * Stable route token for §8.2 `GET /affilun/v1/products`.
	 */
	public const ROUTE_TOKEN_LIST = 'products.list';

	/**
	 * Stable route token for §8.3 `GET /affilun/v1/products/{id}`.
	 */
	public const ROUTE_TOKEN_READ = 'products.read';

	/**
	 * Stable route token for §8.4 `POST /affilun/v1/products`.
	 */
	public const ROUTE_TOKEN_WRITE = 'products.write';

	/**
	 * Synthetic envelope key carrying the desired HTTP status.
	 *
	 * The slice-6b-2B route registration layer extracts and strips
	 * this key when stamping the `WP_REST_Response` status; the
	 * key is never serialized onto the wire response.
	 */
	public const META_HTTP_STATUS = '_http_status';

	/**
	 * Closed-set tokens for the §8.1 `provider` query parameter.
	 */
	private const SEARCH_PROVIDER_TOKENS = array( 'amazon', 'rakuten', 'yahoo_shopping', 'mercari', 'all' );

	/**
	 * Maximum search-keyword length for ordinary provider searches.
	 */
	private const SEARCH_KEYWORDS_MAX_LENGTH = 200;

	/**
	 * Amazon product URLs can include long localized slugs and
	 * attribution parameters before the provider extracts the ASIN.
	 */
	private const AMAZON_SEARCH_INPUT_MAX_LENGTH = 2048;

	/**
	 * Closed-set tokens for the §8.4 `provider` body field.
	 */
	private const WRITE_PROVIDER_TOKENS = array( 'amazon', 'rakuten', 'yahoo_shopping', 'mercari' );

	/**
	 * §8.1 search use case.
	 */
	private SearchProductsUseCase $searchUseCase;

	/**
	 * §8.2 list use case.
	 */
	private ListProductsUseCase $listUseCase;

	/**
	 * §8.3 single-product read use case.
	 */
	private GetProductUseCase $getUseCase;

	/**
	 * §8.4 upsert use case.
	 */
	private SaveProductUseCase $saveUseCase;

	/**
	 * §6.1 ProductSummary mapper.
	 */
	private ProductSummaryMapper $summaryMapper;

	/**
	 * §6.2 ProductDetail mapper.
	 */
	private ProductDetailMapper $detailMapper;

	/**
	 * §6.3 SearchResultItem mapper.
	 */
	private SearchResultItemMapper $searchItemMapper;

	/**
	 * §15.2 provider exception → error envelope mapper.
	 */
	private ProviderExceptionMapper $exceptionMapper;

	/**
	 * §3.2 / §3.3 capability policy.
	 */
	private CapabilityPolicy $capabilityPolicy;

	/**
	 * §17.1 request input validator.
	 */
	private RequestValidator $requestValidator;

	/**
	 * §3 request adapter (header / param / body extraction).
	 */
	private RestRequestAdapter $requestAdapter;

	/**
	 * Resolver for the current WordPress user id.
	 *
	 * Defaults to a static closure that calls
	 * {@see \get_current_user_id()} when available and returns null
	 * otherwise (so unit tests run without booting WordPress).
	 *
	 * @var callable(): ?int
	 */
	private $userIdProvider;

	/**
	 * Resolver for whether the current user holds a given capability.
	 *
	 * Defaults to a static closure that calls
	 * {@see \current_user_can()} when available and returns false
	 * otherwise (so unit tests run without booting WordPress).
	 *
	 * @var callable(string): bool
	 */
	private $capabilityProvider;

	/**
	 * Construct the controller with explicit dependency injection.
	 *
	 * The optional `$userIdProvider` / `$capabilityProvider`
	 * callables follow the established
	 * {@see \Affilun\Application\Render\RenderServiceProvider}
	 * pattern: when null they fall back to WordPress-aware static
	 * closures that gracefully degrade to "anonymous / no caps"
	 * when the WordPress runtime is not loaded (the unit-test
	 * harness).
	 *
	 * @param SearchProductsUseCase   $searchUseCase    §8.1 search use case.
	 * @param ListProductsUseCase     $listUseCase      §8.2 list use case.
	 * @param GetProductUseCase       $getUseCase       §8.3 read use case.
	 * @param SaveProductUseCase      $saveUseCase      §8.4 upsert use case.
	 * @param ProductSummaryMapper    $summaryMapper    §6.1 mapper.
	 * @param ProductDetailMapper     $detailMapper     §6.2 mapper.
	 * @param SearchResultItemMapper  $searchItemMapper §6.3 mapper.
	 * @param ProviderExceptionMapper $exceptionMapper  §15.2 mapper.
	 * @param CapabilityPolicy        $capabilityPolicy §3.2 / §3.3 policy.
	 * @param RequestValidator        $requestValidator §17.1 validator.
	 * @param RestRequestAdapter      $requestAdapter   §3 request adapter.
	 * @param callable|null           $userIdProvider     `(): ?int` resolver, or null to use the WP default.
	 * @param callable|null           $capabilityProvider `(string): bool` resolver, or null to use the WP default.
	 */
	public function __construct(
		SearchProductsUseCase $searchUseCase,
		ListProductsUseCase $listUseCase,
		GetProductUseCase $getUseCase,
		SaveProductUseCase $saveUseCase,
		ProductSummaryMapper $summaryMapper,
		ProductDetailMapper $detailMapper,
		SearchResultItemMapper $searchItemMapper,
		ProviderExceptionMapper $exceptionMapper,
		CapabilityPolicy $capabilityPolicy,
		RequestValidator $requestValidator,
		RestRequestAdapter $requestAdapter,
		?callable $userIdProvider = null,
		?callable $capabilityProvider = null
	) {
		$this->searchUseCase    = $searchUseCase;
		$this->listUseCase      = $listUseCase;
		$this->getUseCase       = $getUseCase;
		$this->saveUseCase      = $saveUseCase;
		$this->summaryMapper    = $summaryMapper;
		$this->detailMapper     = $detailMapper;
		$this->searchItemMapper = $searchItemMapper;
		$this->exceptionMapper  = $exceptionMapper;
		$this->capabilityPolicy = $capabilityPolicy;
		$this->requestValidator = $requestValidator;
		$this->requestAdapter   = $requestAdapter;

		$this->userIdProvider = $userIdProvider ?? static function (): ?int {
			if ( ! function_exists( 'get_current_user_id' ) ) {
				return null;
			}
			$id = (int) get_current_user_id();
			return $id !== 0 ? $id : null;
		};

		$this->capabilityProvider = $capabilityProvider ?? static function ( string $cap ): bool {
			if ( ! function_exists( 'current_user_can' ) ) {
				return false;
			}
			return (bool) current_user_can( $cap );
		};
	}

	/**
	 * §8.1 `GET /affilun/v1/products/search` dispatch.
	 *
	 * Validates `provider` / `keywords` / `page` / `per_page`,
	 * builds a {@see SearchQuery}, drives
	 * {@see SearchProductsUseCase}, and renders the §8.1 success
	 * envelope. `meta.warnings` is populated when the bundle
	 * carries any captured per-provider exceptions (the §8.1
	 * partial-success contract).
	 *
	 * Phase-6 deferrals: per-snapshot `savedId` is always null;
	 * `is_saved` therefore reads `false` on every item. See the
	 * file-level docblock for the rationale.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §4.1 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function search( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_SEARCH );
		$ctx      = $this->buildContext( $request, $required );

		if ( ! $this->capabilityPolicy::isAuthorized( $ctx, $required ) ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_FORBIDDEN,
				'Insufficient capabilities.',
				ErrorEnvelope::STATUS_FORBIDDEN,
				$ctx
			);
		}

		$provider = $this->requestAdapter->paramString( $request, 'provider' );
		$keywords = $this->requestAdapter->paramString( $request, 'keywords' );
		$page     = $this->requestAdapter->paramInt( $request, 'page', 1 );
		$perPage  = $this->requestAdapter->paramInt( $request, 'per_page', 20 );
		$sort     = $this->requestAdapter->paramString( $request, 'sort' );

		$keywordsMaxLength = $provider === 'amazon'
			? self::AMAZON_SEARCH_INPUT_MAX_LENGTH
			: self::SEARCH_KEYWORDS_MAX_LENGTH;

		$result = ValidationResult::valid();
		$result = $this->requestValidator->requireOneOf( $result, 'provider', $provider, self::SEARCH_PROVIDER_TOKENS );
		$result = $this->requestValidator->requireString( $result, 'keywords', $keywords, 1, $keywordsMaxLength );
		$result = $this->requestValidator->requireInt( $result, 'page', $page, 1, PHP_INT_MAX );
		$result = $this->requestValidator->requireInt( $result, 'per_page', $perPage, 1, 50 );

		if ( ! $result->isValid() ) {
			return $this->validationErrorResponse( $result, $ctx );
		}

		// Validation has guaranteed these are non-null with the
		// expected types; refine for the static analyzer.
		$providerToken = (string) $provider;
		$keywordValue  = (string) $keywords;
		$pageValue     = (int) $page;
		$perPageValue  = (int) $perPage;

		try {
			$query = new SearchQuery(
				$keywordValue,
				$pageValue,
				$perPageValue,
				$sort,
				null,
				null,
				null,
				array(),
				null,
				null,
				null
			);

			$bundle = $this->searchUseCase->execute( $query, $providerToken );
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

		/** @var list<array<string, mixed>> $items */
		$items      = array();
		$totalItems = 0;

		foreach ( $bundle->results() as $perProviderResult ) {
			foreach ( $perProviderResult->items() as $snapshot ) {
				$items[] = $this->searchItemMapper->map( $snapshot, null );
			}
			$total       = $perProviderResult->total();
			$totalItems += $total ?? $perProviderResult->count();
		}

		$totalPages = $totalItems === 0
			? 0
			: (int) max( 1, (int) ceil( $totalItems / $perPageValue ) );

		$pagination = array(
			'page'        => $pageValue,
			'per_page'    => $perPageValue,
			'total_items' => $totalItems,
			'total_pages' => $totalPages,
		);

		$warnings = $this->buildWarnings( $bundle->providerWarnings(), $ctx );

		$extraMeta = array();
		if ( $warnings !== array() ) {
			$extraMeta['warnings'] = $warnings;
		}

		$envelope = SuccessEnvelope::fromContext(
			array(
				'items'      => $items,
				'pagination' => $pagination,
			),
			$ctx,
			$extraMeta
		);

		return $this->successResponse( $envelope, 200 );
	}

	/**
	 * §8.2 `GET /affilun/v1/products` dispatch.
	 *
	 * Validates the §5.1 / §5.2 / §5.3 query parameters and the
	 * closed-set filter tokens, builds a {@see ProductListQuery}
	 * (catching its construction-time validation), and drives
	 * {@see ListProductsUseCase}.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §4.2 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function list( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_LIST );
		$ctx      = $this->buildContext( $request, $required );

		if ( ! $this->capabilityPolicy::isAuthorized( $ctx, $required ) ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_FORBIDDEN,
				'Insufficient capabilities.',
				ErrorEnvelope::STATUS_FORBIDDEN,
				$ctx
			);
		}

		$page           = $this->requestAdapter->paramInt( $request, 'page', 1 );
		$perPage        = $this->requestAdapter->paramInt( $request, 'per_page', 20 );
		$provider       = $this->requestAdapter->paramString( $request, 'provider' );
		$search         = $this->requestAdapter->paramString( $request, 'search' );
		$syncState      = $this->requestAdapter->paramString( $request, 'sync_state' );
		$usedInPostId   = $this->requestAdapter->paramInt( $request, 'used_in_post_id' );
		$lifecycleState = $this->requestAdapter->paramString( $request, 'lifecycle_state' );
		$sort           = $this->requestAdapter->paramString( $request, 'sort' );
		$order          = $this->requestAdapter->paramString( $request, 'order' );

		$result = ValidationResult::valid();
		$result = $this->requestValidator->requireInt( $result, 'page', $page, 1, PHP_INT_MAX );
		$result = $this->requestValidator->requireInt( $result, 'per_page', $perPage, 1, 50 );
		$result = $this->requestValidator->optionalString( $result, 'provider', $provider, 1, 64 );
		$result = $this->requestValidator->optionalString( $result, 'search', $search, 1, 200 );
		$result = $this->requestValidator->optionalString( $result, 'sync_state', $syncState, 1, 64 );
		$result = $this->requestValidator->optionalInt( $result, 'used_in_post_id', $usedInPostId, 1, PHP_INT_MAX );
		$result = $this->requestValidator->optionalString( $result, 'lifecycle_state', $lifecycleState, 1, 64 );
		$result = $this->requestValidator->optionalString( $result, 'sort', $sort, 1, 64 );
		$result = $this->requestValidator->optionalString( $result, 'order', $order, 1, 8 );

		if ( ! $result->isValid() ) {
			return $this->validationErrorResponse( $result, $ctx );
		}

		try {
			$query = new ProductListQuery(
				(int) $page,
				(int) $perPage,
				$provider,
				$search,
				$syncState,
				$usedInPostId,
				$lifecycleState ?? ProductListQuery::LIFECYCLE_STATE_ACTIVE,
				$sort ?? ProductListQuery::SORT_KEY_UPDATED_AT,
				$order ?? ProductListQuery::SORT_ORDER_DESC
			);

			$listResult = $this->listUseCase->execute( $query );
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

		$items = array();
		foreach ( $listResult->items() as $record ) {
			/** @var ProductRecord $record */
			$items[] = $this->summaryMapper->map( $record, $this->imageHintsFromRecord( $record ) );
		}

		$pagination = array(
			'page'        => $listResult->page(),
			'per_page'    => $listResult->perPage(),
			'total_items' => $listResult->totalItems(),
			'total_pages' => $listResult->totalPages(),
		);

		$envelope = SuccessEnvelope::list( $items, $pagination, $ctx );

		return $this->successResponse( $envelope, 200 );
	}

	/**
	 * Build mapper image hints from the persisted product snapshot.
	 *
	 * Product rows already carry the primary image URL saved from
	 * provider search results. Older rows or manually imported rows may
	 * have no image, and malformed legacy URLs are treated as missing so
	 * one bad row does not break the whole products list endpoint.
	 *
	 * @param ProductRecord $record Persisted record.
	 */
	private function imageHintsFromRecord( ProductRecord $record ): ImageHints {
		$primaryUrl = $record->imagePrimaryUrl();
		if ( $primaryUrl === null || $primaryUrl === '' ) {
			return ImageHints::none();
		}

		try {
			return new ImageHints( $primaryUrl );
		} catch ( InvalidArgumentException ) {
			return ImageHints::none();
		}
	}

	/**
	 * §8.3 `GET /affilun/v1/products/{id}` dispatch.
	 *
	 * Validates the path id, drives {@see GetProductUseCase}, and
	 * renders §6.2 ProductDetail. {@see ProductNotFoundException}
	 * surfaces as 404 via {@see self::mapException()}.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §4.1 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function read( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_READ );
		$ctx      = $this->buildContext( $request, $required );

		if ( ! $this->capabilityPolicy::isAuthorized( $ctx, $required ) ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_FORBIDDEN,
				'Insufficient capabilities.',
				ErrorEnvelope::STATUS_FORBIDDEN,
				$ctx
			);
		}

		$id = $this->requestAdapter->paramInt( $request, 'id' );

		$result = ValidationResult::valid();
		$result = $this->requestValidator->requireInt( $result, 'id', $id, 1, PHP_INT_MAX );

		if ( ! $result->isValid() ) {
			return $this->validationErrorResponse( $result, $ctx );
		}

		try {
			$record = $this->getUseCase->execute( (int) $id );
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

		$envelope = SuccessEnvelope::fromContext(
			$this->detailMapper->map( $record, null, null ),
			$ctx
		);

		return $this->successResponse( $envelope, 200 );
	}

	/**
	 * §8.4 `POST /affilun/v1/products` dispatch.
	 *
	 * Validates `provider` / `provider_item_id` /
	 * `snapshot.title` / `snapshot.detail_url`, builds a
	 * {@see ProductSnapshot} (catching VO-construction failures as
	 * 422), drives {@see SaveProductUseCase}, and renders §8.4
	 * with HTTP status 201 on insert and 200 on update.
	 *
	 * Phase-6 deferral: the controller does NOT consult the
	 * `If-Match` header. The §8.4 contract permits unconditional
	 * upsert when `If-Match` is absent; this slice extends that
	 * posture across the board pending a future
	 * optimistic-locking slice.
	 *
	 * Snapshot fields not derivable from the request body are
	 * assigned safe defaults: `availability` →
	 * {@see AvailabilityInfo::unknown()}, `affiliateUrl` / `rating`
	 * / `reviewCount` → null, `categories` / `attributes` /
	 * `rawMeta` → empty arrays, `fetchedAt` → "now" in UTC.
	 *
	 * @param mixed $request WP_REST_Request, or array shim.
	 *
	 * @return array<string, mixed> §8.4 envelope + synthetic
	 *                              {@see self::META_HTTP_STATUS}.
	 */
	public function write( mixed $request ): array {
		$required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_WRITE );
		$ctx      = $this->buildContext( $request, $required );

		if ( ! $this->capabilityPolicy::isAuthorized( $ctx, $required ) ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_FORBIDDEN,
				'Insufficient capabilities.',
				ErrorEnvelope::STATUS_FORBIDDEN,
				$ctx
			);
		}

		$body           = $this->requestAdapter->jsonBody( $request );
		$provider       = $this->extractStringField( $body, 'provider' );
		$providerItemId = $this->extractStringField( $body, 'provider_item_id' );
		$snapshotInput  = array_key_exists( 'snapshot', $body ) ? $body['snapshot'] : null;
		$snapshotIsMap  = is_array( $snapshotInput )
			&& ( $snapshotInput === array() || ! array_is_list( $snapshotInput ) );
		$snapshotMap    = $snapshotIsMap ? $snapshotInput : array();

		$title        = $this->extractStringField( $snapshotMap, 'title' );
		$detailUrl    = $this->extractStringField( $snapshotMap, 'detail_url' );
		$affiliateUrl = $this->extractStringField( $snapshotMap, 'affiliate_url' );
		$brand        = $this->extractStringField( $snapshotMap, 'brand' );
		$description  = $this->extractStringField( $snapshotMap, 'description' );
		$primaryUrl   = $this->extractStringField( $snapshotMap, 'image_primary_url' );

		$result = ValidationResult::valid();
		$result = $this->requestValidator->requireOneOf( $result, 'provider', $provider, self::WRITE_PROVIDER_TOKENS );
		$result = $this->requestValidator->requireString( $result, 'provider_item_id', $providerItemId, 1, 128 );

		if ( ! $snapshotIsMap ) {
			$result = $result->withField( 'snapshot', 'Must be an object.' );
		}

		$result = $this->requestValidator->requireString( $result, 'title', $title, 1, 512 );
		$result = $this->requireHttpUrl( $result, 'detail_url', $detailUrl );

		if ( $primaryUrl !== null ) {
			$result = $this->requireHttpUrl( $result, 'image_primary_url', $primaryUrl );
		}
		if ( $affiliateUrl !== null ) {
			$result = $this->requireHttpUrl( $result, 'affiliate_url', $affiliateUrl );
		}

		$gallery = $this->validateGallery( $result, $snapshotMap );
		$result  = $gallery['result'];

		$priceFields = $this->validatePriceFields( $result, $snapshotMap );
		$result      = $priceFields['result'];

		if ( ! $result->isValid() ) {
			return $this->validationErrorResponse( $result, $ctx );
		}

		try {
			$now      = new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
			$price    = $priceFields['price'] !== null
				? new PriceInfo(
					$priceFields['price']['amount'],
					$priceFields['price']['currency'],
					$priceFields['price']['label'],
					$now,
					false,
					false,
					null
				)
				: null;
			$snapshot = new ProductSnapshot(
				(string) $provider,
				new ProviderProductId( (string) $provider, (string) $providerItemId ),
				(string) $title,
				$brand,
				$description,
				(string) $detailUrl,
				$affiliateUrl,
				$primaryUrl,
				$gallery['urls'],
				$price,
				AvailabilityInfo::unknown(),
				null,
				null,
				array(),
				array(),
				$now,
				array()
			);

			$saveResult = $this->saveUseCase->execute( $snapshot, $this->extractProductUrlDrafts( $body ) );
		} catch ( Throwable $e ) {
			return $this->mapException( $e, $ctx );
		}

		$status = $saveResult->created() ? 201 : 200;

		$envelope = SuccessEnvelope::fromContext(
			array(
				'id'      => $saveResult->record()->id(),
				'created' => $saveResult->created(),
				'product' => $this->summaryMapper->map( $saveResult->record(), null ),
			),
			$ctx
		);

		return $this->successResponse( $envelope, $status );
	}

	/**
	 * Extract child provider URL drafts from the write payload.
	 *
	 * @param array<string,mixed> $body Request body.
	 *
	 * @return list<ProductUrlDraft>
	 */
	private function extractProductUrlDrafts( array $body ): array {
		if ( ! array_key_exists( 'provider_urls', $body ) || $body['provider_urls'] === null ) {
			return array();
		}

		$raw = $body['provider_urls'];
		if ( ! is_array( $raw ) || ! array_is_list( $raw ) ) {
			throw new InvalidArgumentException( 'provider_urls must be a list.' );
		}

		$drafts = array();
		foreach ( $raw as $index => $entry ) {
			if ( ! is_array( $entry ) ) {
				throw new InvalidArgumentException( sprintf( 'provider_urls[%d] must be an object.', (int) $index ) );
			}

			$provider       = $this->extractStringField( $entry, 'provider_type' )
				?? $this->extractStringField( $entry, 'provider' );
			$url            = $this->extractStringField( $entry, 'url' )
				?? $this->extractStringField( $entry, 'detail_url' );
			$providerItemId = $this->extractStringField( $entry, 'provider_item_id' );
			$source         = $this->extractStringField( $entry, 'source' ) ?? 'user';

			if ( $provider === null || ! in_array( $provider, self::WRITE_PROVIDER_TOKENS, true ) ) {
				throw new InvalidArgumentException( sprintf( 'provider_urls[%d].provider_type is invalid.', (int) $index ) );
			}
			if ( $url === null || preg_match( '#\Ahttps?://#i', $url ) !== 1 ) {
				throw new InvalidArgumentException( sprintf( 'provider_urls[%d].url must be an HTTP(S) URL.', (int) $index ) );
			}

			$drafts[] = new ProductUrlDraft( $provider, $providerItemId, $url, $source );
		}

		return $drafts;
	}

	/**
	 * Build the per-request {@see RequestContext} carrying the
	 * resolved user id and the currently-held subset of the
	 * supplied required-capability list.
	 *
	 * The caps map is populated by probing each candidate cap via
	 * the injected `$capabilityProvider`; only caps the provider
	 * confirms are entered into the map. {@see CapabilityPolicy::isAuthorized()}
	 * therefore behaves correctly against the resulting context.
	 *
	 * @param mixed        $request       WP_REST_Request, or array shim.
	 * @param list<string> $candidateCaps Caps to probe and populate.
	 */
	private function buildContext( mixed $request, array $candidateCaps ): RequestContext {
		$userIdResolver = $this->userIdProvider;
		$userId         = $userIdResolver();
		if ( $userId !== null && ! is_int( $userId ) ) {
			$userId = null;
		}

		$capResolver = $this->capabilityProvider;

		$caps = array();
		foreach ( $candidateCaps as $cap ) {
			if ( $capResolver( $cap ) === true ) {
				$caps[ $cap ] = 'true';
			}
		}

		return $this->requestAdapter->buildRequestContext( $request, $userId, $caps );
	}

	/**
	 * Build a §4.3 error envelope + synthetic
	 * {@see self::META_HTTP_STATUS}.
	 *
	 * @param string               $code      §15.1 code.
	 * @param string               $message   Human-readable summary.
	 * @param int                  $status    §14.1 HTTP status.
	 * @param RequestContext       $ctx       Per-request meta.
	 * @param array<string, mixed> $details   Structured diagnostic context.
	 * @param bool                 $retryable §10.2 retryability hint.
	 *
	 * @return array<string, mixed>
	 */
	private function errorResponse(
		string $code,
		string $message,
		int $status,
		RequestContext $ctx,
		array $details = array(),
		bool $retryable = false
	): array {
		$envelope = ErrorEnvelope::fromContext( $code, $message, $status, $ctx, $details, $retryable );

		return $envelope->toArray() + array( self::META_HTTP_STATUS => $status );
	}

	/**
	 * Build a §4.4 validation error envelope + synthetic
	 * {@see self::META_HTTP_STATUS} pinned to 422.
	 *
	 * @param ValidationResult $result Accumulator carrying the
	 *                                 per-field errors.
	 * @param RequestContext   $ctx    Per-request meta.
	 *
	 * @return array<string, mixed>
	 */
	private function validationErrorResponse( ValidationResult $result, RequestContext $ctx ): array {
		$envelope = $result->toEnvelope( $ctx );

		return $envelope->toArray() + array( self::META_HTTP_STATUS => ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY );
	}

	/**
	 * Wrap a {@see SuccessEnvelope} into the response array shape
	 * the route registration layer consumes.
	 *
	 * @param SuccessEnvelope $envelope Source envelope.
	 * @param int             $status   §14.1 HTTP status to stamp.
	 *
	 * @return array<string, mixed>
	 */
	private function successResponse( SuccessEnvelope $envelope, int $status ): array {
		return $envelope->toArray() + array( self::META_HTTP_STATUS => $status );
	}

	/**
	 * Translate an arbitrary {@see Throwable} into a §4.3 envelope
	 * + synthetic {@see self::META_HTTP_STATUS}.
	 *
	 * Mapping order:
	 *
	 *   1. {@see ProductNotFoundException} (Application layer) → 404.
	 *   2. {@see ProviderException} → routed through
	 *      {@see ProviderExceptionMapper}.
	 *   3. {@see InvalidArgumentException} → 422 with
	 *      `details.exception = $e->getMessage()`.
	 *   4. Other {@see Throwable} → 500 `affilun_internal_error` with
	 *      the literal message `'Internal server error.'`.
	 *
	 * @param Throwable      $e   Exception to translate.
	 * @param RequestContext $ctx Per-request meta.
	 *
	 * @return array<string, mixed>
	 */
	private function mapException( Throwable $e, RequestContext $ctx ): array {
		if ( $e instanceof ProductNotFoundException ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_PRODUCT_NOT_FOUND,
				$e->getMessage() !== '' ? $e->getMessage() : 'Product not found.',
				ErrorEnvelope::STATUS_NOT_FOUND,
				$ctx
			);
		}

		if ( $e instanceof ProviderException ) {
			$envelope = $this->exceptionMapper->map( $e, $ctx );

			return $envelope->toArray() + array( self::META_HTTP_STATUS => $envelope->status() );
		}

		if ( $e instanceof InvalidArgumentException ) {
			return $this->errorResponse(
				ErrorEnvelope::CODE_VALIDATION_FAILED,
				'Validation failed.',
				ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY,
				$ctx,
				array( 'exception' => $e->getMessage() )
			);
		}

		// Log the actual exception class + message + first frame so
		// operators can correlate the opaque 500 envelope with the
		// underlying defect via WP_DEBUG_LOG. The response body still
		// carries only the literal `'Internal server error.'` per
		// the §10.1 "no internal detail leakage" posture.
		if ( function_exists( 'error_log' ) ) {
			$trace_line = '';
			$trace      = $e->getTrace();
			if ( isset( $trace[0] ) && is_array( $trace[0] ) ) {
				$file       = isset( $trace[0]['file'] ) ? (string) $trace[0]['file'] : '?';
				$line       = isset( $trace[0]['line'] ) ? (string) $trace[0]['line'] : '?';
				$trace_line = ' at ' . $file . ':' . $line;
			}
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Intentional diagnostic logging for unmapped 500s; see surrounding comment.
			error_log(
				sprintf(
					'AFFILUN ProductsController unmapped exception: %s: %s%s',
					get_class( $e ),
					$e->getMessage(),
					$trace_line
				)
			);
		}

		return $this->errorResponse(
			ErrorEnvelope::CODE_INTERNAL_ERROR,
			'Internal server error.',
			ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR,
			$ctx
		);
	}

	/**
	 * Build the §8.1 `meta.warnings` payload from a bundle's
	 * captured per-provider exceptions.
	 *
	 * Each captured exception is routed through
	 * {@see ProviderExceptionMapper::map()} so the §15.2 stable
	 * code surfaces verbatim onto each warning entry. The HTTP
	 * status the mapper picks is discarded — `meta.warnings`
	 * carries no status field.
	 *
	 * @param array<string, ProviderException> $providerWarnings Captured exceptions.
	 * @param RequestContext                   $ctx              Per-request meta.
	 *
	 * @return list<array<string, string>>
	 */
	private function buildWarnings( array $providerWarnings, RequestContext $ctx ): array {
		$warnings = array();
		foreach ( $providerWarnings as $token => $exception ) {
			$envelope   = $this->exceptionMapper->map( $exception, $ctx );
			$warnings[] = array(
				'provider' => $token,
				'code'     => $envelope->code(),
				'message'  => $envelope->message(),
			);
		}

		return $warnings;
	}

	/**
	 * Pull a string field from a body map, returning null when
	 * absent or non-string.
	 *
	 * @param array<mixed> $source Body map.
	 * @param string       $key    Field name.
	 */
	private function extractStringField( array $source, string $key ): ?string {
		if ( ! array_key_exists( $key, $source ) ) {
			return null;
		}
		$value = $source[ $key ];
		if ( ! is_string( $value ) ) {
			return null;
		}
		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			return null;
		}

		return $trimmed;
	}

	/**
	 * Validate an HTTP / HTTPS URL field; appends a single message
	 * on failure.
	 *
	 * @param ValidationResult $r     Running accumulator.
	 * @param string           $field Field name.
	 * @param mixed            $value Value to inspect.
	 */
	private function requireHttpUrl( ValidationResult $r, string $field, mixed $value ): ValidationResult {
		if ( ! is_string( $value ) || $value === '' ) {
			return $r->withField( $field, 'Must be an http(s) URL.' );
		}

		if ( preg_match( '#\Ahttps?://#i', $value ) !== 1 ) {
			return $r->withField( $field, 'Must be an http(s) URL.' );
		}

		if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) {
			return $r->withField( $field, 'Must be an http(s) URL.' );
		}

		return $r;
	}

	/**
	 * Validate the optional `snapshot.image_gallery` field.
	 *
	 * Returns the running accumulator alongside the normalized
	 * list of gallery URLs (empty list when the field is absent or
	 * fails validation; the controller relies on the validator's
	 * `isValid()` short-circuit before consuming the URLs).
	 *
	 * @param ValidationResult $r           Running accumulator.
	 * @param array<mixed>     $snapshotMap Snapshot body map.
	 *
	 * @return array{result: ValidationResult, urls: list<string>}
	 */
	private function validateGallery( ValidationResult $r, array $snapshotMap ): array {
		if ( ! array_key_exists( 'image_gallery', $snapshotMap ) ) {
			return array(
				'result' => $r,
				'urls'   => array(),
			);
		}

		$gallery = $snapshotMap['image_gallery'];
		if ( ! is_array( $gallery ) || ! array_is_list( $gallery ) ) {
			return array(
				'result' => $r->withField( 'image_gallery', 'Must be a list.' ),
				'urls'   => array(),
			);
		}

		$urls   = array();
		$result = $r;
		foreach ( $gallery as $entry ) {
			if ( ! is_string( $entry ) || preg_match( '#\Ahttps?://#i', $entry ) !== 1 ) {
				$result = $result->withField( 'image_gallery', 'Every entry must be an http(s) URL.' );
				continue;
			}
			$urls[] = $entry;
		}

		return array(
			'result' => $result,
			'urls'   => $urls,
		);
	}

	/**
	 * Validate the optional `snapshot.price` sub-object.
	 *
	 * Returns the running accumulator alongside the extracted
	 * price triple (`amount` / `currency` / `label`) when the
	 * field is present and well-shaped, or `null` when absent.
	 * Validation failures populate the accumulator and the
	 * returned price triple is `null`.
	 *
	 * @param ValidationResult $r           Running accumulator.
	 * @param array<mixed>     $snapshotMap Snapshot body map.
	 *
	 * @return array{result: ValidationResult, price: array{amount: string, currency: ?string, label: ?string}|null}
	 */
	private function validatePriceFields( ValidationResult $r, array $snapshotMap ): array {
		if ( ! array_key_exists( 'price', $snapshotMap ) ) {
			return array(
				'result' => $r,
				'price'  => null,
			);
		}

		$price = $snapshotMap['price'];
		if ( ! is_array( $price ) || ( $price !== array() && array_is_list( $price ) ) ) {
			return array(
				'result' => $r->withField( 'price', 'Must be an object.' ),
				'price'  => null,
			);
		}

		$amount   = array_key_exists( 'amount', $price ) ? $price['amount'] : null;
		$currency = array_key_exists( 'currency', $price ) ? $price['currency'] : null;
		$label    = array_key_exists( 'label', $price ) ? $price['label'] : null;

		$result = $r;
		$result = $this->requestValidator->requireString( $result, 'amount', $amount, 1, 32 );

		if ( $currency !== null ) {
			$result = $this->requestValidator->requireString( $result, 'currency', $currency, 1, 16 );
		}

		if ( $label !== null ) {
			$result = $this->requestValidator->requireString( $result, 'label', $label, 1, 64 );
		}

		if ( ! is_string( $amount ) ) {
			return array(
				'result' => $result,
				'price'  => null,
			);
		}

		return array(
			'result' => $result,
			'price'  => array(
				'amount'   => $amount,
				'currency' => is_string( $currency ) ? $currency : null,
				'label'    => is_string( $label ) ? $label : null,
			),
		);
	}
}
