# frozen_string_literal: true

#
# Copyright (C) 2022 - present Instructure, Inc.
#
# This file is part of Canvas.
#
# Canvas is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, version 3 of the License.
#
# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along
# with this program. If not, see <http://www.gnu.org/licenses/>.
#

class RruleValidationError < StandardError
  def initialize(msg = "Failed converting an RRULE to natural language")
    super
  end
end

# rubocop:disable Style/IfInsideElse
module RruleHelper
  RECURRING_EVENT_LIMIT = 400

  def rrule_to_natural_language(rrule)
    rropts = rrule_parse(rrule)
    rrule_validate_common_opts(rropts)
    case rropts["FREQ"]
    when "DAILY"
      parse_daily(rropts)
    when "WEEKLY"
      parse_weekly(rropts)
    when "MONTHLY"
      parse_monthly(rropts)
    when "YEARLY"
      parse_yearly(rropts)
    else
      raise RruleValidationError, I18n.t("Invalid FREQ '%{freq}'", freq: rropts["FREQ"])
    end
  rescue => e
    logger.error "RRULE to natural language failure: #{e}"
    nil
  end

  def rrule_parse(rrule)
    Hash[*rrule.sub(/^RRULE:/, "").split(/[;=]/)]
  end

  def rrule_validate_common_opts(rropts)
    raise RruleValidationError, I18n.t("Missing INTERVAL") unless rropts.key?("INTERVAL")
    raise RruleValidationError, I18n.t("INTERVAL must be > 0") unless rropts["INTERVAL"].to_i > 0

    # We do not support never ending series because each event in the series
    # must get created in the db to support the paginated calendar_events api
    raise RruleValidationError, I18n.t("Missing COUNT or UNTIL") unless rropts.key?("COUNT") || rropts.key?("UNTIL")

    if rropts.key?("COUNT")
      raise RruleValidationError, I18n.t("COUNT must be > 0") unless rropts["COUNT"].to_i > 0
      raise RruleValidationError, I18n.t("COUNT must be <= %{limit}", limit: RruleHelper::RECURRING_EVENT_LIMIT) unless rropts["COUNT"].to_i <= RruleHelper::RECURRING_EVENT_LIMIT
    else
      begin
        format_date(rropts["UNTIL"])
      rescue
        raise RruleValidationError, I18n.t("Invalid UNTIL '%{until_date}'", until_date: rropts["UNTIL"])
      end
    end
  end

  private

  DAYS_OF_WEEK = {
    "SU" => I18n.t("Sun"),
    "MO" => I18n.t("Mon"),
    "TU" => I18n.t("Tue"),
    "WE" => I18n.t("Wed"),
    "TH" => I18n.t("Thu"),
    "FR" => I18n.t("Fri"),
    "SA" => I18n.t("Sat")
  }.freeze
  MONTHS = [
    nil,
    I18n.t("January"),
    I18n.t("February"),
    I18n.t("March"),
    I18n.t("April"),
    I18n.t("May"),
    I18n.t("June"),
    I18n.t("July"),
    I18n.t("August"),
    I18n.t("September"),
    I18n.t("October"),
    I18n.t("November"),
    I18n.t("December"),
  ].freeze
  DAYS_IN_MONTH = [nil, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31].freeze
  private_constant :DAYS_OF_WEEK, :MONTHS, :DAYS_IN_MONTH

  def byday_to_days(byday)
    byday.split(/\s*,\s*/).map { |d| DAYS_OF_WEEK[d] }.join(", ")
  end

  def bymonth_to_month(bymonth)
    MONTHS[bymonth]
  end

  # days is array of string digits
  #  e.g. ["1","15"]
  def join_month_dys(days)
    days.join(",")
  end

  def parse_byday(byday)
    byday.split(",").map do |d|
      match = /\A([-+]?\d+)?([A-Z]{2})\z/.match(d)
      raise RruleValidationError, I18n.t("Invalid BYDAY '%{byday}'", byday:) unless match

      {
        occurrence: match[1].to_i,
        day_of_week: DAYS_OF_WEEK[match[2]]
      }
    end
  end

  def parse_bymonth(bymonth)
    month = bymonth.to_i
    raise RruleValidationError, I18n.t("Invalid BYMONTH '%{bymonth}'", bymonth:) unless month.between?(1, 12)

    month
  end

  def parse_bymonthday(bymonthday, month)
    raise RruleValidationError, I18n.t("Unsupported BYMONTHDAY, only a single day is permitted.") unless bymonthday.split(",").length == 1

    monthday = bymonthday.to_i

    # not validating if we're in a leap year
    raise RruleValidationError, I18n.t("Invalid BYMONTHDAY '%{bymonthday}'", bymonthday:) unless monthday.between?(1, DAYS_IN_MONTH[month])

    monthday
  end

  def format_date(date_str)
    date = date_str.split("T")[0]
    year = date[0, 4].to_i
    month = date[4, 2].to_i
    day = date[6, 2].to_i

    I18n.l(Date.new(year, month, day), format: :medium)
  end

  def format_month_day(month, day)
    # 2024 is a leap year, and can handle formatting 2/29
    I18n.l(Date.new(2024, month, day), format: :short)
  end

  def parse_daily(rropts)
    interval = rropts["INTERVAL"].to_i
    times = rropts["COUNT"]
    until_date = rropts["UNTIL"]

    if times
      I18n.t({
               one: "Daily, %{times} times",
               other: "Every %{count} days, %{times} times"
             },
             {
               count: interval,
               times:
             })
    else
      I18n.t({
               one: "Daily until %{until}",
               other: "Every %{count} days until %{until}"
             },
             {
               count: interval,
               until: format_date(until_date)
             })
    end
  end

  def parse_weekly(rropts)
    return parse_weekly_byday(rropts) if rropts["BYDAY"]

    interval = rropts["INTERVAL"].to_i
    times = rropts["COUNT"]
    until_date = rropts["UNTIL"]

    if times
      I18n.t({
               one: "Weekly, %{times} times",
               other: "Every %{count} weeks, %{times} times"
             },
             {
               count: interval,
               times:
             })
    else
      I18n.t({
               one: "Weekly until %{until}",
               other: "Every %{count} weeks until %{until}"
             },
             {
               count: interval,
               until: format_date(until_date)
             })
    end
  end

  def parse_weekly_byday(rropts)
    interval = rropts["INTERVAL"].to_i
    times = rropts["COUNT"]
    until_date = rropts["UNTIL"]
    by_day = byday_to_days(rropts["BYDAY"]) if rropts["BYDAY"]

    if times
      I18n.t({
               one: "Weekly on %{byday}, %{times} times",
               other: "Every %{count} weeks on %{byday}, %{times} times"
             },
             {
               count: interval,
               byday: by_day,
               times:
             })
    else
      I18n.t({
               one: "Weekly on %{byday} until %{until}",
               other: "Every %{count} weeks on %{byday} until %{until}"
             },
             {
               count: interval,
               byday: by_day,
               until: format_date(until_date)
             })
    end
  end

  def parse_monthly(rropts)
    if rropts["BYDAY"]
      parse_monthly_byday(rropts)
    elsif rropts["BYMONTHDAY"]
      parse_monthly_bymonthday(rropts)
    else
      parse_generic_monthly(rropts)
    end
  end

  def parse_monthly_byday(rropts)
    interval = rropts["INTERVAL"].to_i
    times = rropts["COUNT"]
    until_date = rropts["UNTIL"]
    by_days = parse_byday(rropts["BYDAY"])
    days_of_week = by_days.pluck(:day_of_week).join(", ")
    occurrence = by_days.first[:occurrence]

    if times
      if occurrence == 0
        I18n.t({
                 one: "Monthly every %{days}, %{times} times",
                 other: "Every %{count} months on %{days}, %{times} times"
               },
               {
                 count: interval,
                 days: days_of_week,
                 times:
               })
      else
        I18n.t({
                 one: "Monthly on the %{ord} %{days}, %{times} times",
                 other: "Every %{count} months on the %{ord} %{days}, %{times} times"
               },
               {
                 count: interval,
                 ord: occurrence.ordinalize,
                 days: days_of_week,
                 times:
               })
      end
    else
      if occurrence == 0
        I18n.t({
                 one: "Monthly every %{days} until %{until}",
                 other: "Every %{count} months on %{days} until %{until}"
               },
               {
                 count: interval,
                 days: days_of_week,
                 until: format_date(until_date)
               })
      else
        I18n.t({
                 one: "Monthly on the %{ord} %{days} until %{until}",
                 other: "Every %{count} months on the %{ord} %{days} until %{until}"
               },
               {
                 count: interval,
                 ord: occurrence.ordinalize,
                 days: days_of_week,
                 until: format_date(until_date)
               })
      end
    end
  end

  def parse_monthly_bymonthday(rropts)
    interval = rropts["INTERVAL"].to_i
    times = rropts["COUNT"]
    until_date = rropts["UNTIL"]
    days_of_month = rropts["BYMONTHDAY"].split(",")

    if times
      if days_of_month.length == 1
        I18n.t({
                 one: "Monthly on day %{days}, %{times} times",
                 other: "Every %{count} months on day %{days}, %{times} times"
               },
               {
                 count: interval,
                 days: days_of_month[0],
                 times:
               })
      else
        I18n.t({
                 one: "Monthly on days %{days}, %{times} times",
                 other: "Every %{count} months on days %{days}, %{times} times"
               },
               {
                 count: interval,
                 days: join_month_dys(days_of_month),
                 times:
               })
      end
    else
      if days_of_month.length == 1
        I18n.t({
                 one: "Monthly on day %{days} until %{until}",
                 other: "Every %{count} months on day %{days} until %{until}"
               },
               {
                 count: interval,
                 days: days_of_month[0],
                 until: format_date(until_date)
               })
      else
        I18n.t({
                 one: "Monthly on days %{days} until %{until}",
                 other: "Every %{count} months on days %{days} until %{until}",
               },
               {
                 count: interval,
                 days: join_month_dys(days_of_month),
                 until: format_date(until_date)
               })
      end
    end
  end

  def parse_generic_monthly(rropts)
    interval = rropts["INTERVAL"].to_i
    times = rropts["COUNT"]
    until_date = rropts["UNTIL"]

    if times
      I18n.t({
               one: "Monthly, %{times} times",
               other: "Every %{count} months, %{times} times"
             },
             {
               count: interval,
               times:
             })
    else
      I18n.t({
               one: "Monthly until %{until}",
               other: "Every %{count} months until %{until}"
             },
             {
               count: interval,
               until: format_date(until_date)
             })
    end
  end

  def parse_yearly(rropts)
    if rropts["BYDAY"]
      parse_yearly_byday(rropts)
    elsif rropts["BYMONTHDAY"]
      parse_yearly_bymonthday(rropts)
    else
      raise RruleValidationError, I18n.t("A yearly RRULE must include BYDAY or BYMONTHDAY")
    end
  end

  def parse_yearly_byday(rropts)
    times = rropts["COUNT"]
    interval = rropts["INTERVAL"].to_i
    until_date = rropts["UNTIL"]
    month = bymonth_to_month(parse_bymonth(rropts["BYMONTH"]))
    by_days = parse_byday(rropts["BYDAY"])
    days_of_week = by_days.pluck(:day_of_week).join(", ")
    occurrence = by_days.first[:occurrence]

    if times
      if [0, 1].include?(occurrence)
        I18n.t({
                 one: "Annually on the first %{days} of %{month}, %{times} times",
                 other: "Every %{count} years on the first %{days} of %{month}, %{times} times"
               },
               {
                 count: interval,
                 days: days_of_week,
                 month:,
                 times:
               })
      else
        I18n.t({
                 one: "Annually on the %{ord} %{days} of %{month}, %{times} times",
                 other: "Every %{count} years on the %{ord} %{days} of %{month}, %{times} times"
               },
               {
                 count: interval,
                 ord: occurrence.ordinalize,
                 days: days_of_week,
                 month:,
                 times:
               })
      end
    else
      if [0, 1].include?(occurrence)
        I18n.t({
                 one: "Annually on the first %{days} of %{month} until %{until}",
                 other: "Every %{count} years on the first %{days} of %{month} until %{until}"
               },
               {
                 count: interval,
                 days: days_of_week,
                 month:,
                 until: format_date(until_date)
               })
      else
        I18n.t({
                 one: "Annually on the %{ord} %{days} of %{month} until %{until}",
                 other: "Every %{count} years on the %{ord} %{days} of %{month} until %{until}"
               },
               {
                 count: interval,
                 ord: occurrence.ordinalize,
                 days: days_of_week,
                 month:,
                 until: format_date(until_date)
               })
      end
    end
  end

  def parse_yearly_bymonthday(rropts)
    times = rropts["COUNT"]
    interval = rropts["INTERVAL"].to_i
    until_date = rropts["UNTIL"]
    month = parse_bymonth(rropts["BYMONTH"])
    day = parse_bymonthday(rropts["BYMONTHDAY"], month)
    date = format_month_day(month, day)

    if times
      I18n.t({
               one: "Annually on %{date}, %{times} times",
               other: "Every %{count} years on %{date}, %{times} times"
             },
             {
               count: interval,
               date:,
               times:
             })
    else
      I18n.t({
               one: "Annually on %{date} until %{until}",
               other: "Every %{count} years on %{date} until %{until}"
             },
             {
               count: interval,
               date:,
               until: format_date(until_date)
             })
    end
  end
end
# rubocop:enable Style/IfInsideElse
