Polish docs in the Calendar module

Polish docs in the Calendar module

defmodule Calendar do
  @moduledoc """
  This module defines the responsibilities for working with
  calendars, dates, times and datetimes in Elixir.

  It defines types and the minimal implementation
  for a calendar behaviour in Elixir. The goal of the calendar
  features in Elixir is to provide a base for interoperability
  rather than a full-featured datetime API.

  For the actual date, time and datetime structs, see `Date`,
  `Time`, `NaiveDateTime`, and `DateTime`.

  Types for year, month, day, and more are *overspecified*.
  For example, the `t:month/0` type is specified as an integer
  instead of `1..12`. This is because different calendars may
  have a different number of days per month.
  """

  @type year :: integer
  @type month :: pos_integer
  @type day :: pos_integer
  @type week :: pos_integer
  @type day_of_week :: non_neg_integer
  @type era :: non_neg_integer

  @typedoc """
  A tuple representing the `day` and the `era`.
  """
  @type day_of_era :: {day :: non_neg_integer(), era}

  @type hour :: non_neg_integer
  @type minute :: non_neg_integer
  @type second :: non_neg_integer

  @typedoc """
  The internal time format is used when converting between calendars.

  It represents time as a fraction of a day (starting from midnight).
  `parts_in_day` specifies how much of the day is already passed,
  while `parts_per_day` signifies how many parts are there in a day.
  """
  @type day_fraction :: {parts_in_day :: non_neg_integer, parts_per_day :: pos_integer}

  @typedoc """
  The internal date format that is used when converting between calendars.

  This is the number of days including the fractional part that has passed of
  the last day since `0000-01-01+00:00T00:00.000000` in ISO 8601 notation (also
  known as *midnight 1 January BC 1* of the proleptic Gregorian calendar).
  """
  @type iso_days :: {days :: integer, day_fraction}

  @typedoc """
  Microseconds with stored precision.

  The precision represents the number of digits that must be used when
  representing the microseconds to external format. If the precision is `0`,
  it means microseconds must be skipped.
  """
  @type microsecond :: {value :: non_neg_integer, precision :: non_neg_integer}

  @typedoc "A calendar implementation."
  @type calendar :: module

  @typedoc "The time zone ID according to the IANA tz database (for example, `Europe/Zurich`)."
  @type time_zone :: String.t()

  @typedoc "The time zone abbreviation (for example, `CET` or `CEST` or `BST`)."
  @type zone_abbr :: String.t()

  @typedoc """
  The time zone UTC offset in seconds for standard time.

  See also `t:std_offset/0`.
  """
  @type utc_offset :: integer

  @typedoc """
  The time zone standard offset in seconds (typically not zero in summer times).

  It must be added to `t:utc_offset/0` to get the total offset from UTC used for "wall time".
  """
  @type std_offset :: integer

  @typedoc "Any map or struct that contains the date fields."
  @type date :: %{optional(any) => any, calendar: calendar, year: year, month: month, day: day}

  @typedoc "Any map or struct that contains the time fields."
  @type time :: %{
          optional(any) => any,
          hour: hour,
          minute: minute,
          second: second,
          microsecond: microsecond
        }

  @typedoc "Any map or struct that contains the naive datetime fields."
  @type naive_datetime :: %{
          optional(any) => any,
          calendar: calendar,
          year: year,
          month: month,
          day: day,
          hour: hour,
          minute: minute,
          second: second,
          microsecond: microsecond
        }

  @typedoc "Any map or struct that contains the datetime fields."
  @type datetime :: %{
          optional(any) => any,
          calendar: calendar,
          year: year,
          month: month,
          day: day,
          hour: hour,
          minute: minute,
          second: second,
          microsecond: microsecond,
          time_zone: time_zone,
          zone_abbr: zone_abbr,
          utc_offset: utc_offset,
          std_offset: std_offset
        }

  @typedoc """
  Specifies the time zone database for calendar operations.

  Many functions in the `DateTime` module require a time zone database.
  By default, this module uses the default time zone database returned by
  `Calendar.get_time_zone_database/0`, which defaults to
  `Calendar.UTCOnlyTimeZoneDatabase`. This database only handles `Etc/UTC`
  datetimes and returns `{:error, :utc_only_time_zone_database}`
  for any other time zone.

  Other time zone databases (including ones provided by packages)
  can be configured as default either via configuration:

      config :elixir, :time_zone_database, CustomTimeZoneDatabase

  or by calling `Calendar.put_time_zone_database/1`.

  See `Calendar.TimeZoneDatabase` for more information on custom
  time zone databases.
  """
  @type time_zone_database :: module()

  @doc """
  Returns how many days there are in the given month of the given year.
  """
  @callback days_in_month(year, month) :: day

  @doc """
  Returns how many months there are in the given year.
  """
  @callback months_in_year(year) :: month

  @doc """
  Returns `true` if the given year is a leap year.

  A leap year is a year of a longer length than normal. The exact meaning
  is up to the calendar. A calendar must return `false` if it does not support
  the concept of leap years.
  """
  @callback leap_year?(year) :: boolean

  @doc """
  Calculates the day of the week from the given `year`, `month`, and `day`.

  `starting_on` represents the starting day of the week. All
  calendars must support at least the `:default` value. They may
  also support other values representing their days of the week.
  """
  @callback day_of_week(year, month, day, starting_on :: :default | atom) ::
              {day_of_week(), first_day_of_week :: non_neg_integer(),
               last_day_of_week :: non_neg_integer()}

  @doc """
  Calculates the day of the year from the given `year`, `month`, and `day`.
  """
  @callback day_of_year(year, month, day) :: non_neg_integer()

  @doc """
  Calculates the quarter of the year from the given `year`, `month`, and `day`.
  """
  @callback quarter_of_year(year, month, day) :: non_neg_integer()

  @doc """
  Calculates the year and era from the given `year`.
  """
  @callback year_of_era(year, month, day) :: {year, era}

  @doc """
  Calculates the day and era from the given `year`, `month`, and `day`.
  """
  @callback day_of_era(year, month, day) :: day_of_era()

  @doc """
  Converts the date into a string according to the calendar.
  """
  @callback date_to_string(year, month, day) :: String.t()

  @doc """
  Converts the naive datetime (without time zone) into a string according to the calendar.
  """
  @callback naive_datetime_to_string(year, month, day, hour, minute, second, microsecond) ::
              String.t()

  @doc """
  Converts the datetime (with time zone) into a string according to the calendar.
  """
  @callback datetime_to_string(
              year,
              month,
              day,
              hour,
              minute,
              second,
              microsecond,
              time_zone,
              zone_abbr,
              utc_offset,
              std_offset
            ) :: String.t()

  @doc """
  Converts the time into a string according to the calendar.
  """
  @callback time_to_string(hour, minute, second, microsecond) :: String.t()

  @doc """
  Converts the datetime (without time zone) into the `t:iso_days/0` format.
  """
  @callback naive_datetime_to_iso_days(year, month, day, hour, minute, second, microsecond) ::
              iso_days

  @doc """
  Converts `t:iso_days/0` to the calendar's datetime format.
  """
  @callback naive_datetime_from_iso_days(iso_days) ::
              {year, month, day, hour, minute, second, microsecond}

  @doc """
  Converts the given time to the `t:day_fraction/0` format.
  """
  @callback time_to_day_fraction(hour, minute, second, microsecond) :: day_fraction

  @doc """
  Converts `t:day_fraction/0` to the calendar's time format.
  """
  @callback time_from_day_fraction(day_fraction) :: {hour, minute, second, microsecond}

  @doc """
  Define the rollover moment for the calendar.

  This is the moment, in your calendar, when the current day ends
  and the next day starts.

  The result of this function is used to check if two calendars roll over at
  the same time of day. If they do not, we can only convert datetimes and times
  between them. If they do, this means that we can also convert dates as well
  as naive datetimes between them.

  This day fraction should be in its most simplified form possible, to make comparisons fast.

  ## Examples

    * If in your calendar a new day starts at midnight, return `{0, 1}`.
    * If in your calendar a new day starts at sunrise, return `{1, 4}`.
    * If in your calendar a new day starts at noon, return `{1, 2}`.
    * If in your calendar a new day starts at sunset, return `{3, 4}`.

  """
  @callback day_rollover_relative_to_midnight_utc() :: day_fraction

  @doc """
  Should return `true` if the given date describes a proper date in the calendar.
  """
  @callback valid_date?(year, month, day) :: boolean

  @doc """
  Should return `true` if the given time describes a proper time in the calendar.
  """
  @callback valid_time?(hour, minute, second, microsecond) :: boolean

  @doc """
  Parses the string representation for a time returned by `c:time_to_string/4`
  into a time tuple.
  """
  @doc since: "1.10.0"
  @callback parse_time(String.t()) ::
              {:ok, {hour, minute, second, microsecond}}
              | {:error, atom}

  @doc """
  Parses the string representation for a date returned by `c:date_to_string/3`
  into a date tuple.
  """
  @doc since: "1.10.0"
  @callback parse_date(String.t()) ::
              {:ok, {year, month, day}}
              | {:error, atom}

  @doc """
  Parses the string representation for a naive datetime returned by
  `c:naive_datetime_to_string/7` into a naive datetime tuple.

  The given string may contain a timezone offset but it is ignored.
  """
  @doc since: "1.10.0"
  @callback parse_naive_datetime(String.t()) ::
              {:ok, {year, month, day, hour, minute, second, microsecond}}
              | {:error, atom}

  @doc """
  Parses the string representation for a datetime returned by
  `c:datetime_to_string/11` into a datetime tuple.

  The returned datetime must be in UTC. The original `utc_offset`
  it was written in must be returned in the result.
  """
  @doc since: "1.10.0"
  @callback parse_utc_datetime(String.t()) ::
              {:ok, {year, month, day, hour, minute, second, microsecond}, utc_offset}
              | {:error, atom}

  # General Helpers

  @doc """
  Returns `true` if two calendars have the same moment of starting a new day,
  `false` otherwise.

  If two calendars are not compatible, we can only convert datetimes and times
  between them. If they are compatible, this means that we can also convert
  dates as well as naive datetimes between them.
  """
  @doc since: "1.5.0"
  @spec compatible_calendars?(Calendar.calendar(), Calendar.calendar()) :: boolean
  def compatible_calendars?(calendar, calendar), do: true

  def compatible_calendars?(calendar1, calendar2) do
    calendar1.day_rollover_relative_to_midnight_utc() ==
      calendar2.day_rollover_relative_to_midnight_utc()
  end

  @doc """
  Returns a microsecond tuple truncated to a given precision (`:microsecond`,
  `:millisecond`, or `:second`).
  """
  @doc since: "1.6.0"
  @spec truncate(Calendar.microsecond(), :microsecond | :millisecond | :second) ::
          Calendar.microsecond()
  def truncate(microsecond_tuple, :microsecond), do: microsecond_tuple

  def truncate({microsecond, precision}, :millisecond) do
    output_precision = min(precision, 3)
    {div(microsecond, 1000) * 1000, output_precision}
  end

  def truncate(_, :second), do: {0, 0}

  @doc """
  Sets the current time zone database.
  """
  @doc since: "1.8.0"
  @spec put_time_zone_database(time_zone_database()) :: :ok
  def put_time_zone_database(database) do
    Application.put_env(:elixir, :time_zone_database, database)
  end

  @doc """
  Gets the current time zone database.
  """
  @doc since: "1.8.0"
  @spec get_time_zone_database() :: time_zone_database()
  def get_time_zone_database() do
    Application.fetch_env!(:elixir, :time_zone_database)
  end

  @doc """
  Formats the given date, time, or datetime into a string.

  The datetime can be any of the `Calendar` types (`Time`, `Date`,
  `NaiveDateTime`, and `DateTime`) or any map, as long as they
  contain all of the relevant fields necessary for formatting.
  For example, if you use `%Y` to format the year, the datetime
  must have the `:year` field. Therefore, if you pass a `Time`,
  or a map without the `:year` field to a format that expects `%Y`,
  an error will be raised.

  ## Options

    * `:preferred_datetime` - a string for the preferred format to show datetimes,
      it can't contain the `%c` format and defaults to `"%Y-%m-%d %H:%M:%S"`
      if the option is not received

    * `: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

    * `: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 function that receives either `:am` or `:pm` and returns
      the name of the period of the day, if the option is not received it defaults
      to a function that returns `"am"` and `"pm"`, respectively

    *  `:month_names` - a function that receives a number and returns the name of
      the corresponding month, if the option is not received it defaults to a
      function that returns the month names in English

    * `:abbreviated_month_names` - a function that receives a number and returns the
      abbreviated name of the corresponding month, if the option is not received it
      defaults to a function that returns the abbreviated month names in English

    * `:day_of_week_names` - a function that receives a number and returns the name of
      the corresponding day of week, if the option is not received it defaults to a
      function that returns the day of week names in English

    * `:abbreviated_day_of_week_names` - a function that receives a number and returns
      the abbreviated name of the corresponding day of week, if the option is not received
      it defaults to a function that returns the abbreviated day of week names in English

  ## Formatting syntax

  The formatting syntax for strftime is a sequence of characters in the following format:

      %<padding><width><format>

  where:

    * `%`: indicates the start of a formatted section
    * `<padding>`: set the padding (see below)
    * `<width>`: a number indicating the minimum size of the formatted section
    * `<format>`: the format itself (see below)

  ### Accepted padding options

    * `-`: no padding, removes all padding from the format
    * `_`: pad with spaces
    * `0`: pad with zeroes

  ### Accepted formats

  The accepted formats are:

  Format | Description                                                             | Examples (in ISO)
  :----- | :-----------------------------------------------------------------------| :------------------------
  a      | Abbreviated name of day                                                 | Mon
  A      | Full name of day                                                        | Monday
  b      | Abbreviated month name                                                  | Jan
  B      | Full month name                                                         | January
  c      | Preferred date+time representation                                      | 2018-10-17 12:34:56
  d      | Day of the month                                                        | 01, 31
  f      | Microseconds *(does not support width and padding modifiers)*           | 000000, 999999, 0123
  H      | Hour using a 24-hour clock                                              | 00, 23
  I      | Hour using a 12-hour clock                                              | 01, 12
  j      | Day of the year                                                         | 001, 366
  m      | Month                                                                   | 01, 12
  M      | Minute                                                                  | 00, 59
  p      | "AM" or "PM" (noon is "PM", midnight as "AM")                           | AM, PM
  P      | "am" or "pm" (noon is "pm", midnight as "am")                           | am, pm
  q      | Quarter                                                                 | 1, 2, 3, 4
  S      | Second                                                                  | 00, 59, 60
  u      | Day of the week                                                         | 1 (Monday), 7 (Sunday)
  x      | Preferred date (without time) representation                            | 2018-10-17
  X      | Preferred time (without date) representation                            | 12:34:56
  y      | Year as 2-digits                                                        | 01, 01, 86, 18
  Y      | Year                                                                    | -0001, 0001, 1986
  z      | +hhmm/-hhmm time zone offset from UTC (empty string if naive)           | +0300, -0530
  Z      | Time zone abbreviation (empty string if naive)                          | CET, BRST
  %      | Literal "%" character                                                   | %

  Any other character will be interpreted as an invalid format and raise an error.

  ## Examples

  Without options:

      iex> Calendar.strftime(~U[2019-08-26 13:52:06.0Z], "%y-%m-%d %I:%M:%S %p")
      "19-08-26 01:52:06 PM"

      iex> Calendar.strftime(~U[2019-08-26 13:52:06.0Z], "%a, %B %d %Y")
      "Mon, August 26 2019"

      iex> Calendar.strftime(~U[2020-04-02 13:52:06.0Z], "%B %-d, %Y")
      "April 2, 2020"

      iex> Calendar.strftime(~U[2019-08-26 13:52:06.0Z], "%c")
      "2019-08-26 13:52:06"

  With options:

      iex> Calendar.strftime(~U[2019-08-26 13:52:06.0Z], "%c", preferred_datetime: "%H:%M:%S %d-%m-%y")
      "13:52:06 26-08-19"

      iex> Calendar.strftime(
      ...>  ~U[2019-08-26 13:52:06.0Z],
      ...>  "%A",
      ...>  day_of_week_names: fn day_of_week ->
      ...>    {"segunda-feira", "terça-feira", "quarta-feira", "quinta-feira",
      ...>    "sexta-feira", "sábado", "domingo"}
      ...>    |> elem(day_of_week - 1)
      ...>  end
      ...>)
      "segunda-feira"

      iex> Calendar.strftime(
      ...>  ~U[2019-08-26 13:52:06.0Z],
      ...>  "%B",
      ...>  month_names: fn month ->
      ...>    {"январь", "февраль", "март", "апрель", "май", "июнь",
      ...>    "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"}
      ...>    |> elem(month - 1)
      ...>  end
      ...>)
      "август"

  """
  @doc since: "1.11.0"
  @spec strftime(map(), String.t(), keyword()) :: String.t()
  def strftime(date_or_time_or_datetime, string_format, user_options \\ [])
      when is_map(date_or_time_or_datetime) and is_binary(string_format) do
    parse(
      string_format,
      date_or_time_or_datetime,
      options(user_options),
      []
    )
    |> IO.iodata_to_binary()
  end

  defp parse("", _datetime, _format_options, acc),
    do: Enum.reverse(acc)

  defp parse("%" <> rest, datetime, format_options, acc),
    do: parse_modifiers(rest, nil, nil, {datetime, format_options, acc})

  defp parse(<<char, rest::binary>>, datetime, format_options, acc),
    do: parse(rest, datetime, format_options, [char | acc])

  defp parse_modifiers("-" <> rest, width, nil, parser_data) do
    parse_modifiers(rest, width, "", parser_data)
  end

  defp parse_modifiers("0" <> rest, nil, nil, parser_data) do
    parse_modifiers(rest, nil, ?0, parser_data)
  end

  defp parse_modifiers("_" <> rest, width, nil, parser_data) do
    parse_modifiers(rest, width, ?\s, parser_data)
  end

  defp parse_modifiers(<<digit, rest::binary>>, width, pad, parser_data) when digit in ?0..?9 do
    new_width = (width || 0) * 10 + (digit - ?0)

    parse_modifiers(rest, new_width, pad, parser_data)
  end

  # set default padding if none was specified
  defp parse_modifiers(<<format, _::binary>> = rest, width, nil, parser_data) do
    parse_modifiers(rest, width, default_pad(format), parser_data)
  end

  # set default width if none was specified
  defp parse_modifiers(<<format, _::binary>> = rest, nil, pad, parser_data) do
    parse_modifiers(rest, default_width(format), pad, parser_data)
  end

  defp parse_modifiers(rest, width, pad, {datetime, format_options, acc}) do
    format_modifiers(rest, width, pad, datetime, format_options, acc)
  end

  defp am_pm(hour, format_options) when hour > 11 do
    format_options.am_pm_names.(:pm)
  end

  defp am_pm(hour, format_options) when hour <= 11 do
    format_options.am_pm_names.(:am)
  end

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

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

  # Literally just %
  defp format_modifiers("%" <> rest, width, pad, datetime, format_options, acc) do
    parse(rest, datetime, format_options, [pad_leading("%", width, pad) | acc])
  end

  # Abbreviated name of day
  defp format_modifiers("a" <> rest, width, pad, datetime, format_options, acc) do
    result =
      datetime
      |> Date.day_of_week()
      |> format_options.abbreviated_day_of_week_names.()
      |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Full name of day
  defp format_modifiers("A" <> rest, width, pad, datetime, format_options, acc) do
    result =
      datetime
      |> Date.day_of_week()
      |> format_options.day_of_week_names.()
      |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Abbreviated month name
  defp format_modifiers("b" <> rest, width, pad, datetime, format_options, acc) do
    result =
      datetime.month
      |> format_options.abbreviated_month_names.()
      |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Full month name
  defp format_modifiers("B" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.month |> format_options.month_names.() |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Preferred date+time representation
  defp format_modifiers(
         "c" <> _rest,
         _width,
         _pad,
         _datetime,
         %{preferred_datetime_invoked: true},
         _acc
       ) do
    raise ArgumentError,
          "tried to format preferred_datetime within another preferred_datetime format"
  end

  defp format_modifiers("c" <> rest, width, pad, datetime, format_options, acc) do
    result =
      format_options.preferred_datetime
      |> parse(datetime, %{format_options | preferred_datetime_invoked: true}, [])
      |> pad_preferred(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Day of the month
  defp format_modifiers("d" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.day |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Microseconds
  defp format_modifiers("f" <> rest, _width, _pad, datetime, format_options, acc) do
    {microsecond, precision} = datetime.microsecond

    result =
      microsecond
      |> Integer.to_string()
      |> String.pad_leading(6, "0")
      |> binary_part(0, max(precision, 1))

    parse(rest, datetime, format_options, [result | acc])
  end

  # Hour using a 24-hour clock
  defp format_modifiers("H" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.hour |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Hour using a 12-hour clock
  defp format_modifiers("I" <> rest, width, pad, datetime, format_options, acc) do
    result = (rem(datetime.hour() + 23, 12) + 1) |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Day of the year
  defp format_modifiers("j" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime |> Date.day_of_year() |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Month
  defp format_modifiers("m" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.month |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Minute
  defp format_modifiers("M" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.minute |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # "AM" or "PM" (noon is "PM", midnight as "AM")
  defp format_modifiers("p" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.hour |> am_pm(format_options) |> String.upcase() |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # "am" or "pm" (noon is "pm", midnight as "am")
  defp format_modifiers("P" <> rest, width, pad, datetime, format_options, acc) do
    result =
      datetime.hour
      |> am_pm(format_options)
      |> String.downcase()
      |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Quarter
  defp format_modifiers("q" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime |> Date.quarter_of_year() |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Second
  defp format_modifiers("S" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.second |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Day of the week
  defp format_modifiers("u" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime |> Date.day_of_week() |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Preferred date (without time) representation
  defp format_modifiers(
         "x" <> _rest,
         _width,
         _pad,
         _datetime,
         %{preferred_date_invoked: true},
         _acc
       ) do
    raise ArgumentError,
          "tried to format preferred_date within another preferred_date format"
  end

  defp format_modifiers("x" <> rest, width, pad, datetime, format_options, acc) do
    result =
      format_options.preferred_date
      |> parse(datetime, %{format_options | preferred_date_invoked: true}, [])
      |> pad_preferred(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Preferred time (without date) representation
  defp format_modifiers(
         "X" <> _rest,
         _width,
         _pad,
         _datetime,
         %{preferred_time_invoked: true},
         _acc
       ) do
    raise ArgumentError,
          "tried to format preferred_time within another preferred_time format"
  end

  defp format_modifiers("X" <> rest, width, pad, datetime, format_options, acc) do
    result =
      format_options.preferred_time
      |> parse(datetime, %{format_options | preferred_time_invoked: true}, [])
      |> pad_preferred(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Year as 2-digits
  defp format_modifiers("y" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.year |> rem(100) |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Year
  defp format_modifiers("Y" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.year |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # +hhmm/-hhmm time zone offset from UTC (empty string if naive)
  defp format_modifiers(
         "z" <> rest,
         width,
         pad,
         datetime = %{utc_offset: utc_offset, std_offset: std_offset},
         format_options,
         acc
       ) do
    absolute_offset = abs(utc_offset + std_offset)

    offset_number =
      Integer.to_string(div(absolute_offset, 3600) * 100 + rem(div(absolute_offset, 60), 60))

    sign = if utc_offset + std_offset >= 0, do: "+", else: "-"
    result = "#{sign}#{pad_leading(offset_number, width, pad)}"
    parse(rest, datetime, format_options, [result | acc])
  end

  defp format_modifiers("z" <> rest, _width, _pad, datetime, format_options, acc) do
    parse(rest, datetime, format_options, ["" | acc])
  end

  # Time zone abbreviation (empty string if naive)
  defp format_modifiers("Z" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime |> Map.get(:zone_abbr, "") |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  defp format_modifiers(rest, _width, _pad, _datetime, _format_options, _acc) do
    {next, _rest} = String.next_grapheme(rest) || {"", ""}
    raise ArgumentError, "invalid strftime format: %#{next}"
  end

  defp pad_preferred(result, width, pad) when length(result) < width do
    pad_preferred([pad | result], width, pad)
  end

  defp pad_preferred(result, _width, _pad), do: result

  defp pad_leading(string, count, padding) do
    to_pad = count - byte_size(string)
    if to_pad > 0, do: do_pad_leading(to_pad, padding, string), else: string
  end

  defp do_pad_leading(0, _, acc), do: acc

  defp do_pad_leading(count, padding, acc),
    do: do_pad_leading(count - 1, padding, [padding | acc])

  defp options(user_options) do
    default_options = %{
      preferred_date: "%Y-%m-%d",
      preferred_time: "%H:%M:%S",
      preferred_datetime: "%Y-%m-%d %H:%M:%S",
      am_pm_names: fn
        :am -> "am"
        :pm -> "pm"
      end,
      month_names: fn month ->
        {"January", "February", "March", "April", "May", "June", "July", "August", "September",
         "October", "November", "December"}
        |> elem(month - 1)
      end,
      day_of_week_names: fn day_of_week ->
        {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}
        |> elem(day_of_week - 1)
      end,
      abbreviated_month_names: fn month ->
        {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
        |> elem(month - 1)
      end,
      abbreviated_day_of_week_names: fn day_of_week ->
        {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} |> elem(day_of_week - 1)
      end,
      preferred_datetime_invoked: false,
      preferred_date_invoked: false,
      preferred_time_invoked: false
    }

    Enum.reduce(user_options, default_options, fn {key, value}, acc ->
      if Map.has_key?(acc, key) do
        %{acc | key => value}
      else
        raise ArgumentError, "unknown option #{inspect(key)} given to Calendar.strftime/3"
      end
    end)
  end
end

defmodule Calendar do
  @moduledoc """
  This module defines the responsibilities for working with
  calendars, dates, times and datetimes in Elixir.

  Currently it defines types and the minimal implementation
  for a calendar behaviour in Elixir. The goal of the Calendar
  features in Elixir is to provide a base for interoperability
  instead of full-featured datetime API.

  For the actual date, time and datetime structures, see `Date`,
  `Time`, `NaiveDateTime` and `DateTime`.

  Note designations for year, month, day, and the like, are overspecified
  (i.e. an integer instead of `1..12` for months) because different
  calendars may have a different number of days per month, months per year and so on.
  """

  @type year :: integer
  @type month :: pos_integer
  @type day :: pos_integer
  @type week :: pos_integer
  @type day_of_week :: non_neg_integer
  @type era :: non_neg_integer

  @typedoc """
  A tuple representing the `day` and the `era`.
  """
  @type day_of_era :: {day :: non_neg_integer(), era}

  @type hour :: non_neg_integer
  @type minute :: non_neg_integer
  @type second :: non_neg_integer

  @typedoc """
  The internal time format is used when converting between calendars.

  It represents time as a fraction of a day (starting from midnight).
  `parts_in_day` specifies how much of the day is already passed,
  while `parts_per_day` signifies how many parts there fit in a day.
  """
  @type day_fraction :: {parts_in_day :: non_neg_integer, parts_per_day :: pos_integer}

  @typedoc """
  The internal date format that is used when converting between calendars.

  This is the number of days including the fractional part that has passed of
  the last day since 0000-01-01+00:00T00:00.000000 in ISO 8601 notation (also
  known as midnight 1 January BC 1 of the proleptic Gregorian calendar).
  """
  @type iso_days :: {days :: integer, day_fraction}

  @typedoc """
  Microseconds with stored precision.

  The precision represents the number of digits that must be used when
  representing the microseconds to external format. If the precision is 0,
  it means microseconds must be skipped.
  """
  @type microsecond :: {value :: non_neg_integer, precision :: non_neg_integer}

  @typedoc "A calendar implementation"
  @type calendar :: module

  @typedoc "The time zone ID according to the IANA tz database (for example, Europe/Zurich)"
  @type time_zone :: String.t()

  @typedoc "The time zone abbreviation (for example, CET or CEST or BST, and such)"
  @type zone_abbr :: String.t()

  @typedoc """
  The time zone UTC offset in seconds for standard time.

  See also `t:std_offset/0`.
  """
  @type utc_offset :: integer

  @typedoc """
  The time zone standard offset in seconds (typically not zero in summer times).

  It must be added to `t:utc_offset/0` to get the total offset from UTC used for "wall time".
  """
  @type std_offset :: integer

  @typedoc "Any map/struct that contains the date fields"
  @type date :: %{optional(any) => any, calendar: calendar, year: year, month: month, day: day}

  @typedoc "Any map/struct that contains the time fields"
  @type time :: %{
          optional(any) => any,
          hour: hour,
          minute: minute,
          second: second,
          microsecond: microsecond
        }

  @typedoc "Any map/struct that contains the naive_datetime fields"
  @type naive_datetime :: %{
          optional(any) => any,
          calendar: calendar,
          year: year,
          month: month,
          day: day,
          hour: hour,
          minute: minute,
          second: second,
          microsecond: microsecond
        }

  @typedoc "Any map/struct that contains the datetime fields"
  @type datetime :: %{
          optional(any) => any,
          calendar: calendar,
          year: year,
          month: month,
          day: day,
          hour: hour,
          minute: minute,
          second: second,
          microsecond: microsecond,
          time_zone: time_zone,
          zone_abbr: zone_abbr,
          utc_offset: utc_offset,
          std_offset: std_offset
        }

  @typedoc """
  Specifies the time zone database for calendar operations.

  Many functions in the `DateTime` module require a time zone database.
  By default, it uses the default time zone database returned by
  `Calendar.get_time_zone_database/0`, which defaults to
  `Calendar.UTCOnlyTimeZoneDatabase` which only handles "Etc/UTC"
  datetimes and returns `{:error, :utc_only_time_zone_database}`
  for any other time zone.

  Other time zone databases (including ones provided by packages)
  can be configured as default either via configuration:

      config :elixir, :time_zone_database, CustomTimeZoneDatabase

  or by calling `Calendar.put_time_zone_database/1`.

  See `Calendar.TimeZoneDatabase` for more information on custom
  time zone databases.
  """
  @type time_zone_database :: module()

  @doc """
  Returns how many days there are in the given year-month.
  """
  @callback days_in_month(year, month) :: day

  @doc """
  Returns how many months there are in the given year.
  """
  @callback months_in_year(year) :: month

  @doc """
  Returns `true` if the given year is a leap year.

  A leap year is a year of a longer length than normal. The exact meaning
  is up to the calendar. A calendar must return `false` if it does not support
  the concept of leap years.
  """
  @callback leap_year?(year) :: boolean

  @doc """
  Calculates the day of the week from the given `year`, `month`, and `day`.

  The `starting_on` represents the starting day of the week. All
  calendars must support at least the `:default` value. They may
  also support other values representing their days of the week.
  """
  @callback day_of_week(year, month, day, starting_on :: :default | atom) ::
              {day_of_week(), first_day_of_week :: non_neg_integer(),
               last_day_of_week :: non_neg_integer()}

  @doc """
  Calculates the day of the year from the given `year`, `month`, and `day`.
  """
  @callback day_of_year(year, month, day) :: non_neg_integer()

  @doc """
  Calculates the quarter of the year from the given `year`, `month`, and `day`.
  """
  @callback quarter_of_year(year, month, day) :: non_neg_integer()

  @doc """
  Calculates the year and era from the given `year`.
  """
  @callback year_of_era(year, month, day) :: {year, era}

  @doc """
  Calculates the day and era from the given `year`, `month`, and `day`.
  """
  @callback day_of_era(year, month, day) :: day_of_era()

  @doc """
  Converts the date into a string according to the calendar.
  """
  @callback date_to_string(year, month, day) :: String.t()

  @doc """
  Converts the datetime (without time zone) into a string according to the calendar.
  """
  @callback naive_datetime_to_string(year, month, day, hour, minute, second, microsecond) ::
              String.t()

  @doc """
  Converts the datetime (with time zone) into a string according to the calendar.
  """
  @callback datetime_to_string(
              year,
              month,
              day,
              hour,
              minute,
              second,
              microsecond,
              time_zone,
              zone_abbr,
              utc_offset,
              std_offset
            ) :: String.t()

  @doc """
  Converts the time into a string according to the calendar.
  """
  @callback time_to_string(hour, minute, second, microsecond) :: String.t()

  @doc """
  Converts the given datetime (without time zone) into the `t:iso_days/0` format.
  """
  @callback naive_datetime_to_iso_days(year, month, day, hour, minute, second, microsecond) ::
              iso_days

  @doc """
  Converts `t:iso_days/0` to the Calendar's datetime format.
  """
  @callback naive_datetime_from_iso_days(iso_days) ::
              {year, month, day, hour, minute, second, microsecond}

  @doc """
  Converts the given time to the `t:day_fraction/0` format.
  """
  @callback time_to_day_fraction(hour, minute, second, microsecond) :: day_fraction

  @doc """
  Converts `t:day_fraction/0` to the Calendar's time format.
  """
  @callback time_from_day_fraction(day_fraction) :: {hour, minute, second, microsecond}

  @doc """
  Define the rollover moment for the given calendar.

  This is the moment, in your calendar, when the current day ends
  and the next day starts.

  The result of this function is used to check if two calendars rollover at
  the same time of day. If they do not, we can only convert datetimes and times
  between them. If they do, this means that we can also convert dates as well
  as naive datetimes between them.

  This day fraction should be in its most simplified form possible, to make comparisons fast.

  ## Examples

    * If, in your Calendar, a new day starts at midnight, return {0, 1}.
    * If, in your Calendar, a new day starts at sunrise, return {1, 4}.
    * If, in your Calendar, a new day starts at noon, return {1, 2}.
    * If, in your Calendar, a new day starts at sunset, return {3, 4}.

  """
  @callback day_rollover_relative_to_midnight_utc() :: day_fraction

  @doc """
  Should return `true` if the given date describes a proper date in the calendar.
  """
  @callback valid_date?(year, month, day) :: boolean

  @doc """
  Should return `true` if the given time describes a proper time in the calendar.
  """
  @callback valid_time?(hour, minute, second, microsecond) :: boolean

  @doc """
  Parses the string representation for a time returned by `c:time_to_string/4`
  into a time-tuple.
  """
  @doc since: "1.10.0"
  @callback parse_time(String.t()) ::
              {:ok, {hour, minute, second, microsecond}}
              | {:error, atom}

  @doc """
  Parses the string representation for a date returned by `c:date_to_string/3`
  into a date-tuple.
  """
  @doc since: "1.10.0"
  @callback parse_date(String.t()) ::
              {:ok, {year, month, day}}
              | {:error, atom}

  @doc """
  Parses the string representation for a naive datetime returned by
  `c:naive_datetime_to_string/7` into a naive-datetime-tuple.

  The given string may contain a timezone offset but it is ignored.
  """
  @doc since: "1.10.0"
  @callback parse_naive_datetime(String.t()) ::
              {:ok, {year, month, day, hour, minute, second, microsecond}}
              | {:error, atom}

  @doc """
  Parses the string representation for a datetime returned by
  `c:datetime_to_string/11` into a datetime-tuple.

  The returned datetime must be in UTC. The original `utc_offset`
  it was written in must be returned in the result.
  """
  @doc since: "1.10.0"
  @callback parse_utc_datetime(String.t()) ::
              {:ok, {year, month, day, hour, minute, second, microsecond}, utc_offset}
              | {:error, atom}

  # General Helpers

  @doc """
  Returns `true` if two calendars have the same moment of starting a new day,
  `false` otherwise.

  If two calendars are not compatible, we can only convert datetimes and times
  between them. If they are compatible, this means that we can also convert
  dates as well as naive datetimes between them.
  """
  @doc since: "1.5.0"
  @spec compatible_calendars?(Calendar.calendar(), Calendar.calendar()) :: boolean
  def compatible_calendars?(calendar, calendar), do: true

  def compatible_calendars?(calendar1, calendar2) do
    calendar1.day_rollover_relative_to_midnight_utc() ==
      calendar2.day_rollover_relative_to_midnight_utc()
  end

  @doc """
  Returns a microsecond tuple truncated to a given precision (`:microsecond`,
  `:millisecond` or `:second`).
  """
  @doc since: "1.6.0"
  @spec truncate(Calendar.microsecond(), :microsecond | :millisecond | :second) ::
          Calendar.microsecond()
  def truncate(microsecond_tuple, :microsecond), do: microsecond_tuple

  def truncate({microsecond, precision}, :millisecond) do
    output_precision = min(precision, 3)
    {div(microsecond, 1000) * 1000, output_precision}
  end

  def truncate(_, :second), do: {0, 0}

  @doc """
  Sets the current time zone database.
  """
  @doc since: "1.8.0"
  @spec put_time_zone_database(time_zone_database()) :: :ok
  def put_time_zone_database(database) do
    Application.put_env(:elixir, :time_zone_database, database)
  end

  @doc """
  Gets the current time zone database.
  """
  @doc since: "1.8.0"
  @spec get_time_zone_database() :: time_zone_database()
  def get_time_zone_database() do
    Application.fetch_env!(:elixir, :time_zone_database)
  end

  @doc """
  Formats received datetime into a string.

  The datetime can be any of the Calendar types (`Time`, `Date`,
  `NaiveDateTime`, and `DateTime`) or any map, as long as they
  contain all of the relevant fields necessary for formatting.
  For example, if you use `%Y` to format the year, the datetime
  must have the `:year` field. Therefore, if you pass a `Time`,
  or a map without the `:year` field to a format that expects `%Y`,
  an error will be raised.

  ## Options

    * `:preferred_datetime` - a string for the preferred format to show datetimes,
      it can't contain the `%c` format and defaults to `"%Y-%m-%d %H:%M:%S"`
      if the option is not received

    * `: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

    * `: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 function that receives either `:am` or `:pm` and returns
      the name of the period of the day, if the option is not received it defaults
      to a function that returns `"am"` and `"pm"`, respectively

    *  `:month_names` - a function that receives a number and returns the name of
      the corresponding month, if the option is not received it defaults to a
      function that returns the month names in English

    * `:abbreviated_month_names` - a function that receives a number and returns the
      abbreviated name of the corresponding month, if the option is not received it
      defaults to a function that returns the abbreviated month names in English

    * `:day_of_week_names` - a function that receives a number and returns the name of
      the corresponding day of week, if the option is not received it defaults to a
      function that returns the day of week names in English

    * `:abbreviated_day_of_week_names` - a function that receives a number and returns
      the abbreviated name of the corresponding day of week, if the option is not received
      it defaults to a function that returns the abbreviated day of week names in English

  ## Formatting syntax

  The formatting syntax for strftime is a sequence of characters in the following format:

      %<padding><width><format>

  where:

    * `%`: indicates the start of a formatted section
    * `<padding>`: set the padding (see below)
    * `<width>`: a number indicating the minimum size of the formatted section
    * `<format>`: the format itself (see below)

  ### Accepted padding options

    * `-`: no padding, removes all padding from the format
    * `_`: pad with spaces
    * `0`: pad with zeroes

  ### Accepted formats

  The accepted formats are:

  Format | Description                                                             | Examples (in ISO)
  :----- | :-----------------------------------------------------------------------| :------------------------
  a      | Abbreviated name of day                                                 | Mon
  A      | Full name of day                                                        | Monday
  b      | Abbreviated month name                                                  | Jan
  B      | Full month name                                                         | January
  c      | Preferred date+time representation                                      | 2018-10-17 12:34:56
  d      | Day of the month                                                        | 01, 31
  f      | Microseconds *(does not support width and padding modifiers)*           | 000000, 999999, 0123
  H      | Hour using a 24-hour clock                                              | 00, 23
  I      | Hour using a 12-hour clock                                              | 01, 12
  j      | Day of the year                                                         | 001, 366
  m      | Month                                                                   | 01, 12
  M      | Minute                                                                  | 00, 59
  p      | "AM" or "PM" (noon is "PM", midnight as "AM")                           | AM, PM
  P      | "am" or "pm" (noon is "pm", midnight as "am")                           | am, pm
  q      | Quarter                                                                 | 1, 2, 3, 4
  S      | Second                                                                  | 00, 59, 60
  u      | Day of the week                                                         | 1 (Monday), 7 (Sunday)
  x      | Preferred date (without time) representation                            | 2018-10-17
  X      | Preferred time (without date) representation                            | 12:34:56
  y      | Year as 2-digits                                                        | 01, 01, 86, 18
  Y      | Year                                                                    | -0001, 0001, 1986
  z      | +hhmm/-hhmm time zone offset from UTC (empty string if naive)           | +0300, -0530
  Z      | Time zone abbreviation (empty string if naive)                          | CET, BRST
  %      | Literal "%" character                                                   | %

  Any other character will be interpreted as an invalid format and raise an error

  ## Examples

  Without options:

      iex> Calendar.strftime(~U[2019-08-26 13:52:06.0Z], "%y-%m-%d %I:%M:%S %p")
      "19-08-26 01:52:06 PM"

      iex> Calendar.strftime(~U[2019-08-26 13:52:06.0Z], "%a, %B %d %Y")
      "Mon, August 26 2019"

      iex> Calendar.strftime(~U[2020-04-02 13:52:06.0Z], "%B %-d, %Y")
      "April 2, 2020"

      iex> Calendar.strftime(~U[2019-08-26 13:52:06.0Z], "%c")
      "2019-08-26 13:52:06"

  With options:

      iex> Calendar.strftime(~U[2019-08-26 13:52:06.0Z], "%c", preferred_datetime: "%H:%M:%S %d-%m-%y")
      "13:52:06 26-08-19"

      iex> Calendar.strftime(
      ...>  ~U[2019-08-26 13:52:06.0Z],
      ...>  "%A",
      ...>  day_of_week_names: fn day_of_week ->
      ...>    {"segunda-feira", "terça-feira", "quarta-feira", "quinta-feira",
      ...>    "sexta-feira", "sábado", "domingo"}
      ...>    |> elem(day_of_week - 1)
      ...>  end
      ...>)
      "segunda-feira"

      iex> Calendar.strftime(
      ...>  ~U[2019-08-26 13:52:06.0Z],
      ...>  "%B",
      ...>  month_names: fn month ->
      ...>    {"январь", "февраль", "март", "апрель", "май", "июнь",
      ...>    "июль", "август", "сентябрь", "октябрь", "ноябрь", "декабрь"}
      ...>    |> elem(month - 1)
      ...>  end
      ...>)
      "август"
  """
  @doc since: "1.11.0"
  @spec strftime(map(), String.t(), keyword()) :: String.t()
  def strftime(date_or_time_or_datetime, string_format, user_options \\ [])
      when is_map(date_or_time_or_datetime) and is_binary(string_format) do
    parse(
      string_format,
      date_or_time_or_datetime,
      options(user_options),
      []
    )
    |> IO.iodata_to_binary()
  end

  defp parse("", _datetime, _format_options, acc),
    do: Enum.reverse(acc)

  defp parse("%" <> rest, datetime, format_options, acc),
    do: parse_modifiers(rest, nil, nil, {datetime, format_options, acc})

  defp parse(<<char, rest::binary>>, datetime, format_options, acc),
    do: parse(rest, datetime, format_options, [char | acc])

  defp parse_modifiers("-" <> rest, width, nil, parser_data) do
    parse_modifiers(rest, width, "", parser_data)
  end

  defp parse_modifiers("0" <> rest, nil, nil, parser_data) do
    parse_modifiers(rest, nil, ?0, parser_data)
  end

  defp parse_modifiers("_" <> rest, width, nil, parser_data) do
    parse_modifiers(rest, width, ?\s, parser_data)
  end

  defp parse_modifiers(<<digit, rest::binary>>, width, pad, parser_data) when digit in ?0..?9 do
    new_width = (width || 0) * 10 + (digit - ?0)

    parse_modifiers(rest, new_width, pad, parser_data)
  end

  # set default padding if none was specified
  defp parse_modifiers(<<format, _::binary>> = rest, width, nil, parser_data) do
    parse_modifiers(rest, width, default_pad(format), parser_data)
  end

  # set default width if none was specified
  defp parse_modifiers(<<format, _::binary>> = rest, nil, pad, parser_data) do
    parse_modifiers(rest, default_width(format), pad, parser_data)
  end

  defp parse_modifiers(rest, width, pad, {datetime, format_options, acc}) do
    format_modifiers(rest, width, pad, datetime, format_options, acc)
  end

  defp am_pm(hour, format_options) when hour > 11 do
    format_options.am_pm_names.(:pm)
  end

  defp am_pm(hour, format_options) when hour <= 11 do
    format_options.am_pm_names.(:am)
  end

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

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

  # Literally just %
  defp format_modifiers("%" <> rest, width, pad, datetime, format_options, acc) do
    parse(rest, datetime, format_options, [pad_leading("%", width, pad) | acc])
  end

  # Abbreviated name of day
  defp format_modifiers("a" <> rest, width, pad, datetime, format_options, acc) do
    result =
      datetime
      |> Date.day_of_week()
      |> format_options.abbreviated_day_of_week_names.()
      |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Full name of day
  defp format_modifiers("A" <> rest, width, pad, datetime, format_options, acc) do
    result =
      datetime
      |> Date.day_of_week()
      |> format_options.day_of_week_names.()
      |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Abbreviated month name
  defp format_modifiers("b" <> rest, width, pad, datetime, format_options, acc) do
    result =
      datetime.month
      |> format_options.abbreviated_month_names.()
      |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Full month name
  defp format_modifiers("B" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.month |> format_options.month_names.() |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Preferred date+time representation
  defp format_modifiers(
         "c" <> _rest,
         _width,
         _pad,
         _datetime,
         %{preferred_datetime_invoked: true},
         _acc
       ) do
    raise ArgumentError,
          "tried to format preferred_datetime within another preferred_datetime format"
  end

  defp format_modifiers("c" <> rest, width, pad, datetime, format_options, acc) do
    result =
      format_options.preferred_datetime
      |> parse(datetime, %{format_options | preferred_datetime_invoked: true}, [])
      |> pad_preferred(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Day of the month
  defp format_modifiers("d" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.day |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Microseconds
  defp format_modifiers("f" <> rest, _width, _pad, datetime, format_options, acc) do
    {microsecond, precision} = datetime.microsecond

    result =
      microsecond
      |> Integer.to_string()
      |> String.pad_leading(6, "0")
      |> binary_part(0, max(precision, 1))

    parse(rest, datetime, format_options, [result | acc])
  end

  # Hour using a 24-hour clock
  defp format_modifiers("H" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.hour |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Hour using a 12-hour clock
  defp format_modifiers("I" <> rest, width, pad, datetime, format_options, acc) do
    result = (rem(datetime.hour() + 23, 12) + 1) |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Day of the year
  defp format_modifiers("j" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime |> Date.day_of_year() |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Month
  defp format_modifiers("m" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.month |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Minute
  defp format_modifiers("M" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.minute |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # "AM" or "PM" (noon is "PM", midnight as "AM")
  defp format_modifiers("p" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.hour |> am_pm(format_options) |> String.upcase() |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # "am" or "pm" (noon is "pm", midnight as "am")
  defp format_modifiers("P" <> rest, width, pad, datetime, format_options, acc) do
    result =
      datetime.hour
      |> am_pm(format_options)
      |> String.downcase()
      |> pad_leading(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Quarter
  defp format_modifiers("q" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime |> Date.quarter_of_year() |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Second
  defp format_modifiers("S" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.second |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Day of the week
  defp format_modifiers("u" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime |> Date.day_of_week() |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Preferred date (without time) representation
  defp format_modifiers(
         "x" <> _rest,
         _width,
         _pad,
         _datetime,
         %{preferred_date_invoked: true},
         _acc
       ) do
    raise ArgumentError,
          "tried to format preferred_date within another preferred_date format"
  end

  defp format_modifiers("x" <> rest, width, pad, datetime, format_options, acc) do
    result =
      format_options.preferred_date
      |> parse(datetime, %{format_options | preferred_date_invoked: true}, [])
      |> pad_preferred(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Preferred time (without date) representation
  defp format_modifiers(
         "X" <> _rest,
         _width,
         _pad,
         _datetime,
         %{preferred_time_invoked: true},
         _acc
       ) do
    raise ArgumentError,
          "tried to format preferred_time within another preferred_time format"
  end

  defp format_modifiers("X" <> rest, width, pad, datetime, format_options, acc) do
    result =
      format_options.preferred_time
      |> parse(datetime, %{format_options | preferred_time_invoked: true}, [])
      |> pad_preferred(width, pad)

    parse(rest, datetime, format_options, [result | acc])
  end

  # Year as 2-digits
  defp format_modifiers("y" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.year |> rem(100) |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # Year
  defp format_modifiers("Y" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime.year |> Integer.to_string() |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  # +hhmm/-hhmm time zone offset from UTC (empty string if naive)
  defp format_modifiers(
         "z" <> rest,
         width,
         pad,
         datetime = %{utc_offset: utc_offset, std_offset: std_offset},
         format_options,
         acc
       ) do
    absolute_offset = abs(utc_offset + std_offset)

    offset_number =
      Integer.to_string(div(absolute_offset, 3600) * 100 + rem(div(absolute_offset, 60), 60))

    sign = if utc_offset + std_offset >= 0, do: "+", else: "-"
    result = "#{sign}#{pad_leading(offset_number, width, pad)}"
    parse(rest, datetime, format_options, [result | acc])
  end

  defp format_modifiers("z" <> rest, _width, _pad, datetime, format_options, acc) do
    parse(rest, datetime, format_options, ["" | acc])
  end

  # Time zone abbreviation (empty string if naive)
  defp format_modifiers("Z" <> rest, width, pad, datetime, format_options, acc) do
    result = datetime |> Map.get(:zone_abbr, "") |> pad_leading(width, pad)
    parse(rest, datetime, format_options, [result | acc])
  end

  defp format_modifiers(rest, _width, _pad, _datetime, _format_options, _acc) do
    {next, _rest} = String.next_grapheme(rest) || {"", ""}
    raise ArgumentError, "invalid strftime format: %#{next}"
  end

  defp pad_preferred(result, width, pad) when length(result) < width do
    pad_preferred([pad | result], width, pad)
  end

  defp pad_preferred(result, _width, _pad), do: result

  defp pad_leading(string, count, padding) do
    to_pad = count - byte_size(string)
    if to_pad > 0, do: do_pad_leading(to_pad, padding, string), else: string
  end

  defp do_pad_leading(0, _, acc), do: acc

  defp do_pad_leading(count, padding, acc),
    do: do_pad_leading(count - 1, padding, [padding | acc])

  defp options(user_options) do
    default_options = %{
      preferred_date: "%Y-%m-%d",
      preferred_time: "%H:%M:%S",
      preferred_datetime: "%Y-%m-%d %H:%M:%S",
      am_pm_names: fn
        :am -> "am"
        :pm -> "pm"
      end,
      month_names: fn month ->
        {"January", "February", "March", "April", "May", "June", "July", "August", "September",
         "October", "November", "December"}
        |> elem(month - 1)
      end,
      day_of_week_names: fn day_of_week ->
        {"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"}
        |> elem(day_of_week - 1)
      end,
      abbreviated_month_names: fn month ->
        {"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
        |> elem(month - 1)
      end,
      abbreviated_day_of_week_names: fn day_of_week ->
        {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"} |> elem(day_of_week - 1)
      end,
      preferred_datetime_invoked: false,
      preferred_date_invoked: false,
      preferred_time_invoked: false
    }

    Enum.reduce(user_options, default_options, fn {key, value}, acc ->
      if Map.has_key?(acc, key) do
        %{acc | key => value}
      else
        raise ArgumentError, "unknown option #{inspect(key)} given to Calendar.strftime/3"
      end
    end)
  end
end


test presence

test presence

defmodule Romeo.ConnectionTest do
  use ExUnit.Case
  use UserHelper
  use Romeo.XML
  import Romeo.XML

  setup do
    romeo  = build_user("romeo", tls: true)
    juliet = build_user("juliet", resource: "juliet", tls: true)

    setup_presence_subscriptions(romeo[:nickname], juliet[:nickname])

    {:ok, romeo: romeo, juliet: juliet}
  end

  test "connection no TLS" do
    romeo = build_user("romeo")

    {:ok, pid} = Romeo.Connection.start_link(romeo)

    assert_receive {:stanza_received, xmlstreamstart()}
    assert_receive {:stanza_received, xmlel(name: "stream:features")}
    assert_receive {:stanza_received, xmlel(name: "success")}
    assert_receive {:stanza_received, xmlstreamstart()}
    assert_receive {:stanza_received, xmlel(name: "stream:features")}
    assert_receive {:resource_bound, _}
    assert_receive :connection_ready
  end

  test "connection TLS", %{romeo: romeo} do
    {:ok, pid} = Romeo.Connection.start_link(romeo)

    assert_receive {:stanza_received, xmlstreamstart()}
    assert_receive {:stanza_received, xmlel(name: "stream:features")}
    assert_receive {:stanza_received, xmlel(name: "proceed")}
    assert_receive {:stanza_received, xmlstreamstart()}
    assert_receive {:stanza_received, xmlel(name: "stream:features")}
    assert_receive {:stanza_received, xmlel(name: "success")}
    assert_receive {:stanza_received, xmlstreamstart()}
    assert_receive {:stanza_received, xmlel(name: "stream:features")}
    assert_receive {:resource_bound, _}
    assert_receive :connection_ready
  end

  test "sending presence", %{romeo: romeo} do
    {:ok, pid} = Romeo.Connection.start_link(romeo)

    assert_receive :connection_ready

    assert :ok = Romeo.Connection.send(pid, Romeo.Stanza.presence)
    assert_receive {:stanza_received, xmlel(name: "presence") = presence}
    assert attr(presence, "from") == "romeo@localhost/romeo"
    assert attr(presence, "to") == "romeo@localhost/romeo"

    assert :ok = Romeo.Connection.send(pid, Romeo.Stanza.join("lobby@conference.localhost", "romeo"))
    assert_receive {:stanza_received, xmlel(name: "presence") = presence}
    assert attr(presence, "from") == "lobby@conference.localhost/romeo"
  end
end

defmodule Romeo.ConnectionTest do
  use ExUnit.Case
  use UserHelper
  use Romeo.XML
  import Romeo.XML

  setup do
    romeo  = build_user("romeo", tls: true)
    juliet = build_user("juliet", resource: "juliet", tls: true)

    setup_presence_subscriptions(romeo[:nickname], juliet[:nickname])

    {:ok, romeo: romeo, juliet: juliet}
  end

  test "connection no TLS" do
    romeo = build_user("romeo")

    {:ok, pid} = Romeo.Connection.start_link(romeo)

    assert_receive {:stanza_received, xmlstreamstart()}
    assert_receive {:stanza_received, xmlel(name: "stream:features")}
    assert_receive {:stanza_received, xmlel(name: "success")}
    assert_receive {:stanza_received, xmlstreamstart()}
    assert_receive {:stanza_received, xmlel(name: "stream:features")}
    assert_receive {:resource_bound, _}
    assert_receive :connection_ready
  end

  test "connection TLS", %{romeo: romeo} do
    {:ok, pid} = Romeo.Connection.start_link(romeo)

    assert_receive {:stanza_received, xmlstreamstart()}
    assert_receive {:stanza_received, xmlel(name: "stream:features")}
    assert_receive {:stanza_received, xmlel(name: "proceed")}
    assert_receive {:stanza_received, xmlstreamstart()}
    assert_receive {:stanza_received, xmlel(name: "stream:features")}
    assert_receive {:stanza_received, xmlel(name: "success")}
    assert_receive {:stanza_received, xmlstreamstart()}
    assert_receive {:stanza_received, xmlel(name: "stream:features")}
    assert_receive {:resource_bound, _}
    assert_receive :connection_ready
  end
end


Remove an unused function clause in MarcoPolo.Protocol

defmodule MarcoPolo.Protocol do
  @moduledoc false

  require Integer

  import MarcoPolo.Protocol.BinaryHelpers

  alias MarcoPolo.GenericParser, as: GP
  alias MarcoPolo.Error
  alias MarcoPolo.Document
  alias MarcoPolo.Protocol.RecordSerialization

  @type encodable_term ::
    boolean
    | nil
    | binary
    | integer
    | iolist
    | {:short, integer}
    | {:int, integer}
    | {:long, integer}
    | {:raw, binary}
    | MarcoPolo.Document.t

  @ok    <<0>>
  @error <<1>>

  @doc """
  Encodes an operation given its name (`op_name`) and a list of arguments
  (`args`).
  """
  @spec encode_op(atom, [encodable_term]) :: iodata
  def encode_op(op_name, args) do
    [req_code(op_name), encode_list_of_terms(args)]
  end

  @doc """
  Encdes a list of terms.
  """
  @spec encode_list_of_terms([encodable_term]) :: iodata
  def encode_list_of_terms(list) when is_list(list) do
    Enum.map list, &encode_term/1
  end

  @doc """
  Encodes a given term according to the binary protocol.

  The type of `term` is usually inferred by its value but in some cases it can
  be specified by using a tagged tuple. For example, to force encodng of an
  integer as an OrientDB short, you can pass `{:short, n}`.
  """
  # Made public for testing.
  @spec encode_term(encodable_term) :: iodata
  def encode_term(term)

  # Booleans.
  def encode_term(true),  do: <<1>>
  def encode_term(false), do: <<0>>

  # nil.
  def encode_term(nil), do: encode_term({:int, -1})

  # Strings and bytes.
  def encode_term(str) when is_binary(str), do: encode_term({:int, byte_size(str)}) <> str

  # Encoding an Elixir integer defaults to encoding an OrientDB int (4 bytes).
  def encode_term(i) when is_integer(i), do: encode_term({:int, i})

  # Typed integers (short, int and long) have to be tagged.
  def encode_term({:short, i}), do: <<i :: short>>
  def encode_term({:int, i}),   do: <<i :: int>>
  def encode_term({:long, i}),  do: <<i :: long>>

  # A list is assumed to be iodata and is converted to binary before being serialized.
  def encode_term(data) when is_list(data), do: [encode_term(IO.iodata_length(data)), data]

  # Raw bytes (that have no leading length, just the bytes).
  def encode_term({:raw, bytes}) when is_binary(bytes) or is_list(bytes), do: bytes

  # An entire record.
  def encode_term(%Document{} = record), do: encode_term(RecordSerialization.encode(record))

  @doc """
  Decodes an instance of `type` from `data`.

  Returns a `{value, rest}` tuple or the `:incomplete` atom if `data` doesn't
  contain a full instance of `type`.
  """
  @spec decode_term(binary, atom) :: {term, binary} | :incomplete
  def decode_term(data, type)

  def decode_term(<<-1 :: int, rest :: binary>>, type) when type in [:string, :bytes] do
    {nil, rest}
  end

  def decode_term(<<length :: int, data :: binary>>, type) when type in [:string, :bytes] do
    case data do
      <<parsed :: bytes-size(length), rest :: binary>> -> {parsed, rest}
      _                                                -> :incomplete
    end
  end

  def decode_term(<<byte, rest :: binary>>, :byte), do: {byte, rest}

  def decode_term(<<i :: short, rest :: binary>>, :short), do: {i, rest}
  def decode_term(<<i :: int, rest :: binary>>, :int),     do: {i, rest}
  def decode_term(<<i :: long, rest :: binary>>, :long),   do: {i, rest}

  def decode_term(_data, _type) do
    :incomplete
  end

  def parse_connection_resp(data, connection_op) do
    parse_resp(connection_op, data, nil)
  end

  @doc """
  Parses the response to a given operation.

  `op_name` (the name of the operation as an atom, like `:record_load`) is used
  to determine the structure of the response. `schema` is passed down to the
  record deserialization (in case there are records) in order to decode possible
  property ids.
  """
  @spec parse_resp(atom, binary, Dict.t) :: :ok
  def parse_resp(op_name, data, schema) do
    case parse_header(data) do
      :incomplete ->
        :incomplete
      {:ok, sid, rest} ->
        case parse_resp_contents(op_name, rest, schema) do
          {:unknown_property_id, rest} ->
            {:error, :unknown_property_id, rest}
          {resp, rest} ->
            {:ok, sid, resp, rest}
          :incomplete ->
            :incomplete
        end
      {:error, _sid, rest} ->
        parse_errors(rest)
    end
  end

  defp parse_header(@ok <> <<sid :: int, rest :: binary>>),
    do: {:ok, sid, rest}
  defp parse_header(@error <> <<sid :: int, rest :: binary>>),
    do: {:error, sid, rest}
  defp parse_header(_),
    do: :incomplete

  defp parse_errors(data) do
    case parse_errors(data, []) do
      {errors, rest} -> {:error, Error.from_errors(errors), rest}
      :incomplete    -> :incomplete
    end
  end

  defp parse_errors(<<1, rest :: binary>>, acc) do
    case GP.parse(rest, [&decode_term(&1, :string), &decode_term(&1, :string)]) do
      {[class, message], rest} -> parse_errors(rest, [{class, message}|acc])
      :incomplete              -> :incomplete
    end
  end

  defp parse_errors(<<0, rest :: binary>>, acc) do
    case decode_term(rest, :bytes) do
      {_exception_dump, rest} -> {Enum.reverse(acc), rest}
      :incomplete             -> :incomplete
    end
  end

  defp parse_resp_contents(:connect, data, _) do
    GP.parse(data, [&decode_term(&1, :int), &decode_term(&1, :bytes)])
  end

  defp parse_resp_contents(:db_open, data, _) do
    parsers = [
      &decode_term(&1, :int),   # sid
      &decode_term(&1, :bytes), # token
      GP.array_parser(
        &decode_term(&1, :short),                       # number of clusters
        [&decode_term(&1, :string), &decode_term(&1, :short)] # cluster name + cluster id
      ),
      &decode_term(&1, :bytes), # cluster config
      &decode_term(&1, :string), # orientdb release
    ]

    case GP.parse(data, parsers) do
      {[sid, token, _clusters, _config, _release], rest} -> {[sid, token], rest}
      :incomplete                                        -> :incomplete
    end
  end

  defp parse_resp_contents(:db_create, rest, _), do: {nil, rest}

  defp parse_resp_contents(:db_exist, data, _) do
    case decode_term(data, :byte) do
      {exists?, rest} -> {exists? == 1, rest}
      :incomplete     -> :incomplete
    end
  end

  defp parse_resp_contents(:db_drop, rest, _), do: {nil, rest}

  defp parse_resp_contents(:db_size, data, _), do: decode_term(data, :long)

  defp parse_resp_contents(:db_countrecords, data, _), do: decode_term(data, :long)

  defp parse_resp_contents(:db_reload, data, _) do
    cluster_parsers = [&decode_term(&1, :string), &decode_term(&1, :short)]
    array_parser    = GP.array_parser(&decode_term(&1, :short), cluster_parsers)
    GP.parse(data, array_parser)
  end

  # REQUEST_RECORD_LOAD and REQUEST_RECORD_LOAD_IF_VERSION_NOT_LATEST reply in
  # the exact same way.
  defp parse_resp_contents(op, data, schema)
      when op in [:record_load, :record_load_if_version_not_latest] do
    parse_resp_to_record_load(data, [], schema)
  end

  defp parse_resp_contents(:record_create, data, _) do
    parsers = [
      &decode_term(&1, :short), # cluster id
      &decode_term(&1, :long),  # cluster position
      &decode_term(&1, :int),   # version
      &parse_collection_changes/1,
    ]

    case GP.parse(data, parsers) do
      {[cluster_id, position, version, _], rest} ->
        {[cluster_id, position, version], rest}
      :incomplete ->
        :incomplete
    end
  end

  defp parse_resp_contents(:record_delete, data, _) do
    case decode_term(data, :byte) do
      {0, rest}   -> {false, rest}
      {1, rest}   -> {true, rest}
      :incomplete -> :incomplete
    end
  end

  @null_result       ?n
  @list              ?l
  @set               ?s
  @single_record     ?r
  @serialized_result ?a

  defp parse_resp_contents(:command, data, schema) do
    parse_resp_to_command(data, schema)
  end

  defp parse_resp_to_record_load(<<1, rest :: binary>>, acc, schema) do
    parsers = [
      &decode_term(&1, :byte),  # version
      &decode_term(&1, :int),   # type
      &decode_term(&1, :bytes), # contents
    ]

    case GP.parse(rest, parsers) do
      {[_type, version, record_content], rest} ->
        case RecordSerialization.decode(record_content, schema) do
          :unknown_property_id ->
            {:unknown_property_id, rest}
          record ->
            record = %{record | version: version}
            parse_resp_to_record_load(rest, [record|acc], schema)
        end
      :incomplete ->
        :incomplete
    end
  end

  defp parse_resp_to_record_load(<<0, rest :: binary>>, acc, _) do
    {Enum.reverse(acc), rest}
  end

  defp parse_resp_to_record_load(_, _acc, _) do
    :incomplete
  end

  defp parse_resp_to_command(<<type, data :: binary>>, schema)
      when type in [@list, @set] do
    parsers = [GP.array_parser(&decode_term(&1, :int), &parse_record_with_rid(&1, schema)),
               &decode_term(&1, :byte)]

    case GP.parse(data, parsers) do
      # TODO find out why OrientDB shoves a 0 byte at the end of this list, not
      # mentioned in the docs :(
      {[records, 0], rest} -> {records, rest}
      _                    -> :incomplete
    end
  end

  defp parse_resp_to_command(<<@single_record, rest :: binary>>, schema) do
    case GP.parse(rest, [&parse_record_with_rid(&1, schema), &decode_term(&1, :byte)]) do
      # TODO find out why OrientDB shoves a 0 byte at the end of this list, not
      # mentioned in the docs :(
      {[record, 0], rest} ->
        {record, rest}
      :incomplete ->
        :incomplete
    end
  end

  defp parse_resp_to_command(<<@serialized_result, rest :: binary>>, _) do
    case GP.parse(rest, [&decode_term(&1, :bytes), &decode_term(&1, :byte)]) do
      # TODO find out why OrientDB shoves a 0 byte at the end of this binary
      # dump, not mentioned in the docs :(
      {[binary, 0], rest} -> {binary, rest}
      _                   -> :incomplete
    end
  end

  defp parse_resp_to_command(<<@null_result, rest :: binary>>, _) do
    # TODO find out why OrientDB shoves a 0 byte at the end of this binary
    # dump, not mentioned in the docs :(
    <<0, rest :: binary>> = rest
    {nil, rest}
  end

  defp parse_resp_to_command(_, _) do
    :incomplete
  end

  # Meaning of the first two bytes in a record definition:
  # 0  - full-fledged record
  # -2 - null record
  # -3 - RID only (cluster_id as a short, cluster_position as a long)

  defp parse_record_with_rid(<<0 :: short, rest :: binary>>, schema) do
    parsers = [
      &decode_term(&1, :byte),
      &decode_term(&1, :short),
      &decode_term(&1, :long),
      &decode_term(&1, :int),
      &decode_term(&1, :bytes)
    ]

    case GP.parse(rest, parsers) do
      {[_record_type, _cluster_id, _cluster_pos, version, record_content], rest} ->
        case RecordSerialization.decode(record_content, schema) do
          :unknown_property_id ->
            {:unknown_property_id, rest}
          record ->
            record = %{record | version: version}
            {record, rest}
        end
      :incomplete ->
        :incomplete
    end
  end

  defp parse_record_with_rid(<<-2 :: short, rest :: binary>>, _schema) do
    {nil, rest}
  end

  defp parse_record_with_rid(<<-3 :: short, rest :: binary>>, _schema) do
    GP.parse(rest, [&decode_term(&1, :short), &decode_term(&1, :long)])
  end

  defp parse_record_with_rid(_, _schema) do
    :incomplete
  end

  defp parse_collection_changes(data) do
    array_elem_parsers = [
      &decode_term(&1, :long),
      &decode_term(&1, :long),
      &decode_term(&1, :long),
      &decode_term(&1, :long),
      &decode_term(&1, :int),
    ]

    GP.parse(data, GP.array_parser(&decode_term(&1, :int), array_elem_parsers))
  end

  defp req_code(:shutdown),                          do: 1
  defp req_code(:connect),                           do: 2
  defp req_code(:db_open),                           do: 3
  defp req_code(:db_create),                         do: 4
  defp req_code(:db_exist),                          do: 6
  defp req_code(:db_drop),                           do: 7
  defp req_code(:config_get),                        do: 70
  defp req_code(:config_set),                        do: 71
  defp req_code(:config_list),                       do: 72
  defp req_code(:db_list),                           do: 74
  defp req_code(:db_close),                          do: 5
  defp req_code(:db_size),                           do: 8
  defp req_code(:db_countrecords),                   do: 9
  defp req_code(:datacluster_copy),                  do: 14
  defp req_code(:datacluster_lh_cluster_is_used),    do: 16
  defp req_code(:record_metadata),                   do: 29
  defp req_code(:record_load),                       do: 30
  defp req_code(:record_create),                     do: 31
  defp req_code(:record_update),                     do: 32
  defp req_code(:record_delete),                     do: 33
  defp req_code(:record_copy),                       do: 34
  defp req_code(:record_clean_out),                  do: 38
  defp req_code(:positions_floor),                   do: 39
  defp req_code(:command),                           do: 41
  defp req_code(:positions_ceiling),                 do: 42
  defp req_code(:record_load_if_version_not_latest), do: 44
  defp req_code(:tx_commit),                         do: 60
  defp req_code(:db_reload),                         do: 73
  defp req_code(:push_record),                       do: 79
  defp req_code(:push_distrib_config),               do: 80
  defp req_code(:db_copy),                           do: 90
  defp req_code(:replication),                       do: 91
  defp req_code(:cluster),                           do: 92
  defp req_code(:db_transfer),                       do: 93
  defp req_code(:db_freeze),                         do: 94
  defp req_code(:db_release),                        do: 95
  defp req_code(:create_sbtree_bonsai),              do: 110
  defp req_code(:sbtree_bonsai_get),                 do: 111
  defp req_code(:sbtree_bonsai_first_key),           do: 112
  defp req_code(:sbtree_bonsai_get_entries_major),   do: 113
  defp req_code(:ridbag_get_size),                   do: 114
end

defmodule MarcoPolo.Protocol do
  @moduledoc false

  require Integer

  import MarcoPolo.Protocol.BinaryHelpers

  alias MarcoPolo.GenericParser, as: GP
  alias MarcoPolo.Error
  alias MarcoPolo.Document
  alias MarcoPolo.Protocol.RecordSerialization

  @type encodable_term ::
    boolean
    | nil
    | binary
    | integer
    | iolist
    | {:short, integer}
    | {:int, integer}
    | {:long, integer}
    | {:raw, binary}
    | MarcoPolo.Document.t

  @ok    <<0>>
  @error <<1>>

  @doc """
  Encodes an operation given its name (`op_name`) and a list of arguments
  (`args`).
  """
  @spec encode_op(atom, [encodable_term]) :: iodata
  def encode_op(op_name, args) do
    [req_code(op_name), encode_list_of_terms(args)]
  end

  @doc """
  Encdes a list of terms.
  """
  @spec encode_list_of_terms([encodable_term]) :: iodata
  def encode_list_of_terms(list) when is_list(list) do
    Enum.map list, &encode_term/1
  end

  @doc """
  Encodes a given term according to the binary protocol.

  The type of `term` is usually inferred by its value but in some cases it can
  be specified by using a tagged tuple. For example, to force encodng of an
  integer as an OrientDB short, you can pass `{:short, n}`.
  """
  # Made public for testing.
  @spec encode_term(encodable_term) :: iodata
  def encode_term(term)

  # Booleans.
  def encode_term(true),  do: <<1>>
  def encode_term(false), do: <<0>>

  # nil.
  def encode_term(nil), do: encode_term({:int, -1})

  # Strings and bytes.
  def encode_term(str) when is_binary(str), do: encode_term({:int, byte_size(str)}) <> str

  # Encoding an Elixir integer defaults to encoding an OrientDB int (4 bytes).
  def encode_term(i) when is_integer(i), do: encode_term({:int, i})

  # Typed integers (short, int and long) have to be tagged.
  def encode_term({:short, i}), do: <<i :: short>>
  def encode_term({:int, i}),   do: <<i :: int>>
  def encode_term({:long, i}),  do: <<i :: long>>

  # A list is assumed to be iodata and is converted to binary before being serialized.
  def encode_term(data) when is_list(data), do: [encode_term(IO.iodata_length(data)), data]

  # Raw bytes (that have no leading length, just the bytes).
  def encode_term({:raw, bytes}) when is_binary(bytes) or is_list(bytes), do: bytes

  # An entire record.
  def encode_term(%Document{} = record), do: encode_term(RecordSerialization.encode(record))

  @doc """
  Decodes an instance of `type` from `data`.

  Returns a `{value, rest}` tuple or the `:incomplete` atom if `data` doesn't
  contain a full instance of `type`.
  """
  @spec decode_term(binary, atom) :: {term, binary} | :incomplete
  def decode_term(data, type)

  def decode_term(<<-1 :: int, rest :: binary>>, type) when type in [:string, :bytes] do
    {nil, rest}
  end

  def decode_term(<<length :: int, data :: binary>>, type) when type in [:string, :bytes] do
    case data do
      <<parsed :: bytes-size(length), rest :: binary>> -> {parsed, rest}
      _                                                -> :incomplete
    end
  end

  def decode_term(<<byte, rest :: binary>>, :byte), do: {byte, rest}

  def decode_term(<<i :: short, rest :: binary>>, :short), do: {i, rest}
  def decode_term(<<i :: int, rest :: binary>>, :int),     do: {i, rest}
  def decode_term(<<i :: long, rest :: binary>>, :long),   do: {i, rest}

  def decode_term(_data, _type) do
    :incomplete
  end

  def parse_connection_resp(data, connection_op) do
    parse_resp(connection_op, data, nil)
  end

  @doc """
  Parses the response to a given operation.

  `op_name` (the name of the operation as an atom, like `:record_load`) is used
  to determine the structure of the response. `schema` is passed down to the
  record deserialization (in case there are records) in order to decode possible
  property ids.
  """
  @spec parse_resp(atom, binary, Dict.t) :: :ok
  def parse_resp(op_name, data, schema) do
    case parse_header(data) do
      :incomplete ->
        :incomplete
      {:ok, sid, rest} ->
        case parse_resp_contents(op_name, rest, schema) do
          {:unknown_property_id, rest} ->
            {:error, :unknown_property_id, rest}
          {resp, rest} ->
            {:ok, sid, resp, rest}
          :incomplete ->
            :incomplete
        end
      {:error, _sid, rest} ->
        parse_errors(rest)
    end
  end

  defp parse_header(@ok <> <<sid :: int, rest :: binary>>),
    do: {:ok, sid, rest}
  defp parse_header(@error <> <<sid :: int, rest :: binary>>),
    do: {:error, sid, rest}
  defp parse_header(_),
    do: :incomplete

  defp parse_errors(data) do
    case parse_errors(data, []) do
      {errors, rest} -> {:error, Error.from_errors(errors), rest}
      :incomplete    -> :incomplete
    end
  end

  defp parse_errors(<<1, rest :: binary>>, acc) do
    case GP.parse(rest, [&decode_term(&1, :string), &decode_term(&1, :string)]) do
      {[class, message], rest} -> parse_errors(rest, [{class, message}|acc])
      :incomplete              -> :incomplete
    end
  end

  defp parse_errors(<<0, rest :: binary>>, acc) do
    case decode_term(rest, :bytes) do
      {_exception_dump, rest} -> {Enum.reverse(acc), rest}
      :incomplete             -> :incomplete
    end
  end

  defp parse_resp_contents(:connect, data, _) do
    GP.parse(data, [&decode_term(&1, :int), &decode_term(&1, :bytes)])
  end

  defp parse_resp_contents(:db_open, data, _) do
    parsers = [
      &decode_term(&1, :int),   # sid
      &decode_term(&1, :bytes), # token
      GP.array_parser(
        &decode_term(&1, :short),                       # number of clusters
        [&decode_term(&1, :string), &decode_term(&1, :short)] # cluster name + cluster id
      ),
      &decode_term(&1, :bytes), # cluster config
      &decode_term(&1, :string), # orientdb release
    ]

    case GP.parse(data, parsers) do
      {[sid, token, _clusters, _config, _release], rest} -> {[sid, token], rest}
      :incomplete                                        -> :incomplete
    end
  end

  defp parse_resp_contents(:db_create, rest, _), do: {nil, rest}

  defp parse_resp_contents(:db_exist, data, _) do
    case decode_term(data, :byte) do
      {exists?, rest} -> {exists? == 1, rest}
      :incomplete     -> :incomplete
    end
  end

  defp parse_resp_contents(:db_drop, rest, _), do: {nil, rest}

  defp parse_resp_contents(:db_size, data, _), do: decode_term(data, :long)

  defp parse_resp_contents(:db_countrecords, data, _), do: decode_term(data, :long)

  defp parse_resp_contents(:db_reload, data, _) do
    cluster_parsers = [&decode_term(&1, :string), &decode_term(&1, :short)]
    array_parser    = GP.array_parser(&decode_term(&1, :short), cluster_parsers)
    GP.parse(data, array_parser)
  end

  # REQUEST_RECORD_LOAD and REQUEST_RECORD_LOAD_IF_VERSION_NOT_LATEST reply in
  # the exact same way.
  defp parse_resp_contents(op, data, schema)
      when op in [:record_load, :record_load_if_version_not_latest] do
    parse_resp_to_record_load(data, [], schema)
  end

  defp parse_resp_contents(:record_create, data, _) do
    parsers = [
      &decode_term(&1, :short), # cluster id
      &decode_term(&1, :long),  # cluster position
      &decode_term(&1, :int),   # version
      &parse_collection_changes/1,
    ]

    case GP.parse(data, parsers) do
      {[cluster_id, position, version, _], rest} ->
        {[cluster_id, position, version], rest}
      :incomplete ->
        :incomplete
    end
  end

  defp parse_resp_contents(:record_delete, data, _) do
    case decode_term(data, :byte) do
      {0, rest}   -> {false, rest}
      {1, rest}   -> {true, rest}
      :incomplete -> :incomplete
    end
  end

  @null_result       ?n
  @list              ?l
  @set               ?s
  @single_record     ?r
  @serialized_result ?a

  defp parse_resp_contents(:command, data, schema) do
    parse_resp_to_command(data, schema)
  end

  defp parse_resp_to_record_load(<<1, rest :: binary>>, acc, schema) do
    parsers = [
      &decode_term(&1, :byte),  # version
      &decode_term(&1, :int),   # type
      &decode_term(&1, :bytes), # contents
    ]

    case GP.parse(rest, parsers) do
      {[_type, version, record_content], rest} ->
        case RecordSerialization.decode(record_content, schema) do
          :unknown_property_id ->
            {:unknown_property_id, rest}
          record ->
            record = %{record | version: version}
            parse_resp_to_record_load(rest, [record|acc], schema)
        end
      :incomplete ->
        :incomplete
    end
  end

  defp parse_resp_to_record_load(<<0, rest :: binary>>, acc, _) do
    {Enum.reverse(acc), rest}
  end

  defp parse_resp_to_record_load(_, _acc, _) do
    :incomplete
  end

  defp parse_resp_to_command(<<type, data :: binary>>, schema)
      when type in [@list, @set] do
    parsers = [GP.array_parser(&decode_term(&1, :int), &parse_record_with_rid(&1, schema)),
               &decode_term(&1, :byte)]

    case GP.parse(data, parsers) do
      # TODO find out why OrientDB shoves a 0 byte at the end of this list, not
      # mentioned in the docs :(
      {[records, 0], rest} -> {records, rest}
      _                    -> :incomplete
    end
  end

  defp parse_resp_to_command(<<@single_record, rest :: binary>>, schema) do
    case GP.parse(rest, [&parse_record_with_rid(&1, schema), &decode_term(&1, :byte)]) do
      # TODO find out why OrientDB shoves a 0 byte at the end of this list, not
      # mentioned in the docs :(
      {[record, 0], rest} ->
        {record, rest}
      :incomplete ->
        :incomplete
    end
  end

  defp parse_resp_to_command(<<@serialized_result, rest :: binary>>, _) do
    case GP.parse(rest, [&decode_term(&1, :bytes), &decode_term(&1, :byte)]) do
      # TODO find out why OrientDB shoves a 0 byte at the end of this binary
      # dump, not mentioned in the docs :(
      {[binary, 0], rest} -> {binary, rest}
      _                   -> :incomplete
    end
  end

  defp parse_resp_to_command(<<@null_result, rest :: binary>>, _) do
    # TODO find out why OrientDB shoves a 0 byte at the end of this binary
    # dump, not mentioned in the docs :(
    <<0, rest :: binary>> = rest
    {nil, rest}
  end

  defp parse_resp_to_command(<<byte, _rest :: binary>>, _) do
    raise "first byte of response to REQUEST_COMMAND (#{inspect byte}) is unknown"
  end

  defp parse_resp_to_command(_, _) do
    :incomplete
  end

  # Meaning of the first two bytes in a record definition:
  # 0  - full-fledged record
  # -2 - null record
  # -3 - RID only (cluster_id as a short, cluster_position as a long)

  defp parse_record_with_rid(<<0 :: short, rest :: binary>>, schema) do
    parsers = [
      &decode_term(&1, :byte),
      &decode_term(&1, :short),
      &decode_term(&1, :long),
      &decode_term(&1, :int),
      &decode_term(&1, :bytes)
    ]

    case GP.parse(rest, parsers) do
      {[_record_type, _cluster_id, _cluster_pos, version, record_content], rest} ->
        case RecordSerialization.decode(record_content, schema) do
          :unknown_property_id ->
            {:unknown_property_id, rest}
          record ->
            record = %{record | version: version}
            {record, rest}
        end
      :incomplete ->
        :incomplete
    end
  end

  defp parse_record_with_rid(<<-2 :: short, rest :: binary>>, _schema) do
    {nil, rest}
  end

  defp parse_record_with_rid(<<-3 :: short, rest :: binary>>, _schema) do
    GP.parse(rest, [&decode_term(&1, :short), &decode_term(&1, :long)])
  end

  defp parse_record_with_rid(_, _schema) do
    :incomplete
  end

  defp parse_collection_changes(data) do
    array_elem_parsers = [
      &decode_term(&1, :long),
      &decode_term(&1, :long),
      &decode_term(&1, :long),
      &decode_term(&1, :long),
      &decode_term(&1, :int),
    ]

    GP.parse(data, GP.array_parser(&decode_term(&1, :int), array_elem_parsers))
  end

  defp req_code(:shutdown),                          do: 1
  defp req_code(:connect),                           do: 2
  defp req_code(:db_open),                           do: 3
  defp req_code(:db_create),                         do: 4
  defp req_code(:db_exist),                          do: 6
  defp req_code(:db_drop),                           do: 7
  defp req_code(:config_get),                        do: 70
  defp req_code(:config_set),                        do: 71
  defp req_code(:config_list),                       do: 72
  defp req_code(:db_list),                           do: 74
  defp req_code(:db_close),                          do: 5
  defp req_code(:db_size),                           do: 8
  defp req_code(:db_countrecords),                   do: 9
  defp req_code(:datacluster_copy),                  do: 14
  defp req_code(:datacluster_lh_cluster_is_used),    do: 16
  defp req_code(:record_metadata),                   do: 29
  defp req_code(:record_load),                       do: 30
  defp req_code(:record_create),                     do: 31
  defp req_code(:record_update),                     do: 32
  defp req_code(:record_delete),                     do: 33
  defp req_code(:record_copy),                       do: 34
  defp req_code(:record_clean_out),                  do: 38
  defp req_code(:positions_floor),                   do: 39
  defp req_code(:command),                           do: 41
  defp req_code(:positions_ceiling),                 do: 42
  defp req_code(:record_load_if_version_not_latest), do: 44
  defp req_code(:tx_commit),                         do: 60
  defp req_code(:db_reload),                         do: 73
  defp req_code(:push_record),                       do: 79
  defp req_code(:push_distrib_config),               do: 80
  defp req_code(:db_copy),                           do: 90
  defp req_code(:replication),                       do: 91
  defp req_code(:cluster),                           do: 92
  defp req_code(:db_transfer),                       do: 93
  defp req_code(:db_freeze),                         do: 94
  defp req_code(:db_release),                        do: 95
  defp req_code(:create_sbtree_bonsai),              do: 110
  defp req_code(:sbtree_bonsai_get),                 do: 111
  defp req_code(:sbtree_bonsai_first_key),           do: 112
  defp req_code(:sbtree_bonsai_get_entries_major),   do: 113
  defp req_code(:ridbag_get_size),                   do: 114
end