;; 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.main.data.workspace.clipboard
  (:require
   [app.common.data :as d]
   [app.common.data.macros :as dm]
   [app.common.exceptions :as ex]
   [app.common.features :as cfeat]
   [app.common.files.changes-builder :as pcb]
   [app.common.files.helpers :as cfh]
   [app.common.files.variant :as cfv]
   [app.common.geom.point :as gpt]
   [app.common.geom.rect :as grc]
   [app.common.geom.shapes :as gsh]
   [app.common.geom.shapes.grid-layout :as gslg]
   [app.common.logic.libraries :as cll]
   [app.common.logic.shapes :as cls]
   [app.common.schema :as sm]
   [app.common.transit :as t]
   [app.common.types.component :as ctk]
   [app.common.types.container :as ctn]
   [app.common.types.file :as ctf]
   [app.common.types.shape :as cts]
   [app.common.types.shape-tree :as ctst]
   [app.common.types.shape.layout :as ctl]
   [app.common.types.shape.text :as types.text]
   [app.common.types.text :as txt]
   [app.common.types.typography :as ctt]
   [app.common.uuid :as uuid]
   [app.config :as cf]
   [app.main.data.changes :as dch]
   [app.main.data.event :as ev]
   [app.main.data.exports.assets :as de]
   [app.main.data.exports.wasm :as wasm.exports]
   [app.main.data.helpers :as dsh]
   [app.main.data.notifications :as ntf]
   [app.main.data.persistence :as dps]
   [app.main.data.workspace.media :as dwm]
   [app.main.data.workspace.selection :as dws]
   [app.main.data.workspace.shapes :as dwsh]
   [app.main.data.workspace.texts :as dwtxt]
   [app.main.data.workspace.undo :as dwu]
   [app.main.data.workspace.wasm-text :as dwwt]
   [app.main.errors]
   [app.main.features :as features]
   [app.main.refs :as refs]
   [app.main.repo :as rp]
   [app.main.router :as rt]
   [app.main.store :as st]
   [app.main.streams :as ms]
   [app.util.clipboard :as clipboard]
   [app.util.code-gen.markup-svg :as svg]
   [app.util.code-gen.style-css :as css]
   [app.util.globals :as ug]
   [app.util.http :as http]
   [app.util.i18n :as i18n :refer [tr]]
   [app.util.text.content :as tc]
   [app.util.webapi :as wapi]
   [beicon.v2.core :as rx]
   [clojure.set :as set]
   [cuerdas.core :as str]
   [potok.v2.core :as ptk]
   [promesa.core :as p]))

(defn copy-selected
  []
  (letfn [(sort-selected [state data]
            (let [selected (dsh/lookup-selected state)
                  objects  (dsh/lookup-page-objects state)

                  ;; Narrow the objects map so it contains only relevant data for
                  ;; selected and its parents
                  objects  (cfh/selected-subtree objects selected)
                  selected (->> (ctst/sort-z-index objects selected)
                                (reverse)
                                (into (d/ordered-set)))]

              (assoc data :selected selected)))

          (fetch-image [entry]
            (let [url (cf/resolve-file-media entry)]
              (->> (http/send! {:method :get
                                :uri url
                                :response-type :blob})
                   (rx/map :body)
                   (rx/mapcat wapi/read-file-as-data-url)
                   (rx/map #(assoc entry :data %)))))

          ;; Prepare the shape object. Mainly needed for image shapes
          ;; for retrieve the image data and convert it to the
          ;; data-url.
          (prepare-object [objects parent-frame-id obj]
            (let [obj     (maybe-translate obj objects parent-frame-id)
                  ;; Texts can have different fills for pieces of the text
                  imgdata (concat
                           (->> (or (:position-data obj) [obj])
                                (mapcat :fills)
                                (keep :fill-image))
                           (->> (:strokes obj)
                                (keep :stroke-image))
                           (when (cfh/image-shape? obj)
                             [(:metadata obj)])
                           (when (:fill-image obj)
                             [(:fill-image obj)]))]

              (if (seq imgdata)
                (->> (rx/from imgdata)
                     (rx/mapcat fetch-image)
                     (rx/reduce conj [])
                     (rx/map (fn [images]
                               (assoc obj ::images images))))
                (rx/of obj))))

          (collect-variants [state shape]
            (let [page-id (:current-page-id state)
                  data    (dsh/lookup-file-data state)
                  objects (-> (dsh/get-page data page-id)
                              (get :objects))

                  components (cfv/find-variant-components data objects (:id shape))]
              (into {} (map (juxt :id :variant-properties) components))))


          ;; Collects all the items together and split images into a
          ;; separated data structure for a more easy paste process.
          ;; Also collects the variant properties of the copied variants


          (collect-data [state result {:keys [id ::images] :as item}]
            (cond-> result
              :always
              (update :objects assoc id (dissoc item ::images))

              (some? images)
              (update :images into images)

              (ctk/is-variant-container? item)
              (update :variant-properties merge (collect-variants state item))))

          (maybe-translate [shape objects parent-frame-id]
            (if (= parent-frame-id uuid/zero)
              shape
              (let [frame (get objects parent-frame-id)]
                (gsh/translate-to-frame shape frame))))

          ;; When copying an instance that is nested inside another one, we need to
          ;; advance the shape refs to one or more levels of remote mains.
          (advance-copies [state selected data]
            (let [file      (dsh/lookup-file state)
                  libraries (:files state)
                  ;; FIXME
                  page      (dsh/lookup-page state)
                  heads     (mapcat #(ctn/get-child-heads (:objects data) %) selected)]
              (update data :objects
                      #(reduce (partial advance-copy file libraries page)
                               %
                               heads))))

          (advance-copy [file libraries page objects shape]
            (if (and (ctk/instance-head? shape) (not (ctk/main-instance? shape)))
              (let [level-delta (if (nil? (ctk/get-swap-slot shape))
                                  (ctn/get-nesting-level-delta (:objects page) shape uuid/zero)
                                  0)]
                (if (pos? level-delta)
                  (reduce (partial advance-shape file libraries page level-delta)
                          objects
                          (cfh/get-children-with-self objects (:id shape)))
                  objects))
              objects))

          (advance-shape [file libraries page level-delta objects shape]
            (let [new-shape-ref (ctf/advance-shape-ref file page libraries shape level-delta {:include-deleted? true})
                  container     (ctn/make-container page :page)
                  new-touched   (ctf/get-touched-from-ref-chain-until-target-ref container libraries shape new-shape-ref)]
              (cond-> objects
                (and (some? new-shape-ref) (not= new-shape-ref (:shape-ref shape)))
                (-> (assoc-in [(:id shape) :shape-ref] new-shape-ref)
                    (assoc-in [(:id shape) :touched] new-touched)))))

          (on-copy-error [error]
            (js/console.error "clipboard blocked:" error)
            (rx/empty))]

    (ptk/reify ::copy-selected
      ptk/WatchEvent
      (watch [_ state _]
        (let [text (wapi/get-current-selected-text)]
          (if-not (str/empty? text)
            (try
              (clipboard/to-clipboard text)
              (catch :default e
                (on-copy-error e)))

            (let [objects  (dsh/lookup-page-objects state)
                  selected (->> (dsh/lookup-selected state)
                                (cfh/clean-loops objects))
                  features (-> (get state :features)
                               (set/difference cfeat/frontend-only-features))

                  file-id  (:current-file-id state)
                  frame-id (cfh/common-parent-frame objects selected)
                  file     (dsh/lookup-file state file-id)
                  version  (get file :version)

                  initial  {:type :copied-shapes
                            :features features
                            :version version
                            :file-id file-id
                            :selected selected
                            :objects {}
                            :images #{}}

                  shapes   (->> (cfh/selected-with-children objects selected)
                                (keep (d/getf objects)))]

              ;; The clipboard API doesn't handle well asynchronous calls because it expects to use
              ;; the clipboard in an user interaction. If you do an async call the callback is outside
              ;; the thread of the UI and so Safari blocks the copying event.
              ;; We use the API `ClipboardItem` that allows promises to be passed and so the event
              ;; will wait for the promise to resolve and everything should work as expected.
              ;; This only works in the current versions of the browsers.
              (if (some? (unchecked-get ug/global "ClipboardItem"))
                (let [resolve-data-promise
                      (p/create
                       (fn [resolve reject]
                         (->> (rx/from shapes)
                              (rx/merge-map (partial prepare-object objects frame-id))
                              (rx/reduce (partial collect-data state) initial)
                              (rx/map (partial sort-selected state))
                              (rx/map (partial advance-copies state selected))
                              (rx/map #(t/encode-str % {:type :json-verbose}))
                              (rx/map #(wapi/create-blob % "text/plain"))
                              (rx/subs! resolve reject))))]
                  (->> (rx/from (clipboard/to-clipboard-promise "text/plain" resolve-data-promise))
                       (rx/catch on-copy-error)
                       (rx/ignore)))

                ;; FIXME: this is to support Firefox versions below 116 that don't support
                ;; `ClipboardItem` after the version 116 is less common we could remove this.
                ;; https://caniuse.com/?search=ClipboardItem
                (->> (rx/from shapes)
                     (rx/merge-map (partial prepare-object objects frame-id))
                     (rx/reduce (partial collect-data state) initial)
                     (rx/map (partial sort-selected state))
                     (rx/map (partial advance-copies state selected))
                     (rx/map #(t/encode-str % {:type :json-verbose}))
                     (rx/map clipboard/to-clipboard)
                     (rx/catch on-copy-error)
                     (rx/ignore))))))))))

(declare ^:private paste-transit-shapes)
(declare ^:private paste-transit-props)
(declare ^:private paste-html-text)
(declare ^:private paste-text)
(declare ^:private paste-image)
(declare ^:private paste-svg-text)
(declare ^:private paste-shapes)

(def ^:private default-options
  #js {:decodeTransit t/decode-str
       :allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")})

(defn- create-paste-from-blob
  [in-viewport? replace?]
  (fn [blob]
    (let [type (.-type blob)]
      (cond
        (= type "image/svg+xml")
        (->> (rx/from (.text blob))
             (rx/map paste-svg-text))

        (some #(= type %) clipboard/image-types)
        (rx/of (paste-image blob))

        (= type "text/html")
        (->> (rx/from (.text blob))
             (rx/map paste-html-text))

        (= type "application/transit+json")
        (->> (rx/from (.text blob))
             (rx/map t/decode-str)
             (rx/filter map?)
             (rx/map
              (fn [pdata]
                (-> pdata
                    (assoc :in-viewport in-viewport?)
                    (assoc :replace replace?))))
             (rx/mapcat
              (fn [pdata]
                (case (:type pdata)
                  :copied-props  (rx/of (paste-transit-props pdata))
                  :copied-shapes (rx/of (paste-transit-shapes pdata))
                  (rx/empty)))))

        :else
        (->> (rx/from (.text blob))
             (rx/map paste-text))))))

(defn- clipboard-permission-error?
  "Check if the given error is a clipboard permission error
  (NotAllowedError DOMException)."
  [cause]
  (and (instance? js/DOMException cause)
       (= (.-name cause) "NotAllowedError")))

(defn- clipboard-unavailable-error?
  "Check if the given error is a clipboard API unavailable error
  (thrown when navigator.clipboard is undefined, e.g. on insecure
   origins per the W3C Secure Contexts spec)."
  [cause]
  (and (instance? js/Error cause)
       (str/starts-with? (.-message cause) "Clipboard API is unavailable.")))

(defn- on-clipboard-permission-error
  [cause]
  (cond
    (clipboard-permission-error? cause)
    (rx/of (ntf/show {:content (tr "errors.clipboard-permission-denied")
                      :type :toast
                      :level :warning
                      :timeout 5000}))

    (clipboard-unavailable-error? cause)
    (rx/of (ntf/show {:content (tr "errors.clipboard-api-unavailable")
                      :type :toast
                      :level :warning
                      :timeout 5000}))

    :else
    (rx/throw cause)))

(defn paste-from-clipboard
  "Perform a `paste` operation using the Clipboard API."
  ([] (paste-from-clipboard nil))
  ([{:keys [replace?]}]
   (ptk/reify ::paste-from-clipboard
     ptk/WatchEvent
     (watch [_ _ _]
       (->> (clipboard/from-navigator default-options)
            (rx/mapcat (create-paste-from-blob false (boolean replace?)))
            (rx/take 1)
            (rx/catch on-clipboard-permission-error))))))

(defn paste-from-event
  "Perform a `paste` operation from user emmited event."
  [event in-viewport?]
  (ptk/reify ::paste-from-event
    ptk/WatchEvent
    (watch [_ state _]
      (let [objects     (dsh/lookup-page-objects state)
            edit-id     (dm/get-in state [:workspace-local :edition])
            is-editing? (and edit-id (= :text (get-in objects [edit-id :type])))]

        ;; Some paste events can be fired while we're editing a text
        ;; we forbid that scenario so the default behaviour is executed
        (if is-editing?
          (rx/empty)
          (->> (clipboard/from-synthetic-clipboard-event event default-options)
               (rx/mapcat (create-paste-from-blob in-viewport? false))))))))

(defn copy-selected-svg
  []
  (ptk/reify ::copy-selected-svg
    ptk/EffectEvent
    (effect [_ state _]
      (let [objects         (dsh/lookup-page-objects state)
            selected        (->> (dsh/lookup-selected state)
                                 (ctst/sort-z-index objects)
                                 (mapv (d/getf objects)))
            parent-frame-id (cfh/common-parent-frame objects selected)

            maybe-translate
            #(if (= parent-frame-id uuid/zero) %
                 (gsh/translate-to-frame % (get objects parent-frame-id)))

            shapes          (mapv maybe-translate selected)
            svg-formatted   (svg/generate-formatted-markup objects shapes)]
        (-> (clipboard/to-clipboard-multi
             {"image/svg+xml" svg-formatted
              "text/plain"    svg-formatted})
            (p/catch (fn [cause]
                       (js/console.error "clipboard error:" cause))))))))

(defn copy-selected-css
  []
  (ptk/reify ::copy-selected-css
    ptk/EffectEvent
    (effect [_ state _]
      (let [objects (dsh/lookup-page-objects state)
            selected (->> (dsh/lookup-selected state) (mapv (d/getf objects)))
            css (css/generate-style objects selected selected {:with-prelude? false})]
        (clipboard/to-clipboard css)))))

(defn copy-selected-css-nested
  []
  (ptk/reify ::copy-selected-css-nested
    ptk/EffectEvent
    (effect [_ state _]
      (let [objects (dsh/lookup-page-objects state)
            selected (->> (dsh/lookup-selected state)
                          (cfh/selected-with-children objects)
                          (mapv (d/getf objects)))
            css (css/generate-style objects selected selected {:with-prelude? false})]
        (clipboard/to-clipboard css)))))

(defn copy-selected-text
  []
  (ptk/reify ::copy-selected-text
    ptk/EffectEvent
    (effect [_ state _]
      (let [selected (dsh/lookup-selected state)
            objects  (dsh/lookup-page-objects state)

            text-shapes
            (->> (cfh/selected-with-children objects selected)
                 (keep (d/getf objects))
                 (filter cfh/text-shape?))

            selected (into (d/ordered-set) (map :id) text-shapes)

            ;; Narrow the objects map so it contains only relevant data for
            ;; selected and its parents
            objects  (cfh/selected-subtree objects selected)
            selected (->> (ctst/sort-z-index objects selected)
                          (into (d/ordered-set)))

            text
            (->> selected
                 (map
                  (fn [id]
                    (let [shape (get objects id)]
                      (-> shape :content txt/content->text))))
                 (str/join "\n"))]

        (clipboard/to-clipboard text)))))

(defn copy-selected-props
  []
  (ptk/reify ::copy-selected-props
    ptk/WatchEvent
    (watch [_ state _]
      (letfn [(fetch-image [entry]
                (let [url (cf/resolve-file-media entry)]
                  (->> (http/send! {:method :get
                                    :uri url
                                    :response-type :blob})
                       (rx/map :body)
                       (rx/mapcat wapi/read-file-as-data-url)
                       (rx/map #(assoc entry :data %)))))

              (resolve-images [data]
                (let [images
                      (concat
                       (->> data :props :fills (keep :fill-image))
                       (->> data :props :strokes (keep :stroke-image)))]

                  (if (seq images)
                    (->> (rx/from images)
                         (rx/mapcat fetch-image)
                         (rx/reduce conj #{})
                         (rx/map #(assoc data :images %)))
                    (rx/of data))))

              (on-copy-error [error]
                (js/console.error "clipboard blocked:" error)
                (rx/empty))]

        (let [selected (dsh/lookup-selected state)]
          (if (> (count selected) 1)
            ;; If multiple items are selected don't do anything
            (rx/empty)

            (let [selected (->> (dsh/lookup-selected state) first)
                  objects  (dsh/lookup-page-objects state)]
              (when-let [shape (get objects selected)]
                (let [props (cts/extract-props shape)
                      features (-> (get state :features)
                                   (set/difference cfeat/frontend-only-features))
                      version  (-> (dsh/lookup-file state)
                                   (get :version))

                      copy-data {:type :copied-props
                                 :features features
                                 :version version
                                 :props props
                                 :images #{}}]

                  ;; The clipboard API doesn't handle well asynchronous calls because it expects to use
                  ;; the clipboard in an user interaction. If you do an async call the callback is outside
                  ;; the thread of the UI and so Safari blocks the copying event.
                  ;; We use the API `ClipboardItem` that allows promises to be passed and so the event
                  ;; will wait for the promise to resolve and everything should work as expected.
                  ;; This only works in the current versions of the browsers.
                  (if (some? (unchecked-get ug/global "ClipboardItem"))
                    (let [resolve-data-promise
                          (p/create
                           (fn [resolve reject]
                             (->> (rx/of copy-data)
                                  (rx/mapcat resolve-images)
                                  (rx/map #(t/encode-str % {:type :json-verbose}))
                                  (rx/map #(wapi/create-blob % "text/plain"))
                                  (rx/subs! resolve reject))))]

                      (->> (rx/from (clipboard/to-clipboard-promise "text/plain" resolve-data-promise))
                           (rx/catch on-copy-error)
                           (rx/ignore)))
                    ;; FIXME: this is to support Firefox versions below 116 that don't support
                    ;; `ClipboardItem` after the version 116 is less common we could remove this.
                    ;; https://caniuse.com/?search=ClipboardItem
                    (->> (rx/of copy-data)
                         (rx/mapcat resolve-images)
                         (rx/map #(clipboard/to-clipboard (t/encode-str % {:type :json-verbose})))
                         (rx/catch on-copy-error)
                         (rx/ignore))))))))))))

(defn paste-selected-props
  []
  (ptk/reify ::paste-selected-props
    ptk/WatchEvent
    (watch [_ state _]
      (when-not (-> state :workspace-global :read-only?)
        (letfn [(decode-entry [entry]
                  (-> entry t/decode-str paste-transit-props))

                (on-error [cause]
                  (cond
                    (clipboard-permission-error? cause)
                    (rx/of (ntf/show {:content (tr "errors.clipboard-permission-denied")
                                      :type :toast
                                      :level :warning
                                      :timeout 5000}))

                    (clipboard-unavailable-error? cause)
                    (rx/of (ntf/show {:content (tr "errors.clipboard-api-unavailable")
                                      :type :toast
                                      :level :warning
                                      :timeout 5000}))

                    (:not-implemented (ex-data cause))
                    (rx/of (ntf/warn (tr "errors.clipboard-not-implemented")))

                    :else
                    (do
                      (js/console.error "Clipboard error:" cause)
                      (rx/empty))))]

          (->> (clipboard/from-navigator default-options)
               (rx/mapcat #(.text %))
               (rx/map decode-entry)
               (rx/take 1)
               (rx/catch on-error)))))))

(defn- selected-frame? [state]
  (let [selected (dsh/lookup-selected state)
        objects  (dsh/lookup-page-objects state)]

    (and (= 1 (count selected))
         (= :frame (get-in objects [(first selected) :type])))))

(defn- get-tree-root-shapes [tree]
  ;; This fn gets a map of shapes and finds what shapes are parent of the rest
  (let [shapes-in-tree (vals tree)
        shape-ids (keys tree)
        parent-ids (set (map #(:parent-id %) shapes-in-tree))]
    (->> shape-ids
         (filter #(contains? parent-ids %)))))

(defn- any-same-frame-from-selected? [state frame-ids]
  (let [selected (first (dsh/lookup-selected state))]
    (< 0 (count (filter #(= % selected) frame-ids)))))

(defn- frame-same-size?
  [paste-obj frame-obj]
  (and
   (= (:height (:selrect (first (vals paste-obj))))
      (:height (:selrect frame-obj)))
   (= (:width (:selrect (first (vals paste-obj))))
      (:width (:selrect frame-obj)))))

(def ^:private
  schema:paste-data-shapes
  [:map {:title "paste-data-shapes"}
   [:type [:= :copied-shapes]]
   [:features ::sm/set-of-strings]
   [:version :int]
   [:file-id ::sm/uuid]
   [:selected ::sm/set-of-uuid]
   [:objects
    [:map-of ::sm/uuid :map]]
   [:images [:set :map]]
   [:position {:optional true} ::gpt/point]])

(def ^:private
  schema:paste-data-props
  [:map {:title "paste-data-props"}
   [:type [:= :copied-props]]
   [:features ::sm/set-of-strings]
   [:version :int]
   [:props
    ;; todo type the properties
    [:map-of :keyword :any]]])

(def schema:paste-data
  [:multi {:title "paste-data" :dispatch :type}
   [:copied-shapes schema:paste-data-shapes]
   [:copied-props schema:paste-data-props]])

(def paste-data-valid?
  (sm/lazy-validator schema:paste-data))

(defn- paste-transit-shapes
  [{:keys [images] :as pdata}]
  (letfn [(upload-media [file-id imgpart]
            (->> (http/send! {:uri (:data imgpart)
                              :response-type :blob
                              :method :get})
                 (rx/map :body)
                 (rx/map
                  (fn [blob]
                    {:name (:name imgpart)
                     :file-id file-id
                     :content blob
                     :is-local true}))
                 (rx/mapcat (partial rp/cmd! :upload-file-media-object))
                 (rx/map #(assoc % :prev-id (:id imgpart)))))]

    (ptk/reify ::paste-transit-shapes
      ptk/WatchEvent
      (watch [_ state _]
        (let [file-id  (:current-file-id state)
              features (get state :features)]

          (when-not (paste-data-valid? pdata)
            (ex/raise :type :validation
                      :code :invalid-paste-data
                      :hibt "invalid paste data found"))

          (cfeat/check-paste-features! features (:features pdata))

          (case (:type pdata)
            :copied-shapes
            (if (= file-id (:file-id pdata))
              (let [pdata (assoc pdata :images [])]
                (rx/of (paste-shapes pdata)))
              (->> (rx/from images)
                   (rx/merge-map (partial upload-media file-id))
                   (rx/reduce conj [])
                   (rx/map #(assoc pdata :images %))
                   (rx/map paste-shapes)))
            nil))))))

(defn- paste-transit-props
  [pdata]

  (letfn [(upload-media [file-id imgpart]
            (->> (http/send! {:uri (:data imgpart)
                              :response-type :blob
                              :method :get})
                 (rx/map :body)
                 (rx/map
                  (fn [blob]
                    {:name (:name imgpart)
                     :file-id file-id
                     :content blob
                     :is-local true}))
                 (rx/mapcat (partial rp/cmd! :upload-file-media-object))
                 (rx/map #(vector (:id imgpart) %))))

          (update-image-data
            [pdata media-map]
            (update
             pdata :props
             (fn [props]
               (-> props
                   (d/update-when
                    :fills
                    (fn [fills]
                      (mapv (fn [fill]
                              (cond-> fill
                                (some? (:fill-image fill))
                                (update-in [:fill-image :id] #(get media-map % %))))
                            fills)))
                   (d/update-when
                    :strokes
                    (fn [strokes]
                      (mapv (fn [stroke]
                              (cond-> stroke
                                (some? (:stroke-image stroke))
                                (update-in [:stroke-image :id] #(get media-map % %))))
                            strokes)))))))

          (upload-images
            [file-id pdata]
            (->> (rx/from (:images pdata))
                 (rx/merge-map (partial upload-media file-id))
                 (rx/reduce conj {})
                 (rx/map (partial update-image-data pdata))))]

    (ptk/reify ::paste-transit-props
      ptk/WatchEvent
      (watch [_ state _]
        (let [features (get state :features)
              objects  (dsh/lookup-page-objects state)
              selected (dsh/lookup-selected state)

              ;; With WASM, pasted props change the text content but not the
              ;; selrect, so auto-grow text shapes need an explicit relayout.
              text-ids (into []
                             (comp (filter #(cfh/text-shape? (get objects %)))
                                   (filter #(not= :fixed (:grow-type (get objects %)))))
                             selected)]

          (when (paste-data-valid? pdata)
            (cfeat/check-paste-features! features (:features pdata))
            (case (:type pdata)
              :copied-props
              ;; Wrap in a single undo transaction so the async wasm text
              ;; resize is bundled with the props change (one undo step).
              (let [undo-id       (js/Symbol)
                    resize-texts? (and (features/active-feature? state "render-wasm/v1")
                                       (seq text-ids))]
                (rx/concat
                 (rx/of (dwu/start-undo-transaction undo-id))
                 (->> (rx/of pdata)
                      (rx/mapcat (partial upload-images (:current-file-id state)))
                      (rx/map
                       #(dwsh/update-shapes
                         selected
                         (fn [shape objects] (cts/patch-props shape (:props pdata) objects))
                         {:with-objects? true})))
                 (rx/of (ptk/data-event :layout/update {:ids selected}))
                 (if resize-texts?
                   (rx/of (dwwt/resize-wasm-text-all text-ids {:undo-id undo-id}))
                   (rx/of (dwu/commit-undo-transaction undo-id)))))
              ;;
              (rx/empty))))))))

(defn paste-shapes
  [{in-viewport? :in-viewport :as pdata}]
  (letfn [(translate-media [mdata media-idx attr]
            (let [id   (-> (get mdata attr) :id)
                  mobj (get media-idx id)]
              (if mobj
                (update mdata attr assoc :id (:id mobj))
                mdata)))

          (add-obj? [chg]
            (= (:type chg) :add-obj))

          (process-rchange-shape [obj media-idx]
            (let [translate-fill-image   #(translate-media % media-idx :fill-image)
                  translate-stroke-image #(translate-media % media-idx :stroke-image)
                  translate-fills        #(mapv translate-fill-image %)
                  translate-strokes      #(mapv translate-stroke-image %)
                  process-text-node      #(d/update-when % :fills translate-fills)]

              (-> obj
                  (update :fills translate-fills)
                  (update :strokes translate-strokes)
                  (d/update-when :content #(txt/transform-nodes process-text-node %))
                  ;; Removes the position-data so it's regenerated
                  (dissoc :position-data))))

          ;; Analyze the rchange and replace staled media and
          ;; references to the new uploaded media-objects.
          (process-rchange [media-idx change]
            (if (add-obj? change)
              (update change :obj process-rchange-shape media-idx)
              change))

          (calculate-paste-position [state pobjects selected position replace-id]
            (let [page-objects         (dsh/lookup-page-objects state)
                  selected-objs        (map (d/getf pobjects) selected)
                  first-selected-obj   (first selected-objs)
                  page-selected        (dsh/lookup-selected state)
                  wrapper              (gsh/shapes->rect selected-objs)
                  orig-pos             (gpt/point (:x1 wrapper) (:y1 wrapper))
                  frame-id             (first page-selected)
                  frame-object         (get page-objects frame-id)
                  base                 (cfh/get-base-shape page-objects page-selected)
                  index                (cfh/get-position-on-parent page-objects (:id base))
                  tree-root            (get-tree-root-shapes pobjects)
                  only-one-root-shape? (and
                                        (< 1 (count pobjects))
                                        (= 1 (count tree-root)))
                  replaced             (some->> replace-id (get page-objects))]

              (cond
                ;; Paste in place: center pasted content on the replaced shape and
                ;; reparent to its container. The replaced shape is deleted below
                ;; so the new content takes its z-index slot.
                (some? replaced)
                (let [delta        (gpt/subtract (gsh/shape->center replaced)
                                                 (grc/rect->center wrapper))
                      parent-id    (:parent-id replaced)
                      target-index (cfh/get-position-on-parent page-objects replace-id)]
                  [parent-id delta target-index])

                ;; Paste next to selected frame, if selected is itself or of the same size as the copied
                (and (selected-frame? state)
                     (or (any-same-frame-from-selected? state (keys pobjects))
                         (and only-one-root-shape?
                              (frame-same-size? pobjects (first tree-root)))))
                (let [selected-frame-obj (get page-objects (first page-selected))
                      parent-id          (:parent-id base)
                      paste-x            (+ (:width selected-frame-obj) (:x selected-frame-obj) 50)
                      paste-y            (:y selected-frame-obj)
                      delta              (gpt/subtract (gpt/point paste-x paste-y) orig-pos)]

                  [parent-id delta index])

                ;; Paste inside selected frame otherwise
                (selected-frame? state)
                (let [selected-frame-obj (get page-objects (first page-selected))
                      origin-frame-id (:frame-id first-selected-obj)
                      origin-frame-object (get page-objects origin-frame-id)

                      margin-x (-> (- (:width origin-frame-object) (+ (:x wrapper) (:width wrapper)))
                                   (min (- (:width frame-object) (:width wrapper))))

                      margin-y  (-> (- (:height origin-frame-object) (+ (:y wrapper) (:height wrapper)))
                                    (min (- (:height frame-object) (:height wrapper))))

                      ;; Pasted objects mustn't exceed the selected frame x limit
                      paste-x (if (> (+ (:width wrapper) (:x1 wrapper)) (:width frame-object))
                                (+ (- (:x frame-object) (:x orig-pos)) (- (:width frame-object) (:width wrapper) margin-x))
                                (:x frame-object))

                      ;; Pasted objects mustn't exceed the selected frame y limit
                      paste-y (if (> (+ (:height wrapper) (:y1 wrapper)) (:height frame-object))
                                (+ (- (:y frame-object) (:y orig-pos)) (- (:height frame-object) (:height wrapper) margin-y))
                                (:y frame-object))

                      delta (if (= origin-frame-id uuid/zero)
                              ;; When the origin isn't in a frame the result is pasted in the center.
                              (gpt/subtract (gsh/shape->center frame-object) (grc/rect->center wrapper))
                              ;; When pasting from one frame to another frame the object
                              ;; position must be limited to container boundaries. If
                              ;; the pasted object doesn't fit we try to:
                              ;;
                              ;; - Align it to the limits on the x and y axis
                              ;; - Respect the distance of the object to the right
                              ;;   and bottom in the original frame
                              (gpt/point paste-x paste-y))

                      target-index
                      (if (and (ctl/flex-layout? selected-frame-obj) (ctl/reverse? selected-frame-obj))
                        (dec 0) ;; Before the first index 0
                        (count (:shapes selected-frame-obj)))]
                  [frame-id delta target-index])

                (empty? page-selected)
                (let [frame-id (ctst/top-nested-frame page-objects position)
                      delta    (gpt/subtract position orig-pos)]
                  [frame-id delta])

                :else
                (let [parent-id (:parent-id base)
                      delta     (if in-viewport?
                                  (gpt/subtract position orig-pos)
                                  (gpt/subtract (gpt/point (:selrect base)) orig-pos))]
                  [parent-id delta index]))))

          ;; Change the indexes of the pasted shapes
          (change-add-obj-index [objects selected index change]
            (let [;; if there is no current element selected, we want
                  ;; the first (inc index) to be 0
                  index (d/nilv index -1)
                  set-index (fn [[result index] id]
                              [(assoc result id index) (inc index)])

                  ;; FIXME: optimize ???
                  map-ids
                  (->> selected
                       (map #(get-in objects [% :id]))
                       (reduce set-index [{} (inc index)])
                       first)]

              (if (and (add-obj? change)
                       (contains? map-ids (:old-id change)))
                (assoc change :index (get map-ids (:old-id change)))
                change)))

          (process-shape [file-id frame-id parent-id shape]
            (cond-> shape
              :always
              (assoc :frame-id frame-id :parent-id parent-id)

              (and (or (cfh/group-shape? shape)
                       (cfh/bool-shape? shape))
                   (nil? (:shapes shape)))
              (assoc :shapes [])

              (cfh/text-shape? shape)
              (ctt/remove-external-typographies file-id)))]

    (ptk/reify ::paste-shapes
      ptk/WatchEvent
      (watch [it state _]
        (let [file-id      (:current-file-id state)
              page         (dsh/lookup-page state)

              media-idx    (->> (:images pdata)
                                (d/index-by :prev-id))

              selected     (:selected pdata)

              objects      (:objects pdata)

              variant-props (:variant-properties pdata)

              position     (deref ms/mouse-position)

              ;; Replace mode is only valid with a single selected shape.
              ;; In that case we drop the pasted content at its position and
              ;; delete it in the same transaction.
              page-selected (dsh/lookup-selected state)
              replace-id    (when (and (:replace pdata) (= 1 (count page-selected)))
                              (first page-selected))

              ;; Calculate position for the pasted elements
              [candidate-parent-id
               delta
               index]      (calculate-paste-position state objects selected position replace-id)

              page-objects (:objects page)

              libraries    (dsh/lookup-libraries state)
              ldata        (dsh/lookup-file-data state file-id)

              [parent-id
               frame-id]   (ctn/find-valid-parent-and-frame-ids candidate-parent-id page-objects (vals objects) true libraries)

              index        (if (= candidate-parent-id parent-id)
                             index
                             0)

              index        (if index
                             index
                             (dec (count (dm/get-in page-objects [parent-id :shapes]))))

              selected     (if (and (ctl/flex-layout? page-objects parent-id) (not (ctl/reverse? page-objects parent-id)))
                             (into (d/ordered-set) (reverse selected))
                             selected)

              objects      (update-vals objects (partial process-shape file-id frame-id parent-id))

              all-objects  (merge page-objects objects)

              drop-cell    (when (ctl/grid-layout? all-objects parent-id)
                             (gslg/get-drop-cell frame-id all-objects position))

              changes      (-> (pcb/empty-changes it)
                               (cll/generate-duplicate-changes all-objects page selected delta
                                                               libraries ldata file-id {:variant-props variant-props})
                               (pcb/amend-changes (partial process-rchange media-idx))
                               (pcb/amend-changes (partial change-add-obj-index objects selected index)))

              ;; Adds a resize-parents operation so the groups are
              ;; updated. We add all the new objects
              changes      (->> (:redo-changes changes)
                                (filter add-obj?)
                                (map :id)
                                (pcb/resize-parents changes))

              changes      (if (some? replace-id)
                             (second (cls/generate-delete-shapes changes #{replace-id} {}))
                             changes)

              orig-shapes  (map (d/getf all-objects) selected)

              children-after (-> (pcb/get-objects changes)
                                 (dm/get-in [parent-id :shapes])
                                 set)

              ;; At the end of the process, we want to select the new created shapes
              ;; that are a direct child of the shape parent-id
              selected     (into (d/ordered-set)
                                 (comp
                                  (filter add-obj?)
                                  (map (comp :id :obj))
                                  (filter #(contains? children-after %)))
                                 (:redo-changes changes))

              changes      (cond-> changes
                             (some? drop-cell)
                             (pcb/update-shapes [parent-id]
                                                #(ctl/add-children-to-cell % selected all-objects drop-cell)))

              add-component-to-variant? (and
                                         ;; Any of the shapes is a head
                                         (some ctk/instance-head? orig-shapes)
                                         ;; Any ancestor of the destination parent is a variant
                                         (->> (cfh/get-parents-with-self page-objects parent-id)
                                              (some ctk/is-variant?)))
              undo-id      (js/Symbol)]

          (rx/concat
           (->> (rx/from orig-shapes)
                (rx/map (fn [shape]
                          (let [parent-type   (cfh/get-shape-type all-objects (:parent-id shape))
                                external-lib? (not= file-id (:component-file shape))
                                component     (ctn/get-component-from-shape shape libraries)
                                origin        "workspace:paste"]

                            ;; NOTE: we don't emit the create-shape event all the time for
                            ;; avoid send a lot of events (that are not necessary); this
                            ;; decision is made explicitly by the responsible team.
                            (if (ctk/instance-head? shape)
                              (ev/event {::ev/name "use-library-component"
                                         ::ev/origin origin
                                         :is-external-library external-lib?
                                         :type (get shape :type)
                                         :parent-type parent-type
                                         :is-variant (ctk/is-variant? component)})
                              (if (cfh/has-layout? objects (:parent-id shape))
                                (ev/event {::ev/name "layout-add-element"
                                           ::ev/origin origin
                                           :type (get shape :type)
                                           :parent-type parent-type})
                                (ev/event {::ev/name "create-shape"
                                           ::ev/origin origin
                                           :type (get shape :type)
                                           :parent-type parent-type})))))))

           (rx/of (dwu/start-undo-transaction undo-id)
                  (dch/commit-changes changes)
                  (dws/select-shapes selected)
                  (ptk/data-event :layout/update {:ids [frame-id]})
                  (dwu/commit-undo-transaction undo-id)
                  (when add-component-to-variant?
                    (ev/event {::ev/name "add-component-to-variant"})))))))))

(defn- as-content [text]
  (let [paragraphs (->> (str/lines text)
                        (map str/trim)
                        (mapv #(hash-map :type "paragraph"
                                         :children [(merge (txt/get-default-text-attrs) {:text %})])))]
    ;; if text is composed only by line breaks paragraphs is an empty list and should be nil
    (when (d/not-empty? paragraphs)
      {:type "root"
       :children [{:type "paragraph-set" :children paragraphs}]})))

(defn- calculate-paste-position [state]
  (cond
    ;; Pasting inside a frame
    (selected-frame? state)
    (let [page-selected (dsh/lookup-selected state)
          page-objects  (dsh/lookup-page-objects state)
          frame-id (first page-selected)
          frame-object (get page-objects frame-id)]
      (gsh/shape->center frame-object))

    :else
    (deref ms/mouse-position)))

(defn- paste-html-text
  [html]
  (assert (string? html))
  (ptk/reify ::paste-html-text
    ptk/WatchEvent
    (watch [_ state  _]
      (let [style   (deref refs/workspace-clipboard-style)
            root    (dwtxt/create-root-from-html html style (features/active-feature? @st/state "text-editor/v2-html-paste"))
            text    (.-textContent root)
            content (tc/dom->cljs root)]
        (when (types.text/valid-content? content)
          (let [id            (uuid/next)
                width         (max 8 (min (* 7 (count text)) 700))
                height        16
                {:keys [x y]} (calculate-paste-position state)
                skip-edition? (features/active-feature? state "text-editor-wasm/v1")

                shape {:id id
                       :type :text
                       :name (txt/generate-shape-name text)
                       :x x
                       :y y
                       :width width
                       :height height
                       :grow-type (if (> (count text) 100) :auto-height :auto-width)
                       :content content}
                undo-id (js/Symbol)]
            (rx/concat
             (rx/of (dwu/start-undo-transaction undo-id)
                    (dwsh/create-and-add-shape :text x y shape
                                               (when skip-edition? {:skip-edition? true})))
             (if skip-edition?
               (rx/of (dwwt/resize-wasm-text-debounce id {:undo-group id
                                                          :undo-id undo-id}))
               (rx/of (dwu/commit-undo-transaction undo-id))))))))))

(defn- paste-text
  [text]
  (dm/assert! (string? text))
  (ptk/reify ::paste-text
    ptk/WatchEvent
    (watch [_ state _]
      (let [id            (uuid/next)
            width         (max 8 (min (* 7 (count text)) 700))
            height        16
            {:keys [x y]} (calculate-paste-position state)
            skip-edition? (features/active-feature? state "text-editor-wasm/v1")

            shape {:id id
                   :type :text
                   :name (txt/generate-shape-name text)
                   :x x
                   :y y
                   :width width
                   :height height
                   :grow-type (if (> (count text) 100) :auto-height :auto-width)
                   :content (as-content text)}
            undo-id (js/Symbol)]

        (rx/concat
         (rx/of (dwu/start-undo-transaction undo-id)
                (dwsh/create-and-add-shape :text x y shape
                                           (when skip-edition? {:skip-edition? true})))
         (if skip-edition?
           (rx/of (dwwt/resize-wasm-text-debounce id {:undo-group id
                                                      :undo-id undo-id}))
           (rx/of (dwu/commit-undo-transaction undo-id))))))))

;; TODO: why not implement it in terms of upload-media-workspace?
(defn- paste-svg-text
  [text]
  (dm/assert! (string? text))
  (ptk/reify ::paste-svg-text
    ptk/WatchEvent
    (watch [_ state _]
      (let [position (calculate-paste-position state)
            file-id  (:current-file-id state)]
        (->> (dwm/svg->clj ["svg" text])
             (rx/map #(dwm/svg-uploaded % file-id position)))))))

(defn- paste-image
  [image]
  (ptk/reify ::paste-image
    ptk/WatchEvent
    (watch [_ state _]
      (let [file-id  (:current-file-id state)
            position (calculate-paste-position state)
            params   {:file-id file-id
                      :blobs [image]
                      :position position}]
        (rx/of (dwm/upload-media-workspace params))))))

(defn copy-link-to-clipboard
  []
  (ptk/reify ::copy-link-to-clipboard
    ptk/WatchEvent
    (watch [_ _ _]
      (clipboard/to-clipboard (rt/get-current-href)))))

(defn copy-id-to-clipboard
  [id]
  (ptk/reify ::copy-id-to-clipboard
    ptk/WatchEvent
    (watch [_ _ _]
      (->> (rx/from (clipboard/to-clipboard id))
           (rx/map (fn [_]
                     (ntf/info "The id has been copied to the clipboard")))))))

(defn copy-as-image
  []
  (ptk/reify ::copy-as-image
    ptk/WatchEvent
    (watch [_ state _]
      (let [file-id  (:current-file-id state)
            page-id  (:current-page-id state)
            selected (first (dsh/lookup-selected state))

            export (de/normalize-export {:file-id file-id
                                         :page-id page-id
                                         :object-id selected
                                         ;; webp would be preferrable, but PNG is the most supported image MIME type by clipboard APIs.
                                         :type :png
                                         ;; Always use 2 to ensure good enough quality for wireframes.
                                         :scale 2
                                         :suffix ""
                                         :enabled true
                                         :name ""})

            ;; Create a deferred promise immediately, before any async operations.
            ;; Registering the clipboard write NOW preserves the user-gesture security
            ;; context; the actual blob is supplied asynchronously once the export finishes.
            deferred      (p/deferred)
            write-promise (clipboard/to-clipboard-promise "image/png" deferred)]

        (rx/concat
         ;; Ensure current state persisted before exporting.
         (dps/force-persist-and-wait 400)

         ;; Exporting itself can take its time, better to notify that we are busy.
         (rx/of (ntf/info (tr "workspace.clipboard.copying")))

         ;; Call exporter to get image URI, then fetch blob and resolve the deferred.
         (->> (if (and (features/active-feature? state "render-wasm/v1")
                       (features/active-feature? state "wasm-export/v1"))
                (rx/of {:uri (wasm.exports/export-image-uri export)})
                (rp/cmd! :export
                         {:exports [export]
                          :profile-id (:profile-id state)
                          :cmd :export-shapes
                          :wait true}))

              (rx/mapcat (fn [{:keys [uri]}]
                           (http/send! {:method :get
                                        :uri uri
                                        :response-type :blob})))
              (rx/map :body)
              (rx/mapcat (fn [blob]
                           ;; Resolve the deferred with the fetched blob; the browser
                           ;; will now complete the clipboard write it started earlier.
                           (p/resolve deferred blob)
                           (rx/from write-promise)))
              (rx/map (fn [_]
                        (ntf/success (tr "workspace.clipboard.image-copied"))))
              (rx/catch (fn [e]
                          (js/console.error "clipboard error:" e)
                          ;; Reject the deferred in case the error occurred before the
                          ;; blob was fetched, so the pending clipboard write is cancelled.
                          (p/reject deferred e)
                          (rx/of (ntf/error (tr "workspace.clipboard.image-copy-failed")))))))))))
