<?php
/**
 * Base class for config provider managers.
 *
 * @package Ealab\Deployer
 */

namespace Ealab\Deployer\Tracking\Config;

use Dakwamine\Component\ComponentBasedObject;
use Dakwamine\Component\Exception\UnmetDependencyException;
use Dakwamine\Component\RootDependencyDefinition;
use Ealab\Deployer\Exception\EaLabWebServiceException;
use Ealab\Deployer\Exception\InvalidConfigException;
use Ealab\Deployer\Exception\LicenceException;
use Ealab\Deployer\Exception\LicenceKeyUnsetException;
use Ealab\Deployer\Exception\WpDeployException;
use Ealab\Deployer\Licence\LicenceManager;
use Ealab\Deployer\Tracking\TrackingManager;
use Throwable;

/**
 * Base class for config provider managers.
 */
abstract class ConfigProviderManagerBase extends ComponentBasedObject {
	/**
	 * Whether the sources have been loaded.
	 *
	 * @var bool True if loaded at least once.
	 */
	protected bool $config_providers_loaded = false;

	/**
	 * The licence manager.
	 *
	 * @var LicenceManager|null
	 */
	private ?LicenceManager $licence_manager = null;

	/**
	 * The config overrides that will be returned as the active config if not null.
	 *
	 * @var array|null
	 */
	protected ?array $config_override = null;

	/**
	 * Use this to collect the third-party provider definitions.
	 *
	 * Third-party providers are providers that are not managed by the plugin.
	 *
	 * This can alter the default config provider definitions.
	 *
	 * @param array $base_definitions The base definitions.
	 */
	protected function alterConfigProviderDefinitions( array &$base_definitions = array() ): void {
		$manager_id = static::getManagerId();

		// Dev note: The order of the providers is important.
		$base_definitions = apply_filters( "deploy_sync_content__config_manager__{$manager_id}__config_providers_defs__alter", $base_definitions );
	}

	/**
	 * Builds the config providers.
	 *
	 * The base implementation will build the current config provider that manages database storage.
	 *
	 * @return array<ConfigProviderBase> The config providers.
	 *
	 * @throws WpDeployException If a config provider could not be instantiated.
	 */
	protected function buildConfigProviders(): array {
		// Clean up the previous providers just in case.
		$this->removeComponentsByClassName( ConfigProviderBase::class );

		$config = array();

		$provider_definition = $this->getBaseConfigProviderDefinitions();

		$this->alterConfigProviderDefinitions( $provider_definition );

		foreach ( $provider_definition as $definition ) {
			if ( ! is_array( $definition ) || empty( $definition['class'] ) ) {
				continue;
			}

			/** @var ConfigProviderBase $provider */
			$provider = $this->addComponentByClassName( $definition['class'] );

			$options = null;

			if ( ! empty( $definition['options'] ) && is_array( $definition['options'] ) ) {
				$options = $definition['options'];
			}

			$provider->init( $this, $options );

			$config[] = $provider;
		}

		// Finally load + append the current config provider.
		$config[] = $this->getCurrentConfigProvider();

		return $config;
	}

	/**
	 * Gets the active config, ready for use in most contexts.
	 *
	 * This will collect the config from the known sources if the current config is not set.
	 *
	 * It may be outdated, so it is recommended to call updateCurrentConfig() if an updated config is required.
	 *
	 * @param array<string,mixed>|null $options The options to use for collecting the configs.
	 *
	 * @return array<string,array> The config.
	 *
	 * @throws InvalidConfigException If the config is invalid.
	 * @throws WpDeployException If a tracker config source could not be instantiated.
	 */
	public function getActiveConfig( ?array $options = null ): array {
		if ( null !== $this->config_override ) {
			return $this->config_override;
		}

		$current_config_provider = $this->getCurrentConfigProvider();

		$config = $current_config_provider->collect();

		if ( null === $config ) {
			// The current config is not set, initialize by asking the sources to collect their configuration.
			// This is a long process (call to repository and such), and the results will be saved by
			// the current config provider into the database for later reuse.
			$config = $this->updateCurrentConfig();
		}

		return $config;
	}

	/**
	 * Gets the active deployment config.
	 *
	 * The result can be used for active config overrides. See setActiveDeploymentConfigOverride().
	 *
	 * @return array<string,array<string,mixed>> The active deployment config, keyed by config ID.
	 *
	 * @throws InvalidConfigException Config error.
	 * @throws Throwable All other errors.
	 * @throws WpDeployException Deploy & Sync Content error.
	 */
	public static function getActiveDeploymentConfig(): array {
		$deployment_config = array();

		$config_provider_managers = apply_filters( 'deploy_sync_content__get_config_provider_managers', array() );

		foreach ( $config_provider_managers as $id => $config_provider_manager ) {
			/** @var ConfigProviderManagerBase $config_provider_instance */
			$config_provider_instance = ComponentBasedObject::getRootComponentByClassName( $config_provider_manager, true );

			$deployment_config[ $id ] = $config_provider_instance->getActiveConfig();
		}

		// Key sort to tidy things up.
		ksort( $deployment_config );

		return $deployment_config;
	}

	/**
	 * Implement this to get the definitions of the providers.
	 *
	 * The definitions are used to build the providers.
	 *
	 * Do not include the current config provider definition;
	 * it will be added automatically.
	 *
	 * @return array<string,mixed> The config provider definitions. Contains the following keys:
	 *     - "class" (string): The class name of the config provider.
	 *     - "options" (array<string,mixed>|null): Optional. The options to pass to the config provider if any.
	 */
	protected function getBaseConfigProviderDefinitions(): array {
		return array();
	}

	/**
	 * The strategy to use when merging two collected definitions for the same value.
	 *
	 * E.g. "value_handler.option" uses the replace strategy because we don't want the
	 * values to cumulate.
	 * In the other hand, we want to cumulate definitions for the "exclusions" config.
	 *
	 * @return string One of the ConfigCollector::CONFIG_MERGE_STRATEGY__* values.
	 */
	public static function getConfigMergeStrategy(): string {
		return ConfigCollector::CONFIG_MERGE_STRATEGY__REPLACE;
	}

	/**
	 * Builds / gets a config provider instance identifiable by class name.
	 *
	 * @param string $class_name The class name of the config provider.
	 *
	 * @return ConfigProviderBase The config provider instance.
	 *
	 * @throws WpDeployException If the config provider could not be found.
	 */
	protected function getConfigProviderByClass( string $class_name ): ConfigProviderBase {
		try {
			/** @var ConfigProviderBase|null $instance */
			$instance = $this->getComponentByClassName( $class_name );

			if ( $instance ) {
				// Already instantiated.
				return $instance;
			}

			// Not yet instantiated, do it now.
			$instance = $this->getComponentByClassName( $class_name, true );
		} catch ( UnmetDependencyException $e ) {
			throw new WpDeployException( sprintf( 'Config provider not found: %s.', $class_name ) );
		}

		$instance->init( $this );

		return $instance;
	}

	/**
	 * Gets the config providers.
	 *
	 * @param array<string,mixed>|null $options The options. Available options:
	 *     - 'without_current', bool: Whether to exclude the current config provider. Defaults to false.
	 *
	 * @return array<ConfigProviderBase> The tracker config sources classes.
	 *
	 * @throws WpDeployException If a tracker config source could not be instantiated.
	 */
	protected function getConfigProviders( ?array $options = null ): array {
		if ( ! $this->config_providers_loaded ) {
			// This will build the providers, current included.
			$instances = $this->buildConfigProviders();

			$this->config_providers_loaded = true;
		} else {
			$instances = $this->getComponentsByClassName( ConfigProviderBase::class );
		}

		if ( ! empty( $options['without_current'] ) ) {
			// Remove the current config provider from the list.
			$current_config_provider_class = static::getCurrentConfigProviderClass();

			if ( empty( $current_config_provider_class ) ) {
				// Technical error, this should never happen.
				throw new WpDeployException( 'Current config provider class not defined.' );
			}

			$instances = array_filter(
				$instances,
				function ( ConfigProviderBase $instance ) use ( $current_config_provider_class ) {
					return ! ( $instance instanceof $current_config_provider_class );
				}
			);
		}

		return $instances;
	}

	/**
	 * The current config provider is a special case of config providers.
	 *
	 * It is not called for collecting the config from the different sources,
	 * but for getting the current "saved" config.
	 * It should not be included in the config providers definitions;
	 * it will be retrieved by other means (getCurrentConfigProviderClass).
	 *
	 * @return ConfigProviderBase The current config provider.
	 *
	 * @throws WpDeployException If the current config provider could not be found.
	 */
	public function getCurrentConfigProvider(): ConfigProviderBase {
		$current_config_provider_class = static::getCurrentConfigProviderClass();

		if ( empty( $current_config_provider_class ) ) {
			// Technical error, this should never happen.
			throw new WpDeployException( 'Current config provider class not defined.' );
		}

		return $this->getConfigProviderByClass( $current_config_provider_class );
	}

	/**
	 * Gets the current config provider class name.
	 *
	 * This is the class that will be used as the "current" config provider,
	 * which is the one that saves/loads the config into/from the database.
	 *
	 * @return string The current config provider class name.
	 */
	protected static function getCurrentConfigProviderClass(): string {
		// This is the default implementation of current config provider.
		return CurrentConfigProvider::class;
	}

	/**
	 * {@inheritDoc}
	 */
	public function getDependencyDefinitions(): array {
		$definitions = parent::getDependencyDefinitions();

		$definitions[] = new RootDependencyDefinition( LicenceManager::class, $this->licence_manager );

		return $definitions;
	}

	/**
	 * The config provider manager ID.
	 *
	 * Used to build the hooks names and identify the config provider manager where needed.
	 * This must be unique, and contain simple characters only (e.g. a-z, 0-9, dot, hyphen, and underscore).
	 *
	 * @return string The config provider manager ID.
	 */
	abstract public static function getManagerId(): string;

	/**
	 * Normalizes the config.
	 *
	 * This must be called before saving the config. See the default implementation of ConfigProviderBase::setConfig().
	 *
	 * @param array<string,mixed> $config The config to normalize.
	 *
	 * @throws WpDeployException Any error on config processing.
	 */
	public function normalizeConfig( array &$config ): void {
		// Basic sort by root level keys.
		ksort( $config );
	}

	/**
	 * Called right after collecting the config from the sources.
	 *
	 * This can be used to clean up the collected config, add additional config,
	 * etc. before it is validated.
	 *
	 * @param array<string,mixed> $collected_config The collected config.
	 *
	 * @throws WpDeployException Any error on config processing.
	 */
	protected function onCollectedConfig( array &$collected_config ): void {
	}

	/**
	 * Called right after saving the config to its intended location.
	 *
	 * @param array $config The config which has been saved.
	 *
	 * @throws WpDeployException Any error on config processing.
	 */
	protected function postSave( array &$config ): void {
	}

	/**
	 * Updates the current configuration.
	 *
	 * The update merges the configuration in this order:
	 * - collected configs: repository, static providers, etc. Set $collect_from_providers to false to disable this.
	 * - current config: will rewrite any collected configs. Set $options['keep_existing'] to false to disable this.
	 * - $additional_config: will rewrite any previously collected configs.
	 *
	 * @param bool                $collect_from_providers Whether to collect the config from the providers.
	 * @param array<string,array> $additional_config Additional custom config to merge into the collected config.
	 *     Leave empty to simply recollect the config from the sources.
	 * @param array<string,mixed> $options Options to control config update:
	 *     - "keep_existing" (bool): if true, the current config values will be kept. Default: true.
	 *       When set to false, it allows to totally rewrite the config, as when set by the UI.
	 *
	 * @return array<string,array> The updated config.
	 *
	 * @throws EaLabWebServiceException Web service error.
	 * @throws InvalidConfigException If the config to update is invalid.
	 * @throws LicenceException Invalid licence.
	 * @throws LicenceKeyUnsetException Licence key is unset.
	 * @throws WpDeployException If a tracker config source could not be instantiated or if the given tracker config
	 *   is invalid.
	 */
	public function updateCurrentConfig( bool $collect_from_providers = true, array $additional_config = array(), array $options = array() ): array {
		$current_config_provider = $this->getCurrentConfigProvider();

		$config = array();

		// This will be set to true when a serious blocking issue should prevent the config update.
		$has_blocking_issues = false;

		$all_reasons = array();

		$add_admin_notice_for_collection_error = static function ( string $reason ) use ( &$all_reasons ) {
			$all_reasons[] = $reason;

			add_action(
				'admin_notices',
				static function () use ( $reason ) {
					echo '<div class="notice notice-warning is-dismissible"><p>';
					printf( _dgettext( 'deployer-core', 'The Deploy & Sync Content configuration for %s could not collect one of its sources. Reason: %s' ), esc_html( static::getManagerId() ), esc_html( $reason ) );
					echo '</p></div>';
				}
			);
		};

		if ( $collect_from_providers ) {
			foreach ( $this->getConfigProviders( array( 'without_current' => true ) ) as $provider ) {
				try {
					// Collect the sources and merge them into the current config.
					// The current source is excluded here because we want it to come
					// last, so it can overwrite any collected config.
					$config = array_merge( $config, ( $provider->collect() ?? array() ) );
				} catch ( EaLabWebServiceException $e ) {
					// Happens in cases such as:
					// - The internet connection is down.
					// - The EA Lab web service failed to deliver a valid response.
					$add_admin_notice_for_collection_error( $e->getMessage() );

					try {
						// At least check if the licence is locally valid.
						// Validate without revalidation, because this would fail.
						$this->licence_manager->validateLicenceKey(
							array( 'allow_revalidation' => false )
						);
					} catch ( Throwable $e ) {
						// Licence is locally invalid.
						$has_blocking_issues = true;
					}

					continue;
				} catch ( Throwable $e ) {
					$has_blocking_issues = true;
					$add_admin_notice_for_collection_error( $e->getMessage() );

					continue;
				}
			}
		}

		if ( $options['keep_existing'] ?? true ) {
			try {
				// Replace the collected config with the current config.
				$config = array_merge( $config, ( $current_config_provider->collect() ?? array() ) );
			} catch ( InvalidConfigException $e ) {
				// This is when the current config provider failed to give its current config.
				// We should not update the current config because we need to at least retrieve
				// the personalized configuration to not lose them.
				$has_blocking_issues = true;
				$add_admin_notice_for_collection_error( $e->getMessage() );
			}
		}

		if ( ! empty( $has_blocking_issues ) ) {
			// Don't update the config.
			$err_message = sprintf( _dgettext( 'deployer-core', 'The <em>Deploy & Sync Content</em> configuration for %1$s has not been updated due to a failure during the collection process. Reason(s): %2$s' ), esc_html( static::getManagerId() ), implode( ', ', $all_reasons ) );

			add_action(
				'admin_notices',
				static function () use ( $err_message ) {
					echo '<div class="notice notice-error is-dismissible"><p>';
					echo wp_kses_data( $err_message );
					echo '</p></div>';
				}
			);

			// Dev note: this may prevent the plugin activation.
			throw new WpDeployException( $err_message );
		}

		// Merge the additional config, which may overwrite any collected config.
		$config = array_merge( $config, $additional_config );

		$this->onCollectedConfig( $config );

		// Validate the config.
		$this->validateConfig( $config );

		$current_config_provider->setConfig( $config, false );

		$this->postSave( $config );

		return $config;
	}

	/**
	 * Validates the config.
	 *
	 * @param array<string,mixed> $config The config to validate.
	 *     It is not required to be normalized; normalization is done after.
	 *
	 * @throws InvalidConfigException If the config is invalid. The message contains the error.
	 */
	public function validateConfig( array $config ): void {
	}

	/**
	 * Sets the active config override until it's unset, or the end of the session.
	 *
	 * Make sure it does not cause side effects.
	 *
	 * @param array|null $config The config override. If null, it will unset the config override.
	 */
	public function setActiveConfigOverride( ?array $config = null ): void {
		$this->config_override = $config;
	}

	/**
	 * Sets the active deployment config override.
	 *
	 * @param array $deployment_config The deployment config: tracker + options + exclusions + shorthand refs...
	 *
	 * @throws InvalidConfigException Config error.
	 * @throws Throwable All other errors.
	 * @throws WpDeployException Deploy & Sync Content error.
	 */
	public static function setActiveDeploymentConfigOverride( array $deployment_config ): void {
		// Load the config provider managers.
		$config_provider_managers = apply_filters( 'deploy_sync_content__get_config_provider_managers', array() );

		foreach ( $config_provider_managers as $id => $config_provider_manager ) {
			/** @var ConfigProviderManagerBase $config_provider_instance */
			$config_provider_instance = ComponentBasedObject::getRootComponentByClassName( $config_provider_manager, true );

			// Set the active config that will be used only on this session.
			$config_provider_instance->setActiveConfigOverride( $deployment_config[ $id ] ?? array() );
		}

		// Reload all the trackers, which will use the overrides.
		$tracking_manager = ComponentBasedObject::getRootComponentByClassName( TrackingManager::class, true );

		// Unload all the currently loaded trackers.
		$tracking_manager->reloadItemTrackers();
	}

	/**
	 * Unsets the active config override.
	 */
	public function unsetActiveConfigOverride(): void {
		$this->config_override = null;
	}

	/**
	 * Unsets the deployment active config override.
	 *
	 * @throws InvalidConfigException Config error.
	 * @throws Throwable All other errors.
	 * @throws WpDeployException Deploy & Sync Content error.
	 */
	public static function unsetDeploymentActiveConfigOverride(): void {
		// Load the config provider managers.
		$config_provider_managers = apply_filters( 'deploy_sync_content__get_config_provider_managers', array() );

		foreach ( $config_provider_managers as $config_provider_manager ) {
			/** @var ConfigProviderManagerBase $config_provider_instance */
			$config_provider_instance = ComponentBasedObject::getRootComponentByClassName( $config_provider_manager, true );

			// Unset the config override.
			$config_provider_instance->unsetActiveConfigOverride();
		}

		// Reload all the trackers, which will use the overrides.
		$tracking_manager = ComponentBasedObject::getRootComponentByClassName( TrackingManager::class, true );

		// Unload all the currently loaded trackers.
		$tracking_manager->reloadItemTrackers();
	}
}
