;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC

(ns app.binfile.v2
  "A sqlite3 based binary file exportation with support for exportation
  of entire team (or multiple teams) at once."
  (:refer-clojure :exclude [read])
  (:require
   [app.binfile.common :as bfc]
   [app.common.data :as d]
   [app.common.features :as cfeat]
   [app.common.logging :as l]
   [app.common.transit :as t]
   [app.common.uuid :as uuid]
   [app.config :as cf]
   [app.db :as db]
   [app.db.sql :as sql]
   [app.loggers.audit :as-alias audit]
   [app.loggers.webhooks :as-alias webhooks]
   [app.media :as media]
   [app.storage :as sto]
   [app.storage.tmp :as tmp]
   [app.util.events :as events]
   [app.util.time :as dt]
   [app.worker :as-alias wrk]
   [clojure.set :as set]
   [cuerdas.core :as str]
   [datoteka.io :as io]
   [promesa.util :as pu])
  (:import
   java.sql.DriverManager))

(set! *warn-on-reflection* true)

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; LOW LEVEL API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn- create-database
  ([cfg]
   (let [path (tmp/tempfile :prefix "penpot.binfile." :suffix ".sqlite")]
     (create-database cfg path)))
  ([cfg path]
   (let [db (DriverManager/getConnection (str "jdbc:sqlite:" path))]
     (assoc cfg ::db db ::path path))))

(def ^:private
  sql:create-kvdata-table
  "CREATE TABLE kvdata (
     tag text NOT NULL,
     key text NOT NULL,
     val text NOT NULL,
     dat blob NULL
   )")

(def ^:private
  sql:create-kvdata-index
  "CREATE INDEX kvdata__tag_key__idx
      ON kvdata (tag, key)")

(defn- setup-schema!
  [{:keys [::db]}]
  (db/exec-one! db [sql:create-kvdata-table])
  (db/exec-one! db [sql:create-kvdata-index]))

(defn- write!
  [{:keys [::db]} tag k v & [data]]
  (db/insert! db :kvdata
              {:tag (d/name tag)
               :key (str k)
               :val (t/encode-str v {:type :json-verbose})
               :dat data}
              {::db/return-keys false}))

(defn- read-blob
  [{:keys [::db]} tag k]
  (let [obj (db/get db :kvdata
                    {:tag (d/name tag)
                     :key (str k)}
                    {::sql/columns [:dat]})]
    (:dat obj)))

(defn- read-seq
  ([{:keys [::db]} tag]
   (->> (db/query db :kvdata
                  {:tag (d/name tag)}
                  {::sql/columns [::val]})
        (map :val)
        (map t/decode-str)))
  ([{:keys [::db]} tag k]
   (->> (db/query db :kvdata
                  {:tag (d/name tag)
                   :key (str k)}
                  {::sql/columns [::val]})
        (map :val)
        (map t/decode-str))))

(defn- read-obj
  [{:keys [::db]} tag k]
  (let [obj (db/get db :kvdata
                    {:tag (d/name tag)
                     :key (str k)}
                    {::sql/columns [:val]})]
    (-> obj :val t/decode-str)))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; IMPORT/EXPORT IMPL
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(declare ^:private write-project!)
(declare ^:private write-file!)

(defn- write-team!
  [cfg team-id]

  (let [team  (bfc/get-team cfg team-id)
        fonts (bfc/get-fonts cfg team-id)]

    (events/tap :progress
                {:op :export
                 :section :write-team
                 :id team-id
                 :name (:name team)})

    (l/trc :hint "write" :obj "team"
           :id (str team-id)
           :fonts (count fonts))

    (when-let [photo-id (:photo-id team)]
      (vswap! bfc/*state* update :storage-objects conj photo-id))

    (vswap! bfc/*state* update :teams conj team-id)
    (vswap! bfc/*state* bfc/collect-storage-objects fonts)

    (write! cfg :team team-id team)

    (doseq [{:keys [id] :as font} fonts]
      (vswap! bfc/*state* update :team-font-variants conj id)
      (write! cfg :team-font-variant id font))))

(defn- write-project!
  [cfg project-id]
  (let [project (bfc/get-project cfg project-id)]
    (events/tap :progress
                {:op :export
                 :section :write-project
                 :id project-id
                 :name (:name project)})
    (l/trc :hint "write" :obj "project" :id (str project-id))
    (write! cfg :project (str project-id) project)
    (vswap! bfc/*state* update :projects conj project-id)))

(defn- write-file!
  [cfg file-id]
  (let [file   (bfc/get-file cfg file-id)
        thumbs (bfc/get-file-object-thumbnails cfg file-id)
        media  (bfc/get-file-media cfg file)
        rels   (bfc/get-files-rels cfg #{file-id})]

    (events/tap :progress
                {:op :export
                 :section :write-file
                 :id file-id
                 :name (:name file)})

    (vswap! bfc/*state* (fn [state]
                          (-> state
                              (update :files conj file-id)
                              (update :file-media-objects into bfc/xf-map-id media)
                              (bfc/collect-storage-objects thumbs)
                              (bfc/collect-storage-objects media))))

    (write! cfg :file file-id file)
    (write! cfg :file-rels file-id rels)

    (run! (partial write! cfg :file-media-object file-id) media)
    (run! (partial write! cfg :file-object-thumbnail file-id) thumbs)

    (when-let [thumb (bfc/get-file-thumbnail cfg file)]
      (vswap! bfc/*state* bfc/collect-storage-objects [thumb])
      (write! cfg :file-thumbnail file-id thumb))

    (l/trc :hint "write" :obj "file"
           :thumbnails (count thumbs)
           :rels (count rels)
           :media (count media))))

(defn- write-storage-object!
  [{:keys [::sto/storage] :as cfg} id]
  (let [sobj (sto/get-object storage id)
        data (with-open [input (sto/get-object-data storage sobj)]
               (io/read-as-bytes input))]

    (l/trc :hint "write" :obj "storage-object" :id (str id) :size (:size sobj))
    (write! cfg :storage-object id (meta sobj) data)))

(defn- read-storage-object!
  [{:keys [::sto/storage ::bfc/timestamp] :as cfg} id]
  (let [mdata   (read-obj cfg :storage-object id)
        data    (read-blob cfg :storage-object id)
        hash    (sto/calculate-hash data)

        content (-> (sto/content data)
                    (sto/wrap-with-hash hash))

        params  (-> mdata
                    (assoc ::sto/content content)
                    (assoc ::sto/deduplicate? true)
                    (assoc ::sto/touched-at timestamp))

        sobject  (sto/put-object! storage params)]

    (vswap! bfc/*state* update :index assoc id (:id sobject))

    (l/trc :hint "read" :obj "storage-object"
           :id (str id)
           :new-id (str (:id sobject))
           :size (:size sobject))))

(defn read-team!
  [{:keys [::db/conn ::bfc/timestamp] :as cfg} team-id]
  (l/trc :hint "read" :obj "team" :id (str team-id))

  (let [team (read-obj cfg :team team-id)
        team (-> team
                 (update :id bfc/lookup-index)
                 (update :photo-id bfc/lookup-index)
                 (assoc :created-at timestamp)
                 (assoc :modified-at timestamp))]

    (events/tap :progress
                {:op :import
                 :section :read-team
                 :id team-id
                 :name (:name team)})

    (db/insert! conn :team
                (update team :features db/encode-pgarray conn "text")
                ::db/return-keys false)

    (doseq [font (->> (read-seq cfg :team-font-variant)
                      (filter #(= team-id (:team-id %))))]
      (let [font (-> font
                     (update :id bfc/lookup-index)
                     (update :team-id bfc/lookup-index)
                     (update :woff1-file-id bfc/lookup-index)
                     (update :woff2-file-id bfc/lookup-index)
                     (update :ttf-file-id bfc/lookup-index)
                     (update :otf-file-id bfc/lookup-index)
                     (assoc :created-at timestamp)
                     (assoc :modified-at timestamp))]
        (db/insert! conn :team-font-variant font
                    ::db/return-keys false)))

    team))

(defn read-project!
  [{:keys [::db/conn ::bfc/timestamp] :as cfg} project-id]
  (l/trc :hint "read" :obj "project" :id (str project-id))

  (let [project (read-obj cfg :project project-id)
        project (-> project
                    (update :id bfc/lookup-index)
                    (update :team-id bfc/lookup-index)
                    (assoc :created-at timestamp)
                    (assoc :modified-at timestamp))]

    (events/tap :progress
                {:op :import
                 :section :read-project
                 :id project-id
                 :name (:name project)})

    (db/insert! conn :project project
                ::db/return-keys false)))

(defn read-file!
  [{:keys [::db/conn ::bfc/timestamp] :as cfg} file-id]
  (l/trc :hint "read" :obj "file" :id (str file-id))

  (let [file (-> (read-obj cfg :file file-id)
                 (update :id bfc/lookup-index)
                 (update :project-id bfc/lookup-index)
                 (bfc/process-file))]

    (events/tap :progress
                {:op :import
                 :section :read-file
                 :id file-id
                 :name (:name file)})

    ;; All features that are enabled and requires explicit migration are
    ;; added to the state for a posterior migration step.
    (doseq [feature (-> (::bfc/features cfg)
                        (set/difference cfeat/no-migration-features)
                        (set/difference (:features file)))]
      (vswap! bfc/*state* update :pending-to-migrate (fnil conj []) [feature (:id file)]))

    (bfc/persist-file! cfg file))

  (doseq [thumbnail (read-seq cfg :file-object-thumbnail file-id)]
    (let [thumbnail (-> thumbnail
                        (update :file-id bfc/lookup-index)
                        (update :media-id bfc/lookup-index))
          file-id    (:file-id thumbnail)

          thumbnail (update thumbnail :object-id
                            #(str/replace-first % #"^(.*?)/" (str file-id "/")))]

      (db/insert! conn :file-tagged-object-thumbnail thumbnail
                  {::db/return-keys false})))

  (doseq [rel (read-obj cfg :file-rels file-id)]
    (let [rel (-> rel
                  (update :file-id bfc/lookup-index)
                  (update :library-file-id bfc/lookup-index)
                  (assoc :synced-at timestamp))]
      (db/insert! conn :file-library-rel rel
                  ::db/return-keys false)))

  (doseq [media (read-seq cfg :file-media-object file-id)]
    (let [media (-> media
                    (update :id bfc/lookup-index)
                    (update :file-id bfc/lookup-index)
                    (update :media-id bfc/lookup-index)
                    (update :thumbnail-id bfc/lookup-index))]
      (db/insert! conn :file-media-object media
                  ::db/return-keys false
                  ::sql/on-conflict-do-nothing true))))

(def ^:private empty-summary
  {:teams #{}
   :files #{}
   :projects #{}
   :file-media-objects #{}
   :team-font-variants #{}
   :storage-objects #{}})

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; PUBLIC API
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

(defn export-team!
  [cfg team-id]
  (let [id  (uuid/next)
        tp  (dt/tpoint)

        cfg (-> (create-database cfg)
                (update ::sto/storage media/configure-assets-storage))]

    (l/inf :hint "start"
           :operation "export"
           :id (str id)
           :path (str (::path cfg)))

    (try
      (db/tx-run! cfg (fn [cfg]
                        (setup-schema! cfg)
                        (binding [bfc/*state* (volatile! empty-summary)]
                          (write-team! cfg team-id)

                          (run! (partial write-project! cfg)
                                (bfc/get-team-projects cfg team-id))

                          (run! (partial write-file! cfg)
                                (bfc/get-team-files cfg team-id))

                          (run! (partial write-storage-object! cfg)
                                (-> bfc/*state* deref :storage-objects))

                          (write! cfg :manifest "team-id" team-id)
                          (write! cfg :manifest "objects" (deref bfc/*state*))

                          (::path cfg))))
      (finally
        (pu/close! (::db cfg))

        (let [elapsed (tp)]
          (l/inf :hint "end"
                 :operation "export"
                 :id (str id)
                 :elapsed (dt/format-duration elapsed)))))))

(defn import-team!
  [cfg path]
  (let [id  (uuid/next)
        tp  (dt/tpoint)

        cfg (-> (create-database cfg path)
                (update ::sto/storage media/configure-assets-storage)
                (assoc ::bfc/timestamp (dt/now)))]

    (l/inf :hint "start"
           :operation "import"
           :id (str id)
           :path (str (::path cfg)))

    (try
      (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}]
                        (db/exec-one! conn ["SET idle_in_transaction_session_timeout = 0"])
                        (db/exec-one! conn ["SET CONSTRAINTS ALL DEFERRED"])

                        (binding [bfc/*state* (volatile! {:index {}})]
                          (let [objects (read-obj cfg :manifest "objects")]

                            ;; We first process all storage objects, they have
                            ;; deduplication so we can't rely on simple reindex. This
                            ;; operation populates the index for all storage objects.
                            (run! (partial read-storage-object! cfg) (:storage-objects objects))

                            ;; Populate index with all the incoming objects
                            (vswap! bfc/*state* update :index
                                    (fn [index]
                                      (-> index
                                          (bfc/update-index (:teams objects))
                                          (bfc/update-index (:projects objects))
                                          (bfc/update-index (:files objects))
                                          (bfc/update-index (:file-media-objects objects))
                                          (bfc/update-index (:team-font-variants objects)))))

                            (let [team-id  (read-obj cfg :manifest "team-id")
                                  team     (read-team! cfg team-id)
                                  features (cfeat/get-team-enabled-features cf/flags team)
                                  cfg      (assoc cfg ::bfc/features features)]

                              (run! (partial read-project! cfg) (:projects objects))
                              (run! (partial read-file! cfg) (:files objects))

                              ;; (run-pending-migrations! cfg)

                              team)))))
      (finally
        (pu/close! (::db cfg))

        (let [elapsed (tp)]
          (l/inf :hint "end"
                 :operation "import"
                 :id (str id)
                 :elapsed (dt/format-duration elapsed)))))))
