# Base type for file information related to zip entries.
module Compress::Zip::FileInfo
  SIGNATURE                 = 0x04034b50
  DATA_DESCRIPTOR_SIGNATURE = 0x08074b50

  DEFLATE_END_SIGNATURE = Bytes[80, 75, 7, 8, read_only: true]

  property version : UInt16 = Zip::VERSION
  property general_purpose_bit_flag = 0_u16
  property compression_method = CompressionMethod::DEFLATED
  property time : Time
  property crc32 = 0_u32
  property compressed_size = 0_u32
  property uncompressed_size = 0_u32
  property filename = ""
  property extra = Bytes.empty
  property comment = ""
  property offset = 0_u32

  # :nodoc:
  def initialize(*, at_file_header io : IO)
    @version = read(io, UInt16)
    file_name_length, extra_field_length, time = initialize_meta(io)
    @time = time
    @filename = io.read_string(file_name_length)
    if extra_field_length != 0
      @extra = Bytes.new(extra_field_length)
      io.read_fully(@extra)
    end
  end

  # :nodoc:
  def initialize(*, at_central_directory_header io : IO)
    read io, UInt16            # version made by
    @version = read io, UInt16 # version needed to extract
    file_name_length, extra_field_length, time = initialize_meta(io)
    @time = time
    file_comment_length = read(io, UInt16) # file comment length
    read(io, UInt16)                       # disk number start
    read(io, UInt16)                       # internal file attribute
    read(io, UInt32)                       # external file attribute
    @offset = read(io, UInt32)             # relative offset of local header
    @filename = io.read_string(file_name_length)
    if extra_field_length != 0
      @extra = Bytes.new(extra_field_length)
      io.read_fully(@extra)
    end
    if file_comment_length != 0
      @comment = io.read_string(file_comment_length)
    end
  end

  # :nodoc:
  def initialize_meta(io : IO)
    @general_purpose_bit_flag = read(io, UInt16)
    @compression_method = CompressionMethod.new(read(io, UInt16))
    time = read(io, UInt16)
    date = read(io, UInt16)
    time = from_dos(date, time)
    @crc32 = read(io, UInt32)
    @compressed_size = read(io, UInt32)
    @uncompressed_size = read(io, UInt32)
    file_name_length = read(io, UInt16)
    extra_field_length = read(io, UInt16)
    {file_name_length, extra_field_length, time}
  end

  def initialize(@filename : String, @time = Time.utc, @comment = "", @extra = Bytes.empty)
  end

  # Returns `true` if this entry is a directory.
  def dir? : Bool
    filename.ends_with?('/')
  end

  # Returns `true` if this entry is a file.
  def file? : Bool
    !dir?
  end

  protected def to_io(io : IO)
    write io, SIGNATURE # 4
    write io, @version  # 2
    meta_count = meta_to_io(io)
    io << @filename
    io.write(extra)
    meta_count + 6 + @filename.bytesize + extra.size
  end

  protected def meta_to_io(io : IO)
    write io, @general_purpose_bit_flag # 2
    write io, @compression_method.value # 2
    date, time = to_dos
    write io, time                      # 2
    write io, date                      # 2
    write io, @crc32                    # 4
    write io, @compressed_size          # 4
    write io, @uncompressed_size        # 4
    write io, @filename.bytesize.to_u16 # filename length (2)
    write io, extra.size.to_u16         # extra field length (2)
    24                                  # the 24 bytes we just wrote
  end

  protected def write_data_descriptor(io : IO)
    io.write FileInfo::DEFLATE_END_SIGNATURE # 4
    write io, @crc32                         # 4
    write io, @compressed_size               # 4
    write io, @uncompressed_size             # 4
    16                                       # the 16 bytes we just wrote
  end

  protected def decompressor_for(io, is_sized = false)
    case compression_method
    when .stored?
      io = IO::Sized.new(io, compressed_size) unless is_sized
    when .deflated?
      if compressed_size == 0 && bit_3_set?
        # Read until we end decompressing the deflate data,
        # which has an unknown size
      else
        io = IO::Sized.new(io, compressed_size) unless is_sized
      end

      io = Compress::Deflate::Reader.new(io)
    else
      raise "Unsupported compression method: #{compression_method}"
    end

    io
  end

  protected def bit_3_set?
    (general_purpose_bit_flag & 0b1000) != 0
  end

  # date is:
  # - 7 bits for year (0 is 1980)
  # - 4 bits for month
  # - 5 bits for day
  # time is:
  # - 5 bits for hour
  # - 6 bits for minute
  # - 5 bits for seconds (0..29), precision of two seconds

  private def from_dos(date, time)
    year = 1980 + (date >> 9)
    month = (date >> 5) & 0b1111
    day = date & 0b11111

    hour = time >> 11
    minute = (time >> 5) & 0b111111
    second = (time & 0b11111) * 2

    Time.utc(year.to_i, month.to_i, day.to_i, hour.to_i, minute.to_i, second.to_i)
  end

  private def to_dos
    date = ((@time.year - 1980) << 9) |
           (@time.month << 5) |
           @time.day

    time = (@time.hour << 11) |
           (@time.minute << 5) |
           (@time.second // 2)

    {date.to_u16, time.to_u16}
  end

  private def read(io, type)
    io.read_bytes(type, IO::ByteFormat::LittleEndian)
  end

  private def write(io, value)
    io.write_bytes(value, IO::ByteFormat::LittleEndian)
  end
end
