<?php
/**
 * In-memory log repository for tests.
 *
 * Reference {@see LogRepository} implementation that keeps every
 * persisted {@see LogRecord} 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 WpdbLogRepository} against a live database instead.
 *
 * Semantic parity with {@see WpdbLogRepository}:
 *
 *   - {@see self::save()} is insert-only, matching the
 *     {@see LogRepository::save()} contract. A null-id record is
 *     assigned the next auto-increment id starting at 1 and always
 *     advancing; a non-null-id record raises {@see \RuntimeException}
 *     because `affilun_logs` is append-only (§9.2 omits `updated_at`,
 *     §9.4 defers retention to a purge job).
 *
 *   - Unlike the product / embed doubles, this repository enforces NO
 *     unique secondary index because `database-schema.md` §9.3
 *     declares none. The only uniqueness invariant is the primary-key
 *     axis, which the auto-increment cursor maintains.
 *
 *   - {@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.
 *
 * The store deliberately holds {@see LogRecord} 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 LogRecord} constructor.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use InvalidArgumentException;
use RuntimeException;

/**
 * Array-backed {@see LogRepository} implementation for unit tests.
 *
 * See the file-level docblock for the semantic parity guarantees this
 * double maintains with {@see WpdbLogRepository}, including the
 * insert-only `save()` semantics that distinguish it from the other
 * AFFILUN persistence doubles.
 */
final class InMemoryLogRepository implements LogRepository {

	/**
	 * 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_logs` is append-only.
	 *
	 * @var array<int, LogRecord>
	 */
	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<LogRecord> $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 = 'InMemoryLogRepository 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( 'InMemoryLogRepository seed contains duplicate id %d.', $id );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

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

	/**
	 * Persist the record and return its post-insert form.
	 *
	 * See {@see LogRepository::save()} for the full contract. This
	 * implementation enforces the insert-only rule by raising
	 * {@see \RuntimeException} when the supplied record already
	 * carries an id; the rationale is documented at length in the
	 * contract's file-level docblock (append-only schema, retention
	 * via purge job).
	 *
	 * On insert, the next auto-increment id is allocated and the
	 * record is stored under it. The returned record carries the
	 * newly-allocated id without mutating the input.
	 *
	 * @param LogRecord $record Record to persist. Must carry a null id.
	 *
	 * @throws RuntimeException When the supplied record already
	 *                          carries a non-null id.
	 */
	public function save( LogRecord $record ): LogRecord {
		$id = $record->id();

		if ( $id !== null ) {
			$message = sprintf(
				'InMemoryLogRepository::save() is insert-only; '
				. 'refusing to persist a record that already carries id %d. '
				. 'affilun_logs is append-only per database-schema.md §9.2 / §9.4.',
				$id
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message );
		}

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

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

		$this->records[ $assigned ] = $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 ): ?LogRecord {
		if ( $id <= 0 ) {
			$message = sprintf( 'InMemoryLogRepository::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;
	}

	/**
	 * 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.
	 *
	 * Single-row only; bulk purge belongs with the `cleanup_logs`
	 * scheduled job (§8.4 / §9.4) and is intentionally not part of
	 * the {@see LogRepository} contract.
	 *
	 * @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( 'InMemoryLogRepository::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;
		}

		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 LogRepository}
	 * contract and the Wpdb-backed implementation does not offer it.
	 *
	 * @return list<LogRecord>
	 */
	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 LogRepository::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 LogListQuery $query Validated list-query inputs.
	 */
	public function findPaginated( LogListQuery $query ): LogListResult {
		$matching = array();
		foreach ( $this->records as $record ) {
			if ( ! $this->matchesQuery( $record, $query ) ) {
				continue;
			}
			$matching[] = $record;
		}

		usort(
			$matching,
			static function ( LogRecord $a, LogRecord $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<LogRecord> $slice */
		return new LogListResult( $slice, $total, $query->page(), $perPage );
	}

	/**
	 * Group every stored row by PSR-3 level and return the per-level
	 * counts.
	 *
	 * See {@see LogRepository::countByLevel()} for the contract; in
	 * particular, levels with zero occurrences are omitted so callers
	 * default-fill against {@see LogListQuery::VALID_LEVELS}.
	 *
	 * @return array<string, int>
	 */
	public function countByLevel(): array {
		$counts = array();
		foreach ( $this->records as $record ) {
			$level            = $record->level();
			$counts[ $level ] = ( $counts[ $level ] ?? 0 ) + 1;
		}
		return $counts;
	}

	/**
	 * Return the most recent log entries, newest-first, capped at
	 * `$limit`.
	 *
	 * See {@see LogRepository::findRecent()} for the contract. The
	 * ordering proxy mirrors {@see self::findPaginated()} (descending
	 * id since this double allocates ids monotonically).
	 *
	 * @param int $limit Maximum number of entries to return (1..100).
	 *
	 * @return list<LogRecord>
	 *
	 * @throws InvalidArgumentException When `$limit` is out of range.
	 */
	public function findRecent( int $limit ): array {
		if ( $limit < 1 || $limit > LogListQuery::MAX_PER_PAGE ) {
			$message = sprintf(
				'InMemoryLogRepository::findRecent() requires limit between 1 and %d, got %d.',
				LogListQuery::MAX_PER_PAGE,
				$limit
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$ordered = array_values( $this->records );
		usort(
			$ordered,
			static function ( LogRecord $a, LogRecord $b ): int {
				return ( $b->id() ?? 0 ) <=> ( $a->id() ?? 0 );
			}
		);

		return array_slice( $ordered, 0, $limit );
	}

	/**
	 * Whether `$record` satisfies every non-null filter axis on
	 * `$query`.
	 *
	 * @param LogRecord    $record Record under test.
	 * @param LogListQuery $query  Filter axes.
	 */
	private function matchesQuery( LogRecord $record, LogListQuery $query ): bool {
		if ( $query->level() !== null && $record->level() !== $query->level() ) {
			return false;
		}
		if ( $query->channel() !== null && $record->channel() !== $query->channel() ) {
			return false;
		}
		if ( $query->relatedProductId() !== null && $record->relatedProductId() !== $query->relatedProductId() ) {
			return false;
		}
		if ( $query->relatedJobId() !== null && $record->relatedJobId() !== $query->relatedJobId() ) {
			return false;
		}
		return true;
	}
}
