<?php

/*
 * 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!
 */

namespace TYPO3\CMS\Core\Package;

use Composer\InstalledVersions;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
use TYPO3\CMS\Core\Cache\Event\CacheWarmupEvent;
use TYPO3\CMS\Core\Core\ClassLoadingInformation;
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Information\Typo3Information;
use TYPO3\CMS\Core\Package\Cache\PackageCacheEntry;
use TYPO3\CMS\Core\Package\Cache\PackageCacheInterface;
use TYPO3\CMS\Core\Package\Event\PackagesMayHaveChangedEvent;
use TYPO3\CMS\Core\Package\Exception\InvalidPackageKeyException;
use TYPO3\CMS\Core\Package\Exception\InvalidPackageManifestException;
use TYPO3\CMS\Core\Package\Exception\InvalidPackagePathException;
use TYPO3\CMS\Core\Package\Exception\InvalidPackageStateException;
use TYPO3\CMS\Core\Package\Exception\PackageManagerCacheUnavailableException;
use TYPO3\CMS\Core\Package\Exception\PackageStatesFileNotWritableException;
use TYPO3\CMS\Core\Package\Exception\ProtectedPackageKeyException;
use TYPO3\CMS\Core\Package\Exception\UnknownPackageException;
use TYPO3\CMS\Core\Package\Exception\UnknownPackagePathException;
use TYPO3\CMS\Core\Package\MetaData\PackageConstraint;
use TYPO3\CMS\Core\Service\DependencyOrderingService;
use TYPO3\CMS\Core\Service\OpcodeCacheService;
use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Core\Utility\ArrayUtility;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;

/**
 * The default TYPO3 Package Manager
 *
 * @phpstan-type PackageKey non-empty-string
 * @phpstan-type PackageName non-empty-string
 * @phpstan-type PackageConstraints array{dependencies: list<PackageKey>, suggestions: list<PackageKey>}
 * @phpstan-type StateConfiguration array{packagePath?: non-empty-string}
 * @phpstan-type StatesConfiguration array{packages?: array<PackageKey, StateConfiguration>, version?: int}
 */
class PackageManager implements SingletonInterface
{
    /**
     * @var DependencyOrderingService
     */
    protected $dependencyOrderingService;

    /**
     * @var PackageCacheInterface
     */
    protected $packageCache;

    /**
     * @var array{local?: non-empty-string, system?: non-empty-string}
     */
    protected $packagesBasePaths = [];

    /**
     * @var array<PackageName, PackageKey>
     */
    protected $packageAliasMap = [];

    /**
     * Absolute path leading to the various package directories
     * @var string
     */
    protected $packagesBasePath;

    /**
     * Array of available packages, indexed by package key
     * @var array<PackageKey, PackageInterface>
     */
    protected $packages = [];

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

    /**
     * A map between ComposerName and PackageKey, only available when scanAvailablePackages is run,
     * e.g. `['typo3/cms-core' => 'core', 'typo3/cms-backend' => 'backend']`
     * @var array<PackageName, PackageKey>
     */
    protected $composerNameToPackageKeyMap = [];

    /**
     * List of active packages as package key => package object
     * @var array<PackageKey, PackageInterface>
     */
    protected $activePackages = [];

    /**
     * @var string
     */
    protected $packageStatesPathAndFilename;

    /**
     * Package states configuration as stored in the PackageStates.php file
     * @var StatesConfiguration
     */
    protected $packageStatesConfiguration = [];

    /**
     * The regex to match paths with EXT:{package/key}
     */
    protected ?string $packagePathMatchRegex;

    private array $frameworkPackageNames = [];
    private array $installedPackageNames = [];

    public function __construct(DependencyOrderingService $dependencyOrderingService, ?string $packageStatesPathAndFilename = null, ?string $packagesBasePath = null)
    {
        $this->dependencyOrderingService = $dependencyOrderingService;
        $this->packagesBasePath = $packagesBasePath ?? Environment::getProjectPath() . '/';
        $this->packageStatesPathAndFilename = $packageStatesPathAndFilename ?? Environment::getPackageStatesFile();
    }

    /**
     * Access the composer.json of typo3/cms package
     * to find out all framework package names, so that we
     * can safely assume this is not a composer dependency
     */
    private function populateFrameworkAndComposerPackageNames(): void
    {
        $this->frameworkPackageNames = require __DIR__ . '/../../Resources/Private/Php/framework-packages.php';
        $this->installedPackageNames = array_filter(
            InstalledVersions::getInstalledPackages(),
            fn(string $packageName) => !in_array($packageName, $this->frameworkPackageNames, true) && $packageName !== 'typo3/cms',
        );
    }

    /**
     * @internal
     */
    public function setPackageCache(PackageCacheInterface $packageCache)
    {
        $this->packageCache = $packageCache;
    }

    /**
     * Initializes the package manager
     * @internal
     */
    public function initialize()
    {
        try {
            $this->loadPackageManagerStatesFromCache();
        } catch (PackageManagerCacheUnavailableException) {
            $this->loadPackageStates();
            $this->initializePackageObjects();
            $appPackage = new VirtualAppPackage(
                $this,
                Environment::getProjectPath() . '/',
                '',
            );
            $this->registerPackage($appPackage);
            $this->registerActivePackage($appPackage);
            $this->validateResources();
            $this->saveToPackageCache();
        }
    }

    /**
     * @internal
     * @return string|null
     */
    public function getCacheIdentifier()
    {
        try {
            return $this->packageCache->getIdentifier();
        } catch (PackageManagerCacheUnavailableException) {
            return null;
        }
    }

    /**
     * Saves the current state of all relevant information in the package cache
     */
    protected function saveToPackageCache(): void
    {
        // Build cache entry
        $cacheEntry = PackageCacheEntry::fromPackageData(
            $this->packageStatesConfiguration,
            $this->packageAliasMap,
            $this->composerNameToPackageKeyMap,
            $this->packages,
        );
        $this->packageCache->store($cacheEntry);
    }

    /**
     * @todo this does not belong here and should be extracted into a different service
     */
    protected function validateResources(): void
    {
        $publicPrefixes = [];
        foreach ($this->packages as $package) {
            if (!$package instanceof Package) {
                continue;
            }
            $packageKey = $package->getPackageKey();
            $resourcePaths = [];
            foreach ($package->getResources()->getPublicResourceDefinitions() as $resource) {
                $relativePath = $resource->getRelativePath();
                if (
                    str_starts_with($relativePath, '/')
                    || str_ends_with($relativePath, '/')
                    || !GeneralUtility::validPathStr($relativePath)
                ) {
                    throw new \RuntimeException(sprintf(
                        'Invalid relative path "%s" defined by package "%s". Relative paths must not have leading, or trailing slashes, or backpaths (../), or other malicious characters.',
                        $relativePath,
                        $packageKey,
                    ), 1774612899);
                }
                if (isset($resourcePaths[$relativePath])) {
                    throw new \RuntimeException(sprintf(
                        'Relative path "%s" is already defined by package "%s". Resource definitions must be unique.',
                        $relativePath,
                        $packageKey,
                    ), 1774612999);
                }
                $resourcePaths[$relativePath] = true;
                $publicPrefix = $resource->getPublicPrefix();
                if (!is_string($publicPrefix)) {
                    continue;
                }
                if (isset($publicPrefixes[$publicPrefix])) {
                    throw new \RuntimeException(sprintf(
                        'Public prefix "%s" is already defined by package "%s" can not be redefined by package "%s"',
                        $publicPrefix,
                        $publicPrefixes[$publicPrefix],
                        $packageKey,
                    ), 1774612898);
                }
                $publicPrefixes[$publicPrefix] = $packageKey;
            }
        }
    }

    /**
     * Attempts to load the package manager states from cache
     *
     * @throws PackageManagerCacheUnavailableException
     */
    protected function loadPackageManagerStatesFromCache()
    {
        $cacheEntry = $this->packageCache->fetch();
        $this->packageStatesConfiguration = $cacheEntry->getConfiguration();
        $this->packageAliasMap = $cacheEntry->getAliasMap();
        $this->composerNameToPackageKeyMap = $cacheEntry->getComposerNameMap();
        $this->packages = $cacheEntry->getPackages();
    }

    /**
     * Loads the states of available packages from the PackageStates.php file.
     * The result is stored in $this->packageStatesConfiguration.
     *
     * @throws Exception\PackageStatesUnavailableException
     */
    protected function loadPackageStates()
    {
        $this->packageStatesConfiguration = (@include $this->packageStatesPathAndFilename) ?: [];
        PackageCacheEntry::ensureValidPackageConfiguration($this->packageStatesConfiguration);
        $this->registerPackagesFromConfiguration($this->packageStatesConfiguration['packages'], false);
    }

    /**
     * Initializes activePackages property
     *
     * Saves PackageStates.php if list of required extensions has changed.
     */
    protected function initializePackageObjects()
    {
        $requiredPackages = [];
        $activePackages = [];
        foreach ($this->packages as $packageKey => $package) {
            if ($package->isProtected()) {
                $requiredPackages[$packageKey] = $package;
            }
            if (isset($this->packageStatesConfiguration['packages'][$packageKey])) {
                $activePackages[$packageKey] = $package;
            }
        }
        $previousActivePackages = $activePackages;
        $activePackages = array_merge($requiredPackages, $activePackages);

        if ($activePackages != $previousActivePackages) {
            foreach ($requiredPackages as $package) {
                $this->registerActivePackage($package);
            }
            $this->sortAndSavePackageStates();
        }
    }

    protected function registerActivePackage(PackageInterface $package)
    {
        // reset the active packages so they are rebuilt.
        $this->activePackages = [];
        $this->packagePathMatchRegex = null;
        $this->packageStatesConfiguration['packages'][$package->getPackageKey()] = ['packagePath' => str_replace($this->packagesBasePath, '', $package->getPackagePath())];
    }

    /**
     * Scans all directories in the packages directories for available packages.
     * For each package a Package object is created and stored in $this->packages.
     * @internal
     */
    public function scanAvailablePackages()
    {
        if (Environment::isComposerMode()) {
            return;
        }
        $packagePaths = $this->scanPackagePathsForExtensions();
        $packages = [];
        foreach ($packagePaths as $packageKey => $packagePath) {
            $packages[$packageKey] = ['packagePath' => str_replace($this->packagesBasePath, '', $packagePath)];
        }

        $this->availablePackagesScanned = true;
        $registerOnlyNewPackages = !empty($this->packages);
        $this->registerPackagesFromConfiguration($packages, $registerOnlyNewPackages);
    }

    /**
     * Resolves a path in the form EXT:vendor/package/Path/To/Resource to an absolute filesystem path
     *
     * @throws UnknownPackageException
     * @throws UnknownPackagePathException
     */
    public function resolvePackagePath(string $path): string
    {
        $packageKey = $this->extractPackageKeyFromPackagePath($path);
        $package = $this->getPackage($packageKey);

        return str_replace('EXT:' . $packageKey . '/', $package->getPackagePath(), $path);
    }

    /**
     * Extracts the package key from a path in the form EXT:vendor/package/Path/To/Resource
     *
     * @throws UnknownPackageException
     * @throws UnknownPackagePathException
     * @internal
     */
    public function extractPackageKeyFromPackagePath(string $path): string
    {
        if (!PathUtility::isExtensionPath($path)) {
            throw new UnknownPackageException('Given path is not an extension path starting with "EXT:" ' . $path, 1631630764);
        }
        if (!isset($this->packagePathMatchRegex)) {
            $this->packagePathMatchRegex = sprintf(
                '/^EXT:(%s)\//',
                implode(
                    '|',
                    array_map(
                        static function (string $packageKey): string {
                            return preg_quote($packageKey, '/');
                        },
                        array_merge(
                            array_keys($this->getActivePackages()),
                            array_keys($this->packageAliasMap),
                            array_keys($this->composerNameToPackageKeyMap)
                        )
                    )
                )
            );
        }
        $result = preg_match($this->packagePathMatchRegex, $path, $matches);
        if (!$result || empty($matches[1])) {
            throw new UnknownPackagePathException('Package path "' . $path . '" is not available. Please check if the package referenced in the path exists and that the package key is correct (package keys are case sensitive).', 1631630087);
        }

        return $matches[1];
    }

    /**
     * Event listener to retrigger scanning of available packages.
     */
    public function packagesMayHaveChanged(PackagesMayHaveChangedEvent $event): void
    {
        $this->scanAvailablePackages();
    }

    /**
     * Fetches all directories from sysext/local locations and checks if the extension contains an composer.json
     * with a type "typo3-cms-framework", or "typo3-cms-extension".
     *
     * @return array
     */
    protected function scanPackagePathsForExtensions()
    {
        $collectedExtensionPaths = [];
        $this->populateFrameworkAndComposerPackageNames();
        foreach ($this->getPackageBasePaths() as $packageBasePath) {
            // Only add the extension if we have a composer.json and the extension is not yet registered.
            // This is crucial in order to allow overriding of system extension by local extensions
            // and strongly depends on the order of paths defined in $this->packagesBasePaths.
            $finder = new Finder();
            $finder
                ->name('composer.json')
                ->followLinks()
                ->depth(0)
                ->ignoreUnreadableDirs()
                ->in($packageBasePath);

            /** @var SplFileInfo $fileInfo */
            foreach ($finder as $fileInfo) {
                try {
                    $composerManifest = json_decode($fileInfo->getContents(), false, 512, JSON_THROW_ON_ERROR);
                } catch (\JsonException) {
                    continue;
                }
                $packageType = $composerManifest->type ?? '';
                // Only allow typo3-cms package types
                if (!str_starts_with($packageType, 'typo3-cms-')) {
                    continue;
                }
                $packageProvides = array_keys((array)($composerManifest->extra->{'typo3/cms'}->Package->providesPackages ?? []));
                $this->installedPackageNames = array_merge($this->installedPackageNames, $packageProvides);
                $path = PathUtility::dirname($fileInfo->getPathname());
                // Fix Windows backslashes
                $currentPath = GeneralUtility::fixWindowsFilePath($path) . '/';
                $packageKey = $this->getPackageKeyFromManifest($composerManifest, $currentPath);
                if (!isset($collectedExtensionPaths[$packageKey])) {
                    $collectedExtensionPaths[$packageKey] = $currentPath;
                }
            }
        }
        return $collectedExtensionPaths;
    }

    /**
     * Requires and registers all packages which were defined in packageStatesConfiguration
     *
     * @param array<PackageKey, StateConfiguration> $packages
     * @param bool $registerOnlyNewPackages
     * @throws Exception\InvalidPackageStateException
     * @throws Exception\PackageStatesFileNotWritableException
     */
    protected function registerPackagesFromConfiguration(array $packages, $registerOnlyNewPackages = false)
    {
        $packageStatesHasChanged = false;
        foreach ($packages as $packageKey => $stateConfiguration) {
            if ($registerOnlyNewPackages && $this->isPackageRegistered($packageKey)) {
                continue;
            }

            if (!isset($stateConfiguration['packagePath'])) {
                $this->unregisterPackageByPackageKey($packageKey);
                $packageStatesHasChanged = true;
                continue;
            }

            try {
                $packagePath = PathUtility::sanitizeTrailingSeparator($this->packagesBasePath . $stateConfiguration['packagePath']);
                $package = new Package($this, $packageKey, $packagePath);
            } catch (InvalidPackagePathException|InvalidPackageKeyException|InvalidPackageManifestException $exception) {
                $this->unregisterPackageByPackageKey($packageKey);
                $packageStatesHasChanged = true;
                continue;
            }

            $this->registerPackage($package);
        }
        if ($packageStatesHasChanged) {
            $this->sortAndSavePackageStates();
        }
    }

    /**
     * Register a native TYPO3 package
     *
     * @param PackageInterface $package The Package to be registered
     * @return PackageInterface
     * @throws Exception\InvalidPackageStateException
     * @internal
     */
    public function registerPackage(PackageInterface $package)
    {
        $packageKey = $package->getPackageKey();
        if ($this->isPackageRegistered($packageKey)) {
            throw new InvalidPackageStateException('Package "' . $packageKey . '" is already registered.', 1338996122);
        }
        $composerName = $package->getValueFromComposerManifest('name');
        if ($composerName !== $packageKey) {
            $this->composerNameToPackageKeyMap[$composerName] = $packageKey;
        }
        $this->packages[$packageKey] = $package;

        foreach ($package->getPackageReplacementKeys() as $packageToReplace => $versionConstraint) {
            $this->packageAliasMap[$packageToReplace] = $package->getPackageKey();
        }
        return $package;
    }

    /**
     * Unregisters a package from the list of available packages
     *
     * @param string|PackageKey $packageKey Package Key of the package to be unregistered
     */
    protected function unregisterPackageByPackageKey($packageKey)
    {
        try {
            $package = $this->getPackage($packageKey);
            foreach ($package->getPackageReplacementKeys() as $packageToReplace => $versionConstraint) {
                unset($this->packageAliasMap[$packageToReplace]);
            }
        } catch (UnknownPackageException $e) {
        }
        $this->composerNameToPackageKeyMap = array_filter(
            $this->composerNameToPackageKeyMap,
            static function (string $aliasedKey) use ($packageKey): bool {
                return $aliasedKey !== $packageKey;
            }
        );
        unset($this->packages[$packageKey]);
        unset($this->packageStatesConfiguration['packages'][$packageKey]);
    }

    /**
     * Resolves a TYPO3 package key from a composer package name.
     *
     * @param string|PackageName $composerName
     * @return string|PackageKey|PackageName
     * @internal
     */
    public function getPackageKeyFromComposerName($composerName)
    {
        if (isset($this->packageAliasMap[$composerName])) {
            return $this->packageAliasMap[$composerName];
        }
        if (isset($this->composerNameToPackageKeyMap[$composerName])) {
            return $this->composerNameToPackageKeyMap[$composerName];
        }
        return $composerName;
    }

    /**
     * Returns a PackageInterface object for the specified package.
     * A package is available, if the package directory contains valid MetaData information.
     *
     * @param string|PackageKey $packageKey
     * @return PackageInterface The requested package object
     * @throws Exception\UnknownPackageException if the specified package is not known
     */
    public function getPackage($packageKey)
    {
        if (!$this->isPackageRegistered($packageKey) && !$this->isPackageAvailable($packageKey)) {
            throw new UnknownPackageException('Package "' . $packageKey . '" is not available. Please check if the package exists and that the package key is correct (package keys are case sensitive).', 1166546734);
        }
        return $this->packages[$this->getPackageKeyFromComposerName($packageKey)];
    }

    /**
     * Returns TRUE if a package is available (the package's files exist in the packages directory)
     * or FALSE if it's not. If a package is available it doesn't mean necessarily that it's active!
     *
     * @param string|PackageKey $packageKey The key of the package to check
     * @return bool TRUE if the package is available, otherwise FALSE
     */
    public function isPackageAvailable($packageKey)
    {
        if ($this->isPackageRegistered($packageKey)) {
            return true;
        }

        // If activePackages is empty, the PackageManager is currently initializing
        // thus packages should not be scanned
        if (!$this->availablePackagesScanned && !empty($this->activePackages)) {
            $this->scanAvailablePackages();
        }

        return $this->isPackageRegistered($packageKey);
    }

    /**
     * Returns TRUE if a package is activated or FALSE if it's not.
     *
     * @param string|PackageKey $packageKey The key of the package to check
     * @return bool TRUE if package is active, otherwise FALSE
     */
    public function isPackageActive($packageKey)
    {
        $packageKey = $this->getPackageKeyFromComposerName($packageKey);

        return isset($this->packageStatesConfiguration['packages'][$packageKey]);
    }

    /**
     * Deactivates a package and updates the packagestates configuration
     *
     * @param string|PackageKey $packageKey
     * @throws Exception\PackageStatesFileNotWritableException
     * @throws Exception\ProtectedPackageKeyException
     * @throws Exception\UnknownPackageException
     * @internal
     */
    public function deactivatePackage($packageKey)
    {
        if (!$this->isPackageActive($packageKey)) {
            return;
        }
        $package = $this->getPackage($packageKey);
        if ($package->isProtected()) {
            throw new ProtectedPackageKeyException('The package "' . $packageKey . '" is protected and cannot be deactivated.', 1308662891);
        }

        $packagesWithDependencies = $this->sortActivePackagesByDependencies();
        foreach ($packagesWithDependencies as $packageStateKey => $packageStateConfiguration) {
            if ($packageKey === $packageStateKey || empty($packageStateConfiguration['dependencies'])) {
                continue;
            }
            if (in_array($packageKey, $packageStateConfiguration['dependencies'], true)) {
                $this->deactivatePackage($packageStateKey);
            }
        }

        $this->activePackages = [];
        $this->packagePathMatchRegex = null;
        unset($this->packageStatesConfiguration['packages'][$packageKey]);
        $this->sortAndSavePackageStates();
    }

    /**
     * @param string|PackageKey $packageKey
     * @internal
     */
    public function activatePackage($packageKey)
    {
        $package = $this->getPackage($packageKey);
        $this->registerTransientClassLoadingInformationForPackage($package);

        if ($this->isPackageActive($packageKey)) {
            return;
        }

        $this->registerActivePackage($package);
        $this->sortAndSavePackageStates();
    }

    /**
     * @throws \TYPO3\CMS\Core\Exception
     */
    protected function registerTransientClassLoadingInformationForPackage(PackageInterface $package)
    {
        if (Environment::isComposerMode()) {
            return;
        }
        ClassLoadingInformation::registerTransientClassLoadingInformationForPackage($package);
    }

    /**
     * Removes a package from the file system.
     *
     * @param string|PackageKey $packageKey
     * @throws Exception
     * @throws Exception\ProtectedPackageKeyException
     * @throws Exception\UnknownPackageException
     * @internal
     */
    public function deletePackage($packageKey)
    {
        if (!$this->isPackageAvailable($packageKey)) {
            throw new UnknownPackageException('Package "' . $packageKey . '" is not available and cannot be removed.', 1166543253);
        }

        $package = $this->getPackage($packageKey);
        if ($package->isProtected()) {
            throw new ProtectedPackageKeyException('The package "' . $packageKey . '" is protected and cannot be removed.', 1220722120);
        }

        if ($this->isPackageActive($packageKey)) {
            $this->deactivatePackage($packageKey);
        }

        $this->unregisterPackage($package);
        $this->sortAndSavePackageStates();

        $packagePath = $package->getPackagePath();
        $deletion = GeneralUtility::rmdir($packagePath, true);
        if ($deletion === false) {
            throw new Exception('Please check file permissions. The directory "' . $packagePath . '" for package "' . $packageKey . '" could not be removed.', 1301491089);
        }
    }

    /**
     * Returns an array of \TYPO3\CMS\Core\Package objects of all active packages.
     * A package is active, if it is available and has been activated in the package
     * manager settings.
     *
     * @return array<array-key|PackageKey, PackageInterface>
     */
    public function getActivePackages(bool $includeAppPackage = false)
    {
        if (empty($this->activePackages) && !empty($this->packageStatesConfiguration['packages'])) {
            foreach ($this->packageStatesConfiguration['packages'] as $packageKey => $packageConfig) {
                $this->activePackages[$packageKey] = $this->getPackage($packageKey);
            }
        }
        $activePackages = $this->activePackages;
        if (!$includeAppPackage) {
            unset($activePackages[VirtualAppPackage::APP_PACKAGE_KEY]);
        }
        return $activePackages;
    }

    /**
     * Returns TRUE if a package was already registered or FALSE if it's not.
     *
     * @param string|PackageKey $packageKey
     * @return bool
     */
    protected function isPackageRegistered($packageKey)
    {
        $packageKey = $this->getPackageKeyFromComposerName($packageKey);

        return isset($this->packages[$packageKey]);
    }

    /**
     * Orders all active packages by comparing their dependencies. By this, the packages
     * and package configurations arrays holds all packages in the correct
     * initialization order.
     *
     * @return array<PackageKey, PackageConstraints>
     */
    protected function sortActivePackagesByDependencies()
    {
        $packagesWithDependencies = $this->resolvePackageDependencies($this->packageStatesConfiguration['packages']);

        // sort the packages by key at first, so we get a stable sorting of "equivalent" packages afterwards
        ksort($packagesWithDependencies);
        $sortedPackageKeys = $this->sortPackageStatesConfigurationByDependency($packagesWithDependencies);

        // Reorder the packages according to the loading order
        $this->packageStatesConfiguration['packages'] = [];
        foreach ($sortedPackageKeys as $packageKey) {
            $this->registerActivePackage($this->packages[$packageKey]);
        }
        return $packagesWithDependencies;
    }

    /**
     * Resolves the dependent packages from the meta data of all packages recursively. The
     * resolved direct or indirect dependencies of each package will put into the package
     * states configuration array.
     *
     * @param array<PackageKey, mixed> $packageConfig
     * @return array<PackageKey, PackageConstraints>
     */
    protected function resolvePackageDependencies($packageConfig)
    {
        $packagesWithDependencies = [];
        foreach ($packageConfig as $packageKey => $_) {
            $packagesWithDependencies[$packageKey]['dependencies'] = $this->getDependencyArrayForPackage($packageKey);
            $packagesWithDependencies[$packageKey]['suggestions'] = $this->getSuggestionArrayForPackage($packageKey);
        }
        return $packagesWithDependencies;
    }

    /**
     * Returns an array of suggested package keys for the given package.
     *
     * @param string|PackageKey $packageKey The package key to fetch the suggestions for
     * @return list<PackageKey>|null An array of directly suggested packages
     */
    protected function getSuggestionArrayForPackage($packageKey)
    {
        if (!isset($this->packages[$packageKey])) {
            return null;
        }
        $suggestedPackageKeys = [];
        $suggestedPackageConstraints = $this->packages[$packageKey]->getPackageMetaData()->getConstraintsByType(MetaData::CONSTRAINT_TYPE_SUGGESTS);
        foreach ($suggestedPackageConstraints as $constraint) {
            if ($constraint instanceof PackageConstraint) {
                $suggestedPackageName = $constraint->getValue();
                $suggestedPackageKey = $this->getPackageKeyFromComposerName($suggestedPackageName);
                if (isset($this->packages[$suggestedPackageKey])) {
                    $suggestedPackageKeys[] = $suggestedPackageKey;
                }
            }
        }
        return array_reverse($suggestedPackageKeys);
    }

    /**
     * Saves the current content of $this->packageStatesConfiguration to the
     * PackageStates.php file.
     *
     * @throws Exception\PackageStatesFileNotWritableException
     */
    protected function savePackageStates()
    {
        $this->packageStatesConfiguration['version'] = 5;

        $fileDescription = "# PackageStates.php\n\n";
        $fileDescription .= "# This file is maintained by TYPO3's package management. Although you can edit it\n";
        $fileDescription .= "# manually, you should rather use the extension manager for maintaining packages.\n";
        $fileDescription .= "# This file will be regenerated automatically if it doesn't exist. Deleting this file\n";
        $fileDescription .= "# should, however, never become necessary if you use the package commands.\n";

        if (!@is_writable($this->packageStatesPathAndFilename)) {
            // If file does not exist, try to create it
            $fileHandle = @fopen($this->packageStatesPathAndFilename, 'x');
            if (!$fileHandle) {
                throw new PackageStatesFileNotWritableException(
                    sprintf('We could not update the list of installed packages because the file %s is not writable. Please, check the file system permissions for this file and make sure that the web server can update it.', $this->packageStatesPathAndFilename),
                    1382449759
                );
            }
            fclose($fileHandle);
        }
        $packageConfiguration = $this->packageStatesConfiguration;
        // The VirtualAppPackage must never be written to PackageStates.php
        // It is either pulled from cache, or registered manually after
        // all real packages have been pulled in from disk
        unset($packageConfiguration['packages'][VirtualAppPackage::APP_PACKAGE_KEY]);
        $packageStatesCode = "<?php\n$fileDescription\nreturn " . ArrayUtility::arrayExport($packageConfiguration) . ";\n";
        GeneralUtility::writeFile($this->packageStatesPathAndFilename, $packageStatesCode, true);
        // Cache depends on package states file, therefore we invalidate it
        $this->packageCache->invalidate();

        GeneralUtility::makeInstance(OpcodeCacheService::class)->clearAllActive($this->packageStatesPathAndFilename);
    }

    /**
     * Saves the current content of $this->packageStatesConfiguration to the
     * PackageStates.php file.
     *
     * @throws Exception\PackageStatesFileNotWritableException
     */
    protected function sortAndSavePackageStates()
    {
        $this->sortActivePackagesByDependencies();
        $this->savePackageStates();
    }

    /**
     * Check the conformance of the given package key
     *
     * @param string|PackageKey $packageKey The package key to validate
     * @return bool If the package key is valid, returns TRUE otherwise FALSE
     */
    public function isPackageKeyValid($packageKey)
    {
        return preg_match(PackageInterface::PATTERN_MATCH_EXTENSIONKEY, $packageKey) === 1 || preg_match(PackageInterface::PATTERN_MATCH_COMPOSER_NAME, $packageKey) === 1 || preg_match(PackageInterface::PATTERN_MATCH_PACKAGEKEY, $packageKey) === 1;
    }

    /**
     * Returns an array of \TYPO3\CMS\Core\Package objects of all available packages.
     * A package is available, if the package directory contains valid meta information.
     *
     * @return array<array-key|PackageKey, PackageInterface> Array of PackageInterface
     */
    public function getAvailablePackages()
    {
        if ($this->availablePackagesScanned === false) {
            $this->scanAvailablePackages();
        }

        return $this->packages;
    }

    /**
     * Unregisters a package from the list of available packages
     *
     * @param PackageInterface $package The package to be unregistered
     * @throws Exception\InvalidPackageStateException
     * @internal
     */
    public function unregisterPackage(PackageInterface $package)
    {
        $packageKey = $package->getPackageKey();
        if (!$this->isPackageRegistered($packageKey)) {
            throw new InvalidPackageStateException('Package "' . $packageKey . '" is not registered.', 1338996142);
        }
        $this->unregisterPackageByPackageKey($packageKey);
    }

    /**
     * Reloads a package and its information
     *
     * @param string|PackageKey $packageKey
     * @throws Exception\InvalidPackageStateException if the package isn't available
     * @internal
     */
    public function reloadPackageInformation($packageKey)
    {
        if (!$this->isPackageRegistered($packageKey)) {
            throw new InvalidPackageStateException('Package "' . $packageKey . '" is not registered.', 1436201329);
        }

        $package = $this->packages[$packageKey];
        $packagePath = $package->getPackagePath();
        $newPackage = new Package($this, $packageKey, $packagePath);
        $this->packages[$packageKey] = $newPackage;
        unset($package);
    }

    /**
     * Returns contents of Composer manifest as a stdObject
     *
     * @return \stdClass
     * @throws InvalidPackageManifestException
     * @internal
     */
    public function getComposerManifest(string $manifestPath, bool $isBuildingPackageArtifact = false)
    {
        $composerManifest = new \stdClass();
        if (file_exists($manifestPath . 'composer.json')) {
            $json = file_get_contents($manifestPath . 'composer.json');
            if ($json !== false) {
                $composerManifest = json_decode($json);
            }
            if (!$composerManifest instanceof \stdClass) {
                throw new InvalidPackageManifestException('The composer.json found for extension "' . PathUtility::basename($manifestPath) . '" is invalid!', 1439555561);
            }
        }

        if ($isBuildingPackageArtifact
            // Framework packages (TYPO3 system extensions) derive their version from Typo3Version and are
            // not required to declare "providesPackages". Every other extension must declare the required
            // metadata in composer.json, since ext_emconf.php is no longer evaluated as a fallback for it.
            // @todo validate the contents and availability of the composer manifest instead of applying nullsafe fallbacks here
            || $this->isFrameworkPackage($composerManifest->name ?? '')
            || $this->declaresRequiredPackageMetadata($composerManifest)
        ) {
            return $composerManifest;
        }
        throw new InvalidPackageManifestException(
            sprintf(
                'The composer.json of extension "%s" must declare the extension version and the "providesPackages" definition in the "extra/typo3/cms" section. See %s for details.',
                $this->getPackageKeyFromManifest($composerManifest, $manifestPath),
                Typo3Information::getDocsLink('changelog:deprecation-108345-1774126701'),
            ),
            1780502553
        );
    }

    private function declaresRequiredPackageMetadata(\stdClass $manifest): bool
    {
        return isset($manifest->extra->{'typo3/cms'}->Package->providesPackages)
            && (
                ($manifest->version ?? null) !== null
                || isset($manifest->extra->{'typo3/cms'}->version)
            );
    }

    /**
     * Returns an array of dependent package keys for the given package. It will
     * do this recursively, so dependencies of dependent packages will also be
     * in the result.
     *
     * @param string|PackageKey $packageKey The package key to fetch the dependencies for
     * @param array $trace An array of already visited package keys, to detect circular dependencies
     * @return list<string>|null An array of direct or indirect dependent packages
     * @throws Exception\InvalidPackageKeyException
     */
    protected function getDependencyArrayForPackage($packageKey, array &$dependentPackageKeys = [], array $trace = [])
    {
        if (!isset($this->packages[$packageKey])) {
            return null;
        }
        if (in_array($packageKey, $trace, true) !== false) {
            return $dependentPackageKeys;
        }
        $trace[] = $packageKey;
        $dependentPackageConstraints = $this->packages[$packageKey]->getPackageMetaData()->getConstraintsByType(MetaData::CONSTRAINT_TYPE_DEPENDS);
        foreach ($dependentPackageConstraints as $constraint) {
            if ($constraint instanceof PackageConstraint) {
                $dependentPackageName = $constraint->getValue();
                // This check is done here for classic mode, to ignore "php" package for
                // dependency ordering of extensions. In Composer mode, packages won't have
                // this dependency tracked anymore at this point.
                if ($dependentPackageName === 'php') {
                    continue;
                }
                $dependentPackageKey = $this->getPackageKeyFromComposerName($dependentPackageName);
                if (in_array($dependentPackageKey, $dependentPackageKeys, true) === false && in_array($dependentPackageKey, $trace, true) === false) {
                    $dependentPackageKeys[] = $dependentPackageKey;
                }
                $this->getDependencyArrayForPackage($dependentPackageKey, $dependentPackageKeys, $trace);
            }
        }
        return array_reverse($dependentPackageKeys);
    }

    /**
     * Resolves package key from Composer manifest
     *
     * If it is a TYPO3 package the name of the containing directory will be used.
     *
     * Else if the composer name of the package matches the first part of the lowercased namespace of the package, the mixed
     * case version of the composer name / namespace will be used, with backslashes replaced by dots.
     *
     * Else the composer name will be used with the slash replaced by a dot
     *
     * @throws Exception\InvalidPackageManifestException
     */
    protected function getPackageKeyFromManifest(\stdClass $manifest, string $packagePath): string
    {
        if (!empty($manifest->extra->{'typo3/cms'}->{'extension-key'})) {
            return $manifest->extra->{'typo3/cms'}->{'extension-key'};
        }
        if (empty($manifest->name) || (isset($manifest->type) && str_starts_with($manifest->type, 'typo3-cms-'))) {
            throw new InvalidPackageManifestException('Invalid composer manifest (no extension key set) in package path: ' . $packagePath, 1348146451);
        }
        return $manifest->name;
    }

    /**
     * The order of paths is crucial for allowing overriding of system extension by local extensions.
     * Pay attention if you change order of the paths here.
     *
     * @return array{local?: string, system?: string}
     */
    protected function getPackageBasePaths()
    {
        if ($this->packagesBasePaths === []) {
            // Check if the directory even exists and if it is not empty
            if (is_dir(Environment::getExtensionsPath()) && $this->hasSubDirectories(Environment::getExtensionsPath())) {
                $this->packagesBasePaths['local'] = Environment::getExtensionsPath() . '/*/';
            }
            $this->packagesBasePaths['system'] = Environment::getFrameworkBasePath() . '/*/';
        }
        return $this->packagesBasePaths;
    }

    /**
     * Returns true if the given path has valid subdirectories, false otherwise.
     */
    protected function hasSubDirectories(string $path): bool
    {
        return !empty(glob(rtrim($path, '/\\') . '/*', GLOB_ONLYDIR));
    }

    /**
     * @param array<PackageKey, PackageConstraints> $packageStatesConfiguration
     * @return list<PackageKey> Returns the packageStatesConfiguration sorted by dependencies
     * @throws \UnexpectedValueException
     */
    protected function sortPackageStatesConfigurationByDependency(array $packageStatesConfiguration)
    {
        return $this->dependencyOrderingService->calculateOrder($this->buildDependencyGraph($packageStatesConfiguration));
    }

    /**
     * Convert the package configuration into a dependency definition
     *
     * This converts "dependencies" and "suggestions" to "after" syntax for the usage in DependencyOrderingService
     *
     * @param array $packageStatesConfiguration
     * @param array $packageKeys
     * @return array
     * @throws \UnexpectedValueException
     */
    protected function convertConfigurationForGraph(array $packageStatesConfiguration, array $packageKeys)
    {
        $dependencies = [];
        foreach ($packageKeys as $packageKey) {
            if (!isset($packageStatesConfiguration[$packageKey]['dependencies']) && !isset($packageStatesConfiguration[$packageKey]['suggestions'])) {
                continue;
            }
            $dependencies[$packageKey] = [
                'after' => [],
            ];
            if (isset($packageStatesConfiguration[$packageKey]['dependencies'])) {
                foreach ($packageStatesConfiguration[$packageKey]['dependencies'] as $dependentPackageKey) {
                    if (!in_array($dependentPackageKey, $packageKeys, true)) {
                        throw new \UnexpectedValueException(
                            'The package "' . $packageKey . '" depends on "'
                            . $dependentPackageKey . '" which is not present in the system.',
                            1519931815
                        );
                    }
                    $dependencies[$packageKey]['after'][] = $dependentPackageKey;
                }
            }
            if (isset($packageStatesConfiguration[$packageKey]['suggestions'])) {
                foreach ($packageStatesConfiguration[$packageKey]['suggestions'] as $suggestedPackageKey) {
                    // skip suggestions on not existing packages
                    if (in_array($suggestedPackageKey, $packageKeys, true)) {
                        // Suggestions actually have never been meant to influence loading order.
                        // We misuse this currently, as there is no other way to influence the loading order
                        // for not-required packages (soft-dependency).
                        // When considering suggestions for the loading order, we might create a cyclic dependency
                        // if the suggested package already has a real dependency on this package, so the suggestion
                        // has do be dropped in this case and must *not* be taken into account for loading order evaluation.
                        $dependencies[$packageKey]['after-resilient'][] = $suggestedPackageKey;
                    }
                }
            }
        }
        return $dependencies;
    }

    /**
     * Checks whether the given package name is a Composer dependency.
     * In classic mode some dedicated typo3 packages, PHP, composer-runtime-api
     * and PHP extensions are detected as Composer dependency.
     *
     * All other platform packages, if required, must be added to "providesPackages"
     *
     * @internal Only to be called within TYPO3\CMS\Core\Package namespace
     */
    public function isComposerDependency(string $packageName): bool
    {
        if ($this->isFrameworkPackage($packageName)) {
            return false;
        }
        return
            // PHP version
            $packageName === 'php'
            // Composer version
            || $packageName === 'composer-runtime-api'
            // PHP extension
            || str_starts_with($packageName, 'ext-')
            // Installed packages (mostly shipped packages)
            || in_array($packageName, $this->installedPackageNames, true);
    }

    /**
     * @internal Only to be called within TYPO3\CMS\Core\Package namespace
     */
    public function isFrameworkPackage(string $packageName): bool
    {
        if ($this->frameworkPackageNames === []) {
            $this->populateFrameworkAndComposerPackageNames();
        }
        return in_array($packageName, $this->frameworkPackageNames, true);
    }

    /**
     * Adds all root packages of current dependency graph as dependency to all extensions
     *
     * This ensures that the framework extensions (aka sysext) are
     * always loaded first, before any other external extension.
     *
     * @param array<PackageKey, PackageConstraints> $packageStateConfiguration
     * @param list<PackageKey> $rootPackageKeys
     * @return array<PackageKey, PackageConstraints>
     */
    protected function addDependencyToFrameworkToAllExtensions(array $packageStateConfiguration, array $rootPackageKeys)
    {
        $frameworkPackageKeys = $this->findFrameworkPackages($packageStateConfiguration);
        $extensionPackageKeys = array_diff(array_keys($packageStateConfiguration), $frameworkPackageKeys);
        foreach ($extensionPackageKeys as $packageKey) {
            // Remove framework packages from list
            $packageKeysWithoutFramework = array_diff(
                $packageStateConfiguration[$packageKey]['dependencies'],
                $frameworkPackageKeys
            );
            // The order of the array_merge is crucial here,
            // we want the framework first
            $packageStateConfiguration[$packageKey]['dependencies'] = array_merge(
                $rootPackageKeys,
                $packageKeysWithoutFramework
            );
        }
        return $packageStateConfiguration;
    }

    /**
     * Builds the dependency graph for all packages
     *
     * This method also introduces dependencies among the dependencies
     * to ensure the loading order is exactly as specified in the list.
     *
     * @param array<PackageKey, PackageConstraints> $packageStateConfiguration
     * @return array<array-key|PackageKey, array<array-key, bool>>
     */
    protected function buildDependencyGraph(array $packageStateConfiguration)
    {
        $frameworkPackageKeys = $this->findFrameworkPackages($packageStateConfiguration);
        $frameworkPackagesDependencyGraph = $this->dependencyOrderingService->buildDependencyGraph($this->convertConfigurationForGraph($packageStateConfiguration, $frameworkPackageKeys));
        $packageStateConfiguration = $this->addDependencyToFrameworkToAllExtensions($packageStateConfiguration, $this->dependencyOrderingService->findRootIds($frameworkPackagesDependencyGraph));

        $packageKeys = array_keys($packageStateConfiguration);
        return $this->dependencyOrderingService->buildDependencyGraph($this->convertConfigurationForGraph($packageStateConfiguration, $packageKeys));
    }

    /**
     * @param array<PackageKey, PackageConstraints> $packageStateConfiguration
     * @return list<PackageKey>
     */
    protected function findFrameworkPackages(array $packageStateConfiguration)
    {
        $frameworkPackageKeys = [];
        foreach ($packageStateConfiguration as $packageKey => $packageConfiguration) {
            $package = $this->getPackage($packageKey);
            if ($package->getPackageMetaData()->isFrameworkType()) {
                $frameworkPackageKeys[] = $packageKey;
            }
        }

        return $frameworkPackageKeys;
    }

    /**
     * @internal
     */
    public function warmupCaches(CacheWarmupEvent $event): void
    {
        if (Environment::isComposerMode()) {
            return;
        }
        if ($event->hasGroup('system')) {
            if (count($this->packageStatesConfiguration) === 0) {
                $this->loadPackageStates();
                $this->initializePackageObjects();
            }
            $this->validateResources();
            $this->saveToPackageCache();
        }
    }

    /**
     * Create PackageStates.php if missing and LocalConfiguration exists, used to have an Install Tool session running
     *
     * It is fired if PackageStates.php is deleted on a running instance,
     * all packages marked as "part of minimal system" are activated in this case.
     * @param bool $useFactoryDefault if true, use the "isPartOfFactoryDefault" otherwise use "isPartOfMinimalUsableSystem"
     * @internal
     */
    public function recreatePackageStatesFileIfMissing(bool $useFactoryDefault = false): void
    {
        if (!Environment::isComposerMode() && !file_exists($this->packageStatesPathAndFilename)) {
            $packages = $this->getAvailablePackages();
            foreach ($packages as $package) {
                if ($useFactoryDefault ? $package->isPartOfFactoryDefault() : $package->isPartOfMinimalUsableSystem()) {
                    $this->activatePackage($package->getPackageKey());
                }
            }

            $this->sortActivePackagesByDependencies();
            $this->savePackageStates();
        }
    }
}
