<?php


namespace AutoAdmin\Bundle\BrickBuilder\Doctrine\EventListener;

use AutoAdmin\Bundle\BrickBuilder\Brick\BrickConfigManager;
use AutoAdmin\Bundle\BrickBuilder\Brick\Config\BrickConfiguration;
use AutoAdmin\Bundle\BrickBuilder\Brick\ConfigSetting\EntityCollectionSetting;
use AutoAdmin\Bundle\BrickBuilder\Brick\ConfigSetting\EntitySetting;
use AutoAdmin\Bundle\BrickBuilder\Brick\ConfigSetting\ScalarCollectionSetting;
use AutoAdmin\Bundle\BrickBuilder\Brick\ConfigSetting\ScalarSetting;
use AutoAdmin\Bundle\BrickBuilder\Model\AbstractBrickSubContent;
use AutoAdmin\Bundle\BrickBuilder\Model\BrickInterface;
use AutoAdmin\Bundle\Content\Model\SequencedInterface;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Events;
use Doctrine\Persistence\Event\LifecycleEventArgs;
use Exception;
use ReflectionClass;
use RuntimeException;
use Symfony\Component\DependencyInjection\ContainerInterface;

class BrickListener implements EventSubscriber
{
    private ?BrickConfigManager $configManager;
    private EntityManagerInterface $em;
    private array $trackedBricks = [];
    private array $toUpdate = [];
    private array $toRemove = [];

    public function __construct(BrickConfigManager $configManager, EntityManagerInterface $manager)
    {
        $this->configManager = $configManager;
        $this->em = $manager;
    }


    public function getSubscribedEvents(): array
    {
        return [
            // load
            Events::postLoad,
            // update
            Events::preUpdate,
            Events::postUpdate,
            // persist
            Events::prePersist,
            Events::postPersist,
        ];
    }

    /**
     * @throws Exception
     */
    public function postLoad(LifecycleEventArgs $args): void
    {
        $object = $args->getObject();
        if ($object instanceof BrickInterface) {
            $this->retrieve($args);
        }
    }

    /**
     * @throws Exception
     */
    public function preUpdate(LifecycleEventArgs $args): void
    {
        $this->store($args);
    }

    public function postUpdate(LifecycleEventArgs $args): void
    {
        $this->removeObjects($args);
        $this->updateObjects($args);
    }

    /**
     * @throws Exception
     */
    public function prePersist(LifecycleEventArgs $args): void
    {
        $this->store($args);
    }

    public function postPersist(LifecycleEventArgs $args): void
    {
        $this->removeObjects($args);
        $this->updateObjects($args);
    }

    protected function updateObjects(LifecycleEventArgs $args)
    {
        $object = $args->getObject();
        if ($object instanceof BrickInterface && $this->toUpdate) {
            foreach ($this->toUpdate as $key => $item) {
                $this->em->persist($item);
                unset($this->toUpdate[$key]);
            }
            $this->em->flush();
        }
    }

    protected function removeObjects(LifecycleEventArgs $args)
    {
        $object = $args->getObject();
        if ($object instanceof BrickInterface && $this->toRemove) {
            foreach ($this->toRemove as $key => $item) {
                $this->em->remove($item);
                unset($this->toRemove[$key]);
            }
            $this->em->flush();
        }
    }

    /**
     * @throws Exception
     */
    protected function store(LifecycleEventArgs $args)
    {
        $entity = $args->getObject();

        if ($entity instanceof BrickInterface) {
            $splHash = spl_object_hash($entity);
            // only handle brick storage once per flush, avoid recursion
            if (in_array($splHash, $this->trackedBricks)) {
                return;
            }

//            $changeSet = $this->em->getUnitOfWork()->getEntityChangeSet($entity);
//            if (empty($changeSet)) {
//                return;
//            }

            $this->trackedBricks[] = $splHash;

            $this->checkRemovedItems($entity);
            $this->toDatabaseFormat($entity);
        }
    }

    /**
     * @throws Exception
     */
    /**
     * @throws Exception
     */
    protected function retrieve(LifecycleEventArgs $args)
    {
        $entity = $args->getObject();
        if ($entity instanceof BrickInterface) {
            $this->fromDatabaseFormat($entity);
        }
    }

    /**
     * @throws Exception
     */
    protected function checkRemovedItems(BrickInterface $brick)
    {
        $changeSet = $this->em->getUnitOfWork()->getEntityChangeSet($brick);
        if (empty($changeSet['settings'])) {
            return;
        }
        $settingsChangeSet = $changeSet['settings'];
        list($before, $after) = $settingsChangeSet;
        /** @var BrickConfiguration $configuration */
        $configuration = $this->configManager->getHandlerConfiguration($brick->getType());
        $settingsConfig = $configuration->getSettings();
        $settings = $brick->getSettings();
        foreach ($settings as $key => $val) {
            if (!array_key_exists($key, $configuration->getSettings())) {
                continue;
            }
            $settingConfig = $settingsConfig[$key];
            switch ($settingConfig->getType()) {
                case EntitySetting::class:
                    $old = $before[$key] ?? null;
                    $new = $this->objectToString($after[$key]);
                    // if we may have a divergence, try to get source object
                    if (!empty($old) && $new !== $old) {
                        if (is_string($old)) {
                            $old = $this->stringToObject($old);
                            if (null !== $old) {
                                $this->removeIfNeeded($old, $new);
                            }
                        }
                    }
                    break;
                case EntityCollectionSetting::class:
                    $val = array_filter($val);
                    $oldValues = [];
                    if (!empty($before[$key])) {
                        foreach ($before[$key] as $item) {
//                        $oldValues[] = $item;
                            if (!is_string($item)) {
                                throw new Exception('Encountered value that is not a string.');
                            }
                            $oldValues[] = $this->stringToObject($item);
                        }
                    }

                    $newValues = [];
                    foreach ($after[$key] as $item) {
                        $newValues[] = $item;
                    }

                    foreach ($oldValues as $item) {
                        $found = false;
                        foreach ($newValues as $newValue) {
                            if ($newValue === $item) {
                                $found = true;
                            }
                        }

                        if ($found) {
                            continue;
                        }

                        if ($item) {
                            foreach ($val as $k => $v) {
                                if ($item === $v) {
                                    unset($val[$k]);
                                }
                            }
                            $this->toRemove[] = $item;
                        }
                    }
                    $settings[$key] = $val;
                    break;
                case ScalarSetting::class:
                case ScalarCollectionSetting::class:
                default:
                    break;
            }
        }
        $brick->setSettings($settings);
    }

    /**
     * Retrieve from database
     * @throws Exception
     */
    protected function fromDatabaseFormat(BrickInterface $brick)
    {
        /** @var BrickConfiguration $configuration */
        $configuration = $this->configManager->getHandlerConfiguration($brick->getType());
        $settings = $brick->getSettings();

        foreach ($settings as $key => $val) {
            if (!array_key_exists($key, $settingsConfigs = $configuration->getSettings())) {
                continue;
            }
            $settingConfig = $settingsConfigs[$key];
            //$settings[$key] = $settingConfig->getType();
            switch ($settingConfig->getType()) {
                case EntitySetting::class:
                    if ($val !== null && !is_string($val)) {
                        throw new Exception('Encountered value that is not a string. Brick id : ' . $brick->getId());
                    }
                    $settings[$key] = $this->stringToObject($val);

                    break;
                case EntityCollectionSetting::class:
                    foreach ($val as $index => $obj) {
                        if (!is_string($obj)) {
                            throw new Exception('Encountered value that is not a string.');
                        }
                        $settings[$key][$index] = $this->stringToObject($obj);
                    }
                    break;
                case ScalarSetting::class:
                case ScalarCollectionSetting::class:
                default:
                    break;
            }
        }
        $brick->setSettings($settings);
    }

    /**
     * Storing to database
     * @throws Exception
     */
    protected function toDatabaseFormat(BrickInterface $brick)
    {
        /** @var BrickConfiguration $configuration */
        $configuration = $this->configManager->getHandlerConfiguration($brick->getType());

        /** @var BrickInterface $child */
        $index = 0;
        foreach ($brick->getChildren() as $child) {
            $child->setPosition($index);
            $index++;
        }

        $settings = $brick->getSettings();

        foreach ($settings as $key => $val) {
            if (!array_key_exists($key, $settingsConfigs = $configuration->getSettings())) {
                continue;
            }

            // already transformed this brick, don't redo it.
            if (!empty($this->trackedBricks[spl_object_hash($brick)][$key])) {
                continue;
            }

            $settingConfig = $settingsConfigs[$key];

            switch ($settingConfig->getType()) {
                case EntitySetting::class:
                    $settings[$key] = $this->toDatabaseValue($val);
                    break;
                case EntityCollectionSetting::class:
                    /*
                     * If already string-format, we don't need to transform it
                     */
                    foreach ($val as $index => $obj) {
                        if (!is_string($obj) && !is_object($obj)) {
                            throw new RuntimeException(
                                'Setting encountered during save was neither object nor string.'
                            );
                        }

                        $converted = is_object($obj) ? $this->toDatabaseValue($obj) : $obj;
                        $settings[$key][$index] = $converted;
                    }
                    $settings[$key] = array_filter(array_values($settings[$key]));// reindex

                    // try to order items following their sequence
                    uasort(
                        $settings[$key],
                        function ($a, $b) {
                            if (
                                !$a instanceof SequencedInterface
                                || !$b instanceof SequencedInterface
                            ) {
                                return 0;
                            }

                            return $a->getSequence() < $b->getSequence();
                        }
                    );
                    break;
                case ScalarCollectionSetting::class:
                    $settings[$key] = array_filter(array_values($val));// reindex
                    break;
                case ScalarSetting::class:
                default:
                    break;
            }

            // mark brick setting index as transformed to avoid reprocessing if multiple flush are occurring
            $this->trackedBricks[spl_object_hash($brick)][$key] = [$val, $settings[$key]];
        }

        $brick->setSettings($settings);
    }

    /**
     * @throws Exception
     */
    protected function toDatabaseValue($object): ?string
    {
        if (is_string($object)) {
            $object = $this->stringToObject($object);
        }

        if (is_null($object)) {
            return null;
        }

        $em = $this->em;
        if (empty($object->getId())) {
//            throw new \RuntimeException('Brick setting value of type "'.get_class($object).'" is not persisted.');

            // save un-persisted entity as we need its id to transform
            $em->persist($object);
            $em->flush();
            $em->refresh($object);
        } else {
            $this->toUpdate[] = $object;
        }

        return $this->objectToString($object);
    }

    protected function objectToString(?object $object): ?string
    {
        if (is_null($object) || empty($object->getId())) {
            return null;
        }

        return get_class($object) . '#' . $object->getId();
    }

    /**
     * @param string|null $stringObject
     * @return object|null
     * @throws Exception
     */
    protected function stringToObject(?string $stringObject): ?object
    {
        if (is_null($stringObject)) {
            return null;
        }

        list($class, $identifier) = explode('#', $stringObject);
        if (empty($class)) {
            throw new Exception('Could not extract class and identifier from "' . $stringObject . '"');
        }
        $repo = $this->em->getRepository($class);

        return $repo->find($identifier);
    }

    /**
     * @throws Exception
     */
    private function removeIfNeeded(object $old, ?string $new)
    {
        $refl = new ReflectionClass($old);
        if (!$refl->isSubclassOf(AbstractBrickSubContent::class)) {
            return;
        }

        // 2 cases:
        if ($new === null) {
            // - $old is an object with a given id and $new is null, always delete
            $this->toRemove[] = $old;
        } else {
            $new = $this->stringToObject($new);
            // - $old is an object with a given id and $new is an object with a different id, delete old only if id is different
            if ($old->getId() != $new->getId()) {
                $this->toRemove[] = $old;
            }
        }
    }
}
