require 'stringio'
require 'zlib'

# Initial implementation keeps every file payload and the final ZIP in memory.
# Keep this service boundary stable so job/cached export paths can replace it later.
class MaterialZipExporter
  Entry = Struct.new(:path, :data, :mtime, keyword_init: true)
  MissingFile = Struct.new(:material_id, :export_path, :blob_id, :filename, keyword_init: true)

  class EmptyExportError < StandardError; end
  class DuplicatePathError < StandardError; end
  class MissingFileError < StandardError
    attr_reader :missing_files

    def initialize missing_files
      @missing_files = missing_files

      super("Missing files: #{missing_files.map(&:export_path).join(', ')}")
    end
  end

  def initialize profile: 'legacy_drive', tag_id: nil
    @profile = profile.presence || 'legacy_drive'
    @tag_id = tag_id.presence
  end

  def export
    entries = build_entries
    raise EmptyExportError if entries.empty?

    ZipWriter.write(entries)
  end

  private

  def build_entries
    rows = MaterialExportItem
           .enabled
           .includes(material: { file_attachment: :blob })
           .joins(:material)
           .merge(Material.kept)
           .where(profile: @profile)
           .order(:export_path)

    rows = rows.where(materials: { tag_id: @tag_id }) if @tag_id

    missing_files = []

    entries = rows.filter_map do |item|
      material = item.material
      next unless material.file.attached?

      data = download_blob(item, missing_files)
      next unless data

      Entry.new(path: item.export_path,
                data:,
                mtime: material.updated_at || Time.current)
    end

    raise MissingFileError.new(missing_files) if missing_files.any?

    paths = entries.map(&:path)
    duplicated = paths.find { |path| paths.count(path) > 1 }
    raise DuplicatePathError, duplicated if duplicated

    entries
  end

  def download_blob item, missing_files
    blob = item.material.file.blob

    blob.download
  rescue ActiveStorage::FileNotFoundError
    missing_files << MissingFile.new(
        material_id: item.material_id,
        export_path: item.export_path,
        blob_id: blob.id,
        filename: blob.filename.to_s,
    )
    nil
  end

  class ZipWriter
    VERSION_NEEDED = 20
    GP_FLAG = 0x0800
    COMPRESSION_STORE = 0

    def self.write entries
      new(entries).write
    end

    def initialize entries
      @entries = entries
      @central_directory = []
    end

    def write
      io = StringIO.new(''.b)

      @entries.each do |entry|
        write_entry(io, entry)
      end

      central_start = io.pos
      @central_directory.each { |header| io.write(header) }
      central_size = io.pos - central_start

      io.write([0x06054b50, 0, 0, @entries.size, @entries.size,
                central_size, central_start, 0].pack('VvvvvVVv'))
      io.string
    end

    private

    def write_entry io, entry
      path = entry.path.b
      data = entry.data.b
      crc32 = Zlib.crc32(data)
      dos_time, dos_date = dos_timestamp(entry.mtime)
      offset = io.pos

      local_header = [0x04034b50, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
                      dos_time, dos_date, crc32, data.bytesize, data.bytesize,
                      path.bytesize, 0].pack('VvvvvvVVVvv')

      io.write(local_header)
      io.write(path)
      io.write(data)

      @central_directory << central_header(path:, crc32:, size: data.bytesize,
                                           dos_time:, dos_date:, offset:)
    end

    def central_header path:, crc32:, size:, dos_time:, dos_date:, offset:
      [0x02014b50, VERSION_NEEDED, VERSION_NEEDED, GP_FLAG, COMPRESSION_STORE,
       dos_time, dos_date, crc32, size, size, path.bytesize, 0, 0, 0, 0, 0,
       offset].pack('VvvvvvvVVVvvvvvVV') + path
    end

    def dos_timestamp time
      local = time.to_time
      dos_time = (local.hour << 11) | (local.min << 5) | (local.sec / 2)
      dos_date = ((local.year - 1980) << 9) | (local.month << 5) | local.day
      [dos_time, dos_date]
    end
  end
end
