;; 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 backend-tests.storage-test
  (:require
   [app.common.exceptions :as ex]
   [app.common.time :as ct]
   [app.common.uuid :as uuid]
   [app.db :as db]
   [app.rpc :as-alias rpc]
   [app.storage :as sto]
   [backend-tests.helpers :as th]
   [clojure.test :as t]
   [cuerdas.core :as str]
   [datoteka.fs :as fs]
   [datoteka.io :as io]
   [mockery.core :refer [with-mocks]]))

(t/use-fixtures :once th/state-init)
(t/use-fixtures :each (th/serial
                       th/database-reset
                       th/clean-storage))

(defn configure-storage-backend
  "Given storage map, returns a storage configured with the appropriate
  backend for assets."
  [storage]
  (assoc storage ::sto/backend :fs))

(t/deftest put-and-retrieve-object
  (let [storage (-> (:app.storage/storage th/*system*)
                    (configure-storage-backend))
        content (sto/content "content")
        object  (sto/put-object! storage {::sto/content content
                                          :content-type "text/plain"
                                          :other "data"})]

    (t/is (sto/object? object))
    (t/is (fs/path? (sto/get-object-path storage object)))

    (t/is (nil? (:expired-at object)))
    (t/is (= :fs (:backend object)))
    (t/is (= "data" (:other (meta object))))
    (t/is (= "text/plain" (:content-type (meta object))))
    (t/is (= "content" (slurp (sto/get-object-data storage object))))
    (t/is (= "content" (slurp (sto/get-object-path storage object))))))

(t/deftest tempfile-objects-are-not-deduplicated
  (let [storage (-> (:app.storage/storage th/*system*)
                    (configure-storage-backend))
        content (-> (sto/content "content")
                    (sto/wrap-with-hash "same-hash"))
        object1 (sto/put-object! storage {::sto/content content
                                          ::sto/deduplicate? true
                                          ::sto/touched-at (ct/in-future {:minutes 10})
                                          :bucket "tempfile"
                                          :content-type "text/plain"})
        object2 (sto/put-object! storage {::sto/content content
                                          ::sto/deduplicate? true
                                          ::sto/touched-at (ct/in-future {:minutes 10})
                                          :bucket "tempfile"
                                          :content-type "text/plain"})]
    (t/is (not= (:id object1) (:id object2)))))

(t/deftest put-and-retrieve-expired-object
  (let [storage (-> (:app.storage/storage th/*system*)
                    (configure-storage-backend))
        content (sto/content "content")
        object  (sto/put-object! storage {::sto/content content
                                          ::sto/expired-at (ct/in-future {:hours 1})
                                          :content-type "text/plain"})]

    (t/is (sto/object? object))
    (t/is (ct/inst? (:expired-at object)))
    (t/is (ct/is-after? (:expired-at object) (ct/now)))
    (t/is (nil? (sto/get-object storage (:id object))))))

(t/deftest put-and-delete-object
  (let [storage (-> (:app.storage/storage th/*system*)
                    (configure-storage-backend))
        content (sto/content "content")
        object  (sto/put-object! storage {::sto/content content
                                          :content-type "text/plain"
                                          :expired-at (ct/in-future {:seconds 1})})]
    (t/is (sto/object? object))
    (t/is (true? (sto/del-object! storage object)))

    ;; retrieving the same object should be not nil because the
    ;; deletion is not immediate
    (t/is (some? (sto/get-object-data storage object)))
    (t/is (some? (sto/get-object-url storage object)))
    (t/is (some? (sto/get-object-path storage object)))

    ;; But you can't retrieve the object again because in database is
    ;; marked as deleted/expired.
    (t/is (nil? (sto/get-object storage (:id object))))))

(t/deftest deleted-gc-task
  (let [storage (-> (:app.storage/storage th/*system*)
                    (configure-storage-backend))
        content1 (sto/content "content1")
        content2 (sto/content "content2")
        content3 (sto/content "content3")

        object1  (sto/put-object! storage {::sto/content content1
                                           ::sto/expired-at (ct/now)
                                           :content-type "text/plain"})
        object2  (sto/put-object! storage {::sto/content content2
                                           ::sto/expired-at (ct/in-future {:hours 2})
                                           :content-type "text/plain"})
        object3  (sto/put-object! storage {::sto/content content3
                                           ::sto/expired-at (ct/in-future {:hours 1})
                                           :content-type "text/plain"})]

    (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 0}))]
      (let [res (th/run-task! :storage-gc-deleted {})]
        (t/is (= 1 (:deleted res)))))

    (let [res (th/db-exec-one! ["select count(*) from storage_object;"])]
      (t/is (= 2 (:count res))))

    (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:minutes 61}))]
      (let [res (th/run-task! :storage-gc-deleted {})]
        (t/is (= 1 (:deleted res)))))

    (let [res (th/db-exec-one! ["select count(*) from storage_object;"])]
      (t/is (= 1 (:count res))))))

(t/deftest touched-gc-task-1
  (let [storage (-> (:app.storage/storage th/*system*)
                    (configure-storage-backend))
        prof    (th/create-profile* 1)
        proj    (th/create-project* 1 {:profile-id (:id prof)
                                       :team-id (:default-team-id prof)})

        file    (th/create-file* 1 {:profile-id (:id prof)
                                    :project-id (:default-project-id prof)
                                    :is-shared false})

        mfile   {:filename "sample.jpg"
                 :path (th/tempfile "backend_tests/test_files/sample.jpg")
                 :mtype "image/jpeg"
                 :size 312043}

        params  {::th/type :upload-file-media-object
                 ::rpc/profile-id (:id prof)
                 :file-id (:id file)
                 :is-local true
                 :name "testfile"
                 :content mfile}

        out1    (th/command! params)
        out2    (th/command! params)]

    (t/is (nil? (:error out1)))
    (t/is (nil? (:error out2)))

    (let [result-1 (:result out1)
          result-2 (:result out2)]

      (t/is (uuid? (:id result-1)))
      (t/is (uuid? (:id result-2)))

      (t/is (uuid? (:media-id result-1)))
      (t/is (uuid? (:media-id result-2)))

      (t/is (= (:media-id result-1) (:media-id result-2)))

      (th/db-update! :file-media-object
                     {:deleted-at (ct/now)}
                     {:id (:id result-1)})

      ;; run the objects gc task for permanent deletion
      (let [res (th/run-task! :objects-gc {})]
        (t/is (= 1 (:processed res))))

      ;; check that we still have all the storage objects
      (let [res (th/db-exec-one! ["select count(*) from storage_object"])]
        (t/is (= 2 (:count res))))

      ;; now check if the storage objects are touched
      (let [res (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"])]
        (t/is (= 2 (:count res))))

      ;; run the touched gc task
      (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
                  (th/run-task! :storage-gc-touched {}))]
        (t/is (= 2 (:freeze res)))
        (t/is (= 0 (:delete res))))

      ;; now check that there are no touched objects
      (let [res (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"])]
        (t/is (= 0 (:count res))))

      ;; now check that all objects are marked to be deleted
      (let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])]
        (t/is (= 0 (:count res)))))))

(defn- upload-font-chunked!
  "Splits `font-bytes` into a single chunk, creates an upload session,
   uploads the chunk, and returns the session-id UUID."
  [prof ^bytes font-bytes mtype]
  (let [tmp        (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-")
        _          (io/write* tmp font-bytes)
        mfile      {:filename "chunk" :path tmp :mtype mtype :size (alength font-bytes)}
        session-id (-> (th/command! {::th/type :create-upload-session
                                     ::rpc/profile-id (:id prof)
                                     :total-chunks 1})
                       :result :session-id)
        out        (th/command! {::th/type :upload-chunk
                                 ::rpc/profile-id (:id prof)
                                 :session-id session-id
                                 :index 0
                                 :content mfile})]
    (assert (nil? (:error out)))
    session-id))

(t/deftest touched-gc-task-2
  (let [storage (-> (:app.storage/storage th/*system*)
                    (configure-storage-backend))
        prof    (th/create-profile* 1 {:is-active true})
        team-id (:default-team-id prof)
        proj-id (:default-project-id prof)
        font-id (uuid/custom 10 1)

        proj    (th/create-project* 1 {:profile-id (:id prof)
                                       :team-id team-id})

        file    (th/create-file* 1 {:profile-id (:id prof)
                                    :project-id proj-id
                                    :is-shared false})

        ttfdata (-> (io/resource "backend_tests/test_files/font-1.ttf")
                    (io/read*))

        mfile   {:filename "sample.jpg"
                 :path (th/tempfile "backend_tests/test_files/sample.jpg")
                 :mtype "image/jpeg"
                 :size 312043}

        params1 {::th/type :upload-file-media-object
                 ::rpc/profile-id (:id prof)
                 :file-id (:id file)
                 :is-local true
                 :name "testfile"
                 :content mfile}

        session-id (upload-font-chunked! prof ttfdata "font/ttf")

        params2 {::th/type :create-font-variant
                 ::rpc/profile-id (:id prof)
                 :team-id team-id
                 :font-id font-id
                 :font-family "somefont"
                 :font-weight 400
                 :font-style "normal"
                 :uploads {"font/ttf" session-id}}

        out1     (th/command! params1)
        out2     (th/command! params2)]

    ;; (th/print-result! out)

    (t/is (nil? (:error out1)))
    (t/is (nil? (:error out2)))

    ;; run the touched gc task
    (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
                (th/run-task! :storage-gc-touched {}))]
      (t/is (= 5 (:freeze res)))
      (t/is (= 1 (:delete res)))

      (let [result-1 (:result out1)
            result-2 (:result out2)]

        (th/db-update! :team-font-variant
                       {:deleted-at (ct/now)}
                       {:id (:id result-2)})

        ;; run the objects gc task for permanent deletion
        (let [res (th/run-task! :objects-gc {})]
          (t/is (= 1 (:processed res))))

        ;; revert touched state to all storage objects

        (th/db-exec-one! ["update storage_object set touched_at=?" (ct/now)])

        ;; Run the task again
        (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
                    (th/run-task! :storage-gc-touched {}))]
          (t/is (= 2 (:freeze res)))
          (t/is (= 4 (:delete res))))

        ;; now check that there are no touched objects
        (let [res (th/db-exec-one! ["select count(*) from storage_object where touched_at is not null"])]
          (t/is (= 0 (:count res))))

        ;; now check that all objects are marked to be deleted
        (let [res (th/db-exec-one! ["select count(*) from storage_object where deleted_at is not null"])]
          (t/is (= 4 (:count res))))))))

(t/deftest touched-gc-task-3
  (let [storage (-> (:app.storage/storage th/*system*)
                    (configure-storage-backend))
        prof    (th/create-profile* 1)
        proj    (th/create-project* 1 {:profile-id (:id prof)
                                       :team-id (:default-team-id prof)})
        file    (th/create-file* 1 {:profile-id (:id prof)
                                    :project-id (:default-project-id prof)
                                    :is-shared false})
        mfile   {:filename "sample.jpg"
                 :path (th/tempfile "backend_tests/test_files/sample.jpg")
                 :mtype "image/jpeg"
                 :size 312043}

        params  {::th/type :upload-file-media-object
                 ::rpc/profile-id (:id prof)
                 :file-id (:id file)
                 :is-local true
                 :name "testfile"
                 :content mfile}

        out1    (th/command! params)
        out2    (th/command! params)]

    (t/is (nil? (:error out1)))
    (t/is (nil? (:error out2)))

    (let [result-1 (:result out1)
          result-2 (:result out2)]

      ;; now we proceed to manually mark all storage objects touched
      (th/db-exec! ["update storage_object set touched_at=?" (ct/now)])

      ;; run the touched gc task
      (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
                  (th/run-task! :storage-gc-touched {}))]
        (t/is (= 2 (:freeze res)))
        (t/is (= 0 (:delete res))))

      ;; check that we have all object in the db
      (let [rows (th/db-exec! ["select * from storage_object"])]
        (t/is (= 2 (count rows)))))

    ;; now we proceed to manually delete all file_media_object
    (th/db-exec! ["update file_media_object set deleted_at = ?" (ct/now)])

    (let [res (th/run-task! :objects-gc {})]
      (t/is (= 2 (:processed res))))

    ;; run the touched gc task
    (let [res (binding [ct/*clock* (ct/fixed-clock (ct/in-future {:hours 3}))]
                (th/run-task! :storage-gc-touched {}))]
      (t/is (= 0 (:freeze res)))
      (t/is (= 2 (:delete res))))

    ;; check that we have all no objects
    (let [rows (th/db-exec! ["select * from storage_object where deleted_at is null"])]
      (t/is (= 0 (count rows))))))

(t/deftest tempfile-bucket-test
  (let [storage (-> (:app.storage/storage th/*system*)
                    (configure-storage-backend))
        content1 (sto/content "content1")
        now      (ct/now)

        object1  (sto/put-object! storage {::sto/content content1
                                           ::sto/touched-at (ct/plus now {:minutes 1})
                                           :bucket "tempfile"
                                           :content-type "text/plain"})]


    (binding [ct/*clock* (ct/fixed-clock now)]
      (let [res (th/run-task! :storage-gc-touched {})]
        (t/is (= 0 (:freeze res)))
        (t/is (= 0 (:delete res)))))


    (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 3}))]
      (let [res (th/run-task! :storage-gc-touched {})]
        (t/is (= 0 (:freeze res)))
        (t/is (= 1 (:delete res)))))


    (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 1}))]
      (let [res (th/run-task! :storage-gc-deleted {})]
        (t/is (= 0 (:deleted res)))))

    (binding [ct/*clock* (ct/fixed-clock (ct/plus now {:hours 2}))]
      (let [res (th/run-task! :storage-gc-deleted {})]
        (t/is (= 0 (:deleted res)))))))
