<?php
/**
 * Affiliate link result value object.
 *
 * Immutable value object that represents the output of an affiliate-
 * link builder: the tracked URL it produced together with the
 * presentation hints the rendering layer needs to mark the link up
 * correctly. The type unifies the cross-provider response shape
 * `specifications/provider-api-contracts.md` §7.8 enumerates so REST
 * controllers, render pipelines, and the sync layer can consume the
 * builder's output without having to re-derive the rel-attribute
 * policy or the tracking-applied flag from sibling fields.
 *
 * Field semantics match `provider-api-contracts.md` §7.8 exactly:
 *
 *   - `url` is the affiliate-tracked URL the builder minted.
 *     Required per §7.8 ("`url` は必須") and validated as a
 *     non-empty `http(s)://` URL using the same scheme guard the
 *     other Domain DTOs apply, so a programming error
 *     (`javascript:` / `data:` / `ftp://` URLs slipping through)
 *     surfaces here rather than at the rendering boundary where
 *     the consequences are user-visible.
 *
 *   - `relAttributes` is the list of `rel` token values the
 *     rendering layer SHOULD apply to the resulting `<a>` element
 *     ("nofollow", "sponsored", "noopener", "noreferrer", ...).
 *     Per §7.8 ("`relAttributes` は `nofollow`, `sponsored`,
 *     `noopener`, `noreferrer` などを含みうる") the set is open-
 *     ended; this DTO does NOT close the set because future
 *     standards bodies may mint additional tokens and clamping at
 *     this layer would force a re-release every time. The list is
 *     normalized to a unique, lowercase, positionally-keyed list
 *     so the rendering layer can `implode(' ', ...)` directly.
 *
 *   - `target` is the `target` attribute value the rendering layer
 *     SHOULD apply to the resulting `<a>` element ("_blank" being
 *     the canonical "open in a new tab" choice). Held as a plain
 *     string at this layer; the closed set of HTML5 `target`
 *     keywords is enforced by the rendering layer that knows the
 *     surrounding markup. Nullable because not every consumer
 *     wants to override the host theme's default link target.
 *
 *   - `trackingApplied` is the explicit "did the builder actually
 *     stamp tracking parameters onto the URL?" flag. Required.
 *     Independent of `url` because a builder may legitimately
 *     return the original detail URL unchanged when no tracking
 *     context applies (no associate tag configured, the product
 *     is from a provider whose program the site has not opted
 *     into, ...); consuming surfaces use this flag to decide
 *     whether to render an "affiliate link" disclosure badge.
 *
 *   - `warnings` is the list of human-readable, administrator-
 *     facing warning messages the builder accumulated while
 *     producing the URL ("associate tag not configured",
 *     "fallback to non-affiliate URL", ...). Per §7.8
 *     ("`warnings` は UI で管理者向けに表示可能") consuming
 *     surfaces are expected to surface them in the admin UI
 *     rather than to log them silently or to expose them to
 *     end-users. The list is normalized to a positionally-keyed
 *     list so JSON-encoding produces a JSON array.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because an affiliate-link result represents a single completed
 * builder pass: any change in the underlying tracking decision
 * should be modeled as a fresh builder call producing a fresh
 * {@see AffiliateLinkResult}, not as an in-place edit of an
 * existing one.
 *
 * @package CommerceLinkBuilder\Domain\Affiliate
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Domain\Affiliate;

use InvalidArgumentException;

/**
 * Immutable normalized affiliate-link builder output.
 *
 * See the file-level docblock for the mapping to
 * `provider-api-contracts.md` §7.8, the open-set rationale for
 * `relAttributes`, and the independence of `trackingApplied` from
 * `url`.
 */
final class AffiliateLinkResult {

	/**
	 * Affiliate-tracked URL per §7.8. Required and validated as a
	 * `http(s)://` URL.
	 */
	private string $url;

	/**
	 * `rel` attribute tokens the rendering layer SHOULD apply.
	 *
	 * Normalized to a unique, lowercase, positionally-keyed list
	 * so callers can `implode(' ', ...)` directly.
	 *
	 * @var list<string>
	 */
	private array $relAttributes;

	/**
	 * `target` attribute value, or null when the rendering layer
	 * should fall back to the host theme's default.
	 */
	private ?string $target;

	/**
	 * Whether the builder actually stamped tracking parameters
	 * onto the URL.
	 */
	private bool $trackingApplied;

	/**
	 * Administrator-facing warning messages the builder
	 * accumulated, in emission order.
	 *
	 * @var list<string>
	 */
	private array $warnings;

	/**
	 * Construct a result with explicit values for every field.
	 *
	 * The constructor is exhaustive so a programming error
	 * (missing required field on a write, swapped url / target)
	 * surfaces at the type system boundary rather than at the
	 * rendering layer.
	 *
	 * Validation summary:
	 *
	 *   - `url` is trimmed and validated as a `http(s)://` URL;
	 *     empty / malformed / non-http schemes raise
	 *     {@see InvalidArgumentException}.
	 *   - `relAttributes` entries are trimmed, lowercased, and
	 *     deduplicated. Non-string entries and entries that are
	 *     empty after trimming are rejected.
	 *   - `target`, when non-null, is trimmed and rejected when
	 *     the trim leaves an empty string.
	 *   - `warnings` entries are trimmed and rejected when not a
	 *     string or when empty after trimming.
	 *
	 * @param string       $url             Affiliate-tracked URL.
	 * @param list<string> $relAttributes   `rel` attribute tokens.
	 * @param string|null  $target          `target` attribute value, or null.
	 * @param bool         $trackingApplied Whether tracking was applied.
	 * @param list<string> $warnings        Administrator-facing warnings.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  §7.8 contract or the
	 *                                  cross-field validation rules
	 *                                  summarized above.
	 */
	public function __construct(
		string $url,
		array $relAttributes,
		?string $target,
		bool $trackingApplied,
		array $warnings
	) {
		$this->url             = self::normalizeUrl( $url );
		$this->relAttributes   = self::normalizeRelAttributes( $relAttributes );
		$this->target          = self::normalizeOptionalString( 'target', $target );
		$this->trackingApplied = $trackingApplied;
		$this->warnings        = self::normalizeWarnings( $warnings );
	}

	/**
	 * Construct a "passthrough" result for the case where no
	 * affiliate tracking applies and the builder is forwarding the
	 * provider's own detail URL unchanged.
	 *
	 * Convenience factory for the common "no tracking configured /
	 * fallback to plain detail link" path so call sites do not have
	 * to spell out the empty `relAttributes`, the null `target`,
	 * the explicit `trackingApplied = false`, and the empty
	 * warnings list every time.
	 *
	 * @param string $url Plain detail URL to forward.
	 *
	 * @throws InvalidArgumentException When `$url` fails the URL
	 *                                  validator on the primary
	 *                                  constructor.
	 */
	public static function passthrough( string $url ): self {
		return new self(
			$url,
			array(),
			null,
			false,
			array()
		);
	}

	/**
	 * Affiliate-tracked URL per §7.8.
	 */
	public function url(): string {
		return $this->url;
	}

	/**
	 * `rel` attribute tokens the rendering layer SHOULD apply,
	 * normalized to a unique lowercase list.
	 *
	 * @return list<string>
	 */
	public function relAttributes(): array {
		return $this->relAttributes;
	}

	/**
	 * `target` attribute value, or null.
	 */
	public function target(): ?string {
		return $this->target;
	}

	/**
	 * Whether the builder actually stamped tracking parameters
	 * onto the URL.
	 */
	public function trackingApplied(): bool {
		return $this->trackingApplied;
	}

	/**
	 * Administrator-facing warning messages, in emission order.
	 *
	 * @return list<string>
	 */
	public function warnings(): array {
		return $this->warnings;
	}

	/**
	 * Report whether this result carries any administrator-facing
	 * warnings.
	 *
	 * Convenience guard so call sites can branch on "is there
	 * anything to surface in the admin UI?" without manually
	 * counting the list.
	 */
	public function hasWarnings(): bool {
		return $this->warnings !== array();
	}

	/**
	 * Render the `relAttributes` list as a single space-separated
	 * string suitable for the HTML `rel` attribute, or null when
	 * the list is empty.
	 *
	 * Returning null (rather than the empty string) when the list
	 * is empty lets rendering layers conditionally emit the
	 * attribute via `?:` without producing `rel=""` markup.
	 */
	public function relAttributesAsString(): ?string {
		if ( $this->relAttributes === array() ) {
			return null;
		}

		return implode( ' ', $this->relAttributes );
	}

	/**
	 * Validate and normalize the `url` field at construction.
	 *
	 * Trims surrounding whitespace, rejects the empty string, and
	 * verifies the result is a syntactically-valid `http(s)://`
	 * URL. The scheme guard is intentionally restrictive: the §7.8
	 * "`url` は必須" rule is meant to produce a clickable link, and
	 * `data:` / `javascript:` / `ftp://` schemes are never that
	 * even when they happen to satisfy `filter_var()`.
	 *
	 * @param string $url Raw URL.
	 *
	 * @throws InvalidArgumentException When `$url` is empty after
	 *                                  trimming, fails URL
	 *                                  validation, or uses a
	 *                                  non-http scheme.
	 */
	private static function normalizeUrl( string $url ): string {
		$trimmed = trim( $url );
		if ( $trimmed === '' ) {
			$message = 'AffiliateLinkResult url must be a non-empty URL.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( filter_var( $trimmed, FILTER_VALIDATE_URL ) === false ) {
			$message = sprintf(
				'AffiliateLinkResult url must be a valid http(s):// URL; got "%s".',
				$url
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Domain layer is WordPress-independent per coding-standards.md §1.3 B; wp_parse_url() would introduce a forbidden dependency.
		$scheme = strtolower( (string) parse_url( $trimmed, PHP_URL_SCHEME ) );
		if ( $scheme !== 'http' && $scheme !== 'https' ) {
			$message = sprintf(
				'AffiliateLinkResult url must use the http or https scheme; got "%s".',
				$url
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize the `relAttributes` list.
	 *
	 * Each entry is trimmed and lowercased; non-string entries
	 * raise {@see InvalidArgumentException}, as do entries that
	 * trim to the empty string. The resulting list is deduplicated
	 * (last occurrence wins) and re-keyed to a positional list so
	 * callers always see a `list<>` shape.
	 *
	 * The set of accepted token values is deliberately open-ended:
	 * §7.8 names `nofollow`, `sponsored`, `noopener`, `noreferrer`
	 * as examples ("などを含みうる") rather than as a closed set,
	 * and HTML5 `rel` token grammar is itself extensible. Closing
	 * the set at this layer would force a re-release every time a
	 * new token is standardized.
	 *
	 * @param array<int|string, mixed> $relAttributes Raw list.
	 *
	 * @return list<string>
	 *
	 * @throws InvalidArgumentException When any entry is not a
	 *                                  string or is empty after
	 *                                  trimming.
	 */
	private static function normalizeRelAttributes( array $relAttributes ): array {
		$normalized = array();
		foreach ( $relAttributes as $index => $entry ) {
			if ( ! is_string( $entry ) ) {
				$message = sprintf(
					'AffiliateLinkResult relAttributes[%s] must be a string; got %s.',
					(string) $index,
					gettype( $entry )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$trimmed = strtolower( trim( $entry ) );
			if ( $trimmed === '' ) {
				$message = sprintf(
					'AffiliateLinkResult relAttributes[%s] must be a non-empty string.',
					(string) $index
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( ! in_array( $trimmed, $normalized, true ) ) {
				$normalized[] = $trimmed;
			}
		}

		return $normalized;
	}

	/**
	 * Validate and normalize a nullable string field that, when
	 * non-null, must carry non-empty content.
	 *
	 * Used for the `target` field where "present but empty" is a
	 * programming error rather than a meaningful state — the host
	 * theme's default applies when the field is null, so
	 * `target=""` markup is never the intended output.
	 *
	 * @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(
				'AffiliateLinkResult %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 `warnings` list.
	 *
	 * Each entry is trimmed; non-string entries raise
	 * {@see InvalidArgumentException}, as do entries that trim to
	 * the empty string. Order is preserved (warnings are emitted
	 * in chronological order during the builder pass) and the
	 * resulting list is positionally keyed so JSON-encoding
	 * produces a JSON array.
	 *
	 * Unlike `relAttributes`, warnings are NOT deduplicated: the
	 * same warning may legitimately fire twice during a single
	 * builder pass (for example, "fallback to non-affiliate URL"
	 * emitted for both the primary and an alternative URL the
	 * builder considered) and consuming surfaces are entitled to
	 * see both occurrences.
	 *
	 * @param array<int|string, mixed> $warnings Raw warnings list.
	 *
	 * @return list<string>
	 *
	 * @throws InvalidArgumentException When any entry is not a
	 *                                  string or is empty after
	 *                                  trimming.
	 */
	private static function normalizeWarnings( array $warnings ): array {
		$normalized = array();
		foreach ( $warnings as $index => $warning ) {
			if ( ! is_string( $warning ) ) {
				$message = sprintf(
					'AffiliateLinkResult warnings[%s] must be a string; got %s.',
					(string) $index,
					gettype( $warning )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$trimmed = trim( $warning );
			if ( $trimmed === '' ) {
				$message = sprintf(
					'AffiliateLinkResult warnings[%s] must be a non-empty string.',
					(string) $index
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[] = $trimmed;
		}

		return $normalized;
	}
}
