<?php
/**
 * List-audit-events use case.
 *
 * Phase 2 (KI-006) Application-layer use case that drives the
 * §12.4 `GET /affilun/v1/audit/events` REST surface documented in
 * `specifications/rest-api-spec.md`. The use case is a thin
 * orchestration seam between the persistence-layer query surface
 * ({@see \Affilun\Infrastructure\Persistence\AuditEventRepository::findPaginated()})
 * and the §12.4 envelope projection — it does NOT own filter
 * validation (that lives on
 * {@see \Affilun\Infrastructure\Persistence\AuditEventListQuery})
 * and does NOT own envelope construction beyond the per-row
 * projection (the REST controller wraps the result VO in the
 * canonical success envelope).
 *
 * Field projection (per §12.4):
 *
 *   - `audit_event_type` ← `event_type`
 *   - `audit_kind`       ← `audit_kind`
 *   - `actor`            ← composite `{ type, id }` from
 *                          `actor_type` / `actor_id`
 *   - `target`           ← composite `{ type, id }` from
 *                          `target_type` / `target_id`
 *   - `action`           ← `action`
 *   - `result`           ← `result`
 *   - `safe_summary`     ← `safe_summary`
 *   - `changed_fields_summary` ← decoded `changed_fields_json`
 *                                 (or null when absent / malformed)
 *   - `request_id`       ← `context_json['request_id']` when
 *                          present and string-shaped
 *   - `correlation_id`   ← `context_json['correlation_id']` when
 *                          present and string-shaped
 *   - `job_id`           ← `context_json['job_id']` when present
 *                          and string-shaped
 *   - `created_at`       ← ISO-8601 UTC instant
 *
 * The §10.2 `safe_summary` slot is contractually scrubbed by the
 * audit writer (typically through the same `LogMasker` family that
 * scrubs `affilun_logs`), so this use case does NOT re-mask the field
 * — re-masking would risk double-encoding already-redacted tokens
 * and would shadow the writer's responsibility for keeping the
 * audit trail tamper-evident.
 *
 * @package Affilun\Application\Audit\UseCase
 */

declare(strict_types=1);

namespace Affilun\Application\Audit\UseCase;

use Affilun\Infrastructure\Persistence\AuditEventListQuery;
use Affilun\Infrastructure\Persistence\AuditEventRecord;
use Affilun\Infrastructure\Persistence\AuditEventRepository;
use DateTimeImmutable;

/**
 * Application-layer use case for `GET /affilun/v1/audit/events` (§12.4).
 *
 * See the file-level docblock for the orchestration shape (query
 * → repository → per-row projection → result VO) and the field
 * mapping the projection performs.
 */
final class ListAuditEventsUseCase {

	/**
	 * Persistence-layer query surface.
	 */
	private AuditEventRepository $events;

	/**
	 * Construct the use case with explicit collaborator
	 * injection.
	 *
	 * @param AuditEventRepository $events Persistence-layer query surface.
	 */
	public function __construct( AuditEventRepository $events ) {
		$this->events = $events;
	}

	/**
	 * Execute the §12.4 list-audit-events query.
	 *
	 * Calls
	 * {@see AuditEventRepository::findPaginated()} with the
	 * supplied query VO, projects every returned
	 * {@see AuditEventRecord} into the §12.4 envelope row shape
	 * via {@see self::projectRow()}, and wraps the result in a
	 * {@see ListAuditEventsResult} carrying the pagination axes
	 * echoed back from the underlying
	 * {@see \Affilun\Infrastructure\Persistence\AuditEventListResult}.
	 *
	 * @param AuditEventListQuery $query Validated list-query inputs.
	 */
	public function execute( AuditEventListQuery $query ): ListAuditEventsResult {
		$page = $this->events->findPaginated( $query );

		$items = array();
		foreach ( $page->items() as $record ) {
			$items[] = self::projectRow( $record );
		}

		return new ListAuditEventsResult(
			$items,
			$page->total(),
			$page->page(),
			$page->perPage(),
			$page->pageCount()
		);
	}

	/**
	 * Project a persisted {@see AuditEventRecord} into the §12.4
	 * envelope row shape.
	 *
	 * The actor / target slots collapse to null when both their
	 * type and id components are absent so the envelope does not
	 * carry a half-populated `{ type: null, id: null }` map. The
	 * `changed_fields_summary` slot decodes `changed_fields_json`
	 * into the structured summary; malformed JSON is silently
	 * dropped (returns null) so a single bad row cannot break the
	 * page projection.
	 *
	 * Exposed as a public static helper so adjacent consumers
	 * (the §12.5 summary follow-up, integration tests) can reuse
	 * the canonical projection without re-deriving it.
	 *
	 * @param AuditEventRecord $record Persisted audit-event record.
	 *
	 * @return array<string, mixed>
	 */
	public static function projectRow( AuditEventRecord $record ): array {
		$actor  = self::projectActorOrTarget( $record->actorType(), $record->actorId() );
		$target = self::projectActorOrTarget( $record->targetType(), $record->targetId() );

		$context = self::decodeJsonObject( $record->contextJson() );

		$requestId     = self::stringOrNull( $context['request_id'] ?? null );
		$correlationId = self::stringOrNull( $context['correlation_id'] ?? null );
		$jobId         = self::stringOrNull( $context['job_id'] ?? null );

		return array(
			'id'                     => $record->id(),
			'audit_event_type'       => $record->eventType(),
			'audit_kind'             => $record->auditKind(),
			'actor'                  => $actor,
			'target'                 => $target,
			'action'                 => $record->action(),
			'result'                 => $record->result(),
			'safe_summary'           => $record->safeSummary(),
			'changed_fields_summary' => self::decodeJsonObject( $record->changedFieldsJson() ),
			'request_id'             => $requestId,
			'correlation_id'         => $correlationId,
			'job_id'                 => $jobId,
			'created_at'             => $record->createdAt()->format( DateTimeImmutable::ATOM ),
		);
	}

	/**
	 * Build an `{ type, id }` actor / target sub-object, or null
	 * when both components are absent.
	 *
	 * @param string|null $type Actor / target type.
	 * @param string|null $id   Actor / target id.
	 *
	 * @return array{type: string|null, id: string|null}|null
	 */
	private static function projectActorOrTarget( ?string $type, ?string $id ): ?array {
		if ( $type === null && $id === null ) {
			return null;
		}
		return array(
			'type' => $type,
			'id'   => $id,
		);
	}

	/**
	 * Decode an optional JSON-encoded object back into an
	 * associative array, or return null when the input is null,
	 * empty, malformed, or decodes to a non-object value.
	 *
	 * Used for both the `changed_fields_json` and `context_json`
	 * slots so a single bad row cannot break the projection.
	 *
	 * @param string|null $json Raw JSON payload.
	 *
	 * @return array<string, mixed>|null
	 */
	private static function decodeJsonObject( ?string $json ): ?array {
		if ( $json === null || $json === '' ) {
			return null;
		}
		$decoded = json_decode( $json, true );
		if ( ! is_array( $decoded ) ) {
			return null;
		}
		// Reject non-associative arrays so a list-shaped payload
		// does not leak through as a struct slot.
		if ( $decoded !== array() && array_is_list( $decoded ) ) {
			return null;
		}
		/** @var array<string, mixed> $decoded */
		return $decoded;
	}

	/**
	 * Coerce a context-slot value to a string, returning null
	 * when the input is null or non-string. Used for the
	 * `request_id` / `correlation_id` / `job_id` lifts out of
	 * `context_json` so the envelope never carries a non-string
	 * tracker token.
	 *
	 * @param mixed $value Candidate value.
	 */
	private static function stringOrNull( $value ): ?string {
		if ( ! is_string( $value ) ) {
			return null;
		}
		$trimmed = trim( $value );
		return $trimmed === '' ? null : $trimmed;
	}
}
