<?php

namespace Icinga\Module\Director\Resolver;

use Icinga\Application\Benchmark;
use Icinga\Data\Filter\Filter;
use Icinga\Module\Director\Data\AssignFilterHelper;
use Icinga\Module\Director\Objects\DynamicApplyMatches;
use Zend_Db_Adapter_Abstract as ZfDB;

class IcingaObjectResolver
{
    /** @var ZfDB */
    protected $db;

    protected $nameMaps;

    protected $baseTable = 'not_configured';

    protected $ignoredProperties = [];

    protected $relatedTables = [];

    protected $booleans = [];

    /**
     * @var array[]
     */
    protected $templates;

    /**
     * @var array[]
     */
    protected $resolvedTemplateProperties;

    /**
     * @var array
     */
    protected $inheritancePaths;

    protected $flatImports = [];

    protected $templateVars;

    protected $resolvedTemplateVars = [];

    protected $groupMemberShips;

    protected $resolvedGroupMemberShips;

    public function __construct(ZfDb $db)
    {
        // TODO: loop detection. Not critical right now, as this isn't the main resolver
        Benchmark::measure('Object Resolver for ' . $this->baseTable . ' warming up');
        $this->db = $db;
        // Fetch: ignore disabled?
        $this->prepareNameMaps();
        $this->templates = [];
        foreach ($this->fetchPlainObjects($this->baseTable, 'template') as $template) {
            $id = $template->id;
            $this->stripIgnoredProperties($template);
            $this->stripNullProperties($template);
            $this->templates[$id] = (array) $template;
        }
        $this->templateVars = $this->fetchTemplateVars();
        $this->inheritancePaths = $this->fetchInheritancePaths($this->baseTable, 'host_id');
        foreach ($this->inheritancePaths as $path) {
            $this->getResolvedImports($path);
        }

        // Using already resolved data, so this is unused right now:
        // $this->groupMemberShips = $this->fetchAllGroups();
        $this->resolvedGroupMemberShips = $this->fetchAllResolvedGroups();

        foreach ($this->inheritancePaths as $path) {
            if (! isset($this->resolvedTemplateProperties[$path])) {
                $properties = (object) $this->getResolvedProperties($path);
                $this->replaceRelatedNames($properties);
                $this->convertBooleans($properties);
                $this->resolvedTemplateProperties[$path] = $properties;
                $this->resolvedTemplateVars[$path] = $this->getResolvedVars($path);
            }
        }

        Benchmark::measure('Object Resolver for ' . $this->baseTable . ' is ready');

        // Notes:
        // default != null:
        // most icinga objects: disabled => n
        // Icinga(ScheduledDowntime|TimePeriod)Range: range_type => include, merge_behaviour => set
        // IcingaTemplateChoice: min_required => 0, max_allowed => 1
        // IcingaZone: is_global => n
        // ImportSource: import_state => unknown
        // SyncRule: sync_state => unknown
    }

    protected static function addUniqueMembers(&$list, $newMembers)
    {
        foreach (\array_reverse($newMembers) as $member) {
            $pos = \array_search($member, $list);
            if ($pos !== false) {
                unset($list[$pos]);
            }

            \array_unshift($list, $member);
        }
    }

    public function fetchResolvedObjects()
    {
        $objects = [];
        $allVars = $this->fetchNonTemplateVars();
        foreach ($this->fetchPlainObjects($this->baseTable, 'object') as $object) {
            $id = $object->id; // id will be stripped
            $objects[$id] = $this->enrichObject($object, $allVars);
        }

        return $objects;
    }

    public function fetchObjectsMatchingFilter(Filter $filter)
    {
        $filter = clone($filter);
        DynamicApplyMatches::setType($this->getType());
        DynamicApplyMatches::fixFilterColumns($filter);
        $helper = new AssignFilterHelper($filter);
        $objects = [];
        $allVars = $this->fetchNonTemplateVars();
        foreach ($this->fetchPlainObjects($this->baseTable, 'object') as $object) {
            $id = $object->id; // id will be stripped
            $object = $this->enrichObject($object, $allVars);
            if ($helper->matches($object)) {
                $objects[$id] = $object;
            }
        }

        return $objects;
    }

    protected function enrichObject($object, $allVars)
    {
        $id = $object->id;
        $this->stripIgnoredProperties($object);
        if (isset($allVars[$id])) {
            $vars = $allVars[$id];
        } else {
            $vars = [];
        }
        $vars += $this->getInheritedVarsById($id);

        // There is no merge, +/-, not yet. Unused, as we use resolved groups:
        // if (isset($this->groupMemberShips[$id])) {
        //     $groups = $this->groupMemberShips[$id];
        // } else {
        //     $groups = $this->getInheritedGroupsById($id);
        // }
        if (isset($this->resolvedGroupMemberShips[$id])) {
            $groups = $this->resolvedGroupMemberShips[$id];
        } else {
            $groups = [];
        }

        foreach ($this->getInheritedPropertiesById($id) as $property => $value) {
            if (! isset($object->$property)) {
                $object->$property = $value;
            }
        }
        $this->replaceRelatedNames($object);
        $this->convertBooleans($object);
        $this->stripNullProperties($object);
        if (! empty($vars)) {
            $object->vars = (object) $vars;
            static::flattenVars($object);
        }
        if (! empty($groups)) {
            $object->groups = $groups;
        }

        $templates = $this->getTemplateNamesByID($id);
        if (! empty($templates)) {
            $object->templates = \array_reverse($templates);
        }

        return $object;
    }

    /**
     * @param string $baseTable e.g. icinga_host
     * @param string $relColumn e.g. host_id
     * @return array
     */
    protected function fetchInheritancePaths($baseTable, $relColumn)
    {
        if ($this->db instanceof \Zend_Db_Adapter_Pdo_Pgsql) {
            $groupColumn = "ARRAY_TO_STRING(ARRAY_AGG(parent_$relColumn ORDER BY weight), ',')";
        } else {
            $groupColumn = "GROUP_CONCAT(parent_$relColumn ORDER BY weight SEPARATOR ',')";
        }
        $query = $this->db->select()
            ->from([
                'oi' => "{$baseTable}_inheritance"
            ], [
                $relColumn,
                $groupColumn
            ])
            ->group($relColumn)
            // Ordering by length increases the possibility to have less cycles afterwards
            ->order("LENGTH($groupColumn)");

        return $this->db->fetchPairs($query);
    }

    protected function getInheritedPropertiesById($objectId)
    {
        if (isset($this->inheritancePaths[$objectId])) {
            return $this->getResolvedProperties($this->inheritancePaths[$objectId]);
        } else {
            return [];
        }
    }

    protected function getInheritedVarsById($objectId)
    {
        if (isset($this->inheritancePaths[$objectId])) {
            return $this->getResolvedVars($this->inheritancePaths[$objectId]);
        } else {
            return [];
        }
    }

    protected function getInheritedGroupsById($objectId)
    {
        if (isset($this->inheritancePaths[$objectId])) {
            return $this->getResolvedGroups($this->inheritancePaths[$objectId]);
        } else {
            return [];
        }
    }

    protected function getTemplateNamesByID($objectId)
    {
        if (isset($this->inheritancePaths[$objectId])) {
            return $this->translateTemplateIdsToNames(
                $this->getResolvedImports($this->inheritancePaths[$objectId])
            );
        } else {
            return [];
        }
    }

    /**
     * @param $path
     * @return array[]
     */
    protected function getResolvedProperties($path)
    {
        $result = [];
        // + adds only non existing members, so let's reverse our templates
        foreach ($this->getResolvedImports($path) as $templateId) {
            $result += $this->templates[$templateId];
        }
        unset($result['object_name']);

        return $result;
    }

    protected function getResolvedVars($path)
    {
        $result = [];
        foreach ($this->getResolvedImports($path) as $templateId) {
            $result += $this->getTemplateVars($templateId);
        }

        return $result;
    }

    protected function getTemplateVars($templateId)
    {
        if (isset($this->templateVars[$templateId])) {
            return $this->templateVars[$templateId];
        } else {
            return [];
        }
    }

    protected function getResolvedGroups($path)
    {
        $pos = \strpos($path, ',');
        if ($pos === false) {
            if (isset($this->groupMemberShips[$path])) {
                return $this->groupMemberShips[$path];
            } else {
                return [];
            }
        } else {
            $first = \substr($path, 0, $pos);
            $parentPath = \substr($path, $pos + 1);
            $currentGroups = $this->getResolvedVars($first);

            // There is no merging +/-, not yet
            if (empty($currentGroups)) {
                return $this->getResolvedVars($parentPath);
            } else {
                return $currentGroups;
            }
        }
    }

    /**
     * Hint: this ships most important (last) imports first
     *
     * @param $path
     * @return array
     */
    protected function getResolvedImports($path)
    {
        if (! isset($this->flatImports[$path])) {
            $this->flatImports[$path] = $this->calculateFlatImports($path);
        }

        return $this->flatImports[$path];
    }

    protected function calculateFlatImports($path)
    {
        $imports = \preg_split('/,/', $path);
        $ancestors = [];
        foreach ($imports as $template) {
            if (isset($this->inheritancePaths[$template])) {
                $this->addUniqueMembers(
                    $ancestors,
                    $this->calculateFlatImports($this->inheritancePaths[$template])
                );
            }
            $this->addUniqueMembers($ancestors, [$template]);
        }

        return $ancestors;
    }

    protected function fetchPlainObjects($table, $objectType = null)
    {
        $query = $this->db->select()
            ->from(['o' => $table])
            ->order('o.object_name');

        if ($objectType !== null) {
            $query->where('o.object_type = ?', $objectType);
        }

        return $this->db->fetchAll($query);
    }


    /**
     * @param \stdClass $object
     */
    protected function replaceRelatedNames($object)
    {
        foreach ($this->nameMaps as $property => $map) {
            if (\property_exists($object, $property)) {
                //  Hint: substr strips _id
                if ($object->$property === null) {
                    $object->{\substr($property, 0, -3)} = null;
                } else {
                    $object->{\substr($property, 0, -3)} = $map[$object->$property];
                }
                unset($object->$property);
            }
        }
    }

    protected function translateTemplateIdsToNames($ids)
    {
        $names = [];
        foreach ($ids as $id) {
            if (isset($this->templates[$id])) {
                $names[] = $this->templates[$id]['object_name'];
            } else {
                throw new \RuntimeException("There is no template with ID $id");
            }
        }

        return $names;
    }

    protected function stripIgnoredProperties($object)
    {
        foreach ($this->ignoredProperties as $key) {
            unset($object->$key);
        }
    }

    public function prepareNameMaps()
    {
        // TODO: fetch from dummy Object? How to ignore irrelevant ones like choices?
        $relatedNames = [];
        foreach ($this->relatedTables as $key => $relatedTable) {
            $relatedNames[$key] = $this->fetchRelationMap($this->baseTable, $relatedTable, $key);
        }

        $this->nameMaps = $relatedNames;
    }

    protected function convertBooleans($object)
    {
        foreach ($this->booleans as $property) {
            if (\property_exists($object, $property) && $object->$property !== null) {
                //  Hint: substr strips _id
                $object->$property = $object->$property === 'y';
            }
        }
    }

    protected function stripNullProperties($object)
    {
        foreach (\array_keys((array) $object) as $property) {
            if ($object->$property === null) {
                unset($object->$property);
            }
        }
    }

    protected function fetchRelationMap($sourceTable, $destinationTable, $property)
    {
        $query = $this->db->select()
            ->from(['d' => $destinationTable], ['d.id', 'd.object_name'])
            ->join(['o' => $sourceTable], "d.id = o.$property", [])
            ->order('d.object_name');

        return $this->db->fetchPairs($query);
    }

    protected function fetchTemplateVars()
    {
        $query = $this->prepareVarsQuery()->where('o.object_type = ?', 'template');
        return $this->fetchAndCombineVars($query);
    }

    protected function fetchNonTemplateVars()
    {
        $query = $this->prepareVarsQuery()->where('o.object_type != ?', 'template');
        return $this->fetchAndCombineVars($query);
    }

    protected function fetchAndCombineVars($query)
    {
        $vars = [];
        foreach ($this->db->fetchAll($query) as $var) {
            $id = $var->object_id;
            if (! isset($vars[$id])) {
                $vars[$id] = [];
            }
            if ($var->format === 'json') {
                $vars[$id][$var->varname] = \json_decode($var->varvalue);
            } else {
                $vars[$id][$var->varname] = $var->varvalue;
            }
        }

        return $vars;
    }

    protected function fetchAllGroups()
    {
        $query = $this->prepareGroupsQuery();
        return $this->fetchAndCombineGroups($query);
    }

    protected function fetchAllResolvedGroups()
    {
        $query = $this->prepareGroupsQuery(true);
        return $this->fetchAndCombineGroups($query);
    }

    protected function fetchAndCombineGroups($query)
    {
        $groups = [];
        foreach ($this->db->fetchAll($query) as $group) {
            $id = $group->object_id;
            if (isset($groups[$id])) {
                $groups[$id][$group->group_id] = $group->group_name;
            } else {
                $groups[$id] = [
                    $group->group_id => $group->group_name
                ];
            }
        }

        return $groups;
    }

    protected function prepareGroupsQuery($resolved = false)
    {
        $type = $this->getType();
        $groupsTable = $this->baseTable . 'group';
        $groupMembershipTable = "{$groupsTable}_$type";
        if ($resolved) {
            $groupMembershipTable .= '_resolved';
        }
        $oRef = "{$type}_id";
        $gRef = "{$type}group_id";

        return $this->db->select()
            ->from(['gm' => $groupMembershipTable], [
                'object_id'  => $oRef,
                'group_id'   => $gRef,
                'group_name' => 'g.object_name'
            ])
            ->join(['g' => $groupsTable], "g.id = gm.$gRef", [])
            ->order("gm.$oRef")
            ->order('g.object_name');
    }

    protected function prepareVarsQuery()
    {
        $table = $this->baseTable . '_var';
        $ref = $this->getType() . '_id';
        return $this->db->select()
            ->from(['v' => $table], [
                'object_id' => $ref,
                'v.varname',
                'v.varvalue',
                'v.format',
                // 'v.checksum',
            ])
            ->join(['o' => $this->baseTable], "o.id = v.$ref", [])
            ->order('o.id')
            ->order('v.varname');
    }

    protected function getType()
    {
        return \preg_replace('/^icinga_/', '', $this->baseTable);
    }

    /**
     * Helper, flattens all vars of a given object
     *
     * The object itself will be modified, and the 'vars' property will be
     * replaced with corresponding 'vars.whatever' properties
     *
     * @param $object
     * @param string $key
     */
    protected static function flattenVars(\stdClass $object, $key = 'vars')
    {
        if (\property_exists($object, $key)) {
            foreach ($object->vars as $k => $v) {
                if (\is_object($v)) {
                    static::flattenVars($v, $k);
                }
                $object->{$key . '.' . $k} = $v;
            }
            unset($object->$key);
        }
    }
}
