<?php

// SPDX-FileCopyrightText: 2024 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later

namespace Icinga\Module\Notifications\Daemon;

use Evenement\EventEmitter;
use Icinga\Application\Logger;
use Icinga\Module\Notifications\Common\Database;
use Icinga\Module\Notifications\Hook\ObjectsRendererHook;
use Icinga\Module\Notifications\Model\BrowserSession;
use Icinga\Module\Notifications\Model\Daemon\Connection;
use Icinga\Module\Notifications\Model\Daemon\Event;
use Icinga\Module\Notifications\Model\Daemon\EventIdentifier;
use Icinga\Module\Notifications\Model\Incident;
use Icinga\Module\Notifications\Model\IncidentHistory;
use ipl\Sql\Connection as SQLConnection;
use ipl\Stdlib\Filter;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;

use function React\Async\delay;

class Daemon extends EventEmitter
{
    /** @var string */
    protected const PREFIX = '[daemon] - ';

    /** @var Logger Instance of the logger class */
    protected static Logger $logger;

    /** @var ?Daemon Instance of this class */
    private static ?Daemon $instance = null;

    /** @var LoopInterface Main loop */
    protected LoopInterface $loop;

    /** @var Server Server object */
    protected Server $server;

    /** @var Sender Sender object */
    protected Sender $sender;

    /** @var SQLConnection Database object */
    protected SQLConnection $database;

    /** @var bool Token which can be triggered to exit the main routine */
    protected bool $cancellationToken;

    /** @var int Timestamp holding the creation's time of this {@see self::$instance instance} */
    protected int $initializedAt;

    /** @var ?int Last checked incident identifier */
    protected ?int $lastIncidentId = null;

    /**
     * Construct the singleton instance of the Daemon class
     */
    private function __construct()
    {
        self::$logger = Logger::getInstance();
        self::$logger::info(self::PREFIX . "spawned");

        $this->load();
    }

    /**
     * Return the singleton instance of the Daemon class
     *
     * @return Daemon Singleton instance
     */
    public static function get(): Daemon
    {
        if (self::$instance === null) {
            self::$instance = new Daemon();
        }

        return self::$instance;
    }

    /**
     * Run the loading logic
     *
     * @return void
     */
    protected function load(): void
    {
        self::$logger::debug(self::PREFIX . "loading");

        $this->loop = Loop::get();
        $this->signalHandling($this->loop);
        $this->server = Server::get($this->loop);
        $this->sender = Sender::get($this, $this->server);
        $this->database = Database::get();

        $this->database->connect();

        $this->cancellationToken = false;
        $this->initializedAt = time();

        $this->run();

        self::$logger::debug(self::PREFIX . "loaded");
    }

    /**
     * Run the unloading logic
     *
     * @return void
     */
    protected function unload(): void
    {
        self::$logger::debug(self::PREFIX . "unloading");

        $this->cancellationToken = true;

        $this->database->disconnect();
        $this->server->unload();
        $this->sender->unload();
        $this->loop->stop();

        unset($this->initializedAt);
        unset($this->database);
        unset($this->server);
        unset($this->sender);
        unset($this->loop);

        self::$logger::debug(self::PREFIX . "unloaded");
    }

    /**
     * Run the reloading logic
     *
     * @return void
     */
    protected function reload(): void
    {
        self::$logger::debug(self::PREFIX . "reloading");

        $this->unload();
        $this->load();

        self::$logger::debug(self::PREFIX . "reloaded");
    }

    /**
     * Unload the class object and exit the script
     *
     * @param bool $isManualShutdown manual trigger for the shutdown
     *
     * @return never
     */
    protected function shutdown(bool $isManualShutdown = false): never
    {
        self::$logger::info(self::PREFIX . "shutting down" . ($isManualShutdown ? " (manually triggered)" : ""));

        $initAt = $this->initializedAt;
        $this->unload();

        self::$logger::info(self::PREFIX . "exited after " . floor((time() - $initAt)) . " seconds");
        exit(0);
    }

    /**
     * (Re-)Attach to process exit signals and call the shutdown logic
     *
     * @param LoopInterface $loop ReactPHP's main loop
     *
     * @return void
     */
    protected function signalHandling(LoopInterface $loop): void
    {
        $reloadFunc = function () {
            $this->reload();
        };

        $exitFunc = function () {
            $this->shutdown(true);
        };

        // clear existing signal handlers
        $loop->removeSignal(SIGHUP, $reloadFunc);
        $loop->removeSignal(SIGINT, $exitFunc);
        $loop->removeSignal(SIGQUIT, $exitFunc);
        $loop->removeSignal(SIGTERM, $exitFunc);

        // add new signal handlers
        $loop->addSignal(SIGHUP, $reloadFunc);
        $loop->addSignal(SIGINT, $exitFunc);
        $loop->addSignal(SIGQUIT, $exitFunc);
        $loop->addSignal(SIGTERM, $exitFunc);
    }

    /**
     * Clean up old sessions in the database
     *
     * @return void
     */
    protected function housekeeping(): void
    {
        self::$logger::debug(self::PREFIX . "running housekeeping job");

        $staleBrowserSessions = BrowserSession::on(Database::get())
            ->filter(Filter::lessThan('authenticated_at', time() - 86400));
        $deletions = 0;

        /** @var BrowserSession $session */
        foreach ($staleBrowserSessions as $session) {
            $this->database->delete('browser_session', ['php_session_id = ?' => $session->php_session_id]);
            ++$deletions;
        }

        if ($deletions > 0) {
            self::$logger::info(self::PREFIX . "housekeeping cleaned " . $deletions . " stale browser sessions");
        }

        self::$logger::debug(self::PREFIX . "finished housekeeping job");
    }

    /**
     * Process new notifications (if there are any)
     *
     * @return void
     */
    protected function processNotifications(): void
    {
        $numOfNotifications = 0;

        if ($this->lastIncidentId === null) {
            // get the newest incident identifier
            /** @var IncidentHistory $latestIncidentNotification */
            $latestIncidentNotification = IncidentHistory::on(Database::get())
                ->filter(Filter::equal('type', 'notified'))
                ->orderBy('id', 'DESC')
                ->first();
            if (! $latestIncidentNotification) {
                // early return as we don't need to check for new entries if we don't have any at all
                return;
            }

            $this->lastIncidentId = $latestIncidentNotification->id;
            self::$logger::debug(
                self::PREFIX
                . "fetched latest incident notification identifier: <id: "
                . $this->lastIncidentId
                . ">"
            );
        }

        // grab new notifications and the current connections
        $notifications = IncidentHistory::on(Database::get())
            ->with(['incident', 'incident.object', 'incident.object.source'])
            ->withColumns(['incident.object.id_tags'])
            ->filter(Filter::greaterThan('id', $this->lastIncidentId))
            ->filter(Filter::equal('type', 'notified'))
            ->filter(Filter::equal('notification_state', 'sent'))
            ->orderBy('id', 'ASC');
        /** @var array<int, array<Connection>> $connections */
        $connections = $this->server->getMatchedConnections();

        /** @var IncidentHistory $notification */
        $notificationsToProcess = [];
        foreach ($notifications as $notification) {
            if ($notification->contact_id !== null && isset($connections[$notification->contact_id])) {
                ObjectsRendererHook::register($notification->incident->object);
                $notificationsToProcess[] = $notification;

                ++$numOfNotifications;
            }

            $this->lastIncidentId = $notification->id;
        }

        if ($numOfNotifications > 0) {
            ObjectsRendererHook::load(false);

            foreach ($notificationsToProcess as $notification) {
                /** @var Incident $incident */
                $incident = $notification->incident;

                $this->emit(EventIdentifier::ICINGA2_NOTIFICATION, [
                    new Event(
                        EventIdentifier::ICINGA2_NOTIFICATION,
                        $notification->contact_id,
                        (object) [
                            'incident_id' => $notification->incident_id,
                            'severity'    => $incident->severity->getValue(),
                            'title'       => ObjectsRendererHook::getObjectNameAsString($incident->object),
                            'message'     => $notification->message
                        ]
                    )
                ]);
            }
        }

        if ($numOfNotifications > 0) {
            self::$logger::debug(self::PREFIX . "sent " . $numOfNotifications . " notifications");
        }
    }

    /**
     * Run main logic
     *
     * This method registers the needed Daemon routines on PhpReact's {@link Loop main loop}.
     * It adds a cancellable infinite loop, which processes new database entries (notifications) every 3 seconds.
     * In addition, a cleanup routine gets registered, which cleans up stale browser sessions each hour if they are
     * older than a day.
     *
     * @return void
     */
    protected function run(): void
    {
        $this->loop->futureTick(function () {
            while ($this->cancellationToken === false) {
                $beginMs = (int) (microtime(true) * 1000);

                self::$logger::debug(self::PREFIX . "ticking at " . time());
                $this->processNotifications();

                $endMs = (int) (microtime(true) * 1000);
                if (($endMs - $beginMs) < 3000) {
                    // run took less than 3 seconds; delay for the remaining duration to prevent heavy db loads
                    delay((3000 - ($endMs - $beginMs)) / 1000);
                }
            }
            self::$logger::debug(self::PREFIX . "cancellation triggered; exiting loop");
            $this->shutdown();
        });

        // run housekeeping job every hour
        $this->loop->addPeriodicTimer(3600.0, function () {
            $this->housekeeping();
        });
        // run housekeeping once on daemon start
        $this->loop->futureTick(function () {
            $this->housekeeping();
        });
    }
}
