defmodule Zstream.Unzip do
  @moduledoc false
  alias Zstream.Entry
  alias Zstream.Unzip.Extra

  defmodule Error do
    defexception [:message]
  end

  import Bitwise

  defmodule LocalHeader do
    @moduledoc false
    defstruct [
      :version_need_to_extract,
      :general_purpose_bit_flag,
      :compression_method,
      :last_modified_file_time,
      :last_modified_file_date,
      :crc32,
      :uncompressed_size,
      :compressed_size,
      :file_name_length,
      :extra_field_length,
      :file_name,
      :extra_field,
      :extras
    ]
  end

  defmodule State do
    @moduledoc false
    defstruct next: :local_file_header,
              buffer: "",
              local_header: nil,
              data_sent: 0,
              decoder: nil,
              decoder_state: nil
  end

  defmodule VerifierState do
    @moduledoc false
    defstruct local_header: nil,
              crc32: 0,
              uncompressed_size: 0
  end

  def unzip(stream, _options \\ []) do
    Stream.concat([stream, [:eof]])
    |> Stream.transform(%State{}, &execute_state_machine/2)
    |> Stream.transform(%VerifierState{}, &verify/2)
  end

  defp verify({:local_header, local_header}, state) do
    entry = %Entry{
      name: local_header.file_name,
      compressed_size: local_header.compressed_size,
      size: local_header.uncompressed_size,
      mtime: dos_time(local_header.last_modified_file_date, local_header.last_modified_file_time),
      extras: local_header.extras
    }

    {[{:entry, entry}], %{state | local_header: local_header}}
  end

  defp verify({:data, :eof}, state) do
    unless state.crc32 == state.local_header.crc32 do
      raise Error, "Invalid crc32, expected: #{state.local_header.crc32}, actual: #{state.crc32}"
    end

    unless state.uncompressed_size == state.local_header.uncompressed_size do
      raise Error,
            "Invalid size, expected: #{state.local_header.uncompressed_size}, actual: #{state.uncompressed_size}"
    end

    {[{:data, :eof}], %VerifierState{}}
  end

  defp verify({:data, data}, state) do
    {[{:data, data}],
     %{
       state
       | crc32: :erlang.crc32(state.crc32, data),
         uncompressed_size: state.uncompressed_size + IO.iodata_length(data)
     }}
  end

  # Specification is available at
  # https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT
  defp execute_state_machine(:eof, state) do
    if state.next == :done do
      {[], state}
    else
      raise Error, "Unexpected end of input"
    end
  end

  defp execute_state_machine(data, state) do
    data =
      if state.buffer not in ["", []] do
        [state.buffer, data]
      else
        data
      end

    size = IO.iodata_length(data)

    enough_data? =
      case state.next do
        :local_file_header ->
          size >= 30

        :next_header ->
          size >= 30

        :filename_extra_field ->
          size >= state.local_header.file_name_length + state.local_header.extra_field_length

        :done ->
          true

        :file_data ->
          true
      end

    if enough_data? do
      apply(__MODULE__, state.next, [data, %{state | buffer: ""}])
    else
      {[], %{state | buffer: data}}
    end
  end

  def local_file_header(data, state) do
    data = IO.iodata_to_binary(data)

    {:ok, local_header, rest} = parse_local_header(data)
    {decoder, decoder_state} = Zstream.Decoder.init(local_header.compression_method)

    if bit_set?(local_header.general_purpose_bit_flag, 3) do
      raise Error, "Zip files with data descriptor record are not supported"
    end

    execute_state_machine(rest, %{
      state
      | local_header: local_header,
        next: :filename_extra_field,
        decoder: decoder,
        decoder_state: decoder_state
    })
  end

  def filename_extra_field(data, state) do
    data = IO.iodata_to_binary(data)
    start = 0
    length = state.local_header.file_name_length
    file_name = binary_part(data, start, length)
    start = start + length
    length = state.local_header.extra_field_length
    extra_field = binary_part(data, start, length)
    start = start + length
    rest = binary_part(data, start, byte_size(data) - start)
    state = put_in(state.local_header.file_name, file_name)
    state = put_in(state.local_header.extra_field, extra_field)
    state = %{state | next: :file_data}
    state = put_in(state.local_header.extras, Extra.parse(extra_field, []))

    zip64_extended_information =
      Enum.find(state.local_header.extras, &match?(%Extra.Zip64ExtendedInformation{}, &1))

    state =
      if zip64_extended_information && state.local_header.compressed_size == 0xFFFFFFFF do
        put_in(state.local_header.compressed_size, zip64_extended_information.compressed_size)
      else
        state
      end

    state =
      if zip64_extended_information && state.local_header.uncompressed_size == 0xFFFFFFFF do
        put_in(state.local_header.uncompressed_size, zip64_extended_information.size)
      else
        state
      end

    {results, new_state} = execute_state_machine(rest, state)
    {Stream.concat([{:local_header, state.local_header}], results), new_state}
  end

  def file_data(data, state) do
    size = IO.iodata_length(data)

    if size + state.data_sent < state.local_header.compressed_size do
      {chunks, state} = decode(data, state)
      {chunks, %{state | data_sent: state.data_sent + size}}
    else
      data = IO.iodata_to_binary(data)
      length = state.local_header.compressed_size - state.data_sent
      file_chunk = binary_part(data, 0, length)
      {chunks, state} = decode_close(file_chunk, state)
      start = length
      rest = binary_part(data, start, size - start)

      state = %{state | data_sent: 0, next: :next_header}
      {results, state} = execute_state_machine(rest, state)
      {Stream.concat([chunks, [{:data, :eof}], results]), state}
    end
  end

  def next_header(data, state) do
    data = IO.iodata_to_binary(data)

    case :binary.match(data, <<0x4B50::little-size(16)>>, scope: {0, 28}) do
      :nomatch ->
        raise Error, "Invalid zip file, could not find any signature header"

      {start, 2} ->
        <<signature::little-size(32), _::binary>> =
          rest = binary_part(data, start, byte_size(data) - start)

        case signature do
          0x04034B50 ->
            execute_state_machine(rest, %{state | next: :local_file_header})

          # archive extra data record
          0x08064B50 ->
            {[], %{state | next: :done}}

          # central directory header
          0x02014B50 ->
            {[], %{state | next: :done}}
        end
    end
  end

  def done(_, state) do
    {[], state}
  end

  defp decode(data, state) do
    decoder = state.decoder
    decoder_state = state.decoder_state
    {chunks, decoder_state} = decoder.decode(data, decoder_state)
    state = put_in(state.decoder_state, decoder_state)
    {chunks, state}
  end

  defp decode_close(data, state) do
    {chunks, state} = decode(data, state)

    chunks =
      Stream.concat(
        chunks,
        Stream.resource(
          fn -> state.decoder.close(state.decoder_state) end,
          fn
            empty when empty in [nil, "", []] ->
              {:halt, nil}

            data ->
              {[{:data, data}], nil}
          end,
          fn _ -> :ok end
        )
      )

    state = put_in(state.decoder, nil)
    state = put_in(state.decoder_state, nil)
    {chunks, state}
  end

  # local file header signature
  defp parse_local_header(
         <<0x04034B50::little-size(32), version_need_to_extract::little-size(16),
           general_purpose_bit_flag::little-size(16), compression_method::little-size(16),
           last_modified_file_time::little-size(16), last_modified_file_date::little-size(16),
           crc32::little-size(32), compressed_size::little-size(32),
           uncompressed_size::little-size(32), file_name_length::little-size(16),
           extra_field_length::little-size(16), rest::binary>>
       ) do
    {:ok,
     %LocalHeader{
       version_need_to_extract: version_need_to_extract,
       general_purpose_bit_flag: general_purpose_bit_flag,
       compression_method: compression_method,
       last_modified_file_time: last_modified_file_time,
       last_modified_file_date: last_modified_file_date,
       crc32: crc32,
       compressed_size: compressed_size,
       uncompressed_size: uncompressed_size,
       file_name_length: file_name_length,
       extra_field_length: extra_field_length
     }, rest}
  end

  defp parse_local_header(_), do: raise(Error, "Invalid local header")

  defp bit_set?(bits, n) do
    (bits &&& 1 <<< n) > 0
  end

  defp dos_time(date, time) do
    <<year::size(7), month::size(4), day::size(5)>> = <<date::size(16)>>
    <<hour::size(5), minute::size(6), second::size(5)>> = <<time::size(16)>>

    {:ok, datetime} =
      NaiveDateTime.new(
        1980 + year,
        month,
        day,
        hour,
        minute,
        min(second * 2, 59)
      )

    datetime
  end
end
