<?php
declare(strict_types=1);

/**
 * CakePHP(tm) : Rapid Development Framework (https://cakephp.org)
 * Copyright 2005-2011, Cake Software Foundation, Inc. (https://cakefoundation.org)
 *
 * Licensed under The MIT License
 * Redistributions of files must retain the above copyright notice.
 *
 * @copyright     Copyright (c) Cake Software Foundation, Inc. (https://cakefoundation.org)
 * @link          https://cakephp.org CakePHP(tm) Project
 * @since         3.6.0
 * @license       https://opensource.org/licenses/mit-license.php MIT License
 */
namespace Cake\Core;

use Cake\Core\Exception\CakeException;
use Cake\Core\Exception\MissingPluginException;
use Cake\Utility\Hash;
use Countable;
use Generator;
use InvalidArgumentException;
use Iterator;

/**
 * Plugin Collection
 *
 * Holds onto plugin objects loaded into an application, and
 * provides methods for iterating, and finding plugins based
 * on criteria.
 *
 * This class implements the Iterator interface to allow plugins
 * to be iterated, handling the situation where a plugin's hook
 * method (usually bootstrap) loads another plugin during iteration.
 *
 * While its implementation supported nested iteration it does not
 * support using `continue` or `break` inside loops.
 *
 * @template-implements \Iterator<string, \Cake\Core\PluginInterface>
 */
class PluginCollection implements Iterator, Countable
{
    /**
     * Plugin list
     *
     * @var array<string, \Cake\Core\PluginInterface>
     */
    protected array $plugins = [];

    /**
     * Names of plugins
     *
     * @var array<string>
     */
    protected array $names = [];

    /**
     * Iterator position stack.
     *
     * @var array<int>
     */
    protected array $positions = [];

    /**
     * Loop depth
     *
     * @var int
     */
    protected int $loopDepth = -1;

    /**
     * Constructor
     *
     * @param array<\Cake\Core\PluginInterface> $plugins The map of plugins to add to the collection.
     */
    public function __construct(array $plugins = [])
    {
        foreach ($plugins as $plugin) {
            $this->add($plugin);
        }
        PluginConfig::loadInstallerConfig();
    }

    /**
     * Add plugins from config array.
     *
     * @param array $config Configuration array. For e.g.:
     *   ```
     *   [
     *       'Company/TestPluginThree',
     *       'TestPlugin' => ['onlyDebug' => true, 'onlyCli' => true],
     *       'Nope' => ['optional' => true],
     *       'Named' => ['routes' => false, 'bootstrap' => false],
     *   ]
     *   ```
     * @return void
     */
    public function addFromConfig(array $config): void
    {
        $notDebug = !Configure::read('debug');
        $notCli = PHP_SAPI !== 'cli';

        /** @var array{onlyDebug?: bool, onlyCli?: bool, optional?: bool} $options */
        foreach (Hash::normalize($config, default: []) as $name => $options) {
            $onlyDebug = $options['onlyDebug'] ?? false;
            $onlyCli = $options['onlyCli'] ?? false;
            $optional = $options['optional'] ?? false;

            if (
                ($onlyDebug && $notDebug)
                || ($onlyCli && $notCli)
            ) {
                continue;
            }

            try {
                $plugin = $this->create($name, $options);
                $this->add($plugin);
            } catch (MissingPluginException $e) {
                if (!$optional) {
                    throw $e;
                }
            }
        }
    }

    /**
     * Locate a plugin path by looking at configuration data.
     *
     * This will use the `plugins` Configure key, and fallback to enumerating `App::path('plugins')`
     *
     * This method is not part of the official public API as plugins with
     * no plugin class are being phased out.
     *
     * @param string $name The plugin name to locate a path for.
     * @return string
     * @throws \Cake\Core\Exception\MissingPluginException when a plugin path cannot be resolved.
     * @internal
     */
    public function findPath(string $name): string
    {
        // Ensure plugin config is loaded each time. This is necessary primarily
        // for testing because the Configure::clear() call in TestCase::tearDown()
        // wipes out all configuration including plugin paths config.
        PluginConfig::loadInstallerConfig();

        /** @var string|null $path */
        $path = Configure::read('plugins.' . $name);
        if ($path) {
            return $path;
        }

        $pluginPath = str_replace('/', DIRECTORY_SEPARATOR, $name);
        $paths = App::path('plugins');
        foreach ($paths as $path) {
            if (is_dir($path . $pluginPath)) {
                return $path . $pluginPath . DIRECTORY_SEPARATOR;
            }
        }

        throw new MissingPluginException(['plugin' => $name]);
    }

    /**
     * Add a plugin to the collection
     *
     * Plugins will be keyed by their names.
     *
     * @param \Cake\Core\PluginInterface $plugin The plugin to load.
     * @return $this
     */
    public function add(PluginInterface $plugin)
    {
        $name = $plugin->getName();
        if (isset($this->plugins[$name])) {
            throw new CakeException(sprintf('Plugin named `%s` is already loaded', $name));
        }

        $this->plugins[$name] = $plugin;
        $this->names = array_keys($this->plugins);

        return $this;
    }

    /**
     * Remove a plugin from the collection if it exists.
     *
     * @param string $name The named plugin.
     * @return $this
     */
    public function remove(string $name)
    {
        unset($this->plugins[$name]);
        $this->names = array_keys($this->plugins);

        return $this;
    }

    /**
     * Remove all plugins from the collection
     *
     * @return $this
     */
    public function clear()
    {
        $this->plugins = [];
        $this->names = [];
        $this->positions = [];
        $this->loopDepth = -1;

        return $this;
    }

    /**
     * Check whether the named plugin exists in the collection.
     *
     * @param string $name The named plugin.
     * @return bool
     */
    public function has(string $name): bool
    {
        return isset($this->plugins[$name]);
    }

    /**
     * Get the a plugin by name.
     *
     * If a plugin isn't already loaded it will be autoloaded on first access
     * and that plugins loaded this way may miss some hook methods.
     *
     * @param string $name The plugin to get.
     * @return \Cake\Core\PluginInterface The plugin.
     * @throws \Cake\Core\Exception\MissingPluginException when unknown plugins are fetched.
     */
    public function get(string $name): PluginInterface
    {
        if ($this->has($name)) {
            return $this->plugins[$name];
        }

        $plugin = $this->create($name);
        $this->add($plugin);

        return $plugin;
    }

    /**
     * Create a plugin instance from a name/classname and configuration.
     *
     * @param string $name The plugin name or classname
     * @param array<string, mixed> $config Configuration options for the plugin.
     * @return \Cake\Core\PluginInterface
     * @throws \Cake\Core\Exception\MissingPluginException When plugin instance could not be created.
     * @throws \InvalidArgumentException When class name cannot be found or an empty name is provided.
     * @phpstan-param class-string<\Cake\Core\PluginInterface>|string $name
     */
    public function create(string $name, array $config = []): PluginInterface
    {
        if ($name === '') {
            throw new InvalidArgumentException('Plugin name cannot be empty.');
        }

        if (str_contains($name, '\\')) {
            if (!class_exists($name)) {
                throw new InvalidArgumentException(sprintf('Class `%s` does not exist.', $name));
            }

            /** @var \Cake\Core\PluginInterface */
            return new $name($config);
        }

        $config += ['name' => $name];
        $namespace = str_replace('/', '\\', $name);

        $pos = strpos($name, '/');
        $namePart = $pos === false ? $name : substr($name, $pos + 1);

        // Check for [Vendor/]Foo/FooPlugin class
        $className = $namespace . '\\' . $namePart . 'Plugin';

        if (!class_exists($className)) {
            // Check for [Vendor/]Foo/Plugin class
            $className = $namespace . '\\' . 'Plugin';

            if (class_exists($className)) {
                deprecationWarning(
                    '5.3.0',
                    'Loading plugins with a plugin class named `Plugin` is deprecated.'
                    . " Rename the class to `{$namePart}Plugin` instead.",
                );
            } else {
                $className = BasePlugin::class;
                if (empty($config['path'])) {
                    $config['path'] = $this->findPath($name);
                }

                deprecationWarning(
                    '5.3.0',
                    'Loading plugins without a plugin class is deprecated.'
                    . " You can create the missing class using `bin/cake bake plugin {$name} --class-only`.",
                );
            }
        }

        /** @var class-string<\Cake\Core\PluginInterface> $className */
        return new $className($config);
    }

    /**
     * Implementation of Countable.
     *
     * Get the number of plugins in the collection.
     *
     * @return int
     */
    public function count(): int
    {
        return count($this->plugins);
    }

    /**
     * Part of Iterator Interface
     *
     * @return void
     */
    public function next(): void
    {
        $this->positions[$this->loopDepth]++;
    }

    /**
     * Part of Iterator Interface
     *
     * @return string
     */
    public function key(): string
    {
        return $this->names[$this->positions[$this->loopDepth]];
    }

    /**
     * Part of Iterator Interface
     *
     * @return \Cake\Core\PluginInterface
     */
    public function current(): PluginInterface
    {
        $position = $this->positions[$this->loopDepth];
        $name = $this->names[$position];

        return $this->plugins[$name];
    }

    /**
     * Part of Iterator Interface
     *
     * @return void
     */
    public function rewind(): void
    {
        $this->positions[] = 0;
        $this->loopDepth += 1;
    }

    /**
     * Part of Iterator Interface
     *
     * @return bool
     */
    public function valid(): bool
    {
        $valid = isset($this->names[$this->positions[$this->loopDepth]]);
        if (!$valid) {
            array_pop($this->positions);
            $this->loopDepth -= 1;
        }

        return $valid;
    }

    /**
     * Filter the plugins to those with the named hook enabled.
     *
     * @param string $hook The hook to filter plugins by
     * @return \Generator<\Cake\Core\PluginInterface> A generator containing matching plugins.
     * @throws \InvalidArgumentException on invalid hooks
     */
    public function with(string $hook): Generator
    {
        if (!in_array($hook, PluginInterface::VALID_HOOKS, true)) {
            throw new InvalidArgumentException(sprintf('The `%s` hook is not a known plugin hook.', $hook));
        }
        foreach ($this as $plugin) {
            if ($plugin->isEnabled($hook)) {
                yield $plugin;
            }
        }
    }
}
