<?php
/**
 * Package Loader
 *
 * Core class that discovers and loads marketplace packages from one or more
 * package directories. Supports multi-directory discovery with priority ordering
 * so that add-ons (e.g. Pro) can register their own packages/ directory.
 *
 * @package Sequensy
 * @since 4.0.0
 */

declare(strict_types=1);

namespace Sequensy\Packages;

use Sequensy\Actions\ActionDefinition;
use Sequensy\Actions\ActionRegistry;
use Sequensy\Actions\ClosureActionHandler;
use Sequensy\Integrations\IntegrationMetadataRegistry;
use Sequensy\Integrations\IntegrationStorage;
use Sequensy\Triggers\Configuration\ConfigurableTrigger;
use Sequensy\Triggers\Configuration\TriggerDefinition;
use Sequensy\Triggers\TriggerRegistry;
use WP_Error;

// If this file is called directly, abort.
if ( ! defined( 'WPINC' ) ) {
	die;
}

/**
 * Class PackageLoader
 *
 * Manages the lifecycle of marketplace packages:
 * - Discovery from one or more package directories via wp_option registry
 * - Autoloader registration for package namespaces
 * - Metadata registration with integration registries
 * - Package initialization (action registration, hooks)
 * - REST route registration for package-specific endpoints
 * - Lazy-loading of packages during action execution
 */
class PackageLoader {

	/**
	 * WordPress option name storing the installed packages registry.
	 *
	 * @var string
	 */
	const OPTION_NAME = 'sequensy_installed_packages';

	/**
	 * File name for the package manifest within each package directory.
	 *
	 * @var string
	 */
	const MANIFEST_FILE = 'sequensy-package.json';

	/**
	 * Absolute path to the primary packages directory.
	 *
	 * Kept for backward compatibility with internal methods that operate
	 * on a single directory (activate, deactivate, get_installed, etc.).
	 *
	 * @var string
	 */
	private string $packages_dir;

	/**
	 * Absolute paths to package directories, ordered by priority (first = highest).
	 *
	 * @var string[]
	 */
	private array $package_dirs;

	/**
	 * Cache of all installed package manifests (active + inactive).
	 *
	 * @var PackageManifest[]|null
	 */
	private ?array $all_manifests = null;

	/**
	 * Cache of active package manifests (filtered subset of $all_manifests).
	 *
	 * @var PackageManifest[]|null
	 */
	private ?array $manifests = null;

	/**
	 * Track which packages have been initialized.
	 *
	 * @var array<string, bool>
	 */
	private array $initialized = array();

	/**
	 * Whether autoloaders have been registered.
	 *
	 * @var bool
	 */
	private bool $autoloaders_registered = false;

	/**
	 * Manifest index keyed by slug for fast lookup.
	 *
	 * @var array<string, PackageManifest>|null
	 */
	private ?array $manifest_index = null;

	/**
	 * Cache of loaded package.php arrays keyed by slug.
	 *
	 * @var array<string, array>
	 */
	private array $package_definitions = array();

	/**
	 * Constructor.
	 *
	 * @param string|null $packages_dir Absolute path to the packages directory.
	 *                                  Defaults to SEQUENSY_PLUGIN_DIR . 'packages/'.
	 */
	public function __construct( ?string $packages_dir = null ) {
		$default_dir = $packages_dir ?? ( SEQUENSY_PLUGIN_DIR . 'packages/' );

		/**
		 * Filter package discovery directories.
		 *
		 * Pro registers its packages/ dir here with higher priority (prepended).
		 * First directory wins when the same slug exists in multiple directories.
		 *
		 * @param string[] $dirs Package directory paths.
		 */
		$this->package_dirs = apply_filters( 'sequensy_package_directories', array( trailingslashit( $default_dir ) ) );

		// Keep legacy single-dir property for backward compat with internal methods.
		$this->packages_dir = $this->package_dirs[0] ?? trailingslashit( $default_dir );
	}

	/**
	 * Discover installed packages from the wp_option registry.
	 *
	 * Reads the registry option and loads manifests for active packages.
	 * Falls back to auto-discovery if the option does not exist.
	 * Validates compatibility of each package against the current environment
	 * (PHP version, WordPress version, Sequensy version).
	 *
	 * @return PackageManifest[] Array of valid, active package manifests.
	 */
	public function discover(): array {
		if ( null !== $this->manifests ) {
			return $this->manifests;
		}

		$installed       = $this->get_installed();
		$this->manifests = array();

		foreach ( $this->discover_all() as $manifest ) {
			$slug = $manifest->slug;

			if ( isset( $installed[ $slug ]['active'] ) && false === $installed[ $slug ]['active'] ) {
				continue;
			}

			if ( ! $this->has_required_plugins( $manifest ) ) {
				continue;
			}

			$this->manifests[] = $manifest;
		}

		return $this->manifests;
	}

	/**
	 * Discover ALL installed packages regardless of active state.
	 *
	 * Reads the registry option and loads manifests for all packages.
	 * Used by the REST API to list all packages for management UI.
	 * Unlike discover(), this includes inactive packages.
	 *
	 * @return PackageManifest[] Array of all installed package manifests.
	 */
	public function discover_all(): array {
		if ( null !== $this->all_manifests ) {
			return $this->all_manifests;
		}

		$this->all_manifests = array();
		$installed           = $this->get_installed();

		foreach ( $installed as $slug => $info ) {
			$dir           = $info['dir'] ?? $this->packages_dir;
			$manifest_path = trailingslashit( $dir ) . $slug . '/' . self::MANIFEST_FILE;

			try {
				$manifest = PackageManifest::from_file( $manifest_path );
			} catch ( \InvalidArgumentException $e ) {
				$this->log_error(
					sprintf( 'Failed to load package manifest for "%s": %s', $slug, $e->getMessage() )
				);
				continue;
			}

			if ( ! $this->is_compatible( $manifest ) ) {
				continue;
			}

			$this->all_manifests[] = $manifest;
		}

		return $this->all_manifests;
	}

	/**
	 * Get a package manifest by slug.
	 *
	 * Searches all installed packages (including inactive) for use by
	 * REST API connection checks and metadata lookups.
	 *
	 * @param string $slug Package slug.
	 * @return PackageManifest|null
	 */
	public function get_manifest( string $slug ): ?PackageManifest {
		if ( null === $this->manifest_index ) {
			$this->manifest_index = array();
			// discover_all() caches in $all_manifests, so this is free on repeat calls.
			foreach ( $this->discover_all() as $manifest ) {
				$this->manifest_index[ $manifest->slug ] = $manifest;
			}
		}
		return $this->manifest_index[ $slug ] ?? null;
	}

	/**
	 * Activate an installed package.
	 *
	 * @param string $slug Package slug.
	 * @return bool|WP_Error True on success, WP_Error if package is not installed.
	 */
	public function activate( string $slug ): bool|WP_Error {
		$slug   = sanitize_file_name( $slug );
		$result = $this->set_active_in_dirs( $slug, true );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// Clear manifest caches so discover()/discover_all() pick up the change.
		$this->all_manifests  = null;
		$this->manifests      = null;
		$this->manifest_index = null;

		return true;
	}

	/**
	 * Deactivate an installed package.
	 *
	 * @param string $slug Package slug.
	 * @return bool|WP_Error True on success, WP_Error if package is not installed.
	 */
	public function deactivate( string $slug ): bool|WP_Error {
		$slug   = sanitize_file_name( $slug );
		$result = $this->set_active_in_dirs( $slug, false );

		if ( is_wp_error( $result ) ) {
			return $result;
		}

		// Clear manifest caches so discover()/discover_all() reflect the change.
		$this->all_manifests  = null;
		$this->manifests      = null;
		$this->manifest_index = null;

		return true;
	}

	/**
	 * Check if an installed package is active.
	 *
	 * @param string $slug Package slug.
	 * @return bool True if the package is active.
	 */
	public function is_active( string $slug ): bool {
		$installed = $this->get_installed();

		if ( ! isset( $installed[ $slug ] ) ) {
			return false;
		}

		return $installed[ $slug ]['active'] ?? true;
	}

	/**
	 * Set active status for a package in the registry.
	 *
	 * @param string $slug   Package slug.
	 * @param bool   $active Whether to activate (true) or deactivate (false).
	 * @return bool|WP_Error True on success, WP_Error if package not found.
	 */
	private function set_active_in_dirs( string $slug, bool $active ): bool|WP_Error {
		$installed = $this->get_installed();

		if ( ! isset( $installed[ $slug ] ) ) {
			return new WP_Error(
				'not_installed',
				sprintf(
					/* translators: %s: package slug */
					__( 'Package "%s" is not installed.', 'sequensy' ),
					$slug
				)
			);
		}

		$installed[ $slug ]['active'] = $active;
		$this->write_installed( $installed );

		return true;
	}

	/**
	 * Register PSR-4 autoloaders for all discovered packages.
	 *
	 * Maps each package's namespace to its src/ directory.
	 *
	 * @return void
	 */
	public function register_autoloaders(): void {
		if ( $this->autoloaders_registered ) {
			return;
		}

		$this->autoloaders_registered = true;

		$manifests = $this->discover();

		// Build a single namespace → directory map.
		$namespace_map = array();

		foreach ( $manifests as $manifest ) {
			$namespace = $manifest->namespace;

			// Skip packages without a namespace (pure array packages without src/).
			if ( null === $namespace || '' === $namespace ) {
				continue;
			}

			// Ensure trailing backslash on namespace prefix.
			if ( '\\' !== substr( $namespace, -1 ) ) {
				$namespace .= '\\';
			}

			$namespace_map[ $namespace ] = $manifest->path . 'src/';
		}

		if ( empty( $namespace_map ) ) {
			return;
		}

		// Register ONE autoloader for all packages instead of one per package.
		spl_autoload_register(
			function ( string $class_name ) use ( $namespace_map ) {
				foreach ( $namespace_map as $prefix => $base_dir ) {
					$len = strlen( $prefix );
					if ( strncmp( $prefix, $class_name, $len ) !== 0 ) {
						continue;
					}

					$relative_class = substr( $class_name, $len );
					$file           = $base_dir . str_replace( '\\', '/', $relative_class ) . '.php';

					if ( file_exists( $file ) ) {
						require_once $file;
						return;
					}
				}
			}
		);
	}

	/**
	 * Register metadata for all discovered packages.
	 *
	 * Builds metadata from the manifest and PackageInterface methods.
	 *
	 * @return void
	 */
	public function register_metadata(): void {
		$manifests = $this->discover();

		foreach ( $manifests as $manifest ) {
			$package = $this->load_package_definition( $manifest, false );

			if ( null !== $package ) {
				// Array-based package: build metadata from package.php.
				$this->register_array_package_metadata( $manifest, $package );
			} elseif ( null !== $manifest->integration_class ) {
				// Legacy class-based package.
				$this->register_class_package_metadata( $manifest );
			}
		}
	}

	/**
	 * Register metadata for an array-based package.
	 *
	 * @param PackageManifest $manifest Package manifest.
	 * @param array           $package  Package definition array.
	 * @return void
	 */
	private function register_array_package_metadata( PackageManifest $manifest, array $package ): void {
		$actions = array();
		foreach ( $package['actions'] ?? array() as $action_def ) {
			$namespaced_type = $manifest->slug . '/' . $action_def['type'];
			$actions[]       = new ActionDefinition(
				type:            $namespaced_type,
				label:           $action_def['label'],
				description:     $action_def['description'] ?? '',
				integration:     $manifest->slug,
				schema:          $action_def['schema'] ?? array(),
				output_fields:   $action_def['output_fields'] ?? array(),
				required_fields: $action_def['required_fields'] ?? array(),
				handler_class:   '',
				category:        $action_def['category'] ?? null,
				icon:            $action_def['icon'] ?? null,
				options:         $action_def['options'] ?? array(),
			);
		}

		$triggers = array();
		foreach ( $package['triggers'] ?? array() as $trigger_def ) {
			$namespaced_type = $manifest->slug . '/' . $trigger_def['type'];
			$triggers[]      = new TriggerDefinition(
				type:             $namespaced_type,
				label:            $trigger_def['label'] ?? '',
				description:      $trigger_def['description'] ?? '',
				hook:             $trigger_def['hook'],
				priority:         $trigger_def['priority'] ?? 10,
				accepted_args:    $trigger_def['accepted_args'] ?? 1,
				available_fields: $trigger_def['available_fields'] ?? array(),
				build_context:    $trigger_def['build_context'],
				integration:      $manifest->slug,
				should_fire:      $trigger_def['should_fire'] ?? null,
				options:          $trigger_def['options'] ?? array(),
			);
		}

		$metadata = array(
			'slug'     => $manifest->slug,
			'metadata' => array(
				'name'        => $manifest->name,
				'description' => $manifest->description,
				'category'    => $manifest->category,
				'icon'        => $manifest->icon,
			),
			'actions'  => $actions,
		);

		if ( ! empty( $triggers ) ) {
			$metadata['triggers'] = $triggers;
		}

		IntegrationMetadataRegistry::register( $manifest->slug, $metadata );
	}

	/**
	 * Register metadata for a legacy class-based package.
	 *
	 * @param PackageManifest $manifest Package manifest.
	 * @return void
	 */
	private function register_class_package_metadata( PackageManifest $manifest ): void {
		$class = $manifest->integration_class;

		if ( ! class_exists( $class ) ) {
			$this->log_error(
				sprintf( 'Integration class "%s" not found for package "%s".', $class, $manifest->slug )
			);
			return;
		}

		$metadata = array(
			'slug'     => $manifest->slug,
			'metadata' => array(
				'name'        => $manifest->name,
				'description' => $manifest->description,
				'category'    => $manifest->category,
				'icon'        => $manifest->icon,
			),
			'actions'  => $class::get_action_definitions(),
		);

		$triggers = $class::get_trigger_definitions();
		if ( ! empty( $triggers ) ) {
			$metadata['triggers'] = $triggers;
		}

		IntegrationMetadataRegistry::register( $manifest->slug, $metadata );
	}

	/**
	 * Register credential schemas for packages that require auth.
	 *
	 * Reads auth configuration from the manifest and registers it
	 * with IntegrationStorage so the credentials UI and validation work.
	 *
	 * @return void
	 */
	public function register_credentials(): void {
		$manifests = $this->discover();

		foreach ( $manifests as $manifest ) {
			$metadata = array(
				'name'        => $manifest->name,
				'description' => $manifest->description,
				'category'    => $manifest->category,
				'icon'        => $manifest->icon,
				'credentials' => null !== $manifest->auth
					? $this->index_credentials( $manifest->auth['credentials'] ?? array() )
					: array(),
			);

			if ( null !== $manifest->website ) {
				$metadata['website'] = $manifest->website;
			}
			if ( null !== $manifest->docs_url ) {
				$metadata['docs_url'] = $manifest->docs_url;
			}

			IntegrationStorage::register_integration( $manifest->slug, $metadata );

			// Only register integration class for legacy class-based packages.
			if ( null !== $manifest->integration_class && ! $this->is_array_package( $manifest ) ) {
				IntegrationStorage::register_integration_class( $manifest->slug, $manifest->integration_class );
			}
		}
	}

	/**
	 * Convert a manifest credentials array to a keyed object.
	 *
	 * Manifest format:  [['name' => 'api_key', 'label' => 'API Key', ...], ...]
	 * REST/frontend format: ['api_key' => ['label' => 'API Key', 'type' => 'password', ...], ...]
	 *
	 * @param array $credentials Indexed array of credential field definitions.
	 * @return array Associative array keyed by field name.
	 */
	private function index_credentials( array $credentials ): array {
		$indexed = array();
		foreach ( $credentials as $field ) {
			if ( ! isset( $field['name'] ) ) {
				continue;
			}
			$name = $field['name'];
			unset( $field['name'] );
			$indexed[ $name ] = $field;
		}
		return $indexed;
	}

	/**
	 * Initialize all discovered packages.
	 *
	 * Calls init() on each package's integration class to register action handlers
	 * and hook into WordPress.
	 *
	 * @return void
	 */
	public function initialize(): void {
		$manifests = $this->discover();

		foreach ( $manifests as $manifest ) {
			$this->initialize_package( $manifest );
		}
	}

	/**
	 * Register REST routes for all discovered packages that define a routes class.
	 *
	 * Instantiates each package's routes_class and calls register_routes().
	 *
	 * @return void
	 */
	public function register_routes(): void {
		$manifests = $this->discover();

		foreach ( $manifests as $manifest ) {
			if ( null === $manifest->routes_class ) {
				continue;
			}

			$routes_class = $manifest->routes_class;

			if ( ! class_exists( $routes_class ) ) {
				$this->log_error(
					sprintf( 'Routes class "%s" not found for package "%s".', $routes_class, $manifest->slug )
				);
				continue;
			}

			$routes = new $routes_class();

			if ( method_exists( $routes, 'register_routes' ) ) {
				$routes->register_routes();
			}
		}
	}

	/**
	 * Get the absolute path to the primary packages directory.
	 *
	 * @return string Packages directory path (with trailing slash).
	 */
	public function get_packages_dir(): string {
		return $this->packages_dir;
	}

	/**
	 * Get all package directory paths ordered by priority.
	 *
	 * @return string[] Package directory paths.
	 */
	public function get_package_dirs(): array {
		return $this->package_dirs;
	}

	/**
	 * Clear internal caches so subsequent calls reflect filesystem changes.
	 *
	 * @param string|null $slug Optional slug to clear from package_definitions cache.
	 *                          When null, only manifest caches are cleared.
	 * @return void
	 */
	public function clear_caches( ?string $slug = null ): void {
		$this->all_manifests  = null;
		$this->manifests      = null;
		$this->manifest_index = null;

		if ( null !== $slug ) {
			unset( $this->package_definitions[ $slug ] );
		}
	}

	/**
	 * Get the installed packages registry from wp_option.
	 *
	 * Falls back to auto-discovery if the option does not exist yet.
	 *
	 * @return array<string, array> Associative array of installed packages keyed by slug.
	 */
	public function get_installed(): array {
		$installed = get_option( self::OPTION_NAME, null );

		if ( null === $installed || ! is_array( $installed ) ) {
			$installed = $this->auto_discover_packages();
			update_option( self::OPTION_NAME, $installed, true );
		}

		/**
		 * Filter the installed packages registry before use.
		 *
		 * Allows add-ons to suppress entries at runtime (e.g. Pro hides bundled
		 * packages so only marketplace-installed packages are loaded).
		 *
		 * @param array<string, array> $installed Registry keyed by slug.
		 */
		return apply_filters( 'sequensy_filter_installed_registry', $installed );
	}

	/**
	 * Lazy-load a package's integration when an action is about to execute.
	 *
	 * Hook handler for 'sequensy_before_execute_action'. Ensures the package's
	 * autoloader is registered and its integration class is initialized before
	 * the action handler is invoked.
	 *
	 * @param string $action_type Action type being executed.
	 * @param string $integration Integration slug.
	 * @return void
	 */
	public function ensure_loaded( string $action_type, string $integration ): void {
		// Check if we have an installed package for this integration.
		$installed = $this->get_installed();

		if ( ! isset( $installed[ $integration ] ) ) {
			return;
		}

		// Ensure autoloaders are registered.
		$this->register_autoloaders();

		// Find and initialize the matching manifest (eager=false forces full init).
		$manifests = $this->discover();

		foreach ( $manifests as $manifest ) {
			if ( $manifest->slug === $integration ) {
				$this->initialize_package( $manifest, false );
				break;
			}
		}
	}

	/**
	 * Initialize a single package.
	 *
	 * Guards against duplicate initialization. In eager mode (default),
	 * integration-type packages (action-only, no triggers) are deferred
	 * to be loaded on-demand via ensure_loaded().
	 *
	 * @param PackageManifest $manifest Package manifest.
	 * @param bool            $eager    Whether this is an eager boot-time call. Default true.
	 * @return void
	 */
	private function initialize_package( PackageManifest $manifest, bool $eager = true ): void {
		if ( isset( $this->initialized[ $manifest->slug ] ) ) {
			return;
		}

		// In eager mode, defer integration-type packages (action-only, no triggers).
		// They will be initialized on-demand via ensure_loaded().
		if ( $eager && 'integration' === $manifest->type ) {
			return;
		}

		$package = $this->load_package_definition( $manifest, false );

		if ( null !== $package ) {
			// Array-based package.
			$this->initialize_array_package( $manifest, $package );
		} elseif ( null !== $manifest->integration_class ) {
			// Legacy class-based package.
			$this->initialize_class_package( $manifest );
		}

		$this->initialized[ $manifest->slug ] = true;
	}

	/**
	 * Initialize an array-based package.
	 *
	 * Registers api_factory, action handlers, triggers, and calls boot.
	 *
	 * @param PackageManifest $manifest Package manifest.
	 * @param array           $package  Package definition array.
	 * @return void
	 */
	private function initialize_array_package( PackageManifest $manifest, array $package ): void {
		// Register api_factory if present.
		if ( isset( $package['api_factory'] ) && is_callable( $package['api_factory'] ) ) {
			IntegrationStorage::register_api_factory( $manifest->slug, $package['api_factory'] );
		}

		// Register action handlers.
		$registry = ActionRegistry::get_instance();
		foreach ( $package['actions'] ?? array() as $action_def ) {
			$namespaced_type = $manifest->slug . '/' . $action_def['type'];
			$handler         = new ClosureActionHandler(
				array_merge(
					$action_def,
					array(
						'type'        => $namespaced_type,
						'integration' => $manifest->slug,
					)
				),
				$action_def['handler']
			);
			$registry->register( $namespaced_type, $handler );
		}

		// Register triggers.
		$trigger_registry = TriggerRegistry::get_instance();
		$container        = \Sequensy\Container::getInstance();

		foreach ( $package['triggers'] ?? array() as $trigger_def ) {
			$namespaced_type = $manifest->slug . '/' . $trigger_def['type'];
			$definition      = new TriggerDefinition(
				type:             $namespaced_type,
				label:            $trigger_def['label'] ?? '',
				description:      $trigger_def['description'] ?? '',
				hook:             $trigger_def['hook'],
				priority:         $trigger_def['priority'] ?? 10,
				accepted_args:    $trigger_def['accepted_args'] ?? 1,
				available_fields: $trigger_def['available_fields'] ?? array(),
				build_context:    $trigger_def['build_context'],
				integration:      $manifest->slug,
				should_fire:      $trigger_def['should_fire'] ?? null,
				options:          $trigger_def['options'] ?? array(),
			);

			$trigger = new ConfigurableTrigger(
				$definition,
				$container->get( 'workflow_storage' ),
				$container->get( 'queue_manager' ),
				$container->get( 'logger' ),
				$container->get( 'filter_evaluator' ),
				$container->get( 'execution_guard' ),
			);

			$trigger_registry->register( $definition->type, $trigger );
		}

		// Call boot if present.
		if ( isset( $package['boot'] ) && is_callable( $package['boot'] ) ) {
			( $package['boot'] )();
		}
	}

	/**
	 * Initialize a legacy class-based package.
	 *
	 * @param PackageManifest $manifest Package manifest.
	 * @return void
	 */
	private function initialize_class_package( PackageManifest $manifest ): void {
		$class = $manifest->integration_class;

		if ( ! class_exists( $class ) ) {
			$this->log_error(
				sprintf( 'Integration class "%s" not found for package "%s".', $class, $manifest->slug )
			);
			return;
		}

		if ( method_exists( $class, 'boot' ) ) {
			$class::boot();
		}
	}

	/**
	 * Load a package's definitions from package.php.
	 *
	 * @param PackageManifest $manifest  Package manifest.
	 * @param bool            $validate  Whether to run PackageArrayValidator. Default true.
	 *                                   Pass false during boot to skip redundant validation.
	 * @return array|null Package definition array, or null if not found/invalid.
	 */
	private function load_package_definition( PackageManifest $manifest, bool $validate = true ): ?array {
		if ( isset( $this->package_definitions[ $manifest->slug ] ) ) {
			return $this->package_definitions[ $manifest->slug ];
		}

		$package_file = $manifest->path . 'package.php';

		if ( ! file_exists( $package_file ) ) {
			return null;
		}

		$package = null;
		$error   = null;

		// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Silences warnings during package require.
		set_error_handler( static function () {} ); // @codingStandardsIgnoreLine
		try {
			$package = require $package_file;
		} catch ( \Throwable $e ) {
			$error = $e->getMessage();
		}
		restore_error_handler();

		if ( null !== $error || ! is_array( $package ) ) {
			$this->log_error(
				sprintf(
					'Package "%s" failed to load: %s',
					$manifest->slug,
					$error ?? 'package.php did not return a valid array'
				)
			);
			return null;
		}

		if ( $validate ) {
			$errors = PackageArrayValidator::validate( $package, $manifest->slug );
			if ( ! empty( $errors ) ) {
				foreach ( $errors as $error ) {
					$this->log_error( $error );
				}
				return null;
			}
		}

		$this->package_definitions[ $manifest->slug ] = $package;
		return $package;
	}

	/**
	 * Check if a package uses the array-based format.
	 *
	 * @param PackageManifest $manifest Package manifest.
	 * @return bool True if package has a package.php file.
	 */
	private function is_array_package( PackageManifest $manifest ): bool {
		return file_exists( $manifest->path . 'package.php' );
	}

	/**
	 * Check if a package is compatible with the current environment.
	 *
	 * Validates PHP version, WordPress version, and Sequensy version requirements.
	 *
	 * @param PackageManifest $manifest Package manifest to check.
	 * @return bool True if compatible, false otherwise.
	 */
	private function is_compatible( PackageManifest $manifest ): bool {
		// Check PHP version.
		if ( version_compare( PHP_VERSION, $manifest->min_php_version, '<' ) ) {
			return false;
		}

		// Check WordPress version.
		global $wp_version;
		if ( version_compare( $wp_version, $manifest->min_wp_version, '<' ) ) {
			return false;
		}

		// Check Sequensy version.
		if ( defined( 'SEQUENSY_VERSION' ) ) {
			if ( version_compare( SEQUENSY_VERSION, $manifest->min_sequensy_version, '<' ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Check if required WordPress plugins are active.
	 *
	 * @param PackageManifest $manifest Package manifest.
	 * @return bool True if all required plugins are active or no requirements exist.
	 */
	private function has_required_plugins( PackageManifest $manifest ): bool {
		if ( null === $manifest->requires || empty( $manifest->requires['plugins'] ) ) {
			return true;
		}

		// is_plugin_active() is defined in wp-admin/includes/plugin.php.
		if ( ! function_exists( 'is_plugin_active' ) ) {
			require_once ABSPATH . 'wp-admin/includes/plugin.php';
		}

		foreach ( $manifest->requires['plugins'] as $plugin_file ) {
			if ( ! is_plugin_active( $plugin_file ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Write the installed packages registry to wp_option.
	 *
	 * @param array $data Installed packages data.
	 * @return bool True on success, false on failure.
	 */
	public function write_installed( array $data ): bool {
		return update_option( self::OPTION_NAME, $data, true );
	}

	/**
	 * Auto-discover packages by scanning all package directories.
	 *
	 * Scans each directory in $package_dirs for subdirectories containing
	 * a sequensy-package.json manifest. First directory wins for duplicate slugs.
	 *
	 * @return array<string, array> Registry array keyed by slug.
	 */
	public function auto_discover_packages(): array {
		$registry = array();

		foreach ( $this->package_dirs as $dir ) {
			$dir = trailingslashit( $dir );
			if ( ! is_dir( $dir ) ) {
				continue;
			}

			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Directory may not exist.
			$entries = @scandir( $dir );
			if ( false === $entries ) {
				continue;
			}

			foreach ( $entries as $slug ) {
				if ( '.' === $slug[0] ) {
					continue;
				}

				// First directory wins — skip duplicates.
				if ( isset( $registry[ $slug ] ) ) {
					continue;
				}

				$manifest_path = $dir . $slug . '/' . self::MANIFEST_FILE;
				if ( ! file_exists( $manifest_path ) ) {
					continue;
				}

				// Read version from manifest.
				$version = '1.0.0';
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read.
				$contents = file_get_contents( $manifest_path );
				if ( false !== $contents ) {
					$data = json_decode( $contents, true );
					if ( is_array( $data ) && isset( $data['version'] ) ) {
						$version = $data['version'];
					}
				}

				$registry[ $slug ] = array(
					'version'      => $version,
					'active'       => false,
					'dir'          => $dir,
					'installed_at' => gmdate( 'Y-m-d\TH:i:s\Z' ),
					'checksum'     => '',
				);
			}
		}

		return $registry;
	}

	/**
	 * Re-scan disk and merge new packages into the existing registry.
	 *
	 * Preserves active state for existing packages, adds newly discovered
	 * packages, and removes packages that no longer exist on disk.
	 *
	 * @return void
	 */
	public function refresh_registry(): void {
		$existing  = get_option( self::OPTION_NAME, array() );
		$existing  = is_array( $existing ) ? $existing : array();
		$on_disk   = $this->auto_discover_packages();
		$refreshed = array();

		foreach ( $on_disk as $slug => $info ) {
			if ( isset( $existing[ $slug ] ) ) {
				// Preserve active state, update dir and version.
				$refreshed[ $slug ] = array_merge(
					$existing[ $slug ],
					array(
						'version' => $info['version'],
						'dir'     => $info['dir'],
					)
				);
			} else {
				$refreshed[ $slug ] = $info;
			}
		}

		update_option( self::OPTION_NAME, $refreshed, true );
		$this->clear_caches();
	}

	/**
	 * Delete the installed packages registry option.
	 *
	 * Used by activation hooks to force auto-discovery on next page load.
	 *
	 * @return bool True if the option was deleted, false otherwise.
	 */
	public static function delete_registry(): bool {
		return delete_option( self::OPTION_NAME );
	}

	/**
	 * Log an error message.
	 *
	 * @param string $message Error message to log.
	 * @return void
	 */
	private function log_error( string $message ): void {
		if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			error_log( '[Sequensy PackageLoader] ' . $message );
		}
	}
}
