(ns metabase.search.filter
  (:require
   [honey.sql.helpers :as sql.helpers]
   [metabase.driver.common.parameters.dates :as params.dates]
   [metabase.premium-features.core :as premium-features]
   [metabase.search.config :as search.config]
   [metabase.search.permissions :as search.permissions]
   [metabase.search.spec :as search.spec]
   [metabase.util.date-2 :as u.date]
   [metabase.util.i18n :refer [tru]]
   [toucan2.core :as t2])
  (:import
   (java.time LocalDate)))
(defn- remove-if-falsey [m k]
  (if (m k) m (dissoc m k)))
(defn- visible-to? [search-ctx {:keys [visibility] :as _spec}]
  (case visibility
    :all      true
    :app-user (not (search.permissions/sandboxed-or-impersonated-user? search-ctx))))

Map the context keys to their corresponding filters

(def ^:private context-key->filter
  (reduce-kv (fn [m k {:keys [context-key]}]
               (assoc m context-key k))
             {}
             ;; TODO remove special handling of :id
             (dissoc search.config/filters :id)))

Returns a set of models that are applicable given the search context.

If the context has optional filters, the models will be restricted for the set of supported models only.

(defn search-context->applicable-models
  [search-ctx]
  ;; Archived is an eccentric one - we treat it as false for models that don't map it, rather than removing them.
  ;; TODO move this behavior to the spec somehow
  (let [required (->> (remove-if-falsey search-ctx :archived?) keys (keep context-key->filter))]
    (into #{}
          (remove nil?)
          (for [search-model (:models search-ctx)
                :let [spec (search.spec/spec search-model)]]
            (when (and (visible-to? search-ctx spec) (every? (:attrs spec) required))
              (:name spec))))))

A list of the search models which are not associated with collections, even indirectly.

(defn models-without-collection
  []
  (->> (search.spec/specifications)
       vals
       (filter #(-> % :attrs :collection-id false?))
       (map :name)))
(defn- date-range-filter-clause
  [dt-col dt-val]
  (let [date-range (try
                     (params.dates/date-string->range dt-val {:inclusive-end? false})
                     (catch Exception _e
                       (throw (ex-info (tru "Failed to parse datetime value: {0}" dt-val) {:status-code 400}))))
        start      (some-> (:start date-range) u.date/parse)
        end        (some-> (:end date-range) u.date/parse)
        dt-col     (if (some #(instance? LocalDate %) [start end])
                     [:cast dt-col :date]
                     dt-col)]
    (cond
      (= start end)
      [:= dt-col start]
      (nil? start)
      [:< dt-col end]
      (nil? end)
      [:> dt-col start]
      :else
      [:and [:>= dt-col start] [:< dt-col end]])))
(defmulti ^:private where-clause*
  {:arglists '([filter-type column v])}
  (fn [filter-type _column _v] filter-type))
(defmethod where-clause* ::single-value [_ k v] [:= k v])
(defmethod where-clause* ::date-range [_ k v] (date-range-filter-clause k v))
(defmethod where-clause* ::list [_ k v] [:in k v])

Build a clause limiting the entries to those (not) within or within personal collections, if relevant. WARNING: this method queries the appdb, and its approach will get very slow when there are many users!

(defn personal-collections-where-clause
  [{filter-type :filter-items-in-personal-collection :keys [current-user-id] :as search-ctx} collection-id-col]
  (case (or filter-type "all")
    "all" nil
    "only-mine"
    [:or
     [:= :collection.personal_owner_id current-user-id]
     [:like :collection.location (format "/%d/%%" (t2/select-one-pk :model/Collection :personal_owner_id [:= current-user-id]))]]
    "exclude-others"
    (let [with-filter #(personal-collections-where-clause
                        (assoc search-ctx :filter-items-in-personal-collection %)
                        collection-id-col)]
      [:or (with-filter "only-mine") (with-filter "exclude")])
    (let [personal-ids   (t2/select-pks-vec :model/Collection :personal_owner_id [:not= nil])
          child-patterns (for [id personal-ids] (format "/%d/%%" id))]
      (case filter-type
        "only"
        `[:or
          ;; top level personal collections
          [:and [:not= :collection.personal_owner_id nil] [:= :collection.location "/"]]
          ;; their sub-collections
          ~@(for [p child-patterns] [:like :collection.location p])]
        "exclude"
        `[:or
          ;; not in a collection
          [:= ~collection-id-col nil]
          [:and
           ;; neither in a top-level personal collection
           [:= :collection.personal_owner_id nil]
           ;; nor within one of their sub-collections
           ~@(for [p child-patterns] [:not-like :collection.location p])]]))))

Return a HoneySQL clause corresponding to all the optional search filters.

(defn with-filters
  [search-context qry]
  (as-> qry qry
    (sql.helpers/where qry (if (seq (:models search-context))
                             [:in :search_index.model (:models search-context)]
                             ;; Ideally, we would not get this far, and bail out earlier.
                             [:= 1 2]))
    (sql.helpers/where qry (when-let [ids (:ids search-context)]
                             [:and
                              [:in :search_index.model_id (map str ids)]
                              ;; NOTE: we limit id-based search to only a subset of the models
                              ;; TODO this should just become part of the model spec e.g. :search-by-id?
                              [:in :search_index.model ["card" "dataset" "metric" "dashboard" "action"]]]))
    (sql.helpers/where qry [:or
                            ;; leverage the fact that only card-related models populate this attribute
                            [:= nil :search_index.dashboard_id]
                            (when (:include-dashboard-questions? search-context)
                              [:not= [:inline 0] [:coalesce :search_index.dashboardcard_count [:inline 0]]])])
    (reduce (fn [qry {t :type :keys [context-key required-feature supported-value? field]}]
              (or (when-some [v (get search-context context-key)]
                    (assert (supported-value? v) (str "Unsupported value for " context-key " - " v))
                    (when (or (nil? required-feature) (premium-features/has-feature? required-feature))
                      (when-some [c (where-clause* t (keyword (str "search_index." field)) v)]
                        (sql.helpers/where qry c))))
                  qry))
            qry
            (vals (dissoc search.config/filters :id :native-query)))))