<?php
/**
 * Persistence DTO for the `affilun_embeds` table.
 *
 * Immutable value object that mirrors the canonical column set defined
 * in `specifications/database-schema.md` §6.2. The record is the narrow
 * data surface exchanged between the {@see EmbedRepository} contract
 * and its implementations ({@see InMemoryEmbedRepository},
 * {@see WpdbEmbedRepository}); it deliberately carries no behavior
 * beyond construction-time invariants and field accessors so that:
 *
 *   - Tests can compare two records field-by-field without having to
 *     reach into a mutable graph of domain entities.
 *   - The repository implementations can map rows to and from the
 *     database without a second indirection layer.
 *   - Phase 3 domain entities (reusable embed definitions, rendering
 *     configurations) can be derived from this record without the
 *     persistence layer having to depend on the domain package.
 *
 * Field semantics match `database-schema.md` §6.2 exactly:
 *
 *   - `id` is null for records that have not yet been persisted. The
 *     repository's `save()` contract is responsible for assigning the
 *     auto-increment id and returning a new record that carries it.
 *
 *   - Nullable columns in the schema (`name`, `created_by`,
 *     `updated_by`) are nullable on the record; non-nullable columns
 *     (`uuid`, `type`, `config_json`, timestamps) are required by the
 *     constructor so a programming error surfaces at construction
 *     time rather than at the database boundary.
 *
 *   - `type` is held as a plain string at this layer; the
 *     `type IN ('single','grid')` enum constraint documented in §6.2
 *     is enforced by the domain / REST layer, not by this DTO,
 *     matching the approach taken for `sync_state` /
 *     `lifecycle_state` on {@see ProductRecord}.
 *
 *   - Timestamps (`created_at`, `updated_at`) are held as UTC
 *     `\DateTimeImmutable` instances. The persistence layer converts
 *     to and from the MySQL `Y-m-d H:i:s` representation on the
 *     storage boundary; callers that want a formatted string should
 *     use {@see self::formatDateTime()}.
 *
 * Instances are immutable. To produce a modified copy (for example
 * after the repository has assigned an id or after the application
 * layer has advanced `updated_at`), call {@see self::withId()} or
 * {@see self::withUpdatedAt()}, each of which returns a new instance
 * and leaves the original untouched.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

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

/**
 * Immutable row-shaped value object for `affilun_embeds`.
 *
 * See the file-level docblock for the mapping to `database-schema.md`
 * §6.2 and the rationale for keeping this type behavior-free.
 */
final class EmbedRecord {

	/**
	 * Canonical MySQL `DATETIME` format used by every timestamp column
	 * on this table, matching the WordPress convention of storing UTC
	 * values as `Y-m-d H:i:s`.
	 */
	public const MYSQL_DATETIME_FORMAT = 'Y-m-d H:i:s';

	/**
	 * Auto-increment primary key, or null for unpersisted records.
	 */
	private ?int $id;

	/**
	 * Externally-visible prefixed-ULID identifier per §6.2
	 * (`emb_<26 chars Crockford base32>`). Held as a plain string at
	 * this layer; the format is validated by the application layer.
	 */
	private string $uuid;

	/**
	 * Optional management-oriented display name. Nullable per §6.2.
	 */
	private ?string $name;

	/**
	 * Type discriminator per §6.2. Initial enum is `single` / `grid`;
	 * this DTO does not enforce the set (see file-level docblock).
	 */
	private string $type;

	/**
	 * Serialized JSON configuration blob per §6.4. Held as a plain
	 * string; this layer does not parse the payload.
	 */
	private string $configJson;

	/**
	 * WordPress user id of the creating actor, or null when the
	 * creator is not tracked (for example programmatically seeded
	 * definitions).
	 */
	private ?int $createdBy;

	/**
	 * WordPress user id of the most recent editor, or null.
	 */
	private ?int $updatedBy;

	/**
	 * Row creation timestamp. Required per §6.2.
	 */
	private DateTimeImmutable $createdAt;

	/**
	 * Row update timestamp. Required per §6.2.
	 */
	private DateTimeImmutable $updatedAt;

	/**
	 * Construct a record with an explicit value for every column.
	 *
	 * The constructor is deliberately exhaustive so that a programming
	 * error (missing column on a write) surfaces at the type system
	 * boundary rather than at the database. Named-argument callers and
	 * the `fromArray()` / `toArray()` helpers are the ergonomic entry
	 * points for most code; construction-by-position is reserved for
	 * the repository implementations themselves.
	 *
	 * @param int|null          $id         Primary key, null for unpersisted records.
	 * @param string            $uuid       Prefixed-ULID external identifier.
	 * @param string|null       $name       Optional display name.
	 * @param string            $type       Type discriminator (`single` / `grid`).
	 * @param string            $configJson Serialized configuration JSON.
	 * @param int|null          $createdBy  Creating WordPress user id.
	 * @param int|null          $updatedBy  Last-editor WordPress user id.
	 * @param DateTimeImmutable $createdAt  Row creation timestamp.
	 * @param DateTimeImmutable $updatedAt  Row update timestamp.
	 *
	 * @throws InvalidArgumentException When a non-nullable string is
	 *                                  empty, `id` is non-positive, or
	 *                                  either actor id is non-positive.
	 */
	public function __construct(
		?int $id,
		string $uuid,
		?string $name,
		string $type,
		string $configJson,
		?int $createdBy,
		?int $updatedBy,
		DateTimeImmutable $createdAt,
		DateTimeImmutable $updatedAt
	) {
		if ( $id !== null && $id <= 0 ) {
			$message = sprintf( 'EmbedRecord id must be a positive integer, got %d.', $id );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $createdBy !== null && $createdBy <= 0 ) {
			$message = sprintf( 'EmbedRecord createdBy must be a positive integer, got %d.', $createdBy );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $updatedBy !== null && $updatedBy <= 0 ) {
			$message = sprintf( 'EmbedRecord updatedBy must be a positive integer, got %d.', $updatedBy );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->assertNonEmpty( 'uuid', $uuid );
		$this->assertNonEmpty( 'type', $type );
		$this->assertNonEmpty( 'configJson', $configJson );

		$this->id         = $id;
		$this->uuid       = $uuid;
		$this->name       = $name;
		$this->type       = $type;
		$this->configJson = $configJson;
		$this->createdBy  = $createdBy;
		$this->updatedBy  = $updatedBy;
		$this->createdAt  = self::normalizeToUtc( $createdAt ) ?? $createdAt;
		$this->updatedAt  = self::normalizeToUtc( $updatedAt ) ?? $updatedAt;
	}

	/**
	 * Ergonomic named constructor for fresh (unpersisted) records.
	 *
	 * Required columns are taken as positional arguments; the two
	 * nullable actor columns and the nullable display name flow
	 * through an overrides array so callers that do not track actor
	 * ids can omit them entirely.
	 *
	 * @param string               $uuid       Prefixed-ULID external id.
	 * @param string               $type       Type discriminator.
	 * @param string               $configJson Serialized configuration JSON.
	 * @param DateTimeImmutable    $now        Current UTC timestamp.
	 * @param array<string, mixed> $optional   Optional column overrides keyed by property name.
	 */
	public static function createNew(
		string $uuid,
		string $type,
		string $configJson,
		DateTimeImmutable $now,
		array $optional = array()
	): self {
		$name = null;
		if ( array_key_exists( 'name', $optional ) && $optional['name'] !== null ) {
			$name = (string) $optional['name'];
		}

		$createdBy = null;
		if ( array_key_exists( 'createdBy', $optional ) && $optional['createdBy'] !== null ) {
			$createdBy = (int) $optional['createdBy'];
		}

		$updatedBy = null;
		if ( array_key_exists( 'updatedBy', $optional ) && $optional['updatedBy'] !== null ) {
			$updatedBy = (int) $optional['updatedBy'];
		}

		return new self(
			null,
			$uuid,
			$name,
			$type,
			$configJson,
			$createdBy,
			$updatedBy,
			$now,
			$now
		);
	}

	/**
	 * Reconstitute a record from a row-shaped associative array.
	 *
	 * Intended as the single decode seam used by
	 * {@see WpdbEmbedRepository} when mapping `$wpdb->get_row()`
	 * results back into records. Unknown keys are ignored so callers
	 * can pass raw result rows without having to filter them; missing
	 * non-nullable keys raise {@see InvalidArgumentException}.
	 *
	 * DateTime columns accept either a `DateTimeImmutable` instance
	 * (produced by in-memory repositories) or a `Y-m-d H:i:s` string
	 * (produced by `$wpdb`). Invalid strings raise
	 * {@see InvalidArgumentException}.
	 *
	 * @param array<string, mixed> $row Row-shaped associative array.
	 *
	 * @throws InvalidArgumentException When a required key is missing
	 *                                  or a timestamp string cannot
	 *                                  be parsed.
	 */
	public static function fromArray( array $row ): self {
		$requireString = static function ( string $key ) use ( $row ): string {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				$message = sprintf( 'EmbedRecord::fromArray() missing required key "%s".', $key );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			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 ];
		};

		$optionalInt = static function ( string $key ) use ( $row ): ?int {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				return null;
			}
			return (int) $row[ $key ];
		};

		$requireDate = static function ( string $key ) use ( $row ): DateTimeImmutable {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				$message = sprintf( 'EmbedRecord::fromArray() missing required timestamp "%s".', $key );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			return self::parseDateTime( $key, $row[ $key ] );
		};

		$idRaw = $row['id'] ?? null;
		$id    = $idRaw === null ? null : (int) $idRaw;

		return new self(
			$id,
			$requireString( 'uuid' ),
			$optionalString( 'name' ),
			$requireString( 'type' ),
			$requireString( 'config_json' ),
			$optionalInt( 'created_by' ),
			$optionalInt( 'updated_by' ),
			$requireDate( 'created_at' ),
			$requireDate( 'updated_at' )
		);
	}

	/**
	 * Encode the record as a row-shaped associative array keyed by the
	 * canonical snake_case column names from §6.2.
	 *
	 * Used by {@see WpdbEmbedRepository::save()} to produce the payload
	 * handed to `$wpdb->insert()` / `$wpdb->update()`. Timestamps are
	 * formatted via {@see self::formatDateTime()} so the array is
	 * round-trippable through {@see self::fromArray()}.
	 *
	 * @return array<string, string|int|null>
	 */
	public function toArray(): array {
		return array(
			'id'          => $this->id,
			'uuid'        => $this->uuid,
			'name'        => $this->name,
			'type'        => $this->type,
			'config_json' => $this->configJson,
			'created_by'  => $this->createdBy,
			'updated_by'  => $this->updatedBy,
			'created_at'  => self::formatDateTime( $this->createdAt ),
			'updated_at'  => self::formatDateTime( $this->updatedAt ),
		);
	}

	/**
	 * Produce a copy of this record with the given primary-key id
	 * assigned. Used by the repository's `save()` path immediately
	 * after an insert to surface the database-assigned id to callers.
	 *
	 * @param int $id Newly-assigned primary key. Must be positive.
	 *
	 * @throws InvalidArgumentException When `$id` is non-positive.
	 */
	public function withId( int $id ): self {
		if ( $id <= 0 ) {
			$message = sprintf( 'EmbedRecord::withId() requires a positive integer, got %d.', $id );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$clone     = clone $this;
		$clone->id = $id;
		return $clone;
	}

	/**
	 * Produce a copy of this record with a new `updated_at` timestamp.
	 *
	 * Used by the repository's `save()` path on the update branch so
	 * `updated_at` advances even when no other field changed.
	 *
	 * @param DateTimeImmutable $updatedAt New `updated_at` value; normalized to UTC.
	 */
	public function withUpdatedAt( DateTimeImmutable $updatedAt ): self {
		$clone            = clone $this;
		$clone->updatedAt = self::normalizeToUtc( $updatedAt ) ?? $updatedAt;
		return $clone;
	}

	/**
	 * Auto-increment primary key, or null for unpersisted records.
	 */
	public function id(): ?int {
		return $this->id;
	}

	/**
	 * Report whether this record has been assigned a primary key.
	 */
	public function isPersisted(): bool {
		return $this->id !== null;
	}

	/**
	 * Prefixed-ULID external identifier per §6.2.
	 */
	public function uuid(): string {
		return $this->uuid;
	}

	/**
	 * Optional management-oriented display name.
	 */
	public function name(): ?string {
		return $this->name;
	}

	/**
	 * Type discriminator per §6.2.
	 */
	public function type(): string {
		return $this->type;
	}

	/**
	 * Serialized JSON configuration blob per §6.4.
	 */
	public function configJson(): string {
		return $this->configJson;
	}

	/**
	 * WordPress user id of the creating actor, or null.
	 */
	public function createdBy(): ?int {
		return $this->createdBy;
	}

	/**
	 * WordPress user id of the most recent editor, or null.
	 */
	public function updatedBy(): ?int {
		return $this->updatedBy;
	}

	/**
	 * UTC `created_at` timestamp for this record.
	 */
	public function createdAt(): DateTimeImmutable {
		return $this->createdAt;
	}

	/**
	 * UTC `updated_at` timestamp for this record.
	 */
	public function updatedAt(): DateTimeImmutable {
		return $this->updatedAt;
	}

	/**
	 * Format a {@see DateTimeInterface} as a canonical MySQL `DATETIME`
	 * string in UTC, or return null when the input is null.
	 *
	 * Exposed as a public static helper so the repository implementations
	 * and tests can round-trip timestamps through the same path.
	 *
	 * @param DateTimeInterface|null $value Timestamp to format.
	 */
	public static function formatDateTime( ?DateTimeInterface $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$utc = $value instanceof DateTimeImmutable
			? $value->setTimezone( new DateTimeZone( 'UTC' ) )
			: DateTimeImmutable::createFromInterface( $value )->setTimezone( new DateTimeZone( 'UTC' ) );

		return $utc->format( self::MYSQL_DATETIME_FORMAT );
	}

	/**
	 * Parse a MySQL `DATETIME` string (or an already-constructed
	 * {@see DateTimeImmutable}) into a UTC `DateTimeImmutable`.
	 *
	 * @param string $key   Column name, used only in the
	 *                      error message to surface which
	 *                      timestamp failed to parse.
	 * @param mixed  $value Value to parse.
	 *
	 * @throws InvalidArgumentException When `$value` is not a
	 *                                  {@see DateTimeImmutable} or a
	 *                                  parseable MySQL `DATETIME`
	 *                                  string.
	 */
	private static function parseDateTime( string $key, $value ): DateTimeImmutable {
		if ( $value instanceof DateTimeImmutable ) {
			return self::normalizeToUtc( $value ) ?? $value;
		}

		if ( is_string( $value ) && $value !== '' ) {
			$parsed = DateTimeImmutable::createFromFormat(
				self::MYSQL_DATETIME_FORMAT,
				$value,
				new DateTimeZone( 'UTC' )
			);

			if ( $parsed instanceof DateTimeImmutable ) {
				return $parsed;
			}

			try {
				return new DateTimeImmutable( $value, new DateTimeZone( 'UTC' ) );
				// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Fall through to the descriptive throw below.
			} catch ( \Exception $unused ) {
				// Fall through to the descriptive throw below.
				unset( $unused );
			}
		}

		$message = sprintf(
			'EmbedRecord::fromArray() could not parse "%s" as a MySQL DATETIME.',
			$key
		);
		// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
		throw new InvalidArgumentException( $message );
	}

	/**
	 * Normalize a nullable timestamp to UTC so equality checks and
	 * repository round-trips do not depend on the caller's timezone.
	 *
	 * @param DateTimeImmutable|null $value Value to normalize.
	 */
	private static function normalizeToUtc( ?DateTimeImmutable $value ): ?DateTimeImmutable {
		if ( $value === null ) {
			return null;
		}

		return $value->setTimezone( new DateTimeZone( 'UTC' ) );
	}

	/**
	 * Guard against an empty string for a column that the schema
	 * declares as `NOT NULL`.
	 *
	 * @param string $field Column / property name for the error message.
	 * @param string $value Value to validate.
	 *
	 * @throws InvalidArgumentException When `$value` is the empty string.
	 */
	private function assertNonEmpty( string $field, string $value ): void {
		if ( $value === '' ) {
			$message = sprintf( 'EmbedRecord requires a non-empty value for "%s".', $field );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}
}
