;; 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.ui.workspace.shapes.text.v2-editor
  (:require-macros [app.main.style :as stl])
  (:require
   [app.common.data :as d]
   [app.common.data.macros :as dm]
   [app.common.files.helpers :as cfh]
   [app.common.geom.rect :as grc]
   [app.common.geom.shapes :as gsh]
   [app.common.geom.shapes.text :as gst]
   [app.common.math :as mth]
   [app.common.types.color :as color]
   [app.common.types.text :as txt]
   [app.config :as cf]
   [app.main.data.helpers :as dsh]
   [app.main.data.workspace :as dw]
   [app.main.data.workspace.texts :as dwt]
   [app.main.features :as features]
   [app.main.fonts :as fonts]
   [app.main.refs :as refs]
   [app.main.store :as st]
   [app.main.ui.css-cursors :as cur]
   [app.main.ui.hooks :as h]
   [app.render-wasm.api :as wasm.api]
   [app.util.dom :as dom]
   [app.util.globals :as global]
   [app.util.keyboard :as kbd]
   [app.util.object :as obj]
   [app.util.text.content :as content]
   [app.util.text.content.styles :as styles]
   [app.util.timers :as ts]
   [cuerdas.core :as str]
   [rumext.v2 :as mf]))

(defn get-contrast-color [background-color]
  (when background-color
    (let [luminance (color/hex->lum background-color)]
      (if (> luminance 0.5) "#000000" "#ffffff"))))

(defn- gen-name
  [editor]
  (when (some? editor)
    (let [editor-root (.-root editor)
          result (.-textContent editor-root)]
      (when (not= result "") result))))

(defn coalesce-per-tick
  "Return a function that runs `f` at most once per JS tick.
  Rationale: `needslayout` can fire on every input (including key repeat). We want
  to break nested store update loops."
  [f]
  (let [scheduled?* (atom false)]
    (fn []
      (when-not @scheduled?*
        (reset! scheduled?* true)
        (ts/asap
         (fn []
           (reset! scheduled?* false)
           (f)))))))

(defn- get-fonts
  [content]
  (let [extract-fn (juxt :font-id :font-variant-id)
        default    (extract-fn txt/default-typography)]
    (->> (tree-seq map? :children content)
         (into #{default} (keep extract-fn)))))

(defn- load-fonts!
  [fonts]
  (->> fonts
       (run! (fn [[font-id variant-id]]
               (when (some? font-id)
                 (fonts/ensure-loaded! font-id variant-id))))))

(defn- initialize-event-handlers
  "Internal editor events handler initializer/destructor"
  [shape-id content editor-ref canvas-ref container-ref text-color]
  (let [editor-node
        (mf/ref-val editor-ref)

        canvas-node
        (mf/ref-val canvas-ref)

        ;; Gets the default font from the workspace refs. Ignore it when the
        ;; remembered font is no longer installed, falling back to the built-in
        ;; default so a new text shape never inherits a missing font.
        default-font
        (fonts/valid-default-font (deref refs/default-font))

        style-defaults
        (styles/get-style-defaults
         (merge
          (txt/get-default-text-attrs)
          {:fills [{:fill-color text-color :fill-opacity 1}]}
          txt/default-root-attrs
          default-font))

        options
        #js {:styleDefaults style-defaults
             :allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")}

        instance
        (dwt/create-editor editor-node canvas-node options)

        ;; Store original content to compare name later
        original-content content

        on-key-up
        (fn [event]
          (dom/stop-propagation event)
          (when (kbd/esc? event)
            (st/emit! :interrupt (dw/clear-edition-mode))))

        on-blur
        (fn []
          (when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
            (let [state @st/state
                  objects (dsh/lookup-page-objects state)
                  shape (get objects shape-id)
                  current-name (:name shape)
                  generated-name (gen-name instance)
                  ;; Update name if: (1) it's a new shape (nil original content), or
                  ;; (2) the current name matches the generated name from original content
                  ;; (meaning it was never manually renamed)
                  update-name? (or (nil? original-content)
                                   (and (some? current-name)
                                        (some? original-content)
                                        (= current-name (txt/generate-shape-name (txt/content->text original-content)))))]
              (st/emit! (dwt/v2-update-text-shape-content shape-id content
                                                          :update-name? update-name?
                                                          :name generated-name
                                                          :finalize? true
                                                          ;; Single undo entry for the whole edit
                                                          :save-undo? true
                                                          :original-content original-content))))

          (let [container-node (mf/ref-val container-ref)]
            (dom/set-style! container-node "opacity" 0)))

        on-focus
        (fn []
          (let [container-node (mf/ref-val container-ref)]
            (dom/set-style! container-node "opacity" 1)))

        on-style-change
        (fn [event]
          (let [styles     (styles/get-styles-from-event event)
                fills      (:fills styles)
                fill-color (when (sequential? fills) (some :fill-color fills))]
            ;; Dynamically update the caret color as the cursor moves between spans
            (when-let [container-node (mf/ref-val container-ref)]
              (dom/set-style! container-node "--text-editor-caret-color"
                              (or fill-color text-color)))
            (st/emit! (dwt/v2-update-text-editor-styles shape-id styles))))

        on-needs-layout
        (coalesce-per-tick
         (fn []
           (when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
             ;; For WASM renderer, use the dedicated layout sync to avoid touching shape content
             ;; during `needslayout` bursts. For non-wasm, keep existing behavior.
             (if (features/active-feature? @st/state "render-wasm/v1")
               (st/emit! (dwt/v2-sync-wasm-text-layout shape-id content))
               (st/emit! (dwt/v2-update-text-shape-content shape-id content
                                                           :update-name? true
                                                           :save-undo? false))))))

        on-change
        (fn []
          (let [is-empty? (dwt/is-empty? instance)
                save-undo? (not is-empty?)]
            (when-let [content (content/dom->cljs (dwt/get-editor-root instance))]
              (st/emit! (dwt/v2-update-text-shape-content shape-id content
                                                          :update-name? true
                                                          :save-undo? save-undo?)))))

        on-clipboard-change
        (fn [event]
          (let [style (.-detail event)]
            (st/emit! (dw/set-clipboard-style style))))]

    (.addEventListener ^js global/document "keyup" on-key-up)
    (.addEventListener ^js instance "focus" on-focus)
    (.addEventListener ^js instance "needslayout" on-needs-layout)
    (.addEventListener ^js instance "stylechange" on-style-change)
    (.addEventListener ^js instance "change" on-change)
    (.addEventListener ^js instance "clipboardchange" on-clipboard-change)

    (st/emit! (dwt/update-editor instance))
    (when (some? content)
      (dwt/set-editor-root! instance (content/cljs->dom content)))
    (when (some? instance)
      (st/emit! (dwt/focus-editor)))

    ;; This function is called when the component is unmounted
    (fn []
      ;; Explicitly call on-blur here instead of relying on browser blur events,
      ;; because in Firefox blur is not reliably fired when leaving the text editor
      ;; by clicking elsewhere. The component does unmount when the shape is
      ;; deselected, so we can safely call the blur handler here to finalize the editor.
      (on-blur)
      (.removeEventListener ^js global/document "keyup" on-key-up)
      (.removeEventListener ^js instance "focus" on-focus)
      (.removeEventListener ^js instance "needslayout" on-needs-layout)
      (.removeEventListener ^js instance "stylechange" on-style-change)
      (.removeEventListener ^js instance "change" on-change)
      (.removeEventListener ^js instance "clipboardchange" on-clipboard-change)
      (dwt/dispose! instance)
      (st/emit! (dwt/update-editor nil)))))

(defn vertical-align-editor-classes
  "Returns `[align-top? align-center? align-bottom?]` for the text editor root
  flex layout. When `render-wasm?` is true, the `foreignObject` is already
  positioned using the same vertical offset as Skia (`content_rect`); applying
  `justify-content` center/end here would double the offset and misalign the DOM
  editor with the rendered text, caret, and selection."
  [content render-wasm?]
  (if render-wasm?
    [true false false]
    [(= (:vertical-align content "top") "top")
     (= (:vertical-align content) "center")
     (= (:vertical-align content) "bottom")]))

(defn get-color-from-content [content]
  (let [nodes     (tree-seq map? :children content)
        get-color (fn [node]
                    ;; Handle both new format (:fills vector) and old/deprecated format
                    ;; (direct :fill-color on the content node — pre-fills-refactor files)
                    (or (some :fill-color (:fills node))
                        (:fill-color node)))]
    ;; Prefer inline (leaf) text nodes over paragraph nodes. The paragraph's :fills
    ;; tracks the last-typed color, so using it directly would make the caret take
    ;; the last span's color rather than the first visible span's color.
    ;; Inline nodes have no :type; they are identified by the presence of :text.
    (or (->> nodes (filter #(contains? % :text)) (some get-color))
        (->> nodes (some get-color)))))

(defn get-default-text-color
  "Returns the appropriate text color based on fill, frame, and background."
  [{:keys [frame background-color]}]
  (if (and frame (not (cfh/root? frame)) (seq (:fills frame)))
    (let [fill-color (some #(when (:fill-color %) (:fill-color %)) (:fills frame))]
      (if fill-color
        (get-contrast-color fill-color)
        (get-contrast-color background-color)))
    (get-contrast-color background-color)))

(mf/defc text-editor-html*
  "Text editor (HTML)"
  {::mf/wrap [mf/memo]}
  [{:keys [shape canvas-ref render-wasm?] :or {render-wasm? false}}]
  (let [content          (:content shape)
        shape-id         (dm/get-prop shape :id)
        fill-color       (get-color-from-content content)

        ;; This is a reference to the dom element that
        ;; should contain the TextEditor.
        editor-ref       (mf/use-ref nil)
        ;; This reference is to the container
        container-ref    (mf/use-ref nil)

        page             (mf/deref refs/workspace-page)
        objects          (get page :objects)
        frame            (cfh/get-frame objects shape-id)
        background-color (:background page)

        text-color       (or fill-color (get-default-text-color {:frame frame
                                                                 :background-color background-color}) color/black)

        [align-top? align-center? align-bottom?]
        (vertical-align-editor-classes content render-wasm?)

        fonts
        (-> (mf/use-memo (mf/deps content) #(get-fonts content))
            (h/use-equal-memo))]

    (mf/with-effect [fonts]
      (load-fonts! fonts))

    ;; WARN: we explicitly do not pass content on effect dependency
    ;; array because we only need to initialize this once with initial
    ;; content
    (mf/with-effect [shape-id]
      (initialize-event-handlers shape-id
                                 content
                                 editor-ref
                                 canvas-ref
                                 container-ref
                                 text-color))

    (mf/with-effect [text-color]
      (let [container-node (mf/ref-val container-ref)]
        (dom/set-style! container-node "--text-editor-caret-color" text-color)))

    [:div
     {:class (dm/str (cur/get-dynamic "text" (:rotation shape))
                     " "
                     (stl/css :text-editor-container))
      :ref container-ref
      :data-testid "text-editor-container"
      :style {:width "var(--editor-container-width)"
              :height "var(--editor-container-height)"
              :min-width "var(--editor-container-min-width, 1px)"
              :min-height "var(--editor-container-min-height, 1px)"}}
     ;; We hide the editor when is blurred because otherwise the
     ;; selection won't let us see the underlying text. Use opacity
     ;; because display or visibility won't allow to recover focus
     ;; afterwards.

     ;; IMPORTANT! This is now done through DOM mutations (see
     ;; on-blur and on-focus) but I keep this for future references.
     ;; :opacity (when @blurred 0)}}

     [:div
      {:class (dm/str
               "mousetrap "
               (stl/css-case
                :text-editor-content true
                :grow-type-fixed (= (:grow-type shape) :fixed)
                :grow-type-auto-width (= (:grow-type shape) :auto-width)
                :grow-type-auto-height (= (:grow-type shape) :auto-height)
                :align-top    align-top?
                :align-center align-center?
                :align-bottom align-bottom?))
       :ref editor-ref
       :data-testid "text-editor-content"
       :data-x (dm/get-prop shape :x)
       :data-y (dm/get-prop shape :y)
       :content-editable true
       :role "textbox"
       :aria-multiline true
       :aria-autocomplete "none"}]]))

(defn- shape->justify
  [{:keys [content]}]
  (case (d/nilv (:vertical-align content) "top")
    "center" "center"
    "top"    "flex-start"
    "bottom" "flex-end"
    nil))

(defn- font-family-from-font-id [font-id]
  (if (str/includes? font-id "gfont-noto-sans")
    (let [lang (str/replace font-id #"gfont\-noto\-sans\-" "")]
      (if (>= (count lang) 3) (str/capital lang) (str/upper lang)))
    "Noto Color Emoji"))

;; Text Editor Wrapper
;; This is an SVG element that wraps the HTML editor.
;;
(mf/defc text-editor
  "Text editor wrapper component"
  {::mf/wrap [mf/memo]
   ::mf/props :obj
   ::mf/forward-ref true}
  [{:keys [shape modifiers canvas-ref] :as props} _]
  (let [shape-id  (dm/get-prop shape :id)
        modifiers (dm/get-in modifiers [shape-id :modifiers])

        fallback-fonts (wasm.api/fonts-from-text-content (:content shape) false)
        fallback-families (map (fn [font]
                                 (font-family-from-font-id (:font-id font))) fallback-fonts)

        clip-id   (dm/str "text-edition-clip" shape-id)

        text-modifier-ref
        (mf/use-memo (mf/deps (:id shape)) #(refs/workspace-text-modifier-by-id (:id shape)))

        text-modifier
        (mf/deref text-modifier-ref)

        ;; For Safari It's necesary to scale the editor with the zoom
        ;; level to fix a problem with foreignObjects not scaling
        ;; correctly with the viewbox
        ;;
        ;; NOTE: this teoretically breaks hooks rules, but in practice
        ;; it is imposible to really break it
        maybe-zoom
        (when (cf/check-browser? :safari)
          (mf/deref refs/selected-zoom))

        vbox
        (mf/deref refs/vbox)

        shape (cond-> shape
                (some? text-modifier)
                (dwt/apply-text-modifier text-modifier)

                (some? modifiers)
                (gsh/transform-shape modifiers))

        render-wasm? (mf/use-memo #(features/active-feature? @st/state "render-wasm/v1"))

        [{:keys [x y width height selrect-width selrect-height]} transform]
        (if render-wasm?
          (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id)
                selrect-transform (mf/deref refs/workspace-selrect)
                [selrect transform] (dsh/get-selrect selrect-transform shape)
                selrect-height (:height selrect)
                selrect-width (:width selrect)
                max-width (max width selrect-width)
                max-height (max height selrect-height)
                ;; During auto-width editing we keep the shape width trimmed, but the caret
                ;; must be able to move after trailing spaces. Expand only the editor
                ;; overlay up to one viewport width to avoid clipping caret rendering.
                viewport-width (or (:width vbox) 0)
                overlay-width (if (= (:grow-type shape) :auto-width)
                                (+ max-width viewport-width)
                                max-width)
                valign (-> shape :content :vertical-align)
                y (:y selrect)
                y (case valign
                    "bottom" (+ y (- selrect-height height))
                    "center" (+ y (/ (- selrect-height height) 2))
                    y)]
            [(assoc selrect :y y :width overlay-width :height max-height
                    :selrect-width selrect-width :selrect-height selrect-height) transform])

          (let [bounds (gst/shape->rect shape)
                x      (mth/min (dm/get-prop bounds :x)
                                (dm/get-prop shape :x))
                y      (mth/min (dm/get-prop bounds :y)
                                (dm/get-prop shape :y))
                width  (mth/max (dm/get-prop bounds :width)
                                (dm/get-prop shape :width))
                height (mth/max (dm/get-prop bounds :height)
                                (dm/get-prop shape :height))]
            [(grc/make-rect x y width height) (gsh/transform-matrix shape)]))

        style
        (cond-> #js {:pointerEvents "all"}
          render-wasm?
          (obj/merge!
           #js {"--editor-container-width" "auto"
                "--editor-container-height" "auto"
                "--editor-container-min-width" (dm/str (max 1 selrect-width) "px")
                "--editor-container-min-height" (dm/str (max 1 selrect-height) "px")
                "--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro")
                :display "flex"})

          (not render-wasm?)
          (obj/merge!
           #js {"--editor-container-width" (dm/str (max 1 width) "px")
                "--editor-container-height" (dm/str (max 1 height) "px")})

          ;; Transform is necessary when there is a text overflow and the vertical
          ;; aligment is center or bottom.
          (and (not render-wasm?)
               (not (cf/check-browser? :safari-16)))
          (obj/merge!
           #js {:transform (dm/fmt "translate(%px, %px)" (- (dm/get-prop shape :x) x) (- (dm/get-prop shape :y) y))})

          (and (cf/check-browser? :safari) (not (cf/check-browser? :safari-16)))
          (obj/merge!
           #js {:height "100%"
                :display "flex"
                :flexDirection "column"
                :justifyContent (shape->justify shape)})

          (or (cf/check-browser? :safari-26) (cf/check-browser? :safari-18))
          (obj/merge!
           #js {:position "fixed"
                :transform-origin "top left"
                :transform (dm/fmt "scale(%)" maybe-zoom)})

          (cf/check-browser? :safari-16)
          (obj/merge!
           #js {:position "fixed"
                :left 0
                :top  (- (dm/get-prop shape :y) y)
                :transform-origin "top left"
                :transform (when (some? maybe-zoom)
                             (dm/fmt "scale(%)" maybe-zoom))}))]

    [:g.text-editor {:clip-path (dm/fmt "url(#%)" clip-id)
                     :transform (dm/str transform)
                     :data-testid "text-editor"}
     [:defs
      [:clipPath {:id clip-id}
       [:rect {:x x :y y :width width :height height}]]]

     [:foreignObject {:x x :y y :width width :height height}
      [:div {:style style}
       [:> text-editor-html* {:shape shape
                              :canvas-ref canvas-ref
                              :render-wasm? render-wasm?
                              :key (dm/str shape-id)}]]]]))
