# frozen_string_literal: true

# This class represents a Docker Image.
class Docker::Image
  include Docker::Base

  # Given a command and optional list of streams to attach to, run a command on
  # an Image. This will not modify the Image, but rather create a new Container
  # to run the Image. If the image has an embedded config, no command is
  # necessary, but it will fail with 500 if no config is saved with the image
  def run(cmd = nil, options = {})
    opts = {'Image' => self.id}.merge(options)
    opts["Cmd"] = cmd.is_a?(String) ? cmd.split(/\s+/) : cmd
    begin
      Docker::Container.create(opts, connection)
                       .tap(&:start!)
    rescue ServerError, ClientError => ex
      if cmd
        raise ex
      else
        raise ex, "No command specified."
      end
    end
  end

  # Push the Image to the Docker registry.
  def push(creds = nil, options = {}, &block)
    repo_tag = options.delete(:repo_tag) || ensure_repo_tags.first
    raise ArgumentError, "Image is untagged" if repo_tag.nil?
    repo, tag = Docker::Util.parse_repo_tag(repo_tag)
    raise ArgumentError, "Image does not have a name to push." if repo.nil?

    body = +""
    credentials = creds || Docker.creds || {}
    headers = Docker::Util.build_auth_header(credentials)
    opts = {:tag => tag}.merge(options)
    connection.post("/images/#{repo}/push", opts, :headers => headers,
                    :response_block => self.class.response_block(body, &block))
    self
  end

  # Tag the Image.
  def tag(opts = {})
    self.info['RepoTags'] ||= []
    connection.post(path_for(:tag), opts)
    repo = opts['repo'] || opts[:repo]
    tag = opts['tag'] || opts[:tag] || 'latest'
    self.info['RepoTags'] << "#{repo}:#{tag}"
  end

  # Given a path of a local file and the path it should be inserted, creates
  # a new Image that has that file.
  def insert_local(opts = {})
    local_paths = opts.delete('localPath')
    output_path = opts.delete('outputPath')

    local_paths = [ local_paths ] unless local_paths.is_a?(Array)

    file_hash = Docker::Util.file_hash_from_paths(local_paths)

    file_hash['Dockerfile'] = dockerfile_for(file_hash, output_path)

    tar = Docker::Util.create_tar(file_hash)
    body = connection.post('/build', opts, :body => tar)
    self.class.send(:new, connection, 'id' => Docker::Util.extract_id(body))
  end

  # Remove the Image from the server.
  def remove(opts = {})
    name = opts.delete(:name)

    unless name
      if ::Docker.podman?(connection)
        name = self.id.split(':').last
      else
        name = self.id
      end
    end

    connection.delete("/images/#{name}", opts)
  end
  alias_method :delete, :remove

  # Return a String representation of the Image.
  def to_s
    "Docker::Image { :id => #{self.id}, :info => #{self.info.inspect}, "\
      ":connection => #{self.connection} }"
  end

  # #json returns extra information about an Image, #history returns its
  # history.
  [:json, :history].each do |method|
    define_method(method) do |opts = {}|
      Docker::Util.parse_json(connection.get(path_for(method), opts))
    end
  end

  # Save the image as a tarball
  def save(filename = nil)
    self.class.save(self.id, filename, connection)
  end

  # Save the image as a tarball to an IO object.
  def save_stream(opts = {}, &block)
    self.class.save_stream(self.id, opts, connection, &block)
  end

  # Update the @info hash, which is the only mutable state in this object.
  def refresh!
    img = Docker::Image.all({:all => true}, connection).find { |image|
      image.id.start_with?(self.id) || self.id.start_with?(image.id)
    }
    info.merge!(self.json)
    img && info.merge!(img.info)
    self
  end

  class << self

    # Create a new Image.
    def create(opts = {}, creds = nil, conn = Docker.connection, &block)
      credentials = creds.nil? ? Docker.creds : MultiJson.dump(creds)
      headers = credentials && Docker::Util.build_auth_header(credentials) || {}
      body = +''
      conn.post(
        '/images/create',
        opts,
        :headers => headers,
        :response_block => response_block(body, &block)
        )
      # NOTE: see associated tests for why we're looking at image#end_with?
      image = opts['fromImage'] || opts[:fromImage]
      tag = opts['tag'] || opts[:tag]
      image = "#{image}:#{tag}" if tag && !image.end_with?(":#{tag}")
      get(image, {}, conn)
    end

    # Return a specific image.
    def get(id, opts = {}, conn = Docker.connection)
      image_json = conn.get("/images/#{id}/json", opts)
      hash = Docker::Util.parse_json(image_json) || {}
      new(conn, hash)
    end

    # Delete a specific image
    def remove(id, opts = {}, conn = Docker.connection)
      conn.delete("/images/#{id}", opts)
    end
    alias_method :delete, :remove

    # Prune images
    def prune(conn = Docker.connection)
      conn.post("/images/prune", {})
    end


    # Save the raw binary representation or one or more Docker images
    #
    # @param names [String, Array#String] The image(s) you wish to save
    # @param filename [String] The file to export the data to.
    # @param conn [Docker::Connection] The Docker connection to use
    #
    # @return [NilClass, String] If filename is nil, return the string
    # representation of the binary data. If the filename is not nil, then
    # return nil.
    def save(names, filename = nil, conn = Docker.connection)
      if filename
        File.open(filename, 'wb') do |file|
          save_stream(names, {}, conn, &response_block_for_save(file))
        end
        nil
      else
        string = +''
        save_stream(names, {}, conn, &response_block_for_save(string))
        string
      end
    end

    # Stream the contents of Docker image(s) to a block.
    #
    # @param names [String, Array#String] The image(s) you wish to save
    # @param conn [Docker::Connection] The Docker connection to use
    # @yield chunk [String] a chunk of the Docker image(s).
    def save_stream(names, opts = {}, conn = Docker.connection, &block)
      # By using compare_by_identity we can create a Hash that has
      # the same key multiple times.
      query = {}.tap(&:compare_by_identity)
      Array(names).each { |name| query['names'.dup] = name }
      conn.get(
        '/images/get',
        query,
        opts.merge(:response_block => block)
      )
      nil
    end

    # Load a tar Image
    def load(tar, opts = {}, conn = Docker.connection, creds = nil, &block)
       headers = build_headers(creds)
       io = tar.is_a?(String) ? File.open(tar, 'rb') : tar
       body = +""
       conn.post(
         '/images/load',
         opts,
         :headers => headers,
         :response_block => response_block(body, &block)
       ) { io.read(Excon.defaults[:chunk_size]).to_s }
    end

    # Check if an image exists.
    def exist?(id, opts = {}, conn = Docker.connection)
      get(id, opts, conn)
      true
    rescue Docker::Error::NotFoundError
      false
    end

    # Return every Image.
    def all(opts = {}, conn = Docker.connection)
      hashes = Docker::Util.parse_json(conn.get('/images/json', opts)) || []
      hashes.map { |hash| new(conn, hash) }
    end

    # Given a query like `{ :term => 'sshd' }`, queries the Docker Registry for
    # a corresponding Image.
    def search(query = {}, connection = Docker.connection, creds = nil)
      credentials = creds.nil? ? Docker.creds : creds.to_json
      headers = credentials && Docker::Util.build_auth_header(credentials) || {}
      body = connection.get(
        '/images/search',
        query,
        :headers => headers,
      )
      hashes = Docker::Util.parse_json(body) || []
      hashes.map { |hash| new(connection, 'id' => hash['name']) }
    end

    # Import an Image from the output of Docker::Container#export. The first
    # argument may either be a File or URI.
    def import(imp, opts = {}, conn = Docker.connection)
      require 'open-uri'

      # This differs after Ruby 2.4
      if URI.public_methods.include?(:open)
        munged_open = URI.method(:open)
      else
        munged_open = self.method(:open)
      end

      munged_open.call(imp) do |io|
        import_stream(opts, conn) do
          io.read(Excon.defaults[:chunk_size]).to_s
        end
      end
    rescue StandardError
      raise Docker::Error::IOError, "Could not import '#{imp}'"
    end

    def import_stream(options = {}, connection = Docker.connection, &block)
      body = connection.post(
        '/images/create',
         options.merge('fromSrc' => '-'),
         :headers => { 'Content-Type' => 'application/tar',
                       'Transfer-Encoding' => 'chunked' },
         &block
      )
      new(connection, 'id'=> Docker::Util.parse_json(body)['status'])
    end

    # Given a Dockerfile as a string, builds an Image.
    def build(commands, opts = {}, connection = Docker.connection, &block)
      body = +""
      connection.post(
        '/build', opts,
        :body => Docker::Util.create_tar('Dockerfile' => commands),
        :response_block => response_block(body, &block)
      )
      new(connection, 'id' => Docker::Util.extract_id(body))
    rescue Docker::Error::ServerError
      raise Docker::Error::UnexpectedResponseError
    end

    # Given File like object containing a tar file, builds an Image.
    #
    # If a block is passed, chunks of output produced by Docker will be passed
    # to that block.
    def build_from_tar(tar, opts = {}, connection = Docker.connection,
                       creds = nil, &block)

      headers = build_headers(creds)

      # The response_block passed to Excon will build up this body variable.
      body = +""
      connection.post(
        '/build', opts,
        :headers => headers,
        :response_block => response_block(body, &block)
      ) { tar.read(Excon.defaults[:chunk_size]).to_s }

      new(connection,
          'id' => Docker::Util.extract_id(body),
          :headers => headers)
    end

    # Given a directory that contains a Dockerfile, builds an Image.
    #
    # If a block is passed, chunks of output produced by Docker will be passed
    # to that block.
    def build_from_dir(dir, opts = {}, connection = Docker.connection,
                       creds = nil, &block)

      tar = Docker::Util.create_dir_tar(dir)
      build_from_tar tar, opts, connection, creds, &block
    ensure
      unless tar.nil?
        tar.close
        FileUtils.rm(tar.path, force: true)
      end
    end
  end

  private

  # A method to build the config header and merge it into the
  # headers sent by build_from_dir.
  def self.build_headers(creds=nil)
    credentials = creds || Docker.creds || {}
    config_header = Docker::Util.build_config_header(credentials)

    headers = { 'Content-Type'      => 'application/tar',
                'Transfer-Encoding' => 'chunked' }
    headers = headers.merge(config_header) if config_header
    headers
  end

  # Convenience method to return the path for a particular resource.
  def path_for(resource)
    "/images/#{self.id}/#{resource}"
  end


  # Convience method to get the Dockerfile for a file hash and a path to
  # output to.
  def dockerfile_for(file_hash, output_path)
    dockerfile = +"from #{self.id}\n"

    file_hash.keys.each do |basename|
      dockerfile << "add #{basename} #{output_path}\n"
    end

    dockerfile
  end

  def ensure_repo_tags
    refresh! unless info.has_key?('RepoTags')
    info['RepoTags']
  end

  # Generates the block to be passed as a reponse block to Excon. The returned
  # lambda will append Docker output to the first argument, and yield output to
  # the passed block, if a block is given.
  def self.response_block(body)
    lambda do |chunk, remaining, total|
      body << chunk
      yield chunk if block_given?
    end
  end

  # Generates the block to be passed in to the save request. This lambda will
  # append the streaming data to the file provided.
  def self.response_block_for_save(file)
    lambda do |chunk, remianing, total|
      file << chunk
    end
  end
end
