<?php
/**
 * Yahoo!ショッピング affiliate link builder.
 *
 * Mints affiliate-tracked URLs for Yahoo!ショッピング 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 Yahoo!ショッピング's affiliate-tracking conventions
 * documented in `specifications/provider-api-contracts.md` §9.3
 * / §18.3.
 *
 * Design notes:
 *
 *   - Yahoo!ショッピング affiliate links are minted via the Value
 *     Commerce affiliate-network redirect endpoint:
 *
 *       https://ck.jp.ap.valuecommerce.com/servlet/referral?
 *           sid={affiliate_sid}&pid={affiliate_pid}&vc_url={detail_url}
 *
 *     Per §9.3 the required AffiliateContext fields are
 *     `clientId`, `affiliateSid`, and `affiliatePid`. The
 *     `clientId` is consumed by the request factory at search /
 *     lookup time (it is the Yahoo Developer Network App ID, not
 *     a link-build axis). The `affiliateSid` / `affiliatePid`
 *     pair is the Value Commerce attribution couple this builder
 *     stamps onto the redirect URL.
 *
 *     **Spec uncertainty.** §9.3 names the fields but does not
 *     spell out the exact wire format of the resulting affiliate
 *     URL. The redirect-endpoint shape encoded here matches the
 *     publicly-documented Value Commerce
 *     `ck.jp.ap.valuecommerce.com/servlet/referral` redirect
 *     used across Japanese affiliate-network deployments. A
 *     future integration test may refine the parameter names or
 *     add additional required fields (Value Commerce sometimes
 *     requires a `vc_lurl` mirror parameter). The structure is
 *     set up so a single change to {@see self::buildRedirectUrl()}
 *     can adjust the on-wire format without touching the
 *     route-selection logic.
 *
 *   - Route selection:
 *
 *       1. When BOTH {@see AffiliateContext::sid()} AND
 *          {@see AffiliateContext::pid()} are present AND the
 *          snapshot carries a non-empty
 *          {@see ProductSnapshot::detailUrl()}, the builder
 *          synthesizes a fresh Value Commerce redirect URL.
 *          {@see AffiliateLinkResult::trackingApplied()} is
 *          true.
 *
 *       2. When the snapshot already carries a pre-minted
 *          {@see ProductSnapshot::affiliateUrl()} but the
 *          context-supplied sid/pid pair is missing, the
 *          builder forwards the pre-minted URL verbatim. This
 *          is the "the upstream / a previous sync already
 *          stamped the affiliate parameters" case.
 *
 *       3. Otherwise, the builder falls back to
 *          {@see AffiliateLinkResult::passthrough()} on the
 *          snapshot's detail URL with a single administrator-
 *          facing warning so the 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.
 *
 *   - Tracked results stamp the canonical four-token `rel` set
 *     (`nofollow sponsored noopener noreferrer`) and
 *     `target="_blank"` per §18.3 + §7.8. The passthrough route
 *     stamps the same `rel` set but elides `target` so the host
 *     theme's link-target convention applies.
 *
 *   - Provider mismatch on the supplied snapshot raises
 *     {@see ProviderRequestException} per §7.3 lifted to the
 *     affiliate-link build boundary.
 *
 *   - Per §10.1, neither the produced
 *     {@see AffiliateLinkResult} nor any thrown exception may
 *     leak credential material. The builder forwards only the
 *     supplied {@see AffiliateContext} fields and the snapshot's
 *     public URLs.
 *
 * @package Affilun\Infrastructure\Provider\YahooShopping
 */

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.
//
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped

namespace Affilun\Infrastructure\Provider\YahooShopping;

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 Yahoo!ショッピング product
 * snapshots via the Value Commerce affiliate-network redirect
 * endpoint.
 */
final class YahooShoppingAffiliateLinkBuilder {

	/**
	 * §4 stable provider key for Yahoo!ショッピング per §4.1.
	 */
	public const PROVIDER_KEY = 'yahoo_shopping';

	/**
	 * Value Commerce affiliate-network redirect endpoint.
	 *
	 * The endpoint accepts `sid` (site identifier) and `pid`
	 * (program / placement identifier) as query parameters plus
	 * `vc_url` 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 is a
	 * single-location change. See the file-level docblock for the
	 * spec uncertainty around the exact parameter set.
	 */
	public const REDIRECT_ENDPOINT = 'https://ck.jp.ap.valuecommerce.com/servlet/referral';

	/**
	 * `rel` attribute tokens stamped on every link the builder
	 * produces.
	 *
	 * @var list<string>
	 */
	private const REL_TOKENS = array( 'nofollow', 'sponsored', 'noopener', 'noreferrer' );

	/**
	 * `target` attribute value stamped on tracked-URL results.
	 */
	private const TARGET_BLANK = '_blank';

	/**
	 * Provider-level credentials used when the caller does not pass
	 * a per-request affiliate context.
	 */
	private ?YahooShoppingCredentials $credentials;

	/**
	 * @param YahooShoppingCredentials|null $credentials Optional configured credentials.
	 */
	public function __construct( ?YahooShoppingCredentials $credentials = null ) {
		$this->credentials = $credentials;
	}

	/**
	 * 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. When both {@see AffiliateContext::sid()} and
	 *      {@see AffiliateContext::pid()} are configured AND the
	 *      snapshot's detail URL is non-empty, synthesize a
	 *      fresh Value Commerce redirect URL.
	 *
	 *   3. When the snapshot already carries a pre-minted
	 *      affiliate URL and the context's sid/pid pair is not
	 *      both present, forward the pre-minted URL verbatim.
	 *
	 *   4. Otherwise, fall back to
	 *      {@see AffiliateLinkResult::passthrough()} on the
	 *      snapshot's detail URL with an administrator-facing
	 *      warning.
	 *
	 *   5. Stamp the canonical `rel` token set on tracked
	 *      results; the passthrough route inherits the empty
	 *      `rel` list from {@see AffiliateLinkResult::passthrough()}.
	 *
	 * @param ProductSnapshot  $snapshot Snapshot whose detail URL
	 *                                   should be tracked.
	 * @param AffiliateContext $context  Per-request affiliate
	 *                                   context.
	 *
	 * @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(),
				)
			);
		}

		$sid       = $context->sid();
		$pid       = $context->pid();
		$detailUrl = $snapshot->detailUrl();

		if ( ( $sid === null || $pid === null )
			&& $this->credentials !== null
			&& $this->credentials->hasAffiliateCredentials()
		) {
			$sid = $this->credentials->affiliateSid();
			$pid = $this->credentials->affiliatePid();
		}

		// Route 1: context-supplied sid+pid pair wins. Synthesize
		// a fresh Value Commerce redirect URL targeting the
		// snapshot's detail URL.
		if ( $sid !== null && $pid !== null && $detailUrl !== '' ) {
			$redirectUrl = self::isValueCommerceRedirectUrl( $detailUrl )
				? $detailUrl
				: self::buildRedirectUrl( $sid, $pid, $detailUrl );

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

		// Route 2: no context-supplied id pair, but the snapshot
		// carries a pre-minted affiliate URL from a prior sync.
		// Forward it verbatim.
		$snapshotAffiliateUrl = $snapshot->affiliateUrl();
		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(
			$detailUrl,
			self::REL_TOKENS,
			null,
			false,
			array(
				'Yahoo!ショッピング affiliate sid/pid is not configured; '
					. 'falling back to the unaffiliated detail URL.',
			)
		);
	}

	/**
	 * Build a Value Commerce affiliate referral base URL for the
	 * supplied sid / pid pair.
	 *
	 * The redirect URL has the shape:
	 *
	 *     https://ck.jp.ap.valuecommerce.com/servlet/referral?
	 *         sid=<sid>&pid=<pid>&vc_url=
	 *
	 * The sid / pid values are URL-encoded so any embedded special
	 * characters round-trip correctly.
	 *
	 * @param string $sid             Value Commerce site identifier.
	 * @param string $pid             Value Commerce program / placement id.
	 */
	public static function buildReferralBaseUrl(
		string $sid,
		string $pid
	): string {
		return sprintf(
			'%s?sid=%s&pid=%s&vc_url=',
			self::REDIRECT_ENDPOINT,
			rawurlencode( $sid ),
			rawurlencode( $pid )
		);
	}

	/**
	 * Report whether a URL already points at the ValueCommerce referral endpoint.
	 *
	 * @param string $url URL to inspect.
	 */
	public static function isValueCommerceRedirectUrl( string $url ): bool {
		return preg_match(
			'#\Ahttps?://ck\.jp\.ap\.valuecommerce\.com/servlet/referral(?:\?|$)#i',
			$url
		) === 1;
	}

	/**
	 * Build a Value Commerce affiliate redirect URL for a destination URL.
	 *
	 * @param string $sid             Value Commerce site identifier.
	 * @param string $pid             Value Commerce program / placement id.
	 * @param string $destinationUrl  Detail URL to redirect to.
	 */
	private static function buildRedirectUrl(
		string $sid,
		string $pid,
		string $destinationUrl
	): string {
		return sprintf(
			'%s%s',
			self::buildReferralBaseUrl( $sid, $pid ),
			rawurlencode( $destinationUrl )
		);
	}
}
