# Copyright (c) 2010-2017 GoodData Corporation. All rights reserved.
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
require 'active_support/core_ext/hash/slice'

module GoodData
  module Helpers
    ENCODED_PARAMS_KEY = 'gd_encoded_params'
    ENCODED_HIDDEN_PARAMS_KEY = 'gd_encoded_hidden_params'
    class << self
      # Encodes parameters for passing them to GD execution platform.
      # Core types are kept and complex types (arrays, structures, etc) are
      # JSON encoded into key hash "gd_encoded_params" or
      # "gd_encoded_hidden_params", depending on the 'hidden' method param.
      # The two different keys are used because the params and hidden params
      # are merged by the platform and if we use the same key,
      # the param would be overwritten.
      #
      # Core types are following:
      # - Boolean (true, false)
      # - Fixnum
      # - Float
      # - Nil
      # - String
      #
      # @param [Hash] params Parameters to be encoded
      # @return [Hash] Encoded parameters
      def encode_params(params, data_key)
        res = {}
        nested = {}
        core_types = [FalseClass, Integer, Float, NilClass, TrueClass, String]
        params.each do |k, v|
          if core_types.include?(v.class)
            res[k] = v
          else
            nested[k] = v
          end
        end
        res[data_key] = nested.to_json unless nested.empty?
        res
      end

      # Encodes public parameters for passing them to GD execution platform.
      # @param [Hash] params Parameters to be encoded
      # @return [Hash] Encoded parameters
      def encode_public_params(params)
        encode_params(params, ENCODED_PARAMS_KEY)
      end

      # Encodes hidden parameters for passing them to GD execution platform.
      # @param [Hash] params Parameters to be encoded
      # @return [Hash] Encoded parameters
      def encode_hidden_params(params)
        encode_params(params, ENCODED_HIDDEN_PARAMS_KEY)
      end

      # Decodes params as they came from the platform.
      # @params Parameter hash need to be decoded
      # @option options [Boolean] :resolve_reference_params Resolve reference parameters in gd_encoded_params or not
      # @return [Hash] Decoded parameters
      def decode_params(params, options = {})
        key = ENCODED_PARAMS_KEY.to_s
        hidden_key = ENCODED_HIDDEN_PARAMS_KEY.to_s
        data_params = params[key] || '{}'
        hidden_data_params = if params.key?(hidden_key) && params[hidden_key].nil?
                               "{\"#{hidden_key}\" : null}"
                             elsif params.key?(hidden_key)
                               params[hidden_key]
                             else
                               '{}'
                             end

        reference_values = []
        # Replace reference parameters by the actual values. Use backslash to escape a reference parameter, e.g: \${not_a_param},
        # the ${not_a_param} will not be replaced
        if options[:resolve_reference_params]
          data_params, reference_values = resolve_reference_params(data_params, params)
          hidden_data_params, = resolve_reference_params(hidden_data_params, params)
        end

        begin
          parsed_data_params = data_params.is_a?(Hash) ? data_params : JSON.parse(data_params)
        rescue JSON::ParserError => exception
          reason = exception.message
          reference_values.each { |secret_value| reason.gsub!("\"#{secret_value}\"", '"***"') }
          raise exception.class, "Error reading json from '#{key}', reason: #{reason}"
        end

        begin
          parsed_hidden_data_params = hidden_data_params.is_a?(Hash) ? hidden_data_params : JSON.parse(hidden_data_params)
        rescue JSON::ParserError => exception
          raise exception.class, "Error reading json from '#{hidden_key}'"
        end

        # Add the nil on ENCODED_HIDDEN_PARAMS_KEY
        # if the data was retrieved from API You will not have the actual values so encode -> decode is not losless. The nil on the key prevents the server from deleting the key
        parsed_hidden_data_params[ENCODED_HIDDEN_PARAMS_KEY] = nil unless parsed_hidden_data_params.empty?

        params.delete(key)
        params.delete(hidden_key)
        params = GoodData::Helpers.deep_merge(params, parsed_data_params)
        params = GoodData::Helpers.deep_merge(params, parsed_hidden_data_params)

        if options[:convert_pipe_delimited_params]
          convert_pipe_delimited_params = lambda do |args|
            args = args.select { |k, _| k.include? "|" }
            lines = args.keys.map do |k|
              hash = {}
              last_a = nil
              last_e = nil
              k.split("|").reduce(hash) do |a, e|
                last_a = a
                last_e = e
                a[e] = {}
              end
              last_a[last_e] = args[k]
              hash
            end

            lines.reduce({}) do |a, e|
              GoodData::Helpers.deep_merge(a, e)
            end
          end

          pipe_delimited_params = convert_pipe_delimited_params.call(params)
          params.delete_if do |k, _|
            k.include?('|')
          end
          params = GoodData::Helpers.deep_merge(params, pipe_delimited_params)
        end

        params
      end

      # A helper which allows you to diff two lists of objects. The objects
      # can be arbitrary objects as long as they respond to to_hash because
      # the diff is eventually done on hashes. It allows you to specify
      # several options to allow you to limit on what the sameness test is done
      #
      # @param [Array<Object>] old_list List of objects that serves as a base for comparison
      # @param [Array<Object>] new_list List of objects that is compared agianst the old_list
      # @return [Hash] A structure that contains the result of the comparison. There are
      # four keys.
      # :added contains the list that are in new_list but were not in the old_list
      # :added contains the list that are in old_list but were not in the new_list
      # :same contains objects that are in both lists and they are the same
      # :changed contains list of objects that changed along ith original, the new one
      # and the list of changes
      def diff(old_list, new_list, options = {})
        old_list = old_list.map(&:to_hash)
        new_list = new_list.map(&:to_hash)

        fields = options[:fields]
        lookup_key = options[:key]

        old_lookup = Hash[old_list.map { |v| [v[lookup_key], v] }]

        res = {
          :added => [],
          :removed => [],
          :changed => [],
          :same => []
        }

        new_list.each do |new_obj|
          old_obj = old_lookup[new_obj[lookup_key]]
          if old_obj.nil?
            res[:added] << new_obj
            next
          end

          if fields
            sliced_old_obj = old_obj.slice(*fields)
            sliced_new_obj = new_obj.slice(*fields)
          else
            sliced_old_obj = old_obj
            sliced_new_obj = new_obj
          end
          if sliced_old_obj != sliced_new_obj
            difference = sliced_new_obj.to_a - sliced_old_obj.to_a
            differences = Hash[*difference.mapcat { |x| x }]
            res[:changed] << {
              old_obj: old_obj,
              new_obj: new_obj,
              diff: differences
            }
          else
            res[:same] << old_obj
          end
        end

        new_lookup = Hash[new_list.map { |v| [v[lookup_key], v] }]
        old_list.each do |old_obj|
          new_obj = new_lookup[old_obj[lookup_key]]
          if new_obj.nil?
            res[:removed] << old_obj
            next
          end
        end

        res
      end

      def create_lookup(collection, on)
        lookup = {}
        if on.is_a?(Array)
          collection.each do |e|
            key = e.values_at(*on)
            lookup[key] = [] unless lookup.key?(key)
            lookup[key] << e
          end
        else
          collection.each do |e|
            key = e[on]
            lookup[key] = [] unless lookup.key?(key)
            lookup[key] << e
          end
        end
        lookup
      end

      def stringify_values(value)
        case value
        when nil
          value
        when Hash
          Hash[
            value.map do |k, v|
              [k, stringify_values(v)]
            end
          ]
        when Array
          value.map do |v|
            stringify_values(v)
          end
        else
          value.to_s
        end
      end

      private

      def resolve_reference_params(data_params, params)
        reference_values = []
        regexps = Regexp.union(/\\\\/, /\\\$/, /\$\{([^\n\{\}]+)\}/)
        resolve_reference = lambda do |v|
          if v.is_a? Hash
            Hash[
              v.map do |k, v2|
                [k, resolve_reference.call(v2)]
              end
            ]
          elsif v.is_a? Array
            v.map do |v2|
              resolve_reference.call(v2)
            end
          elsif !v.is_a?(String)
            v
          else
            v.gsub(regexps) do |match|
              if match =~ /\\\\/
                data_params.is_a?(Hash) ? '\\' : '\\\\' # rubocop: disable Metrics/BlockNesting
              elsif match =~ /\\\$/
                '$'
              elsif match =~ /\$\{([^\n\{\}]+)\}/
                val = params["#{$1}"]
                if val
                  reference_values << val
                  val
                else
                  GoodData.logger.warn "Reference '#{$1}' is not found!"
                  match
                end
              end
            end
          end
        end

        data_params = if data_params.is_a? Hash
                        Hash[data_params.map do |k, v|
                          [k, resolve_reference.call(v)]
                        end]
                      else
                        resolve_reference.call(data_params)
                      end

        [data_params, reference_values]
      end
    end
  end
end
