<?php
/**
 * Audit-event draft value object.
 *
 * Phase 2 (KI-006 follow-up) immutable VO consumed by the
 * {@see AuditEventWriter} port. Carries every §10.2
 * `affilun_audit_events` column the call site is responsible for —
 * which is everything except the auto-increment `id` slot
 * (database-assigned on persist) and the `created_at` timestamp
 * (stamped by the writer from its injected
 * {@see \Affilun\Domain\Clock} so every audit row
 * carries a clock the persistence layer can trust rather than a
 * caller-supplied instant that could drift).
 *
 * Field-shape rules (mirror the {@see \Affilun\Infrastructure\Persistence\AuditEventRecord}
 * constructor invariants — the writer hands the draft straight to
 * the record, so the validation done here is the single point of
 * failure for malformed call-site input):
 *
 *   - Required (`NOT NULL`) columns are non-nullable on the draft:
 *     `eventType`, `auditKind`, `action`, `result`, `safeSummary`.
 *     Each is rejected with {@see InvalidArgumentException} when
 *     empty after trimming so a programming error in the call
 *     site (forgotten interpolation, "" sentinel) surfaces at
 *     draft construction rather than as a confusing DB-side
 *     error later.
 *
 *   - Nullable columns from the schema (`actorType`, `actorId`,
 *     `targetType`, `targetId`, `changedFieldsJson`,
 *     `contextJson`) are nullable on the draft. Empty / whitespace-
 *     only strings normalize to null so the persisted row stays
 *     canonical (a stray `actorId = ""` from a partially-known
 *     actor is treated as "no actor" rather than an empty-string
 *     filter trap).
 *
 *   - `changedFieldsJson` and `contextJson` are validated as
 *     well-formed JSON strings when present. Malformed JSON
 *     raises {@see InvalidArgumentException} at construction time
 *     because the §12.4 envelope projection
 *     ({@see \Affilun\Application\Audit\UseCase\ListAuditEventsUseCase::projectRow()})
 *     decodes these slots and silently drops malformed payloads;
 *     catching the malformed input here keeps the audit trail
 *     observable rather than mysteriously absent on the read
 *     side. Both slots additionally MUST decode to a JSON object
 *     (associative array) — list-shaped payloads are rejected so
 *     the §12.4 `request_id` / `correlation_id` / `job_id` lift
 *     out of `contextJson` is well-defined.
 *
 *   - `safeSummary` is held as a plain string. The §10.2 "秘密
 *     情報を含めない" rule is the call site's responsibility (run
 *     the summary through {@see \Affilun\Application\Logs\LogMasker}
 *     before constructing the draft when secrets are involved).
 *     The draft does NOT re-mask because re-masking already-
 *     scrubbed text could double-encode tokens and would shadow
 *     the writer's responsibility split documented on
 *     {@see AuditEventWriter}.
 *
 * Convenience factories:
 *
 *   - {@see self::forUserAction()} packages the common
 *     "(user `$userId`) did `$action` on `$targetType:$targetId`
 *     with result `$result`" shape so admin call sites can stay
 *     terse.
 *
 *   - {@see self::forSystemAction()} packages the equivalent
 *     "(system) did `$action` on `$targetType:$targetId`" shape
 *     for background workers that have no human actor (cron
 *     dispatch, retention purge, migration runner).
 *
 *   - {@see self::forJobAction()} packages the
 *     "(job `$jobId`) did `$action` on `$targetType:$targetId`"
 *     shape for `affilun_sync_jobs`-driven worker emits, with the
 *     job id automatically routed into both `actorId` and the
 *     `contextJson.job_id` slot so the §12.4 envelope projection
 *     surfaces the tracker on the read side.
 *
 *   - Each factory accepts an optional `$context` array that is
 *     JSON-encoded into `contextJson`, and an optional
 *     `$changedFields` array that is JSON-encoded into
 *     `changedFieldsJson`. Both arrays may be empty (in which
 *     case the corresponding JSON slot is null) and are required
 *     to be associative — sequential-list payloads raise
 *     {@see InvalidArgumentException} at construction so the
 *     §12.4 envelope projection's "JSON object" assumption holds.
 *
 * Immutable: there is no `withX()` mutation surface. Drafts are
 * produced once at the call site and handed to the writer.
 *
 * @package Affilun\Application\Audit
 */

declare(strict_types=1);

namespace Affilun\Application\Audit;

use InvalidArgumentException;

/**
 * Immutable draft VO consumed by {@see AuditEventWriter::record()}.
 *
 * See the file-level docblock for the field-shape rules, the
 * convenience factories ({@see self::forUserAction()},
 * {@see self::forSystemAction()}, {@see self::forJobAction()}),
 * and the safe-summary responsibility split that keeps secret
 * scrubbing in the call site rather than the persistence
 * boundary.
 */
final class AuditEventDraft {

	/**
	 * §10.2 actor-type token for human user actions originating
	 * from the WordPress admin (Settings save, capability
	 * change, destructive admin operation).
	 */
	public const ACTOR_TYPE_USER = 'user';

	/**
	 * §10.2 actor-type token for unattended system actions
	 * (plugin activation, schema migration, cron-driven retention
	 * purge that has no per-row human triggerer).
	 */
	public const ACTOR_TYPE_SYSTEM = 'system';

	/**
	 * §10.2 actor-type token for background-worker actions
	 * driven by a `affilun_sync_jobs` row (sync_product handler
	 * terminal transition, periodic-sync sweep enqueue).
	 */
	public const ACTOR_TYPE_JOB = 'job';

	/**
	 * §10.2 result token for "the action ran to completion as
	 * intended". Most call sites should pass this verbatim.
	 */
	public const RESULT_SUCCESS = 'success';

	/**
	 * §10.2 result token for "the action started but failed to
	 * complete". Used by the sync handler's terminal-failure
	 * branch and by the settings save handler when validation
	 * passes but the persistence layer rejects the write.
	 */
	public const RESULT_FAILED = 'failed';

	/**
	 * §10.2 result token for "the action was rejected before it
	 * could start". Used by the capability gate and the §17
	 * validation barrier when a malformed request is short-
	 * circuited before reaching the application logic.
	 */
	public const RESULT_REJECTED = 'rejected';

	/**
	 * §10.2 event type discriminator. Required, non-empty.
	 */
	private string $eventType;

	/**
	 * §10.2 audit-kind classification. Required, non-empty.
	 * Validation against the closed enum is performed at the
	 * persistence boundary by the audit writer's repository
	 * (the draft trusts the call site here so a future kind
	 * extension does not require a draft-level patch).
	 */
	private string $auditKind;

	/**
	 * §10.2 action verb. Required, non-empty.
	 */
	private string $action;

	/**
	 * §10.2 action result. Required, non-empty.
	 */
	private string $result;

	/**
	 * §10.2 human-readable safe summary. Required, non-empty.
	 * MUST already be scrubbed of secrets by the call site.
	 */
	private string $safeSummary;

	/**
	 * Optional actor classification (`user`, `system`, `job`).
	 */
	private ?string $actorType;

	/**
	 * Optional actor identifier.
	 */
	private ?string $actorId;

	/**
	 * Optional target classification.
	 */
	private ?string $targetType;

	/**
	 * Optional target identifier.
	 */
	private ?string $targetId;

	/**
	 * Optional JSON-encoded changed-fields summary.
	 */
	private ?string $changedFieldsJson;

	/**
	 * Optional JSON-encoded auxiliary context (`request_id`,
	 * `correlation_id`, `job_id`, etc.).
	 */
	private ?string $contextJson;

	/**
	 * Construct an audit-event draft from explicit per-axis
	 * values.
	 *
	 * @param string      $eventType         §10.2 event type
	 *                                       discriminator
	 *                                       (non-empty).
	 * @param string      $auditKind         §10.2 audit-kind
	 *                                       classification
	 *                                       (non-empty).
	 * @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;
	 *                                       call-site-scrubbed).
	 * @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|null $changedFieldsJson Optional JSON-encoded
	 *                                       changed-fields summary
	 *                                       (must decode to a JSON
	 *                                       object).
	 * @param string|null $contextJson       Optional JSON-encoded
	 *                                       auxiliary context
	 *                                       (must decode to a JSON
	 *                                       object).
	 *
	 * @throws InvalidArgumentException When any required string
	 *                                  is empty after trimming,
	 *                                  or when either JSON slot
	 *                                  is malformed / decodes to
	 *                                  a non-object payload.
	 */
	public function __construct(
		string $eventType,
		string $auditKind,
		string $action,
		string $result,
		string $safeSummary,
		?string $actorType = null,
		?string $actorId = null,
		?string $targetType = null,
		?string $targetId = null,
		?string $changedFieldsJson = null,
		?string $contextJson = null
	) {
		$this->assertNonEmpty( 'eventType', $eventType );
		$this->assertNonEmpty( 'auditKind', $auditKind );
		$this->assertNonEmpty( 'action', $action );
		$this->assertNonEmpty( 'result', $result );
		$this->assertNonEmpty( 'safeSummary', $safeSummary );

		$this->eventType         = $eventType;
		$this->auditKind         = $auditKind;
		$this->action            = $action;
		$this->result            = $result;
		$this->safeSummary       = $safeSummary;
		$this->actorType         = self::nullIfEmpty( $actorType );
		$this->actorId           = self::nullIfEmpty( $actorId );
		$this->targetType        = self::nullIfEmpty( $targetType );
		$this->targetId          = self::nullIfEmpty( $targetId );
		$this->changedFieldsJson = self::assertJsonObjectOrNull(
			'changedFieldsJson',
			self::nullIfEmpty( $changedFieldsJson )
		);
		$this->contextJson       = self::assertJsonObjectOrNull(
			'contextJson',
			self::nullIfEmpty( $contextJson )
		);
	}

	/**
	 * Convenience factory for "(user `$userId`) did `$action` on
	 * `$targetType:$targetId` with result `$result`" shape.
	 *
	 * Stamps `actorType = 'user'` and `actorId = $userId`. The
	 * `$context` and `$changedFields` arrays are JSON-encoded
	 * into the corresponding slots when non-empty; an empty
	 * array yields a null slot.
	 *
	 * @param string               $eventType     §10.2 event type.
	 * @param string               $auditKind     §10.2 audit-kind.
	 * @param string               $action        §10.2 action verb.
	 * @param string               $result        §10.2 result token.
	 * @param string               $safeSummary   §10.2 safe summary.
	 * @param int|string           $userId        WP user id (will be
	 *                                            coerced to string).
	 * @param string|null          $targetType    Optional target type.
	 * @param string|null          $targetId      Optional target id.
	 * @param array<string, mixed> $changedFields Optional changed-fields
	 *                                            summary; encoded into
	 *                                            `changedFieldsJson`.
	 * @param array<string, mixed> $context       Optional auxiliary
	 *                                            context; encoded into
	 *                                            `contextJson`.
	 *
	 * @throws InvalidArgumentException When validation fails per
	 *                                  the constructor contract.
	 */
	public static function forUserAction(
		string $eventType,
		string $auditKind,
		string $action,
		string $result,
		string $safeSummary,
		int|string $userId,
		?string $targetType = null,
		?string $targetId = null,
		array $changedFields = array(),
		array $context = array()
	): self {
		return new self(
			$eventType,
			$auditKind,
			$action,
			$result,
			$safeSummary,
			self::ACTOR_TYPE_USER,
			(string) $userId,
			$targetType,
			$targetId,
			self::encodeJsonObjectOrNull( 'changedFields', $changedFields ),
			self::encodeJsonObjectOrNull( 'context', $context )
		);
	}

	/**
	 * Convenience factory for unattended "(system) did `$action`
	 * on `$targetType:$targetId`" shape.
	 *
	 * Stamps `actorType = 'system'` and leaves `actorId` null.
	 * Used by plugin lifecycle events (activation, schema
	 * migration) and the retention purge worker that has no
	 * per-row human triggerer.
	 *
	 * @param string               $eventType     §10.2 event type.
	 * @param string               $auditKind     §10.2 audit-kind.
	 * @param string               $action        §10.2 action verb.
	 * @param string               $result        §10.2 result token.
	 * @param string               $safeSummary   §10.2 safe summary.
	 * @param string|null          $targetType    Optional target type.
	 * @param string|null          $targetId      Optional target id.
	 * @param array<string, mixed> $changedFields Optional changed-fields
	 *                                            summary.
	 * @param array<string, mixed> $context       Optional auxiliary
	 *                                            context.
	 *
	 * @throws InvalidArgumentException When validation fails per
	 *                                  the constructor contract.
	 */
	public static function forSystemAction(
		string $eventType,
		string $auditKind,
		string $action,
		string $result,
		string $safeSummary,
		?string $targetType = null,
		?string $targetId = null,
		array $changedFields = array(),
		array $context = array()
	): self {
		return new self(
			$eventType,
			$auditKind,
			$action,
			$result,
			$safeSummary,
			self::ACTOR_TYPE_SYSTEM,
			null,
			$targetType,
			$targetId,
			self::encodeJsonObjectOrNull( 'changedFields', $changedFields ),
			self::encodeJsonObjectOrNull( 'context', $context )
		);
	}

	/**
	 * Convenience factory for "(job `$jobId`) did `$action` on
	 * `$targetType:$targetId`" shape.
	 *
	 * Stamps `actorType = 'job'`, `actorId = $jobId`, AND mirrors
	 * the job id into `contextJson.job_id` so the §12.4 envelope
	 * projection's `job_id` lift surfaces the tracker on the
	 * read side. Additional `$context` keys are merged on top of
	 * the auto-populated `job_id` (caller-supplied `job_id` wins
	 * if explicitly provided).
	 *
	 * @param string               $eventType     §10.2 event type.
	 * @param string               $auditKind     §10.2 audit-kind.
	 * @param string               $action        §10.2 action verb.
	 * @param string               $result        §10.2 result token.
	 * @param string               $safeSummary   §10.2 safe summary.
	 * @param int|string           $jobId         Sync job id (will be
	 *                                            coerced to string).
	 * @param string|null          $targetType    Optional target type.
	 * @param string|null          $targetId      Optional target id.
	 * @param array<string, mixed> $changedFields Optional changed-fields
	 *                                            summary.
	 * @param array<string, mixed> $context       Optional auxiliary
	 *                                            context (merged on top
	 *                                            of auto-populated
	 *                                            `job_id`).
	 *
	 * @throws InvalidArgumentException When validation fails per
	 *                                  the constructor contract.
	 */
	public static function forJobAction(
		string $eventType,
		string $auditKind,
		string $action,
		string $result,
		string $safeSummary,
		int|string $jobId,
		?string $targetType = null,
		?string $targetId = null,
		array $changedFields = array(),
		array $context = array()
	): self {
		$jobIdString = (string) $jobId;

		// Auto-populate job_id in the context payload so the
		// §12.4 envelope projection's `job_id` lift surfaces
		// the tracker on the read side. Caller-supplied
		// `job_id` wins when explicitly provided.
		$mergedContext = array_merge( array( 'job_id' => $jobIdString ), $context );

		return new self(
			$eventType,
			$auditKind,
			$action,
			$result,
			$safeSummary,
			self::ACTOR_TYPE_JOB,
			$jobIdString,
			$targetType,
			$targetId,
			self::encodeJsonObjectOrNull( 'changedFields', $changedFields ),
			self::encodeJsonObjectOrNull( 'context', $mergedContext )
		);
	}

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

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

	/**
	 * §10.2 action verb.
	 */
	public function action(): string {
		return $this->action;
	}

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

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

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

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

	/**
	 * Optional target classification, or null when not supplied.
	 */
	public function targetType(): ?string {
		return $this->targetType;
	}

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

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

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

	/**
	 * Reject empty / whitespace-only strings for the required
	 * non-nullable columns so a programming error in the call
	 * site surfaces at draft construction.
	 *
	 * @param string $field Field name used in the error message.
	 * @param string $value Candidate value.
	 *
	 * @throws InvalidArgumentException When `$value` is empty
	 *                                  after trimming.
	 */
	private function assertNonEmpty( string $field, string $value ): void {
		if ( trim( $value ) === '' ) {
			$message = sprintf(
				'AuditEventDraft requires a non-empty value for "%s".',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}
	}

	/**
	 * Normalize an optional string column: treat the empty
	 * trimmed 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;
	}

	/**
	 * Validate that the supplied JSON string decodes to a JSON
	 * object (associative array), or pass through null verbatim.
	 *
	 * Malformed JSON, non-array decoded values, and list-shaped
	 * payloads all raise {@see InvalidArgumentException} so the
	 * §12.4 envelope projection's "JSON object" assumption
	 * holds at the read boundary.
	 *
	 * @param string      $field Field name used in the error message.
	 * @param string|null $json  Candidate JSON string.
	 *
	 * @throws InvalidArgumentException When `$json` is malformed
	 *                                  or decodes to a non-object
	 *                                  payload.
	 */
	private static function assertJsonObjectOrNull( string $field, ?string $json ): ?string {
		if ( $json === null ) {
			return null;
		}

		$decoded = json_decode( $json, true );
		if ( $decoded === null && json_last_error() !== JSON_ERROR_NONE ) {
			$message = sprintf(
				'AuditEventDraft: %s must be well-formed JSON; %s.',
				$field,
				json_last_error_msg()
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		if ( ! is_array( $decoded ) ) {
			$message = sprintf(
				'AuditEventDraft: %s must decode to a JSON object; got %s.',
				$field,
				gettype( $decoded )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		// Reject sequential-list payloads so the §12.4 envelope
		// projection's `request_id` / `correlation_id` / `job_id`
		// lift out of `contextJson` is well-defined.
		if ( $decoded !== array() && array_is_list( $decoded ) ) {
			$message = sprintf(
				'AuditEventDraft: %s must decode to a JSON object (associative array); got a list-shaped payload.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		return $json;
	}

	/**
	 * Encode an optional associative array into a JSON string
	 * for the {@see self::$changedFieldsJson} /
	 * {@see self::$contextJson} slots, returning null when the
	 * input array is empty.
	 *
	 * Rejects list-shaped (sequential) arrays so the §12.4
	 * envelope projection's "JSON object" assumption holds at
	 * the read boundary.
	 *
	 * @param string               $field Field name used in the error message.
	 * @param array<string, mixed> $payload Associative payload.
	 *
	 * @throws InvalidArgumentException When `$payload` is a
	 *                                  list-shaped array, or when
	 *                                  JSON encoding fails.
	 */
	private static function encodeJsonObjectOrNull( string $field, array $payload ): ?string {
		if ( $payload === array() ) {
			return null;
		}

		if ( array_is_list( $payload ) ) {
			$message = sprintf(
				'AuditEventDraft: %s must be an associative array; got a list-shaped payload.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		$encoded = function_exists( 'wp_json_encode' )
			? wp_json_encode( $payload )
			// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-unit fallback when WordPress runtime is not loaded.
			: json_encode( $payload );

		if ( ! is_string( $encoded ) ) {
			$message = sprintf(
				'AuditEventDraft: failed to JSON-encode %s payload.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		return $encoded;
	}
}
