lib/util/cron.ex

# Copyright(c) 2015-2023 ACCESS CO., LTD. All rights reserved.

use Croma

defmodule Antikythera.Cron do
  @moduledoc """
  Calculate time schedules based on cron format strings.

  `parse/1` recognizes the [POSIX specifications of crontab format](http://www.unix.com/man-page/posix/1posix/crontab)
  with the extension of "step values" (explained below).
  The parsed object can be used to compute next matching time in `next/2`.

  Note that all times are in UTC, as is the case with `Antikythera.Time`.

  ## Schedule format

  - The cron schedule is specified by 5 fields separated by whitespaces.
  - Allowed values for each field are:
      - minutes      : 0-59
      - hours        : 0-23
      - day of month : 1-31
      - month        : 1-12
      - day of week  : 0-6 (0=Sunday)
  - Multiple elements can be used within a field by separating each by `,`.
  - An element shall be either a number or two numbers separated by a `-` (meaning an inclusive range).
  - A field may contain `*` which stands for "first-last".
  - Step values as in "/<skip>" can be used in conjunction with ranges.
    For example,
      - "0-18/4" is identical to "0,4,8,12,16", and
      - "*/10" in minutes field is identical to "0,10,20,30,40,50".
  - If both 'day of month' and 'day of week' are not "*", then the dates are the ones matching **either** of the fields.
    For example, "30 4 1,15 * 5" indicates both of the followings:
      - 4:30 on the 1st and 15th of each month
      - 4:30 on every Friday
  - Schedules that actually don't represent valid date are not allowed.
    For example, "0 0 31 4 *" is rejected as 31st of April does not exist.
  """

  alias Croma.Result, as: R
  alias Antikythera.{Time, MilliSecondsSinceEpoch}

  [
    {Minute, 0, 59},
    {Hour, 0, 23},
    {DayOfMonth, 1, 31},
    {Month, 1, 12},
    {DayOfWeek, 0, 6}
  ]
  |> Enum.each(fn {mod, min, max} ->
    m = Module.safe_concat(__MODULE__, mod)

    defmodule m do
      defmodule Int do
        use Croma.SubtypeOfInt, min: min, max: max
      end

      defun min() :: Int.t(), do: Int.min()
      defun max() :: Int.t(), do: Int.max()

      @typedoc "Wildcard `:*` or sorted list of values."
      @type t :: :* | [Int.t()]

      defun valid?(v :: term) :: boolean do
        :* -> true
        l when is_list(l) -> Enum.all?(l, &Int.valid?/1)
      end
    end
  end)

  # defmodule using variable name does not automatically make alias
  alias Antikythera.Cron.{Minute, Hour, DayOfMonth, Month, DayOfWeek}

  use Croma.Struct,
    recursive_new?: true,
    fields: [
      minute: Minute,
      hour: Hour,
      day_of_month: DayOfMonth,
      month: Month,
      day_of_week: DayOfWeek,
      source: Croma.String
    ]

  defun parse!(s :: v[String.t()]) :: t do
    parse(s) |> R.get!()
  end

  defun parse(s :: v[String.t()]) :: R.t(t) do
    case String.split(s, " ", trim: true) do
      [minute, hour, day_of_month, month, day_of_week] ->
        R.m do
          l1 <- parse_field(minute, Minute)
          l2 <- parse_field(hour, Hour)
          l3 <- parse_field(day_of_month, DayOfMonth)
          l4 <- parse_field(month, Month)
          l5 <- parse_field(day_of_week, DayOfWeek)

          if matching_dates_exist?(l3, l4) do
            {:ok,
             %__MODULE__{
               minute: l1,
               hour: l2,
               day_of_month: l3,
               month: l4,
               day_of_week: l5,
               source: s
             }}
          else
            {:error, {:invalid_value, [__MODULE__]}}
          end
        end

      _ ->
        {:error, {:invalid_value, [__MODULE__]}}
    end
  end

  defp matching_dates_exist?(day_of_month, month) do
    # The following combinations of month/day do not exist: 2/30, 2/31, 4/31, 6/31, 9/31, 11/31
    # Cron patterns that only specify those dates are prohibited in order to prevent infinite loops in `next/2`.
    case {day_of_month, month} do
      {[30 | _], [2]} -> false
      {[31], ms} when is_list(ms) -> !Enum.all?(ms, &(&1 in [2, 4, 6, 9, 11]))
      _ -> true
    end
  end

  defp parse_field(s, mod) do
    case s do
      "*" ->
        {:ok, :*}

      _ ->
        String.split(s, ",")
        |> Enum.map(&parse_element(&1, mod))
        |> R.sequence()
        |> R.map(&(List.flatten(&1) |> Enum.sort() |> Enum.uniq()))
    end
  rescue
    _ in [MatchError, ArgumentError, FunctionClauseError] ->
      {:error, {:invalid_value, [__MODULE__, mod]}}
  end

  defp parse_element(str, mod) do
    case str do
      "*/" <> step ->
        {:ok, Enum.take_every(mod.min()..mod.max(), String.to_integer(step))}

      _ ->
        {range, step} = parse_range_and_step(str)
        {first, last} = parse_first_and_last(range)

        cond do
          first < mod.min() -> {:error, {:invalid_value, [__MODULE__, mod]}}
          last > mod.max() -> {:error, {:invalid_value, [__MODULE__, mod]}}
          true -> {:ok, Enum.take_every(first..last, step)}
        end
    end
  end

  defp parse_range_and_step(str) do
    case String.split(str, "/") do
      [r, s] -> {r, String.to_integer(s)}
      [_] -> {str, 1}
    end
  end

  defp parse_first_and_last(range) do
    case String.split(range, "-") do
      [f, l] ->
        {String.to_integer(f), String.to_integer(l)}

      [_] ->
        i = String.to_integer(range)
        {i, i}
    end
  end

  defun next(cron :: v[t], t :: v[Time.t()]) :: v[Time.t()] do
    # ensure that returned time is larger than the given time `t` by making "1 minute after `t`" as the starting point
    next_impl(cron, beginning_of_next_minute(t))
  end

  defp next_impl(cron, {_, ymd1, {h1, m1, _}, _} = t) do
    ymd2 = find_matching_date(cron, ymd1)

    if ymd2 == ymd1 do
      # no reset, `h1` and `m1` are still valid
      case find_matching_hour_and_minute(cron, h1, m1) do
        {h2, m2} ->
          {Time, ymd2, {h2, m2, 0}, 0}

        nil ->
          # can't find matching hour and minute in this day; search again from the beginning of the next day
          next_impl(cron, beginning_of_next_day(t))
      end
    else
      # hour and minute are reset to 0, we don't have to worry about carries
      {h2, m2} = find_matching_hour_and_minute(cron, 0, 0)
      {Time, ymd2, {h2, m2, 0}, 0}
    end
  end

  defp beginning_of_next_minute(t), do: Time.truncate_to_minute(t) |> Time.shift_minutes(1)
  defp beginning_of_next_day(t), do: Time.truncate_to_day(t) |> Time.shift_days(1)

  defp find_matching_date(
         %__MODULE__{day_of_month: dom, day_of_week: dow, month: month} = cron,
         ymd
       ) do
    case {dom, dow, month} do
      {:*, :*, :*} ->
        ymd

      {:*, :*, _} ->
        find_matching_month(cron, ymd)

      {_, :*, _} ->
        find_matching_date_by_day_of_month(cron, ymd)

      {:*, _, _} ->
        find_matching_date_by_day_of_week(cron, ymd)

      {_, _, _} ->
        min(
          find_matching_date_by_day_of_month(cron, ymd),
          find_matching_date_by_day_of_week(cron, ymd)
        )
    end
  end

  defp find_matching_date_by_day_of_month(%__MODULE__{day_of_month: day_of_month} = cron, ymd) do
    {y, m, d1} = find_matching_month(cron, ymd)
    last_day_of_month = :calendar.last_day_of_the_month(y, m)

    case Enum.find(day_of_month, &(&1 >= d1)) do
      d2 when d2 <= last_day_of_month ->
        {y, m, d2}

      _ ->
        # can't find matching date in this month; search again from the 1st day of the next month
        find_matching_date_by_day_of_month(cron, next_month_1st(y, m))
    end
  end

  defp find_matching_date_by_day_of_week(%__MODULE__{day_of_week: day_of_week} = cron, ymd1) do
    {y, m, d1} = ymd2 = find_matching_month(cron, ymd1)
    dow1 = day_of_the_week(ymd2)
    d2 = d1 + num_days_to_day_of_week(day_of_week, dow1)

    if d2 <= :calendar.last_day_of_the_month(y, m) do
      {y, m, d2}
    else
      # can't find matching date in this month; search again from the 1st day of the next month
      find_matching_date_by_day_of_week(cron, next_month_1st(y, m))
    end
  end

  defp num_days_to_day_of_week(day_of_week, dow_offset) do
    case Enum.find(day_of_week, &(&1 >= dow_offset)) do
      nil -> hd(day_of_week) + 7 - dow_offset
      dow2 -> dow2 - dow_offset
    end
  end

  defp find_matching_month(%__MODULE__{month: month} = cron, {y, m1, d}) do
    case find_matching_value(month, m1) do
      nil -> find_matching_month(cron, {y + 1, 1, 1})
      ^m1 -> {y, m1, d}
      m2 -> {y, m2, 1}
    end
  end

  defpt day_of_the_week(ymd) do
    case :calendar.day_of_the_week(ymd) do
      # `:calendar.daynum` type is defined as `1..7`; we need to convert 7 to 0 (which represents sunday)
      7 -> 0
      dow -> dow
    end
  end

  defp next_month_1st(y, 12), do: {y + 1, 1, 1}
  defp next_month_1st(y, m), do: {y, m + 1, 1}

  defp find_matching_hour_and_minute(%__MODULE__{hour: hour, minute: minute} = cron, h1, m1) do
    case find_matching_value(hour, h1) do
      # can't find matching hour in this day
      nil ->
        nil

      ^h1 ->
        # no reset, `m1` is still valid
        case find_matching_value(minute, m1) do
          nil when h1 == 23 -> nil
          nil -> find_matching_hour_and_minute(cron, h1 + 1, 0)
          m2 -> {h1, m2}
        end

      h2 ->
        {h2, find_matching_value(minute, 0)}
    end
  end

  defp find_matching_value(:*, v), do: v
  defp find_matching_value(l, v), do: Enum.find(l, &(&1 >= v))

  defun next_in_epoch_milliseconds(cron :: v[t], t :: v[MilliSecondsSinceEpoch.t()]) ::
          v[MilliSecondsSinceEpoch.t()] do
    next(cron, Time.from_epoch_milliseconds(t)) |> Time.to_epoch_milliseconds()
  end
end