lib/format/datetime/formatter.ex

defmodule Timex.Format.DateTime.Formatter do
  @moduledoc """
  This module defines the behaviour for custom DateTime formatters.
  """

  alias Timex.{Timezone, Translator, Types}
  alias Timex.Translator
  alias Timex.Format.FormatError
  alias Timex.Format.DateTime.Formatters.{Default, Strftime, Relative}
  alias Timex.Parse.DateTime.Tokenizers.Directive

  @callback tokenize(format_string :: String.t()) ::
              {:ok, [Directive.t()]} | {:error, term}
  @callback format(date :: Types.calendar_types(), format_string :: String.t()) ::
              {:ok, String.t()} | {:error, term}
  @callback format!(date :: Types.calendar_types(), format_string :: String.t()) ::
              String.t() | no_return
  @callback lformat(
              date :: Types.calendar_types(),
              format_string :: String.t(),
              locale :: String.t()
            ) ::
              {:ok, String.t()} | {:error, term}
  @callback lformat!(
              date :: Types.calendar_types(),
              format_string :: String.t(),
              locale :: String.t()
            ) ::
              String.t() | no_return

  @doc false
  defmacro __using__(_opts) do
    quote do
      @behaviour Timex.Format.DateTime.Formatter

      alias Timex.Parse.DateTime.Tokenizers.Directive
      import Timex.Format.DateTime.Formatter, only: [format_token: 5, format_token: 6]
    end
  end

  @doc """
  Formats a Date, DateTime, or NaiveDateTime as a string, using the provided format string,
  locale, and formatter. If the locale does not have translations, "en" will be used by
  default.

  If a formatter is not provided, the formatter used is `Timex.Format.DateTime.Formatters.DefaultFormatter`

  If an error is encountered during formatting, `lformat!` will raise
  """
  @spec lformat!(Types.valid_datetime(), String.t(), String.t(), atom | nil) ::
          String.t() | no_return
  def lformat!(date, format_string, locale, formatter \\ Default)

  def lformat!({:error, reason}, _format_string, _locale, _formatter),
    do: raise(ArgumentError, to_string(reason))

  def lformat!(date, format_string, locale, formatter) do
    with {:ok, formatted} <- lformat(date, format_string, locale, formatter) do
      formatted
    else
      {:error, :invalid_date} ->
        raise ArgumentError, "invalid_date"

      {:error, {:format, reason}} ->
        raise FormatError, message: to_string(reason)

      {:error, reason} ->
        raise FormatError, message: to_string(reason)
    end
  end

  @doc """
  Formats a Date, DateTime, or NaiveDateTime as a string, using the provided format string,
  locale, and formatter.

  If the locale provided does not have translations, "en" is used by default.

  If a formatter is not provided, the formatter used is `Timex.Format.DateTime.Formatters.DefaultFormatter`
  """
  @spec lformat(Types.valid_datetime(), String.t(), String.t(), atom | nil) ::
          {:ok, String.t()} | {:error, term}
  def lformat(date, format_string, locale, formatter \\ Default)

  def lformat({:error, _} = err, _format_string, _locale, _formatter),
    do: err

  def lformat(datetime, format_string, locale, :strftime),
    do: lformat(datetime, format_string, locale, Strftime)

  def lformat(datetime, format_string, locale, :relative),
    do: lformat(datetime, format_string, locale, Relative)

  def lformat(%{__struct__: struct} = date, format_string, locale, formatter)
      when struct in [Date, DateTime, NaiveDateTime, Time] and is_binary(format_string) and
             is_binary(locale) and is_atom(formatter) do
    formatter.lformat(date, format_string, locale)
  end

  def lformat(date, format_string, locale, formatter)
      when is_binary(format_string) and is_binary(locale) and is_atom(formatter) do
    with %NaiveDateTime{} = datetime <- Timex.to_naive_datetime(date) do
      formatter.lformat(datetime, format_string, locale)
    end
  end

  @doc """
  Formats a Date, DateTime, or NaiveDateTime as a string, using the provided format
  string and formatter. If a formatter is not provided, the formatter
  used is `Timex.Format.DateTime.Formatters.DefaultFormatter`.

  Formatting will use the configured default locale, "en" if no other default is given.

  If an error is encountered during formatting, `format!` will raise.
  """
  @spec format!(Types.valid_datetime(), String.t(), atom | nil) :: String.t() | no_return
  def format!(date, format_string, formatter \\ Default)

  def format!(date, format_string, formatter),
    do: lformat!(date, format_string, Translator.current_locale(), formatter)

  @doc """
  Formats a Date, DateTime, or NaiveDateTime as a string, using the provided format
  string and formatter. If a formatter is not provided, the formatter
  used is `Timex.Format.DateTime.Formatters.DefaultFormatter`.

  Formatting will use the configured default locale, "en" if no other default is given.
  """
  @spec format(Types.valid_datetime(), String.t(), atom | nil) ::
          {:ok, String.t()} | {:error, term}
  def format(date, format_string, formatter \\ Default)

  def format(datetime, format_string, :strftime),
    do: lformat(datetime, format_string, Translator.current_locale(), Strftime)

  def format(datetime, format_string, :relative),
    do: lformat(datetime, format_string, Translator.current_locale(), Relative)

  def format(datetime, format_string, formatter),
    do: lformat(datetime, format_string, Translator.current_locale(), formatter)

  @doc """
  Validates the provided format string, using the provided formatter,
  or if none is provided, the default formatter. Returns `:ok` when valid,
  or `{:error, reason}` if not valid.
  """
  @spec validate(String.t(), atom | nil) :: :ok | {:error, term}
  def validate(format_string, formatter \\ Default)

  def validate(format_string, formatter) when is_binary(format_string) and is_atom(formatter) do
    formatter =
      case formatter do
        :strftime -> Strftime
        :relative -> Relative
        _ -> formatter
      end

    case formatter.tokenize(format_string) do
      {:error, _} = error ->
        error

      {:ok, []} ->
        {:error, "There were no formatting directives in the provided string."}

      {:ok, directives} when is_list(directives) ->
        :ok
    end
  end

  @doc """
  Given a token (as found in `Timex.Parsers.Directive`), and a Date, DateTime, or NaiveDateTime struct,
  produce a string representation of the token using values from the struct, using the default locale.
  """
  @spec format_token(atom, Types.calendar_types(), list(), list(), list()) ::
          String.t() | {:error, term}
  def format_token(token, date, modifiers, flags, width) do
    format_token(Translator.current_locale(), token, date, modifiers, flags, width)
  end

  @doc """
  Given a token (as found in `Timex.Parsers.Directive`), and a Date, DateTime, or NaiveDateTime struct,
  produce a string representation of the token using values from the struct.
  """
  @spec format_token(String.t(), atom, Types.calendar_types(), list(), list(), list()) ::
          String.t() | {:error, term}
  def format_token(locale, token, date, modifiers, flags, width)

  # Formats
  def format_token(locale, :iso_date, date, modifiers, _flags, _width) do
    flags = [padding: :zeroes]
    year = format_token(locale, :year4, date, modifiers, flags, width_spec(4..4))
    month = format_token(locale, :month, date, modifiers, flags, width_spec(2..2))
    day = format_token(locale, :day, date, modifiers, flags, width_spec(2..2))
    "#{year}-#{month}-#{day}"
  end

  def format_token(locale, :iso_time, date, modifiers, _flags, _width) do
    flags = [padding: :zeroes]
    hour = format_token(locale, :hour24, date, modifiers, flags, width_spec(2..2))
    minute = format_token(locale, :min, date, modifiers, flags, width_spec(2..2))
    sec = format_token(locale, :sec, date, modifiers, flags, width_spec(2..2))
    ms = format_token(locale, :sec_fractional, date, modifiers, flags, width_spec(-1, nil))
    "#{hour}:#{minute}:#{sec}#{ms}"
  end

  def format_token(locale, token, date, modifiers, _flags, _width)
      when token in [:iso_8601_extended, :iso_8601_extended_z] do
    date =
      case token do
        :iso_8601_extended -> date
        :iso_8601_extended_z -> Timezone.convert(date, "UTC")
      end

    flags = [padding: :zeroes]
    year = format_token(locale, :year4, date, modifiers, flags, width_spec(4..4))
    month = format_token(locale, :month, date, modifiers, flags, width_spec(2..2))
    day = format_token(locale, :day, date, modifiers, flags, width_spec(2..2))
    hour = format_token(locale, :hour24, date, modifiers, flags, width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, flags, width_spec(2..2))
    sec = format_token(locale, :sec, date, modifiers, flags, width_spec(2..2))
    ms = format_token(locale, :sec_fractional, date, modifiers, flags, width_spec(-1, nil))

    case token do
      :iso_8601_extended ->
        case format_token(locale, :zoffs_colon, date, modifiers, flags, width_spec(-1, nil)) do
          "" ->
            {:error, {:missing_timezone_information, date}}

          tz ->
            "#{year}-#{month}-#{day}T#{hour}:#{min}:#{sec}#{ms}#{tz}"
        end

      :iso_8601_extended_z ->
        "#{year}-#{month}-#{day}T#{hour}:#{min}:#{sec}#{ms}Z"
    end
  end

  def format_token(locale, token, date, modifiers, _flags, _width)
      when token in [:iso_8601_basic, :iso_8601_basic_z] do
    date =
      case token do
        :iso_8601_basic -> date
        :iso_8601_basic_z -> Timezone.convert(date, "UTC")
      end

    flags = [padding: :zeroes]
    year = format_token(locale, :year4, date, modifiers, flags, width_spec(4..4))
    month = format_token(locale, :month, date, modifiers, flags, width_spec(2..2))
    day = format_token(locale, :day, date, modifiers, flags, width_spec(2..2))
    hour = format_token(locale, :hour24, date, modifiers, flags, width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, flags, width_spec(2..2))
    sec = format_token(locale, :sec, date, modifiers, flags, width_spec(2..2))
    ms = format_token(locale, :sec_fractional, date, modifiers, flags, width_spec(-1, nil))

    case token do
      :iso_8601_basic ->
        case format_token(locale, :zoffs, date, modifiers, flags, width_spec(-1, nil)) do
          "" ->
            {:error, {:missing_timezone_information, date}}

          tz ->
            "#{year}#{month}#{day}T#{hour}#{min}#{sec}#{ms}#{tz}"
        end

      :iso_8601_basic_z ->
        "#{year}#{month}#{day}T#{hour}#{min}#{sec}#{ms}Z"
    end
  end

  def format_token(locale, token, date, modifiers, _flags, _width)
      when token in [:rfc_822, :rfc_822z] do
    # Mon, 05 Jun 14 23:20:59 +0200
    date =
      case token do
        :rfc_822 -> date
        :rfc_822z -> Timezone.convert(date, "UTC")
      end

    flags = [padding: :zeroes]
    year = format_token(locale, :year2, date, modifiers, flags, width_spec(2..2))
    month = format_token(locale, :mshort, date, modifiers, flags, width_spec(-1, nil))
    day = format_token(locale, :day, date, modifiers, flags, width_spec(2..2))
    hour = format_token(locale, :hour24, date, modifiers, flags, width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, flags, width_spec(2..2))
    sec = format_token(locale, :sec, date, modifiers, flags, width_spec(2..2))
    wday = format_token(locale, :wdshort, date, modifiers, flags, width_spec(-1, nil))

    case token do
      :rfc_822 ->
        case format_token(locale, :zoffs, date, modifiers, flags, width_spec(-1, nil)) do
          "" ->
            {:error, {:missing_timezone_information, date}}

          tz ->
            "#{wday}, #{day} #{month} #{year} #{hour}:#{min}:#{sec} #{tz}"
        end

      :rfc_822z ->
        "#{wday}, #{day} #{month} #{year} #{hour}:#{min}:#{sec} Z"
    end
  end

  def format_token(locale, token, date, modifiers, _flags, _width)
      when token in [:rfc_1123, :rfc_1123z] do
    # `Tue, 05 Mar 2013 23:25:19 GMT`
    date =
      case token do
        :rfc_1123 -> date
        :rfc_1123z -> Timezone.convert(date, "UTC")
      end

    flags = [padding: :zeroes]
    year = format_token(locale, :year4, date, modifiers, flags, width_spec(4..4))
    month = format_token(locale, :mshort, date, modifiers, flags, width_spec(-1, nil))
    day = format_token(locale, :day, date, modifiers, flags, width_spec(2..2))
    hour = format_token(locale, :hour24, date, modifiers, flags, width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, flags, width_spec(2..2))
    sec = format_token(locale, :sec, date, modifiers, flags, width_spec(2..2))
    wday = format_token(locale, :wdshort, date, modifiers, flags, width_spec(-1, nil))

    case token do
      :rfc_1123 ->
        case format_token(locale, :zoffs, date, modifiers, flags, width_spec(-1, nil)) do
          "" ->
            {:error, {:missing_timezone_information, date}}

          tz ->
            "#{wday}, #{day} #{month} #{year} #{hour}:#{min}:#{sec} #{tz}"
        end

      :rfc_1123z ->
        "#{wday}, #{day} #{month} #{year} #{hour}:#{min}:#{sec} Z"
    end
  end

  def format_token(locale, token, date, modifiers, _flags, _width)
      when token in [:rfc_3339, :rfc_3339z] do
    # `2013-03-05T23:25:19+02:00`
    date =
      case token do
        :rfc_3339 -> date
        :rfc_3339z -> Timezone.convert(date, "UTC")
      end

    flags = [padding: :zeroes]
    year = format_token(locale, :year4, date, modifiers, flags, width_spec(4..4))
    month = format_token(locale, :month, date, modifiers, flags, width_spec(2..2))
    day = format_token(locale, :day, date, modifiers, flags, width_spec(2..2))
    hour = format_token(locale, :hour24, date, modifiers, flags, width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, flags, width_spec(2..2))
    sec = format_token(locale, :sec, date, modifiers, flags, width_spec(2..2))
    ms = format_token(locale, :sec_fractional, date, modifiers, flags, width_spec(-1, nil))

    case token do
      :rfc_3339 ->
        case format_token(locale, :zoffs_colon, date, modifiers, flags, width_spec(-1, nil)) do
          "" ->
            {:error, {:missing_timezone_information, date}}

          tz ->
            "#{year}-#{month}-#{day}T#{hour}:#{min}:#{sec}#{ms}#{tz}"
        end

      :rfc_3339z ->
        "#{year}-#{month}-#{day}T#{hour}:#{min}:#{sec}#{ms}Z"
    end
  end

  def format_token(locale, :unix, date, modifiers, _flags, _width) do
    # Tue Mar  5 23:25:19 PST 2013`
    flags = [padding: :zeroes]
    year = format_token(locale, :year4, date, modifiers, [padding: :spaces], width_spec(4..4))
    month = format_token(locale, :mshort, date, modifiers, flags, width_spec(-1, nil))
    day = format_token(locale, :day, date, modifiers, [padding: :spaces], width_spec(2..2))
    hour = format_token(locale, :hour24, date, modifiers, [padding: :zeroes], width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, [padding: :zeroes], width_spec(2..2))
    sec = format_token(locale, :sec, date, modifiers, [padding: :zeroes], width_spec(2..2))
    wday = format_token(locale, :wdshort, date, modifiers, flags, width_spec(-1, nil))
    tz = format_token(locale, :zabbr, date, modifiers, flags, width_spec(-1, nil))
    "#{wday} #{month} #{day} #{hour}:#{min}:#{sec} #{tz} #{year}"
  end

  def format_token(locale, :ansic, date, modifiers, flags, _width) do
    # Tue Mar  5 23:25:19 2013`
    year = format_token(locale, :year4, date, modifiers, [padding: :spaces], width_spec(4..4))
    month = format_token(locale, :mshort, date, modifiers, flags, width_spec(-1, nil))
    day = format_token(locale, :day, date, modifiers, [padding: :spaces], width_spec(2..2))
    hour = format_token(locale, :hour24, date, modifiers, [padding: :zeroes], width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, [padding: :zeroes], width_spec(2..2))
    sec = format_token(locale, :sec, date, modifiers, [padding: :zeroes], width_spec(2..2))
    wday = format_token(locale, :wdshort, date, modifiers, flags, width_spec(-1, nil))
    "#{wday} #{month} #{day} #{hour}:#{min}:#{sec} #{year}"
  end

  def format_token(locale, :asn1_utc_time, date, modifiers, _flags, _width) do
    # `130305232519Z`
    date = Timezone.convert(date, "UTC")
    flags = [padding: :zeroes]
    year = format_token(locale, :year2, date, modifiers, flags, width_spec(2..2))
    month = format_token(locale, :month, date, modifiers, flags, width_spec(2..2))
    day = format_token(locale, :day, date, modifiers, flags, width_spec(2..2))
    hour = format_token(locale, :hour24, date, modifiers, flags, width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, flags, width_spec(2..2))
    sec = format_token(locale, :sec, date, modifiers, flags, width_spec(2..2))
    "#{year}#{month}#{day}#{hour}#{min}#{sec}Z"
  end

  def format_token(locale, :asn1_generalized_time, date, modifiers, _flags, _width) do
    # `130305232519`
    flags = [padding: :zeroes]
    year = format_token(locale, :year4, date, modifiers, flags, width_spec(4..4))
    month = format_token(locale, :month, date, modifiers, flags, width_spec(2..2))
    day = format_token(locale, :day, date, modifiers, flags, width_spec(2..2))
    hour = format_token(locale, :hour24, date, modifiers, flags, width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, flags, width_spec(2..2))
    sec = format_token(locale, :sec, date, modifiers, flags, width_spec(2..2))
    ms = format_token(locale, :sec_fractional, date, modifiers, flags, width_spec(-1, nil))
    "#{year}#{month}#{day}#{hour}#{min}#{sec}#{ms}"
  end

  def format_token(locale, :asn1_generalized_time_z, date, modifiers, flags, width) do
    # `130305232519Z`
    date = Timezone.convert(date, "UTC")
    base = format_token(locale, :asn1_generalized_time, date, modifiers, flags, width)
    base <> "Z"
  end

  def format_token(locale, :asn1_generalized_time_tz, date, modifiers, flags, width) do
    # `130305232519-0500`
    offset = format_token(locale, :zoffs, date, modifiers, flags, width)
    base = format_token(locale, :asn1_generalized_time, date, modifiers, flags, width)
    base <> offset
  end

  def format_token(locale, :kitchen, date, modifiers, _flags, _width) do
    # `3:25PM`
    hour = format_token(locale, :hour12, date, modifiers, [], width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, [padding: :zeroes], width_spec(2..2))
    ampm = format_token(locale, :AM, date, modifiers, [], width_spec(-1, nil))
    "#{hour}:#{min}#{ampm}"
  end

  def format_token(locale, :slashed, date, modifiers, _flags, _width) do
    # `04/12/1987`
    flags = [padding: :zeroes]
    year = format_token(locale, :year2, date, modifiers, flags, width_spec(2..2))
    month = format_token(locale, :month, date, modifiers, flags, width_spec(2..2))
    day = format_token(locale, :day, date, modifiers, flags, width_spec(2..2))
    "#{month}/#{day}/#{year}"
  end

  def format_token(locale, token, date, modifiers, _flags, _width)
      when token in [:strftime_iso_clock, :strftime_iso_clock_full] do
    # `23:30:05`
    flags = [padding: :zeroes]
    hour = format_token(locale, :hour24, date, modifiers, flags, width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, flags, width_spec(2..2))

    case token do
      :strftime_iso_clock ->
        "#{hour}:#{min}"

      :strftime_iso_clock_full ->
        sec = format_token(locale, :sec, date, modifiers, flags, width_spec(2..2))
        "#{hour}:#{min}:#{sec}"
    end
  end

  def format_token(locale, :strftime_kitchen, date, modifiers, _flags, _width) do
    # `04:30:01 PM`
    hour = format_token(locale, :hour12, date, modifiers, [padding: :zeroes], width_spec(2..2))
    min = format_token(locale, :min, date, modifiers, [padding: :zeroes], width_spec(2..2))
    sec = format_token(locale, :sec, date, modifiers, [padding: :zeroes], width_spec(2..2))
    ampm = format_token(locale, :AM, date, modifiers, [], width_spec(-1, nil))
    "#{hour}:#{min}:#{sec} #{ampm}"
  end

  def format_token(locale, :strftime_iso_shortdate, date, modifiers, _flags, _width) do
    # ` 5-Jan-2014`
    flags = [padding: :zeroes]
    year = format_token(locale, :year4, date, modifiers, flags, width_spec(4..4))
    month = format_token(locale, :mshort, date, modifiers, flags, width_spec(-1, nil))
    day = format_token(locale, :day, date, modifiers, [padding: :spaces], width_spec(2..2))
    "#{day}-#{month}-#{year}"
  end

  def format_token(locale, :iso_week, date, modifiers, _flags, _width) do
    # 2015-W04
    flags = [padding: :zeroes]
    year = format_token(locale, :iso_year4, date, modifiers, flags, width_spec(4..4))
    week = format_token(locale, :iso_weeknum, date, modifiers, flags, width_spec(2..2))
    "#{year}-W#{week}"
  end

  def format_token(locale, :iso_weekday, date, modifiers, _flags, _width) do
    # 2015-W04-1
    flags = [padding: :zeroes]
    year = format_token(locale, :iso_year4, date, modifiers, flags, width_spec(4..4))
    week = format_token(locale, :iso_weeknum, date, modifiers, flags, width_spec(2..2))
    day = format_token(locale, :wday_mon, date, modifiers, flags, width_spec(1, 1))
    "#{year}-W#{week}-#{day}"
  end

  def format_token(locale, :iso_ordinal, date, modifiers, _flags, _width) do
    # 2015-180
    flags = [padding: :zeroes]
    year = format_token(locale, :year4, date, modifiers, flags, width_spec(4..4))
    day = format_token(locale, :oday, date, modifiers, flags, width_spec(3..3))
    "#{year}-#{day}"
  end

  # Years
  def format_token(_locale, :year4, date, _modifiers, flags, width),
    do: pad_numeric(date.year, flags, width)

  def format_token(_locale, :year2, date, _modifiers, flags, width),
    do: pad_numeric(rem(date.year, 100), flags, width)

  def format_token(_locale, :century, date, _modifiers, flags, width),
    do: pad_numeric(div(date.year, 100), flags, width)

  def format_token(_locale, :iso_year4, date, _modifiers, flags, width) do
    {iso_year, _} = Timex.iso_week(date)
    pad_numeric(iso_year, flags, width)
  end

  def format_token(_locale, :iso_year2, date, _modifiers, flags, width) do
    {iso_year, _} = Timex.iso_week(date)
    pad_numeric(rem(iso_year, 100), flags, width)
  end

  # Months
  def format_token(_locale, :month, date, _modifiers, flags, width),
    do: pad_numeric(date.month, flags, width)

  def format_token(locale, :mshort, date, _, _, _) do
    months = Translator.get_months_abbreviated(locale)
    Map.get(months, date.month)
  end

  def format_token(locale, :mfull, date, _, _, _) do
    months = Translator.get_months(locale)
    Map.get(months, date.month)
  end

  # Days
  def format_token(_locale, :day, date, _modifiers, flags, width),
    do: pad_numeric(date.day, flags, width)

  def format_token(_locale, :oday, date, _modifiers, flags, width),
    do: pad_numeric(Timex.day(date), flags, width)

  # Weeks
  def format_token(_locale, :iso_weeknum, date, _modifiers, flags, width) do
    {_, week} = Timex.iso_week(date)
    pad_numeric(week, flags, width)
  end

  def format_token(_locale, :week_mon, %{:year => year} = date, _modifiers, flags, width) do
    new_year = Timex.Date.new!(year, 1, 1)
    week_start = Timex.Date.beginning_of_week(new_year, :monday)

    # This date can be calculated by taking the day number of the year,
    # shifting the day number of the year down by the number of days which
    # occurred in the previous year, then dividing by 7
    day_num =
      if Date.compare(week_start, new_year) == :lt do
        prev_year_day_start = Date.day_of_year(week_start)
        prev_year_day_end = Date.day_of_year(Timex.Date.new!(week_start.year, 12, 31))
        shift = prev_year_day_end - prev_year_day_start
        shift + Date.day_of_year(Timex.Date.new!(year, date.month, date.day))
      else
        Date.day_of_year(Timex.Date.new!(year, date.month, date.day))
      end

    div(day_num, 7)
    |> pad_numeric(flags, width)
  end

  def format_token(_locale, :week_sun, %{:year => year} = date, _modifiers, flags, width) do
    new_year = Timex.Date.new!(year, 1, 1)
    week_start = Timex.Date.beginning_of_week(new_year, :sunday)

    # This date can be calculated by taking the day number of the year,
    # shifting the day number of the year down by the number of days which
    # occurred in the previous year, then dividing by 7
    day_num =
      if Date.compare(week_start, new_year) == :lt do
        prev_year_day_start = Date.day_of_year(week_start)
        prev_year_day_end = Date.day_of_year(Timex.Date.new!(week_start.year, 12, 31))
        shift = prev_year_day_end - prev_year_day_start
        shift + Date.day_of_year(Timex.Date.new!(year, date.month, date.day))
      else
        Date.day_of_year(Timex.Date.new!(year, date.month, date.day))
      end

    div(day_num, 7)
    |> pad_numeric(flags, width)
  end

  def format_token(_locale, :wday_mon, date, _modifiers, flags, width),
    do: pad_numeric(Timex.weekday!(date, :monday), flags, width)

  def format_token(_locale, :wday_sun, date, _modifiers, flags, width),
    do: pad_numeric(Timex.weekday!(date, :sunday) - 1, flags, width)

  def format_token(locale, :wdshort, date, _modifiers, _flags, _width) do
    day = Timex.weekday(date)
    day_names = Translator.get_weekdays_abbreviated(locale)
    Map.get(day_names, day)
  end

  def format_token(locale, :wdfull, date, _modifiers, _flags, _width) do
    day = Timex.weekday(date)
    day_names = Translator.get_weekdays(locale)
    Map.get(day_names, day)
  end

  # Hours
  def format_token(_locale, :hour24, %{:hour => hour}, _modifiers, flags, width),
    do: pad_numeric(hour, flags, width)

  def format_token(_locale, :hour24, _date, _modifiers, flags, width),
    do: pad_numeric(0, flags, width)

  def format_token(_locale, :hour12, %{:hour => hour}, _modifiers, flags, width) do
    {h, _} = Timex.Time.to_12hour_clock(hour)
    pad_numeric(h, flags, width)
  end

  def format_token(_locale, :hour12, _date, _modifiers, flags, width) do
    {h, _} = Timex.Time.to_12hour_clock(0)
    pad_numeric(h, flags, width)
  end

  def format_token(_locale, :min, %{:minute => min}, _modifiers, flags, width),
    do: pad_numeric(min, flags, width)

  def format_token(_locale, :min, _date, _modifiers, flags, width),
    do: pad_numeric(0, flags, width)

  def format_token(_locale, :sec, %{:second => sec}, _modifiers, flags, width),
    do: pad_numeric(sec, flags, width)

  def format_token(_locale, :sec, _date, _modifiers, flags, width),
    do: pad_numeric(0, flags, width)

  def format_token(
        _locale,
        :sec_fractional,
        %{microsecond: {us, precision}},
        _modifiers,
        _flags,
        width
      )
      when precision > 0 do
    min_width =
      case Keyword.get(width, :min) do
        nil -> precision
        n when n < 0 -> precision
        n -> n
      end

    max_width =
      case Keyword.get(width, :max) do
        nil -> precision
        n when n < min_width -> min_width
        n -> n
      end

    us_str = "#{us}"
    padded_us_str = String.duplicate(pad_char(:zeroes), 6 - byte_size(us_str)) <> us_str
    padded = pad_numeric(padded_us_str, [padding: :zeroes], width_spec(min_width..max_width))
    ".#{padded}"
  end

  def format_token(_locale, :sec_fractional, _date, _modifiers, _flags, width) do
    case Keyword.get(width, :min) do
      n when is_integer(n) and n > 0 ->
        padded = pad_numeric(0, [padding: :zeroes], width_spec(n..n))
        ".#{padded}"

      _ ->
        ""
    end
  end

  def format_token(_locale, :sec_epoch, date, _modifiers, flags, width) do
    case get_in(flags, [:padding]) do
      padding when padding in [:zeroes, :spaces] ->
        {:error,
         {:formatter,
          "Invalid directive flag: Cannot pad seconds from epoch, as it is not a fixed width integer."}}

      _ ->
        pad_numeric(Timex.to_unix(date), flags, width)
    end
  end

  def format_token(_locale, :us, %{microsecond: {us, _precision}}, _modifiers, flags, width) do
    min =
      case Keyword.get(width, :min) do
        nil -> 6
        n when n < 0 -> 6
        n -> n
      end

    max =
      case Keyword.get(width, :max) do
        nil -> 6
        n when n > 6 -> n
        _ -> 6
      end

    pad_numeric(us, flags, width_spec(min..max))
  end

  def format_token(_locale, :us, _date, _modifiers, flags, width) do
    pad_numeric(0, flags, width)
  end

  def format_token(
        _locale,
        :ms,
        _date = %{microsecond: {us, _precision}},
        _modifiers,
        flags,
        _width
      ),
      do: pad_numeric(Kernel.round(us / 1000), flags, width_spec(3..3))

  def format_token(_locale, :ms, _date, _modifiers, flags, width),
    do: pad_numeric(0, flags, width)

  def format_token(locale, :am, %{hour: hour}, _modifiers, _flags, _width) do
    day_periods = Translator.get_day_periods(locale)
    {_, am_pm} = Timex.Time.to_12hour_clock(hour)
    Map.get(day_periods, am_pm)
  end

  def format_token(locale, :am, _date, _modifiers, _flags, _width) do
    day_periods = Translator.get_day_periods(locale)
    {_, am_pm} = Timex.Time.to_12hour_clock(0)
    Map.get(day_periods, am_pm)
  end

  def format_token(locale, :AM, %{hour: hour}, _modifiers, _flags, _width) do
    day_periods = Translator.get_day_periods(locale)

    case Timex.Time.to_12hour_clock(hour) do
      {_, :am} ->
        Map.get(day_periods, :AM)

      {_, :pm} ->
        Map.get(day_periods, :PM)
    end
  end

  def format_token(locale, :AM, _date, _modifiers, _flags, _width) do
    day_periods = Translator.get_day_periods(locale)

    case Timex.Time.to_12hour_clock(0) do
      {_, :am} ->
        Map.get(day_periods, :AM)

      {_, :pm} ->
        Map.get(day_periods, :PM)
    end
  end

  # Timezones
  def format_token(_locale, :zname, %{time_zone: tz}, _modifiers, _flags, _width),
    do: tz

  def format_token(_locale, :zname, _date, _modifiers, _flags, _width),
    do: ""

  def format_token(_locale, :zabbr, %{zone_abbr: abbr}, _modifiers, _flags, _width),
    do: abbr

  def format_token(_locale, :zabbr, _date, _modifiers, _flags, _width),
    do: ""

  def format_token(
        _locale,
        :zoffs,
        %{std_offset: std, utc_offset: utc},
        _modifiers,
        flags,
        _width
      ) do
    case get_in(flags, [:padding]) do
      padding when padding in [:spaces, :none] ->
        {:error,
         {:formatter,
          "Invalid directive flag: Timezone offsets require 0-padding to remain unambiguous."}}

      _ ->
        total_offset = Timezone.total_offset(std, utc)
        offset_hours = div(total_offset, 60 * 60)
        offset_mins = div(rem(total_offset, 60 * 60), 60)
        hour = pad_numeric(offset_hours, [padding: :zeroes], width_spec(2..2))
        min = pad_numeric(offset_mins, [padding: :zeroes], width_spec(2..2))

        cond do
          offset_hours + offset_mins >= 0 -> "+#{hour}#{min}"
          true -> "#{hour}#{min}"
        end
    end
  end

  def format_token(_locale, :zoffs, _date, _modifiers, _flags, _width),
    do: ""

  def format_token(locale, :zoffs_colon, date, modifiers, flags, width) do
    case format_token(locale, :zoffs, date, modifiers, flags, width) do
      {:error, _} = err ->
        err

      "" ->
        ""

      offset ->
        case String.split(offset, "", trim: true, parts: 2) do
          [qualifier, <<hour::binary-size(2), min::binary-size(2)>>] ->
            <<qualifier::binary, hour::binary, ?:, min::binary>>

          [qualifier, <<hour::binary-size(2), "-", min::binary-size(2)>>] ->
            <<qualifier::binary, hour::binary, ?:, min::binary>>
        end
    end
  end

  def format_token(
        locale,
        :zoffs_sec,
        %{std_offset: std, utc_offset: utc} = date,
        modifiers,
        flags,
        width
      ) do
    case format_token(locale, :zoffs_colon, date, modifiers, flags, width) do
      {:error, _} = err ->
        err

      "" ->
        ""

      offset ->
        total_offset = Timezone.total_offset(std, utc)
        offset_secs = rem(rem(total_offset, 60 * 60), 60)
        "#{offset}:#{pad_numeric(offset_secs, [padding: :zeroes], width_spec(2..2))}"
    end
  end

  def format_token(_locale, :zoffs_sec, _date, _modifiers, _flags, _width),
    do: ""

  def format_token(_locale, token, _, _, _, _) do
    {:error, {:formatter, :unsupported_token, token}}
  end

  defp pad_numeric(number, flags, width) when is_integer(number),
    do: pad_numeric("#{number}", flags, width)

  defp pad_numeric(number_str, [], _width), do: number_str

  defp pad_numeric(<<?-, number_str::binary>>, flags, width) do
    res = pad_numeric(number_str, flags, width)
    <<?-, res::binary>>
  end

  defp pad_numeric(number_str, flags, min: min_width, max: max_width) do
    case get_in(flags, [:padding]) do
      pad_type when pad_type in [nil, :none] ->
        number_str

      pad_type ->
        len = byte_size(number_str)

        cond do
          len == min_width -> number_str
          min_width == -1 && max_width == nil -> number_str
          len < min_width -> String.duplicate(pad_char(pad_type), min_width - len) <> number_str
          len > min_width && len > max_width -> binary_part(number_str, 0, max_width)
          len > min_width -> number_str
        end
    end
  end

  defp pad_char(:zeroes), do: <<?0>>
  defp pad_char(:spaces), do: <<32>>

  defp width_spec(min..max), do: [min: min, max: max]
  defp width_spec(min, max), do: [min: min, max: max]
end