<?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.

namespace Shopware\Plugins\ViisonCommon\Components\Migration;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\EntityManager;
use Exception;
use Psr\Log\LoggerInterface;
use Shopware\Plugins\ViisonCommon\Classes\Installation\ExpiringLock;
use Shopware\Plugins\ViisonCommon\Classes\Installation\ExpiringLockStatus;
use Shopware\Plugins\ViisonCommon\Classes\Util\Util;

/**
 * Service that detects and applies pending migrations system wide.
 */
class MigrationService
{
    const EVENT_NAME_COLLECT_MIGRATIONS = 'Shopware_Plugins_ViisonCommon_CollectMigrations';

    const LOCK_ID = 'PickwareMigrationExecution';

    const LOCK_EXPIRATION_IN_SECONDS = 300;

    /**
     * @var \Enlight_Event_EventManager
     */
    private $eventManager;

    /**
     * @var \Zend_Db_Adapter_Abstract
     */
    private $db;

    /**
     * @var EntityManager
     */
    private $entityManager;

    /**
     * @var LoggerInterface
     */
    private $logger;

    /**
     * @param \Enlight_Event_EventManager $eventManager
     * @param \Zend_Db_Adapter_Abstract $db
     * @param EntityManager $entityManager
     * @param LoggerInterface $logger
     */
    public function __construct(
        \Enlight_Event_EventManager $eventManager,
        \Zend_Db_Adapter_Abstract $db,
        $entityManager,
        LoggerInterface $logger
    ) {
        $this->eventManager = $eventManager;
        $this->db = $db;
        $this->entityManager = $entityManager;
        $this->logger = $logger;
    }

    /**
     * Collect all available migrations system wide and executes every pending migration.
     * This is the process explained step by step:
     *
     *  1. Collects all available migration sets via a collect event.
     *  2. Syncs the status of every found migration with the one in the migration database table.
     *  3. Filters out every migration set that does not has a pending migration.
     *  4. Filters out every migration with a 'successful' status from each migration set.
     *  5. Foreach migration set, applies every migration one after another until one fails with status 'error'. After
     *     every migration immediately updates its status to the database.
     *
     * The process is protected by a global lock to avoid simultaneous executions of migrations.
     *
     * @return MigrationExecutionResult
     * @throws MigrationInProgressException
     */
    public function executeMigrations()
    {
        $setupLock = new ExpiringLock(
            self::LOCK_ID,
            'MigrationService',
            self::LOCK_EXPIRATION_IN_SECONDS,
            $this->entityManager
        );
        try {
            $lockStatus = $setupLock->tryAcquireLock();
            if (!$lockStatus->hasBeenAcquired()) {
                throw new MigrationInProgressException();
            }

            return $this->executeMigrationsInternal();
        } finally {
            $setupLock->releaseOnlyIfAlreadyHeld();
        }
    }

    /**
     * "Unlocked" worker method for {@see self::executeMigrations()}
     *
     * @return MigrationExecutionResult
     */
    private function executeMigrationsInternal()
    {
        $maxExecutionTimeInSeconds = 300;
        set_time_limit($maxExecutionTimeInSeconds);

        $transcript = new MigrationTranscript($this->logger);
        $migrationSets = $this->getSetsWithExecutableMigrations();

        if (count($migrationSets) > 0) {
            $transcript->invalidateDefaultCaches();
        }

        foreach ($migrationSets as $migrationSet) {
            $setName = $migrationSet->getName();
            foreach ($migrationSet->getExecutableMigrations() as $migration) {
                $migration->execute($transcript);

                $this->saveMigrationStatus($setName, $migration);

                if ($migration->getStatus() === ManifestedMigration::STATUS_FAILED) {
                    $transcript->getLogger()->error(sprintf(
                        'Migration "%s" of MigrationSet "%s" failed with error message: %s',
                        $migration->getName(),
                        $setName,
                        $migration->getException()->getMessage()
                    ), [
                        'migrationName' => $migration->getName(),
                        'migrationSetName' => $setName,
                        'exception' => Util::exceptionToArray($migration->getException()),
                    ]);

                    break; // Go on with next migration set
                }
            }
        }

        return new MigrationExecutionResult($transcript, $migrationSets);
    }

    /**
     * Finds all MigrationsSets (system wide) that still have executable migrations
     *
     * Executable means its Status is not 'completed' and canExecute() returns true.
     *
     * @return MigrationSet[]
     */
    public function getSetsWithExecutableMigrations()
    {
        $migrationSets = $this->collectMigrationSets();
        $this->syncCurrentMigrationStatus($migrationSets);

        return array_values(array_filter($migrationSets, function (MigrationSet $migrationSet) {
            return $migrationSet->hasExecutableMigrations();
        }));
    }

    /**
     * Collects all available migration sets via a collect event.
     *
     * @return MigrationSet[]
     */
    private function collectMigrationSets()
    {
        $migrationSets = new ArrayCollection();
        $this->eventManager->collect(self::EVENT_NAME_COLLECT_MIGRATIONS, $migrationSets);

        $migrationSets = $migrationSets->toArray();

        $migrationSetNames = [];
        foreach ($migrationSets as $migrationSet) {
            if (!($migrationSet instanceof MigrationSet)) {
                throw new \RuntimeException(
                    sprintf(
                        'Event "%s" returned an element that is not an instance of %s.',
                        self::EVENT_NAME_COLLECT_MIGRATIONS,
                        'Shopware\\Plugins\\ViisonCommon\\Components\\Migration\\MigrationSet'
                    )
                );
            }

            if (in_array(mb_strtolower($migrationSet->getName()), $migrationSetNames, true)) {
                throw new \RuntimeException(sprintf(
                    'Event "%s" returned at least two MigrationSets that have the same name "%s".',
                    self::EVENT_NAME_COLLECT_MIGRATIONS,
                    $migrationSet->getName()
                ));
            }

            $migrationSetNames[] = mb_strtolower($migrationSet->getName());
        }

        return $migrationSets;
    }

    /**
     * Syncs the status of every MigrationSet in $migrationSets with the one in the migration database table.
     *
     * @param MigrationSet[] $migrationSets
     */
    private function syncCurrentMigrationStatus(array $migrationSets)
    {
        $this->createMigrationTableIfNotExists();

        $migrationsInDatabase = $this->db->fetchAll(
            'SELECT `name`, `migrationSetName`, `status` FROM `pickware_common_migrations`'
        );

        $manifestedMigrationStatuses = [];
        foreach ($migrationsInDatabase as $row) {
            $migrationSetName = $row['migrationSetName'];
            if (!isset($manifestedMigrationStatuses[$migrationSetName])) {
                $manifestedMigrationStatuses[$migrationSetName] = [];
            }
            $manifestedMigrationStatuses[$migrationSetName][$row['name']] = $row['status'];
        }

        foreach ($migrationSets as $migrationSet) {
            $migrationSetName = $migrationSet->getName();
            foreach ($migrationSet->getMigrations() as $migration) {
                $isKnownMigration = array_key_exists($migrationSetName, $manifestedMigrationStatuses)
                    && array_key_exists($migration->getName(), $manifestedMigrationStatuses[$migrationSetName]);
                if (!$isKnownMigration || !$migration->canExecute()) {
                    $migration->setStatus(ManifestedMigration::STATUS_PENDING);
                    $this->saveMigrationStatus($migrationSet->getName(), $migration);
                } else {
                    // Refresh the status of the ManifestedMigration with that one from the DB
                    $migration->setStatus($manifestedMigrationStatuses[$migrationSetName][$migration->getName()]);
                }
            }
        }
    }

    /**
     * Saves the status of a Migration to the database.
     *
     * @param string $migrationSetName
     * @param ManifestedMigration $migration
     */
    private function saveMigrationStatus($migrationSetName, ManifestedMigration $migration)
    {
        $e = $migration->getException();

        $this->db->query(
            'INSERT INTO `pickware_common_migrations` (
                `migrationSetName`,
                `name`,
                `firstAppearance`,
                `status`,
                `lastRun`,
                `exception`
            ) VALUES (
                :migrationSetName,
                :name,
                NOW(),
                :status,
                NOW(),
                :exception
            ) ON DUPLICATE KEY UPDATE 
                `lastRun` = NOW(),
                `status` = VALUES (`status`),
                `exception` = :exception',
            [
                'migrationSetName' => $migrationSetName,
                'name' => $migration->getName(),
                'status' => $migration->getStatus(),
                'exception' => ($e !== null) ? json_encode(Util::exceptionToArray($e)) : null,
            ]
        );
    }

    /**
     * Creates the database table that holds the migration status.
     */
    private function createMigrationTableIfNotExists()
    {
        $this->db->query(
            'CREATE TABLE IF NOT EXISTS `pickware_common_migrations` (
                `id` INT(11) UNSIGNED NOT NULL AUTO_INCREMENT,
                `migrationSetName` VARCHAR(255) COLLATE utf8_unicode_ci NOT NULL,
                `name` VARCHAR(255) COLLATE utf8_unicode_ci NOT NULL,
                `firstAppearance` DATETIME NOT NULL,
                `status` VARCHAR(255) COLLATE utf8_unicode_ci NOT NULL DEFAULT :defaultStatus,
                `lastRun` DATETIME NULL,
                `exception` TEXT COLLATE utf8_unicode_ci NULL,
                PRIMARY KEY (`id`),
                UNIQUE INDEX migrationSetName_name (`migrationSetName`, `name`)
            ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci',
            [
                'defaultStatus' => ManifestedMigration::STATUS_PENDING,
            ]
        );
    }

    /**
     * Removes manifested status of a migration set from the database.
     *
     * Call this function if your system state has been reset so you need to re-execute all migrations. This is
     * necessary for example after a full plugin uninstall.
     *
     * @param string $migrationSetName
     */
    public function forgetStatusForMigrationSet($migrationSetName)
    {
        $this->createMigrationTableIfNotExists();

        $this->db->query(
            'DELETE FROM `pickware_common_migrations` WHERE `migrationSetName` = :migrationSetName',
            [
                'migrationSetName' => $migrationSetName,
            ]
        );
    }
}
