(ns metabase.user-key-value.models.user-key-value.types
  (:require
   [clojure.edn :as edn]
   [clojure.java.io :as io]
   [clojure.string :as str]
   [metabase.config :as config]
   [metabase.util :as u]
   [metabase.util.files :as u.files]
   [metabase.util.json :as json]
   [metabase.util.log :as log]
   [metabase.util.malli :as mu]
   [metabase.util.malli.registry :as mr])
  (:import
   (java.io File)
   (java.nio.file
    FileSystems
    Files
    OpenOption
    Path
    StandardWatchEventKinds
    WatchEvent
    WatchKey
    WatchService)))
(set! *warn-on-reflection* true)
(defn- file->namespace [^File f]
  (keyword "namespace" (-> f .getName (str/replace #"\.edn$" ))))
(mr/def ::namespace
  "A namespace for the key-value pair"
  [:and
   {;; api request comes in, turn `foo` into `:namespace/foo`
    :decode/api-request (partial keyword "namespace")
    :encode/api-request name
    ;; writing to the DB, turn `:namespace/foo` into `foo`
    :encode/database name
    ;; reading from the DB, turn `foo` into `:namespace/foo`
    :decode/database (partial keyword "namespace")}
   :keyword])
(mr/def ::expires-at
  "When the key-value pair expires"
  :time/instant)

Declare a new namespace with a schema for the value

(mu/defn- defnamespace
  [namespace :- ::namespace
   schema]
  (derive namespace ::registered-namespace)
  (mr/register! namespace schema))
(defn- known-namespaces
  []
  (descendants ::registered-namespace))

this is just a placeholder so LSP can register the place it lives for jump-to-definition functionality. Actual schema gets created below by [[user-key-value-schema]] and [[update-user-key-value-schema]]

(mr/def ::user-key-value any?)

Build the schema for a ::user-key-value

(defn- user-key-value-schema
  []
  [:and
   [:map
    [:expires-at [:maybe ::expires-at]]
    [:namespace ::namespace]
    [:value {:encode/database json/encode
             :decode/database #(json/decode % keyword)}
     :any]]
   (into [:multi
          {:dispatch :namespace}]
         (map (fn [namespace]
                [namespace namespace]))
         (known-namespaces))])
(defn- update-user-key-value-schema! []
  (log/debug "Updating user-key-value schema")
  (mr/register! ::user-key-value (user-key-value-schema)))
(update-user-key-value-schema!)
(def ^:private types-dir "user_key_value_types")

Loads a schema with the provided namespace

(defn- load-schema!
  [schema namespace]
  (defnamespace namespace schema)
  (update-user-key-value-schema!))

Load a schema from an EDN file, using its filename as the namespace.

(defn- load-schema-from-file!
  [^File file]
  (let [namespace (file->namespace file)
        schema  (-> file slurp edn/read-string)]
    (load-schema! schema namespace)))

Only used in dev. Watch a directory for changes and call the callback with the affected file.

(defn watch-directory!
  [dir callback]
  (let [^WatchService watcher (.newWatchService (FileSystems/getDefault))
        ^Path path    (.toPath (io/file dir))]
    (.register path watcher
               (into-array [StandardWatchEventKinds/ENTRY_CREATE
                            StandardWatchEventKinds/ENTRY_MODIFY
                            StandardWatchEventKinds/ENTRY_DELETE]))
    (future
      (loop []
        (when-let [^WatchKey key (.take watcher)]
          (doseq [^WatchEvent event (.pollEvents key)]
            (let [kind (.kind event)
                  filename (.context event)
                  file (io/file dir (.toString filename))]
              (cond
                (= kind StandardWatchEventKinds/ENTRY_CREATE) (callback file :create)
                (= kind StandardWatchEventKinds/ENTRY_MODIFY) (callback file :modify)
                (= kind StandardWatchEventKinds/ENTRY_DELETE) (callback file :delete))))
          (.reset key)
          (recur))))))

Only used in dev. Handle a file change in the types directory.

(defn handle-file-change!
  [^File file action]
  (case action
    :create (load-schema-from-file! file)
    :modify (load-schema-from-file! file)
    :delete (let [namespace (file->namespace file)]
              ;; this is kind of silly. we don't have a way to delete something from the registry, so just hackily
              ;; make a schema that can't ever be valid. In production, we're not going to be watching files, so
              ;; this is solely for dev.
              (defnamespace namespace [:and true? false?]))))

Loads all type schemas from the a given resource path. This is the production code path which doesn't implement file-watching, and works when running in a JAR.

(defn load-all-schemas-prod!
  [dir]
  (u.files/with-open-path-to-resource [dir dir]
    (with-open [ds (Files/newDirectoryStream dir)]
      (let [schemas (reduce
                     (fn [acc ^Path file]
                       (let [schema (try
                                      (-> file
                                          (Files/newInputStream (u/varargs OpenOption))
                                          slurp
                                          edn/read-string)
                                      (catch Throwable e
                                        (throw
                                         (ex-info (format "Error loading schema %s: %s" (str file) (ex-message e))
                                                  {}
                                                  e))))
                             namespace (keyword "namespace"
                                                (-> file
                                                    .getFileName
                                                    (str/replace #"\.edn$" "")))]
                         (conj acc [schema namespace])))
                     []
                     ds)]
        (doseq [[schema namespace] schemas]
          (load-schema! schema namespace))
        (update-user-key-value-schema!)))))

In production, just load the schemas. In development, watch for changes as well.

(defn load-and-watch-schemas!
  []
  (load-all-schemas-prod! types-dir)
  ;; in dev, watch both types directories for changes
  (when config/is-dev?
    (watch-directory! (io/file (io/resource types-dir)) handle-file-change!)))