require "ffi_yajl" unless defined?(FFI_Yajl)
require_relative "rest_object_endpoint"
require_relative "../chef_data/data_normalizer"
require_relative "../rest_error_response"
require_relative "../solr/solr_parser"
require_relative "../solr/solr_doc"

module ChefZero
  module Endpoints
    # /search/INDEX
    class SearchEndpoint < RestBase
      def get(request)
        orgname = request.rest_path[1]
        results = search(request, orgname)

        results["rows"] = results["rows"].map { |name, uri, value, search_value| value }

        json_response(200, results)
      rescue ChefZero::Solr::ParseError
        bad_search_request(request)
      end

      def post(request)
        orgname = request.rest_path[1]
        full_results = search(request, orgname)
        keys = FFI_Yajl::Parser.parse(request.body)
        partial_results = full_results["rows"].map do |name, uri, doc, search_value|
          data = {}
          keys.each_pair do |key, path|
            if path.size > 0
              value = search_value
              path.each do |path_part|
                value = value[path_part] unless value.nil?
              end
              data[key] = value
            else
              data[key] = nil
            end
          end
          {
            "url" => uri,
            "data" => data,
          }
        end
        json_response(200, {
          "rows" => partial_results,
          "start" => full_results["start"],
          "total" => full_results["total"],
        })
      rescue ChefZero::Solr::ParseError
        bad_search_request(request)
      end

      private

      def bad_search_request(request)
        query_string = request.query_params["q"]
        resp = { "error" => ["invalid search query: '#{query_string}'"] }
        json_response(400, resp)
      end

      def search_container(request, index, orgname)
        relative_parts, normalize_proc =
          case index
          when "client"
            [ ["clients"], Proc.new { |client, name| ChefData::DataNormalizer.normalize_client(client, name, orgname) } ]
          when "node"
            [ ["nodes"], Proc.new { |node, name| ChefData::DataNormalizer.normalize_node(node, name) } ]
          when "environment"
            [ ["environments"], Proc.new { |environment, name| ChefData::DataNormalizer.normalize_environment(environment, name) } ]
          when "role"
            [ ["roles"], Proc.new { |role, name| ChefData::DataNormalizer.normalize_role(role, name) } ]
          else
            [ ["data", index], Proc.new { |data_bag_item, id| ChefData::DataNormalizer.normalize_data_bag_item(data_bag_item, index, id, "DELETE") } ]
          end

        [
          request.rest_path[0..1] + relative_parts,
          normalize_proc,
        ]
      end

      def expand_for_indexing(value, index, id)
        if index == "node"
          result = {}
          deep_merge!(value["default"] || {}, result)
          deep_merge!(value["normal"] || {}, result)
          deep_merge!(value["override"] || {}, result)
          deep_merge!(value["automatic"] || {}, result)
          result["recipe"] = []
          result["role"] = []
          if value["run_list"]
            value["run_list"].each do |run_list_entry|
              if run_list_entry =~ /^(recipe|role)\[(.*)\]/
                result[$1] << $2
              end
            end
          end
          value.each_pair do |key, val|
            result[key] = val unless %w{default normal override automatic}.include?(key)
          end
          result

        elsif !%w{client environment role}.include?(index)
          ChefData::DataNormalizer.normalize_data_bag_item(value, index, id, "GET")
        else
          value
        end
      end

      def search(request, orgname = nil)
        # Extract parameters
        index = request.rest_path[3]
        query_string = request.query_params["q"] || "*:*"
        solr_query = ChefZero::Solr::SolrParser.new(query_string).parse
        sort_string = request.query_params["sort"]
        start = request.query_params["start"].to_i
        rows = request.query_params["rows"].to_i

        # Get the search container
        container, expander = search_container(request, index, orgname)

        # Search!
        result = []
        list_data(request, container).each do |name|
          value = get_data(request, container + [name])
          expanded = expander.call(FFI_Yajl::Parser.parse(value), name)
          result << [ name, build_uri(request.base_uri, container + [name]), expanded, expand_for_indexing(expanded, index, name) ]
        end
        result = result.select do |name, uri, value, search_value|
          solr_query.matches_doc?(ChefZero::Solr::SolrDoc.new(search_value, name))
        end
        total = result.size

        # Sort
        if sort_string
          sort_key, sort_order = sort_string.split(/\s+/, 2)
          result = result.sort_by { |name, uri, value, search_value| ChefZero::Solr::SolrDoc.new(search_value, name)[sort_key] }
          result = result.reverse if sort_order == "DESC"
        end

        {
          # Paginate
          #
          # Slice the array based on the value of the "rows" query parameter. If
          # this is a positive integer, we'll get the number of rows asked for.
          # If it's nil, we'll get -1, which gives us all of the elements.
          #
          # Do the same for "start", which will start at 0 if that value is not
          # given.
          "rows" => result[start..(rows - 1)],
          # Also return start and total in the object
          "start" => start,
          "total" => total,
        }
      end

      # Deep Merge core documentation.
      # deep_merge! method permits merging of arbitrary child elements. The two top level
      # elements must be hashes. These hashes can contain unlimited (to stack limit) levels
      # of child elements. These child elements to not have to be of the same types.
      # Where child elements are of the same type, deep_merge will attempt to merge them together.
      # Where child elements are not of the same type, deep_merge will skip or optionally overwrite
      # the destination element with the contents of the source element at that level.
      # So if you have two hashes like this:
      #   source = {:x => [1,2,3], :y => 2}
      #   dest =   {:x => [4,5,'6'], :y => [7,8,9]}
      #   dest.deep_merge!(source)
      #   Results: {:x => [1,2,3,4,5,'6'], :y => 2}
      # By default, "deep_merge!" will overwrite any unmergeables and merge everything else.
      # To avoid this, use "deep_merge" (no bang/exclamation mark)
      def deep_merge!(source, dest)
        # if dest doesn't exist, then simply copy source to it
        if dest.nil?
          dest = source; return dest
        end

        case source
        when nil
          dest
        when Hash
          source.each do |src_key, src_value|
            if dest.is_a?(Hash)
              if dest[src_key]
                dest[src_key] = deep_merge!(src_value, dest[src_key])
              else # dest[src_key] doesn't exist so we take whatever source has
                dest[src_key] = src_value
              end
            else # dest isn't a hash, so we overwrite it completely
              dest = source
            end
          end
        when Array
          if dest.is_a?(Array)
            dest |= source
          else
            dest = source
          end
        when String
          dest = source
        else # src_hash is not an array or hash, so we'll have to overwrite dest
          dest = source
        end
        dest
      end # deep_merge!
    end
  end
end
