(ns ajet.chat.auth-gw.oauth
  "OAuth login flows — GitHub, Gitea, and generic OIDC.

   Handles the full OAuth2 authorization code flow:
   1. Generate authorize URL with state parameter (CSRF protection)
   2. Exchange authorization code for access token
   3. Fetch user profile from provider
   4. Find or create local user + oauth_account
   5. Create session and redirect

   Providers are loaded dynamically from the database (not static config)."
  (:require [babashka.http-client :as http]
            [clojure.data.json :as json]
            [clojure.string :as str]
            [clojure.tools.logging :as log]
            [ring.util.codec :as codec]
            [ajet.chat.auth-gw.auth :as auth]
            [ajet.chat.auth-gw.pages :as pages]
            [ajet.chat.auth-gw.setup :as setup])
  (:import [java.security SecureRandom]
           [java.util Base64]))

;;; ---------------------------------------------------------------------------
;;; State parameter (CSRF protection)
;;; ---------------------------------------------------------------------------

(def ^:private secure-random (SecureRandom.))

(defn- generate-state
  "Generate a random state parameter for CSRF protection."
  []
  (let [buf (byte-array 16)]
    (.nextBytes secure-random buf)
    (.encodeToString (.withoutPadding (Base64/getUrlEncoder)) buf)))

;; In-memory state store with expiry (5 minutes)
(def ^:private state-store (atom {}))

(defn- store-state!
  "Store a state parameter with metadata. Returns the state string."
  [metadata]
  (let [state (generate-state)
        expiry (+ (System/currentTimeMillis) (* 5 60 1000))]
    ;; Clean up expired states while we're at it
    (swap! state-store
           (fn [store]
             (let [now (System/currentTimeMillis)
                   cleaned (into {} (filter (fn [[_ v]] (> (:expiry v) now))) store)]
               (assoc cleaned state (merge metadata {:expiry expiry})))))
    state))

(defn- consume-state!
  "Validate and consume a state parameter. Returns the metadata or nil."
  [state]
  (when state
    (let [result (atom nil)]
      (swap! state-store
             (fn [store]
               (let [entry (get store state)]
                 (if (and entry (> (:expiry entry) (System/currentTimeMillis)))
                   (do (reset! result (dissoc entry :expiry))
                       (dissoc store state))
                   (do (reset! result nil)
                       store)))))
      @result)))

;;; ---------------------------------------------------------------------------
;;; GitHub OAuth
;;; ---------------------------------------------------------------------------

(def ^:private github-authorize-url "https://github.com/login/oauth/authorize")
(def ^:private github-token-url "https://github.com/login/oauth/access_token")
(def ^:private github-user-url "https://api.github.com/user")
(def ^:private github-emails-url "https://api.github.com/user/emails")

(defn- github-authorize-redirect
  "Build the GitHub OAuth authorize redirect URL."
  [client-id state redirect-uri]
  (str github-authorize-url "?"
       (codec/form-encode {"client_id"    client-id
                           "redirect_uri" redirect-uri
                           "scope"        "read:user user:email"
                           "state"        state})))

(defn- github-exchange-code
  "Exchange an authorization code for an access token with GitHub."
  [client-id client-secret code redirect-uri]
  (let [resp (http/post github-token-url
               {:headers {"Accept" "application/json"
                          "Content-Type" "application/x-www-form-urlencoded"}
                :body    (codec/form-encode {"client_id"     client-id
                                            "client_secret" client-secret
                                            "code"          code
                                            "redirect_uri"  redirect-uri})
                :throw   false
                :timeout 10000})]
    (when (= 200 (:status resp))
      (let [body (json/read-str (:body resp) :key-fn keyword)]
        (:access_token body)))))

(defn- github-fetch-profile
  "Fetch the user profile from GitHub using the access token."
  [access-token]
  (let [user-resp   (http/get github-user-url
                      {:headers {"Authorization" (str "Bearer " access-token)
                                 "Accept"        "application/json"}
                       :throw   false
                       :timeout 10000})
        emails-resp (http/get github-emails-url
                      {:headers {"Authorization" (str "Bearer " access-token)
                                 "Accept"        "application/json"}
                       :throw   false
                       :timeout 10000})]
    (when (= 200 (:status user-resp))
      (let [user   (json/read-str (:body user-resp) :key-fn keyword)
            emails (when (= 200 (:status emails-resp))
                     (json/read-str (:body emails-resp) :key-fn keyword))
            primary-email (or (->> emails
                                   (filter :primary)
                                   first
                                   :email)
                              (:email user))]
        {:provider          :github
         :provider-user-id  (str (:id user))
         :provider-username (:login user)
         :username          (:login user)
         :display-name      (or (:name user) (:login user))
         :email             (or primary-email "")
         :avatar-url        (:avatar_url user)}))))

;;; ---------------------------------------------------------------------------
;;; Gitea OAuth
;;; ---------------------------------------------------------------------------

(defn- gitea-authorize-redirect
  "Build the Gitea OAuth authorize redirect URL."
  [base-url client-id state redirect-uri]
  (let [authorize-url (str (str/replace base-url #"/+$" "") "/login/oauth/authorize")]
    (str authorize-url "?"
         (codec/form-encode {"client_id"     client-id
                             "redirect_uri"  redirect-uri
                             "response_type" "code"
                             "scope"         ""
                             "state"         state}))))

(defn- gitea-exchange-code
  "Exchange an authorization code for an access token with Gitea."
  [base-url client-id client-secret code redirect-uri]
  (let [token-url (str (str/replace base-url #"/+$" "") "/login/oauth/access_token")
        resp      (http/post token-url
                    {:headers {"Accept"       "application/json"
                               "Content-Type" "application/x-www-form-urlencoded"}
                     :body    (codec/form-encode {"client_id"     client-id
                                                 "client_secret" client-secret
                                                 "code"          code
                                                 "grant_type"    "authorization_code"
                                                 "redirect_uri"  redirect-uri})
                     :throw   false
                     :timeout 10000})]
    (when (= 200 (:status resp))
      (let [body (json/read-str (:body resp) :key-fn keyword)]
        (:access_token body)))))

(defn- gitea-fetch-profile
  "Fetch the user profile from Gitea using the access token."
  [base-url access-token]
  (let [user-url (str (str/replace base-url #"/+$" "") "/api/v1/user")
        resp     (http/get user-url
                   {:headers {"Authorization" (str "Bearer " access-token)
                              "Accept"        "application/json"}
                    :throw   false
                    :timeout 10000})]
    (when (= 200 (:status resp))
      (let [user (json/read-str (:body resp) :key-fn keyword)]
        {:provider          :gitea
         :provider-user-id  (str (:id user))
         :provider-username (:login user)
         :username          (:login user)
         :display-name      (or (:full_name user) (:login user))
         :email             (or (:email user) "")
         :avatar-url        (:avatar_url user)}))))

;;; ---------------------------------------------------------------------------
;;; OIDC (OpenID Connect)
;;; ---------------------------------------------------------------------------

(defn- oidc-discover
  "Fetch the OIDC discovery document from the issuer's well-known URL."
  [issuer-url]
  (let [discovery-url (str (str/replace issuer-url #"/+$" "") "/.well-known/openid-configuration")
        resp          (http/get discovery-url
                        {:headers {"Accept" "application/json"}
                         :throw   false
                         :timeout 10000})]
    (when (= 200 (:status resp))
      (json/read-str (:body resp) :key-fn keyword))))

(defn- oidc-authorize-redirect
  "Build the OIDC authorize redirect URL."
  [discovery client-id state redirect-uri]
  (let [authorize-url (:authorization_endpoint discovery)]
    (str authorize-url "?"
         (codec/form-encode {"client_id"     client-id
                             "redirect_uri"  redirect-uri
                             "response_type" "code"
                             "scope"         "openid profile email"
                             "state"         state}))))

(defn- oidc-exchange-code
  "Exchange an authorization code for tokens with the OIDC provider."
  [discovery client-id client-secret code redirect-uri]
  (let [token-url (:token_endpoint discovery)
        resp      (http/post token-url
                    {:headers {"Accept"       "application/json"
                               "Content-Type" "application/x-www-form-urlencoded"}
                     :body    (codec/form-encode {"client_id"     client-id
                                                 "client_secret" client-secret
                                                 "code"          code
                                                 "grant_type"    "authorization_code"
                                                 "redirect_uri"  redirect-uri})
                     :throw   false
                     :timeout 10000})]
    (when (= 200 (:status resp))
      (let [body (json/read-str (:body resp) :key-fn keyword)]
        (:access_token body)))))

(defn- oidc-fetch-profile
  "Fetch the user profile from the OIDC userinfo endpoint."
  [discovery access-token]
  (let [userinfo-url (:userinfo_endpoint discovery)
        resp         (http/get userinfo-url
                       {:headers {"Authorization" (str "Bearer " access-token)
                                  "Accept"        "application/json"}
                        :throw   false
                        :timeout 10000})]
    (when (= 200 (:status resp))
      (let [user (json/read-str (:body resp) :key-fn keyword)]
        {:provider          :oidc
         :provider-user-id  (str (:sub user))
         :provider-username (or (:preferred_username user) (:sub user))
         :username          (or (:preferred_username user)
                                (:nickname user)
                                (first (str/split (or (:email user) "user") #"@")))
         :display-name      (or (:name user)
                                (:preferred_username user)
                                (:sub user))
         :email             (or (:email user) "")
         :avatar-url        (:picture user)}))))

;;; ---------------------------------------------------------------------------
;;; Handlers
;;; ---------------------------------------------------------------------------

(defn login-page-handler
  "Handle GET /auth/login — render the login page.

   If setup is not complete, redirect to /setup.
   If a `provider` query param is present, redirect to that provider's
   authorize URL. Otherwise render the login page with provider buttons."
  [{:keys [ds config oauth-providers-atom] :as sys} request]
  (let [first-user?   (zero? (auth/count-users ds))
        setup-done?   (setup/setup-complete? sys)
        providers     @oauth-providers-atom]
    ;; Redirect to setup wizard when: no users, no providers, setup not done
    ;; (If providers exist, show login page so admin can create their account via OAuth)
    (if (and first-user? (not setup-done?) (empty? providers))
      {:status  302
       :headers {"Location" "/setup"}
       :body    ""}
      ;; Normal login page
      (let [providers     providers
            params        (:query-params request)
            provider-slug (get params "provider")
            error         (get params "error")
            invite-code   (or (get params "invite")
                              (get-in request [:cookies "ajet_invite" :value]))
            base-url      (str "http"
                               (when (get-in config [:session :cookie-secure]) "s")
                               "://"
                               (get-in request [:headers "host"]))
            invite-info   (when invite-code
                            (let [invite (auth/find-invite-by-code ds invite-code)]
                              (when (auth/invite-valid? invite)
                                {:community-name (:name invite)})))]
        (if provider-slug
          ;; Redirect to OAuth provider
          (let [provider-row (some #(when (= (:slug %) provider-slug) %) providers)]
            (if provider-row
              (let [redirect-uri (str base-url "/auth/callback/" provider-slug)
                    state        (store-state! {:provider-slug provider-slug
                                                :invite-code   invite-code})
                    redirect-url
                    (case (:provider-type provider-row)
                      "github" (github-authorize-redirect
                                 (:client-id provider-row) state redirect-uri)
                      "gitea"  (gitea-authorize-redirect
                                 (:base-url provider-row)
                                 (:client-id provider-row) state redirect-uri)
                      "oidc"   (let [discovery (oidc-discover (:issuer-url provider-row))]
                                 (if discovery
                                   (oidc-authorize-redirect
                                     discovery (:client-id provider-row) state redirect-uri)
                                   (do (log/error "OIDC discovery failed for" (:issuer-url provider-row))
                                       nil)))
                      nil)]
                (if redirect-url
                  {:status  302
                   :headers {"Location" redirect-url}
                   :body    ""}
                  {:status  302
                   :headers {"Location" (str "/auth/login?error="
                                             (codec/url-encode "Provider configuration error"))}
                   :body    ""}))
              ;; Unknown provider slug
              {:status  302
               :headers {"Location" (str "/auth/login?error="
                                         (codec/url-encode "Unknown provider"))}
               :body    ""}))
          ;; Render login page
          {:status  200
           :headers {"Content-Type" "text/html; charset=utf-8"
                     "Cache-Control" "no-store"}
           :body    (pages/login-page {:providers    providers
                                       :error        error
                                       :invite-info  invite-info
                                       :first-user?  first-user?
                                       })})))))

(defn- handle-oauth-success
  "Common handler after successful OAuth profile fetch.

   Finds or creates the user, creates a session, handles invite flow,
   and redirects appropriately."
  [ds config sys profile invite-code]
  (let [ttl-days      (get-in config [:session :ttl-days] 30)
        cookie-name   (get-in config [:session :cookie-name] "ajet_session")
        cookie-secure (get-in config [:session :cookie-secure] true)
        ;; Find or create user
        existing-user (auth/find-user-by-oauth ds (:provider profile) (:provider-user-id profile))
        user          (or existing-user
                          (auth/create-user-from-oauth! ds profile))
        user-id       (:id user)
        ;; Create session
        raw-token     (auth/create-session! ds user-id ttl-days)
        ;; Determine redirect
        setup-done?   (setup/setup-complete? sys)
        first-user?   (and (nil? existing-user)
                           (= 1 (auth/count-users ds)))
        redirect-to   (cond
                        ;; First-user bootstrap: redirect to setup if not complete
                        (and first-user? (not setup-done?)) "/setup"
                        ;; Invite flow: accept invite then redirect to community
                        invite-code   (str "/invite/" (codec/url-encode invite-code) "/accept")
                        ;; Normal: redirect to app
                        :else         "/")]
    (log/info "OAuth login success for user" user-id
              "provider" (name (:provider profile))
              (if existing-user "existing" "new") "user"
              "redirect-to" redirect-to)
    (cond-> {:status  302
             :headers {"Location" redirect-to}
             :body    ""
             :cookies (auth/session-cookie cookie-name raw-token ttl-days cookie-secure)}
      ;; Clear invite cookie after use
      invite-code
      (update :cookies assoc "ajet_invite" {:value   ""
                                            :path    "/"
                                            :max-age 0}))))

(defn callback-handler
  "Handle GET /auth/callback/:provider — OAuth callback.

   Validates the state parameter, exchanges the code for an access token,
   fetches the user profile, and completes the login flow.

   Looks up the provider by slug from the DB-backed atom."
  [{:keys [ds config oauth-providers-atom] :as sys} request]
  (let [provider-slug (get-in request [:path-params :provider])
        params        (:query-params request)
        code          (get params "code")
        state         (get params "state")
        error-param   (get params "error")
        ;; Look up provider from DB-backed atom
        provider-row  (some #(when (= (:slug %) provider-slug) %) @oauth-providers-atom)
        base-url      (str "http"
                           (when (get-in config [:session :cookie-secure]) "s")
                           "://"
                           (get-in request [:headers "host"]))
        redirect-uri  (str base-url "/auth/callback/" provider-slug)]

    (cond
      ;; Provider returned an error
      error-param
      (do (log/warn "OAuth error from provider" provider-slug ":" error-param)
          {:status  302
           :headers {"Location" (str "/auth/login?error="
                                     (codec/url-encode (str "Login failed: " error-param)))}
           :body    ""})

      ;; Missing code or state
      (or (str/blank? code) (str/blank? state))
      {:status  302
       :headers {"Location" (str "/auth/login?error="
                                 (codec/url-encode "Invalid OAuth callback — missing parameters"))}
       :body    ""}

      ;; Unknown provider
      (nil? provider-row)
      {:status  302
       :headers {"Location" (str "/auth/login?error="
                                 (codec/url-encode "Unknown OAuth provider"))}
       :body    ""}

      ;; Invalid or expired state (CSRF check)
      :else
      (let [state-meta (consume-state! state)]
        (if-not state-meta
          {:status  302
           :headers {"Location" (str "/auth/login?error="
                                     (codec/url-encode "Invalid or expired login session. Please try again."))}
           :body    ""}

          ;; Exchange code and fetch profile
          (let [invite-code (:invite-code state-meta)
                ptype       (:provider-type provider-row)]
            (try
              (let [[access-token profile]
                    (case ptype
                      "github"
                      (let [token (github-exchange-code
                                    (:client-id provider-row)
                                    (:client-secret provider-row)
                                    code redirect-uri)]
                        [token (when token (github-fetch-profile token))])

                      "gitea"
                      (let [;; Server-side URL may differ from browser-facing base-url
                            ;; (e.g. Docker host.docker.internal vs localhost)
                            server-url (or (get-in config [:oauth :gitea-server-base-url])
                                           (:base-url provider-row))
                            token (gitea-exchange-code
                                    server-url
                                    (:client-id provider-row)
                                    (:client-secret provider-row)
                                    code redirect-uri)]
                        [token (when token (gitea-fetch-profile server-url token))])

                      "oidc"
                      (let [discovery (oidc-discover (:issuer-url provider-row))
                            token     (when discovery
                                        (oidc-exchange-code
                                          discovery
                                          (:client-id provider-row)
                                          (:client-secret provider-row)
                                          code redirect-uri))]
                        [token (when token (oidc-fetch-profile discovery token))])

                      ;; Unknown provider type
                      [nil nil])]
                (if profile
                  (handle-oauth-success ds config sys profile invite-code)
                  (do (log/warn "OAuth flow failed — could not obtain profile from" provider-slug)
                      {:status  302
                       :headers {"Location" (str "/auth/login?error="
                                                 (codec/url-encode "Could not authenticate with provider. Please try again."))}
                       :body    ""})))
              (catch Exception e
                (log/error e "OAuth callback error for provider" provider-slug)
                {:status  302
                 :headers {"Location" (str "/auth/login?error="
                                           (codec/url-encode "Authentication service error. Please try again."))}
                 :body    ""}))))))))

(defn logout-handler
  "Handle POST /auth/logout — destroy session and clear cookie."
  [{:keys [ds config]} request]
  (let [cookie-name   (get-in config [:session :cookie-name] "ajet_session")
        cookie-secure (get-in config [:session :cookie-secure] true)
        cookie-value  (auth/extract-session-cookie request cookie-name)
        cookie-result (auth/destroy-session! ds cookie-value cookie-name cookie-secure)]
    (log/info "User logged out")
    (merge {:status  302
            :headers {"Location" "/auth/login"}
            :body    ""}
           cookie-result)))

(defn invite-landing-handler
  "Handle GET /invite/:code — render invite landing page or error."
  [{:keys [ds config]} request]
  (let [code   (get-in request [:path-params :code])
        invite (auth/find-invite-by-code ds code)]
    (cond
      ;; Invite not found
      (nil? invite)
      {:status 404
       :headers {"Content-Type" "text/html; charset=utf-8"}
       :body   (pages/invite-page {:community-name "Unknown"
                                   :invite-code    code
                                   :error          "This invite link is invalid or has been revoked."})}

      ;; Invite expired or exhausted
      (not (auth/invite-valid? invite))
      {:status 410
       :headers {"Content-Type" "text/html; charset=utf-8"}
       :body   (pages/invite-page {:community-name (:name invite)
                                   :invite-code    code
                                   :error          "This invite has expired or reached its maximum uses."})}

      ;; Valid invite — show landing page, set invite cookie
      :else
      {:status  200
       :headers {"Content-Type" "text/html; charset=utf-8"
                 "Cache-Control" "no-store"}
       :cookies {"ajet_invite" {:value     code
                                :path      "/"
                                :max-age   (* 30 60) ;; 30 minutes
                                :http-only true
                                :same-site :lax}}
       :body    (pages/invite-page {:community-name (:name invite)
                                    :invite-code    code})})))
