;; 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 frontend-tests.data.workspace-texts-test
  (:require
   [app.common.geom.rect :as grc]
   [app.common.test-helpers.files :as cthf]
   [app.common.test-helpers.shapes :as cths]
   [app.common.types.modifiers :as ctm]
   [app.common.types.shape :as cts]
   [app.common.types.text :as txt]
   [app.common.uuid :as uuid]
   [app.main.data.workspace.texts :as dwt]
   [app.main.ui.workspace.shapes.text.viewport-texts-html :as vth]
   [cljs.test :as t :include-macros true]
   [frontend-tests.helpers.state :as ths]))

;; ---------------------------------------------------------------------------
;; Helpers
;; ---------------------------------------------------------------------------

(defn- make-text-shape
  "Build a fully initialised text shape at the given position."
  [& {:keys [x y width height position-data]
      :or   {x 10 y 20 width 100 height 50}}]
  (cond-> (cts/setup-shape {:type   :text
                            :x      x
                            :y      y
                            :width  width
                            :height height})
    (some? position-data)
    (assoc :position-data position-data)))

(defn- make-degenerate-text-shape
  "Simulate a text shape decoded from the server via map->Rect (which bypasses
  make-rect's 0.01 minimum enforcement), giving it a zero-width / zero-height
  selrect.  This is the exact condition that triggered the original crash:
  change-dimensions-modifiers divided by sr-width (== 0), producing an Infinity
  scale factor that propagated through the transform pipeline until
  calculate-selrect / center->rect returned nil, and then gpt/point threw
  'invalid arguments (on pointer constructor)'."
  [& {:keys [x y width height]
      :or   {x 10 y 20 width 0 height 0}}]
  (-> (make-text-shape :x x :y y :width 100 :height 50)
      ;; Bypass make-rect by constructing the Rect record directly, the same
      ;; way decode-rect does during JSON deserialization from the backend.
      (assoc :selrect (grc/map->Rect {:x x :y y
                                      :width  width :height  height
                                      :x1 x :y1 y
                                      :x2 (+ x width) :y2 (+ y height)}))))

(defn- sample-position-data
  "Return a minimal position-data vector with the supplied coords."
  [x y]
  [{:x x :y y :width 80 :height 16 :fills [] :text "hello"}])

;; ---------------------------------------------------------------------------
;; Tests: nil / no-op guard
;; ---------------------------------------------------------------------------

(t/deftest apply-text-modifier-nil-modifier-returns-shape-unchanged
  (t/testing "nil text-modifier returns the original shape untouched"
    (let [shape  (make-text-shape)
          result (dwt/apply-text-modifier shape nil)]
      (t/is (= shape result)))))

(t/deftest apply-text-modifier-empty-map-no-keys-returns-shape-unchanged
  (t/testing "modifier with no recognised keys leaves shape unchanged"
    (let [shape    (make-text-shape)
          modifier {}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (= (:selrect shape) (:selrect result)))
      (t/is (= (:width result) (:width shape)))
      (t/is (= (:height result) (:height shape))))))

;; ---------------------------------------------------------------------------
;; Tests: width modifier
;; ---------------------------------------------------------------------------

(t/deftest apply-text-modifier-width-changes-shape-width
  (t/testing "width modifier resizes the shape width"
    (let [shape    (make-text-shape :x 0 :y 0 :width 100 :height 50)
          modifier {:width 200}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (= 200.0 (-> result :selrect :width))))))

(t/deftest apply-text-modifier-width-nil-skips-width-change
  (t/testing "nil :width in modifier does not alter the width"
    (let [shape    (make-text-shape :x 0 :y 0 :width 100 :height 50)
          modifier {:width nil}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (= (-> shape :selrect :width) (-> result :selrect :width))))))

;; ---------------------------------------------------------------------------
;; Tests: height modifier
;; ---------------------------------------------------------------------------

(t/deftest apply-text-modifier-height-changes-shape-height
  (t/testing "height modifier resizes the shape height"
    (let [shape    (make-text-shape :x 0 :y 0 :width 100 :height 50)
          modifier {:height 120}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (= 120.0 (-> result :selrect :height))))))

(t/deftest apply-text-modifier-height-nil-skips-height-change
  (t/testing "nil :height in modifier does not alter the height"
    (let [shape    (make-text-shape :x 0 :y 0 :width 100 :height 50)
          modifier {:height nil}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (= (-> shape :selrect :height) (-> result :selrect :height))))))

;; ---------------------------------------------------------------------------
;; Tests: width + height together
;; ---------------------------------------------------------------------------

(t/deftest apply-text-modifier-width-and-height-both-applied
  (t/testing "both width and height are applied simultaneously"
    (let [shape    (make-text-shape :x 0 :y 0 :width 100 :height 50)
          modifier {:width 300 :height 80}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (= 300.0 (-> result :selrect :width)))
      (t/is (= 80.0  (-> result :selrect :height))))))

;; ---------------------------------------------------------------------------
;; Tests: position-data modifier
;; ---------------------------------------------------------------------------

(t/deftest apply-text-modifier-position-data-is-set-on-shape
  (t/testing "position-data modifier replaces the position-data on shape"
    (let [pd       (sample-position-data 5 10)
          shape    (make-text-shape :x 0 :y 0 :width 100 :height 50)
          modifier {:position-data pd}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (some? (:position-data result))))))

(t/deftest apply-text-modifier-position-data-nil-leaves-position-data-unchanged
  (t/testing "nil :position-data in modifier does not alter position-data"
    (let [pd       (sample-position-data 5 10)
          shape    (-> (make-text-shape :x 0 :y 0 :width 100 :height 50)
                       (assoc :position-data pd))
          modifier {:position-data nil}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (= pd (:position-data result))))))

;; ---------------------------------------------------------------------------
;; Tests: position-data is translated by delta when shape moves
;; ---------------------------------------------------------------------------

(t/deftest apply-text-modifier-position-data-translated-on-resize
  (t/testing "position-data x/y is adjusted by the delta of the selrect origin"
    (let [pd       (sample-position-data 10 20)
          shape    (-> (make-text-shape :x 0 :y 0 :width 100 :height 50)
                       (assoc :position-data pd))
          ;; Only set position-data; no resize so no origin shift expected
          modifier {:position-data pd}
          result   (dwt/apply-text-modifier shape modifier)]
      ;; Delta should be zero (no dimension change), so coords stay the same
      (t/is (= 10.0 (-> result :position-data first :x)))
      (t/is (= 20.0 (-> result :position-data first :y))))))

(t/deftest apply-text-modifier-position-data-not-translated-when-nil
  (t/testing "nil position-data on result after modifier is left as nil"
    (let [shape    (make-text-shape :x 0 :y 0 :width 100 :height 50)
          modifier {:width 200}
          result   (dwt/apply-text-modifier shape modifier)]
      ;; shape had no position-data; modifier doesn't set one — stays nil
      (t/is (nil? (:position-data result))))))

;; ---------------------------------------------------------------------------
;; Tests: degenerate selrect (zero width or height decoded from the server)
;;
;; Root cause of the original crash:
;;   change-dimensions-modifiers divided by (:width selrect) or (:height selrect)
;;   which is 0 when the shape was decoded via map->Rect (bypassing make-rect's
;;   0.01 minimum), producing Infinity → transform pipeline returned nil selrect
;;   → gpt/point threw "invalid arguments (on pointer constructor)".
;; ---------------------------------------------------------------------------

(t/deftest apply-text-modifier-zero-width-selrect-does-not-throw
  (t/testing "width modifier on a shape with zero selrect width does not throw"
    ;; Simulates a shape received from the server whose selrect has width=0
    ;; (map->Rect bypasses the 0.01 floor of make-rect).
    (let [shape    (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50)
          modifier {:width 200}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (some? result))
      (t/is (some? (:selrect result))))))

(t/deftest apply-text-modifier-zero-height-selrect-does-not-throw
  (t/testing "height modifier on a shape with zero selrect height does not throw"
    (let [shape    (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0)
          modifier {:height 80}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (some? result))
      (t/is (some? (:selrect result))))))

(t/deftest apply-text-modifier-zero-width-and-height-selrect-does-not-throw
  (t/testing "both modifiers on a fully-degenerate selrect do not throw"
    (let [shape    (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
          modifier {:width 150 :height 60}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (some? result))
      (t/is (some? (:selrect result))))))

(t/deftest apply-text-modifier-zero-width-selrect-result-has-correct-width
  (t/testing "applying width modifier to a zero-width shape yields the requested width"
    (let [shape    (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50)
          modifier {:width 200}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (= 200.0 (-> result :selrect :width))))))

(t/deftest apply-text-modifier-zero-height-selrect-result-has-correct-height
  (t/testing "applying height modifier to a zero-height shape yields the requested height"
    (let [shape    (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0)
          modifier {:height 80}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (= 80.0 (-> result :selrect :height))))))

(t/deftest apply-text-modifier-nil-modifier-on-degenerate-shape-returns-unchanged
  (t/testing "nil modifier on a zero-selrect shape returns the same shape"
    (let [shape  (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
          result (dwt/apply-text-modifier shape nil)]
      (t/is (identical? shape result)))))

;; ---------------------------------------------------------------------------
;; Tests: shape origin is preserved when there is no dimension change
;; ---------------------------------------------------------------------------

(t/deftest apply-text-modifier-selrect-origin-preserved-without-resize
  (t/testing "selrect x/y origin does not shift when no dimension changes"
    (let [shape    (make-text-shape :x 30 :y 40 :width 100 :height 50)
          modifier {:position-data (sample-position-data 30 40)}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (= (-> shape :selrect :x) (-> result :selrect :x)))
      (t/is (= (-> shape :selrect :y) (-> result :selrect :y))))))

;; ---------------------------------------------------------------------------
;; Tests: returned shape is a proper map-like value
;; ---------------------------------------------------------------------------

(t/deftest apply-text-modifier-returns-shape-with-required-keys
  (t/testing "result always contains the core shape keys"
    (let [shape    (make-text-shape :x 0 :y 0 :width 100 :height 50)
          modifier {:width 200 :height 80}
          result   (dwt/apply-text-modifier shape modifier)]
      (t/is (some? (:id result)))
      (t/is (some? (:type result)))
      (t/is (some? (:selrect result))))))

(t/deftest apply-text-modifier-nil-modifier-returns-same-identity
  (t/testing "nil modifier returns the exact same shape object (identity)"
    (let [shape (make-text-shape)]
      (t/is (identical? shape (dwt/apply-text-modifier shape nil))))))

;; ---------------------------------------------------------------------------
;; Tests: delta-move computation does not throw on degenerate selrect
;;
;; The delta-move in apply-text-modifier calls gpt/point on both the
;; original and new shape selrects.  gpt/point throws when given a
;; non-point-like value (nil, or a map with non-finite :x/:y).  Using
;; ctm/safe-size-rect instead of raw (:selrect …) access ensures a valid
;; rect is always available for that computation.
;; ---------------------------------------------------------------------------

(t/deftest apply-text-modifier-position-data-with-degenerate-selrect-does-not-throw
  (t/testing "position-data modifier on a zero-selrect shape does not throw"
    (let [pd     (sample-position-data 5 10)
          shape  (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
          result (dwt/apply-text-modifier shape {:position-data pd})]
      (t/is (some? result))
      (t/is (= pd (:position-data result)))))

  (t/testing "width + position-data modifier on a zero-selrect shape does not throw"
    (let [pd     (sample-position-data 5 10)
          shape  (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
          result (dwt/apply-text-modifier shape {:width 200 :position-data pd})]
      (t/is (some? result))
      (t/is (some? (:selrect result))))))

;; ---------------------------------------------------------------------------
;; Tests: add-typography event normalises float line-height / letter-spacing
;;
;; These tests exercise the full add-typography event path in texts.cljs:
;; a text shape whose content nodes carry float line-height / letter-spacing
;; values (as produced by JS float arithmetic) must yield a typography entry
;; in the file with properly-rounded *string* values, not the raw floats.
;;
;; Root cause reproduced here: JS float arithmetic (e.g. 1.3 - 0.1) can
;; produce 1.2000000000000002 instead of 1.2.  Without the fix, that number
;; survives through add-typography unchanged, and (ctt/check-typography)
;; would throw because :line-height must be a :string.
;; With the fix (mth/precision + str), it is normalised to "1.2" before the
;; schema check runs.
;; ---------------------------------------------------------------------------

(t/deftest add-typography-normalises-float-line-height
  (t/async
    done
    (let [;; Exact value reproduced from the issue: 1.3 - 0.1 in JS
          float-lh  1.2000000000000002
          content   (txt/change-text nil "hello" :line-height float-lh)
          file      (-> (cthf/sample-file :file1)
                        (cths/add-sample-shape :text1
                                               :type    :text
                                               :x 0 :y 0
                                               :content content))
          shape-id  (:id (cths/get-shape file :text1))
          file-id   (:id file)
          store     (ths/setup-store file)
          events    [;; Pre-select the text shape so add-typography can find it
                     (fn [state] (assoc-in state [:workspace-local :selected] #{shape-id}))
                     (dwt/add-typography file-id)]]

      (ths/run-store
       store done events
       (fn [new-state]
         (let [file'        (ths/get-file-from-state new-state)
               typographies (vals (get-in file' [:data :typographies]))]
           (t/is (= 1 (count typographies))
                 "exactly one typography was added")
           (t/is (= "1.2" (:line-height (first typographies)))
                 "float line-height is normalised to 2-decimal string")))))))

(t/deftest add-typography-truncates-line-height-to-two-decimals
  (t/async
    done
    (let [;; A value with more than 2 decimal places: 1.234234234 → "1.23"
          long-lh   1.234234234
          content   (txt/change-text nil "hello" :line-height long-lh)
          file      (-> (cthf/sample-file :file1)
                        (cths/add-sample-shape :text1
                                               :type    :text
                                               :x 0 :y 0
                                               :content content))
          shape-id  (:id (cths/get-shape file :text1))
          file-id   (:id file)
          store     (ths/setup-store file)
          events    [(fn [state] (assoc-in state [:workspace-local :selected] #{shape-id}))
                     (dwt/add-typography file-id)]]

      (ths/run-store
       store done events
       (fn [new-state]
         (let [file'        (ths/get-file-from-state new-state)
               typographies (vals (get-in file' [:data :typographies]))]
           (t/is (= 1 (count typographies))
                 "exactly one typography was added")
           (t/is (= "1.23" (:line-height (first typographies)))
                 "line-height with more than 2 decimals is truncated to 2")))))))

(t/deftest add-typography-normalises-float-letter-spacing
  (t/async
    done
    (let [;; Analogous imprecision for letter-spacing
          float-ls  0.10000000000000001
          content   (txt/change-text nil "hello" :letter-spacing float-ls)
          file      (-> (cthf/sample-file :file1)
                        (cths/add-sample-shape :text1
                                               :type    :text
                                               :x 0 :y 0
                                               :content content))
          shape-id  (:id (cths/get-shape file :text1))
          file-id   (:id file)
          store     (ths/setup-store file)
          events    [(fn [state] (assoc-in state [:workspace-local :selected] #{shape-id}))
                     (dwt/add-typography file-id)]]

      (ths/run-store
       store done events
       (fn [new-state]
         (let [file'        (ths/get-file-from-state new-state)
               typographies (vals (get-in file' [:data :typographies]))]
           (t/is (= 1 (count typographies))
                 "exactly one typography was added")
           (t/is (= "0.1" (:letter-spacing (first typographies)))
                 "float letter-spacing is normalised to 2-decimal string")))))))

;; ---------------------------------------------------------------------------
;; Tests: save-font must not persist typography refs into the global default font
;;
;; Root cause of #10925: typography assets are file-specific references, but
;; save-font used to write :typography-ref-id / :typography-ref-file into the
;; session-global [:workspace-global :default-font]. That state survives a file
;; switch, and v2-default-text-content bakes it into brand-new text shapes in
;; the other file, so they got a non-existent typography asset instead of the
;; default Penpot font. save-font now strips those two keys.
;; ---------------------------------------------------------------------------

(t/deftest save-font-strips-typography-refs-from-default-font
  (t/async
    done
    (let [file  (-> (cthf/sample-file :file1)
                    (cths/add-sample-shape :text1
                                           :type    :text
                                           :x 0 :y 0
                                           :content (txt/change-text nil "hello")))
          store (ths/setup-store file)
          attrs {:font-id "roboto"
                 :font-family "Roboto"
                 :font-variant-id "regular"
                 :font-size "14"
                 :typography-ref-id (uuid/next)
                 :typography-ref-file (:id file)}]
      (ths/run-store
       store done [(dwt/save-font attrs)]
       (fn [new-state]
         (let [default-font (get-in new-state [:workspace-global :default-font])]
           (t/is (some? default-font))
           (t/is (= "roboto" (:font-id default-font)))
           (t/is (nil? (:typography-ref-id default-font)))
           (t/is (nil? (:typography-ref-file default-font)))))))))

(t/deftest save-font-preserves-other-font-attrs
  (t/async
    done
    (let [store (ths/setup-store (cthf/sample-file :file1))
          attrs {:font-family "Open Sans"
                 :font-id "opensans"
                 :font-variant-id "regular"
                 :font-size "18"
                 :line-height "1.5"
                 :letter-spacing "0"
                 :typography-ref-id (uuid/next)
                 :typography-ref-file (uuid/next)}]
      (ths/run-store store done [(dwt/save-font attrs)]
                     (fn [new-state]
                       (let [default-font (get-in new-state [:workspace-global :default-font])]
                         (t/is (= "Open Sans" (:font-family default-font)))
                         (t/is (= "18" (:font-size default-font)))
                         (t/is (= "1.5" (:line-height default-font)))
                         (t/is (nil? (:typography-ref-id default-font)))
                         (t/is (nil? (:typography-ref-file default-font)))))))))

;; ---------------------------------------------------------------------------
;; Tests: fix-position with degenerate selrect
;; ---------------------------------------------------------------------------

(t/deftest fix-position-zero-width-selrect-does-not-throw
  (t/testing "fix-position on a shape with zero selrect width does not throw"
    (let [shape     (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 50)
          modifiers (ctm/change-dimensions-modifiers shape :width 200 {:ignore-lock? true})
          shape'    (assoc shape :modifiers modifiers)
          result    (vth/fix-position shape')]
      (t/is (some? result))
      (t/is (some? (:selrect result))))))

(t/deftest fix-position-zero-height-selrect-does-not-throw
  (t/testing "fix-position on a shape with zero selrect height does not throw"
    (let [shape     (make-degenerate-text-shape :x 0 :y 0 :width 100 :height 0)
          modifiers (ctm/change-dimensions-modifiers shape :height 80 {:ignore-lock? true})
          shape'    (assoc shape :modifiers modifiers)
          result    (vth/fix-position shape')]
      (t/is (some? result))
      (t/is (some? (:selrect result))))))

(t/deftest fix-position-zero-width-and-height-selrect-does-not-throw
  (t/testing "fix-position on a fully degenerate selrect does not throw"
    (let [shape     (make-degenerate-text-shape :x 0 :y 0 :width 0 :height 0)
          modifiers (ctm/change-dimensions-modifiers shape :width 150 {:ignore-lock? true})
          shape'    (assoc shape :modifiers modifiers)
          result    (vth/fix-position shape')]
      (t/is (some? result))
      (t/is (some? (:selrect result))))))

;; ---------------------------------------------------------------------------
;; Tests: ensure-valid-text-content
;; ---------------------------------------------------------------------------

(t/deftest ensure-valid-text-content-empty-children-repaired
  (t/testing "root with empty :children vector is repaired to canonical tree"
    (let [broken  {:type         "root"
                   :vertical-align "top"
                   :children     []}
          fixed   (dwt/ensure-valid-text-content broken)]
      (t/is (= "root" (:type fixed)))
      (t/is (vector? (:children fixed)))
      (t/is (= 1 (count (:children fixed)))
            "exactly one paragraph-set is seeded")
      (t/is (= "paragraph-set" (get-in fixed [:children 0 :type])))
      (t/is (pos? (count (get-in fixed [:children 0 :children])))
            "paragraph-set has at least one paragraph")
      (t/is (= "" (get-in fixed [:children 0 :children 0 :children 0 :text]))
            "seeded span has empty text")
      (t/is (= "top" (:vertical-align fixed))
            "preserves the original :vertical-align"))))

(t/deftest ensure-valid-text-content-missing-children-repaired
  (t/testing "root with no :children key is repaired to canonical tree"
    (let [broken  {:type "root" :vertical-align "center"}
          fixed   (dwt/ensure-valid-text-content broken)]
      (t/is (vector? (:children fixed)))
      (t/is (pos? (count (:children fixed))))
      (t/is (= "center" (:vertical-align fixed))))))

(t/deftest ensure-valid-text-content-healthy-tree-unchanged
  (t/testing "a well-formed content is returned unchanged"
    (let [healthy {:type "root"
                   :children [{:type "paragraph-set"
                               :children [{:type "paragraph"
                                           :children [{:text "hello"}]}]}]}
          fixed   (dwt/ensure-valid-text-content healthy)]
      (t/is (= healthy fixed)))))

(t/deftest ensure-valid-text-content-nil-unchanged
  (t/testing "nil content is returned unchanged (no repair)"
    (t/is (nil? (dwt/ensure-valid-text-content nil)))))

(t/deftest ensure-valid-text-content-non-root-unchanged
  (t/testing "a non-root content (e.g. paragraph) is left alone"
    (let [node {:type "paragraph" :children []}]
      (t/is (= node (dwt/ensure-valid-text-content node))))))
