# Copyright (C) 2008-2011 AMEE UK Ltd. - http://www.amee.com
# Released as Open Source Software under the BSD 3-Clause license. See LICENSE.txt for details.

require 'typhoeus'
require 'json'
require 'log_buddy'



# LogBuddy.init :log_to_stdout => false
LogBuddy.init :disabled => true
# Set this to true to output curl debug messages in development
DEBUG = false

module AMEE
  class Connection

    RootCA = File.dirname(__FILE__) + '/../../cacert.pem'

    def initialize(server, username, password, options = {})
      unless options.is_a?(Hash)
        raise AMEE::ArgumentError.new("Fourth argument must be a hash of options!")
      end
      
      @server = server
      @username = username
      @password = password
      @ssl = (options[:ssl] == false) ? false : true
      @port = @ssl ? 443 : 80
      @auth_token = nil
      @format = options[:format] || (defined?(JSON) ? :json : :xml)
      @amee_source = options[:amee_source]
      @retries = options[:retries] || 0
      if !valid?
       raise "You must supply connection details - server, username and password are all required!"
      end

      # Working with caching

      # Handle old option
      if options[:enable_caching]
        Kernel.warn '[DEPRECATED] :enable_caching => true is deprecated. Use :cache => :memory_store instead'
        options[:cache] ||= :memory_store
      end
      # Create cache store
      if options[:cache] &&
        (options[:cache_store].class.name == "ActiveSupport::Cache::MemCacheStore" ||
         options[:cache].to_sym == :mem_cache_store)
        raise 'ActiveSupport::Cache::MemCacheStore is not supported, as it doesn\'t allow regexp expiry'
      end
      if options[:cache_store].is_a?(ActiveSupport::Cache::Store)
        # Allows assignment of the entire cache store in Rails apps
        @cache = options[:cache_store]
      elsif options[:cache]
        if options[:cache_options]
          @cache = ActiveSupport::Cache.lookup_store(options[:cache].to_sym, options[:cache_options])
        else
          @cache = ActiveSupport::Cache.lookup_store(options[:cache].to_sym)
        end
      end

      # set up hash to pass to builder block
      @params = {
        :ssl => @ssl,
        :params => {},
        :headers => {}
      }

      if @ssl == true
        @params[:ssl] = true
        if File.exists? RootCA
          @params[:ca_file] = RootCA
        end
      end
      
      self.timeout = options[:timeout] || 60
      @debug = options[:enable_debug]
    end

    attr_reader :format
    attr_reader :server
    attr_reader :username
    attr_reader :password
    attr_reader :retries
    attr_accessor :auth_token #Only used in tests really

    def timeout
      @params[:timeout]
    end

    def timeout=(t)
      @params[:open_timeout] = @params[:timeout] = t
    end

    def version
      authenticate if @version.nil?
      @version
    end

    def valid?
      @username && @password && @server
    end

    # check if we have a valid authentication token
    def authenticated?
      @auth_token =~ /^.*$/
    end

    # GET data from the API, passing in a hash of parameters
    def get(path, data = {})
      # Allow format override
      format = data.delete(:format) || @format
      # Add parameters to URL query string
      get_params = {
        :method => "get", 
        :verbose => DEBUG
      }
      get_params[:params] = data unless data.empty?
      # Create GET request
      get = Typhoeus::Request.new("#{protocol}#{@server}#{path}", get_params)
      # Send request
      do_request(get, format, :cache => true)
    end

    # POST to the AMEE API, passing in a hash of values
    def post(path, data = {})
      # Allow format override
      format = data.delete(:format) || @format
      # Clear cache
      expire_matching "#{raw_path(path)}.*"
      # Extract return unit params
      query_params = {}
      query_params[:returnUnit] = data.delete(:returnUnit) if data[:returnUnit]
      query_params[:returnPerUnit] = data.delete(:returnPerUnit) if data[:returnPerUnit]
      # Create POST request
      post_params = {
        :verbose => DEBUG,
        :method => "post",
        :body => form_encode(data)
      }
      post_params[:params] = query_params unless query_params.empty?
      post = Typhoeus::Request.new("#{protocol}#{@server}#{path}", post_params)
      # Send request
      do_request(post, format)      
    end

    # POST to the AMEE API, passing in a string of data
    def raw_post(path, body, options = {})
      # Allow format override
      format = options.delete(:format) || @format
      # Clear cache
      expire_matching "#{raw_path(path)}.*"
      # Create POST request
      post = Typhoeus::Request.new("#{protocol}#{@server}#{path}", 
        :verbose => DEBUG,
        :method => "post",
        :body => body,
        :headers => { :'Content-type' => options[:content_type] || content_type(format)  }
      )

      # Send request
      do_request(post, format)
    end

    # PUT to the AMEE API, passing in a hash of data
    def put(path, data = {})
      # Allow format override
      format = data.delete(:format) || @format
      # Clear cache
      expire_matching "#{parent_path(path)}.*"
      # Extract return unit params
      query_params = {}
      query_params[:returnUnit] = data.delete(:returnUnit) if data[:returnUnit]
      query_params[:returnPerUnit] = data.delete(:returnPerUnit) if data[:returnPerUnit]
      # Create PUT request
      put_params = {
        :verbose => DEBUG,
        :method => "put",
        :body => form_encode(data)
      }
      put_params[:params] = query_params unless query_params.empty?
      put = Typhoeus::Request.new("#{protocol}#{@server}#{path}", put_params)
       # Send request
      do_request(put, format)
    end

    # PUT to the AMEE API, passing in a string of data
    def raw_put(path, body, options = {})
      # Allow format override
      format = options.delete(:format) || @format
      # Clear cache
      expire_matching "#{parent_path(path)}.*"
      # Create PUT request
      put = Typhoeus::Request.new("#{protocol}#{@server}#{path}", 
        :verbose => DEBUG,
        :method => "put",
        :body => body,
        :headers => { :'Content-type' => options[:content_type] || content_type(format)  }
      )
      # Send request
      do_request(put, format)
    end

    def delete(path)
      # Clear cache
      expire_matching "#{parent_path(path)}.*"
      # Create DELETE request
      delete = Typhoeus::Request.new("#{protocol}#{@server}#{path}", 
        :verbose => DEBUG,
        :method => "delete"
      )
     # Send request
      do_request(delete)
    end

    # Post to the sign in resource on the API, so that all future 
    # requests are signed
    def authenticate
      # :x_amee_source = "X-AMEE-Source".to_sym
      request = Typhoeus::Request.new("#{protocol}#{@server}/auth/signIn", 
        :method => "post",
        :verbose => DEBUG,
        :headers => {
          :Accept => content_type(:xml),
        },
        :body => form_encode(:username=>@username, :password=>@password)
      )

      hydra.queue(request)
      hydra.run
      response = request.response

      @auth_token = response.headers_hash['AuthToken']
      d {request.url}
      d {response.code}
      d {@auth_token}

      connection_failed if response.code == 0

      unless authenticated?
        raise AMEE::AuthFailed.new("Authentication failed. Please check your username and password. (tried #{@username},#{@password})")
      end
      # Detect API version
      if response.body.is_json?
        @version = JSON.parse(response.body)["user"]["apiVersion"].to_f
      elsif response.body.is_xml?
        @version = REXML::Document.new(response.body).elements['Resources'].elements['SignInResource'].elements['User'].elements['ApiVersion'].text.to_f
      else
        @version = 1.0
      end
    end

    protected

    def protocol
      @ssl == true ? 'https://' : 'http://'
    end

    # Encode a hash into a application/x-www-form-urlencoded format
    def form_encode(data)
      data.map { |datum|
        "#{CGI::escape(datum[0].to_s)}=#{CGI::escape(datum[1].to_s)}"
      }.join('&')
    end
    
    ## set up the hydra for running http requests. Increase concurrency as needed
    def hydra
      @hydra ||= Typhoeus::Hydra.new(:max_concurrency => 1)
    end

    def content_type(format = @format)
      case format
      when :xml
        return 'application/xml'
      when :json
        return 'application/json'
      when :atom
        return 'application/atom+xml'
      end
    end

    def redirect?(response)
      response.code == 301 || response.code == 302
    end
    
    def connection_failed
      raise AMEE::ConnectionFailed.new("Connection failed. Check server name or network connection.")
    end
    
    # run each request through some basic error checking, and 
    # if needed log requests
    def response_ok?(response, request)
      
      # first allow for debugging
      d {request.object_id}
      d {request}
      d {response.object_id}
      d {response.code}
      d {response.headers_hash}
      d {response.body}

      case response.code.to_i

      when 502, 503, 504
          raise AMEE::ConnectionFailed.new("A connection error occurred while talking to AMEE: HTTP response code #{response.code}.\nRequest: #{request.method.upcase} #{request.url.gsub(request.host, '')}")
      when 408
        raise AMEE::TimeOut.new("Request timed out.")
      when 404
        raise AMEE::NotFound.new("The URL was not found on the server.\nRequest: #{request.method.upcase} #{request.url.gsub(request.host, '')}")
      when 403
        raise AMEE::PermissionDenied.new("You do not have permission to perform the requested operation.\nRequest: #{request.method.upcase} #{request.url.gsub(request.host, '')}\n#{request.body}\Response: #{response.body}")
      when 401
        authenticate
        return false
      when 400
        if response.body.include? "would have resulted in a duplicate resource being created"
          raise AMEE::DuplicateResource.new("The specified resource already exists. This is most often caused by creating an item that overlaps another in time.\nRequest: #{request.method.upcase} #{request.url.gsub(request.host, '')}\n#{request.body}\Response: #{response.body}")
        else
          raise AMEE::BadRequest.new("Bad request. This is probably due to malformed input data.\nRequest: #{request.method.upcase} #{request.url.gsub(request.host, '')}\n#{request.body}\Response: #{response.body}")
        end
      when 200, 201, 204
        return response
      when 0
        connection_failed
      end
      # If we get here, something unhandled has happened, so raise an unknown error.
      raise AMEE::UnknownError.new("An error occurred while talking to AMEE: HTTP response code #{response.code}.\nRequest: #{request.method.upcase} #{request.url}\n#{request.body}\Response: #{response.body}")
    end

    # Wrapper for sending requests through to the API.
    # Takes care of making sure requests authenticated, and 
    # if set, attempts to retry a number of times set when
    # initialising the class
    def do_request(request, format = @format, options = {})

      # Is this a v3 request?
      v3_request = request.url.include?("/#{v3_hostname}/")

      # make sure we have our auth token before we start
      # any v1 or v2 requests
      if !@auth_token && !v3_request
        d "Authenticating first before we hit #{request.url}"
        authenticate 
      end

      request.headers['Accept'] = content_type(format)
      # Set AMEE source header if set
      request.headers['X-AMEE-Source'] = @amee_source if @amee_source

      # path+query string only (split with an int limits the number of splits)
      path_and_query = '/' + request.url.split('/', 4)[3]

      if options[:cache]
        # Get response with caching
        response = cache(path_and_query) { run_request(request, :xml) }
      else
        response = run_request(request, :xml)
      end
      response
    end

    # run request. Extracted from do_request to make
    # cache code simpler
    def run_request(request, format)
      # Is this a v3 request?
      v3_request = request.url.include?("/#{v3_hostname}/")
      # Execute with retries
      retries = [1] * @retries
      begin 
        begin
          d "Queuing the request for #{request.url}"
          add_authentication_to(request) if @auth_token && !v3_request
          hydra.queue request
          hydra.run
          # Return response if OK
        end while !response_ok?(request.response, request)
        # Store updated authToken
        @auth_token = request.response.headers_hash['AuthToken']
        return request.response
      rescue AMEE::ConnectionFailed, AMEE::TimeOut => e
        if delay = retries.shift
          sleep delay
          retry
        else
          raise
        end
      end
    end
    
    # Take an existing request, and add authentication
    # may no longer be needed now that we authenticate before 
    # making a request anyway
    def add_authentication_to(request=nil)
      if @auth_token
        request.headers['Cookie'] = "AuthToken=#{@auth_token}"
        request.headers['AuthToken'] = @auth_token
      else
        raise "The connection can't authenticate. Check if the auth_token is being set by the server"
      end
    end

    def cache(path, &block)
      key = cache_key(path)
      if @cache && @cache.exist?(key)
        d "CACHE HIT on #{key}" if @debug
        return @cache.read(key)
      end
      d "CACHE MISS on #{key}" if @debug
      data = block.call
      @cache.write(key, data) if @cache
      return data
    end

    def parent_path(path)
      path.split('/')[0..-2].join('/')
    end

    def raw_path(path)
      path.split(/[;?]/)[0]
    end

    def cache_key(path)
      # We have to make sure cache keys don't get too long for the filesystem,
      # so we cut them off if they're too long and add a digest for uniqueness.
      key = @server + path.gsub(/[^0-9a-z\/]/i, '').gsub(/\//i, '_')
      key = (key.length < 250) ? key : key.first(218)+Digest::MD5.hexdigest(key)
    end

    public

    def expire(path, options = nil)
      @cache.delete(cache_key(path), options) if @cache
    end

    def expire_matching(matcher, options = nil)
      @cache.delete_matched(Regexp.new(cache_key(matcher)), options) if @cache
    end

    def expire_all
      @cache.clear if @cache
    end

  end
end