<?php
namespace TYPO3\CMS\Extensionmanager\Utility;

/*
 * This file is part of the TYPO3 CMS project.
 *
 * It is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License, either version 2
 * of the License, or any later version.
 *
 * For the full copyright and license information, please read the
 * LICENSE.txt file that was distributed with this source code.
 *
 * The TYPO3 project - inspiring people to share!
 */

use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use TYPO3\CMS\Core\Utility\VersionNumberUtility;
use TYPO3\CMS\Extensionmanager\Domain\Model\Dependency;
use TYPO3\CMS\Extensionmanager\Domain\Model\Extension;
use TYPO3\CMS\Extensionmanager\Exception;

/**
 * Utility for dealing with dependencies
 */
class DependencyUtility implements \TYPO3\CMS\Core\SingletonInterface
{
    /**
     * @var \TYPO3\CMS\Extbase\Object\ObjectManager
     */
    protected $objectManager;

    /**
     * @var \TYPO3\CMS\Extensionmanager\Domain\Repository\ExtensionRepository
     */
    protected $extensionRepository;

    /**
     * @var \TYPO3\CMS\Extensionmanager\Utility\ListUtility
     */
    protected $listUtility;

    /**
     * @var \TYPO3\CMS\Extensionmanager\Utility\EmConfUtility
     */
    protected $emConfUtility;

    /**
     * @var \TYPO3\CMS\Extensionmanager\Service\ExtensionManagementService
     */
    protected $managementService;

    /**
     * @var array
     */
    protected $availableExtensions = [];

    /**
     * @var string
     */
    protected $localExtensionStorage = '';

    /**
     * @var array
     */
    protected $dependencyErrors = [];

    /**
     * @var bool
     */
    protected $skipDependencyCheck = false;

    /**
     * @param \TYPO3\CMS\Extbase\Object\ObjectManager $objectManager
     */
    public function injectObjectManager(\TYPO3\CMS\Extbase\Object\ObjectManager $objectManager)
    {
        $this->objectManager = $objectManager;
    }

    /**
     * @param \TYPO3\CMS\Extensionmanager\Domain\Repository\ExtensionRepository $extensionRepository
     */
    public function injectExtensionRepository(\TYPO3\CMS\Extensionmanager\Domain\Repository\ExtensionRepository $extensionRepository)
    {
        $this->extensionRepository = $extensionRepository;
    }

    /**
     * @param \TYPO3\CMS\Extensionmanager\Utility\ListUtility $listUtility
     */
    public function injectListUtility(\TYPO3\CMS\Extensionmanager\Utility\ListUtility $listUtility)
    {
        $this->listUtility = $listUtility;
    }

    /**
     * @param \TYPO3\CMS\Extensionmanager\Utility\EmConfUtility $emConfUtility
     */
    public function injectEmConfUtility(\TYPO3\CMS\Extensionmanager\Utility\EmConfUtility $emConfUtility)
    {
        $this->emConfUtility = $emConfUtility;
    }

    /**
     * @param \TYPO3\CMS\Extensionmanager\Service\ExtensionManagementService $managementService
     */
    public function injectManagementService(\TYPO3\CMS\Extensionmanager\Service\ExtensionManagementService $managementService)
    {
        $this->managementService = $managementService;
    }

    /**
     * @param string $localExtensionStorage
     */
    public function setLocalExtensionStorage($localExtensionStorage)
    {
        $this->localExtensionStorage = $localExtensionStorage;
    }

    /**
     * Setter for available extensions
     * gets available extensions from list utility if not already done
     */
    protected function setAvailableExtensions()
    {
        $this->availableExtensions = $this->listUtility->getAvailableExtensions();
    }

    /**
     * @param bool $skipDependencyCheck
     */
    public function setSkipDependencyCheck($skipDependencyCheck)
    {
        $this->skipDependencyCheck = $skipDependencyCheck;
    }

    /**
     * Checks dependencies for special cases (currently typo3 and php)
     *
     * @param Extension $extension
     */
    public function checkDependencies(Extension $extension)
    {
        $this->dependencyErrors = [];
        $dependencies = $extension->getDependencies();
        foreach ($dependencies as $dependency) {
            /** @var Dependency $dependency */
            $identifier = strtolower($dependency->getIdentifier());
            try {
                if (in_array($identifier, Dependency::$specialDependencies)) {
                    if (!$this->skipDependencyCheck) {
                        $methodName = 'check' . ucfirst($identifier) . 'Dependency';
                        $this->{$methodName}($dependency);
                    }
                } else {
                    if ($dependency->getType() === 'depends') {
                        $this->checkExtensionDependency($dependency);
                    }
                }
            } catch (Exception\UnresolvedDependencyException $e) {
                if (in_array($identifier, Dependency::$specialDependencies)) {
                    $extensionKey = $extension->getExtensionKey();
                } else {
                    $extensionKey = $identifier;
                }
                if (!isset($this->dependencyErrors[$extensionKey])) {
                    $this->dependencyErrors[$extensionKey] = [];
                }
                $this->dependencyErrors[$extensionKey][] = [
                    'code' => $e->getCode(),
                    'message' => $e->getMessage()
                ];
            }
        }
    }

    /**
     * Returns TRUE if a dependency error was found
     *
     * @return bool
     */
    public function hasDependencyErrors()
    {
        return !empty($this->dependencyErrors);
    }

    /**
     * Return the dependency errors
     *
     * @return array
     */
    public function getDependencyErrors()
    {
        return $this->dependencyErrors;
    }

    /**
     * Returns true if current TYPO3 version fulfills extension requirements
     *
     * @param Dependency $dependency
     * @throws Exception\UnresolvedTypo3DependencyException
     * @return bool
     */
    protected function checkTypo3Dependency(Dependency $dependency)
    {
        $lowerCaseIdentifier = strtolower($dependency->getIdentifier());
        if ($lowerCaseIdentifier === 'typo3') {
            if (!($dependency->getLowestVersion() === '') && version_compare(VersionNumberUtility::getNumericTypo3Version(), $dependency->getLowestVersion()) === -1) {
                throw new Exception\UnresolvedTypo3DependencyException(
                    'Your TYPO3 version is lower than this extension requires. It requires TYPO3 versions ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion(),
                    1399144499
                );
            }
            if (!($dependency->getHighestVersion() === '') && version_compare($dependency->getHighestVersion(), VersionNumberUtility::getNumericTypo3Version()) === -1) {
                throw new Exception\UnresolvedTypo3DependencyException(
                    'Your TYPO3 version is higher than this extension requires. It requires TYPO3 versions ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion(),
                    1399144521
                );
            }
        } else {
            throw new Exception\UnresolvedTypo3DependencyException(
                'checkTypo3Dependency can only check TYPO3 dependencies. Found dependency with identifier "' . $dependency->getIdentifier() . '"',
                1399144551
            );
        }
        return true;
    }

    /**
     * Returns true if current php version fulfills extension requirements
     *
     * @param Dependency $dependency
     * @throws Exception\UnresolvedPhpDependencyException
     * @return bool
     */
    protected function checkPhpDependency(Dependency $dependency)
    {
        $lowerCaseIdentifier = strtolower($dependency->getIdentifier());
        if ($lowerCaseIdentifier === 'php') {
            if (!($dependency->getLowestVersion() === '') && version_compare(PHP_VERSION, $dependency->getLowestVersion()) === -1) {
                throw new Exception\UnresolvedPhpDependencyException(
                    'Your PHP version is lower than necessary. You need at least PHP version ' . $dependency->getLowestVersion(),
                    1377977857
                );
            }
            if (!($dependency->getHighestVersion() === '') && version_compare($dependency->getHighestVersion(), PHP_VERSION) === -1) {
                throw new Exception\UnresolvedPhpDependencyException(
                    'Your PHP version is higher than allowed. You can use PHP versions ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion(),
                    1377977856
                );
            }
        } else {
            throw new Exception\UnresolvedPhpDependencyException(
                'checkPhpDependency can only check PHP dependencies. Found dependency with identifier "' . $dependency->getIdentifier() . '"',
                1377977858
            );
        }
        return true;
    }

    /**
     * Main controlling function for checking dependencies
     * Dependency check is done in the following way:
     * - installed extension in matching version ? - return true
     * - available extension in matching version ? - mark for installation
     * - remote (TER) extension in matching version? - mark for download
     *
     * @todo handle exceptions / markForUpload
     * @param Dependency $dependency
     * @throws Exception\MissingVersionDependencyException
     * @return bool
     */
    protected function checkExtensionDependency(Dependency $dependency)
    {
        $extensionKey = $dependency->getIdentifier();
        $extensionIsLoaded = $this->isDependentExtensionLoaded($extensionKey);
        if ($extensionIsLoaded === true) {
            $isLoadedVersionCompatible = $this->isLoadedVersionCompatible($dependency);
            if ($isLoadedVersionCompatible === true || $this->skipDependencyCheck) {
                return true;
            }
            $extension = $this->listUtility->getExtension($extensionKey);
            $loadedVersion = $extension->getPackageMetaData()->getVersion();
            if (version_compare($loadedVersion, $dependency->getHighestVersion()) === -1) {
                try {
                    $this->getExtensionFromRepository($extensionKey, $dependency);
                } catch (Exception\UnresolvedDependencyException $e) {
                    throw new Exception\MissingVersionDependencyException(
                        'The extension ' . $extensionKey . ' is installed in version ' . $loadedVersion
                            . ' but needed in version ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion() . ' and could not be fetched from TER',
                        1396302624
                    );
                }
            } else {
                throw new Exception\MissingVersionDependencyException(
                    'The extension ' . $extensionKey . ' is installed in version ' . $loadedVersion .
                    ' but needed in version ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion(),
                    1430561927
                );
            }
        } else {
            $extensionIsAvailable = $this->isDependentExtensionAvailable($extensionKey);
            if ($extensionIsAvailable === true) {
                $isAvailableVersionCompatible = $this->isAvailableVersionCompatible($dependency);
                if ($isAvailableVersionCompatible) {
                    $unresolvedDependencyErrors = $this->dependencyErrors;
                    $this->managementService->markExtensionForInstallation($extensionKey);
                    $this->dependencyErrors = array_merge($unresolvedDependencyErrors, $this->dependencyErrors);
                } else {
                    $extension = $this->listUtility->getExtension($extensionKey);
                    $availableVersion = $extension->getPackageMetaData()->getVersion();
                    if (version_compare($availableVersion, $dependency->getHighestVersion()) === -1) {
                        try {
                            $this->getExtensionFromRepository($extensionKey, $dependency);
                        } catch (Exception\MissingExtensionDependencyException $e) {
                            if (!$this->skipDependencyCheck) {
                                throw new Exception\MissingVersionDependencyException(
                                    'The extension ' . $extensionKey . ' is available in version ' . $availableVersion
                                    . ' but is needed in version ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion() . ' and could not be fetched from TER',
                                    1430560390
                                );
                            }
                        }
                    } else {
                        if (!$this->skipDependencyCheck) {
                            throw new Exception\MissingVersionDependencyException(
                                'The extension ' . $extensionKey . ' is available in version ' . $availableVersion
                                . ' but is needed in version ' . $dependency->getLowestVersion() . ' - ' . $dependency->getHighestVersion(),
                                1430562374
                            );
                        }
                        // Dependency check is skipped and the local version has to be installed
                        $this->managementService->markExtensionForInstallation($extensionKey);
                    }
                }
            } else {
                $unresolvedDependencyErrors = $this->dependencyErrors;
                $this->getExtensionFromRepository($extensionKey, $dependency);
                $this->dependencyErrors = array_merge($unresolvedDependencyErrors, $this->dependencyErrors);
            }
        }

        return false;
    }

    /**
     * Get an extension from a repository
     * (might be in the extension itself or the TER)
     *
     * @param string $extensionKey
     * @param Dependency $dependency
     * @throws Exception\UnresolvedDependencyException
     */
    protected function getExtensionFromRepository($extensionKey, Dependency $dependency)
    {
        if (!$this->getExtensionFromInExtensionRepository($extensionKey)) {
            $this->getExtensionFromTer($extensionKey, $dependency);
        }
    }

    /**
     * Gets an extension from the in extension repository
     * (the local extension storage)
     *
     * @param string $extensionKey
     * @return bool
     */
    protected function getExtensionFromInExtensionRepository($extensionKey)
    {
        if ($this->localExtensionStorage !== '' && is_dir($this->localExtensionStorage)) {
            $extList = \TYPO3\CMS\Core\Utility\GeneralUtility::get_dirs($this->localExtensionStorage);
            if (in_array($extensionKey, $extList)) {
                $this->managementService->markExtensionForCopy($extensionKey, $this->localExtensionStorage);
                return true;
            }
        }
        return false;
    }

    /**
     * Handles checks to find a compatible extension version from TER to fulfill given dependency
     *
     * @todo unit tests
     * @param string $extensionKey
     * @param Dependency $dependency
     * @throws Exception\UnresolvedDependencyException
     */
    protected function getExtensionFromTer($extensionKey, Dependency $dependency)
    {
        $isExtensionDownloadableFromTer = $this->isExtensionDownloadableFromTer($extensionKey);
        if (!$isExtensionDownloadableFromTer) {
            if (!$this->skipDependencyCheck) {
                if ($this->extensionRepository->countAll() > 0) {
                    throw new Exception\MissingExtensionDependencyException(
                        'The extension ' . $extensionKey . ' is not available from TER.',
                        1399161266
                    );
                }
                throw new Exception\MissingExtensionDependencyException(
                    'The extension ' . $extensionKey . ' could not be checked. Please update your Extension-List from TYPO3 Extension Repository (TER).',
                    1430580308
                );
            }
            return;
        }

        $isDownloadableVersionCompatible = $this->isDownloadableVersionCompatible($dependency);
        if (!$isDownloadableVersionCompatible) {
            if (!$this->skipDependencyCheck) {
                throw new Exception\MissingVersionDependencyException(
                    'No compatible version found for extension ' . $extensionKey,
                    1399161284
                );
            }
            return;
        }

        $latestCompatibleExtensionByIntegerVersionDependency = $this->getLatestCompatibleExtensionByIntegerVersionDependency($dependency);
        if (!$latestCompatibleExtensionByIntegerVersionDependency instanceof Extension) {
            if (!$this->skipDependencyCheck) {
                throw new Exception\MissingExtensionDependencyException(
                    'Could not resolve dependency for "' . $dependency->getIdentifier() . '"',
                    1399161302
                );
            }
            return;
        }

        if ($this->isDependentExtensionLoaded($extensionKey)) {
            $this->managementService->markExtensionForUpdate($latestCompatibleExtensionByIntegerVersionDependency);
        } else {
            $this->managementService->markExtensionForDownload($latestCompatibleExtensionByIntegerVersionDependency);
        }
    }

    /**
     * @param string $extensionKey
     * @return bool
     */
    protected function isDependentExtensionLoaded($extensionKey)
    {
        return ExtensionManagementUtility::isLoaded($extensionKey);
    }

    /**
     * @param Dependency $dependency
     * @return bool
     */
    protected function isLoadedVersionCompatible(Dependency $dependency)
    {
        $extensionVersion = ExtensionManagementUtility::getExtensionVersion($dependency->getIdentifier());
        return $this->isVersionCompatible($extensionVersion, $dependency);
    }

    /**
     * @param string $version
     * @param Dependency $dependency
     * @return bool
     */
    protected function isVersionCompatible($version, Dependency $dependency)
    {
        if (!($dependency->getLowestVersion() === '') && version_compare($version, $dependency->getLowestVersion()) === -1) {
            return false;
        }
        if (!($dependency->getHighestVersion() === '') && version_compare($dependency->getHighestVersion(), $version) === -1) {
            return false;
        }
        return true;
    }

    /**
     * Checks whether the needed extension is available
     * (not necessarily installed, but present in system)
     *
     * @param string $extensionKey
     * @return bool
     */
    protected function isDependentExtensionAvailable($extensionKey)
    {
        $this->setAvailableExtensions();
        return array_key_exists($extensionKey, $this->availableExtensions);
    }

    /**
     * Checks whether the available version is compatible
     *
     * @param Dependency $dependency
     * @return bool
     */
    protected function isAvailableVersionCompatible(Dependency $dependency)
    {
        $this->setAvailableExtensions();
        $extensionData = $this->emConfUtility->includeEmConf($this->availableExtensions[$dependency->getIdentifier()]);
        return $this->isVersionCompatible($extensionData['version'], $dependency);
    }

    /**
     * Checks whether a ter extension with $extensionKey exists
     *
     * @param string $extensionKey
     * @return bool
     */
    protected function isExtensionDownloadableFromTer($extensionKey)
    {
        return $this->extensionRepository->countByExtensionKey($extensionKey) > 0;
    }

    /**
     * Checks whether a compatible version of the extension exists in TER
     *
     * @param Dependency $dependency
     * @return bool
     */
    protected function isDownloadableVersionCompatible(Dependency $dependency)
    {
        $versions = $this->getLowestAndHighestIntegerVersions($dependency);
        $count = $this->extensionRepository->countByVersionRangeAndExtensionKey(
            $dependency->getIdentifier(),
            $versions['lowestIntegerVersion'],
            $versions['highestIntegerVersion']
        );
        return !empty($count);
    }

    /**
     * Get the latest compatible version of an extension that's
     * compatible with the current core and PHP version.
     *
     * @param iterable $extensions
     * @return Extension|null
     */
    protected function getCompatibleExtension($extensions)
    {
        foreach ($extensions as $extension) {
            /** @var Extension $extension */
            $this->checkDependencies($extension);
            $extensionKey = $extension->getExtensionKey();

            if (isset($this->dependencyErrors[$extensionKey])) {
                // reset dependencyErrors and continue with next version
                unset($this->dependencyErrors[$extensionKey]);
                continue;
            }

            return $extension;
        }

        return null;
    }

    /**
     * Get the latest compatible version of an extension that
     * fulfills the given dependency from TER
     *
     * @param Dependency $dependency
     * @return Extension
     */
    protected function getLatestCompatibleExtensionByIntegerVersionDependency(Dependency $dependency)
    {
        $versions = $this->getLowestAndHighestIntegerVersions($dependency);
        $compatibleDataSets = $this->extensionRepository->findByVersionRangeAndExtensionKeyOrderedByVersion(
            $dependency->getIdentifier(),
            $versions['lowestIntegerVersion'],
            $versions['highestIntegerVersion']
        );
        return $this->getCompatibleExtension($compatibleDataSets);
    }

    /**
     * Return array of lowest and highest version of dependency as integer
     *
     * @param Dependency $dependency
     * @return array
     */
    protected function getLowestAndHighestIntegerVersions(Dependency $dependency)
    {
        $lowestVersion = $dependency->getLowestVersion();
        $lowestVersionInteger = $lowestVersion ? VersionNumberUtility::convertVersionNumberToInteger($lowestVersion) : 0;
        $highestVersion = $dependency->getHighestVersion();
        $highestVersionInteger = $highestVersion ? VersionNumberUtility::convertVersionNumberToInteger($highestVersion) : 0;
        return [
            'lowestIntegerVersion' => $lowestVersionInteger,
            'highestIntegerVersion' => $highestVersionInteger
        ];
    }

    /**
     * @param string $extensionKey
     * @return array
     */
    public function findInstalledExtensionsThatDependOnMe($extensionKey)
    {
        $availableAndInstalledExtensions = $this->listUtility->getAvailableAndInstalledExtensionsWithAdditionalInformation();
        $dependentExtensions = [];
        foreach ($availableAndInstalledExtensions as $availableAndInstalledExtensionKey => $availableAndInstalledExtension) {
            if (isset($availableAndInstalledExtension['installed']) && $availableAndInstalledExtension['installed'] === true) {
                if (is_array($availableAndInstalledExtension['constraints']) && is_array($availableAndInstalledExtension['constraints']['depends']) && array_key_exists($extensionKey, $availableAndInstalledExtension['constraints']['depends'])) {
                    $dependentExtensions[] = $availableAndInstalledExtensionKey;
                }
            }
        }
        return $dependentExtensions;
    }

    /**
     * Get extensions (out of a given list) that are suitable for the current TYPO3 version
     *
     * @param \TYPO3\CMS\Extbase\Persistence\QueryResultInterface|array $extensions List of extensions to check
     * @return array List of extensions suitable for current TYPO3 version
     */
    public function getExtensionsSuitableForTypo3Version($extensions)
    {
        $suitableExtensions = [];
        /** @var Extension $extension */
        foreach ($extensions as $extension) {
            /** @var Dependency $dependency */
            foreach ($extension->getDependencies() as $dependency) {
                if ($dependency->getIdentifier() === 'typo3') {
                    try {
                        if ($this->checkTypo3Dependency($dependency)) {
                            $suitableExtensions[] = $extension;
                        }
                    } catch (Exception\UnresolvedTypo3DependencyException $e) {
                    }
                    break;
                }
            }
        }
        return $suitableExtensions;
    }

    /**
     * Gets a list of various extensions in various versions and returns
     * a filtered list containing the extension-version combination with
     * the highest version number.
     *
     * @param Extension[] $extensions
     * @param bool $showUnsuitable
     *
     * @return \TYPO3\CMS\Extensionmanager\Domain\Model\Extension[]
     */
    public function filterYoungestVersionOfExtensionList(array $extensions, $showUnsuitable)
    {
        if (!$showUnsuitable) {
            $extensions = $this->getExtensionsSuitableForTypo3Version($extensions);
        }
        $filteredExtensions = [];
        foreach ($extensions as $extension) {
            $extensionKey = $extension->getExtensionKey();
            if (!array_key_exists($extensionKey, $filteredExtensions)) {
                $filteredExtensions[$extensionKey] = $extension;
                continue;
            }
            $currentVersion = $filteredExtensions[$extensionKey]->getVersion();
            $newVersion = $extension->getVersion();
            if (version_compare($newVersion, $currentVersion, '>')) {
                $filteredExtensions[$extensionKey] = $extension;
            }
        }
        return $filteredExtensions;
    }
}
