<?php
/**
 * In-memory product-meta repository for tests.
 *
 * Reference {@see ProductMetaRepository} implementation that keeps
 * every persisted {@see ProductMetaRecord} 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 WpdbProductMetaRepository} against a live database
 * instead.
 *
 * Semantic parity with {@see WpdbProductMetaRepository}:
 *
 *   - {@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 and updates enforce the
 *     `(product_id, meta_key)` composite unique constraint from
 *     `database-schema.md` §5.3 by probing the in-memory index before
 *     accepting the write. A violation raises a
 *     {@see \RuntimeException} whose message mentions the colliding
 *     `(productId, metaKey)` axis so the offending tuple is visible
 *     in test failures.
 *
 *   - {@see self::findById()} and {@see self::findByProductAndKey()}
 *     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 ProductMetaRecord} 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
 * round-trips through the array form on its storage boundary; the two
 * implementations remain behaviorally equivalent because both
 * normalize timestamps to UTC via the {@see ProductMetaRecord}
 * constructor.
 *
 * @package CommerceLinkBuilder\Infrastructure\Persistence
 */

declare(strict_types=1);

// The `meta_key` / `meta_value` column names on `clb_product_meta` are
// part of CLB's own schema (§5.2), not WordPress's `postmeta` table.
// WPCS's SlowDBQuery sniff cannot tell the two apart, so suppress it
// at the file level rather than peppering every reference with a
// per-line `phpcs:ignore` comment.
//
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key
// phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_value

namespace CommerceLinkBuilder\Infrastructure\Persistence;

use InvalidArgumentException;
use RuntimeException;

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

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

	/**
	 * Secondary index over the `(product_id, meta_key)` composite
	 * unique axis, mapping each `productId|metaKey` composite to the
	 * primary-key id currently occupying it.
	 *
	 * The composite key uses a `\u{0000}` (null byte) separator so it
	 * cannot collide with any value a caller could pass as a meta key
	 * on the application side, where keys are bounded to printable
	 * `varchar(191)` content per §5.2.
	 *
	 * Maintained in sync with {@see self::$records} by every mutating
	 * method.
	 *
	 * @var array<string, int>
	 */
	private array $compositeIndex = 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<ProductMetaRecord> $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
	 *                                  `(productId, metaKey)` axis.
	 */
	public function __construct( array $seed = array() ) {
		foreach ( $seed as $record ) {
			$id = $record->id();
			if ( $id === null ) {
				$message = 'InMemoryProductMetaRepository 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( 'InMemoryProductMetaRepository seed contains duplicate id %d.', $id );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$composite = self::compositeKey( $record->productId(), $record->metaKey() );
			if ( array_key_exists( $composite, $this->compositeIndex ) ) {
				$message = sprintf(
					'InMemoryProductMetaRepository seed contains duplicate (product_id=%d, meta_key="%s").',
					$record->productId(),
					$record->metaKey()
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

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

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

		if ( array_key_exists( $composite, $this->compositeIndex ) ) {
			$message = sprintf(
				'InMemoryProductMetaRepository: duplicate (product_id=%d, meta_key="%s") on insert.',
				$record->productId(),
				$record->metaKey()
			);
			// 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->compositeIndex[ $composite ] = $id;

		return $persisted;
	}

	/**
	 * Overwrite an existing row with the supplied record.
	 *
	 * @param int               $id     Primary key of the row being updated.
	 * @param ProductMetaRecord $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 `(product_id, meta_key)` tuple
	 *                          already occupied by a different row.
	 */
	private function update( int $id, ProductMetaRecord $record ): ProductMetaRecord {
		if ( ! array_key_exists( $id, $this->records ) ) {
			$message = sprintf(
				'InMemoryProductMetaRepository: 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 ];
		$newComposite = self::compositeKey( $record->productId(), $record->metaKey() );
		$oldComposite = self::compositeKey( $existing->productId(), $existing->metaKey() );

		if (
			$newComposite !== $oldComposite
			&& array_key_exists( $newComposite, $this->compositeIndex )
		) {
			$message = sprintf(
				'InMemoryProductMetaRepository: update would collide with existing '
				. '(product_id=%d, meta_key="%s").',
				$record->productId(),
				$record->metaKey()
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message );
		}

		if ( $newComposite !== $oldComposite ) {
			unset( $this->compositeIndex[ $oldComposite ] );
			$this->compositeIndex[ $newComposite ] = $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 ): ?ProductMetaRecord {
		if ( $id <= 0 ) {
			$message = sprintf(
				'InMemoryProductMetaRepository::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 `(product_id, meta_key)` composite
	 * unique key.
	 *
	 * Returns null when no row matches; raises
	 * {@see InvalidArgumentException} for non-positive product ids
	 * and empty meta keys because the only way to reach those paths
	 * is a programming error at the call site.
	 *
	 * @param int    $productId Owning `clb_products.id` reference.
	 * @param string $metaKey   Meta key.
	 *
	 * @throws InvalidArgumentException When `$productId` is
	 *                                  non-positive or `$metaKey` is
	 *                                  the empty string.
	 */
	public function findByProductAndKey( int $productId, string $metaKey ): ?ProductMetaRecord {
		if ( $productId <= 0 ) {
			$message = sprintf(
				'InMemoryProductMetaRepository::findByProductAndKey() requires a positive productId, got %d.',
				$productId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

		$composite = self::compositeKey( $productId, $metaKey );

		if ( ! array_key_exists( $composite, $this->compositeIndex ) ) {
			return null;
		}

		$id = $this->compositeIndex[ $composite ];

		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(
				'InMemoryProductMetaRepository::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 ];
		$composite = self::compositeKey( $existing->productId(), $existing->metaKey() );

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

		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 ProductMetaRepository}
	 * contract and the Wpdb-backed implementation does not offer it.
	 *
	 * @return list<ProductMetaRecord>
	 */
	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 a stable lookup key for the
	 * `(product_id, meta_key)` composite unique axis.
	 *
	 * Uses a null-byte separator so the composite cannot collide with
	 * any printable `varchar(191)` meta key the application layer
	 * might supply.
	 *
	 * @param int    $productId Owning `clb_products.id` reference.
	 * @param string $metaKey   Meta key.
	 */
	private static function compositeKey( int $productId, string $metaKey ): string {
		return $productId . "\0" . $metaKey;
	}
}
