<?php

namespace Mautic\CampaignBundle\Executioner;

use Doctrine\Common\Collections\ArrayCollection;
use Mautic\CampaignBundle\Entity\Campaign;
use Mautic\CampaignBundle\Entity\Event;
use Mautic\CampaignBundle\Executioner\ContactFinder\InactiveContactFinder;
use Mautic\CampaignBundle\Executioner\ContactFinder\Limiter\ContactLimiter;
use Mautic\CampaignBundle\Executioner\Exception\NoContactsFoundException;
use Mautic\CampaignBundle\Executioner\Exception\NoEventsFoundException;
use Mautic\CampaignBundle\Executioner\Helper\InactiveHelper;
use Mautic\CampaignBundle\Executioner\Result\Counter;
use Mautic\CampaignBundle\Executioner\Scheduler\EventScheduler;
use Mautic\CoreBundle\Helper\ProgressBarHelper;
use Mautic\CoreBundle\ProcessSignal\ProcessSignalService;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Contracts\Translation\TranslatorInterface;

class InactiveExecutioner implements ExecutionerInterface
{
    /**
     * @var Campaign
     */
    private $campaign;

    private ?ContactLimiter $limiter = null;

    private ?OutputInterface $output = null;

    private ?\Symfony\Component\Console\Helper\ProgressBar $progressBar = null;

    private ?Counter $counter = null;

    private ?ArrayCollection $decisions = null;

    protected ?\DateTime $now = null;

    public function __construct(
        private InactiveContactFinder $inactiveContactFinder,
        private LoggerInterface $logger,
        private TranslatorInterface $translator,
        private EventScheduler $scheduler,
        private InactiveHelper $helper,
        private EventExecutioner $executioner,
        private ProcessSignalService $processSignalService,
    ) {
    }

    /**
     * @return Counter
     *
     * @throws Dispatcher\Exception\LogNotProcessedException
     * @throws Dispatcher\Exception\LogPassedAndFailedException
     * @throws Exception\CannotProcessEventException
     * @throws Scheduler\Exception\NotSchedulableException
     */
    public function execute(Campaign $campaign, ContactLimiter $limiter, ?OutputInterface $output = null)
    {
        $this->campaign = $campaign;
        $this->limiter  = $limiter;
        $this->output   = $output ?: new NullOutput();
        $this->counter  = new Counter();

        try {
            $this->decisions = $this->campaign->getEventsByType(Event::TYPE_DECISION);

            $this->prepareForExecution();
            $this->executeEvents();
        } catch (NoContactsFoundException) {
            $this->logger->debug('CAMPAIGN: No more contacts to process');
        } catch (NoEventsFoundException) {
            $this->logger->debug('CAMPAIGN: No events to process');
        } finally {
            if ($this->progressBar) {
                $this->progressBar->finish();
            }
        }

        return $this->counter;
    }

    /**
     * @param int $decisionId
     *
     * @return Counter
     *
     * @throws Dispatcher\Exception\LogNotProcessedException
     * @throws Dispatcher\Exception\LogPassedAndFailedException
     * @throws Exception\CannotProcessEventException
     * @throws Scheduler\Exception\NotSchedulableException
     */
    public function validate($decisionId, ContactLimiter $limiter, ?OutputInterface $output = null)
    {
        $this->limiter = $limiter;
        $this->output  = $output ?: new NullOutput();
        $this->counter = new Counter();

        try {
            $this->decisions = $this->helper->getCollectionByDecisionId($decisionId);

            $this->checkCampaignIsPublished();
            $this->prepareForExecution();
            $this->executeEvents();
        } catch (NoContactsFoundException) {
            $this->logger->debug('CAMPAIGN: No more contacts to process');
        } catch (NoEventsFoundException) {
            $this->logger->debug('CAMPAIGN: No events to process');
        } finally {
            if ($this->progressBar) {
                $this->progressBar->finish();
            }
        }

        return $this->counter;
    }

    /**
     * @throws NoEventsFoundException
     */
    private function checkCampaignIsPublished(): void
    {
        if (!$this->decisions->count()) {
            throw new NoEventsFoundException();
        }

        $this->campaign = $this->decisions->first()->getCampaign();
        if (!$this->campaign->isPublished()) {
            throw new NoEventsFoundException();
        }

        if ($this->campaign->isDeleted()) {
            throw new NoEventsFoundException();
        }
    }

    /**
     * @throws NoContactsFoundException
     * @throws NoEventsFoundException
     */
    private function prepareForExecution(): void
    {
        $this->logger->debug('CAMPAIGN: Triggering inaction events');

        $this->helper->removeDecisionsWithoutNegativeChildren($this->decisions);

        $totalDecisions = $this->decisions->count();
        if (!$totalDecisions) {
            throw new NoEventsFoundException();
        }
        $totalContacts = 0;
        if (!($this->output instanceof NullOutput)) {
            $totalContacts = $this->inactiveContactFinder->getContactCount($this->campaign->getId(), $this->decisions->getKeys(), $this->limiter);

            $this->output->writeln(
                $this->translator->trans(
                    'mautic.campaign.trigger.decision_count_analyzed',
                    [
                        '%decisions%' => $totalDecisions,
                        '%leads%'     => $totalContacts,
                        '%batch%'     => $this->limiter->getBatchLimit(),
                    ]
                )
            );

            if (!$totalContacts) {
                throw new NoContactsFoundException();
            }
        }

        // Approximate total count because the query to fetch contacts will filter out those that have not arrived to this point in the campaign yet
        $this->progressBar = ProgressBarHelper::init($this->output, $totalContacts * $totalDecisions);
        $this->progressBar->start();
    }

    /**
     * @throws Dispatcher\Exception\LogNotProcessedException
     * @throws Dispatcher\Exception\LogPassedAndFailedException
     * @throws Exception\CannotProcessEventException
     * @throws Scheduler\Exception\NotSchedulableException
     */
    private function executeEvents(): void
    {
        // Use the same timestamp across all contacts processed
        $now = $this->now ?? new \DateTime();

        /** @var Event $decisionEvent */
        foreach ($this->decisions as $decisionEvent) {
            try {
                // We need the parent ID of the decision in order to fetch the time the contact executed this event
                $parentEvent   = $decisionEvent->getParent();
                $parentEventId = $parentEvent && !$parentEvent->isDeleted() ? $parentEvent->getId() : null;

                // Ge the first batch of contacts
                $contacts = $this->inactiveContactFinder->getContacts($this->campaign->getId(), $decisionEvent, $this->limiter);

                // Loop over all contacts till we've processed all those applicable for this decision
                while ($contacts->count()) {
                    // Get the max contact ID before any are removed
                    $batchMinContactId = max($contacts->getKeys()) + 1;

                    $this->progressBar->advance($contacts->count());
                    $this->counter->advanceEvaluated($contacts->count());

                    $inactiveEvents = $decisionEvent->getNegativeChildren();
                    $this->helper->removeContactsThatAreNotApplicable($now, $contacts, $parentEventId, $inactiveEvents, $decisionEvent);
                    $earliestLastActiveDateTime = $this->helper->getEarliestInactiveDateTime();

                    $this->logger->debug(
                        'CAMPAIGN: ('.$decisionEvent->getId().') Earliest date for inactivity for this batch of contacts is '.
                        $earliestLastActiveDateTime->format('Y-m-d H:i:s T')
                    );

                    if ($contacts->count()) {
                        // Record decision for these contacts
                        $this->executioner->recordLogsAsExecutedForEvent($decisionEvent, $contacts, true);

                        // Execute or schedule the events attached to the inactive side of the decision
                        $this->executeLogsForInactiveEvents($inactiveEvents, $contacts, $this->counter, $earliestLastActiveDateTime);
                    }

                    // Clear contacts from memory
                    $this->inactiveContactFinder->clear($contacts);

                    if ($this->limiter->getContactId()) {
                        // No use making another call
                        break;
                    }

                    $this->processSignalService->throwExceptionIfSignalIsCaught();

                    $this->logger->debug('CAMPAIGN: Fetching the next batch of inactive contacts starting with contact ID '.$batchMinContactId);
                    $this->limiter->setBatchMinContactId($batchMinContactId);

                    // Get the next batch, starting with the max contact ID
                    $contacts = $this->inactiveContactFinder->getContacts($this->campaign->getId(), $decisionEvent, $this->limiter);
                }
            } catch (NoContactsFoundException) {
                // On to the next decision
                $this->logger->debug('CAMPAIGN: No more contacts to process for decision ID #'.$decisionEvent->getId());
            }

            // Ensure the batch min is reset from the last decision event
            $this->limiter->resetBatchMinContactId();
        }
    }

    /**
     * @throws Dispatcher\Exception\LogNotProcessedException
     * @throws Dispatcher\Exception\LogPassedAndFailedException
     * @throws Exception\CannotProcessEventException
     * @throws Scheduler\Exception\NotSchedulableException
     */
    private function executeLogsForInactiveEvents(ArrayCollection $events, ArrayCollection $contacts, Counter $childrenCounter, \DateTimeInterface $earliestLastActiveDateTime): void
    {
        $events              = clone $events;
        $eventExecutionDates = $this->scheduler->getSortedExecutionDates($events, $earliestLastActiveDateTime);

        /** @var \DateTime $earliestExecutionDate */
        $earliestExecutionDate = reset($eventExecutionDates);

        $executionDate = $this->executioner->getExecutionDate();

        foreach ($events as $key => $event) {
            // Ignore decisions
            if (Event::TYPE_DECISION == $event->getEventType()) {
                $this->logger->debug('CAMPAIGN: Ignoring child event ID '.$event->getId().' as a decision');

                $events->remove($key);
                continue;
            }

            $eventExecutionDate = $this->scheduler->getExecutionDateForInactivity(
                $eventExecutionDates[$event->getId()],
                $earliestExecutionDate,
                $executionDate
            );

            $this->logger->debug(
                'CAMPAIGN: Event ID# '.$event->getId().
                ' to be executed on '.$eventExecutionDate->format('Y-m-d H:i:s e')
            );

            if ($this->scheduler->shouldScheduleEvent($event, $eventExecutionDate, $executionDate)) {
                $childrenCounter->advanceTotalScheduled($contacts->count());
                $this->scheduler->schedule($event, $eventExecutionDate, $contacts, true);

                $events->remove($key);
                continue;
            }
        }

        if ($events->count()) {
            $this->executioner->executeEventsForContacts($events, $contacts, $childrenCounter, true);
        }
    }
}
