(ns lt.objs.files
  "Provide fns for doing file related operations. A number of fns
  use the node fs library - https://nodejs.org/api/fs.html"
  (:refer-clojure :exclude [open exists?])
  (:require [lt.object :as object]
            [lt.util.load :as load]
            [clojure.string :as string]
            [lt.objs.platform :as platform]
            [lt.util.js :refer [now]])
  (:require-macros [lt.macros :refer [behavior]]))

(def ^:private fs (js/require "fs"))
(def ^:private fpath (js/require "path"))
(def ^:private wrench (load/node-module "wrench"))
(def ^:private shell (js/require "shell"))
(def ^:private os (js/require "os"))
(def ^:private data-path (platform/get-data-path))

(defn- typelist->index [cur types]
  (let [full (map (juxt :name identity) types)
        ext (for [cur types
                  ext (:exts cur)]
              [ext (:name cur)])]
    {:types (into (:types cur {}) full)
     :exts (into (:exts cur {}) ext)}))

(defn- join [& segs]
  (apply (.-join fpath) (filter string? (map str segs))))

(def ignore-pattern #"(^\..*)|\.class$|target/|svn|cvs|\.git|\.pyc|~|\.swp|\.jar|.DS_Store")

(declare files-obj)

(behavior ::file-types
          :triggers #{:object.instant}
          :type :user
          :desc "Files: Associate file types"
          :params [{:label "types"
                    :example "[{:exts [:wisp],\n  :mime \"text/x-clojurescript\",\n  :name \"Wisp\",\n  :tags [:editor.wisp]}]"}]
          :reaction (fn [this types]
                      (object/merge! files-obj (typelist->index @files-obj types))))

(behavior ::file.ignore-pattern
          :triggers #{:object.instant}
          :type :user
          :exclusive true
          :desc "Files: Set ignore pattern"
          :params [{:label "pattern"
                    :example "\"\\\\.git|\\\\.pyc\""}]
          :reaction (fn [this pattern]
                      (set! ignore-pattern (js/RegExp. pattern))))

(behavior ::open-failed
          :triggers #{:files.open.error}
          :reaction (fn [this path e]
                      ;; Do not log stacktrace because it would be too much noise if multiple file openings fail
                      (js/lt.objs.console.error (str "Failed to open path '" path "' with error: " e))))

(def ^:private files-obj (object/create (object/object* ::files
                                                        :tags [:files]
                                                        :exts {}
                                                        :types {})))

(def line-ending "Current platform-specific line" (.-EOL os))
(def separator "Current platform-specific file separator" (.-sep fpath))
(def ^:private available-drives #{})
(def cwd "Directory process is started in" (js/process.cwd))

(when (= separator "\\")
  (.exec (js/require "child_process") "wmic logicaldisk get name"
         (fn [_ out _]
           (let [ds (rest (.split out #"\r\n|\r|\n"))
                 ds (map #(str (.trim %) separator) (remove empty? ds))]
             (set! available-drives (into #{} ds)))
           )))

(defn basename
  ([path] (.basename fpath path))
  ([path ext] (.basename fpath path ext)))

(defn get-roots []
  (if (= separator "\\")
    available-drives
    #{"/"}))

(defn- get-file-parts [path]
  (let [filename (basename path)
        file-parts (string/split filename #"\.")]
    (loop [parts file-parts
           acc []]
      (if (empty? parts)
        acc
        (recur (rest parts) (conj acc (string/join "." parts)))))))

(defn ext [path]
  (subs (.extname fpath path) 1))

(defn without-ext [path]
  (let [i (.lastIndexOf path ".")]
    (if (> i 0)
      (subs path 0 i)
      path)))

(defn- ext->type [ext]
  (let [exts (:exts @files-obj)
        types (:types @files-obj)]
    (-> exts (get ext) types)))

(defn ext->mode [ext]
  (:mime (ext->type ext)))

(defn path->type [path]
  (->> path
       get-file-parts
       (map #(ext->type (keyword %)))
       (remove nil?)
       first))

(defn path->mode [path]
  (->> path
       get-file-parts
       (map #(ext->mode (keyword %)))
       (remove nil?)
       first))

(defn- determine-line-ending [text]
  (let [text (subs text 0 1000)
        rn (re-seq #"\r\n" text)
        n (re-seq #"[^\r]\n" text)]
    (cond
      (and rn n) line-ending
      (and (not rn) (not n)) line-ending
      (not n) "\r\n"
      :else "\n")))

(defn exists? [path]
  (.existsSync fs path))

(defn stats [path]
  (when (exists? path)
    (.statSync fs path)))

(defn dir? [path]
  (when (exists? path)
    (let [stat (.statSync fs path)]
      (.isDirectory stat))))

(defn file? [path]
  (when (exists? path)
    (let [stat (.statSync fs path)]
      (.isFile stat))))

(defn absolute? [path]
  (boolean (re-seq #"^[\\\/]|([\w]+:[\\\/])" path)))

(defn writable? [path]
  (let [perm (-> (.statSync fs path)
                 (.mode.toString 8)
                 (js/parseInt 10)
                 (str))
        perm (subs perm (- (count perm) 3))]
    (#{"7" "6" "3" "2"} (first perm))))

(defn resolve [base cur]
  (.resolve fpath base cur))

(defn real-path [c]
  (.realpathSync fs c))

(defn- ->file|dir [path f]
  (if (dir? (str path separator f))
    (str f separator)
    (str f)))

(defn- bomless-read [path]
  (let [content (.readFileSync fs path "utf-8")]
    (string/replace content "\uFEFF" "")))

(defn open
  "Open file and in callback return map with file's content in :content"
  [path cb]
  (try
    (let [content (bomless-read path)]
      (when content
        (let [e (ext path)]
          (cb {:content content
               :line-ending (determine-line-ending content)
               :type (or (path->mode path) e)})
          (object/raise files-obj :files.open content))
        ))
    (catch :default e
      (object/raise files-obj :files.open.error path e)
      (when cb (cb nil e)))))

(defn open-sync
  "Open file and return map with file's content in :content"
  [path]
  (try
    (let [content (bomless-read path)]
      (when content
        (let [e (ext path)]
          (object/raise files-obj :files.open content)
          {:content content
           :line-ending (determine-line-ending content)
           :type (or (ext->mode (keyword e)) e)}))
        )
    (catch :default e
      (object/raise files-obj :files.open.error path e)
      nil)))

(defn save
  "Save path with given content. Optional callback called after save"
  [path content & [cb]]
  (try
    (.writeFileSync fs path content)
    (object/raise files-obj :files.save path)
    (when cb (cb))
    (catch :default e
      (object/raise files-obj :files.save.error path e)
      (when cb (cb e)))))

(defn append
  "Append content to path. Optional callback called after append"
  [path content & [cb]]
  (try
    (.appendFileSync fs path content)
    (object/raise files-obj :files.save path)
    (when cb (cb))
    (catch :default  e
      (object/raise files-obj :files.save.error path e)
      (when cb (cb e)))))

(defn trash! [path]
  (.moveItemTotrash shell path))

(defn delete!
  "Delete file or directory"
  [path]
  (if (dir? path)
    (.rmdirSyncRecursive wrench path)
    (.unlinkSync fs path)))

(defn move!
  "Move file or directory to given path"
  [from to]
  (.renameSync fs from to))

(defn copy
  "Copy file or directory to given path"
  [from to]
  (if (dir? from)
    (.copyDirSyncRecursive wrench from to)
    (save to (:content (open-sync from)))))

(defn mkdir
  "Make given directory"
  [path]
  (.mkdirSync fs path))

(defn parent
  "Return directory of path"
  [path]
	(.dirname fpath path))

(defn- next-available-name [path]
  (if-not (exists? path)
    path
    (let [ext (ext path)
          name (without-ext (basename path))
          p (parent path)]
      (loop [x 1
             cur (join p (str name x "." ext))]
        (if-not (exists? cur)
          cur
          (recur (inc x) (join p (str name (inc x) (when ext (str "." ext))))))))))

(defn ls
  "Return directory's files"
  ([path] (ls path nil))
  ([path cb]
   (try
     (let [fs (map (partial ->file|dir path) (.readdirSync fs path))]
       (if cb
         (cb fs)
         fs))
     (catch :default e
       (when cb
         (cb nil))
       nil))))

(defn ls-sync
  "Return directory's files applying ignore-pattern. Takes map of options with keys:

  * :files - When set only returns files
  * :dirs - When set only return directories"
  [path opts]
  (try
    (let [fs (remove #(re-seq ignore-pattern %) (map (partial ->file|dir path) (.readdirSync fs path)))]
      (cond
       (:files opts) (filter #(file? (join path %)) fs)
       (:dirs opts) (filter #(dir? (join path %)) fs)
       :else fs))
    (catch :default e
      (js/lt.objs.console.error e))))

(defn full-path-ls
  "Return directory's files as full paths"
  [path]
  (try
    (doall (map (partial join path) (.readdirSync fs path)))
    (catch :default e
      (js/lt.objs.console.error e))))

(defn dirs
  "Return directory's directories"
  [path]
  (try
    (filter dir? (map (partial join path) (.readdirSync fs path)))
    (catch :default e
      (js/lt.objs.console.error e))))

(defn home
  "Return users' home directory (e.g. ~/) or path under it"
  ([] (home nil))
  ([path]
   (let [h (if (= js/process.platform "win32")
             js/process.env.USERPROFILE
             js/process.env.HOME)]
     (join h (or path separator)))))

(defn lt-home
  "Return LT's home directory"
  ([] load/dir)
  ([path]
   (join (lt-home) path)))

(defn lt-user-dir
  "Return LT's user directory. Used for storing user-related content e.g.
  settings, plugins, logs and caches"
  ([] (lt-user-dir ""))
  ([path]
   (if js/process.env.LT_USER_DIR
     (join js/process.env.LT_USER_DIR path)
     (join data-path path))))

(defn walk-up-find
  "Starting at start path, walk up parent directories and return first path
  whose basename matches find"
  [start find]
  (let [roots (get-roots)]
    (loop [cur start
           prev ""]
      (if (or (empty? cur)
              (roots cur)
              (= cur prev))
        nil
        (if (exists? (join cur find))
          (join cur find)
          (recur (parent cur) cur))))))

(defn relative [a b]
  (.relative fpath a b))

(defn- ->name|path [f & [rel]]
  (let [path (if rel
               (relative rel f)
               f)]
    [(.basename fpath f) path]))

(defn- path-segs [path]
  (let [segs (.split path separator)
        segs (if (or (.extname fpath (last segs))
                     (empty? (last segs)))
               (butlast segs)
               segs)]
    (vec (map #(str % separator) segs))))

(defn filter-walk [func path]
  (loop [to-walk (dirs path)
         found (filterv func (full-path-ls path))]
    (if-not (seq to-walk)
      found
      (let [cur (first to-walk)
            neue (filterv func (full-path-ls cur))]
        (recur (concat (rest to-walk) (dirs cur)) (concat found neue))))))
