<?php
/**
 * @link https://craftcms.com/
 * @copyright Copyright (c) Pixel & Tonic, Inc.
 * @license https://craftcms.github.io/license/
 */

namespace craft\services;

use Craft;
use craft\base\Plugin;
use craft\base\PluginInterface;
use craft\db\MigrationManager;
use craft\db\Query;
use craft\db\Table;
use craft\enums\LicenseKeyStatus;
use craft\errors\InvalidLicenseKeyException;
use craft\errors\InvalidPluginException;
use craft\events\PluginEvent;
use craft\helpers\ArrayHelper;
use craft\helpers\DateTimeHelper;
use craft\helpers\Db;
use craft\helpers\FileHelper;
use craft\helpers\Json;
use craft\helpers\StringHelper;
use yii\base\Component;
use yii\base\InvalidArgumentException;
use yii\db\Exception;
use yii\helpers\Inflector;
use yii\web\HttpException;

/**
 * The Plugins service provides APIs for managing plugins.
 * An instance of the Plugins service is globally accessible in Craft via [[\craft\base\ApplicationTrait::getPlugins()|`Craft::$app->plugins`]].
 *
 * @author Pixel & Tonic, Inc. <support@pixelandtonic.com>
 * @since 3.0
 */
class Plugins extends Component
{
    // Constants
    // =========================================================================

    /**
     * @event \yii\base\Event The event that is triggered before any plugins have been loaded
     */
    const EVENT_BEFORE_LOAD_PLUGINS = 'beforeLoadPlugins';

    /**
     * @event \yii\base\Event The event that is triggered after all plugins have been loaded
     */
    const EVENT_AFTER_LOAD_PLUGINS = 'afterLoadPlugins';

    /**
     * @event PluginEvent The event that is triggered before a plugin is enabled
     */
    const EVENT_BEFORE_ENABLE_PLUGIN = 'beforeEnablePlugin';
    /**
     * @event PluginEvent The event that is triggered before a plugin is enabled
     */
    const EVENT_AFTER_ENABLE_PLUGIN = 'afterEnablePlugin';

    /**
     * @event PluginEvent The event that is triggered before a plugin is disabled
     */
    const EVENT_BEFORE_DISABLE_PLUGIN = 'beforeDisablePlugin';
    /**
     * @event PluginEvent The event that is triggered before a plugin is disabled
     */
    const EVENT_AFTER_DISABLE_PLUGIN = 'afterDisablePlugin';

    /**
     * @event PluginEvent The event that is triggered before a plugin is installed
     */
    const EVENT_BEFORE_INSTALL_PLUGIN = 'beforeInstallPlugin';

    /**
     * @event PluginEvent The event that is triggered before a plugin is installed
     */
    const EVENT_AFTER_INSTALL_PLUGIN = 'afterInstallPlugin';

    /**
     * @event PluginEvent The event that is triggered before a plugin is uninstalled
     */
    const EVENT_BEFORE_UNINSTALL_PLUGIN = 'beforeUninstallPlugin';

    /**
     * @event PluginEvent The event that is triggered before a plugin is uninstalled
     */
    const EVENT_AFTER_UNINSTALL_PLUGIN = 'afterUninstallPlugin';

    /**
     * @event PluginEvent The event that is triggered before a plugin's settings are saved
     */
    const EVENT_BEFORE_SAVE_PLUGIN_SETTINGS = 'beforeSavePluginSettings';

    /**
     * @event PluginEvent The event that is triggered before a plugin's settings are saved
     */
    const EVENT_AFTER_SAVE_PLUGIN_SETTINGS = 'afterSavePluginSettings';

    const CONFIG_PLUGINS_KEY = 'plugins';

    // Properties
    // =========================================================================

    /**
     * @var bool Whether plugins have been loaded yet for this request
     */
    private $_pluginsLoaded = false;

    /**
     * @var bool Whether plugins are in the middle of being loaded
     */
    private $_loadingPlugins = false;

    /**
     * @var PluginInterface[] All the enabled plugins, indexed by handles
     */
    private $_plugins = [];

    /**
     * @var array|null Plugin info provided by Composer, indexed by handles
     */
    private $_composerPluginInfo;

    /**
     * @var array|null All of the stored info for enabled plugins, indexed by handles
     */
    private $_enabledPluginInfo;

    /**
     * @var array|null All of the stored info for disabled plugins, indexed by handles
     */
    private $_disabledPluginInfo;

    /**
     * @var array Any plugin handles that must be disabled per the `disablePlugins` config setting
     */
    private $_forceDisabledPlugins;

    /**
     * @var string[] Cache for [[getPluginHandleByClass()]]
     */
    private $_classPluginHandles = [];

    // Public Methods
    // =========================================================================

    /**
     * @inheritdoc
     */
    public function init()
    {
        $this->_forceDisabledPlugins = array_flip(Craft::$app->getConfig()->getGeneral()->disabledPlugins);

        $this->_composerPluginInfo = [];

        $path = Craft::$app->getVendorPath() . DIRECTORY_SEPARATOR . 'craftcms' . DIRECTORY_SEPARATOR . 'plugins.php';

        if (file_exists($path)) {
            /** @var array $plugins */
            $plugins = require $path;

            foreach ($plugins as $packageName => $plugin) {
                $plugin['packageName'] = $packageName;
                // Normalize the base path (and find the actual path, not a possibly-symlinked path)
                if (isset($plugin['basePath'])) {
                    $plugin['basePath'] = FileHelper::normalizePath(realpath($plugin['basePath']));
                }
                $handle = $this->_normalizeHandle(ArrayHelper::remove($plugin, 'handle'));
                $this->_composerPluginInfo[$handle] = $plugin;
            }
        }
    }

    /**
     * Loads the enabled plugins.
     */
    public function loadPlugins()
    {
        if ($this->_pluginsLoaded === true || $this->_loadingPlugins === true || Craft::$app->getIsInstalled() === false) {
            return;
        }

        // Prevent this function from getting called twice.
        $this->_loadingPlugins = true;

        // Fire a 'beforeLoadPlugins' event
        if ($this->hasEventHandlers(self::EVENT_BEFORE_LOAD_PLUGINS)) {
            $this->trigger(self::EVENT_BEFORE_LOAD_PLUGINS);
        }

        // Find all of the installed plugins
        // todo: remove try/catch after next breakpoint
        try {
            $pluginInfo = $this->_createPluginQuery()
                ->indexBy('handle')
                ->all();
        } catch (Exception $e) {
            $pluginInfo = [];
        }

        $this->_enabledPluginInfo = [];

        foreach ($pluginInfo as $handle => $row) {
            try {
                $configData = $this->_getPluginConfigData($handle);
            } catch (InvalidPluginException $e) {
                continue;
            }

            // Skip disabled plugins
            if (empty($configData['enabled'])) {
                continue;
            }

            // Clean up the row data
            $row['edition'] = $configData['edition'] ?? null;
            $row['settings'] = $configData['settings'] ?? [];
            $row['licenseKey'] = $configData['licenseKey'] ?? null;
            $row['enabled'] = true;
            $row['installDate'] = DateTimeHelper::toDateTime($row['installDate']);

            $this->_enabledPluginInfo[$handle] = $row;
        }

        foreach ($this->_enabledPluginInfo as $handle => $row) {
            try {
                $plugin = $this->createPlugin($handle, $row);
            } catch (InvalidPluginException $e) {
                $plugin = null;
            }

            if ($plugin !== null) {
                // If we're not updating, check if the plugin's version number changed, but not its schema version.
                if (!Craft::$app->getIsInMaintenanceMode() && $this->hasPluginVersionNumberChanged($plugin) && !$this->doesPluginRequireDatabaseUpdate($plugin)) {

                    /** @var Plugin $plugin */
                    if (
                        $plugin->minVersionRequired &&
                        strpos($row['version'], 'dev-') !== 0 &&
                        !StringHelper::endsWith($row['version'], '-dev') &&
                        version_compare($row['version'], $plugin->minVersionRequired, '<')
                    ) {
                        throw new HttpException(200, Craft::t('app', 'You need to be on at least {plugin} {version} before you can update to {plugin} {targetVersion}.', [
                            'version' => $plugin->minVersionRequired,
                            'targetVersion' => $plugin->version,
                            'plugin' => $plugin->name
                        ]));
                    }

                    // Update our record of the plugin's version number
                    Craft::$app->getDb()->createCommand()
                        ->update(
                            Table::PLUGINS,
                            ['version' => $plugin->getVersion()],
                            ['id' => $row['id']])
                        ->execute();
                }

                $this->_registerPlugin($plugin);
            }
        }
        unset($row);

        // Sort plugins by their names
        ArrayHelper::multisort($this->_plugins, 'name', SORT_ASC, SORT_NATURAL | SORT_FLAG_CASE);

        $this->_loadingPlugins = false;
        $this->_pluginsLoaded = true;

        // Fire an 'afterLoadPlugins' event
        if ($this->hasEventHandlers(self::EVENT_AFTER_LOAD_PLUGINS)) {
            $this->trigger(self::EVENT_AFTER_LOAD_PLUGINS);
        }
    }

    /**
     * Returns whether plugins have been loaded yet for this request.
     *
     * @return bool
     */
    public function arePluginsLoaded(): bool
    {
        return $this->_pluginsLoaded;
    }

    /**
     * Returns an enabled plugin by its handle.
     *
     * @param string $handle The plugin’s handle
     * @return PluginInterface|null The plugin, or null if it doesn’t exist
     */
    public function getPlugin(string $handle)
    {
        $this->loadPlugins();

        if (isset($this->_plugins[$handle])) {
            return $this->_plugins[$handle];
        }

        return null;
    }

    /**
     * Returns an enabled plugin by its package name.
     *
     * @param string $packageName The plugin’s package name
     * @return PluginInterface|null The plugin, or null if it doesn’t exist
     */
    public function getPluginByPackageName(string $packageName)
    {
        $this->loadPlugins();

        foreach ($this->_plugins as $plugin) {
            /** @var Plugin $plugin */
            if ($plugin->packageName === $packageName) {
                return $plugin;
            }
        }

        return null;
    }

    /**
     * Returns the plugin handle that contains the given class, if any.
     *
     * The plugin may not actually be installed.
     *
     * @param string $class
     * @return string|null The plugin handle, or null if it can’t be determined
     */
    public function getPluginHandleByClass(string $class)
    {
        if (array_key_exists($class, $this->_classPluginHandles)) {
            return $this->_classPluginHandles[$class];
        }
        // Figure out the path to the folder that contains this class
        try {
            // Add a trailing slash so we don't get false positives
            $classPath = FileHelper::normalizePath(dirname((new \ReflectionClass($class))->getFileName())) . DIRECTORY_SEPARATOR;
        } catch (\ReflectionException $e) {
            return $this->_classPluginHandles[$class] = null;
        }

        // Find the plugin that contains this path (if any)
        foreach ($this->_composerPluginInfo as $handle => $info) {
            if (isset($info['basePath']) && strpos($classPath, $info['basePath'] . DIRECTORY_SEPARATOR) === 0) {
                return $this->_classPluginHandles[$class] = $handle;
            }
        }

        return $this->_classPluginHandles[$class] = null;
    }

    /**
     * Returns all the enabled plugins.
     *
     * @return PluginInterface[]
     */
    public function getAllPlugins(): array
    {
        $this->loadPlugins();

        return $this->_plugins;
    }

    /**
     * Enables a plugin by its handle.
     *
     * @param string $handle The plugin’s handle
     * @return bool Whether the plugin was enabled successfully
     * @throws InvalidPluginException if the plugin isn't installed
     */
    public function enablePlugin(string $handle): bool
    {
        if ($this->isPluginEnabled($handle)) {
            // It's already enabled
            return true;
        }

        if (($info = $this->getStoredPluginInfo($handle)) === null) {
            throw new InvalidPluginException($handle);
        }

        if (($plugin = $this->createPlugin($handle, $info)) === null) {
            throw new InvalidPluginException($handle);
        }

        // Fire a 'beforeEnablePlugin' event
        if ($this->hasEventHandlers(self::EVENT_BEFORE_ENABLE_PLUGIN)) {
            $this->trigger(self::EVENT_BEFORE_ENABLE_PLUGIN, new PluginEvent([
                'plugin' => $plugin
            ]));
        }

        // Enable the plugin in the project config
        Craft::$app->getProjectConfig()->set(self::CONFIG_PLUGINS_KEY . '.' . $handle . '.enabled', true);

        $this->_enabledPluginInfo[$handle] = $info;
        $this->_registerPlugin($plugin);

        // Fire an 'afterEnablePlugin' event
        if ($this->hasEventHandlers(self::EVENT_AFTER_ENABLE_PLUGIN)) {
            $this->trigger(self::EVENT_AFTER_ENABLE_PLUGIN, new PluginEvent([
                'plugin' => $plugin
            ]));
        }

        return true;
    }

    /**
     * Disables a plugin by its handle.
     *
     * @param string $handle The plugin’s handle
     * @return bool Whether the plugin was disabled successfully
     * @throws InvalidPluginException if the plugin isn’t installed
     */
    public function disablePlugin(string $handle): bool
    {
        if (!$this->isPluginInstalled($handle)) {
            throw new InvalidPluginException($handle);
        }

        if (!$this->isPluginEnabled($handle)) {
            // It's already disabled
            return true;
        }

        if (($plugin = $this->getPlugin($handle)) === null) {
            throw new InvalidPluginException($handle);
        }

        // Fire a 'beforeDisablePlugin' event
        if ($this->hasEventHandlers(self::EVENT_BEFORE_DISABLE_PLUGIN)) {
            $this->trigger(self::EVENT_BEFORE_DISABLE_PLUGIN, new PluginEvent([
                'plugin' => $plugin
            ]));
        }

        // Disable the plugin in the project config
        Craft::$app->getProjectConfig()->set(self::CONFIG_PLUGINS_KEY . '.' . $handle . '.enabled', false);

        unset($this->_enabledPluginInfo[$handle]);
        $this->_unregisterPlugin($plugin);

        // Fire an 'afterDisablePlugin' event
        if ($this->hasEventHandlers(self::EVENT_AFTER_DISABLE_PLUGIN)) {
            $this->trigger(self::EVENT_AFTER_DISABLE_PLUGIN, new PluginEvent([
                'plugin' => $plugin
            ]));
        }

        return true;
    }

    /**
     * Installs a plugin by its handle.
     *
     * @param string $handle The plugin’s handle
     * @param string|null $edition The plugin’s edition
     * @return bool Whether the plugin was installed successfully.
     * @throws InvalidPluginException if the plugin doesn’t exist
     * @throws \Throwable if reasons
     */
    public function installPlugin(string $handle, string $edition = null): bool
    {
        $this->loadPlugins();

        if ($this->getStoredPluginInfo($handle) !== null) {
            // It's already installed
            return true;
        }

        $projectConfig = Craft::$app->getProjectConfig();
        $configKey = self::CONFIG_PLUGINS_KEY . '.' . $handle;

        /** @var Plugin $plugin */
        $plugin = $this->createPlugin($handle);

        // Set the edition
        if ($edition === null) {
            // See if one is already set in the project config
            $edition = $projectConfig->get($configKey . '.edition');
        }
        $editions = $plugin::editions();
        if ($edition === null || !in_array($edition, $editions, true)) {
            $edition = reset($editions);
        }
        $plugin->edition = $edition;

        // Fire a 'beforeInstallPlugin' event
        if ($this->hasEventHandlers(self::EVENT_BEFORE_INSTALL_PLUGIN)) {
            $this->trigger(self::EVENT_BEFORE_INSTALL_PLUGIN, new PluginEvent([
                'plugin' => $plugin
            ]));
        }

        $db = Craft::$app->getDb();
        $transaction = $db->beginTransaction();
        try {
            $info = [
                'handle' => $handle,
                'version' => $plugin->getVersion(),
                'schemaVersion' => $plugin->schemaVersion,
                'installDate' => Db::prepareDateForDb(new \DateTime()),
            ];

            $db->createCommand()
                ->insert(Table::PLUGINS, $info)
                ->execute();

            $info['installDate'] = DateTimeHelper::toDateTime($info['installDate']);
            $info['id'] = $db->getLastInsertID(Table::PLUGINS);

            $this->_setPluginMigrator($plugin, $info['id']);

            if ($plugin->install() === false) {
                $transaction->rollBack();

                if ($db->getIsMysql()) {
                    // Explicitly remove the plugins row just in case the transaction was implicitly committed
                    $db->createCommand()
                        ->delete(Table::PLUGINS, ['handle' => $handle])
                        ->execute();
                }

                return false;
            }

            $transaction->commit();
        } catch (\Throwable $e) {
            $transaction->rollBack();
            throw $e;
        }

        // Add the plugin to the project config
        if (!$projectConfig->get($configKey, true)) {
            $projectConfig->set($configKey . '.edition', $edition);
            $projectConfig->set($configKey . '.enabled', true);
            $projectConfig->set($configKey . '.schemaVersion', $plugin->schemaVersion);
        }

        $this->_enabledPluginInfo[$handle] = $info;
        $this->_registerPlugin($plugin);

        // Fire an 'afterInstallPlugin' event
        if ($this->hasEventHandlers(self::EVENT_AFTER_INSTALL_PLUGIN)) {
            $this->trigger(self::EVENT_AFTER_INSTALL_PLUGIN, new PluginEvent([
                'plugin' => $plugin
            ]));
        }

        return true;
    }

    /**
     * Uninstalls a plugin by its handle.
     *
     * @param string $handle The plugin’s handle
     * @return bool Whether the plugin was uninstalled successfully
     * @throws InvalidPluginException if the plugin doesn’t exist
     * @throws \Throwable if reasons
     */
    public function uninstallPlugin(string $handle): bool
    {
        $this->loadPlugins();

        if (!$this->isPluginEnabled($handle)) {
            // Don't allow uninstalling disabled plugins, because that could be buggy
            // if the plugin was composer-updated while disabled, and its uninstall()
            // function is out of sync with what's actually in the database
            if ($this->isPluginInstalled($handle)) {
                throw new InvalidPluginException($handle, 'Uninstalling disabled plugins is not allowed.');
            }
            // It's already uninstalled
            return true;
        }

        if (($plugin = $this->getPlugin($handle)) === null) {
            throw new InvalidPluginException($handle);
        }

        // Fire a 'beforeUninstallPlugin' event
        if ($this->hasEventHandlers(self::EVENT_BEFORE_UNINSTALL_PLUGIN)) {
            $this->trigger(self::EVENT_BEFORE_UNINSTALL_PLUGIN, new PluginEvent([
                'plugin' => $plugin
            ]));
        }

        $transaction = Craft::$app->getDb()->beginTransaction();
        try {
            // Let the plugin uninstall itself first
            if ($plugin->uninstall() === false) {
                $transaction->rollBack();

                return false;
            }

            // Clean up the plugins and migrations tables
            $id = $this->getStoredPluginInfo($handle)['id'];

            Craft::$app->getDb()->createCommand()
                ->delete(Table::PLUGINS, ['id' => $id])
                ->execute();

            $transaction->commit();
        } catch (\Throwable $e) {
            $transaction->rollBack();

            throw $e;
        }

        // Remove the plugin from the project config
        $projectConfig = Craft::$app->getProjectConfig();
        if ($projectConfig->get(self::CONFIG_PLUGINS_KEY . '.' . $handle, true)) {
            Craft::$app->getProjectConfig()->remove(self::CONFIG_PLUGINS_KEY . '.' . $handle);
        }

        $this->_unregisterPlugin($plugin);
        unset($this->_enabledPluginInfo[$handle]);

        // Fire an 'afterUninstallPlugin' event
        if ($this->hasEventHandlers(self::EVENT_AFTER_UNINSTALL_PLUGIN)) {
            $this->trigger(self::EVENT_AFTER_UNINSTALL_PLUGIN, new PluginEvent([
                'plugin' => $plugin
            ]));
        }

        return true;
    }

    /**
     * Switches a plugin’s edition.
     *
     * @param string $handle The plugin’s handle
     * @param string $edition The plugin’s edition
     * @throws InvalidPluginException if the plugin doesn’t exist
     * @throws InvalidArgumentException if $edition is invalid
     * @throws \Throwable if reasons
     */
    public function switchEdition(string $handle, string $edition)
    {
        $info = $this->getPluginInfo($handle);

        /** @var string|PluginInterface $class */
        $class = $info['class'];

        if (!in_array($edition, $class::editions(), true)) {
            throw new InvalidArgumentException('Invalid plugin edition: ' . $edition);
        }

        // Update the project config
        Craft::$app->getProjectConfig()->set(self::CONFIG_PLUGINS_KEY . '.' . $handle . '.edition', $edition);

        if (isset($this->_enabledPluginInfo[$handle])) {
            $this->_enabledPluginInfo[$handle]['edition'] = $edition;
        } else if (isset($this->_disabledPluginInfo[$handle])) {
            $this->_disabledPluginInfo[$handle]['edition'] = $edition;
        }

        // If it's installed, update the instance and our locally stored info
        $plugin = $this->getPlugin($handle);
        if ($plugin !== null) {
            /** @var Plugin $plugin */
            $plugin->edition = $edition;
        }
    }

    /**
     * Saves a plugin's settings.
     *
     * @param PluginInterface $plugin The plugin
     * @param array $settings The plugin’s new settings
     * @return bool Whether the plugin’s settings were saved successfully
     */
    public function savePluginSettings(PluginInterface $plugin, array $settings): bool
    {
        /** @var Plugin $plugin */
        // Save the settings on the plugin
        $plugin->getSettings()->setAttributes($settings, false);

        // Validate them, now that it's a model
        if ($plugin->getSettings()->validate() === false) {
            return false;
        }

        // Fire a 'beforeSavePluginSettings' event
        if ($this->hasEventHandlers(self::EVENT_BEFORE_SAVE_PLUGIN_SETTINGS)) {
            $this->trigger(self::EVENT_BEFORE_SAVE_PLUGIN_SETTINGS, new PluginEvent([
                'plugin' => $plugin
            ]));
        }

        if (!$plugin->beforeSaveSettings()) {
            return false;
        }

        // Update the plugin's settings in the project config
        Craft::$app->getProjectConfig()->set(self::CONFIG_PLUGINS_KEY . '.' . $plugin->handle . '.settings', $plugin->getSettings()->toArray());

        $plugin->afterSaveSettings();

        // Fire an 'afterSavePluginSettings' event
        if ($this->hasEventHandlers(self::EVENT_AFTER_SAVE_PLUGIN_SETTINGS)) {
            $this->trigger(self::EVENT_AFTER_SAVE_PLUGIN_SETTINGS, new PluginEvent([
                'plugin' => $plugin
            ]));
        }

        return true;
    }

    /**
     * Returns whether the given plugin’s version number has changed from what we have recorded in the database.
     *
     * @param PluginInterface $plugin The plugin
     * @return bool Whether the plugin’s version number has changed from what we have recorded in the database
     */
    public function hasPluginVersionNumberChanged(PluginInterface $plugin): bool
    {
        /** @var Plugin $plugin */
        $this->loadPlugins();

        if (($info = $this->getStoredPluginInfo($plugin->id)) === null) {
            return false;
        }

        return $plugin->getVersion() !== $info['version'];
    }

    /**
     * Returns whether the given plugin’s local schema version is greater than the record we have in the database.
     *
     * @param PluginInterface $plugin The plugin
     * @return bool Whether the plugin’s local schema version is greater than the record we have in the database
     */
    public function doesPluginRequireDatabaseUpdate(PluginInterface $plugin): bool
    {
        /** @var Plugin $plugin */
        $this->loadPlugins();

        if (($info = $this->getStoredPluginInfo($plugin->id)) === null) {
            return false;
        }

        return version_compare($plugin->schemaVersion, $info['schemaVersion'], '>');
    }

    /**
     * Returns whether a given plugin is installed (even if it's disabled).
     *
     * @param string $handle The plugin handle
     * @return bool
     */
    public function isPluginInstalled(string $handle): bool
    {
        $this->loadPlugins();

        if (isset($this->_enabledPluginInfo[$handle])) {
            return true;
        }

        return $this->_createPluginQuery()
            ->where(['handle' => $handle])
            ->exists();
    }

    /**
     * Returns whether a given plugin is installed and enabled.
     *
     * @param string $handle The plugin handle
     * @return bool
     */
    public function isPluginEnabled(string $handle): bool
    {
        $this->loadPlugins();
        return isset($this->_enabledPluginInfo[$handle]);
    }

    /**
     * Returns whether a given plugin is installed but disabled.
     *
     * @param string $handle The plugin handle
     * @return bool
     */
    public function isPluginDisabled(string $handle): bool
    {
        return !$this->isPluginEnabled($handle) && $this->isPluginInstalled($handle);
    }

    /**
     * Returns the stored info for a given plugin.
     *
     * @param string $handle The plugin handle
     * @return array|null The stored info, if there is any
     */
    public function getStoredPluginInfo(string $handle)
    {
        $this->loadPlugins();

        if (isset($this->_enabledPluginInfo[$handle])) {
            return $this->_enabledPluginInfo[$handle];
        }

        $row = $this->_createPluginQuery()
            ->where(['handle' => $handle])
            ->one();

        if (!$row) {
            return null;
        }

        $configData = $this->_getPluginConfigData($handle);

        $row['settings'] = $configData['settings'] ?? [];
        $row['enabled'] = $configData['enabled'] ?? false;
        $row['licenseKey'] = $configData['licenseKey'] ?? null;

        $row['installDate'] = DateTimeHelper::toDateTime($row['installDate']);

        return $row;
    }

    /**
     * Returns the Composer-supplied info
     *
     * @param string|null $handle The plugin handle. If null is passed, info for all Composer-installed plugins will be returned.
     * @return array|null The plugin info, or null if an unknown handle was passed.
     */
    public function getComposerPluginInfo(string $handle = null)
    {
        if ($handle === null) {
            return $this->_composerPluginInfo;
        }
        return $this->_composerPluginInfo[$handle] ?? null;
    }

    /**
     * Creates and returns a new plugin instance based on its handle.
     *
     * @param string $handle The plugin’s handle
     * @param array|null $info The plugin’s stored info, if any
     * @return PluginInterface
     * @throws InvalidPluginException if $handle is invalid
     */
    public function createPlugin(string $handle, array $info = null)
    {
        if (!isset($this->_composerPluginInfo[$handle])) {
            throw new InvalidPluginException($handle);
        }

        $config = $this->_composerPluginInfo[$handle];

        if (isset($config['aliases'])) {
            foreach ($config['aliases'] as $alias => $path) {
                Craft::setAlias($alias, $path);
            }

            // Unset them so we don't end up calling Module::setAliases()
            unset($config['aliases']);
        }

        /** @var string|PluginInterface $class */
        $class = $config['class'];

        // Make sure the class exists and it implements PluginInterface
        if (!is_subclass_of($class, PluginInterface::class)) {
            return null;
        }

        // Is it installed?
        if ($info !== null) {
            $config['isInstalled'] = true;

            // Set the edition
            $config['edition'] = $info['edition'] ?? 'standard';
            $editions = $class::editions();
            if (!in_array($config['edition'], $editions, true)) {
                $config['edition'] = reset($editions);
            }

            $settings = array_merge(
                $info['settings'] ?? [],
                Craft::$app->getConfig()->getConfigFromFile($handle)
            );

            if ($settings !== []) {
                $config['settings'] = $settings;
            }
        }

        // Create the plugin
        /** @var Plugin $plugin */
        $plugin = Craft::createObject($config, [$handle, Craft::$app]);

        if ($info !== null) {
            $this->_setPluginMigrator($plugin, $info['id']);
        }

        return $plugin;
    }

    /**
     * Returns info about all of the plugins we can find, whether they’re installed or not.
     *
     * @return array
     */
    public function getAllPluginInfo(): array
    {
        $this->loadPlugins();

        // Get the info arrays
        $info = [];

        foreach (array_keys($this->_composerPluginInfo) as $handle) {
            $info[$handle] = $this->getPluginInfo($handle);
        }

        // Sort plugins by their names
        ArrayHelper::multisort($info, 'name', SORT_ASC, SORT_NATURAL | SORT_FLAG_CASE);

        return $info;
    }

    /**
     * Returns info about a plugin, whether it's installed or not.
     *
     * @param string $handle The plugin’s handle
     * @return array
     * @throws InvalidPluginException if the plugin isn't Composer-installed
     */
    public function getPluginInfo(string $handle): array
    {
        if (!isset($this->_composerPluginInfo[$handle])) {
            throw new InvalidPluginException($handle);
        }

        // Get the info in the DB, if it's installed
        $this->_loadDisabledPluginInfo();
        $pluginInfo = $this->_enabledPluginInfo[$handle] ?? $this->_disabledPluginInfo[$handle] ?? null;

        // Get the plugin if it's enabled
        /** @var Plugin|null $plugin */
        $plugin = $this->getPlugin($handle);

        $info = array_merge([
            'developer' => null,
            'developerUrl' => null,
            'description' => null,
            'documentationUrl' => null,
        ], $this->_composerPluginInfo[$handle]);

        $edition = $pluginInfo['edition'] ?? 'standard';
        if ($plugin) {
            $editions = $plugin::editions();
            if (!in_array($edition, $editions, true)) {
                $edition = reset($editions);
            }
        } else {
            $editions = ['standard'];
        }

        $info['isInstalled'] = $installed = $pluginInfo !== null;
        $info['isEnabled'] = $plugin !== null;
        $info['moduleId'] = $handle;
        $info['edition'] = $edition;
        $info['hasMultipleEditions'] = count($editions) > 1;
        $info['hasCpSettings'] = ($plugin !== null && $plugin->hasCpSettings);
        $info['licenseKey'] = $pluginInfo['licenseKey'] ?? null;
        $info['licenseKeyStatus'] = $pluginInfo['licenseKeyStatus'] ?? LicenseKeyStatus::Unknown;
        $info['licensedEdition'] = $pluginInfo['licensedEdition'] ?? null;
        $info['licenseIssues'] = $installed ? $this->getLicenseIssues($handle) : [];

        // The plugin is in trial if it's missing its license key, or running the wrong edition
        $info['isTrial'] = $installed
            ? (
                ($info['licenseKeyStatus'] === LicenseKeyStatus::Invalid && empty($info['licenseIssues'])) ||
                ($info['licenseKeyStatus'] === LicenseKeyStatus::Valid && !empty($pluginInfo['licensedEdition']) && $pluginInfo['licensedEdition'] !== $edition)
            )
            : false;

        // An upgrade is available if the plugin is in trial or licensed to less than the best edition
        $info['upgradeAvailable'] = (
            $info['isTrial'] ||
            (
                $info['hasMultipleEditions'] &&
                (
                    (!empty($pluginInfo['licensedEdition']) && $pluginInfo['licensedEdition'] !== end($editions)) ||
                    $pluginInfo['edition'] !== end($editions)
                )
            )
        );

        return $info;
    }

    /**
     * Returns whether a plugin has licensing issues.
     *
     * @param string $handle
     * @return bool
     */
    public function hasIssues(string $handle): bool
    {
        return !empty($this->getLicenseIssues($handle));
    }

    /**
     * Returns any issues with a plugin license.
     *
     * The response will be an array containing a combination of these strings:
     *
     * - `wrong_edition` – if the current edition isn't the licensed one, and
     *   testing editions isn't allowed
     * - `mismatched` – if the license key is tied to a different Craft license
     * - `astray` – if the installed version is greater than the highest version
     *   the license is allowed to run
     * - `required` – if no license key is present but one is required
     * - `invalid` – if a license key is present but it’s invalid
     *
     * @param string $handle
     * @return string[]
     */
    public function getLicenseIssues(string $handle): array
    {
        if (isset($this->_enabledPluginInfo[$handle])) {
            $pluginInfo = $this->_enabledPluginInfo[$handle];
        } else {
            $this->_loadDisabledPluginInfo();
            if (!isset($this->_disabledPluginInfo[$handle])) {
                return [];
            }
            $pluginInfo = $this->_disabledPluginInfo[$handle];
        }

        $status = $pluginInfo['licenseKeyStatus'] ?? LicenseKeyStatus::Unknown;

        if ($status === LicenseKeyStatus::Unknown) {
            // Either we don't know yet, or the plugin is free
            return [];
        }

        $issues = [];

        // Make sure they're allowed to run the current edition
        $canTestEditions = Craft::$app->getCanTestEditions();
        if (
            !$canTestEditions &&
            isset($pluginInfo['edition'], $pluginInfo['licensedEdition']) &&
            $pluginInfo['edition'] !== $pluginInfo['licensedEdition']
        ) {
            $issues[] = 'wrong_edition';
        }

        // General license issues
        switch ($pluginInfo['licenseKeyStatus']) {
            case LicenseKeyStatus::Mismatched:
                $issues[] = 'mismatched';
                break;
            case LicenseKeyStatus::Astray:
                $issues[] = 'astray';
                break;
            case LicenseKeyStatus::Invalid:
                if (!empty($pluginInfo['licenseKey'])) {
                    $issues[] = 'invalid';
                } else if (!$canTestEditions) {
                    $issues[] = 'required';
                }
                break;
        }

        return $issues;
    }

    /**
     * Returns a plugin’s SVG icon.
     *
     * @param string $handle The plugin’s handle
     * @return string The given plugin’s SVG icon
     */
    public function getPluginIconSvg(string $handle): string
    {
        // If it's installed, let the plugin say where it lives
        if (($plugin = $this->getPlugin($handle)) !== null) {
            /** @var Plugin $plugin */
            $basePath = $plugin->getBasePath();
        } else {
            if (($basePath = $this->_composerPluginInfo[$handle]['basePath'] ?? false) !== false) {
                $basePath = Craft::getAlias($basePath);
            }
        }

        $iconPath = ($basePath !== false) ? $basePath . DIRECTORY_SEPARATOR . 'icon.svg' : false;

        if ($iconPath === false || !is_file($iconPath) || !FileHelper::isSvg($iconPath)) {
            $iconPath = Craft::getAlias('@app/icons/default-plugin.svg');
        }

        return file_get_contents($iconPath);
    }

    /**
     * Returns the license key stored for a given plugin, if it was purchased through the Store.
     *
     * @param string $handle The plugin’s handle
     * @return string|null The plugin’s license key, or null if it isn’t known
     */
    public function getPluginLicenseKey(string $handle)
    {
        return $this->normalizePluginLicenseKey(Craft::parseEnv($this->getStoredPluginInfo($handle)['licenseKey'] ?? null));
    }

    /**
     * Sets a plugin’s license key.
     *
     * Note this should *not* be used to store license keys generated by third party stores.
     *
     * @param string $handle The plugin’s handle
     * @param string|null $licenseKey The plugin’s license key
     * @return bool Whether the license key was updated successfully
     * @throws InvalidPluginException if the plugin isn't installed
     * @throws InvalidLicenseKeyException if $licenseKey is invalid
     */
    public function setPluginLicenseKey(string $handle, string $licenseKey = null): bool
    {
        if (($plugin = $this->getPlugin($handle)) === null) {
            throw new InvalidPluginException($handle);
        }

        /** @var Plugin $plugin */
        // Validate the license key
        $normalizedLicenseKey = $this->normalizePluginLicenseKey($licenseKey);

        // Set the plugin's license key in the project config
        Craft::$app->getProjectConfig()->set(self::CONFIG_PLUGINS_KEY . '.' . $handle . '.licenseKey', $normalizedLicenseKey);

        // Update our cache of it
        if (isset($this->_enabledPluginInfo[$handle])) {
            $this->_enabledPluginInfo[$handle]['licenseKey'] = $normalizedLicenseKey;
        }

        // If we've cached the plugin's license key status, update the cache
        if ($this->getPluginLicenseKeyStatus($handle) !== LicenseKeyStatus::Unknown) {
            $this->setPluginLicenseKeyStatus($handle, LicenseKeyStatus::Unknown);
        }

        return true;
    }

    /**
     * Normalizes a plugin license key.
     *
     * @param string|null $licenseKey
     * @return string|null
     * @throws InvalidLicenseKeyException
     */
    public function normalizePluginLicenseKey(string $licenseKey = null)
    {
        if ($licenseKey === null || strpos($licenseKey, '$') === 0) {
            return $licenseKey;
        }

        // Normalize to just uppercase numbers/letters
        $licenseKey = mb_strtoupper($licenseKey);
        $licenseKey = preg_replace('/[^A-Z0-9]/', '', $licenseKey);

        if (strlen($licenseKey) != 24) {
            // Invalid key
            throw new InvalidLicenseKeyException($licenseKey);
        }

        return $licenseKey;
    }

    /**
     * Returns the license key status of a given plugin.
     *
     * @param string $handle The plugin’s handle
     * @return string
     */
    public function getPluginLicenseKeyStatus(string $handle): string
    {
        return $this->getStoredPluginInfo($handle)['licenseKeyStatus'] ?? LicenseKeyStatus::Unknown;
    }

    /**
     * Sets the license key status for a given plugin.
     *
     * @param string $handle The plugin’s handle
     * @param string|null $licenseKeyStatus The plugin’s license key status
     * @param string|null $licensedEdition The plugin's licensed edition, if the key is valid
     * @throws InvalidPluginException if the plugin isn't installed
     */
    public function setPluginLicenseKeyStatus(string $handle, string $licenseKeyStatus = null, string $licensedEdition = null)
    {
        $pluginInfo = $this->getPluginInfo($handle);

        if (!$pluginInfo['isInstalled']) {
            throw new InvalidPluginException($handle);
        }

        /** @var Plugin $plugin */
        Craft::$app->getDb()->createCommand()
            ->update(Table::PLUGINS, [
                'licenseKeyStatus' => $licenseKeyStatus,
                'licensedEdition' => $licensedEdition,
            ], ['handle' => $handle])
            ->execute();

        // Update our cache of it
        if (isset($this->_enabledPluginInfo[$handle])) {
            $this->_enabledPluginInfo[$handle]['licenseKeyStatus'] = $licenseKeyStatus;
            $this->_enabledPluginInfo[$handle]['licensedEdition'] = $licensedEdition;
        }
    }

    // Private Methods
    // =========================================================================

    /**
     * Returns a Query object prepped for retrieving sections.
     *
     * @return Query
     */
    private function _createPluginQuery(): Query
    {
        $query = (new Query())
            ->select([
                'id',
                'handle',
                'version',
                'schemaVersion',
                'licenseKeyStatus',
                'installDate'
            ])
            ->from([Table::PLUGINS]);

        // todo: remove schema version condition after next beakpoint
        $schemaVersion = Craft::$app->getProjectConfig()->get('system.schemaVersion');
        if (version_compare($schemaVersion, '3.1.19', '>=')) {
            $query->addSelect(['licensedEdition']);
        }

        return $query;
    }

    /**
     * Converts old school camelCase handles to kebab-case.
     *
     * @param string $handle
     * @return string
     */
    private function _normalizeHandle(string $handle): string
    {
        if (strtolower($handle) !== $handle) {
            $handle = preg_replace('/\-{2,}/', '-', Inflector::camel2id($handle));
        }

        return $handle;
    }

    /**
     * Registers a plugin internally and as an application module.
     *
     * This should only be called for enabled plugins
     *
     * @param PluginInterface $plugin The plugin
     */
    private function _registerPlugin(PluginInterface $plugin)
    {
        /** @var Plugin $plugin */
        $this->_plugins[$plugin->id] = $plugin;
        Craft::$app->setModule($plugin->id, $plugin);
    }

    /**
     * Unregisters a plugin internally and as an application module.
     *
     * @param PluginInterface $plugin The plugin
     */
    private function _unregisterPlugin(PluginInterface $plugin)
    {
        /** @var Plugin $plugin */
        unset($this->_plugins[$plugin->id]);
        Craft::$app->setModule($plugin->id, null);
    }

    /**
     * Sets the 'migrator' component on a plugin.
     *
     * @param PluginInterface $plugin The plugin
     * @param int $id The plugin’s ID
     */
    private function _setPluginMigrator(PluginInterface $plugin, int $id)
    {
        $ref = new \ReflectionClass($plugin);
        $ns = $ref->getNamespaceName();
        /** @var Plugin $plugin */
        $plugin->set('migrator', [
            'class' => MigrationManager::class,
            'type' => MigrationManager::TYPE_PLUGIN,
            'pluginId' => $id,
            'migrationNamespace' => ($ns ? $ns . '\\' : '') . 'migrations',
            'migrationPath' => $plugin->getBasePath() . DIRECTORY_SEPARATOR . 'migrations',
        ]);
    }

    /**
     * Load disabled plugin info.
     */
    private function _loadDisabledPluginInfo()
    {
        if ($this->_disabledPluginInfo === null) {
            $pluginInfo = $this->_createPluginQuery()
                ->indexBy('handle')
                ->all();

            $this->_disabledPluginInfo = [];

            foreach ($pluginInfo as $handle => &$row) {
                try {
                    $configData = $this->_getPluginConfigData($handle);
                } catch (InvalidPluginException $e) {
                    continue;
                }

                // Skip enabled plugins
                if (!empty($configData['enabled'])) {
                    continue;
                }

                // Clean up the row data
                $row['settings'] = $configData['settings'] ?? [];
                $row['licenseKey'] = $configData['licenseKey'] ?? null;
                $row['enabled'] = true;

                $this->_disabledPluginInfo[$handle] = $row;
            }
        }
    }

    /**
     * Load config data for plugin by its handle.
     *
     * @param string $handle
     * @return array
     *
     * @throws InvalidPluginException if plugin not found
     */
    private function _getPluginConfigData(string $handle): array
    {
        // todo: remove this after the next breakpoint
        if (version_compare(Craft::$app->getInfo()->version, '3.1', '<')) {
            $row = (new Query())->from([Table::PLUGINS])->where(['handle' => $handle])->one();
            if (!$row) {
                throw new InvalidPluginException($handle);
            }
            $row['settings'] = Json::decodeIfJson((string)($row['settings'] ?? '[]'));
            return $row;
        }

        $projectConfig = Craft::$app->getProjectConfig();
        $configKey = self::CONFIG_PLUGINS_KEY . '.' . $handle;
        $data = $projectConfig->get($configKey) ?? $projectConfig->get($configKey, true);

        if (!$data) {
            throw new InvalidPluginException($handle);
        }

        // Force disable it?
        if (isset($this->_forceDisabledPlugins[$handle])) {
            $data['enabled'] = false;
        }

        return $data;
    }
}
