<?php
/**
 * In-memory transaction manager for tests.
 *
 * Reference {@see TransactionManager} implementation that records the
 * lifecycle transitions of a transactional scope (begin / commit /
 * rollback) on the instance, with no dependency on `$wpdb` or any
 * real database. This is the canonical test double used by pure-unit
 * tests that need to assert on transaction boundaries without
 * driving a live MySQL connection; integration tests that need real
 * driver semantics should exercise {@see WpdbTransactionManager}
 * against a live database instead.
 *
 * Semantic parity with {@see WpdbTransactionManager}:
 *
 *   - {@see self::transactional()} brackets the supplied callable
 *     with a "begin" event on entry, a "commit" event on normal
 *     return, and a "rollback" event on any thrown exception. The
 *     callable's return value is forwarded verbatim to the caller;
 *     a thrown exception is re-thrown verbatim after rollback.
 *
 *   - Nested calls raise {@see \RuntimeException} before any event
 *     is recorded, matching the contract's "Phase 2 does not support
 *     nesting" rule.
 *
 *   - {@see self::inTransaction()} reflects the live lifecycle state
 *     and is true only between a "begin" event and the matching
 *     "commit" / "rollback" event.
 *
 * The double also exposes the recorded lifecycle events
 * ({@see self::events()}) and a few aggregate counters
 * ({@see self::beginCount()}, {@see self::commitCount()},
 * {@see self::rollbackCount()}) so tests can assert on the exact
 * sequence of transitions a unit of work drove without inspecting
 * private state on the consumer under test.
 *
 * The double does not interact with any other repository — it has no
 * way to "actually roll back" the in-memory state of, say, an
 * {@see InMemoryProductRepository} that the supplied callable
 * mutated. Tests that want to assert on rollback semantics across
 * multiple in-memory repositories should snapshot the relevant
 * repository state before the call and compare after, rather than
 * relying on this double to undo writes. The Wpdb-backed
 * implementation, by contrast, defers to MySQL's actual transaction
 * semantics on a live connection.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use RuntimeException;
use Throwable;

/**
 * Recording {@see TransactionManager} implementation for unit tests.
 *
 * See the file-level docblock for the lifecycle the double records,
 * its non-interaction with other in-memory repositories, and the
 * limited set of accessors it exposes for test assertions.
 */
final class InMemoryTransactionManager implements TransactionManager {

	/**
	 * Lifecycle event marker recorded when a transaction begins.
	 *
	 * Exposed as a class constant so tests can reference the exact
	 * string without duplicating the literal.
	 */
	public const EVENT_BEGIN = 'begin';

	/**
	 * Lifecycle event marker recorded when a transaction commits.
	 */
	public const EVENT_COMMIT = 'commit';

	/**
	 * Lifecycle event marker recorded when a transaction rolls back
	 * due to an exception thrown by the unit of work.
	 */
	public const EVENT_ROLLBACK = 'rollback';

	/**
	 * Whether a transaction is currently open on this instance.
	 *
	 * Toggled to true by {@see self::transactional()} immediately
	 * before invoking the unit of work, and back to false in the
	 * finally branch after the matching commit / rollback event has
	 * been recorded. Used to enforce the "no nesting" rule from the
	 * {@see TransactionManager} contract.
	 */
	private bool $inTransaction = false;

	/**
	 * Ordered log of every lifecycle event recorded by this manager,
	 * each entry being one of {@see self::EVENT_BEGIN},
	 * {@see self::EVENT_COMMIT}, or {@see self::EVENT_ROLLBACK}.
	 *
	 * Maintained append-only across the lifetime of the instance so
	 * tests can assert on the full sequence of transitions, not just
	 * the most recent one.
	 *
	 * @var list<string>
	 */
	private array $events = array();

	/**
	 * Execute the unit of work inside a recorded transactional scope.
	 *
	 * See {@see TransactionManager::transactional()} for the full
	 * contract. This implementation:
	 *
	 *   - Refuses re-entry: if a transaction is already active on
	 *     this instance, raises {@see \RuntimeException} before
	 *     recording any event, matching the contract's
	 *     "Phase 2 does not support nesting" rule.
	 *
	 *   - Records {@see self::EVENT_BEGIN} immediately before
	 *     invoking `$work`.
	 *
	 *   - Records {@see self::EVENT_COMMIT} when `$work` returns
	 *     normally and forwards its return value verbatim.
	 *
	 *   - Records {@see self::EVENT_ROLLBACK} when `$work` throws
	 *     any {@see \Throwable}, then re-throws the original
	 *     exception verbatim (no wrapping, no swallowing).
	 *
	 * @template TReturn
	 *
	 * @param callable():TReturn $work Unit of work to execute.
	 *
	 * @return TReturn The value returned by `$work`.
	 *
	 * @throws RuntimeException When called while a transaction is
	 *                          already active on this instance.
	 * @throws Throwable        Any exception raised by `$work` is
	 *                          re-thrown verbatim after a
	 *                          rollback event has been recorded.
	 */
	public function transactional( callable $work ) {
		if ( $this->inTransaction ) {
			$message = 'InMemoryTransactionManager::transactional() does not support nested calls; '
				. 'a transaction is already active on this instance.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message );
		}

		$this->inTransaction = true;
		$this->events[]      = self::EVENT_BEGIN;

		try {
			$result = $work();
		} catch ( Throwable $error ) {
			$this->events[]      = self::EVENT_ROLLBACK;
			$this->inTransaction = false;
			throw $error;
		}

		$this->events[]      = self::EVENT_COMMIT;
		$this->inTransaction = false;

		return $result;
	}

	/**
	 * Report whether a transaction is currently active on this
	 * instance.
	 *
	 * Mirrors the {@see TransactionManager::inTransaction()} surface;
	 * see the contract for the rule that production code SHOULD NOT
	 * branch on this method.
	 */
	public function inTransaction(): bool {
		return $this->inTransaction;
	}

	/**
	 * Ordered list of every lifecycle event this manager has
	 * recorded across its lifetime, in the order they occurred.
	 *
	 * Each entry is one of {@see self::EVENT_BEGIN},
	 * {@see self::EVENT_COMMIT}, or {@see self::EVENT_ROLLBACK}.
	 * Exposed primarily for test assertions; production code MUST
	 * NOT rely on this method because it has no counterpart on the
	 * {@see TransactionManager} contract and the Wpdb-backed
	 * implementation does not offer it.
	 *
	 * @return list<string>
	 */
	public function events(): array {
		return $this->events;
	}

	/**
	 * Number of {@see self::EVENT_BEGIN} events recorded so far.
	 *
	 * Convenience accessor for tests that only care about the
	 * aggregate count, not the full sequence in {@see self::events()}.
	 */
	public function beginCount(): int {
		return $this->countEvent( self::EVENT_BEGIN );
	}

	/**
	 * Number of {@see self::EVENT_COMMIT} events recorded so far.
	 */
	public function commitCount(): int {
		return $this->countEvent( self::EVENT_COMMIT );
	}

	/**
	 * Number of {@see self::EVENT_ROLLBACK} events recorded so far.
	 */
	public function rollbackCount(): int {
		return $this->countEvent( self::EVENT_ROLLBACK );
	}

	/**
	 * Count occurrences of the given lifecycle event marker in
	 * {@see self::$events}.
	 *
	 * Centralizes the per-event counter logic so the public
	 * accessors above stay terse.
	 *
	 * @param string $event One of the {@see self::EVENT_*} constants.
	 */
	private function countEvent( string $event ): int {
		$count = 0;
		foreach ( $this->events as $recorded ) {
			if ( $recorded === $event ) {
				++$count;
			}
		}
		return $count;
	}
}
