<?php

/**
 * Joomla! Content Management System
 *
 * @copyright  (C) 2007 Open Source Matters, Inc. <https://www.joomla.org>
 * @license    GNU General Public License version 2 or later; see LICENSE.txt
 */

namespace Joomla\CMS\Plugin;

use Joomla\CMS\Application\CMSApplicationInterface;
use Joomla\CMS\Event\AbstractImmutableEvent;
use Joomla\CMS\Event\Result\ResultAwareInterface;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Language\Language;
use Joomla\CMS\Language\LanguageAwareInterface;
use Joomla\CMS\Language\LanguageAwareTrait;
use Joomla\Event\AbstractEvent;
use Joomla\Event\DispatcherAwareInterface;
use Joomla\Event\DispatcherAwareTrait;
use Joomla\Event\DispatcherInterface;
use Joomla\Event\EventInterface;
use Joomla\Event\SubscriberInterface;
use Joomla\Registry\Registry;

// phpcs:disable PSR1.Files.SideEffects
\defined('_JEXEC') or die;
// phpcs:enable PSR1.Files.SideEffects

/**
 * Plugin Class
 *
 * @since  1.5
 *
 * @TODO  Starting from 7.0 the class will no longer implement DispatcherAwareInterface and LanguageAwareInterface
 */
abstract class CMSPlugin implements DispatcherAwareInterface, PluginInterface, LanguageAwareInterface
{
    use DispatcherAwareTrait {
        setDispatcher as traitSetDispatcher;
        getDispatcher as traitGetDispatcher;
    }
    use LanguageAwareTrait {
        setLanguage as traitSetLanguage;
        getLanguage as traitGetLanguage;
    }

    /**
     * A Registry object holding the parameters for the plugin
     *
     * @var    Registry
     * @since  1.5
     */
    public $params = null;

    /**
     * The name of the plugin
     *
     * @var    string
     * @since  1.5
     */
    protected $_name = null;

    /**
     * The plugin type
     *
     * @var    string
     * @since  1.5
     */
    protected $_type = null;

    /**
     * Affects constructor behavior. If true, language files will be loaded automatically.
     *
     * @var    boolean
     * @since  3.1
     */
    protected $autoloadLanguage = false;

    /**
     * Should I try to detect and register legacy event listeners, i.e. methods which accept unwrapped arguments? While
     * this maintains a great degree of backwards compatibility to Joomla! 3.x-style plugins it is much slower. You are
     * advised to implement your plugins using proper Listeners, methods accepting an AbstractEvent as their sole
     * parameter, for best performance. Also bear in mind that Joomla! 5.x onwards will only allow proper listeners,
     * removing support for legacy Listeners.
     *
     * @var    boolean
     * @since  4.0.0
     *
     * @deprecated  4.3 will be removed in 6.0
     *              Implement your plugin methods accepting an AbstractEvent object
     *              Example:
     *              onEventTriggerName(AbstractEvent $event) {
     *                  $context = $event->getArgument(...);
     *              }
     */
    protected $allowLegacyListeners = true;

    /**
     * The application object
     *
     * @var    CMSApplicationInterface
     *
     * @since  4.2.0
     */
    private $application;

    /**
     * Constructor
     *
     * @param array $config      An optional associative array of configuration settings.
     *                           Recognized key values include 'name', 'group', 'params', 'language'
     *                           (this list is not meant to be comprehensive).
     *
     * @since   1.5
     */
    public function __construct($config = [])
    {
        if ($config instanceof DispatcherInterface) {
            @trigger_error(
                \sprintf(
                    'Passing an instance of %1$s to %2$s() will not be supported in 7.0. '
                    . 'Starting from 7.0 CMSPlugin class will no longer implement DispatcherAwareInterface.',
                    DispatcherInterface::class,
                    __METHOD__
                ),
                \E_USER_DEPRECATED
            );

            // Set the dispatcher we are to register our listeners with
            $this->setDispatcher($config);

            $config = \func_num_args() > 1 ? func_get_arg(1) : [];
        }

        // Get the parameters.
        if (isset($config['params'])) {
            if ($config['params'] instanceof Registry) {
                $this->params = $config['params'];
            } else {
                $this->params = new Registry($config['params']);
            }
        }

        // Get the plugin name.
        if (isset($config['name'])) {
            $this->_name = $config['name'];
        }

        // Get the plugin type.
        if (isset($config['type'])) {
            $this->_type = $config['type'];
        }

        // Load the language files if needed.
        if ($this->autoloadLanguage) {
            $this->loadLanguage();
        }

        if (property_exists($this, 'app')) {
            @trigger_error('The application should be injected through setApplication() and requested through getApplication().', E_USER_DEPRECATED);
            $reflection  = new \ReflectionClass($this);
            $appProperty = $reflection->getProperty('app');

            if ($appProperty->isPrivate() === false && \is_null($this->app)) {
                $this->app = Factory::getApplication();
            }
        }

        if (property_exists($this, 'db')) {
            @trigger_error('The database should be injected through the DatabaseAwareInterface and trait.', E_USER_DEPRECATED);
            $reflection = new \ReflectionClass($this);
            $dbProperty = $reflection->getProperty('db');

            if ($dbProperty->isPrivate() === false && \is_null($this->db)) {
                $this->db = Factory::getDbo();
            }
        }
    }

    /**
     * Loads the plugin language file
     *
     * @param   string  $extension  The extension for which a language file should be loaded
     * @param   string  $basePath   The basepath to use
     *
     * @return  boolean  True, if the file has successfully loaded.
     *
     * @since   1.5
     */
    public function loadLanguage($extension = '', $basePath = JPATH_ADMINISTRATOR)
    {
        if (empty($extension)) {
            $extension = 'Plg_' . $this->_type . '_' . $this->_name;
        }

        $extension = strtolower($extension);
        $lang      = $this->getApplication() ? $this->getApplication()->getLanguage() : Factory::getLanguage();

        // If language already loaded, don't load it again.
        if ($lang->getPaths($extension)) {
            return true;
        }

        return $lang->load($extension, $basePath)
            || $lang->load($extension, JPATH_PLUGINS . '/' . $this->_type . '/' . $this->_name);
    }

    /**
     * Registers legacy Listeners to the Dispatcher, emulating how plugins worked under Joomla! 3.x and below.
     *
     * By default, this method will look for all public methods whose name starts with "on". It will register
     * lambda functions (closures) which try to unwrap the arguments of the dispatched Event into method call
     * arguments and call your on<Something> method. The result will be passed back to the Event into its 'result'
     * argument.
     *
     * This method additionally supports Joomla\Event\SubscriberInterface and plugins implementing this will be
     * registered to the dispatcher as a subscriber.
     *
     * @return  void
     *
     * @since   4.0.0
     */
    public function registerListeners()
    {
        // Plugins which are SubscriberInterface implementations are handled without legacy layer support
        if ($this instanceof SubscriberInterface) {
            $this->getDispatcher()->addSubscriber($this);

            return;
        }

        $reflectedObject = new \ReflectionObject($this);
        $methods         = $reflectedObject->getMethods(\ReflectionMethod::IS_PUBLIC);

        /** @var \ReflectionMethod $method */
        foreach ($methods as $method) {
            if (!str_starts_with($method->name, 'on')) {
                continue;
            }

            // Save time if I'm not to detect legacy listeners
            if (!$this->allowLegacyListeners) {
                $this->registerListener($method->name);

                continue;
            }

            /** @var \ReflectionParameter[] $parameters */
            $parameters = $method->getParameters();

            // If the parameter count is not 1 it is by definition a legacy listener
            if (\count($parameters) !== 1) {
                $this->registerLegacyListener($method->name);

                continue;
            }

            /** @var \ReflectionParameter $param */
            $param     = array_shift($parameters);
            $paramName = $param->getName();

            // No type hint / type hint class not an event or parameter name is not "event"? It's a legacy listener.
            if ($paramName !== 'event' || !$this->parameterImplementsEventInterface($param)) {
                $this->registerLegacyListener($method->name);

                continue;
            }

            // Everything checks out, this is a proper listener.
            $this->registerListener($method->name);
        }
    }

    /**
     * Registers a legacy event listener, i.e. a method which accepts individual arguments instead of an AbstractEvent
     * in its arguments. This provides backwards compatibility to Joomla! 3.x-style plugins.
     *
     * This method will register lambda functions (closures) which try to unwrap the arguments of the dispatched Event
     * into old style method arguments and call your on<Something> method with them. The result will be passed back to
     * the Event, as an element into an array argument called 'result'.
     *
     * @param   string  $methodName  The method name to register
     *
     * @return  void
     *
     * @since   4.0.0
     */
    final protected function registerLegacyListener(string $methodName)
    {
        $this->getDispatcher()->addListener(
            $methodName,
            function (AbstractEvent $event) use ($methodName) {
                // Get the event arguments
                $arguments = $event->getArguments();

                // Extract any old results; they must not be part of the method call.
                $allResults = [];

                if (isset($arguments['result'])) {
                    $allResults = $arguments['result'];

                    unset($arguments['result']);
                }

                // Convert to indexed array for unpacking.
                $arguments = array_values($arguments);

                $result = $this->{$methodName}(...$arguments);

                // Ignore null results
                if ($result === null) {
                    return;
                }

                if ($event instanceof ResultAwareInterface) {
                    $event->addResult($result);
                } elseif (!$event instanceof AbstractImmutableEvent) {
                    // Restore the old results and add the new result from our method call
                    $allResults[]    = $result;
                    $event['result'] = $allResults;
                }
            }
        );
    }

    /**
     * Registers a proper event listener, i.e. a method which accepts an AbstractEvent as its sole argument. This is the
     * preferred way to implement plugins in Joomla! 4.x and will be the only possible method with Joomla! 5.x onwards.
     *
     * @param   string  $methodName  The method name to register
     *
     * @return  void
     *
     * @since   4.0.0
     */
    final protected function registerListener(string $methodName)
    {
        $this->getDispatcher()->addListener($methodName, [$this, $methodName]);
    }

    /**
     * Checks if parameter is typehinted to accept \Joomla\Event\EventInterface.
     *
     * @param   \ReflectionParameter  $parameter
     *
     * @return  boolean
     *
     * @since   4.0.0
     */
    private function parameterImplementsEventInterface(\ReflectionParameter $parameter): bool
    {
        $reflectionType = $parameter->getType();

        // Parameter is not typehinted.
        if ($reflectionType === null) {
            return false;
        }

        // Parameter is nullable.
        if ($reflectionType->allowsNull()) {
            return false;
        }

        // Handle standard typehints.
        if ($reflectionType instanceof \ReflectionNamedType) {
            return is_a($reflectionType->getName(), EventInterface::class, true);
        }

        // Handle PHP 8 union types.
        if ($reflectionType instanceof \ReflectionUnionType) {
            foreach ($reflectionType->getTypes() as $type) {
                if (!is_a($type->getName(), EventInterface::class, true)) {
                    return false;
                }
            }

            return true;
        }

        return false;
    }

    /**
     * Returns the internal application or null when not set.
     *
     * @return  CMSApplicationInterface|null
     *
     * @since   4.2.0
     */
    protected function getApplication(): ?CMSApplicationInterface
    {
        return $this->application;
    }

    /**
     * Sets the application to use.
     *
     * @param   CMSApplicationInterface  $application  The application
     *
     * @return  void
     *
     * @since   4.2.0
     */
    public function setApplication(CMSApplicationInterface $application): void
    {
        $this->application = $application;

        if ($application->getLanguage()) {
            $this->setLanguage($application->getLanguage());
        }
    }

    /**
     * Set the language to use.
     *
     * @param   Language  $language  The language to use
     *
     * @return  void
     *
     * @since   5.3.0
     *
     * @deprecated  5.2 will be removed in 7.0
     *              Plugin should use the language from Application, and only after the app is initialised
     */
    public function setLanguage(Language $language): void
    {
        $this->traitSetLanguage($language);
    }

    /**
     * Get the Language.
     *
     * @return  Language
     *
     * @throws  \UnexpectedValueException May be thrown if the language has not been set.
     *
     * @since   5.3.0
     *
     * @deprecated  5.2 will be removed in 7.0
     *              Plugin should use the language from Application, and only after the app is initialised.
     */
    protected function getLanguage(): Language
    {
        @trigger_error(
            __CLASS__ . ': Use of LanguageAwareInterface over CMSPlugin will be removed in 7.0.',
            \E_USER_DEPRECATED
        );

        return $this->traitGetLanguage();
    }

    /**
     * Set the dispatcher to use.
     *
     * @param   DispatcherInterface  $dispatcher  The dispatcher to use.
     *
     * @return  $this
     *
     * @since   5.3.0
     *
     * @deprecated  5.2 will be removed in 7.0
     *              Plugin should implement DispatcherAwareInterface on its own, when it is needed.
     */
    public function setDispatcher(DispatcherInterface $dispatcher)
    {
        @trigger_error(
            __CLASS__ . ': Use of DispatcherAwareInterface over CMSPlugin will be removed in 7.0.'
            . ' Plugin should implement DispatcherAwareInterface on its own, when it is needed.',
            \E_USER_DEPRECATED
        );

        return $this->traitSetDispatcher($dispatcher);
    }

    /**
     * Get the event dispatcher.
     *
     * @return  DispatcherInterface
     *
     * @throws  \UnexpectedValueException May be thrown if the dispatcher has not been set.
     *
     * @since   5.3.0
     *
     * @deprecated  5.2 will be removed in 7.0
     *              Plugin should implement DispatcherAwareInterface on its own, when it is needed.
     */
    public function getDispatcher()
    {
        return $this->traitGetDispatcher();
    }
}
