<?php
/**
 * Persistence DTO for URLs attached to an affilun product.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing exception messages are never rendered as HTML.

use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Immutable row-shaped value object for `affilun_product_urls`.
 */
final class ProductUrlRecord {

	public const MYSQL_DATETIME_FORMAT = 'Y-m-d H:i:s';

	/**
	 * Database row id.
	 */
	private ?int $id;

	/**
	 * Owning product id.
	 */
	private int $productId;

	/**
	 * Provider namespace.
	 */
	private string $providerType;

	/**
	 * Optional provider-side item identifier.
	 */
	private ?string $providerItemId;

	/**
	 * Product or affiliate URL.
	 */
	private string $url;

	/**
	 * Normalized URL hash used for uniqueness.
	 */
	private string $urlHash;

	/**
	 * Source that produced the URL row.
	 */
	private string $source;

	/**
	 * Creation timestamp in UTC.
	 */
	private DateTimeImmutable $createdAt;

	/**
	 * Update timestamp in UTC.
	 */
	private DateTimeImmutable $updatedAt;

	/**
	 * @param int|null          $id             Database row id.
	 * @param int               $productId      Owning product id.
	 * @param string            $providerType   Provider namespace.
	 * @param string|null       $providerItemId Optional provider-side item identifier.
	 * @param string            $url            Product or affiliate URL.
	 * @param string            $urlHash        Normalized URL hash.
	 * @param string            $source         Source that produced the URL row.
	 * @param DateTimeImmutable $createdAt      Creation timestamp.
	 * @param DateTimeImmutable $updatedAt      Update timestamp.
	 */
	public function __construct(
		?int $id,
		int $productId,
		string $providerType,
		?string $providerItemId,
		string $url,
		string $urlHash,
		string $source,
		DateTimeImmutable $createdAt,
		DateTimeImmutable $updatedAt
	) {
		if ( $id !== null && $id <= 0 ) {
			throw new InvalidArgumentException( sprintf( 'ProductUrlRecord id must be positive, got %d.', $id ) );
		}
		if ( $productId <= 0 ) {
			throw new InvalidArgumentException( sprintf( 'ProductUrlRecord productId must be positive, got %d.', $productId ) );
		}
		$this->assertNonEmpty( 'providerType', $providerType );
		$this->assertNonEmpty( 'url', $url );
		$this->assertNonEmpty( 'urlHash', $urlHash );
		$this->assertNonEmpty( 'source', $source );

		$this->id             = $id;
		$this->productId      = $productId;
		$this->providerType   = $providerType;
		$this->providerItemId = self::normalizeOptionalString( $providerItemId );
		$this->url            = $url;
		$this->urlHash        = $urlHash;
		$this->source         = $source;
		$this->createdAt      = self::normalizeToUtc( $createdAt ) ?? $createdAt;
		$this->updatedAt      = self::normalizeToUtc( $updatedAt ) ?? $updatedAt;
	}

	/**
	 * Build a new unsaved URL record.
	 *
	 * @param int                 $productId      Owning product id.
	 * @param string              $providerType   Provider namespace.
	 * @param string|null         $providerItemId Optional provider-side item identifier.
	 * @param string              $url            Product or affiliate URL.
	 * @param DateTimeImmutable   $now            Creation/update timestamp.
	 * @param array<string,mixed> $optional Optional overrides.
	 */
	public static function createNew(
		int $productId,
		string $providerType,
		?string $providerItemId,
		string $url,
		DateTimeImmutable $now,
		array $optional = array()
	): self {
		$source  = isset( $optional['source'] ) && $optional['source'] !== null ? (string) $optional['source'] : 'user';
		$urlHash = isset( $optional['urlHash'] ) && $optional['urlHash'] !== null
			? (string) $optional['urlHash']
			: self::hashUrl( $url );

		return new self( null, $productId, $providerType, $providerItemId, $url, $urlHash, $source, $now, $now );
	}

	/**
	 * @param array<string,mixed> $row Row data.
	 */
	public static function fromArray( array $row ): self {
		$requireString  = static function ( string $key ) use ( $row ): string {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				throw new InvalidArgumentException( sprintf( 'ProductUrlRecord::fromArray() missing required key "%s".', $key ) );
			}
			return (string) $row[ $key ];
		};
		$optionalString = static function ( string $key ) use ( $row ): ?string {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				return null;
			}
			return (string) $row[ $key ];
		};
		$requireInt     = static function ( string $key ) use ( $row ): int {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				throw new InvalidArgumentException( sprintf( 'ProductUrlRecord::fromArray() missing required integer "%s".', $key ) );
			}
			return (int) $row[ $key ];
		};
		$requireDate    = static function ( string $key ) use ( $row ): DateTimeImmutable {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				throw new InvalidArgumentException( sprintf( 'ProductUrlRecord::fromArray() missing required timestamp "%s".', $key ) );
			}
			return self::parseDateTime( $key, $row[ $key ] );
		};

		$idRaw = $row['id'] ?? null;

		return new self(
			$idRaw === null ? null : (int) $idRaw,
			$requireInt( 'product_id' ),
			$requireString( 'provider_type' ),
			$optionalString( 'provider_item_id' ),
			$requireString( 'url' ),
			$requireString( 'url_hash' ),
			$requireString( 'source' ),
			$requireDate( 'created_at' ),
			$requireDate( 'updated_at' )
		);
	}

	/**
	 * @return array<string,string|int|null>
	 */
	public function toArray(): array {
		return array(
			'id'               => $this->id,
			'product_id'       => $this->productId,
			'provider_type'    => $this->providerType,
			'provider_item_id' => $this->providerItemId,
			'url'              => $this->url,
			'url_hash'         => $this->urlHash,
			'source'           => $this->source,
			'created_at'       => self::formatDateTime( $this->createdAt ),
			'updated_at'       => self::formatDateTime( $this->updatedAt ),
		);
	}

	/**
	 * Return a copy with the database row id set.
	 *
	 * @param int $id Database row id.
	 */
	public function withId( int $id ): self {
		if ( $id <= 0 ) {
			throw new InvalidArgumentException( sprintf( 'ProductUrlRecord::withId() requires a positive id, got %d.', $id ) );
		}
		$clone     = clone $this;
		$clone->id = $id;
		return $clone;
	}

	/**
	 * Return a copy with the owning product id changed.
	 *
	 * @param int $productId Owning product id.
	 */
	public function withProductId( int $productId ): self {
		if ( $productId <= 0 ) {
			throw new InvalidArgumentException( sprintf( 'ProductUrlRecord::withProductId() requires a positive id, got %d.', $productId ) );
		}
		$clone            = clone $this;
		$clone->productId = $productId;
		return $clone;
	}

	/**
	 * Return a copy with the update timestamp changed.
	 *
	 * @param DateTimeImmutable $updatedAt Update timestamp.
	 */
	public function withUpdatedAt( DateTimeImmutable $updatedAt ): self {
		$clone            = clone $this;
		$clone->updatedAt = self::normalizeToUtc( $updatedAt ) ?? $updatedAt;
		return $clone;
	}

	/**
	 * Return the database row id.
	 */
	public function id(): ?int {
		return $this->id;
	}

	/**
	 * Return the owning product id.
	 */
	public function productId(): int {
		return $this->productId;
	}

	/**
	 * Return the provider namespace.
	 */
	public function providerType(): string {
		return $this->providerType;
	}

	/**
	 * Return the optional provider-side item identifier.
	 */
	public function providerItemId(): ?string {
		return $this->providerItemId;
	}

	/**
	 * Return the product or affiliate URL.
	 */
	public function url(): string {
		return $this->url;
	}

	/**
	 * Return the normalized URL hash.
	 */
	public function urlHash(): string {
		return $this->urlHash;
	}

	/**
	 * Return the source that produced the URL row.
	 */
	public function source(): string {
		return $this->source;
	}

	/**
	 * Return the creation timestamp.
	 */
	public function createdAt(): DateTimeImmutable {
		return $this->createdAt;
	}

	/**
	 * Return the update timestamp.
	 */
	public function updatedAt(): DateTimeImmutable {
		return $this->updatedAt;
	}

	/**
	 * Hash a URL for uniqueness checks.
	 *
	 * @param string $url URL to hash.
	 */
	public static function hashUrl( string $url ): string {
		return hash( 'sha256', trim( $url ) );
	}

	/**
	 * Assert that a string field is non-empty.
	 *
	 * @param string $field Field name.
	 * @param string $value Field value.
	 */
	private function assertNonEmpty( string $field, string $value ): void {
		if ( trim( $value ) === '' ) {
			throw new InvalidArgumentException( sprintf( 'ProductUrlRecord %s must be non-empty.', $field ) );
		}
	}

	/**
	 * Trim an optional string and normalize blanks to null.
	 *
	 * @param string|null $value Optional string.
	 */
	private static function normalizeOptionalString( ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}
		$trimmed = trim( $value );
		return $trimmed === '' ? null : $trimmed;
	}

	/**
	 * Normalize a timestamp to UTC.
	 *
	 * @param DateTimeInterface|null $value Timestamp.
	 */
	private static function normalizeToUtc( ?DateTimeInterface $value ): ?DateTimeImmutable {
		if ( $value === null ) {
			return null;
		}
		$immutable = $value instanceof DateTimeImmutable
			? $value
			: DateTimeImmutable::createFromInterface( $value );
		return $immutable->setTimezone( new DateTimeZone( 'UTC' ) );
	}

	/**
	 * Parse a MySQL datetime value as UTC.
	 *
	 * @param string $key   Source field name.
	 * @param mixed  $value Raw timestamp value.
	 */
	private static function parseDateTime( string $key, mixed $value ): DateTimeImmutable {
		if ( $value instanceof DateTimeImmutable ) {
			return self::normalizeToUtc( $value ) ?? $value;
		}
		if ( ! is_string( $value ) || trim( $value ) === '' ) {
			throw new InvalidArgumentException( sprintf( 'ProductUrlRecord timestamp "%s" must be a non-empty string.', $key ) );
		}
		$parsed = DateTimeImmutable::createFromFormat( self::MYSQL_DATETIME_FORMAT, $value, new DateTimeZone( 'UTC' ) );
		if ( ! $parsed instanceof DateTimeImmutable ) {
			throw new InvalidArgumentException( sprintf( 'ProductUrlRecord timestamp "%s" is invalid.', $key ) );
		}
		return $parsed;
	}

	/**
	 * Format a timestamp for MySQL storage.
	 *
	 * @param DateTimeInterface $value Timestamp.
	 */
	private static function formatDateTime( DateTimeInterface $value ): string {
		return ( self::normalizeToUtc( $value ) ?? DateTimeImmutable::createFromInterface( $value ) )
			->format( self::MYSQL_DATETIME_FORMAT );
	}
}
