<?php
/**
 * In-memory product repository for tests.
 *
 * Reference {@see ProductRepository} implementation that keeps every
 * persisted {@see ProductRecord} 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
 * `WpdbProductRepository` against a live database instead.
 *
 * Semantic parity with the forthcoming `WpdbProductRepository`:
 *
 *   - {@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 `(provider, provider_item_id)` unique
 *     constraint from `database-schema.md` §4.3 by probing the
 *     in-memory index before accepting the write. A violation raises
 *     a {@see \RuntimeException} whose message mentions both axes so
 *     the offending pair is visible in test failures.
 *
 *   - {@see self::findById()} and {@see self::findByProviderItem()}
 *     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 ProductRecord} 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 will
 * round-trip through the array form on its storage boundary; the two
 * implementations remain behaviorally equivalent because both
 * normalize timestamps to UTC via the {@see ProductRecord} constructor.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use InvalidArgumentException;
use RuntimeException;

/**
 * Array-backed {@see ProductRepository} implementation for unit tests.
 *
 * See the file-level docblock for the semantic parity guarantees this
 * double maintains with the forthcoming `WpdbProductRepository`.
 */
final class InMemoryProductRepository implements ProductRepository {

	/**
	 * 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, ProductRecord>
	 */
	private array $records = array();

	/**
	 * Secondary index over the `(provider, provider_item_id)` unique
	 * axis, keyed by `"{provider}\x00{provider_item_id}"` and mapped
	 * to the primary-key id currently occupying the pair.
	 *
	 * Maintained in sync with {@see self::$records} by every mutating
	 * method. Null-byte separator keeps the composite key unambiguous
	 * even when a provider or item id contains a literal delimiter
	 * character.
	 *
	 * @var array<string, int>
	 */
	private array $providerItemIndex = 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<ProductRecord> $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
	 *                                  `(provider, provider_item_id)`
	 *                                  pair.
	 */
	public function __construct( array $seed = array() ) {
		foreach ( $seed as $record ) {
			$id = $record->id();
			if ( $id === null ) {
				$message = 'InMemoryProductRepository 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( 'InMemoryProductRepository seed contains duplicate id %d.', $id );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$indexKey = self::providerItemKey( $record->provider(), $record->providerItemId() );
			if ( array_key_exists( $indexKey, $this->providerItemIndex ) ) {
				$message = sprintf(
					'InMemoryProductRepository seed contains duplicate (provider, provider_item_id) pair: %s / %s.',
					$record->provider(),
					$record->providerItemId()
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

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

	/**
	 * Persist the record and return its post-save form.
	 *
	 * See {@see ProductRepository::save()} for the full contract. This
	 * implementation:
	 *
	 *   - Treats a null id as an insert, allocates the next
	 *     auto-increment id, probes the `(provider, provider_item_id)`
	 *     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 `(provider, provider_item_id)`
	 *     pair already occupied by a different row, raises
	 *     {@see \RuntimeException} so the collision surfaces loudly.
	 *
	 * @param ProductRecord $record Record to persist.
	 *
	 * @throws RuntimeException When an update targets a missing id or
	 *                          when either branch would violate the
	 *                          `(provider, provider_item_id)` unique
	 *                          constraint.
	 */
	public function save( ProductRecord $record ): ProductRecord {
		$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 ProductRecord $record Record to insert. Must have a null id.
	 *
	 * @throws RuntimeException When the `(provider, provider_item_id)`
	 *                          pair already exists.
	 */
	private function insert( ProductRecord $record ): ProductRecord {
		$indexKey = self::providerItemKey( $record->provider(), $record->providerItemId() );

		if ( array_key_exists( $indexKey, $this->providerItemIndex ) ) {
			$message = sprintf(
				'InMemoryProductRepository: duplicate (provider, provider_item_id) pair on insert: %s / %s.',
				$record->provider(),
				$record->providerItemId()
			);
			// 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->providerItemIndex[ $indexKey ] = $id;

		return $persisted;
	}

	/**
	 * Overwrite an existing row with the supplied record.
	 *
	 * @param int           $id     Primary key of the row being updated.
	 * @param ProductRecord $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 `(provider, provider_item_id)`
	 *                          pair already occupied by a different row.
	 */
	private function update( int $id, ProductRecord $record ): ProductRecord {
		if ( ! array_key_exists( $id, $this->records ) ) {
			$message = sprintf(
				'InMemoryProductRepository: 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 ];
		$newIndexKey = self::providerItemKey( $record->provider(), $record->providerItemId() );
		$oldIndexKey = self::providerItemKey( $existing->provider(), $existing->providerItemId() );

		if (
			$newIndexKey !== $oldIndexKey
			&& array_key_exists( $newIndexKey, $this->providerItemIndex )
		) {
			$message = sprintf(
				'InMemoryProductRepository: update would collide with existing '
				. '(provider, provider_item_id) pair: %s / %s.',
				$record->provider(),
				$record->providerItemId()
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message );
		}

		if ( $newIndexKey !== $oldIndexKey ) {
			unset( $this->providerItemIndex[ $oldIndexKey ] );
			$this->providerItemIndex[ $newIndexKey ] = $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 ): ?ProductRecord {
		if ( $id <= 0 ) {
			$message = sprintf( 'InMemoryProductRepository::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;
	}

	/**
	 * Bulk lookup by primary key.
	 *
	 * Single-pass implementation: deduplicate the input, validate
	 * every id is positive, then walk the deduped input order picking
	 * present entries from the in-memory store. Missing ids are
	 * silently omitted from the returned list, matching the contract
	 * on {@see ProductRepository::findByIds()}.
	 *
	 * @param list<int> $ids Positive ids to look up.
	 *
	 * @return list<ProductRecord> Found records in deduplicated input
	 *                             order; missing ids omitted.
	 *
	 * @throws InvalidArgumentException When `$ids` contains a
	 *                                  non-positive id.
	 */
	public function findByIds( array $ids ): array {
		if ( $ids === array() ) {
			return array();
		}

		$seen    = array();
		$ordered = array();
		foreach ( $ids as $id ) {
			if ( $id <= 0 ) {
				$message = sprintf(
					'InMemoryProductRepository::findByIds() requires positive ids, 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, $seen ) ) {
				continue;
			}
			$seen[ $id ] = true;
			$ordered[]   = $id;
		}

		$result = array();
		foreach ( $ordered as $id ) {
			if ( array_key_exists( $id, $this->records ) ) {
				$result[] = $this->records[ $id ];
			}
		}

		return $result;
	}

	/**
	 * Look up a record by the `(provider, provider_item_id)` unique
	 * constraint.
	 *
	 * Returns null when no row matches either component; raises
	 * {@see InvalidArgumentException} when either argument is an empty
	 * string because the only way to reach that path is a programming
	 * error at the call site.
	 *
	 * @param string $provider       Provider identifier.
	 * @param string $providerItemId Provider-side item identifier.
	 *
	 * @throws InvalidArgumentException When either argument is empty.
	 */
	public function findByProviderItem( string $provider, string $providerItemId ): ?ProductRecord {
		if ( $provider === '' ) {
			$message = 'InMemoryProductRepository::findByProviderItem() requires a non-empty provider.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $providerItemId === '' ) {
			$message = 'InMemoryProductRepository::findByProviderItem() requires a non-empty providerItemId.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$indexKey = self::providerItemKey( $provider, $providerItemId );

		if ( ! array_key_exists( $indexKey, $this->providerItemIndex ) ) {
			return null;
		}

		$id = $this->providerItemIndex[ $indexKey ];

		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( 'InMemoryProductRepository::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 ];
		$indexKey = self::providerItemKey( $existing->provider(), $existing->providerItemId() );

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

		return true;
	}

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

	/**
	 * Look up a paginated, filtered, sorted slice of stored rows.
	 *
	 * Implements {@see ProductRepository::findPaginated()} against the
	 * in-memory store. Filters with non-null values on `$query` narrow
	 * the candidate set in this order:
	 *
	 *   - `provider`: exact-match against {@see ProductRecord::provider()}.
	 *   - `search`: case-insensitive substring match against
	 *     {@see ProductRecord::title()} via `mb_stripos()`.
	 *   - `syncState`: exact-match against {@see ProductRecord::syncState()}.
	 *   - `lifecycleState`: exact-match against
	 *     {@see ProductRecord::lifecycleState()}.
	 *   - `usedInPostId`: NOT honored. The `usedInPostId` filter is a
	 *     Phase-9 concern (sync / usage tracking) and is currently
	 *     silently ignored by both repository implementations. The
	 *     query VO carries the field through so the REST controller
	 *     does not need to pre-strip it; the wider repository
	 *     contract (default-query parity, filter composition) still
	 *     holds because the candidate set is left unfiltered for
	 *     that axis alone.
	 *
	 * After filtering, the candidate set is sorted by the closed-set
	 * `sortKey` (already validated by {@see ProductListQuery}) using a
	 * stable comparator. Records whose sort field is null sort LAST
	 * regardless of order direction so the result envelope never
	 * mixes "no value" rows into the middle of the ordered slice.
	 *
	 * The total row count is computed AFTER filtering and BEFORE
	 * pagination so the returned {@see ProductListResult} reports the
	 * value `total_pages` is derived from per
	 * `specifications/rest-api-spec.md` §4.2.
	 *
	 * @param ProductListQuery $query Validated list-query inputs.
	 */
	public function findPaginated( ProductListQuery $query ): ProductListResult {
		$filtered = array_values( $this->records );

		if ( $query->provider() !== null ) {
			$provider = $query->provider();
			$filtered = array_values(
				array_filter(
					$filtered,
					static function ( ProductRecord $record ) use ( $provider ): bool {
						return $record->provider() === $provider;
					}
				)
			);
		}

		if ( $query->search() !== null ) {
			$needle   = $query->search();
			$filtered = array_values(
				array_filter(
					$filtered,
					static function ( ProductRecord $record ) use ( $needle ): bool {
						return mb_stripos( $record->title(), $needle ) !== false;
					}
				)
			);
		}

		if ( $query->syncState() !== null ) {
			$syncState = $query->syncState();
			$filtered  = array_values(
				array_filter(
					$filtered,
					static function ( ProductRecord $record ) use ( $syncState ): bool {
						return $record->syncState() === $syncState;
					}
				)
			);
		}

		if ( $query->lifecycleState() !== null ) {
			$lifecycleState = $query->lifecycleState();
			$filtered       = array_values(
				array_filter(
					$filtered,
					static function ( ProductRecord $record ) use ( $lifecycleState ): bool {
						return $record->lifecycleState() === $lifecycleState;
					}
				)
			);
		}

		// The `usedInPostId` filter is a Phase-9 concern (sync /
		// usage tracking) and is currently silently ignored by both
		// repository implementations. The candidate set is left
		// unfiltered for that axis alone so the rest of the contract
		// (filter composition, pagination math) still exercises
		// end-to-end.

		$sortKey   = $query->sortKey();
		$sortOrder = $query->sortOrder();

		usort(
			$filtered,
			static function ( ProductRecord $a, ProductRecord $b ) use ( $sortKey, $sortOrder ): int {
				$valueA = self::sortValue( $a, $sortKey );
				$valueB = self::sortValue( $b, $sortKey );

				// Null sort fields sort LAST regardless of direction.
				if ( $valueA === null && $valueB === null ) {
					return 0;
				}
				if ( $valueA === null ) {
					return 1;
				}
				if ( $valueB === null ) {
					return -1;
				}

				if ( is_string( $valueA ) && is_string( $valueB ) ) {
					$cmp = strcmp( $valueA, $valueB );
				} else {
					$cmp = $valueA <=> $valueB;
				}

				return $sortOrder === ProductListQuery::SORT_ORDER_ASC ? $cmp : -$cmp;
			}
		);

		$totalItems = count( $filtered );
		$page       = $query->page();
		$perPage    = $query->perPage();

		$offset = ( $page - 1 ) * $perPage;
		$slice  = array_values( array_slice( $filtered, $offset, $perPage ) );

		return new ProductListResult( $slice, $totalItems, $page, $perPage );
	}

	/**
	 * Extract the comparable value for a record under the given sort
	 * key. Returns null when the record's value for that key is null
	 * (drives the "null sorts LAST" branch in the comparator).
	 *
	 * The closed-set `$sortKey` is enforced by
	 * {@see ProductListQuery}, so no defensive default branch is
	 * needed here.
	 *
	 * @param ProductRecord $record Record to project.
	 * @param string        $sortKey Closed-set sort key.
	 *
	 * @return string|int|null
	 */
	private static function sortValue( ProductRecord $record, string $sortKey ) {
		switch ( $sortKey ) {
			case ProductListQuery::SORT_KEY_TITLE:
				return $record->title();
			case ProductListQuery::SORT_KEY_CREATED_AT:
				return $record->createdAt()->getTimestamp();
			case ProductListQuery::SORT_KEY_LAST_SYNCED_AT:
				$lastSynced = $record->lastSyncedAt();
				return $lastSynced === null ? null : $lastSynced->getTimestamp();
			case ProductListQuery::SORT_KEY_UPDATED_AT:
			default:
				return $record->updatedAt()->getTimestamp();
		}
	}

	/**
	 * 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 ProductRepository}
	 * contract and the forthcoming Wpdb-backed implementation will
	 * not offer it.
	 *
	 * @return list<ProductRecord>
	 */
	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;
	}

	/**
	 * Compose the composite key used by the `(provider, provider_item_id)`
	 * secondary index.
	 *
	 * A null-byte separator is used so the composite key is unambiguous
	 * even when a provider or item id contains a literal delimiter
	 * character. Exposed as a private static helper (rather than inlined)
	 * so that every read / write site through this class agrees on the
	 * exact encoding.
	 *
	 * @param string $provider       Provider identifier.
	 * @param string $providerItemId Provider-side item identifier.
	 */
	private static function providerItemKey( string $provider, string $providerItemId ): string {
		return $provider . "\0" . $providerItemId;
	}
}
