lib/tarearbol/extras/crontab.ex

defmodule Tarearbol.Crontab do
  @moduledoc """
  Helper functions to work with `cron` syntax.
  """

  use Boundary

  @typedoc "Internal representation of the record in cron file"
  @type t :: %__MODULE__{}
  defstruct [:minute, :hour, :day, :month, :day_of_week]

  @doc "Converts the `Time` instance into daily-execution cron string"
  @spec to_cron(dt :: Time.t()) :: binary()
  def to_cron(%Time{minute: minute, hour: hour}), do: "#{minute} #{hour} * * *"

  @prefix ""

  @doc """
  Returns the next `DateTime` the respective `cron` record points to
  with a precision given as the third argument (default: `:second`.)

  If the first parameter is not given, it assumes _the next after now_.

  _Examples_

      iex> dt = DateTime.from_unix!(1567091960)
      ~U[2019-08-29 15:19:20Z]
      iex> Tarearbol.Crontab.next(dt, "42 3 28 08 *")
      [
        origin: ~U[2019-08-29 15:19:20Z],
        next: ~U[2020-08-28 03:42:00Z],
        second: 31494160
      ]

  where `origin` contains the timestamp to lookup the `next` for, `next`
  is the `DateTime` instance of the next event and `second` is the
  {`precision`, `difference_in_that_precision`}.
  """
  @spec next(dt :: nil | DateTime.t(), input :: binary(), opts :: keyword()) :: DateTime.t()
  def next(dt \\ nil, input, opts \\ [])

  def next(nil, input, opts), do: next(DateTime.utc_now(), input, opts)

  def next(%DateTime{} = dt, input, opts) do
    dt
    |> next_as_stream(input, opts)
    |> Enum.drop_while(&(DateTime.compare(&1[:origin], &1[:next]) == :gt))
    |> Enum.take(1)
    |> hd()
  end

  @doc """
  Returns the _list_ of all the events after `dt` (default: `DateTime.utc_now/0`.)

  This function calculates the outcome greedily and, while it might be slightly
  faster than `Tarearbol.Crontab.next_as_stream/3`, it should not be used for
  frequently recurring cron records (like `"* * * * *"`.)
  """
  @spec next_as_list(dt :: nil | DateTime.t(), input :: binary(), opts :: keyword()) ::
          keyword()
  def next_as_list(dt \\ nil, input, opts \\ [])

  def next_as_list(nil, input, opts),
    do: next_as_list(DateTime.utc_now(), input, opts)

  def next_as_list(%DateTime{} = dt, input, opts) do
    precision = Keyword.get(opts, :precision, :second)

    %Tarearbol.Crontab{} = ct = prepare(input)
    dom_or_dow = dom_or_dow_checker(input, ct)

    next_dts =
      for year <- [dt.year, dt.year + 1],
          month <- 1..dt.calendar.months_in_year(year),
          year > dt.year || month >= dt.month,
          ct.month.eval.(month: month),
          day <- 1..dt.calendar.days_in_month(year, month),
          year > dt.year || month > dt.month || day >= dt.day,
          day_of_week <- [dt.calendar.day_of_week(year, month, day)],
          dom_or_dow.(day, day_of_week),
          hour <- 0..23,
          year > dt.year || month > dt.month || day > dt.day || hour >= dt.hour,
          ct.hour.eval.(hour: hour),
          minute <- 0..59,
          year > dt.year || month > dt.month || day > dt.day || hour > dt.hour ||
            minute > dt.minute,
          ct.minute.eval.(minute: minute),
          do: %DateTime{
            year: year,
            month: month,
            day: day,
            hour: hour,
            minute: minute,
            second: 0,
            microsecond: dt.microsecond,
            time_zone: dt.time_zone,
            zone_abbr: dt.zone_abbr,
            utc_offset: dt.utc_offset,
            std_offset: dt.std_offset,
            calendar: dt.calendar
          }

    [
      {:origin, DateTime.truncate(dt, precision)},
      {:next,
       Enum.map(next_dts, fn next_dt ->
         [
           {:timestamp, DateTime.truncate(next_dt, precision)},
           {precision, DateTime.diff(next_dt, dt, precision)}
         ]
       end)}
    ]
  end

  @doc """
  Returns the _stream_ of all the events after `dt` (default: `DateTime.utc_now/0`.)

  This function calculates the outcome lazily, returning a stream.

  See `Tarearbol.Crontab.next_as_list/3` for greedy evaluation.
  """
  @spec next_as_stream(dt :: nil | DateTime.t(), input :: binary(), opts :: keyword()) ::
          Enumerable.t()
  def next_as_stream(dt \\ nil, input, opts \\ [])

  def next_as_stream(nil, input, opts),
    do: next_as_stream(DateTime.utc_now(), input, opts)

  def next_as_stream(
        %DateTime{
          year: dty,
          month: dtm,
          day: dtd,
          hour: dth,
          minute: dtmin
        } = dt,
        input,
        opts
      ) do
    precision = Keyword.get(opts, :precision, :second)

    %Tarearbol.Crontab{} = ct = prepare(input)
    dom_or_dow = dom_or_dow_checker(input, ct)

    Stream.transform([dt.year, dt.year + 1], :ok, fn year, :ok ->
      {Stream.transform(1..dt.calendar.months_in_year(year), :ok, fn
         month, :ok when year <= dty and month < dtm ->
           {[], :ok}

         month, :ok ->
           unless ct.month.eval.(month: month) do
             {[], :ok}
           else
             {Stream.transform(1..dt.calendar.days_in_month(year, month), :ok, fn
                day, :ok when year <= dty and month <= dtm and day < dtd ->
                  {[], :ok}

                day, :ok ->
                  unless dom_or_dow.(day, dt.calendar.day_of_week(year, month, day)) do
                    {[], :ok}
                  else
                    {Stream.transform(0..23, :ok, fn
                       hour, :ok
                       when year <= dty and month <= dtm and day <= dtd and hour < dth ->
                         {[], :ok}

                       hour, :ok ->
                         unless ct.hour.eval.(hour: hour) do
                           {[], :ok}
                         else
                           {Stream.transform(0..59, :ok, fn
                              minute, :ok
                              when year <= dty and month <= dtm and day <= dtd and
                                     hour <= dth and minute < dtmin ->
                                {[], :ok}

                              minute, :ok ->
                                unless ct.minute.eval.(minute: minute) do
                                  {[], :ok}
                                else
                                  next_dt = %DateTime{
                                    year: year,
                                    month: month,
                                    day: day,
                                    hour: hour,
                                    minute: minute,
                                    second: 0,
                                    microsecond: dt.microsecond,
                                    time_zone: dt.time_zone,
                                    zone_abbr: dt.zone_abbr,
                                    utc_offset: dt.utc_offset,
                                    std_offset: dt.std_offset,
                                    calendar: dt.calendar
                                  }

                                  {[
                                     [
                                       {:origin, DateTime.truncate(dt, precision)},
                                       {:next, DateTime.truncate(next_dt, precision)},
                                       {precision, DateTime.diff(next_dt, dt, precision)}
                                     ]
                                   ], :ok}
                                end
                            end), :ok}
                         end
                     end), :ok}
                  end
              end), :ok}
           end
       end), :ok}
    end)

    #    stream
  end

  @doc """
  Parses the cron string into `Tarearbol.Crontab.t()` struct.

  Input format: ["minute hour day/month month day/week"](https://crontab.guru/).
  """

  @spec prepare(input :: binary() | Tarearbol.Crontab.t()) :: Tarearbol.Crontab.t()
  def prepare(input) when is_binary(input),
    do: input |> parse() |> prepare()

  def prepare(%Tarearbol.Crontab{
        minute: minute,
        hour: hour,
        day: day,
        month: month,
        day_of_week: day_of_week
      }) do
    %Tarearbol.Crontab{
      minute: Formulae.compile(minute, imports: :none),
      hour: Formulae.compile(hour, imports: :none),
      day: Formulae.compile(day, imports: :none),
      month: Formulae.compile(month, imports: :none),
      day_of_week: Formulae.compile(day_of_week, imports: :none)
    }
  end

  @doc """
  Parses the cron string into human-readable representation.

  **This function is exported for debugging purposes only, normally one would call `prepare/1` instead.**

  Input format: ["minute hour day/month month day/week"](https://crontab.guru/).

  _Examples:_

      iex> Tarearbol.Crontab.parse "10-30/5 */4 1 */1 6,7"
      %Tarearbol.Crontab{
        day: "(day == 1)",
        day_of_week: "(day_of_week == 6 || day_of_week == 7)",
        hour: "(rem(hour, 4) == 0)",
        minute: "(rem(minute, 5) == 0 && minute >= 10 && minute <= 30)",
        month: "(rem(month, 1) == 0)"
      }

  _In case of malformed input:_

      iex> Tarearbol.Crontab.parse "10-30/5 */4 1 */1 6d,7"
      %Tarearbol.Crontab{
        day: "(day == 1)",
        day_of_week: {:error, {:could_not_parse_integer, "6d"}},
        hour: "(rem(hour, 4) == 0)",
        minute: "(rem(minute, 5) == 0 && minute >= 10 && minute <= 30)",
        month: "(rem(month, 1) == 0)"
      }

  """

  @spec parse(input :: binary()) :: Tarearbol.Crontab.t()
  def parse(input) when is_binary(input),
    do: do_parse(input, {[:hour, :day, :month, :day_of_week], :minute, "", %{}})

  #############################################################################

  @spec do_parse(input :: binary(), {[atom()], atom(), binary(), map()}) ::
          Tarearbol.Crontab.t()

  defp do_parse("@yearly", acc), do: do_parse("0 0 1 1 *", acc)

  defp do_parse("@monthly", acc), do: do_parse("0 0 1 * *", acc)

  defp do_parse("@weekly", acc), do: do_parse("0 0 * * 1", acc)

  defp do_parse("@daily", acc), do: do_parse("0 0 * * *", acc)

  defp do_parse("@hourly", acc), do: do_parse("0 * * * *", acc)

  defp do_parse("@reboot", _acc), do: raise("Not supported")

  defp do_parse("@annually", _acc), do: raise("Not supported")

  defp do_parse("", {[], frac, acc, result}) do
    map = for {k, v} <- Map.put(result, frac, acc), into: %{}, do: {k, parts(k, v)}
    struct(Tarearbol.Crontab, map)
  end

  defp do_parse(" " <> rest, {fracs, frac, acc, result}) do
    result = Map.put(result, frac, acc)
    [frac | fracs] = fracs
    do_parse(rest, {fracs, frac, "", result})
  end

  defp do_parse(<<c::binary-size(1), rest::binary>>, {fracs, frac, acc, result}),
    do: do_parse(rest, {fracs, frac, acc <> c, result})

  #############################################################################

  # defguardp is_digit(c) when c in ?0..?9
  defguardp is_cc(cc) when byte_size(cc) in [1, 2]

  @spec parts(key :: atom(), input :: binary()) :: [binary()]
  defp parts(key, input) do
    input
    |> String.split(",")
    |> Enum.reduce({:ok, []}, fn e, acc ->
      case {acc, String.split(e, "/")} do
        {{:error, reason}, _} ->
          {:error, reason}

        {{:ok, acc}, ["*"]} ->
          with {:ok, result} <- parse_int(key, "1"), do: {:ok, [result | acc]}

        {{:ok, acc}, ["*", t]} when is_cc(t) ->
          with {:ok, result} <- parse_int(key, t), do: {:ok, [result | acc]}

        {{:ok, acc}, [s, t]} when is_cc(s) and is_cc(t) ->
          with {:ok, result} <- parse_int(key, s, t), do: {:ok, [result | acc]}

        {{:ok, acc}, [<<s1::binary-size(1), "-", s2::binary>>, t]} when is_cc(t) ->
          with {:ok, result} <- parse_int(key, s1, s2, t), do: {:ok, [result | acc]}

        {{:ok, acc}, [<<s1::binary-size(2), "-", s2::binary>>, t]} when is_cc(t) ->
          with {:ok, result} <- parse_int(key, s1, s2, t), do: {:ok, [result | acc]}

        {{:ok, acc}, [<<s1::binary-size(1), "-", s2::binary>>]} ->
          with {:ok, result} <- parse_int(key, s1, s2, "1"), do: {:ok, [result | acc]}

        {{:ok, acc}, [<<s1::binary-size(2), "-", s2::binary>>]} ->
          with {:ok, result} <- parse_int(key, s1, s2, "1"), do: {:ok, [result | acc]}

        {{:ok, acc}, [s]} when is_cc(s) ->
          case Integer.parse(s) do
            {int, ""} -> {:ok, ["#{@prefix}#{key} == #{int}" | acc]}
            _ -> {:error, {:could_not_parse_integer, s}}
          end

        {{:ok, _}, unknown} ->
          {:error, {:could_not_parse_field, unknown}}
      end
    end)
    |> case do
      {:ok, acc} ->
        result =
          acc
          |> Enum.reverse()
          |> Enum.join(" || ")

        "(" <> result <> ")"

      other ->
        other
    end
  end

  @spec parse_int(key :: atom(), s :: binary()) :: binary() | {:error, any()}
  defp parse_int(key, s) do
    case str_to_int(s) do
      {:error, reason} -> {:error, reason}
      int -> {:ok, "rem(#{@prefix}#{key}, #{int}) == 0"}
    end
  end

  @spec parse_int(key :: atom(), s1 :: binary(), s2 :: binary()) :: binary() | {:error, any()}
  defp parse_int(key, s1, s2) do
    case {str_to_int(s1), str_to_int(s2)} do
      {{:error, r1}, {:error, r2}} ->
        {:error, [r1, r2]}

      {{:error, r1}, _} ->
        {:error, r1}

      {_, {:error, r2}} ->
        {:error, r2}

      {from, int} ->
        {:ok, "rem(#{@prefix}#{key}, #{int}) == #{rem(from, int)} && #{@prefix}#{key} >= #{from}"}
    end
  end

  @spec parse_int(key :: atom(), s1 :: binary(), s2 :: binary(), s :: binary()) ::
          binary() | {:error, any()}
  defp parse_int(key, s1, s2, s) do
    case {str_to_int(s1), str_to_int(s2), str_to_int(s)} do
      {{:error, r1}, {:error, r2}, {:error, r3}} ->
        {:error, [r1, r2, r3]}

      {{:error, r1}, {:error, r2}, _} ->
        {:error, [r1, r2]}

      {{:error, r1}, _, {:error, r3}} ->
        {:error, [r1, r3]}

      {_, {:error, r2}, {:error, r3}} ->
        {:error, [r2, r3]}

      {{:error, r1}, _, _} ->
        {:error, r1}

      {_, {:error, r2}, _} ->
        {:error, r2}

      {_, _, {:error, r3}} ->
        {:error, r3}

      {from, till, int} ->
        {:ok,
         Enum.join(
           [
             "rem(#{@prefix}#{key}, #{int}) == #{rem(from, int)}",
             "#{@prefix}#{key} >= #{from}",
             "#{@prefix}#{key} <= #{till}"
           ],
           " && "
         )}
    end
  end

  @spec str_to_int(input :: binary(), acc :: {1 | -1, [integer()]} | {:error, any()}) ::
          integer() | {:error, any()}
  defp str_to_int(input, acc \\ {1, []})
  defp str_to_int(_, {:error, reason}), do: {:error, reason}

  defp str_to_int(<<"+", rest::binary>>, {_, []}), do: str_to_int(rest, {1, []})
  defp str_to_int(<<"-", rest::binary>>, {_, []}), do: str_to_int(rest, {-1, []})

  defp str_to_int("", {sign, acc}) do
    acc
    |> Enum.reduce({1, 0}, fn digit, {denom, result} ->
      {denom * 10, result + digit * denom}
    end)
    |> elem(1)
    |> Kernel.*(sign)
  end

  defp str_to_int(<<c::8, rest::binary>>, {sign, acc}) when c in ?0..?9,
    do: str_to_int(rest, {sign, [c - 48 | acc]})

  defp str_to_int(input, _), do: {:error, {:could_not_parse_integer, input}}

  ##############################################################################

  @doc """
  Produces the single formula out of cron record. Might be useful
  for some external check that requires the single validation call.

  _Examples_

      iex> formula = Tarearbol.Crontab.formula("42 3 28 08 *").formula
      ...> formula |> String.split(" && ") |> Enum.sort()
      ["(day == 28)", "(hour == 3)", "(minute == 42)", "(month == 8)", "(rem(day_of_week, 1) == 0)"]

      iex> Tarearbol.Crontab.formula("423 * * * *")
      {:error, [minute: {:could_not_parse_field, ["423"]}]}

  """
  @spec formula(ct :: binary() | Tarearbol.Crontab.t()) :: :error | binary()
  def formula(ct) when is_binary(ct) do
    with f when is_binary(f) <- ct |> parse() |> formula(),
         do: Formulae.compile(f, imports: :none)
  end

  def formula(%Tarearbol.Crontab{} = ct) do
    ct
    |> Enum.reduce({:ok, []}, fn
      {key, {:error, reason}}, {:ok, _} -> {:error, [{key, reason}]}
      {key, {:error, reason}}, {:error, reasons} -> {:error, [{key, reason} | reasons]}
      {_key, _formulae}, {:error, reasons} -> {:error, reasons}
      {_key, formulae}, {:ok, result} -> {:ok, [formulae | result]}
    end)
    |> case do
      {:error, reasons} -> {:error, reasons}
      {:ok, result} -> result |> Enum.reverse() |> Enum.join(" && ")
    end
  end

  @spec dom_or_dow_checker(input :: binary(), ct :: t()) ::
          (non_neg_integer(), non_neg_integer() -> boolean)
  defp dom_or_dow_checker(input, ct) do
    case String.split(input) do
      [_, _, "*", _, "*"] ->
        fn day, _day_of_week ->
          ct.day.eval.(day: day)
        end

      [_, _, _, _, "*"] ->
        fn day, _day_of_week ->
          ct.day.eval.(day: day)
        end

      [_, _, "*", _, _] ->
        fn _day, day_of_week ->
          ct.day_of_week.eval.(day_of_week: day_of_week)
        end

      [_, _, _, _, _] ->
        fn day, day_of_week ->
          ct.day.eval.(day: day) or ct.day_of_week.eval.(day_of_week: day_of_week)
        end
    end
  end

  defimpl Enumerable do
    @moduledoc false

    @doc false
    def count(%Tarearbol.Crontab{} = _sct), do: {:ok, 5}

    @doc false
    Enum.each([:minute, :hour, :day, :month, :day_of_week], fn item ->
      def member?(%Tarearbol.Crontab{} = _sct, unquote(item)), do: {:ok, true}
    end)

    def member?(%Tarearbol.Crontab{} = _sct, _val), do: false

    @doc false
    def slice(%Tarearbol.Crontab{} = _sct), do: raise("Not implemented")

    @doc false
    def reduce(%Tarearbol.Crontab{} = sct, acc, fun) do
      Enumerable.List.reduce(
        for({key, formulae} <- Map.from_struct(sct), do: {key, formulae}),
        acc,
        fun
      )
    end
  end
end