lib/interval/interval.ex

defmodule Timex.Interval do
  @moduledoc """
  This module is used for creating and manipulating date/time intervals.

  ## Examples

      iex> use Timex
      ...> Interval.new(from: ~D[2016-03-03], until: [days: 3])
      %#{__MODULE__}{from: ~N[2016-03-03 00:00:00], left_open: false, right_open: true, step: [days: 1], until: ~N[2016-03-06 00:00:00]}

      iex> use Timex
      ...> Interval.new(from: ~D[2016-03-03], until: ~N[2016-03-10 01:23:45])
      %Timex.Interval{from: ~N[2016-03-03 00:00:00], left_open: false, right_open: true, step: [days: 1], until: ~N[2016-03-10 01:23:45]}

      iex> use Timex
      ...> ~N[2016-03-04 12:34:56] in Interval.new(from: ~D[2016-03-03], until: [days: 3])
      true

      iex> use Timex
      ...> ~D[2016-03-01] in Interval.new(from: ~D[2016-03-03], until: [days: 3])
      false

      iex> use Timex
      ...> Interval.overlaps?(Interval.new(from: ~D[2016-03-01], until: [days: 5]),  Interval.new(from: ~D[2016-03-03], until: [days: 3]))
      true

      iex> use Timex
      ...> Interval.overlaps?(Interval.new(from: ~D[2016-03-01], until: [days: 1]),  Interval.new(from: ~D[2016-03-03], until: [days: 3]))
      false
  """
  alias Timex.Duration

  defmodule FormatError do
    @moduledoc """
    Thrown when an error occurs with formatting an Interval
    """
    defexception message: "Unable to format interval!"

    def exception(message: message) do
      %FormatError{message: message}
    end
  end

  @type t :: %__MODULE__{}
  @type valid_step_unit ::
          :microseconds
          | :milliseconds
          | :seconds
          | :minutes
          | :hours
          | :days
          | :weeks
          | :months
          | :years
  @type valid_interval_step :: {valid_step_unit, integer}
  @type valid_interval_steps :: [valid_interval_step]

  @enforce_keys [:from, :until]
  defstruct from: nil,
            until: nil,
            left_open: false,
            right_open: true,
            step: [days: 1]

  @valid_step_units [
    :microseconds,
    :milliseconds,
    :seconds,
    :minutes,
    :hours,
    :days,
    :weeks,
    :months,
    :years
  ]

  @doc """
  Create a new Interval struct.

  **Note:** By default intervals are left closed, i.e. they include the `from` date/time,
  and exclude the `until` date/time. Put another way, `from <= x < until`. This behavior
  matches that of other popular date/time libraries, such as Joda Time, as well as the SQL
  behavior of the `overlaps` keyword.

  Options:

  - `from`: The date the interval starts at. Should be a `(Naive)DateTime`.
  - `until`: Either a `(Naive)DateTime`, or a time shift that will be applied to the `from` date.
    This value _must_ be greater than `from`, otherwise an error will be returned.
  - `left_open`: Whether the interval is left open. See explanation below.
  - `right_open`: Whether the interval is right open. See explanation below.
  - `step`: The step to use when iterating the interval, defaults to `[days: 1]`

  The terms `left_open` and `right_open` come from the mathematical concept of intervals. You
  can see more detail on the theory [on Wikipedia](https://en.wikipedia.org/wiki/Interval_(mathematics)),
  but it can be more intuitively thought of like so:

  - An "open" bound is exclusive, and a "closed" bound is inclusive
  - So a left-closed interval includes the `from` value, and a left-open interval does not.
  - Likewise, a right-closed interval includes the `until` value, and a right-open interval does not.
  - An open interval is both left and right open, conversely, a closed interval is both left and right closed.

  **Note:** `until` shifts delegate to `Timex.shift`, so the options provided should match its valid options.

  ## Examples

      iex> use Timex
      ...> Interval.new(from: ~D[2014-09-22], until: ~D[2014-09-29])
      ...> |> Interval.format!("%Y-%m-%d", :strftime)
      "[2014-09-22, 2014-09-29)"

      iex> use Timex
      ...> Interval.new(from: ~D[2014-09-22], until: [days: 7])
      ...> |> Interval.format!("%Y-%m-%d", :strftime)
      "[2014-09-22, 2014-09-29)"

      iex> use Timex
      ...> Interval.new(from: ~D[2014-09-22], until: [days: 7], left_open: true, right_open: false)
      ...> |> Interval.format!("%Y-%m-%d", :strftime)
      "(2014-09-22, 2014-09-29]"

      iex> use Timex
      ...> Interval.new(from: ~N[2014-09-22T15:30:00], until: [minutes: 20], right_open: false)
      ...> |> Interval.format!("%H:%M", :strftime)
      "[15:30, 15:50]"

  """
  @spec new(Keyword.t()) ::
          t
          | {:error, :invalid_until}
          | {:error, :invalid_step}
  def new(options \\ []) do
    from =
      case Keyword.get(options, :from) do
        nil ->
          Timex.Protocol.NaiveDateTime.now()

        %NaiveDateTime{} = d ->
          d

        d ->
          Timex.to_naive_datetime(d)
      end

    left_open = Keyword.get(options, :left_open, false)
    right_open = Keyword.get(options, :right_open, true)
    step = Keyword.get(options, :step, days: 1)

    until =
      case Keyword.get(options, :until, days: 1) do
        {:error, _} = err ->
          err

        x when is_list(x) ->
          Timex.shift(from, x)

        %NaiveDateTime{} = d ->
          d

        d ->
          Timex.to_naive_datetime(d)
      end

    cond do
      invalid_step?(step) ->
        {:error, :invalid_step}

      invalid_until?(until) ->
        {:error, :invalid_until}

      Timex.compare(until, from) <= 0 ->
        {:error, :invalid_until}

      :else ->
        %__MODULE__{
          from: from,
          until: until,
          step: step,
          left_open: left_open,
          right_open: right_open
        }
    end
  end

  defp invalid_until?({:error, _}), do: true
  defp invalid_until?(_), do: false

  defp invalid_step?([]), do: false

  defp invalid_step?([{unit, n} | rest]) when unit in @valid_step_units and is_integer(n) do
    invalid_step?(rest)
  end

  defp invalid_step?(_), do: true

  @doc """
  Return the interval duration, given a unit.

  When the unit is one of `:seconds`, `:minutes`, `:hours`, `:days`, `:weeks`, `:months`, `:years`, the result is an `integer`.

  When the unit is `:duration`, the result is a `Duration` struct.

  ## Example

      iex> use Timex
      ...> Interval.new(from: ~D[2014-09-22], until: [months: 5])
      ...> |> Interval.duration(:months)
      5

      iex> use Timex
      ...> Interval.new(from: ~N[2014-09-22T15:30:00], until: [minutes: 20])
      ...> |> Interval.duration(:duration)
      Duration.from_minutes(20)

  """
  def duration(%__MODULE__{until: until, from: from}, :duration) do
    Timex.diff(until, from, :microseconds) |> Duration.from_microseconds()
  end

  def duration(%__MODULE__{until: until, from: from}, unit) do
    Timex.diff(until, from, unit)
  end

  @doc """
  Change the step value for the provided interval.

  The step should be a keyword list valid for use with `Timex.Date.shift`.

  ## Examples

      iex> use Timex
      ...> Interval.new(from: ~D[2014-09-22], until: [days: 3], right_open: true)
      ...> |> Interval.with_step([days: 1]) |> Enum.map(&Timex.format!(&1, "%Y-%m-%d", :strftime))
      ["2014-09-22", "2014-09-23", "2014-09-24"]

      iex> use Timex
      ...> Interval.new(from: ~D[2014-09-22], until: [days: 3], right_open: false)
      ...> |> Interval.with_step([days: 1]) |> Enum.map(&Timex.format!(&1, "%Y-%m-%d", :strftime))
      ["2014-09-22", "2014-09-23", "2014-09-24", "2014-09-25"]

      iex> use Timex
      ...> Interval.new(from: ~D[2014-09-22], until: [days: 3], right_open: false)
      ...> |> Interval.with_step([days: 2]) |> Enum.map(&Timex.format!(&1, "%Y-%m-%d", :strftime))
      ["2014-09-22", "2014-09-24"]

      iex> use Timex
      ...> Interval.new(from: ~D[2014-09-22], until: [days: 3], right_open: false)
      ...> |> Interval.with_step([days: 3]) |> Enum.map(&Timex.format!(&1, "%Y-%m-%d", :strftime))
      ["2014-09-22", "2014-09-25"]

  """
  @spec with_step(t, valid_interval_steps) :: t | {:error, :invalid_step}
  def with_step(%__MODULE__{} = interval, step) do
    if invalid_step?(step) do
      {:error, :invalid_step}
    else
      %__MODULE__{interval | step: step}
    end
  end

  @doc """
  Formats the interval as a human readable string.

  ## Examples

      iex> use Timex
      ...> Interval.new(from: ~D[2014-09-22], until: [days: 3])
      ...> |> Interval.format!("%Y-%m-%d %H:%M", :strftime)
      "[2014-09-22 00:00, 2014-09-25 00:00)"

      iex> use Timex
      ...> Interval.new(from: ~D[2014-09-22], until: [days: 3])
      ...> |> Interval.format!("%Y-%m-%d", :strftime)
      "[2014-09-22, 2014-09-25)"
  """
  def format(%__MODULE__{} = interval, format, formatter \\ nil) do
    case Timex.format(interval.from, format, formatter) do
      {:error, _} = err ->
        err

      {:ok, from} ->
        case Timex.format(interval.until, format, formatter) do
          {:error, _} = err ->
            err

          {:ok, until} ->
            lopen = if interval.left_open, do: "(", else: "["
            ropen = if interval.right_open, do: ")", else: "]"
            {:ok, "#{lopen}#{from}, #{until}#{ropen}"}
        end
    end
  end

  @doc """
  Same as `format/3`, but raises a `Timex.Interval.FormatError` on failure.
  """
  def format!(%__MODULE__{} = interval, format, formatter \\ nil) do
    case format(interval, format, formatter) do
      {:ok, str} ->
        str

      {:error, e} ->
        raise FormatError, message: "#{inspect(e)}"
    end
  end

  @doc """
  Returns true if the first interval includes every point in time the second includes.

  ## Examples

      iex> #{__MODULE__}.contains?(#{__MODULE__}.new(from: ~D[2018-01-01], until: ~D[2018-01-31]), #{
    __MODULE__
  }.new(from: ~D[2018-01-01], until: ~D[2018-01-30]))
      true

      iex> #{__MODULE__}.contains?(#{__MODULE__}.new(from: ~D[2018-01-01], until: ~D[2018-01-30]), #{
    __MODULE__
  }.new(from: ~D[2018-01-01], until: ~D[2018-01-31]))
      false

      iex> #{__MODULE__}.contains?(#{__MODULE__}.new(from: ~D[2018-01-01], until: ~D[2018-01-10]), #{
    __MODULE__
  }.new(from: ~D[2018-01-05], until: ~D[2018-01-15]))
      false
  """
  @spec contains?(__MODULE__.t(), __MODULE__.t()) :: boolean()
  def contains?(%__MODULE__{} = a, %__MODULE__{} = b) do
    Timex.compare(min(a), min(b)) <= 0 && Timex.compare(max(a), max(b)) >= 0
  end

  @doc """
  Returns true if the first interval shares any point(s) in time with the second.

  ## Examples

      iex> #{__MODULE__}.overlaps?(#{__MODULE__}.new(from: ~D[2016-03-04], until: [days: 1]), #{
    __MODULE__
  }.new(from: ~D[2016-03-03], until: [days: 3]))
      true

      iex> #{__MODULE__}.overlaps?(#{__MODULE__}.new(from: ~D[2016-03-07], until: [days: 1]), #{
    __MODULE__
  }.new(from: ~D[2016-03-03], until: [days: 3]))
      false
  """
  @spec overlaps?(__MODULE__.t(), __MODULE__.t()) :: boolean()
  def overlaps?(%__MODULE__{} = a, %__MODULE__{} = b) do
    cond do
      Timex.compare(max(a), min(b)) < 0 ->
        # a is completely before b
        false

      Timex.compare(max(b), min(a)) < 0 ->
        # b is completely before a
        false

      :else ->
        # a and b have overlapping elements
        true
    end
  end

  @doc """
  Removes one interval from another which may reduce, split, or
  eliminate the original interval. Returns a (possibly empty) list
  of intervals representing the remaining time.

  ## Graphs

  The following textual graphs show all the ways that the original interval and
  the removal can relate to each other, and the action that `difference/2` will
  take in each case.
  The original interval is drawn with `O`s and the removal interval with `X`s.

      # return original
      OO
          XX

      # trim end
      OOO
        XXX

      # trim end
      OOOOO
        XXX

      # split
      OOOOOOO
        XXX

      # eliminate
      OO
      XX

      # eliminate
      OO
      XXXX

      # trim beginning
      OOOO
      XX

      # eliminate
        OO
      XXXXXX

      # eliminate
          OO
      XXXXXX

      # trim beginning
        OOO
      XXX

      # return original
           OO
      XX

  ## Examples

      iex> #{__MODULE__}.difference(#{__MODULE__}.new(from: ~N[2018-01-01 02:00:00.000], until: ~N[2018-01-01 04:00:00.000]), #{
    __MODULE__
  }.new(from: ~N[2018-01-01 03:00:00.000], until: ~N[2018-01-01 05:00:00.000]))
      [%#{__MODULE__}{from: ~N[2018-01-01 02:00:00.000], left_open: false, right_open: true, step: [days: 1], until: ~N[2018-01-01 03:00:00.000]}]

      iex> #{__MODULE__}.difference(#{__MODULE__}.new(from: ~N[2018-01-01 01:00:00.000], until: ~N[2018-01-01 05:00:00.000]), #{
    __MODULE__
  }.new(from: ~N[2018-01-01 02:00:00.000], until: ~N[2018-01-01 03:00:00.000]))
      [%#{__MODULE__}{from: ~N[2018-01-01 01:00:00.000], left_open: false, right_open: true, step: [days: 1], until: ~N[2018-01-01 02:00:00.000]}, %#{
    __MODULE__
  }{from: ~N[2018-01-01 03:00:00.000], left_open: false, right_open: true, step: [days: 1], until: ~N[2018-01-01 05:00:00.000]}]

      iex> #{__MODULE__}.difference(#{__MODULE__}.new(from: ~N[2018-01-01 02:00:00.000], until: ~N[2018-01-01 04:00:00.000]), #{
    __MODULE__
  }.new(from: ~N[2018-01-01 01:00:00.000], until: ~N[2018-01-01 05:00:00.000]))
      []
  """
  @spec difference(__MODULE__.t(), __MODULE__.t()) :: [__MODULE__.t()]
  def difference(%__MODULE__{} = original, %__MODULE__{} = removal) do
    cond do
      contains?(removal, original) ->
        # eliminate
        []

      !overlaps?(removal, original) ->
        # return original
        [original]

      Timex.compare(min(removal), min(original)) <= 0 ->
        # trim start
        [Map.put(original, :from, Map.get(removal, :until))]

      Timex.compare(max(original), max(removal)) <= 0 ->
        # trim end
        [Map.put(original, :until, Map.get(removal, :from))]

      true ->
        # split
        part_before = Map.put(original, :until, Map.get(removal, :from))
        part_after = Map.put(original, :from, Map.get(removal, :until))
        [part_before, part_after]
    end
  end

  @doc false
  def min(interval)

  def min(%__MODULE__{from: from, left_open: false}), do: from

  def min(%__MODULE__{from: from, step: step}) do
    case Timex.shift(from, step) do
      {:error, {:unknown_shift_unit, unit}} ->
        raise FormatError, message: "Invalid step unit for interval: #{inspect(unit)}"

      d ->
        d
    end
  end

  @doc false
  def max(interval)

  def max(%__MODULE__{until: until, right_open: false}), do: until
  def max(%__MODULE__{until: until}), do: Timex.shift(until, microseconds: -1)

  defimpl Enumerable, for: Timex.Interval do
    alias Timex.Interval

    def reduce(%Interval{until: until, right_open: open?, step: step} = i, acc, fun) do
      do_reduce({Interval.min(i), until, open?, step}, acc, fun)
    end

    defp do_reduce(_state, {:halt, acc}, _fun),
      do: {:halted, acc}

    defp do_reduce(state, {:suspend, acc}, fun),
      do: {:suspended, acc, &do_reduce(state, &1, fun)}

    defp do_reduce({current_date, end_date, right_open, step}, {:cont, acc}, fun) do
      if has_interval_ended?(current_date, end_date, right_open) do
        {:done, acc}
      else
        case Timex.shift(current_date, step) do
          {:error, {:unknown_shift_unit, unit}} ->
            raise FormatError, message: "Invalid step unit for interval: #{inspect(unit)}"

          {:error, err} ->
            raise FormatError,
              message: "Failed to shift to next element in interval: #{inspect(err)}"

          next_date ->
            do_reduce({next_date, end_date, right_open, step}, fun.(current_date, acc), fun)
        end
      end
    end

    defp has_interval_ended?(current_date, end_date, _right_open = true),
      do: Timex.compare(current_date, end_date) >= 0

    defp has_interval_ended?(current_date, end_date, _right_open = false),
      do: Timex.compare(current_date, end_date) > 0

    def member?(%Interval{} = interval, value) do
      result =
        cond do
          before?(interval, value) ->
            false

          after?(interval, value) ->
            false

          :else ->
            true
        end

      {:ok, result}
    end

    defp before?(%Interval{from: from, left_open: true}, value),
      do: Timex.compare(value, from) <= 0

    defp before?(%Interval{from: from, left_open: false}, value),
      do: Timex.compare(value, from) < 0

    defp after?(%Interval{until: until, right_open: true}, value),
      do: Timex.compare(value, until) >= 0

    defp after?(%Interval{until: until, right_open: false}, value),
      do: Timex.compare(value, until) > 0

    def count(_interval) do
      {:error, __MODULE__}
    end

    def slice(_interval) do
      {:error, __MODULE__}
    end
  end
end