<?php
/**
 * In-memory embed repository for tests.
 *
 * Reference {@see EmbedRepository} implementation that keeps every
 * persisted {@see EmbedRecord} 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/`; integration tests
 * that need real WordPress / MySQL semantics should exercise
 * {@see WpdbEmbedRepository} against a live database instead.
 *
 * Semantic parity with {@see WpdbEmbedRepository}:
 *
 *   - {@see self::save()} performs insert-vs-update based on whether
 *     the supplied record carries an id. Inserts allocate the next
 *     auto-increment id starting at 1 and always advancing; updates
 *     overwrite every column on the matching row and raise a
 *     {@see \RuntimeException} when the id no longer exists, matching
 *     the contract's "update-after-delete is a visible error" rule.
 *
 *   - Inserts enforce the `uuid` unique constraint from
 *     `database-schema.md` §6.3 by probing the in-memory index before
 *     accepting the write. A violation raises a
 *     {@see \RuntimeException} whose message mentions the colliding
 *     uuid so the offending value is visible in test failures.
 *
 *   - {@see self::findById()} and {@see self::findByUuid()} return
 *     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.
 *
 * The store deliberately holds {@see EmbedRecord} 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 EmbedRecord} constructor.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use InvalidArgumentException;
use RuntimeException;

/**
 * Array-backed {@see EmbedRepository} implementation for unit tests.
 *
 * See the file-level docblock for the semantic parity guarantees this
 * double maintains with {@see WpdbEmbedRepository}.
 */
final class InMemoryEmbedRepository implements EmbedRepository {

	/**
	 * Stored records keyed by their assigned auto-increment id.
	 *
	 * Populated by {@see self::save()} on insert and mutated in place
	 * by {@see self::save()} on update; entries are removed by
	 * {@see self::delete()}.
	 *
	 * @var array<int, EmbedRecord>
	 */
	private array $records = array();

	/**
	 * Secondary index over the `uuid` unique axis, mapping each uuid
	 * to the primary-key id currently occupying it.
	 *
	 * Maintained in sync with {@see self::$records} by every mutating
	 * method.
	 *
	 * @var array<string, int>
	 */
	private array $uuidIndex = 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<EmbedRecord> $seed Records to preload.
	 *
	 * @throws InvalidArgumentException When a seed record has no id,
	 *                                  when two seeds share an id, or
	 *                                  when two seeds share the
	 *                                  `uuid` value.
	 */
	public function __construct( array $seed = array() ) {
		foreach ( $seed as $record ) {
			$id = $record->id();
			if ( $id === null ) {
				$message = 'InMemoryEmbedRepository seed records must carry a non-null id.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

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

			$uuid = $record->uuid();
			if ( array_key_exists( $uuid, $this->uuidIndex ) ) {
				$message = sprintf(
					'InMemoryEmbedRepository seed contains duplicate uuid "%s".',
					$uuid
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

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

	/**
	 * Persist the record and return its post-save form.
	 *
	 * See {@see EmbedRepository::save()} for the full contract. This
	 * implementation:
	 *
	 *   - Treats a null id as an insert, allocates the next
	 *     auto-increment id, probes the `uuid` index for a collision,
	 *     and stores the record under the new id.
	 *
	 *   - Treats a non-null id as an update. If no row currently
	 *     matches the id, raises {@see \RuntimeException} per the
	 *     "update-after-delete is a visible error" rule. If the update
	 *     would move the record onto a `uuid` already occupied by a
	 *     different row, raises {@see \RuntimeException} so the
	 *     collision surfaces loudly.
	 *
	 * @param EmbedRecord $record Record to persist.
	 *
	 * @throws RuntimeException When an update targets a missing id or
	 *                          when either branch would violate the
	 *                          `uuid` unique constraint.
	 */
	public function save( EmbedRecord $record ): EmbedRecord {
		$id = $record->id();

		if ( $id === null ) {
			return $this->insert( $record );
		}

		return $this->update( $id, $record );
	}

	/**
	 * Insert a fresh (unpersisted) record under a newly-allocated id.
	 *
	 * @param EmbedRecord $record Record to insert. Must have a null id.
	 *
	 * @throws RuntimeException When the `uuid` value already exists.
	 */
	private function insert( EmbedRecord $record ): EmbedRecord {
		$uuid = $record->uuid();

		if ( array_key_exists( $uuid, $this->uuidIndex ) ) {
			$message = sprintf(
				'InMemoryEmbedRepository: duplicate uuid on insert: "%s".',
				$uuid
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message );
		}

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

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

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

		return $persisted;
	}

	/**
	 * Overwrite an existing row with the supplied record.
	 *
	 * @param int         $id     Primary key of the row being updated.
	 * @param EmbedRecord $record Record carrying the new column values.
	 *
	 * @throws RuntimeException When no row currently matches `$id` or
	 *                          when the update would move the record
	 *                          onto a `uuid` already occupied by a
	 *                          different row.
	 */
	private function update( int $id, EmbedRecord $record ): EmbedRecord {
		if ( ! array_key_exists( $id, $this->records ) ) {
			$message = sprintf(
				'InMemoryEmbedRepository: cannot update missing id %d; row may have been deleted.',
				$id
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message );
		}

		$existing = $this->records[ $id ];
		$newUuid  = $record->uuid();
		$oldUuid  = $existing->uuid();

		if (
			$newUuid !== $oldUuid
			&& array_key_exists( $newUuid, $this->uuidIndex )
		) {
			$message = sprintf(
				'InMemoryEmbedRepository: update would collide with existing uuid "%s".',
				$newUuid
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message );
		}

		if ( $newUuid !== $oldUuid ) {
			unset( $this->uuidIndex[ $oldUuid ] );
			$this->uuidIndex[ $newUuid ] = $id;
		}

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

		return $record;
	}

	/**
	 * 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 ): ?EmbedRecord {
		if ( $id <= 0 ) {
			$message = sprintf( 'InMemoryEmbedRepository::findById() requires a positive id, got %d.', $id );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

	/**
	 * Look up a record by its `uuid` unique key.
	 *
	 * Returns null when no row matches; raises
	 * {@see InvalidArgumentException} when the argument is an empty
	 * string because the only way to reach that path is a programming
	 * error at the call site.
	 *
	 * @param string $uuid Prefixed-ULID external identifier.
	 *
	 * @throws InvalidArgumentException When `$uuid` is empty.
	 */
	public function findByUuid( string $uuid ): ?EmbedRecord {
		if ( $uuid === '' ) {
			$message = 'InMemoryEmbedRepository::findByUuid() requires a non-empty uuid.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! array_key_exists( $uuid, $this->uuidIndex ) ) {
			return null;
		}

		$id = $this->uuidIndex[ $uuid ];

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

	/**
	 * Remove the row identified by the given id.
	 *
	 * 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( 'InMemoryEmbedRepository::delete() requires a positive id, got %d.', $id );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

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

		unset( $this->records[ $id ], $this->uuidIndex[ $existing->uuid() ] );

		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 EmbedRepository}
	 * contract and the Wpdb-backed implementation does not offer it.
	 *
	 * @return list<EmbedRecord>
	 */
	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;
	}
}
