<?php

namespace Icinga\Module\Director;

use Exception;
use Icinga\Application\Config;
use Icinga\Exception\ConfigurationError;
use Icinga\Exception\ProgrammingError;
use Icinga\Module\Director\Exception\NestingError;
use Icinga\Module\Director\Objects\IcingaApiUser;
use Icinga\Module\Director\Objects\IcingaCommand;
use Icinga\Module\Director\Objects\IcingaEndpoint;
use Icinga\Module\Director\Objects\IcingaObject;
use Icinga\Module\Director\Objects\IcingaZone;
use Icinga\Module\Director\Core\CoreApi;
use Icinga\Module\Director\Core\RestApiClient;
use RuntimeException;

class KickstartHelper
{
    /** @var Db */
    protected $db;

    /** @var CoreApi */
    protected $api;

    /** @var IcingaApiUser */
    protected $apiUser;

    /** @var  IcingaEndpoint */
    protected $deploymentEndpoint;

    /** @var  IcingaEndpoint[] */
    protected $loadedEndpoints;

    /** @var  IcingaEndpoint[] */
    protected $removeEndpoints;

    /** @var  IcingaZone[] */
    protected $loadedZones;

    /** @var  IcingaZone[] */
    protected $removeZones;

    /** @var  IcingaCommand[] */
    protected $loadedCommands;

    /** @var  IcingaCommand[] */
    protected $removeCommands;

    protected $config = [
        'endpoint' => null,
        'host'     => null,
        'port'     => null,
        'username' => null,
        'password' => null,
    ];

    /**
     * KickstartHelper constructor.
     * @param Db $db
     */
    public function __construct(Db $db)
    {
        $this->db = $db;
    }

    /**
     * Trigger a complete kickstart run
     */
    public function run()
    {
        $this->fetchEndpoints()
            ->reconnectToDeploymentEndpoint()
            ->fetchZones()
            ->fetchCommands()
            ->storeZones()
            ->storeEndpoints()
            ->storeCommands()
            ->removeEndpoints()
            ->removeZones()
            ->removeCommands();

        $this->apiUser()->store();
    }

    /**
     * @return bool
     */
    public function isConfigured()
    {
        $config = $this->fetchConfigFileSection();
        return array_key_exists('endpoint', $config)
            && array_key_exists('username', $config);
    }

    /**
     * @return KickstartHelper
     * @throws ProgrammingError
     */
    public function loadConfigFromFile()
    {
        return $this->setConfig($this->fetchConfigFileSection());
    }

    /**
     * @return array
     */
    protected function fetchConfigFileSection()
    {
        return Config::module('director', 'kickstart')
            ->getSection('config')
            ->toArray();
    }

    /**
     * @param  array $config
     * @return $this
     * @throws ProgrammingError
     */
    public function setConfig($config)
    {
        foreach ($config as $key => $value) {
            if ($value === '') {
                continue;
            }

            if (! array_key_exists($key, $this->config)) {
                throw new ProgrammingError(
                    '"%s" is not a valid config setting for the kickstart helper',
                    $key
                );
            }

            $this->config[$key] = $value;
        }

        return $this;
    }

    /**
     * @return bool
     */
    public function isRequired()
    {
        $stats = $this->db->getObjectSummary();
        return (int) $stats['apiuser']->cnt_total === 0;
    }

    /**
     * @param  $key
     * @param  mixed $default
     * @return mixed
     */
    protected function getValue($key, $default = null)
    {
        if ($this->config[$key] === null) {
            return $default;
        }

        return $this->config[$key];
    }

    /**
     * @return IcingaApiUser
     * @throws \Icinga\Exception\NotFoundError
     */
    protected function apiUser()
    {
        if ($this->apiUser === null) {
            $name = $this->getValue('username');

            $user = IcingaApiUser::create(array(
                'object_name' => $this->getValue('username'),
                'object_type' => 'external_object',
                'password'    => $this->getValue('password')
            ), $this->db);

            if (IcingaApiUser::exists($name, $this->db)) {
                $this->apiUser = IcingaApiUser::load($name, $this->db)->replaceWith($user);
            } else {
                $this->apiUser = $user;
            }

            $this->apiUser->store();
        }

        return $this->apiUser;
    }

    /**
     * @param IcingaObject[] $objects
     * @return IcingaObject[]
     * @throws NestingError
     */
    protected function sortByParent(array $objects)
    {
        $sorted = array();

        $cnt = 0;
        while (! empty($objects)) {
            $cnt++;
            if ($cnt > 20) {
                $this->throwObjectLoop($objects);
            }

            $unset = array();
            foreach ($objects as $key => $object) {
                $parentName = $object->get('parent');
                if ($parentName === null || array_key_exists($parentName, $sorted)) {
                    $sorted[$object->getObjectName()] = $object;
                    $unset[] = $key;
                }
            }

            foreach ($unset as $key) {
                unset($objects[$key]);
            }
        }

        return $sorted;
    }

    /**
     * @param IcingaObject[] $objects
     * @throws NestingError
     */
    protected function throwObjectLoop(array $objects)
    {
        $names = array();
        if (empty($objects)) {
            $class = 'Nothing';
        } else {
            $class = explode('/\\/', get_class(current($objects)))[0];
        }

        foreach ($objects as $object) {
            $names[] = $object->getObjectName();
        }

        throw new NestingError(
            'Loop detected while resolving %s: %s',
            $class,
            implode(', ', $names)
        );
    }

    /**
     * @return $this
     * @throws NestingError
     * @throws \Icinga\Exception\NotFoundError
     */
    protected function fetchZones()
    {
        $db = $this->db;
        $this->loadedZones = $this->sortByParent(
            $this->api()->setDb($db)->getZoneObjects()
        );

        return $this;
    }

    /**
     * @return $this
     * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
     * @throws \Icinga\Exception\NotFoundError
     */
    protected function storeZones()
    {
        $db = $this->db;
        $existing = IcingaObject::loadAllExternalObjectsByType('zone', $db);

        foreach ($this->loadedZones as $name => $object) {
            if (array_key_exists($name, $existing)) {
                $object = $existing[$name]->replaceWith($object);
                unset($existing[$name]);
            }

            $object->store();
        }

        $this->removeZones = $existing;

        return $this;
    }

    /**
     * @return $this
     */
    protected function removeZones()
    {
        return $this->removeObjects($this->removeEndpoints, 'External Zone');
    }

    /**
     * @return $this
     * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
     * @throws \Icinga\Exception\NotFoundError
     */
    protected function fetchEndpoints()
    {
        $db = $this->db;
        $this->loadedEndpoints = $this->api()->setDb($db)->getEndpointObjects();

        $master = $this->getValue('endpoint');
        if (array_key_exists($master, $this->loadedEndpoints)) {
            $apiuser = $this->apiUser();
            $apiuser->store();
            $object = $this->loadedEndpoints[$master];
            $object->apiuser = $apiuser->object_name;
            $this->deploymentEndpoint = $object;
        }

        return $this;
    }

    /**
     * @return $this
     * @throws \Icinga\Exception\NotFoundError
     * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
     */
    protected function storeEndpoints()
    {
        $db = $this->db;
        $existing = IcingaObject::loadAllExternalObjectsByType('endpoint', $db);

        foreach ($this->loadedEndpoints as $name => $object) {
            if (array_key_exists($name, $existing)) {
                $object = $existing[$name]->replaceWith($object);
                unset($existing[$name]);
            }

            $object->store();
        }

        $this->removeEndpoints = $existing;

        $db->settings()->master_zone = $this->deploymentEndpoint->zone;

        return $this;
    }

    /**
     * @return $this
     */
    protected function removeEndpoints()
    {
        return $this->removeObjects($this->removeEndpoints, 'External Endpoint');
    }

    /**
     * @return $this
     * @throws ConfigurationError
     */
    protected function reconnectToDeploymentEndpoint()
    {
        $master = $this->getValue('endpoint');

        if (! $this->deploymentEndpoint) {
            throw new ConfigurationError(
                'I found no Endpoint object called "%s" on %s:%d',
                $master,
                $this->getHost(),
                $this->getPort()
            );
        }

        $ep = $this->deploymentEndpoint;

        $epHost = $ep->get('host');
        if (! $epHost) {
            $epHost = $ep->getObjectName();
        }

        try {
            $this->switchToDeploymentApi()->getStatus();
        } catch (Exception $e) {
            throw new ConfigurationError(
                'I was unable to re-establish a connection to the Endpoint "%s" (%s:%d).'
                . ' When reconnecting to the configured Endpoint (%s:%d) I get an error: %s'
                . ' Please re-check your Icinga 2 endpoint configuration',
                $master,
                $this->getHost(),
                $this->getPort(),
                $epHost,
                $ep->get('port'),
                $e->getMessage()
            );
        }

        return $this;
    }

    /**
     * @return $this
     * @throws \Icinga\Exception\NotFoundError
     */
    protected function fetchCommands()
    {
        $api = $this->api()->setDb($this->db);
        $this->loadedCommands = array_merge(
            $api->getSpecificCommandObjects('Check'),
            $api->getSpecificCommandObjects('Notification'),
            $api->getSpecificCommandObjects('Event')
        );

        return $this;
    }

    /**
     * @return $this
     * @throws \Icinga\Exception\NotFoundError
     * @throws \Icinga\Module\Director\Exception\DuplicateKeyException
     */
    protected function storeCommands()
    {
        $db = $this->db;
        $existing = IcingaObject::loadAllExternalObjectsByType('command', $db);

        foreach ($this->loadedCommands as $name => $object) {
            if (array_key_exists($name, $existing)) {
                $object = $existing[$name]->replaceWith($object);
                unset($existing[$name]);
            }

            $object->store();
        }

        $this->removeCommands = $existing;

        return $this;
    }

    /**
     * @return $this
     */
    protected function removeCommands()
    {
        return $this->removeObjects($this->removeCommands, 'External Command');
    }

    protected function removeObjects(array $objects, $typeName)
    {
        foreach ($objects as $object) {
            try {
                $object->delete();
            } catch (Exception $e) {
                throw new RuntimeException(sprintf(
                    "Failed to remove %s '%s', it's eventually still in use",
                    $typeName,
                    $object->getObjectName()
                ), 0, $e);
            }
        }

        return $this;
    }

    /**
     * @param Db $db
     * @return $this
     */
    public function setDb(Db $db)
    {
        $this->db = $db;
        return $this;
    }

    /**
     * @return string
     */
    protected function getHost()
    {
        return $this->getValue('host', $this->getValue('endpoint'));
    }

    /**
     * @return int
     */
    protected function getPort()
    {
        return (int) $this->getValue('port', 5665);
    }

    /**
     * @return CoreApi
     * @throws \Icinga\Exception\NotFoundError
     */
    protected function getDeploymentApi()
    {
        unset($this->api);
        $ep = $this->deploymentEndpoint;

        $epHost = $ep->get('host');
        if (!$epHost) {
            $epHost = $ep->object_name;
        }

        $client = new RestApiClient(
            $epHost,
            $ep->get('port')
        );

        $apiuser = $this->apiUser();
        $client->setCredentials($apiuser->object_name, $apiuser->password);

        $api = new CoreApi($client);
        $api->setDb($this->db);

        return $api;
    }

    /**
     * @return CoreApi
     * @throws \Icinga\Exception\NotFoundError
     */
    protected function getConfiguredApi()
    {
        unset($this->api);
        $client = new RestApiClient(
            $this->getHost(),
            $this->getPort()
        );

        $apiuser = $this->apiUser();
        $client->setCredentials($apiuser->object_name, $apiuser->password);

        $api = new CoreApi($client);
        $api->setDb($this->db);

        return $api;
    }

    /**
     * @return CoreApi
     * @throws \Icinga\Exception\NotFoundError
     */
    protected function switchToDeploymentApi()
    {
        return $this->api = $this->getDeploymentApi();
    }

    /**
     * @return CoreApi
     * @throws \Icinga\Exception\NotFoundError
     */
    protected function api()
    {
        if ($this->api === null) {
            $this->api = $this->getConfiguredApi();
        }

        return $this->api;
    }
}
