<?php

declare(strict_types=1);

/*
 * This file is part of Contao.
 *
 * (c) Leo Feyer
 *
 * @license LGPL-3.0-or-later
 */

namespace Contao\CoreBundle\Cron;

use Contao\CoreBundle\Entity\CronJob as CronJobEntity;
use Contao\CoreBundle\Exception\CronExecutionSkippedException;
use Contao\CoreBundle\Repository\CronJobRepository;
use Cron\CronExpression;
use Doctrine\DBAL\Exception\LockWaitTimeoutException;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;

class Cron
{
    public const SCOPE_WEB = 'web';
    public const SCOPE_CLI = 'cli';

    /**
     * @var \Closure():CronJobRepository
     */
    private \Closure $repository;

    /**
     * @var \Closure():EntityManagerInterface
     */
    private \Closure $entityManager;

    private ?LoggerInterface $logger;

    /**
     * @var array<CronJob>
     */
    private array $cronJobs = [];

    /**
     * @param \Closure():CronJobRepository      $repository
     * @param \Closure():EntityManagerInterface $entityManager
     */
    public function __construct(\Closure $repository, \Closure $entityManager, ?LoggerInterface $logger = null)
    {
        $this->repository = $repository;
        $this->entityManager = $entityManager;
        $this->logger = $logger;
    }

    public function addCronJob(CronJob $cronjob): void
    {
        $this->cronJobs[] = $cronjob;
    }

    /**
     * Run all the registered Contao cron jobs.
     */
    public function run(string $scope): void
    {
        // Validate scope
        if (self::SCOPE_WEB !== $scope && self::SCOPE_CLI !== $scope) {
            throw new \InvalidArgumentException('Invalid scope "'.$scope.'"');
        }

        /** @var CronJobRepository $repository */
        $repository = ($this->repository)();

        /** @var EntityManagerInterface $entityManager */
        $entityManager = ($this->entityManager)();

        /** @var array<CronJob> $cronJobsToBeRun */
        $cronJobsToBeRun = [];
        $now = new \DateTimeImmutable();

        // Return if another cron process is already running
        try {
            $repository->lockTable();
        } catch (LockWaitTimeoutException $e) {
            return;
        }

        try {
            // Go through each cron job
            foreach ($this->cronJobs as $cron) {
                $interval = $cron->getInterval();
                $name = $cron->getName();

                // Determine the last run date
                $lastRunDate = null;
                $lastRunEntity = $repository->findOneByName($name);

                if (null !== $lastRunEntity) {
                    $lastRunDate = $lastRunEntity->getLastRun();
                } else {
                    $lastRunEntity = new CronJobEntity($name);
                    $entityManager->persist($lastRunEntity);
                }

                // Check if the cron should be run
                $expression = CronExpression::factory($interval);

                if (null !== $lastRunDate && $now < $expression->getNextRunDate($lastRunDate)) {
                    continue;
                }

                // Store the previous run in case the cronjob skips itself
                $cron->setPreviousRun($lastRunEntity->getLastRun());

                // Update the cron entry
                $lastRunEntity->setLastRun($now);

                // Add job to the cron jobs to be run
                $cronJobsToBeRun[] = $cron;
            }

            $entityManager->flush();
        } finally {
            $repository->unlockTable();
        }

        $exception = null;

        // Execute all cron jobs to be run
        foreach ($cronJobsToBeRun as $cron) {
            try {
                if (null !== $this->logger) {
                    $this->logger->debug(sprintf('Executing cron job "%s"', $cron->getName()));
                }

                $cron($scope);
            } catch (CronExecutionSkippedException $e) {
                // Restore previous run date in case cronjob skips itself
                $lastRunEntity = $repository->findOneByName($cron->getName());
                $lastRunEntity->setLastRun($cron->getPreviousRun());
                $entityManager->flush();
            } catch (\Throwable $e) {
                // Catch any exceptions so that other cronjobs are still executed
                if (null !== $this->logger) {
                    $this->logger->error((string) $e);
                }

                if (null === $exception) {
                    $exception = $e;
                }
            }
        }

        // Throw the first exception
        if (null !== $exception) {
            throw $exception;
        }
    }
}
