lib/datix.ex

defmodule Datix do
  @moduledoc """
  A date-time parser using `Calendar.strftime/3` format strings.
  """

  alias Datix.{FormatStringError, OptionError, ParseError}

  @type t :: %{
          optional(:am_pm) => :am | :pm,
          optional(:day) => pos_integer(),
          optional(:day_of_week) => pos_integer(),
          optional(:day_of_year) => pos_integer(),
          optional(:hour) => pos_integer(),
          optional(:hour_12) => pos_integer(),
          optional(:microsecond) => pos_integer(),
          optional(:minute) => pos_integer(),
          optional(:month) => pos_integer(),
          optional(:quarter) => pos_integer(),
          optional(:second) => pos_integer(),
          optional(:year) => pos_integer(),
          optional(:year_2_digit) => pos_integer(),
          optional(:zone_abbr) => String.t(),
          optional(:zone_offset) => integer()
        }

  @typedoc """
  An **opaque** type representing a compiled format.

  The struct representation is internal and could change in the future without notice.
  """
  @typedoc since: "0.2.0"
  @opaque compiled :: %__MODULE__{
            format: [
              {:exact, binary()}
              | {:modifier, {char(), padder :: char(), width :: non_neg_integer()}}
            ],
            original: String.t()
          }

  @doc false
  defstruct format: [], original: nil

  defimpl Inspect do
    def inspect(%@for{original: original}, opts) do
      Inspect.Algebra.concat(["Datix.compile!(", Inspect.BitString.inspect(original, opts), ")"])
    end
  end

  @doc """
  Parses a date-time string according to the given `format`.

  See the `Calendar.strftime/3` documentation for how to specify a format string.

  `format` can be a format string or, since v0.2.0 of the library,
  a **compiled format** as returned by `compile/1`.

  If parsing is successful, this function returns `{:ok, datix}` where `datix` is
  a map of type `t:t/0`. If you are looking for functions that return Elixir
  structs (such as `DateTime` and similar), see `Datix.DateTime`, `Datix.Date`,
  `Datix.Time`, and `Datix.NaiveDateTime`.

  If there's an error, this function returns `{:error, error}` where error
  is an *exception struct*. You can raise it manually with `raise/1`.

  ## Options

    * `:preferred_date` - a string for the preferred format to show dates,
      it can't contain the `%x` format and defaults to `"%Y-%m-%d"`
      if the option is not received

    *  `:month_names` - a list of the month names, if the option is not received
      it defaults to a list of month names in English

    * `:abbreviated_month_names` - a list of abbreviated month names, if the
      option is not received it defaults to a list of abbreviated month names in
      English

    * `:day_of_week_names` - a list of day names, if the option is not received
      it defaults to a list of day names in English

    * `:abbreviated_day_of_week_names` - a list of abbreviated day names, if the
      option is not received it defaults to a list of abbreviated day names in
      English

    * `:preferred_time` - a string for the preferred format to show times,
      it can't contain the `%X` format and defaults to `"%H:%M:%S"`
      if the option is not received

    * `:am_pm_names` - a keyword list with the names of the period of the day,
      defaults to `[am: "am", pm: "pm"]`.

  ## Examples

      iex> Datix.strptime("2021/01/10", "%Y/%m/%d")
      {:ok, %{day: 10, month: 1, year: 2021}}

      iex> Datix.strptime("21/01/10", "%y/%m/%d")
      {:ok, %{day: 10, month: 1, year_2_digit: 21}}

      iex> Datix.strptime("13/14/15", "%H/%M/%S")
      {:ok, %{hour: 13, minute: 14, second: 15}}

      iex> Datix.strptime("1 PM", "%-I %p")
      {:ok, %{am_pm: :pm, hour_12: 1}}

      iex> Datix.strptime("Tuesday", "%A")
      {:ok, %{day_of_week: 2}}

      iex> Datix.strptime("Tue", "%a")
      {:ok, %{day_of_week: 2}}

      iex> Datix.strptime("Di", "%a",
      ...>   abbreviated_day_of_week_names: ~w(Mo Di Mi Do Fr Sa So))
      {:ok, %{day_of_week: 2}}

      iex> compiled = Datix.compile!("%Y/%m/%d")
      iex> Datix.strptime("2021/01/10", compiled)
      {:ok, %{day: 10, month: 1, year: 2021}}

      iex> Datix.strptime("irrelevant", "%l")
      {:error, %Datix.FormatStringError{reason: {:invalid_modifier, "%l"}}}

  """
  @spec strptime(String.t(), String.t() | compiled(), keyword()) ::
          {:ok, Datix.t()} | {:error, ParseError.t() | FormatStringError.t() | OptionError.t()}
  def strptime(date_time_str, format, opts \\ [])

  def strptime(date_time_str, %__MODULE__{format: format}, opts) do
    with {:ok, options} <- options(opts) do
      case parse(format, date_time_str, options, %{}) do
        {:ok, result, ""} -> {:ok, result}
        {:ok, _result, _rest} -> {:error, %ParseError{reason: :invalid_input}}
        error -> error
      end
    end
  end

  def strptime(date_time_str, format_str, opts) when is_binary(format_str) do
    with {:ok, compiled} <- compile(format_str) do
      strptime(date_time_str, compiled, opts)
    end
  end

  @doc """
  Parses a date-time string according to the given `format`, erroring out for
  invalid arguments.
  """
  @spec strptime!(String.t(), String.t() | compiled(), keyword()) :: Datix.t()
  def strptime!(date_time_str, format, opts \\ []) do
    case strptime(date_time_str, format, opts) do
      {:ok, data} -> data
      {:error, reason} when is_exception(reason) -> raise reason
    end
  end

  @doc false
  @spec calendar(keyword()) :: module()
  def calendar(opts), do: Keyword.get(opts, :calendar, Calendar.ISO)

  @doc false
  def assume(data, Date) do
    case Map.has_key?(data, :year) || Map.has_key?(data, :year_2_digit) do
      true -> Map.merge(%{month: 1, day: 1}, data)
      false -> Map.merge(%{year: 0, month: 1, day: 1}, data)
    end
  end

  def assume(data, Time) do
    case Map.has_key?(data, :hour) || Map.has_key?(data, :hour_12) do
      true -> Map.merge(%{minute: 0, second: 0, microsecond: {0, 0}}, data)
      false -> Map.merge(%{hour: 0, minute: 0, second: 0, microsecond: {0, 0}}, data)
    end
  end

  @doc """
  Compiles the given `format` string.

  If the `format` string is a valid format string, then this function returns
  `{:ok, compiled}`. `compiled` is a term that represents a compiled format (its internal
  representation is private). You can pass a `t:compiled/0` term to `strptime/3` and
  such.

  If the `format` string is invalid, this function returns `{:error, reason}`, where
  `reason` is an *exception struct*.

  You can use this function for two reasons:

    * You have the same format string that you want to compile once and then use
      to parse over and over

    * You want to *validate* a format string

  """
  @doc since: "0.2.0"
  @spec compile(String.t()) :: {:ok, compiled()} | {:error, FormatStringError.t()}
  def compile(format) when is_binary(format) do
    case compile(format, _acc = []) do
      {:ok, compiled_format} -> {:ok, %__MODULE__{format: compiled_format, original: format}}
      {:error, reason} when is_exception(reason) -> {:error, reason}
    end
  end

  @doc """
  Like `compile/1`, but returns the compiled struct directly or raises in case of errors.

  ## Examples

      iex> Datix.compile!("%Y-%m-%d")
      Datix.compile!("%Y-%m-%d")

      iex> Datix.compile!("%l")
      ** (Datix.FormatStringError) invalid format string because of invalid modifier: %l

  """
  @doc since: "0.2.0"
  @spec compile!(String.t()) :: compiled()
  def compile!(format) do
    case compile(format) do
      {:ok, compiled} -> compiled
      {:error, reason} when is_exception(reason) -> raise reason
    end
  end

  defp compile("", acc), do: {:ok, Enum.reverse(acc)}

  defp compile("%" <> rest, acc) do
    with {:ok, modifier, rest} <- compile_modifier(rest, nil, nil) do
      compile(rest, [{:modifier, modifier} | acc])
    end
  end

  defp compile(<<_, _::binary>> = rest, acc) do
    {exact, rest} = take_until_modifier(rest, _acc = "")
    compile(rest, [{:exact, exact} | acc])
  end

  defp take_until_modifier(<<>> = rest, acc), do: {acc, rest}
  defp take_until_modifier(<<?%, _::binary>> = rest, acc), do: {acc, rest}

  defp take_until_modifier(<<char, rest::binary>>, acc),
    do: take_until_modifier(rest, <<acc::binary, char>>)

  defp compile_modifier("-" <> rest, _padding, nil = width) do
    compile_modifier(rest, _padding = "", width)
  end

  defp compile_modifier("_" <> rest, _padding, nil = width) do
    compile_modifier(rest, _padding = ?\s, width)
  end

  defp compile_modifier("0" <> rest, _padding, nil = width) do
    compile_modifier(rest, _padding = ?0, width)
  end

  defp compile_modifier(<<digit, rest::binary>>, padding, width) when digit in ?0..?9 do
    compile_modifier(rest, padding, (width || 0) * 10 + (digit - ?0))
  end

  defp compile_modifier(<<format, rest::binary>>, padding, width) do
    modifier = {format, padding || default_padding(format), width || default_width(format)}

    if format in 'aAbBpPcxXdHIjmMqSufzZyY%' do
      {:ok, modifier, rest}
    else
      {:error, %FormatStringError{reason: {:invalid_modifier, modifier_to_string(modifier)}}}
    end
  end

  defp parse([], date_time_rest, _opts, acc), do: {:ok, acc, date_time_rest}

  defp parse(_format, "", _opts, _acc), do: {:error, %ParseError{reason: :invalid_input}}

  defp parse([{:modifier, modifier} | format_rest], date_time_str, opts, acc) do
    with {:ok, new_acc, date_time_rest} <- parse_date_time(modifier, date_time_str, opts, acc) do
      parse(format_rest, date_time_rest, opts, new_acc)
    end
  end

  defp parse([{:exact, exact} | format_rest], date_time_str, opts, acc) do
    expected_size = byte_size(exact)

    case date_time_str do
      <<got::size(expected_size)-binary, date_time_rest::binary>> when got == exact ->
        parse(format_rest, date_time_rest, opts, acc)

      _other ->
        {:error, %ParseError{reason: {:expected_exact, exact, _got = date_time_str}}}
    end
  end

  defp parse_date_time({format, padding, _width} = modifier, date_time_str, opts, acc)
       when format in 'aAbBpP' do
    with {:ok, value, rest} <- parse_string(date_time_str, padding, enumeration(format, opts)),
         {:ok, new_acc} <- put(acc, format, value) do
      {:ok, new_acc, rest}
    else
      error -> error(error, modifier)
    end
  end

  defp parse_date_time({format, padding, width} = modifier, date_time_str, _opts, acc)
       when format in 'dHIjmMqSu' do
    with {:ok, value, rest} <-
           parse_pos_integer(date_time_str, padding, width, _exact_width? = false),
         {:ok, new_acc} <- put(acc, format, value) do
      {:ok, new_acc, rest}
    else
      error -> error(error, modifier)
    end
  end

  defp parse_date_time({format, padding, width} = modifier, date_time_str, _opts, acc)
       when format in 'yY' do
    exact_width? = format == ?Y

    with {:ok, value, rest} <- parse_integer(date_time_str, padding, width, exact_width?),
         {:ok, new_acc} <- put(acc, format, value) do
      {:ok, new_acc, rest}
    else
      error -> error(error, modifier)
    end
  end

  defp parse_date_time({?f, _padding, _width} = modifier, date_time_str, _opts, acc) do
    with {:ok, microsecond, rest} <- parse_pos_integer(date_time_str),
         {:ok, new_acc} <- put(acc, :microsecond, microsecond) do
      {:ok, new_acc, rest}
    else
      error -> error(error, modifier)
    end
  end

  defp parse_date_time({?z, padding, width} = modifier, date_time_str, _opts, acc) do
    with {:ok, zone_offset, rest} <-
           parse_signed_integer(date_time_str, padding, width, _exact_width? = true),
         {:ok, new_acc} <- put(acc, :zone_offset, zone_offset(zone_offset)) do
      {:ok, new_acc, rest}
    else
      error -> error(error, modifier)
    end
  end

  defp parse_date_time({?Z, padding, _width} = modifier, date_time_str, _opts, acc) do
    with {:ok, zone_abbr, rest} <- parse_upcase_string(date_time_str, padding),
         {:ok, new_acc} <- put(acc, :zone_abbr, zone_abbr) do
      {:ok, new_acc, rest}
    else
      error -> error(error, modifier)
    end
  end

  defp parse_date_time(
         {format, _padding, _width} = modifier,
         _date_time_str,
         %{preferred: format},
         _acc
       ) do
    {:error, {:cycle, modifier_to_string(modifier)}}
  end

  defp parse_date_time({format, _padding, _width}, date_time_str, opts, acc)
       when format in 'cxX' do
    {:ok, %__MODULE__{format: compiled_format}} = compile(preferred_format(format, opts))
    parse(compiled_format, date_time_str, Map.put(opts, :preferred, format), acc)
  end

  defp parse_date_time({?%, _padding, _width}, "%" <> date_time_rest, _opts, acc) do
    {:ok, acc, date_time_rest}
  end

  defp parse_date_time({?%, _padding, _width}, _date_time_rest, _opts, _acc) do
    {:error, %ParseError{reason: :invalid_string, modifier: "%%"}}
  end

  defp parse_date_time(modifier, _date_time_str, _opts, _acc) do
    {:error, %FormatStringError{reason: {:invalid_modifier, modifier_to_string(modifier)}}}
  end

  defp parse_integer(str, padding, width, exact_width?, int \\ nil)

  defp parse_integer("-" <> int_str, padding, width, exact_width?, nil) do
    with {:ok, int, rest} <- parse_pos_integer(int_str, padding, width, exact_width?, nil) do
      {:ok, int * -1, rest}
    end
  end

  defp parse_integer(int_str, padding, width, exact_width?, nil) do
    parse_pos_integer(int_str, padding, width, exact_width?, nil)
  end

  defp parse_pos_integer(str) do
    case Integer.parse(str) do
      {int, rest} -> {:ok, int, rest}
      :error -> {:error, %ParseError{reason: :invalid_integer}}
    end
  end

  defp parse_pos_integer(str, padding, max_width, exact_width?, int \\ nil)

  defp parse_pos_integer(rest, _padding, 0 = _width, _exact_width?, int) do
    {:ok, int || 0, rest}
  end

  defp parse_pos_integer(<<digit, rest::binary>>, "" = padding, width, exact_width?, int)
       when digit in ?0..?9 do
    parse_pos_integer(rest, padding, width - 1, exact_width?, (int || 0) * 10 + (digit - ?0))
  end

  defp parse_pos_integer(rest, "" = _padding, _width, _exact_width?, int), do: {:ok, int, rest}

  defp parse_pos_integer(<<padding, rest::binary>>, padding, width, exact_width?, nil = acc) do
    parse_pos_integer(rest, padding, width - 1, exact_width?, acc)
  end

  defp parse_pos_integer(<<digit, rest::binary>>, padding, width, exact_width?, int)
       when digit in ?0..?9 do
    parse_pos_integer(rest, padding, width - 1, exact_width?, (int || 0) * 10 + (digit - ?0))
  end

  # If no integer was parsed yet when we get to a non-digit, then there was no integer,
  # so we return an error.
  defp parse_pos_integer(_str, _padding, _width, _exact_width?, nil),
    do: {:error, %ParseError{reason: :invalid_integer}}

  # If an integer was parsed then we can return it even if we have some "width" left,
  # since the width represents the maximum width.
  defp parse_pos_integer(str, _padding, _width_left, false = _exact_width?, int),
    do: {:ok, int, str}

  defp parse_pos_integer(_str, _padding, _width_left, true = _exact_width?, _int),
    do: {:error, %ParseError{reason: :invalid_integer}}

  defp parse_signed_integer("-" <> str, padding, width, exact_width?) do
    with {:ok, value, rest} <- parse_pos_integer(str, padding, width, exact_width?) do
      {:ok, value * -1, rest}
    end
  end

  defp parse_signed_integer("+" <> str, padding, width, exact_width?),
    do: parse_pos_integer(str, padding, width, exact_width?)

  defp parse_signed_integer(_str, _padding, _width, _exact_width?),
    do: {:error, %ParseError{reason: :invalid_integer}}

  defp parse_string(str, padding, list, pos \\ 0)

  defp parse_string(<<padding, rest::binary>>, padding, list, 0 = pos) do
    parse_string(rest, padding, list, pos)
  end

  defp parse_string(_str, _padding, [], _pos), do: {:error, %ParseError{reason: :invalid_string}}

  defp parse_string(str, padding, [item | list], pos) do
    case String.starts_with?(str, item) do
      false -> parse_string(str, padding, list, pos + 1)
      true -> {:ok, pos + 1, String.slice(str, String.length(item)..-1)}
    end
  end

  defp parse_upcase_string(str, padding, acc \\ [])

  defp parse_upcase_string(<<padding, rest::binary>>, padding, [] = acc) do
    parse_upcase_string(rest, padding, acc)
  end

  defp parse_upcase_string(<<char, rest::binary>>, padding, acc) when char in ?A..?Z do
    parse_upcase_string(rest, padding, [char | acc])
  end

  defp parse_upcase_string(_rest, _padding, []),
    do: {:error, %ParseError{reason: :invalid_string}}

  defp parse_upcase_string(rest, _padding, acc) do
    {:ok, acc |> Enum.reverse() |> IO.iodata_to_binary(), rest}
  end

  defp modifier_to_string({format, padding, width}) do
    IO.iodata_to_binary([
      "%",
      padding_to_string(padding, format),
      width_to_string(width, format),
      format
    ])
  end

  defp padding_to_string(padding, format) do
    case padding == default_padding(format) do
      true -> ""
      false -> padding
    end
  end

  defp width_to_string(width, format) do
    case width == default_width(format) do
      true -> ""
      false -> to_string(width)
    end
  end

  defp error({:error, %ParseError{} = error}, modifier) do
    {:error, %ParseError{error | modifier: modifier_to_string(modifier)}}
  end

  defp zone_offset(value) do
    hour = div(value, 100)
    minute = rem(value, 100)
    hour * 3600 + minute * 60
  end

  defp default_padding(format) when format in 'aAbBpPZ', do: ?\s
  defp default_padding(_format), do: ?0

  defp default_width(format) when format in 'Yz', do: 4
  defp default_width(?j), do: 3
  defp default_width(format) when format in 'dHImMSy', do: 2
  defp default_width(format) when format in 'qu', do: 1
  defp default_width(_format), do: 0

  defp put(acc, key, value) when is_atom(key) do
    case Map.fetch(acc, key) do
      {:ok, ^value} -> {:ok, acc}
      {:ok, expected} -> {:error, %ParseError{reason: {:conflict, expected, value}}}
      :error -> {:ok, Map.put(acc, key, value)}
    end
  end

  defp put(acc, format, 1) when format in 'pP', do: put(acc, :am_pm, :am)
  defp put(acc, format, 2) when format in 'pP', do: put(acc, :am_pm, :pm)
  defp put(acc, format, value), do: put(acc, key(format), value)

  defp key(format) when format in 'aA', do: :day_of_week
  defp key(format) when format in 'bB', do: :month
  defp key(?d), do: :day
  defp key(?H), do: :hour
  defp key(?I), do: :hour_12
  defp key(?j), do: :day_of_year
  defp key(?m), do: :month
  defp key(?M), do: :minute
  defp key(?y), do: :year_2_digit
  defp key(?Y), do: :year
  defp key(?q), do: :quarter
  defp key(?S), do: :second
  defp key(?u), do: :day_of_week

  defp preferred_format(?c, opts), do: opts.preferred_datetime
  defp preferred_format(?x, opts), do: opts.preferred_date
  defp preferred_format(?X, opts), do: opts.preferred_time

  defp enumeration(?a, opts), do: opts.abbreviated_day_of_week_names
  defp enumeration(?A, opts), do: opts.day_of_week_names
  defp enumeration(?b, opts), do: opts.abbreviated_month_names
  defp enumeration(?B, opts), do: opts.month_names

  defp enumeration(?p, opts),
    do: [String.upcase(opts.am_pm_names[:am]), String.upcase(opts.am_pm_names[:pm])]

  defp enumeration(?P, opts),
    do: [String.downcase(opts.am_pm_names[:am]), String.downcase(opts.am_pm_names[:pm])]

  defp options(opts) do
    defaults = %{
      preferred_date: "%Y-%m-%d",
      preferred_time: "%H:%M:%S",
      preferred_datetime: "%Y-%m-%d %H:%M:%S",
      am_pm_names: [am: "am", pm: "pm"],
      month_names: [
        "January",
        "February",
        "March",
        "April",
        "May",
        "June",
        "July",
        "August",
        "September",
        "October",
        "November",
        "December"
      ],
      day_of_week_names: [
        "Monday",
        "Tuesday",
        "Wednesday",
        "Thursday",
        "Friday",
        "Saturday",
        "Sunday"
      ],
      abbreviated_month_names: [
        "Jan",
        "Feb",
        "Mar",
        "Apr",
        "May",
        "Jun",
        "Jul",
        "Aug",
        "Sep",
        "Oct",
        "Nov",
        "Dec"
      ],
      abbreviated_day_of_week_names: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
    }

    extra_allowed_keys = [:pivot_year, :time_zone]

    opts
    |> Keyword.delete(:calendar)
    |> Enum.reduce_while({:ok, defaults}, fn {key, value}, {:ok, acc} ->
      cond do
        Map.has_key?(acc, key) -> {:cont, {:ok, %{acc | key => value}}}
        key in extra_allowed_keys -> {:cont, {:ok, acc}}
        true -> {:halt, {:error, %OptionError{reason: :unknown, option: key}}}
      end
    end)
  end
end