<?php

declare(strict_types=1);

/*
 * Chill is a software for social workers
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Chill\MainBundle\DependencyInjection\Widget;

use Chill\MainBundle\DependencyInjection\Widget\Factory\WidgetFactoryInterface;
use Doctrine\Common\Proxy\Exception\InvalidArgumentException;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;

/**
 * Compile the configurations and inject required service into container.
 *
 * The widgets are services tagged with :
 *
 * ```
 * { name: chill_widget, alias: my_alias, place: my_place }
 * ```
 *
 * Or, if the tag does not exist or if you need to add some config to your
 * service depending on the config, you should use a `WidgetFactory` (see
 * `WidgetFactoryInterface`.
 *
 * To reuse this compiler pass, simple execute the doProcess metho in your
 * compiler. Example :
 *
 * ```
 * namespace Chill\MainBundle\DependencyInjection\CompilerPass;
 *
 * use Symfony\Component\DependencyInjection\ContainerBuilder;
 * use Chill\MainBundle\DependencyInjection\Widget\AbstractWidgetsCompilerPass;
 * class WidgetsCompilerPass extends AbstractWidgetsCompilerPass {
 *
 *    public function process(ContainerBuilder $container)
 *    {
 *       $this->doProcess($container, 'chill_main', 'chill_main.widgets');
 *    }
 * }
 * ```
 */
abstract class AbstractWidgetsCompilerPass implements CompilerPassInterface
{
    /**
     * the key to use to identify widget for a given place.
     */
    final public const WIDGET_CONFIG_ALIAS = 'widget_alias';

    /**
     * the key to use to order widget for a given place.
     */
    final public const WIDGET_CONFIG_ORDER = 'order';

    /**
     * The service which will manage the widgets.
     *
     * @var string
     */
    final public const WIDGET_MANAGER = 'chill.main.twig.widget';

    /**
     * the method wich register the widget into give service.
     */
    final public const WIDGET_MANAGER_METHOD_REGISTER = 'addWidget';

    /**
     * the key used to collect the alias in the service definition's tag.
     * the alias must be
     * injected into the configuration under 'alias' key.
     *
     * @var string
     */
    final public const WIDGET_SERVICE_TAG_ALIAS = 'alias';

    /**
     * the value of the `name` key in service definitions's tag.
     *
     * @var string
     */
    final public const WIDGET_SERVICE_TAG_NAME = 'chill_widget';

    /**
     * the key used to collect the authorized place in the service definition's tag.
     *
     * @var string
     */
    final public const WIDGET_SERVICE_TAG_PLACES = 'place';

    /**
     * cache of ordering by place.
     *
     * @internal used by function cacheAndGetOrdering
     */
    private array $cacheOrdering = [];

    /**
     * @var WidgetFactoryInterface[]
     */
    private ?array $widgetFactories = null;

    private array $widgetServices = [];

    /**
     * process the configuration and the container to add the widget available.
     *
     * @param string $extension                          the extension of your bundle
     * @param string $containerWidgetConfigParameterName the key under which we can use the widget configuration
     *
     * @throws \LogicException
     * @throws \UnexpectedValueException if the given extension does not implement HasWidgetExtensionInterface
     */
    public function doProcess(
        ContainerBuilder $container,
        $extension,
        $containerWidgetConfigParameterName,
    ) {
        if (!$container->hasDefinition(self::WIDGET_MANAGER)) {
            throw new \LogicException('the service '.self::WIDGET_MANAGER.' should be present. It is required by '.self::class);
        }

        $managerDefinition = $container->getDefinition(self::WIDGET_MANAGER);

        // collect the widget factories
        /** @var HasWidgetFactoriesExtensionInterface $extensionClass */
        $extensionClass = $container->getExtension($extension);
        // throw an error if extension does not implement HasWidgetFactoriesExtensionInterface
        if (!$extensionClass instanceof HasWidgetFactoriesExtensionInterface) {
            throw new \UnexpectedValueException(sprintf('The extension for %s do not implements %s. You should implement %s on %s', $extension, HasWidgetFactoriesExtensionInterface::class, HasWidgetFactoriesExtensionInterface::class, $extensionClass::class));
        }

        $this->widgetFactories = $extensionClass->getWidgetFactories();

        // collect the availabled tagged services
        $this->collectTaggedServices($container);

        // collect the widgets and their config :
        $widgetParameters = $container->getParameter($containerWidgetConfigParameterName);

        // and add them to the delegated_block
        foreach ($widgetParameters as $place => $widgets) {
            foreach ($widgets as $param) {
                $alias = $param[self::WIDGET_CONFIG_ALIAS];
                // check that the service exists
                if (!\array_key_exists($alias, $this->widgetServices)) {
                    throw new InvalidConfigurationException(sprintf('The alias %s is not defined.', $alias));
                }

                // check that the widget is allowed at this place
                if (!$this->isPlaceAllowedForWidget($place, $alias, $container)) {
                    throw new InvalidConfigurationException(sprintf('The widget with alias %s is not allowed at place %s', $alias, $place));
                }

                // get the order, eventually corrected
                $order = $this->cacheAndGetOrdering($place, $param[self::WIDGET_CONFIG_ORDER]);

                // register the widget with config to the service, using the method
                // `addWidget`
                if ($this->widgetServices[$alias] instanceof WidgetFactoryInterface) {
                    /** @var WidgetFactoryInterface $factory */
                    $factory = $this->widgetServices[$alias];
                    // get the config (under the key which equals to widget_alias
                    $config = $param[$factory->getWidgetAlias()] ?? [];
                    // register the service into the container
                    $serviceId = $this->registerServiceIntoContainer(
                        $container,
                        $factory,
                        $place,
                        $order,
                        $config
                    );

                    $managerDefinition->addMethodCall(
                        self::WIDGET_MANAGER_METHOD_REGISTER,
                        [
                            $place,
                            $order,
                            new Reference($serviceId),
                            $config,
                        ]
                    );
                } else {
                    $managerDefinition->addMethodCall(
                        self::WIDGET_MANAGER_METHOD_REGISTER,
                        [
                            $place,
                            $order,
                            new Reference($this->widgetServices[$alias]),
                            [], // the config is alway an empty array
                        ]
                    );
                }
            }
        }
    }

    /**
     * This method collect all service tagged with `self::WIDGET_SERVICE_TAG`, and
     * add also the widget defined by factories.
     *
     * This method also check that the service is correctly tagged with `alias` and
     * `places`, or the factory give a correct alias and more than one place.
     *
     * @throws InvalidConfigurationException
     * @throws InvalidArgumentException
     */
    protected function collectTaggedServices(ContainerBuilder $container)
    {
        // first, check the service tagged in service definition
        foreach ($container->findTaggedServiceIds(self::WIDGET_SERVICE_TAG_NAME) as $id => $attrs) {
            foreach ($attrs as $attr) {
                // check the alias is set
                if (!isset($attr[self::WIDGET_SERVICE_TAG_ALIAS])) {
                    throw new InvalidConfigurationException('you should add an '.self::WIDGET_SERVICE_TAG_ALIAS.' key on the service '.$id);
                }

                // check the place is set
                if (!isset($attr[self::WIDGET_SERVICE_TAG_PLACES])) {
                    throw new InvalidConfigurationException(sprintf('You should add a %s key on the service %s', self::WIDGET_SERVICE_TAG_PLACES, $id));
                }

                // check the alias does not exists yet
                if (\array_key_exists($attr[self::WIDGET_SERVICE_TAG_ALIAS], $this->widgetServices)) {
                    throw new InvalidConfigurationException('a service has already be defined with the '.self::WIDGET_SERVICE_TAG_ALIAS.' '.$attr[self::WIDGET_SERVICE_TAG_ALIAS]);
                }

                // register the service as available
                $this->widgetServices[$attr[self::WIDGET_SERVICE_TAG_ALIAS]] = $id;
            }
        }

        // add the services defined by factories
        foreach ($this->widgetFactories as $factory) {
            /** @var WidgetFactoryInterface $factory */
            $alias = $factory->getWidgetAlias();

            // check the alias is not empty
            if (empty($alias)) {
                throw new \LogicException(sprintf('the widget factory %s returns an empty alias', $factory::class));
            }

            // check the places are not empty
            if (!\is_array($factory->getAllowedPlaces())) {
                throw new \UnexpectedValueException("the method 'getAllowedPlaces' ".'should return a non-empty array. Unexpected value on '.$factory::class);
            }

            if (0 === \count($factory->getAllowedPlaces())) {
                throw new \LengthException("The method 'getAllowedPlaces' should ".'return a non-empty array, but returned 0 elements on '.$factory::class.'::getAllowedPlaces()');
            }

            // check the alias does not exists yet
            if (\array_key_exists($alias, $this->widgetServices)) {
                throw new InvalidConfigurationException('a service has already be defined with the '.self::WIDGET_SERVICE_TAG_ALIAS.' '.$alias);
            }

            // register the factory as available
            $this->widgetServices[$factory->getWidgetAlias()] = $factory;
        }
    }

    /**
     * register the service into container.
     *
     * @param string $place
     * @param float  $order
     *
     * @return string the id of the new service
     */
    protected function registerServiceIntoContainer(
        ContainerBuilder $container,
        WidgetFactoryInterface $factory,
        $place,
        $order,
        array $config,
    ) {
        $serviceId = $factory->getServiceId($container, $place, $order, $config);
        $definition = $factory->createDefinition(
            $container,
            $place,
            $order,
            $config
        );
        $container->setDefinition($serviceId, $definition);

        return $serviceId;
    }

    /**
     * check if the ordering has already be used for the given $place and,
     * if yes, correct the ordering by incrementation of 1 until the ordering
     * has not be used.
     *
     * recursive method.
     *
     * @param string $place
     * @param float  $ordering
     *
     * @return float
     */
    private function cacheAndGetOrdering($place, $ordering)
    {
        // create a key in the cache array if not exists
        if (!\array_key_exists($place, $this->cacheOrdering)) {
            $this->cacheOrdering[$place] = [];
        }

        // check if the order exists
        if (array_search($ordering, $this->cacheOrdering[$place], true)) {
            // if the order exists, increment of 1 and try again
            return $this->cacheAndGetOrdering($place, $ordering + 1);
        }
        // cache the ordering
        $this->cacheOrdering[$place][] = $ordering;

        return $ordering;
    }

    /**
     * get the places where the service is allowed.
     *
     * @return unknown
     */
    private function isPlaceAllowedForWidget(mixed $place, mixed $widgetAlias, ContainerBuilder $container)
    {
        if ($this->widgetServices[$widgetAlias] instanceof WidgetFactoryInterface) {
            if (
                \in_array($place, $this->widgetServices[$widgetAlias]
                    ->getAllowedPlaces(), true)
            ) {
                return true;
            }
        } else {
            $definition = $container->findDefinition($this->widgetServices[$widgetAlias]);

            foreach ($definition->getTag(self::WIDGET_SERVICE_TAG_NAME) as $attrs) {
                $placeValue = $attrs[self::WIDGET_SERVICE_TAG_PLACES];

                if ($placeValue === $place) {
                    return true;
                }
            }
        }

        return false;
    }
}
