<?php
/**
 * Persistence DTO for the `affilun_logs` table.
 *
 * Immutable value object that mirrors the canonical column set defined
 * in `specifications/database-schema.md` §9.2. The record is the narrow
 * data surface exchanged between the {@see LogRepository} contract and
 * its implementations ({@see InMemoryLogRepository},
 * {@see WpdbLogRepository}); 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 / later observability-surface code (diagnostics viewers,
 *     purge jobs, correlation-based drill-downs) 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` §9.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:
 *     `channel`, `level`, `message`, `createdAt`.
 *
 *   - Nullable columns from the schema (`contextJson`,
 *     `relatedProductId`, `relatedJobId`) are nullable on the record
 *     so the common "orphan diagnostics note" case can be constructed
 *     without synthesizing unrelated cross-references.
 *
 *   - `channel` is held as a plain string at this layer; the §9.2
 *     logical channel enum (`provider.amazon`, `provider.rakuten`,
 *     `provider.yahoo_shopping`, `sync`, `render`, `cache`, `rest`,
 *     `block`, `audit`, `security`, `cron`, `migration`, with `other`
 *     as the catch-all) is enforced by the application-layer logger,
 *     matching the approach taken for `sync_state` /
 *     `lifecycle_state` on {@see ProductRecord}, `type` on
 *     {@see EmbedRecord}, and `status` / `jobType` on
 *     {@see SyncJobRecord}.
 *
 *   - `level` is likewise held as a plain string; the PSR-3 six-value
 *     set (`debug`, `info`, `notice`, `warning`, `error`, `critical`;
 *     ADR-0008 / ADR-0019) is enforced by the application layer.
 *
 *   - `relatedProductId` / `relatedJobId` are `bigint unsigned` on the
 *     schema side; the record rejects non-positive values at
 *     construction time so a programming error (wrapped-around cast,
 *     accidental `0` sentinel) surfaces before the database boundary.
 *
 *   - `createdAt` is held as a UTC `\DateTimeImmutable` instance. 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()}.
 *
 *   - Unlike the other AFFILUN persistence DTOs (`ProductRecord`,
 *     `EmbedRecord`, `SyncJobRecord`), this record carries NO
 *     `updatedAt` column because §9.2 deliberately omits it: log rows
 *     are append-only. Consequently there is no `withUpdatedAt()`
 *     helper — the append-only nature is reflected in the
 *     {@see LogRepository} contract as well, which rejects attempts to
 *     save a record that already carries an id.
 *
 * Instances are immutable. {@see self::withId()} returns a copy with
 * the given primary-key id assigned and is the only mutation helper
 * offered by this DTO; there is no reason to advance any other column
 * on a persisted log row, and doing so would contradict the
 * append-only contract.
 *
 * @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_logs`.
 *
 * See the file-level docblock for the mapping to `database-schema.md`
 * §9.2 and the rationale for keeping this type behavior-free and
 * append-only.
 */
final class LogRecord {

	/**
	 * Canonical MySQL `DATETIME` format used by the `created_at` 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;

	/**
	 * Log channel per §9.2. Held as a plain string at this layer; the
	 * logical enum (dotted provider channels plus subsystem names) is
	 * enforced by the application-layer logger.
	 */
	private string $channel;

	/**
	 * Severity per §9.2 (PSR-3, six values). Held as a plain string;
	 * the set is enforced by the application-layer logger.
	 */
	private string $level;

	/**
	 * Human-readable log message per §9.2.
	 */
	private string $message;

	/**
	 * Serialized JSON context per §9.2 / §3.3, or null when the log
	 * entry has no structured context.
	 */
	private ?string $contextJson;

	/**
	 * Cross-reference to a `affilun_products.id` row per §9.2, or null
	 * when the entry is not product-scoped. Advisory per §11.2 — AFFILUN
	 * does not declare foreign keys at the DB level.
	 */
	private ?int $relatedProductId;

	/**
	 * Cross-reference to a `affilun_sync_jobs.id` row per §9.2, or null
	 * when the entry is not job-scoped. Advisory per §11.2.
	 */
	private ?int $relatedJobId;

	/**
	 * Row creation timestamp. Required per §9.2. No `updated_at`
	 * companion exists because `affilun_logs` is append-only.
	 */
	private DateTimeImmutable $createdAt;

	/**
	 * 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            $channel          Log channel.
	 * @param string            $level            PSR-3 severity.
	 * @param string            $message          Log message body.
	 * @param string|null       $contextJson      Serialized JSON context, nullable.
	 * @param int|null          $relatedProductId Related product id, nullable.
	 * @param int|null          $relatedJobId     Related job id, nullable.
	 * @param DateTimeImmutable $createdAt        Row creation timestamp.
	 *
	 * @throws InvalidArgumentException When a non-nullable string is
	 *                                  empty, `id` is non-positive, or
	 *                                  either cross-reference id is
	 *                                  non-positive.
	 */
	public function __construct(
		?int $id,
		string $channel,
		string $level,
		string $message,
		?string $contextJson,
		?int $relatedProductId,
		?int $relatedJobId,
		DateTimeImmutable $createdAt
	) {
		if ( $id !== null && $id <= 0 ) {
			$message_invalid_id = sprintf( 'LogRecord 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_invalid_id );
		}

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

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

		$this->assertNonEmpty( 'channel', $channel );
		$this->assertNonEmpty( 'level', $level );
		$this->assertNonEmpty( 'message', $message );

		$this->id               = $id;
		$this->channel          = $channel;
		$this->level            = $level;
		$this->message          = $message;
		$this->contextJson      = $contextJson;
		$this->relatedProductId = $relatedProductId;
		$this->relatedJobId     = $relatedJobId;
		$this->createdAt        = self::normalizeToUtc( $createdAt ) ?? $createdAt;
	}

	/**
	 * Ergonomic named constructor for fresh (unpersisted) records.
	 *
	 * Required columns are taken as positional arguments; the three
	 * nullable columns (`contextJson`, `relatedProductId`,
	 * `relatedJobId`) flow through an overrides array so a bare
	 * "channel + level + message + now" call site stays terse.
	 *
	 * @param string               $channel  Log channel.
	 * @param string               $level    PSR-3 severity.
	 * @param string               $message  Log message body.
	 * @param DateTimeImmutable    $now      Current UTC timestamp.
	 * @param array<string, mixed> $optional Optional column overrides
	 *                                       keyed by property name.
	 */
	public static function createNew(
		string $channel,
		string $level,
		string $message,
		DateTimeImmutable $now,
		array $optional = array()
	): self {
		$contextJson = null;
		if ( array_key_exists( 'contextJson', $optional ) && $optional['contextJson'] !== null ) {
			$contextJson = (string) $optional['contextJson'];
		}

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

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

		return new self(
			null,
			$channel,
			$level,
			$message,
			$contextJson,
			$relatedProductId,
			$relatedJobId,
			$now
		);
	}

	/**
	 * Reconstitute a record from a row-shaped associative array.
	 *
	 * Intended as the single decode seam used by
	 * {@see WpdbLogRepository} 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}.
	 *
	 * The `created_at` column accepts 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 the 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( 'LogRecord::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( 'LogRecord::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( 'channel' ),
			$requireString( 'level' ),
			$requireString( 'message' ),
			$optionalString( 'context_json' ),
			$optionalInt( 'related_product_id' ),
			$optionalInt( 'related_job_id' ),
			$requireDate( 'created_at' )
		);
	}

	/**
	 * Encode the record as a row-shaped associative array keyed by the
	 * canonical snake_case column names from §9.2.
	 *
	 * Used by {@see WpdbLogRepository::save()} to produce the payload
	 * handed to `$wpdb->insert()`. The timestamp is 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,
			'channel'            => $this->channel,
			'level'              => $this->level,
			'message'            => $this->message,
			'context_json'       => $this->contextJson,
			'related_product_id' => $this->relatedProductId,
			'related_job_id'     => $this->relatedJobId,
			'created_at'         => self::formatDateTime( $this->createdAt ),
		);
	}

	/**
	 * 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.
	 *
	 * No `withUpdatedAt()` counterpart exists because `affilun_logs` has
	 * no `updated_at` column — log rows are append-only (§9.1 / §9.4).
	 *
	 * @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( 'LogRecord::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;
	}

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

	/**
	 * Log channel per §9.2.
	 */
	public function channel(): string {
		return $this->channel;
	}

	/**
	 * PSR-3 severity per §9.2.
	 */
	public function level(): string {
		return $this->level;
	}

	/**
	 * Log message body per §9.2.
	 */
	public function message(): string {
		return $this->message;
	}

	/**
	 * Serialized JSON context per §9.2, or null.
	 */
	public function contextJson(): ?string {
		return $this->contextJson;
	}

	/**
	 * Cross-reference to a `affilun_products.id` row per §9.2, or null.
	 */
	public function relatedProductId(): ?int {
		return $this->relatedProductId;
	}

	/**
	 * Cross-reference to a `affilun_sync_jobs.id` row per §9.2, or null.
	 */
	public function relatedJobId(): ?int {
		return $this->relatedJobId;
	}

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

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