(ns metabase.driver.common.parameters.parse
  (:require
   [clojure.string :as str]
   [metabase.driver.common.parameters :as params]
   [metabase.lib.schema.common :as lib.schema.common]
   [metabase.query-processor.error-type :as qp.error-type]
   [metabase.util :as u]
   [metabase.util.i18n :refer [tru]]
   [metabase.util.log :as log]
   [metabase.util.malli :as mu])
  (:import
   (metabase.driver.common.parameters Optional Param)))
(set! *warn-on-reflection* true)
(def ^:private StringOrToken  [:or
                               :string
                               [:map
                                [:token :keyword]
                                [:text  :string]]])
(def ^:private ParsedToken
  [:or
   :string
   (lib.schema.common/instance-of-class Param)
   (lib.schema.common/instance-of-class Optional)])

Returns any adjacent strings in coll combined together

(defn- combine-adjacent-strings
  [coll]
  (apply concat
         (for [subseq (partition-by string? coll)]
           (if (string? (first subseq))
             [(apply str subseq)]
             subseq))))

Returns a vector of [index match] for string or regex pattern found in s

(defn- find-token
  [s pattern]
  (if (string? pattern)
    (when-let [index (str/index-of s pattern)]
      [index pattern])
    (let [m (re-matcher pattern s)]
      (when (.find m)
        [(.start m) (subs s (.start m) (.end m))]))))
(defn- tokenize-one [s pattern token]
  (loop [acc [], s s]
    (if (empty? s)
      acc
      (if-let [[index text] (find-token s pattern)]
        (recur (conj acc (subs s 0 index) {:text text :token token})
               (subs s (+ index (count text))))
        (conj acc s)))))
(def ^:private param-token-patterns
  [["[[" :optional-begin]
   ["]]" :optional-end]
    ;; param-begin should only match the last two opening brackets in a sequence of > 2, e.g.
    ;; [{$match: {{{x}}, field: 1}}] should parse to ["[$match: {" (param "x") ", field: 1}}]"]
   [#"(?s)\{\{(?!\{)" :param-begin]
   ["}}" :param-end]
   ["'" :single-quote]])
(def ^:private sql-token-patterns
  (concat
   [["/*" :block-comment-begin]
    ["*/" :block-comment-end]
    ["--" :line-comment-begin]
    ["\n" :newline]]
   param-token-patterns))
(mu/defn- tokenize :- [:sequential StringOrToken]
  [s                   :- :string
   handle-sql-comments :- :boolean]
  (reduce
   (fn [strs [token-str token]]
     (filter
      (some-fn keyword? seq)
      (mapcat
       (fn [s]
         (if-not (string? s)
           [s]
           (tokenize-one s token-str token)))
       strs)))
   [s]
   (if handle-sql-comments
     sql-token-patterns
     param-token-patterns)))
(defn- param [& parsed]
  (let [[k & more] (combine-adjacent-strings parsed)]
    (when (or (seq more)
              (not (string? k)))
      (throw (ex-info (tru "Invalid '{{...}}' clause: expected a param name")
                      {:type qp.error-type/invalid-query})))
    (let [k (str/trim k)]
      (when (empty? k)
        (throw (ex-info (tru "'{{...}}' clauses cannot be empty.")
                        {:type qp.error-type/invalid-query})))
      (params/->Param k))))
(defn- optional [& parsed]
  (when-not (some params/Param? parsed)
    (throw (ex-info (tru "'[[...]]' clauses must contain at least one '{{...}}' clause.")
                    {:type qp.error-type/invalid-query})))
  (params/->Optional (combine-adjacent-strings parsed)))
(mu/defn- parse-tokens* :- [:tuple
                            [:sequential ParsedToken]
                            [:maybe [:sequential StringOrToken]]]
  [tokens         :- [:maybe [:sequential StringOrToken]]
   optional-level :- :int
   param-level    :- :int
   in-string?     :- :boolean
   comment-mode   :- [:maybe [:enum :block-comment-begin :line-comment-begin]]]
  (loop [acc [],
         [string-or-token & more] tokens
         in-string? in-string?]
    (cond
      (nil? string-or-token)
      (if (or (pos? optional-level) (pos? param-level))
        (throw (ex-info (tru "Invalid query: found '[[' or '{{' with no matching ']]' or '}}'")
                        {:type qp.error-type/invalid-query}))
        [acc nil])
      (string? string-or-token)
      (recur (conj acc string-or-token) more in-string?)
      :else
      (let [{:keys [text token]} string-or-token]
        (case token
          :optional-begin
          (if comment-mode
            (recur (conj acc text) more in-string?)
            (let [[parsed more] (try (let [[parsed more] (parse-tokens* more (inc optional-level) param-level in-string? comment-mode)]
                                       [(apply optional parsed) more])
                                     (catch clojure.lang.ExceptionInfo e
                                       (if (and in-string? (= qp.error-type/invalid-query (:type (ex-data e))))
                                         [text more]
                                         (throw e))))]
              (recur (conj acc parsed) more in-string?)))
          :param-begin
          (if comment-mode
            (recur (conj acc text) more in-string?)
            (let [[parsed more] (try (let [[parsed more] (parse-tokens* more optional-level (inc param-level) in-string? comment-mode)]
                                       [(apply param parsed) more])
                                     (catch clojure.lang.ExceptionInfo e
                                       (if (and in-string? (= qp.error-type/invalid-query (:type (ex-data e))))
                                         [text more]
                                         (throw e))))]
              (recur (conj acc parsed) more in-string?)))
          (:line-comment-begin :block-comment-begin)
          (if (or comment-mode (pos? optional-level) in-string?)
            (recur (conj acc text) more in-string?)
            (let [[parsed more] (parse-tokens* more optional-level param-level in-string? token)]
              (recur (into acc (cons text parsed)) more in-string?)))
          :block-comment-end
          (if (= comment-mode :block-comment-begin)
            [(conj acc text) more]
            (recur (conj acc text) more in-string?))
          :newline
          (if (= comment-mode :line-comment-begin)
            [(conj acc text) more]
            (recur (conj acc text) more in-string?))
          :optional-end
          (if (pos? optional-level)
            [acc more]
            (recur (conj acc text) more in-string?))
          :param-end
          (if (pos? param-level)
            [acc more]
            (recur (conj acc text) more in-string?))
          :single-quote
          (recur (conj acc text) more (not in-string?)))))))
(mu/defn parse :- [:sequential ParsedToken]
  "Attempts to parse parameters in string `s`. Parses any optional clauses or parameters found, and returns a sequence
   of non-parameter string fragments (possibly) interposed with `Param` or `Optional` instances.
   If `handle-sql-comments` is true (default) then we make a best effort to ignore params in SQL comments."
  ([s :- :string]
   (parse s true))
  ([s                   :- :string
    handle-sql-comments :- :boolean]
   (let [tokenized (tokenize s handle-sql-comments)]
     (if (= [s] tokenized)
       [s]
       (do
         (log/tracef "Tokenized native query ->\n%s" (u/pprint-to-str tokenized))
         (u/prog1 (combine-adjacent-strings (first (parse-tokens* tokenized 0 0 false nil)))
           (log/tracef "Parsed native query ->\n%s" (u/pprint-to-str <>))))))))