(ns ajet.chat.auth-gw.auth
  "Authentication — session and token validation, creation, and destruction.

   Token format: 32 random bytes -> base64url encoded (43 chars).
   Stored as bcrypt hash in the database.
   Sessions use rolling expiry (default 30 days), extended on each valid request."
  (:require [clojure.string :as str]
            [clojure.tools.logging :as log]
            [ajet.chat.shared.db :as db])
  (:import [at.favre.lib.crypto.bcrypt BCrypt]
           [java.security SecureRandom]
           [java.util Base64]
           [java.time Instant Duration]
           [java.sql Timestamp]))

;;; ---------------------------------------------------------------------------
;;; Token generation
;;; ---------------------------------------------------------------------------

(def ^:private secure-random (SecureRandom.))
(def ^:private token-byte-length 32)
(def ^:private bcrypt-cost 12)

(defn- generate-token-bytes
  "Generate cryptographically random bytes."
  ^bytes []
  (let [buf (byte-array token-byte-length)]
    (.nextBytes secure-random buf)
    buf))

(defn- base64url-encode
  "Encode bytes to base64url string (no padding)."
  ^String [^bytes data]
  (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) data))

(defn- base64url-decode
  "Decode a base64url string to bytes."
  ^bytes [^String s]
  (.decode (Base64/getUrlDecoder) s))

(defn- bcrypt-hash-bytes
  "Hash raw token chars with bcrypt, returning the hash as a string."
  ^String [^chars token-chars]
  (.hashToString (BCrypt/withDefaults) bcrypt-cost token-chars))

(defn- bcrypt-verify
  "Verify a raw token string against a bcrypt hash. Returns true if match."
  [^String token ^String hash]
  (let [result (.verify (BCrypt/verifyer) (.getBytes token "UTF-8") (.getBytes hash "UTF-8"))]
    (.verified result)))

(defn- generate-raw-token
  "Generate a random token. Returns the raw base64url-encoded string."
  []
  (base64url-encode (generate-token-bytes)))

;;; ---------------------------------------------------------------------------
;;; Timestamp helpers
;;; ---------------------------------------------------------------------------

(defn- now-instant []
  (Instant/now))

(defn- instant->timestamp [^Instant inst]
  (Timestamp/from inst))

(defn- session-expiry
  "Calculate session expiry from now + ttl-days."
  [ttl-days]
  (instant->timestamp (.plus (now-instant) (Duration/ofDays ttl-days))))

(defn- expired?
  "Check if a timestamp is in the past."
  [^Timestamp ts]
  (.before ts (instant->timestamp (now-instant))))

;;; ---------------------------------------------------------------------------
;;; Session validation
;;; ---------------------------------------------------------------------------

(defn validate-session
  "Extract and validate the session cookie.

   Looks up the session by token hash in the sessions table, verifies the
   bcrypt hash matches, and checks expiry.

   Returns a map with user info on success:
     {:user-id ... :user-role ... :session-id ...}
   Returns nil on failure (invalid/expired/missing)."
  [ds cookie-value]
  (when (and cookie-value (not (empty? cookie-value)))
    (try
      ;; Look up all non-expired sessions and verify against each
      ;; In practice, we rely on bcrypt verification being the gate
      (let [sessions (db/execute! ds
                       {:select [:s.id :s.user-id :s.token-hash :s.expires-at
                                 :u.username :u.display-name :u.email]
                        :from   [[:sessions :s]]
                        :join   [[:users :u] [:= :s.user-id :u.id]]
                        :where  [:> :s.expires-at (instant->timestamp (now-instant))]})]
        ;; Find the session whose hash matches the provided token
        (some (fn [session]
                (when (bcrypt-verify cookie-value (:token-hash session))
                  {:session-id   (:id session)
                   :user-id      (str (:user-id session))
                   :username     (:username session)
                   :display-name (:display-name session)
                   :email        (:email session)}))
              sessions))
      (catch Exception e
        (log/error e "Error validating session")
        nil))))

(defn validate-api-token
  "Extract and validate a Bearer token from the Authorization header.

   Looks up the token in the api_tokens table, verifies bcrypt hash,
   checks expiry.

   Returns a map on success:
     {:api-user-id ... :user-id ... :scopes [...]}
   Returns nil on failure."
  [ds authorization-header]
  (when authorization-header
    (let [parts (str/split authorization-header #"\s+" 2)]
      (when (and (= "Bearer" (first parts)) (second parts))
        (let [raw-token (second parts)]
          (try
            (let [tokens (db/execute! ds
                           {:select [:at.id :at.api-user-id :at.token-hash
                                     :at.expires-at :at.scopes
                                     :au.user-id :au.community-id]
                            :from   [[:api_tokens :at]]
                            :join   [[:api_users :au] [:= :at.api-user-id :au.id]]
                            :where  [:or
                                     [:= :at.expires-at nil]
                                     [:> :at.expires-at (instant->timestamp (now-instant))]]})]
              (some (fn [token-row]
                      (when (bcrypt-verify raw-token (:token-hash token-row))
                        {:api-user-id  (str (:api-user-id token-row))
                         :user-id      (str (:user-id token-row))
                         :community-id (str (:community-id token-row))
                         :scopes       (:scopes token-row)}))
                    tokens))
            (catch Exception e
              (log/error e "Error validating API token")
              nil)))))))

(defn validate-webhook-token
  "Validate a Bearer token for webhook incoming requests.

   Looks up the token in the webhooks table and verifies bcrypt hash.

   Returns a map on success:
     {:webhook-id ... :community-id ... :channel-id ...}
   Returns nil on failure."
  [ds authorization-header]
  (when authorization-header
    (let [parts (str/split authorization-header #"\s+" 2)]
      (when (and (= "Bearer" (first parts)) (second parts))
        (let [raw-token (second parts)]
          (try
            (let [webhooks (db/execute! ds
                             {:select [:id :community-id :channel-id
                                       :token-hash :name]
                              :from   [:webhooks]})]
              (some (fn [wh]
                      (when (bcrypt-verify raw-token (:token-hash wh))
                        {:webhook-id   (str (:id wh))
                         :community-id (str (:community-id wh))
                         :channel-id   (str (:channel-id wh))
                         :webhook-name (:name wh)}))
                    webhooks))
            (catch Exception e
              (log/error e "Error validating webhook token")
              nil)))))))

;;; ---------------------------------------------------------------------------
;;; Session TTL extension
;;; ---------------------------------------------------------------------------

(defn extend-session-ttl!
  "Asynchronously extend the session's expires_at. Fire-and-forget via future."
  [ds session-id ttl-days]
  (future
    (try
      (db/execute! ds
        {:update :sessions
         :set    {:expires-at (session-expiry ttl-days)}
         :where  [:= :id session-id]})
      (catch Exception e
        (log/warn e "Failed to extend session TTL for" session-id)))))

;;; ---------------------------------------------------------------------------
;;; Session creation
;;; ---------------------------------------------------------------------------

(defn create-session!
  "Create a new session for a user.

   Generates a random token, bcrypt hashes it, inserts into sessions table.
   Returns the raw token (to be set as cookie value)."
  [ds user-id ttl-days]
  (let [raw-token  (generate-raw-token)
        token-hash (bcrypt-hash-bytes (.toCharArray raw-token))
        session-id (java.util.UUID/randomUUID)
        now-ts     (instant->timestamp (now-instant))
        expires    (session-expiry ttl-days)]
    (db/execute! ds
      {:insert-into :sessions
       :values [{:id         session-id
                 :user-id    (if (instance? java.util.UUID user-id)
                               user-id
                               (java.util.UUID/fromString (str user-id)))
                 :token-hash token-hash
                 :expires-at expires
                 :created-at now-ts}]})
    (log/info "Created session" session-id "for user" user-id)
    raw-token))

;;; ---------------------------------------------------------------------------
;;; Session destruction
;;; ---------------------------------------------------------------------------

(defn destroy-session!
  "Destroy a session by deleting from the database.

   Returns a Ring response map fragment with cleared cookie."
  [ds cookie-value cookie-name cookie-secure?]
  (when (and cookie-value (not (empty? cookie-value)))
    ;; Find and delete matching session
    (try
      (let [sessions (db/execute! ds
                       {:select [:id :token-hash]
                        :from   [:sessions]})]
        (doseq [session sessions]
          (when (bcrypt-verify cookie-value (:token-hash session))
            (db/execute! ds
              {:delete-from :sessions
               :where       [:= :id (:id session)]})
            (log/info "Destroyed session" (:id session)))))
      (catch Exception e
        (log/warn e "Error destroying session"))))
  ;; Return cleared cookie header
  {:cookies {cookie-name {:value     ""
                          :path      "/"
                          :max-age   0
                          :http-only true
                          :secure    cookie-secure?
                          :same-site :lax}}})

;;; ---------------------------------------------------------------------------
;;; User lookup / creation for OAuth
;;; ---------------------------------------------------------------------------

(defn find-user-by-oauth
  "Look up a user by OAuth provider and provider user ID.

   Returns the user map or nil."
  [ds provider provider-user-id]
  (db/execute-one! ds
    {:select [:u.id :u.username :u.display-name :u.email :u.avatar-url :u.created-at]
     :from   [[:users :u]]
     :join   [[:oauth_accounts :oa] [:= :oa.user-id :u.id]]
     :where  [:and
              [:= :oa.provider (name provider)]
              [:= :oa.provider-user-id (str provider-user-id)]]}))

(defn create-user-from-oauth!
  "Create a new user and linked OAuth account from provider profile data.

   profile: {:username, :display-name, :email, :avatar-url,
             :provider, :provider-user-id, :provider-username}

   Returns the created user map."
  [ds profile]
  (let [user-id    (java.util.UUID/randomUUID)
        oauth-id   (java.util.UUID/randomUUID)
        now-ts     (instant->timestamp (now-instant))
        ;; Ensure unique username — append random suffix if collision
        base-username (:username profile)
        user       (db/execute-one! ds
                     {:insert-into :users
                      :values [{:id           user-id
                                :username     base-username
                                :display-name (or (:display-name profile) base-username)
                                :email        (or (:email profile) "")
                                :avatar-url   (:avatar-url profile)
                                :created-at   now-ts}]
                      :returning [:*]})]
    ;; Create OAuth account link
    (db/execute! ds
      {:insert-into :oauth_accounts
       :values [{:id               oauth-id
                 :user-id          user-id
                 :provider         (name (:provider profile))
                 :provider-user-id (str (:provider-user-id profile))
                 :provider-username (:provider-username profile)
                 :created-at       now-ts}]})
    (log/info "Created user" user-id "via OAuth" (name (:provider profile))
              "provider-user-id" (:provider-user-id profile))
    user))

(defn count-users
  "Return the total number of users in the database."
  [ds]
  (let [result (db/execute-one! ds
                 {:select [[[:count :*] :cnt]]
                  :from   [:users]})]
    (or (:cnt result) 0)))

(defn find-user-by-username
  "Look up a user by username. Returns the user map or nil."
  [ds username]
  (db/execute-one! ds
    {:select [:*]
     :from   [:users]
     :where  [:= :username username]}))

;;; ---------------------------------------------------------------------------
;;; OAuth Provider queries (DB-stored providers)
;;; ---------------------------------------------------------------------------

(defn list-oauth-providers
  "Return all enabled OAuth providers from the database, ordered by sort_order."
  [ds]
  (db/execute! ds
    {:select [:*]
     :from   [:oauth-providers]
     :where  [:= :enabled true]
     :order-by [[:sort-order :asc] [:created-at :asc]]}))

(defn list-all-oauth-providers
  "Return all OAuth providers from the database (including disabled)."
  [ds]
  (db/execute! ds
    {:select [:*]
     :from   [:oauth-providers]
     :order-by [[:sort-order :asc] [:created-at :asc]]}))

(defn count-oauth-providers
  "Count enabled OAuth providers."
  [ds]
  (let [result (db/execute-one! ds
                 {:select [[[:count :*] :cnt]]
                  :from   [:oauth-providers]
                  :where  [:= :enabled true]})]
    (or (:cnt result) 0)))

(defn find-oauth-provider-by-slug
  "Look up an OAuth provider by slug. Returns the provider row or nil."
  [ds slug]
  (db/execute-one! ds
    {:select [:*]
     :from   [:oauth-providers]
     :where  [:and [:= :slug slug] [:= :enabled true]]}))

(defn insert-oauth-provider!
  "Insert a new OAuth provider. Returns the created row."
  [ds provider-map]
  (db/execute-one! ds
    {:insert-into :oauth-providers
     :values [provider-map]
     :returning [:*]}))

(defn delete-oauth-provider!
  "Delete an OAuth provider by ID."
  [ds id]
  (db/execute! ds
    {:delete-from :oauth-providers
     :where [:= :id id]}))

;;; ---------------------------------------------------------------------------
;;; System settings
;;; ---------------------------------------------------------------------------

(defn get-system-setting
  "Read a system setting value by key. Returns the string value or nil."
  [ds key]
  (:value (db/execute-one! ds
            {:select [:value]
             :from   [:system-settings]
             :where  [:= :key key]})))

(defn set-system-setting!
  "Upsert a system setting."
  [ds key value]
  (db/execute! ds
    {:insert-into :system-settings
     :values [{:key key :value value :updated-at (instant->timestamp (now-instant))}]
     :on-conflict [:key]
     :do-update-set {:value value :updated-at (instant->timestamp (now-instant))}}))

;;; ---------------------------------------------------------------------------
;;; Invite helpers
;;; ---------------------------------------------------------------------------

(defn find-invite-by-code
  "Look up an invite by code. Returns the invite map or nil."
  [ds code]
  (db/execute-one! ds
    {:select [:i.id :i.community-id :i.code :i.max-uses :i.uses
              :i.expires-at :i.created-at
              :c.name :c.slug]
     :from   [[:invites :i]]
     :join   [[:communities :c] [:= :i.community-id :c.id]]
     :where  [:= :i.code code]}))

(defn invite-valid?
  "Check if an invite is still valid (not expired, not exhausted)."
  [invite]
  (and invite
       ;; Not expired
       (or (nil? (:expires-at invite))
           (not (expired? (:expires-at invite))))
       ;; Not exhausted
       (or (nil? (:max-uses invite))
           (< (:uses invite) (:max-uses invite)))))

;;; ---------------------------------------------------------------------------
;;; Cookie helpers
;;; ---------------------------------------------------------------------------

(defn session-cookie
  "Build a session cookie map for Ring."
  [cookie-name raw-token ttl-days secure?]
  {cookie-name {:value     raw-token
                :path      "/"
                :max-age   (* ttl-days 24 60 60)
                :http-only true
                :secure    secure?
                :same-site :lax}})

(defn extract-session-cookie
  "Extract the session token from Ring request cookies."
  [request cookie-name]
  (get-in request [:cookies cookie-name :value]))
