##
# This class is compatible with IO class (https://ruby-doc.org/core-2.3.1/IO.html)
# source: https://gitlab.com/snippets/1685610
module Gitlab
  module Ci
    class Trace
      class ChunkedIO
        CHUNK_SIZE = ::Ci::JobTraceChunk::CHUNK_SIZE

        FailedToGetChunkError = Class.new(StandardError)

        attr_reader :job
        attr_reader :tell, :size
        attr_reader :chunk, :chunk_range

        alias_method :pos, :tell

        def initialize(job, &block)
          @job = job
          @chunks_cache = []
          @tell = 0
          @size = job_chunks.last.try(&:end_offset).to_i
          yield self if block_given?
        end

        def close
          # no-op
        end

        def binmode
          # no-op
        end

        def binmode?
          true
        end

        def seek(pos, where = IO::SEEK_SET)
          new_pos =
            case where
            when IO::SEEK_END
              size + pos
            when IO::SEEK_SET
              pos
            when IO::SEEK_CUR
              tell + pos
            else
              -1
            end

          raise 'new position is outside of file' if new_pos < 0 || new_pos > size

          @tell = new_pos
        end

        def eof?
          tell == size
        end

        def each_line
          until eof?
            line = readline
            break if line.nil?

            yield(line)
          end
        end

        def read(length = nil, outbuf = "")
          out = ""

          length = size - tell unless length

          until length <= 0 || eof?
            data = chunk_slice_from_offset
            break if data.empty?

            chunk_bytes = [CHUNK_SIZE - chunk_offset, length].min
            chunk_data = data.byteslice(0, chunk_bytes)

            out << chunk_data
            @tell += chunk_data.bytesize
            length -= chunk_data.bytesize
          end

          # If outbuf is passed, we put the output into the buffer. This supports IO.copy_stream functionality
          if outbuf
            outbuf.slice!(0, outbuf.bytesize)
            outbuf << out
          end

          out
        end

        def readline
          out = ""

          until eof?
            data = chunk_slice_from_offset
            new_line = data.index("\n")

            if !new_line.nil?
              out << data[0..new_line]
              @tell += new_line + 1
              break
            else
              out << data
              @tell += data.bytesize
            end
          end

          out
        end

        def write(data)
          raise 'Could not write empty data' unless data.present?

          start_pos = tell
          data = data.force_encoding(Encoding::BINARY)

          while tell < start_pos + data.bytesize
            # get slice from current offset till the end where it falls into chunk
            chunk_bytes = CHUNK_SIZE - chunk_offset
            chunk_data = data.byteslice(tell - start_pos, chunk_bytes)

            # append data to chunk, overwriting from that point
            ensure_chunk.append(chunk_data, chunk_offset)

            # move offsets within buffer
            @tell += chunk_data.bytesize
            @size = [size, tell].max
          end

          tell - start_pos
        ensure
          invalidate_chunk_cache
        end

        def truncate(offset)
          raise 'Outside of file' if offset > size

          @tell = offset
          @size = offset

          # remove all next chunks
          job_chunks.where('chunk_index > ?', chunk_index).destroy_all

          # truncate current chunk
          current_chunk.truncate(chunk_offset) if chunk_offset != 0
        ensure
          invalidate_chunk_cache
        end

        def flush
          # no-op
        end

        def present?
          true
        end

        def destroy!
          job_chunks.destroy_all
          @tell = @size = 0
        ensure
          invalidate_chunk_cache
        end

        private

        ##
        # The below methods are not implemented in IO class
        #
        def in_range?
          @chunk_range&.include?(tell)
        end

        def chunk_slice_from_offset
          unless in_range?
            current_chunk.tap do |chunk|
              raise FailedToGetChunkError unless chunk

              @chunk = chunk.data.force_encoding(Encoding::BINARY)
              @chunk_range = chunk.range
            end
          end

          @chunk[chunk_offset..CHUNK_SIZE]
        end

        def chunk_offset
          tell % CHUNK_SIZE
        end

        def chunk_index
          tell / CHUNK_SIZE
        end

        def chunk_start
          chunk_index * CHUNK_SIZE
        end

        def chunk_end
          [chunk_start + CHUNK_SIZE, size].min
        end

        def invalidate_chunk_cache
          @chunks_cache = []
        end

        def current_chunk
          @chunks_cache[chunk_index] ||= job_chunks.find_by(chunk_index: chunk_index)
        end

        def build_chunk
          @chunks_cache[chunk_index] = ::Ci::JobTraceChunk.new(job: job, chunk_index: chunk_index)
        end

        def ensure_chunk
          current_chunk || build_chunk
        end

        def job_chunks
          ::Ci::JobTraceChunk.where(job: job)
        end
      end
    end
  end
end