(ns cljcastr.dom
  (:require [clojure.string :as str]))

(defn error! [& msgs]
  (throw (js/Error. (str/join " " msgs))))

(defn ->id
  "Given an CSS selector, returns the element ID. This assumes that the
   selector is something like \"#foo\" or \"#foo.bar\", not something more
   complex like \"#foo > div.bar\"."
  [selector]
  (->> selector
       (re-find #"^#?([-\w]+)*.*$")
       second))

(defn ->selector
  "Given an element ID, returns a selector to query that ID."
  [id]
  (str "#" id))

(defn get-el
  "Returns the first element in the document matching the CSS selector
   `selector`. If the `el` param is present, the selector applies to its
   children instead of the top-level document. If `selector` is not a string,
   it is assumed to be an element and just returned."
  ([selector]
   (get-el js/document selector))
  ([el selector]
   (if (string? selector)
     (when selector
       (.querySelector el selector))
     selector)))

(defn get-els
  "Like `get-el`, but returns all elements matching `selector`. If `selector` is
   not a string, it is assumed to be a list of elements and just returned."
  ([selector]
   (get-els js/document selector))
  ([el selector]
   (if (string? selector)
     (when selector
       (.querySelectorAll el selector))
     ;; Selector is not a string, so assume it's an element and return it
     selector)))

(defn get-id
  "Returns ID of the element identified by `selector`. `selector` may also
   be an element."
  [selector]
  (when-let [el (get-el selector)]
    (.-id el)))

(defn get-parent
  "Returns parent of the element identified by `selector`. `selector` may also
   be an element."
  [selector]
  (when-let [el (get-el selector)]
    (.-parentElement el)))

(defn get-selector
  "Returns an CSS selector for element `el` using its ID, or `nil` if it has no
   ID. `el` may also be a string, in which case it is assumed to be a selector
   and just returned."
  [el]
  (when el
    (let [selector (if (string? el) el (str "#" (.-id el)))]
      (when (not= "#" selector)
        selector))))

(defn active-element?
  "Returns true if the element identified by `selector` is the currently active
   element in the document (i.e. the cursor is in a textbox, a button is
   focused, etc). `selector` may also be an element."
  [selector]
  (= (.-activeElement js/document)
     (get-el selector)))

(defn get-attribute
  "Returns attribue `attr` of the element identified by `selector`. `selector`
   may also be an element. If the `el` param is present, the selector applies to
   its children instead of the top-level document."
  ([selector attr]
   (when-let [el (get-el selector)]
     (.getAttribute el (name attr))))
  ([el selector attr]
   (when-let [el (get-el el selector)]
     (get-attribute el attr))))

(defn get-html
  "Returns inner HTML of the element identified by `selector`. `selector` may
   also be an element."
  [selector]
  (when-let [el (get-el selector)]
    (.-innerHTML el)))

(defn get-text
  "Returns inner text of the element identified by `selector`. `selector` may
   also be an element."
  [selector]
  (when-let [el (get-el selector)]
    (or (.-textContent el) (.-innerText el))))

(defn get-value
  "Returns value of the element identified by `selector`. `selector` may
   also be an element."
  [selector]
  (when-let [el (get-el selector)]
    (.-value el)))

(defn get-child
  "Returns the `n`th child of the element identified by `selector`. `selector`
   may also be an element. If `n` is out of bounds, returns `nil`."
  [selector n]
  (when-let [el (get-el selector)]
    (->> el .-childNodes seq (drop n) first)))

(defn get-children
  "Returns children of the element identified by `selector`. `selector` may
   also be an element."
  [selector]
  (when-let [el (get-el selector)]
    (-> el .-childNodes seq)))

(defn add-children!
  "Adds list of `children` to the element identified by `selector`. `selector`
   may also be an element. Returns the element."
  [selector children]
  (when-let [el (get-el selector)]
    (doseq [child children
            :when (not (nil? child))]
      (.appendChild el child))
    el))

(defn add-child!
  "Convenience function to add a single child to the element identified by
   `selector`. `selector` may also be an element. Returns the element."
  [selector child]
  (add-children! selector [child]))

(defn insert-child-after!
  "Inserts `child` after the element identified by `selector`. `selector` may
   also be an element. Returns the parent element."
  [selector child]
  (when-let [sibling (get-el selector)]
    (let [next-sibling (.-nextSibling sibling)
          parent (.-parentNode sibling)]
      (if next-sibling
        (do
          (.insertBefore parent child next-sibling)
          parent)
        (add-child! parent child)))))

(defn insert-child-before!
  "Inserts `child` before the element identified by `selector`. `selector` may
   also be an element. Returns the parent element."
  [selector child]
  (when-let [sibling (get-el selector)]
    (let [parent (.-parentNode sibling)]
      (.insertBefore parent child sibling)
      parent)))

(defn insert-child!
  "Inserts `child` as the nth child of the element identified by `selector`.
   `selector` may also be an element. If the element has no children or `n` is
   at the end of the list (or beyond), `child` will be added with `add-child!`.
   Returns the parent element."
  [selector child n]
  (when-let [el (get-el selector)]
    (let [n (if (neg? n) 0 n)
          children (seq (.-childNodes el))
          num-children (count children)]
      (if (or (empty? children) (>= n (dec num-children)))
        (add-child! el child)
        (do
          (.insertBefore el child (nth children n))
          el)))))

(defn clear-children!
  "Removes all children from the element identified by `selector`. `selector`
   may also be an element. Returns the element."
  [selector]
  (when-let [el (get-el selector)]
    (.replaceChildren el)
    el))

(defn set-children!
  "Sets children of the element identified by `selector` to the list of
   `children`. `selector` may also be an element. Returns the element."
  [selector children]
  (when-let [el (get-el selector)]
    (clear-children! el)
    (add-children! el children)))

(defn set-child!
  "Convenience function to set children of the element identified by
   `selector` to a single `child`. `selector` may also be an element.
   Returns the element."
  [selector child]
  (set-children! selector [child]))

(defn take-children!
  "Sets children of the element identified by `selector` to its first `n`
   children. `selector` may also be an element. Returns the element."
  [selector n]
  (when-let [el (get-el selector)]
    (->> el
         .-childNodes
         seq
         (take n)
         (set-children! el))))

(defn remove-child!
  "Removes the node identified by `child-selector` from its parent.
   Returns the parent element. `child-selector` may also be an element."
  [child-selector]
  (when-let [child-el (get-el child-selector)]
    (when-let [parent (.-parentNode child-el)]
      (.removeChild parent child-el)
      parent)))

(defn get-classes
  "Returns list of classes for the element identified by `selector`. `selector`
   may also be an element."
  [selector]
  (when-let [el (get-el selector)]
    (-> el .-classList seq)))

(defn add-classes!
  "Adds `classes` to the element identified by `selector`. `selector` may also be
   an element. Returns the element."
  [selector classes]
  (when-let [el (get-el selector)]
    (let [classes (if (sequential? classes) classes [classes])]
      (doseq [cls classes]
        (-> el .-classList (.add cls))))
    el))

(defn add-class!
  "Convenience function to add a single class `cls` to the element identified by
   `selector`. `selector` may also be an element."
  [selector cls]
  (add-classes! selector [cls]))

(defn clear-classes!
  "Removes all classes from the element identified by `selector`. `selector` may
   also be an element. Returns the element."
  [selector]
  (when-let [el (get-el selector)]
    (set! (.-className el) "")
    el))

(defn set-classes!
  "Sets classes of the element identified by `selector` to list of `classes`.
   `selector` may also be an element. Returns the element."
  [selector classes]
  (when-let [el (get-el selector)]
    (clear-classes! el)
    (add-classes! el classes)))

(defn set-class!
  "Convenience function to set classes of the element identified by `selector`
   to a single class `cls`. `selector` may also be an element. Returns the
   element."
  [selector cls]
  (set-classes! selector [cls]))

(defn remove-class!
  "Removes class `cls` from the element identified by `selector`. `selector` may
   also be an element. Returns the element."
  [selector cls]
  (when-let [el (get-el selector)]
    (-> el .-classList (.remove cls))
    el))

(defn has-class?
  "Returns true if the element identified by `selector` has class `cls`.
   `selector` may also be an element."
  [selector cls]
  (when-let [el (get-el selector)]
    (-> el .-classList (.contains cls))))

(defn remove-attribute!
  "Removes attribute `attr` from the element identified by `selector`. `selector`
   may also be an element. Returns the element."
  [selector attr]
  (when-let [el (get-el selector)]
    (.removeAttribute el (name attr))
    el))

(defn remove-attributes!
  "Convenience function to remove list of attributes from the element identified
   by `selector`. `selector` may also be an element. Returns the element."
  [selector attrs]
  (when-let [el (get-el selector)]
    (doseq [attr attrs] (remove-attribute! el attr))
    el))

(defn set-attribute!
  "Sets attribute `attr` of the element identified by `selector` to `v`.
   `selector` may also be an element. Returns the element."
  [selector attr v]
  (when-let [el (get-el selector)]
    (.setAttribute el (name attr) v)
    el))

(defn set-attributes!
  "Given a list of attribute name / value pairs `attrs`, sets each attribute of
   the element identified by `selector` to the corresponding value. `selector`
   may also be an element. Returns the element."
  [selector attrs]
  (when-let [el (get-el selector)]
    (if (or (sequential? attrs) (map? attrs))
      (doseq [[k v] attrs]
        (set-attribute! el k v))
      (error! "attrs must be a list of attribute / value pairs; was:"
              (type attrs)))
    el))

(defn get-data
  "Returns value of data attribute `k` of the element identified by `selector`.
   `selector` may also be an element."
  [selector k]
  (when-let [el (get-el selector)]
    (.getAttribute el (str "data-" (name k)))))

(defn set-data!
  "Sets data attribute `k` of the element identified by `selector` to `v`.
   `selector` may also be an element. Returns the element."
  [selector k v]
  (when-let [el (get-el selector)]
    (.setAttribute el (str "data-" (name k)) v)
    el))

(defn set-html!
  "Sets inner HTML of the element identified by `selector` to `html`.
   `selector` may also be an element. Returns the element."
  [selector html]
  (when-let [el (get-el selector)]
    (set! (.-innerHTML el) html)
    el))

(defn set-id!
  "Sets id of the element identified by `selector` to `id`. `selector` may also
   be an element. Returns the element."
  [selector id]
  (when-let [el (get-el selector)]
    (set! (.-id el) id)
    el))

(defn set-styles!
  "Sets styles of the element identified by `selector` to `styles`, which can be
   either a string containing CSS styles (for example, `font-weight: bold;`) or
   a map of style to value (for example: `{:font-weight \"bold\"}`. `selector`
   may also be an element. Returns the element."
  [selector styles]
  (when-let [el (get-el selector)]
    (let [styles
          (cond
            (string? styles) styles
            (map? styles) (->> styles
                               (map (fn [[k v]] (str (name k) ": " k ";")))
                               (str/join " "))
            :else (error! "styles must be a string or a map; was:" (type styles)))]
      (set! (.-style el) styles))
    el))

(defn clear-styles!
  "Removes all inline styles from the element identified by `selector`.
  `selector` may also be an element. Returns the element."
  [selector]
  (when-let [el (get-el selector)]
    (.clear (.-attributeStyleMap el))
    el))

(defn set-text!
  "Sets inner text of the element identified by `selector` to `text`. `selector`
   may also be an element. Returns the element."
  [selector text]
  (when-let [el (get-el selector)]
    (set! (.-textContent el) text)
    el))

(defn set-value!
  "Sets value of the element identified by `selector` to `v`. `selector` may
   also be an element. Returns the element."
  [selector v]
  (when-let [el (get-el selector)]
    (set! (.-value el) v)
    el))

(defn select-el!
  "Selects all text in the element identified by `selector`. `selector` may also
   be an element. Returns the element."
  [selector]
  (when-let [el (get-el selector)]
    (let [sel (js/window.getSelection)
          rng (js/document.createRange)]
      (.selectNodeContents rng el)
      (.removeAllRanges sel)
      (.addRange sel rng))
    el))

(defn focus-el
  "Focuses the element identified by `selector`. `selector` may also be an
   element. Returns the element."
  [selector]
  (when-let [el (get-el selector)]
    (.focus el)
    el))

(defn scroll-to-el
  "Scrolls to the element identified by `selector`. `selector` may also be an
   element. Returns the element. If the `focus?` param is true (default for the
   one-arity version of the function), also focuses the element."
  ([selector]
   (scroll-to-el selector true))
  ([selector focus?]
   (when-let [el (get-el selector)]
     (.scrollIntoView el)
     (when focus?
       (focus-el el))
     el)))

(defn create-el
  "Creates an element of type `el-type`. Optionally, an `opts` map can be
   passed with the following keys:
   `:attrs` - map of attributes to values
   `:child` - child node to add to the element
   `:children` - list of child nodes of the element
   `:class` - single class to add to the element
   `:classes` - list of classes to add to the element
   `:id` - id of the element
   `:styles` - map of styles to add to the element
   `:text` - text content of the element"
  ([el-type]
   (create-el el-type {}))
  ([el-type opts]
   (let [el (js/document.createElement (name el-type))]
     (when (:attrs opts) (set-attributes! el (:attrs opts)))
     (when (:child opts) (set-child! el (:child opts)))
     (when (:children opts) (set-children! el (:children opts)))
     (when (:class opts) (add-class! el (:class opts)))
     (when (:classes opts) (add-classes! el (remove nil? (:classes opts))))
     (when (:id opts) (set! (.-id el) (:id opts)))
     (when (:styles opts) (set-styles! el (:styles opts)))
     (when (:text opts) (set-text! el (:text opts)))
     el)))

(defn create-els
  "Given a list of `[:el-type opts]`, creates a list of elements as per
   `create-el`."
  [el-defs]
  (map (partial apply create-el) el-defs))

(defn create-link
  "Creates a link (\"a\" element) to URI `href` with the text `text`. `opts` may
   also be passed, with the same options as for `create-el`."
  ([href text]
   (create-link href text {}))
  ([href text opts]
   (let [opts (-> opts
                  (assoc-in [:attrs :href] href)
                  (assoc :text text))]
     (create-el :a opts))))

(defn create-link-new-tab
  "Creates a link (\"a\" element) to URI `href` with the text `text` which opens
   in a new tab. `opts` may also be passed, with the same options as for
   `create-link`."
  ([href text]
   (create-link-new-tab href text {}))
  ([href text opts]
   (create-link href text (update opts :attrs merge
                                  {:target "_blank", :rel "noopener noreferrer"}))))

(defn move-cursor!
  "Moves the cursor to the specified offset within the editable element
   identified by `selector`. `selector` may also be an element. If `offset`
   is `:end`, the cursor will be placed at the end, and if it is negative, the
   cursor will be placed `offset` characters from the end. Returns the element."
  [selector offset]
  (when-let [el (get-el selector)]
    (let [el (-> el (get-child 0))  ; the child is a Text object
          len (count (get-text el))
          sel (js/window.getSelection)
          rng (js/document.createRange)]
      (let [offset (cond
                     (= :end offset) len
                     (neg? offset) (+ len offset)
                     :else offset)]
        (.setStart rng el offset)
        (.setEnd rng el offset))
      (.removeAllRanges sel)
      (.addRange sel rng))
    el))

(defn get-selection
  "Returns a map containing the `:start-offset`, `:end-offset`, and
   `:selected-el` of the current selection. If there is no selection,
   `:start-offset` and `:end-offset` will be the same value, and `:selected-el`
   should not be relied on; it's probably the element containing the cursor,
   but no guarantees are made."
  []
  (let [sel (js/window.getSelection)
        range (when (pos? (.-rangeCount sel)) (.getRangeAt sel 0))
        start-offset (if range (.-startOffset range) 0)
        end-offset (if range (.-endOffset range) 0)
        el (when range (-> range .-startContainer .-parentElement))]
    {:start-offset start-offset
     :end-offset end-offset
     :selected-el el}))

(defn set-timeout!
  "Sets a timer which calls function `f` after the specified delay in
   milliseconds, or immediately if `delay` is not specified."
  ([f]
   (set-timeout! f 0))
  ([f delay]
   (js/setTimeout f delay)))

(defn cancel-timeout!
  "Cancels timeout with `id` previously returned by `set-timeout!`."
  [id]
  (js/clearTimeout id))

(defn add-listener!
  "Adds an event listener to the element specified by `selector` for events of
   type `event-type`, registering in the `state` atom. `selector` may also be an
   element, in which case the listener is registered in `state` by the ID of the
   element, unless it is `js/document` or `js/winodw`, which are handled as
   special cases. If the element has no ID and is not `js/document` or
   `js/window`, the listener is not registered and therefore cannot be removed by
   `clear-listeners!`. Returns the list of listeners on the element."
  [state selector event-type f]
  (.addEventListener (get-el selector) event-type f)
  (let [selector (or (get-selector selector)
                     (when (= js/document selector) "js/document")
                     (when (= js/window selector) "js/window"))]
    (when selector
      (swap! state update-in [:listeners selector event-type] #(cons f %))
      (get-in @state [:listeners selector]))))

(defn clear-listeners!
  "Removes all listeners from the element specified by `selector` and updates the
   `state` atom. If `event-type` is passed, only listeners for the specified
   event type are removed. If only the `state` atom is passed, removes all
   listeners on all elements. `selector` may also be an element with an ID or
   `js/document`; otherwise, nothing will be done. Returns the list of listeners
   on the element."
  ([state]
   (doseq [[selector _] (:listeners @state)]
     (clear-listeners! state selector)))
  ([state selector]
   (let [selector (get-selector selector)]  ; handle `selector` being an element
     (when selector
       (doseq [[event-type _] (get-in @state [:listeners selector])]
         (clear-listeners! state selector event-type)))))
  ([state selector event-type]
   (when-let [selector (get-selector selector)]
     (let [el (cond
                (= "js/document" selector) js/document
                (= "js/window" selector) js/window
                :else (get-el selector))]
       (doseq [f (get-in @state [:listeners selector event-type])]
         (.removeEventListener el event-type f)))
     (swap! state update-in [:listeners selector] dissoc event-type)
     (get-in @state [:listeners selector]))))
