<?php
/**
 * Amazon Creators API affiliate link builder.
 *
 * Phase B (KI-019, ADR-0022) collaborator that mints the
 * affiliate-tracked URL the rendering layer surfaces on every
 * Amazon product card / button. The Creators API is unusual
 * among the providers this codebase integrates because every
 * `detailPageURL` value the API returns is **already tagged**
 * with the requesting partner tag (Amazon stamps the
 * `tag=<partnerTag>` query parameter on the server side, plus
 * `linkCode=ogi` and other tracking parameters). For most
 * snapshots the right behavior is therefore "use the upstream
 * URL verbatim" rather than "build a new URL from scratch" the
 * way the legacy PA-API 5.0 builder did.
 *
 * Why this class exists despite the upstream pre-tagging:
 *
 *   - **Defense in depth.** A snapshot can be persisted at one
 *     point in time (with one partner tag) and rendered later
 *     under a different partner tag (e.g. an operator rotates
 *     credentials between two Associate accounts on the same
 *     site). Re-stamping the partner tag at link-build time
 *     guarantees the rendered link always carries the
 *     credential-of-record's tag, regardless of what was
 *     baked into the snapshot at fetch time.
 *
 *   - **Snapshot fallback.** A snapshot whose `affiliateUrl`
 *     field is null (constructed from a non-Creators-API
 *     pathway, or from an older row that predates the field)
 *     still needs an affiliate-shaped URL. We synthesize one
 *     from `detailUrl` + the credentials' `partnerTag` here.
 *
 *   - **Test reproducibility.** Hard-coding the tag-stamping
 *     logic in this class lets the test suite assert against
 *     deterministic URL output without relying on Amazon's
 *     server-side tagging behavior.
 *
 * Algorithm:
 *
 *   1. Pick the source URL: prefer `snapshot->affiliateUrl()`
 *      (which is the pre-tagged `detailPageURL` Amazon
 *      returned), fall back to `snapshot->detailUrl()` when
 *      affiliate URL is null.
 *
 *   2. Validate the source URL is an absolute http(s) URL. A
 *      malformed source URL raises
 *      {@see ProviderRequestException} so the calling provider
 *      can surface it through the standard §10.2 error path
 *      rather than the rendering layer crashing on a bad href.
 *
 *   3. Re-stamp the `tag` query parameter with
 *      `$credentials->partnerTag()`. If the URL already carries
 *      a `tag` parameter (the typical case for upstream
 *      `detailPageURL`), it is overwritten; if not, the
 *      parameter is appended. All other query parameters
 *      (notably `linkCode`, `language`, `psc`, `th`) are
 *      preserved verbatim so Amazon's link-attribution surface
 *      keeps working.
 *
 *   4. Construct an {@see AffiliateLinkResult} with the
 *      re-stamped URL plus the §7.8 default `rel` set
 *      ({@see self::DEFAULT_REL_ATTRIBUTES}). The `target` /
 *      `nofollow` / `sponsored` / `noopener` policies live at
 *      the Phase 5 rendering layer (LinkAttributeBuilder), so
 *      this class does NOT decide them.
 *
 * What this class deliberately does NOT do:
 *
 *   - It does not strip pre-existing tracking parameters
 *     Amazon adds (`linkCode=ogi`, `language=en_US`, etc).
 *     Those are part of Amazon's attribution surface and
 *     stripping them would lose attribution data.
 *
 *   - It does not encode the URL or escape its components.
 *     The rendering layer (`esc_url`) handles output escaping
 *     at the template boundary; this class returns the raw URL.
 *
 *   - It does not override the `target` / `rel` link
 *     attributes in any provider-specific way. The cross
 *     -provider `LinkAttributeBuilder` is the single seam
 *     responsible for `target="_blank"` / `rel="sponsored
 *     nofollow noopener noreferrer"` policy enforcement.
 *
 *   - It does not consult the
 *     {@see \CommerceLinkBuilder\Domain\Affiliate\AffiliateContext}
 *     beyond the partner tag. Amazon's Creators API does not
 *     surface the same multi-axis tracking context (sid / pid /
 *     campaign / subId / source / medium) that 楽天 / Yahoo!
 *     do; partner tag is the entire contract.
 *
 * @package CommerceLinkBuilder\Infrastructure\Provider\Amazon\CreatorsApi
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Provider\Amazon\CreatorsApi;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped,Squiz.Commenting.FunctionComment.MissingParamTag -- This service builds exception messages and has extensive file-level method documentation; it renders no HTML.

use CommerceLinkBuilder\Domain\Affiliate\AffiliateContext;
use CommerceLinkBuilder\Domain\Affiliate\AffiliateLinkResult;
use CommerceLinkBuilder\Domain\Product\ProductSnapshot;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderRequestException;
use CommerceLinkBuilder\Domain\Provider\ProviderProductId;

/**
 * Mint Amazon Creators API affiliate-tracked link results.
 *
 * See the file-level docblock for the "re-stamp the partner
 * tag even when the upstream URL is pre-tagged" rationale, the
 * `detailPageURL` -> `detailUrl` source-URL fallback, and the
 * deliberate non-responsibilities (no escape, no rel/target
 * policy, no AffiliateContext consumption beyond partner tag).
 */
final class AmazonCreatorsApiAffiliateLinkBuilder {

	/**
	 * §4 stable provider key stamped on every exception this
	 * class raises.
	 *
	 * Mirrors {@see AmazonCreatorsApiProvider::PROVIDER_KEY} but
	 * is duplicated here as a class constant so this builder can
	 * be loaded and exercised in tests without forcing the full
	 * provider class graph to be autoloaded first.
	 */
	public const PROVIDER_KEY = 'amazon';

	/**
	 * Query parameter name Amazon Associates uses to attribute
	 * a click-through to a partner tag.
	 *
	 * Hard-coded as `tag` because that is the canonical Amazon
	 * Associates contract; it does not vary by marketplace or
	 * by Creators API credential version.
	 */
	public const PARTNER_TAG_PARAMETER = 'tag';

	/**
	 * `rel` attribute tokens stamped on every link result this
	 * builder returns.
	 *
	 * Per `specifications/security-and-compliance.md` §13.1 +
	 * `commerce-link-builder-plugin-design.md` §11.2 the Amazon
	 * affiliate link MUST carry `sponsored` (FTC / Associates
	 * disclosure) and `nofollow` (search-engine attribution).
	 * `noopener` is stamped defensively so the rendering layer's
	 * `target="_blank"` policy never opens a tab vulnerable to
	 * `window.opener` exploits even if a future template forgets
	 * to add it explicitly. `noreferrer` is intentionally NOT
	 * included here because Amazon's Associates program
	 * sometimes uses Referer for attribution; the rendering
	 * layer's `LinkAttributeBuilder` may still add it per the
	 * §13.5 site-wide default.
	 *
	 * @var list<string>
	 */
	public const DEFAULT_REL_ATTRIBUTES = array(
		'sponsored',
		'nofollow',
		'noopener',
	);

	/**
	 * Long-lived credentials this builder re-stamps the partner
	 * tag from.
	 *
	 * Held by reference so a credential rotation that produces a
	 * fresh {@see AmazonCreatorsApiCredentials} instance only
	 * affects link results built after the rotation; in-flight
	 * results remain consistent with the credential snapshot
	 * they were built against. The credentials VO is itself
	 * immutable, so holding it by reference is safe.
	 */
	private AmazonCreatorsApiCredentials $credentials;

	/**
	 * Construct the affiliate link builder with explicit
	 * credential injection.
	 *
	 * @param AmazonCreatorsApiCredentials $credentials Long-lived
	 *                                                  credentials
	 *                                                  whose
	 *                                                  `partnerTag`
	 *                                                  is stamped
	 *                                                  on every
	 *                                                  built URL.
	 */
	public function __construct( AmazonCreatorsApiCredentials $credentials ) {
		$this->credentials = $credentials;
	}

	/**
	 * Build an {@see AffiliateLinkResult} for the supplied
	 * snapshot, re-stamping the credentials' partner tag onto
	 * the source URL.
	 *
	 * The `$context` parameter is accepted to satisfy the
	 * cross-provider
	 * {@see \CommerceLinkBuilder\Domain\Provider\ProviderInterface::buildAffiliateLink()}
	 * contract but is intentionally not consumed by this
	 * builder: Amazon's Creators API does not expose the
	 * multi-axis tracking surface (sid / pid / campaign /
	 * subId / source / medium) the 楽天 / Yahoo! providers map
	 * through {@see AffiliateContext}. Future Amazon-specific
	 * `subId` support could be added here without changing the
	 * cross-provider call shape.
	 *
	 * @param ProductSnapshot  $snapshot Snapshot the affiliate
	 *                                   link is being built for.
	 *                                   Source URL is taken from
	 *                                   `affiliateUrl` when
	 *                                   non-null, else
	 *                                   `detailUrl`.
	 * @param AffiliateContext $context  Cross-provider tracking
	 *                                   context. Currently
	 *                                   unused by this builder
	 *                                   (see the rationale above).
	 *
	 * @throws ProviderRequestException When neither `affiliateUrl`
	 *                                  nor `detailUrl` is a
	 *                                  usable absolute http(s)
	 *                                  URL, or when the URL
	 *                                  cannot be parsed.
	 */
	public function build(
		ProductSnapshot $snapshot,
		AffiliateContext $context
	): AffiliateLinkResult {
		// Acknowledge the parameter to keep static analysis quiet
		// without consuming it; see the docblock above for why
		// this builder does not branch on the AffiliateContext.
		unset( $context );

		$source = $this->resolveSourceUrl( $snapshot );

		$tagged = $this->stampPartnerTag( $source );

		return new AffiliateLinkResult(
			$tagged,
			self::DEFAULT_REL_ATTRIBUTES,
			null,
			true,
			array()
		);
	}

	/**
	 * Build a link-only Amazon Associates URL from an ASIN.
	 *
	 * This fallback path deliberately does not use Creators API
	 * product content. It only constructs the Amazon detail-page
	 * URL from the operator-supplied ASIN and stamps the assigned
	 * partner tag as a Special Link-compatible query parameter.
	 * Callers use it when catalog access is unavailable but a
	 * direct product-page link is still permitted.
	 *
	 * @param ProviderProductId $productId Amazon ASIN identifier.
	 *
	 * @throws ProviderRequestException When the id is for another
	 *                                  provider or the ASIN shape is
	 *                                  unsafe for URL construction.
	 */
	public function buildForProductId( ProviderProductId $productId ): AffiliateLinkResult {
		$tagged = $this->stampPartnerTag( $this->detailUrlForProductId( $productId ) );

		return new AffiliateLinkResult(
			$tagged,
			self::DEFAULT_REL_ATTRIBUTES,
			null,
			true,
			array( 'amazon_link_only_fallback' )
		);
	}

	/**
	 * Build the untracked Amazon detail-page URL for an ASIN.
	 *
	 * @param ProviderProductId $productId Amazon ASIN identifier.
	 *
	 * @throws ProviderRequestException When the id is for another
	 *                                  provider or the ASIN shape is
	 *                                  unsafe for URL construction.
	 */
	public function detailUrlForProductId( ProviderProductId $productId ): string {
		$this->assertAmazonAsin( $productId );

		return sprintf(
			'https://%s/dp/%s',
			$this->credentials->marketplace(),
			rawurlencode( strtoupper( $productId->value() ) )
		);
	}

	/**
	 * Pick the source URL to re-stamp the partner tag onto.
	 *
	 * Prefers `affiliateUrl` (which the response normalizer
	 * populates with Amazon's pre-tagged `detailPageURL`) and
	 * falls back to `detailUrl` (the bare product page URL)
	 * when `affiliateUrl` is null. Both cases must yield an
	 * absolute http(s) URL or this method raises
	 * {@see ProviderRequestException}.
	 *
	 * @throws ProviderRequestException When neither field
	 *                                  yields a usable URL.
	 */
	private function resolveSourceUrl( ProductSnapshot $snapshot ): string {
		$affiliate = $snapshot->affiliateUrl();
		if ( is_string( $affiliate ) && $affiliate !== '' && self::isAbsoluteHttpUrl( $affiliate ) ) {
			return $affiliate;
		}

		$detail = $snapshot->detailUrl();
		if ( $detail !== '' && self::isAbsoluteHttpUrl( $detail ) ) {
			return $detail;
		}

		throw new ProviderRequestException(
			self::PROVIDER_KEY,
			'Amazon Creators API affiliate link builder could not resolve a usable source URL '
				. '(neither affiliateUrl nor detailUrl is an absolute http(s) URL).',
			array(
				'reason'                 => 'no_usable_source_url',
				'snapshot_provider'      => $snapshot->provider(),
				'snapshot_provider_id'   => $snapshot->providerProductId()->value(),
				'snapshot_affiliate_url' => $affiliate,
				'snapshot_detail_url'    => $detail,
			)
		);
	}

	/**
	 * Validate that the supplied id is an Amazon ASIN that can be
	 * safely embedded into a `/dp/{asin}` path.
	 *
	 * @param ProviderProductId $productId Candidate Amazon ASIN.
	 *
	 * @throws ProviderRequestException When the id does not belong
	 *                                  to Amazon or the ASIN shape
	 *                                  is invalid.
	 */
	private function assertAmazonAsin( ProviderProductId $productId ): void {
		if ( ! $productId->belongsTo( self::PROVIDER_KEY ) ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				sprintf(
					'Amazon link-only fallback expected provider "%s"; got "%s".',
					self::PROVIDER_KEY,
					$productId->provider()
				),
				array(
					'reason'            => 'provider_mismatch',
					'expected_provider' => self::PROVIDER_KEY,
					'actual_provider'   => $productId->provider(),
				)
			);
		}

		if ( preg_match( '/^[A-Za-z0-9]{10}$/', $productId->value() ) !== 1 ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				'Amazon link-only fallback requires a 10-character alphanumeric ASIN.',
				array(
					'reason'              => 'invalid_asin',
					'provider_product_id' => $productId->value(),
				)
			);
		}
	}

	/**
	 * Re-stamp the credentials' partner tag onto the supplied
	 * URL, preserving every other query parameter.
	 *
	 * @throws ProviderRequestException When the URL cannot be
	 *                                  parsed by {@see wp_parse_url()}.
	 */
	private function stampPartnerTag( string $url ): string {
		$parts = wp_parse_url( $url );
		if ( $parts === false || ! is_array( $parts ) || ! isset( $parts['scheme'], $parts['host'] ) ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				sprintf(
					'Amazon Creators API affiliate link builder could not parse source URL "%s".',
					$url
				),
				array(
					'reason'     => 'unparseable_source_url',
					'source_url' => $url,
				)
			);
		}

		$query = array();
		if ( isset( $parts['query'] ) && is_string( $parts['query'] ) && $parts['query'] !== '' ) {
			parse_str( $parts['query'], $query );
		}

		// Overwrite (or append) the partner tag verbatim. parse_str
		// preserves every other query parameter, so Amazon's
		// `linkCode=ogi`, `psc=1`, `th=1`, `language=en_US`, etc.
		// flow through unchanged.
		$query[ self::PARTNER_TAG_PARAMETER ] = $this->credentials->partnerTag();

		$rebuilt = $parts['scheme'] . '://';
		if ( isset( $parts['user'] ) && is_string( $parts['user'] ) && $parts['user'] !== '' ) {
			$rebuilt .= $parts['user'];
			if ( isset( $parts['pass'] ) && is_string( $parts['pass'] ) ) {
				$rebuilt .= ':' . $parts['pass'];
			}
			$rebuilt .= '@';
		}
		$rebuilt .= $parts['host'];
		if ( isset( $parts['port'] ) && is_int( $parts['port'] ) ) {
			$rebuilt .= ':' . $parts['port'];
		}
		if ( isset( $parts['path'] ) && is_string( $parts['path'] ) && $parts['path'] !== '' ) {
			$rebuilt .= $parts['path'];
		}

		$encoded = http_build_query( $query, '', '&' );
		if ( $encoded !== '' ) {
			$rebuilt .= '?' . $encoded;
		}

		if ( isset( $parts['fragment'] ) && is_string( $parts['fragment'] ) && $parts['fragment'] !== '' ) {
			$rebuilt .= '#' . $parts['fragment'];
		}

		return $rebuilt;
	}

	/**
	 * Whether the supplied URL is an absolute http(s) URL with
	 * a non-empty host.
	 *
	 * Defensive against malformed snapshots / hand-edited
	 * persisted rows; the rendering layer's `esc_url()` is the
	 * authoritative escape barrier, but failing fast here lets
	 * the calling provider surface the failure through the
	 * §10.2 exception path rather than letting the rendering
	 * layer silently drop the link.
	 */
	private static function isAbsoluteHttpUrl( string $url ): bool {
		$parts = wp_parse_url( $url );
		if ( $parts === false || ! is_array( $parts ) ) {
			return false;
		}

		$scheme = $parts['scheme'] ?? null;
		if ( ! is_string( $scheme ) ) {
			return false;
		}
		$schemeLower = strtolower( $scheme );
		if ( $schemeLower !== 'http' && $schemeLower !== 'https' ) {
			return false;
		}

		$host = $parts['host'] ?? null;
		if ( ! is_string( $host ) || $host === '' ) {
			return false;
		}

		return true;
	}
}
