;; 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 Sucursal en España SL

(ns app.rpc.commands.files
  (:require
   [app.binfile.common :as bfc]
   [app.common.data :as d]
   [app.common.data.macros :as dm]
   [app.common.exceptions :as ex]
   [app.common.features :as cfeat]
   [app.common.files.helpers :as cfh]
   [app.common.files.migrations :as fmg]
   [app.common.files.stats :as cfs]
   [app.common.logging :as l]
   [app.common.schema :as sm]
   [app.common.schema.desc-js-like :as-alias smdj]
   [app.common.time :as ct]
   [app.common.transit :as t]
   [app.common.types.components-list :as ctkl]
   [app.common.types.file :as ctf]
   [app.common.uri :as uri]
   [app.config :as cf]
   [app.db :as db]
   [app.db.sql :as-alias sql]
   [app.features.fdata :as feat.fdata]
   [app.features.logical-deletion :as ldel]
   [app.http.sse :as sse]
   [app.loggers.audit :as-alias audit]
   [app.loggers.webhooks :as-alias webhooks]
   [app.msgbus :as mbus]
   [app.redis :as rds]
   [app.rpc :as-alias rpc]
   [app.rpc.commands.projects :as projects]
   [app.rpc.commands.teams :as teams]
   [app.rpc.cond :as-alias cond]
   [app.rpc.doc :as-alias doc]
   [app.rpc.helpers :as rph]
   [app.rpc.permissions :as perms]
   [app.util.blob :as blob]
   [app.util.events :as events]
   [app.util.pointer-map :as pmap]
   [app.util.services :as sv]
   [app.worker :as wrk]
   [cuerdas.core :as str]))

;; --- FEATURES

(defn resolve-public-uri
  [media-id]
  (when media-id
    (str (cf/get :public-uri) "/assets/by-id/" media-id)))

;; --- HELPERS

(def long-cache-duration
  (ct/duration {:days 7}))

(defn decode-row
  [{:keys [features] :as row}]
  (when row
    (cond-> row
      (db/pgarray? features) (assoc :features (db/decode-pgarray features #{})))))

(defn check-version!
  [file]
  (let [version (:version file)]
    (when (> version fmg/version)
      (ex/raise :type :restriction
                :code :file-version-not-supported
                :hint "file version is greated that the maximum"
                :file-version version
                :max-version fmg/version))
    file))


;; --- FILE DATA

;; --- FILE PERMISSIONS

(def has-edit-permissions?
  (perms/make-edition-predicate-fn bfc/get-file-permissions))

(def has-read-permissions?
  (perms/make-read-predicate-fn perms/get-file-read-permissions))

(def has-comment-permissions?
  (perms/make-comment-predicate-fn perms/get-file-read-permissions))

(def check-edition-permissions!
  (perms/make-check-fn has-edit-permissions?))

(def check-read-permissions!
  (perms/make-check-fn has-read-permissions?))

;; A user has comment permissions if she has read permissions, or
;; explicit comment permissions through the share-id

(defn check-comment-permissions!
  [cfg profile-id file-id share-id]
  (let [perms       (perms/get-file-read-permissions cfg profile-id file-id share-id)
        can-read    (has-read-permissions? perms)
        can-comment (has-comment-permissions? perms)]
    (when-not (or can-read can-comment)
      (ex/raise :type :not-found
                :code :object-not-found
                :hint "not found"))))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; QUERY COMMANDS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;; --- COMMAND QUERY: get-file (by id)

(def schema:file
  [:map {:title "File"}
   [:id ::sm/uuid]
   [:features ::cfeat/features]
   [:has-media-trimmed ::sm/boolean]
   [:comment-thread-seqn [::sm/int {:min 0}]]
   [:name [:string {:max 250}]]
   [:revn [::sm/int {:min 0}]]
   [:vern [::sm/int {:min 0}]]
   [:modified-at ::ct/inst]
   [:is-shared ::sm/boolean]
   [:project-id ::sm/uuid]
   [:created-at ::ct/inst]
   [:data {:optional true} ::sm/any]])

(def schema:permissions-mixin
  [:map {:title "PermissionsMixin"}
   [:permissions perms/schema:permissions]])

(def schema:file-with-permissions
  [:merge {:title "FileWithPermissions"}
   schema:file
   schema:permissions-mixin])

(def ^:private
  schema:get-file
  [:map {:title "get-file"}
   [:features {:optional true} ::cfeat/features]
   [:id ::sm/uuid]])

(defn get-minimal-file
  [cfg id & {:as opts}]
  (let [opts (assoc opts ::sql/columns [:id :modified-at :deleted-at :revn :vern])]
    (db/get cfg :file {:id id} opts)))

(defn- get-minimal-file-with-perms
  [cfg {:keys [:id ::rpc/profile-id]}]
  (let [mfile (get-minimal-file cfg id)
        perms (perms/get-file-read-permissions cfg profile-id id)]
    (assoc mfile :permissions perms)))

(defn get-file-etag
  [{:keys [::rpc/profile-id]} {:keys [modified-at revn vern deleted-at permissions]}]
  (str profile-id "/" revn "/" vern "/" (hash fmg/available-migrations) "/"
       (ct/format-inst modified-at :iso)
       "/"
       (uri/map->query-string permissions)
       "/"
       (some-> deleted-at (ct/format-inst :iso))))

(sv/defmethod ::get-file
  "Retrieve a file by its ID. Only authenticated users."
  {::doc/added "1.17"
   ::rpc/id-type :file
   ::cond/get-object #(get-minimal-file-with-perms %1 %2)
   ::cond/key-fn get-file-etag
   ::sm/params schema:get-file
   ::sm/result schema:file-with-permissions
   ::db/transaction true}
  [cfg {:keys [::rpc/profile-id id project-id] :as params}]
  ;; The COND middleware makes initial request for a file and
  ;; permissions when the incoming request comes with an
  ;; ETAG. When ETAG does not matches, the request is resolved
  ;; and this code is executed, in this case the permissions
  ;; will be already prefetched and we just reuse them instead
  ;; of making an additional database queries.
  (let [perms (or (:permissions (::cond/object params))
                  (perms/get-file-read-permissions cfg profile-id id))]
    (check-read-permissions! perms)

    (let [team (teams/get-team cfg
                               :profile-id profile-id
                               :project-id project-id
                               :file-id id)

          file (-> (bfc/get-file cfg id
                                 :project-id project-id)
                   (assoc :permissions perms)
                   (check-version!))]

      (-> (cfeat/get-team-enabled-features cf/flags team)
          (cfeat/check-client-features! (:features params))
          (cfeat/check-file-features! (:features file)))

      (as-> file file
        ;; This operation is needed for backward comapatibility with
        ;; frontends that does not support pointer-map resolution
        ;; mechanism; this just resolves the pointers on backend and
        ;; return a complete file
        (if (and (contains? (:features file) "fdata/pointer-map")
                 (not (contains? (:features params) "fdata/pointer-map")))
          (feat.fdata/realize-pointers cfg file)
          file)

        ;; This operation is needed for backward comapatibility with
        ;; frontends that does not support objects-map mechanism; this
        ;; just converts all objects map instaces to plain maps
        (if (and (contains? (:features file) "fdata/objects-map")
                 (not (contains? (:features params) "fdata/objects-map")))
          (feat.fdata/realize-objects cfg file)
          file)))))

;; --- COMMAND QUERY: get-file-fragment (by id)

(def schema:file-fragment
  [:map {:title "FileFragment"}
   [:id ::sm/uuid]
   [:file-id ::sm/uuid]
   [:created-at ::ct/inst]
   [:content ::sm/any]])

(def schema:get-file-fragment
  [:map {:title "get-file-fragment"}
   [:file-id ::sm/uuid]
   [:fragment-id ::sm/uuid]
   [:share-id {:optional true} ::sm/uuid]])

(defn- get-file-fragment
  [cfg file-id fragment-id]
  (some-> (db/get cfg :file-data {:file-id file-id :id fragment-id :type "fragment"})
          (update :data blob/decode)))

(sv/defmethod ::get-file-fragment
  "Retrieve a file fragment by its ID. Only authenticated users."
  {::doc/added "1.17"
   ::rpc/auth false
   ::sm/params schema:get-file-fragment
   ::sm/result schema:file-fragment}
  [cfg {:keys [::rpc/profile-id file-id fragment-id share-id]}]
  (db/run! cfg (fn [cfg]
                 (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)]
                   (check-read-permissions! perms)
                   (-> (get-file-fragment cfg file-id fragment-id)
                       (rph/with-http-cache long-cache-duration))))))

;; --- COMMAND QUERY: get-project-files

(def ^:private sql:project-files
  "select f.id,
          f.project_id,
          f.created_at,
          f.modified_at,
          f.name,
          f.revn,
          f.vern,
          f.is_shared,
          ft.media_id AS thumbnail_id,
          p.team_id
     from file as f
     inner join project as p on (p.id = f.project_id)
     left join file_thumbnail as ft on (ft.file_id = f.id
                                        and ft.revn = f.revn
                                        and ft.deleted_at is null)
    where f.project_id = ?
      and f.deleted_at is null
    order by f.modified_at desc")

(defn get-project-files
  [conn project-id]
  (db/exec! conn [sql:project-files project-id]))

(def schema:get-project-files
  [:map {:title "get-project-files"}
   [:project-id ::sm/uuid]])

(def schema:files
  [:vector schema:file])

(sv/defmethod ::get-project-files
  "Get all files for the specified project."
  {::doc/added "1.17"
   ::sm/params schema:get-project-files
   ::sm/result schema:files}
  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id project-id]}]
  (projects/check-read-permissions! cfg profile-id project-id)
  (get-project-files pool project-id))

;; --- COMMAND QUERY: has-file-libraries

(declare get-has-file-libraries)

(def schema:has-file-libraries
  [:map {:title "has-file-libraries"}
   [:file-id ::sm/uuid]])

(sv/defmethod ::has-file-libraries
  "Checks if the file has libraries. Returns a boolean"
  {::doc/added "1.15.1"
   ::sm/params schema:has-file-libraries
   ::sm/result ::sm/boolean}
  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
  (dm/with-open [conn (db/open pool)]
    (check-read-permissions! cfg profile-id file-id)
    (get-has-file-libraries conn file-id)))

(def ^:private sql:has-file-libraries
  "SELECT COUNT(*) > 0 AS has_libraries
     FROM file_library_rel AS flr
     JOIN file AS fl ON (flr.library_file_id = fl.id)
    WHERE flr.file_id = ?::uuid
      AND (fl.deleted_at IS NULL OR
           fl.deleted_at > now())")

(defn- get-has-file-libraries
  [conn file-id]
  (let [row (db/exec-one! conn [sql:has-file-libraries file-id])]
    (:has-libraries row)))


;; --- COMMAND QUERY: get-library-usage


(declare get-library-usage)

(def schema:get-library-usage
  [:map {:title "get-library-usage"}
   [:file-id ::sm/uuid]])
:sample
(sv/defmethod ::get-library-usage
  "Gets the number of files that use the specified library."
  {::doc/added "2.10.0"
   ::sm/params schema:get-library-usage
   ::sm/result ::sm/int}
  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id]}]
  (dm/with-open [conn (db/open pool)]
    (check-read-permissions! cfg profile-id file-id)
    (get-library-usage conn file-id)))

(def ^:private sql:get-library-usage
  "SELECT COUNT(*) AS used
     FROM file_library_rel AS flr
     JOIN file AS fl ON (flr.library_file_id = fl.id)
    WHERE flr.library_file_id = ?::uuid
      AND (fl.deleted_at IS NULL OR
           fl.deleted_at > now())")

(defn- get-library-usage
  [conn file-id]
  (let [row (db/exec-one! conn [sql:get-library-usage file-id])]
    {:used-in (:used row)}))


;; --- QUERY COMMAND: get-page

(defn- prune-objects
  "Given the page data and the object-id returns the page data with all
  other not needed objects removed from the `:objects` data
  structure."
  [page id-or-ids]
  (update page :objects (fn [objects]
                          (reduce (fn [result object-id]
                                    (->> (cfh/get-children-with-self objects object-id)
                                         (filter some?)
                                         (d/index-by :id)
                                         (merge result)))
                                  {}
                                  (if (uuid? id-or-ids)
                                    [id-or-ids]
                                    id-or-ids)))))

(defn- prune-thumbnails
  "Given the page data, removes the `:thumbnail` prop from all
  shapes."
  [page]
  (update page :objects update-vals #(dissoc % :thumbnail)))

(defn get-page
  [{:keys [::db/conn] :as cfg} {:keys [profile-id file-id page-id object-id share-id] :as params}]

  (when (and (uuid? object-id)
             (not (uuid? page-id)))
    (ex/raise :type :validation
              :code :params-validation
              :hint "page-id is required when object-id is provided"))

  (let [perms (perms/get-file-read-permissions cfg profile-id file-id share-id)
        file  (bfc/get-file cfg file-id :read-only? true)

        proj  (db/get conn :project {:id (:project-id file)})

        team  (-> (db/get conn :team {:id (:team-id proj)})
                  (teams/decode-row))

        _     (-> (cfeat/get-team-enabled-features cf/flags team)
                  (cfeat/check-client-features! (:features params))
                  (cfeat/check-file-features! (:features file)))

        page  (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
                (let [page-id (or page-id (-> file :data :pages first))
                      page    (dm/get-in file [:data :pages-index page-id])]
                  (if (pmap/pointer-map? page)
                    (deref page)
                    page)))]

    (when-not perms
      (ex/raise :type :not-found
                :code :object-not-found
                :hint "object not found"))

    (cond-> (prune-thumbnails page)
      (some? object-id)
      (prune-objects object-id))))

(def schema:get-page
  [:map {:title "get-page"}
   [:file-id ::sm/uuid]
   [:page-id {:optional true} ::sm/uuid]
   [:share-id {:optional true} ::sm/uuid]
   [:object-id {:optional true} [:or ::sm/uuid [::sm/set ::sm/uuid]]]
   [:features {:optional true} ::cfeat/features]])

(sv/defmethod ::get-page
  "Retrieves the page data from file and returns it. If no page-id is
  specified, the first page will be returned. If object-id is
  specified, only that object and its children will be returned in the
  page objects data structure.

  If you specify the object-id, the page-id parameter becomes
  mandatory.

  Mainly used for rendering purposes on the exporter. It does not
  accepts client features."
  {::doc/added "1.17"
   ::sm/params schema:get-page}
  [cfg {:keys [::rpc/profile-id file-id share-id] :as params}]
  (db/tx-run! cfg
              (fn [cfg]
                (check-read-permissions! cfg profile-id file-id share-id)
                (get-page cfg (assoc params :profile-id profile-id)))))

;; --- COMMAND QUERY: get-team-shared-files

(defn- get-components-with-variants
  "Return a set with all the variant-ids, and a list of components, but
  with only one component by variant.

  Returns a vector of unique components and a set of all variant ids"
  [fdata]
  (loop [variant-ids #{}
         components' []
         components  (ctkl/components-seq fdata)]
    (if-let [{:keys [variant-id] :as component} (first components)]
      (cond
        (nil? variant-id)
        (recur variant-ids
               (conj components' component)
               (rest components))

        (contains? variant-ids variant-id)
        (recur variant-ids
               components'
               (rest components))

        :else
        (recur (conj variant-ids variant-id)
               (conj components' component)
               (rest components)))

      [(d/index-by :id components') variant-ids])))

(defn- sample-assets
  [assets limit]
  (let [assets (into [] (map val) assets)]
    {:count (count assets)
     :sample (->> assets
                  (sort-by #(str/lower (:name %)))
                  (into [] (take limit)))}))

(defn- calculate-library-summary
  "Calculate the file library summary (counters and samples)"
  [{:keys [data] :as file}]
  (let [load-objects
        (fn [sample]
          (mapv #(ctf/load-component-objects data %) sample))

        [components variant-ids]
        (get-components-with-variants data)

        components-sample
        (-> (sample-assets components 4)
            (update :sample load-objects))]

    {:components components-sample
     :variants {:count (count variant-ids)}
     :colors (sample-assets (:colors data) 3)
     :typographies (sample-assets (:typographies data) 3)}))

(def ^:private file-summary-cache-key-ttl
  (ct/duration {:days 30}))

(def file-summary-cache-key-prefix
  "penpot.library-summary.")

(defn- get-file-with-summary
  "Get a file without data with a summary of its local library content"
  [cfg id]
  (let [get-from-cache
        (fn [{:keys [::rds/conn]} cache-key]
          (when-let [result (rds/get conn cache-key)]
            (let [file    (bfc/get-file cfg id :load-data? false)
                  summary (t/decode-str result)]
              (-> (assoc file :library-summary summary)
                  (dissoc :data)))))

        calculate-from-db
        (fn []
          (let [file   (bfc/get-file cfg id)
                result (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg id)]
                         (calculate-library-summary file))]
            (-> file
                (assoc :library-summary result)
                (dissoc :legacy-data)
                (dissoc :data))))

        persist-to-cache
        (fn [{:keys [::rds/conn]} data cache-key]
          (rds/set conn cache-key (t/encode-str data)
                   (rds/build-set-args {:ex file-summary-cache-key-ttl})))]

    (if (contains? cf/flags :redis-cache)
      (let [cache-key (str file-summary-cache-key-prefix id)]
        (or (rds/run! cfg get-from-cache cache-key)
            (let [file (calculate-from-db)]
              (rds/run! cfg persist-to-cache (:library-summary file) cache-key)
              file)))
      (calculate-from-db))))

(def ^:private sql:team-shared-files
  "WITH file_library_agg AS (
      SELECT flr.file_id,
             coalesce(array_agg(flr.library_file_id) filter (WHERE flr.library_file_id IS NOT NULL), '{}') AS library_file_ids
        FROM file_library_rel flr
       GROUP BY flr.file_id
   )

   SELECT f.id,
          fla.library_file_ids,
          ft.media_id AS thumbnail_id
     FROM file AS f
    INNER JOIN project AS p ON (p.id = f.project_id)
     LEFT JOIN file_thumbnail AS ft ON (ft.file_id = f.id AND ft.revn = f.revn AND ft.deleted_at IS NULL)
     LEFT JOIN file_library_agg AS fla ON (fla.file_id = f.id)
    WHERE f.is_shared = true
      AND f.deleted_at IS NULL
      AND p.deleted_at IS NULL
      AND p.team_id = ?
    ORDER BY f.modified_at DESC")

(defn- get-team-shared-files
  [{:keys [::db/conn] :as cfg} {:keys [team-id profile-id]}]
  (teams/check-read-permissions! cfg profile-id team-id)

  (let [process-row
        (fn [{:keys [id library-file-ids]}]
          (let [file (get-file-with-summary cfg id)]
            (assoc file :library-file-ids (db/decode-pgarray library-file-ids #{}))))

        xform
        (map process-row)]

    (->> (db/plan conn [sql:team-shared-files team-id] {:fetch-size 1})
         (transduce xform conj #{}))))

(def ^:private schema:get-team-shared-files
  [:map {:title "get-team-shared-files"}
   [:team-id ::sm/uuid]])

(sv/defmethod ::get-team-shared-files
  "Get all file (libraries) for the specified team."
  {::doc/added "1.17"
   ::sm/params schema:get-team-shared-files}
  [cfg {:keys [::rpc/profile-id] :as params}]
  (db/tx-run! cfg get-team-shared-files (assoc params :profile-id profile-id)))

;; --- COMMAND QUERY: get-file-summary

(defn- get-file-summary
  [cfg id]
  (let [file (get-file-with-summary cfg id)]
    (-> (:library-summary file)
        (assoc :name (:name file)))))

(def ^:private
  schema:get-file-summary
  [:map {:title "get-file-summary"}
   [:id ::sm/uuid]])

(sv/defmethod ::get-file-summary
  "Retrieve a file summary by its ID. Only authenticated users."
  {::doc/added "1.20"
   ::rpc/id-type :file
   ::sm/params schema:get-file-summary}
  [cfg {:keys [::rpc/profile-id id] :as params}]
  (check-read-permissions! cfg profile-id id)
  (get-file-summary cfg id))


;; --- COMMAND QUERY: get-file-stats

(def ^:private sql:file-stats-library-counts
  "SELECT
     (SELECT COUNT(*)
        FROM file_library_rel AS flr
        JOIN file AS fl ON (fl.id = flr.library_file_id)
       WHERE flr.file_id = ?::uuid
         AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS library_count,
     (SELECT COUNT(*)
        FROM file_library_rel AS flr
        JOIN file AS fl ON (fl.id = flr.file_id)
       WHERE flr.library_file_id = ?::uuid
         AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS referenced_by_count")

(defn- get-file-stats-library-counts
  [conn file-id]
  (let [row (db/exec-one! conn [sql:file-stats-library-counts file-id file-id])]
    {:library-count       (or (:library-count row) 0)
     :referenced-by-count (or (:referenced-by-count row) 0)}))

(defn- get-file-stats
  [{:keys [::db/conn] :as cfg} file-id]
  (let [file    (bfc/get-file cfg file-id)
        base    (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)]
                  (cfs/calc-file-stats (:data file)))
        lib-cnt (get-file-stats-library-counts conn file-id)]
    (-> base
        (merge lib-cnt)
        (assoc :file-id    file-id
               :revn       (:revn file)
               :updated-at (:modified-at file)))))

(def ^:private schema:shape-counts
  [:map {:title "FileStatsShapeCounts"}
   [:total [::sm/int {:min 0}]]
   [:by-type [:map-of :keyword [::sm/int {:min 0}]]]])

(def ^:private schema:get-file-stats-result
  [:map {:title "FileStats"}
   [:file-id ::sm/uuid]
   [:page-count [::sm/int {:min 0}]]
   [:shape-counts schema:shape-counts]
   [:component-count [::sm/int {:min 0}]]
   [:deleted-component-count [::sm/int {:min 0}]]
   [:color-count [::sm/int {:min 0}]]
   [:typography-count [::sm/int {:min 0}]]
   [:library-count [::sm/int {:min 0}]]
   [:referenced-by-count [::sm/int {:min 0}]]
   [:revn [::sm/int {:min 0}]]
   [:updated-at ::ct/inst]])

(def ^:private schema:get-file-stats
  [:map {:title "get-file-stats"}
   [:id ::sm/uuid]])

(sv/defmethod ::get-file-stats
  "Return aggregate statistics for a single file: page count, shape
   counts by type, component/color/typography counts, and inbound and
   outbound library reference counts. Cheap alternative to `get-file`
   when only metrics are needed."
  {::doc/added "2.17"
   ::rpc/id-type :file
   ::sm/params schema:get-file-stats
   ::sm/result schema:get-file-stats-result
   ::db/transaction true}
  [cfg {:keys [::rpc/profile-id id]}]
  (check-read-permissions! cfg profile-id id)
  (get-file-stats cfg id))


;; --- COMMAND QUERY: get-file-libraries

(def ^:private schema:get-file-libraries
  [:map {:title "get-file-libraries"}
   [:file-id ::sm/uuid]])

(sv/defmethod ::get-file-libraries
  "Get libraries used by the specified file."
  {::doc/added "1.17"
   ::sm/params schema:get-file-libraries}
  [cfg {:keys [::rpc/profile-id file-id]}]
  (bfc/check-file-exists cfg file-id)
  (check-read-permissions! cfg profile-id file-id)
  (bfc/get-file-libraries cfg file-id))

;; --- COMMAND QUERY: Files that use this File library

(def ^:private sql:library-using-files
  "SELECT f.id,
          f.name
     FROM file_library_rel AS flr
     JOIN file AS f ON (f.id = flr.file_id)
    WHERE flr.library_file_id = ?
      AND (f.deleted_at IS NULL OR f.deleted_at > now())")

(defn get-library-file-references
  [conn file-id]
  (db/exec! conn [sql:library-using-files file-id]))

(def ^:private schema:get-library-file-references
  [:map {:title "get-library-file-references"}
   [:file-id ::sm/uuid]])

(sv/defmethod ::get-library-file-references
  "Returns all the file references that use specified file (library) id."
  {::doc/added "1.17"
   ::sm/params schema:get-library-file-references}
  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id file-id] :as params}]
  (dm/with-open [conn (db/open pool)]
    (check-read-permissions! cfg profile-id file-id)
    (get-library-file-references conn file-id)))

;; --- COMMAND QUERY: get-team-recent-files

(def sql:team-recent-files
  "with recent_files as (
     select f.id,
            f.revn,
            f.vern,
            f.project_id,
            f.created_at,
            f.modified_at,
            f.name,
            f.is_shared,
            ft.media_id AS thumbnail_id,
            row_number() over w as row_num,
            p.team_id
       from file as f
      inner join project as p on (p.id = f.project_id)
       left join file_thumbnail as ft on (ft.file_id = f.id
                                          and ft.revn = f.revn
                                          and ft.deleted_at is null)
      where p.team_id = ?
        and p.deleted_at is null
        and f.deleted_at is null
     window w as (partition by f.project_id order by f.modified_at desc)
      order by f.modified_at desc
   )
   select * from recent_files where row_num <= 10;")

(defn get-team-recent-files
  [conn team-id]
  (db/exec! conn [sql:team-recent-files team-id]))

(def ^:private schema:get-team-recent-files
  [:map {:title "get-team-recent-files"}
   [:team-id ::sm/uuid]])

(sv/defmethod ::get-team-recent-files
  {::doc/added "1.17"
   ::sm/params schema:get-team-recent-files}
  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id]}]
  (dm/with-open [conn (db/open pool)]
    (teams/check-read-permissions! cfg profile-id team-id)
    (get-team-recent-files conn team-id)))


;; --- COMMAND QUERY: get-team-deleted-files

(def sql:team-deleted-files
  "WITH deleted_files AS (
     SELECT f.id,
            f.revn,
            f.vern,
            f.project_id,
            f.created_at,
            f.modified_at,
            f.name,
            f.deleted_at AS will_be_deleted_at,
            ft.media_id AS thumbnail_id,
            row_number() OVER w AS row_num,
            p.team_id
       FROM file AS f
      INNER JOIN project AS p ON (p.id = f.project_id)
       LEFT JOIN file_thumbnail AS ft on (ft.file_id = f.id
                                          AND ft.revn = f.revn)
      WHERE p.team_id = ?
        AND (p.deleted_at > ?::timestamptz OR
             f.deleted_at > ?::timestamptz)
     WINDOW w AS (PARTITION BY f.project_id
                      ORDER BY f.modified_at DESC)
      ORDER BY f.modified_at DESC
   )
   SELECT * FROM deleted_files")

(defn get-team-deleted-files
  [conn team-id]
  (let [now (ct/now)]
    (db/exec! conn [sql:team-deleted-files team-id now now])))

(def ^:private schema:get-team-deleted-files
  [:map {:title "get-team-deleted-files"}
   [:team-id ::sm/uuid]])

(sv/defmethod ::get-team-deleted-files
  {::doc/added "2.12"
   ::sm/params schema:get-team-deleted-files}
  [cfg {:keys [::rpc/profile-id team-id]}]
  (db/run! cfg (fn [{:keys [::db/conn] :as cfg}]
                 (teams/check-read-permissions! cfg profile-id team-id)
                 (get-team-deleted-files conn team-id))))

;; --- COMMAND QUERY: get-file-info


(defn- get-file-info
  [{:keys [::db/conn] :as cfg} {:keys [id] :as params}]
  (db/get conn :file
          {:id id}
          {::sql/columns [:id :deleted-at]}))

(sv/defmethod ::get-file-info
  "Retrieve minimal file info by its ID."
  {::rpc/auth false
   ::doc/added "2.2.0"
   ::sm/params schema:get-file}
  [cfg params]
  (db/tx-run! cfg get-file-info params))

;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MUTATION COMMANDS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;; --- MUTATION COMMAND: rename-file

(defn rename-file
  [conn {:keys [id name]}]
  (db/update! conn :file
              {:name name
               :modified-at (ct/now)}
              {:id id}
              {::db/return-keys true}))

(sv/defmethod ::rename-file
  {::doc/added "1.17"
   ::rpc/id-type :file
   ::webhooks/event? true

   ::sm/webhook
   [:map {:title "RenameFileEvent"}
    [:id ::sm/uuid]
    [:project-id ::sm/uuid]
    [:name [:string {:max 250}]]
    [:created-at ::ct/inst]
    [:modified-at ::ct/inst]]

   ::sm/params
   [:map {:title "RenameFileParams"}
    [:name [:string {:min 1 :max 250}]]
    [:id ::sm/uuid]]

   ::sm/result
   [:map {:title "SimplifiedFile"}
    [:id ::sm/uuid]
    [:name [:string {:max 250}]]
    [:created-at ::ct/inst]
    [:modified-at ::ct/inst]]

   ::db/transaction true}
  [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id] :as params}]
  (check-edition-permissions! conn profile-id id)
  (let [file (rename-file conn params)]
    (rph/with-meta
      (select-keys file [:id :name :created-at :modified-at])
      {::audit/props {:project-id (:project-id file)
                      :created-at (:created-at file)
                      :modified-at (:modified-at file)}})))

;; --- MUTATION COMMAND: set-file-shared

(def ^:private sql:get-referenced-files
  "SELECT f.id
     FROM file_library_rel AS flr
    INNER JOIN file AS f ON (f.id = flr.file_id)
    WHERE flr.library_file_id = ?
      AND (f.deleted_at IS NULL OR f.deleted_at > now())
    ORDER BY f.created_at ASC;")

(defn- absorb-library-by-file
  [cfg ldata file-id]

  (assert (db/connection-map? cfg)
          "expected cfg with valid connection")

  (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)
            pmap/*tracked* (pmap/create-tracked)]
    (let [file (-> (bfc/get-file cfg file-id
                                 :include-deleted? true
                                 :lock-for-update? true)
                   (update :data ctf/absorb-assets ldata))]

      (l/trc :hint "library absorbed"
             :library-id (str (:id ldata))
             :file-id (str file-id))

      (bfc/update-file! cfg {:id file-id
                             :migrations (:migrations file)
                             :revn (inc (:revn file))
                             :data (:data file)
                             :modified-at (ct/now)
                             :has-media-trimmed false}))))

(defn- absorb-library*
  "Find all files using a shared library, and absorb all library assets
  into the file local libraries"
  [cfg {:keys [id data] :as library}]

  (assert (db/connection-map? cfg)
          "expected cfg with valid connection")

  (let [ids (->> (db/exec! cfg [sql:get-referenced-files id])
                 (sequence bfc/xf-map-id))]

    (l/trc :hint "absorbing library"
           :library-id (str id)
           :files (str/join "," (map str ids)))

    (run! (partial absorb-library-by-file cfg data) ids)
    library))

(defn absorb-library
  [{:keys [::db/conn] :as cfg} id]
  (let [file (-> (bfc/get-file cfg id
                               :realize? true
                               :lock-for-update? true
                               :include-deleted? true)
                 (check-version!))

        proj (db/get* conn :project {:id (:project-id file)}
                      {::db/remove-deleted false})
        team (-> (db/get* conn :team {:id (:team-id proj)}
                          {::db/remove-deleted false})
                 (teams/decode-row))]

    (-> (cfeat/get-team-enabled-features cf/flags team)
        (cfeat/check-file-features! (:features file)))

    (absorb-library* cfg file)))

(defn- set-file-shared
  [{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}]
  (check-edition-permissions! conn profile-id id)
  (let [file (db/get-by-id conn :file id {:columns [:id :name :is-shared]})
        file (cond
               (and (true? (:is-shared file))
                    (false? (:is-shared params)))
               ;; When we disable shared flag on an already shared
               ;; file, we need to perform more complex operation,
               ;; so in this case we retrieve the complete file and
               ;; perform all required validations.
               (let [file (-> (absorb-library cfg id)
                              (assoc :is-shared false))]
                 (db/delete! conn :file-library-rel {:library-file-id id})
                 (db/update! conn :file
                             {:is-shared false
                              :modified-at (ct/now)}
                             {:id id})
                 file)

               (and (false? (:is-shared file))
                    (true? (:is-shared params)))
               (let [file (assoc file :is-shared true)]
                 (db/update! conn :file
                             {:is-shared true
                              :modified-at (ct/now)}
                             {:id id})
                 file)

               (= (:is-shared file) (:is-shared params))
               ;; File is already in the desired state (idempotent);
               ;; this can happen when the frontend sends a duplicate
               ;; request due to optimistic updates or race conditions.
               file

               :else
               (ex/raise :type :validation
                         :code :invalid-shared-state
                         :hint "unexpected state found"
                         :params-is-shared (:is-shared params)
                         :file-is-shared (:is-shared file)))]

    (rph/with-meta
      (select-keys file [:id :name :is-shared])
      {::audit/props {:name (:name file)
                      :project-id (:project-id file)
                      :is-shared (:is-shared file)}})))

(def ^:private
  schema:set-file-shared
  [:map {:title "set-file-shared"}
   [:id ::sm/uuid]
   [:is-shared ::sm/boolean]])

(sv/defmethod ::set-file-shared
  {::doc/added "1.17"
   ::rpc/id-type :file
   ::webhooks/event? true
   ::sm/params schema:set-file-shared}
  [cfg {:keys [::rpc/profile-id] :as params}]
  (db/tx-run! cfg set-file-shared (assoc params :profile-id profile-id)))

;; --- MUTATION COMMAND: delete-file

(defn- mark-file-deleted
  [conn team file-id]
  (let [delay (ldel/get-deletion-delay team)
        file  (db/update! conn :file
                          {:deleted-at (ct/in-future delay)}
                          {:id file-id}
                          {::db/return-keys [:id :name :is-shared :deleted-at
                                             :project-id :created-at :modified-at]})]

    ;; Remove all possible relations for that file
    (db/delete! conn :file-library-rel
                {:library-file-id file-id})

    (wrk/submit! {::db/conn conn
                  ::wrk/task :delete-object
                  ::wrk/params {:object :file
                                :deleted-at (:deleted-at file)
                                :id file-id}})
    file))

(def ^:private
  schema:delete-file
  [:map {:title "delete-file"}
   [:id ::sm/uuid]])

(defn- delete-file
  [{:keys [::db/conn] :as cfg} {:keys [profile-id id] :as params}]
  (check-edition-permissions! conn profile-id id)
  (let [team (teams/get-team conn
                             :profile-id profile-id
                             :file-id id)
        file (mark-file-deleted conn team id)
        msgbus (::mbus/msgbus cfg)]

    (mbus/pub! msgbus
               :topic id
               :message {:type :file-deleted
                         :file-id id
                         :profile-id profile-id})

    (rph/with-meta (rph/wrap)
      {::audit/props {:project-id (:project-id file)
                      :name (:name file)
                      :created-at (:created-at file)
                      :modified-at (:modified-at file)}})))

(sv/defmethod ::delete-file
  {::doc/added "1.17"
   ::rpc/id-type :file
   ::webhooks/event? true
   ::sm/params schema:delete-file}
  [cfg {:keys [::rpc/profile-id] :as params}]
  (db/tx-run! cfg delete-file (assoc params :profile-id profile-id)))

;; --- Library relation helpers

(defn- check-library-team-ownership!
  "Verify that file and library belong to the same team.
  Prevents cross-team library relation injection."
  [conn file-id library-id]
  (let [sql "SELECT EXISTS (
               SELECT 1 FROM file AS f
               JOIN project AS fp ON (fp.id = f.project_id)
               JOIN file AS l ON (l.id = ?)
               JOIN project AS lp ON (lp.id = l.project_id)
               WHERE f.id = ? AND fp.team_id = lp.team_id
             ) AS ok"
        row (db/exec-one! conn [sql library-id file-id])]
    (when-not (:ok row)
      (ex/raise :type :not-found
                :code :object-not-found
                :hint "file and library must belong to the same team"))))

;; --- MUTATION COMMAND: link-file-to-library

(def sql:link-file-to-library
  "insert into file_library_rel (file_id, library_file_id)
   values (?, ?)
       on conflict do nothing;")

(defn link-file-to-library
  [conn {:keys [file-id library-id] :as params}]
  (db/exec-one! conn [sql:link-file-to-library file-id library-id])
  (bfc/upsert-file-library-sync! conn {:file-id file-id
                                       :library-file-id library-id
                                       :synced-at (ct/now)}))

(def ^:private
  schema:link-file-to-library
  [:map {:title "link-file-to-library"}
   [:file-id ::sm/uuid]
   [:library-id ::sm/uuid]])

(sv/defmethod ::link-file-to-library
  "Link a file to a library. Returns the recursive list of libraries used by that library"
  {::doc/added "1.17"
   ::webhooks/event? true
   ::sm/params schema:link-file-to-library
   ::db/transaction true}
  [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]

  (when (= file-id library-id)
    (ex/raise :type :validation
              :code :invalid-library
              :hint "A file cannot be linked to itself"))

  (check-edition-permissions! conn profile-id file-id)
  (check-edition-permissions! conn profile-id library-id)
  (check-library-team-ownership! conn file-id library-id)

  (let [transitive-deps (bfc/get-libraries cfg [library-id])]
    (when (contains? transitive-deps file-id)
      (ex/raise :type :validation
                :code :circular-library-reference
                :hint "linking this library would create a circular dependency")))

  (link-file-to-library conn params)
  (bfc/get-libraries cfg [library-id]))

;; --- MUTATION COMMAND: unlink-file-from-library

(defn unlink-file-from-library
  [conn {:keys [file-id library-id]}]
  (db/delete! conn :file-library-rel
              {:file-id file-id
               :library-file-id library-id}))

(def ^:private schema:unlink-file-to-library
  [:map {:title "unlink-file-to-library"}
   [:file-id ::sm/uuid]
   [:library-id ::sm/uuid]])

(sv/defmethod ::unlink-file-from-library
  {::doc/added "1.17"
   ::webhooks/event? true
   ::sm/params schema:unlink-file-to-library
   ::db/transaction true}
  [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id library-id] :as params}]
  (check-edition-permissions! conn profile-id file-id)
  (check-edition-permissions! conn profile-id library-id)
  (check-library-team-ownership! conn file-id library-id)
  (unlink-file-from-library conn params)
  nil)

;; --- MUTATION COMMAND: update-sync

(defn update-sync
  [conn {:keys [file-id library-id] :as params}]
  (bfc/upsert-file-library-sync! conn {:file-id file-id
                                       :library-file-id library-id
                                       :synced-at (ct/now)}))

(def ^:private schema:update-file-library-sync-status
  [:map {:title "update-file-library-sync-status"}
   [:file-id ::sm/uuid]
   [:library-id ::sm/uuid]])

(sv/defmethod ::update-file-library-sync-status
  "Update the synchronization status of a file->library link"
  {::doc/added "1.17"
   ::sm/params schema:update-file-library-sync-status
   ::db/transaction true}
  [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id library-id] :as params}]
  (check-edition-permissions! conn profile-id file-id)
  (check-edition-permissions! conn profile-id library-id)
  (check-library-team-ownership! conn file-id library-id)
  (update-sync conn params))

;; --- MUTATION COMMAND: ignore-sync

(defn ignore-sync
  [conn {:keys [file-id date] :as params}]
  (db/update! conn :file
              {:ignore-sync-until date
               :modified-at (ct/now)}
              {:id file-id}
              {::db/return-keys true}))

(def ^:private schema:ignore-file-library-sync-status
  [:map {:title "ignore-file-library-sync-status"}
   [:file-id ::sm/uuid]
   [:date ::ct/inst]])

;; TODO: improve naming
(sv/defmethod ::ignore-file-library-sync-status
  "Ignore updates in linked files"
  {::doc/added "1.17"
   ::sm/params schema:ignore-file-library-sync-status
   ::db/transaction true}
  [{:keys [::db/conn]} {:keys [::rpc/profile-id file-id] :as params}]
  (check-edition-permissions! conn profile-id file-id)
  (->  (ignore-sync conn params)
       (update :features db/decode-pgarray #{})))

;; --- MUTATION COMMAND: delete-files-immediatelly

(def ^:private sql:get-delete-team-files-candidates
  "SELECT f.id
    FROM file AS f
    JOIN project AS p ON (p.id = f.project_id)
    JOIN team AS t ON (t.id = p.team_id)
   WHERE t.deleted_at IS NULL
     AND t.id = ?
     AND f.id = ANY(?::uuid[])")

(def ^:private schema:permanently-delete-team-files
  [:map {:title "permanently-delete-team-files"}
   [:team-id ::sm/uuid]
   [:ids [::sm/set ::sm/uuid]]])

(defn- permanently-delete-team-files
  [{:keys [::db/conn]} {:keys [::rpc/request-at team-id ids]}]
  (let [ids (into #{}
                  d/xf:map-id
                  (db/exec! conn [sql:get-delete-team-files-candidates team-id
                                  (db/create-array conn "uuid" ids)]))]

    (reduce (fn [acc id]
              (events/tap :progress {:file-id id :index (inc (count acc)) :total (count ids)})
              (db/update! conn :file
                          {:deleted-at request-at}
                          {:id id}
                          {::db/return-keys false})
              (wrk/submit! {::db/conn conn
                            ::wrk/task :delete-object
                            ::wrk/params {:object :file
                                          :deleted-at request-at
                                          :id id}})
              (conj acc id))
            #{}
            ids)))

(sv/defmethod ::permanently-delete-team-files
  "Mark the specified files to be deleted immediatelly on the
  specified team. The team-id on params will be used to filter and
  check writable permissons on team."

  {::doc/added "2.13"
   ::sm/params schema:permanently-delete-team-files}

  [{:keys [::db/pool] :as cfg} {:keys [::rpc/profile-id team-id] :as params}]
  (teams/check-edition-permissions! pool profile-id team-id)
  (sse/response #(db/tx-run! cfg permanently-delete-team-files params)))

;; --- MUTATION COMMAND: restore-files-immediatelly

(def ^:private sql:resolve-editable-files
  "SELECT f.id, f.project_id
     FROM file AS f
     JOIN project AS p ON (p.id = f.project_id)
     JOIN team AS t ON (t.id = p.team_id)
    WHERE t.deleted_at IS NULL
      AND t.id = ?
      AND f.id = ANY(?::uuid[])")

(def ^:private sql:restore-files
  "UPDATE file SET deleted_at = null, has_media_trimmed = false
    WHERE id = ANY(?::uuid[])")

(def ^:private sql:restore-file-media-objects
  "UPDATE file_media_object SET deleted_at = null
    WHERE file_id = ANY(?::uuid[])")

(def ^:private sql:restore-file-changes
  "UPDATE file_change SET deleted_at = null
    WHERE file_id = ANY(?::uuid[])")

(def ^:private sql:restore-file-data
  "UPDATE file_data SET deleted_at = null
    WHERE file_id = ANY(?::uuid[])")

(def ^:private sql:restore-file-thumbnails
  "UPDATE file_thumbnail SET deleted_at = null
    WHERE file_id = ANY(?::uuid[])")

(def ^:private sql:restore-file-tagged-object-thumbnails
  "UPDATE file_tagged_object_thumbnail SET deleted_at = null
    WHERE file_id = ANY(?::uuid[])")

(defn- restore-files
  [conn file-ids]
  (let [file-ids (db/create-array conn "uuid" file-ids)]
    (db/exec-one! conn [sql:restore-files file-ids])
    (db/exec-one! conn [sql:restore-file-media-objects file-ids])
    (db/exec-one! conn [sql:restore-file-changes file-ids])
    (db/exec-one! conn [sql:restore-file-data file-ids])
    (db/exec-one! conn [sql:restore-file-thumbnails file-ids])
    (db/exec-one! conn [sql:restore-file-tagged-object-thumbnails file-ids])))

(def ^:private sql:restore-projects
  "UPDATE project SET deleted_at = null WHERE id = ANY(?::uuid[])")

(defn- restore-projects
  [conn project-ids]
  (let [project-ids (db/create-array conn "uuid" project-ids)]
    (->> (db/exec-one! conn [sql:restore-projects project-ids])
         (db/get-update-count))))

(defn- restore-deleted-team-files
  [{:keys [::db/conn]} {:keys [::rpc/profile-id team-id ids]}]
  (teams/check-edition-permissions! conn profile-id team-id)
  (let [total-files
        (count ids)

        {:keys [files projects]}
        (reduce (fn [result {:keys [id project-id]}]
                  (let [index (-> result :files count)]
                    (events/tap :progress {:file-id id :index (inc index) :total total-files})
                    (-> result
                        (update :files conj id)
                        (update :projects conj project-id))))
                {:files #{} :projects #{}}
                (db/plan conn [sql:resolve-editable-files team-id
                               (db/create-array conn "uuid" ids)]))]

    (when (seq files)
      (restore-files conn files))

    (when (seq projects)
      (restore-projects conn projects))

    files))

(def ^:private schema:restore-deleted-team-files
  [:map {:title "restore-deleted-team-files"}
   [:team-id ::sm/uuid]
   [:ids [::sm/set ::sm/uuid]]])

(sv/defmethod ::restore-deleted-team-files
  "Removes the deletion mark from the specified files (and respective
  projects) on the specified team."
  {::doc/added "2.13"
   ::sse/stream? true
   ::sm/params schema:restore-deleted-team-files}
  [cfg params]
  (sse/response #(db/tx-run! cfg restore-deleted-team-files params)))
