<?php
/**
 * Rakuten affiliate link builder.
 *
 * Mints affiliate-tracked URLs for Rakuten product detail pages
 * given a {@see ProductSnapshot} and an {@see AffiliateContext}.
 * The builder is the canonical seam where the cross-provider
 * "render an outgoing link" surface meets Rakuten's affiliate-
 * tracking conventions documented in
 * `specifications/provider-api-contracts.md` §9.2 / §18.3.
 *
 * Design notes:
 *
 *   - Rakuten exposes affiliate-tracked URLs via two routes:
 *
 *       1. The IchibaItem Search response carries a pre-minted
 *          `Item.affiliateUrl` for every result when the request
 *          included the caller's `affiliateId`. The
 *          {@see RakutenResponseNormalizer} threads that URL onto
 *          {@see ProductSnapshot::affiliateUrl()}.
 *
 *       2. The cross-provider {@see AffiliateContext} may carry an
 *          affiliate id distinct from the one stored at credentials
 *          configuration time (per-campaign attribution, multi-
 *          tenant deployments). When that happens, the snapshot's
 *          pre-minted URL is the wrong one and the builder needs to
 *          synthesize a fresh tracked URL via Rakuten's
 *          `hb.affilun.rakuten.co.jp/hgc/<affiliateId>/?pc=<detailUrl>`
 *          redirect endpoint.
 *
 *     The builder picks between the two routes per call: when the
 *     supplied {@see AffiliateContext::affiliateId()} matches the
 *     pre-minted URL on the snapshot (or when the context is empty
 *     and a pre-minted URL exists), the snapshot's URL is forwarded
 *     verbatim and `trackingApplied` is true. Otherwise the
 *     builder synthesizes a fresh redirect URL.
 *
 *   - When the snapshot has no `affiliateUrl` AND the supplied
 *     context carries no `affiliateId`, the builder falls back to
 *     {@see AffiliateLinkResult::passthrough()} on the snapshot's
 *     detail URL with a single warning ("affiliate id not
 *     configured") so the admin diagnostics surface can prompt
 *     the operator to complete configuration. This matches the
 *     §6.1 contract that buildAffiliateLink MUST return a
 *     clickable link rather than throwing in the no-tracking case.
 *
 *   - The builder always stamps `rel="nofollow sponsored
 *     noopener noreferrer"` and `target="_blank"` on tracked
 *     results per §18.3 ("アフィリエイト ID を付与できる")
 *     combined with the cross-provider §7.8 rendering hint
 *     contract. The passthrough path applies the same `rel` set
 *     because the link still leaves the host site, but elides
 *     `target` so the host theme's link-target convention applies.
 *
 *   - Provider mismatch on the supplied snapshot raises
 *     {@see ProviderRequestException} per §7.3 ("provider 不一致
 *     の ID を他 provider に渡してはならない") lifted to the
 *     affiliate-link build boundary.
 *
 *   - Per `provider-api-contracts.md` §10.1, neither the produced
 *     {@see AffiliateLinkResult} nor any thrown exception may
 *     leak credential material. The builder forwards only the
 *     supplied {@see AffiliateContext::affiliateId()} value and
 *     the snapshot's public URLs; it never touches the
 *     {@see RakutenCredentials} attached to the request factory.
 *
 * @package Affilun\Infrastructure\Provider\Rakuten
 */

declare(strict_types=1);

// Exception messages constructed in this builder are developer-
// facing diagnostic summaries surfaced through ProviderRequestException;
// they never reach an HTML rendering path. Suppress the EscapeOutput
// sniff at the file level rather than peppering every throw site
// with a per-line ignore.
//
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped

namespace Affilun\Infrastructure\Provider\Rakuten;

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

/**
 * Mints affiliate-tracked URLs for Rakuten product snapshots.
 *
 * See the file-level docblock for the two-route strategy
 * (forward the snapshot's pre-minted URL when the context's
 * affiliate id matches, synthesize a redirect URL otherwise),
 * the `rel` / `target` stamping convention, and the §10.1
 * secret-material rule.
 */
final class RakutenAffiliateLinkBuilder {

	/**
	 * §4 stable provider key for Rakuten. Held as a class
	 * constant so the builder never disagrees with the request
	 * factory / response normalizer on the key it tags
	 * provider-mismatch exceptions with.
	 */
	public const PROVIDER_KEY = 'rakuten';

	/**
	 * Base URL for Rakuten's affiliate redirect endpoint.
	 *
	 * The redirect endpoint accepts a path-segment-encoded
	 * affiliate id and a `pc` query parameter carrying the
	 * URL-encoded destination. The endpoint redirects to the
	 * destination after recording the affiliate click.
	 *
	 * Exposed as a class constant so a future endpoint move
	 * (Rakuten has historically migrated host names) is a single-
	 * location change.
	 */
	public const REDIRECT_ENDPOINT = 'https://hb.affilun.rakuten.co.jp/hgc/';

	/**
	 * `rel` attribute tokens stamped on every link the builder
	 * produces.
	 *
	 * Per §18.3 + §7.8 the produced link always carries the four
	 * canonical "affiliate-style" tokens regardless of whether
	 * tracking was actually applied — even the passthrough path
	 * leaves the host site and benefits from the SEO / privacy
	 * hints the tokens convey.
	 *
	 * @var list<string>
	 */
	private const REL_TOKENS = array( 'nofollow', 'sponsored', 'noopener', 'noreferrer' );

	/**
	 * `target` attribute value stamped on tracked-URL results.
	 *
	 * The passthrough path elides this so the host theme's link-
	 * target convention applies; the tracked path forces a new
	 * tab so the affiliate redirect does not interrupt the
	 * reader's flow on the host page.
	 */
	private const TARGET_BLANK = '_blank';

	/**
	 * Mint an {@see AffiliateLinkResult} for the given snapshot
	 * and affiliate context.
	 *
	 * Lifecycle:
	 *
	 *   1. Verify the snapshot belongs to this provider per
	 *      {@see self::PROVIDER_KEY}. A foreign-provider snapshot
	 *      raises {@see ProviderRequestException} carrying the
	 *      supplied and expected provider keys for diagnosis.
	 *
	 *   2. Determine the effective affiliate id:
	 *
	 *        - When the supplied context carries one, use it.
	 *        - Otherwise, leave it null (passthrough path).
	 *
	 *   3. Pick the URL route:
	 *
	 *        - When the context is empty and the snapshot already
	 *          carries an `affiliateUrl`, forward the snapshot's
	 *          URL verbatim with `trackingApplied = true`. This
	 *          is the common case: the response normalizer
	 *          attached the affiliate-tracked URL the upstream
	 *          minted at search time.
	 *
	 *        - When the context carries an `affiliateId`,
	 *          synthesize a fresh redirect URL via
	 *          {@see self::REDIRECT_ENDPOINT}. The fresh URL
	 *          supersedes any pre-minted one because the context-
	 *          supplied id is the more specific signal.
	 *
	 *        - When neither route applies (no snapshot URL, no
	 *          context id), fall back to
	 *          {@see AffiliateLinkResult::passthrough()} on the
	 *          snapshot's detail URL and emit a single
	 *          administrator-facing warning so the diagnostics
	 *          surface can prompt configuration.
	 *
	 *   4. Stamp the canonical `rel` token set on every result;
	 *      stamp `target="_blank"` only on tracked results.
	 *
	 * @param ProductSnapshot  $snapshot Snapshot whose detail URL
	 *                                   should be tracked.
	 * @param AffiliateContext $context  Per-request affiliate
	 *                                   context (campaign, sub-id,
	 *                                   per-campaign affiliate id,
	 *                                   ...).
	 *
	 * @throws ProviderRequestException When `$snapshot` belongs to
	 *                                  a provider other than
	 *                                  {@see self::PROVIDER_KEY}.
	 */
	public function build(
		ProductSnapshot $snapshot,
		AffiliateContext $context
	): AffiliateLinkResult {
		if ( $snapshot->provider() !== self::PROVIDER_KEY ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				sprintf(
					'ProductSnapshot belongs to provider "%s", not "%s".',
					$snapshot->provider(),
					self::PROVIDER_KEY
				),
				array(
					'feature'           => 'affiliate_link',
					'expected_provider' => self::PROVIDER_KEY,
					'actual_provider'   => $snapshot->provider(),
				)
			);
		}

		$contextAffiliateId   = $context->affiliateId();
		$snapshotAffiliateUrl = $snapshot->affiliateUrl();

		// Route 1: context-supplied affiliate id wins. Synthesize a
		// fresh redirect URL targeting the snapshot's detail URL so
		// the resulting click attribution matches the context.
		if ( $contextAffiliateId !== null ) {
			$redirectUrl = $this->buildRedirectUrl(
				$contextAffiliateId,
				$snapshot->detailUrl()
			);

			return new AffiliateLinkResult(
				$redirectUrl,
				self::REL_TOKENS,
				self::TARGET_BLANK,
				true,
				array()
			);
		}

		// Route 2: no context-supplied id, but the snapshot carries
		// a pre-minted affiliate URL from the search-time response.
		// Forward it verbatim — the upstream already stamped the
		// caller's credentials-time affiliate id onto it.
		if ( $snapshotAffiliateUrl !== null ) {
			return new AffiliateLinkResult(
				$snapshotAffiliateUrl,
				self::REL_TOKENS,
				self::TARGET_BLANK,
				true,
				array()
			);
		}

		// Route 3: no tracking applies. Fall back to a passthrough
		// result on the detail URL and warn the administrator so
		// the diagnostics surface can prompt configuration.
		return new AffiliateLinkResult(
			$snapshot->detailUrl(),
			self::REL_TOKENS,
			null,
			false,
			array(
				'Rakuten affiliate id is not configured; '
					. 'falling back to the unaffiliated detail URL.',
			)
		);
	}

	/**
	 * Build a Rakuten affiliate redirect URL for the supplied
	 * affiliate id and destination URL.
	 *
	 * The redirect URL has the shape:
	 *
	 *     https://hb.affilun.rakuten.co.jp/hgc/<affiliateId>/?pc=<urlencoded-destination>
	 *
	 * The affiliate id is path-segment-encoded so any embedded
	 * special characters (the canonical Rakuten format includes
	 * dots) round-trip correctly. The destination URL is
	 * URL-encoded as a query parameter value.
	 *
	 * The endpoint requires a trailing slash before the `?`; the
	 * implementation builds it explicitly so a future change to
	 * {@see self::REDIRECT_ENDPOINT} does not silently break the
	 * format.
	 *
	 * @param string $affiliateId   Rakuten affiliate id.
	 * @param string $destinationUrl Detail URL to redirect to.
	 */
	private function buildRedirectUrl( string $affiliateId, string $destinationUrl ): string {
		return sprintf(
			'%s%s/?pc=%s',
			self::REDIRECT_ENDPOINT,
			rawurlencode( $affiliateId ),
			rawurlencode( $destinationUrl )
		);
	}
}
