<?php
/**
 * Render context value object.
 *
 * Immutable value object that captures the contextual information
 * the rendering pipeline needs to translate a
 * {@see \Affilun\Domain\Product\ProductSnapshot} (and an
 * embed configuration) into HTML. The type unifies the cross-surface
 * inputs `specifications/rendering-and-template-spec.md` §5
 * enumerates so the ViewModel factories, the link-attribute /
 * disclaimer / image-presentation builders, and the renderer
 * exchange a single, validated value rather than agreeing on
 * positional argument lists.
 *
 * Field semantics match `rendering-and-template-spec.md` §5 exactly:
 *
 *   - `surface` is the rendering surface the request is targeting:
 *     `frontend` (live site), `editor` (Gutenberg block editor
 *     preview), `shortcode` (legacy shortcode output), or
 *     `rest_preview` (REST API admin preview). The DTO enforces
 *     the closed set at construction time so a programming error
 *     in a calling controller surfaces here rather than producing
 *     a snapshot the renderer cannot interpret.
 *
 *   - `template` is the §6.4 template key the renderer should pick
 *     when {@see \Affilun\Application\Render\TemplateResolver}
 *     resolves the actual file. Held as a plain string at this
 *     layer; the TemplateResolver enforces the
 *     `^[a-z][a-z0-9-]{1,63}$` grammar at the resolution boundary
 *     per §6.5. The DTO only validates non-empty so a caller that
 *     forgot to pick a template fails loudly.
 *
 *   - `locale` is a BCP-47 language / region tag (`ja-JP`, `en-US`,
 *     ...) that disclaimer / aria-label / formatted-price builders
 *     consult when synthesizing localized strings. Held verbatim;
 *     the consuming surface decides whether to fall back to the
 *     site default when the value is `null`.
 *
 *   - `isAmp` reflects whether the host page is rendering in
 *     Accelerated Mobile Pages mode. The renderer suppresses
 *     `target="_blank"` / `rel="noopener"` and disables lazy-load
 *     attributes when this is true because the AMP runtime
 *     re-implements those concerns.
 *
 *   - `isFeed` reflects whether the request is rendering inside an
 *     RSS / Atom feed. The renderer drops interactive elements
 *     (buttons, badges) when this is true and produces a stripped
 *     "title + detail link" shape that feed readers can render
 *     without the host theme's stylesheet.
 *
 *   - `trackingEnabled` reflects whether the consuming surface
 *     wants the renderer to emit affiliate-tracked URLs. Admin
 *     previews and feed outputs typically set this to `false` so
 *     attribution is not double-counted.
 *
 *   - `lazyLoadEnabled` reflects whether image elements should
 *     carry `loading="lazy"` markup. Defaults to `true` for the
 *     frontend surface; admin previews and AMP both want it
 *     disabled.
 *
 *   - `linkTarget` is the explicit `target` attribute value the
 *     consuming surface wants stamped on outgoing links. When
 *     `null`, the link-attribute builder falls back to its
 *     surface-aware default (`_blank` on the frontend,
 *     `null` / unset in the editor and feed surfaces).
 *
 *   - `linkRelPolicy` is the explicit `rel` policy token the
 *     consuming surface wants applied. Held as a plain string
 *     so the link-attribute builder can map it to a token list;
 *     the recognized policies are documented on the builder, not
 *     on this DTO. `null` means "use the builder's default".
 *
 *   - `debug` reflects whether the renderer should emit additional
 *     diagnostic markup (data attributes, comment markers) to help
 *     with development. Defaults to `false`; consuming surfaces
 *     opt in explicitly.
 *
 *   - `requestId` is an opaque correlation identifier the
 *     observability layer can stamp into log lines and HTML
 *     `data-*` attributes so a rendered card can be traced back
 *     to its originating request. Nullable because not every
 *     rendering pass is correlated.
 *
 * Instances are immutable. The DTO exposes a small set of
 * `with*()` mutation helpers because callers frequently need to
 * thread one or two adjustments through a layered call chain
 * without re-spelling every other field:
 *
 *   - {@see self::withTemplate()} replaces the template key when
 *     the ViewModel factory picks a more specific variant than
 *     the consuming surface defaulted to (for example downgrading
 *     to `single-fallback-card` when the snapshot is missing
 *     required fields).
 *
 *   - {@see self::withSurface()} replaces the surface token when
 *     a sub-renderer needs to adjust its rendering posture (the
 *     editor preview embedded inside a `rest_preview` response,
 *     for example).
 *
 *   - {@see self::withDebug()} flips the debug flag without
 *     touching anything else, used by the diagnostics surface to
 *     produce a pair of "with debug" / "without debug" snapshots
 *     for the same render.
 *
 * Other fields have no in-place edit path because changing them
 * (different locale, different request id) is a different
 * rendering operation and should be modeled as a fresh
 * {@see RenderContext}.
 *
 * @package Affilun\Domain\Render
 */

declare(strict_types=1);

namespace Affilun\Domain\Render;

use InvalidArgumentException;

/**
 * Immutable rendering-context input.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §5, the per-field validation
 * rules, and the rationale for the small set of `with*()` mutation
 * helpers exposed here.
 */
final class RenderContext {

	/**
	 * §5.2 surface token for live frontend rendering.
	 *
	 * Exposed as a class constant so call sites can reference the
	 * exact token without duplicating the literal, and so a
	 * future spec change has a single location to update.
	 */
	public const SURFACE_FRONTEND = 'frontend';

	/**
	 * §5.2 surface token for the Gutenberg block editor preview.
	 */
	public const SURFACE_EDITOR = 'editor';

	/**
	 * §5.2 surface token for the legacy shortcode output path.
	 */
	public const SURFACE_SHORTCODE = 'shortcode';

	/**
	 * §5.2 surface token for the REST API admin preview path.
	 */
	public const SURFACE_REST_PREVIEW = 'rest_preview';

	/**
	 * Closed set of accepted §5.2 surface tokens, in declaration
	 * order. Maintained as a class-level constant so the validator
	 * and the test suite can iterate the same source of truth.
	 *
	 * @var list<string>
	 */
	private const VALID_SURFACES = array(
		self::SURFACE_FRONTEND,
		self::SURFACE_EDITOR,
		self::SURFACE_SHORTCODE,
		self::SURFACE_REST_PREVIEW,
	);

	/**
	 * One of the {@see self::SURFACE_*} tokens. Validated at
	 * construction time so an out-of-set value never reaches the
	 * renderer.
	 */
	private string $surface;

	/**
	 * §6.4 template key the renderer should pick. Held as a plain
	 * string; the TemplateResolver enforces the grammar at the
	 * resolution boundary.
	 */
	private string $template;

	/**
	 * BCP-47 locale tag, or null when the consuming surface wants
	 * the renderer to fall back to the site default.
	 */
	private ?string $locale;

	/**
	 * Whether the host page is rendering in AMP mode.
	 */
	private bool $isAmp;

	/**
	 * Whether the request is rendering inside an RSS / Atom feed.
	 */
	private bool $isFeed;

	/**
	 * Whether the consuming surface wants affiliate-tracked URLs
	 * on outgoing links.
	 */
	private bool $trackingEnabled;

	/**
	 * Whether image elements should carry `loading="lazy"` markup.
	 */
	private bool $lazyLoadEnabled;

	/**
	 * Explicit `target` attribute value, or null to defer to the
	 * link-attribute builder's surface-aware default.
	 */
	private ?string $linkTarget;

	/**
	 * Explicit `rel` policy token, or null to defer to the
	 * link-attribute builder's default.
	 */
	private ?string $linkRelPolicy;

	/**
	 * Whether the renderer should emit additional diagnostic
	 * markup.
	 */
	private bool $debug;

	/**
	 * Opaque correlation identifier, or null when the rendering
	 * pass is not correlated.
	 */
	private ?string $requestId;

	/**
	 * Construct a render context with explicit values for every
	 * field.
	 *
	 * The constructor is exhaustive so a programming error
	 * (forgetting to declare a flag on a write, swapping
	 * `linkTarget` / `linkRelPolicy`) surfaces at the type system
	 * boundary rather than at the renderer. Named-argument
	 * construction is the ergonomic entry point for most call
	 * sites; the shared {@see self::frontend()} / {@see self::editor()}
	 * factories cover the common cases.
	 *
	 * Validation summary:
	 *
	 *   - `surface` is trimmed and matched against
	 *     {@see self::VALID_SURFACES}; out-of-set tokens raise
	 *     {@see InvalidArgumentException} carrying the offending
	 *     value verbatim and a comma-separated list of the
	 *     accepted tokens for diagnosis.
	 *   - `template` is trimmed and rejected when empty after
	 *     trimming. Format validation lives at the TemplateResolver.
	 *   - `locale`, `linkTarget`, `linkRelPolicy`, `requestId`,
	 *     when non-null, are trimmed and rejected when the trim
	 *     leaves an empty string — "present but empty" is treated
	 *     as a programming error.
	 *
	 * @param string      $surface         §5.2 surface token.
	 * @param string      $template        §6.4 template key.
	 * @param string|null $locale          BCP-47 locale tag.
	 * @param bool        $isAmp           AMP rendering flag.
	 * @param bool        $isFeed          Feed rendering flag.
	 * @param bool        $trackingEnabled Whether tracked URLs are wanted.
	 * @param bool        $lazyLoadEnabled Whether `loading="lazy"` is wanted.
	 * @param string|null $linkTarget      Explicit `target` value.
	 * @param string|null $linkRelPolicy   Explicit `rel` policy.
	 * @param bool        $debug           Diagnostic-markup flag.
	 * @param string|null $requestId       Correlation identifier.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		string $surface,
		string $template,
		?string $locale,
		bool $isAmp,
		bool $isFeed,
		bool $trackingEnabled,
		bool $lazyLoadEnabled,
		?string $linkTarget,
		?string $linkRelPolicy,
		bool $debug,
		?string $requestId
	) {
		$this->surface         = self::normalizeSurface( $surface );
		$this->template        = self::normalizeRequiredString( 'template', $template );
		$this->locale          = self::normalizeOptionalString( 'locale', $locale );
		$this->isAmp           = $isAmp;
		$this->isFeed          = $isFeed;
		$this->trackingEnabled = $trackingEnabled;
		$this->lazyLoadEnabled = $lazyLoadEnabled;
		$this->linkTarget      = self::normalizeOptionalString( 'linkTarget', $linkTarget );
		$this->linkRelPolicy   = self::normalizeOptionalString( 'linkRelPolicy', $linkRelPolicy );
		$this->debug           = $debug;
		$this->requestId       = self::normalizeOptionalString( 'requestId', $requestId );
	}

	/**
	 * Construct a context targeting the live frontend surface.
	 *
	 * Convenience factory for the common case: `surface` is
	 * `frontend`, `lazyLoadEnabled` and `trackingEnabled` default
	 * to true, AMP / feed / debug default to false, and the
	 * remaining fields are accepted as named arguments. Tests and
	 * controllers that need an exotic combination should use the
	 * primary constructor directly.
	 *
	 * @param string      $template        §6.4 template key.
	 * @param string|null $locale          BCP-47 locale tag.
	 * @param string|null $requestId       Correlation identifier.
	 */
	public static function frontend(
		string $template,
		?string $locale = null,
		?string $requestId = null
	): self {
		return new self(
			self::SURFACE_FRONTEND,
			$template,
			$locale,
			false,
			false,
			true,
			true,
			null,
			null,
			false,
			$requestId
		);
	}

	/**
	 * Construct a context targeting the Gutenberg block editor
	 * preview surface.
	 *
	 * Editor previews disable tracking (admin views must not
	 * inflate affiliate attribution counters) and disable
	 * lazy-loading (the editor preview is small and visible on
	 * load). The remaining axes follow the primary constructor's
	 * defaults; callers that need to adjust them can chain
	 * `with*()` helpers.
	 *
	 * @param string      $template        §6.4 template key.
	 * @param string|null $locale          BCP-47 locale tag.
	 * @param string|null $requestId       Correlation identifier.
	 */
	public static function editor(
		string $template,
		?string $locale = null,
		?string $requestId = null
	): self {
		return new self(
			self::SURFACE_EDITOR,
			$template,
			$locale,
			false,
			false,
			false,
			false,
			null,
			null,
			false,
			$requestId
		);
	}

	/**
	 * Closed set of accepted §5.2 surface tokens, in declaration
	 * order.
	 *
	 * Exposed for tests and for diagnostics surfaces that want to
	 * enumerate the valid tokens without duplicating the list.
	 *
	 * @return list<string>
	 */
	public static function validSurfaces(): array {
		return self::VALID_SURFACES;
	}

	/**
	 * §5.2 surface token this context targets.
	 */
	public function surface(): string {
		return $this->surface;
	}

	/**
	 * §6.4 template key the renderer should pick.
	 */
	public function template(): string {
		return $this->template;
	}

	/**
	 * BCP-47 locale tag, or null.
	 */
	public function locale(): ?string {
		return $this->locale;
	}

	/**
	 * Whether the host page is rendering in AMP mode.
	 */
	public function isAmp(): bool {
		return $this->isAmp;
	}

	/**
	 * Whether the request is rendering inside a feed.
	 */
	public function isFeed(): bool {
		return $this->isFeed;
	}

	/**
	 * Whether tracked URLs are wanted on outgoing links.
	 */
	public function trackingEnabled(): bool {
		return $this->trackingEnabled;
	}

	/**
	 * Whether image elements should carry `loading="lazy"`.
	 */
	public function lazyLoadEnabled(): bool {
		return $this->lazyLoadEnabled;
	}

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

	/**
	 * Explicit `rel` policy token, or null.
	 */
	public function linkRelPolicy(): ?string {
		return $this->linkRelPolicy;
	}

	/**
	 * Whether the renderer should emit diagnostic markup.
	 */
	public function debug(): bool {
		return $this->debug;
	}

	/**
	 * Opaque correlation identifier, or null.
	 */
	public function requestId(): ?string {
		return $this->requestId;
	}

	/**
	 * Convenience guard: report whether the context targets the
	 * live frontend surface.
	 *
	 * Exposed because the §5.2 surface set has a single dominant
	 * "production rendering" token that consuming surfaces
	 * routinely branch on; a dedicated helper keeps those branches
	 * readable without forcing every call site to compare against
	 * the raw string constant.
	 */
	public function isFrontend(): bool {
		return $this->surface === self::SURFACE_FRONTEND;
	}

	/**
	 * Convenience guard: report whether the context targets the
	 * Gutenberg block editor preview surface.
	 */
	public function isEditor(): bool {
		return $this->surface === self::SURFACE_EDITOR;
	}

	/**
	 * Produce a copy of this context with `template` replaced.
	 *
	 * Used by the ViewModel factory when it picks a more specific
	 * template than the consuming surface defaulted to (for
	 * example downgrading to `single-fallback-card` when the
	 * snapshot is missing required fields). Other fields are
	 * preserved verbatim.
	 *
	 * @param string $template New §6.4 template key.
	 *
	 * @throws InvalidArgumentException When `$template` is empty
	 *                                  after trimming.
	 */
	public function withTemplate( string $template ): self {
		$clone           = clone $this;
		$clone->template = self::normalizeRequiredString( 'template', $template );
		return $clone;
	}

	/**
	 * Produce a copy of this context with `surface` replaced.
	 *
	 * Used by sub-renderers that need to adjust their rendering
	 * posture (an editor preview embedded inside a `rest_preview`
	 * response, for example). Other fields are preserved verbatim.
	 *
	 * @param string $surface New §5.2 surface token.
	 *
	 * @throws InvalidArgumentException When `$surface` is not a
	 *                                  member of the §5.2 closed
	 *                                  set.
	 */
	public function withSurface( string $surface ): self {
		$clone          = clone $this;
		$clone->surface = self::normalizeSurface( $surface );
		return $clone;
	}

	/**
	 * Produce a copy of this context with `debug` replaced.
	 *
	 * Used by the diagnostics surface to produce a pair of
	 * "with debug" / "without debug" snapshots for the same
	 * render. Other fields are preserved verbatim.
	 *
	 * @param bool $debug New diagnostic-markup flag.
	 */
	public function withDebug( bool $debug ): self {
		$clone        = clone $this;
		$clone->debug = $debug;
		return $clone;
	}

	/**
	 * Validate and normalize the `surface` field at construction.
	 *
	 * The surface is trimmed first so a carelessly-decoded payload
	 * (trailing newline, padded space) is normalized to the same
	 * canonical form a freshly-constructed surface would carry.
	 * The trimmed value MUST appear in {@see self::VALID_SURFACES};
	 * anything else raises {@see InvalidArgumentException} carrying
	 * the offending input verbatim and a comma-separated list of
	 * the accepted tokens for diagnosis.
	 *
	 * @param string $surface Raw surface as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$surface` is not a
	 *                                  member of the §5.2 closed
	 *                                  set.
	 */
	private static function normalizeSurface( string $surface ): string {
		$trimmed = trim( $surface );

		if ( ! in_array( $trimmed, self::VALID_SURFACES, true ) ) {
			$message = sprintf(
				'RenderContext surface must be one of [%s]; got "%s".',
				implode( ', ', self::VALID_SURFACES ),
				$surface
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize a required string field at
	 * construction.
	 *
	 * Trims surrounding whitespace and rejects the result when it
	 * is the empty string. Used for the `template` field.
	 *
	 * @param string $field Field name for the error message.
	 * @param string $value Raw value as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$value` is empty
	 *                                  after trimming.
	 */
	private static function normalizeRequiredString( string $field, string $value ): string {
		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'RenderContext %s must be 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 a nullable string field that, when
	 * non-null, must carry non-empty content.
	 *
	 * Used for the `locale` / `linkTarget` / `linkRelPolicy` /
	 * `requestId` fields where "present but empty" is a
	 * programming error rather than a meaningful state.
	 *
	 * @param string      $field Field name for the error message.
	 * @param string|null $value Raw 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(
				'RenderContext %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;
	}
}
