<?php

/*
 * This file is part of the Symfony package.
 *
 * (c) Fabien Potencier <fabien@symfony.com>
 *
 * For the full copyright and license information, please view the LICENSE
 * file that was distributed with this source code.
 */

namespace Symfony\Component\Scheduler\Tests\Generator;

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Scheduler\Generator\Checkpoint;
use Symfony\Component\Scheduler\Generator\MessageContext;
use Symfony\Component\Scheduler\Generator\MessageGenerator;
use Symfony\Component\Scheduler\RecurringMessage;
use Symfony\Component\Scheduler\Schedule;
use Symfony\Component\Scheduler\ScheduleProviderInterface;
use Symfony\Component\Scheduler\Trigger\TriggerInterface;

class MessageGeneratorTest extends TestCase
{
    #[DataProvider('messagesProvider')]
    public function testGetMessagesFromSchedule(string $startTime, array $runs, array $schedule)
    {
        $clock = new MockClock(self::makeDateTime($startTime));

        foreach ($schedule as $i => $s) {
            if (\is_array($s)) {
                $schedule[$i] = $this->createMessage(...$s);
            }
        }
        $schedule = (new Schedule())->add(...$schedule);
        $schedule->stateful(new ArrayAdapter());

        $scheduler = new MessageGenerator($schedule, 'dummy', $clock);

        // Warmup. The first run always returns nothing.
        $this->assertSame([], iterator_to_array($scheduler->getMessages(), false));

        foreach ($runs as $time => $expected) {
            $clock->modify($time);
            $this->assertSame($expected, iterator_to_array($scheduler->getMessages(), false));
        }
    }

    #[DataProvider('messagesProvider')]
    public function testGetMessagesFromScheduleProvider(string $startTime, array $runs, array $schedule)
    {
        $clock = new MockClock(self::makeDateTime($startTime));

        foreach ($schedule as $i => $s) {
            if (\is_array($s)) {
                $schedule[$i] = $this->createMessage(...$s);
            }
        }

        $scheduleProvider = new class($schedule) implements ScheduleProviderInterface {
            public function __construct(private readonly array $schedule)
            {
            }

            public function getSchedule(): Schedule
            {
                $schedule = (new Schedule())->add(...$this->schedule);
                $schedule->stateful(new ArrayAdapter());

                return $schedule;
            }
        };

        $scheduler = new MessageGenerator($scheduleProvider, 'dummy', $clock);

        // Warmup. The first run always returns nothing.
        $this->assertSame([], iterator_to_array($scheduler->getMessages(), false));

        foreach ($runs as $time => $expected) {
            $clock->modify($time);
            $this->assertSame($expected, iterator_to_array($scheduler->getMessages(), false));
        }
    }

    public function testGetMessagesFromScheduleProviderWithRestart()
    {
        $first = (object) ['id' => 'first'];
        $startTime = '22:12:00';
        $runs = [
            '22:12:00' => [],
            '22:12:01' => [],
            '22:13:00' => [$first],
            '22:13:01' => [],
        ];
        $schedule = [[$first, '22:13:00', '22:14:00']];

        $clock = new MockClock(self::makeDateTime($startTime));

        foreach ($schedule as $i => $s) {
            if (\is_array($s)) {
                $schedule[$i] = $this->createMessage(...$s);
            }
        }

        $scheduleProvider = new class($schedule) implements ScheduleProviderInterface {
            private Schedule $schedule;

            public function __construct(array $schedule)
            {
                $this->schedule = (new Schedule())->with(...$schedule);
                $this->schedule->stateful(new ArrayAdapter());
            }

            public function getSchedule(): Schedule
            {
                return $this->schedule;
            }

            public function add(RecurringMessage $message): self
            {
                $this->schedule->add($message);

                return $this;
            }
        };

        $scheduler = new MessageGenerator($scheduleProvider, 'dummy', $clock);

        // Warmup. The first run always returns nothing.
        $this->assertSame([], iterator_to_array($scheduler->getMessages(), false));

        $toAdd = (object) ['id' => 'added-after-start'];

        foreach ($runs as $time => $expected) {
            $clock->modify($time);
            $this->assertSame($expected, iterator_to_array($scheduler->getMessages(), false));
        }

        $scheduleProvider->add($this->createMessage($toAdd, '22:13:10', '22:13:11'));

        $this->assertSame([], iterator_to_array($scheduler->getMessages(), false));

        $clock->sleep(9);
        $this->assertSame([$toAdd], iterator_to_array($scheduler->getMessages(), false));
    }

    public function testYieldedContext()
    {
        $clock = new MockClock(self::makeDateTime('22:12:00'));

        $message = $this->createMessage((object) ['id' => 'message'], '22:13:00', '22:14:00', '22:16:00');
        $schedule = (new Schedule())->add($message);
        $schedule->stateful(new ArrayAdapter());

        $scheduler = new MessageGenerator($schedule, 'dummy', $clock);

        // Warmup. The first run is alw ays returns nothing.
        $this->assertSame([], iterator_to_array($scheduler->getMessages(), false));

        $clock->sleep(2 * 60 + 10);
        $iterator = $scheduler->getMessages();

        $this->assertInstanceOf(MessageContext::class, $context = $iterator->key());
        $this->assertSame($message->getTrigger(), $context->trigger);
        $this->assertEquals(self::makeDateTime('22:13:00'), $context->triggeredAt);
        $this->assertEquals(self::makeDateTime('22:14:00'), $context->nextTriggerAt);

        $iterator->next();
        $this->assertInstanceOf(MessageContext::class, $context = $iterator->key());
        $this->assertSame($message->getTrigger(), $context->trigger);
        $this->assertEquals(self::makeDateTime('22:14:00'), $context->triggeredAt);
        $this->assertEquals(self::makeDateTime('22:16:00'), $context->nextTriggerAt);
    }

    public function testCheckpointSavedInBrokenLoop()
    {
        $clock = new MockClock(self::makeDateTime('22:12:00'));

        $message = $this->createMessage((object) ['id' => 'message'], '22:13:00', '22:14:00', '22:16:00');
        $schedule = (new Schedule())->add($message);

        $cache = new ArrayAdapter();
        $schedule->stateful($cache);
        $checkpoint = new Checkpoint('dummy', cache: $cache);

        $scheduler = new MessageGenerator($schedule, 'dummy', clock: $clock, checkpoint: $checkpoint);

        // Warmup. The first run is always returns nothing.
        $this->assertSame([], iterator_to_array($scheduler->getMessages(), false));

        $clock->sleep(60 + 10); // 22:13:10

        foreach ($scheduler->getMessages() as $message) {
            // Message is handled but loop is broken just after
            break;
        }

        $this->assertEquals(self::makeDateTime('22:13:00'), $checkpoint->time());
    }

    public function testCheckpointSavedInBigBrokenLoop()
    {
        $clock = new MockClock(self::makeDateTime('22:15:00'));

        $message = RecurringMessage::every('1 minute', (object) ['id' => 'message']);
        $schedule = (new Schedule())->add($message);

        $cache = new ArrayAdapter();
        $schedule->stateful($cache);
        $checkpoint = new Checkpoint('dummy', cache: $cache);

        $scheduler = new MessageGenerator($schedule, 'dummy', clock: $clock, checkpoint: $checkpoint);

        // Warmup. The first run always returns nothing.
        $this->assertSame([], iterator_to_array($scheduler->getMessages(), false));
        $this->assertEquals(self::makeDateTime('22:15:00'), $checkpoint->time());

        $clock->sleep(60 + 10); // 22:16:10

        $this->assertCount(1, iterator_to_array($scheduler->getMessages(), false));

        $clock->sleep(2 * 60); // 22:18:10

        $this->assertCount(2, iterator_to_array($scheduler->getMessages(), false));

        $clock->sleep(5 * 60); // 22:23:10

        $this->assertCount(5, iterator_to_array($scheduler->getMessages(), false));

        $this->assertEquals(self::makeDateTime('22:23:00'), $checkpoint->time());
    }

    public function testCheckpointSavedInBigBrokenLoopWithOnlyLastMissed()
    {
        $clock = new MockClock(self::makeDateTime('22:15:00'));

        $message = RecurringMessage::every('1 minute', (object) ['id' => 'message'], until: self::makeDateTime('22:23:00'));
        $schedule = (new Schedule())->add($message);

        $cache = new ArrayAdapter();
        $schedule->stateful($cache)->processOnlyLastMissedRun(true);
        $checkpoint = new Checkpoint('dummy', cache: $cache);

        $scheduler = new MessageGenerator($schedule, 'dummy', clock: $clock, checkpoint: $checkpoint);

        // Warmup. The first run always returns nothing.
        $this->assertSame([], iterator_to_array($scheduler->getMessages(), false));
        $this->assertEquals(self::makeDateTime('22:15:00'), $clock->now());

        $clock->sleep(60 + 10); // 22:16:10
        $this->assertCount(1, iterator_to_array($scheduler->getMessages(), false));

        $clock->sleep(2 * 60); // 22:18:10
        $this->assertCount(1, iterator_to_array($scheduler->getMessages(), false));

        $clock->sleep(5 * 60); // 22:23:10
        $this->assertCount(1, iterator_to_array($scheduler->getMessages(), false));

        $this->assertEquals(self::makeDateTime('22:23:10'), $clock->now());
    }

    public function testCheckpointWithMultipleRecurringMessagesAtSameTriggerTime()
    {
        $clock = new MockClock(self::makeDateTime('22:12:00'));

        $first = (object) ['id' => 'first'];
        $second = (object) ['id' => 'second'];
        $cache = new ArrayAdapter();

        // First MessageGenerator instance: simulates the first messenger:consume process.
        $schedule1 = (new Schedule())->add(
            RecurringMessage::every('30 seconds', $first),
            RecurringMessage::every('30 seconds', $second),
        );
        $schedule1->stateful($cache);
        $scheduler1 = new MessageGenerator($schedule1, 'dummy', clock: $clock, checkpoint: new Checkpoint('dummy', cache: $cache));

        // Warmup.
        $this->assertSame([], iterator_to_array($scheduler1->getMessages(), false));

        // Both messages are due at 22:12:30; the consumer stops after yielding only the
        // first (mimics "messenger:consume --limit 1" or any early loop break).
        $clock->sleep(30);
        $yielded = [];
        foreach ($scheduler1->getMessages() as $message) {
            $yielded[] = $message;
            break;
        }
        $this->assertSame([$first], $yielded);

        // Next messenger:consume process: same checkpoint cache, fresh in-memory state.
        $schedule2 = (new Schedule())->add(
            RecurringMessage::every('30 seconds', $first),
            RecurringMessage::every('30 seconds', $second),
        );
        $schedule2->stateful($cache);
        $scheduler2 = new MessageGenerator($schedule2, 'dummy', clock: $clock, checkpoint: new Checkpoint('dummy', cache: $cache));

        // The "second" message at 22:12:30 was never yielded. It must be picked up now
        // instead of being silently dropped to the next interval at 22:13:00.
        $this->assertSame([$second], iterator_to_array($scheduler2->getMessages(), false));
    }

    public static function messagesProvider(): \Generator
    {
        $first = (object) ['id' => 'first'];
        $second = (object) ['id' => 'second'];
        $third = (object) ['id' => 'third'];

        yield 'first' => [
            'startTime' => '22:12:00',
            'runs' => [
                '22:12:00' => [],
                '22:12:01' => [],
                '22:13:00' => [$first],
                '22:13:01' => [],
            ],
            'schedule' => [[$first, '22:13:00', '22:14:00']],
        ];

        yield 'microseconds' => [
            'startTime' => '22:12:00',
            'runs' => [
                '22:12:59.999999' => [],
                '22:13:00' => [$first],
                '22:13:01' => [],
            ],
            'schedule' => [[$first, '22:13:00', '22:14:00', '22:15:00']],
        ];

        yield 'skipped' => [
            'startTime' => '22:12:00',
            'runs' => [
                '22:14:01' => [$first, $first],
            ],
            'schedule' => [[$first, '22:13:00', '22:14:00', '22:15:00']],
        ];

        yield 'sequence' => [
            'startTime' => '22:12:00',
            'runs' => [
                '22:12:59' => [],
                '22:13:00' => [$first],
                '22:13:01' => [],
                '22:13:59' => [],
                '22:14:00' => [$first],
                '22:14:01' => [],
            ],
            'schedule' => [[$first, '22:13:00', '22:14:00', '22:15:00']],
        ];

        yield 'concurrency' => [
            'startTime' => '22:12:00',
            'runs' => [
                '22:12:00.555' => [],
                '22:13:01.555' => [$third, $first, $first, $second, $first],
                '22:13:02.000' => [$first],
                '22:13:02.555' => [],
            ],
            'schedule' => [
                [$first, '22:12:59', '22:13:00', '22:13:01', '22:13:02', '22:13:03'],
                [$second, '22:13:00', '22:14:00'],
                [$third, '22:12:30', '22:13:30'],
            ],
        ];

        yield 'parallel' => [
            'startTime' => '22:12:00',
            'runs' => [
                '22:12:59' => [],
                '22:13:59' => [$first, $second],
                '22:14:00' => [$first, $second],
                '22:14:01' => [],
            ],
            'schedule' => [
                [$first, '22:13:00', '22:14:00', '22:15:00'],
                [$second, '22:13:00', '22:14:00', '22:15:00'],
            ],
        ];

        yield 'past' => [
            'startTime' => '22:12:00',
            'runs' => [
                '22:12:01' => [],
            ],
            'schedule' => [
                RecurringMessage::trigger(new class implements TriggerInterface {
                    public function __toString(): string
                    {
                        return 'foo';
                    }

                    public function getNextRunDate(\DateTimeImmutable $run): ?\DateTimeImmutable
                    {
                        return null;
                    }
                }, (object) []),
            ],
        ];
    }

    private function createMessage(object $message, string ...$runs): RecurringMessage
    {
        $runs = array_map(static fn ($time) => self::makeDateTime($time), $runs);
        sort($runs);

        $ticks = [self::makeDateTime(''), 0];
        $trigger = $this->createStub(TriggerInterface::class);
        $trigger
            ->method('getNextRunDate')
            ->willReturnCallback(function (\DateTimeImmutable $lastTick) use ($runs, &$ticks): \DateTimeImmutable {
                [$tick, $count] = $ticks;
                if ($lastTick > $tick) {
                    $ticks = [$lastTick, 1];
                } elseif ($lastTick == $tick && $count < 2) {
                    $ticks = [$lastTick, ++$count];
                } else {
                    $this->fail(\sprintf('Invalid tick %s', $lastTick->format(\DateTimeImmutable::RFC3339_EXTENDED)));
                }

                foreach ($runs as $run) {
                    if ($lastTick < $run) {
                        return $run;
                    }
                }

                $this->fail(\sprintf('There is no next run for tick %s', $lastTick->format(\DateTimeImmutable::RFC3339_EXTENDED)));
            });

        return RecurringMessage::trigger($trigger, $message);
    }

    private static function makeDateTime(string $time): \DateTimeImmutable
    {
        return new \DateTimeImmutable('2020-02-20T'.$time, new \DateTimeZone('UTC'));
    }
}
