<?php
/**
 * WordPress-backed schema installer.
 *
 * Thin adapter that implements {@see SchemaInstaller} against the
 * WordPress `dbDelta()` helper. `dbDelta()` is defined in
 * `wp-admin/includes/upgrade.php` and is not autoloaded by WordPress
 * outside the admin context, so this adapter lazily requires the file
 * on first use to keep the plugin usable from activation hooks, CLI
 * commands, and cron callbacks alike.
 *
 * The adapter is intentionally allocation-light on the hot path:
 * it forwards the caller's SQL to `dbDelta()` verbatim and normalizes
 * the return value to `list<string>` so that {@see SchemaInstaller}
 * implementations share a single return shape regardless of whether
 * the underlying installer returned an associative array (dbDelta's
 * historical behavior) or something already index-sequential.
 *
 * Autoload / require behavior:
 *
 *   - `dbDelta()` lives in `ABSPATH . 'wp-admin/includes/upgrade.php'`.
 *     Production call sites (plugin activation, cron, CLI) may be
 *     invoked before the admin bootstrap has required that file, so
 *     the adapter requires it on first use via `require_once`. The
 *     guard is `function_exists( 'dbDelta' )` rather than a state flag
 *     so a host that has already pulled the file in (for example via
 *     `wp core update`) pays no extra include cost.
 *
 *   - `ABSPATH` is a WordPress runtime constant. When it is not
 *     defined (unit tests running outside WordPress), the adapter
 *     cannot do its job; callers in that environment are expected to
 *     inject an in-memory fake implementing {@see SchemaInstaller}
 *     instead of instantiating this adapter.
 *
 * Error signaling:
 *
 *   - `dbDelta()` itself does not throw on ordinary "table already
 *     exists" outcomes; it simply returns an empty or short list of
 *     diagnostic messages. That matches the {@see SchemaInstaller}
 *     contract, so the adapter forwards the return value without
 *     interpreting it.
 *
 *   - If the DDL is malformed and `$wpdb->query()` reports an error,
 *     `dbDelta()` surfaces the failure through `$wpdb->last_error`
 *     rather than throwing. The adapter inspects that field after the
 *     call and raises a {@see \RuntimeException} so the migration
 *     runner can treat the step as failed (per its re-throw contract).
 *
 * @package CommerceLinkBuilder\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Persistence;

use RuntimeException;

/**
 * {@see SchemaInstaller} implementation backed by WordPress's `dbDelta()`.
 *
 * See the file-level docblock for lazy-require behavior and error
 * signaling. This adapter is deliberately allocation-light: it forwards
 * the caller's SQL verbatim and normalizes only the return shape.
 */
final class WpdbSchemaInstaller implements SchemaInstaller {

	/**
	 * Apply a single `CREATE TABLE` DDL statement via `dbDelta()`.
	 *
	 * Lazily requires `wp-admin/includes/upgrade.php` on first use so
	 * the adapter is safe to call from activation hooks, cron
	 * callbacks, and CLI commands — contexts where WordPress does not
	 * autoload the admin upgrade helpers. The require is guarded by
	 * `function_exists( 'dbDelta' )` so repeat calls and hosts that
	 * have already pulled the file in pay no extra cost.
	 *
	 * `dbDelta()` historically returns an associative array keyed by
	 * table/column name; the adapter reindexes it to `list<string>` so
	 * consumers of {@see SchemaInstaller} see a uniform shape.
	 *
	 * @param string $createTableSql Fully-formed `CREATE TABLE` DDL.
	 *
	 * @return list<string> Diagnostic messages from `dbDelta()`; empty
	 *                      when no change was required.
	 *
	 * @throws RuntimeException When `ABSPATH` is not defined (adapter
	 *                          instantiated outside WordPress) or when
	 *                          `$wpdb->last_error` is populated after
	 *                          the `dbDelta()` call.
	 */
	public function install( string $createTableSql ): array {
		if ( ! defined( 'ABSPATH' ) ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException(
				'WpdbSchemaInstaller requires a loaded WordPress environment (ABSPATH is not defined). '
				. 'Unit tests should inject an in-memory SchemaInstaller fake instead.'
			);
		}

		if ( ! function_exists( 'dbDelta' ) ) {
			require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		}

		global $wpdb;

		// Clear any prior error string so the post-call probe only sees
		// errors produced by the current dbDelta() invocation.
		if ( isset( $wpdb ) && property_exists( $wpdb, 'last_error' ) ) {
			$wpdb->last_error = '';
		}

		/** @var array<array-key, mixed> $messages */
		$messages = dbDelta( $createTableSql );

		if ( isset( $wpdb ) && property_exists( $wpdb, 'last_error' ) ) {
			$lastError = (string) $wpdb->last_error;
			if ( $lastError !== '' ) {
				$message = sprintf(
					'dbDelta() reported a database error while applying schema: %s',
					$lastError
				);

				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new RuntimeException( $message );
			}
		}

		$normalized = array();
		foreach ( $messages as $message ) {
			if ( is_string( $message ) ) {
				$normalized[] = $message;
			} elseif ( is_scalar( $message ) ) {
				$normalized[] = (string) $message;
			}
		}

		return $normalized;
	}
}
