<?php
/* Icinga Web 2 | (c) 2013 Icinga Development Team | GPLv2+ */

namespace Icinga\Protocol\Livestatus;

use Icinga\Data\SimpleQuery;
use Icinga\Exception\IcingaException;
use Icinga\Data\Filter\Filter;
use Icinga\Data\Filter\FilterChain;
use Icinga\Data\Filter\FilterExpression;
use Icinga\Data\Filter\FilterOr;
use Icinga\Data\Filter\FilterAnd;
use Icinga\Data\Filter\FilterNot;
use Exception;

class Query extends SimpleQuery
{
    protected $customvars = array();

    /**
     * Columns in this array are always "combined" ones creating their value
     * based on a filter expression. The result is always either "1" or "0"
     */
    protected $filter_flags = array();

    /**
     * Columns that return arrays. Will be decoded.
     */
    protected $arrayColumns = array(
        'members' => true,
    );

    /**
     * Columns to be fetched for sorting / filtering, will not be returned
     */
    protected $extraFiltercolumns = array();

    /**
     * All available columns. To be overridden by specific query implementations
     */
    protected $available_columns = array();

    protected $count = false;

    /**
     * Headers for columns sent to Livestatus socket
     */
    protected $preparedHeaders = array();

    public function hasColumns()
    {
        return $this->columns !== null;
    }

    public function getColumns()
    {
        return $this->columns;
    }

    public function withHeaders(& $row)
    {
        return array_combine($this->preparedHeaders, $row->toArray());
    }

    /**
     * Whether the named columns value is generated by a filter expression
     */
    public function isFilterFlag($column)
    {
        return array_key_exists($column, $this->filter_flags);
    }

    // completes a given row
    public function resultRow(& $row)
    {
        // $row -> raw SplArray
        // $res -> object
        // $cv  ->
        // $result -> object to be returned
        $result = (object) array();
        $res = $this->withHeaders($row);
        $cv = array();
        if (array_key_exists('custom_variables', $res)) {
            foreach ($this->parseArray($res['custom_variables']) as $cvp) {
                $cv[$cvp[0]] = $cvp[1];
            }
        }

        $combined = array();

        foreach ($this->columns as $alias => $col) {
            if (is_int($alias)) {
                $alias = $col;
            }
            if ($col[0] === '_') {
                $result->$alias = array_key_exists($alias, $cv) ? $cv[$alias] : null;
            } else {
                $func = 'mungeResult_' . $col;
                if (method_exists($this, $func)) {
                    $this->$func($res[$this->available_columns[$col]], $result);
                } elseif (is_array($this->available_columns[$col])) {
                    $combined[$alias] = $col;
                    $result->$alias = null;
                } else {
                    if (strpos($this->available_columns[$col], ' ') === false) {
                        $result->$alias = $res[$this->available_columns[$col]];
                    } else {
                        $result->$alias = $res[$alias];
                    }
                }
            }
        }
       // TODO: Quite some redundancy here :(
        if (! $this->filterIsSupported()) {
            foreach ($this->filter->listFilteredColumns() as $col) {
                if ($this->isFilterFlag($col)) {
                    $result->$col = (string) (int) $this->filterStringToFilter(
                        $this->filter_flags[$col]
                    )->matches((object) $res);
                } else {
                    $func = 'combineResult_' . $col;
                    if (method_exists($this, $func)) {
                        $result->$col = $this->$func($result, $res);
                    }
                }
            }
        }

        foreach ($combined as $alias => $col) {
            if ($this->isFilterFlag($col)) {
                $result->$alias = (string) (int) $this->filterStringToFilter(
                    $this->filter_flags[$col]
                )->matches((object) $res);
                continue;
            }
            $func = 'combineResult_' . $col;
            if (method_exists($this, $func)) {
                $result->$alias = $this->$func($result, $res);
            } else {
                $result->$alias = implode(' - ', $this->available_columns[$col]);
            }
        }


        return $result;
    }

    /**
     * Parse the given encoded array
     *
     * @param string $str the encoded array string
     *
     * @return array
     */
    public function parseArray($str)
    {
        if (empty($str)) {
            return array();
        }

        $result = array();
        $entries = preg_split('/,/', $str);
        foreach ($entries as $e) {
            $result[] = preg_split('/;/', $e);
        }

        return $result;
    }

    public function getColumnAliases()
    {
        $this->columnsToString();
        return $this->preparedHeaders;

        // TODO: Remove once no longer needed:
        $aliases = array();
        $hasCustom = false;
        foreach ($this->getColumns() as $key => $val) {
            if ($val[0] === '_') {
                $this->customvars[$val] = null;
                if (! $hasCustom) {
                    $aliases[] = 'custom_variables';
                    $hasCustom = true;
                }
                continue;
            }
            if (is_int($key)) {
                $aliases[] = $val;
            } else {
                $aliases[] = $key;
            }
        }
        return $aliases;
    }
/*
    public function count()
    {
        $this->count = true;
        return $this;
    }
*/

    /**
     * Automagic string casting
     *
     * @return string
     */
    public function __toString()
    {
        try {
            return $this->toString();
        } catch (Exception $e) {
            trigger_error(
                sprintf(
                    '%s in %s on line %d',
                    $e->getMessage(),
                    $e->getFile(),
                    $e->getLine()
                ),
                E_USER_ERROR
            );
        }
    }

    /**
     * Render query string
     *
     * @return string
     */
    public function toString()
    {
        if ($this->table === null) {
            throw new IcingaException('Table is required');
        }

        // Headers we always send
        $default_headers = array(
            // Our preferred output format is CSV as it allows us to fetch and
            // process the result row by row
            'OutputFormat: csv',
            'ResponseHeader: fixed16',
            // Tried to find a save list of separators, this might be subject to
            // change and eventually be transforment into constants
            'Separators: ' . implode(' ', array(ord("\n"), ord('`'), ord(','), ord(';'))),
            // We always use the keepalive feature, connection teardown happens
            // in the connection destructor
            'KeepAlive: on'
        );
        $parts = array(
            sprintf('GET %s', $this->table)
        );

        // Fetch all required columns
        $parts[] =  $this->columnsToString();

        // In case we need to apply a userspace filter as of Livestatus lacking
        // support for some of them we also need to fetch all filtered columns
        if ($this->filterIsSupported() && $filter = $this->filterToString()) {
            $parts[] =  $filter;
        }

        // TODO: Old way of rendering a count query, this should definitively be
        // improved
        if ($this->count === true) {
            $parts[] = 'Stats: state >= 0';
        }

        // TODO: Well... ordering is still missing
        if (! $this->count && $this->hasLimit() && ! $this->hasOrder()) {
            $parts[] = 'Limit: ' . ($this->getLimit() + $this->getOffset());
        }
        $lql = implode("\n", $parts)
             . "\n"
             . implode("\n", $default_headers)
             . "\n\n";

        return $lql;
    }

    /**
     * Get all available columns
     *
     * @return array
     */
    public function getAvailableColumns()
    {
        return $this->available_columns;
    }

    protected function columnsToString()
    {
        $columns = array();
        $this->preparedHeaders = array();


        $usedColumns = $this->columns;
        if (! $this->filterIsSupported()) {
            foreach ($this->filter->listFilteredColumns() as $col) {
                if (! in_array($col, $usedColumns)) {
                    $usedColumns[] = $col;
                }
            }
        }
        foreach ($usedColumns as $col) {
// TODO: No alias if filter???
           if (array_key_exists($col, $this->available_columns)) {
              // Alias if such
              $col = $this->available_columns[$col];
           }
           if ($col[0] === '_') {
               $columns['custom_variables'] = true;
           } elseif (is_array($col)) {
               foreach ($col as $k) {
                   $columns[$k] = true;
               }
           } else {
               $columns[$col] = true;
           }
        }

        $this->preparedHeaders = array_keys($columns);

        if ($this->count === false && $this->columns !== null) {
            return 'Columns: ' . implode(' ', array_keys($columns));
        } else {
            return ''; // TODO: 'Stats: state >= 0'; when count
        }
    }

    /**
     * Whether Livestatus is able to apply the current filter
     *
     * TODO: find a better method name
     * TODO: more granular checks, also render filter-flag columns with lql
     *
     * @return bool
     */
    public function filterIsSupported()
    {
        foreach ($this->filter->listFilteredColumns() as $column) {
            if (is_array($this->available_columns[$column])) {
                // Combined column, hardly filterable. Is it? May work!
                return false;
            }
        }
        return true;
    }

    /**
     * Create a Filter object for a given URL-like filter string. We allow
     * for spaces as we do not search for custom string values here. This is
     * internal voodoo.
     *
     * @param string $string Filter string
     *
     * @return Filter
     */
    protected function filterStringToFilter($string)
    {
        return Filter::fromQueryString(str_replace(' ', '', $string));
    }

    /**
     * Render the current filter to LQL
     *
     * @return string
     */
    protected function filterToString()
    {
        return $this->renderFilter($this->filter);
    }

    /**
     * Filter rendering
     *
     * Happens recursively, useful for filters and for Stats expressions
     *
     * @param Filter $filter    The filter that should be rendered
     * @param string $type      Filter type. Usually "Filter" or "Stats"
     * @param int    $level     Nesting level during recursion. Don't touch
     * @param bool   $keylookup Whether to resolve alias names
     *
     * @return string
     */
    protected function renderFilter(Filter $filter, $type = 'Filter', $level = 0, $keylookup = true)
    {
        $str = '';
        if ($filter instanceof FilterChain) {
            if ($filter instanceof FilterAnd) {
                $op = 'And';
            } elseif ($filter instanceof FilterOr) {
                $op = 'Or';
            } elseif ($filter instanceof FilterNot) {
                $op = 'Negate';
            } else {
                throw new IcingaException(
                    'Cannot render filter: %s',
                    $filter
                );
            }
            $parts = array();
            if (! $filter->isEmpty()) {
                foreach ($filter->filters() as $f) {
                    $parts[] = $this->renderFilter($f, $type, $level + 1, $keylookup);
                }
                $str .= implode("\n", $parts);
                if ($type === 'Filter') {
                    if (count($parts) > 1) {
                        $str .= "\n" . $op . ': ' . count($parts);
                    }
                } else {
                    $str .= "\n" . $type . $op . ': ' . count($parts);
                }
            }
        } else {
            $str .= $type . ': ' . $this->renderFilterExpression($filter, $keylookup);
        }

        return $str;
    }

    /**
     * Produce a safe regex string as required by LQL
     *
     * @param string $expression search expression
     *
     * @return string
     */
    protected function safeRegex($expression)
    {
        return '^' . preg_replace('/\*/', '.*', $expression) . '$';
    }

    /**
     * Render a single filter expression
     *
     * @param FilterExpression $filter    the filter expression
     * @param bool             $keylookup whether to resolve alias names
     *
     * @return string
     */
    public function renderFilterExpression(FilterExpression $filter, $keylookup = true)
    {
        if ($keylookup) {
            $col = $this->available_columns[$filter->getColumn()];
        } else {
            $col = $filter->getColumn();
        }

        $isArray = array_key_exists($col, $this->arrayColumns);

        $sign = $filter->getSign();
        if ($isArray && $sign === '=') {
            $sign = '>=';
        }
        $expression = $filter->getExpression();
        if ($sign === '=' && strpos($expression, '*') !== false) {
            return $col . ' ~~ ' . $this->safeRegex($expression);
        } elseif ($sign === '!=' && strpos($expression, '*') !== false) {
            return $col . ' !~~ ' . $this->safeRegex($expression);
        } else {
            return $col . ' ' . $sign . ' ' . $expression;
        }
    }

    public function __destruct()
    {
        unset($this->connection);
    }
}
