require 'faraday'
require 'openssl'
require 'base64'

module AlgoliaV2
  module Search
    # Class Client
    class Client
      include CallType
      include Helpers

      # Initialize a client to connect to Algolia
      #
      # @param search_config [Search::Config] a Search::Config object which contains your APP_ID and API_KEY
      # @option adapter [Object] adapter object used for the connection
      # @option logger [Object]
      # @option http_requester [Object] http_requester object used for the connection
      #
      def initialize(search_config, opts = {})
        @config      = search_config
        adapter      = opts[:adapter] || Defaults::ADAPTER
        logger       = opts[:logger] || LoggerHelper.create('debug.log')
        requester    = opts[:http_requester] || Defaults::REQUESTER_CLASS.new(adapter, logger)
        @transporter = Transport::Transport.new(@config, requester)
      end

      # Create a new client providing only app ID and API key
      #
      # @param app_id [String] Algolia application ID
      # @param api_key [String] Algolia API key
      #
      # @return self
      #
      def self.create(app_id, api_key)
        config = Search::Config.new(application_id: app_id, api_key: api_key)
        create_with_config(config)
      end

      # Create a new client providing only the search config
      #
      # @param config [Search::Config]
      #
      # @return self
      #
      def self.create_with_config(config)
        new(config)
      end

      # Fetch the task status until it returns as "published", meaning the operation is done
      #
      # @param index_name [String]
      # @param task_id [Integer]
      # @param time_before_retry [Integer] time before retrying the call, in ms
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return nil
      #
      def wait_task(index_name, task_id, time_before_retry = WAIT_TASK_DEFAULT_TIME_BEFORE_RETRY, opts = {})
        loop do
          status = get_task_status(index_name, task_id, opts)
          if status == 'published'
            return
          end
          sleep(time_before_retry.to_f / 1000)
        end
      end

      # Check the status of a task on the server.
      # All server task are asynchronous and you can check the status of a task with this method.
      #
      # @param index_name [String] index used for the calls
      # @param task_id [Integer] the id of the task returned by server
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [String]
      #
      def get_task_status(index_name, task_id, opts = {})
        res = @transporter.read(:GET, path_encode('/1/indexes/%s/task/%s', index_name, task_id), {}, opts)
        get_option(res, 'status')
      end

      # # # # # # # # # # # # # # # # # # # # #
      # INDEX METHODS
      # # # # # # # # # # # # # # # # # # # # #

      # Initialize an index with a given name
      #
      # @param index_name [String] name of the index to init
      #
      # @return [Index] new Index instance
      #
      def init_index(index_name)
        stripped_index_name = index_name.strip
        if stripped_index_name.empty?
          raise AlgoliaError, 'Please provide a valid index name'
        end
        Index.new(stripped_index_name, @transporter, @config)
      end

      # List all indexes of the client
      #
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def list_indexes(opts = {})
        @transporter.read(:GET, '/1/indexes', {}, opts)
      end

      # Retrieve the client logs
      #
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def get_logs(opts = {})
        @transporter.read(:GET, '/1/logs', {}, opts)
      end

      # # # # # # # # # # # # # # # # # # # # #
      # COPY OPERATIONS
      # # # # # # # # # # # # # # # # # # # # #

      # Copy the rules from source index to destination index
      #
      # @param src_index_name [String] Name of the source index
      # @param dest_index_name [String] Name of the destination index
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [IndexingResponse]
      #
      def copy_rules(src_index_name, dest_index_name, opts = {})
        request_options         = symbolize_hash(opts)
        request_options[:scope] = ['rules']
        copy_index(src_index_name, dest_index_name, request_options)
      end

      # Copy the rules from source index to destination index and wait for the task to complete
      #
      # @param src_index_name [String] Name of the source index
      # @param dest_index_name [String] Name of the destination index
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [IndexingResponse]
      #
      def copy_rules!(src_index_name, dest_index_name, opts = {})
        request_options         = symbolize_hash(opts)
        request_options[:scope] = ['rules']
        copy_index!(src_index_name, dest_index_name, request_options)
      end

      # Copy the settings from source index to destination index
      #
      # @param src_index_name [String] Name of the source index
      # @param dest_index_name [String] Name of the destination index
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [IndexingResponse]
      #
      def copy_settings(src_index_name, dest_index_name, opts = {})
        request_options         = symbolize_hash(opts)
        request_options[:scope] = ['settings']
        copy_index(src_index_name, dest_index_name, request_options)
      end

      # Copy the settings from source index to destination index and wait for the task to complete
      #
      # @param src_index_name [String] Name of the source index
      # @param dest_index_name [String] Name of the destination index
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [IndexingResponse]
      #
      def copy_settings!(src_index_name, dest_index_name, opts = {})
        request_options         = symbolize_hash(opts)
        request_options[:scope] = ['settings']
        copy_index!(src_index_name, dest_index_name, request_options)
      end

      # Copy the synonyms from source index to destination index
      #
      # @param src_index_name [String] Name of the source index
      # @param dest_index_name [String] Name of the destination index
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [IndexingResponse]
      #
      def copy_synonyms(src_index_name, dest_index_name, opts = {})
        request_options         = symbolize_hash(opts)
        request_options[:scope] = ['synonyms']
        copy_index(src_index_name, dest_index_name, request_options)
      end

      # Copy the synonyms from source index to destination index and wait for the task to complete
      #
      # @param src_index_name [String] Name of the source index
      # @param dest_index_name [String] Name of the destination index
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [IndexingResponse]
      #
      def copy_synonyms!(src_index_name, dest_index_name, opts = {})
        request_options         = symbolize_hash(opts)
        request_options[:scope] = ['synonyms']
        copy_index!(src_index_name, dest_index_name, request_options)
      end

      # Copy the source index to the destination index
      #
      # @param src_index_name [String] Name of the source index
      # @param dest_index_name [String] Name of the destination index
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [IndexingResponse]
      #
      def copy_index(src_index_name, dest_index_name, opts = {})
        response = @transporter.write(:POST, path_encode('/1/indexes/%s/operation', src_index_name), { operation: 'copy', destination: dest_index_name }, opts)

        IndexingResponse.new(init_index(src_index_name), response)
      end

      # Copy the source index to the destination index and wait for the task to complete
      #
      # @param src_index_name [String] Name of the source index
      # @param dest_index_name [String] Name of the destination index
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [IndexingResponse]
      #
      def copy_index!(src_index_name, dest_index_name, opts = {})
        response     = copy_index(src_index_name, dest_index_name, opts)

        response.wait(opts)
      end

      # # # # # # # # # # # # # # # # # # # # #
      # MOVE OPERATIONS
      # # # # # # # # # # # # # # # # # # # # #

      # Move the source index to the destination index
      #
      # @param src_index_name [String] Name of the source index
      # @param dest_index_name [String] Name of the destination index
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [IndexingResponse]
      #
      def move_index(src_index_name, dest_index_name, opts = {})
        response = @transporter.write(:POST, path_encode('/1/indexes/%s/operation', src_index_name), { operation: 'move', destination: dest_index_name }, opts)

        IndexingResponse.new(init_index(src_index_name), response)
      end

      # Move the source index to the destination index and wait for the task to complete
      #
      # @param src_index_name [String] Name of the source index
      # @param dest_index_name [String] Name of the destination index
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [IndexingResponse]
      #
      def move_index!(src_index_name, dest_index_name, opts = {})
        response = move_index(src_index_name, dest_index_name, opts)

        response.wait(opts)
      end

      # # # # # # # # # # # # # # # # # # # # #
      # API KEY METHODS
      # # # # # # # # # # # # # # # # # # # # #

      # Get the designated API key
      #
      # @param key_id [String] API key to retrieve
      #
      # @return [Hash]
      #
      def get_api_key(key_id, opts = {})
        @transporter.read(:GET, path_encode('/1/keys/%s', key_id), {}, opts)
      end

      # Add an API key with the given ACL
      #
      # @param acl [Array] API key to retrieve
      # @param opts [Hash] contains extra parameters to send with your query used for the key
      #
      # @return [AddApiKeyResponse]
      #
      def add_api_key(acl, opts = {})
        response = @transporter.write(:POST, '/1/keys', { acl: acl }, opts)

        AddApiKeyResponse.new(self, response)
      end

      # Add an API key with the given ACL and wait for the task to complete
      #
      # @param acl [Array] API key to retrieve
      # @param opts [Hash] contains extra parameters to send with your query used for the key
      #
      # @return [AddApiKeyResponse]
      #
      def add_api_key!(acl, opts = {})
        response = add_api_key(acl, opts)

        response.wait(opts)
      end

      # Update an API key with the optional parameters
      #
      # @param key [String] API key to update
      # @param opts [Hash] contains extra parameters to send with your query used to update the key
      #
      # @return [UpdateApiKeyResponse]
      #
      def update_api_key(key, opts = {})
        request_options = symbolize_hash(opts)

        response = @transporter.write(:PUT, path_encode('/1/keys/%s', key), {}, request_options)

        UpdateApiKeyResponse.new(self, response, request_options)
      end

      # Update an API key with the optional parameters and wait for the task to complete
      #
      # @param key [String] API key to update
      # @param opts [Hash] contains extra parameters to send with your query used to update the key
      #
      # @return [UpdateApiKeyResponse]
      #
      def update_api_key!(key, opts = {})
        response = update_api_key(key, opts)

        response.wait(opts)
      end

      # Delete the given API key
      #
      # @param key [String] API key to delete
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [DeleteApiKeyResponse]
      #
      def delete_api_key(key, opts = {})
        response = @transporter.write(:DELETE, path_encode('/1/keys/%s', key), {}, opts)

        DeleteApiKeyResponse.new(self, response, key)
      end

      # Delete the given API key and wait for the task to complete
      #
      # @param key [String] API key to delete
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [DeleteApiKeyResponse]
      #
      def delete_api_key!(key, opts = {})
        response = delete_api_key(key, opts)

        response.wait(opts)
      end

      # Restore the given API key
      #
      # @param key [String] API key to restore
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [RestoreApiKeyResponse]
      #
      def restore_api_key(key, opts = {})
        @transporter.write(:POST, path_encode('/1/keys/%s/restore', key), {}, opts)

        RestoreApiKeyResponse.new(self, key)
      end

      # Restore the given API key and wait for the task to complete
      #
      # @param key [String] API key to restore
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [RestoreApiKeyResponse]
      #
      def restore_api_key!(key, opts = {})
        response = restore_api_key(key, opts)

        response.wait(opts)
      end

      # List all keys associated with the current client
      #
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def list_api_keys(opts = {})
        @transporter.read(:GET, '/1/keys', {}, opts)
      end

      # Generate a secured API key from the given parent key with the given restrictions
      #
      # @param parent_key [String] Parent API key used the generate the secured key
      # @param restrictions [Hash] Restrictions to apply on the secured key
      #
      # @return [String]
      #
      def self.generate_secured_api_key(parent_key, restrictions)
        url_encoded_restrictions = to_query_string(symbolize_hash(restrictions))
        hmac                     = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), parent_key, url_encoded_restrictions)
        Base64.encode64("#{hmac}#{url_encoded_restrictions}").gsub("\n", '')
      end

      # Returns the time the given securedAPIKey remains valid in seconds
      #
      # @param secured_api_key [String]
      #
      # @return [Integer]
      #
      def self.get_secured_api_key_remaining_validity(secured_api_key)
        now         = Time.now.to_i
        decoded_key = Base64.decode64(secured_api_key)
        regex       = 'validUntil=(\d+)'
        matches     = decoded_key.match(regex)

        if matches.nil?
          raise AlgoliaError, 'The SecuredAPIKey doesn\'t have a validUntil parameter.'
        end

        valid_until = matches[1].to_i

        valid_until - now
      end

      # # # # # # # # # # # # # # # # # # # # #
      # MULTIPLE* METHODS
      # # # # # # # # # # # # # # # # # # # # #

      # Batch multiple operations
      #
      # @param operations [Array] array of operations (addObject, updateObject, ...)
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [MultipleIndexBatchIndexingResponse]
      #
      def multiple_batch(operations, opts = {})
        response = @transporter.write(:POST, '/1/indexes/*/batch', { requests: operations }, opts)

        MultipleIndexBatchIndexingResponse.new(self, response)
      end

      # Batch multiple operations and wait for the task to complete
      #
      # @param operations [Array] array of operations (addObject, updateObject, ...)
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [MultipleIndexBatchIndexingResponse]
      #
      def multiple_batch!(operations, opts = {})
        response = multiple_batch(operations, opts)

        response.wait(opts)
      end

      # Retrieve multiple objects in one batch request
      #
      # @param requests [Array] array of requests
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def multiple_get_objects(requests, opts = {})
        @transporter.read(:POST, '/1/indexes/*/objects', { requests: requests }, opts)
      end

      # Search multiple indices
      #
      # @param queries [Array] array of queries
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def multiple_queries(queries, opts = {})
        @transporter.read(:POST, '/1/indexes/*/queries', { requests: queries }, opts)
      end

      # # # # # # # # # # # # # # # # # # # # #
      # MCM METHODS
      # # # # # # # # # # # # # # # # # # # # #

      # Assign or Move a userID to a cluster.
      #
      # @param user_id [String]
      # @param cluster_name [String]
      #
      # @return [Hash]
      #
      def assign_user_id(user_id, cluster_name, opts = {})
        request_options           = symbolize_hash(opts)
        request_options[:headers] = { 'X-Algolia-User-ID': user_id }

        @transporter.write(:POST, '/1/clusters/mapping', { cluster: cluster_name }, request_options)
      end

      # Assign multiple userIDs to a cluster.
      #
      # @param user_ids [Array]
      # @param cluster_name [String]
      #
      # @return [Hash]
      #
      def assign_user_ids(user_ids, cluster_name, opts = {})
        @transporter.write(:POST, '/1/clusters/mapping/batch', { cluster: cluster_name, users: user_ids }, opts)
      end

      # Get the top 10 userIDs with the highest number of records per cluster.
      #
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def get_top_user_ids(opts = {})
        @transporter.read(:GET, '/1/clusters/mapping/top', {}, opts)
      end

      # Returns the userID data stored in the mapping.
      #
      # @param user_id [String]
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def get_user_id(user_id, opts = {})
        @transporter.read(:GET, path_encode('/1/clusters/mapping/%s', user_id), {}, opts)
      end

      # List the clusters available in a multi-clusters setup for a single appID
      #
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def list_clusters(opts = {})
        @transporter.read(:GET, '/1/clusters', {}, opts)
      end

      # List the userIDs assigned to a multi-clusters appID
      #
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def list_user_ids(opts = {})
        @transporter.read(:GET, '/1/clusters/mapping', {}, opts)
      end

      # Remove a userID and its associated data from the multi-clusters
      #
      # @param user_id [String]
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def remove_user_id(user_id, opts = {})
        request_options           = symbolize_hash(opts)
        request_options[:headers] = { 'X-Algolia-User-ID': user_id }

        @transporter.write(:DELETE, '/1/clusters/mapping', {}, request_options)
      end

      # Search for userIDs
      #
      # @param query [String]
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def search_user_ids(query, opts = {})
        @transporter.read(:POST, '/1/clusters/mapping/search', { query: query }, opts)
      end

      # Get the status of your clusters' migrations or user creations
      #
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return [Hash]
      #
      def pending_mappings?(opts = {})
        retrieve_mappings = false

        request_options = symbolize_hash(opts)
        if request_options.has_key?(:retrieveMappings)
          retrieve_mappings = request_options[:retrieveMappings]
          request_options.delete(:retrieveMappings)
        end

        @transporter.read(:GET, '/1/clusters/mapping/pending' + handle_params({ getClusters: retrieve_mappings }), {}, request_options)
      end

      # Aliases the pending_mappings? method
      #
      alias_method :has_pending_mappings, :pending_mappings?

      # # # # # # # # # # # # # # # # # # # # #
      # CUSTOM DICTIONARIES METHODS
      # # # # # # # # # # # # # # # # # # # # #

      # Save entries for a given dictionary
      #
      # @param dictionary [String] dictionary name. Can be either 'stopwords', 'plurals' or 'compounds'
      # @param dictionary_entries [Array<Hash>] array of dictionary entries
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return DictionaryResponse
      #
      def save_dictionary_entries(dictionary, dictionary_entries, opts = {})
        response = @transporter.write(
          :POST,
          path_encode('/1/dictionaries/%s/batch', dictionary),
          { clearExistingDictionaryEntries: false, requests: chunk('addEntry', dictionary_entries) },
          opts
        )

        DictionaryResponse.new(self, response)
      end

      # Save entries for a given dictionary and wait for the task to finish
      #
      # @param dictionary [String] dictionary name. Can be either 'stopwords', 'plurals' or 'compounds'
      # @param dictionary_entries [Array<Hash>] array of dictionary entries
      # @param opts [Hash] contains extra parameters to send with your query
      #
      def save_dictionary_entries!(dictionary, dictionary_entries, opts = {})
        response = save_dictionary_entries(dictionary, dictionary_entries, opts)

        response.wait(opts)
      end

      # Replace entries for a given dictionary
      #
      # @param dictionary [String] dictionary name. Can be either 'stopwords', 'plurals' or 'compounds'
      # @param dictionary_entries [Array<Hash>] array of dictionary entries
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return DictionaryResponse
      #
      def replace_dictionary_entries(dictionary, dictionary_entries, opts = {})
        response = @transporter.write(
          :POST,
          path_encode('/1/dictionaries/%s/batch', dictionary),
          { clearExistingDictionaryEntries: true, requests: chunk('addEntry', dictionary_entries) },
          opts
        )

        DictionaryResponse.new(self, response)
      end

      # Replace entries for a given dictionary and wait for the task to finish
      #
      # @param dictionary [String] dictionary name. Can be either 'stopwords', 'plurals' or 'compounds'
      # @param dictionary_entries [Array<Hash>] array of dictionary entries
      # @param opts [Hash] contains extra parameters to send with your query
      #
      def replace_dictionary_entries!(dictionary, dictionary_entries, opts = {})
        response = replace_dictionary_entries(dictionary, dictionary_entries, opts)

        response.wait(opts)
      end

      # Delete entries for a given dictionary
      #
      # @param dictionary [String] dictionary name. Can be either 'stopwords', 'plurals' or 'compounds'
      # @param object_ids [Array<Hash>] array of object ids
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return DictionaryResponse
      #
      def delete_dictionary_entries(dictionary, object_ids, opts = {})
        request  = object_ids.map do |object_id|
          { objectID: object_id }
        end
        response = @transporter.write(
          :POST,
          path_encode('/1/dictionaries/%s/batch', dictionary),
          { clearExistingDictionaryEntries: false, requests: chunk('deleteEntry', request) },
          opts
        )

        DictionaryResponse.new(self, response)
      end

      # Delete entries for a given dictionary and wait for the task to finish
      #
      # @param dictionary [String] dictionary name. Can be either 'stopwords', 'plurals' or 'compounds'
      # @param object_ids [Array<Hash>] array of object ids
      # @param opts [Hash] contains extra parameters to send with your query
      #
      def delete_dictionary_entries!(dictionary, object_ids, opts = {})
        response = delete_dictionary_entries(dictionary, object_ids, opts)

        response.wait(opts)
      end

      # Clear all entries for a given dictionary
      #
      # @param dictionary [String] dictionary name. Can be either 'stopwords', 'plurals' or 'compounds'
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return DictionaryResponse
      #
      def clear_dictionary_entries(dictionary, opts = {})
        replace_dictionary_entries(dictionary, [], opts)
      end

      # Clear all entries for a given dictionary and wait for the task to finish
      #
      # @param dictionary [String] dictionary name. Can be either 'stopwords', 'plurals' or 'compounds'
      # @param opts [Hash] contains extra parameters to send with your query
      #
      def clear_dictionary_entries!(dictionary, opts = {})
        response = replace_dictionary_entries(dictionary, [], opts)

        response.wait(opts)
      end

      # Search entries for a given dictionary
      #
      # @param dictionary [String] dictionary name. Can be either 'stopwords', 'plurals' or 'compounds'
      # @param query [String] query to send
      # @param opts [Hash] contains extra parameters to send with your query
      #
      def search_dictionary_entries(dictionary, query, opts = {})
        @transporter.read(
          :POST,
          path_encode('/1/dictionaries/%s/search', dictionary),
          { query: query },
          opts
        )
      end

      # Set settings for all the dictionaries
      #
      # @param dictionary_settings [Hash]
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return DictionaryResponse
      #
      def set_dictionary_settings(dictionary_settings, opts = {})
        response = @transporter.write(:PUT, '/1/dictionaries/*/settings', dictionary_settings, opts)

        DictionaryResponse.new(self, response)
      end

      # Set settings for all the dictionaries and wait for the task to finish
      #
      # @param dictionary_settings [Hash]
      # @param opts [Hash] contains extra parameters to send with your query
      #
      # @return DictionaryResponse
      #
      def set_dictionary_settings!(dictionary_settings, opts = {})
        response = set_dictionary_settings(dictionary_settings, opts)

        response.wait(opts)
      end

      # Retrieve settings for all the dictionaries
      #
      # @param opts [Hash] contains extra parameters to send with your query
      #
      def get_dictionary_settings(opts = {})
        @transporter.read(:GET, '/1/dictionaries/*/settings', {}, opts)
      end

      # # # # # # # # # # # # # # # # # # # # #
      # MISC METHODS
      # # # # # # # # # # # # # # # # # # # # #

      # Method available to make custom requests to the API
      #
      def custom_request(data, uri, method, call_type, opts = {})
        if call_type == WRITE
          @transporter.write(method.to_sym, uri, data, opts)
        elsif call_type == READ
          @transporter.read(method.to_sym, uri, data, opts)
        end
      end
    end
  end
end
