;; 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-reflow-test
  "Tests the reflow tasks the layout and text pipelines feed to
  `app.main.data.workspace.reflow`, which is what plugin waits observe. The
  promise view of the settle signal lives in `app.plugins.reflow`; these tests
  use it because it is the wait the plugin API ships."
  (:require
   [app.common.uuid :as uuid]
   [app.main.data.workspace.reflow :as wrf]
   [app.main.data.workspace.shape-layout :as dwsl]
   [app.main.data.workspace.shapes :as dwsh]
   [app.main.data.workspace.texts :as dwtxt]
   [app.main.data.workspace.wasm-text :as dwwt]
   [app.main.fonts :as fonts]
   [app.plugins.reflow :as pwrf]
   [app.render-wasm.api.fonts :as wasm.fonts]
   [app.util.globals :as globals]
   [app.util.http :as http]
   [beicon.v2.core :as rx]
   [cljs.test :as t :include-macros true]
   [frontend-tests.helpers.mock :as mock]
   [potok.v2.core :as ptk]))

(t/use-fixtures :each {:before wrf/reset-pending!
                       :after wrf/reset-pending!})

;; Starts the `:layout/update` pipeline on a throwaway store; its empty state
;; resolves every buffered update to no shapes and applies no modifiers.
(defn- start-pipeline!
  []
  (doto (ptk/store {:state {} :on-error #(js/console.error %)})
    (ptk/emit! (dwsl/initialize-shape-layout))))

(defn- stop-pipeline!
  [store]
  (ptk/emit! store (dwsl/finalize-shape-layout)))

(defn- start-text-pipeline!
  []
  (doto (ptk/store {:state {} :on-error #(js/console.error %)})
    (ptk/emit! (dwtxt/initialize-text-reflow))))

(defn- stop-text-pipeline!
  [store]
  (ptk/emit! store (dwtxt/finalize-text-reflow)))

(t/deftest root-only-layout-update-is-not-pending-work
  ;; The root lays out nothing, so an update with only its id holds no wait.
  (t/async done
    (let [store (start-pipeline!)]
      (ptk/emit! store (ptk/data-event :layout/update {:ids [uuid/zero]}))
      (-> (pwrf/wait-for-layout-update nil 20)
          (.then #(t/is true "resolved with no pending work"))
          (.catch #(t/is false "a root-only update was marked as pending work"))
          (.then (fn []
                   (stop-pipeline! store)
                   (done)))))))

(t/deftest stale-task-cannot-finish-current-work
  ;; Delayed callbacks from a finalized workspace carry the old generation and
  ;; must not drain a new workspace task for the same shape id.
  (t/async done
    (let [id           (uuid/next)
          stale-task   (wrf/start! :text-measure [id])
          _             (wrf/reset-pending!)
          current-task (wrf/start! :text-measure [id])]
      (wrf/finish! stale-task)
      (-> (pwrf/wait-for-layout-update [id] 20)
          (.then #(t/is false "a stale completion drained current work"))
          (.catch #(t/is true "current work stayed pending"))
          (.then (fn []
                   (wrf/finish! current-task)
                   (pwrf/wait-for-layout-update [id] 100)))
          (.then #(t/is true "the exact current task drained normally"))
          (.catch #(t/is false "the current task did not drain"))
          (.then (fn [] (done)))))))

(t/deftest reinstalling-the-pending-scan-resets-work-and-keeps-tracking
  ;; Reinstall the pending scan with the latest reducer.
  (t/async done
    (let [id       (uuid/next)
          stale    (wrf/start! :text-measure [id])
          current* (atom nil)]
      (#'wrf/install-pending-subscription!)
      (-> (pwrf/wait-for-layout-update [id] 100)
          (.then #(t/is true "reinstalling the scan reset its previous generation"))
          (.catch #(t/is false "the replaced scan kept stale work pending"))
          (.then
           (fn []
             (reset! current* (wrf/start! :text-measure [id]))
             (pwrf/wait-for-layout-update [id] 20)))
          (.then #(t/is false "the replacement scan did not track new work"))
          (.catch #(t/is true "the replacement scan tracked new work"))
          (.then
           (fn []
             (wrf/finish! stale)
             (wrf/finish! @current*)
             (pwrf/wait-for-layout-update [id] 100)))
          (.then #(t/is true "the replacement scan drained its exact task"))
          (.catch #(t/is false "the replacement scan did not drain"))
          (.then (fn [] (done)))))))

(t/deftest pending-promise-finishes-at-the-operation-boundary
  ;; Imperative render work is pending from before its thunk starts until the
  ;; exact promise returned by that thunk settles; no timer is involved.
  (t/async done
    (let [id       (uuid/next)
          resolve* (atom nil)
          started? (atom false)]
      (wrf/run-pending!
       :text-measure
       [id]
       (fn []
         (reset! started? true)
         (js/Promise. (fn [resolve _] (reset! resolve* resolve)))))
      (-> (pwrf/wait-for-layout-update [id] 20)
          (.then #(t/is false "resolved while the render operation was pending"))
          (.catch #(t/is @started? "the task was opened before running the operation"))
          (.then (fn []
                   (@resolve*)
                   (pwrf/wait-for-layout-update [id] 100)))
          (.then #(t/is true "resolved as soon as the render operation settled"))
          (.catch #(t/is false "the settled render operation stayed pending"))
          (.then (fn [] (done)))))))

(t/deftest pending-promise-finishes-after-a-synchronous-error
  (let [id (uuid/next)]
    (try
      (wrf/run-pending! :text-measure [id] #(throw (js/Error. "boom")))
      (catch :default _))
    (t/async done
      (-> (pwrf/wait-for-layout-update [id] 100)
          (.then #(t/is true "a synchronous failure drained its exact task"))
          (.catch #(t/is false "a synchronous failure leaked pending work"))
          (.then (fn [] (done)))))))

(t/deftest deleted-shapes-cancel-only-their-work
  (t/async done
    (let [id-a   (uuid/next)
          id-b   (uuid/next)
          task-a (wrf/start! :text-bridge [id-a])
          task-b (wrf/start! :text-bridge [id-b])]
      (wrf/cancel-shapes! [id-a])
      (-> (pwrf/wait-for-layout-update [id-a] 100)
          (.then #(t/is true "deleted shape work was cancelled"))
          (.catch #(t/is false "deleted shape work stayed pending"))
          (.then #(pwrf/wait-for-layout-update [id-b] 20))
          (.then #(t/is false "cancelling one shape drained its sibling"))
          (.catch #(t/is true "sibling work stayed pending"))
          (.then (fn []
                   (wrf/finish! task-a)
                   (wrf/finish! task-b)
                   (done)))))))

(t/deftest text-bridge-waits-for-each-shape
  ;; Starting measurement for one id must not release sibling bridge tasks.
  (t/async done
    (let [store (start-text-pipeline!)
          id-a  (uuid/next)
          id-b  (uuid/next)]
      (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
      (let [task-a (wrf/start! :text-measure [id-a])]
        (wrf/finish! task-a))
      (-> (pwrf/wait-for-layout-update [id-b] 20)
          (.then #(t/is false "the first text released its sibling bridge"))
          (.catch #(t/is true "the sibling bridge stayed pending"))
          (.then (fn []
                   (let [task-b (wrf/start! :text-measure [id-b])]
                     (wrf/finish! task-b))
                   (pwrf/wait-for-layout-update [id-b] 100)))
          (.then #(t/is true "the sibling drained after its own measurement"))
          (.catch #(t/is false "the sibling never drained"))
          (.then (fn []
                   (stop-text-pipeline! store)
                   (done)))))))

(t/deftest text-bridge-observes-out-of-order-work
  ;; Start all bridges before matching work can finish.
  (t/async done
    (let [store (start-text-pipeline!)
          id-a  (uuid/next)
          id-b  (uuid/next)]
      (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a id-b]}))
      (let [task-b (wrf/start! :text-measure [id-b])]
        (wrf/finish! task-b))
      (let [task-a (wrf/start! :text-measure [id-a])]
        (wrf/finish! task-a))
      (-> (pwrf/wait-for-layout-update [id-a id-b] 100)
          (.then #(t/is true "both out-of-order bridges observed their work"))
          (.catch #(t/is false "a bridge missed work that started out of order"))
          (.then (fn []
                   (stop-text-pipeline! store)
                   (done)))))))

(t/deftest text-bridge-does-not-consume-preexisting-work
  ;; Ignore matching work that started before the bridge.
  (t/async done
    (let [store      (start-text-pipeline!)
          id         (uuid/next)
          prior-task (wrf/start! :text-measure [id])]
      (ptk/emit! store (ptk/data-event :text/reflow {:ids [id]}))
      (wrf/finish! prior-task)
      (-> (pwrf/wait-for-layout-update [id] 20)
          (.then #(t/is false "preexisting work released the new bridge"))
          (.catch #(t/is true "the new bridge remained pending"))
          (.then (fn []
                   (let [current-task (wrf/start! :text-measure [id])]
                     (wrf/finish! current-task))
                   (pwrf/wait-for-layout-update [id] 100)))
          (.then #(t/is true "work started after the bridge drained it"))
          (.catch #(t/is false "the causal measurement did not drain the bridge"))
          (.then (fn []
                   (stop-text-pipeline! store)
                   (done)))))))

(t/deftest cancelling-a-bridge-does-not-block-later-reflow-events
  (t/async done
    (let [store (start-text-pipeline!)
          id-a  (uuid/next)
          id-b  (uuid/next)]
      (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-a]}))
      (wrf/cancel-shapes! [id-a])
      (ptk/emit! store (ptk/data-event :text/reflow {:ids [id-b]}))
      (-> (pwrf/wait-for-layout-update [id-b] 20)
          (.then #(t/is false "the later bridge was not opened"))
          (.catch #(t/is true "the later bridge stayed pending"))
          (.then (fn []
                   (let [task-b (wrf/start! :text-measure [id-b])]
                     (wrf/finish! task-b))
                   (pwrf/wait-for-layout-update [id-b] 100)))
          (.then #(t/is true "the later bridge drained after its own work"))
          (.catch #(t/is false "the cancelled bridge blocked the pipeline"))
          (.then (fn []
                   (stop-text-pipeline! store)
                   (done)))))))

(t/deftest finalizing-a-page-cancels-its-text-bridges
  (t/async done
    (let [store (start-text-pipeline!)
          id    (uuid/next)]
      (ptk/emit! store (ptk/data-event :text/reflow {:ids [id]}))
      (ptk/emit! store (ptk/data-event :app.main.data.workspace.pages/finalize-page))
      (-> (pwrf/wait-for-layout-update [id] 100)
          (.then #(t/is true "page teardown drained the unmeasured text bridge"))
          (.catch #(t/is false "page teardown left text work pending"))
          (.then (fn []
                   (stop-text-pipeline! store)
                   (done)))))))

(t/deftest failed-wasm-font-storage-falls-back-and-drains
  (t/async done
    (let [id       (uuid/next)
          font-key {:font-id "gfont-does-not-load"
                    :weight 400
                    :style 0
                    :emoji? false}
          stream   (rx/subject)
          events   (atom [])]
      (->> (#'dwtxt/await-font-faces stream #{font-key} [id])
           (rx/subs! #(swap! events conj %)))
      (#'wasm.fonts/report-font-storage-failed! font-key)
      (-> (pwrf/wait-for-layout-update [id] 100)
          (.then (fn []
                   (t/is (= 1 (count @events))
                         "font failure dispatches one fallback resize")
                   (t/is (wasm.fonts/font-ready? font-key)
                         "the resize gate accepts the failed face's fallback")
                   (done)))
          (.catch (fn [_]
                    (t/is false "font failure leaked pending work")
                    (done)))))))

(t/deftest failed-dom-font-load-falls-back-and-drains
  (t/async done
    (let [id      (uuid/next)
          font-id "gfont-layout-failure-test"]
      (swap! fonts/fontsdb assoc font-id
             {:id font-id
              :backend :google
              :family "Layout Failure Test"
              :variants [{:id "regular"}]})
      (swap! fonts/loaded disj font-id)
      (swap! fonts/loading dissoc font-id)
      (mock/with-mocks
        {globals/browser? (constantly true)
         http/send!       (fn [_] (rx/throw (js/Error. "font fetch failed")))}
        (fn [done']
          (wrf/run-pending! :font [id] #(fonts/ensure-loaded! font-id))
          (-> (pwrf/wait-for-layout-update [id] 500)
              (.then (fn []
                       (t/is (not (contains? @fonts/loading font-id))
                             "a failed load must not remain cached as loading")))
              (.catch #(t/is false "failed DOM font load leaked pending work"))
              (.then (fn []
                       (swap! fonts/fontsdb dissoc font-id)
                       (swap! fonts/loaded disj font-id)
                       (swap! fonts/loading dissoc font-id)
                       (done')))))
        done))))

(t/deftest failed-google-font-css-does-not-abort-shared-consumers
  (t/async done
    (let [font-id "gfont-optional-css-test"
          values  (atom [])
          cleanup #(swap! fonts/fontsdb dissoc font-id)]
      (swap! fonts/fontsdb assoc font-id
             {:id font-id
              :backend :google
              :family "Optional CSS Test"
              :variants [{:id "regular"}]})
      (mock/with-mocks
        {http/send! (fn [_] (rx/throw (js/Error. "font css fetch failed")))}
        (fn [done']
          (->> (fonts/fetch-font-css {:font-id font-id})
               (rx/subs!
                #(swap! values conj %)
                (fn [_]
                  (cleanup)
                  (t/is false "an optional font CSS failure escaped the shared helper")
                  (done'))
                (fn []
                  (cleanup)
                  (t/is (empty? @values)
                        "a failed optional font contributes no CSS")
                  (done')))))
        done))))

(t/deftest deduplicated-wasm-font-failure-settles-every-face
  (t/async done
    (let [font-url "https://example.test/shared-font.ttf"
          regular  {:font-id "gfont-shared-regular"
                    :weight 400
                    :style 0
                    :emoji? false}
          bold     {:font-id "gfont-shared-bold"
                    :weight 700
                    :style 0
                    :emoji? false}]
      (mock/with-mocks
        {http/send! (fn [_] (rx/throw (js/Error. "shared fetch failed")))}
        (fn [done']
          (let [request (#'wasm.fonts/fetch-font regular font-url false false)
                duplicate (#'wasm.fonts/fetch-font bold font-url false false)]
            (t/is (some? request) "the first face owns the shared fetch")
            (t/is (nil? duplicate) "the second face reuses the shared fetch")
            (->> ((:callback request))
                 (rx/subs!
                  (fn [_])
                  (fn [_]
                    (t/is false "the shared fetch failure escaped its fallback")
                    (done'))
                  (fn []
                    (t/is (wasm.fonts/font-ready? regular)
                          "the first face settled through fallback")
                    (t/is (wasm.fonts/font-ready? bold)
                          "the deduplicated face settled through fallback")
                    (done'))))))
        done))))

(t/deftest missing-wasm-font-url-settles-without-entering-fetch-map
  (t/async done
    (let [font-data {:font-id "gfont-missing-url"
                     :weight 400
                     :style 0
                     :emoji? false}]
      (t/is (nil? (#'wasm.fonts/fetch-font font-data nil false false))
            "a missing URL starts no request")
      (t/is (not (contains? @wasm.fonts/fetching nil))
            "missing URLs are not deduplicated under nil")
      (js/setTimeout
       (fn []
         (t/is (wasm.fonts/font-ready? font-data)
               "the missing face settled through fallback")
         (done))
       0))))

(t/deftest wasm-font-resize-waits-for-every-face
  (t/async done
    (let [id           (uuid/next)
          regular-key  {:font-id "gfont-mixed"
                        :weight 400
                        :style 0
                        :emoji? false}
          bold-key     {:font-id "gfont-mixed"
                        :weight 700
                        :style 0
                        :emoji? false}
          stream       (rx/subject)
          events       (atom [])]
      (->> (#'dwtxt/await-font-faces stream #{regular-key bold-key} [id])
           (rx/subs! #(swap! events conj %)))
      (rx/push! wasm.fonts/font-stored-stream regular-key)
      (-> (pwrf/wait-for-layout-update [id] 20)
          (.then #(t/is false "the first face released the font task"))
          (.catch #(t/is true "the second face remained pending"))
          (.then (fn []
                   (rx/push! wasm.fonts/font-storage-failed-stream bold-key)
                   (pwrf/wait-for-layout-update [id] 100)))
          (.then (fn []
                   (t/is (= 1 (count @events))
                         "all faces settling dispatches exactly one resize")
                   (done)))
          (.catch (fn [_]
                    (t/is false "the complete face set did not drain")
                    (done)))))))

(t/deftest buffered-wasm-resize-releases-on-stop-without-a-commit
  (t/async done
    (let [store (ptk/store {:state {} :on-error #(js/console.error %)})
          id    (uuid/next)]
      (ptk/emit! store (dwsh/update-shapes-buffer-start))
      (ptk/emit! store (dwwt/resize-wasm-text-all [id]))
      (-> (pwrf/wait-for-layout-update [id] 20)
          (.then #(t/is false "the buffered resize was not marked pending"))
          (.catch #(t/is true "the resize stayed pending while the buffer was open"))
          (.then (fn []
                   (ptk/emit! store (dwsh/update-shapes-buffer-stop))
                   (pwrf/wait-for-layout-update [id] 500)))
          (.then #(t/is true "buffer stop released the fallback resize"))
          (.catch #(t/is false "buffer stop without a commit leaked pending work"))
          (.then (fn [] (done)))))))

(t/deftest buffered-wasm-resize-releases-on-workspace-finalize
  (t/async done
    (let [store (ptk/store {:state {} :on-error #(js/console.error %)})
          id    (uuid/next)]
      (ptk/emit! store (dwsh/update-shapes-buffer-start))
      (ptk/emit! store (dwwt/resize-wasm-text-all [id]))
      (-> (pwrf/wait-for-layout-update [id] 20)
          (.then #(t/is false "the buffered resize was not marked pending"))
          (.catch #(t/is true "the resize stayed pending while the buffer was open"))
          (.then (fn []
                   (ptk/emit! store (ptk/data-event :app.main.data.workspace/finalize-workspace))
                   (pwrf/wait-for-layout-update [id] 500)))
          (.then #(t/is true "workspace finalization released the buffered resize"))
          (.catch #(t/is false "workspace finalization leaked pending work"))
          (.then (fn [] (done)))))))

(t/deftest layout-update-is-pending-until-the-buffer-flushes
  ;; A shape id is marked on arrival and drained when the update is processed.
  (t/async done
    (let [store (start-pipeline!)]
      (ptk/emit! store (ptk/data-event :layout/update {:ids [(uuid/next) uuid/zero]}))
      (-> (pwrf/wait-for-layout-update nil 20)
          (.then #(t/is false "resolved while the update was still buffered"))
          (.catch #(t/is true "stayed pending until the flush"))
          (.then #(pwrf/wait-for-layout-update nil 5000))
          (.then #(t/is true "resolved once the update was processed"))
          (.catch #(t/is false "the pipeline never drained its mark"))
          (.then (fn []
                   (stop-pipeline! store)
                   (done)))))))
