(ns metabase.api.cache
  (:require
   [metabase.api.common :as api]
   [metabase.api.macros :as api.macros]
   [metabase.config :as config]
   [metabase.models.cache-config :as cache-config]
   [metabase.premium-features.core :as premium-features]
   [metabase.util.cron :as u.cron]
   [metabase.util.i18n :refer [tru trun]]
   [metabase.util.malli :as mu]
   [metabase.util.malli.registry :as mr]
   [metabase.util.malli.schema :as ms]
   [toucan2.core :as t2]))

Data shape

(mr/def ::cache-strategy.base
  [:map
   [:type [:enum :nocache :ttl :duration :schedule]]])
(mr/def ::cache-strategy.nocache
  [:map ; not closed due to a way it's used in tests for clarity
   [:type [:= :nocache]]])
(mr/def ::cache-strategy.ttl
  [:map {:closed true}
   [:type            [:= :ttl]]
   [:multiplier      ms/PositiveInt]
   [:min_duration_ms ms/IntGreaterThanOrEqualToZero]])
(mr/def ::cache-strategy.oss
  "Schema for a caching strategy (OSS)"
  [:and
   ::cache-strategy.base
   [:multi {:dispatch :type}
    [:nocache ::cache-strategy.nocache]
    [:ttl     ::cache-strategy.ttl]]])
(mr/def ::cache-strategy.ee.duration
  [:map {:closed true}
   [:type                  [:= :duration]]
   [:duration              ms/PositiveInt]
   [:unit                  [:enum "hours" "minutes" "seconds" "days"]]
   [:refresh_automatically {:optional true} [:maybe :boolean]]])
(mr/def ::cache-strategy.ee.schedule
  [:map {:closed true}
   [:type                  [:= :schedule]]
   [:schedule              u.cron/CronScheduleString]
   [:refresh_automatically {:optional true} [:maybe :boolean]]])
(mr/def ::cache-strategy.ee
  "Schema for a caching strategy in EE when we have an premium token with `:cache-granular-controls`."
  [:and
   ::cache-strategy.base
   [:multi {:dispatch :type}
    [:nocache  ::cache-strategy.nocache]
    [:ttl      ::cache-strategy.ttl]
    [:duration ::cache-strategy.ee.duration]
    [:schedule ::cache-strategy.ee.schedule]]])
(mr/def ::cache-strategy
  (if config/ee-available?
    [:multi
     {:dispatch (fn [_value]
                  (if (premium-features/has-feature? :cache-granular-controls)
                    :ee
                    :oss))}
     [:ee  ::cache-strategy.ee]
     [:oss ::cache-strategy.oss]]
    ::cache-strategy.oss))
(defn- assert-valid-models [model ids premium?]
  (cond
    (= model "root")
    (when-not (some zero? ids)
      (throw (ex-info (tru "Root configuration is only valid with model_id = 0") {:status-code 404
                                                                                  :model_id    (first ids)})))
    (not premium?)
    (throw (premium-features/ee-feature-error (tru "Granular Caching")))
    :else
    (api/check-404 (t2/select-one (case model
                                    "database"  :model/Database
                                    "dashboard" :model/Dashboard
                                    "question"  :model/Card)
                                  :id [:in ids]))))
(defn- check-cache-access [model id]
  (if (or (nil? id)
          ;; sometimes its a sequence and we're going to check for settings access anyway
          (not (number? id))
          (zero? id))
    ;; if you're not accessing a concrete entity, you have to be an admin
    (api/check-superuser)
    (api/write-check (case model
                       "database" :model/Database
                       "dashboard" :model/Dashboard
                       "question" :model/Card)
                     id)))
(api.macros/defendpoint :get "/"
  "Return cache configuration."
  [_route-params
   {:keys [model collection id]}
   :- [:map
       [:model      {:default ["root"]} (mu/with (ms/QueryVectorOf cache-config/CachingModel)
                                                 {:description "Type of model"})]
       [:collection {:optional true} (mu/with [:maybe ms/PositiveInt]
                                              {:description "Collection id to filter results. Returns everything if not supplied."})]
       [:id         {:optional true} (mu/with [:maybe ms/PositiveInt]
                                              {:description "Model id to get configuration for."})]]]
  (when (and (not (premium-features/enable-cache-granular-controls?))
             (not= model ["root"]))
    (throw (premium-features/ee-feature-error (tru "Granular Caching"))))
  (check-cache-access (first model) id)
  {:data (cache-config/get-list model collection id)})
(api.macros/defendpoint :put "/"
  "Store cache configuration."
  [_route-params
   _query-params
   {:keys [model model_id] :as config} :- [:map
                                           [:model    cache-config/CachingModel]
                                           [:model_id ms/IntGreaterThanOrEqualToZero]
                                           [:strategy ::cache-strategy]]]
  (assert-valid-models model [model_id] (premium-features/enable-cache-granular-controls?))
  (check-cache-access model model_id)
  {:id (cache-config/store! api/*current-user-id* config)})
(api.macros/defendpoint :delete "/"
  "Delete cache configurations."
  [_route-params
   _query-params
   {:keys [model model_id]} :- [:map
                                [:model    cache-config/CachingModel]
                                [:model_id (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]]
  (assert-valid-models model model_id (premium-features/enable-cache-granular-controls?))
  (doseq [id model_id] (check-cache-access model id))
  (cache-config/delete! api/*current-user-id* model model_id)
  nil)
(api.macros/defendpoint :post "/invalidate"
  "Invalidate cache entries.
 
  Use it like `/api/cache/invalidate?database=1&dashboard=15` (any number of database/dashboard/question can be
  supplied).
 
  `&include=overrides` controls whenever you want to invalidate cache for a specific cache configuration without
  touching all nested configurations, or you want your invalidation to trickle down to every card."
  [_route-params
   {:keys [include database dashboard question]}
   :- [:map
       [:include   {:optional true} [:maybe {:description "All cache configuration overrides should invalidate cache too"}
                                     [:= :overrides]]]
       [:database  {:optional true} [:maybe {:description "A list of database ids"}
                                     (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]
       [:dashboard {:optional true} [:maybe {:description "A list of dashboard ids"}
                                     (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]
       [:question  {:optional true} [:maybe {:description "A list of question ids"}
                                     (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]]]
  (when-not (premium-features/enable-cache-granular-controls?)
    (throw (premium-features/ee-feature-error (tru "Granular Caching"))))
 
  (doseq [db-id database] (api/write-check :model/Database db-id))
  (doseq [dashboard-id dashboard] (api/write-check :model/Dashboard dashboard-id))
  (doseq [question-id question] (api/write-check :model/Card question-id))
 
  (let [cnt (cache-config/invalidate! {:databases       database
                                       :dashboards      dashboard
                                       :questions       question
                                       :with-overrides? (= include :overrides)})]
    {:status (if (= cnt -1) 404 200)
     :body   {:count   cnt
              :message (case [(= include :overrides) (if (pos? cnt) 1 cnt)]
                         [true -1]  (tru "Could not find any questions for the criteria you specified.")
                         [true 0]   (tru "No cached results to clear.")
                         [true 1]   (trun "Cleared a cached result." "Cleared {0} cached results." cnt)
                         [false -1] (tru "Nothing to invalidate.")
                         [false 0]  (tru "No cache configuration to invalidate.")
                         [false 1]  (trun "Invalidated cache configuration." "Invalidated {0} cache configurations." cnt))}}))