<?php
/**
 * Fallback card view model.
 *
 * Immutable aggregator value object that carries the §8.4
 * canonical FallbackCardViewModel structure the rendering pipeline
 * passes to a single-product fallback template when the full §8.2
 * ProductCardViewModel cannot be composed. The type collects the
 * §8.4 minimum-viable fields — optional title, optional primary
 * button, closed-set fallback reason, optional admin-facing note,
 * and the {@see CssClasses} pre-tokenized class strings — into a
 * single validated value so renderers and templates do not have to
 * branch on the underlying degradation cause at render time.
 *
 * Field semantics match `specifications/rendering-and-template-spec.md`
 * §8.4 / §13.3 exactly:
 *
 *   - `type` is the §8.4 fixed discriminator string
 *     `'fallback-card'`. The constructor enforces the value so a
 *     programming error in a calling factory surfaces here rather
 *     than producing a payload a downstream polymorphic dispatch
 *     would route to the wrong template family. Exposed as the
 *     class constant {@see self::TYPE}.
 *
 *   - `template` is the §6.4 template key the renderer should pick
 *     when the {@see \Affilun\Application\Render\TemplateResolver}
 *     resolves the actual fallback template file. Held as a plain
 *     string at this layer; the canonical default is
 *     `'single-fallback-card'`.
 *
 *   - `title` is the §8.4 "タイトルがあれば表示" optional title
 *     string. Nullable because some degradation paths (deleted
 *     product, provider unavailable) cannot recover any title at
 *     all; in that case the renderer suppresses the title region
 *     entirely.
 *
 *   - `primaryButton` is the §8.4 "ボタンがあれば表示" optional
 *     primary button. Nullable because a fallback card may be
 *     rendered without any actionable link; carried as a single
 *     {@see ButtonView} (not a list) because §8.4 explicitly
 *     limits the fallback shape to "title + one optional button".
 *
 *   - `reason` is the closed-set degradation cause token mapped
 *     onto the §8.4 「想定用途」 enumeration. The DTO maps the
 *     spec's bullets onto the canonical machine tokens
 *     `'product_deleted'`, `'sync_failed'`, `'provider_unavailable'`,
 *     `'insufficient_data'`, `'image_missing'`, `'price_hidden'`
 *     so the rendering / observability layers can branch on the
 *     cause without parsing a localized note string.
 *
 *   - `note` is the §13.3 / §23.1 admin-facing diagnostic note
 *     ("provider X returned 503 at ..."). Nullable, and MUST be
 *     `null` for end-user surfaces per §13.3 / §23.1 ("エラー詳細
 *     を一般ユーザーに見せない"); the renderer is responsible for
 *     enforcing the visibility policy keyed off the
 *     {@see \Affilun\Domain\Render\RenderContext}.
 *
 *   - `classes` is the §8.4 BEM class container, carried as a
 *     {@see CssClasses} view so the fallback template can stamp
 *     the same root / title / buttons class strings the §8.2
 *     ProductCardViewModel uses.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because a fallback view model is composed once per render at the
 * fallback factory boundary, and changing any field changes the
 * meaning of the fallback payload.
 *
 * @package Affilun\Application\Render\ViewModel
 */

declare(strict_types=1);

namespace Affilun\Application\Render\ViewModel;

use Affilun\Application\Render\ViewModel\Parts\ButtonView;
use Affilun\Application\Render\ViewModel\Parts\CssClasses;
use InvalidArgumentException;

/**
 * Immutable §8.4 FallbackCardViewModel aggregator.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §8.4 / §13.3 and the per-field
 * validation rules.
 */
final class FallbackCardViewModel {

	/**
	 * §8.4 fixed `type` discriminator string.
	 */
	public const TYPE = 'fallback-card';

	/**
	 * §8.4 fixed `kind` cross-VM polymorphic discriminator.
	 *
	 * Held alongside {@see self::TYPE} so a polymorphic dispatch
	 * over the union of all ViewModel aggregators can branch on a
	 * single uniform constant name without inspecting the
	 * concrete class.
	 */
	public const KIND = 'fallback-card';

	/**
	 * Canonical §8.4 default template key.
	 */
	public const DEFAULT_TEMPLATE = 'single-fallback-card';

	/**
	 * §8.4 「想定用途」 "商品削除済み" reason token.
	 */
	public const REASON_PRODUCT_DELETED = 'product_deleted';

	/**
	 * §8.4 「想定用途」 "同期失敗中" reason token.
	 */
	public const REASON_SYNC_FAILED = 'sync_failed';

	/**
	 * §8.4 「想定用途」 "provider 情報不足" reason token.
	 */
	public const REASON_PROVIDER_UNAVAILABLE = 'provider_unavailable';

	/**
	 * §8.4 「想定用途」 generic "情報不足" reason token.
	 */
	public const REASON_INSUFFICIENT_DATA = 'insufficient_data';

	/**
	 * §8.4 「想定用途」 "画像なし" reason token.
	 */
	public const REASON_IMAGE_MISSING = 'image_missing';

	/**
	 * §8.4 「想定用途」 "価格非表示" reason token.
	 */
	public const REASON_PRICE_HIDDEN = 'price_hidden';

	/**
	 * Closed set of accepted §8.4 reason 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_REASONS = array(
		self::REASON_PRODUCT_DELETED,
		self::REASON_SYNC_FAILED,
		self::REASON_PROVIDER_UNAVAILABLE,
		self::REASON_INSUFFICIENT_DATA,
		self::REASON_IMAGE_MISSING,
		self::REASON_PRICE_HIDDEN,
	);

	/**
	 * §8.4 fixed discriminator string.
	 */
	private string $type;

	/**
	 * §6.4 template key the renderer should pick.
	 */
	private string $template;

	/**
	 * §8.4 optional title string.
	 */
	private ?string $title;

	/**
	 * §8.4 optional primary button.
	 */
	private ?ButtonView $primaryButton;

	/**
	 * One of the {@see self::REASON_*} tokens.
	 */
	private string $reason;

	/**
	 * §13.3 / §23.1 admin-facing diagnostic note, or null.
	 */
	private ?string $note;

	/**
	 * §8.4 BEM class container.
	 */
	private CssClasses $classes;

	/**
	 * Construct a fallback card view model with explicit values
	 * for every field.
	 *
	 * Validation summary:
	 *
	 *   - `type` is trimmed and matched against {@see self::TYPE};
	 *     out-of-set tokens raise {@see InvalidArgumentException}.
	 *   - `template` is trimmed and rejected when empty after
	 *     trimming.
	 *   - `title`, `note`, when non-null, are trimmed and rejected
	 *     when the trim leaves an empty string.
	 *   - `reason` is trimmed and matched against
	 *     {@see self::VALID_REASONS}; out-of-set tokens are
	 *     rejected with the offending value verbatim and the
	 *     accepted-token list for diagnosis.
	 *
	 * @param string          $type          §8.4 discriminator string.
	 * @param string          $template      §6.4 template key.
	 * @param string|null     $title         Optional title string.
	 * @param ButtonView|null $primaryButton Optional primary button.
	 * @param string          $reason        One of {@see self::VALID_REASONS}.
	 * @param string|null     $note          Optional admin-facing note.
	 * @param CssClasses      $classes       §8.4 BEM class container.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		string $type,
		string $template,
		?string $title,
		?ButtonView $primaryButton,
		string $reason,
		?string $note,
		CssClasses $classes
	) {
		$this->type          = self::normalizeType( $type );
		$this->template      = self::normalizeTemplate( $template );
		$this->title         = self::normalizeOptionalString( 'title', $title );
		$this->primaryButton = $primaryButton;
		$this->reason        = self::normalizeReason( $reason );
		$this->note          = self::normalizeOptionalString( 'note', $note );
		$this->classes       = $classes;
	}

	/**
	 * Construct a fallback card view model keyed off a reason
	 * token.
	 *
	 * Convenience factory so call sites do not have to spell out
	 * the fixed `type` argument and can default the template to
	 * {@see self::DEFAULT_TEMPLATE} when the canonical fallback
	 * template suffices.
	 *
	 * @param string          $reason        One of {@see self::VALID_REASONS}.
	 * @param CssClasses      $classes       §8.4 BEM class container.
	 * @param string|null     $title         Optional title string.
	 * @param ButtonView|null $primaryButton Optional primary button.
	 * @param string|null     $note          Optional admin-facing note.
	 * @param string          $template      §6.4 template key.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules documented
	 *                                  on the constructor.
	 */
	public static function forReason(
		string $reason,
		CssClasses $classes,
		?string $title = null,
		?ButtonView $primaryButton = null,
		?string $note = null,
		string $template = self::DEFAULT_TEMPLATE
	): self {
		return new self(
			self::TYPE,
			$template,
			$title,
			$primaryButton,
			$reason,
			$note,
			$classes
		);
	}

	/**
	 * Closed set of accepted §8.4 reason tokens, in declaration
	 * order.
	 *
	 * @return list<string>
	 */
	public static function validReasons(): array {
		return self::VALID_REASONS;
	}

	/**
	 * §8.4 fixed `type` discriminator string.
	 */
	public function type(): string {
		return $this->type;
	}

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

	/**
	 * §8.4 optional title string.
	 */
	public function title(): ?string {
		return $this->title;
	}

	/**
	 * §8.4 optional primary button.
	 */
	public function primaryButton(): ?ButtonView {
		return $this->primaryButton;
	}

	/**
	 * §8.4 reason token.
	 */
	public function reason(): string {
		return $this->reason;
	}

	/**
	 * §13.3 / §23.1 admin-facing diagnostic note, or null.
	 */
	public function note(): ?string {
		return $this->note;
	}

	/**
	 * §8.4 BEM class container.
	 */
	public function classes(): CssClasses {
		return $this->classes;
	}

	/**
	 * Report whether this aggregator is the §8.4 fallback variant.
	 *
	 * Always true for {@see FallbackCardViewModel}; the
	 * {@see ProductCardViewModel} sibling returns false.
	 */
	public function isFallback(): bool {
		return true;
	}

	/**
	 * Report whether this view model equals the supplied one.
	 *
	 * Equality is structural: every scalar field must match with
	 * strict comparison, the optional {@see ButtonView} slot must
	 * agree on presence and content, and the {@see CssClasses}
	 * slot must compare equal under its own `equals()`.
	 *
	 * @param self $other Other view model to compare against.
	 */
	public function equals( self $other ): bool {
		if ( $this->type !== $other->type ) {
			return false;
		}

		if ( $this->template !== $other->template ) {
			return false;
		}

		if ( $this->title !== $other->title ) {
			return false;
		}

		if ( ( $this->primaryButton === null ) !== ( $other->primaryButton === null ) ) {
			return false;
		}

		if (
			$this->primaryButton !== null
			&& $other->primaryButton !== null
			&& ! $this->primaryButton->equals( $other->primaryButton )
		) {
			return false;
		}

		if ( $this->reason !== $other->reason ) {
			return false;
		}

		if ( $this->note !== $other->note ) {
			return false;
		}

		if ( ! $this->classes->equals( $other->classes ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Validate and normalize the `type` field at construction.
	 *
	 * @param string $type Raw discriminator as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$type` is not the
	 *                                  §8.4 discriminator string.
	 */
	private static function normalizeType( string $type ): string {
		$trimmed = trim( $type );

		if ( $trimmed !== self::TYPE ) {
			$message = sprintf(
				'FallbackCardViewModel type must be "%s"; got "%s".',
				self::TYPE,
				$type
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize the `template` field at construction.
	 *
	 * @param string $template Raw template key as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$template` is empty
	 *                                  after trimming.
	 */
	private static function normalizeTemplate( string $template ): string {
		$trimmed = trim( $template );
		if ( $trimmed === '' ) {
			$message = 'FallbackCardViewModel template must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize the `reason` field at construction.
	 *
	 * The token is trimmed first so a carelessly-decoded payload
	 * is normalized to the same canonical form a freshly-
	 * constructed token would carry. The trimmed value MUST
	 * appear in {@see self::VALID_REASONS}.
	 *
	 * @param string $reason Raw token as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$reason` is not a
	 *                                  member of the closed set.
	 */
	private static function normalizeReason( string $reason ): string {
		$trimmed = trim( $reason );

		if ( ! in_array( $trimmed, self::VALID_REASONS, true ) ) {
			$message = sprintf(
				'FallbackCardViewModel reason must be one of [%s]; got "%s".',
				implode( ', ', self::VALID_REASONS ),
				$reason
			);
			// 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.
	 *
	 * @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(
				'FallbackCardViewModel %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;
	}
}
