# :nodoc:
# Facilities for time zone lookup based on POSIX TZ strings
module Time::TZ
  # same as `Time.utc(year, 1, 1).to_unix`, except *year* is allowed to be
  # outside its normal range
  def self.jan1_to_unix(year : Int) : Int64
    # assume leap years have the same pattern beyond year 9999
    year -= 1
    days = year * 365 + year // 4 - year // 100 + year // 400
    SECONDS_PER_DAY.to_i64 * days - UNIX_EPOCH.total_seconds
  end

  # same as `Time.unix(unix_seconds).year`, except *unix_seconds* is allowed to
  # be outside its normal range
  def self.unix_to_year(unix_seconds : Int) : Int32
    total_days = ((UNIX_EPOCH.total_seconds + unix_seconds) // SECONDS_PER_DAY).to_i

    num400 = total_days // DAYS_PER_400_YEARS
    total_days -= num400 * DAYS_PER_400_YEARS

    num100 = total_days // DAYS_PER_100_YEARS
    if num100 == 4 # leap
      num100 = 3
    end
    total_days -= num100 * DAYS_PER_100_YEARS

    num4 = total_days // DAYS_PER_4_YEARS
    total_days -= num4 * DAYS_PER_4_YEARS

    numyears = total_days // 365
    if numyears == 4 # leap
      numyears = 3
    end
    total_days -= numyears * 365

    num400 * 400 + num100 * 100 + num4 * 4 + numyears + 1
  end

  # `J*`: one-based ordinal day, excludes leap day
  record Julian1, ordinal : Int16, time : Int32 do
    def always_jan1? : Bool
      ordinal == 1
    end

    def always_dec31? : Bool
      ordinal == 365
    end

    def unix_date_in_year(year : Int) : Int64
      TZ.jan1_to_unix(year) + 86400_i64 * (Time.leap_year?((year - 1) % 400 + 1) && @ordinal >= 60 ? @ordinal : @ordinal - 1)
    end
  end

  # `*`: zero-based ordinal day, includes leap day
  record Julian0, ordinal : Int16, time : Int32 do
    def always_jan1? : Bool
      ordinal == 0
    end

    def always_dec31? : Bool
      # `365` is December 30th in leap years
      false
    end

    def unix_date_in_year(year : Int) : Int64
      TZ.jan1_to_unix(year) + 86400_i64 * @ordinal
    end
  end

  # `M*.*.*`: month-week-day, week 5 is last week
  # also used for Windows system time zones (ignoring the millisecond component)
  record MonthWeekDay, month : Int8, week : Int8, day : Int8, time : Int32 do
    def always_jan1? : Bool
      false
    end

    def always_dec31? : Bool
      false
    end

    def unix_date_in_year(year : Int) : Int64
      # this needs to handle years outside 1..9999; reduce `year` modulo 400 so
      # that it fits into 1..2000, since the number of days per 400 years is
      # divisible by 7
      cycles = (year - 1) // 400
      year = (year - 1) % 400 + 1
      Time.month_week_date(year, @month.to_i32, @week.to_i32, @day.to_i32, location: Time::Location::UTC).to_unix + SECONDS_PER_400_YEARS * cycles
    end

    # 24 * 60 * 60 * (365 * 400 + 100 - 25 + 1)
    SECONDS_PER_400_YEARS = 12622780800_i64

    def self.default : self
      new(0, 0, 0, 0)
    end
  end

  alias POSIXTransition = Julian1 | Julian0 | MonthWeekDay

  def self.lookup(
    unix_seconds : Int, zones : Array(Location::Zone),
    std_index : Int, dst_index : Int,
    transition1 : POSIXTransition, transition2 : POSIXTransition,
  ) : {Location::Zone, {Int64, Int64}}
    if std_index == dst_index
      # all-year standard time or DST time
      is_dst = false
      range_begin = Int64::MIN
      range_end = Int64::MAX
    else
      std_offset = -zones[std_index].offset
      dst_offset = -zones[dst_index].offset

      # Find the local year corresponding to `unix_seconds`, except we cannot
      # rely on `Time`'s timezone facilities since that is exactly what this
      # method implements. It may differ from the UTC year by 0 or 1. musl uses
      # a similar loop.
      utc_year = local_year = TZ.unix_to_year(unix_seconds)

      while true
        datetime1 = transition1.unix_date_in_year(local_year) + transition1.time + std_offset
        datetime2 = transition2.unix_date_in_year(local_year) + transition2.time + dst_offset
        new_year_is_dst = datetime2 < datetime1

        local_new_year = TZ.jan1_to_unix(local_year) + (new_year_is_dst ? dst_offset : std_offset)
        local_new_year_next = TZ.jan1_to_unix(local_year + 1) + (new_year_is_dst ? dst_offset : std_offset)
        break if local_new_year <= unix_seconds < local_new_year_next

        if local_year == utc_year
          local_year += unix_seconds >= local_new_year_next ? 1 : -1
        else
          # Normally `new_year_is_dst` should be identical across all years, but
          # POSIX does not technically forbid something like `M1.1.0,J4`, where
          # one transition could be anything within January 1 - 7, and the other
          # one is January 4 in all years. In this extremely unlikely case there
          # could be a gap after the `local_new_year_next` for a given year and
          # before the `local_new_year` for its next year. We assume this is not
          # practically useful, so we just bail out here.
          raise Time::Error.new "BUG: Failed to determine local year"
        end
      end

      if new_year_is_dst
        if unix_seconds < datetime2
          is_dst = true
          range_begin = transition1.unix_date_in_year(local_year - 1) + transition1.time + std_offset
          range_end = datetime2
        elsif unix_seconds >= datetime1
          is_dst = true
          range_begin = datetime1
          range_end = transition2.unix_date_in_year(local_year + 1) + transition2.time + dst_offset
        else
          is_dst = false
          range_begin = datetime2
          range_end = datetime1
        end
      else
        if unix_seconds < datetime1
          is_dst = false
          range_begin = transition2.unix_date_in_year(local_year - 1) + transition2.time + dst_offset
          range_end = datetime1
        elsif unix_seconds >= datetime2
          is_dst = false
          range_begin = datetime2
          range_end = transition1.unix_date_in_year(local_year + 1) + transition1.time + std_offset
        else
          is_dst = true
          range_begin = datetime1
          range_end = datetime2
        end
      end
    end

    {zones[is_dst ? dst_index : std_index], {range_begin, range_end}}
  end

  # Parses the given *tz* string. Returns the `std_index`, `dst_index`,
  # `transition1`, and `transition2` fields for a yet to be constructed
  # `TZLocation`, or `nil` if *tz* is invalid.
  #
  # Both `std_index` and `dst_index` index into the given *zones* array, which
  # should belong to a `Time::TZLocation`. Missing entries in *zones* are
  # automatically created.
  #
  # *hours_extension* increases the hours range of transition time offsets from
  # 0..24 to -167..+167, according to POSIX.1-2024 or RFC 9636 Section 3.3.2
  # (the latter only for TZif version 3 or higher).
  #
  # C runtime library implementations:
  #
  # * glibc https://sourceware.org/git/?p=glibc.git;a=blob;f=time/tzset.c;hb=2642002380aafb71a1d3b569b6d7ebeab3284816#l321
  # * musl https://git.musl-libc.org/cgit/musl/tree/src/time/__tz.c?id=ef7d0ae21240eac9fc1e8088112bfb0fac507578#n239
  # * bionic https://android.googlesource.com/platform/bionic/+/31fc69f67fc49b1a08f5561ae62d098106da6565/libc/tzcode/localtime.c#1148
  # * wine msvcrt https://gitlab.winehq.org/wine/wine/-/blob/7f833db11ffea4f3f4fa07be31d30559aff9c5fb/dlls/msvcrt/time.c#L127
  def self.parse(tz : String, zones : Array(Location::Zone), hours_extension : Bool) : {Int32, Int32, POSIXTransition, POSIXTransition}?
    reader = Char::Reader.new(tz)

    # colon prefix: implementation-defined (not supported in Crystal)
    # glibc treats the rest of the TZ string as a TZif database path name
    # (`parse_std_or_dst` will reject strings beginning with a `:` anyway so it
    # doesn't have to be checked here)

    # std offset [dst [offset] [, start [/ time] , end [/ time]]]
    reader, std_name = parse_std_or_dst(reader) || return nil
    reader, std_offset = parse_offset(reader, 24, true) || return nil
    std_zone = Location::Zone.new(std_name, -std_offset, false)

    unless reader.has_next?
      # no DST component means all-year standard time
      std_index = zone_index(std_zone, zones)
      default_transition = Julian0.new(0, 0)
      return std_index, std_index, default_transition, default_transition
    end

    reader, dst_name = parse_std_or_dst(reader) || return nil
    if result = parse_offset(reader, 24, true)
      reader, dst_offset = result
    else
      dst_offset = std_offset - 3600
    end
    dst_zone = Time::Location::Zone.new(dst_name, -dst_offset, true)

    # missing transitions: implementation-defined (not supported in Crystal)
    # msvcrt and bionic fall back to `M3.2.0,M11.1.0`, i.e. US rules since 2007
    return nil unless reader.current_char == ','
    reader.next_char
    reader, transition1 = parse_transition(reader, hours_extension) || return nil

    return nil unless reader.current_char == ','
    reader.next_char
    reader, transition2 = parse_transition(reader, hours_extension) || return nil

    # if there are no trailing characters, we have a valid TZ string with
    # transition rules
    return nil if reader.has_next?

    # all-year DST according to POSIX.1-2024 or RFC 9636 Section 3.3.1
    # (we check here so that these locations return true for `#fixed?`)
    if transition1.always_jan1? && transition1.time == 0
      if transition2.always_dec31? && transition2.time == 86400 + std_offset - dst_offset
        dst_index = zone_index(dst_zone, zones)
        default_transition = Julian0.new(0, 0)
        return dst_index, dst_index, default_transition, default_transition
      end
    end

    std_index = zone_index(std_zone, zones)
    dst_index = zone_index(dst_zone, zones)
    {std_index, dst_index, transition1, transition2}
  end

  private def self.zone_index(zone : Location::Zone, zones : Array(Location::Zone)) : Int
    if index = zones.index(zone)
      index
    else
      zones.size.tap { zones << zone }
    end
  end

  private def self.parse_transition(reader : Char::Reader, hours_extension : Bool) : {Char::Reader, POSIXTransition}?
    case reader.current_char
    when 'J'
      reader.next_char
      reader, day = parse_int(reader, 365) || return
      return unless day >= 1

      reader, time = parse_transition_time(reader, hours_extension) || return
      {reader, Julian1.new(day.to_i16!, time)}
    when 'M'
      reader.next_char
      reader, month = parse_int(reader, 12) || return
      return unless month >= 1

      return unless reader.current_char == '.'
      reader.next_char
      reader, week = parse_int(reader, 5) || return
      return unless week >= 1

      return unless reader.current_char == '.'
      reader.next_char
      reader, day = parse_int(reader, 6) || return

      reader, time = parse_transition_time(reader, hours_extension) || return
      {reader, MonthWeekDay.new(month.to_i8!, week.to_i8!, day.to_i8!, time)}
    else
      reader, day = parse_int(reader, 365) || return

      reader, time = parse_transition_time(reader, hours_extension) || return
      {reader, Julian0.new(day.to_i16!, time)}
    end
  end

  private def self.parse_transition_time(reader : Char::Reader, hours_extension : Bool) : {Char::Reader, Int32}?
    if reader.current_char == '/'
      reader.next_char
      parse_offset(reader, hours_extension ? 167 : 24, hours_extension)
    else
      {reader, 7200} # 02:00:00
    end
  end

  private def self.parse_offset(reader : Char::Reader, hour_limit : Int, allow_sign : Bool) : {Char::Reader, Int32}?
    sign = 1
    if allow_sign
      case reader.current_char
      when '-'
        sign = -1
        reader.next_char
      when '+'
        reader.next_char
      end
    end

    reader, hours = parse_int(reader, hour_limit) || return
    minutes = 0
    seconds = 0

    if reader.current_char == ':'
      reader.next_char
      reader, minutes = parse_int(reader, 59) || return
      if reader.current_char == ':'
        reader.next_char
        reader, seconds = parse_int(reader, 59) || return
      end
    end

    total_seconds = sign * (3600 * hours + 60 * minutes + seconds)
    {reader, total_seconds}
  end

  private def self.parse_int(reader : Char::Reader, limit : Int) : {Char::Reader, Int32}?
    start = reader.pos
    while reader.current_char == '0'
      reader.next_char
    end

    value = 0
    while digit = reader.current_char.to_i?
      value = value * 10 + digit
      return unless value <= limit
      reader.next_char
    end

    if reader.pos > start
      {reader, value}
    end
  end

  private def self.parse_std_or_dst(reader : Char::Reader) : {Char::Reader, String}?
    quoted = false
    if reader.current_char == '<'
      reader.next_char
      quoted = true
    end

    start = reader.pos
    while ch = reader.current_char?
      break unless ch.ascii_letter? || (quoted && (ch.ascii_number? || ch.in?('+', '-')))
      reader.next_char
    end
    return unless reader.pos - start >= 3
    finish = reader.pos

    if quoted
      return unless reader.current_char == '>'
      reader.next_char
    end

    name = reader.string.byte_slice(start, finish - start)
    {reader, name}
  end
end

# A time location capable of computing recurring time zone transitions in the
# future using POSIX TZ strings, as defined in [POSIX.1-2024 Section 8.3](https://pubs.opengroup.org/onlinepubs/9799919799/basedefs/V1_chap08.html),
# or in [IETF RFC 9636](https://datatracker.ietf.org/doc/html/rfc9636).
#
# These locations are returned by `Time::Location.posix_tz`.
class Time::TZLocation < Time::Location
  # Indices into this location's zones array. Identical if all-year standard
  # time or DST is in effect.
  @std_index : Int32
  @dst_index : Int32

  # The first and second transition times defined in the TZ string. Not
  # meaningful when `std_index == dst_index`.
  @transition1 : TZ::POSIXTransition
  @transition2 : TZ::POSIXTransition

  # The original TZ string that produced this location.
  @tz_string : String

  protected def initialize(name : String, zones : Array(Zone), @tz_string, @std_index, @dst_index, @transition1, @transition2, transitions = [] of ZoneTransition)
    super(name, zones, transitions)
  end

  def_equals_and_hash name, zones, transitions, @tz_string

  # :nodoc:
  def lookup_with_boundaries(unix_seconds : Int) : {Zone, {Int64, Int64}}
    case
    when zones.empty?
      {Zone::UTC, {Int64::MIN, Int64::MAX}}
    when transitions.empty?
      lookup_posix_tz(unix_seconds)
    when unix_seconds < transitions.first.when
      {lookup_first_zone, {Int64::MIN, transitions.first.when}}
    when unix_seconds >= transitions.last.when
      lookup_posix_tz(unix_seconds)
    else
      lookup_within_fixed_transitions(unix_seconds)
    end
  end

  private def lookup_posix_tz(unix_seconds : Int) : {Zone, {Int64, Int64}}
    zone, range = TZ.lookup(unix_seconds, @zones, @std_index, @dst_index, @transition1, @transition2)
    range_begin, range_end = range

    if last_transition = @transitions.last?
      range_begin = {range_begin, last_transition.when}.max
    end

    {zone, {range_begin, range_end}}
  end
end

# A time location capable of computing recurring time zone transitions in the
# past or future using definitions from the Windows Registry.
#
# These locations are returned by `Time::Location.load`.
class Time::WindowsLocation < Time::Location
  # Two sets of transition rules for times before the first transition or after
  # the last transition. Each corresponds to a `TZLocation`'s `@std_index`,
  # `@dst_index`, `@transition1`, and `@transition2` fields. If there are no
  # fixed transitions then the two sets are equal.
  @past_tz_args : {Int32, Int32, TZ::MonthWeekDay, TZ::MonthWeekDay}
  @future_tz_args : {Int32, Int32, TZ::MonthWeekDay, TZ::MonthWeekDay}

  # The original Windows Registry key name for this location.
  @key_name : String

  def initialize(name : String, zones : Array(Zone), @key_name, @past_tz_args, @future_tz_args = past_tz_args, transitions = [] of ZoneTransition)
    super(name, zones, transitions)
  end

  def_equals_and_hash name, zones, transitions, @key_name

  # :nodoc:
  def lookup_with_boundaries(unix_seconds : Int) : {Zone, {Int64, Int64}}
    case
    when zones.empty?
      {Zone::UTC, {Int64::MIN, Int64::MAX}}
    when transitions.empty?, unix_seconds < transitions.first.when
      lookup_past(unix_seconds)
    when unix_seconds >= transitions.last.when
      lookup_future(unix_seconds)
    else
      lookup_within_fixed_transitions(unix_seconds)
    end
  end

  private def lookup_past(unix_seconds : Int) : {Zone, {Int64, Int64}}
    zone, range = TZ.lookup(unix_seconds, @zones, *@past_tz_args)
    range_begin, range_end = range

    if first_transition = @transitions.first?
      range_end = {range_end, first_transition.when}.min
    end

    {zone, {range_begin, range_end}}
  end

  private def lookup_future(unix_seconds : Int) : {Zone, {Int64, Int64}}
    zone, range = TZ.lookup(unix_seconds, @zones, *@future_tz_args)
    range_begin, range_end = range

    if last_transition = @transitions.last?
      range_begin = {range_begin, last_transition.when}.max
    end

    {zone, {range_begin, range_end}}
  end
end
