require "posix/spawn"
require "thread"

module CC
  module Analyzer
    class Container
      ContainerData = Struct.new(
        :image,         # image used to create the container
        :name,          # name given to the container when created
        :duration,      # duration, for a finished event
        :status,        # status, for a finished event
        :stderr,        # stderr, for a finished event
      )
      ImageRequired = Class.new(StandardError)
      Result = Struct.new(
        :exit_status,
        :timed_out?,
        :duration,
        :maximum_output_exceeded?,
        :output_byte_count,
        :stderr,
      )

      DEFAULT_TIMEOUT = 15 * 60 # 15m
      DEFAULT_MAXIMUM_OUTPUT_BYTES = 500_000_000

      def initialize(image:, name:, command: nil, listener: ContainerListener.new)
        raise ImageRequired if image.blank?
        @image = image
        @name = name
        @command = command
        @listener = listener
        @output_delimeter = "\n"
        @on_output = ->(*) {}
        @timed_out = false
        @maximum_output_exceeded = false
        @stderr_io = StringIO.new
        @output_byte_count = 0
        @counter_mutex = Mutex.new
      end

      def on_output(delimeter = "\n", &block)
        @output_delimeter = delimeter
        @on_output = block
      end

      def run(options = [])
        started = Time.now
        @listener.started(container_data)

        pid, _, out, err = POSIX::Spawn.popen4(*docker_run_command(options))

        @t_out = read_stdout(out)
        @t_err = read_stderr(err)
        t_timeout = timeout_thread

        # blocks until the engine stops. there may still be stdout in flight if
        # it was being produced more quickly than consumed.
        _, status = Process.waitpid2(pid)

        # blocks until all readers are done. they're still governed by the
        # timeout thread at this point. if we hit the timeout while processing
        # output, the threads will be Thread#killed as part of #stop and this
        # will unblock with the correct value in @timed_out
        [@t_out, @t_err].each(&:join)

        if @timed_out
          duration = timeout * 1000
          @listener.timed_out(container_data(duration: duration))
        else
          duration = ((Time.now - started) * 1000).round
          @listener.finished(container_data(duration: duration, status: status))
        end

        Result.new(
          status.exitstatus,
          @timed_out,
          duration,
          @maximum_output_exceeded,
          output_byte_count,
          @stderr_io.string,
        )
      ensure
        kill_reader_threads
        t_timeout.kill if t_timeout
      end

      def stop(message = nil)
        reap_running_container(message)
        kill_reader_threads
      end

      private

      attr_reader :output_byte_count, :counter_mutex

      def docker_run_command(options)
        [
          "docker", "run",
          "--rm",
          "--name", @name,
          options,
          @image,
          @command
        ].flatten.compact
      end

      def read_stdout(out)
        Thread.new do
          begin
            out.each_line(@output_delimeter) do |chunk|
              output = chunk.chomp(@output_delimeter)

              @on_output.call(output)
              check_output_bytes(output.bytesize)
            end
          ensure
            out.close
          end
        end
      end

      def read_stderr(err)
        Thread.new do
          begin
            err.each_line do |line|
              @stderr_io.write(line)
              check_output_bytes(line.bytesize)
            end
          ensure
            err.close
          end
        end
      end

      def timeout_thread
        Thread.new do
          # Doing one long `sleep timeout` seems to fail sometimes, so
          # we do a series of short timeouts before exiting
          start_time = Time.now
          loop do
            sleep 10
            duration = Time.now - start_time
            break if duration >= timeout
          end

          @timed_out = true
          stop("timed out")
        end.run
      end

      def check_output_bytes(last_read_byte_count)
        counter_mutex.synchronize do
          @output_byte_count += last_read_byte_count
        end

        if output_byte_count > maximum_output_bytes
          @maximum_output_exceeded = true
          stop("maximum output exceeded")
        end
      end

      def container_data(duration: nil, status: nil)
        ContainerData.new(@image, @name, duration, status, @stderr_io.string)
      end

      def kill_reader_threads
        @t_out.kill if @t_out
        @t_err.kill if @t_err
      end

      def reap_running_container(message)
        Analyzer.logger.warn("killing container name=#{@name} message=#{message.inspect}")
        POSIX::Spawn::Child.new("docker", "kill", @name)
      end

      def timeout
        ENV.fetch("CONTAINER_TIMEOUT_SECONDS", DEFAULT_TIMEOUT).to_i
      end

      def maximum_output_bytes
        ENV.fetch("CONTAINER_MAXIMUM_OUTPUT_BYTES", DEFAULT_MAXIMUM_OUTPUT_BYTES).to_i
      end
    end
  end
end