require 'active_support'

class LHS::Record

  module Request
    extend ActiveSupport::Concern

    module ClassMethods
      def request(options)
        options ||= {}
        if options.is_a? Array
          multiple_requests(options)
        else
          single_request(options)
        end
      end

      private

      # Convert URLs in options to endpoint templates
      def convert_options_to_endpoints(options)
        if options.is_a?(Array)
          options.map { |request_options| convert_options_to_endpoint(request_options) }
        else
          convert_options_to_endpoint(options)
        end
      end

      def convert_options_to_endpoint(options)
        return unless options.present?
        url = options[:url]
        endpoint = LHS::Endpoint.for_url(url)
        return unless endpoint
        template = endpoint.url
        new_options = options.deep_merge(params: LHC::Endpoint.values_as_params(template, url))
        new_options[:url] = template
        new_options
      end

      # Extends existing raw data with additionaly fetched data
      def extend_raw_data!(data, addition, key)
        return if addition.empty?
        if data.collection?
          extend_base_collection!(data, addition, key)
        elsif data[key]._raw.is_a? Array
          extend_base_array!(data, addition, key)
        elsif data.item?
          extend_base_item!(data, addition, key)
        end
      end

      def extend_base_collection!(data, addition, key)
        data.each_with_index do |item, i|
          item = item[i] if item.is_a? LHS::Collection
          link = item[key.to_sym]
          link.merge_raw!(addition[i]) if link.present?
        end
      end

      def extend_base_array!(data, addition, key)
        data[key].zip(addition) do |item, additional_item|
          item._raw.merge!(additional_item._raw)
        end
      end

      def extend_base_item!(data, addition, key)
        if addition.collection?
          extend_base_item_with_collection!(data, addition, key)
        else # simple case merges hash into hash
          data._raw[key.to_sym].merge!(addition._raw)
        end
      end

      def extend_base_item_with_collection!(data, addition, key)
        target = data[key]
        if target._raw.is_a? Array
          data[key] = addition.map(&:_raw)
        else # hash with items
          extend_base_item_with_hash_of_items!(target, addition)
        end
      end

      def extend_base_item_with_hash_of_items!(target, addition)
        target._raw[items_key] ||= []
        if target._raw[items_key].empty?
          target._raw[items_key] = addition.map(&:_raw)
        else
          target._raw[items_key].each_with_index do |item, index|
            item.merge!(addition[index])
          end
        end
      end

      def handle_includes(includes, data, references = {})
        references ||= {}
        if includes.is_a? Hash
          includes.each { |included, sub_includes| handle_include(included, data, sub_includes, references[included]) }
        elsif includes.is_a? Array
          includes.each { |included| handle_includes(included, data, references[included]) }
        else
          handle_include(includes, data, nil, references[includes])
        end
      end

      def handle_include(included, data, sub_includes = nil, references = nil)
        return if data.blank? || skip_loading_includes?(data, included)
        options = options_for_data(data, included)
        options = extend_with_references(options, references)
        addition = load_include(options, data, sub_includes)
        extend_raw_data!(data, addition, included)
        expand_addition!(data, included) if no_expanded_data?(addition)
      end

      def options_for_data(data, included = nil)
        return options_for_multiple(data, included) if data.collection?
        return options_for_nested_items(data, included) if included && data[included].collection?
        url_option_for(data, included)
      end

      def expand_addition!(data, included)
        addition = data[included]
        options = options_for_data(addition)
        record = record_for_options(options) || self
        options = convert_options_to_endpoints(options) if record_for_options(options)
        expanded_data = begin
          record.without_including.request(options)
        rescue LHC::NotFound
          LHS::Data.new({}, data, record)
        end
        extend_raw_data!(data, expanded_data, included)
      end

      def no_expanded_data?(addition)
        return false if addition.blank?
        if addition.item?
          (addition._raw.keys - [:href]).empty?
        elsif addition.collection?
          addition.all? { |item| item && (item._raw.keys - [:href]).empty? }
        end
      end

      # Extends request options with options provided for this reference
      def extend_with_references(options, references)
        return options unless references
        options ||= {}
        if options.is_a?(Array)
          options.map { |request_options| request_options.merge(references) }
        else
          options.merge(references)
        end
      end

      def skip_loading_includes?(data, included)
        if data.collection?
          data.to_a.none? { |item| item[included].present? }
        else
          !data._raw.key?(included)
        end
      end

      # Load additional resources that are requested with include
      def load_include(options, data, sub_includes)
        record = record_for_options(options) || self
        options = convert_options_to_endpoints(options) if record_for_options(options)
        begin
          record.includes(sub_includes).request(options)
        rescue LHC::NotFound
          LHS::Data.new({}, data, record)
        end
      end

      # Merge explicit params nested in 'params' namespace with original hash.
      def merge_explicit_params!(params)
        return true unless params
        explicit_params = params[:params]
        params.delete(:params)
        params.merge!(explicit_params) if explicit_params
      end

      def multiple_requests(options)
        options = options.map do |option|
          next unless option.present?
          process_options(option, find_endpoint(option[:params]))
        end
        data = LHC.request(options.compact).map do |response|
          LHS::Data.new(response.body, nil, self, response.request)
        end
        data = restore_with_nils(data, locate_nils(options)) # nil objects in data provide location information for mapping
        data = LHS::Data.new(data, nil, self)
        handle_includes(including, data, referencing) if including && !data.empty?
        data
      end

      def locate_nils(array)
        nils = []
        array.each_with_index { |value, index| nils << index if value.nil? }
        nils
      end

      def restore_with_nils(array, nils)
        array = array.dup
        nils.sort.each { |index| array.insert(index, nil) }
        array
      end

      def options_for_multiple(data, key = nil)
        data.map do |item|
          url_option_for(item, key)
        end
      end

      def options_for_nested_items(data, key = nil)
        data[key].map do |item|
          url_option_for(item)
        end
      end

      # Merge explicit params and take configured endpoints options as base
      def process_options(options, endpoint)
        options[:params].deep_symbolize_keys! if options[:params]
        options[:error_handler] = merge_error_handlers(options[:error_handler]) if options[:error_handler]
        options = (endpoint.options || {}).merge(options)
        options[:url] = compute_url!(options[:params]) unless options.key?(:url)
        merge_explicit_params!(options[:params])
        options.delete(:params) if options[:params] && options[:params].empty?
        options
      end

      # LHC supports only one error handler, merge all error handlers to one
      # and reraise
      def merge_error_handlers(handlers)
        lambda do |response|
          return_data = nil
          error_class = LHC::Error.find(response)
          error = error_class.new(error_class, response)
          handlers = handlers.to_a.select { |error_handler| error.is_a? error_handler.class }
          raise(error) unless handlers.any?
          handlers.each do |handler|
            handlers_return = handler.call(response)
            return_data = handlers_return if handlers_return.present?
          end
          return return_data
        end
      end

      def record_for_options(options)
        records = []
        if options.is_a?(Array)
          options.compact.each do |option|
            record = LHS::Record.for_url(option[:url])
            next unless record
            records.push(record)
          end
          raise 'Found more than one record that could be used to do the request' if records.uniq.count > 1
          records.uniq.first
        else # Hash
          LHS::Record.for_url(options[:url])
        end
      end

      def single_request(options)
        options ||= {}
        options = options.dup
        endpoint = find_endpoint(options[:params])
        response = LHC.request(process_options(options, endpoint))
        data = LHS::Data.new(response.body, nil, self, response.request, endpoint)
        handle_includes(including, data, referencing) if including
        data
      end

      def url_option_for(item, key = nil)
        link = key ? item[key] : item
        return { url: link.href } if link.present? && link.href.present?
      end
    end
  end
end