<?php

declare(strict_types=1);

/*
 * Chill is a software for social workers
 *
 * For the full copyright and license information, please view
 * the LICENSE file that was distributed with this source code.
 */

namespace Chill\MainBundle\Cron;

use Chill\MainBundle\Entity\CronJobExecution;
use Chill\MainBundle\Repository\CronJobExecutionRepositoryInterface;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Psr\Log\LoggerInterface;

/**
 * Manage cronjob and execute them.
 *
 * If any :code:`job` argument is given, the :code:`CronManager` schedule job with those steps:
 *
 * - the tasks are ordered, with:
 *    - a priority is given for tasks that weren't never executed;
 *    - then, the tasks are ordered, the last executed are the first in the list
 *
 * Then, for each tasks, and in the given order, the first task where :code:`canRun` return :code:`TRUE` will be executed.
 *
 * The error inside job execution are catched (with the exception of _out of memory error_).
 *
 * The manager will mark the task as executed even if an error is catched. This will lead as failed job
 * will not have priority any more on other tasks.
 *
 * If a tasks is "forced", there is no test about eligibility of the task (the `canRun` method is not called),
 * and the last task execution is not recorded.
 */
final readonly class CronManager implements CronManagerInterface
{
    private const LOG_PREFIX = '[cron manager] ';

    private const UPDATE_AFTER_EXEC = 'UPDATE '.CronJobExecution::class.' cr SET cr.lastEnd = :now, cr.lastStatus = :status WHERE cr.key = :key';

    private const UPDATE_BEFORE_EXEC = 'UPDATE '.CronJobExecution::class.' cr SET cr.lastStart = :now WHERE cr.key = :key';

    private const UPDATE_LAST_EXECUTION_DATA = 'UPDATE '.CronJobExecution::class.' cr SET cr.lastExecutionData = :data WHERE cr.key = :key';

    /**
     * @param CronJobInterface[] $jobs
     */
    public function __construct(
        private CronJobExecutionRepositoryInterface $cronJobExecutionRepository,
        private EntityManagerInterface $entityManager,
        private iterable $jobs,
        private LoggerInterface $logger,
    ) {}

    public function run(?string $forceJob = null): void
    {
        if (null !== $forceJob) {
            $this->runForce($forceJob);

            return;
        }

        [$orderedJobs, $lasts] = $this->getOrderedJobs();

        foreach ($orderedJobs as $job) {
            if ($job->canRun($lasts[$job->getKey()] ?? null)) {
                if (\array_key_exists($job->getKey(), $lasts)) {
                    $executionData = $lasts[$job->getKey()]->getLastExecutionData();

                    $this->entityManager
                        ->createQuery(self::UPDATE_BEFORE_EXEC)
                        ->setParameters([
                            'now' => new \DateTimeImmutable('now'),
                            'key' => $job->getKey(),
                        ])
                        ->execute();
                } else {
                    $execution = new CronJobExecution($job->getKey());
                    $this->entityManager->persist($execution);
                    $this->entityManager->flush();

                    $executionData = $execution->getLastExecutionData();
                }
                $this->entityManager->clear();

                // note: at this step, the entity manager does not have any entity CronJobExecution
                // into his internal memory

                try {
                    $this->logger->info(sprintf('%sWill run job', self::LOG_PREFIX), ['job' => $job->getKey()]);
                    $result = $job->run($executionData);

                    $this->entityManager
                        ->createQuery(self::UPDATE_AFTER_EXEC)
                        ->setParameters([
                            'now' => new \DateTimeImmutable('now'),
                            'status' => CronJobExecution::SUCCESS,
                            'key' => $job->getKey(),
                        ])
                        ->execute();

                    if (null !== $result) {
                        $this->entityManager
                            ->createQuery(self::UPDATE_LAST_EXECUTION_DATA)
                            ->setParameter('data', $result, Types::JSON)
                            ->setParameter('key', $job->getKey(), Types::STRING)
                            ->execute();
                    }

                    $this->logger->info(sprintf('%sSuccessfully run job', self::LOG_PREFIX), ['job' => $job->getKey()]);

                    return;
                } catch (\Exception) {
                    $this->logger->error(sprintf('%sRunning job failed', self::LOG_PREFIX), ['job' => $job->getKey()]);
                    $this->entityManager
                        ->createQuery(self::UPDATE_AFTER_EXEC)
                        ->setParameters([
                            'now' => new \DateTimeImmutable('now'),
                            'status' => CronJobExecution::FAILURE,
                            'key' => $job->getKey(),
                        ])
                        ->execute();

                    return;
                }
            }
        }
    }

    /**
     * @return array{0: array<CronJobInterface>, 1: array<string, CronJobExecution>}
     */
    private function getOrderedJobs(): array
    {
        /** @var array<string, CronJobExecution> $lasts */
        $lasts = [];

        foreach ($this->cronJobExecutionRepository->findAll() as $execution) {
            $lasts[$execution->getKey()] = $execution;
        }

        // order by last, NULL first
        $orderedJobs = iterator_to_array($this->jobs);
        usort(
            $orderedJobs,
            static function (CronJobInterface $a, CronJobInterface $b) use ($lasts): int {
                if (
                    !\array_key_exists($a->getKey(), $lasts) && !\array_key_exists($b->getKey(), $lasts)
                ) {
                    return 0;
                }

                if (!\array_key_exists($a->getKey(), $lasts) && \array_key_exists($b->getKey(), $lasts)) {
                    return -1;
                }

                if (!\array_key_exists($b->getKey(), $lasts) && \array_key_exists($a->getKey(), $lasts)) {
                    return 1;
                }

                return $lasts[$a->getKey()]->getLastStart() <=> $lasts[$b->getKey()]->getLastStart();
            }
        );

        return [$orderedJobs, $lasts];
    }

    private function runForce(string $forceJob): void
    {
        foreach ($this->jobs as $job) {
            if ($job->getKey() === $forceJob) {
                $job->run([]);
            }
        }
    }
}
