<?php
/**
 * Plugin bootstrap orchestrator.
 *
 * Owns the top-level lifecycle of Commerce Link Builder: environment
 * verification, service provider registration, hook wiring, activation /
 * deactivation entry points, and fail-closed behavior when the host
 * environment does not meet the plugin's minimum requirements.
 *
 * The plugin entry file ({@see /commerce-link-builder.php}) creates a
 * single {@see Plugin} instance, passes the plugin file path to it,
 * registers the activation / deactivation callbacks against that
 * instance, and then calls {@see Plugin::boot()} which in turn hooks
 * the provider pipeline onto `plugins_loaded`.
 *
 * Lifecycle at a glance:
 *
 *   new Plugin( CLB_PLUGIN_FILE )
 *     ->withProviders( [ ... ] )   // optional, additive
 *     ->boot();                    // schedules plugins_loaded handler
 *
 *   on plugins_loaded:
 *     1. Run EnvironmentChecker::check().
 *     2. If failing: register an admin_notices renderer describing every
 *        failure and return early. No providers are registered, no hooks
 *        beyond the notice are wired, no fatal is raised.
 *     3. If passing: ServiceProviderRegistry::registerAll() then
 *        bootAll(), then fire the `clb_loaded` action so external
 *        integrations can hook in after the plugin is fully wired.
 *
 * This class contains no feature-specific logic — that lives in the
 * service providers it orchestrates. Its responsibility is strictly
 * "start the plugin, or fail safely".
 *
 * @package CommerceLinkBuilder\Bootstrap
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Bootstrap;

/**
 * Top-level plugin bootstrap.
 *
 * Intentionally not final so that tests can subclass to observe internal
 * transitions without having to exercise the full WordPress lifecycle.
 * Production code should treat it as effectively final and always
 * instantiate the base class directly.
 */
class Plugin {

	/**
	 * Absolute path to the plugin main file.
	 *
	 * Used as the canonical identity for activation / deactivation hooks
	 * and for resolving plugin-relative paths. Stored once at construction
	 * time so downstream code never has to guess.
	 */
	private string $pluginFile;

	/**
	 * Environment checker used to gate bootstrap.
	 */
	private EnvironmentChecker $environmentChecker;

	/**
	 * Service provider registry driving the two-phase bootstrap.
	 */
	private ServiceProviderRegistry $registry;

	/**
	 * Hook registrar shared with every provider's boot() method.
	 */
	private HookRegistrar $hooks;

	/**
	 * Whether {@see self::boot()} has already been invoked.
	 *
	 * Guards against accidental double-bootstrapping, which would cause
	 * hooks to be registered twice and break idempotency.
	 */
	private bool $booted = false;

	/**
	 * Last environment check result, populated on the plugins_loaded
	 * handler. Exposed for tests and diagnostics; production code does
	 * not need to read it.
	 */
	private ?EnvironmentCheckResult $lastEnvironmentCheckResult = null;

	/**
	 * Construct the bootstrap.
	 *
	 * Collaborators default to fresh instances so that the common case
	 * (`new Plugin( CLB_PLUGIN_FILE )`) is ergonomic. Tests may inject
	 * synthetic collaborators to observe behavior without touching
	 * WordPress globals.
	 *
	 * @param string                       $pluginFile         Absolute path to the plugin main file.
	 * @param EnvironmentChecker|null      $environmentChecker Override the environment checker.
	 * @param ServiceProviderRegistry|null $registry           Override the provider registry.
	 * @param HookRegistrar|null           $hooks              Override the hook registrar.
	 */
	public function __construct(
		string $pluginFile,
		?EnvironmentChecker $environmentChecker = null,
		?ServiceProviderRegistry $registry = null,
		?HookRegistrar $hooks = null
	) {
		$this->pluginFile         = $pluginFile;
		$this->environmentChecker = $environmentChecker ?? new EnvironmentChecker();
		$this->registry           = $registry ?? new ServiceProviderRegistry();
		$this->hooks              = $hooks ?? new HookRegistrar();
	}

	/**
	 * Absolute path to the plugin main file as supplied at construction.
	 */
	public function pluginFile(): string {
		return $this->pluginFile;
	}

	/**
	 * Service provider registry owned by this plugin instance.
	 *
	 * Exposed so that the plugin entry file or tests can add providers
	 * without having to thread the registry through other collaborators.
	 */
	public function registry(): ServiceProviderRegistry {
		return $this->registry;
	}

	/**
	 * Hook registrar owned by this plugin instance.
	 *
	 * Providers receive this registrar in their boot() method; it is
	 * exposed here primarily for tests and diagnostics.
	 */
	public function hooks(): HookRegistrar {
		return $this->hooks;
	}

	/**
	 * Environment checker owned by this plugin instance.
	 */
	public function environmentChecker(): EnvironmentChecker {
		return $this->environmentChecker;
	}

	/**
	 * Result of the most recent environment check, or null when boot()
	 * has not yet been driven through its plugins_loaded handler.
	 */
	public function lastEnvironmentCheckResult(): ?EnvironmentCheckResult {
		return $this->lastEnvironmentCheckResult;
	}

	/**
	 * Report whether {@see self::boot()} has already been invoked.
	 */
	public function isBooted(): bool {
		return $this->booted;
	}

	/**
	 * Add one or more service providers to the registry.
	 *
	 * Sugar over {@see ServiceProviderRegistry::add()} that returns $this
	 * so the plugin entry file can chain the call.
	 *
	 * @param list<ServiceProvider> $providers Providers to append, in order.
	 *
	 * @return static
	 */
	public function withProviders( array $providers ): self {
		foreach ( $providers as $provider ) {
			$this->registry->add( $provider );
		}

		return $this;
	}

	/**
	 * Schedule the bootstrap pipeline on `plugins_loaded`.
	 *
	 * Safe to call exactly once per instance. Repeat calls are no-ops so
	 * that accidental double bootstrapping does not double-register the
	 * plugins_loaded handler.
	 *
	 * When WordPress is not loaded (pure-unit test context), this still
	 * records the intent to register via {@see HookRegistrar} so tests
	 * can assert the registration happened, and it is safe to call
	 * {@see self::runBootstrap()} manually to exercise the pipeline.
	 */
	public function boot(): void {
		if ( $this->booted ) {
			return;
		}
		$this->booted = true;

		$this->hooks->addAction(
			'plugins_loaded',
			function (): void {
				$this->runBootstrap();
			}
		);
	}

	/**
	 * Execute the bootstrap pipeline synchronously.
	 *
	 * This is the function scheduled on `plugins_loaded` by {@see self::boot()}.
	 * It is also callable directly from tests and from code paths that
	 * need to drive bootstrap outside of the normal WordPress lifecycle
	 * (for example a diagnostics CLI command).
	 *
	 * Behavior:
	 *
	 *   - Run the environment check. If it fails, register an admin
	 *     notice renderer describing every failure and return. No
	 *     providers are registered, no `clb_loaded` action is fired.
	 *
	 *   - If the environment passes, call registerAll() then bootAll()
	 *     on the registry, then fire the `clb_loaded` action so that
	 *     external integrations can reliably hook in after the plugin
	 *     is fully wired.
	 */
	public function runBootstrap(): void {
		$result                           = $this->environmentChecker->check();
		$this->lastEnvironmentCheckResult = $result;

		if ( $result->hasErrors() ) {
			$this->registerEnvironmentFailureNotice( $result );
			return;
		}

		$this->registry->registerAll();
		$this->registry->bootAll( $this->hooks );

		if ( function_exists( 'do_action' ) ) {
			/**
			 * Fires after Commerce Link Builder has finished its bootstrap
			 * sequence and every service provider has been booted.
			 *
			 * Integrators SHOULD hook here rather than relying on file
			 * load order or on `plugins_loaded` directly, because this
			 * action is only fired once the environment check has passed
			 * and the provider pipeline has completed.
			 *
			 * @since 0.1.0
			 */
			do_action( 'clb_loaded', $this );
		}
	}

	/**
	 * Activation entry point.
	 *
	 * Exposed as an instance method so that the plugin entry file can
	 * register it via register_activation_hook() without reaching into
	 * a static method.
	 *
	 * Behavior:
	 *
	 *   - Run the environment check. If it fails, abort activation via
	 *     wp_die() so the operator sees the reason in the admin UI
	 *     rather than ending up with a half-activated plugin. No
	 *     activation-time side effects are performed.
	 *
	 *   - If the environment passes, fire the `clb_activate` action so
	 *     that service providers (notably the persistence provider,
	 *     which runs the schema migration pipeline) can perform their
	 *     activation-time setup without needing a new lifecycle method
	 *     on the {@see ServiceProvider} contract. The plugin instance
	 *     is passed as the first argument so handlers can reach the
	 *     registry / bindings installed during register().
	 *
	 * Providers that want to hook activation MUST subscribe to
	 * `clb_activate` from their boot() method so that the hook is
	 * wired by the time register_activation_hook() fires the callback
	 * — WordPress loads the plugin main file (which calls boot()) on
	 * the activation request just like on any other request.
	 *
	 * Note that wp_die() normally terminates the request in production,
	 * but in test contexts the stub merely records the call and returns,
	 * so the method explicitly returns after wp_die() to guarantee the
	 * `clb_activate` action is never dispatched on a failing environment.
	 */
	public function onActivate(): void {
		$result = $this->environmentChecker->check();
		if ( $result->hasErrors() ) {
			if ( function_exists( 'wp_die' ) ) {
				wp_die(
					esc_html( implode( "\n", $result->messages() ) ),
					esc_html__( 'Commerce Link Builder activation aborted', 'commerce-link-builder' ),
					array( 'back_link' => true )
				);
			}
			return;
		}

		/*
		 * Eagerly drive the bootstrap pipeline before dispatching
		 * `clb_activate`.
		 *
		 * Rationale: WordPress runs `register_activation_hook()`
		 * callbacks from inside `activate_plugin()`, which loads the
		 * plugin file via `plugin_sandbox_scrape()` — a fresh include
		 * that does NOT fire `plugins_loaded` for the sandboxed scope.
		 * That means the `plugins_loaded` handler scheduled by
		 * {@see self::boot()} never runs on the activation request,
		 * so {@see ServiceProviderRegistry::registerAll()} and
		 * {@see ServiceProviderRegistry::bootAll()} have not been
		 * called yet, and no provider has subscribed to
		 * `clb_activate`. The persistence provider's migration
		 * runner would fire into the void and the host site would be
		 * left with an "active" plugin and no `clb_*` tables.
		 *
		 * `runBootstrap()` is idempotent on repeat calls because it
		 * re-runs the environment check (already passed above) and
		 * the registry's `registerAll()` / `bootAll()` themselves
		 * guard against double-registration. After this call returns
		 * the persistence provider's `clb_activate` listener is
		 * wired and the migration pipeline will run when we dispatch
		 * the action below.
		 */
		$this->runBootstrap();

		if ( function_exists( 'do_action' ) ) {
			/**
			 * Fires during plugin activation, after the environment
			 * check has passed and before WordPress finalizes the
			 * activation response.
			 *
			 * Service providers that need to perform activation-time
			 * setup (schema migrations, default option seeding, cron
			 * scheduling) SHOULD hook here from their boot() method.
			 * The plugin main file is loaded on the activation request
			 * just like on any other request, so boot() has already
			 * wired the provider's hooks by the time this action fires.
			 *
			 * @since 0.1.0
			 *
			 * @param Plugin $plugin The plugin bootstrap instance, so
			 *                       handlers can reach the registry
			 *                       and its bindings.
			 */
			do_action( 'clb_activate', $this );
		}
	}

	/**
	 * Deactivation entry point.
	 *
	 * Currently a no-op. Real cleanup (flushing rewrite rules,
	 * unscheduling cron events, clearing caches) will be added in
	 * later slices alongside the subsystems that introduce those side
	 * effects.
	 */
	public function onDeactivate(): void {
		// Intentionally empty. Populated in later slices.
	}

	/**
	 * Register an admin notice renderer describing every environment
	 * check failure.
	 *
	 * The renderer is wired through {@see HookRegistrar} so that tests
	 * can observe the registration, and so that the diagnostics surface
	 * can enumerate every hook the plugin has attached.
	 *
	 * @param EnvironmentCheckResult $result Failing environment check result.
	 */
	private function registerEnvironmentFailureNotice( EnvironmentCheckResult $result ): void {
		$messages = $result->messages();

		$this->hooks->addAction(
			'admin_notices',
			static function () use ( $messages ): void {
				if ( function_exists( 'current_user_can' ) && ! current_user_can( 'activate_plugins' ) ) {
					return;
				}

				$escape = static function ( string $text ): string {
					if ( function_exists( 'esc_html' ) ) {
						return (string) esc_html( $text );
					}
					return htmlspecialchars( $text, ENT_QUOTES, 'UTF-8' );
				};

				$heading = function_exists( 'esc_html__' )
					? (string) esc_html__( 'Commerce Link Builder is inactive:', 'commerce-link-builder' )
					: $escape( 'Commerce Link Builder is inactive:' );

				echo '<div class="notice notice-error"><p><strong>';
				// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped above.
				echo $heading;
				echo '</strong></p><ul>';
				foreach ( $messages as $message ) {
					echo '<li>';
					// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped above.
					echo $escape( $message );
					echo '</li>';
				}
				echo '</ul></div>';
			}
		);
	}
}
