require 'yajl'
require 'docker'
require 'stringio'

require_relative 'exceptions'

class Hoosegow
  # Minimal API client for Docker, allowing attaching to container
  # stdin/stdout/stderr.
  class Docker
    ::Docker.options[:read_timeout]  = 3600
    ::Docker.options[:write_timeout] = 3600
    DEFAULT_HOST   = "127.0.0.1"
    DEFAULT_PORT   = 4243
    DEFAULT_SOCKET = "/var/run/docker.sock"

    # Initialize a new Docker API client.
    #
    # options - Connection options.
    #           :host   - IP or hostname to connect to (unless using Unix
    #                     socket).
    #           :port   - TCP port to connect to (unless using Unix socket).
    #           :socket - Path to local Unix socket (unless using host and
    #                     port).
    #           :after_create - A proc that will be called after a container is created.
    #           :after_start  - A proc that will be called after a container is started.
    #           :after_stop   - A proc that will be called after a container stops.
    #           :prestart - Start a new container after each `run_container` call.
    #           :volumes  - A mapping of volumes to mount in the container. e.g.
    #                       if the Dockerfile has `VOLUME /work`, where the container will
    #                       write data, and `VOLUME /config` where read-only configuration
    #                       is, you might use
    #                         :volumes => {
    #                           "/config" => "/etc/shared-config",
    #                           "/work"   => "/data/work:rw",
    #                         }
    #                       `:volumes => { "/work" => "/home/localuser/work/to/do" }`
    #           :Other - any option with a capitalized key will be passed on
    #                    to the 'create container' call. See https://docs.docker.com/engine/api/v1.40/#operation/ContainerCreate
    def initialize(options = {})
      set_docker_url! options
      @after_create      = options[:after_create]
      @after_start       = options[:after_start]
      @after_stop        = options[:after_stop]
      @volumes           = options[:volumes]
      @prestart          = options.fetch(:prestart, true)
      @container_options = options.select { |k,v| k =~ /\A[A-Z]/ }
    end

    # Public: Create and start a Docker container if one hasn't been started
    # already, then attach to it its stdin/stdout.
    #
    # image    - The image to run.
    # data     - The data to pipe to the container's stdin.
    #
    # Returns the data from the container's stdout.
    def run_container(image, data, &block)
      unless @prestart && @container
        create_container(image)
        start_container
      end

      begin
        attach_container(data, &block)
      ensure
        wait_container
        delete_container
        if @prestart
          create_container(image)
          start_container
        end
      end
      nil
    end

    # Public: Create a container using the specified image.
    #
    # image - The name of the image to start the container with.
    #
    # Returns nothing.
    def create_container(image)
      create_options = default_container_options(image)

      # Merge in additional :HostConfig options into default options
      if @container_options.has_key?(:HostConfig)
        create_options[:HostConfig].merge!(@container_options[:HostConfig])
      end

      @container = ::Docker::Container.create @container_options.merge(
        create_options
      )
      callback @after_create
    end

    # Public: Start a Docker container.
    #
    # Returns nothing.
    def start_container
      @container.start
      callback @after_start
    end

    # Attach to a container, writing data to container's STDIN.
    #
    # Returns combined STDOUT/STDERR from container.
    def attach_container(data, &block)
      stdin = StringIO.new data
      @container.attach :stdin => stdin, &block
    end

    # Public: Wait for a container to finish.
    #
    # Returns nothing.
    def wait_container
      @container.wait
      callback @after_stop
    end

    # Public: Stop the running container.
    #
    # Returns response body or nil if no container is running.
    def stop_container
      return unless @container
      @container.stop :timeout => 0
      callback @after_stop
    end

    # Public: Delete the last started container.
    #
    # Returns response body or nil if no container was started.
    def delete_container
      return unless @container
      @container.delete :v => 1
    rescue ::Docker::Error::ServerError => e
      $stderr.puts "Docker could not delete #{@container.id}: #{e}"
    end

    # Public: Build a new image.
    #
    # name    - The name to give the image.
    # tarfile - Tarred data for creating image. See https://docs.docker.com/engine/api/v1.40/#operation/ImageBuild
    #
    # Returns Array of build result objects from the Docker API.
    def build_image(name, tarfile)
      # Setup parser to receive chunks and yield parsed JSON objects.
      ret = []
      error = nil
      parser = Yajl::Parser.new
      parser.on_parse_complete = Proc.new do |obj|
        ret << obj
        error = Hoosegow::ImageBuildError.new(obj) if obj["error"]
        yield obj if block_given?
      end

      # Make API call to create image.
      opts = {:t => name, :rm => '1'}
      ::Docker::Image.build_from_tar StringIO.new(tarfile), opts do |chunk|
        parser << chunk
      end

      raise error if error

      # Return Array of received objects.
      ret
    end

    # Check if a Docker image exists.
    #
    # name - The name of the image to check for.
    #
    # Returns true/false.
    def image_exist?(name)
      ::Docker::Image.exist? name
    end

  private

    # Private: Default options used to create containers
    def default_container_options(image)
      {
        :StdinOnce  => true,
        :OpenStdin  => true,
        :HostConfig => {
          :Binds    => volumes_for_bind
        },
        :Image     => image
      }
    end

    # Private: Set the docker URL, if related options are present.
    def set_docker_url!(options)
      if url = docker_url(options)
        ::Docker.url = url
      end
    end

    # Private: Get the URL to use for communicating with Docker. If a host and/or
    # port a present, a TCP socket URL will be generated. Otherwise a Unix
    # socket will be used.
    #
    # options - A Hash of options for building the URL.
    #           :host   - The hostname or IP of a remote Docker daemon
    #                     (optional).
    #           :port   - The TCP port of the remote Docker daemon (optional).
    #           :socket - The path of a local Unix socket (optional).
    #
    # Returns a String url.
    def docker_url(options)
      if options[:host] || options[:port]
        host = options[:host] || DEFAULT_HOST
        port = options[:port] || DEFAULT_PORT
        "tcp://#{host}:#{port}"
      elsif path = options[:socket]
        "unix://#{path}"
      else
        nil
      end
    end

    # Private: Generate the `Binds` argument for creating a container.
    #
    # Given a hash of container_path => local_path in @volumes, generate an
    # array of "local_path:container_path:rw".
    def volumes_for_bind
      result = []
      each_volume do |container_path, local_path, permissions|
        result << "#{local_path}:#{container_path}:#{permissions}"
      end
      result
    end

    # Private: Yields information about each `@volume`.
    #
    #   each_volume do |container_path, local_path, permissions|
    #   end
    def each_volume
      if @volumes
        @volumes.each do |container_path, local_path|
          local_path, permissions = local_path.split(':', 2)
          permissions ||= "ro"
          yield container_path, local_path, permissions
        end
      end
    end

    def callback(callback_proc)
      callback_proc.call(@container.info) if callback_proc
    rescue Object
    end
  end
end
