require "base64" unless defined?(Base64)
require "digest" unless defined?(Digest)
require "openssl" unless defined?(OpenSSL)
require "time" unless defined?(Time)

#
# DEBUG steps:
#
# check .chomp
#

module ChefAPI
  class Authentication

    # @todo: Enable this in the future when Mixlib::Authentication supports
    # signing the full request body instead of just the uploaded file parameter.
    SIGN_FULL_BODY = false

    SIGNATURE = "algorithm=sha1;version=1.0;".freeze

    # Headers
    X_OPS_SIGN          = "X-Ops-Sign".freeze
    X_OPS_USERID        = "X-Ops-Userid".freeze
    X_OPS_TIMESTAMP     = "X-Ops-Timestamp".freeze
    X_OPS_CONTENT_HASH  = "X-Ops-Content-Hash".freeze
    X_OPS_AUTHORIZATION = "X-Ops-Authorization".freeze

    class << self
      #
      # Create a new signing object from the given options. All options are
      # required.
      #
      # @see (#initialize)
      #
      # @option options [String] :user
      # @option options [String, OpenSSL::PKey::RSA] :key
      # @option options [String, Symbol] verb
      # @option options [String] :path
      # @option options [String, IO] :body
      #
      def from_options(options = {})
        user = options.fetch(:user)
        key  = options.fetch(:key)
        verb = options.fetch(:verb)
        path = options.fetch(:path)
        body = options.fetch(:body)

        new(user, key, verb, path, body)
      end
    end

    #
    # Create a new Authentication object for signing. Creating an instance will
    # not run any validations or perform any operations (this is on purpose).
    #
    # @param [String] user
    #   the username/client/user of the user to sign the request. In Hosted
    #   Chef land, this is your "client". In Supermarket land, this is your
    #   "username".
    # @param [String, OpenSSL::PKey::RSA] key
    #   the path to a private key on disk, the raw private key (as a String),
    #   or the raw private key (as an OpenSSL::PKey::RSA instance)
    # @param [Symbol, String] verb
    #   the verb for the request (e.g. +:get+)
    # @param [String] path
    #   the "path" part of the URI (e.g. +/path/to/resource+)
    # @param [String, IO] body
    #   the body to sign for the request, as a raw string or an IO object to be
    #   read in chunks
    #
    def initialize(user, key, verb, path, body)
      @user = user
      @key  = key
      @verb = verb
      @path = path
      @body = body
    end

    #
    # The fully-qualified headers for this authentication object of the form:
    #
    #   {
    #     'X-Ops-Sign'            => 'algorithm=sha1;version=1.1',
    #     'X-Ops-Userid'          => 'sethvargo',
    #     'X-Ops-Timestamp'       => '2014-07-07T02:17:15Z',
    #     'X-Ops-Content-Hash'    => '...',
    #     'x-Ops-Authorization-1' => '...'
    #     'x-Ops-Authorization-2' => '...'
    #     'x-Ops-Authorization-3' => '...'
    #     # ...
    #   }
    #
    # @return [Hash]
    #   the signing headers
    #
    def headers
      {
        X_OPS_SIGN => SIGNATURE,
        X_OPS_USERID => @user,
        X_OPS_TIMESTAMP => canonical_timestamp,
        X_OPS_CONTENT_HASH => content_hash,
      }.merge(signature_lines)
    end

    #
    # The canonical body. This could be an IO object (such as +#body_stream+),
    # an actual string (such as +#body+), or just the empty string if the
    # request's body and stream was nil.
    #
    # @return [String, IO]
    #
    def content_hash
      return @content_hash if @content_hash

      if SIGN_FULL_BODY
        @content_hash = hash(@body || "").chomp
      else
        if @body.is_a?(Multipart::MultiIO)
          filepart = @body.ios.find { |io| io.is_a?(Multipart::MultiIO) }
          file     = filepart.ios.find { |io| !io.is_a?(StringIO) }

          @content_hash = hash(file).chomp
        else
          @content_hash = hash(@body || "").chomp
        end
      end

      @content_hash
    end

    #
    # Parse the given private key. Users can specify the private key as:
    #
    #   - the path to the key on disk
    #   - the raw string key
    #   - an +OpenSSL::PKey::RSA object+
    #
    # Any other implementations are not supported and will likely explode.
    #
    # @todo
    #   Handle errors when the file cannot be read due to insufficient
    #   permissions
    #
    # @return [OpenSSL::PKey::RSA]
    #   the RSA private key as an OpenSSL object
    #
    def canonical_key
      return @canonical_key if @canonical_key

      ChefAPI::Log.info "Parsing private key..."

      if @key.nil?
        ChefAPI::Log.warn "No private key given!"
        raise "No private key given!"
      end

      if @key.is_a?(OpenSSL::PKey::RSA)
        ChefAPI::Log.debug "Detected private key is an OpenSSL Ruby object"
        @canonical_key = @key
      elsif @key =~ /(.+)\.pem$/ || File.exist?(File.expand_path(@key))
        ChefAPI::Log.debug "Detected private key is the path to a file"
        contents = File.read(File.expand_path(@key))
        @canonical_key = OpenSSL::PKey::RSA.new(contents)
      else
        ChefAPI::Log.debug "Detected private key was the literal string key"
        @canonical_key = OpenSSL::PKey::RSA.new(@key)
      end

      @canonical_key
    end

    #
    # The canonical path, with duplicate and trailing slashes removed. This
    # value is then hashed.
    #
    # @example
    #   "/zip//zap/foo" #=> "/zip/zap/foo"
    #
    # @return [String]
    #
    def canonical_path
      @canonical_path ||= hash(@path.squeeze("/").gsub(%r{(/)+$}, "")).chomp
    end

    #
    # The iso8601 timestamp for this request. This value must be cached so it
    # is persisted throughout this entire request.
    #
    # @return [String]
    #
    def canonical_timestamp
      @canonical_timestamp ||= Time.now.utc.iso8601
    end

    #
    # The uppercase verb.
    #
    # @example
    #   :get #=> "GET"
    #
    # @return [String]
    #
    def canonical_method
      @canonical_method ||= @verb.to_s.upcase
    end

    #
    # The canonical request, from the path, body, user, and current timestamp.
    #
    # @return [String]
    #
    def canonical_request
      [
        "Method:#{canonical_method}",
        "Hashed Path:#{canonical_path}",
        "X-Ops-Content-Hash:#{content_hash}",
        "X-Ops-Timestamp:#{canonical_timestamp}",
        "X-Ops-UserId:#{@user}",
      ].join("\n")
    end

    #
    # The canonical request, encrypted by the given private key.
    #
    # @return [String]
    #
    def encrypted_request
      canonical_key.private_encrypt(canonical_request)
    end

    #
    # The +X-Ops-Authorization-N+ headers. This method takes the encrypted
    # request, splits on a newline, and creates a signed header authentication
    # request. N begins at 1, not 0 because the original author of
    # Mixlib::Authentication did not believe in computer science.
    #
    # @return [Hash]
    #
    def signature_lines
      signature = Base64.encode64(encrypted_request)
      signature.split(/\n/).each_with_index.inject({}) do |hash, (line, index)|
        hash["#{X_OPS_AUTHORIZATION}-#{index + 1}"] = line
        hash
      end
    end

    private

    #
    # Hash the given object.
    #
    # @param [String, IO] object
    #   a string or IO object to hash
    #
    # @return [String]
    #   the hashed value
    #
    def hash(object)
      if object.respond_to?(:read)
        digest_io(object)
      else
        digest_string(object)
      end
    end

    #
    # Digest the given IO, reading in 1024 bytes at one time.
    #
    # @param [IO] io
    #   the IO (or File object)
    #
    # @return [String]
    #
    def digest_io(io)
      digester = Digest::SHA1.new

      while buffer = io.read(1024)
        digester.update(buffer)
      end

      io.rewind

      Base64.encode64(digester.digest)
    end

    #
    # Digest a string.
    #
    # @param [String] string
    #   the string to digest
    #
    # @return [String]
    #
    def digest_string(string)
      Base64.encode64(Digest::SHA1.digest(string))
    end
  end
end
