<?php
/**
 * Audit-event row DTO.
 *
 * Phase 2 (KI-006) row-shaped immutable value object that mirrors
 * the `affilun_audit_events` schema described in
 * `specifications/database-schema.md` §10.2. Carries every column
 * the repository layer round-trips through `$wpdb`, with a
 * `null` id slot for unpersisted rows the repository fills in on
 * insert.
 *
 * Field-shape rules:
 *
 *   - Required (`NOT NULL`) columns are non-nullable on the
 *     record: `eventType`, `auditKind`, `action`, `result`,
 *     `safeSummary`, `createdAt`.
 *
 *   - Nullable columns from the schema (`actorType`, `actorId`,
 *     `targetType`, `targetId`, `changedFieldsJson`,
 *     `contextJson`) are nullable on the record so a fresh row
 *     can be constructed without synthesizing unrelated values.
 *
 *   - `eventType`, `auditKind`, `actorType`, `actorId`,
 *     `targetType`, `targetId`, `action`, `result` are held as
 *     plain strings at this layer; the §10.2 enum vocabularies
 *     (for example `audit_kind ∈ { configuration_change,
 *     destructive_action, security_event, operational_action,
 *     compliance_relevant_action }`) are enforced by the
 *     application layer / writer surface, matching the
 *     {@see LogRecord} convention for the channel / level
 *     fields on `affilun_logs`.
 *
 *   - `safeSummary` is held as a plain string; the §10.2
 *     "秘密情報を含めない" rule is enforced by the application
 *     layer's audit writer (typically through the same
 *     `LogMasker` family that scrubs `affilun_logs`).
 *
 *   - `changedFieldsJson` and `contextJson` are held as opaque
 *     JSON strings; this layer does not parse them. Callers that
 *     need the structured form decode them through their own
 *     JSON path on the application side.
 *
 *   - `createdAt` is held as a UTC `\DateTimeImmutable`. The
 *     persistence layer converts to and from the MySQL
 *     `Y-m-d H:i:s` representation on the storage boundary;
 *     callers never see the string form through the record
 *     surface.
 *
 * Append-only:
 *
 *   `affilun_audit_events` is append-only per §10.1: rows are
 *   inserted but never mutated in place. Consequently this
 *   record exposes no `withX()` helpers beyond {@see self::withId()}
 *   (used by the repository's insert path to surface the
 *   driver-assigned id back to callers); corrections to a
 *   previously-recorded event are themselves recorded as new
 *   audit rows so the evidence trail remains tamper-evident.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Immutable row-shaped DTO for the `affilun_audit_events` table.
 *
 * See the file-level docblock for the field-shape rules, the
 * append-only invariant the table maintains, and the application-
 * layer responsibilities for enum vocabularies and secret
 * scrubbing.
 */
final class AuditEventRecord {

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

	/**
	 * Audit event type discriminator per §10.2 (e.g.
	 * `settings.updated`, `cache.cleared`). The closed enum is
	 * enforced by the application layer; this DTO accepts any
	 * non-empty string.
	 */
	private string $eventType;

	/**
	 * Audit-kind classification per §10.2: one of
	 * `configuration_change`, `destructive_action`,
	 * `security_event`, `operational_action`,
	 * `compliance_relevant_action`. Enforced by the application
	 * layer.
	 */
	private string $auditKind;

	/**
	 * Optional actor classification (`user`, `system`, `job`,
	 * etc.). Null when the actor is not known or not relevant.
	 */
	private ?string $actorType;

	/**
	 * Optional actor identifier (WordPress user id as string,
	 * worker id, etc.). Null when the actor is not known.
	 */
	private ?string $actorId;

	/**
	 * Optional target classification (`product`, `settings`,
	 * `cache`, `migration`, etc.). Null when the audit event has
	 * no specific target.
	 */
	private ?string $targetType;

	/**
	 * Optional target identifier (product id, option key, etc.).
	 * Null when the audit event has no specific target.
	 */
	private ?string $targetId;

	/**
	 * Action verb per §10.2 (e.g. `update`, `clear`, `delete`).
	 * Non-empty.
	 */
	private string $action;

	/**
	 * Action result per §10.2: one of `success`, `failed`,
	 * `rejected`. Enforced by the application layer; this DTO
	 * accepts any non-empty string.
	 */
	private string $result;

	/**
	 * Human-readable safe summary per §10.2. The application
	 * layer is responsible for ensuring no secret data leaks into
	 * this slot (typically via the `LogMasker` family).
	 */
	private string $safeSummary;

	/**
	 * Optional JSON-encoded changed-fields summary per §10.2.
	 * Null when the audit event records no field-level diff.
	 */
	private ?string $changedFieldsJson;

	/**
	 * Optional JSON-encoded auxiliary context per §10.2 carrying
	 * `request_id` / `correlation_id` / `job_id` / etc. Null when
	 * the audit event has no structured context.
	 */
	private ?string $contextJson;

	/**
	 * Row creation timestamp. Required per §10.2; no
	 * `updated_at` companion exists because `affilun_audit_events`
	 * is append-only.
	 */
	private DateTimeImmutable $createdAt;

	/**
	 * Construct an audit event record from explicit column values.
	 *
	 * @param int|null          $id                Auto-increment id, or
	 *                                             null for unpersisted
	 *                                             records.
	 * @param string            $eventType         §10.2 event type
	 *                                             discriminator
	 *                                             (non-empty).
	 * @param string            $auditKind         §10.2 audit-kind
	 *                                             classification
	 *                                             (non-empty).
	 * @param string|null       $actorType         Optional actor
	 *                                             classification.
	 * @param string|null       $actorId           Optional actor id.
	 * @param string|null       $targetType        Optional target
	 *                                             classification.
	 * @param string|null       $targetId          Optional target id.
	 * @param string            $action            §10.2 action verb
	 *                                             (non-empty).
	 * @param string            $result            §10.2 action result
	 *                                             (non-empty).
	 * @param string            $safeSummary       §10.2 safe summary
	 *                                             (non-empty).
	 * @param string|null       $changedFieldsJson Optional JSON-encoded
	 *                                             changed-fields summary.
	 * @param string|null       $contextJson       Optional JSON-encoded
	 *                                             auxiliary context.
	 * @param DateTimeImmutable $createdAt         UTC creation timestamp.
	 *
	 * @throws InvalidArgumentException When `$id` is non-positive (when not
	 *                                  null), when any non-nullable string
	 *                                  is empty after trimming, or when
	 *                                  `$createdAt` cannot be normalized to
	 *                                  UTC.
	 */
	public function __construct(
		?int $id,
		string $eventType,
		string $auditKind,
		?string $actorType,
		?string $actorId,
		?string $targetType,
		?string $targetId,
		string $action,
		string $result,
		string $safeSummary,
		?string $changedFieldsJson,
		?string $contextJson,
		DateTimeImmutable $createdAt
	) {
		if ( $id !== null && $id <= 0 ) {
			$message = sprintf(
				'AuditEventRecord requires id to be null or a positive integer; got %d.',
				$id
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->assertNonEmpty( 'eventType', $eventType );
		$this->assertNonEmpty( 'auditKind', $auditKind );
		$this->assertNonEmpty( 'action', $action );
		$this->assertNonEmpty( 'result', $result );
		$this->assertNonEmpty( 'safeSummary', $safeSummary );

		$this->id                = $id;
		$this->eventType         = $eventType;
		$this->auditKind         = $auditKind;
		$this->actorType         = self::nullIfEmpty( $actorType );
		$this->actorId           = self::nullIfEmpty( $actorId );
		$this->targetType        = self::nullIfEmpty( $targetType );
		$this->targetId          = self::nullIfEmpty( $targetId );
		$this->action            = $action;
		$this->result            = $result;
		$this->safeSummary       = $safeSummary;
		$this->changedFieldsJson = self::nullIfEmpty( $changedFieldsJson );
		$this->contextJson       = self::nullIfEmpty( $contextJson );
		$this->createdAt         = self::normalizeToUtc( $createdAt ) ?? $createdAt;
	}

	/**
	 * Ergonomic named constructor for fresh (unpersisted) records.
	 *
	 * Required columns are taken as positional arguments;
	 * everything optional flows through an overrides array so
	 * callers that just want "record this audit event now" can
	 * stay terse.
	 *
	 * Defaults:
	 *
	 *   - `actorType`, `actorId`, `targetType`, `targetId`,
	 *     `changedFieldsJson`, `contextJson` all default to null.
	 *
	 * @param string               $eventType   §10.2 event type discriminator.
	 * @param string               $auditKind   §10.2 audit-kind classification.
	 * @param string               $action      §10.2 action verb.
	 * @param string               $result      §10.2 action result.
	 * @param string               $safeSummary §10.2 safe summary.
	 * @param DateTimeImmutable    $now         Current UTC timestamp.
	 * @param array<string, mixed> $optional    Optional column overrides
	 *                                          keyed by property name.
	 */
	public static function createNew(
		string $eventType,
		string $auditKind,
		string $action,
		string $result,
		string $safeSummary,
		DateTimeImmutable $now,
		array $optional = array()
	): self {
		$actorType         = null;
		$actorId           = null;
		$targetType        = null;
		$targetId          = null;
		$changedFieldsJson = null;
		$contextJson       = null;

		if ( array_key_exists( 'actorType', $optional ) && $optional['actorType'] !== null ) {
			$actorType = (string) $optional['actorType'];
		}
		if ( array_key_exists( 'actorId', $optional ) && $optional['actorId'] !== null ) {
			$actorId = (string) $optional['actorId'];
		}
		if ( array_key_exists( 'targetType', $optional ) && $optional['targetType'] !== null ) {
			$targetType = (string) $optional['targetType'];
		}
		if ( array_key_exists( 'targetId', $optional ) && $optional['targetId'] !== null ) {
			$targetId = (string) $optional['targetId'];
		}
		if (
			array_key_exists( 'changedFieldsJson', $optional )
			&& $optional['changedFieldsJson'] !== null
		) {
			$changedFieldsJson = (string) $optional['changedFieldsJson'];
		}
		if ( array_key_exists( 'contextJson', $optional ) && $optional['contextJson'] !== null ) {
			$contextJson = (string) $optional['contextJson'];
		}

		return new self(
			null,
			$eventType,
			$auditKind,
			$actorType,
			$actorId,
			$targetType,
			$targetId,
			$action,
			$result,
			$safeSummary,
			$changedFieldsJson,
			$contextJson,
			$now
		);
	}

	/**
	 * Reconstitute a record from a row-shaped associative array.
	 *
	 * Intended as the single decode seam used by
	 * {@see WpdbAuditEventRepository} when mapping
	 * `$wpdb->get_row()` results back into records. Unknown keys
	 * are ignored so callers can pass raw result rows without
	 * filtering them; missing non-nullable keys raise
	 * {@see InvalidArgumentException}.
	 *
	 * `created_at` 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 `created_at` 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(
					'AuditEventRecord::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;
			}
			$value = (string) $row[ $key ];
			return $value === '' ? null : $value;
		};

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

		if ( ! array_key_exists( 'created_at', $row ) || $row['created_at'] === null ) {
			$message = 'AuditEventRecord::fromArray() missing required timestamp "created_at".';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
		$createdAt = self::parseDateTime( 'created_at', $row['created_at'] );

		return new self(
			$id,
			$requireString( 'event_type' ),
			$requireString( 'audit_kind' ),
			$optionalString( 'actor_type' ),
			$optionalString( 'actor_id' ),
			$optionalString( 'target_type' ),
			$optionalString( 'target_id' ),
			$requireString( 'action' ),
			$requireString( 'result' ),
			$requireString( 'safe_summary' ),
			$optionalString( 'changed_fields_json' ),
			$optionalString( 'context_json' ),
			$createdAt
		);
	}

	/**
	 * Encode the record as a row-shaped associative array keyed
	 * by the canonical snake_case column names from §10.2.
	 *
	 * Used by {@see WpdbAuditEventRepository::save()} to produce
	 * the payload handed to `$wpdb->insert()`. The `created_at`
	 * 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,
			'event_type'          => $this->eventType,
			'audit_kind'          => $this->auditKind,
			'actor_type'          => $this->actorType,
			'actor_id'            => $this->actorId,
			'target_type'         => $this->targetType,
			'target_id'           => $this->targetId,
			'action'              => $this->action,
			'result'              => $this->result,
			'safe_summary'        => $this->safeSummary,
			'changed_fields_json' => $this->changedFieldsJson,
			'context_json'        => $this->contextJson,
			'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.
	 *
	 * @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(
				'AuditEventRecord::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;
	}

	/**
	 * §10.2 event type discriminator (e.g. `settings.updated`).
	 */
	public function eventType(): string {
		return $this->eventType;
	}

	/**
	 * §10.2 audit-kind classification (e.g. `configuration_change`).
	 */
	public function auditKind(): string {
		return $this->auditKind;
	}

	/**
	 * Optional actor classification (`user` / `system` / `job`),
	 * or null when not known.
	 */
	public function actorType(): ?string {
		return $this->actorType;
	}

	/**
	 * Optional actor identifier, or null when not known.
	 */
	public function actorId(): ?string {
		return $this->actorId;
	}

	/**
	 * Optional target classification, or null when the event has
	 * no specific target.
	 */
	public function targetType(): ?string {
		return $this->targetType;
	}

	/**
	 * Optional target identifier, or null when the event has no
	 * specific target.
	 */
	public function targetId(): ?string {
		return $this->targetId;
	}

	/**
	 * §10.2 action verb (e.g. `update`, `clear`, `delete`).
	 */
	public function action(): string {
		return $this->action;
	}

	/**
	 * §10.2 action result (`success` / `failed` / `rejected`).
	 */
	public function result(): string {
		return $this->result;
	}

	/**
	 * §10.2 human-readable safe summary.
	 */
	public function safeSummary(): string {
		return $this->safeSummary;
	}

	/**
	 * Optional JSON-encoded changed-fields summary, or null.
	 */
	public function changedFieldsJson(): ?string {
		return $this->changedFieldsJson;
	}

	/**
	 * Optional JSON-encoded auxiliary context (`request_id` /
	 * `correlation_id` / `job_id` etc.), or null.
	 */
	public function contextJson(): ?string {
		return $this->contextJson;
	}

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

	/**
	 * Reject empty / whitespace-only strings for the required
	 * non-nullable columns so a programming error in the writer
	 * surfaces at construction time.
	 *
	 * @param string $field Field name used in the error message.
	 * @param string $value Candidate value.
	 *
	 * @throws InvalidArgumentException When `$value` is the empty
	 *                                  string after trimming.
	 */
	private function assertNonEmpty( string $field, string $value ): void {
		if ( trim( $value ) === '' ) {
			$message = sprintf(
				'AuditEventRecord 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 );
		}
	}

	/**
	 * Normalize an optional string column: trim it and treat the
	 * empty result as null so the persisted row stays canonical.
	 *
	 * @param string|null $value Candidate value.
	 */
	private static function nullIfEmpty( ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}
		$trimmed = trim( $value );
		return $trimmed === '' ? null : $value;
	}

	/**
	 * Normalize a `\DateTimeImmutable` to UTC, returning null when
	 * the conversion would fail. Mirrors the pattern used by
	 * {@see LogRecord} and {@see SyncJobRecord} so every AFFILUN row
	 * record maintains the same UTC invariant on its timestamp
	 * slots.
	 *
	 * @param DateTimeImmutable|null $value Value to normalize.
	 */
	private static function normalizeToUtc( ?DateTimeImmutable $value ): ?DateTimeImmutable {
		if ( $value === null ) {
			return null;
		}
		try {
			return $value->setTimezone( new DateTimeZone( 'UTC' ) );
		} catch ( \Exception $unused ) {
			unset( $unused );
			return null;
		}
	}

	/**
	 * Parse a `created_at` value into a UTC `\DateTimeImmutable`,
	 * accepting either an existing `DateTimeImmutable` or a
	 * `Y-m-d H:i:s` string.
	 *
	 * @param string $key   Column name used in the error message.
	 * @param mixed  $value Raw value from the row array.
	 *
	 * @throws InvalidArgumentException When `$value` is neither a
	 *                                  `DateTimeImmutable` nor a
	 *                                  parseable 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;
			}
		}

		$message = sprintf(
			'AuditEventRecord::fromArray() failed to parse timestamp "%s" from value of type %s.',
			$key,
			is_object( $value ) ? get_class( $value ) : gettype( $value )
		);
		// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
		throw new InvalidArgumentException( $message );
	}

	/**
	 * Format a `\DateTimeImmutable` as a MySQL `DATETIME` string
	 * in UTC. Mirrors {@see LogRecord::formatDateTime()} so the
	 * persistence layer's storage boundary stays consistent
	 * across every AFFILUN row record.
	 *
	 * @param DateTimeImmutable $value UTC instant to format.
	 */
	private static function formatDateTime( DateTimeImmutable $value ): string {
		return self::normalizeToUtc( $value )
			?->format( self::MYSQL_DATETIME_FORMAT )
			?? $value->format( self::MYSQL_DATETIME_FORMAT );
	}
}
