<?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\MissingPluginException;
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.
 */
class PluginCollection implements Iterator, Countable
{
    /**
     * Plugin list
     *
     * @var array<\Cake\Core\PluginInterface>
     */
    protected $plugins = [];

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

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

    /**
     * Loop depth
     *
     * @var int
     */
    protected $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);
        }
        $this->loadConfig();
    }

    /**
     * Load the path information stored in vendor/cakephp-plugins.php
     *
     * This file is generated by the cakephp/plugin-installer package and used
     * to locate plugins on the filesystem as applications can use `extra.plugin-paths`
     * in their composer.json file to move plugin outside of vendor/
     *
     * @internal
     * @return void
     */
    protected function loadConfig(): void
    {
        if (Configure::check('plugins')) {
            return;
        }
        $vendorFile = dirname(dirname(__DIR__)) . DIRECTORY_SEPARATOR . 'cakephp-plugins.php';
        if (!is_file($vendorFile)) {
            $vendorFile = dirname(dirname(dirname(dirname(__DIR__)))) . DIRECTORY_SEPARATOR . 'cakephp-plugins.php';
            if (!is_file($vendorFile)) {
                Configure::write(['plugins' => []]);

                return;
            }
        }

        $config = require $vendorFile;
        Configure::write($config);
    }

    /**
     * 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.
        $this->loadConfig();

        $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();
        $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.
     */
    public function create(string $name, array $config = []): PluginInterface
    {
        if (strpos($name, '\\') !== false) {
            /** @var \Cake\Core\PluginInterface */
            return new $name($config);
        }

        $config += ['name' => $name];
        /** @var class-string<\Cake\Core\PluginInterface> $className */
        $className = str_replace('/', '\\', $name) . '\\' . 'Plugin';
        if (!class_exists($className)) {
            $className = BasePlugin::class;
            if (empty($config['path'])) {
                $config['path'] = $this->findPath($name);
            }
        }

        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("The `{$hook}` hook is not a known plugin hook.");
        }
        foreach ($this as $plugin) {
            if ($plugin->isEnabled($hook)) {
                yield $plugin;
            }
        }
    }
}
