<?php

namespace Internetgalerie\IgDatapoolFe\Services;

use TYPO3\CMS\Core\SingletonInterface;
use TYPO3\CMS\Extbase\Property\PropertyMapper;
use TYPO3\CMS\Extbase\Persistence\QueryInterface;
use Internetgalerie\IgDatapoolFe\Controller\ActionController;
use DateTime;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use Exception;
use TYPO3\CMS\Extbase\Persistence\Generic\Qom\ConstraintInterface;
use TYPO3\CMS\Extbase\Reflection\ReflectionService;

/**
 * Sevice to build search queries.
 * Usage:
 *     public function findWithSearchLogic(){
 *         $query = $this -> createQuery();
 *
 *        // initialize the search logic service and alias it as $s
 *        $s = $this->initSearch($query);
 *
 *        // build logic and apply it to the query
 *        $s->apply(// LEAVE THIS COMMENT! (disables Aptana auto-formatter)
 *            $s->_and(
 *                $s->equals('property','argument', 'defaultValue', $boolForceValue),
 *                $s->equals('kanton', 'formvalue'),
 *                $s->_or (
 *                    $s->parse('description','keyword'),
 *                    $s->parse('title', 'keyword'),
 *                    $s->parse('somethingelse', 'keyword')
 *                )
 *            )
 *        );
 *         [...]
 */
class SearchLogicService implements SingletonInterface
{
    /**
     * Request Arguments inside _search[] or mit AT
     *
     * @var Array
     */
    protected $arguments = [];

    /**
     * reference to the current query
     *
     * @var QueryInterface $query
     */
    protected $query = null;

    /**
     * @var ReflectionService
     */
    protected $reflectionService;

    /**
     * @var PropertyMapper
     */
    protected $propertyMapper;

    /**
     * @param ReflectionService $reflectionService
     */
    public function injectReflectionService(ReflectionService $reflectionService): void
    {
        $this->reflectionService = $reflectionService;
    }

    /**
     * @param PropertyMapper $propertyMapper
     */
    public function injectPropertyMapper(PropertyMapper $propertyMapper): void
    {
        $this->propertyMapper = $propertyMapper;
    }

    /**
     * bind the current $query objet to the search logic
     * This has to be called first!
     *
     * @param  $query \TYPO3\CMS\Extbase\Persistence\QueryInterface
     * @return void
     */
    public function bind(QueryInterface $query): void
    {
        $this -> query = $query;
        $request = ActionController::$currentController -> getRequest();
        // take the shortcut if there is no search argument
        if (!$request -> hasArgument('@search')) {
            return;
        }

        //get the argumetns
        $args = $request -> getArgument('@search');
        if (is_array($args)) {
            $this -> arguments = $args;
        }
    }

    /**
     * Apply the constraints to the Query
     * use as follows:
     * $s->apply(// LEAVE THIS COMMENT! (disables Aptana auto-formatter)
     *    $s->_and(
     *        $s->equals('property','argument'),
     *        $s->equals('kanton', 'formvalue'),
     *     []...]
     *
     * @param ConstraintInterface $constraint
     */
    public function apply($constraint = null): void
    {
        $request = ActionController::$currentController -> getRequest();

        // take the shortcut if there is no search argument
        if ($constraint == null && count($this -> arguments)) {
            //get all properties
            $classSchema = $this->reflectionService->getClassSchema(ActionController::$currentObjectClass);
            //$modelProperties = $this -> reflectionService -> getClassPropertyNames(\Internetgalerie\IgDatapoolFe\Controller\ActionController::$currentObjectClass);
            $modelProperties = $classSchema->getProperties();

            //Build on the fly constraints
            $arr = [];
            foreach ($this->arguments as $key => $_) {
                if (!in_array($key, $modelProperties)) {
                    continue;
                }

                $curConstraint = $this -> parse($key, $key);
                if ($curConstraint != null) {
                    $arr[] = $curConstraint;
                }
            }
            if (count($arr) > 1) {
                $constraint = $this -> query -> logicalAnd($arr);
            } else {
                $constraint = array_shift($arr);
            }
        }
        if ($constraint != null) {
            $this -> query -> matching($constraint);
        }
    }

    /**
     * Logical AND conjunction
     *
     * @param ConstraintInterface $_ List of constarints
     * @return ConstraintInterface And Conjunction
     */
    public function _and($_)
    {
        $res = [];
        foreach (func_get_args() as $arg) {
            if ($arg != null) {
                $res[] = $arg;
            }
        }
        if (!count($res)) {
            return null;
        }
        return $this -> query -> logicalAnd($res);
    }

    /**
     * Logical OR conjunction
     *
     * @param ConstraintInterface $_ List of constarints
     * @return ConstraintInterface OR Conjunction
     */
    public function _or($_)
    {
        $res = [];
        foreach (func_get_args() as $arg) {
            if ($arg != null) {
                $res[] = $arg;
            }
        }
        if (!count($res)) {
            return null;
        }
        return $this -> query -> logicalOr($res);
    }

    /**
     * Logical Not Operator
     *
     * @param ConstraintInterface $arg Constraint to be negated
     * @return ConstraintInterface
     */
    public function _not($arg)
    {
        if ($arg == null) {
            return null;
        }
        return $this -> query -> logicalNot($arg);
    }

    /**
     * If condition.
     * E.g., used for checkbox searches.
     *
     * s->_if( $s->arg('someBoolean')==1, $s->parse('name','', 'ffb', true) )
     *
     * @param bool $condition
     */
    public function _if($condition, $then, $else = null)
    {
        return $condition ? $then : $else;
    }

    /**
     * Equals Operator.
     *
     * @param string $property   The property aka DB-Field
     * @param mixed  $argument   The Formfield (can also be an object etc..)
     * @param string $default    The default value, if none submitted
     * @param bool   $forcevalue Force the defaultValue, even if one is submitted.
     */
    public function equals($property, $argument, $default = '', $forceDefault = false)
    {
        if (($value = $this -> getArg($argument, $default, $property, $forceDefault)) === false) {
            return null;
        }
        return $this -> query -> equals($property, $value, false);
    }

    /**
     * Case-sensitive Equals Operator.
     *
     * @param string $property   The property aka DB-Field
     * @param mixed  $argument   The Formfield (can also be an object etc..)
     * @param string $default    The default value, if none submitted
     * @param bool   $forceValue Force the defaultValue, even if one is submitted.
     */
    public function equalsCaseSensitive($property, $argument, $default = '', $forceDefault = false)
    {
        if (($value = $this -> getArg($argument, $default, $property, $forceDefault)) === false) {
            return null;
        }
        return $this -> query -> equals($property, $value, true);
    }

    /**
     * In Operator.
     *
     * @param string $property   The property aka DB-Field
     * @param mixed  $argument   The Formfield (can also be an object etc..)
     * @param string $default    The default value, if none submitted
     * @param bool   $forceValue Force the defaultValue, even if one is submitted.
     */
    public function in($property, $argument, $default = '', $forceDefault = false)
    {
        if (($value = $this -> getArg($argument, $default, $property, $forceDefault)) === false) {
            return null;
        }
        return $this -> query -> in($property, $value);
    }

    /**
     * Contains Operator, this one is powerful: if you need EXISTS etc..
     *
     * @param string $property   The property aka DB-Field
     * @param mixed  $argument   The Formfield (can also be an object etc..)
     * @param string $default    The default value, if none submitted
     * @param bool   $forceValue Force the defaultValue, even if one is submitted.
     */
    public function contains($property, $argument, $default = '', $forceDefault = false)
    {
        if (($value = $this -> getArg($argument, $default, $property, $forceDefault)) === false) {
            return null;
        }
        return $this -> query -> contains($property, $value);
    }

    /**
     * Like Operator. Substring search
     *
     * @param string $property   The property aka DB-Field
     * @param mixed  $argument   The Formfield (can also be an object etc..)
     * @param string $default    The default value, if none submitted
     * @param bool   $forceValue Force the defaultValue, even if one is submitted.
     */
    public function like($property, $argument, $default = '', $forceDefault = false)
    {
        if (($value = $this -> getArg($argument, $default, $property, $forceDefault)) === false) {
            return null;
        }
        return $this -> query -> like($property, '%' . $value . '%', false);
    }

    /**
     * Case Senssitive Like Operator. Substring search
     *
     * @param string $property   The property aka DB-Field
     * @param mixed  $argument   The Formfield (can also be an object etc..)
     * @param string $default    The default value, if none submitted
     * @param bool   $forceValue Force the defaultValue, even if one is submitted.
     */
    public function likeCaseSensitive($property, $argument, $default = '', $forceDefault = false)
    {
        if (($value = $this -> getArg($argument, $default, $property, $forceDefault)) === false) {
            return null;
        }
        return $this -> query -> like($property, $value, true);
    }

    /**
     * Less Than (<) Operator.
     *
     * @param string $property   The property aka DB-Field
     * @param mixed  $argument   The Formfield (can also be an object etc..)
     * @param string $default    The default value, if none submitted
     * @param bool   $forceValue Force the defaultValue, even if one is submitted.
     */
    public function lessThan($property, $argument, $default = '', $forceDefault = false)
    {
        if (($value = $this -> getArg($argument, $default, $property)) === false) {
            return null;
        }
        return $this -> query -> lessThan($property, $value);
    }

    /**
     * Greater Than (>) Operator.
     *
     * @param string $property   The property aka DB-Field
     * @param mixed  $argument   The Formfield (can also be an object etc..)
     * @param string $default    The default value, if none submitted
     * @param bool   $forceValue Force the defaultValue, even if one is submitted.
     */
    public function greaterThan($property, $argument, $default = '', $forceDefault = false)
    {
        if (($value = $this -> getArg($argument, $default, $property)) === false) {
            return null;
        }
        return $this -> query -> greaterThan($property, $value);
    }

    /**
     * Less Than or Equal (<=) Operator.
     *
     * @param string $property   The property aka DB-Field
     * @param mixed  $argument   The Formfield (can also be an object etc..)
     * @param string $default    The default value, if none submitted
     * @param bool   $forceValue Force the defaultValue, even if one is submitted.
     */
    public function lessThanOrEqual($property, $argument, $default = '', $forceDefault = false)
    {
        if (($value = $this -> getArg($argument, $default, $property, $forceDefault)) === false) {
            return null;
        }
        return $this -> query -> lessThanOrEqual($property, $value);
    }

    /**
     * Greater Than or Equal (>=) Operator.
     *
     * @param string $property   The property aka DB-Field
     * @param mixed  $argument   The Formfield (can also be an object etc..)
     * @param string $default    The default value, if none submitted
     * @param bool   $forceValue Force the defaultValue, even if one is submitted.
     */
    public function greaterThanOrEqual($property, $argument, $default = '', $forceDefault = false)
    {
        if (($value = $this -> getArg($argument, $default, $property, $forceDefault)) === false) {
            return null;
        }
        return $this -> query -> greaterThanOrEqual($property, $value);
    }

    /**
     * Parser Search: supports searches like
     * "one stiring" -not +needthis (this)|(that)
     *
     * @param string $property   The property aka DB-Field
     * @param mixed  $argument   The Formfield (can also be an object etc..)
     * @param string $default    The default value, if none submitted
     * @param bool   $forcevalue Force the defaultValue, even if one is submitted.
     */
    public function parse($property, $argument, $default = '', $forceDefault = false)
    {
        if (($value = $this -> getArg($argument, $default, $property, $forceDefault)) === false) {
            return null;
        }

        return $this -> parserSearch($property, $value);
    }

    /**
     * Check if argument is set and return it.
     *
     * @param string $name    form field anme
     * @param string $default Default Value
     */
    protected function getArg($name, $default = '', $property = '', $forceDefault = false)
    {
        $res = false;
        if ($forceDefault) {
            $res = $default;
        } else {
            $res = $this -> arguments[$name];
            if (!($res != '' || is_array($res)) && $default != '') {
                //then use the default
                $res = $default;
            }
        }

        //convert
        $res = $this -> convertArgument($res, $property);
        //check if it is something useful after convert
        if ($res) {
            return $res;
        }

        //or return false
        return false;
    }

    /**
     * Convert special argumetns lke datetime
     *
     * @param mixed _res (AT) the current value
     * @param string                            $property the current property
     */
    protected function convertArgument($res, $property)
    {
        //$this -> reflectionService -> getPropertyTagValues(\Internetgalerie\IgDatapoolFe\Controller\ActionController::$currentObjectClass, $property, 'var');
        $classSchema = $this->reflectionService->getClassSchema(ActionController::$currentObjectClass);
        $propertyObject = $classSchema->getProperty($property);
        $type = $propertyObject->getType();
        //$type = array_shift();
        switch ($type) {
        case '\DateTime':
            $res = $this -> propertyMapper -> convert($res, 'DateTime');
            if (!($res instanceof DateTime)) {
                return false;
            }
            $res = $res -> format('Y-m-d');
            break;
            // --> implement more if needed!
        }
        return $res;
    }

    /**
     * You can also use the old search logic for your sql sattements - in SOME cases this might make sense..
     * But normally... JUST DON'T!!!
     *
     * @param  string $xml the searhc logic xml
     * @return string the built sql query
     */
    public function yesImReallySureThatIWantToUseTheDeprecatedSearchLogic($xml)
    {
        $arr = GeneralUtility::xml2array(trim($xml));

        if (!is_array($arr)) {
            throw new Exception('The supplied DB Logic XML is invalid: ' . $arr);
        }

        $logic = new DataPoolV2SearchLogicService($arr);

        return $logic -> getSQLWhere($this -> arguments);
    }

    /**
     * returns an argument
     */
    public function arg($string)
    {
        return $this -> arguments[$string];
    }

    /**
     * sets a search argument
     */
    public function setArg($string, $value): void
    {
        $this -> arguments[$string] = $value;
    }

    /**
     * Parser Serach ported from DataPool 2.0
     */
    private function parserSearch($field, $inputString)
    {
        // split the stirng into chars.
        $psArray = str_split((string) $inputString);

        // lets rebuild it, we start empty!
        $parseString = ' ';

        //if there is no modifier as the first argument, insert a +
        if (!in_array($psArray[0], ['+', '-'])) {
            $parseString .= '+';
        }

        $c = 0;
        //LOOPY LOOP LOOP LOOP through each char in order to normalize the string
        $quote = false;
        // nope, we are not inside a quote by defautl.
        foreach ($psArray as $char) {
            // inside a quote or not inside a quote, that is the question!
            $quote = ($char == '"' ? !$quote : $quote);
            if ($quote) {
                //as long as we are inside.. just continue and ignore whitespaces...
                $parseString .= $char;
            } else {
                if ($char == ' ' && isset($psArray[$c + 1]) && !in_array($psArray[$c + 1], ['-', '+'])) {
                    //add a + for the next part (if there is no -/+ prefix)
                    $parseString .= ' +';
                } elseif ($char == '|') {
                    //in case there is an or, insert this special stuff.
                    $parseString .= '#|#';
                } else {
                    //if its just a char, we are still inside a word. just continue adding it.
                    $parseString .= $char;
                }
            }
            $c++;
        }

        //now we have a normalized search string!
        $matches = [];
        //looks wierd.. but seems to work.
        $pattern = '
/
\s(\+|\-) #modifier nach whitespache
(
("[^"]+") #klammer begriff
|
([^\-\+][^\"\s]*) #1. buchstabe kein +-, dann beliebig viele inkl +-
)
/x';
        preg_match_all($pattern, $parseString, $matches);

        //inside the matches we have all the nrmalized +string or -"some thing" stuff.
        // can we also have ors!? #|#?
        $all = [];
        foreach ($matches [0] as $expr) {
            $expr = ltrim($expr, ' ');
            $op = $expr[0];
            $expr = ltrim($expr, '+ -');
            // add the constarint to the AND-array
            $all[] = $this -> parseSearchExpr($field, $op, $expr);
        }
        //if there is more than one constraint, and it
        if (count($all) > 1) {
            return $this -> query -> logicalAnd($all);
        } else {
            // else just return it
            return array_shift($all);
        }
    }

    /**
     * There might be ORs inside one normalized expression
     * Handle them
     */
    private function parseSearchExpr($field, $op, $expr)
    {
        $elems = explode('#|#', (string) $expr);
        $parsed = [];
        foreach ($elems as $expr) {
            $parsed[] = $this -> parseSearchElem($field, $op, $expr);
        }

        //return a logical or or just a constraint
        if (count($parsed) > 1) {
            return $this -> query -> logicalOr($parsed);
        } else {
            return array_shift($parsed);
        }
    }

    /**
     * For a search  Element $expr (i.e. word or "some words") build a LIKE constarint
     * IF there is a "-", negate it!
     *
     * @param string $field The property name
     * @param string $op    might be -, ie negated
     * @param string $expr  the current word/value
     */
    private function parseSearchElem($field, $op, $expr)
    {
        //there might be a ", trim it
        $expr = trim($expr, '"');
        $expr = '%' . $expr . '%';
        $constraint = $this -> query -> like($field, $expr, false);

        //in case of negation (-) negate it
        if ($op == '-') {
            $constraint = $this -> query -> logicalNot($constraint);
        }
        return $constraint;
    }

    /**
     * Auto sorting stuff
     */

    /**
     * get the current AutoSorting Constaraints
     *
     * @return array property => sortorder
     */
    public function getAutoSorting()
    {
        if ($arr = $this -> getCurrentSorting()) {
            return [$arr['property'] => $arr['order'] ? QueryInterface::ORDER_ASCENDING : QueryInterface::ORDER_DESCENDING];
        }
        return null;
    }

    /**
     * get the new sorting parameters for property $property
     *
     * @param  string  $property
     * @param  boolean $testProperty test the property with reflectionService, only sort if property exists
     * @return array
     */
    public function getNewSorting($property, $testProperty=true)
    {
        $old = $this -> getCurrentSorting($testProperty);
        $newSorting = ['property' => $property];
        $newSorting['order'] = $old['property'] == $property ? intval(!$old['order']) : 1;
        return $newSorting;
    }

    /**
     * Get the current sorting as array
     *
     * @param  boolean $testProperty test the property with reflectionService, only sort if property exists
     * @return array
     */
    public function getCurrentSorting($testProperty=true)
    {
        $s = ['', ''];
        if (ActionController::$currentController -> getRequest() -> hasArgument('@sorting')) {
            $s = explode('_', (string) ActionController::$currentController -> getRequest() -> getArgument('@sorting'));
        }
        $sortArray['property'] = $s[0];
        $sortArray['order'] = $s[1];
        //$this -> reflectionService -> getClassPropertyNames(\Internetgalerie\IgDatapoolFe\Controller\ActionController::$currentObjectClass)
        $classSchema = $this->reflectionService->getClassSchema(ActionController::$currentObjectClass);
        $modelProperties = $classSchema->getProperties();
        if (!trim($sortArray['property']) || ($testProperty && $sortArray['property'] && !in_array($sortArray['property'], array_keys($modelProperties)))) {
            return null;
        }
        return $sortArray;
    }
}
