<?php
// Copyright (c) Pickware GmbH. All rights reserved.
// This file is part of software that is released under a proprietary license.
// You must not copy, modify, distribute, make publicly available, or execute
// its contents or parts thereof without express permission by the copyright
// holder, unless otherwise permitted by law.

namespace Shopware\Plugins\ViisonPickwareCommon\Classes;

use Doctrine\DBAL\Query\Expression\CompositeExpression;
use Doctrine\ORM\Query\Expr\Andx;
use Doctrine\ORM\Query\Expr\Comparison;
use Doctrine\ORM\Query\Expr\Orx;
use Doctrine\ORM\QueryBuilder;
use InvalidArgumentException;

class QueryBuilderHelper
{
    /**
     * @var QueryBuilder
     */
    protected $queryBuilder;

    /**
     * @param QueryBuilder $queryBuilder
     */
    public function __construct(QueryBuilder $queryBuilder)
    {
        $this->queryBuilder = $queryBuilder;
    }

    /**
     * Adds nested filter conditions to the given QueryBuilder. A filter must be defined as an array of conditions in
     * the following format:
     *
     * filter: [
     *     condition,
     *     condition,
     *     ...
     * ]
     *
     * Any condition (both top-level and nested) can either be a) a single condition (following Shopware's filter
     * syntax) or b) a combination of child conditions:
     *
     * a) condition = {
     *     property: 'article.id',
     *     expression: '<',
     *     value: 12,
     * }
     *
     * b) condition = {
     *     children: [
     *         condition,
     *         condition,
     *         ...
     *     ]
     * }
     *
     * Top-level conditions (direct children of the passed `$filter`) follow the exact same syntax as used by Shopware's
     * own {@link QueryBuilder::addFilter()}. That is, to join a filter element by an `OR` operator, you must set the
     * `operator` field to `true`/`1`:
     *
     * condition = {
     *     property: 'article.id',
     *     expression: '<',
     *     value: 12,
     *     operator: true // considered `false` if not set
     * }
     *
     * Nested conditions on the other hand are always combined as defined by their parent condition. That is, setting
     * the `disjunction` field in the parent component to `true`/`1` combines all its children using an `OR` operator:
     *
     * condition = {
     *     disjunction: true, // considered `false` if not set
     *     children: [
     *         condition,
     *         condition,
     *         ...
     *     ]
     * }
     *
     * @param array $filter
     */
    public function addNestedFilter(array $filter)
    {
        // Assign top level conditions directly to the QueryBuilder
        foreach ($filter as $condition) {
            if (self::isDisjunction($condition, 'operator')) {
                $this->queryBuilder->orWhere($this->getNestedCondition($condition));
            } else {
                $this->queryBuilder->andWhere($this->getNestedCondition($condition));
            }
        }
    }

    /**
     * Returns a single condition for the given array. May be a recursive call on children of a condition.
     *
     * @param array $condition
     * @return Andx|Orx|Comparison
     * @throws InvalidArgumentException if the `$condition` contains an empty `children` array.
     */
    protected function getNestedCondition(array $condition)
    {
        // Check for any children
        if (!isset($condition['children']) || !is_array($condition['children'])) {
            return $this->getComparisonFromCondition($condition);
        }
        if (count($condition['children']) === 0) {
            throw new InvalidArgumentException('The filter element\'s "children" must not be empty.');
        }

        // Recursively create the child conditions
        $childConditions = array_map(
            function (array $childCondition) {
                return $this->getNestedCondition($childCondition);
            },
            $condition['children']
        );

        if (self::isDisjunction($condition, 'disjunction')) {
            return $this->queryBuilder->expr()->orX(...$childConditions);
        } else {
            return $this->queryBuilder->expr()->andX(...$childConditions);
        }
    }

    /**
     * Creates a query builder {@link Comparison} for the passed `$filter`. The supported format follows Shopware's
     * query builder format:
     *
     * [
     *     property: 'article.id',
     *     expression: '<',
     *     value: 12
     * ]
     *
     * If no 'expression' is provided, one will be assigned depending on the type of value ('=' as default). If a value
     * is provided, it is added as a parameter to this helper's `queryBuilder`.
     *
     * @param array $filter
     * @return Comparison
     * @throws InvalidArgumentException if the passed `$filter` contains an invalid `property`.
     */
    protected function getComparisonFromCondition(array $filter)
    {
        // Check for invalid properties
        $property = $filter['property'];
        if (!preg_match('/^[a-z][a-z0-9_.]+$/i', $property)) {
            throw new InvalidArgumentException(sprintf('Invalid filter property "%s".', $property));
        }

        // Parse expression or insert default values
        $value = (isset($filter['value'])) ? $filter['value'] : null;
        $expression = (isset($filter['expression'])) ? $filter['expression'] : null;
        if ($expression === null) {
            switch (true) {
                case is_string($value):
                    $expression = 'LIKE';
                    break;
                case is_array($value):
                    $expression = 'IN';
                    break;
                case $value === null:
                    $expression = 'IS NULL';
                    break;
                default:
                    $expression = '=';
                    break;
            }
        }

        // Set the value as a parameter, if necessary
        $expressionParameterKey = null;
        if ($value !== null) {
            $parameterKey = sprintf('%s%s', str_replace('.', '_', $property), uniqid());
            $expressionParameterKey = (is_array($value)) ? sprintf('(:%s)', $parameterKey) : sprintf(':%s', $parameterKey);
            $this->queryBuilder->setParameter($parameterKey, $value);
        }

        return new Comparison($property, $expression, $expressionParameterKey);
    }

    /**
     * @param array $condition
     * @param string $key
     * @return boolean
     */
    protected static function isDisjunction(array $condition, $key)
    {
        return isset($condition[$key]) && boolval($condition[$key]);
    }
}
