<?php
/**
 * Product card view model factory.
 *
 * Phase-5 §9.1 ViewModel factory that translates the domain-layer
 * inputs (a {@see \Affilun\Domain\Product\ProductSnapshot},
 * a list of pre-built
 * {@see \Affilun\Domain\Affiliate\AffiliateLinkResult}
 * outputs, an
 * {@see \Affilun\Application\Render\EmbedConfigInput},
 * and a {@see \Affilun\Domain\Render\RenderContext})
 * into a §8.2 {@see ProductCardViewModel} when the snapshot can
 * support a full card, or into a §8.4 {@see FallbackCardViewModel}
 * when the §13.3 graceful-degradation rules require a downgrade.
 *
 * Mapping responsibilities follow `specifications/rendering-and-template-spec.md`
 * §9.1 (factory responsibility), §10 (per-region display rules),
 * and §13 (graceful degradation):
 *
 *   - The factory is a pure orchestrator: it composes the three
 *     §11 / §12 / §10.8 builders
 *     ({@see LinkAttributeBuilder},
 *     {@see ImagePresentationBuilder},
 *     {@see DisclaimerBuilder}) injected at construction time and
 *     does no I/O of its own. Tests can substitute mock builders
 *     to exercise individual edges of the §13.3 graceful-
 *     degradation contract without touching the production
 *     builders' wiring.
 *
 *   - The §13.3 fallback decision tree runs in a fixed order so
 *     the outcome is deterministic regardless of which signal is
 *     present:
 *
 *       1. Caller-driven `prefersFallback` (admin "preview
 *          fallback" toggle) → REASON_INSUFFICIENT_DATA.
 *       2. Snapshot is null (the product was deleted between the
 *          controller's lookup and the factory's invocation) →
 *          REASON_PRODUCT_DELETED.
 *       3. Snapshot title is empty after trimming (defensive guard
 *          even though the {@see ProductSnapshot} invariant
 *          forbids it) → REASON_INSUFFICIENT_DATA.
 *       4. Snapshot availability reports
 *          {@see AvailabilityInfo::STATUS_UNAVAILABLE} (delisted
 *          / region-restricted product) → REASON_PROVIDER_UNAVAILABLE.
 *          The §13.3 "ボタンなし: detail URL があれば最低限のリンク
 *          を生成可能" branch surfaces the title plus the first
 *          successfully-built button (when any) so the user can
 *          still navigate to the provider page.
 *       5. Otherwise the full §8.2 {@see ProductCardViewModel} is
 *          composed.
 *
 *   - Per-button §13.3 graceful degradation: any
 *     {@see InvalidArgumentException} raised from
 *     {@see LinkAttributeBuilder::build()} (the spec-canonical
 *     example is "neither URL is `http(s)://`") silently drops
 *     that single button rather than propagating the failure.
 *     The factory never logs at this boundary — §23.1 forbids
 *     surfacing scheme-validation errors to end users — and the
 *     remaining buttons render unaffected.
 *
 *   - Locale-aware label formatting follows the same `ja-*` /
 *     `en-*` / fallback split the sibling builders apply: the
 *     §10.5 price label is rendered with `'¥'` + integer thousands
 *     for `JPY` under a Japanese (or unspecified) locale and with
 *     `'$'` + two-digit decimals for `USD` under an English
 *     locale; any other currency / locale combination falls back
 *     to a generic `"<amount> <currency>"` string. The §10.6
 *     availability label falls back to a AFFILUN-canonical string
 *     keyed by locale when the provider supplies none.
 *
 *   - Provider-label resolution maps the lowercase provider keys
 *     known to Phase 5 onto their human-readable Japanese labels
 *     (`amazon` → "Amazon", `rakuten` → "楽天市場", `yahoo` →
 *     "Yahoo!ショッピング") and falls back to `ucfirst($provider)`
 *     for unknown keys so a future provider always renders some
 *     readable label without forcing a code change.
 *
 *   - Custom root class composition: when {@see EmbedConfigInput::customRootClass()}
 *     is non-null, it is appended after the canonical
 *     `affilun-card affilun-card-{template} affilun-provider-{provider}`
 *     tokens by reconstructing a fresh {@see CssClasses} via its
 *     primary constructor; the other class axes are reused
 *     verbatim from {@see CssClasses::defaultsForTemplate()}.
 *
 *   - The §10.6 availability mapping closes the §7.6 status set
 *     onto the §10.6 closed-set view tokens: `in_stock` →
 *     STATUS_IN_STOCK, `out_of_stock` / `unavailable` →
 *     STATUS_OUT_OF_STOCK, `limited` / `preorder` →
 *     STATUS_LIMITED, anything else → STATUS_UNKNOWN. The view
 *     is marked visible only when the snapshot status is one of
 *     `in_stock`, `out_of_stock`, or `limited`.
 *
 * Instances carry only the injected builders and the
 * {@see self::build()} method is otherwise pure: same input, same
 * output, no I/O, no globals.
 *
 * @package Affilun\Application\Render\ViewModel
 */

declare(strict_types=1);

namespace Affilun\Application\Render\ViewModel;

use Affilun\Application\Render\Builder\ButtonConfigInput;
use Affilun\Application\Render\Builder\DisclaimerBuilder;
use Affilun\Application\Render\Builder\ImagePresentationBuilder;
use Affilun\Application\Render\Builder\LinkAttributeBuilder;
use Affilun\Application\Render\EmbedConfigInput;
use Affilun\Application\Render\ViewModel\Parts\AvailabilityView;
use Affilun\Application\Render\ViewModel\Parts\ButtonView;
use Affilun\Application\Render\ViewModel\Parts\CssClasses;
use Affilun\Application\Render\ViewModel\Parts\PriceView;
use Affilun\Application\Render\ViewModel\Parts\ProductInfoView;
use Affilun\Domain\Affiliate\AffiliateLinkResult;
use Affilun\Domain\Pricing\AvailabilityInfo;
use Affilun\Domain\Pricing\PriceInfo;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Render\RenderContext;
use DateTimeImmutable;
use DateTimeZone;
use Throwable;
use InvalidArgumentException;

/**
 * Phase-5 §9.1 ViewModel factory with §13.3 graceful degradation.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §9.1 / §10 / §13 and the
 * fixed-order fallback decision tree.
 */
final class ProductCardViewModelFactory {

	/**
	 * §11 link-attribute builder.
	 */
	private LinkAttributeBuilder $linkAttributeBuilder;

	/**
	 * §10.8 disclaimer builder.
	 */
	private DisclaimerBuilder $disclaimerBuilder;

	/**
	 * §10.4 / §12 image-presentation builder.
	 */
	private ImagePresentationBuilder $imagePresentationBuilder;

	/**
	 * Phase-5 lowercase-provider-key → human-readable-label map.
	 *
	 * Keys cover the providers Phase 5 ships with; unknown keys
	 * fall back to {@see self::resolveProviderLabel()}'s
	 * `ucfirst()` branch so a future provider always renders some
	 * readable label without forcing a code change.
	 *
	 * @var array<string, string>
	 */
	private const PROVIDER_LABELS = array(
		'amazon'         => 'Amazon',
		'rakuten'        => '楽天市場',
		'yahoo_shopping' => 'Yahoo!ショッピング',
	);

	/**
	 * §6.4 template token used by the §13.3 fallback paths when
	 * composing {@see CssClasses}. Intentionally generic so the
	 * fallback rendering does not collide with the active card
	 * template's BEM tree.
	 */
	private const FALLBACK_TEMPLATE_TOKEN = 'fallback';

	/**
	 * §13.3 provider-key sentinel used when no snapshot is
	 * available (the snapshot is null and the lowercase provider
	 * key is therefore unknown).
	 */
	private const FALLBACK_UNKNOWN_PROVIDER = 'unknown';

	/**
	 * Construct a factory with explicit builders for every
	 * collaborator.
	 *
	 * @param LinkAttributeBuilder     $linkAttributeBuilder     §11 link-attribute builder.
	 * @param DisclaimerBuilder        $disclaimerBuilder        §10.8 disclaimer builder.
	 * @param ImagePresentationBuilder $imagePresentationBuilder §10.4 / §12 image-presentation builder.
	 */
	public function __construct(
		LinkAttributeBuilder $linkAttributeBuilder,
		DisclaimerBuilder $disclaimerBuilder,
		ImagePresentationBuilder $imagePresentationBuilder
	) {
		$this->linkAttributeBuilder     = $linkAttributeBuilder;
		$this->disclaimerBuilder        = $disclaimerBuilder;
		$this->imagePresentationBuilder = $imagePresentationBuilder;
	}

	/**
	 * Build the §8.2 / §8.4 view model the renderer will consume.
	 *
	 * @param ProductSnapshot|null      $snapshot         Domain product snapshot, or null when missing.
	 * @param EmbedConfigInput          $config           Per-instance display configuration.
	 * @param RenderContext             $context          Active render context.
	 * @param list<AffiliateLinkResult> $affiliateResults Pre-built affiliate-link results, one per button config.
	 *
	 * @throws InvalidArgumentException When `$affiliateResults` is
	 *                                  not a list of
	 *                                  {@see AffiliateLinkResult},
	 *                                  or when its length does not
	 *                                  match `$config->buttonConfigs()`.
	 */
	public function build(
		?ProductSnapshot $snapshot,
		EmbedConfigInput $config,
		RenderContext $context,
		array $affiliateResults
	): ProductCardViewModel|FallbackCardViewModel {
		$buttonConfigs = $config->buttonConfigs();
		self::assertAffiliateResults( $buttonConfigs, $affiliateResults );

		$fallbackClasses = CssClasses::defaultsForTemplate(
			self::FALLBACK_TEMPLATE_TOKEN,
			self::providerCssToken( $snapshot !== null ? $snapshot->provider() : self::FALLBACK_UNKNOWN_PROVIDER )
		);

		if ( $config->prefersFallback() ) {
			return FallbackCardViewModel::forReason(
				FallbackCardViewModel::REASON_INSUFFICIENT_DATA,
				$fallbackClasses,
				null,
				null,
				null
			);
		}

		if ( $snapshot === null ) {
			return FallbackCardViewModel::forReason(
				FallbackCardViewModel::REASON_PRODUCT_DELETED,
				$fallbackClasses,
				null,
				null,
				null
			);
		}

		if ( trim( $snapshot->title() ) === '' ) {
			return FallbackCardViewModel::forReason(
				FallbackCardViewModel::REASON_INSUFFICIENT_DATA,
				$fallbackClasses,
				null,
				null,
				null
			);
		}

		if ( $snapshot->availability()->status() === AvailabilityInfo::STATUS_UNAVAILABLE ) {
			$buttons       = $this->buildButtons( $snapshot, $context, $buttonConfigs, $affiliateResults );
			$primaryButton = $buttons === array() ? null : $buttons[0];

			return FallbackCardViewModel::forReason(
				FallbackCardViewModel::REASON_PROVIDER_UNAVAILABLE,
				$fallbackClasses,
				$snapshot->title(),
				$primaryButton,
				null
			);
		}

		$providerLabel = self::resolveProviderLabel( $snapshot->provider() );

		$productInfo = new ProductInfoView(
			1,
			$snapshot->provider(),
			$providerLabel,
			$snapshot->providerProductId()->value(),
			$snapshot->title(),
			$snapshot->brand(),
			$snapshot->description(),
			$snapshot->detailUrl(),
			$snapshot->affiliateUrl()
		);

		$images       = $this->imagePresentationBuilder->build( $snapshot, $context );
		$price        = $this->buildPrice( $snapshot->price(), $config, $context );
		$availability = $this->buildAvailability( $snapshot->availability(), $context );
		$buttons      = $this->buildButtons( $snapshot, $context, $buttonConfigs, $affiliateResults );
		$disclaimers  = $this->disclaimerBuilder->build( $snapshot, $snapshot->price(), $context );
		$classes      = $this->buildClasses( $context->template(), $snapshot->provider(), $config->customRootClass() );

		return ProductCardViewModel::create(
			$productInfo,
			$images,
			$price,
			$availability,
			$config->meta(),
			$classes,
			$context->template(),
			$buttons,
			array(),
			$disclaimers
		);
	}

	/**
	 * Validate the `$affiliateResults` argument shape against the
	 * `$buttonConfigs` list it must mirror positionally.
	 *
	 * @param list<ButtonConfigInput>  $buttonConfigs    Button configuration inputs.
	 * @param array<int|string, mixed> $affiliateResults Pre-built affiliate-link results.
	 *
	 * @throws InvalidArgumentException When the lengths differ or
	 *                                  any entry is not an
	 *                                  {@see AffiliateLinkResult}.
	 */
	private static function assertAffiliateResults(
		array $buttonConfigs,
		array $affiliateResults
	): void {
		if ( count( $affiliateResults ) !== count( $buttonConfigs ) ) {
			$message = sprintf(
				'ProductCardViewModelFactory expected %d affiliate result(s) to match buttonConfigs; got %d.',
				count( $buttonConfigs ),
				count( $affiliateResults )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$expected = 0;
		foreach ( $affiliateResults as $key => $value ) {
			if ( $key !== $expected ) {
				$message = 'ProductCardViewModelFactory affiliateResults must be a zero-based contiguous list.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( ! $value instanceof AffiliateLinkResult ) {
				$message = 'ProductCardViewModelFactory affiliateResults entries must be AffiliateLinkResult.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			++$expected;
		}
	}

	/**
	 * Compose the §10.7 buttons-region list, silently dropping any
	 * single button whose {@see LinkAttributeBuilder::build()} call
	 * raises {@see InvalidArgumentException}.
	 *
	 * @param ProductSnapshot           $snapshot         Domain product snapshot.
	 * @param RenderContext             $context          Active render context.
	 * @param list<ButtonConfigInput>   $buttonConfigs    Button configuration inputs.
	 * @param list<AffiliateLinkResult> $affiliateResults Pre-built affiliate-link results.
	 *
	 * @return list<ButtonView>
	 */
	private function buildButtons(
		ProductSnapshot $snapshot,
		RenderContext $context,
		array $buttonConfigs,
		array $affiliateResults
	): array {
		$buttons = array();
		foreach ( $buttonConfigs as $index => $buttonConfig ) {
			$affiliate = $affiliateResults[ $index ];
			try {
				$buttons[] = $this->linkAttributeBuilder->build(
					$snapshot,
					$affiliate,
					$context,
					$buttonConfig
				);
			} catch ( InvalidArgumentException $e ) {
				// §13.3 graceful degradation: silently skip the
				// failing button and continue. Logging is the
				// observability layer's concern, not this
				// factory's.
				unset( $e );
			}
		}

		return $buttons;
	}

	/**
	 * Compose the §10.5 price view per the locale-driven label
	 * formatting rules documented at the file level.
	 *
	 * @param PriceInfo|null   $priceInfo Snapshot price, or null.
	 * @param EmbedConfigInput $config    Per-instance display configuration.
	 * @param RenderContext    $context   Active render context.
	 */
	private function buildPrice(
		?PriceInfo $priceInfo,
		EmbedConfigInput $config,
		RenderContext $context
	): PriceView {
		if ( $priceInfo === null ) {
			return PriceView::hidden();
		}

		if ( ! $config->meta()->showPrice() ) {
			return PriceView::hidden();
		}

		$label    = self::formatPriceLabel( $priceInfo, $context->locale() );
		$observed = self::formatObservedAt( $priceInfo->observedAt() );
		$currency = $priceInfo->currency() ?? 'UNKNOWN';

		return new PriceView(
			true,
			$label,
			$priceInfo->amount(),
			$currency,
			$observed,
			$priceInfo->disclaimerRequired()
		);
	}

	/**
	 * Format the price observation timestamp for display.
	 *
	 * Persisted timestamps are stored in UTC, but the renderer is
	 * the presentation boundary so we localize here to the WordPress
	 * site timezone (`wp_timezone()`) when available; otherwise we
	 * fall back to the UTC string `PriceInfo::formatDateTime()`
	 * produces. The output stays as the spec§10.5 `'Y-m-d H:i:s'`
	 * shape so existing template tests keep matching the structure.
	 *
	 * @param \DateTimeInterface|null $observedAt UTC timestamp.
	 */
	private static function formatObservedAt( ?\DateTimeInterface $observedAt ): ?string {
		if ( $observedAt === null ) {
			return null;
		}

		$tz = self::resolveDisplayTimezone();

		$source = $observedAt instanceof DateTimeImmutable
			? $observedAt
			: DateTimeImmutable::createFromInterface( $observedAt );

		return $source->setTimezone( $tz )->format( 'Y-m-d H:i:s' );
	}

	/**
	 * Resolve the timezone in which observation timestamps should be
	 * displayed. Prefers WordPress's site timezone (`wp_timezone()`),
	 * falls back to UTC when the helper is unavailable or returns an
	 * unusable value.
	 */
	private static function resolveDisplayTimezone(): DateTimeZone {
		if ( function_exists( 'wp_timezone' ) ) {
			try {
				$tz = wp_timezone();
				if ( $tz instanceof DateTimeZone ) {
					return $tz;
				}
			} catch ( Throwable $e ) {
				unset( $e );
			}
		}

		return new DateTimeZone( 'UTC' );
	}

	/**
	 * Compose the §10.6 availability view from the snapshot's
	 * structured status.
	 *
	 * @param AvailabilityInfo $info    Snapshot availability info.
	 * @param RenderContext    $context Active render context.
	 */
	private function buildAvailability(
		AvailabilityInfo $info,
		RenderContext $context
	): AvailabilityView {
		$mapped  = self::mapAvailabilityStatus( $info->status() );
		$visible = self::availabilityVisibleStatuses( $info->status() );
		$label   = $info->label() ?? self::resolveAvailabilityLabel( $mapped, $context->locale() );

		return new AvailabilityView( $visible, $mapped, $label );
	}

	/**
	 * Compose the §8.2 `classes` slot, optionally appending the
	 * caller-supplied custom root-class string after the
	 * canonical `affilun-card affilun-card-{template} affilun-provider-{provider}`
	 * tokens.
	 *
	 * @param string      $template        §6.4 template key.
	 * @param string      $provider        Lowercase provider key.
	 * @param string|null $customRootClass Caller-supplied root class string, or null.
	 */
	private function buildClasses(
		string $template,
		string $provider,
		?string $customRootClass
	): CssClasses {
		$defaults = CssClasses::defaultsForTemplate( $template, self::providerCssToken( $provider ) );

		if ( $customRootClass === null ) {
			return $defaults;
		}

		return new CssClasses(
			$defaults->root() . ' ' . $customRootClass,
			$defaults->image(),
			$defaults->body(),
			$defaults->title(),
			$defaults->price(),
			$defaults->buttons()
		);
	}

	/**
	 * Resolve the §10.7 provider label per the
	 * {@see self::PROVIDER_LABELS} map, falling back to
	 * `ucfirst($provider)` for unknown keys.
	 *
	 * @param string $provider Lowercase provider key.
	 */
	private static function resolveProviderLabel( string $provider ): string {
		if ( isset( self::PROVIDER_LABELS[ $provider ] ) ) {
			return self::PROVIDER_LABELS[ $provider ];
		}

		return ucfirst( $provider );
	}

	/**
	 * Translate a canonical provider key (the domain-layer
	 * `^[a-z][a-z0-9_]*$` form used across persistence / REST /
	 * audit, e.g. `yahoo_shopping`) into the hyphenated form
	 * {@see CssClasses::defaultsForTemplate()} accepts as a
	 * `^[a-z0-9-]+$` token (e.g. `yahoo-shopping`).
	 *
	 * The CssClasses VO intentionally rejects `_` so a typo
	 * (uppercase, embedded space, stray underscore) cannot reach
	 * the rendered class attribute. The persistence layer however
	 * stores `yahoo_shopping` as the canonical key (per
	 * `Domain\Provider\ProviderInterface::key()` docblock and
	 * `database-schema.md` §4.3 enum). This helper is the single
	 * boundary that translates between the two forms; every
	 * caller into `CssClasses::defaultsForTemplate()` from this
	 * factory MUST funnel the provider key through here.
	 *
	 * @param string $provider Canonical provider key.
	 */
	private static function providerCssToken( string $provider ): string {
		return str_replace( '_', '-', $provider );
	}

	/**
	 * Format the §10.5 numeric price label per the locale + currency
	 * combination documented at the file level.
	 *
	 * @param PriceInfo   $priceInfo Snapshot price.
	 * @param string|null $locale    BCP-47 locale tag, or null.
	 */
	private static function formatPriceLabel( PriceInfo $priceInfo, ?string $locale ): string {
		$amount   = (float) $priceInfo->amount();
		$currency = $priceInfo->currency();

		if ( self::isJapaneseLocale( $locale ) && $currency === 'JPY' ) {
			return '¥' . number_format( $amount );
		}

		if ( self::isEnglishLocale( $locale ) && $currency === 'USD' ) {
			return '$' . number_format( $amount, 2 );
		}

		$fallback = $priceInfo->amount() . ' ' . ( $currency ?? '' );

		return trim( $fallback );
	}

	/**
	 * Map the §7.6 {@see AvailabilityInfo} status set onto the
	 * §10.6 {@see AvailabilityView} closed set.
	 *
	 * @param string $snapshotStatus One of {@see AvailabilityInfo::STATUS_*}.
	 */
	private static function mapAvailabilityStatus( string $snapshotStatus ): string {
		switch ( $snapshotStatus ) {
			case AvailabilityInfo::STATUS_IN_STOCK:
				return AvailabilityView::STATUS_IN_STOCK;
			case AvailabilityInfo::STATUS_OUT_OF_STOCK:
			case AvailabilityInfo::STATUS_UNAVAILABLE:
				return AvailabilityView::STATUS_OUT_OF_STOCK;
			case AvailabilityInfo::STATUS_LIMITED:
			case AvailabilityInfo::STATUS_PREORDER:
				return AvailabilityView::STATUS_LIMITED;
			default:
				return AvailabilityView::STATUS_UNKNOWN;
		}
	}

	/**
	 * Report whether the §10.6 availability region should render
	 * for the supplied snapshot status.
	 *
	 * @param string $snapshotStatus One of {@see AvailabilityInfo::STATUS_*}.
	 */
	private static function availabilityVisibleStatuses( string $snapshotStatus ): bool {
		return $snapshotStatus === AvailabilityInfo::STATUS_IN_STOCK
			|| $snapshotStatus === AvailabilityInfo::STATUS_OUT_OF_STOCK
			|| $snapshotStatus === AvailabilityInfo::STATUS_LIMITED;
	}

	/**
	 * Resolve a AFFILUN-canonical §10.6 availability label keyed by
	 * the mapped view status and the active locale.
	 *
	 * @param string      $viewStatus Mapped {@see AvailabilityView::STATUS_*} token.
	 * @param string|null $locale     BCP-47 locale tag, or null.
	 */
	private static function resolveAvailabilityLabel( string $viewStatus, ?string $locale ): ?string {
		$ja = self::isJapaneseLocale( $locale );

		switch ( $viewStatus ) {
			case AvailabilityView::STATUS_IN_STOCK:
				return $ja ? '在庫あり' : 'In stock';
			case AvailabilityView::STATUS_OUT_OF_STOCK:
				return $ja ? '在庫なし' : 'Out of stock';
			case AvailabilityView::STATUS_LIMITED:
				return $ja ? '残りわずか' : 'Limited stock';
			default:
				return null;
		}
	}

	/**
	 * Test whether the supplied locale tag selects the Japanese
	 * formatting branch.
	 *
	 * Null and empty locale tags select Japanese (the plugin's
	 * default audience); explicit `ja` / `ja-*` / `ja_*` tags also
	 * select Japanese; everything else returns false.
	 *
	 * @param string|null $locale BCP-47 locale tag, or null.
	 */
	private static function isJapaneseLocale( ?string $locale ): bool {
		if ( $locale === null ) {
			return true;
		}

		$normalized = strtolower( trim( $locale ) );
		if ( $normalized === '' ) {
			return true;
		}

		if ( $normalized === 'ja' ) {
			return true;
		}

		return strpos( $normalized, 'ja-' ) === 0
			|| strpos( $normalized, 'ja_' ) === 0;
	}

	/**
	 * Test whether the supplied locale tag selects the English
	 * formatting branch.
	 *
	 * Only explicit `en` / `en-*` / `en_*` tags return true; null
	 * and other locales return false so the generic fallback
	 * applies.
	 *
	 * @param string|null $locale BCP-47 locale tag, or null.
	 */
	private static function isEnglishLocale( ?string $locale ): bool {
		if ( $locale === null ) {
			return false;
		}

		$normalized = strtolower( trim( $locale ) );
		if ( $normalized === '' ) {
			return false;
		}

		if ( $normalized === 'en' ) {
			return true;
		}

		return strpos( $normalized, 'en-' ) === 0
			|| strpos( $normalized, 'en_' ) === 0;
	}
}
