<?php
/**
 * Affiliate context value object.
 *
 * Immutable value object that captures the per-request contextual
 * information an affiliate-link builder needs to mint a tracked URL
 * for a provider product. The type unifies the cross-provider
 * parameter set `specifications/provider-api-contracts.md` §7.7
 * enumerates so REST controllers, render pipelines, and the sync
 * layer can pass a single, validated value through to whatever
 * provider implementation is wired in — without each call site
 * having to re-validate the same fields.
 *
 * Field semantics match `provider-api-contracts.md` §7.7 exactly:
 *
 *   - `trackingId` is the cross-provider canonical tracking
 *     identifier the application layer uses to correlate clicks /
 *     attribution back to a specific CLB site / campaign. Held as
 *     a plain string at this layer; the application layer is
 *     responsible for whatever encoding the underlying analytics
 *     pipeline expects. Nullable per §7.7 because not every
 *     provider needs it.
 *
 *   - `associateTag` is the Amazon-specific Associates Program
 *     tag (`xxxxx-22`). Held verbatim. Nullable for non-Amazon
 *     providers per §7.7 ("不要項目は無視してよい").
 *
 *   - `affiliateId` is the 楽天 / Yahoo!ショッピング affiliate
 *     account identifier. Nullable.
 *
 *   - `sid` is the secondary tracking identifier some affiliate
 *     networks accept (Yahoo!ショッピング `sid` parameter). Held
 *     verbatim. Nullable.
 *
 *   - `pid` is the program / placement identifier some affiliate
 *     networks accept. Nullable.
 *
 *   - `campaign` is the human-readable campaign tag the
 *     application layer uses to group attribution buckets
 *     ("spring_sale_2026", "block_picker_default"). Held
 *     verbatim. Nullable.
 *
 *   - `subId` is the sub-identifier some networks accept for
 *     finer-grained attribution (per-page sub-ids, per-author
 *     sub-ids, ...). Nullable.
 *
 *   - `source` is the UTM-style source tag the application layer
 *     wants to forward through the affiliate URL ("blog",
 *     "newsletter", "twitter", ...). Nullable.
 *
 *   - `medium` is the UTM-style medium tag ("cpc", "organic",
 *     "social", ...). Nullable.
 *
 *   - `customParameters` is the structured catch-all for
 *     provider-specific parameters the cross-provider field set
 *     does not name explicitly. Held as
 *     `array<string, scalar|null>` — the same shape
 *     {@see \CommerceLinkBuilder\Domain\Search\SearchQuery::filters}
 *     uses — so the rendering / mapping layers do not have to
 *     special-case affiliate parameters separately from search
 *     filters. Empty map by default.
 *
 * Per §7.7, "provider ごとに必要な項目のみ使用する" / "不要項目は
 * 無視してよい": every named field is nullable on this DTO because
 * the cross-provider field set is a union of what every supported
 * provider might need; a given provider implementation reads only
 * the fields that apply to it, ignores the rest, and is itself
 * responsible for raising
 * {@see \CommerceLinkBuilder\Domain\Provider\Exception\ProviderConfigurationException}
 * (Phase 3 slice 3f) when a required field is missing for that
 * provider's request shape. The DTO does not encode per-provider
 * required-field rules because doing so would re-couple the type
 * to the closed set of providers — exactly what §2.4 (capabilities)
 * is meant to avoid.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because an affiliate context is composed once at the boundary
 * (REST controller / render pipeline) and then handed off to the
 * provider implementation; in-place edits would only invite
 * ambiguity about which fields are authoritative.
 *
 * @package CommerceLinkBuilder\Domain\Affiliate
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Domain\Affiliate;

use InvalidArgumentException;

/**
 * Immutable normalized affiliate-context input.
 *
 * See the file-level docblock for the mapping to
 * `provider-api-contracts.md` §7.7, the per-provider field-use
 * convention, and the rationale for keeping the type free of
 * mutation helpers.
 */
final class AffiliateContext {

	/**
	 * Cross-provider canonical tracking identifier, or null.
	 */
	private ?string $trackingId;

	/**
	 * Amazon Associates Program tag, or null.
	 */
	private ?string $associateTag;

	/**
	 * 楽天 / Yahoo!ショッピング affiliate account id, or null.
	 */
	private ?string $affiliateId;

	/**
	 * Secondary tracking identifier (Yahoo!ショッピング `sid`),
	 * or null.
	 */
	private ?string $sid;

	/**
	 * Program / placement identifier, or null.
	 */
	private ?string $pid;

	/**
	 * Application-layer campaign tag, or null.
	 */
	private ?string $campaign;

	/**
	 * Sub-identifier for finer-grained attribution, or null.
	 */
	private ?string $subId;

	/**
	 * UTM-style source tag, or null.
	 */
	private ?string $source;

	/**
	 * UTM-style medium tag, or null.
	 */
	private ?string $medium;

	/**
	 * Structured catch-all for provider-specific parameters.
	 *
	 * @var array<string, scalar|null>
	 */
	private array $customParameters;

	/**
	 * Construct an affiliate context with explicit values for
	 * every field.
	 *
	 * Every named field is nullable per §7.7; the constructor
	 * trims surrounding whitespace from non-null strings and
	 * rejects them when the trim leaves an empty string —
	 * "present but empty" is treated as a programming error
	 * because it conveys nothing the null case does not already
	 * convey.
	 *
	 * `customParameters` keys MUST be non-empty strings (after
	 * trimming) and values MUST be scalar or null. Nested arrays /
	 * objects are rejected because §7.7 expects a flat parameter
	 * surface that providers can map to URL query parameters.
	 *
	 * @param string|null                $trackingId       Canonical tracking id.
	 * @param string|null                $associateTag     Amazon Associates tag.
	 * @param string|null                $affiliateId      Rakuten / Yahoo affiliate id.
	 * @param string|null                $sid              Secondary tracking id.
	 * @param string|null                $pid              Program / placement id.
	 * @param string|null                $campaign         Campaign tag.
	 * @param string|null                $subId            Sub-identifier.
	 * @param string|null                $source           UTM-style source tag.
	 * @param string|null                $medium           UTM-style medium tag.
	 * @param array<string, scalar|null> $customParameters Structured catch-all map.
	 *
	 * @throws InvalidArgumentException When any named field is the
	 *                                  empty string after trimming,
	 *                                  or when `customParameters`
	 *                                  carries a non-string key or
	 *                                  a non-scalar value.
	 */
	public function __construct(
		?string $trackingId,
		?string $associateTag,
		?string $affiliateId,
		?string $sid,
		?string $pid,
		?string $campaign,
		?string $subId,
		?string $source,
		?string $medium,
		array $customParameters
	) {
		$this->trackingId       = self::normalizeOptionalString( 'trackingId', $trackingId );
		$this->associateTag     = self::normalizeOptionalString( 'associateTag', $associateTag );
		$this->affiliateId      = self::normalizeOptionalString( 'affiliateId', $affiliateId );
		$this->sid              = self::normalizeOptionalString( 'sid', $sid );
		$this->pid              = self::normalizeOptionalString( 'pid', $pid );
		$this->campaign         = self::normalizeOptionalString( 'campaign', $campaign );
		$this->subId            = self::normalizeOptionalString( 'subId', $subId );
		$this->source           = self::normalizeOptionalString( 'source', $source );
		$this->medium           = self::normalizeOptionalString( 'medium', $medium );
		$this->customParameters = self::normalizeCustomParameters( $customParameters );
	}

	/**
	 * Construct an empty affiliate context — every named field
	 * null and `customParameters` empty.
	 *
	 * Convenience factory for the common "no affiliate tracking
	 * configured" case so call sites do not have to spell out ten
	 * `null` arguments. Useful for tests and for rendering paths
	 * that want to preview an unaffiliated detail link.
	 */
	public static function empty(): self {
		return new self(
			null,
			null,
			null,
			null,
			null,
			null,
			null,
			null,
			null,
			array()
		);
	}

	/**
	 * Cross-provider canonical tracking identifier, or null.
	 */
	public function trackingId(): ?string {
		return $this->trackingId;
	}

	/**
	 * Amazon Associates Program tag, or null.
	 */
	public function associateTag(): ?string {
		return $this->associateTag;
	}

	/**
	 * 楽天 / Yahoo!ショッピング affiliate account id, or null.
	 */
	public function affiliateId(): ?string {
		return $this->affiliateId;
	}

	/**
	 * Secondary tracking identifier, or null.
	 */
	public function sid(): ?string {
		return $this->sid;
	}

	/**
	 * Program / placement identifier, or null.
	 */
	public function pid(): ?string {
		return $this->pid;
	}

	/**
	 * Application-layer campaign tag, or null.
	 */
	public function campaign(): ?string {
		return $this->campaign;
	}

	/**
	 * Sub-identifier for finer-grained attribution, or null.
	 */
	public function subId(): ?string {
		return $this->subId;
	}

	/**
	 * UTM-style source tag, or null.
	 */
	public function source(): ?string {
		return $this->source;
	}

	/**
	 * UTM-style medium tag, or null.
	 */
	public function medium(): ?string {
		return $this->medium;
	}

	/**
	 * Structured catch-all for provider-specific parameters.
	 *
	 * @return array<string, scalar|null>
	 */
	public function customParameters(): array {
		return $this->customParameters;
	}

	/**
	 * Report whether this context carries any non-null information.
	 *
	 * Convenience guard so call sites can branch on "is there
	 * anything to apply?" without manually inspecting every
	 * accessor. Returns true when every named field is null AND
	 * `customParameters` is empty; the inverse, false, means at
	 * least one tracking signal is present.
	 */
	public function isEmpty(): bool {
		return $this->trackingId === null
			&& $this->associateTag === null
			&& $this->affiliateId === null
			&& $this->sid === null
			&& $this->pid === null
			&& $this->campaign === null
			&& $this->subId === null
			&& $this->source === null
			&& $this->medium === null
			&& $this->customParameters === array();
	}

	/**
	 * Validate and normalize a nullable string field that, when
	 * non-null, must carry non-empty content.
	 *
	 * Used for every named field on this DTO because §7.7 treats
	 * each field as either "present with content" or "absent" —
	 * "present but empty" conveys nothing additional and is
	 * therefore a programming error.
	 *
	 * @param string      $field Field name for the error message.
	 * @param string|null $value Raw value, or null.
	 *
	 * @return string|null Trimmed value, or null.
	 *
	 * @throws InvalidArgumentException When `$value` is the empty
	 *                                  string after trimming.
	 */
	private static function normalizeOptionalString( string $field, ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'AffiliateContext %s must be null or a non-empty string.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize the `customParameters` map.
	 *
	 * Keys MUST be non-empty strings (after trimming) and values
	 * MUST be scalar or null. Nested arrays / objects are rejected
	 * because §7.7 expects a flat parameter surface that providers
	 * can map to URL query parameters.
	 *
	 * @param array<int|string, mixed> $customParameters Raw map.
	 *
	 * @return array<string, scalar|null>
	 *
	 * @throws InvalidArgumentException When any key is not a
	 *                                  non-empty string or any
	 *                                  value is not scalar / null.
	 */
	private static function normalizeCustomParameters( array $customParameters ): array {
		$normalized = array();
		foreach ( $customParameters as $key => $value ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'AffiliateContext customParameters keys must be strings; got %s.',
					gettype( $key )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$trimmedKey = trim( $key );
			if ( $trimmedKey === '' ) {
				$message = 'AffiliateContext customParameters keys must be non-empty strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( $value !== null && ! is_scalar( $value ) ) {
				$message = sprintf(
					'AffiliateContext customParameters["%s"] must be scalar or null; got %s.',
					$trimmedKey,
					gettype( $value )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[ $trimmedKey ] = $value;
		}

		return $normalized;
	}
}
