module BikeSearchable
  extend ActiveSupport::Concern

  class << self
    # searchable_interpreted_params returns the args for by all other public methods in this class
    # query_params:
    #   query_items: array of select2 query items. Parsed into query, manufacturer and color
    #   serial: required for search_close_serials
    #   query: full text search string. Overrides query_items if passed explicitly
    #   colors: array of colors, friendly found, faster if integers. Overrides query_items if passed explicitly
    #   manufacturer: friendly found, faster if integer. Overrides query_items if passed explicitly.
    #   primary_activity: friendly found, faster if integer. NOT INCLUDED in query_items
    #   stolenness: can be 'all', 'non', 'stolen', 'found', 'proximity'. Defaults to 'stolen'
    #   location: location for proximity search. Only for stolenness == 'proximity'. 'ip'/'you' uses IP geocoding and returns location object
    #   distance: distance in miles for matches. Only for stolenness == 'proximity'
    #   bounding_box: bounding box generated by geocoder. Only for stolenness == 'proximity'
    def searchable_interpreted_params(query_params, ip: nil)
      # Include the passed location and distance (if they are set)
      # they will be overridden in stolenness if searching by proximity
      params = {location: query_params[:location], distance: query_params[:distance]}.select { |_, value| value }

      if query_params[:serial].present?
        params[:serial] = SerialNormalizer.normalized_and_corrected(query_params[:serial])
        params[:serial_no_space] = SerialNormalizer.no_space(params[:serial])
        params[:raw_serial] = query_params[:serial]
      end

      params
        .merge(searchable_query_items_query(query_params)) # query if present
        .merge(searchable_query_items_manufacturer(query_params)) # manufacturer if present
        .merge(searchable_query_items_colors(query_params)) # color if present
        .merge(searchable_query_items_cycle_type(query_params)) # cycle_type if present
        .merge(searchable_query_items_propulsion_type(query_params)) # propulsion_type if present
        .merge(searchable_query_items_primary_activity(query_params)) # primary_activity if present
        .merge(searchable_query_stolenness(query_params, ip))
        .to_h
    end

    # Initial autocomplete options hashes for the main select search input
    # ignores manufacturer_id and color_ids we don't have
    def selected_query_items_options(interpreted_params)
      items = []
      items += [interpreted_params[:query]] if interpreted_params[:query].present?
      if interpreted_params[:manufacturer]
        items += [interpreted_params[:manufacturer]].flatten.map { |id| Manufacturer.friendly_find(id) }
          .compact.map(&:autocomplete_result_hash)
      end
      if interpreted_params[:colors].present?
        items += interpreted_params[:colors].map { |id| Color.friendly_find(id)&.autocomplete_result_hash }.compact
      end
      if interpreted_params[:cycle_type].present?
        items += [CycleType.friendly_find(interpreted_params[:cycle_type]).autocomplete_result_hash]
      end
      if interpreted_params[:propulsion_type].present?
        items += [PropulsionType.autocomplete_result_hash_for(interpreted_params[:propulsion_type])]
      end
      items.flatten.compact
    end

    # returns nil OR [location, [lat, lng]] OR [location, nil]
    def search_location(location, ip_address = nil)
      location = location.to_s.strip
      return if location.match?(/anywhere/i)

      if ["", "ip", "you"].include?(location.strip.downcase)
        result = IpAddressParser.location_hash_geocoder(ip_address)
        return if result.blank?

        [result[:formatted_address], [result[:latitude], result[:longitude]]]
      else
        [location, nil]
      end
    end

    private

    def searchable_query_items_query(query_params)
      return {query: query_params[:query]} if query_params[:query].present?

      query = query_params[:query_items]&.select { |i| !(/\A[cmvp]_/ =~ i) }&.join(" ")
      query.present? ? {query: query} : {}
    end

    def searchable_query_items_manufacturer(query_params)
      # we expect a singular manufacturer but deal with arrays because the multi-select search
      manufacturer_id = extracted_query_items_manufacturer_id(query_params)
      if manufacturer_id && !manufacturer_id.is_a?(Integer)
        manufacturer_id = [manufacturer_id].flatten.map { |m_id|
          next m_id.to_i if m_id.is_a?(Integer) || m_id.strip =~ /\A\d*\z/

          Manufacturer.friendly_find_id(m_id)
        }.compact
        manufacturer_id = manufacturer_id.first if manufacturer_id.count == 1
      end
      manufacturer_id ? {manufacturer: manufacturer_id} : {}
    end

    def searchable_query_items_colors(query_params)
      # params[:colors] should be an array (or a comma delineated string) - otherwise we parse out of the query string
      if query_params[:colors].present?
        colors = query_params[:colors].is_a?(String) ? query_params[:colors].split(",") : query_params[:colors]
        return {colors: colors.map { |id| Color.friendly_find_id(id) }.compact}
      end
      color_ids = extracted_query_items_color_ids(query_params)
      if color_ids && !color_ids.is_a?(Integer)
        color_ids = color_ids.map { |c_id|
          next c_id.to_i if c_id.is_a?(Integer) || c_id.strip =~ /\A\d*\z/

          Color.friendly_find_id(c_id)
        }
      end
      color_ids ? {colors: color_ids} : {}
    end

    def searchable_query_items_cycle_type(query_params)
      # we expect a singular cycle_type but deal with arrays because the multi-select search
      cycle_type_id = extracted_query_items_cycle_type_id(query_params)
      if cycle_type_id.present?
        cycle_type_id = cycle_type_id.first if cycle_type_id.is_a?(Array)
        cycle_type = CycleType.find_sym(cycle_type_id)
      end
      cycle_type ? {cycle_type: cycle_type} : {}
    end

    def searchable_query_items_propulsion_type(query_params)
      # we expect a singular propulsion_type but deal with arrays because the multi-select search
      propulsion_type_id = extracted_query_items_propulsion_type_id(query_params)
      if propulsion_type_id.present?
        propulsion_type_id = propulsion_type_id.first if propulsion_type_id.is_a?(Array)
        propulsion_type = :motorized if %w[motorized 10].include?(propulsion_type_id.to_s)
        propulsion_type ||= PropulsionType.find_sym(propulsion_type_id)
      end
      propulsion_type ? {propulsion_type: propulsion_type} : {}
    end

    # Returns an array of primary_activity ids, if a primary activity is found
    # (to handle primary_activity family matching)
    def searchable_query_items_primary_activity(query_params)
      # Only supported as a separate parameter
      return {} if query_params[:primary_activity].blank?

      id_and_family_ids = PrimaryActivity.friendly_find_id_and_family_ids(query_params[:primary_activity])
      return {} if id_and_family_ids.none?

      {primary_activity: id_and_family_ids.first, primary_activity_family_ids: id_and_family_ids.last}
    end

    def searchable_query_stolenness(query_params, ip_address)
      stolennness_hash = if %w[all non found impounded].include?(query_params[:stolenness])
        {stolenness: query_params[:stolenness]}
      elsif query_params[:stolenness] == "proximity"
        extracted_searchable_proximity_hash(query_params, ip_address)
      end

      stolennness_hash || {stolenness: "stolen"}
    end

    def extracted_query_items_manufacturer_id(query_params)
      return query_params[:manufacturer] if query_params[:manufacturer].present?

      manufacturer_id = query_params[:query_items]&.select { |i| i.start_with?("m_") }
      return nil unless manufacturer_id&.any?

      manufacturer_id.map { |i| i.gsub("m_", "").to_i }
    end

    def extracted_query_items_propulsion_type_id(query_params)
      return query_params[:propulsion_type] if query_params[:propulsion_type].present?

      propulsion_type_id = query_params[:query_items]&.select { |i| i.start_with?("p_") }
      return nil unless propulsion_type_id&.any?

      propulsion_type_id.map { |i| i.gsub("p_", "").to_i }
    end

    def extracted_query_items_cycle_type_id(query_params)
      return query_params[:cycle_type] if query_params[:cycle_type].present?

      cycle_type_id = query_params[:query_items]&.select { |i| i.start_with?("v_") }
      return nil unless cycle_type_id&.any?

      cycle_type_id.map { |i| i.gsub("v_", "").to_i }
    end

    def extracted_query_items_color_ids(query_params)
      return query_params[:colors] if query_params[:colors].present?

      color_ids = query_params[:query_items]&.select { |i| i.start_with?("c_") }
      return nil unless color_ids&.any?

      color_ids.map { |i| i.gsub("c_", "").to_i }
    end

    def extracted_searchable_proximity_hash(query_params, ip_address)
      location, coordinates = search_location(query_params[:location], ip_address)
      return if location.blank?

      distance = GeocodeHelper.permitted_distance(query_params[:distance])
      bounding_box = if coordinates.present?
        GeocodeHelper.bounding_box(coordinates, distance)
      else
        GeocodeHelper.bounding_box(location, distance)
      end

      return if bounding_box.empty? # If we can't create a bounding box, skip

      {
        bounding_box: bounding_box,
        stolenness: query_params[:stolenness],
        location:, # This will overwrite the user's input
        distance:
      }
    end
  end

  module ClassMethods
    def search(interpreted_params)
      matching_serial(interpreted_params[:serial], interpreted_params[:serial_no_space])
        .non_serial_matches(interpreted_params)
    end

    def search_close_serials(interpreted_params)
      return none if interpreted_params[:serial].blank? # normalized_serial blank

      # serials_not_containing excludes exact matches too
      serials_not_containing(interpreted_params[:serial], interpreted_params[:serial_no_space])
        .non_serial_matches(interpreted_params)
        .where("LEVENSHTEIN(serial_normalized_no_space, ?) < 3", interpreted_params[:serial_no_space])
    end

    def search_serials_containing(interpreted_params)
      return none if interpreted_params[:serial].blank? # normalized_serial blank

      not_matching_serial(interpreted_params[:serial], interpreted_params[:serial_no_space])
        .non_serial_matches(interpreted_params)
        .serials_containing(interpreted_params[:serial], interpreted_params[:serial_no_space])
    end

    def permitted_search_params
      [:cycle_type, :distance, :location, :manufacturer, :query, :propulsion_type, :primary_activity,
        :serial, :stolenness, colors: [], query_items: []].freeze
    end

    # NOTE: This where query should exactly match not_matching_serial
    # This method is called from outside this class
    def matching_serial(serial, serial_no_space = nil)
      return all unless serial.present?

      serial_no_space ||= SerialNormalizer.no_space(serial)
      # Note: @@ is postgres fulltext search
      where("serial_normalized @@ ? OR serial_normalized_no_space = ?", serial, serial_no_space)
    end

    # TODO: actually make private?
    # Private (internal only) methods below here, as defined at the start

    def non_serial_matches(interpreted_params)
      # For each of the of the colors, call searching_matching_color_ids with the color_id on the previous ;)
      (interpreted_params[:colors] || [nil])
        .reduce(self) { |matches, c_id| matches.search_matching_color_ids(c_id) }
        .search_matching_stolenness(interpreted_params)
        .search_matching_query(interpreted_params[:query])
        .search_matching_cycle_type(interpreted_params[:cycle_type])
        .search_matching_propulsion_type(interpreted_params[:propulsion_type])
        .search_matching_primary_activity(interpreted_params[:primary_activity_family_ids])
        .where(interpreted_params[:manufacturer] ? {manufacturer_id: interpreted_params[:manufacturer]} : {})
    end

    # Actual searcher methods
    # The searcher methods return `all` so they can be chained together even if they don't modify anything

    def search_matching_color_ids(color_id)
      return all unless color_id # So we can chain this if we don't have any colors

      where("primary_frame_color_id=? OR secondary_frame_color_id=? OR tertiary_frame_color_id =?", color_id, color_id, color_id)
    end

    def search_matching_cycle_type(cycle_type)
      return all unless cycle_type.present?

      where(cycle_type: cycle_type)
    end

    def search_matching_propulsion_type(propulsion_type)
      return all unless propulsion_type.present?

      where(propulsion_type: (propulsion_type == :motorized) ? PropulsionType::MOTORIZED : propulsion_type)
    end

    def search_matching_primary_activity(family_ids)
      return all unless family_ids.present?

      where(primary_activity_id: family_ids)
    end

    def search_matching_query(query)
      query.presence && pg_search(query) || all
    end

    # TODO: Better way of matching this with matching_serial
    def not_matching_serial(serial, serial_no_space)
      return all unless serial.present?

      where.not("serial_normalized @@ ? OR serial_normalized_no_space = ?", serial, serial_no_space)
    end

    # NOTE: THis query should exactly match serials_not_containing
    def serials_containing(serial, serial_no_space)
      return all unless serial.present?

      where("serial_normalized_no_space LIKE ?", "%#{serial_no_space}%")
    end

    # TODO: Better way of matching this with serials_containing
    def serials_not_containing(serial, serial_no_space)
      return all unless serial.present?

      where.not("serial_normalized_no_space LIKE ?", "%#{serial_no_space}%")
    end

    def search_matching_stolenness(interpreted_params)
      case interpreted_params[:stolenness]
      when "all"
        all
      when "found", "impounded"
        # Note: does not include impounded
        status_impounded
      when "non"
        # Note: does not include impounded
        status_with_owner
      when "proximity"
        stolen_or_impounded.within_bounding_box(interpreted_params[:bounding_box])
      else
        stolen_or_impounded
      end
    end
  end
end
