<?php

declare(strict_types=1);

namespace Gls\GlsPoland\PrestaShop\Hook;

use PrestaShop\PrestaShop\Adapter\Hook\HookDispatcher;
use PrestaShop\PrestaShop\Core\Hook\HookDispatcherInterface;
use PrestaShop\PrestaShop\Core\Hook\HookInterface;
use PrestaShop\PrestaShop\Core\Hook\RenderedHookInterface;
use PrestaShopBundle\Service\Hook\HookEvent;
use PrestaShopBundle\Service\Hook\RenderingHookEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

/**
 * @internal
 *
 * Fixes the output of recursively called "display" hooks. The default implementation stores content returned by modules
 * in a class property, resetting its value after all listeners are called but without checking if it is empty when
 * dispatching the current event. As a result, when calling a hook from another hook (e.g. rendering grid templates in
 * admin order details page hooks), the previously rendered content is prepended to the hook at the top of the call
 * stack, while only content from the listeners called after the recursive {@see self::dispatch()} call is displayed
 * for the original hook.
 *
 * @see HookDispatcher::doDispatch()
 *
 * The decorated class has to be extended since at least {@see \PrestaShop\PrestaShop\Core\Hook\HookDispatcher} relies
 * on implementation.
 */
final class RecursionHandlingHookDispatcher extends HookDispatcher
{
    /**
     * @var HookDispatcherInterface|null
     */
    private $dispatcher;

    /**
     * @var \ReflectionProperty|false
     */
    private $renderingContent;

    /**
     * NOTE:
     * This constructor intentionally keeps the argument untyped/optional.
     * In some installation-time containers this class can be auto-registered by resource scanning.
     * A typed HookDispatcherInterface argument may then resolve to this very same service and create
     * a circular reference. Runtime usage always passes the dispatcher explicitly from HookExtension.
     */
    public function __construct($dispatcher = null)
    {
        if (null !== $dispatcher && !$dispatcher instanceof HookDispatcherInterface) {
            throw new \InvalidArgumentException(sprintf(
                'Expected instance of %s, got %s.',
                HookDispatcherInterface::class,
                is_object($dispatcher) ? get_class($dispatcher) : gettype($dispatcher)
            ));
        }

        $this->dispatcher = $dispatcher;
    }

    public function __call(string $name, array $arguments)
    {
        return $this->getDispatcher()->$name(...$arguments);
    }

    public function dispatch($eventName, $event = null): object
    {
        if (!$event instanceof RenderingHookEvent) {
            return $this->getDispatcher()->dispatch($eventName, $event);
        }

        return $this->callHandlingPreviousHookContent(function () use ($eventName, $event) {
            return $this->getDispatcher()->dispatch($eventName, $event);
        });
    }

    public function renderForParameters($eventName, array $parameters = []): RenderingHookEvent
    {
        return $this->callHandlingPreviousHookContent(function () use ($eventName, $parameters) {
            return $this->getDispatcher()->renderForParameters($eventName, $parameters);
        });
    }

    public function dispatchMultiple(array $eventNames, array $eventParameters): void
    {
        $this->getDispatcher()->dispatchMultiple($eventNames, $eventParameters);
    }

    public function dispatchForParameters($eventName, array $parameters = []): HookEvent
    {
        return $this->getDispatcher()->dispatchForParameters($eventName, $parameters);
    }

    public function addListener($eventName, $listener, $priority = 0): void
    {
        $this->getDispatcher()->addListener($eventName, $listener, $priority);
    }

    public function addSubscriber(EventSubscriberInterface $subscriber): void
    {
        $this->getDispatcher()->addSubscriber($subscriber);
    }

    public function removeListener($eventName, $listener): void
    {
        $this->getDispatcher()->removeListener($eventName, $listener);
    }

    public function removeSubscriber(EventSubscriberInterface $subscriber): void
    {
        $this->getDispatcher()->removeSubscriber($subscriber);
    }

    public function getListeners($eventName = null): array
    {
        return $this->getDispatcher()->getListeners($eventName);
    }

    public function getListenerPriority($eventName, $listener): ?int
    {
        return $this->getDispatcher()->getListenerPriority($eventName, $listener);
    }

    public function hasListeners($eventName = null): bool
    {
        return $this->getDispatcher()->hasListeners($eventName);
    }

    public function dispatchHook(HookInterface $hook): HookEvent
    {
        return $this->getDispatcher()->dispatchHook($hook);
    }

    public function dispatchWithParameters($hookName, array $hookParameters = []): void
    {
        $this->getDispatcher()->dispatchWithParameters($hookName, $hookParameters);
    }

    public function dispatchRendering(HookInterface $hook): RenderedHookInterface
    {
        return $this->callHandlingPreviousHookContent(function () use ($hook) {
            return $this->getDispatcher()->dispatchRendering($hook);
        });
    }

    public function dispatchRenderingWithParameters($hookName, array $hookParameters = []): RenderedHookInterface
    {
        return $this->callHandlingPreviousHookContent(function () use ($hookName, $hookParameters) {
            return $this->getDispatcher()->dispatchRenderingWithParameters($hookName, $hookParameters);
        });
    }

    /**
     * @template T of (RenderingHookEvent|RenderedHookInterface)
     *
     * @param callable(): T $callback
     *
     * @return T
     */
    private function callHandlingPreviousHookContent(callable $callback)
    {
        if (null === $renderingContent = $this->getRenderingContent()) {
            return $callback();
        }

        $dispatcher = $this->getDispatcher();
        if ([] === $previousContent = $renderingContent->getValue($dispatcher)) {
            return $callback();
        }

        $renderingContent->setValue($dispatcher, []);
        $result = $callback();
        $renderingContent->setValue($dispatcher, $previousContent);

        return $result;
    }

    private function getRenderingContent(): ?\ReflectionProperty
    {
        if (isset($this->renderingContent)) {
            return $this->renderingContent ?: null;
        }

        $class = new \ReflectionClass($this->getDispatcher());
        if (!$class->hasProperty('renderingContent')) {
            $this->renderingContent = false;

            return null;
        }

        $this->renderingContent = $class->getProperty('renderingContent');
        $this->renderingContent->setAccessible(true);

        return $this->renderingContent;
    }

    private function getDispatcher(): HookDispatcherInterface
    {
        if (!$this->dispatcher instanceof HookDispatcherInterface) {
            throw new \LogicException(sprintf(
                '%s requires a valid inner hook dispatcher instance.',
                __CLASS__
            ));
        }

        return $this->dispatcher;
    }
}
