<?php declare(strict_types=1);

namespace Newland\NeosFiltering\Factory;

use Neos\ContentRepository\Domain\Model\NodeInterface;
use Neos\Flow\Annotations as Flow;
use Neos\Flow\Configuration\ConfigurationManager;
use Neos\Flow\Configuration\Exception\InvalidConfigurationException;
use Neos\Flow\I18n\Translator;
use Neos\Flow\ObjectManagement\ObjectManager;
use Neos\Neos\Service\Controller\DataSourceController;
use Neos\Neos\Service\DataSource\DataSourceInterface;
use Newland\Contracts\Neos\Filter\CompositeFilterItem;
use Newland\Contracts\Neos\Filter\DataSourcedFilterItem;
use Newland\Contracts\Neos\Filter\FilterItem;
use Newland\Contracts\Neos\Filter\FilterRoot;
use Newland\Contracts\Neos\Filter\QueryBoundFilterItem;
use Newland\Contracts\Neos\Filter\RangedFilterItem;
use Newland\Contracts\Neos\Filter\RangeSource;
use Newland\Contracts\Neos\Filter\TitledFilterItem;
use Newland\NeosFiltering\Factory\Override\Hide;
use Newland\NeosFiltering\Factory\Override\LimitSelectable;
use Newland\NeosFiltering\Factory\Override\Preselect;
use Newland\NeosFiltering\Factory\Override\Show;
use Newland\NeosFiltering\Items\CheckboxList;
use Newland\NeosFiltering\Items\CheckboxListHidden;
use Newland\NeosFiltering\Items\DirectEquals;
use Newland\NeosFiltering\Items\Group;
use Newland\NeosFiltering\Items\ObstructionWrapper;
use Newland\NeosFiltering\Items\PaginationInput;
use Newland\NeosFiltering\Items\Range;
use Newland\NeosFiltering\Items\Root;
use Newland\NeosFiltering\Items\Section;
use Newland\NeosFiltering\Items\SelectBox;
use Newland\NeosFiltering\RangeSource\DirectRangeSource;
use Newland\NeosFiltering\Validation\BaseValidator;
use Newland\NeosFiltering\Validation\DisallowNullChildrenValidator;
use Newland\NeosFiltering\Validation\SchemaFileValidator;

/**
 * @Flow\Scope("singleton")
 */
class DefaultFilterFactory
{
    protected const HIDE_IF_IS_SINGLE = 'if_single';

    /**
     * Map of setter function names to configuration names.
     * If a setter with the given name is found then the configuration with the
     * given name must be given.
     *
     * @var array<string, string>
     */
    protected $requiredSetters = [
        'setCombine' => 'combine',
        'setDatabaseColumn' => 'databaseColumn',
    ];

    /**
     * Map of setter function names to configuration names.
     * If a setter with the given name is found and a configuration with the given name is found then
     * the setter is being called. If one of them does not exist then nothing happens.
     *
     * @var array<string, string>
     */
    protected $optionalSetters = [ 'setState' => 'defaultState' ];

    /** @var array<string, string> */
    protected $predefinedTypes = [
        'checkbox_list' => CheckboxList::class,
        'checkbox_list_hidden' => CheckboxListHidden::class,
        'group' => Group::class,
        'range' => Range::class,
        'section' => Section::class,
        'select' => SelectBox::class,
        'select_box' => SelectBox::class,
        'pagination_input' => PaginationInput::class,
    ];

    /** @var array<string, string> */
    protected $overrideBehaviours = [
        'hide' => Hide::class,
        'show' => Show::class,
        'limit_selectable' => LimitSelectable::class,
        'preselect' => Preselect::class,
    ];

    /** @var string[] */
    protected $forbiddenQueryStrings = [ 'type' ];

    /**
     * @var Translator
     * @Flow\Inject()
     */
    protected $translator;

    /**
     * @var ObjectManager
     * @Flow\Inject()
     */
    protected $objectManager;

    /**
     * @var BaseValidator
     * @Flow\Inject()
     */
    protected $validator;

    /**
     * @var ConfigurationManager
     * @Flow\Inject()
     */
    protected $configurationManager;

    /** @var NodeInterface */
    protected $node;

    public function __construct(NodeInterface $node)
    {
        $this->node = $node;
    }

    public function createFilterFromSettings(string $configurationKey): Root
    {
        $configuration = $this->configurationManager->getConfiguration(
            ConfigurationManager::CONFIGURATION_TYPE_SETTINGS,
            $configurationKey
        );

        if ($configuration === null) {
            throw new InvalidConfigurationException(sprintf(
                'Configuration with key %s not found',
                $configurationKey
            ));
        }

        return $this->createFilter($configuration);
    }

    public function createFilter(array $configuration, array $overrides = []): Root
    {
        $configuration = $this->removeDisabledFromConfiguration($configuration);
        $this->validator->throwIfConfigurationInvalid($this->validatorClassNames($configuration), $configuration);
        $class = $this->getRootClassName();

        /** @var Root $root */
        $root = new $class([], $this->node);
        $this->setCommonConfigurationOptions($root, $configuration, $overrides, $root);
        $root->setFormAttributes($configuration['formAttributes'] ?? []);
        $root->setJoins($configuration['join'] ?? []);
        $root->setOrder($configuration['order'] ?? []);

        return $root;
    }

    protected function getRootClassName(): string
    {
        return Root::class;
    }

    public function createFilterItem(array $configuration, array $overrides, FilterRoot $root): FilterItem
    {
        $type = $configuration['type'] ?? null;
        if ($type === null) {
            throw new \InvalidArgumentException('item configuration must have `type` property');
        }

        $className = $this->predefinedTypes[$type] ?? $type;
        if (!class_exists($className)) {
            throw new \InvalidArgumentException(
                sprintf(
                    '%s is not a valid `type` attribute: must be one of [%s] or fully qualified class name',
                    $className,
                    implode(', ', array_keys($this->predefinedTypes))
                )
            );
        }

        $instance = $this->objectManager->get($className);
        if (!($instance instanceof FilterItem)) {
            throw new \InvalidArgumentException(
                sprintf(
                    'Class %s specified as type does not implement interface %s',
                    $className,
                    FilterItem::class
                )
            );
        }

        $instance = $this->setCommonConfigurationOptions($instance, $configuration, $overrides, $root);
        $instance = $this->applyOverrideIfAvailable($instance, $configuration, $overrides);

        if (!($instance instanceof ObstructionWrapper) && ($configuration['hideInFrontend'] ?? false)) {
            $instance = new ObstructionWrapper($instance);
        }

        return $instance;
    }


    protected function applyOverrideIfAvailable(FilterItem $item, array $configuration, array $overrides): FilterItem
    {
        if (!array_key_exists('override', $configuration)) {
            return $item;
        }

        $behaviourName = $configuration['override']['behaviour'] ?? null;
        if (array_key_exists($behaviourName, $this->overrideBehaviours)) {
            $behaviourClass = $this->overrideBehaviours[$behaviourName];
        } elseif (class_exists((string) $behaviourName)) {
            $behaviourClass = $behaviourName;
        } else {
            throw new InvalidConfigurationException(sprintf(
                'Override behaviour must be one of [%s] or fully qualified class name',
                implode(', ', array_keys($this->overrideBehaviours))
            ));
        }

        $behaviourInstance = new $behaviourClass();
        if (!($behaviourInstance instanceof OverrideBehaviour)) {
            throw new InvalidConfigurationException(sprintf(
                'Override behaviours must implement %s but %s does not',
                OverrideBehaviour::class,
                $behaviourClass
            ));
        }

        $property = $configuration['override']['property'];
        $override = $overrides[$property] ?? null;
        if (empty($override)) {
            return $item;
        }

        return $behaviourInstance->applyOverride($item, $override, $configuration['override']);
    }

    protected function setCommonConfigurationOptions(
        FilterItem $item,
        array $configuration,
        array $overrides,
        FilterRoot $root
    ): FilterItem {
        $item->setRoot($root);
        $item->setConfiguration($configuration);
        $this->setQueryStringIfQueryBound($item, $configuration);
        $this->addChildrenForCompositeItems($item, $configuration, $overrides);
        $this->setTitleOnItemIfTitled($item, $configuration);
        $this->initializeDataSourceOnItem($item, $configuration);
        $this->initializeRangeSourceOnItem($item, $configuration);
        $this->callRequiredSetters($item, $configuration);
        $this->callOptionalSetters($item, $configuration);
        return $this->wrapInGroupForDatabaseWhereStatement($item, $configuration);
    }

    protected function callRequiredSetters(FilterItem $item, array $configuration): void
    {
        $missingConfigurationOptions = [];
        foreach ($this->requiredSetters as $setter => $configurationKey) {
            if (method_exists($item, $setter)) {
                if (!array_key_exists($configurationKey, $configuration)) {
                    $missingConfigurationOptions[] = $configurationKey;
                    continue;
                }

                $item->$setter($configuration[$configurationKey]);
            }
        }

        if (\count($missingConfigurationOptions) > 0) {
            throw new \InvalidArgumentException(
                sprintf(
                    'Failed to build filter item: required configuration options [%s] not provided'
                    . ' for filter item %s',
                    implode(', ', $missingConfigurationOptions),
                    json_encode($configuration)
                )
            );
        }
    }


    protected function callOptionalSetters(FilterItem $item, array $configuration): void
    {
        foreach ($this->optionalSetters as $setter => $configurationKey) {
            if (array_key_exists($configurationKey, $configuration) && method_exists($item, $setter)) {
                $item->$setter($configuration[$configurationKey]);
            }
        }
    }

    protected function addChildrenForCompositeItems(FilterItem $item, array $configuration, array $overrides): void
    {
        if ($item instanceof CompositeFilterItem) {
            if (!array_key_exists('children', $configuration)) {
                throw new \InvalidArgumentException(
                    sprintf(
                        'Failed to build filter item: composite filter items must have a `children` configuration'
                        . ' for filter item %s',
                        json_encode($configuration)
                    )
                );
            }
            foreach ($configuration['children'] ?? [] as $childConfig) {
                // Children can be unset via project yaml configuration and will then leave a value of NULL.
                if ($childConfig !== null) {
                    $item->addItem($this->createFilterItem($childConfig, $overrides, $item->getRoot()));
                }
            }
        }
    }

    protected function setTitleOnItemIfTitled(FilterItem $item, array $configuration): void
    {
        if (!($item instanceof TitledFilterItem)) {
            return;
        }

        if (array_key_exists('title', $configuration)) {
            $label = $this->translator->translateById(
                $configuration['title']['id'],
                [],
                null,
                null,
                $configuration['title']['source'] ?? null,
                $configuration['title']['package'] ?? null
            );
            $label = $label ?? $configuration['title']['id'];
            $item->setTitle($label);
        }
    }

    protected function initializeDataSourceOnItem(FilterItem $item, array $configuration): void
    {
        if (!($item instanceof DataSourcedFilterItem)) {
            return;
        }

        if (!array_key_exists('dataSource', $configuration)) {
            throw new \InvalidArgumentException(
                sprintf(
                    'Failed to build filter item: data sourced filter items must have a `dataSource`'
                    . ' configuration for filter item %s',
                    json_encode($configuration)
                )
            );
        }

        $item->setDataSource($this->getDataSource($configuration['dataSource']));
        $item->setDataSourceArguments($configuration['dataSourceArguments'] ?? []);
    }

    protected function initializeRangeSourceOnItem(FilterItem $item, array $configuration): void
    {
        if (!($item instanceof RangedFilterItem)) {
            return;
        }

        if (array_key_exists('rangeSource', $configuration)) {
            $source = $this->getRangeSource(
                $configuration['rangeSource'],
                $configuration['rangeSourceArguments'] ?? []
            );
            $item->setRangeSource($source);
        } elseif (array_key_exists('range', $configuration)) {
            $source = new DirectRangeSource();
            $source->setArguments($configuration['range']);
            $item->setRangeSource($source);
        } else {
            throw new \InvalidArgumentException(
                sprintf(
                    'Failed to build filter item: ranged filter items must have either range or'
                    . ' rangeSource configurations for filter item %s',
                    json_encode($configuration)
                )
            );
        }
    }

    protected function wrapInGroupForDatabaseWhereStatement(FilterItem $item, array $configuration): FilterItem
    {
        if (!array_key_exists('databaseWhere', $configuration)) {
            return $item;
        }

        $equals = new DirectEquals();
        $equals->setStatement($configuration['databaseWhere']);

        $group = new Group();
        $group->setCombine(Group::$AND);
        $group->addItem($item);
        $group->addItem($equals);
        return $group;
    }

    private function getRangeSource(string $className, array $arguments): RangeSource
    {
        if (!class_exists($className)) {
            throw new \InvalidArgumentException(
                sprintf(
                    'Class %s does not exist. Range sources must be valid classes.',
                    $className
                )
            );
        }

        $instance = new $className();
        if (!($instance instanceof RangeSource)) {
            throw new \InvalidArgumentException(
                sprintf(
                    'Class %s specified as range source does not implement %s',
                    $className,
                    RangeSource::class
                )
            );
        }

        $instance->setArguments($arguments);
        return $instance;
    }

    protected function setQueryStringIfQueryBound(FilterItem $item, array $configuration): void
    {
        if ($item instanceof QueryBoundFilterItem) {
            if (!array_key_exists('queryString', $configuration)) {
                throw new \InvalidArgumentException(
                    sprintf(
                        'Failed to build filter item: required configuration queryString not provided'
                        . ' for filter item %s',
                        json_encode($configuration)
                    )
                );
            }

            $queryString = $configuration['queryString'];
            if ($queryString[0] === '@') {
                throw new \InvalidArgumentException(
                    'type must not be internal flow identifier (must not start with "@")'
                );
            }
            if (in_array($queryString, $this->forbiddenQueryStrings, true)) {
                throw new \InvalidArgumentException(sprintf(
                    'type must not be [%s] - they are reserved request arguments',
                    implode($this->forbiddenQueryStrings)
                ));
            }
            $item->setQueryString($configuration['queryString']);
        }
    }


    protected function getDataSource(string $identifier): DataSourceInterface
    {
        $dataSourceClassName = DataSourceController::getDataSources($this->objectManager)[$identifier] ?? null;
        if ($dataSourceClassName === null) {
            throw new \InvalidArgumentException(
                sprintf('Cannot find data source with identifier %s', $identifier)
            );
        }
        return new $dataSourceClassName();
    }

    protected function validatorClassNames(array $configuration): array
    {
        $validators = $configuration['validators'] ?? [];
        $validators[] = SchemaFileValidator::class;
        $validators[] = DisallowNullChildrenValidator::class;
        return $validators;
    }

    private function removeDisabledFromConfiguration(array $configuration): array
    {
        foreach ($configuration['children'] ?? [] as $name => $child) {
            if ($child['disableCompletely'] ?? false) {
                unset($configuration['children'][$name]);
            } elseif (\is_array($configuration)) {
                $configuration[$name] = $this->removeDisabledFromConfiguration($child);
            }
        }

        return $configuration;
    }
}
