<?php

declare(strict_types=1);

namespace Drupal\Tests\flowdrop_trigger\Unit\Service;

use Drupal\flowdrop_trigger\FlowDropTriggerConfigInterface;
use Drupal\flowdrop_trigger\Service\CronScheduleMatcher;
use Drupal\flowdrop_trigger\Service\CronTriggerStateService;
use Drupal\Tests\UnitTestCase;
use Prophecy\Argument;
use Prophecy\PhpUnit\ProphecyTrait;
use Psr\Log\LoggerInterface;

/**
 * Tests the CronScheduleMatcher service.
 *
 * @coversDefaultClass \Drupal\flowdrop_trigger\Service\CronScheduleMatcher
 * @group flowdrop_trigger
 */
class CronScheduleMatcherTest extends UnitTestCase {

  use ProphecyTrait;

  /**
   * The cron schedule matcher under test.
   *
   * @var \Drupal\flowdrop_trigger\Service\CronScheduleMatcher
   */
  private CronScheduleMatcher $matcher;

  /**
   * The cron trigger state service mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\flowdrop_trigger\Service\CronTriggerStateService
   */
  private $stateService;

  /**
   * The logger mock.
   *
   * @var \Prophecy\Prophecy\ObjectProphecy|\Psr\Log\LoggerInterface
   */
  private $logger;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    $this->stateService = $this->prophesize(CronTriggerStateService::class);
    $this->logger = $this->prophesize(LoggerInterface::class);

    $this->matcher = new CronScheduleMatcher(
      $this->stateService->reveal(),
      $this->logger->reveal(),
    );
  }

  /**
   * @covers ::shouldTriggerRun
   */
  public function testEmptyScheduleAlwaysRuns(): void {
    $config = $this->createMockConfig('t1', []);

    $this->assertTrue($this->matcher->shouldTriggerRun($config->reveal()));
  }

  /**
   * @covers ::shouldTriggerRun
   */
  public function testNullConfigIdReturnsFalse(): void {
    $config = $this->prophesize(FlowDropTriggerConfigInterface::class);
    $config->getConditions()->willReturn([
      'cron_schedule' => ['mode' => 'simple', 'interval' => 'every_hour'],
    ]);
    $config->id()->willReturn(NULL);

    $this->assertFalse($this->matcher->shouldTriggerRun($config->reveal()));
  }

  /**
   * @covers ::shouldTriggerRun
   */
  public function testMaxExecutionsReachedReturnsFalse(): void {
    $config = $this->createMockConfig('t1', [
      'max_executions' => 10,
      'mode' => 'simple',
    ]);

    $this->stateService->hasReachedMaxExecutions('t1', 10)->willReturn(TRUE);

    $this->assertFalse($this->matcher->shouldTriggerRun($config->reveal()));
  }

  /**
   * @covers ::evaluateSimpleSchedule
   */
  public function testSimpleScheduleNeverRunAllowed(): void {
    $config = $this->createMockConfig('t1', [
      'mode' => 'simple',
      'interval' => 'every_hour',
    ]);

    $this->stateService->hasReachedMaxExecutions(Argument::cetera())->willReturn(FALSE);
    $this->stateService->getTimeSinceLastRun('t1')->willReturn(NULL);

    $this->assertTrue($this->matcher->shouldTriggerRun($config->reveal()));
  }

  /**
   * @covers ::evaluateSimpleSchedule
   */
  public function testSimpleScheduleIntervalNotReached(): void {
    $config = $this->createMockConfig('t1', [
      'mode' => 'simple',
      'interval' => 'every_hour',
    ]);

    $this->stateService->hasReachedMaxExecutions(Argument::cetera())->willReturn(FALSE);
    // Only 600 seconds since last run, but interval is 3600.
    $this->stateService->getTimeSinceLastRun('t1')->willReturn(600);

    $this->assertFalse($this->matcher->shouldTriggerRun($config->reveal()));
  }

  /**
   * @covers ::evaluateSimpleSchedule
   */
  public function testSimpleScheduleIntervalReached(): void {
    $config = $this->createMockConfig('t1', [
      'mode' => 'simple',
      'interval' => 'every_hour',
    ]);

    $this->stateService->hasReachedMaxExecutions(Argument::cetera())->willReturn(FALSE);
    $this->stateService->getTimeSinceLastRun('t1')->willReturn(4000);

    $this->assertTrue($this->matcher->shouldTriggerRun($config->reveal()));
  }

  /**
   * @covers ::evaluateSimpleSchedule
   */
  public function testSimpleScheduleDayOfWeekFiltering(): void {
    $config = $this->createMockConfig('t1', [
      'mode' => 'simple',
      'interval' => 0,
      // Only allow Monday (1).
      'days_of_week' => [1],
    ]);

    $this->stateService->hasReachedMaxExecutions(Argument::cetera())->willReturn(FALSE);

    // This test is time-dependent. We test the method logic by checking
    // that day filtering occurs - if today is not Monday, it returns false.
    $result = $this->matcher->shouldTriggerRun($config->reveal());
    $today = (int) (new \DateTimeImmutable())->format('w');

    if ($today === 1) {
      $this->assertTrue($result);
    }
    else {
      $this->assertFalse($result);
    }
  }

  /**
   * @covers ::evaluateAdvancedSchedule
   */
  public function testAdvancedScheduleEmptyExpressionAlwaysRuns(): void {
    $config = $this->createMockConfig('t1', [
      'mode' => 'advanced',
      'cron_expression' => '',
    ]);

    $this->stateService->hasReachedMaxExecutions(Argument::cetera())->willReturn(FALSE);

    $this->assertTrue($this->matcher->shouldTriggerRun($config->reveal()));
  }

  /**
   * @covers ::evaluateAdvancedSchedule
   */
  public function testAdvancedScheduleInvalidExpressionReturnsFalse(): void {
    $config = $this->createMockConfig('t1', [
      'mode' => 'advanced',
      'cron_expression' => 'invalid cron',
    ]);

    $this->stateService->hasReachedMaxExecutions(Argument::cetera())->willReturn(FALSE);

    $this->assertFalse($this->matcher->shouldTriggerRun($config->reveal()));
    $this->logger->error(Argument::containingString('Invalid cron expression'), Argument::any())
      ->shouldHaveBeenCalled();
  }

  /**
   * @covers ::evaluateAdvancedSchedule
   */
  public function testAdvancedScheduleNeverRunChecksDueNow(): void {
    $config = $this->createMockConfig('t1', [
      'mode' => 'advanced',
      // Every minute - always due.
      'cron_expression' => '* * * * *',
    ]);

    $this->stateService->hasReachedMaxExecutions(Argument::cetera())->willReturn(FALSE);
    $this->stateService->getLastRun('t1')->willReturn(0);

    $this->assertTrue($this->matcher->shouldTriggerRun($config->reveal()));
  }

  /**
   * @covers ::evaluateAdvancedSchedule
   */
  public function testAdvancedScheduleNotDueSinceLastRun(): void {
    $config = $this->createMockConfig('t1', [
      'mode' => 'advanced',
      // Once per day at midnight.
      'cron_expression' => '0 0 * * *',
    ]);

    $this->stateService->hasReachedMaxExecutions(Argument::cetera())->willReturn(FALSE);
    // Last run 1 second ago - should not be due again.
    $this->stateService->getLastRun('t1')->willReturn(time() - 1);

    $this->assertFalse($this->matcher->shouldTriggerRun($config->reveal()));
  }

  /**
   * @covers ::getInterval
   * @dataProvider intervalDataProvider
   */
  public function testGetIntervalPredefinedKeys(string $key, int $expected): void {
    $config = $this->createMockConfig('t1', [
      'mode' => 'simple',
      'interval' => $key,
    ]);

    $this->stateService->hasReachedMaxExecutions(Argument::cetera())->willReturn(FALSE);
    // Make sure interval check happens by having a recent last run.
    $this->stateService->getTimeSinceLastRun('t1')->willReturn($expected - 1);

    // Should return false because interval hasn't been reached.
    $this->assertFalse($this->matcher->shouldTriggerRun($config->reveal()));
  }

  /**
   * Data provider for interval tests.
   */
  public static function intervalDataProvider(): array {
    return [
      'every_minute' => ['every_minute', 60],
      'every_5_minutes' => ['every_5_minutes', 300],
      'every_hour' => ['every_hour', 3600],
      'daily' => ['daily', 86400],
      'weekly' => ['weekly', 604800],
    ];
  }

  /**
   * @covers ::getInterval
   */
  public function testGetIntervalCustomKey(): void {
    $config = $this->createMockConfig('t1', [
      'mode' => 'simple',
      'interval' => 'custom',
      'custom_interval' => 7200,
    ]);

    $this->stateService->hasReachedMaxExecutions(Argument::cetera())->willReturn(FALSE);
    // 7199 seconds elapsed, interval is 7200.
    $this->stateService->getTimeSinceLastRun('t1')->willReturn(7199);

    $this->assertFalse($this->matcher->shouldTriggerRun($config->reveal()));
  }

  /**
   * @covers ::isValidCronExpression
   */
  public function testIsValidCronExpression(): void {
    $this->assertTrue($this->matcher->isValidCronExpression('* * * * *'));
    $this->assertTrue($this->matcher->isValidCronExpression('0 */6 * * *'));
    $this->assertFalse($this->matcher->isValidCronExpression(''));
    $this->assertFalse($this->matcher->isValidCronExpression('not a cron'));
  }

  /**
   * @covers ::describeCronExpression
   */
  public function testDescribeCronExpressionKnownPatterns(): void {
    $this->assertEquals('Every minute', $this->matcher->describeCronExpression('* * * * *'));
    $this->assertEquals('Daily at midnight', $this->matcher->describeCronExpression('0 0 * * *'));
  }

  /**
   * @covers ::describeCronExpression
   */
  public function testDescribeCronExpressionUnknownReturnsExpression(): void {
    $this->assertEquals('30 2 * * 3', $this->matcher->describeCronExpression('30 2 * * 3'));
  }

  /**
   * @covers ::getNextRunTime
   */
  public function testGetNextRunTimeValidExpression(): void {
    $result = $this->matcher->getNextRunTime('* * * * *');
    $this->assertInstanceOf(\DateTimeInterface::class, $result);
  }

  /**
   * @covers ::getNextRunTime
   */
  public function testGetNextRunTimeInvalidReturnsNull(): void {
    $this->assertNull($this->matcher->getNextRunTime('invalid'));
  }

  /**
   * Creates a mock trigger config.
   *
   * @return \Prophecy\Prophecy\ObjectProphecy<\Drupal\flowdrop_trigger\FlowDropTriggerConfigInterface>
   *   The mock trigger config.
   */
  private function createMockConfig(string $id, array $schedule) {
    $config = $this->prophesize(FlowDropTriggerConfigInterface::class);
    $config->id()->willReturn($id);
    $config->getConditions()->willReturn(
      empty($schedule) ? [] : ['cron_schedule' => $schedule]
    );
    return $config;
  }

}
