class Time::Location
  @@location_cache = {} of String => NamedTuple(time: Time, location: Location)

  # `InvalidTZDataError` is raised if a zoneinfo file contains invalid
  # time zone data.
  #
  # Details on the exact cause can be found in the error message.
  class InvalidTZDataError < Time::Error
    def initialize(message : String? = "Malformed time zone information", cause : Exception? = nil)
      super(message, cause)
    end
  end

  # :nodoc:
  def self.load?(name : String, sources : Enumerable(String)) : Time::Location?
    if source = find_zoneinfo_file(name, sources)
      load_from_dir_or_zip(name, source)
    end
  end

  # :nodoc:
  def self.load(name : String, sources : Enumerable(String)) : Time::Location?
    load(name, sources) { |source| raise InvalidLocationNameError.new(name, source) }
  end

  # :nodoc:
  def self.load(name : String, sources : Enumerable(String), &) : Time::Location?
    if source = find_zoneinfo_file(name, sources)
      load_from_dir_or_zip(name, source) || yield source
    end
  end

  # :nodoc:
  def self.load_android(name : String, sources : Enumerable(String)) : Time::Location?
    load_android(name, sources) { |path| raise InvalidLocationNameError.new(name, path) }
  end

  # :nodoc:
  def self.load_android(name : String, sources : Enumerable(String), &) : Time::Location?
    if path = find_android_tzdata_file(sources)
      load_from_android_tzdata(name, path) || yield path
    end
  end

  # :nodoc:
  def self.load_from_dir_or_zip(name : String, source : String) : Time::Location?
    if source.ends_with?(".zip")
      open_file_cached(name, source) do |file|
        read_zip_file(name, file) do |io|
          read_zoneinfo(name, io)
        end
      end
    else
      path = File.join(source, name)
      open_file_cached(name, path) do |file|
        read_zoneinfo(name, file)
      end
    end
  end

  # :nodoc:
  def self.load_from_android_tzdata(name : String, path : String) : Time::Location?
    return nil unless File.exists?(path)

    mtime = File.info(path).modification_time
    if (cache = @@location_cache[name]?) && cache[:time] == mtime
      cache[:location]
    else
      File.open(path) do |file|
        read_android_tzdata(file, false) do |location_name, location|
          @@location_cache[location_name] = {time: mtime, location: location}
        end
        @@location_cache[name].try &.[:location]
      end
    end
  end

  private def self.open_file_cached(name : String, path : String, &)
    return nil unless File.exists?(path)

    mtime = File.info(path).modification_time
    if (cache = @@location_cache[name]?) && cache[:time] == mtime
      cache[:location]
    else
      File.open(path) do |file|
        location = yield file
        if location
          @@location_cache[name] = {time: mtime, location: location}

          return location
        end
      end
    end
  end

  # :nodoc:
  def self.find_zoneinfo_file(name : String, sources : Enumerable(String)) : String?
    sources.each do |source|
      if source.ends_with?(".zip")
        path = source
      else
        path = File.join(source, name)
      end

      return source if File.exists?(path) && File.file?(path) && File::Info.readable?(path)
    end
  end

  # :nodoc:
  def self.find_android_tzdata_file(sources : Enumerable(String)) : String?
    sources.find do |path|
      File.exists?(path) && File.file?(path) && File::Info.readable?(path)
    end
  end

  # :nodoc:
  # Parse "zoneinfo" time zone file.
  # This is the standard file format used by most operating systems.
  # See https://datatracker.ietf.org/doc/html/rfc9636, https://data.iana.org/time-zones/tz-link.html,
  # https://github.com/eggert/tz, tzfile(5)
  def self.read_zoneinfo(location_name : String, io : IO) : Time::Location
    raise InvalidTZDataError.new unless io.read_string(4) == "TZif"

    # 1-byte version, then 15 bytes of padding
    version = io.read_byte
    raise InvalidTZDataError.new unless version.in?(0_u8, '2'.ord, '3'.ord, '4'.ord)
    io.skip(15)

    # six big-endian 32-bit integers:
    #	number of UTC/local indicators
    #	number of standard/wall indicators
    #	number of leap seconds
    #	number of transition times
    #	number of local time zones
    #	number of characters of time zone abbrev strings

    isutcnt = read_int32(io)
    isstdcnt = read_int32(io)
    leapcnt = read_int32(io)
    timecnt = read_int32(io)
    typecnt = read_int32(io)
    charcnt = read_int32(io)

    time_size = 4
    if version != 0
      # TZif version 2+ file; skip the version 1 body and read the next header
      io.skip(timecnt * (time_size + 1) + typecnt * 6 + charcnt + leapcnt * (time_size + 4) + isstdcnt + isutcnt)

      raise InvalidTZDataError.new("Missing version 2+ header") unless io.read_string(4) == "TZif"
      raise InvalidTZDataError.new("Version mismatch") unless io.read_byte == version
      io.skip(15)

      isutcnt = read_int32(io)
      isstdcnt = read_int32(io)
      leapcnt = read_int32(io)
      timecnt = read_int32(io)
      typecnt = read_int32(io)
      charcnt = read_int32(io)

      time_size = 8
    end

    transitionsdata = read_buffer(io, timecnt * time_size)

    # Time zone indices for transition times.
    transition_indexes = Bytes.new(timecnt)
    io.read_fully(transition_indexes)

    zonedata = read_buffer(io, typecnt * 6)

    abbreviations = read_buffer(io, charcnt)

    leap_second_time_pairs = Bytes.new(leapcnt * (time_size + 4))
    io.read_fully(leap_second_time_pairs)

    isstddata = Bytes.new(isstdcnt)
    io.read_fully(isstddata)

    isutcdata = Bytes.new(isutcnt)
    io.read_fully(isutcdata)

    zones = Array(Zone).new(typecnt) do
      utoff = read_int32(zonedata)
      isdst = zonedata.read_byte != 0_u8
      desigidx = zonedata.read_byte
      raise InvalidTZDataError.new unless desigidx && desigidx < abbreviations.size
      abbreviations.pos = desigidx
      name = abbreviations.gets(Char::ZERO, chomp: true)
      raise InvalidTZDataError.new unless name
      Zone.new(name, utoff, isdst)
    end

    transitions = Array(ZoneTransition).new(timecnt) do |transition_id|
      time = time_size == 8 ? read_int64(transitionsdata) : read_int32(transitionsdata).to_i64
      zone_idx = transition_indexes[transition_id]
      raise InvalidTZDataError.new unless zone_idx < zones.size

      isstd = !isstddata[transition_id]?.in?(nil, 0_u8)
      isutc = !isutcdata[transition_id]?.in?(nil, 0_u8)

      ZoneTransition.new(time, zone_idx, isstd, isutc)
    end

    if version != 0
      unless io.read_byte === '\n'
        raise InvalidTZDataError.new("Missing TZ footer")
      end
      unless tz_string = io.gets
        raise InvalidTZDataError.new("Missing TZ string")
      end

      unless tz_string.empty?
        hours_extension = version != '2'.ord # version 3+
        if tz_args = TZ.parse(tz_string, zones, hours_extension)
          return TZLocation.new(location_name, zones, tz_string, *tz_args, transitions)
        end
        raise InvalidTZDataError.new("Invalid TZ string: #{tz_string}")
      end
    end

    new(location_name, zones, transitions)
  rescue exc : IO::Error
    raise InvalidTZDataError.new(cause: exc)
  end

  private ANDROID_TZDATA_NAME_LENGTH = 40
  private ANDROID_TZDATA_ENTRY_SIZE  = ANDROID_TZDATA_NAME_LENGTH + 12

  # :nodoc:
  # Reads a packed tzdata file for Android's Bionic C runtime. Defined in
  # https://android.googlesource.com/platform/bionic/+/master/libc/tzcode/bionic.cpp
  def self.read_android_tzdata(io : IO, local : Bool, & : String, Time::Location ->)
    header = io.read_string(12)
    raise InvalidTZDataError.new unless header.starts_with?("tzdata") && header.ends_with?('\0')

    index_offset = read_int32(io)
    data_offset = read_int32(io)
    io.skip(4) # final_offset
    unless index_offset <= data_offset && (data_offset - index_offset).divisible_by?(ANDROID_TZDATA_ENTRY_SIZE)
      raise InvalidTZDataError.new
    end

    io.seek(index_offset)
    entries = Array.new((data_offset - index_offset) // ANDROID_TZDATA_ENTRY_SIZE) do
      name = io.read_string(40).rstrip('\0')
      start = read_int32(io)
      length = read_int32(io)
      io.skip(4) # unused
      {name, start, length}
    end

    entries.each do |(name, start, length)|
      io.seek(start + data_offset)
      yield name, read_zoneinfo(local ? "Local" : name, read_buffer(io, length))
    end
  end

  private def self.read_int32(io : IO)
    io.read_bytes(Int32, IO::ByteFormat::BigEndian)
  end

  private def self.read_int64(io : IO)
    io.read_bytes(Int64, IO::ByteFormat::BigEndian)
  end

  private def self.read_buffer(io : IO, size : Int)
    buffer = Bytes.new(size)
    io.read_fully(buffer)
    IO::Memory.new(buffer)
  end

  # :nodoc:
  CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50
  # :nodoc:
  END_OF_CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x06054b50
  # :nodoc:
  ZIP_TAIL_SIZE = 22
  # :nodoc:
  LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50
  # :nodoc:
  COMPRESSION_METHOD_UNCOMPRESSED = 0_i16

  # This method loads an entry from an uncompressed zip file.
  # See http://www.onicos.com/staff/iz/formats/zip.html for ZIP format layout
  private def self.read_zip_file(name : String, file : File, &)
    file.seek -ZIP_TAIL_SIZE, IO::Seek::End

    if file.read_bytes(Int32, IO::ByteFormat::LittleEndian) != END_OF_CENTRAL_DIRECTORY_HEADER_SIGNATURE
      raise InvalidTZDataError.new("Corrupt ZIP file #{file.path}")
    end

    file.skip 6
    num_entries = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
    file.skip 4

    file.pos = file.read_bytes(Int32, IO::ByteFormat::LittleEndian)

    num_entries.times do
      break if file.read_bytes(Int32, IO::ByteFormat::LittleEndian) != CENTRAL_DIRECTORY_HEADER_SIGNATURE

      file.skip 6
      compression_method = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
      file.skip 12
      _uncompressed_size = file.read_bytes(Int32, IO::ByteFormat::LittleEndian)
      filename_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
      extra_field_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
      file_comment_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
      file.skip 8
      local_file_header_pos = file.read_bytes(Int32, IO::ByteFormat::LittleEndian)
      filename = file.read_string(filename_length)

      unless filename == name
        file.skip extra_field_length + file_comment_length
        next
      end

      unless compression_method == COMPRESSION_METHOD_UNCOMPRESSED
        raise InvalidTZDataError.new("Unsupported compression in ZIP file: #{file.path}")
      end

      file.pos = local_file_header_pos

      unless file.read_bytes(Int32, IO::ByteFormat::LittleEndian) == LOCAL_FILE_HEADER_SIGNATURE
        raise InvalidTZDataError.new("Invalid ZIP file: #{file.path}")
      end
      file.skip 4
      unless file.read_bytes(Int16, IO::ByteFormat::LittleEndian) == COMPRESSION_METHOD_UNCOMPRESSED
        raise InvalidTZDataError.new("Invalid ZIP file: #{file.path}")
      end
      file.skip 16
      unless file.read_bytes(Int16, IO::ByteFormat::LittleEndian) == filename_length
        raise InvalidTZDataError.new("Invalid ZIP file: #{file.path}")
      end
      extra_field_length = file.read_bytes(Int16, IO::ByteFormat::LittleEndian)
      unless file.gets(filename_length) == name
        raise InvalidTZDataError.new("Invalid ZIP file: #{file.path}")
      end

      file.skip extra_field_length

      return yield file
    end
  end
end
