(module reactive
  "Reactive primitives for UI programming")

(def ^:dynamic *reactive-context*
  "Bind to a JS array to track `deref`s of reactive cells. Any cell that is
  derefed will push itself onto the array, and can subsequently be watched. "
  nil)

(defn cell
  "Reactive container. Like [[box]], but can participate in a reactive signal
  graph. See also [[formula]]. Takes the initial value as argument."
  [v]
  (specify! #js {:val v
                 :watches {}
                 :notify #{}}
    Swappable
    (-swap! [this f args]
      (let [old-val (.-val this)
            new-val (apply f old-val args)]
        (set! (.-val this) new-val)
        (doseq [[k f] (.-watches this)]
          (f k this old-val new-val))
        new-val))

    Derefable
    (deref [this]
      (when *reactive-context*
        (.push *reactive-context* this))
      (.-val this))

    TaggedValue
    (-tag [this] "cell")
    (-tag-value [this] (.-val this))

    Watchable
    (add-watch! [this key watch-fn]
      (set! (.-watches this) (assoc (.-watches this) key watch-fn))
      this)
    (remove-watch! [this key]
      (set! (.-watches this) (dissoc (.-watches this) key))
      this)))

(defprotocol Formula
  (-recompute! [this]))

(defn track-reactions! [this compute! update!]
  (binding [*reactive-context* #js []]
    (let [new-val (compute!)
          old-inputs (.-inputs this)
          new-inputs *reactive-context*]
      (set! (.-inputs this) new-inputs)
      (doseq [input (remove (set old-inputs) new-inputs)]
        (add-watch! input this (fn [k r o n] (update! o n))))
      (doseq [input (remove (set new-inputs) old-inputs)]
        (remove-watch! input this))
      new-val)))

(defn cursor [cell path]
  (let [this #js {:watches {}}]
    (add-watch! cell this
      (fn [k r o n]
        (let [old (get-in o path)
              new (get-in n path)]
          (when (not= o n)
            (doseq [[k f] (.-watches this)]
              (f k this old new))))))

    (specify! this
      Swappable
      (-swap! [this f args]
        (apply swap! cell update-in path f args)
        (get-in @cell path))

      Derefable
      (deref [this]
        (when *reactive-context*
          (.push *reactive-context* this))
        (binding [*reactive-context* nil]
          (get-in (lang:deref cell) path)))

      TaggedValue
      (-tag [this] "cursor")
      (-tag-value [this] {:cell cell :path path})

      Watchable
      (add-watch! [this key watch-fn]
        (set! (.-watches this) (assoc (.-watches this) key watch-fn))
        this)
      (remove-watch! [this key]
        (set! (.-watches this) (dissoc (.-watches this) key))
        this))))

(defn formula* [thunk]
  (specify! #js {:val ::new
                 :watches {}
                 :inputs #js []}
    Derefable
    (deref [this]
      (when (= ::new (.-val this))
        (-recompute! this))
      (when *reactive-context*
        (.push *reactive-context* this))
      (.-val this))

    TaggedValue
    (-tag [this] "formula")
    (-tag-value [this] (.-val this))

    Watchable
    (add-watch! [this key watch-fn]
      (set! (.-watches this) (assoc (.-watches this) key watch-fn))
      this)
    (remove-watch! [this key]
      (set! (.-watches this) (dissoc (.-watches this) key))
      this)

    Formula
    (-recompute! [this]
      (track-reactions!
        this
        (fn []
          (let [old-val (.-val this)
                new-val (thunk)]
            (set! (.-val this) new-val)
            (doseq [[k f] (.-watches this)]
              (f k this old-val new-val))
            new-val))
        (fn [old new]
          (when (not= old new)
            (-recompute! this)))))))

(defmacro formula
  "Create a formula cell, containing a computed value based on the value of
  other cells (formula or regular), which will update automatically when any of
  the dependent cells update. Macro version, see [[formula*]] for a version
  which takes a zero-arity function (thunk)"
  [& body]
  `(formula* (fn [] ~@body)))

(defn reaction* [thunk]
  (doto
    (specify! #js {:inputs #js []}
      TaggedValue
      (-tag [this] "reaction")
      (-tag-value [this] thunk)

      Formula
      (-recompute! [this]
      (track-reactions!
        this
        thunk
        (fn [old new]
          (when (not= old new)
            (-recompute! this))))))
    -recompute!))

(defmacro reaction!
  "Similar to [[formula]], but for side effects only. See [[reaction*]] for a
  non-macro version that takes a thunk."
  [& body]
  `(reaction* (fn [] ~@body)))
