<?php

namespace Icinga\Module\Director\Daemon;

use Exception;
use gipfl\IcingaCliDaemon\DbResourceConfigWatch;
use gipfl\IcingaCliDaemon\RetryUnless;
use Icinga\Data\ConfigObject;
use Icinga\Module\Director\Db;
use Icinga\Module\Director\Db\Migrations;
use ipl\Stdlib\EventEmitter;
use React\EventLoop\LoopInterface;
use React\Promise\Deferred;
use RuntimeException;
use SplObjectStorage;

use function React\Promise\reject;
use function React\Promise\resolve;

class DaemonDb
{
    use EventEmitter;

    /** @var LoopInterface */
    private $loop;

    /** @var Db */
    protected $connection;

    /** @var \Zend_Db_Adapter_Abstract */
    protected $db;

    /** @var DaemonProcessDetails */
    protected $details;

    /** @var DbBasedComponent[] */
    protected $registeredComponents = [];

    /** @var DbResourceConfigWatch|null */
    protected $configWatch;

    /** @var array|null */
    protected $dbConfig;

    /** @var RetryUnless|null */
    protected $pendingReconnection;

    /** @var Deferred|null */
    protected $pendingDisconnect;

    /** @var \React\EventLoop\TimerInterface */
    protected $refreshTimer;

    /** @var \React\EventLoop\TimerInterface */
    protected $schemaCheckTimer;

    /** @var int */
    protected $startupSchemaVersion;

    public function __construct(DaemonProcessDetails $details, $dbConfig = null)
    {
        $this->details = $details;
        $this->dbConfig = $dbConfig;
    }

    public function register(DbBasedComponent $component)
    {
        $this->registeredComponents[] = $component;

        return $this;
    }

    public function setConfigWatch(DbResourceConfigWatch $configWatch)
    {
        $this->configWatch = $configWatch;
        $configWatch->notify(function ($config) {
            $this->disconnect()->then(function () use ($config) {
                return $this->onNewConfig($config);
            });
        });
        if ($this->loop) {
            $configWatch->run($this->loop);
        }

        return $this;
    }

    public function run(LoopInterface $loop)
    {
        $this->loop = $loop;
        $this->connect();
        $this->refreshTimer = $loop->addPeriodicTimer(3, function () {
            $this->refreshMyState();
        });
        $this->schemaCheckTimer = $loop->addPeriodicTimer(15, function () {
            $this->checkDbSchema();
        });
        if ($this->configWatch) {
            $this->configWatch->run($this->loop);
        }
    }

    protected function onNewConfig($config)
    {
        if ($config === null) {
            if ($this->dbConfig === null) {
                Logger::error('DB configuration is not valid');
            } else {
                Logger::error('DB configuration is no longer valid');
            }
            $this->emitStatus('no configuration');
            $this->dbConfig = $config;

            return resolve();
        } else {
            $this->emitStatus('configuration loaded');
            $this->dbConfig = $config;

            return $this->establishConnection($config);
        }
    }

    protected function establishConnection($config)
    {
        if ($this->connection !== null) {
            Logger::error('Trying to establish a connection while being connected');
            return reject();
        }
        $callback = function () use ($config) {
            $this->reallyEstablishConnection($config);
        };
        $onSuccess = function () {
            $this->pendingReconnection = null;
            $this->onConnected();
        };
        if ($this->pendingReconnection) {
            $this->pendingReconnection->reset();
            $this->pendingReconnection = null;
        }
        $this->emitStatus('connecting');

        return $this->pendingReconnection = RetryUnless::succeeding($callback)
            ->setInterval(0.2)
            ->slowDownAfter(10, 10)
            ->run($this->loop)
            ->then($onSuccess)
            ;
    }

    protected function reallyEstablishConnection($config)
    {
        $connection = new Db(new ConfigObject($config));
        $connection->getDbAdapter()->getConnection();
        $migrations = new Migrations($connection);
        if (! $migrations->hasSchema()) {
            $this->emitStatus('no schema', 'error');
            throw new RuntimeException('DB has no schema');
        }
        $this->wipeOrphanedInstances($connection);
        if ($this->hasAnyOtherActiveInstance($connection)) {
            $this->emitStatus('locked by other instance', 'warning');
            throw new RuntimeException('DB is locked by a running daemon instance, will retry');
        }
        $this->startupSchemaVersion = $migrations->getLastMigrationNumber();
        $this->details->set('schema_version', $this->startupSchemaVersion);

        $this->connection = $connection;
        $this->db = $connection->getDbAdapter();
        $this->loop->futureTick(function () {
            $this->refreshMyState();
        });

        return $connection;
    }

    protected function checkDbSchema()
    {
        if ($this->connection === null) {
            return;
        }

        if ($this->schemaIsOutdated()) {
            $this->emit('schemaChange', [
                $this->getStartupSchemaVersion(),
                $this->getDbSchemaVersion()
            ]);
        }
    }

    protected function schemaIsOutdated()
    {
        return $this->getStartupSchemaVersion() < $this->getDbSchemaVersion();
    }

    protected function getStartupSchemaVersion()
    {
        return $this->startupSchemaVersion;
    }

    protected function getDbSchemaVersion()
    {
        if ($this->connection === null) {
            throw new RuntimeException(
                'Cannot determine DB schema version without an established DB connection'
            );
        }
        $migrations = new Migrations($this->connection);

        return  $migrations->getLastMigrationNumber();
    }

    protected function onConnected()
    {
        $this->emitStatus('connected');
        Logger::info('Connected to the database');
        foreach ($this->registeredComponents as $component) {
            $component->initDb($this->connection);
        }
    }

    /**
     * @return \React\Promise\PromiseInterface
     */
    protected function reconnect()
    {
        return $this->disconnect()->then(function () {
            return $this->connect();
        }, function (Exception $e) {
            Logger::error('Disconnect failed. This should never happen: ' . $e->getMessage());
            exit(1);
        });
    }

    /**
     * @return \React\Promise\ExtendedPromiseInterface
     */
    public function connect()
    {
        if ($this->connection === null) {
            if ($this->dbConfig) {
                return $this->establishConnection($this->dbConfig);
            }
        }

        return resolve();
    }

    /**
     * @return \React\Promise\ExtendedPromiseInterface
     */
    public function disconnect()
    {
        if (! $this->connection) {
            return resolve();
        }
        if ($this->pendingDisconnect) {
            return $this->pendingDisconnect->promise();
        }

        $this->eventuallySetStopped();
        $this->pendingDisconnect = new Deferred();
        $pendingComponents = new SplObjectStorage();
        foreach ($this->registeredComponents as $component) {
            $pendingComponents->attach($component);
            $resolve = function () use ($pendingComponents, $component) {
                $pendingComponents->detach($component);
                if ($pendingComponents->count() === 0) {
                    $this->pendingDisconnect->resolve();
                }
            };
            // TODO: What should we do in case they don't?
            $component->stopDb()->then($resolve);
        }

        try {
            if ($this->db) {
                $this->db->closeConnection();
            }
        } catch (Exception $e) {
            Logger::error('Failed to disconnect: ' . $e->getMessage());
        }

        return $this->pendingDisconnect->promise()->then(function () {
            $this->connection = null;
            $this->db = null;
            $this->pendingDisconnect = null;
        });
    }

    protected function emitStatus($message, $level = 'info')
    {
        $this->emit('state', [$message, $level]);

        return $this;
    }

    protected function hasAnyOtherActiveInstance(Db $connection)
    {
        $db = $connection->getDbAdapter();

        return (int) $db->fetchOne(
            $db->select()
                ->from('director_daemon_info', 'COUNT(*)')
                ->where('ts_stopped IS NULL')
        ) > 0;
    }

    protected function wipeOrphanedInstances(Db $connection)
    {
        $db = $connection->getDbAdapter();
        $db->delete('director_daemon_info', 'ts_stopped IS NOT NULL');
        $db->delete('director_daemon_info', $db->quoteInto(
            'instance_uuid_hex = ?',
            $this->details->getInstanceUuid()
        ));
        $count = $db->delete(
            'director_daemon_info',
            'ts_stopped IS NULL AND ts_last_update < ' . (
                DaemonUtil::timestampWithMilliseconds() - (60 * 1000)
            )
        );
        if ($count > 1) {
            Logger::error("Removed $count orphaned daemon instance(s) from DB");
        }
    }

    protected function refreshMyState()
    {
        if ($this->db === null || $this->pendingReconnection || $this->pendingDisconnect) {
            return;
        }
        try {
            $updated = $this->db->update(
                'director_daemon_info',
                $this->details->getPropertiesToUpdate(),
                $this->db->quoteInto('instance_uuid_hex = ?', $this->details->getInstanceUuid())
            );

            if (! $updated) {
                $this->db->insert(
                    'director_daemon_info',
                    $this->details->getPropertiesToInsert()
                );
            }
        } catch (Exception $e) {
            Logger::error($e->getMessage());
            $this->reconnect();
        }
    }

    protected function eventuallySetStopped()
    {
        try {
            if (! $this->db) {
                return;
            }
            $this->db->update(
                'director_daemon_info',
                ['ts_stopped' => DaemonUtil::timestampWithMilliseconds()],
                $this->db->quoteInto('instance_uuid_hex = ?', $this->details->getInstanceUuid())
            );
        } catch (Exception $e) {
            Logger::error('Failed to update daemon info (setting ts_stopped): ' . $e->getMessage());
        }
    }
}
