<?php
/**
 * Persistence DTO for the `affilun_product_meta` table.
 *
 * Immutable value object that mirrors the canonical column set defined
 * in `specifications/database-schema.md` §5.2. The record is the narrow
 * data surface exchanged between the {@see ProductMetaRepository}
 * contract and its implementations
 * ({@see InMemoryProductMetaRepository},
 * {@see WpdbProductMetaRepository}); 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 code (provider snapshots that flow optional
 *     attributes into the meta table) can build richer domain types
 *     on top of this record without the persistence layer having to
 *     depend on them.
 *
 * Field semantics match `database-schema.md` §5.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.
 *
 *   - Required (`NOT NULL`) columns are non-nullable on the record:
 *     `productId`, `metaKey`, `createdAt`, `updatedAt`.
 *
 *   - The single nullable column from the schema (`meta_value`) is
 *     nullable on the record so callers can represent "key present
 *     but value unset" without having to synthesize a sentinel
 *     string.
 *
 *   - `productId` is a `bigint unsigned` foreign-key-style reference
 *     to `affilun_products.id`; the record rejects non-positive values at
 *     construction time so a programming error (wrapped-around cast,
 *     accidental `0` sentinel) surfaces before the database boundary.
 *     Per `database-schema.md` §11.2, AFFILUN does not declare foreign
 *     keys at the DB level — the cross-reference is advisory and the
 *     application layer owns referential integrity.
 *
 *   - `metaKey` is `varchar(191)` on the schema side; the DTO does
 *     not impose a length limit because the column itself enforces it
 *     at write time, but it does reject the empty string because an
 *     empty key cannot participate in the
 *     `(product_id, meta_key)` unique axis from §5.3.
 *
 *   - Timestamps (`createdAt`, `updatedAt`) 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);

// The `meta_key` / `meta_value` column names on `affilun_product_meta` are
// part of AFFILUN's own schema (§5.2), not WordPress's `postmeta` table.
// WPCS's SlowDBQuery sniff cannot tell the two apart, so suppress it
// at the file level rather than peppering every reference with a
// per-line `phpcs:ignore` comment.
//
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value

namespace Affilun\Infrastructure\Persistence;

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

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

	/**
	 * 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;

	/**
	 * Foreign-key-style reference to `affilun_products.id` per §5.2.
	 * Advisory per §11.2 — AFFILUN does not declare DB-level foreign keys.
	 */
	private int $productId;

	/**
	 * Meta key per §5.2, participating in the
	 * `(product_id, meta_key)` unique axis from §5.3.
	 */
	private string $metaKey;

	/**
	 * Meta value per §5.2, nullable.
	 */
	private ?string $metaValue;

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

	/**
	 * Row update timestamp. Required per §5.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 int               $productId Owning `affilun_products.id` reference.
	 * @param string            $metaKey   Meta key.
	 * @param string|null       $metaValue Meta value, nullable.
	 * @param DateTimeImmutable $createdAt Row creation timestamp.
	 * @param DateTimeImmutable $updatedAt Row update timestamp.
	 *
	 * @throws InvalidArgumentException When `id` is non-positive,
	 *                                  `productId` is non-positive, or
	 *                                  `metaKey` is the empty string.
	 */
	public function __construct(
		?int $id,
		int $productId,
		string $metaKey,
		?string $metaValue,
		DateTimeImmutable $createdAt,
		DateTimeImmutable $updatedAt
	) {
		if ( $id !== null && $id <= 0 ) {
			$message = sprintf( 'ProductMetaRecord 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 ( $productId <= 0 ) {
			$message = sprintf(
				'ProductMetaRecord productId must be a positive integer, got %d.',
				$productId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->assertNonEmpty( 'metaKey', $metaKey );

		$this->id        = $id;
		$this->productId = $productId;
		$this->metaKey   = $metaKey;
		$this->metaValue = $metaValue;
		$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 single
	 * nullable column (`metaValue`) flows through an overrides array
	 * so callers that only want to assert the key's presence can stay
	 * terse.
	 *
	 * @param int                  $productId Owning `affilun_products.id` reference.
	 * @param string               $metaKey   Meta key.
	 * @param DateTimeImmutable    $now       Current UTC timestamp.
	 * @param array<string, mixed> $optional  Optional column overrides keyed by property name.
	 */
	public static function createNew(
		int $productId,
		string $metaKey,
		DateTimeImmutable $now,
		array $optional = array()
	): self {
		$metaValue = null;
		if ( array_key_exists( 'metaValue', $optional ) && $optional['metaValue'] !== null ) {
			$metaValue = (string) $optional['metaValue'];
		}

		return new self(
			null,
			$productId,
			$metaKey,
			$metaValue,
			$now,
			$now
		);
	}

	/**
	 * Reconstitute a record from a row-shaped associative array.
	 *
	 * Intended as the single decode seam used by
	 * {@see WpdbProductMetaRepository} 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( 'ProductMetaRecord::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 ];
		};

		$requireInt = static function ( string $key ) use ( $row ): int {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				$message = sprintf( 'ProductMetaRecord::fromArray() missing required integer "%s".', $key );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			return (int) $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 ];
		};

		$requireDate = static function ( string $key ) use ( $row ): DateTimeImmutable {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				$message = sprintf( 'ProductMetaRecord::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,
			$requireInt( 'product_id' ),
			$requireString( 'meta_key' ),
			$optionalString( 'meta_value' ),
			$requireDate( 'created_at' ),
			$requireDate( 'updated_at' )
		);
	}

	/**
	 * Encode the record as a row-shaped associative array keyed by the
	 * canonical snake_case column names from §5.2.
	 *
	 * Used by {@see WpdbProductMetaRepository::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,
			'product_id' => $this->productId,
			'meta_key'   => $this->metaKey,
			'meta_value' => $this->metaValue,
			'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( 'ProductMetaRecord::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;
	}

	/**
	 * Produce a copy of this record with a new `meta_value`.
	 *
	 * Exposed because `meta_value` is the only column on this table
	 * that callers routinely advance in place — meta entries are
	 * keyed by `(product_id, meta_key)` and overwritten when the
	 * upstream value changes. This helper does NOT advance
	 * `updated_at`; callers should compose with
	 * {@see self::withUpdatedAt()} when persisting.
	 *
	 * @param string|null $metaValue New value, nullable.
	 */
	public function withMetaValue( ?string $metaValue ): self {
		$clone            = clone $this;
		$clone->metaValue = $metaValue;
		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;
	}

	/**
	 * Owning `affilun_products.id` reference per §5.2.
	 */
	public function productId(): int {
		return $this->productId;
	}

	/**
	 * Meta key per §5.2.
	 */
	public function metaKey(): string {
		return $this->metaKey;
	}

	/**
	 * Meta value per §5.2, or null.
	 */
	public function metaValue(): ?string {
		return $this->metaValue;
	}

	/**
	 * 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(
			'ProductMetaRecord::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( 'ProductMetaRecord 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 );
		}
	}
}
