<?php
// Copyright (c) Pickware GmbH. All rights reserved.
// This file is part of software that is released under a proprietary license.
// You must not copy, modify, distribute, make publicly available, or execute
// its contents or parts thereof without express permission by the copyright
// holder, unless otherwise permitted by law.

use Enlight\Event\SubscriberInterface;
use Shopware\Components\Logger;
use Shopware\Models\Plugin\Plugin;
use Shopware\Plugins\ViisonCommon\Classes\Exceptions\InstallationException;
use Shopware\Plugins\ViisonCommon\Classes\Installation\ExpiringLock;
use Shopware\Plugins\ViisonCommon\Classes\Installation\ExpiringLockStatus;
use Shopware\Plugins\ViisonCommon\Classes\Installation\InstallationMessageUtil;
use Shopware\Plugins\ViisonCommon\Classes\Localization\BootstrapSnippetManager;
use Shopware\Plugins\ViisonCommon\Classes\Util\Util;
use Shopware\Plugins\ViisonCommon\Components\ExceptionTranslation\ExceptionTranslator;

if (!class_exists('ViisonCommon_Plugin_BootstrapV7')) {
    require_once('PluginBootstrapV7.php');
}

abstract class ViisonCommon_Plugin_BootstrapV8 extends ViisonCommon_Plugin_BootstrapV7
{
    const LOCK_IDENTIFIER = 'VIISON_PLUGIN_SETUP';

    /**
     * @var Logger
     */
    protected $logger = null;

    /**
     * @var BootstrapSnippetManager
     */
    protected $bootstrapSnippetManager = null;

    /**
     * Array of messages the user gets shown as GrowlMessage when the update/installation was successful.
     *
     * @var string[]
     */
    protected $growlMessages = [];

    /**
     * @var string[] Caches to invalidated after a setup process
     */
    protected $invalidatedCaches = [
        'config',
        'template',
        'proxy',
    ];

    /**
     * @var int Seconds until the lock gets released automatically
     */
    protected $setupLockExpirationInSeconds = 300;

    /**
     * The actual version of the plugin (and its bootstrap) implementing this class.
     *
     * You MUST override this property in your bootstrap implementation. We have to define the plugin version in code to
     * be able to determine which version of the plugin files is loaded into the byte code cache (if enabled) and hence
     * detect outdated caches.
     *
     * Note: Do not access this property directly for reading, but use 'self::getCodeVersion()' instead!
     *
     * @var string|null
     */
    protected $codeVersion = null;

    /**
     * The minimum Shopware version that is required to use the plugin implementing this class.
     *
     * You MUST override this property in your bootstrap implementation.
     *
     * @var string|null
     */
    protected $minRequiredShopwareVersion = null;

    /**
     * Installs or updates the plugin from version $oldVersion to the current version.
     *
     * This is the actual install/update method. Do all the needed install and migration steps here.
     *
     * Implementations should throw exceptions on on any error that can happen during update. There is a predefined
     * InstallationException in ShopwareCommon. Do not return array, string or boolean. If no exception is thrown during
     * this method, the update/installation is considered to have succeeded.
     *
     * Use the method ViisonCommon_Plugin_BootstrapV8::addLocalizedGrowlMessage() to pass through messages to the user
     * after successful installation.
     *
     * The caches listed in $this->invalidatedCaches are automatically cleared after execution. If it is necessary
     * to also clean the theme and http cache, call invalidateFrontendCache(); during execution.
     *
     * @param string $oldVersion The old version of the plugin in case of an update, 'install' on complete install
     */
    abstract protected function runUpdate($oldVersion);

    /**
     * Uninstalls the plugin.
     *
     * This is the actual uninstall method. Do all the needed uninstall steps here.
     *
     * Implementations should throw exceptions on on any error that can happen during uninstall. There is a predefined
     * InstallationException in ShopwareCommon. Do not return array, string or boolean. If no exception is thrown during
     * this method, the uninstall is considered to have succeeded.
     *
     * Use the method ViisonCommon_Plugin_BootstrapV8::addLocalizedGrowlMessage() to pass through messages to the user
     * after successful uninstall.
     *
     * The caches listed in $this->invalidatedCaches are automatically cleared after execution. If it is necessary
     * to also clean the theme and http cache, call invalidateFrontendCache(); during execution.
     *
     * @param boolean $deleteData Whether to keep or remove all plugin data on uninstall
     */
    abstract protected function runUninstall($deleteData);

    /**
     * Activates the plugin.
     *
     * This is the actual enable method. Do all the needed steps for enabling here.
     *
     * Implementations should throw exceptions on on any error that can happen during enabling. There is a predefined
     * InstallationException in ShopwareCommon. Do not return array, string or boolean. If no exception is thrown during
     * this method, the enabling is considered to have succeeded.
     *
     * Use the method ViisonCommon_Plugin_BootstrapV8::addLocalizedGrowlMessage() to pass through messages to the user
     * after successful enabling.
     *
     * The caches listed in $this->invalidatedCaches are automatically cleared after execution. If it is necessary
     * to also clean the theme and http cache, call invalidateFrontendCache(); during execution.
     */
    abstract protected function runActivation();

    /**
     * Disables the plugin.
     *
     * This is the actual disable method. Do all the needed steps for disabling here.
     *
     * Implementations should throw exceptions on on any error that can happen during disabling. There is a predefined
     * InstallationException in ShopwareCommon. Do not return array, string or boolean. If no exception is thrown during
     * this method, the disabling is considered to have succeeded.
     *
     * Use the method ViisonCommon_Plugin_BootstrapV8::addLocalizedGrowlMessage() to pass through messages to the user
     * after successful disabling.
     *
     * The caches listed in $this->invalidatedCaches are automatically cleared after execution. If it is necessary
     * to also clean the theme and http cache, call invalidateFrontendCache(); during execution.
     */
    abstract protected function runDeactivation();

    /**
     * {@inheritdoc}
     *
     * This is the default method that Shopware sometimes calls to determine the plugin's version, e.g. to determine
     * whether an update is available. Hence we return the version that is also contained in the plugin.json info file.
     */
    public function getVersion()
    {
        return $this->getPluginJsonVersion();
    }

    /**
     * {@inheritdoc}
     *
     * This is the default method that shopware calls to get all kinds of information about the plugin. We override it
     * here to provide a fallback version for dev builds, which don't have a version in their plugin.json (the parent
     * implementation is defined in 'ViisonCommon_Plugin_BootstrapV2' and reads the plugin.json file).
     */
    public function getInfo()
    {
        $info = parent::getInfo();
        if (empty($info['version'])) {
            // Only zipped plugins contain a version in their plugin.json, so if we run into this case it's most likely
            // a dev build. Hence just use the version defined in the code as fallback.
            $info['version'] = $this->getCodeVersion();
        }

        return $info;
    }

    /**
     * {@inheritdoc}
     *
     * This bootstrap version supports secure uninstallation out of the box, hence we enable it by default.
     */
    public function getCapabilities()
    {
        $capabilities = parent::getCapabilities();
        $capabilities['secureUninstall'] = true;

        return $capabilities;
    }

    /**
     * {@inheritdoc}
     *
     * @return array
     * @deprecated Do not use/override this method, implement self::runUpdate() instead.
     */
    public function install()
    {
        parent::install();

        return $this->executeSetupMethod(
            'Installation',
            function () {
                $this->runUpdate('install');
            }
        );
    }

    /**
     * {@inheritdoc}
     *
     * @return array
     * @deprecated Do not use/override this method, implement self::runUpdate() instead.
     */
    public function update($oldVersion)
    {
        parent::update($oldVersion);

        return $this->executeSetupMethod(
            sprintf('Update from version %s to %s', $oldVersion, $this->getVersion()),
            function () use ($oldVersion) {
                $this->runUpdate($oldVersion);
            }
        );
    }

    /**
     * {@inheritdoc}
     *
     * @return array
     * @deprecated Do not use/override this method, implement self::runUninstall() instead.
     */
    public function secureUninstall()
    {
        parent::secureUninstall();

        return $this->executeSetupMethod(
            'Secure uninstallation (preserving plugin data)',
            function () {
                $this->runUninstall(false);
            }
        );
    }

    /**
     * {@inheritdoc}
     *
     * @return array
     * @deprecated Do not use/override this method, implement self::runUninstall() instead.
     */
    public function uninstall()
    {
        parent::uninstall();

        return $this->executeSetupMethod(
            'Complete uninstallation (deleting plugin data)',
            function () {
                $this->runUninstall(true);
            }
        );
    }

    /**
     * {@inheritdoc}
     *
     * @return array
     * @deprecated Do not use/override this method, implement self::runActivation() instead.
     */
    public function enable()
    {
        parent::enable();

        $executionResult = $this->executeSetupMethod(
            'Activation',
            function () {
                $this->runActivation();
            }
        );
        if ($executionResult['success'] === false) {
            // When executing an activation or a deactivation of the plugin, Shopware would throw their own, generic
            // exception if the method returned false (which the setup execution does, if it catches an exception).
            // Hence the only way to show a custom message for a failed activation or deactivation is to throw our own
            // exception here.
            throw new Exception($executionResult['message']);
        }

        return $executionResult;
    }

    /**
     * {@inheritdoc}
     *
     * @return array
     * @deprecated Do not use/override this method, implement self::runDeactivation() instead.
     */
    public function disable()
    {
        parent::disable();

        $executionResult = $this->executeSetupMethod(
            'Deactivation',
            function () {
                $this->runDeactivation();
            }
        );
        if ($executionResult['success'] === false) {
            // When executing an activation or a deactivation of the plugin, Shopware would throw their own, generic
            // exception if the method returned false (which the setup execution does, if it catches an exception).
            // Hence the only way to show a custom message for a failed activation or deactivation is to throw our own
            // exception here.
            throw new Exception($executionResult['message']);
        }

        return $executionResult;
    }

    /**
     * Returns the version as contained in the plugin.json info file.
     *
     * This version is either the one defined in the plugin.json file of the implementing plugin or, if this code is
     * executed in a dev build, the code version of this plugin. See {@see self::getInfo()} for more info on the
     * fallback.
     *
     * @return string
     */
    protected function getPluginJsonVersion()
    {
        return $this->getInfo()['version'];
    }

    /**
     * Returns the value of the property self::$codeVersion.
     *
     * The property is null by default and MUST be overridden by the implementing bootstrap. If it's not overridden,
     * this method throws a LogicException to inform about the missing override (internally only this getter is used).
     *
     * @return string
     * @throws LogicException if the property is null.
     */
    protected function getCodeVersion()
    {
        if ($this->codeVersion === null) {
            throw new LogicException(sprintf(
                'Property "codeVersion" must be set to the current version of plugin "%s" in its bootstrap.',
                $this->getName()
            ));
        }

        return $this->codeVersion;
    }

    /**
     * @return string
     */
    protected function getMinRequiredShopwareVersion()
    {
        if ($this->minRequiredShopwareVersion === null) {
            throw new LogicException(sprintf(
                'Property "minRequiredShopwareVersion" must be set to minimum required Shopware version in the '.
                'Bootstrap.php of the plugin "%s".',
                $this->getName()
            ));
        }

        return $this->minRequiredShopwareVersion;
    }

    /**
     * Adds a localized update hint that is shown to the user as GrowlMessage after successful update or install.
     *
     * @param string $snippetNamespace
     * @param string $snippetName
     * @param mixed[] $snippetArguments The snippet is processed with the method vsprintf(). Fill this parameters
     *     with the values for the replacement.
     */
    protected function addLocalizedGrowlMessage($snippetNamespace, $snippetName, $snippetArguments = [])
    {
        $this->growlMessages[] = vsprintf(
            $this->getBootstrapSnippetManager()->getNamespace($snippetNamespace)->get($snippetName),
            $snippetArguments
        );
    }

    /**
     * Invalidates the caches "theme" and "http" additionally.
     *
     * Call this method inside of runUpdate(), runActivation(), runDeactivation(), runUninstall() to also clear
     * frontend cache (http & theme) after the setup process.
     *
     * By default, only the caches proxy, template and config are cleared. This should be sufficient for most plugins.
     *
     * If it is required to invalidate some special combination of caches, override self::$invalidatedCaches.
     */
    protected function invalidateFrontendCache()
    {
        $this->invalidatedCaches[] = 'theme';
        $this->invalidatedCaches[] = 'http';
    }

    /**
     * Executes a setup step (install, update, uninstall, enable, disable).
     *
     * @param string $descriptionOfSetupMethod A human readable description of the process that can be inserted in the
     * template string "%s of plugin "..." started."
     * @param callable $setupCallback A callback containing the actual business logic of the setup step.
     * @return array
     */
    protected function executeSetupMethod($descriptionOfSetupMethod, callable $setupCallback)
    {
        $setupLock = null;
        /** @var ExpiringLockStatus|null $lockStatus */
        $lockStatus = null;
        try {
            $this->logInfo(sprintf(
                '%s for plugin "%s" requested.',
                $descriptionOfSetupMethod,
                $this->getName()
            ));

            if (version_compare($this->getPluginJsonVersion(), $this->getCodeVersion(), '>')) {
                // The plugin's bootstrap file that is cached by the enabled byte code caches (OPcache, APCu etc.) is
                // outdated, hence clear the caches and ask the user to try again
                $this->clearAvailableByteCodeCaches();

                throw InstallationException::byteCodeCacheOutdated($this);
            }

            // Assert the installed shopware version
            if (!$this->assertMinimumVersion($this->getMinRequiredShopwareVersion())) {
                throw InstallationException::shopwareVersionNotSufficient(
                    $this,
                    $this->getMinRequiredShopwareVersion(),
                    $this->get('config')->version
                );
            }

            $setupLock = new ExpiringLock(
                self::LOCK_IDENTIFIER,
                $this->getName(),
                $this->setupLockExpirationInSeconds,
                $this->get('models')
            );

            $this->logInfo(sprintf(
                'Plugin "%s" is trying to acquire a lock to block other plugin setups for the next %d seconds.',
                $this->getName(),
                $this->setupLockExpirationInSeconds
            ));
            $lockStatus = $setupLock->tryAcquireLock();
            if (!$lockStatus->hasBeenAcquired()) {
                throw InstallationException::installationBlockedByActiveLock(
                    $this,
                    $lockStatus->getDescription(),
                    $lockStatus->getRemainingLockTimeInSeconds()
                );
            }

            $this->logInfo(sprintf(
                '%s of plugin "%s" started.',
                $descriptionOfSetupMethod,
                $this->getName()
            ));

            // Execute the actual setup method
            $installationStartTime = microtime(true);
            $setupCallback();
            $installationDurationInSeconds = microtime(true) - $installationStartTime;

            $this->logInfo(sprintf(
                '%s of plugin "%s" has been successfully finished. Run time: %01.3f s',
                $descriptionOfSetupMethod,
                $this->getName(),
                $installationDurationInSeconds
            ));

            $setupLock->releaseOnlyIfAlreadyHeld();
            $this->logInfo(sprintf(
                'Plugin "%s" has released its lock to block other plugin setups.',
                $this->getName()
            ));

            return [
                'success' => true,
                'message' => InstallationMessageUtil::formatUpdateMessage($this->growlMessages),
                'invalidateCache' => $this->invalidatedCaches,
            ];
        } catch (\Exception $e) {
            $this->logException(
                sprintf(
                    '%s of plugin "%s" failed with the following error: "%s"',
                    $descriptionOfSetupMethod,
                    $this->getName(),
                    $e->getMessage()
                ),
                $e
            );

            // $setupLock may be null when acquiring failed in try-block.
            if ($setupLock && $lockStatus && $lockStatus->hasBeenAcquired()) {
                $setupLock->releaseOnlyIfAlreadyHeld();
                $this->logInfo(sprintf(
                    'Plugin "%s" has released its lock to block other plugin setups.',
                    $this->getName()
                ));
            }

            $exceptionTranslator = new ExceptionTranslator($this->getBootstrapSnippetManager());
            $translatedErrorMessage = $exceptionTranslator->translate($e);

            return [
                'success' => false,
                'message' => $translatedErrorMessage,
            ];
        }
    }

    /**
     * @param $message
     */
    protected function logInfo($message)
    {
        $currentUser = Util::getCurrentUser();
        $this->getLogger()->info(
            $message,
            [
                'pluginName' => $this->getName(),
                'version' => $this->getVersion(),
                'username' => $currentUser ? $currentUser->getName() : '',
            ]
        );
    }

    /**
     * @param $message
     * @param Exception $e
     */
    protected function logException($message, \Exception $e)
    {
        $this->getLogger()->error(
            $message . ' ' . $e->getTraceAsString(),
            [
                'pluginName' => $this->getName(),
                'versionNew' => $this->getVersion(),
                'exceptionMessage' => $e->getMessage(),
                'exceptionCode' => $e->getCode(),
                'exceptionFile' => $e->getFile(),
                'exceptionLine' => $e->getLine(),
            ]
        );
    }

    /**
     * @return BootstrapSnippetManager
     */
    protected function getBootstrapSnippetManager()
    {
        // Lazy initialization of the BootstrapSnippetManager has to be done because in the self::__construct() method
        // no autoloader is registered yet. Instantiating of classes there would lead to a 'Class not found' Exception.
        // In addition, lazy initialization has some performance advantages because then it is initialized only when it
        // is needed and not every site load.
        if (!$this->bootstrapSnippetManager) {
            $this->bootstrapSnippetManager = new BootstrapSnippetManager(
                $this->get('service_container'),
                [
                    __DIR__ . '/Snippets',
                    $this->Path() . '/Snippets',
                ]
            );
        }

        return $this->bootstrapSnippetManager;
    }

    /**
     * @return Logger
     */
    protected function getLogger()
    {
        // Lazy initialization of the Logger because it has some performance advantages because then it is initialized
        // only when it is needed and not every site load.
        if (!$this->logger) {
            $this->logger = $this->get('pluginlogger');
        }

        return $this->logger;
    }

    /**
     * Checks for the availability of any known byte code caches and, if available, clears them.
     *
     * Currently the following caches are checked and cleared:
     *  - OPcache
     *  - APC cache
     *  - APCu cache
     */
    protected function clearAvailableByteCodeCaches()
    {
        if (function_exists('opcache_reset')) {
            opcache_reset();
            $this->logInfo(sprintf(
                'Plugin "%s" cleared the PHP OPcache.',
                $this->getName()
            ));
        }
        if (function_exists('apc_clear_cache')) {
            apc_clear_cache();
            $this->logInfo(sprintf(
                'Plugin "%s" cleared the APC cache.',
                $this->getName()
            ));
        }
        if (function_exists('apcu_clear_cache')) {
            apcu_clear_cache();
            $this->logInfo(sprintf(
                'Plugin "%s" cleared the APCu cache.',
                $this->getName()
            ));
        }
    }

    /**
     * Returns this plugin's model containing all info about the plugin.
     *
     * This method is NOT safe to use while the plugins and their namespaces are still being loaded, because it uses the
     * doctrine entity managed! Consider using {@link Util::getPluginInfo()} instead!
     *
     * Note: This is replacement for 'Shopware_Components_Plugin_Bootstrap::Plugin()', which is super expensive because
     *       it fetches the plugin model every time it's called. However, since that method is declared 'final', we have
     *       to use a different method name here (our name is better anyway).
     *
     * @return Plugin
     */
    public function getPlugin()
    {
        return Util::getPlugin($this->getName());
    }

    /**
     * {@inheritdoc}
     *
     * This override fixes a notice that is raised when trying to access the field 'currentVersion' of the array
     * returned by this method (or rather the original implementation in PluginBootstrapV2) if it does not exist. That
     * will happen in development builds of the plugin, because when using this version of the bootstrap, the plugin
     * no longer defines its version in its plugin.json file (a released plugin will not raise that notice. Even though
     * Shopware suppresses notices, we have to prevent it in this special case, because that it is actually raised
     * before Shopware's error handler is registered.
     */
    protected function getPluginInfo()
    {
        $pluginInfo = parent::getPluginInfo();

        if (!isset($pluginInfo['currentVersion'])) {
            // Add an empty value to prevent notices
            $pluginInfo['currentVersion'] = '';
        }

        return $pluginInfo;
    }

    /**
     * {@inheritdoc}
     *
     * This is a bug fixing override of the same method in PluginBootstrapV3. The same fix was added to
     * PluginBootstrapV3 but is added here again to ensure it is applied when extending this bootstrap version.
     */
    protected function isSubscriberRegistered(SubscriberInterface $subscriber)
    {
        // Check the passed subscriber for any subscribed events
        $subscribedEvents = $subscriber::getSubscribedEvents();
        if (empty($subscribedEvents)) {
            return true;
        }

        // Get all currently registered event listeners using reflection. This is necessary, because calling
        // 'getListener()' or 'getAllListeners()' both trigger the *lazy loading* of the ContainerAwareEventManager
        // (which is not lazy at all) and causes all suscribers on services to be initialized. However, since this
        // method is executed really 'early' (before the main dispatch loop starts), some DI resource like 'shop' are
        // not yet available. If now a subscriber that is loaded *lazily* depends on one of these resources, the DI
        // container throws an exception (see also https://github.com/VIISON/ShopwarePickwareERP/issues/680).
        /** @var \Enlight_Event_EventManager $eventManager */
        $eventManager = $this->get('events');
        $reflection = new \ReflectionClass($eventManager);
        $property = $reflection->getProperty('listeners');
        $property->setAccessible(true);
        $listeners = $property->getValue($eventManager);

        // Use the first subscribed event to determine whether the passed subscriber is already registered
        $eventNames = array_keys($subscribedEvents);
        $eventName = mb_strtolower($eventNames[0]);
        if (!isset($listeners[$eventName])) {
            return false;
        }
        foreach ($listeners[$eventName] as $listener) {
            if ($listener instanceof \Enlight_Event_Handler_Default) {
                $listenerInstance = $listener->getListener();
                if (is_array($listenerInstance) &&
                    count($listenerInstance) > 0 &&
                    is_a($listenerInstance[0], get_class($subscriber))
                ) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * {@inheritdoc}
     *
     * This is a bug fixing override of the same method in PluginBootstrapV3. The same fix was added to
     * PluginBootstrapV3 but is added here again to ensure it is applied when extending this bootstrap version.
     */
    protected function isInstalledAndActive()
    {
        return $this->isViisonCommonLoaded() && Util::isPluginInstalledAndActive(null, $this->getName());
    }
}
