<?php
/**
 * In-memory audit-event repository for tests.
 *
 * Phase 2 (KI-006) reference {@see AuditEventRepository}
 * implementation that keeps every persisted
 * {@see AuditEventRecord} in a plain PHP array on the instance,
 * with no dependency on `$wpdb` or the WordPress options /
 * plugin lifecycle. This is the canonical test double used by
 * pure-unit tests under
 * `tests/Unit/Infrastructure/Persistence/` and by the §12.4
 * audit-list controller / use-case tests; integration tests that
 * need real WordPress / MySQL semantics should exercise the
 * Wpdb-backed implementation against a live database instead.
 *
 * Semantic parity with the Wpdb-backed implementation:
 *
 *   - {@see self::save()} is INSERT-ONLY. Records that already
 *     carry a non-null id raise {@see \RuntimeException} per the
 *     §10.1 append-only invariant — there is no in-place update
 *     path, so a "save the row I just read back" call must
 *     surface as a programming error rather than silently
 *     duplicating the audit row.
 *
 *   - Insert allocates the next auto-increment id starting at 1
 *     and always advancing; the auto-increment cursor never
 *     rewinds even after a {@see self::delete()}, mirroring the
 *     MySQL `AUTO_INCREMENT` convention so an id observed by a
 *     past assertion remains traceable.
 *
 *   - {@see self::findById()} returns null on miss rather than
 *     throwing, per the contract's "not found is a success
 *     condition" rule.
 *
 *   - {@see self::delete()} is idempotent and returns true only
 *     when a row actually matched. Reserved for the §10.4
 *     retention purge path; application-layer audit writers MUST
 *     NOT call it during normal operation.
 *
 *   - {@see self::findPaginated()} filters by every non-null
 *     axis on the supplied {@see AuditEventListQuery}, sorts the
 *     remaining rows by descending id (a proxy for
 *     `created_at DESC, id DESC` since this double allocates
 *     ids monotonically and seeds preserve insertion order), and
 *     slices to the requested page. The total reported on the
 *     result envelope reflects the filter set BEFORE pagination.
 *
 *   - {@see self::countByAuditKind()} groups every stored row by
 *     `audit_kind` and returns the per-kind counts; kinds with
 *     zero occurrences are OMITTED from the returned map per the
 *     contract.
 *
 * The store deliberately holds {@see AuditEventRecord} instances
 * directly (rather than round-tripping through `toArray()` /
 * `fromArray()`) so unit tests can assert on referential
 * equality of timestamp objects and other immutable values
 * without fighting the MySQL `DATETIME` string format. The
 * Wpdb-backed implementation rounds-trips through the array
 * form on its storage boundary; the two implementations remain
 * behaviorally equivalent because both normalize timestamps to
 * UTC via the {@see AuditEventRecord} constructor.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use RuntimeException;

/**
 * Array-backed {@see AuditEventRepository} implementation for
 * unit tests.
 *
 * See the file-level docblock for the semantic parity guarantees
 * this double maintains with the Wpdb-backed implementation, the
 * §10.1 append-only invariant the constructor and `save()` path
 * enforce, and the {@see self::findPaginated()} ordering rule.
 */
final class InMemoryAuditEventRepository implements AuditEventRepository {

	/**
	 * Stored records keyed by their assigned auto-increment id.
	 *
	 * Populated by {@see self::save()} on insert; entries are
	 * removed by {@see self::delete()}. There is no in-place
	 * update path because `affilun_audit_events` is append-only.
	 *
	 * @var array<int, AuditEventRecord>
	 */
	private array $records = array();

	/**
	 * Next auto-increment id to allocate on insert.
	 *
	 * Starts at 1 and advances monotonically with every
	 * successful insert. Mirrors the MySQL `AUTO_INCREMENT`
	 * convention: ids are never reused, even after a delete.
	 */
	private int $nextId = 1;

	/**
	 * Construct the repository, optionally seeded with a set of
	 * already-persisted records.
	 *
	 * Seeding is primarily useful for tests that need to exercise
	 * "existing environment" code paths without driving a
	 * sequence of {@see self::save()} calls first. Seed records
	 * MUST carry a non-null id; their ids are honored verbatim,
	 * and the repository's auto-increment cursor advances past
	 * the highest seeded id so subsequent inserts cannot collide
	 * with a seeded row.
	 *
	 * @param list<AuditEventRecord> $seed Records to preload.
	 *
	 * @throws InvalidArgumentException When a seed record has no
	 *                                  id or when two seeds
	 *                                  share an id.
	 */
	public function __construct( array $seed = array() ) {
		foreach ( $seed as $record ) {
			$id = $record->id();
			if ( $id === null ) {
				$message = 'InMemoryAuditEventRepository seed records must carry a non-null id.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
				throw new InvalidArgumentException( $message );
			}

			if ( array_key_exists( $id, $this->records ) ) {
				$message = sprintf(
					'InMemoryAuditEventRepository seed contains duplicate id %d.',
					$id
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
				throw new InvalidArgumentException( $message );
			}

			$this->records[ $id ] = $record;
			if ( $id >= $this->nextId ) {
				$this->nextId = $id + 1;
			}
		}
	}

	/**
	 * Persist the record and return its post-save form.
	 *
	 * INSERT-ONLY: rejects records that already carry a non-null
	 * id with {@see \RuntimeException} per the §10.1 append-only
	 * invariant.
	 *
	 * @param AuditEventRecord $record Record to persist (id MUST be null).
	 *
	 * @throws RuntimeException When `$record` already carries a
	 *                          non-null id.
	 */
	public function save( AuditEventRecord $record ): AuditEventRecord {
		if ( $record->id() !== null ) {
			$message = sprintf(
				'InMemoryAuditEventRepository::save() rejects records with a non-null id (got %d); '
				. 'affilun_audit_events is append-only per database-schema.md §10.1.',
				$record->id()
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new RuntimeException( $message );
		}

		$id = $this->nextId;
		++$this->nextId;

		$persisted = $record->withId( $id );

		$this->records[ $id ] = $persisted;

		return $persisted;
	}

	/**
	 * Look up a record by its primary key.
	 *
	 * Returns null when no row matches; raises
	 * {@see InvalidArgumentException} for non-positive ids so a
	 * programming error at the call site surfaces loudly instead
	 * of silently missing.
	 *
	 * @param int $id Primary key to look up.
	 *
	 * @throws InvalidArgumentException When `$id` is non-positive.
	 */
	public function findById( int $id ): ?AuditEventRecord {
		if ( $id <= 0 ) {
			$message = sprintf(
				'InMemoryAuditEventRepository::findById() requires a positive id, got %d.',
				$id
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		return $this->records[ $id ] ?? null;
	}

	/**
	 * Remove the row identified by the given id.
	 *
	 * Reserved for the §10.4 retention purge path; application-
	 * layer audit writers MUST NOT call this during normal
	 * operation. Idempotent: returns true when a row was
	 * removed, false when no row matched.
	 *
	 * Non-positive ids raise {@see InvalidArgumentException} so
	 * a programming error at the call site surfaces loudly
	 * instead of silently missing.
	 *
	 * @param int $id Primary key of the row to delete.
	 *
	 * @throws InvalidArgumentException When `$id` is non-positive.
	 */
	public function delete( int $id ): bool {
		if ( $id <= 0 ) {
			$message = sprintf(
				'InMemoryAuditEventRepository::delete() requires a positive id, got %d.',
				$id
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message; not user output.
			throw new InvalidArgumentException( $message );
		}

		if ( ! array_key_exists( $id, $this->records ) ) {
			return false;
		}

		unset( $this->records[ $id ] );

		return true;
	}

	/**
	 * Total number of rows currently stored.
	 */
	public function count(): int {
		return count( $this->records );
	}

	/**
	 * Return every stored record, ordered by ascending primary key.
	 *
	 * Exposed purely as a convenience for test assertions that
	 * want to enumerate the repository contents without driving
	 * a paginated listing. Production code MUST NOT rely on this
	 * method because it has no counterpart on the
	 * {@see AuditEventRepository} contract and the Wpdb-backed
	 * implementation does not offer it.
	 *
	 * @return list<AuditEventRecord>
	 */
	public function all(): array {
		$ordered = $this->records;
		ksort( $ordered, SORT_NUMERIC );
		return array_values( $ordered );
	}

	/**
	 * Next auto-increment id the repository would allocate on
	 * the following insert.
	 *
	 * Exposed purely as a convenience for test assertions that
	 * want to probe the internal cursor without issuing a dummy
	 * insert. Like {@see self::all()}, production code MUST NOT
	 * rely on this.
	 */
	public function nextId(): int {
		return $this->nextId;
	}

	/**
	 * Look up a paginated, filtered slice of stored rows.
	 *
	 * See {@see AuditEventRepository::findPaginated()} for the
	 * contract. Filtering is applied per non-null axis on
	 * `$query`; the remaining rows are sorted by descending id
	 * (a proxy for `created_at DESC, id DESC` since this double
	 * allocates ids monotonically) and sliced to the requested
	 * page.
	 *
	 * @param AuditEventListQuery $query Validated list-query inputs.
	 */
	public function findPaginated( AuditEventListQuery $query ): AuditEventListResult {
		$matching = array();
		foreach ( $this->records as $record ) {
			if ( ! $this->matchesQuery( $record, $query ) ) {
				continue;
			}
			$matching[] = $record;
		}

		usort(
			$matching,
			static function ( AuditEventRecord $a, AuditEventRecord $b ): int {
				return ( $b->id() ?? 0 ) <=> ( $a->id() ?? 0 );
			}
		);

		$total   = count( $matching );
		$offset  = $query->offset();
		$perPage = $query->perPage();
		$slice   = array_slice( $matching, $offset, $perPage );

		/** @var list<AuditEventRecord> $slice */
		return new AuditEventListResult( $slice, $total, $query->page(), $perPage );
	}

	/**
	 * Group every stored row by `audit_kind` and return the
	 * per-kind counts.
	 *
	 * See {@see AuditEventRepository::countByAuditKind()} for
	 * the contract; in particular, kinds with zero occurrences
	 * are omitted so callers default-fill against
	 * {@see AuditEventListQuery::VALID_AUDIT_KINDS}.
	 *
	 * @return array<string, int>
	 */
	public function countByAuditKind(): array {
		$counts = array();
		foreach ( $this->records as $record ) {
			$kind            = $record->auditKind();
			$counts[ $kind ] = ( $counts[ $kind ] ?? 0 ) + 1;
		}
		return $counts;
	}

	/**
	 * Whether `$record` satisfies every non-null filter axis on
	 * `$query`.
	 *
	 * Phase 2 KI-006 follow-up: the `request_id` / `correlation_id`
	 * / `job_id` axes are honored by JSON-decoding the record's
	 * `context_json` slot and comparing against the matching key.
	 * The Wpdb-backed implementation honors the same axes via
	 * `JSON_EXTRACT(context_json, '$.{key}')` so both
	 * implementations stay behaviorally equivalent.
	 *
	 * @param AuditEventRecord    $record Record under test.
	 * @param AuditEventListQuery $query  Filter axes.
	 */
	private function matchesQuery( AuditEventRecord $record, AuditEventListQuery $query ): bool {
		if ( $query->auditKind() !== null && $record->auditKind() !== $query->auditKind() ) {
			return false;
		}
		if ( $query->actorId() !== null && $record->actorId() !== $query->actorId() ) {
			return false;
		}
		if ( $query->targetType() !== null && $record->targetType() !== $query->targetType() ) {
			return false;
		}
		if ( $query->targetId() !== null && $record->targetId() !== $query->targetId() ) {
			return false;
		}

		$needsContextLookup = $query->requestId() !== null
			|| $query->correlationId() !== null
			|| $query->jobId() !== null;
		if ( ! $needsContextLookup ) {
			return true;
		}

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

		if ( $query->requestId() !== null
			&& self::contextStringValue( $context, 'request_id' ) !== $query->requestId() ) {
			return false;
		}
		if ( $query->correlationId() !== null
			&& self::contextStringValue( $context, 'correlation_id' ) !== $query->correlationId() ) {
			return false;
		}
		if ( $query->jobId() !== null
			&& self::contextStringValue( $context, 'job_id' ) !== $query->jobId() ) {
			return false;
		}

		return true;
	}

	/**
	 * Decode a record's `context_json` slot into an associative
	 * array, returning an empty array when the slot is null,
	 * empty, malformed, or decodes to a non-object payload.
	 *
	 * Returning an empty array on every degenerate input keeps
	 * the per-axis comparison branches in {@see self::matchesQuery()}
	 * free of null-safety boilerplate; a missing key is then a
	 * straightforward `array_key_exists()` miss.
	 *
	 * @param string|null $contextJson Raw JSON payload from
	 *                                 {@see AuditEventRecord::contextJson()}.
	 *
	 * @return array<string, mixed>
	 */
	private static function decodeContext( ?string $contextJson ): array {
		if ( $contextJson === null || $contextJson === '' ) {
			return array();
		}
		$decoded = json_decode( $contextJson, true );
		if ( ! is_array( $decoded ) ) {
			return array();
		}
		if ( $decoded !== array() && array_is_list( $decoded ) ) {
			return array();
		}
		/** @var array<string, mixed> $decoded */
		return $decoded;
	}

	/**
	 * Read the named slot from a decoded `context_json` map and
	 * return it as a string, or null when the slot is absent or
	 * non-string.
	 *
	 * Mirrors the value-coercion rule the §12.4 envelope
	 * projection applies on the read side
	 * ({@see \Affilun\Application\Audit\UseCase\ListAuditEventsUseCase})
	 * so the in-memory filter and the projected row see the
	 * same set of values for the same key.
	 *
	 * @param array<string, mixed> $context Decoded context map.
	 * @param string               $key     Slot name to read.
	 */
	private static function contextStringValue( array $context, string $key ): ?string {
		if ( ! array_key_exists( $key, $context ) ) {
			return null;
		}
		$value = $context[ $key ];
		if ( ! is_string( $value ) ) {
			return null;
		}
		return $value;
	}

	/**
	 * Delete every stored record whose `created_at` is strictly
	 * older than the supplied UTC cutoff and return the number
	 * of records removed.
	 *
	 * Phase 2 KI-006 follow-up: implements the §10.4 retention
	 * purge contract documented on
	 * {@see AuditEventRepository::deleteOlderThan()}.
	 *
	 * Strict-less-than comparison rule: a record whose
	 * `created_at` exactly equals the cutoff is preserved (so a
	 * row landing on the boundary survives at least one full
	 * sweep before disappearing on the next tick). The cutoff is
	 * normalized to UTC before comparison so a non-UTC clock
	 * cannot smuggle in an off-by-one drift.
	 *
	 * The auto-increment cursor is intentionally left untouched
	 * after a purge, mirroring the MySQL `AUTO_INCREMENT`
	 * convention also enforced by {@see self::delete()}: ids are
	 * never reused, even after rows disappear from the table.
	 *
	 * @param DateTimeImmutable $cutoff UTC cutoff instant.
	 *
	 * @return int Number of rows removed.
	 */
	public function deleteOlderThan( DateTimeImmutable $cutoff ): int {
		$normalizedCutoff = $cutoff->setTimezone( new DateTimeZone( 'UTC' ) );

		$removed = 0;
		foreach ( $this->records as $id => $record ) {
			if ( $record->createdAt() < $normalizedCutoff ) {
				unset( $this->records[ $id ] );
				++$removed;
			}
		}

		return $removed;
	}
}
