<?php

/**
 * Matomo - free/libre analytics platform
 *
 * @link    https://matomo.org
 * @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
 */

namespace Piwik;

use Piwik\Columns\Updater as ColumnUpdater;
use Piwik\Container\StaticContainer;
use Piwik\Plugin\Manager;
use Piwik\Plugins\Installation\ServerFilesGenerator;
use Piwik\Updater\Migration;
use Piwik\Exception\MissingFilePermissionException;
use Piwik\Updater\UpdateObserver;
use Zend_Db_Exception;

/**
 * Load and execute all relevant, incremental update scripts for Piwik core and plugins, and bump the component version numbers for completed updates.
 *
 */
class Updater
{
    public const INDEX_CURRENT_VERSION = 0;
    public const INDEX_NEW_VERSION = 1;
    public const OPTION_KEY_MATOMO_UPDATE_HISTORY = 'MatomoUpdateHistory';

    private $pathUpdateFileCore;
    private $pathUpdateFilePlugins;
    private $hasMajorDbUpdate = false;
    private $updatedClasses = array();
    private $componentsWithNewVersion = array();
    private $componentsWithUpdateFile = array();

    /**
     * @var UpdateObserver[]
     */
    private $updateObservers = array();

    /**
     * @var Columns\Updater
     */
    private $columnsUpdater;

    /**
     * Currently used Updater instance, set on construction. This instance is used to provide backwards
     * compatibility w/ old code that may use the deprecated static methods in Updates.
     *
     * @var Updater
     */
    private static $activeInstance;

    /**
     * Constructor.
     *
     * @param string|null $pathUpdateFileCore The path to core Update files.
     * @param string|null $pathUpdateFilePlugins The path to plugin update files. Should contain a `'%s'` placeholder
     *                                           for the plugin name.
     * @param Columns\Updater|null $columnsUpdater The dimensions updater instance.
     */
    public function __construct($pathUpdateFileCore = null, $pathUpdateFilePlugins = null, ?Columns\Updater $columnsUpdater = null)
    {
        $this->pathUpdateFileCore = $pathUpdateFileCore ?: PIWIK_INCLUDE_PATH . '/core/Updates/';

        if ($pathUpdateFilePlugins) {
            $this->pathUpdateFilePlugins = $pathUpdateFilePlugins;
        } else {
            $this->pathUpdateFilePlugins = null;
        }

        $this->columnsUpdater = $columnsUpdater ?: new Columns\Updater();

        self::$activeInstance = $this;
    }

    /**
     * Adds an UpdateObserver to the internal list of listeners.
     *
     * @param UpdateObserver $listener
     */
    public function addUpdateObserver(UpdateObserver $listener)
    {
        $this->updateObservers[] = $listener;
    }

    /**
     * Marks a component as successfully updated to a specific version in the database. Sets an option
     * that looks like `"version_$componentName"`.
     *
     * @param string $name The component name. Eg, a plugin name, `'core'` or dimension column name.
     * @param string $version The component version (should use semantic versioning).
     * @param bool   $isNew indicates if the component is a new one (for plugins)
     */
    public function markComponentSuccessfullyUpdated($name, $version, $isNew = false)
    {
        try {
            Option::set(self::getNameInOptionTable($name), $version, $autoLoad = 1);
        } catch (\Exception $e) {
            // case when the option table is not yet created (before 0.2.10)
        }

        if ($isNew) {

            /**
             * Event triggered after a new component has been installed.
             *
             * @param string $name The component that has been installed.
             */
            Piwik::postEvent('Updater.componentInstalled', array($name));

            return;
        }

        /**
         * Event triggered after a component has been updated.
         *
         * Can be used to handle logic that should be done after a component was updated
         *
         * **Example**
         *
         *     Piwik::addAction('Updater.componentUpdated', function ($componentName, $updatedVersion) {
         *          $mail = new Mail();
         *          $mail->setDefaultFromPiwik();
         *          $mail->addTo('test@example.org');
         *          $mail->setSubject('Component was updated);
         *          $message = sprintf(
         *              'Component %1$s has been updated to version %2$s',
         *              $componentName, $updatedVersion
         *          );
         *          $mail->setBodyText($message);
         *          $mail->send();
         *     });
         *
         * @param string $componentName 'core', plugin name or dimension name
         * @param string $updatedVersion version updated to
         */
        Piwik::postEvent('Updater.componentUpdated', array($name, $version));
    }

    /**
     * Marks a component as successfully uninstalled. Deletes an option
     * that looks like `"version_$componentName"`.
     *
     * @param string $name The component name. Eg, a plugin name, `'core'` or dimension column name.
     */
    public function markComponentSuccessfullyUninstalled($name)
    {
        try {
            Option::delete(self::getNameInOptionTable($name));
        } catch (\Exception $e) {
            // case when the option table is not yet created (before 0.2.10)
        }

        /**
         * Event triggered after a component has been uninstalled.
         *
         * @param string $name The component that has been uninstalled.
         */
        Piwik::postEvent('Updater.componentUninstalled', array($name));
    }

    /**
     * Returns the currently installed version of a Piwik component.
     *
     * @param string $name The component name. Eg, a plugin name, `'core'` or dimension column name.
     * @return string A semantic version.
     * @throws \Exception
     */
    public function getCurrentComponentVersion($name)
    {
        try {
            $currentVersion = Option::get(self::getNameInOptionTable($name));
        } catch (\Exception $e) {
            // mysql error 1146: table doesn't exist
            if (Db::get()->isErrNo($e, '1146')) {
                // case when the option table is not yet created (before 0.2.10)
                $currentVersion = false;
            } else {
                // failed for some other reason
                throw $e;
            }
        }

        return $currentVersion;
    }

    /**
     * Returns a list of components (core | plugin) that need to run through the upgrade process.
     *
     * @param string[] $componentsToCheck An array mapping component names to the latest locally available version.
     *                                    If the version is later than the currently installed version, the component
     *                                    must be upgraded.
     *
     *                                    Example: `array('core' => '2.11.0')`
     * @return array( componentName => array( file1 => version1, [...]), [...])
     */
    public function getComponentsWithUpdateFile($componentsToCheck)
    {
        $this->componentsWithNewVersion = $this->getComponentsWithNewVersion($componentsToCheck);
        $this->componentsWithUpdateFile = $this->loadComponentsWithUpdateFile();
        return $this->componentsWithUpdateFile;
    }

    /**
     * Component has a new version?
     *
     * @param string $componentName
     * @return bool TRUE if component is to be updated; FALSE if not
     */
    public function hasNewVersion($componentName)
    {
        return isset($this->componentsWithNewVersion[$componentName]);
    }

    /**
     * Does one of the new versions involve a major database update?
     * Note: getSqlQueriesToExecute() must be called before this method!
     *
     * @return bool
     */
    public function hasMajorDbUpdate()
    {
        return $this->hasMajorDbUpdate;
    }

    /**
     * Returns the list of SQL queries that would be executed during the update
     *
     * @return Migration[] of SQL queries
     * @throws \Exception
     */
    public function getSqlQueriesToExecute()
    {
        $queries    = [];
        $classNames = [];

        foreach ($this->componentsWithUpdateFile as $componentName => $componentUpdateInfo) {
            foreach ($componentUpdateInfo as $file => $fileVersion) {
                require_once $file; // prefixed by PIWIK_INCLUDE_PATH

                $className = $this->getUpdateClassName($componentName, $fileVersion);
                if (!class_exists($className, false)) {
                    // throwing an error here causes Matomo to show the safe mode instead of showing an exception fatal only
                    // that makes it possible to deactivate / uninstall a broken plugin to recover Matomo directly
                    throw new \Error("The class $className was not found in $file");
                }

                if (in_array($className, $classNames)) {
                    continue; // prevent from getting updates from Piwik\Columns\Updater multiple times
                }

                $classNames[] = $className;

                $migrationsForComponent = Access::doAsSuperUser(function () use ($className) {
                    /** @var Updates $update */
                    $update = StaticContainer::getContainer()->make($className);
                    return $update->getMigrations($this);
                });
                foreach ($migrationsForComponent as $index => $migration) {
                    $migration = $this->keepBcForOldMigrationQueryFormat($index, $migration);
                    $queries[] = $migration;
                }
                $this->hasMajorDbUpdate = $this->hasMajorDbUpdate || call_user_func([$className, 'isMajorUpdate']);
            }
        }
        return $queries;
    }

    public function getUpdateClassName($componentName, $fileVersion)
    {
        $suffix = strtolower(str_replace(array('-', '.'), '_', $fileVersion));
        $className = 'Updates_' . $suffix;

        if ($componentName == 'core') {
            return '\\Piwik\\Updates\\' . $className;
        }

        if (ColumnUpdater::isDimensionComponent($componentName)) {
            return '\\Piwik\\Columns\\Updater';
        }

        return '\\Piwik\\Plugins\\' . $componentName . '\\' . $className;
    }

    /**
     * Update the named component
     *
     * @param string $componentName 'core', or plugin name
     * @throws \Exception|UpdaterErrorException
     * @return array of warning strings if applicable
     */
    public function update($componentName)
    {
        $warningMessages = array();

        $this->executeListenerHook('onComponentUpdateStarting', array($componentName));

        foreach ($this->componentsWithUpdateFile[$componentName] as $file => $fileVersion) {
            try {
                require_once $file; // prefixed by PIWIK_INCLUDE_PATH

                $className = $this->getUpdateClassName($componentName, $fileVersion);
                if (
                    !in_array($className, $this->updatedClasses)
                    && class_exists($className, false)
                ) {
                    $this->executeListenerHook('onComponentUpdateFileStarting', array($componentName, $file, $className, $fileVersion));

                    $this->executeSingleUpdateClass($className);

                    $this->executeListenerHook('onComponentUpdateFileFinished', array($componentName, $file, $className, $fileVersion));

                    // makes sure to call Piwik\Columns\Updater only once as one call updates all dimensions at the same
                    // time for better performance
                    $this->updatedClasses[] = $className;
                }

                $this->markComponentSuccessfullyUpdated($componentName, $fileVersion);
            } catch (UpdaterErrorException $e) {
                $this->executeListenerHook('onError', array($componentName, $fileVersion, $e));
                throw $e;
            } catch (\Exception $e) {
                $warningMessages[] = $e->getMessage();

                $this->executeListenerHook('onWarning', array($componentName, $fileVersion, $e));
            }
        }

        // to debug, create core/Updates/X.php, update the core/Version.php, throw an Exception in the try, and comment the following lines
        $updatedVersion = $this->componentsWithNewVersion[$componentName][self::INDEX_NEW_VERSION];
        $this->markComponentSuccessfullyUpdated($componentName, $updatedVersion);

        $this->executeListenerHook('onComponentUpdateFinished', array($componentName, $updatedVersion, $warningMessages));
        ServerFilesGenerator::createFilesForSecurity();
        return $warningMessages;
    }

    /**
     * Construct list of update files for the outdated components
     *
     * @return array( componentName => array( file1 => version1, [...]), [...])
     */
    private function loadComponentsWithUpdateFile()
    {
        $componentsWithUpdateFile = array();

        foreach ($this->componentsWithNewVersion as $name => $versions) {
            $currentVersion = $versions[self::INDEX_CURRENT_VERSION];
            $newVersion = $versions[self::INDEX_NEW_VERSION];

            if ($name == 'core') {
                $pathToUpdates = $this->pathUpdateFileCore . '*.php';
            } elseif (ColumnUpdater::isDimensionComponent($name)) {
                $componentsWithUpdateFile[$name][PIWIK_INCLUDE_PATH . '/core/Columns/Updater.php'] = $newVersion;
            } else {
                if ($this->pathUpdateFilePlugins) {
                    $pathToUpdates = sprintf($this->pathUpdateFilePlugins, $name) . '*.php';
                } else {
                    $pathToUpdates = Manager::getPluginDirectory($name) . '/Updates/*.php';
                }
            }

            if (!empty($pathToUpdates)) {
                $files = _glob($pathToUpdates);
                if ($files == false) {
                    $files = array();
                }

                foreach ($files as $file) {
                    $fileVersion = basename($file, '.php');
                    if (
// if the update is from a newer version
                        version_compare($currentVersion, $fileVersion) == -1
                        // but we don't execute updates from non existing future releases
                        && version_compare($fileVersion, $newVersion) <= 0
                    ) {
                        $componentsWithUpdateFile[$name][$file] = $fileVersion;
                    }
                }
            }

            if (isset($componentsWithUpdateFile[$name])) {
                // order the update files by version asc
                uasort($componentsWithUpdateFile[$name], "version_compare");
            } else {
                // there are no update file => nothing to do, update to the new version is successful
                $this->markComponentSuccessfullyUpdated($name, $newVersion);
            }
        }

        return $componentsWithUpdateFile;
    }

    /**
     * Construct list of outdated components
     *
     * @param string[] $componentsToCheck An array mapping component names to the latest locally available version.
     *                                    If the version is later than the currently installed version, the component
     *                                    must be upgraded.
     *
     *                                    Example: `array('core' => '2.11.0')`
     * @throws \Exception
     * @return array array( componentName => array( oldVersion, newVersion), [...])
     */
    public function getComponentsWithNewVersion($componentsToCheck)
    {
        $componentsToUpdate = array();

        // we make sure core updates are processed before any plugin updates
        if (isset($componentsToCheck['core'])) {
            $coreVersions = $componentsToCheck['core'];
            unset($componentsToCheck['core']);
            $componentsToCheck = array_merge(array('core' => $coreVersions), $componentsToCheck);
        }

        $recordedCoreVersion = $this->getCurrentComponentVersion('core');
        if (empty($recordedCoreVersion)) {
            // This should not happen
            $recordedCoreVersion = Version::VERSION;
            $this->markComponentSuccessfullyUpdated('core', $recordedCoreVersion);
        }

        foreach ($componentsToCheck as $name => $version) {
            $currentVersion = $this->getCurrentComponentVersion($name);

            if (ColumnUpdater::isDimensionComponent($name)) {
                $isComponentOutdated = $currentVersion !== $version;
            } else {
                // note: when versionCompare == 1, the version in the DB is newer, we choose to ignore
                $isComponentOutdated = version_compare($currentVersion, $version) == -1;
            }

            if ($isComponentOutdated || $currentVersion === false) {
                $componentsToUpdate[$name] = array(
                    self::INDEX_CURRENT_VERSION => $currentVersion,
                    self::INDEX_NEW_VERSION     => $version
                );
            }
        }

        return $componentsToUpdate;
    }

    /**
     * Updates multiple components, while capturing & returning errors and warnings.
     *
     * @param string[] $componentsWithUpdateFile Component names mapped with arrays of update files. Same structure
     *                                           as the result of `getComponentsWithUpdateFile()`.
     * @return array Information about the update process, including:
     *
     *               * **warnings**: The list of warnings that occurred during the update process.
     *               * **errors**: The list of updater exceptions thrown during individual component updates.
     *               * **coreError**: True if an exception was thrown while updating core.
     *               * **deactivatedPlugins**: The list of plugins that were deactivated due to an error in the
     *                                         update process.
     */
    public function updateComponents($componentsWithUpdateFile)
    {
        $warnings = array();
        $errors   = array();
        $deactivatedPlugins = array();
        $coreError = false;

        try {
            $history = Option::get(self::OPTION_KEY_MATOMO_UPDATE_HISTORY);
            $history = explode(',', (string) $history);
            $previousVersion = Option::get(self::getNameInOptionTable('core'));

            if (!empty($previousVersion) && !in_array($previousVersion, $history, true)) {
                // this allows us to see which versions of matomo the user was using before this update so we better understand
                // which version maybe regressed something
                array_unshift($history, $previousVersion);
                $history = array_slice($history, 0, 6); // lets keep only the last 6 versions
                Option::set(self::OPTION_KEY_MATOMO_UPDATE_HISTORY, implode(',', $history));
            }
        } catch (\Exception $e) {
            // case when the option table is not yet created (before 0.2.10)
        }

        if (!empty($componentsWithUpdateFile)) {
            Access::doAsSuperUser(function () use ($componentsWithUpdateFile, &$coreError, &$deactivatedPlugins, &$errors, &$warnings) {

                $pluginManager = \Piwik\Plugin\Manager::getInstance();

                // if error in any core update, show message + help message + EXIT
                // if errors in any plugins updates, show them on screen, disable plugins that errored + CONTINUE
                // if warning in any core update or in any plugins update, show message + CONTINUE
                // if no error or warning, success message + CONTINUE
                foreach ($componentsWithUpdateFile as $name => $filenames) {
                    try {
                        $warnings = array_merge($warnings, $this->update($name));
                    } catch (UpdaterErrorException $e) {
                        $errors[] = $e->getMessage();
                        if ($name == 'core') {
                            $coreError = true;
                            break;
                        } elseif ($pluginManager->isPluginActivated($name) && $pluginManager->isPluginBundledWithCore($name)) {
                            $coreError = true;
                            break;
                        } elseif ($pluginManager->isPluginActivated($name)) {
                            $pluginManager->deactivatePlugin($name);
                            $deactivatedPlugins[] = $name;
                        }
                    }
                }
            });
        }

        Filesystem::deleteAllCacheOnUpdate();
        ServerFilesGenerator::createFilesForSecurity();

        $result = array(
            'warnings'  => $warnings,
            'errors'    => $errors,
            'coreError' => $coreError,
            'deactivatedPlugins' => $deactivatedPlugins
        );

        /**
         * Triggered after Piwik has been updated.
         */
        Piwik::postEvent('CoreUpdater.update.end');

        return $result;
    }

    /**
     * Returns any updates that should occur for core and all plugins that are both loaded and
     * installed. Also includes updates required for dimensions.
     *
     * @return string[]|null Returns the result of `getComponentsWithUpdateFile()`.
     */
    public function getComponentUpdates()
    {
        $componentsToCheck = array(
            'core' => Version::VERSION
        );

        $manager = \Piwik\Plugin\Manager::getInstance();
        $plugins = $manager->getLoadedPlugins();
        foreach ($plugins as $pluginName => $plugin) {
            if ($manager->isPluginInstalled($pluginName)) {
                $componentsToCheck[$pluginName] = $plugin->getVersion();
            }
        }

        $columnsVersions = $this->columnsUpdater->getAllVersions($this);
        foreach ($columnsVersions as $component => $version) {
            $componentsToCheck[$component] = $version;
        }

        $componentsWithUpdateFile = $this->getComponentsWithUpdateFile($componentsToCheck);

        if (count($componentsWithUpdateFile) == 0) {
            $this->columnsUpdater->onNoUpdateAvailable($columnsVersions);

            return null;
        }

        return $componentsWithUpdateFile;
    }

    /**
     * Execute multiple migration queries from a single Update file.
     *
     * @param string $file The path to the Updates file.
     * @param Migration[] $migrations An array of migrations
     * @api
     */
    public function executeMigrations($file, $migrations)
    {
        foreach ($migrations as $index => $migration) {
            $migration = $this->keepBcForOldMigrationQueryFormat($index, $migration);
            $this->executeMigration($file, $migration);
        }
    }

    /**
     * @param $file
     * @param Migration $migration
     * @throws UpdaterErrorException
     * @api
     */
    public function executeMigration($file, Migration $migration)
    {
        try {
            $this->executeListenerHook('onStartExecutingMigration', array($file, $migration));

            $migration->exec();
        } catch (\Exception $e) {
            if (!$migration->shouldIgnoreError($e)) {
                $message = sprintf(
                    "%s:\nError trying to execute the migration '%s'.\nThe error was: %s",
                    $file,
                    $migration->__toString(),
                    $e->getMessage()
                );
                throw new UpdaterErrorException($message);
            }
        }

        $this->executeListenerHook('onFinishedExecutingMigration', array($file, $migration));
    }

    private function executeListenerHook($hookName, $arguments)
    {
        foreach ($this->updateObservers as $listener) {
            call_user_func_array(array($listener, $hookName), $arguments);
        }
    }

    private function executeSingleUpdateClass($className)
    {
        $update = StaticContainer::getContainer()->make($className);
        try {
            call_user_func(array($update, 'doUpdate'), $this);
        } catch (\Exception $e) {
            // if an Update file executes PHP statements directly, DB exceptions be handled by executeSingleMigrationQuery, so
            // make sure to check for them here
            if ($e instanceof Zend_Db_Exception) {
                throw new UpdaterErrorException($e->getMessage(), $e->getCode(), $e);
            } elseif ($e instanceof MissingFilePermissionException) {
                throw new UpdaterErrorException($e->getMessage(), $e->getCode(), $e);
            }{
                throw $e;
            }
        }
    }

    private function keepBcForOldMigrationQueryFormat($index, $migration)
    {
        if (!is_object($migration)) {
            // keep BC for old format (pre 3.0): array($sqlQuery => $errorCodeToIgnore)
            $migrationFactory = StaticContainer::get('Piwik\Updater\Migration\Factory');
            $migration = $migrationFactory->db->sql($index, $migration);
        }

        return $migration;
    }

    /**
     * Record version of successfully completed component update
     *
     * @param string $name
     * @param string $version
     */
    public static function recordComponentSuccessfullyUpdated($name, $version)
    {
        self::$activeInstance->markComponentSuccessfullyUpdated($name, $version);
    }

    /**
     * Returns the flag name to use in the option table to record current schema version
     * @param string $name
     * @return string
     */
    private static function getNameInOptionTable($name)
    {
        return 'version_' . $name;
    }
}
