;; 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

#_:clj-kondo/ignore
(ns app.util.object
  "A collection of helpers for work with javascript objects."
  (:refer-clojure :exclude [set! new get merge clone contains? array? into-array reify class])
  #?(:cljs (:require-macros [app.util.object]))
  (:require
   [app.common.data :as d]
   [app.common.json :as json]
   [app.common.schema :as sm]
   [clojure.core :as c]
   [cuerdas.core :as str]
   [rumext.v2.util :as mfu]))

#?(:cljs
   (defn array?
     [o]
     (.isArray js/Array o)))

#?(:cljs
   (defn into-array
     [o]
     (js/Array.from o)))

#?(:cljs
   (defn create [] #js {}))

#?(:cljs
   (defn get
     ([obj k]
      (when (some? obj)
        (unchecked-get obj k)))
     ([obj k default]
      (let [result (get obj k)]
        (if (undefined? result) default result)))))

#?(:cljs
   (defn contains?
     [obj k]
     (when (some? obj)
       (js/Object.hasOwn obj k))))

#?(:cljs
   (defn clone
     [a]
     (js/Object.assign #js {} a)))

#?(:cljs
   (defn merge!
     ([a b]
      (js/Object.assign a b))
     ([a b & more]
      (reduce merge! (merge! a b) more))))

#?(:cljs
   (defn merge
     ([a b]
      (js/Object.assign #js {} a b))
     ([a b & more]
      (reduce merge! (merge a b) more))))

#?(:cljs
   (defn set!
     [obj key value]
     (unchecked-set obj key value)
     obj))

#?(:cljs
   (defn unset!
     [obj key]
     (js-delete obj key)
     obj))

#?(:cljs
   (def ^:private not-found-sym
     (js/Symbol "not-found")))

#?(:cljs
   (defn update!
     [obj key f & args]
     (let [found (c/get obj key not-found-sym)]
       (when-not ^boolean (identical? found not-found-sym)
         (unchecked-set obj key (apply f found args)))
       obj)))

#?(:cljs
   (defn ^boolean in?
     [obj prop]
     (js* "~{} in ~{}" prop obj)))

#?(:cljs
   (defn without-empty
     [^js obj]
     (when (some? obj)
       (js* "Object.entries(~{}).reduce((a, [k,v]) => (v == null ? a : (a[k]=v, a)), {}) " obj))))

#?(:cljs
   (defn plain-object?
     ^boolean
     [o]
     (and (some? o)
          (identical? (.getPrototypeOf js/Object o)
                      (.-prototype js/Object)))))

#?(:cljs
   (defn stringify
     [obj]
     (js/JSON.stringify obj)))

;; EXPERIMENTAL: unsafe, does not checks and not validates the input,
;; should be improved over time, for now it works for define a class
;; extending js/Error that is more than enought for a first, quick and
;; dirty macro impl for generating classes.

(defmacro class
  "Create a class instance"
  [& {:keys [name extends constructor]}]

  (let [params
        (if (and constructor (= 'fn (first constructor)))
          (into [] (drop 1) (second constructor))
          [])

        constructor-sym
        (symbol name)

        constructor
        (if constructor
          constructor
          `(fn ~name [~'this]
             (.call ~extends ~'this)))]

    `(let [konstructor# ~constructor
           extends# ~extends
           ~constructor-sym
           (fn ~constructor-sym ~params
             (cljs.core/this-as ~'this
                                (konstructor# ~'this ~@params)))]

       (set! (.-prototype ~constructor-sym)
             (js/Object.create (.-prototype extends#)))
       (set! (.-constructor (.-prototype ~constructor-sym))
             konstructor#)

       ~constructor-sym)))

#?(:clj
   (defmacro add-properties!
     "Adds properties to an object using `.defineProperty`"
     [rsym & properties]
     (let [rsym       (with-meta rsym {:tag 'js})

           this-sym   (with-meta (gensym (str rsym "-this-")) {:tag 'js})
           target-sym (with-meta (gensym (str rsym "-target-")) {:tag 'js})
           cause-sym  (gensym "cause-")

           make-sym
           (fn [pname prefix]
             (-> (gensym (str "prop-" prefix "-" (str/slug pname) "-"))
                 (with-meta {:tag 'js})))

           make-sym
           (memoize make-sym)

           bindings
           (->> properties
                (mapcat (fn [params]
                          (let [pname         (c/get params :name)
                                get-expr      (c/get params :get)
                                set-expr      (c/get params :set)
                                fn-expr       (c/get params :fn)
                                schema-n      (c/get params :schema)
                                wrap          (c/get params :wrap)
                                schema-1      (c/get params :schema-1)
                                this?         (c/get params :this false)
                                on-error      (c/get params :on-error)

                                decode-expr
                                (c/get params :decode/fn)

                                decode-options
                                (c/get params :decode/options)

                                decode-options
                                (if (and decode-expr decode-options)
                                  (do
                                    (println "WARN: decode/fn and decode/options are excluding, ignoring decode/options")
                                    nil)
                                  decode-options)

                                decode-expr
                                (or decode-expr 'app.common.json/->clj)

                                fn-sym
                                (-> (gensym (str "internal-fn-" (str/slug pname) "-"))
                                    (with-meta {:tag 'function}))

                                coercer-sym
                                (-> (gensym (str "coercer-fn-" (str/slug pname) "-"))
                                    (with-meta {:tag 'function}))

                                decode-sym
                                (-> (gensym (str "decode-fn-" (str/slug pname) "-"))
                                    (with-meta {:tag 'function}))

                                schema-sym
                                (-> (gensym (str "schema-" (str/slug pname) "-"))
                                    (with-meta {:tag 'function}))

                                wrap-sym
                                (-> (gensym (str "wrap-fn-" (str/slug pname) "-"))
                                    (with-meta {:tag 'function}))

                                val-sym
                                (gensym (str "val-" (str/slug pname) "-"))

                                wrap-error-handling
                                (if on-error
                                  (fn [expr]
                                    `(try
                                       ~expr
                                       (catch :default ~cause-sym
                                         (~on-error ~cause-sym))))
                                  identity)]

                            (concat
                             (when wrap
                               [wrap-sym wrap])

                             (when get-expr
                               [(make-sym pname "get-fn")
                                (if this?
                                  `(fn []
                                     (let [~this-sym (~'js* "this")
                                           ~fn-sym ~get-expr]
                                       ~(wrap-error-handling
                                         `(.call ~fn-sym ~this-sym ~this-sym))))
                                  `(fn []
                                     (let [~this-sym (~'js* "this")
                                           ~fn-sym ~get-expr]
                                       ~(wrap-error-handling
                                         `(.call ~fn-sym ~this-sym)))))])

                             (when set-expr
                               [schema-sym  schema-n

                                coercer-sym `(if (and (some? ~schema-sym)
                                                      (not (fn? ~schema-sym)))
                                               (sm/coercer ~schema-sym)
                                               nil)

                                decode-sym decode-expr

                                (make-sym pname "set-fn")
                                `(fn [~val-sym]
                                   ~(wrap-error-handling
                                     `(let [~this-sym (~'js* "this")
                                            ~fn-sym   ~set-expr

                                            ;; We only emit schema and coercer bindings if
                                            ;; schema-n is provided
                                            ~@(if (some? schema-n)
                                                [schema-sym
                                                 `(if (fn? ~schema-sym)
                                                    (~schema-sym ~val-sym)
                                                    ~schema-sym)

                                                 coercer-sym
                                                 `(if (nil? ~coercer-sym)
                                                    (sm/coercer ~schema-sym)
                                                    ~coercer-sym)

                                                 val-sym
                                                 (if (not= decode-expr 'app.common.json/->clj)
                                                   `(~decode-sym ~val-sym)
                                                   `(~decode-sym ~val-sym ~decode-options))

                                                 val-sym
                                                 `(~coercer-sym ~val-sym)]
                                                [])]

                                        ~(if this?
                                           `(.call ~fn-sym ~this-sym ~this-sym ~val-sym)
                                           `(.call ~fn-sym ~this-sym ~val-sym)))))])

                             (when fn-expr
                               [schema-sym  (or schema-n schema-1)
                                coercer-sym `(if (and (some? ~schema-sym)
                                                      (not (fn? ~schema-sym)))
                                               (sm/coercer ~schema-sym)
                                               nil)
                                decode-sym decode-expr

                                (make-sym pname "get-fn")
                                `(fn []
                                   (let [~this-sym (~'js* "this")
                                         ~fn-sym   ~(if (and (list? fn-expr)
                                                             (= 'fn (first fn-expr)))
                                                      (let [[sa sb & sother] fn-expr]
                                                        `(~sa ~sb ~(wrap-error-handling `(do ~@sother))))
                                                      fn-expr)

                                         ~fn-sym   ~(if this?
                                                      `(.bind ~fn-sym ~this-sym ~this-sym)
                                                      `(.bind ~fn-sym ~this-sym))

                                         ;; We only emit schema and coercer bindings if
                                         ;; schema-n or schema-1 is provided
                                         ~@(if (or schema-n schema-1)
                                             [fn-sym `(fn* [~@(if schema-1 [val-sym] [])]
                                                           ~(wrap-error-handling
                                                             `(let [~@(if schema-n
                                                                        [val-sym `(into-array (cljs.core/js-arguments))]
                                                                        [])
                                                                    ~val-sym
                                                                    ~(if (not= decode-expr 'app.common.json/->clj)
                                                                       `(~decode-sym ~val-sym)
                                                                       `(~decode-sym ~val-sym ~decode-options))

                                                                    ~schema-sym
                                                                    (if (fn? ~schema-sym)
                                                                      (~schema-sym ~val-sym)
                                                                      ~schema-sym)

                                                                    ~coercer-sym
                                                                    (if (nil? ~coercer-sym)
                                                                      (sm/coercer ~schema-sym)
                                                                      ~coercer-sym)

                                                                    ~val-sym
                                                                    (~coercer-sym ~val-sym)]

                                                                ~(if schema-1
                                                                   `(~fn-sym ~val-sym)
                                                                   `(apply ~fn-sym ~val-sym)))))]
                                             [])]
                                     ~(if wrap
                                        `(~wrap-sym ~fn-sym)
                                        fn-sym)))]))))))]

       `(let [~target-sym ~rsym
              ~@bindings]
          ;; Creates the `.defineProperty` per property
          ~@(for [params properties
                  :let [pname    (c/get params :name)
                        get-expr (c/get params :get)
                        set-expr (c/get params :set)
                        fn-expr  (c/get params :fn)
                        enum?    (c/get params :enumerable true)
                        conf?    (c/get params :configurable)
                        writ?    (c/get params :writable)]]
              `(.defineProperty
                js/Object
                ~target-sym
                ~pname
                (cljs.core/js-obj
                 ~@(concat
                    ["enumerable" (boolean enum?)]

                    (when conf?
                      ["configurable" true])

                    (when (some? writ?)
                      ["writable" true])

                    (when (or get-expr)
                      ["get" (make-sym pname "get-fn")])

                    (when fn-expr
                      ["get" (make-sym pname "get-fn")])

                    (when set-expr
                      ["set" (make-sym pname "set-fn")])))))

          ;; Returns the object
          ~target-sym))))

(defn- collect-properties
  [params]
  (let [[tmeta params] (if (map? (first params))
                         [(first params) (rest params)]
                         [{} params])]
    (loop [params (seq params)
           props  []
           defs   {}
           curr   :start
           ckey   nil]
      (cond
        (= curr :start)
        (let [candidate (first params)]
          (cond
            (keyword? candidate)
            (recur (rest params) props defs :property candidate)

            (nil? candidate)
            (recur (rest params) props defs :end nil)

            :else
            (recur (rest params) props defs :definition candidate)))

        (= :end curr)
        [tmeta props defs]

        (= :property curr)
        (let [definition (first params)]
          (if (some? definition)
            (let [definition (if (map? definition)
                               (c/merge {:wrap (:wrap tmeta)
                                         :on-error (:on-error tmeta)}
                                        definition)
                               (-> {:enumerable false}
                                   (c/merge (meta definition))
                                   (assoc :wrap (:wrap tmeta))
                                   (assoc :on-error (:on-error tmeta))
                                   (assoc :fn definition)
                                   (dissoc :get :set :line :column)
                                   (d/without-nils)))
                  definition (assoc definition :name (name ckey))]

              (recur (rest params)
                     (conj props definition)
                     defs
                     :start
                     nil))

            (let [hint (str "expected property definition for: " curr)]
              (throw (ex-info hint {:key curr})))))

        (= :definition curr)
        (let [[params props defs curr ckey]
              (loop [params params
                     defs   (update defs ckey #(or % []))]
                (let [candidate (first params)
                      params    (rest params)]
                  (cond
                    (nil? candidate)
                    [params props defs :end]

                    (keyword? candidate)
                    [params props defs :property candidate]

                    (symbol? candidate)
                    [params props defs :definition candidate]

                    :else
                    (recur params (update defs ckey conj candidate)))))]
          (recur params props defs curr ckey))

        :else
        (throw (ex-info "invalid params" {}))))))

#?(:cljs
   (def type-symbol
     (js/Symbol.for "penpot.reify:type")))

#?(:cljs
   (defn type-of?
     [o t]
     (let [o (get o type-symbol)]
       (= o t))))

#?(:cljs
   (def Proxy
     (app.util.object/class
      :name "Proxy"
      :extends js/Object
      :constructor (constantly nil))))

(defmacro reify
  "A domain specific variation of reify that creates anonymous objects
  on demand with the ability to assign protocol implementations and
  custom properties"
  [& params]
  (let [[tmeta properties definitions]
        (collect-properties params)

        f-sym
        (gensym "to-string-")

        type-name
        (or (c/get tmeta :name) (str (gensym "anonymous")))

        obj-sym
        (gensym "obj-")]

    `(let [~obj-sym (new Proxy)
           ~f-sym   (fn [] ~type-name)]
       (add-properties! ~obj-sym
                        {:name ~'js/Symbol.toStringTag
                         :enumerable false
                         :get ~f-sym}
                        {:name (js/Symbol.for "penpot.reify:type")
                         :enumerable false
                         :get ~f-sym}
                        ~@properties)

       ~(if-let [definitions (seq definitions)]
          `(cljs.core/specify! ~obj-sym
             ~@(mapcat (fn [[k v]] (cons k v)) definitions))
          obj-sym))))
