(ns leiningen.jar
  "Package up all the project's files into a jar file."
  (:require [leiningen.pom :as pom]
            [leiningen.clean :as clean]
            [leiningen.compile :as compile]
            [leiningen.core.project :as project]
            [leiningen.core.eval :as eval]
            [leiningen.core.main :as main]
            [leiningen.core.utils :as utils]
            [bultitude.core :as b]
            [clojure.set :as set]
            [clojure.string :as string]
            [clojure.java.io :as io])
  (:import (java.util.jar Manifest JarEntry JarOutputStream)
           (java.io BufferedOutputStream FileOutputStream
                    ByteArrayInputStream)))

(def ^:deprecated whitelist-keys
  "Deprecated: use leiningen.core.project/whitelist-keys instead"
  project/whitelist-keys)

(defn- unix-path [path]
  (.replace path "\\" "/"))

(defn- default-manifest [project]
  {"Created-By" (str "Leiningen " (main/leiningen-version))
   "Built-By" (System/getProperty "user.name")
   "Build-Jdk" (System/getProperty "java.version")
   "Leiningen-Project-ArtifactId" (:name project)
   "Leiningen-Project-GroupId" (:group project)
   "Leiningen-Project-Version" (:version project)})

(declare ^:private manifest-entry)

(defn- manifest-entries [project manifest-seq]
  (map (partial manifest-entry project) manifest-seq))

(defn- manifest-entry [project [k v]]
  (cond (symbol? v) (manifest-entry project [k (resolve v)])
        (fn? v) (manifest-entry project [k (v project)])
        (coll? v) (->> v ;; Sub-manifest = manifest section
                       (manifest-entries project)
                       (cons (str "\nName: " (name k) "\n"))
                       (string/join))
        :else (->> (str (name k) ": " v)
                   (partition-all 70)  ;; Manifest spec says lines <= 72 chars
                   (map (partial apply str))
                   (string/join "\n ")  ;; Manifest spec says join with "\n "
                   (format "%s\n"))))

(defn- place-sections-last
  "Places sections at the end of the manifest seq, as specified by the
  Manifest spec. Retains ordering otherwise (if mf is ordered)."
  [mf]
  (sort-by val (fn [v1 v2]
                 (and (not (coll? v1)) (coll? v2)))
           (seq mf)))

(defn ^:internal make-manifest [project]
  (let [project-manifest (into {} (:manifest project))
        default-manifest' (cond-> (default-manifest project)
                            ;; Add default "Main-Class" only if :main is not
                            ;; explicitly set to nil
                            (:main project :not-found)
                            (assoc "Main-Class"
                                   (munge (str (:main project 'clojure.main)))))]
    (->> (merge default-manifest' project-manifest)
         ;; manifest's "Main-Class" always overrides default "Main-Class"
         place-sections-last
         (manifest-entries project)
         (cons "Manifest-Version: 1.0\n") ;; Manifest-Version line must be first
         (string/join "")
         .getBytes
         ByteArrayInputStream.
         Manifest.)))

(defn ^:internal manifest-map [manifest]
  (let [attrs (.getMainAttributes manifest)]
    (zipmap (map str (keys attrs)) (vals attrs))))

(defn- added-file?
  "Returns true if the file is already added to the jar, false otherwise. Prints
  a warning if the file is not a directory."
  [file relative-path added-paths]
  ;; Path may be blank if it is the root path
  (if (or (string/blank? relative-path) (added-paths relative-path))
    (do
      (when-not (.isDirectory file)
        (main/info "Warning: skipped duplicate file:" relative-path))
      true)))

(defn- skip-file?
  "Skips the file if it doesn't exist. If the file is not the
  root-file (specified by :path), will also skip it if it is a dotfile, emacs
  backup file or matches an exclusion pattern."
  [file relative-path root-file exclusion-patterns inclusion-patterns]
  (or (not (.exists file))
      (and
       (not= file root-file)
       (not (some #(re-find % relative-path) inclusion-patterns))
       (or
        (re-find #"^\.?#" (.getName file))
        (re-find #"~$" (.getName file))
        (some #(re-find % relative-path) exclusion-patterns)))))

(defmulti ^:private copy-to-jar (fn [project jar-os acc spec] (:type spec)))

(defn- relativize-path
  "Relativizes a path: Removes the root-path of a path if not already removed."
  [path root-path]
  (if (.startsWith path root-path)
    (.substring path (.length root-path))
    path))

(defn- full-path ;; Q: is this a good name for this action?
  "Appends the path string with a '/' if the file is a directory."
  [file path]
  (if (.isDirectory file)
    (str path "/")
    path))

(defn- dir-string
  "Returns the file's directory as a string, or the string representation of the
  file itself if it is a directory."
  [file]
  (if-not (.isDirectory file)
    (str (.getParent file) "/")
    (str file "/")))

(defn- put-jar-entry!
  "Adds a jar entry to the Jar output stream."
  [jar-os file path]
  (.putNextEntry jar-os (doto (JarEntry. path)
                       (.setTime (.lastModified file))))
  (when-not (.isDirectory file)
    (io/copy file jar-os)))

(defmethod copy-to-jar :path [project jar-os acc spec]
  (let [root-file (io/file (:path spec))
        root-dir-path (unix-path (dir-string root-file))
        paths (for [child (file-seq root-file)
                    :let [path (relativize-path
                                 (full-path child (unix-path (str child)))
                                 root-dir-path)]]
                (when-not (or (skip-file? child path root-file
                                          (:jar-exclusions project)
                                          (:jar-inclusions project))
                              (added-file? child path acc))
                  (put-jar-entry! jar-os child path)
                  path))]
    (into acc paths)))

(defmethod copy-to-jar :paths [project jar-os acc spec]
  (reduce (partial copy-to-jar project jar-os) acc
          (for [path (:paths spec)]
            {:type :path :path path})))

(defmethod copy-to-jar :bytes [project jar-os acc spec]
  (let [path (unix-path (:path spec))]
    (when-not (some #(re-find % path) (:jar-exclusions project))
      (.putNextEntry jar-os (JarEntry. path))
      (let [bytes (if (string? (:bytes spec))
                    (.getBytes (:bytes spec))
                    (:bytes spec))]
        (io/copy (ByteArrayInputStream. bytes) jar-os)))
    (conj acc path)))

(defmethod copy-to-jar :fn [project jar-os acc spec]
  (let [f (eval (:fn spec))
        dynamic-spec (f project)]
    (copy-to-jar project jar-os acc dynamic-spec)))

(defn write-jar [project out-file filespecs]
  (with-open [jar-os (-> out-file
                         (FileOutputStream.)
                         (BufferedOutputStream.)
                         (JarOutputStream. (make-manifest project)))]
    (let [jar-paths (reduce (partial copy-to-jar project jar-os)
                            #{}
                            filespecs)]
      (if (:main project)
        (let [main-path (str (-> (string/replace (:main project) "." "/")
                                 (string/replace "-" "_"))
                              ".class")]
          (when-not (some #{main-path} jar-paths)
            (main/info "Warning: The Main-Class specified does not exist"
                       "within the jar. It may not be executable as expected."
                       "A gen-class directive may be missing in the namespace"
                       "which contains the main method, or the namespace has not"
                       "been AOT-compiled."))))
      jar-paths)))

;; TODO: change in 3.0; this is hideous
(defn- filespecs [project]
  (let [root-files (.list (io/file (:root project)))
        readmes (filter (partial re-find #"^(?i)readme") root-files)
        licenses (filter (partial re-find #"^(?i)license") root-files)
        scope (partial format "META-INF/leiningen/%s/%s/%s"
                       (:group project) (:name project))]
    (concat [{:type :bytes
              :path (format "META-INF/maven/%s/%s/pom.xml"
                            (:group project) (:name project))
              :bytes (.getBytes (pom/make-pom project))}
             {:type :bytes :path (scope "project.clj")
              :bytes (.getBytes (slurp (str (:root project) "/project.clj")))}]
            (for [doc (map (partial io/file (:root project))
                        (concat readmes licenses))
                  :when (.isFile doc)]
              {:type :bytes :path (scope (.getName doc))
               :bytes (.getBytes (slurp doc))})
            [{:type :path :path (:compile-path project)}
             {:type :paths :paths (:resource-paths project)}]
            (if-not (:omit-source project)
              [{:type :paths
                :paths (set (concat (:source-paths project)
                                    (:java-source-paths project)))}])
            (:filespecs project))))

;; Split out backwards-compatibility. Collapse into get-jar-filename for 3.0
(defn get-classified-jar-filename [project classifier]
  (let [target (doto (io/file (:target-path project)) utils/mkdirs)
        suffix (if classifier (str "-" (name classifier) ".jar") ".jar")
        name-kw (if (= classifier :standalone) :uberjar-name :jar-name)
        jar-name (or (project name-kw) (str (:name project) "-%s" suffix))
        jar-name (format jar-name (:version project))]
    (str (io/file target jar-name))))

(defn- compile-main? [{:keys [main source-paths] :as project}]
  (and main (not (:skip-aot (meta main)))
       (some #(or (.exists (io/file % (b/path-for main "clj")))
                  (.exists (io/file % (b/path-for main "cljc")))) source-paths)))

(def ^:private implicit-aot-warning
  (delay
   (main/info "Warning: specified :main without including it in :aot."
              "\nImplicit AOT of :main will be removed in Leiningen 3.0.0."
              "\nIf you only need AOT for your uberjar, consider adding"
              ":aot :all into your\n:uberjar profile instead.")))

(defn warn-implicit-aot [orig-project]
  (let [project (project/merge-profiles orig-project [:uberjar])]
    (when (and (:main project) (not (:skip-aot (meta (:main project))))
               (not= :all (:aot project))
               (not= [:all] (:aot project))
               (not (some #{(:main project)} (:aot project)))
               (not (some #(re-matches % (str (:main project)))
                          (filter compile/regex? (:aot project)))))
      (force implicit-aot-warning))))

;; TODO: remove for 3.0
(defn- add-main [project given-main]
  (warn-implicit-aot project)
  (let [project (if given-main
                  (assoc project :main (symbol given-main))
                  project)]
    (if (and (compile-main? project)
             (not= :all (:aot project))
             (not= [:all] (:aot project)))
      (update-in project [:aot] conj (:main project))
      project)))

(defn- process-project
  "Like update-in, but for preparing projects for (uber)jaring. f is a function
  that will take the old project and any supplied args and return the new
  project, but with whitelisted keys retained and with the main argument
  inserted if provided."
  [project main f & args]
  (-> (apply f project args)
      (project/retain-whitelisted-keys project)
      (add-main main)))

(defn- preprocess-project [project & [main]]
  (process-project project main project/unmerge-profiles
                   (project/non-leaky-profiles project)))

(defn- get-jar-filename*
  [project uberjar?]
  (get-classified-jar-filename project (when uberjar? :standalone)))

(defn get-jar-filename [project & [uberjar?]]
  (get-jar-filename* (preprocess-project project) uberjar?))

(defn build-jar
  "Build a jar for the given project and jar-file."
  [project jar-file]
  (eval/prep project)
  (write-jar project jar-file (filespecs project))
  (main/info "Created" (str jar-file))
  jar-file)

(defn main-jar
  [project provided-profiles main]
  (let [project (process-project project main project/merge-profiles
                                 provided-profiles)]
    {[:extension "jar"] (build-jar project (get-jar-filename* project nil))}))

(defn classifier-jar
  "Package up all the project's classified files into a jar file.

Create a $PROJECT-$VERSION-$CLASSIFIER.jar file containing project's source
files as well as .class files if applicable. The classifier is looked up in the
project`s :classifiers map. If it's a map, it's merged like a profile. If it's a
keyword, it's looked up in :profiles before being merged."
  [{:keys [target-path] :as project} provided-profiles classifier spec]
  (when (:dependencies spec)
    (main/warn
     "WARNING: Classifier specifies :dependencies which will be ignored."))
  (let [profiles (concat provided-profiles [::target ::classifier])
        target-profile {:target-path
                        (.getPath (io/file target-path (name classifier)))}
        project (-> project
                    (vary-meta assoc-in [:profiles ::classifier] spec)
                    (vary-meta assoc-in [:profiles ::target] target-profile)
                    (process-project nil project/merge-profiles profiles))]

    [[:classifier (name classifier) :extension "jar"]
     (build-jar project (get-classified-jar-filename project classifier))]))

(defn classifier-jars
  "Package up all the project's classified files into jar files.

Create a $PROJECT-$VERSION-$CLASSIFIER.jar file for each entry in the project's
:classifiers. Returns a map of :classifier/:extension coordinates to files."
  [{:keys [classifiers] :as project} provided-profiles]
  (into {}
        (map #(apply classifier-jar project provided-profiles %) classifiers)))

(defn jar
  "Package up all the project's files into a jar file.

Create a $PROJECT-$VERSION.jar file containing project's source files as well
as .class files if applicable. If project.clj contains a :main key, the -main
function in that namespace will be used as the main-class for executable jar.

With an argument, the jar will be built with an alternate main."
  ([project main]
     (utils/with-write-permissions (:root project)
       (when (:auto-clean project true)
         (clean/clean project))
       (let [scoped-profiles (set (project/pom-scope-profiles project :provided))
             default-profiles (set (project/expand-profile project :default))
             provided-profiles (remove
                                (set/difference default-profiles scoped-profiles)
                                (-> project meta :included-profiles))
             project (preprocess-project project main)]
         (merge (main-jar project provided-profiles main)
                (classifier-jars project provided-profiles)))))
  ([project] (jar project nil)))
