<?php

/**
 * Class InventoryLib
 */
class InventoryLib
{
    /**
     * @var VariablesDictionaryModel
     */
    private $variableDictionaryModel;
    private $pdo;


    public function __construct(VariablesDictionaryModel $variablesDictionary)
    {
        $this->variableDictionaryModel = $variablesDictionary;
        $this->pdo = CfPdo::getInstance()->getConnection();
    }

    /**
     * @var int
     */
    private $limit = 1000;
    /**
     * @var int
     */
    private $maxLimit = 1000000000;

    public const LESS_OPERATOR = '<';
    public const GREATER_OPERATOR = '>';
    public const EQUAL_OPERATOR = '=';
    public const NOT_EQUAL_OPERATOR = '!=';
    public const LESS_OR_EQUAL_OPERATOR = '<=';
    public const GREATER_OR_EQUAL_OPERATOR = '>=';
    public const MATCHES_OPERATOR = 'matches';
    public const NOT_MATCH_OPERATOR = 'not_match';
    public const CONTAINS_OPERATOR = 'contains';
    public const NOT_CONTAIN_OPERATOR = 'not_contain';
    public const REGEX_MATCHES_OPERATOR = 'regex_matches';
    public const REGEX_NOT_MATCH_OPERATOR = 'regex_not_match';
    public const IS_REPORTED_OPERATOR = 'is_reported';
    public const IS_NOT_REPORTED_OPERATOR = 'is_not_reported';
    public const IP_MASK_OPERATOR = 'ip_mask';

    public const SEARCH_COLUMNS = ['Host name', 'OS', 'CFEngine ID', 'IPv4 addresses'];
    public const SEARCH_ATTRIBUTE = 'cfHostSearchQuery';
    /**
     * @var array
     */
    private $operatorsMap = [
        //Numbers
        self::LESS_OPERATOR => ['operator' => '<', 'function' => 'cf_convertToNum'],
        self::GREATER_OPERATOR => ['operator' => '>', 'function' => 'cf_convertToNum'],
        self::EQUAL_OPERATOR => ['operator' => '=', 'function' => 'cf_convertToNum'],
        self::NOT_EQUAL_OPERATOR => ['operator' => '!=', 'function' => 'cf_convertToNum'],
        self::LESS_OR_EQUAL_OPERATOR => ['operator' => '<=', 'function' => 'cf_convertToNum'],
        self::GREATER_OR_EQUAL_OPERATOR => ['operator' => '>=', 'function' => 'cf_convertToNum'],
        //Strings
        self::MATCHES_OPERATOR => ['operator' => 'ILIKE'],
        self::NOT_MATCH_OPERATOR => ['operator' => 'NOT ILIKE'],
        self::CONTAINS_OPERATOR => ['operator' => 'LIKE'],
        self::NOT_CONTAIN_OPERATOR => ['operator' => 'NOT LIKE'],
        'slist' => [
            self::MATCHES_OPERATOR => ['operator' => '~'],
            self::NOT_MATCH_OPERATOR => ['operator' => '!~'],
        ],
        self::REGEX_MATCHES_OPERATOR => ['operator' => '~*'],
        self::REGEX_NOT_MATCH_OPERATOR => ['operator' => '!~*'],
        self::IS_REPORTED_OPERATOR => ['operator' => 'IS NOT NULL'],
        self::IS_NOT_REPORTED_OPERATOR => ['operator' => 'IS NULL'],
        self::IP_MASK_OPERATOR => ['operator' => '>>'],
    ];

    /**
     * @param $data
     * @return string
     * @throws Exception
     */
    public function applyFilter($data): string
    {
        $pdo = CfPdo::getInstance()->getConnection();

        $where = ['WHERE'];
        if (isset($data->filter) && (is_object($data->filter) || is_array($data->filter))) {
            $dataFilter = $data->filter;

            foreach ($dataFilter as $variable => $opVal) {
                foreach ($opVal as $operator => $value) {
                    $andOrOperator = 'AND';
                    // if operator starts with `or|` like `or|match` then we apply OR condition and use match operator
                    if (str_starts_with($operator, 'or|')) {
                        $andOrOperator = 'OR';
                        $operator = substr($operator, 3);
                    }
                    if (is_array($value)) {
                        foreach ($value as $v) {
                            $where[] = sizeof($where) > 1 ? $andOrOperator : '';
                            $where[] = $this->makeWhereCondition($operator, $variable, $v, $pdo);
                        }
                    } else {
                        $where[] = sizeof($where) > 1 ? $andOrOperator : '';
                        $where[] = $this->makeWhereCondition($operator, $variable, $value, $pdo);
                    }
                }
            }
        }

        if (
            (property_exists($data, 'hostContextInclude') && sizeof($data->hostContextInclude) > 0) ||
            (property_exists($data, 'hostContextExclude') && sizeof($data->hostContextExclude) > 0)
        ) {
            /**
            * contexts allow usage of "_" and ":" signs
            * "_" is treated by postgres text search parser as whitespace
            * ":" sign is not allowed in tsquery and postgreql throws an error
            * replace "_" and ":" signs with "." instead, as we do in the cache generation
            */
            $hostContextInclude = property_exists($data, 'hostContextInclude') ?
                str_replace(['_', ':'], '.', $data->hostContextInclude) :
                '';
            $hostContextExclude = property_exists($data, 'hostContextExclude') ?
                str_replace(['_', ':'], '.', $data->hostContextExclude) :
                '';

            $where[] = sizeof($where) === 1 ? '' : ' AND ';
            // here is safe to use user data in the query because the data passes to the Query API and not executing directly
            // and data is additionally quoted to prevent SQL injections
            $where[] = " inventory_new.hostkey IN  ( 
                        SELECT v_contextcache.hostkey FROM v_contextcache 
                        WHERE ContextVector @@ to_tsquery(
                        'simple', 
                        {$pdo->quote($this->makeContextsFilter($hostContextInclude, $hostContextExclude))}
                        ))";

        }

        if (isset($data->hostFilter)) {
            $this->applyHostsFilter($where, $data->hostFilter, $pdo);
        }

        //if $where contains only 1 element it's WHERE, skip it
        return sizeof($where) > 1 ? implode(' ', $where) : '';
    }

    public const ENTRIES_ATTRIBUTE_MAP = [
        'ip' => ['attribute' => 'IPv4 addresses', 'operator' => self::MATCHES_OPERATOR],
        'hostkey' => ['attribute' => 'hostkey', 'operator' => self::EQUAL_OPERATOR],
        'ip_mask' => ['attribute' => 'IPv4 addresses', 'operator' => self::IP_MASK_OPERATOR],
        'hostname' => ['attribute' => 'Host name', 'operator' => self::EQUAL_OPERATOR],
        'mac' => ['attribute' => 'MAC addresses', 'operator' => self::MATCHES_OPERATOR]
    ];

    private function makeHostFiltersConditions($entries, $pdo): array
    {
        $conditions = [];
        foreach ($entries as $entry => $data) {
            foreach ($data as $value) {
                $conditions[] = $this->makeWhereCondition(
                    self::ENTRIES_ATTRIBUTE_MAP[$entry]['operator'],
                    self::ENTRIES_ATTRIBUTE_MAP[$entry]['attribute'],
                    $value,
                    $pdo
                );
            }
        }
        return $conditions;
    }

    private function applyHostsFilter(&$where, $hostFilter, \PDO $pdo): void
    {
        $includeConditions = [];
        $excludeConditions = [];

        if (isset($hostFilter->includes->entries)) {
            $includeConditions = $this->makeHostFiltersConditions($hostFilter->includes->entries, $pdo);
        }

        if (isset($hostFilter->excludes->entries)) {
            $excludeConditions = $this->makeHostFiltersConditions($hostFilter->excludes->entries, $pdo);
            $excludeConditions = array_map(fn ($condition) => "NOT($condition)", $excludeConditions);
        }

        // if no where then following conditions will be the main where statement otherwise will be joined via AND/OR
        $whereCond = (sizeof($where) <= 1) ? '' : (!empty($excludeConditions) && empty($includeConditions) ? ' AND ' : ' OR ');

        if (!empty($includeConditions) && empty($excludeConditions)) {
            $where[] = $whereCond . '(' . join(' OR ', $includeConditions) . ')';
        } elseif (!empty($includeConditions) && !empty($excludeConditions)) {
            // When both includes and excludes are set, we need to use filter rules together with includes,
            // like `WHERE (filter OR includes) AND excludes`, otherwise, excluded entries will not affect the result.
            // array_slice($where, 1) - removes first item from the $where which is 'WHERE'
            $where = [
                'WHERE',
                '(' . implode(' ', array_slice($where, 1)) . $whereCond . '(' . join(' OR ', $includeConditions) . ')) AND (' . join(' AND ', $excludeConditions) . ')'
            ];
        } elseif (!empty($excludeConditions)) {
            $where[] = $whereCond . '(' . join(' AND ', $excludeConditions) . ')';
        }
    }

    private function buildSearchQuerySql(string $searchQuery, string $literalOperator, \PDO $pdo): string
    {
        if ($literalOperator !== self::MATCHES_OPERATOR) {
            throw new ResponseException(
                self::SEARCH_ATTRIBUTE . ' filter supports only ' . self::MATCHES_OPERATOR . ' operator.',
                Response::NOTACCEPTABLE
            );
        }

        $where = array_map(
            fn ($column) => "values->>'$column' LIKE {$pdo->quote('%' . trim($searchQuery, "'") . '%')}",
            self::SEARCH_COLUMNS
        );

        return '(' . implode(' OR ', $where) . ')';
    }

    private function makeContextsFilter($hostContextInclude, $hostContextExclude): string
    {
        $where = [];
        if ($hostContextInclude != null) {
            if (is_array($hostContextInclude)) {
                $where[] = "(" . implode(' & ', $hostContextInclude) . ")";
            } else {
                $where[] = "($hostContextInclude)";
            }
        }

        if ($hostContextExclude != null) {
            if (is_array($hostContextExclude)) {
                $where[] = "(!" . implode(' & !', $hostContextExclude) . ")";
            } else {
                $where[] = "(!$hostContextExclude)";
            }
        }

        return implode(' & ', $where);
    }


    /**
     * @param $data
     * @return string
     */
    public function applySorting($data)
    {
        if (empty($data->sort)) {
            return '';
        }
        $sort = $data->sort;
        if ($sort[0] == '-') {  //check first symbol
            $variable = substr($sort, 1); //if first symbol is `-`, get variable without `-`
            $direction = 'DESC';
        } else {
            $variable = $sort;
            $direction = 'ASC';
        }

        $variableEntity = $this->variableDictionaryModel->getByName($variable);

        if ($variableEntity && in_array($variableEntity['type'], ['int', 'real'])) {
            return " ORDER BY cf_convertToNum(values->>{$this->pdo->quote($variable)}) $direction ";
        }

        return " ORDER BY values->>{$this->pdo->quote($variable)} $direction ";

    }


    /**
     * @param $data
     * @return string
     * @throws Exception
     */
    public function applySelect($data)
    {
        $variableModel = new VariablesDictionaryModel();
        if (!isset($data->select)) {
            throw new Exception('Select field is required');
        }

        if (!is_array($data->select)) {
            throw new Exception('Wrong Select data');
        }

        $selectArray = $data->select;

        $selectData = [];
        $functionsMap = $variableModel->getFunctionsMap();

        /**
         * var $aliases contains associative array where key is an alias and value is inventory attribute
         * if alias will be passed as select argument then API will return value from related inventory attribute
         */
        $aliases = (new CfInventoryAliases())->getAliases();

        foreach ($selectArray as $key => $select) {

            $selectAs = '"' . trim(
                $this->pdo->quote(
                    str_replace('"', '""', $select) // escape double quotes
                ),
                "'" // trims ' as we don't need them in AS statement
            ) . '"'; // surrounds AS value by "
            if (isset($aliases[strtolower($select)])) {
                $select = $aliases[strtolower($select)];
            }

            if ($select == 'hostkey') {
                $selectData[] = " $select AS $selectAs";
            } elseif ($select == 'resultCount') {
                $selectData[] = " count(*) as count ";
            } elseif (isset($functionsMap[$select])) {
                $selectData[] = " {$functionsMap[$select]}(values->>{$this->pdo->quote($select)}) AS $selectAs";
            } else {
                $selectData[] = " values->>{$this->pdo->quote($select)} AS $selectAs";
            }


        }
        return implode(', ', $selectData);
    }


    /**
     * @param $literalOperator
     * @param $variable
     * @param $value
     * @return string
     * @throws Exception
     */
    private function makeWhereCondition($literalOperator, $variable, $value, \PDO $pdo)
    {
        $variableEntity = $this->variableDictionaryModel->getByName($variable);
        $variableType = null;

        if (is_array($variableEntity) && isset($variableEntity['type'])) {
            $variableType = $variableEntity['type'];
        }

        if (!isset($this->operatorsMap[$literalOperator])) {
            throw new Exception('Wrong abbreviation of operator');
        }

        $conditionData = $this->operatorsMap[$variableType][$literalOperator] ?? $this->operatorsMap[$literalOperator];
        $value = $this->prepareValueToWhere($value, $literalOperator, $variableType);

        if ($variable == 'hostkey') {
            $whereCond = vsprintf("%s %s %s", [$variable, 'LIKE', $value]);
        } elseif ($variable === self::SEARCH_ATTRIBUTE) {
            $whereCond = $this->buildSearchQuerySql($value, $literalOperator, $pdo);
        } elseif ($literalOperator === self::IP_MASK_OPERATOR) {
            $whereCond = vsprintf(
                // make an array::inet[] from the list data
                "%s %s ANY(regexp_split_to_array(REPLACE(values->>%s, ' ', ''), ',')::inet[])",
                [$value, $conditionData['operator'], $this->pdo->quote($variable)]
            );
        } elseif (isset($conditionData['function']) && in_array($variableType, ['int', 'real'])) {
            $whereCond = vsprintf(
                "%s(values->>%s) %s %s(%s)",
                [
                    $conditionData['function'],
                    $this->pdo->quote($variable),
                    $conditionData['operator'],
                    $conditionData['function'],
                    $value
                ]
            );
        } else {
            /**
             * If a variable is not reported, then a negative comparison will not return it.
             * For example, CFEngine roles != 'policy_server' in the case of regular hosts will not work as they're not reporting it.
             * To solve this case, we set not reported values to the ideographic space if operators are not is_reported or is_not_reported
             */
            $variablePattern =
                (in_array($literalOperator, [self::IS_REPORTED_OPERATOR, self::IS_NOT_REPORTED_OPERATOR]))
                    ? "values->>%s"
                    : "COALESCE (values->>%s, '　')";
            $whereCond = vsprintf("$variablePattern %s %s", [$this->pdo->quote($variable), $conditionData['operator'], $value]);
        }

        return $whereCond;
    }

    private function prepareValueToWhere($value, $literalOperator, $type)
    {
        $filterValueFormatter = FormatInventoryFilterFactory::getImplementation($type);
        return $filterValueFormatter->getValueForWhere($value, $literalOperator, $this->pdo);
    }


    /**
     * @param $data
     * @return string
     */
    public function changeResponse($data, $showSQL = false)
    {
        $data = json_decode($data);
        if (isset(reset($data->data)->query) and !$showSQL) {
            unset(reset($data->data)->query);
        }
        return json_encode($data, JSON_PRETTY_PRINT);
    }

    /**
     * @param $limit
     * @return int
     */
    public function getLimit($limit = 1000)
    {
        //if the limit is null then set -1 as non-limit value
        $limit = $limit === null ? -1 : $limit;
        $limit = (isset($limit) && is_numeric($limit)) ? $limit : $this->limit;
        return $limit > $this->maxLimit ? $this->maxLimit : $limit;
    }

    public function setFilterFromGroup(&$data, string $username)
    {
        $modelName = ucfirst($data->group->type) . 'HostGroupsModel';
        if (class_exists($modelName)) {
            /**
             * @var $groupModel BaseHostGroupsModel
             */
            $groupModel = new $modelName($username);
        } else {
            throw new InvalidArgumentException('Wrong group type.');
        }

        if (!(['filter' => $groupFilter] = $groupModel->get($data->group->id, updateHostCountCache: false))) {
            throw new InvalidArgumentException('Group is not found.');
        }

        $data->filter = $groupFilter['filter'];
        if (isset($groupFilter['hostFilter'])) {
            $data->hostFilter = json_decode(json_encode($groupFilter['hostFilter'])); // host filter should be an object
        }
        $data->hostContextExclude = $groupFilter['hostContextExclude'] ?? [];
        $data->hostContextInclude = $groupFilter['hostContextInclude'] ?? [];
    }

    public function extendSQLWithInventoryCTE(&$data): void
    {
        $inventoryLib = new InventoryLib(new VariablesDictionaryModel());
        $where = $inventoryLib->applyFilter($data);
        $cte = "WITH inventory_cte as (SELECT hostkey as filtered_hostkey FROM inventory_new $where) ";

        $re = '/^(\s+)*WITH/';
        preg_match($re, $data->query, $matches);
        // if user query already contain CTE then add inventory together with existing one
        if (sizeof($matches) > 0) {
            $data->query = preg_replace($re, $cte . ', ', $data->query);
        } else {
            $data->query = $cte . $data->query;
        }
    }
}
