;; modules/links_db.fnl
;; Provides a simple interface for storing and retrieving shortened links with SQLite

;; -------------------------------------------------------------------------- ;;
;; Dependencies

(local hashids (require :hashids))
(local lume (require :lume))
(local sqlite (require :sqlite))

;; -------------------------------------------------------------------------- ;;
;; Local modules

(local fs_utils (require :modules.fs_utils))

;; -------------------------------------------------------------------------- ;;
;; Constants

(local sql_glob_links_query "
SELECT link_id, link_dest, visit_count FROM links
WHERE (link_id GLOB :glob_pattern OR link_dest GLOB :glob_pattern)
AND is_custom BETWEEN :custom AND 1
ORDER BY created DESC")

;; -------------------------------------------------------------------------- ;;
;; Helper functions

(fn random_whole_number [num_digits]
  "Return a random whole number with `num_digits` digits."
  (math.floor (* (math.random) (^ 10 num_digits))))

(fn generate_link_id []
  "Return a random 6 character base62 link ID."
  (let [seed_length 10
        id_length 6
        encoder (hashids.new (tostring (random_whole_number seed_length)) id_length)]
    (encoder:encode (random_whole_number id_length))))

(fn encode_glob_string [string]
  "Return `string` encoded as a case-insensitive glob string."
  (let [encode_char_fn (fn [char] (.. "[" (string.lower char) (string.upper char) "]"))]
    (string.gsub string "%a" encode_char_fn)))

;; -------------------------------------------------------------------------- ;;
;; Initialize database

;; ---------------------------------- ;;
;; Ensure 'db/' folder exists

(when (not (fs_utils.dir_exists? "db"))
  (print "🚨 ERROR: No folder to place SQLite database files in.\n❓ Did you forget to mount a volume at '/simple-shortener/db'?")
  (os.exit -1))

;; ---------------------------------- ;;
;; Connect to SQLite database

;; Tables:
;; links | Stores information about shortened links
;;   link_id - The unique base62 identifying string for the shortened link
;;   link_dest - The fully qualified URI that the shortened link points at
;;   is_custom - Whether `link_id` was a custom value provided by the user
;;   created - When the link was created (stored as Unix time)
;;   deleted - When the link was deleted (stored as Unix time)
;;             (if zero, the link has not been deleted)

(local db (sqlite {:uri "db/links.db"
                   :links {:link_id {:type "text"
                                     :primary true}
                           :link_dest {:type "text"
                                       :required true}
                           :is_custom {:type "number"
                                       :default 0}
                           :visit_count {:type "number"
                                         :default 0}
                           :created {:type "number"
                                     :default (sqlite.lib.strftime "%s" "now")}
                           :deleted {:type "number"
                                     :default 0}}}))

;; ---------------------------------- ;;
;; Database method overrides

(fn db.links.id_available? [self link_id]
  "Return true if a row with primary key `link_id` is not yet in the database, otherwise return false."
  (= (length (self:__get {:where {: link_id}})) 0))

(fn db.links.where [self where]
  "Return first match against `where` that has not been flagged as deleted.
Returns a table containing the row data on success, otherwise nil."
  (let [where (lume.merge (or where {}) {:deleted 0})]
    (self:__where where)))

(fn db.links.get [self query]
  "Return rows matching `query` that have not been flagged as deleted.
Returns a sequential table of rows."
  (let [where (lume.merge (or query.where {}) {:deleted 0})
        query (lume.merge query {:where where})]
    (self:__get query)))

;; -------------------------------------------------------------------------- ;;
;; Module definition

(local links_db {})

(fn links_db.insert_link [?link_id link_dest]
  "Insert a link to `link_dest` and with optional custom ID `link_id` into the database.
Returns the ID of the link after insertion, or nil if the operation failed."
  (let [link_id (or ?link_id (generate_link_id))
        is_custom (if ?link_id 1 0)]
    (if (and (db.links:id_available? link_id)
             (db.links:insert {: link_id : link_dest : is_custom}))
        link_id
        nil)))

(fn links_db.link_id_available? [link_id]
  "Return true if a link with ID `link_id` is not yet in the database, otherwise return false."
  (db.links:id_available? link_id))

(fn links_db.redirect_link [link_id link_dest]
  "Redirect the link with ID `link_id` to new destination `link_dest` in the database.
Returns true on success, otherwise returns false."
  (and (db.links:where {: link_id})
       (db.links:update {:where {: link_id}
                         :set {: link_dest}})))

(fn links_db.delete_link [link_id]
  "Flag the link with ID `link_id` as deleted in the database.
If the link has a non-zero is_custom column, then the row will deleted so that the ID may be reused.
Returns true on success, otherwise returns false."
  (let [current_time (os.time)
        link (db.links:where {: link_id})]
    (if (= link.is_custom 0)
        (db.links:update {:where {: link_id}
                          :set {:deleted current_time}})
        (db.links:remove {: link_id}))))

(fn links_db.increment_link_visit_count [link_id]
  "Increment the recorded visit count of the link with ID `link_id` in the database.
Returns true on success, otherwise returns false."
  (let [link (db.links:where {: link_id})]
    (db.links:update {:where {: link_id}
                      :set {:visit_count (+ link.visit_count 1)}})))

(fn links_db.fetch_link_dest [link_id]
  "Return the destination of the link with ID `link_id` from the database.
Returns a URI string on success, otherwise returns nil."
  (let [link (db.links:where {: link_id})]
    (?. link :link_dest)))

(fn links_db.fetch_links [limit ?custom]
  "Return up to `limit` of the most recent link_id/link_dest pairs from the database.
If `?custom` is non-nil, return only links with custom IDs.
Returns a sequential table of links."
  (db.links:get {:select [:link_id :link_dest :visit_count]
                 :where (when ?custom {:is_custom 1})
                 :order_by {:desc :created}
                 : limit}))

(fn links_db.search_links [query limit ?custom]
  "Return up to `limit` of the most recent link_id/link_dest pairs from the database that contain `query`.
Both the ID and the destination URI of each link are searched for `query`.
If `?custom` is non-nil, return only links with custom IDs.
Returns a sequential table of links."
  (let [glob_pattern (.. "*" (encode_glob_string query) "*")
        custom (if ?custom 1 0)
        search_fn #(db:eval sql_glob_links_query {: glob_pattern : custom})
        results (db:with_open search_fn)]
    (if (= (type results) "table")
        results
        [])))

;; -------------------------------------------------------------------------- ;;
;; Return module

links_db
