<?php
/**
 * Link-attribute builder.
 *
 * Pure-function builder that translates the §11 inputs
 * (`ProductSnapshot`, `AffiliateLinkResult`, `RenderContext`,
 * `ButtonConfigInput`) into a §10.7
 * {@see \Affilun\Application\Render\ViewModel\Parts\ButtonView}.
 *
 * The mapping follows `specifications/rendering-and-template-spec.md`
 * §10.7 (button attributes), §11.1 (LinkAttributeBuilder
 * responsibilities), §11.2 (rel policy), and §11.3 (target policy):
 *
 *   - URL selection: prefer the affiliate-tracked URL when the
 *     caller opted in via `prefersTrackedUrl` and the
 *     AffiliateLinkResult reports tracking was applied; otherwise
 *     fall back to the snapshot's detail URL. A non-`http(s)://`
 *     result raises {@see InvalidArgumentException} so the
 *     ViewModel factory can downgrade to a FallbackCardViewModel
 *     per §13.3.
 *
 *   - Target selection follows the §11.3 priority chain: AMP and
 *     feed surfaces never stamp `target` (the runtime / reader
 *     handles navigation), an explicit `RenderContext::linkTarget`
 *     overrides the affiliate hint, the affiliate hint comes
 *     next, and the frontend default is `_blank`.
 *
 *   - Rel-token merging follows §11.2: `sponsored` is always
 *     present (affiliate links are sponsored by definition),
 *     `nofollow` is always present, and `_blank` adds
 *     `noopener noreferrer`. The `RenderContext::linkRelPolicy`
 *     can override the policy: `'minimal'` keeps only
 *     `nofollow sponsored`, `'strict'` always includes all four,
 *     and unknown policy strings raise.
 *
 *   - Class composition combines `affilun-button` and
 *     `affilun-button-{provider}` with the optional preset class
 *     string, deduplicating tokens in stable order.
 *
 *   - Aria-label localization renders the JP template
 *     "{title} を {providerLabel} で見る" for `ja-*` locales (and
 *     the unspecified-locale default), and the English template
 *     "View {title} on {providerLabel}" for any non-`ja-*`
 *     locale.
 *
 * Instances carry no state and the {@see self::build()} method is
 * pure: same input, same output, no I/O, no globals.
 *
 * The companion {@see ButtonConfigInput} value object lives in a
 * sibling file so it is easy to extend without churning this
 * file's docblock; the builder owns the closed set of `rel`
 * policies as `REL_POLICY_*` constants and exposes the closed set
 * via {@see self::validRelPolicies()}.
 *
 * @package Affilun\Application\Render\Builder
 */

declare(strict_types=1);

namespace Affilun\Application\Render\Builder;

use Affilun\Application\Render\ViewModel\Parts\ButtonView;
use Affilun\Domain\Affiliate\AffiliateLinkResult;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Render\RenderContext;
use InvalidArgumentException;

/**
 * Pure §11 link-attribute builder.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §10.7 / §11 and the §13.3
 * graceful-degradation contract.
 */
final class LinkAttributeBuilder {

	/**
	 * §11.2 default rel policy: standard merge of affiliate
	 * tokens plus `sponsored` / `nofollow` plus `noopener
	 * noreferrer` when `target="_blank"`.
	 */
	public const REL_POLICY_DEFAULT = 'default';

	/**
	 * §11.2 minimal rel policy: only `nofollow sponsored` is
	 * emitted regardless of target.
	 */
	public const REL_POLICY_MINIMAL = 'minimal';

	/**
	 * §11.2 strict rel policy: always emit
	 * `nofollow sponsored noopener noreferrer` regardless of
	 * target.
	 */
	public const REL_POLICY_STRICT = 'strict';

	/**
	 * Closed set of accepted §11.2 rel-policy tokens, in
	 * declaration order.
	 *
	 * @var list<string>
	 */
	private const VALID_REL_POLICIES = array(
		self::REL_POLICY_DEFAULT,
		self::REL_POLICY_MINIMAL,
		self::REL_POLICY_STRICT,
	);

	/**
	 * §11.2 canonical token order for the merged `rel` value.
	 *
	 * @var list<string>
	 */
	private const REL_CANONICAL_ORDER = array(
		'nofollow',
		'sponsored',
		'noopener',
		'noreferrer',
	);

	/**
	 * Closed set of accepted §11.2 rel-policy tokens.
	 *
	 * @return list<string>
	 */
	public static function validRelPolicies(): array {
		return self::VALID_REL_POLICIES;
	}

	/**
	 * Build a §10.7 {@see ButtonView} from the supplied inputs.
	 *
	 * @param ProductSnapshot     $snapshot  Domain product snapshot.
	 * @param AffiliateLinkResult $affiliate Affiliate-link builder output.
	 * @param RenderContext       $context   Active render context.
	 * @param ButtonConfigInput   $config    Button configuration input.
	 *
	 * @throws InvalidArgumentException When the selected URL is
	 *                                  not `http(s)://`, when the
	 *                                  context's rel policy is
	 *                                  unknown, or when any
	 *                                  ButtonView field violates
	 *                                  its own constructor
	 *                                  contract.
	 */
	public function build(
		ProductSnapshot $snapshot,
		AffiliateLinkResult $affiliate,
		RenderContext $context,
		ButtonConfigInput $config
	): ButtonView {
		$url       = $this->selectUrl( $snapshot, $affiliate, $config );
		$target    = $this->selectTarget( $affiliate, $context );
		$rel       = $this->mergeRel( $affiliate, $context, $target );
		$className = $this->composeClassName( $snapshot->provider(), $config->presetClassName() );
		$ariaLabel = $this->composeAriaLabel( $snapshot->title(), $config->providerLabel(), $context->locale() );

		return new ButtonView(
			$snapshot->provider(),
			$config->label(),
			$url,
			$target,
			$rel,
			$className,
			$ariaLabel
		);
	}

	/**
	 * Select the §10.7 button URL per the §11 selection rule.
	 *
	 * @param ProductSnapshot     $snapshot  Domain product snapshot.
	 * @param AffiliateLinkResult $affiliate Affiliate-link builder output.
	 * @param ButtonConfigInput   $config    Button configuration input.
	 *
	 * @throws InvalidArgumentException When the chosen URL is not
	 *                                  `http(s)://`.
	 */
	private function selectUrl(
		ProductSnapshot $snapshot,
		AffiliateLinkResult $affiliate,
		ButtonConfigInput $config
	): string {
		$candidate = ( $config->prefersTrackedUrl() && $affiliate->trackingApplied() )
			? $affiliate->url()
			: $snapshot->detailUrl();

		if ( ! self::isHttpUrl( $candidate ) ) {
			$message = sprintf(
				'LinkAttributeBuilder could not derive an http(s):// button URL; got "%s".',
				$candidate
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $candidate;
	}

	/**
	 * Resolve the §11.3 `target` attribute per the priority chain
	 * documented at the file level.
	 *
	 * @param AffiliateLinkResult $affiliate Affiliate-link builder output.
	 * @param RenderContext       $context   Active render context.
	 */
	private function selectTarget(
		AffiliateLinkResult $affiliate,
		RenderContext $context
	): ?string {
		if ( $context->isAmp() ) {
			return null;
		}

		if ( $context->isFeed() ) {
			return null;
		}

		$override = $context->linkTarget();
		if ( $override !== null ) {
			return $override;
		}

		$affiliateTarget = $affiliate->target();
		if ( $affiliateTarget !== null ) {
			return $affiliateTarget;
		}

		if ( $context->isFrontend() ) {
			return ButtonView::TARGET_BLANK;
		}

		return null;
	}

	/**
	 * Merge the §11.2 `rel` token list and apply any
	 * RenderContext-level policy override.
	 *
	 * @param AffiliateLinkResult $affiliate Affiliate-link builder output.
	 * @param RenderContext       $context   Active render context.
	 * @param string|null         $target    Resolved §11.3 `target` value.
	 *
	 * @throws InvalidArgumentException When the policy string is
	 *                                  not a member of
	 *                                  {@see self::VALID_REL_POLICIES}.
	 */
	private function mergeRel(
		AffiliateLinkResult $affiliate,
		RenderContext $context,
		?string $target
	): string {
		$policy = $context->linkRelPolicy() ?? self::REL_POLICY_DEFAULT;
		if ( ! in_array( $policy, self::VALID_REL_POLICIES, true ) ) {
			$message = sprintf(
				'LinkAttributeBuilder rel policy must be one of [%s]; got "%s".',
				implode( ', ', self::VALID_REL_POLICIES ),
				$policy
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $policy === self::REL_POLICY_MINIMAL ) {
			return self::sortCanonical( array( 'nofollow', 'sponsored' ) );
		}

		if ( $policy === self::REL_POLICY_STRICT ) {
			return self::sortCanonical(
				array( 'nofollow', 'sponsored', 'noopener', 'noreferrer' )
			);
		}

		$tokens = array();
		foreach ( $affiliate->relAttributes() as $token ) {
			$tokens[] = strtolower( $token );
		}

		$tokens[] = 'sponsored';
		$tokens[] = 'nofollow';

		if ( $target === ButtonView::TARGET_BLANK ) {
			$tokens[] = 'noopener';
			$tokens[] = 'noreferrer';
		}

		return self::sortCanonical( $tokens );
	}

	/**
	 * Compose the §10.7 `class` attribute string from the
	 * canonical `affilun-button` / `affilun-button-{provider}` tokens
	 * plus the caller-supplied preset class string.
	 *
	 * @param string      $provider Lowercase provider key.
	 * @param string|null $preset   Caller-supplied preset class string, or null.
	 */
	private function composeClassName( string $provider, ?string $preset ): string {
		$tokens = array( 'affilun-button', 'affilun-button-' . $provider );

		if ( $preset !== null ) {
			$parts = preg_split( '/\s+/', trim( $preset ) );
			if ( is_array( $parts ) ) {
				foreach ( $parts as $part ) {
					if ( $part !== '' ) {
						$tokens[] = $part;
					}
				}
			}
		}

		$seen   = array();
		$result = array();
		foreach ( $tokens as $token ) {
			if ( ! isset( $seen[ $token ] ) ) {
				$seen[ $token ] = true;
				$result[]       = $token;
			}
		}

		return implode( ' ', $result );
	}

	/**
	 * Compose the §10.7 `aria-label` per the locale-driven
	 * template selection.
	 *
	 * @param string      $title         Product title.
	 * @param string      $providerLabel Human-readable provider name.
	 * @param string|null $locale        BCP-47 locale tag, or null.
	 */
	private function composeAriaLabel(
		string $title,
		string $providerLabel,
		?string $locale
	): string {
		if ( self::isJapaneseLocale( $locale ) ) {
			return trim( sprintf( '%s を %s で見る', $title, $providerLabel ) );
		}

		return trim( sprintf( 'View %s on %s', $title, $providerLabel ) );
	}

	/**
	 * Sort a token list into the §11.2 canonical order, dropping
	 * duplicates case-insensitively.
	 *
	 * @param list<string> $tokens Raw token list.
	 */
	private static function sortCanonical( array $tokens ): string {
		$lookup = array();
		foreach ( $tokens as $token ) {
			$lookup[ strtolower( $token ) ] = true;
		}

		$ordered = array();
		foreach ( self::REL_CANONICAL_ORDER as $canonical ) {
			if ( isset( $lookup[ $canonical ] ) ) {
				$ordered[] = $canonical;
				unset( $lookup[ $canonical ] );
			}
		}

		// Preserve any non-canonical tokens (open-set per
		// AffiliateLinkResult §7.8) after the canonical four,
		// keeping their first-seen order.
		foreach ( array_keys( $lookup ) as $extra ) {
			$ordered[] = $extra;
		}

		return implode( ' ', $ordered );
	}

	/**
	 * Test whether a URL string starts with `http://` or
	 * `https://`.
	 *
	 * @param string $url Raw URL string.
	 */
	private static function isHttpUrl( string $url ): bool {
		$trimmed = trim( $url );
		if ( $trimmed === '' ) {
			return false;
		}

		return strpos( $trimmed, 'http://' ) === 0
			|| strpos( $trimmed, 'https://' ) === 0;
	}

	/**
	 * Test whether the supplied locale tag selects the Japanese
	 * aria-label template.
	 *
	 * Null and empty locale tags select Japanese (the plugin's
	 * default audience); explicit `ja` / `ja-*` tags also
	 * select Japanese; everything else selects English.
	 *
	 * @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;
	}
}
