defmodule Search.DateParser do
  import NimbleParsec

  defp build_datetime(naive, tz_off, tz_hour, tz_minute) do
    tz_hour =
      tz_hour
      |> Integer.to_string()
      |> String.pad_leading(2, "0")

    tz_minute =
      tz_minute
      |> Integer.to_string()
      |> String.pad_leading(2, "0")

    iso8601_string = "#{NaiveDateTime.to_iso8601(naive)}#{tz_off}#{tz_hour}#{tz_minute}"

    # Unbelievable that there is no way to build this with integer arguments.
    # WTF, Elixir?
    {:ok, datetime, _offset} = DateTime.from_iso8601(iso8601_string)

    datetime
  end

  defp timezone_bounds([]), do: ["+", 0, 0]
  defp timezone_bounds([tz_off, tz_hour]), do: [tz_off, tz_hour, 0]
  defp timezone_bounds([tz_off, tz_hour, tz_minute]), do: [tz_off, tz_hour, tz_minute]

  defp date_bounds([year]) do
    lower = %NaiveDateTime{year: year, month: 1, day: 1, hour: 0, minute: 0, second: 0}
    upper = NaiveDateTime.add(lower, 31_536_000, :second)
    [lower, upper]
  end

  defp date_bounds([year, month]) do
    lower = %NaiveDateTime{year: year, month: month, day: 1, hour: 0, minute: 0, second: 0}
    upper = NaiveDateTime.add(lower, 2_592_000, :second)
    [lower, upper]
  end

  defp date_bounds([year, month, day]) do
    lower = %NaiveDateTime{year: year, month: month, day: day, hour: 0, minute: 0, second: 0}
    upper = NaiveDateTime.add(lower, 86400, :second)
    [lower, upper]
  end

  defp date_bounds([year, month, day, hour]) do
    lower = %NaiveDateTime{year: year, month: month, day: day, hour: hour, minute: 0, second: 0}
    upper = NaiveDateTime.add(lower, 3600, :second)
    [lower, upper]
  end

  defp date_bounds([year, month, day, hour, minute]) do
    lower = %NaiveDateTime{
      year: year,
      month: month,
      day: day,
      hour: hour,
      minute: minute,
      second: 0
    }

    upper = NaiveDateTime.add(lower, 60, :second)
    [lower, upper]
  end

  defp date_bounds([year, month, day, hour, minute, second]) do
    lower = %NaiveDateTime{
      year: year,
      month: month,
      day: day,
      hour: hour,
      minute: minute,
      second: second
    }

    upper = NaiveDateTime.add(lower, 1, :second)
    [lower, upper]
  end

  defp absolute_datetime(opts) do
    date = Keyword.fetch!(opts, :date)
    timezone = Keyword.get(opts, :timezone, [])

    [lower, upper] = date_bounds(date)
    [tz_off, tz_hour, tz_minute] = timezone_bounds(timezone)

    lower = build_datetime(lower, tz_off, tz_hour, tz_minute)
    upper = build_datetime(upper, tz_off, tz_hour, tz_minute)

    [lower, upper]
  end

  defp relative_datetime([count, scale]) do
    now = NaiveDateTime.utc_now()

    lower = NaiveDateTime.add(now, count * -scale, :second)
    upper = NaiveDateTime.add(now, (count - 1) * -scale, :second)

    [lower, upper]
  end

  space =
    choice([string(" "), string("\t"), string("\n"), string("\r"), string("\v"), string("\f")])
    |> ignore()

  year = integer(4)
  month = integer(2)
  day = integer(2)

  hour = integer(2)
  minute = integer(2)
  second = integer(2)
  tz_hour = integer(2)
  tz_minute = integer(2)

  ymd_sep = ignore(string("-"))
  hms_sep = ignore(string(":"))
  iso8601_sep = ignore(choice([string("T"), string("t"), space]))
  iso8601_tzsep = choice([string("+"), string("-")])
  zulu = ignore(choice([string("Z"), string("z")]))

  date_part =
    year
    |> optional(
      ymd_sep
      |> concat(month)
      |> optional(
        ymd_sep
        |> concat(day)
        |> optional(
          iso8601_sep
          |> optional(
            hour
            |> optional(
              hms_sep
              |> concat(minute)
              |> optional(concat(hms_sep, second))
            )
          )
        )
      )
    )
    |> tag(:date)

  timezone_part =
    choice([
      iso8601_tzsep
      |> concat(tz_hour)
      |> optional(
        hms_sep
        |> concat(tz_minute)
      )
      |> tag(:timezone),
      zulu
    ])

  absolute_date =
    date_part
    |> optional(timezone_part)
    |> reduce(:absolute_datetime)
    |> unwrap_and_tag(:date)

  relative_date =
    integer(min: 1)
    |> ignore(concat(space, empty()))
    |> choice([
      string("second") |> optional(string("s")) |> replace(1),
      string("minute") |> optional(string("s")) |> replace(60),
      string("hour") |> optional(string("s")) |> replace(3_600),
      string("day") |> optional(string("s")) |> replace(86_400),
      string("week") |> optional(string("s")) |> replace(604_800),
      string("month") |> optional(string("s")) |> replace(2_592_000),
      string("year") |> optional(string("s")) |> replace(31_536_000)
    ])
    |> ignore(string(" ago"))
    |> reduce(:relative_datetime)
    |> unwrap_and_tag(:date)

  date =
    choice([
      absolute_date,
      relative_date
    ])
    |> repeat(space)
    |> eos()
    |> label("a RFC3339 datetime fragment, like `2019-01-01', or relative date, like `3 days ago'")

  defparsec :parse, date
end