<?php

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

namespace Icinga\Module\X509\Clicommands;

use DateTime;
use Exception;
use Icinga\Application\Config;
use Icinga\Application\Logger;
use Icinga\Data\ResourceFactory;
use Icinga\Module\X509\CertificateUtils;
use Icinga\Module\X509\Command;
use Icinga\Module\X509\Common\Database;
use Icinga\Module\X509\Common\JobUtils;
use Icinga\Module\X509\Hook\SniHook;
use Icinga\Module\X509\Job;
use Icinga\Module\X509\Model\X509Job;
use Icinga\Module\X509\Model\X509Schedule;
use Icinga\Module\X509\Schedule;
use InvalidArgumentException;
use ipl\Orm\Query;
use ipl\Scheduler\Contract\Frequency;
use ipl\Scheduler\Scheduler;
use ipl\Stdlib\Filter;
use React\EventLoop\Loop;
use React\Promise\PromiseInterface;
use stdClass;
use Throwable;

class JobsCommand extends Command
{
    use JobUtils;

    /**
     * Run all configured jobs based on their schedule
     *
     * USAGE:
     *
     *     icingacli x509 jobs run [OPTIONS]
     *
     * OPTIONS
     *
     * --job=<name>
     *     Run all configured schedules only of the specified job.
     *
     * --schedule=<name>
     *     Run only the given schedule of the specified job. Providing a schedule name
     *     without a job will fail immediately.
     *
     * --parallel=<number>
     *     Allow parallel scanning of targets up to the specified number. Defaults to 256.
     *     May cause **too many open files** error if set to a number higher than the configured one (ulimit).
     *
     * --import-ca-file=<file>
     *     Import all X.509 certificates from the given file and mark them as trusted
     *     before starting the scheduler. Behaves the same as the "import" command.
     */
    public function runAction(): void
    {
        $parallel = (int) $this->params->get('parallel', Job::DEFAULT_PARALLEL);
        if ($parallel <= 0) {
            $this->fail("The 'parallel' option must be set to at least 1");
        }

        $jobName = (string) $this->params->get('job');
        $scheduleName = (string) $this->params->get('schedule');
        if (! $jobName && $scheduleName) {
            throw new InvalidArgumentException('You cannot provide a schedule without a job');
        }

        if ($importCaFile = (string) $this->params->get('import-ca-file')) {
            if (! is_file($importCaFile)) {
                $this->fail('The specified certificate file does not exist.');
            }

            $count = CertificateUtils::trustBundle($importCaFile);

            Logger::info('Processed %d X.509 certificate%s.', $count, $count !== 1 ? 's' : '');
        }

        $scheduler = new Scheduler();
        $this->attachJobsLogging($scheduler);

        $signalHandler = function () use ($scheduler) {
            $scheduler->removeTasks();

            Loop::futureTick(function () {
                Loop::stop();
            });
        };
        Loop::addSignal(SIGINT, $signalHandler);
        Loop::addSignal(SIGTERM, $signalHandler);

        /** @var Job[] $scheduled Caches scheduled jobs */
        $scheduled = [];
        // Periodically check configuration changes to ensure that new jobs are scheduled, jobs are updated,
        // and deleted jobs are canceled.
        $watchdog = function () use (&$watchdog, &$scheduled, $scheduler, $parallel, $jobName, $scheduleName) {
            $jobs = [];
            try {
                // Since this is a long-running daemon, the resources or module config may change meanwhile.
                // Therefore, reload the resources and module config from disk each time (at 5m intervals)
                // before reconnecting to the database.
                ResourceFactory::setConfig(Config::app('resources', true));
                Config::module('x509', 'config', true);

                $jobs = $this->fetchSchedules($jobName, $scheduleName);
            } catch (Throwable $err) {
                Logger::error('Failed to fetch job schedules from the database: %s', $err);
                Logger::debug($err->getTraceAsString());
            }

            $outdatedJobs = array_diff_key($scheduled, $jobs);
            foreach ($outdatedJobs as $job) {
                Logger::info(
                    'Removing schedule %s of job %s, as it either no longer exists in the configuration or its'
                    . ' config has been changed',
                    $job->getSchedule()->getName(),
                    $job->getName()
                );

                $scheduler->remove($job);

                unset($scheduled[$job->getUuid()->toString()]);
            }

            $newJobs = array_diff_key($jobs, $scheduled);
            foreach ($newJobs as $key => $job) {
                $job->setParallel($parallel);

                /** @var stdClass $config */
                $config = $job->getSchedule()->getConfig();
                try {
                    /** @var Frequency $type */
                    $type = $config->type;
                    $frequency = $type::fromJson($config->frequency);
                } catch (Throwable $err) {
                    Logger::error(
                        'Cannot create schedule %s of job %s: %s',
                        $job->getSchedule()->getName(),
                        $job->getName(),
                        $err->getMessage()
                    );

                    continue;
                }

                $scheduler->schedule($job, $frequency);

                $scheduled[$key] = $job;
            }

            Loop::addTimer(5 * 60, $watchdog);
        };
        // Check configuration and add jobs directly after starting the scheduler.
        Loop::futureTick($watchdog);
    }

    /**
     * Fetch job schedules from database
     *
     * @param ?string $jobName
     * @param ?string $scheduleName
     *
     * @return Job[]
     */
    protected function fetchSchedules(?string $jobName, ?string $scheduleName): array
    {
        $conn = Database::get();
        // Even if the Job class regularly pings the same connection whenever its frequency becomes due and is run by
        // the scheduler, we need to explicitly ping that same connection here, as the interval of the schedule jobs
        // could be larger than the daemon configuration reload interval (5m).
        $conn->ping();

        $jobs = X509Job::on($conn);
        if ($jobName) {
            $jobs->filter(Filter::equal('name', $jobName));
        }

        $jobSchedules = [];
        $snimap = SniHook::getAll();
        /** @var X509Job $jobConfig */
        foreach ($jobs as $jobConfig) {
            $cidrs = $this->parseCIDRs($jobConfig->cidrs);
            $ports = $this->parsePorts($jobConfig->ports);

            /** @var Query $schedules */
            $schedules = $jobConfig->schedule;
            if ($scheduleName) {
                $schedules->filter(Filter::equal('name', $scheduleName));
            }

            $schedules = $schedules->execute();
            $hasSchedules = $schedules->hasResult();

            /** @var X509Schedule $scheduleModel */
            foreach ($schedules as $scheduleModel) {
                $job = (new Job($jobConfig->name, $cidrs, $ports, $snimap, Schedule::fromModel($scheduleModel)))
                    ->setId($jobConfig->id)
                    ->setExcludes($this->parseExcludes($jobConfig->exclude_targets));

                $jobSchedules[$job->getUuid()->toString()] = $job;
            }

            if (! $hasSchedules) {
                Logger::info('Skipping job %s because no schedules are configured', $jobConfig->name);
            }
        }

        return $jobSchedules;
    }

    /**
     * Set up logging of jobs states based on scheduler events
     *
     * @param Scheduler $scheduler
     */
    protected function attachJobsLogging(Scheduler $scheduler): void
    {
        $scheduler->on(Scheduler::ON_TASK_CANCEL, function (Job $task, array $_) {
            Logger::info('Schedule %s of job %s canceled', $task->getSchedule()->getName(), $task->getName());
        });

        $scheduler->on(Scheduler::ON_TASK_DONE, function (Job $task, $targets = 0) {
            if ($targets === 0) {
                $sinceLastScan = $task->getSinceLastScan();
                if ($sinceLastScan) {
                     Logger::info(
                         'Schedule %s of job %s does not have any targets to be rescanned matching since last scan: %s',
                         $task->getSchedule()->getName(),
                         $task->getName(),
                         $sinceLastScan->format('Y-m-d H:i:s')
                     );
                } else {
                    Logger::warning(
                        'Schedule %s of job %s does not have any targets',
                        $task->getSchedule()->getName(),
                        $task->getName()
                    );
                }
            } else {
                Logger::info(
                    'Scanned %d target(s) by schedule %s of job %s',
                    $targets,
                    $task->getSchedule()->getName(),
                    $task->getName()
                );

                try {
                    $verified = CertificateUtils::verifyCertificates(Database::get());

                    Logger::info('Checked %d certificate chain(s)', $verified);
                } catch (Exception $err) {
                    Logger::error($err->getMessage());
                    Logger::debug($err->getTraceAsString());
                }
            }
        });

        $scheduler->on(Scheduler::ON_TASK_FAILED, function (Job $task, Throwable $e) {
            Logger::error(
                'Failed to run schedule %s of job %s: %s',
                $task->getSchedule()->getName(),
                $task->getName(),
                $e->getMessage()
            );
            Logger::debug($e->getTraceAsString());
        });

        $scheduler->on(Scheduler::ON_TASK_RUN, function (Job $task, PromiseInterface $_) {
            Logger::info('Running schedule %s of job %s', $task->getSchedule()->getName(), $task->getName());
        });

        $scheduler->on(Scheduler::ON_TASK_SCHEDULED, function (Job $task, DateTime $dateTime) {
            Logger::info(
                'Scheduling %s of job %s to run at %s',
                $task->getSchedule()->getName(),
                $task->getName(),
                $dateTime->format('Y-m-d H:i:s')
            );
        });

        $scheduler->on(Scheduler::ON_TASK_EXPIRED, function (Job $task, DateTime $dateTime) {
            Logger::info(
                'Detaching expired schedule %s of job %s at %s',
                $task->getSchedule()->getName(),
                $task->getName(),
                $dateTime->format('Y-m-d H:i:s')
            );
        });
    }
}
