;; To start a REPL:
;;
;; bb dev
;;
;; Then connect to it in Emacs:
;;
;; C-c l C (cider-connect-cljs), host: localhost; port: 1339; REPL type: nbb

(ns cljcastr.transcribe
  (:require [cljcastr.dom :as dom]
            [cljcastr.js-utils :as util :refer [log ->int]]
            [cljcastr.time :as time]
            [cljs.pprint :refer [pprint]]
            [clojure.edn :as edn]
            [clojure.string :as str]
            [promesa.core :as p]))

(defonce state (atom {:ops []}))

(def seek-duration-sec 1.0)

(def speed-step 0.25)

(def min-playback-rate 0.25)

(def footnotes-sel "#footnotes > ol")

(def footnote-num-context-words 10)

(defn save-operation! [op]
  (log :debug "Saving operation:" (clj->js op))
  (swap! state update :ops #(-> op (cons %) vec))
  op)

(defn pop-operation! []
  (let [[op & ops] (:ops @state)]
    (swap! state assoc :ops (vec ops))
    op))

(defn resize-textbox! []
  (let [window-height (.-innerHeight js/window)
        el (dom/get-el "#textbox")
        top (-> el .getBoundingClientRect .-top)
        el-height (int (- window-height top 20))]
    (log :debug (str "Window resized to " window-height
                     "px; setting textbox height to " el-height "px"))
    (dom/set-styles! el (str "height: " el-height "px;"))))

(defn hide-message! []
  (dom/set-styles! "#message" "display: none")
  (resize-textbox!))

(defn show-message!
  ([msg]
   (show-message! :info msg))
  ([msg-type msg]
   (log msg-type msg)
   (let [message-el (dom/get-el "#message")
         text-el (dom/get-el "#message-text")]
     (dom/set-class! message-el (name msg-type))
     (dom/set-text! text-el msg)
     (dom/set-styles! message-el "display: flex"))
   (resize-textbox!)))

(defn error! [msg]
  (show-message! :error msg))

(defn show-import-controls! []
  (dom/set-attribute! "#import-controls" :open true))

(defn hide-import-controls!
  ([]
   (dom/remove-attribute! "#import-controls" :open))
  ([{:keys [audio-url transcript-url] :as _query-params}]
   (let [{:keys [audio-opened transcript-imported]} @state
         hide-audio? (or (not audio-url) audio-opened)
         hide-transcript? (or (not transcript-url) transcript-imported)]
     (when (and hide-audio? hide-transcript?)
       (hide-import-controls!)))))

(defn get-audio-duration []
  (-> (dom/get-el "audio") .-duration))

(defn get-audio-ts []
  (-> (dom/get-el "audio") .-currentTime))

(defn set-audio-ts! [ts]
  (let [ts (if (string? ts) (time/ts->sec ts) ts)]
    (set! (.-currentTime (dom/get-el "audio")) ts)))

(defn get-audio-playback-rate []
  (-> (dom/get-el "audio") .-playbackRate))

(defn set-audio-playback-rate! [rate]
  (set! (.-playbackRate (dom/get-el "audio")) (max rate min-playback-rate)))

(defn clear-storage! []
  (let [storage (.-localStorage js/window)]
    (log :debug "Clearing" (.-length storage) "keys from local storage")
    (.clear storage)))

(defn load-key [k]
  (-> (.-localStorage js/window)
      (.getItem k)))

(defn save-key! [k v]
  (log :debug "Saving key" k "to local storage:" v)
  (-> (.-localStorage js/window)
      (.setItem k (if (= "null" v) "" v))))

(defn remove-key! [k]
  (log :debug "Removing key" k "from local storage")
  (-> (.-localStorage js/window)
      (.removeItem k)))

(defn load-num-paragraphs []
  (-> (load-key "transcript-num-paragraphs")
      js/parseInt))

(defn save-num-paragraphs! [n]
  (save-key! "transcript-num-paragraphs" (str n)))

(defn inc-num-paragraphs! []
  (let [n (load-num-paragraphs)]
    (save-num-paragraphs! (inc n))))

(defn load-num-footnotes []
  (-> (load-key "transcript-num-footnotes")
      js/parseInt))

(defn save-num-footnotes! [n]
  (save-key! "transcript-num-footnotes" (str n)))

(defn inc-num-footnotes! []
  (let [n (load-num-footnotes)]
    (save-num-footnotes! (inc n))))

(defn save-el! [el]
  (save-key! (.-id el) (dom/get-text el)))

(defn save-paragraph! [p-el]
  (doseq [el (.-childNodes p-el)]
    (save-el! el)))

(defn span->footnote-k [span-el]
  (-> (dom/get-id span-el)
      (str/replace #"footnote-\d+-" "")
      keyword))

(defn save-footnote! [footnote-el]
  (let [id (dom/get-id footnote-el)]
    (save-key! (str id "-paragraph-num")
               (dom/get-data footnote-el :paragraph-num))
    (save-key! (str id "-offset")
               (dom/get-data footnote-el :offset)))
  (doseq [el (dom/get-children footnote-el)
          :let [k (span->footnote-k el)
                text (dom/get-text el)]]
    (save-key! (dom/get-id el)
               (if (= k (-> text (str/replace " " "-") keyword))
                 ""
                 text))))

(defn load-transcript-filename []
  (load-key "transcript-filename"))

(defn save-transcript-filename! [filename]
  (swap! state assoc :transcript-filename filename)
  (swap! state dissoc :transcript-url)
  (save-key! "transcript-filename" filename)
  (remove-key! "transcript-url"))

(defn load-transcript-url []
  (load-key "transcript-url"))

(defn save-transcript-url! [url]
  (swap! state assoc :transcript-url url)
  (swap! state dissoc :transcript-filename)
  (save-key! "transcript-url" url)
  (remove-key! "transcript-filename"))

(defn load-audio-filename []
  (load-key "audio-filename"))

(defn save-audio-filename! [filename]
  (swap! state assoc :audio-filename filename)
  (swap! state dissoc :audio-url)
  (save-key! "audio-filename" filename)
  (remove-key! "audio-url"))

(defn load-audio-url [url]
  (load-key "audio-url" url))

(defn save-audio-url! [url]
  (swap! state assoc :audio-url url)
  (swap! state dissoc :audio-filename)
  (save-key! "audio-url" url)
  (remove-key! "audio-filename"))

(defn read-transcript [text]
  (edn/read-string text))

(defn ts-el? [el]
  (when-let [id (.-id el)]
    (re-matches #"transcript-p-\d+-ts" id)))

(defn speaker-el? [el]
  (when-let [id (.-id el)]
    (re-matches #"transcript-p-\d+-speaker" id)))

(defn text-el? [el]
  (when-let [id (.-id el)]
    (re-matches #"transcript-p-\d+-text" id)))

(defn transcript-p-id [i]
  (str "transcript-p-" i))

(defn get-transcript-p [i]
  (dom/get-el (str "#" (transcript-p-id i))))

(defn footnote-li-id [i]
  (str "footnote-" i))

(defn get-footnote-li [i]
  (dom/get-el (str "#" (footnote-li-id i))))

(defn footnote-num
  "Given a footnote li, returns the footnote number as an int."
  [li]
  (-> (dom/get-id li)
      (str/replace #"footnote-" "")
      ->int))

(defn get-paragraph-parent [el]
  (let [id (or (.-id el) "")
        parent (.-parentNode el)]
    (if (re-matches #"transcript-p-\d+$" id)
      el
      (when parent
        (get-paragraph-parent parent)))))

(defn get-paragraph-el [p k]
  (dom/get-el p (str ".transcript-" (name k))))

(defn get-paragraph-num [el]
  (when-let [p (get-paragraph-parent el)]
    (->> p .-id (re-find #"\d+") js/parseInt)))

(defn get-ts [p]
  (-> (get-paragraph-el p :ts)
      dom/get-text
      not-empty))

(defn get-speaker [p]
  (-> (get-paragraph-el p :speaker)
      dom/get-text
      not-empty))

(defn get-text [p]
  (-> (get-paragraph-el p :text)
      dom/get-text
      not-empty))

(defn get-current-paragraph-num []
  (-> js/window .getSelection .-anchorNode get-paragraph-num))

(defn get-location []
  (let [sel (.getSelection js/window)
        el (.-anchorNode sel)
        offset (.-anchorOffset sel)
        parent (.-parentNode el)
        paragraph (get-paragraph-parent el)
        paragraph-num (get-paragraph-num paragraph)]
    {:sel sel
     :el el
     :parent parent
     :paragraph paragraph
     :paragraph-num paragraph-num
     :offset offset
     :in-speaker (some speaker-el? [el parent])
     :in-text (some text-el? [el parent])}))

(defn get-selected-p []
  (let [{:keys [selected-el] :as sel} (dom/get-selection)
        classes (dom/get-classes selected-el)
        p (cond
            (some (partial = "transcript-p") classes) selected-el
            (some (partial = "transcript") classes) (dom/get-parent selected-el))]
    (assoc sel :selected-p p)))

(defn transcript-el-id [i k]
  (str "transcript-p-" i "-" (name k)))

(defn footnote-el-id [i k]
  (str "footnote-" i "-" (name k)))

(defn get-transcript-el [i k]
  (dom/get-el (str "#" (transcript-el-id i k))))

(defn get-transcript-ts [i]
  (transcript-el-id i :ts))

(defn transcript-el-class [k]
  (str "transcript-" (name k)))

(defn p->map
  "Converts a paragraph element into a Clojure map"
  [p]
  (let [i (-> (dom/get-id p) (str/replace #"^.+-(\d+)$" "$1") ->int)]
    (assoc
     (->> [:ts :speaker :text]
          (map (fn [k] [k (-> (dom/get-el p (str ".transcript-" (name k)))
                              dom/get-text)]))
          (into {}))
     :i i)))

(defn load-paragraph [i]
  (log :debug "Loading paragraph" i "from local storage")
  (->> [:ts :speaker :text]
       (map (fn [k]
              [k (load-key (transcript-el-id i k))]))
       (into {})))

(defn load-transcript []
  (let [num-paragraphs (load-num-paragraphs)]
    (when (pos-int? num-paragraphs)
      (log :info "Loading transcript from local storage; restoring"
           num-paragraphs "paragraphs")
      (->> (range num-paragraphs)
           (map load-paragraph)))))

(defn load-footnote [i]
  (log :debug "Loading footnote" i "from local storage")
  (->> [:paragraph-num :offset :context :href :link-text :text :permalink]
       (map (fn [k]
              [k (let [v (load-key (footnote-el-id i k))]
                   (when-not (or (= "null" v) (empty? v))
                     v))]))
       (into {})))

(defn load-footnotes []
  (let [num-footnotes (load-num-footnotes)]
    (when (pos-int? num-footnotes)
      (log :info "Loading footnotes from local storage; restoring"
           num-footnotes "footnotes")
      (->> (range num-footnotes)
           ;; Footnotes are indexed starting from 1
           (map (comp load-footnote inc))))))

(declare create-transcript-p)
(declare combine-paragraphs!)
(declare undo-combine-paragraphs!)
(declare insert-paragraph!)
(declare delete-paragraph!)
(declare undo-delete-paragraph!)
(declare insert-footnote!)

(defn handle-undo! [ev]
  (when (.-ctrlKey ev)
    (when-let [op (pop-operation!)]
      (case (:type op)
        :combine-paragraphs
        (undo-combine-paragraphs! op)

        :delete-paragraph
        (undo-delete-paragraph! op)

        :insert-paragraph
        (let [{:keys [new-paragraph-num pos]} op]
          (if (= :after pos)
            (combine-paragraphs! (dec new-paragraph-num) new-paragraph-num
                                 {:save-operation false})
            (delete-paragraph! new-paragraph-num {:save-operation false})))

        :insert-timestamp
        (let [{:keys [paragraph-num]} op]
          (-> (get-transcript-el paragraph-num :ts)
              (dom/set-text! "")))

        :remove-timestamp
        (let [{:keys [paragraph-num ts]} op]
          (-> (get-transcript-el paragraph-num :ts)
              (dom/set-text! ts))))

      (.preventDefault ev))))

(defn handle-delete-paragraph! [ev paragraph-num paragraph-k]
  (when (.-shiftKey ev)
    (let [prev-paragraph-num (if (zero? paragraph-num)
                               0
                               (dec paragraph-num))
          prev-el (get-transcript-el prev-paragraph-num paragraph-k)]
      (delete-paragraph! paragraph-num)
      (log :debug "Deleted paragraph" paragraph-num
           "moving cursor to paragraph" prev-paragraph-num
           "k:" paragraph-k
           "el:" prev-el)
      (dom/move-cursor! prev-el 0))
    (.preventDefault ev)))

(defn handle-speaker-keydown! [ev]
  (let [k (.-key ev)
        el (.-target ev)
        p (get-paragraph-parent el)
        {:keys [offset paragraph-num]} (get-location)
        {:keys [start-offset end-offset]} (dom/get-selection)
        text (dom/get-text el)
        length (count text)
        first-paragraph? (zero? paragraph-num)
        last-paragraph? (= paragraph-num (dec (load-num-paragraphs)))
        at-beginning? (= 0 offset)
        at-end? (= offset length)
        selection-empty? (= start-offset end-offset)
        move-to-text! (fn []
                        (dom/move-cursor! (get-paragraph-el p :text) 0)
                        (.preventDefault ev))
        move-to! (fn [el-key pos]
                   (dom/move-cursor! (get-transcript-el (dec paragraph-num) el-key)
                                     pos)
                   (.preventDefault ev))
        move-left! #(move-to! :text :end)
        move-up! #(let [prev-speaker (get-transcript-el (dec paragraph-num) :speaker)]
                    (if (dom/get-text prev-speaker)
                      (move-to! :speaker 0)
                      (move-left!)))
        move-down! #(let [next-speaker (get-transcript-el (inc paragraph-num) :speaker)
                          next-text (get-transcript-el (inc paragraph-num) :text)]
                      (if (empty? (dom/get-text next-speaker))
                        (dom/move-cursor! next-text 0)
                        (dom/move-cursor! next-speaker :end))
                      (.preventDefault ev))]
    (log :debug "Key pressed in speaker el:"
         {:key k
          :paragraph-num paragraph-num
          :offset offset
          :length length
          :first-paragraph? first-paragraph?
          :last-paragraph? last-paragraph?
          :at-beginning? at-beginning?
          :at-end? at-end?
          :event ev})

    (case k
      "Enter"
      (if at-beginning?
        (do
          (insert-paragraph!
           {:cur-p p
            :new-paragraph-num paragraph-num
            :new-p (create-transcript-p paragraph-num
                                        {:ts (time/sec->ts (get-audio-ts) true)
                                         :text ""})
            :pos :before})
          (.preventDefault ev))
        (move-to-text!))

      "ArrowRight"
      (when (and at-end?
                 selection-empty?)
        (move-to-text!))

      "ArrowDown"
      (when (and at-end?
                 (not last-paragraph?)
                 selection-empty?)
        (move-down!))

      "ArrowUp"
      (when (and at-beginning?
                 (not first-paragraph?)
                 selection-empty?)
        (move-up!))

      "ArrowLeft"
      (when (and at-beginning?
                 (not first-paragraph?)
                 selection-empty?)
        (move-up!))

      "Backspace"
      (when-not (handle-delete-paragraph! ev paragraph-num :speaker)
        (when (and at-beginning?
                   (not first-paragraph?)
                   selection-empty?)
          (if (get-speaker p)
            ;; We have a speaker, so delete the previous paragraph
            (delete-paragraph! (dec paragraph-num))
            ;; Otherwise, combine with the previous paragraph
            (combine-paragraphs! (dec paragraph-num) paragraph-num))
          (.preventDefault ev)))

      "Delete"
      ;; If no paragraph is deleted, normal editing will happen
      (handle-delete-paragraph! ev paragraph-num :speaker)

      "z"
      (handle-undo! ev)

      :ignored)))

(defn handle-text-keydown! [ev]
  (let [k (.-key ev)
        el (.-target ev)
        p (get-paragraph-parent el)
        {:keys [offset paragraph-num]} (get-location)
        {:keys [start-offset end-offset]} (dom/get-selection)
        text (dom/get-text el)
        length (count text)
        first-paragraph? (zero? paragraph-num)
        last-paragraph? (= paragraph-num (dec (load-num-paragraphs)))
        at-beginning? (= 0 offset)
        at-end? (= offset length)
        selection-empty? (= start-offset end-offset)
        move-up! (fn [pos]
                   (dom/move-cursor! (get-transcript-el (dec paragraph-num) :text)
                                     pos)
                   (.preventDefault ev))
        move-down! (fn []
                     (dom/move-cursor! (get-transcript-el (inc paragraph-num) :text) 0)
                     (.preventDefault ev))
        move-to-speaker! (fn []
                           (dom/move-cursor! (get-paragraph-el p :speaker) :end)
                           (.preventDefault ev))]
    (log :debug "Key pressed in text el:"
         {:key k
          :paragraph-num paragraph-num
          :offset offset
          :length length
          :first-paragraph? first-paragraph?
          :last-paragraph? last-paragraph?
          :at-beginning? at-beginning?
          :at-end? at-end?
          :event ev})

    (case k
      ;; Don't allow enter to be pressed at the beginning of text elements to
      ;; prevent blank paragraphs being inserted
      "Enter"
      (when at-beginning?
        (insert-paragraph!
         {:cur-p p
          :new-paragraph-num paragraph-num
          :new-p (create-transcript-p paragraph-num
                                      {:ts (time/sec->ts (get-audio-ts) true)
                                       :text ""})
          :pos :before})
        (.preventDefault ev))

      "ArrowUp"
      (when (and at-beginning?
                 (not first-paragraph?)
                 selection-empty?)
        (move-up! 0))

      "ArrowLeft"
      (when (and at-beginning? selection-empty?)
        (cond
          (get-speaker p) (move-to-speaker!)
          (not first-paragraph?) (move-up! :end)))

      "ArrowDown"
      (when (and at-end? (not last-paragraph?) selection-empty?)
        (move-down!))

      "ArrowRight"
      (when (and at-end? (not last-paragraph?) selection-empty?)
        (move-down!))

      "Backspace"
      (when-not (handle-delete-paragraph! ev paragraph-num :text)
        (when (and at-beginning? selection-empty?)
          (cond
            ;; If we have a speaker, jump to the end of the speaker element
            (get-speaker p)
            (do
              (dom/move-cursor! (get-paragraph-el p :speaker) :end)
              (.preventDefault ev))

            ;; If we don't have a speaker, combine with the previous paragraph
            (not first-paragraph?)
            (do
              (combine-paragraphs! (dec paragraph-num) paragraph-num)
              (.preventDefault ev)))))

      "Delete"
      (when-not (handle-delete-paragraph! ev paragraph-num :text)
        (when (and at-end? (not last-paragraph?) selection-empty?)
          (dom/set-text! el (str text " "))
          (combine-paragraphs! paragraph-num (inc paragraph-num))
          (dom/move-cursor! el (inc offset))
          (.preventDefault ev)))

      "F"
      (when (.-ctrlKey ev)
        (insert-footnote!)
        (.preventDefault ev))

      "z"
      (handle-undo! ev)

      :ignored)))

(defn create-transcript-el [i [k v]]
  (let [el (dom/create-el
            "div"
            {:id (transcript-el-id i k)
             :classes ["transcript" (transcript-el-class k)
                       (when (= :ts k) "link")]})
        v (or v "")]
    (set! (.-innerText el) v)
    (dom/set-attribute! el "contenteditable" (if (= :ts k) "false" "true"))

    ;; Add a click handler to all ts elements that seeks to the specified
    ;; timestamp, if any.
    (when (= :ts k)
      (do
        (dom/set-text! el (str/replace v #"[.]\d+$" ""))
        (.addEventListener el "click"
                           (fn [ev]
                             (when-let [ts (-> ev .-target dom/get-text) not-empty]
                               (log :debug "Seeking audio to timestamp:" ts)
                               (set-audio-ts! ts))))))

    ;; Don't allow enter to be pressed in speaker elements to prevent new
    ;; paragraphs being inserted
    (when (= :speaker k)
      (.addEventListener el "keydown" handle-speaker-keydown!))

    (when (= :text k)
      (.addEventListener el "keydown" handle-text-keydown!))

    el))

(defn create-transcript-els [i paragraph]
  (->> [:ts :speaker :text]
       (map (fn [k] (create-transcript-el i [k (get paragraph k)])))
       (remove nil?)))

(defn create-transcript-p [i paragraph]
  (let [p (dom/create-el "div" {:id (str "transcript-p-" i)
                                :class "transcript-p"})]
    (dom/set-children! p (create-transcript-els i paragraph))
    p))

(defn create-footnote-li [i {:keys [paragraph-num offset context]
                             :as footnote}]
  (let [footnote-num (inc i)
        li (dom/create-el :li {:id (str "footnote-" footnote-num)
                               :class "footnote"})]
    (dom/set-data! li :paragraph-num paragraph-num)
    (dom/set-data! li :offset offset)
    (->> [:num :context :href :link-text :text :permalink]
         (map
          (fn [k]
            (dom/create-el :span {:id (str "footnote-" footnote-num "-" (name k))
                                  :class (case k
                                           :num "link"
                                           :context "footnote-context"
                                           "example")
                                  :text (case k
                                          :num (str "[" footnote-num "]")
                                          :context context
                                          (or (not-empty (footnote k))
                                              (-> (name k) (str/replace "-" " "))))
                                  :attrs {:contenteditable
                                          (not (contains? #{:num :context} k))}})))
         (dom/set-children! li))))

(defn create-footnote
  ([]
   (create-footnote (get-selected-p)))
  ([{:keys [start-offset selected-p] :as p}]
   (let [paragraph-num (get-paragraph-num selected-p)]
     {:context (->> (get-text selected-p)
                             (take start-offset)
                             str/join
                             (re-seq #"\w+\W*")
                             reverse
                             (take footnote-num-context-words)
                             reverse
                             str/join
                             str/trim)
      :paragraph-num paragraph-num
      :offset start-offset})))

(defn update-paragraph! [i p-data]
  (let [p (get-transcript-p i)]
    (doseq [k [:ts :speaker :text]]
      (dom/set-text! (get-transcript-el i k) (p-data k)))
    p))

(defn transcript->elements [transcript]
  (map-indexed create-transcript-p transcript))

(defn clear-transcript! [target-el]
  (dom/clear-children! target-el))

(defn display-transcript! [target-el transcript]
  (doseq [p (transcript->elements transcript)]
    (.appendChild target-el p)))

(defn footnotes->elements [footnotes]
  (map-indexed create-footnote-li footnotes))

(defn clear-footnotes! []
  (dom/clear-children! footnotes-sel))

(defn display-footnotes! [footnotes]
  (dom/set-children! footnotes-sel (footnotes->elements footnotes)))

(defn display-audio-duration! []
  (dom/set-text! "#audio-dur" (time/sec->ts (get-audio-duration) true)))

(defn display-audio-ts! []
  (let [ts (get-audio-ts)]
    (dom/set-text! "#audio-ts" (time/sec->ts ts true))
    ;; Jump to ts changes every 10 seconds unless the cursor is in the texbox
    ;; (i.e. the user is potentially editing the value)
    (when-not (dom/active-element? "#jump-to-ts-text")
      (dom/set-value! "#jump-to-ts-text" (time/sec->ts (- ts (mod ts 5)) true)))))

(defn display-audio-playback-rate! []
  (dom/set-text! "#audio-rate" (str (get-audio-playback-rate) "x")))

(defn init-jump-to-ts! []
  (dom/set-value! "#jump-to-ts-text" "00:00"))

(defn p->ts [p]
  (-> (get-paragraph-el p :ts) dom/get-text))

(defn p->sec [p]
  (-> (p->ts p) time/ts->sec))

(defn p-at-ts
  "Returns the paragraph at the specified timestamp, or if no such paragraph
   exists, the last paragraph before the specified timestamp."
  [ts]
  (let [target-sec (time/ts->sec ts)]
    (->> (dom/get-el "#textbox")
         dom/get-children
         (filter #(<= (p->sec %) target-sec))
         (sort-by p->sec)
         last)))

(defn display-audio! []
  (display-audio-ts!)
  (display-audio-duration!)
  (display-audio-playback-rate!)
  (init-jump-to-ts!)
  (dom/set-styles! "#audio-controls" "display: flex;"))

(defn restore-transcript! [target-el]
  (when-not (get-transcript-p 0)
    (display-transcript! target-el (load-transcript))
    (show-message! "Transcript restored from local storage")))

(defn restore-footnotes! []
  (when-not (get-footnote-li 0)
    (display-footnotes! (load-footnotes))
    (show-message! "Footnotes restored from local storage")))

(defn save-edn! [filename data]
  (let [a (dom/create-el "a")
        blob (js/Blob. [(with-out-str (pprint data))]
                       (clj->js {:type "application/edn"}))]
    (set! (.-href a) (js/URL.createObjectURL blob))
    (set! (.-download a) filename)
    (.click a))
  (show-message! (str "Transcript saved to file: " filename)))

(defn save-transcript! [target-el]
  (let [children (.-childNodes target-el)]
    (doseq [p children]
      (save-paragraph! p))))

(defn save-footnotes! []
  (doseq [li (dom/get-children footnotes-sel)]
    (save-footnote! li)))

(defn export-transcript! []
  (save-edn! (or (load-transcript-filename) "transcript.edn")
             {:transcript (vec (load-transcript))
              :footnotes (vec (load-footnotes))}))

(defn import-transcript! [target-el {:keys [footnotes transcript]}]
  (swap! state assoc :footnotes footnotes, :transcript transcript)
  (clear-transcript! target-el)
  (display-transcript! target-el transcript)
  (clear-footnotes!)
  (display-footnotes! footnotes)
  (hide-import-controls!)
  (clear-storage!)
  (save-transcript! target-el)
  (save-footnotes!)
  (save-num-paragraphs! (count transcript))
  (save-num-footnotes! (count footnotes)))

(defn import-transcript-file! [target-el filename event]
  (let [contents (-> event .-target .-result)
        transcript (read-transcript contents)]
    (log :debug "Loaded file:" contents)
    (import-transcript! target-el transcript)
    (save-transcript-filename! filename))
  (show-message! (str "Transcript imported from file: " filename)))

(defn import-transcript-url! [target-el url]
  (log :debug "Fetching transcript from:" url)
  (p/let [response (js/fetch (js/Request. url))]
    (log :debug "Fetch transcript response:" response)
    (if (.-ok response)
      (do
        (p/->> response
               .text
               read-transcript
               (import-transcript! target-el))
        (save-transcript-url! url)
        (show-message! (str "Transcript imported from URL: " url)))
      (error! (str "Failed to import transcript from URL " url ": "
                   (.-statusText response))))))

(defn read-transcript-file! [target-el event]
  (log :debug "Transcript file selected:" event)
  (let [file (-> event .-target .-files first)
        reader (js/FileReader.)]
    (set! (.-onload reader)
          (partial import-transcript-file! target-el (.-name file)))
    (.readAsText reader file)))

(defn load-audio! [audio-el event]
  (log :debug "Audio file selected:" event)
  (let [file (-> event .-target .-files first)
        filename (.-name file)
        url (js/URL.createObjectURL file)]
    (set! (.-src audio-el) url)
    (save-audio-filename! filename)
    (display-audio!)
    (swap! state assoc :paused true, :audio-opened true)
    (hide-import-controls! (util/get-query-params))))

(defn open-audio-url! [audio-el url]
  (log :debug "Loading audio from URL:" url)
  (save-audio-url! url)
  (swap! state assoc :audio-opened true)
  (hide-import-controls! (util/get-query-params))
  (set! (.-src audio-el) url))

(defn audio-loaded? []
  (or (:audio-filename @state) (:audio-url @state)))

(defn handle-audio-loaded! [_ev]
  (let [{:keys [audio-filename audio-url]} @state
        src (or audio-filename audio-url)
        src-type (if audio-filename "file" "URL")]
    (log :info "Audio loaded; duration:" (get-audio-duration))
    (display-audio!)
    (swap! state assoc :paused true)
    (show-message! (str "Loaded audio from " src-type ": " src))
    (resize-textbox!)))

(defn handle-audio-error! [ev]
  (let [err (-> ev .-target .-error)
        src (or (:audio-filename @state) (:audio-url @state))
        msg (cond
              (= (.-code err) (.-MEDIA_ERR_ABORTED err))
              "Audio playback aborted by user"

              (= (.-code err) (.-MEDIA_ERR_NETWORK err))
              (let [cur-pos (get-audio-ts)
                    dur (get-audio-duration)]
                (str "Audio download failed from URL: " src
                     (if cur-pos
                       (str "; playback position " (time/sec->ts cur-pos)
                            " / " (time/sec->ts dur))
                       "")))

              (= (.-code err) (.-MEDIA_ERR_DECODE err))
              (str "Failed to decode audio: " src)

              (= (.-code err) (.-MEDIA_ERR_SRC_NOT_SUPPORTED err))
              (if (audio-loaded?)
                (str "Audio failed to load from "
                     (if (:audio-filename @state)
                       (str "file: " src "; format not supported")
                       (str "URL: " src "; file not found or format not supported"))))

              :else
              "Unknown audio error")]
    (error! msg)))

(defn play! []
  (let [audio (dom/get-el "audio")
        pos (-> (get-audio-ts) (time/sec->ts false))]
    (log :debug "Playing audio from" pos)
    (.play audio)
    (dom/set-styles! "#play-button" "display: none;")
    (dom/set-styles! "#pause-button" "display: block;")
    (swap! state assoc :playing true)))

(defn pause! []
  (let [audio (dom/get-el "audio")
        pos (-> (get-audio-ts) (time/sec->ts false))]
    (log :debug "Pausing audio at" pos)
    (.pause audio)
    (dom/set-styles! "#play-button" "display: block;")
    (dom/set-styles! "#pause-button" "display: none;")
    (swap! state assoc :playing false)))

(defn play-pause! []
  (when (audio-loaded?)
    (if (:playing @state)
      (pause!)
      (play!))))

(defn seek! [delta]
  (when (audio-loaded?)
    (let [ts (get-audio-ts)
          target-ts (+ ts delta)]
      (log :debug
           "Seeking from" (time/sec->ts ts false)
           "to" (time/sec->ts target-ts false))
      (set-audio-ts! target-ts))))

(defn seek-with-increasing-duration! [direction]
  (let [[state-key direction-multiplier increment-duration-f]
        (case direction
          :forward [:next-seek-forward-duration 1 +]
          :backward [:next-seek-backward-duration -1 -])
        seek-sec (-> (@state state-key)
                     (or (* seek-duration-sec direction-multiplier))
                     (* (get-audio-playback-rate)))]
    (seek! seek-sec)
    ;; Seeking backward again within 1 sec increases the seek duration by 1 sec
    (let [next-seek-sec (increment-duration-f seek-sec 1)]
      (log :debug "Next seek duration:" next-seek-sec "seconds")
      (swap! state assoc state-key next-seek-sec)
      ;; Cancel previous timeouts if any
      (doseq [k [:next-seek-forward-timeout-id :next-seek-backward-timeout-id]
              :let [timeout-id (@state k)
                    dir (if (= :next-seek-forward-timeout-id k)
                          "forward" "backward")]
              :when timeout-id]
        (log :debug "Cancelling" dir "seek timeout:" timeout-id)
        (dom/cancel-timeout! timeout-id)
        (swap! state dissoc k)))
    ;; Reset the seek duration to the default in 1 second
    (doseq [k [:next-seek-forward-duration :next-seek-backward-duration]
            :let [timeout-ms 1000
                  [timeout-id-k dir] (if (= :next-seek-forward-duration k)
                                       [:next-seek-forward-timeout-id "forward"]
                                       [:next-seek-backward-timeout-id "backward"])
                  timeout-id
                  (dom/set-timeout!
                   #(do
                      (log :debug "Resetting next seek" dir "duration to default")
                      (swap! state dissoc k))
                   timeout-ms)]]
      (log :debug "Will reset" dir "seek duration in" timeout-ms "ms; ID:" timeout-id)
      (swap! state assoc timeout-id-k timeout-id))))

(defn seek-backward! []
  (seek-with-increasing-duration! :backward))

(defn seek-forward! []
  (seek-with-increasing-duration! :forward))

(defn set-speed! [delta]
  (when (audio-loaded?)
    (let [rate (get-audio-playback-rate)
          target-rate (-> (+ rate delta) (max min-playback-rate))]
      (log :debug
           "Changing speed from" rate "to" target-rate)
      (set-audio-playback-rate! target-rate))))

(defn speed-up! []
  (set-speed! speed-step))

(defn slow-down! []
  (set-speed! (* speed-step -1)))

(defn jump-to-ts!
  ([]
   (jump-to-ts! (dom/get-value "#jump-to-ts-text")))
  ([target-ts]
   (let [target-el (-> (p-at-ts target-ts) (get-paragraph-el :text))]
     (log :debug "Jumping to timestamp" target-ts
          "and focusing element" target-el)
     (dom/focus-el target-el)
     (set-audio-ts! target-ts)
     target-el)))

(defn renumber-el [el i]
  (let [cur-id (dom/get-id el)
        new-id (str/replace cur-id #"\d+" (str i))]
    (log :debug "Updating id of el from" cur-id "to" new-id)
    (dom/set-id! el new-id)))

(defn set-paragraph-id! [p i]
  (renumber-el p i)
  (doseq [child (dom/get-children p)]
    (renumber-el child i))
  (save-paragraph! p))

(defn set-footnote-id! [li i]
  (renumber-el li i)
  (doseq [child (dom/get-children li)]
    (renumber-el child i)
    (when (= :num (span->footnote-k child))
      (dom/set-text! child (str "[" i "]"))))
  (save-footnote! li))

(defn update-paragraph-ids! [start op]
  (let [f (case op
            :inc inc
            :dec dec)]
    (loop [p (get-transcript-p start)
           paragraph-num (f start)]
      (if p
        (do
          (set-paragraph-id! p paragraph-num)
          (recur (.-nextSibling p) (inc paragraph-num)))
        (save-num-paragraphs! paragraph-num)))))

(defn update-footnote-ids! [start op]
  (let [f (case op
            :inc inc
            :dec dec)]
    (loop [li (get-footnote-li start)
           footnote-num (f start)]
      (if li
        (do
          (set-footnote-id! li footnote-num)
          (recur (.-nextSibling li) (inc footnote-num)))
        (save-num-footnotes! footnote-num)))))

(defn inc-paragraph-ids! [start]
  (update-paragraph-ids! start :inc))

(defn dec-paragraph-ids! [start]
  (update-paragraph-ids! start :dec))

(defn inc-footnote-ids! [start]
  (update-footnote-ids! start :inc))

(defn dec-footnote-ids! [start]
  (update-footnote-ids! start :dec))

(defn insert-paragraph!
  [{:keys [cur-text cur-p new-paragraph-num new-p pos]
    :or {pos :after}}]
  (let [cur-paragraph-num (if (= :after pos)
                            (dec new-paragraph-num)
                            new-paragraph-num)]
    (when (= :after pos)
      ;; Update text of current paragraph
      (let [el (get-paragraph-el cur-p :text)]
        (dom/set-text! el cur-text)
        (save-el! el)))

    ;; Insert the new paragraph
    (if (= :after pos)
      (do
        (log :debug "Inserting paragraph" new-p "after" cur-p)
        (inc-paragraph-ids! new-paragraph-num)
        (dom/insert-child-after! cur-p new-p))
      (do
        (log :debug "Inserting paragraph" new-p "before" cur-p)
        (inc-paragraph-ids! new-paragraph-num)
        (dom/insert-child-before! cur-p new-p)))
    (save-paragraph! new-p)

    ;; Remember this insertion so we can undo it
    (save-operation! {:type :insert-paragraph
                      :pos pos
                      :new-paragraph-num new-paragraph-num})

    (.focus (get-transcript-el new-paragraph-num :text))))

(defn insert-paragraph-from-input! [el]
  (let [cur-paragraph-num (get-paragraph-num el)
        cur-text (-> el (dom/get-child 0) dom/get-text str/trim)
        cur-p (get-paragraph-parent el)
        new-paragraph-num (inc cur-paragraph-num)
        new-text (-> el .-lastChild dom/get-text str/trim)
        new-p (create-transcript-p new-paragraph-num {:text new-text})]
    (insert-paragraph! {:el el
                        :cur-text cur-text
                        :cur-p cur-p
                        :new-paragraph-num new-paragraph-num
                        :new-p new-p
                        :pos :after})))

(defn delete-paragraph!
  ([paragraph-num]
   (delete-paragraph! paragraph-num {:save-operation true}))
  ([paragraph-num {:keys [save-operation]}]
   (let [prev-i (load-paragraph paragraph-num)
         p (get-transcript-p paragraph-num)]
     (log :debug (str "Deleting paragraph " paragraph-num ":")
          prev-i)
     (dom/remove-child! p)
     (dec-paragraph-ids! (inc paragraph-num))
     (when save-operation
       (save-operation! {:type :delete-paragraph
                         :i paragraph-num
                         :prev-i prev-i})))))

(defn undo-delete-paragraph! [{:keys [i prev-i] :as _op}]
  (log :debug "Undo delete paragraph" i prev-i)
  (let [resurrected-p (create-transcript-p i prev-i)]
    (dom/insert-child-before! (get-transcript-p i) resurrected-p)
    (inc-paragraph-ids! (inc i))
    (save-paragraph! resurrected-p)))

(defn combine-paragraphs!
  ([i j]
   (combine-paragraphs! i j {:save-operation true}))
  ([i j {:keys [save-operation]}]
   (if (= 1 (Math/abs (- i j)))
     (let [prev-i (load-paragraph i)
           prev-j (load-paragraph j)
           i-text-el (get-transcript-el i :text)
           j-text-el (get-transcript-el j :text)
           i-end-index (count (dom/get-text i-text-el))
           new-text (->> [i-text-el j-text-el]
                         (map (comp str/trim dom/get-text))
                         (str/join " "))]
       (log :debug (str "Combining paragraphs " j " and " i)
            prev-j prev-i)
       (dom/set-text! i-text-el new-text)
       (delete-paragraph! j {:save-operation false})
       (-> i-text-el (dom/move-cursor! i-end-index))
       (save-key! (transcript-el-id i :text) new-text)
       (when save-operation
         (save-operation! {:type :combine-paragraphs
                           :i i, :j j, :prev-i prev-i, :prev-j prev-j})))
     (error! (str "Only adjacent paragraphs can be combined; "
                  "attempted to combine " i " and " j)))))

(defn undo-combine-paragraphs! [{:keys [i prev-i prev-j] :as _op}]
  (log :debug "Undo combining paragraphs" i "and" (inc i)
       (clj->js prev-i) (clj->js prev-j))
  (let [j (inc i)
        cur-p (get-transcript-p i)
        new-p (create-transcript-p j prev-j)]
    (inc-paragraph-ids! j)
    (dom/insert-child-after! cur-p new-p)
    (save-paragraph! new-p)
    (->> prev-i (update-paragraph! i) save-paragraph!)))

(defn insert-footnote! []
  (let [footnote (create-footnote)
        footnotes (dom/get-children footnotes-sel)
        insert-before
        (some (fn [li]
                (let [paragraph-num (dom/get-data li :paragraph-num)
                      offset (dom/get-data li :offset)]
                  (and (or (> paragraph-num (:paragraph-num footnote))
                           (and (= paragraph-num (:paragraph-num footnote))
                                (> offset (:offset footnote))))
                       li)))
              footnotes)]
    (if insert-before
      (let [i (footnote-num insert-before)]
        (log :debug "Inserting footnote" i "before" insert-before)
        (inc-footnote-ids! i)
        (dom/insert-child-before! insert-before
                                  (create-footnote-li i footnote)))
      (let [i (count footnotes)]
        (log :debug "Inserting footnote" i "at end")
        (save-num-footnotes! (inc i))
        (dom/add-child! footnotes-sel
                        (create-footnote-li i footnote))))))

(comment

  ;; Bug here with inserting footnote in the middle and not saving to local
  ;; storage correctly?

  )

;; select-text! must be declared so it can refer to itself when unregistering
(declare select-text!)

(defn select-example-text! [ev]
  (let [el (.-target ev)]
    (when (dom/has-class? el "example")
      (dom/select-el! el))))

(defn select-text! [ev]
  (select-example-text! ev)
  (let [el (.-target ev)]
    (dom/clear-listeners! state el (.-type ev))))

(defn handle-input! [ev]
  (let [el (.-target ev)
        input-type (.-inputType ev)
        p (get-paragraph-parent el)]
    (log :debug "Got input event:" ev)
    (cond
      (= "insertParagraph" input-type)
      (insert-paragraph-from-input! el)

      :else
      (do
        (save-key! (.-id el) (dom/get-text el))
        (pop-operation!)))
    (dom/remove-class! el "example")
    (when-not (get-paragraph-el p :ts)
      (remove-key! (transcript-el-id (get-paragraph-num p) :ts)))))

(defn insert-timestamp! []
  (let [{:keys [in-speaker in-text offset] :as loc} (get-location)
        ts (time/sec->ts (get-audio-ts) true)]
    (if (or in-speaker (and in-text (= 0 offset)))
      (let [{:keys [paragraph paragraph-num]} loc
            ts-el (get-paragraph-el paragraph :ts)]
        (log :debug "Adding timestamp to paragraph" paragraph-num)
        (dom/set-text! ts-el ts)
        (save-el! ts-el)
        (save-operation! {:type :insert-timestamp
                          :paragraph-num paragraph-num}))
      (let [{:keys [el paragraph paragraph-num]} loc
            cur-text (-> el dom/get-text (subs 0 offset) str/trim)
            new-text (-> el dom/get-text (subs offset) str/trim)
            new-paragraph-num (inc paragraph-num)]
        (insert-paragraph!
         {:cur-text cur-text
          :cur-p paragraph
          :new-paragraph-num new-paragraph-num
          :new-p (create-transcript-p new-paragraph-num
                                      {:ts ts, :text new-text})})))))

(defn remove-timestamp! []
  (let [{:keys [paragraph paragraph-num]} (get-location)
        ts-el (get-paragraph-el paragraph :ts)
        ts (dom/get-text ts-el)]
    (log :debug "Removing timestamp from paragraph" paragraph-num)
    (dom/set-text! ts-el "")
    (save-el! ts-el)
    (save-operation! {:type :remove-timestamp
                      :paragraph-num paragraph-num
                      :ts ts})))

(defn handle-audio-url-button! [_ev]
  (when-let [url (not-empty (dom/get-value "#audio-url"))]
    (open-audio-url! (dom/get-el "audio") url)))

(defn handle-transcript-url-button! [ev]
  (when-let [url (not-empty (dom/get-value "#transcript-url"))]
    (import-transcript-url! (dom/get-el "#textbox") url)))

(defn handle-enter!
  ([on-enter-fn ev]
   (handle-enter! on-enter-fn true ev))
  ([on-enter-fn prevent-default? ev]
   (when (= "Enter" (.-key ev))
     (log :debug "Enter pressed; preventing default handler?" prevent-default?)
     (when prevent-default?
       (.preventDefault ev))
     (on-enter-fn))))

(defn handle-global-keys! [ev]
  (let [handle! (fn [f]
                  (log :debug "Key pressed:" (.-key ev))
                  (try
                    (f)
                    (catch :default e
                      (log :error e)))
                  (.preventDefault ev))]
    (case (.-key ev)
      "Escape" (handle! play-pause!)
      "F1" (handle! seek-backward!)
      "F2" (handle! seek-forward!)
      "F3" (handle! slow-down!)
      "F4" (handle! speed-up!)
      "j" (when (.-ctrlKey ev)
            (handle! insert-timestamp!))
      "k" (when (.-ctrlKey ev)
            (handle! remove-timestamp!))
      "s" (when (.-ctrlKey ev)
            (handle! export-transcript!))
      "z" (handle-undo! ev)
      :unmapped-key)))

(defn init-transcript!
  "If no transcript is available in local storage, create one."
  [transcript-el]
  (when-not (dom/get-children transcript-el)
    (let [p (create-transcript-p 0 {:ts "00:00"
                                    :speaker "Speaker 1"
                                    :text "Insert text here"})]
      (dom/set-child! transcript-el p))
    (let [speaker-el (get-transcript-el 0 :speaker)
          text-el (get-transcript-el 0 :text)]
      (dom/add-class! speaker-el "example")
      (dom/add-listener! state speaker-el "click" select-text!)
      (dom/add-listener! state speaker-el "focus" select-example-text!)
      (dom/add-class! text-el "example")
      (dom/add-listener! state text-el "click" select-text!)
      (dom/add-listener! state text-el "focus" select-example-text!)
      (.focus speaker-el)
      (dom/select-el! speaker-el))
    (save-transcript! transcript-el)
    (save-num-paragraphs! 1)))

(defn load-query-params! []
  (let [{:keys [audio-url transcript-url]} (util/get-query-params)]
    (when audio-url
      (dom/set-value! "#audio-url" audio-url)
      (show-import-controls!))
    (when transcript-url
      (dom/set-value! "#transcript-url" transcript-url)
      (show-import-controls!))))

(defn add-audio-listeners! [audio-el]
  (dom/add-listener! state audio-el "durationchange"
                     handle-audio-loaded!)
  (dom/add-listener! state audio-el "error"
                     handle-audio-error!)
  (dom/add-listener! state audio-el "ratechange"
                     display-audio-playback-rate!)
  (dom/add-listener! state audio-el "timeupdate"
                     display-audio-ts!)
  (dom/add-listener! state "#play-button" "click" play-pause!)
  (dom/add-listener! state "#pause-button" "click" play-pause!)
  (dom/add-listener! state "#rewind-button" "click" seek-backward!)
  (dom/add-listener! state "#fast-forward-button" "click" seek-forward!)
  (dom/add-listener! state "#slow-down-button" "click" slow-down!)
  (dom/add-listener! state "#speed-up-button" "click" speed-up!)
  (dom/add-listener! state "#jump-to-ts-text" "keydown"
                     (partial handle-enter! jump-to-ts!))
  (dom/add-listener! state "#jump-to-ts-button" "click" jump-to-ts!))

(defn add-import-listeners! [transcript-el]
  (dom/add-listener! state "#audio-file" "change"
                     #(let [input (.-target %)]
                        (load-audio! (dom/get-el "audio") %)
                        (dom/set-value! input "")))
  (dom/add-listener! state "#audio-file-button" "click"
                     #(-> (dom/get-el "#audio-file") .click))
  (dom/add-listener! state "#transcript-file" "change"
                     #(let [input (.-target %)]
                        (read-transcript-file! transcript-el %)
                        (dom/set-value! input "")))
  (dom/add-listener! state "#transcript-file-button" "click"
                     #(-> (dom/get-el "#transcript-file") .click))
  (dom/add-listener! state "#audio-url-button" "click"
                     handle-audio-url-button!)
  (dom/add-listener! state "#audio-url" "keydown"
                     (partial handle-enter! handle-audio-url-button!))
  (dom/add-listener! state "#transcript-url-button" "click"
                     handle-transcript-url-button!)
  (dom/add-listener! state "#transcript-url" "keydown"
                     (partial handle-enter! handle-transcript-url-button!)))

(defn add-export-listeners! [transcript-el]
  (dom/add-listener! state "#export" "click"
                     export-transcript!)
  (dom/add-listener! state "#clear" "click"
                     (fn [_ev]
                       (clear-storage!)
                       (clear-transcript! transcript-el)
                       (clear-footnotes!)
                       (init-transcript! transcript-el)
                       (show-message! "Transcript cleared"))))

(defn add-input-listeners! [transcript-el]
  (dom/add-listener! state transcript-el "input"
                     handle-input!))

(defn add-key-listeners! []
  (dom/add-listener! state js/document "keydown"
                     handle-global-keys!))

(defn add-close-message-listeners! []
  (doseq [sel ["#close-message-button" "#textbox"]]
    (dom/add-listener! state sel "click" hide-message!)))

(defn init-ui! []
  (let [transcript-el (dom/get-el "#textbox")]
    (load-query-params!)
    (dom/clear-listeners! state)
    (dom/add-listener! state js/window "resize" resize-textbox!)
    (add-import-listeners! transcript-el)
    (add-export-listeners! transcript-el)
    (add-input-listeners! transcript-el)
    (add-audio-listeners! "audio")
    (add-key-listeners!)
    (add-close-message-listeners!)
    (restore-transcript! transcript-el)
    (restore-footnotes!)
    (init-transcript! transcript-el)
    (resize-textbox!)
    (when-let [anchor-link (util/get-anchor-link)]
      (log :debug "Scrolling to anchor:" anchor-link)
      (dom/scroll-to-el anchor-link))))

(when-not (:initialised @state)
  (util/set-log-level!)
  (init-ui!)
  (swap! state assoc :initialised true))

(comment

  (init-ui!)

  (js/console.clear)

  )
