<?php

/**
 * @package    Grav\Common\GPM
 *
 * @copyright  Copyright (c) 2015 - 2026 Trilby Media, LLC. All rights reserved.
 * @license    MIT License; see LICENSE file for details.
 */

namespace Grav\Common\GPM;

use Exception;
use Grav\Common\Data\Data;
use Grav\Common\Grav;
use Grav\Common\Filesystem\Folder;
use Grav\Common\HTTP\Response;
use Grav\Common\Inflector;
use Grav\Common\Iterator;
use Grav\Common\Utils;
use RocketTheme\Toolbox\File\YamlFile;
use RuntimeException;
use stdClass;
use function array_key_exists;
use function count;
use function in_array;
use function is_array;
use function is_object;
use function property_exists;

/**
 * Class GPM
 * @package Grav\Common\GPM
 */
class GPM extends Iterator
{
    /** @var Local\Packages Local installed Packages */
    private $installed;
    /** @var Remote\Packages|null Remote available Packages */
    private $repository;
    /** @var Remote\GravCore|null Remove Grav Packages */
    private $grav;
    /** @var callable|null */
    private $callback;

    /** @var array Internal cache */
    protected $cache;
    /** @var array */
    protected $install_paths = [
        'plugins' => 'user/plugins/%name%',
        'themes' => 'user/themes/%name%',
        'skeletons' => 'user/'
    ];

    /**
     * Creates a new GPM instance with Local and Remote packages available
     *
     * @param bool $refresh Applies to Remote Packages only and forces a refetch of data
     * @param callable|null $callback Either a function or callback in array notation
     */
    public function __construct(private $refresh = false, $callback = null)
    {
        parent::__construct();

        Folder::create(CACHE_DIR . '/gpm');

        $this->cache = [];
        $this->installed = new Local\Packages();
        $this->callback = $callback;
    }

    /**
     * Magic getter method
     *
     * @param string $offset Asset name value
     * @return mixed Asset value
     */
    #[\ReturnTypeWillChange]
    public function __get($offset)
    {
        return match ($offset) {
            'grav' => $this->getGrav(),
            default => parent::__get($offset),
        };
    }

    /**
     * Magic method to determine if the attribute is set
     *
     * @param string $offset Asset name value
     * @return bool True if the value is set
     */
    #[\ReturnTypeWillChange]
    public function __isset($offset)
    {
        return match ($offset) {
            'grav' => $this->getGrav() !== null,
            default => parent::__isset($offset),
        };
    }

    /**
     * Return the locally installed packages
     *
     * @return Local\Packages
     */
    public function getInstalled()
    {
        return $this->installed;
    }

    /**
     * Returns the Locally installable packages
     *
     * @param array $list_type_installed
     * @return array The installed packages
     */
    public function getInstallable($list_type_installed = ['plugins' => true, 'themes' => true])
    {
        $items = ['total' => 0];
        foreach ($list_type_installed as $type => $type_installed) {
            if ($type_installed === false) {
                continue;
            }
            $methodInstallableType = 'getInstalled' . ucfirst((string) $type);
            $to_install = $this->$methodInstallableType();
            $items[$type] = $to_install;
            $items['total'] += count($to_install);
        }

        return $items;
    }

    /**
     * Returns the amount of locally installed packages
     *
     * @return int Amount of installed packages
     */
    public function countInstalled()
    {
        $installed = $this->getInstalled();

        return count($installed['plugins']) + count($installed['themes']);
    }

    /**
     * Return the instance of a specific Package
     *
     * @param  string $slug The slug of the Package
     * @return Local\Package|null The instance of the Package
     */
    public function getInstalledPackage($slug)
    {
        return $this->getInstalledPlugin($slug) ?? $this->getInstalledTheme($slug);
    }

    /**
     * Return the instance of a specific Plugin
     *
     * @param  string $slug The slug of the Plugin
     * @return Local\Package|null The instance of the Plugin
     */
    public function getInstalledPlugin($slug)
    {
        return $this->installed['plugins'][$slug] ?? null;
    }

    /**
     * Returns the Locally installed plugins
     * @return Iterator The installed plugins
     */
    public function getInstalledPlugins()
    {
        return $this->installed['plugins'];
    }


    /**
     * Returns the plugin's enabled state
     *
     * @param  string $slug
     * @return bool True if the Plugin is Enabled. False if manually set to enable:false. Null otherwise.
     */
    public function isPluginEnabled($slug): bool
    {
        $grav = Grav::instance();

        return ($grav['config']['plugins'][$slug]['enabled'] ?? false) === true;
    }

    /**
     * Checks if a Plugin is installed
     *
     * @param  string $slug The slug of the Plugin
     * @return bool True if the Plugin has been installed. False otherwise
     */
    public function isPluginInstalled($slug): bool
    {
        return isset($this->installed['plugins'][$slug]);
    }

    /**
     * @param string $slug
     * @return bool
     */
    public function isPluginInstalledAsSymlink($slug)
    {
        $plugin = $this->getInstalledPlugin($slug);

        return (bool)($plugin->symlink ?? false);
    }

    /**
     * Return the instance of a specific Theme
     *
     * @param  string $slug The slug of the Theme
     * @return Local\Package|null The instance of the Theme
     */
    public function getInstalledTheme($slug)
    {
        return $this->installed['themes'][$slug] ?? null;
    }

    /**
     * Returns the Locally installed themes
     *
     * @return Iterator The installed themes
     */
    public function getInstalledThemes()
    {
        return $this->installed['themes'];
    }

    /**
     * Checks if a Theme is enabled
     *
     * @param  string $slug The slug of the Theme
     * @return bool True if the Theme has been set to the default theme. False if installed, but not enabled. Null otherwise.
     */
    public function isThemeEnabled($slug): bool
    {
        $grav = Grav::instance();

        $current_theme = $grav['config']['system']['pages']['theme'] ?? null;

        return $current_theme === $slug;
    }

    /**
     * Checks if a Theme is installed
     *
     * @param  string $slug The slug of the Theme
     * @return bool True if the Theme has been installed. False otherwise
     */
    public function isThemeInstalled($slug): bool
    {
        return isset($this->installed['themes'][$slug]);
    }

    /**
     * Returns the amount of updates available
     *
     * @return int Amount of available updates
     */
    public function countUpdates()
    {
        return count($this->getUpdatablePlugins()) + count($this->getUpdatableThemes());
    }

    /**
     * Returns an array of Plugins and Themes that can be updated.
     * Plugins and Themes are extended with the `available` property that relies to the remote version
     *
     * @param array $list_type_update specifies what type of package to update
     * @return array Array of updatable Plugins and Themes.
     *               Format: ['total' => int, 'plugins' => array, 'themes' => array]
     */
    public function getUpdatable($list_type_update = ['plugins' => true, 'themes' => true])
    {
        $items = ['total' => 0];
        foreach ($list_type_update as $type => $type_updatable) {
            if ($type_updatable === false) {
                continue;
            }
            $methodUpdatableType = 'getUpdatable' . ucfirst((string) $type);
            $to_update = $this->$methodUpdatableType();
            $items[$type] = $to_update;
            $items['total'] += count($to_update);
        }

        return $items;
    }

    /**
     * Returns an array of Plugins that can be updated.
     * The Plugins are extended with the `available` property that relies to the remote version
     *
     * @return array Array of updatable Plugins
     */
    public function getUpdatablePlugins()
    {
        $items = [];

        $repository = $this->getRepository();
        if (null === $repository) {
            return $items;
        }

        $plugins = $repository['plugins'];

        // local cache to speed things up
        if (isset($this->cache[__METHOD__])) {
            return $this->cache[__METHOD__];
        }

        foreach ($this->installed['plugins'] as $slug => $plugin) {
            if (!isset($plugins[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {
                continue;
            }

            if (!$this->isRemotePackagePublished($plugins[$slug])) {
                continue;
            }

            $local_version = $plugin->version ?? 'Unknown';
            $remote_version = $plugins[$slug]->version;

            if (version_compare($local_version, $remote_version) < 0) {
                $plugins[$slug]->available = $remote_version;
                $plugins[$slug]->version = $local_version;
                $plugins[$slug]->type = $plugins[$slug]->release_type;
                $items[$slug] = $plugins[$slug];
            }
        }

        $this->cache[__METHOD__] = $items;

        return $items;
    }

    /**
     * Get the latest release of a package from the GPM
     *
     * @param string $package_name
     * @return string|null
     */
    public function getLatestVersionOfPackage($package_name)
    {
        $repository = $this->getRepository();
        if (null === $repository) {
            return null;
        }

        $plugins = $repository['plugins'];
        if (isset($plugins[$package_name])) {
            return $plugins[$package_name]->available ?: $plugins[$package_name]->version;
        }

        //Not a plugin, it's a theme?
        $themes = $repository['themes'];
        if (isset($themes[$package_name])) {
            return $themes[$package_name]->available ?: $themes[$package_name]->version;
        }

        return null;
    }

    /**
     * Get the `blocked_upgrade` hint the repository attaches to a down-served
     * package: the next release above the one being served, and the Grav/PHP
     * floor that keeps this install from receiving it. Null when the served
     * package is already the latest (no down-serving happened).
     *
     * @param string $package_name
     * @return array|null
     */
    public function getBlockedUpgrade($package_name)
    {
        $repository = $this->getRepository();
        if (null === $repository) {
            return null;
        }

        foreach (['plugins', 'themes'] as $type) {
            $packages = $repository[$type] ?? null;
            if ($packages && isset($packages[$package_name])) {
                $blocked = $packages[$package_name]->blocked_upgrade;

                return is_array($blocked) && $blocked ? $blocked : null;
            }
        }

        return null;
    }

    /**
     * Extract the lowest version-like token from a composer-style constraint
     * (`>=2.0.7` → `2.0.7`, `^8.1` → `8.1`). Returns null when there's no
     * usable version token.
     *
     * @param string|null $constraint
     * @return string|null
     */
    protected function extractMinConstraintVersion($constraint)
    {
        if (!is_string($constraint) || $constraint === '' || $constraint === '*') {
            return null;
        }

        if (!preg_match('/\d+(?:\.\d+){0,2}(?:[.-][a-zA-Z0-9.-]+)?/', $constraint, $m)) {
            return null;
        }

        return $m[0];
    }

    /**
     * Check if a Plugin or Theme is updatable
     *
     * @param  string $slug The slug of the package
     * @return bool True if updatable. False otherwise or if not found
     */
    public function isUpdatable($slug)
    {
        return $this->isPluginUpdatable($slug) || $this->isThemeUpdatable($slug);
    }

    /**
     * Checks if a Plugin is updatable
     *
     * @param  string $plugin The slug of the Plugin
     * @return bool True if the Plugin is updatable. False otherwise
     */
    public function isPluginUpdatable($plugin)
    {
        return array_key_exists($plugin, (array)$this->getUpdatablePlugins());
    }

    /**
     * Returns an array of Themes that can be updated.
     * The Themes are extended with the `available` property that relies to the remote version
     *
     * @return array Array of updatable Themes
     */
    public function getUpdatableThemes()
    {
        $items = [];

        $repository = $this->getRepository();
        if (null === $repository) {
            return $items;
        }

        $themes = $repository['themes'];

        // local cache to speed things up
        if (isset($this->cache[__METHOD__])) {
            return $this->cache[__METHOD__];
        }

        foreach ($this->installed['themes'] as $slug => $plugin) {
            if (!isset($themes[$slug]) || $plugin->symlink || !$plugin->version || $plugin->gpm === false) {
                continue;
            }

            if (!$this->isRemotePackagePublished($themes[$slug])) {
                continue;
            }

            $local_version = $plugin->version ?? 'Unknown';
            $remote_version = $themes[$slug]->version;

            if (version_compare($local_version, $remote_version) < 0) {
                $themes[$slug]->available = $remote_version;
                $themes[$slug]->version = $local_version;
                $themes[$slug]->type = $themes[$slug]->release_type;
                $items[$slug] = $themes[$slug];
            }
        }

        $this->cache[__METHOD__] = $items;

        return $items;
    }

    /**
     * Checks if a Theme is Updatable
     *
     * @param  string $theme The slug of the Theme
     * @return bool True if the Theme is updatable. False otherwise
     */
    public function isThemeUpdatable($theme)
    {
        return array_key_exists($theme, (array)$this->getUpdatableThemes());
    }

    /**
     * Get the release type of a package (stable / testing)
     *
     * @param string $package_name
     * @return string|null
     */
    public function getReleaseType($package_name)
    {
        $repository = $this->getRepository();
        if (null === $repository) {
            return null;
        }

        $plugins = $repository['plugins'];
        if (isset($plugins[$package_name])) {
            return $plugins[$package_name]->release_type;
        }

        //Not a plugin, it's a theme?
        $themes = $repository['themes'];
        if (isset($themes[$package_name])) {
            return $themes[$package_name]->release_type;
        }

        return null;
    }

    /**
     * Determine whether a remote package is marked as published.
     *
     * Remote package metadata introduced a `published` flag to hide releases that are not yet public.
     * Older repository payloads may omit the key, so we default to treating packages as published
     * unless the flag is explicitly set to `false`.
     *
     * @param object|array $package
     * @return bool
     */
    protected function isRemotePackagePublished($package): bool
    {
        if (is_object($package) && method_exists($package, 'getData')) {
            $data = $package->getData();
            if ($data instanceof Data) {
                $published = $data->get('published');
                return $published !== false;
            }
        }

        if (is_array($package)) {
            if (array_key_exists('published', $package)) {
                return $package['published'] !== false;
            }

            return true;
        }

        $value = null;
        if (is_object($package) && property_exists($package, 'published')) {
            $value = $package->published;
        }

        return $value !== false;
    }

    /**
     * Returns true if the package latest release is stable
     *
     * @param string $package_name
     * @return bool
     */
    public function isStableRelease($package_name)
    {
        return $this->getReleaseType($package_name) === 'stable';
    }

    /**
     * Returns true if the package latest release is testing
     *
     * @param string $package_name
     * @return bool
     */
    public function isTestingRelease($package_name)
    {
        $package = $this->getInstalledPackage($package_name);
        $testing = $package->testing ?? false;

        return $this->getReleaseType($package_name) === 'testing' || $testing;
    }

    /**
     * Returns a Plugin from the repository
     *
     * @param  string $slug The slug of the Plugin
     * @return Remote\Package|null  Package if found, NULL if not
     */
    public function getRepositoryPlugin($slug)
    {
        $packages = $this->getRepositoryPlugins();

        return $packages ? ($packages[$slug] ?? null) : null;
    }

    /**
     * Returns the list of Plugins available in the repository
     *
     * @return Iterator|null The Plugins remotely available
     */
    public function getRepositoryPlugins()
    {
        return $this->getRepository()['plugins'] ?? null;
    }

    /**
     * Returns a Theme from the repository
     *
     * @param  string $slug The slug of the Theme
     * @return Remote\Package|null  Package if found, NULL if not
     */
    public function getRepositoryTheme($slug)
    {
        $packages = $this->getRepositoryThemes();

        return $packages ? ($packages[$slug] ?? null) : null;
    }

    /**
     * Returns the list of Themes available in the repository
     *
     * @return Iterator|null The Themes remotely available
     */
    public function getRepositoryThemes()
    {
        return $this->getRepository()['themes'] ?? null;
    }

    /**
     * Returns the list of Plugins and Themes available in the repository
     *
     * @return Remote\Packages|null Available Plugins and Themes
     *               Format: ['plugins' => array, 'themes' => array]
     */
    public function getRepository()
    {
        if (null === $this->repository) {
            try {
                $this->repository = new Remote\Packages($this->refresh, $this->callback);
            } catch (Exception) {}
        }

        return $this->repository;
    }

    /**
     * Returns Grav version available in the repository
     *
     * @return Remote\GravCore|null
     */
    public function getGrav()
    {
        if (null === $this->grav) {
            try {
                $this->grav = new Remote\GravCore($this->refresh, $this->callback);
            } catch (Exception) {}
        }

        return $this->grav;
    }

    /**
     * Searches for a Package in the repository
     *
     * @param  string $search Can be either the slug or the name
     * @param  bool $ignore_exception True if should not fire an exception (for use in Twig)
     * @return Remote\Package|false Package if found, FALSE if not
     */
    public function findPackage($search, $ignore_exception = false)
    {
        $search = strtolower($search);

        $found = $this->getRepositoryPlugin($search) ?? $this->getRepositoryTheme($search);
        if ($found) {
            return $found;
        }

        $themes = $this->getRepositoryThemes();
        $plugins = $this->getRepositoryPlugins();

        if (null === $themes || null === $plugins) {
            $gpm_cache = Grav::instance()['locator']->findResource('cache://gpm', true, true);
            if (!is_writable($gpm_cache)) {
                throw new RuntimeException('The cache/gpm folder is not writable. Please check the folder permissions.');
            }

            if ($ignore_exception) {
                return false;
            }

            throw new RuntimeException('GPM not reachable. Please check your internet connection or check the Grav site is reachable');
        }

        foreach ($themes as $slug => $theme) {
            if ($search === $slug || $search === $theme->name) {
                return $theme;
            }
        }

        foreach ($plugins as $slug => $plugin) {
            if ($search === $slug || $search === $plugin->name) {
                return $plugin;
            }
        }

        return false;
    }

    /**
     * Download the zip package via the URL
     *
     * @param string $package_file
     * @param string $tmp
     * @return string|null
     */
    public static function downloadPackage($package_file, $tmp)
    {
        $package = parse_url($package_file);
        if (!is_array($package)) {
            throw new \RuntimeException("Malformed GPM URL: {$package_file}");
        }

        $filename = Utils::basename($package['path'] ?? '');

        if (Grav::instance()['config']->get('system.gpm.official_gpm_only') && !static::isOfficialUrl($package_file)) {
            throw new RuntimeException('Only official GPM URLs are allowed. You can modify this behavior in the System configuration.');
        }

        $output = Response::get($package_file, []);

        if ($output) {
            Folder::create($tmp);
            file_put_contents($tmp . DS . $filename, $output);
            return $tmp . DS . $filename;
        }

        return null;
    }

    /**
     * Is the URL served by the official GPM repository?
     *
     * The host name only identifies the server once TLS has authenticated it,
     * so a plain http:// URL is not official whatever host it names.
     *
     * @param string $url
     * @return bool
     */
    public static function isOfficialUrl($url): bool
    {
        $parts = parse_url((string)$url);

        return is_array($parts)
            && strtolower($parts['scheme'] ?? '') === 'https'
            && strtolower($parts['host'] ?? '') === 'getgrav.org'
            && ($parts['port'] ?? 443) === 443;
    }

    /**
     * Copy the local zip package to tmp
     *
     * @param string $package_file
     * @param string $tmp
     * @return string|null
     */
    public static function copyPackage($package_file, $tmp)
    {
        $package_file = realpath($package_file);

        if ($package_file && file_exists($package_file)) {
            $filename = Utils::basename($package_file);
            Folder::create($tmp);
            copy($package_file, $tmp . DS . $filename);
            return $tmp . DS . $filename;
        }

        return null;
    }

    /**
     * Try to guess the package type from the source files
     *
     * @param string $source
     * @return string|false
     */
    public static function getPackageType($source)
    {
        $plugin_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Plugin/m';
        $theme_regex = '/^class\\s{1,}[a-zA-Z0-9]{1,}\\s{1,}extends.+Theme/m';

        if (file_exists($source . 'system/defines.php') &&
            file_exists($source . 'system/config/system.yaml')
        ) {
            return 'grav';
        }

        // must have a blueprint
        if (!file_exists($source . 'blueprints.yaml')) {
            return false;
        }

        // either theme or plugin
        $name = Utils::basename($source);
        if (Utils::contains($name, 'theme')) {
            return 'theme';
        }
        if (Utils::contains($name, 'plugin')) {
            return 'plugin';
        }

        $glob = glob($source . '*.php') ?: [];
        foreach ($glob as $filename) {
            $contents = file_get_contents($filename);
            if (!$contents) {
                continue;
            }
            if (preg_match($theme_regex, $contents)) {
                return 'theme';
            }
            if (preg_match($plugin_regex, $contents)) {
                return 'plugin';
            }
        }

        // Assume it's a theme
        return 'theme';
    }

    /**
     * Try to guess the package name from the source files
     *
     * @param string $source
     * @return string|false
     */
    public static function getPackageName($source)
    {
        $ignore_yaml_files = ['blueprints', 'languages'];

        $glob = glob($source . '*.yaml') ?: [];
        foreach ($glob as $filename) {
            $name = strtolower(Utils::basename($filename, '.yaml'));
            if (in_array($name, $ignore_yaml_files)) {
                continue;
            }

            return $name;
        }

        return false;
    }

    /**
     * Find/Parse the blueprint file
     *
     * @param string $source
     * @return array|false
     */
    public static function getBlueprints($source)
    {
        $blueprint_file = $source . 'blueprints.yaml';
        if (!file_exists($blueprint_file)) {
            return false;
        }

        $file = YamlFile::instance($blueprint_file);
        $blueprint = (array)$file->content();
        $file->free();

        return $blueprint;
    }

    /**
     * Get the install path for a name and a particular type of package
     *
     * @param string $type
     * @param string $name
     * @return string
     */
    public static function getInstallPath($type, $name)
    {
        $locator = Grav::instance()['locator'];

        if ($type === 'theme') {
            $install_path = $locator->findResource('themes://', false) . DS . $name;
        } else {
            $install_path = $locator->findResource('plugins://', false) . DS . $name;
        }

        return $install_path;
    }

    /**
     * Searches for a list of Packages in the repository
     *
     * @param  array $searches An array of either slugs or names
     * @return array Array of found Packages
     *                        Format: ['total' => int, 'not_found' => array, <found-slugs>]
     */
    public function findPackages($searches = [])
    {
        $packages = ['total' => 0, 'not_found' => []];
        $inflector = new Inflector();

        foreach ($searches as $search) {
            $repository = '';
            // if this is an object, get the search data from the key
            if (is_object($search)) {
                $search = (array)$search;
                $key = key($search);
                $repository = $search[$key];
                $search = $key;
            }

            $found = $this->findPackage($search);
            if ($found) {
                // set override repository if provided
                if ($repository) {
                    $found->override_repository = $repository;
                }
                if (!isset($packages[$found->package_type])) {
                    $packages[$found->package_type] = [];
                }

                $packages[$found->package_type][$found->slug] = $found;
                $packages['total']++;
            } else {
                // make a best guess at the type based on the repo URL
                if (Utils::contains($repository, '-theme')) {
                    $type = 'themes';
                } else {
                    $type = 'plugins';
                }

                $not_found = new stdClass();
                $not_found->name = $inflector::camelize($search);
                $not_found->slug = $search;
                $not_found->package_type = $type;
                $not_found->install_path = str_replace('%name%', $search, $this->install_paths[$type]);
                $not_found->override_repository = $repository;
                $packages['not_found'][$search] = $not_found;
            }
        }

        return $packages;
    }

    /**
     * Return the list of packages that have the passed one as dependency
     *
     * @param string $slug The slug name of the package
     * @return array
     */
    public function getPackagesThatDependOnPackage($slug)
    {
        $plugins = $this->getInstalledPlugins();
        $themes = $this->getInstalledThemes();
        $packages = array_merge($plugins->toArray(), $themes->toArray());

        $list = [];
        foreach ($packages as $package_name => $package) {
            $dependencies = $package['dependencies'] ?? [];
            foreach ($dependencies as $dependency) {
                if (is_array($dependency) && isset($dependency['name'])) {
                    $dependency = $dependency['name'];
                }

                if ($dependency === $slug) {
                    $list[] = $package_name;
                }
            }
        }

        return $list;
    }


    /**
     * Get the required version of a dependency of a package
     *
     * @param string $package_slug
     * @param string $dependency_slug
     * @return mixed|null
     */
    public function getVersionOfDependencyRequiredByPackage($package_slug, $dependency_slug)
    {
        $dependencies = $this->getInstalledPackage($package_slug)->dependencies ?? [];
        foreach ($dependencies as $dependency) {
            if (isset($dependency[$dependency_slug])) {
                return $dependency[$dependency_slug];
            }
        }

        return null;
    }

    /**
     * Check the package identified by $slug can be updated to the version passed as argument.
     * Thrown an exception if it cannot be updated because another package installed requires it to be at an older version.
     *
     * @param string $slug
     * @param string $version_with_operator
     * @param array $ignore_packages_list
     * @return bool
     * @throws RuntimeException
     */
    public function checkNoOtherPackageNeedsThisDependencyInALowerVersion($slug, $version_with_operator, $ignore_packages_list)
    {
        // check if any of the currently installed package need this in a lower version than the one we need. In case, abort and tell which package
        $dependent_packages = $this->getPackagesThatDependOnPackage($slug);
        $version = $this->calculateVersionNumberFromDependencyVersion($version_with_operator);

        if (count($dependent_packages)) {
            foreach ($dependent_packages as $dependent_package) {
                $other_dependency_version_with_operator = $this->getVersionOfDependencyRequiredByPackage($dependent_package, $slug);
                $other_dependency_version = $this->calculateVersionNumberFromDependencyVersion($other_dependency_version_with_operator);

                // check version is compatible with the one needed by the current package
                if ($this->versionFormatIsNextSignificantRelease($other_dependency_version_with_operator)) {
                    $compatible = $this->checkNextSignificantReleasesAreCompatible($version, $other_dependency_version);
                    if (!$compatible && !in_array($dependent_package, $ignore_packages_list, true)) {
                        throw new RuntimeException(
                            "Package <cyan>$slug</cyan> is required in an older version by package <cyan>$dependent_package</cyan>. This package needs a newer version, and because of this it cannot be installed. The <cyan>$dependent_package</cyan> package must be updated to use a newer release of <cyan>$slug</cyan>.",
                            2
                        );
                    }
                }
            }
        }

        return true;
    }

    /**
     * Check the passed packages list can be updated
     *
     * @param array $packages_names_list
     * @return void
     * @throws Exception
     */
    public function checkPackagesCanBeInstalled($packages_names_list)
    {
        foreach ($packages_names_list as $package_name) {
            $latest = $this->getLatestVersionOfPackage($package_name);
            $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion($package_name, $latest, $packages_names_list);
        }
    }

    /**
     * The Grav generation this install belongs to, as the `compatibility` field
     * in a blueprint spells it: '1.7' for Grav 1.x, '2.0' for Grav 2.x.
     *
     * @return string
     */
    public static function gravGeneration(): string
    {
        return ((int)GRAV_VERSION) >= 2 ? '2.0' : '1.7';
    }

    /**
     * Whether a package declares itself compatible with the Grav running now.
     *
     * A package that declares nothing is treated as compatible, because roughly
     * two thirds of the repository declares nothing and refusing those would
     * break far more than it fixed. Only an explicit list that leaves this
     * generation out counts as incompatible.
     *
     * @param  mixed $compatibility The blueprint's `compatibility.grav` value.
     * @return bool
     */
    public static function declaresGravCompatibility($compatibility): bool
    {
        $declared = is_array($compatibility) ? ($compatibility['grav'] ?? $compatibility) : $compatibility;
        if (!$declared) {
            return true;
        }

        $running = (int)GRAV_VERSION;
        foreach ((array)$declared as $generation) {
            if ((int)$generation === $running) {
                return true;
            }
        }

        return false;
    }

    /**
     * Whether a dependency could be installed on this Grav at all.
     *
     * False in two situations, and both mean the same thing to a caller: the
     * repository does not serve the package to this install (the download feed
     * filters on each blueprint's own `compatibility`, so a package built for
     * another Grav generation is simply absent from the index), or it is in the
     * index and names generations that do not include this one.
     *
     * The classic `admin` plugin is the case this exists for: it is Grav 1.7
     * only, Grav 2 ships admin2 instead, and a plugin compatible with both
     * generations has no way to say "admin, but only on 1.7". Such a
     * requirement is unsatisfiable here rather than unmet, so it is dropped
     * instead of failing the install (getgrav/grav-premium-issues#618).
     *
     * @param  string $slug
     * @return bool
     */
    /**
     * Rewrite dependencies that name the admin panel under the slug the running
     * generation of Grav actually ships.
     *
     * A plugin that supports both 1.7 and 2.0 and asks for `admin >= 1.10.x` is
     * saying "I need the admin panel". On Grav 2 that is `admin2`, so the
     * requirement is met by admin2 rather than being unsatisfiable. The version
     * constraint is deliberately dropped: 1.10.x describes the classic admin's
     * numbering and says nothing about admin2's, so carrying it over would
     * compare two unrelated version lines. Without a constraint the requirement
     * is simply "the admin panel is present", which is true on any Grav 2 site
     * that has one — and installable when it is not
     * (getgrav/grav-premium-issues#618).
     *
     * This mirrors the `replaced_by` record the compatibility registry already
     * holds for `admin`; it is repeated here because dependency resolution must
     * not depend on a network lookup.
     *
     * @param  array $dependencies slug => version-constraint
     * @return array
     */
    protected function remapGenerationDependencies(array $dependencies): array
    {
        if (self::gravGeneration() !== '2.0' || !isset($dependencies['admin'])) {
            return $dependencies;
        }

        unset($dependencies['admin']);

        // Already required in its own right, or already there: nothing to add.
        if (!isset($dependencies['admin2']) && !$this->isPluginInstalled('admin2')) {
            $dependencies['admin2'] = '*';
        }

        return $dependencies;
    }

    public function dependencyIsInstallable(string $slug): bool
    {
        $package = $this->findPackage($slug, true);
        if (!$package) {
            return false;
        }

        return self::declaresGravCompatibility($package->compatibility ?? null);
    }

    /**
     * Fetch the dependencies, check the installed packages and return an array with
     * the list of packages with associated an information on what to do: install, update or ignore.
     *
     * `ignore` means the package is already installed and can be safely left as-is.
     * `install` means the package is not installed and must be installed.
     * `update` means the package is already installed and must be updated as a dependency needs a higher version.
     *
     * A dependency that cannot be installed on this Grav generation at all is
     * dropped from the result entirely, the same way `grav`, `php` and
     * symlinked plugins are.
     *
     * @param array $packages
     * @return array
     * @throws RuntimeException
     */
    public function getDependencies($packages)
    {
        $dependencies = $this->calculateMergedDependenciesOfPackages($packages);
        $dependencies = $this->remapGenerationDependencies($dependencies);
        foreach ($dependencies as $dependency_slug => $dependencyVersionWithOperator) {
            $dependency_slug = (string)$dependency_slug;
            if (in_array($dependency_slug, $packages, true)) {
                unset($dependencies[$dependency_slug]);
                continue;
            }

            // Check PHP version
            if ($dependency_slug === 'php') {
                $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);
                if (version_compare($testVersion, PHP_VERSION) === 1) {
                    //Needs a Grav update first
                    throw new RuntimeException("<red>One of the packages require PHP {$dependencies['php']}. Please update PHP to resolve this");
                }

                unset($dependencies[$dependency_slug]);
                continue;
            }

            //First, check for Grav dependency. If a dependency requires Grav > the current version, abort and tell.
            if ($dependency_slug === 'grav') {
                $testVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);
                if (version_compare($testVersion, GRAV_VERSION) === 1) {
                    //Needs a Grav update first
                    throw new RuntimeException("<red>One of the packages require Grav {$dependencies['grav']}. Please update Grav to the latest release.");
                }

                unset($dependencies[$dependency_slug]);
                continue;
            }

            if ($this->isPluginInstalled($dependency_slug)) {
                if ($this->isPluginInstalledAsSymlink($dependency_slug)) {
                    unset($dependencies[$dependency_slug]);
                    continue;
                }

                $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);

                // get currently installed version
                $locator = Grav::instance()['locator'];
                $blueprints_path = $locator->findResource('plugins://' . $dependency_slug . DS . 'blueprints.yaml');
                $file = YamlFile::instance($blueprints_path);
                $package_yaml = $file->content();
                $file->free();
                $currentlyInstalledVersion = $package_yaml['version'];

                // if requirement is next significant release, check is compatible with currently installed version, might not be
                if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)
                    && $this->firstVersionIsLower($dependencyVersion, $currentlyInstalledVersion)) {
                    $compatible = $this->checkNextSignificantReleasesAreCompatible($dependencyVersion, $currentlyInstalledVersion);

                    if (!$compatible) {
                        throw new RuntimeException(
                            'Dependency <cyan>' . $dependency_slug . '</cyan> is required in an older version than the one installed. This package must be updated. Please get in touch with its developer.',
                            2
                        );
                    }
                }

                //if I already have the latest release, remove the dependency
                $latestRelease = $this->getLatestVersionOfPackage($dependency_slug);

                if ($this->firstVersionIsLower($latestRelease, $dependencyVersion)) {
                    // The required version is newer than anything the GPM will
                    // serve this install. The usual reason is that the newer
                    // release raised its own Grav/PHP floor above what's running,
                    // so the repository down-served an older one. When that's the
                    // case the served package carries a `blocked_upgrade` hint —
                    // use it to explain the real fix instead of guessing the
                    // cache is stale.
                    $blocked = $this->getBlockedUpgrade($dependency_slug);
                    if ($blocked
                        && isset($blocked['version'])
                        && !$this->firstVersionIsLower($blocked['version'], $dependencyVersion)) {
                        $needs = [];
                        $gravMin = $this->extractMinConstraintVersion($blocked['grav'] ?? null);
                        if ($gravMin && version_compare($gravMin, GRAV_VERSION, '>')) {
                            $needs['Grav'] = $gravMin;
                        }
                        $phpMin = $this->extractMinConstraintVersion($blocked['php'] ?? null);
                        if ($phpMin && version_compare($phpMin, PHP_VERSION, '>')) {
                            $needs['PHP'] = $phpMin;
                        }

                        if ($needs) {
                            $requires = [];
                            $current = ['Grav' => GRAV_VERSION, 'PHP' => PHP_VERSION];
                            foreach ($needs as $what => $min) {
                                $requires[] = "<cyan>{$what} {$min}</cyan> (you have {$current[$what]})";
                            }

                            throw new RuntimeException(
                                'Dependency <cyan>' . $package_yaml['name'] . '</cyan> requires version <cyan>'
                                . $dependencyVersion . '</cyan>, but <cyan>' . $dependency_slug . ' '
                                . $blocked['version'] . '</cyan> requires ' . implode(' and ', $requires)
                                . '. Update ' . implode(' and ', array_keys($needs)) . ' first, then try again.',
                                1
                            );
                        }
                    }

                    //throw an exception if a required version cannot be found in the GPM yet
                    throw new RuntimeException(
                        'Dependency <cyan>' . $package_yaml['name'] . '</cyan> is required in version <cyan>' . $dependencyVersion . '</cyan> which is higher than the latest release, <cyan>' . $latestRelease . '</cyan>. Try running `bin/gpm -f index` to force a refresh of the GPM cache',
                        1
                    );
                }

                if ($this->firstVersionIsLower($currentlyInstalledVersion, $dependencyVersion)) {
                    $dependencies[$dependency_slug] = 'update';
                } elseif ($currentlyInstalledVersion === $latestRelease) {
                    unset($dependencies[$dependency_slug]);
                } else {
                    // an update is not strictly required mark as 'ignore'
                    $dependencies[$dependency_slug] = 'ignore';
                }
            } else {
                // Not installed, and not installable on this Grav generation:
                // drop the requirement rather than trying and failing. The
                // classic `admin` plugin on Grav 2 is the case that matters —
                // see dependencyIsInstallable().
                if (!$this->dependencyIsInstallable($dependency_slug)) {
                    unset($dependencies[$dependency_slug]);
                    continue;
                }

                $dependencyVersion = $this->calculateVersionNumberFromDependencyVersion($dependencyVersionWithOperator);

                // if requirement is next significant release, check is compatible with latest available version, might not be
                if ($this->versionFormatIsNextSignificantRelease($dependencyVersionWithOperator)) {
                    $latestVersionOfPackage = $this->getLatestVersionOfPackage($dependency_slug);
                    if ($this->firstVersionIsLower($dependencyVersion, $latestVersionOfPackage)) {
                        $compatible = $this->checkNextSignificantReleasesAreCompatible(
                            $dependencyVersion,
                            $latestVersionOfPackage
                        );

                        if (!$compatible) {
                            throw new RuntimeException(
                                'Dependency <cyan>' . $dependency_slug . '</cyan> is required in an older version than the latest release available, and it cannot be installed. This package must be updated. Please get in touch with its developer.',
                                2
                            );
                        }
                    }
                }

                $dependencies[$dependency_slug] = 'install';
            }
        }

        $dependencies_slugs = array_keys($dependencies);
        $this->checkNoOtherPackageNeedsTheseDependenciesInALowerVersion(array_merge($packages, $dependencies_slugs));

        return $dependencies;
    }

    /**
     * @param array $dependencies_slugs
     * @return void
     */
    public function checkNoOtherPackageNeedsTheseDependenciesInALowerVersion($dependencies_slugs)
    {
        foreach ($dependencies_slugs as $dependency_slug) {
            $this->checkNoOtherPackageNeedsThisDependencyInALowerVersion(
                $dependency_slug,
                $this->getLatestVersionOfPackage($dependency_slug),
                $dependencies_slugs
            );
        }
    }

    /**
     * @param string $firstVersion
     * @param string $secondVersion
     * @return bool
     */
    private function firstVersionIsLower($firstVersion, $secondVersion)
    {
        return version_compare($firstVersion, $secondVersion) === -1;
    }

    /**
     * Calculates and merges the dependencies of a package
     *
     * @param string $packageName The package information
     * @param array $dependencies The dependencies array
     * @return array
     */
    /**
     * Keep only the dependency entries that apply to the Grav running now.
     *
     * An entry may carry an optional `grav` key naming the generation(s) it is
     * for, spelled the way `compatibility.grav` spells them:
     *
     *     dependencies:
     *       - { name: grav, version: '>=2.0.25' }
     *       - { name: form, version: '>=7.0.0', grav: '1.7' }
     *       - { name: form, version: '>=9.1.0', grav: '2.0' }
     *
     * An entry with no `grav` key applies everywhere, so every blueprint
     * written before this existed behaves exactly as it did.
     *
     * Where a package has both a qualified entry for this generation and an
     * unqualified one, the qualified entry wins outright rather than being
     * merged with it. Merging takes the higher of two versions, which would
     * silently ignore a deliberately lower requirement for this generation.
     *
     * Note for anyone adding a qualified entry: a Grav older than the one that
     * introduced this reads `name` and `version` and ignores `grav` entirely,
     * so it would apply every entry unconditionally and merge them to the
     * highest version. Declare a `grav` dependency naming a core new enough to
     * understand this, and list it before any qualified entry, so such an
     * install stops with "please update Grav" instead.
     *
     * @param  array $dependencies Raw blueprint `dependencies` entries.
     * @return array
     */
    protected function filterDependenciesForGeneration($dependencies): array
    {
        $generation = self::gravGeneration();

        $applies = [];
        $qualified = [];
        foreach ((array)$dependencies as $dependency) {
            if (!is_array($dependency)) {
                $applies[] = $dependency;
                continue;
            }

            $for = $dependency['grav'] ?? null;
            if ($for === null || $for === '' || $for === []) {
                $applies[] = $dependency;
                continue;
            }

            foreach ((array)$for as $named) {
                if ((int)$named === (int)$generation) {
                    $applies[] = $dependency;
                    if (isset($dependency['name'])) {
                        $qualified[$dependency['name']] = true;
                    }
                    break;
                }
            }
        }

        if (!$qualified) {
            return $applies;
        }

        // Drop the unqualified entries for any package that also named this
        // generation explicitly.
        return array_values(array_filter($applies, static function ($dependency) use ($qualified) {
            if (!is_array($dependency) || !isset($dependency['name'])) {
                return true;
            }

            return !isset($qualified[$dependency['name']]) || isset($dependency['grav']);
        }));
    }

    private function calculateMergedDependenciesOfPackage($packageName, $dependencies)
    {
        $packageData = $this->findPackage($packageName);

        // findPackage() returns false when the index has no such package, and
        // reading a property off that is a PHP 8 warning before empty() ever
        // sees it.
        if (!$packageData || empty($packageData->dependencies)) {
            return $dependencies;
        }

        foreach ($this->filterDependenciesForGeneration($packageData->dependencies) as $dependency) {
            $dependencyName = $dependency['name'] ?? null;
            if (!$dependencyName) {
                continue;
            }

            $dependencyVersion = $dependency['version'] ?? '*';

            if (!isset($dependencies[$dependencyName])) {
                // Dependency added for the first time
                $dependencies[$dependencyName] = $dependencyVersion;

                //Factor in the package dependencies too
                $dependencies = $this->calculateMergedDependenciesOfPackage($dependencyName, $dependencies);
            } elseif ($dependencyVersion !== '*') {
                // Dependency already added by another package
                // If this package requires a version higher than the currently stored one, store this requirement instead
                $currentDependencyVersion = $dependencies[$dependencyName];
                $currently_stored_version_number = $this->calculateVersionNumberFromDependencyVersion($currentDependencyVersion);

                $currently_stored_version_is_in_next_significant_release_format = false;
                if ($this->versionFormatIsNextSignificantRelease($currentDependencyVersion)) {
                    $currently_stored_version_is_in_next_significant_release_format = true;
                }

                if (!$currently_stored_version_number) {
                    $currently_stored_version_number = '*';
                }

                $current_package_version_number = $this->calculateVersionNumberFromDependencyVersion($dependencyVersion);
                if (!$current_package_version_number) {
                    throw new RuntimeException("Bad format for version of dependency {$dependencyName} for package {$packageName}", 1);
                }

                $current_package_version_is_in_next_significant_release_format = false;
                if ($this->versionFormatIsNextSignificantRelease($dependencyVersion)) {
                    $current_package_version_is_in_next_significant_release_format = true;
                }

                //If I had stored '*', change right away with the more specific version required
                if ($currently_stored_version_number === '*') {
                    $dependencies[$dependencyName] = $dependencyVersion;
                } elseif (!$currently_stored_version_is_in_next_significant_release_format && !$current_package_version_is_in_next_significant_release_format) {
                    //Comparing versions equals or higher, a simple version_compare is enough
                    if (version_compare($currently_stored_version_number, $current_package_version_number) === -1) {
                        //Current package version is higher
                        $dependencies[$dependencyName] = $dependencyVersion;
                    }
                } else {
                    $compatible = $this->checkNextSignificantReleasesAreCompatible($currently_stored_version_number, $current_package_version_number);
                    if (!$compatible) {
                        throw new RuntimeException("Dependency {$dependencyName} is required in two incompatible versions", 2);
                    }
                }
            }
        }

        return $dependencies;
    }

    /**
     * Calculates and merges the dependencies of the passed packages
     *
     * @param array $packages
     * @return array
     */
    public function calculateMergedDependenciesOfPackages($packages)
    {
        $dependencies = [];

        foreach ($packages as $package) {
            $dependencies = $this->calculateMergedDependenciesOfPackage($package, $dependencies);
        }

        return $dependencies;
    }

    /**
     * Returns the actual version from a dependency version string.
     * Examples:
     *      $versionInformation == '~2.0' => returns '2.0'
     *      $versionInformation == '>=2.0.2' => returns '2.0.2'
     *      $versionInformation == '2.0.2' => returns '2.0.2'
     *      $versionInformation == '*' => returns null
     *      $versionInformation == '' => returns null
     *
     * @param string $version
     * @return string|null
     */
    public function calculateVersionNumberFromDependencyVersion($version)
    {
        if ($version === '*') {
            return null;
        }
        if ($version === '') {
            return null;
        }
        if ($this->versionFormatIsNextSignificantRelease($version)) {
            return trim(substr($version, 1));
        }
        if ($this->versionFormatIsEqualOrHigher($version)) {
            return trim(substr($version, 2));
        }

        return $version;
    }

    /**
     * Check if the passed version information contains next significant release (tilde) operator
     *
     * Example: returns true for $version: '~2.0'
     *
     * @param string $version
     * @return bool
     */
    public function versionFormatIsNextSignificantRelease($version): bool
    {
        return str_starts_with($version, '~');
    }

    /**
     * Check if the passed version information contains equal or higher operator
     *
     * Example: returns true for $version: '>=2.0'
     *
     * @param string $version
     * @return bool
     */
    public function versionFormatIsEqualOrHigher($version): bool
    {
        return str_starts_with($version, '>=');
    }

    /**
     * Check if two releases are compatible by next significant release
     *
     * ~1.2 is equivalent to >=1.2 <2.0.0
     * ~1.2.3 is equivalent to >=1.2.3 <1.3.0
     *
     * In short, allows the last digit specified to go up
     *
     * @param string $version1 the version string (e.g. '2.0.0' or '1.0')
     * @param string $version2 the version string (e.g. '2.0.0' or '1.0')
     * @return bool
     */
    public function checkNextSignificantReleasesAreCompatible($version1, $version2): bool
    {
        $version1array = explode('.', $version1);
        $version2array = explode('.', $version2);

        if (count($version1array) > count($version2array)) {
            [$version1array, $version2array] = [$version2array, $version1array];
        }

        $i = 0;
        while ($i < count($version1array) - 1) {
            if ($version1array[$i] !== $version2array[$i]) {
                return false;
            }
            $i++;
        }

        return true;
    }
}
