<?php

declare(strict_types=1);

namespace Drupal\flowdrop_trigger\Service;

use Cron\CronExpression;
use Drupal\flowdrop_trigger\FlowDropTriggerConfigInterface;
use Psr\Log\LoggerInterface;

/**
 * Service for evaluating cron trigger schedules.
 *
 * Determines whether a cron trigger should fire based on its schedule
 * configuration. Supports two modes:
 *
 * - Simple mode: Interval-based scheduling with time windows and day filters
 * - Advanced mode: Standard cron expression syntax
 *
 * @see \Drupal\flowdrop_trigger\Service\CronTriggerStateService
 *
 * @internal This class is not part of the public API and may change without notice.
 */
class CronScheduleMatcher {

  /**
   * Pre-defined interval options (in seconds).
   */
  public const INTERVALS = [
    "every_minute" => 60,
    "every_5_minutes" => 300,
    "every_15_minutes" => 900,
    "every_30_minutes" => 1800,
    "every_hour" => 3600,
    "every_2_hours" => 7200,
    "every_6_hours" => 21600,
    "every_12_hours" => 43200,
    "daily" => 86400,
    "weekly" => 604800,
  ];

  /**
   * Constructs a CronScheduleMatcher object.
   *
   * @param \Drupal\flowdrop_trigger\Service\CronTriggerStateService $stateService
   *   The cron trigger state service.
   * @param \Psr\Log\LoggerInterface $logger
   *   The logger service.
   */
  public function __construct(
    protected readonly CronTriggerStateService $stateService,
    protected readonly LoggerInterface $logger,
  ) {}

  /**
   * Determine if a trigger should run based on its schedule.
   *
   * @param \Drupal\flowdrop_trigger\FlowDropTriggerConfigInterface $config
   *   The trigger configuration.
   *
   * @return bool
   *   TRUE if the trigger should run, FALSE otherwise.
   */
  public function shouldTriggerRun(FlowDropTriggerConfigInterface $config): bool {
    $conditions = $config->getConditions();
    $schedule = $conditions["cron_schedule"] ?? [];

    // If no schedule is configured, always run (backward compatibility).
    if (empty($schedule)) {
      return TRUE;
    }

    $triggerId = $config->id();
    if ($triggerId === NULL) {
      return FALSE;
    }

    // Check max executions first.
    $maxExecutions = $schedule["max_executions"] ?? 0;
    if ($maxExecutions > 0) {
      if ($this->stateService->hasReachedMaxExecutions($triggerId, $maxExecutions)) {
        $this->logger->debug("Trigger @id skipped: max executions (@max) reached", [
          "@id" => $triggerId,
          "@max" => $maxExecutions,
        ]);
        return FALSE;
      }
    }

    // Get timezone.
    $timezone = $this->getTimezone($schedule);

    // Check schedule based on mode.
    $mode = $schedule["mode"] ?? "simple";

    if ($mode === "advanced") {
      return $this->evaluateAdvancedSchedule($triggerId, $schedule, $timezone);
    }

    return $this->evaluateSimpleSchedule($triggerId, $schedule, $timezone);
  }

  /**
   * Evaluate a simple mode schedule.
   *
   * @param string $triggerId
   *   The trigger configuration ID.
   * @param array<string, mixed> $schedule
   *   The schedule configuration.
   * @param \DateTimeZone $timezone
   *   The timezone for time calculations.
   *
   * @return bool
   *   TRUE if the trigger should run.
   */
  protected function evaluateSimpleSchedule(
    string $triggerId,
    array $schedule,
    \DateTimeZone $timezone,
  ): bool {
    $now = new \DateTimeImmutable("now", $timezone);

    // Check day of week restriction.
    if (!$this->isDayAllowed($now, $schedule)) {
      $this->logger->debug("Trigger @id skipped: day of week not allowed", [
        "@id" => $triggerId,
      ]);
      return FALSE;
    }

    // Check time window restriction.
    if (!$this->isTimeWindowActive($now, $schedule)) {
      $this->logger->debug("Trigger @id skipped: outside time window", [
        "@id" => $triggerId,
      ]);
      return FALSE;
    }

    // Check interval.
    $interval = $this->getInterval($schedule);
    if ($interval > 0) {
      $timeSinceLastRun = $this->stateService->getTimeSinceLastRun($triggerId);

      // If never run, allow it to run.
      if ($timeSinceLastRun === NULL) {
        return TRUE;
      }

      // Check if enough time has passed.
      if ($timeSinceLastRun < $interval) {
        $this->logger->debug("Trigger @id skipped: interval not reached (@elapsed/@interval seconds)", [
          "@id" => $triggerId,
          "@elapsed" => $timeSinceLastRun,
          "@interval" => $interval,
        ]);
        return FALSE;
      }
    }

    return TRUE;
  }

  /**
   * Evaluate an advanced mode schedule (cron expression).
   *
   * @param string $triggerId
   *   The trigger configuration ID.
   * @param array<string, mixed> $schedule
   *   The schedule configuration.
   * @param \DateTimeZone $timezone
   *   The timezone for time calculations.
   *
   * @return bool
   *   TRUE if the trigger should run.
   */
  protected function evaluateAdvancedSchedule(
    string $triggerId,
    array $schedule,
    \DateTimeZone $timezone,
  ): bool {
    $cronExpression = $schedule["cron_expression"] ?? "";

    if (empty($cronExpression)) {
      // No expression configured, allow run.
      return TRUE;
    }

    try {
      $cron = new CronExpression($cronExpression);
      $now = new \DateTimeImmutable("now", $timezone);

      // Get the last run time.
      $lastRun = $this->stateService->getLastRun($triggerId);

      if ($lastRun === 0) {
        // Never run before - check if we're currently in a valid run window.
        // We allow running if the cron is due within the last minute.
        return $cron->isDue($now);
      }

      // Check if there's been a due time since the last run.
      $lastRunDate = (new \DateTimeImmutable())->setTimestamp($lastRun)->setTimezone($timezone);

      // Get the next run time after the last run.
      $nextRunAfterLast = $cron->getNextRunDate($lastRunDate, 0, FALSE, $timezone->getName());

      // If next run time is in the past or now, we should run.
      if ($nextRunAfterLast <= $now) {
        return TRUE;
      }

      $this->logger->debug("Trigger @id skipped: cron expression not due (next run: @next)", [
        "@id" => $triggerId,
        "@next" => $nextRunAfterLast->format("Y-m-d H:i:s"),
      ]);

      return FALSE;
    }
    catch (\Exception $e) {
      $this->logger->error("Invalid cron expression '@expr' for trigger @id: @message", [
        "@expr" => $cronExpression,
        "@id" => $triggerId,
        "@message" => $e->getMessage(),
      ]);
      return FALSE;
    }
  }

  /**
   * Check if the current day is allowed by the schedule.
   *
   * @param \DateTimeImmutable $now
   *   The current date/time.
   * @param array<string, mixed> $schedule
   *   The schedule configuration.
   *
   * @return bool
   *   TRUE if the day is allowed.
   */
  protected function isDayAllowed(\DateTimeImmutable $now, array $schedule): bool {
    $allowedDays = $schedule["days_of_week"] ?? [];

    // Empty array means all days are allowed.
    if (empty($allowedDays)) {
      return TRUE;
    }

    // Get current day of week (0 = Sunday, 6 = Saturday).
    $currentDay = (int) $now->format("w");

    return in_array($currentDay, $allowedDays, TRUE);
  }

  /**
   * Check if the current time is within the allowed time window.
   *
   * @param \DateTimeImmutable $now
   *   The current date/time.
   * @param array<string, mixed> $schedule
   *   The schedule configuration.
   *
   * @return bool
   *   TRUE if within the time window.
   */
  protected function isTimeWindowActive(\DateTimeImmutable $now, array $schedule): bool {
    $windowStart = $schedule["time_window_start"] ?? "";
    $windowEnd = $schedule["time_window_end"] ?? "";

    // If neither is set, always active.
    if (empty($windowStart) && empty($windowEnd)) {
      return TRUE;
    }

    $currentTime = $now->format("H:i");

    // If only start is set, check if current time is >= start.
    if (!empty($windowStart) && empty($windowEnd)) {
      return $currentTime >= $windowStart;
    }

    // If only end is set, check if current time is <= end.
    if (empty($windowStart) && !empty($windowEnd)) {
      return $currentTime <= $windowEnd;
    }

    // Both are set - check if within window.
    // Handle overnight windows (e.g., 22:00 to 06:00).
    if ($windowStart <= $windowEnd) {
      // Normal window (e.g., 09:00 to 17:00).
      return $currentTime >= $windowStart && $currentTime <= $windowEnd;
    }
    else {
      // Overnight window (e.g., 22:00 to 06:00).
      return $currentTime >= $windowStart || $currentTime <= $windowEnd;
    }
  }

  /**
   * Get the interval in seconds from the schedule configuration.
   *
   * @param array<string, mixed> $schedule
   *   The schedule configuration.
   *
   * @return int
   *   The interval in seconds, or 0 if not configured.
   */
  protected function getInterval(array $schedule): int {
    $interval = $schedule["interval"] ?? 0;

    // If it's a string key from our pre-defined intervals, convert it.
    if (is_string($interval)) {
      // Handle "custom" interval - use the custom_interval value.
      if ($interval === "custom") {
        return (int) ($schedule["custom_interval"] ?? 3600);
      }

      // Check pre-defined intervals.
      if (isset(self::INTERVALS[$interval])) {
        return self::INTERVALS[$interval];
      }
    }

    return (int) $interval;
  }

  /**
   * Get the timezone for schedule calculations.
   *
   * @param array<string, mixed> $schedule
   *   The schedule configuration.
   *
   * @return \DateTimeZone
   *   The timezone object.
   */
  protected function getTimezone(array $schedule): \DateTimeZone {
    $tzString = $schedule["timezone"] ?? "";

    if (empty($tzString)) {
      // Use site's default timezone.
      $tzString = date_default_timezone_get();
    }

    try {
      return new \DateTimeZone($tzString);
    }
    catch (\Exception) {
      // Fall back to UTC if invalid.
      return new \DateTimeZone("UTC");
    }
  }

  /**
   * Validate a cron expression.
   *
   * @param string $expression
   *   The cron expression to validate.
   *
   * @return bool
   *   TRUE if valid, FALSE otherwise.
   */
  public function isValidCronExpression(string $expression): bool {
    if (empty($expression)) {
      return FALSE;
    }

    try {
      new CronExpression($expression);
      return TRUE;
    }
    catch (\Exception) {
      return FALSE;
    }
  }

  /**
   * Get the next run time for a cron expression.
   *
   * @param string $expression
   *   The cron expression.
   * @param string $timezone
   *   The timezone string.
   *
   * @return \DateTimeInterface|null
   *   The next run time, or NULL if invalid.
   */
  public function getNextRunTime(string $expression, string $timezone = "UTC"): ?\DateTimeInterface {
    try {
      $cron = new CronExpression($expression);
      $tz = new \DateTimeZone($timezone);
      return $cron->getNextRunDate("now", 0, FALSE, $tz->getName());
    }
    catch (\Exception) {
      return NULL;
    }
  }

  /**
   * Get human-readable description of a cron expression.
   *
   * @param string $expression
   *   The cron expression.
   *
   * @return string
   *   A human-readable description or the expression itself if parsing fails.
   */
  public function describeCronExpression(string $expression): string {
    // Common patterns with descriptions.
    $patterns = [
      "* * * * *" => "Every minute",
      "*/5 * * * *" => "Every 5 minutes",
      "*/15 * * * *" => "Every 15 minutes",
      "*/30 * * * *" => "Every 30 minutes",
      "0 * * * *" => "Every hour",
      "0 */2 * * *" => "Every 2 hours",
      "0 */6 * * *" => "Every 6 hours",
      "0 */12 * * *" => "Every 12 hours",
      "0 0 * * *" => "Daily at midnight",
      "0 0 * * 0" => "Weekly on Sunday at midnight",
      "0 0 1 * *" => "Monthly on the 1st at midnight",
      "0 9 * * 1-5" => "Weekdays at 9am",
      "0 9 * * *" => "Daily at 9am",
      "0 18 * * *" => "Daily at 6pm",
    ];

    return $patterns[$expression] ?? $expression;
  }

  /**
   * Get available pre-defined interval options.
   *
   * @return array<string, string>
   *   Array of interval options keyed by ID with human-readable labels.
   */
  public static function getIntervalOptions(): array {
    return [
      "every_minute" => "Every minute",
      "every_5_minutes" => "Every 5 minutes",
      "every_15_minutes" => "Every 15 minutes",
      "every_30_minutes" => "Every 30 minutes",
      "every_hour" => "Every hour",
      "every_2_hours" => "Every 2 hours",
      "every_6_hours" => "Every 6 hours",
      "every_12_hours" => "Every 12 hours",
      "daily" => "Daily (24 hours)",
      "weekly" => "Weekly (7 days)",
      "custom" => "Custom interval (seconds)",
    ];
  }

  /**
   * Get available day of week options.
   *
   * @return array<int, string>
   *   Array of day options keyed by day number (0=Sunday).
   */
  public static function getDayOfWeekOptions(): array {
    return [
      0 => "Sunday",
      1 => "Monday",
      2 => "Tuesday",
      3 => "Wednesday",
      4 => "Thursday",
      5 => "Friday",
      6 => "Saturday",
    ];
  }

}
