<?php
/**
 * WordPress-backed transaction manager.
 *
 * Production {@see TransactionManager} implementation that brackets a
 * unit of work with the WordPress `$wpdb` connection's transaction
 * primitives — `START TRANSACTION` on entry, `COMMIT` on success,
 * `ROLLBACK` on failure — by routing each statement through
 * `$wpdb->query()`.
 *
 * Design notes:
 *
 *   - The implementation owns the lifecycle state on the manager
 *     instance, NOT on the underlying `$wpdb` object. Two
 *     {@see WpdbTransactionManager} instances pointing at the same
 *     `$wpdb` would each track their own `$inTransaction` flag, which
 *     in turn means callers MUST share a single manager instance per
 *     request (the persistence service provider does this by binding
 *     the manager into the registry once). The Phase 2 contract
 *     explicitly forbids nested calls from the same instance, so the
 *     "two managers, same `$wpdb`" foot-gun is a deliberate trade-off:
 *     the alternative — probing `$wpdb` for connection-level state —
 *     would require driver-specific introspection that is brittle
 *     across MySQL / MariaDB versions.
 *
 *   - Each transaction primitive is issued as a standalone
 *     `$wpdb->query()` call rather than `$wpdb->startTransaction()` /
 *     `$wpdb->commit()` / `$wpdb->rollback()` because those helper
 *     methods are not part of WordPress's stable public surface and
 *     are subject to change between releases. Routing through
 *     `query()` keeps the dependency surface narrow and matches the
 *     access pattern the rest of the persistence layer already uses.
 *
 *   - Driver errors (`$wpdb->last_error`) are promoted to
 *     {@see RuntimeException} after every transaction-control call,
 *     so a failed `START TRANSACTION` / `COMMIT` / `ROLLBACK` surfaces
 *     loudly at the boundary instead of producing a silently
 *     half-applied state. When a unit of work throws and the ensuing
 *     ROLLBACK itself fails, the rollback failure is wrapped in a
 *     {@see RuntimeException} that chains the original exception via
 *     `$previous` so neither error is lost.
 *
 *   - The unit of work's return value is forwarded verbatim, matching
 *     {@see TransactionManager::transactional()}'s value-transparent
 *     contract. Exceptions thrown by the unit of work are re-thrown
 *     verbatim after rollback — no wrapping, no swallowing — so the
 *     caller can match on the original type.
 *
 *   - `$wpdb` is typed as `object` rather than the concrete `\wpdb`
 *     class so this file stays trivially loadable in unit tests that
 *     run outside a WordPress environment, matching the convention
 *     used by every other Wpdb-backed repository in this namespace.
 *     The manager still requires the real interface surface at call
 *     time; the abstract type hint only keeps the PSR-4 loader and
 *     the PHPCS / PHPStan analyzers happy in pure-unit mode.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use RuntimeException;
use Throwable;

/**
 * {@see TransactionManager} implementation backed by WordPress's
 * `$wpdb`.
 *
 * See the file-level docblock for the per-instance lifecycle-state
 * design, the `$wpdb->query()`-based driver-call strategy, and the
 * error-promotion policy.
 */
final class WpdbTransactionManager implements TransactionManager {

	/**
	 * SQL statement issued to open a fresh transaction.
	 *
	 * Named as a class constant so tests and diagnostics can
	 * reference the exact string without duplicating the literal,
	 * and so a future driver-portability change has a single
	 * location to update.
	 */
	private const SQL_BEGIN = 'START TRANSACTION';

	/**
	 * SQL statement issued to commit the active transaction.
	 */
	private const SQL_COMMIT = 'COMMIT';

	/**
	 * SQL statement issued to roll back the active transaction.
	 */
	private const SQL_ROLLBACK = 'ROLLBACK';

	/**
	 * WordPress `$wpdb`-like object used for every transaction
	 * primitive.
	 *
	 * Typed as `object` rather than `\wpdb` so this file stays
	 * loadable in unit tests that run outside a WordPress
	 * environment; see the file-level docblock for the rationale.
	 */
	private object $wpdb;

	/**
	 * Whether a transaction is currently open on this manager
	 * instance.
	 *
	 * Toggled to true by {@see self::transactional()} immediately
	 * after `START TRANSACTION` succeeds and back to false in the
	 * finally branch after the matching `COMMIT` / `ROLLBACK` has
	 * been issued. Used to enforce the "no nesting" rule from the
	 * {@see TransactionManager} contract.
	 *
	 * NOTE: This flag tracks the lifecycle on the manager instance
	 * only, not on the underlying `$wpdb` connection. Callers MUST
	 * share a single manager instance per request to avoid two
	 * managers each thinking the connection is idle while it is
	 * actually inside a transaction owned by the other.
	 */
	private bool $inTransaction = false;

	/**
	 * Construct the manager.
	 *
	 * @param object $wpdb WordPress `$wpdb`-like object exposing
	 *                     `query()` and the `last_error` property.
	 */
	public function __construct( object $wpdb ) {
		$this->wpdb = $wpdb;
	}

	/**
	 * Execute the unit of work inside a database transaction.
	 *
	 * See {@see TransactionManager::transactional()} for the full
	 * contract. This implementation:
	 *
	 *   - Refuses re-entry: if a transaction is already active on
	 *     this manager instance, raises {@see RuntimeException}
	 *     before issuing any driver call, matching the contract's
	 *     "Phase 2 does not support nesting" rule.
	 *
	 *   - Issues `START TRANSACTION` via `$wpdb->query()` and
	 *     promotes any driver error to {@see RuntimeException}
	 *     before invoking the unit of work.
	 *
	 *   - On normal return, issues `COMMIT` and promotes any driver
	 *     error to {@see RuntimeException}, then forwards the
	 *     callable's return value verbatim.
	 *
	 *   - On any thrown {@see Throwable}, issues `ROLLBACK`. A
	 *     successful rollback re-throws the original exception
	 *     verbatim. A driver error during rollback surfaces as a
	 *     fresh {@see RuntimeException} that chains the original
	 *     exception via `$previous` so neither error is lost.
	 *
	 * @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, or
	 *                          when the underlying driver reports
	 *                          an error during
	 *                          `START TRANSACTION` / `COMMIT` /
	 *                          `ROLLBACK`.
	 * @throws Throwable        Any exception raised by `$work` is
	 *                          re-thrown verbatim after the
	 *                          transaction has been rolled back, so
	 *                          long as the rollback itself succeeded.
	 */
	public function transactional( callable $work ) {
		if ( $this->inTransaction ) {
			$message = 'WpdbTransactionManager::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->runStatement( 'begin' );
		$this->inTransaction = true;

		try {
			$result = $work();
		} catch ( Throwable $error ) {
			try {
				$this->safeRollback( $error );
			} finally {
				$this->inTransaction = false;
			}
			throw $error;
		}

		try {
			$this->runStatement( 'commit' );
		} finally {
			$this->inTransaction = false;
		}

		return $result;
	}

	/**
	 * Report whether a transaction is currently active on this
	 * manager 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;
	}

	/**
	 * Issue `ROLLBACK` after a unit-of-work failure, taking care to
	 * preserve the original exception when the rollback itself fails.
	 *
	 * On a successful rollback, the method returns normally and the
	 * caller is responsible for re-throwing the original
	 * `$workError`. When the rollback raises a driver error, this
	 * method throws a fresh {@see RuntimeException} that chains
	 * `$workError` via `$previous` so the test / diagnostics surface
	 * sees both failures.
	 *
	 * @param Throwable $workError The exception raised by the unit of
	 *                             work, preserved as the chained
	 *                             cause when rollback itself fails.
	 *
	 * @throws RuntimeException When `$wpdb->query('ROLLBACK')` raises
	 *                          a driver error.
	 */
	private function safeRollback( Throwable $workError ): void {
		try {
			$this->runStatement( 'rollback' );
		} catch ( RuntimeException $rollbackError ) {
			$message = sprintf(
				'WpdbTransactionManager: ROLLBACK failed after a unit-of-work exception: %s '
				. '(original error: %s)',
				$rollbackError->getMessage(),
				$workError->getMessage()
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message, 0, $workError );
		}
	}

	/**
	 * Issue a single transaction-control statement and promote any
	 * driver error to {@see RuntimeException}.
	 *
	 * Centralizes the clear / dispatch / probe sequence shared by
	 * `START TRANSACTION`, `COMMIT`, and `ROLLBACK` so the
	 * per-method bodies stay focused on lifecycle bookkeeping.
	 *
	 * @param string $operation Short operation label used in error
	 *                          messages (for example `begin`,
	 *                          `commit`, `rollback`).
	 *
	 * @throws RuntimeException When `$wpdb->query()` returns false
	 *                          or `$wpdb->last_error` is populated
	 *                          after the call.
	 */
	private function runStatement( string $operation ): void {
		$this->clearLastError();

		if ( $operation === 'begin' ) {
			$sql = self::SQL_BEGIN;
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Fixed transaction-control statement has no user-supplied parameters.
			$result = $this->wpdb->query( self::SQL_BEGIN );
		} elseif ( $operation === 'commit' ) {
			$sql = self::SQL_COMMIT;
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Fixed transaction-control statement has no user-supplied parameters.
			$result = $this->wpdb->query( self::SQL_COMMIT );
		} else {
			$sql = self::SQL_ROLLBACK;
			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared -- Fixed transaction-control statement has no user-supplied parameters.
			$result = $this->wpdb->query( self::SQL_ROLLBACK );
		}

		$this->assertNoDriverError( $operation );

		if ( $result === false ) {
			$message = sprintf(
				'WpdbTransactionManager: $wpdb->query(%s) returned false during %s '
				. 'without raising a driver error.',
				$sql,
				$operation
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message );
		}
	}

	/**
	 * Clear `$wpdb->last_error` before a call so the post-call probe
	 * only sees errors produced by the current statement.
	 */
	private function clearLastError(): void {
		if ( property_exists( $this->wpdb, 'last_error' ) ) {
			$this->wpdb->last_error = '';
		}
	}

	/**
	 * Raise a {@see RuntimeException} when `$wpdb->last_error` is
	 * populated, carrying the driver message verbatim for diagnosis.
	 *
	 * @param string $operation Short operation label used in the
	 *                          error message (for example `begin`,
	 *                          `commit`, `rollback`).
	 *
	 * @throws RuntimeException When a driver error is detected.
	 */
	private function assertNoDriverError( string $operation ): void {
		if ( ! property_exists( $this->wpdb, 'last_error' ) ) {
			return;
		}

		$lastError = (string) $this->wpdb->last_error;
		if ( $lastError === '' ) {
			return;
		}

		$message = sprintf(
			'WpdbTransactionManager: $wpdb reported an error during %s: %s',
			$operation,
			$lastError
		);
		// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
		throw new RuntimeException( $message );
	}
}
