require_relative 'exchange_handler'
require_relative 'rest_parameters'
require_relative 'rest_parameters_defaults'
require_relative 'rest_exchanger_factory'
require_relative '../core_ext/hash'
require_relative '../not_found_errors'
require_relative 'handler_accessors'
require_relative '../interpreter'
require 'json'
require 'jsonpath'
require 'nori'
require 'erb'
require 'hashie/extensions/indifferent_access'

module Soaspec

  # Wraps around Savon client defining default values dependent on the soap request
  class RestHandler < ExchangeHandler
    extend Soaspec::RestParameters
    include Soaspec::RestParametersDefaults
    extend Soaspec::RestExchangeFactory

    # User used in making API calls
    attr_accessor :api_username

    # Setup object to handle communicating with a particular SOAP WSDL
    # @param [Hash] options Options defining REST request. base_url, default_hash
    def initialize(name = self.class.to_s, options = {})
      raise "Base URL not set! Please set in class with 'base_url' method" unless base_url_value
      if name.is_a?(Hash) && options == {} # If name is not set, use first parameter as the options hash
        options = name
        name = self.class.to_s
      end
      super
      set_remove_keys(options, %i[api_username default_hash template_name])
      @init_options = options
      init_merge_options # Call this to verify any issues with options on creating object
    end

    # Used in together with Exchange request that passes such override parameters
    # @param [Hash] override_parameters Params to characterize REST request
    # @option override_parameters [Hash] :params Extra parameters (E.g. headers)
    # @option override_parameters [String] suburl URL appended to base_url of class
    # @option override_parameters [Hash] :q Query for REST
    # @option override_parameters [Symbol] :method REST method (:get, :post, :patch, etc)
    # Following are for the body of the request
    # @option override_parameters [Hash] :body Hash to be converted to JSON in request body
    # @option override_parameters [String] :payload String to be passed directly in request body
    # @option override_parameters [String] :template_name Path to file to be read via ERB and passed in request body
    # @return [RestClient::Response] Response from making request
    def make_request(override_parameters)
      @merged_options ||= init_merge_options
      test_values = override_parameters
      test_values[:params] ||= {}
      test_values[:method] ||= :post
      test_values[:suburl] = test_values[:suburl].to_s if test_values[:suburl]
      test_values[:params][:params] = test_values[:q] if test_values[:q] # Use q for query parameters. Nested :params is ugly and long
      # In order for ERB to be calculated at correct time, the first time request is made, the resource should be created
      @resource ||= RestClient::Resource.new(ERB.new(base_url_value).result(binding), @merged_options)

      @resource_used = test_values[:suburl] ? @resource[test_values[:suburl]] : @resource

      begin
        response = case test_values[:method]
                   when :post, :patch, :put
                     Soaspec::SpecLogger.info("request body: #{post_data(test_values)}")
                     @resource_used.send(test_values[:method].to_s, post_data(test_values), test_values[:params])
                   else # :get, :delete
                     @resource_used.send(test_values[:method].to_s, test_values[:params])
                   end
      rescue RestClient::ExceptionWithResponse => e
        response = e.response
      end
      Soaspec::SpecLogger.info(["response_headers: #{response.headers}", "response_body: #{response}"])
      response
    end

    # Add values to here when extending this class to have default REST options.
    # See rest client resource at https://github.com/rest-client/rest-client for details
    # It's easier to set headers via 'headers' accessor rather than here
    # @return [Hash] Options adding to & overriding defaults
    def rest_resource_options
      {
      }
    end

    # Perform ERB on each header value
    # @return [Hash] Hash from 'rest_client_headers' passed through ERB
    def parse_headers
      Hash[rest_client_headers.map do |header_name, header_value|
        raise ArgumentError, "Header '#{header_name}' is null. Headers are #{rest_client_headers}" if header_value.nil?
        [header_name, ERB.new(header_value).result(binding)]
      end]
    end

    # Convert snakecase to PascalCase
    def convert_to_pascal_case(key)
      return key if /[[:upper:]]/ =~ key[0] # If first character already capital, don't do conversion
      key.split('_').map(&:capitalize).join
    end

    # Initialize value of merged options
    # @return [Hash] Hash of merged options
    def init_merge_options
      options = rest_resource_options
      options.merge! basic_auth_params if respond_to? :basic_auth_params
      options[:headers] ||= {}
      options[:headers].merge! parse_headers
      if Soaspec.auto_oauth && respond_to?(:access_token)
        options[:headers][:authorization] ||= ERB.new('Bearer <%= access_token %>').result(binding)
      end
      options.merge(@init_options)
    end

    # @param [Hash] format Format of expected result.
    # @return [Object] Generic body to be displayed in error messages
    def response_body(response, format: :hash)
      extract_hash response
    end

    # @return [Boolean] Whether response body includes String
    def include_in_body?(response, expected)
      response.body.include? expected
    end

    # @@return [Boolean] Whether the request found the desired value or not
    def found?(response)
      status_code_for(response) != 404
    end

    # @return [Boolean] Whether response contains expected value
    def include_value?(response, expected)
      extract_hash(response).include_value? expected
    end

    # @return [Boolean] Whether response body contains expected key
    def include_key?(response, expected)
      value_from_path(response, expected)
    end

    # @return [Integer] HTTP Status code for response
    def status_code_for(response)
      response.code
    end

    # Returns the value at the provided xpath
    # @param [RestClient::Response] response
    # @param [String] xpath
    # @return [Enumerable] Value inside element found through Xpath
    def xpath_elements_for(response: nil, xpath: nil, attribute: nil)
      raise ArgumentError unless response && xpath
      raise "Can't perform XPATH if response is not XML" unless Interpreter.response_type_for(response) == :xml
      xpath = "//*[@#{attribute}]" unless attribute.nil?
      if xpath[0] != '/'
        xpath = convert_to_pascal_case(xpath) if pascal_keys?
        xpath = '//' + xpath
      end
      temp_doc = Nokogiri.parse(response.body).dup
      if strip_namespaces? && !xpath.include?(':')
        temp_doc.remove_namespaces!
        temp_doc.xpath(xpath)
      else
        temp_doc.xpath(xpath, temp_doc.collect_namespaces)
      end
    end

    # @return [Enumerable] List of values matching JSON path
    def json_path_values_for(response, path, attribute: nil)
      raise 'JSON does not support attributes' if attribute
      if path[0] != '$'
        path = convert_to_pascal_case(path) if pascal_keys?
        path = '$..' + path
      end
      JsonPath.on(response.body, path)
    end

    # Based on a exchange, return the value at the provided xpath
    # If the path does not begin with a '/', a '//' is added to it
    # @param [Response] response
    # @param [Object] path Xpath, JSONPath or other path identifying how to find element
    # @param [String] attribute Generic attribute to find. Will override path
    # @return [String] Value at Xpath
    def value_from_path(response, path, attribute: nil)
      path = path.to_s
      case Interpreter.response_type_for(response)
      when :xml
        result = xpath_elements_for(response: response, xpath: path, attribute: attribute).first
        raise NoElementAtPath, "No value at Xpath '#{path}'" unless result
        return result.inner_text if attribute.nil?
        return result.attributes[attribute].inner_text
      when :json
        paths_to_check = path.split(',')
        matching_values = paths_to_check.collect do |path_to_check|
          json_path_values_for(response, path_to_check, attribute: attribute)
        end.reject(&:empty?)
        raise NoElementAtPath, "Path '#{path}' not found in '#{response.body}'" if matching_values.empty?
        matching_values.first.first
      when :hash
        response.dig(path.split('.')) # Use path as Hash dig expression separating params via '.' TODO: Unit test
      else
        response.to_s[/path/] # Perform regular expression using path if not XML nor JSON TODO: Unit test
      end
    end

    # @return [Enumerable] List of values returned from path
    def values_from_path(response, path, attribute: nil)
      path = path.to_s
      case Interpreter.response_type_for(response)
      when :xml
        xpath_elements_for(response: response, xpath: path, attribute: attribute).map(&:inner_text)
      when :json
        json_path_values_for(response, path, attribute: attribute)
      else
        raise "Unable to interpret type of #{response.body}"
      end
    end

    # TODO: This and 'to_hash' method should be merged
    # Convert XML or JSON response into a Hash
    # @param [String] response Response as a String (either in XML or JSON)
    # @return [Hash]
    def extract_hash(response)
      raise ArgumentError("Empty Body. Can't assert on it") if response.body.empty?
      case Interpreter.response_type_for response
      when :json
        converted = JSON.parse(response.body)
        return converted.transform_keys_to_symbols if converted.is_a? Hash
        return converted.map!(&:transform_keys_to_symbols) if converted.is_a? Array
        raise 'Incorrect Type produced ' + converted.class
      when :xml
        parser = Nori.new(convert_tags_to: lambda { |tag| tag.snakecase.to_sym })
        parser.parse(response.body)
      else
        raise "Neither XML nor JSON detected. It is #{type}. Don't know how to parse It is #{response.body}"
      end
    end

    # @return [Hash] Hash representing response body
    def to_hash(response)
      case Interpreter.response_type_for(response)
      when :xml
        parser = Nori.new(strip_namespaces: strip_namespaces?, convert_tags_to: ->(tag) { tag.snakecase.to_sym })
        parser.parse(response.body.to_s)
      when :json
        JSON.parse(response.body.to_s)
      else
        raise "Unable to interpret type of #{response.body}"
      end
    end

    # @response [RestClient::Request] Request of API call. Either intended request or actual request
    def request(response)
      return 'Request not yet sent' if response.nil?
      response.request
    end

    private

    # Work out data to send based upon payload, template_name, or body
    # @return [String] Payload to send in REST request
    def post_data(test_values)
      data = if @request_option == :hash && test_values[:body]
               test_values[:payload] = JSON.generate(hash_used_in_request(test_values[:body])).to_s
             elsif @request_option == :template
               test_values = test_values[:body].dup if test_values[:body]
               Soaspec::TemplateReader.new.render_body(template_name, binding)
             else
               test_values[:payload]
             end
      # Soaspec::SpecLogger.info "Request Empty for '#{@request_option}'" if data.strip.empty?
      data
    end

    # @return [Hash] Hash used in REST request based on data conversion
    def hash_used_in_request(override_hash)
      request = @default_hash.merge(override_hash)
      if pascal_keys?
        request.map { |k, v| [convert_to_pascal_case(k.to_s), v] }.to_h
      else
        request
      end
    end
  end
end