;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC Sucursal en España SL

(ns app.rpc.commands.auth
  (:require
   [app.auth :as auth]
   [app.auth.oidc :as oidc]
   [app.auth.passwords :as passwords]
   [app.common.data :as d]
   [app.common.exceptions :as ex]
   [app.common.features :as cfeat]
   [app.common.logging :as l]
   [app.common.schema :as sm]
   [app.common.time :as ct]
   [app.common.uri :as u]
   [app.common.uuid :as uuid]
   [app.config :as cf]
   [app.db :as db]
   [app.email :as eml]
   [app.email.blacklist :as email.blacklist]
   [app.email.whitelist :as email.whitelist]
   [app.http :as-alias http]
   [app.http.session :as session]
   [app.loggers.audit :as audit]
   [app.media :as media]
   [app.rpc :as-alias rpc]
   [app.rpc.climit :as-alias climit]
   [app.rpc.commands.profile :as profile]
   [app.rpc.commands.teams :as teams]
   [app.rpc.doc :as-alias doc]
   [app.rpc.helpers :as rph]
   [app.setup :as-alias setup]
   [app.setup.welcome-file :refer [create-welcome-file]]
   [app.storage :as sto]
   [app.tokens :as tokens]
   [app.util.services :as sv]
   [app.worker :as wrk]
   [cuerdas.core :as str]))

(def schema:password
  [::sm/word-string {:max 500}])

(def schema:token
  [::sm/word-string {:max 6000}])

(defn- elapsed-verify-threshold?
  [profile]
  (let [elapsed (ct/diff (:modified-at profile) (ct/now))
        verify-threshold (cf/get :email-verify-threshold)]
    (pos? (compare elapsed verify-threshold))))

;; ---- COMMAND: login with password

(defn login-with-password
  [cfg {:keys [email password] :as params}]

  (when-not (or (contains? cf/flags :login)
                (contains? cf/flags :login-with-password))
    (ex/raise :type :restriction
              :code :login-disabled
              :hint "login is disabled"))

  (letfn [(check-password [cfg profile password]
            (if (= (:password profile) "!")
              (ex/raise :type :validation
                        :code :account-without-password
                        :hint "the current account does not have password")
              (let [result (auth/verify-password password (:password profile))]
                (when (:update result)
                  (l/trc :hint "updating profile password"
                         :id (str (:id profile))
                         :email (:email profile))
                  (profile/update-profile-password! cfg (assoc profile :password password)))
                (:valid result))))

          (validate-profile [cfg profile]
            (when-not profile
              (ex/raise :type :validation
                        :code :wrong-credentials))
            (when-not (:is-active profile)
              (ex/raise :type :validation
                        :code :wrong-credentials))
            (when (:is-blocked profile)
              (ex/raise :type :restriction
                        :code :profile-blocked
                        :hint "profile is marked as blocked"))
            (when-not (check-password cfg profile password)
              (ex/raise :type :validation
                        :code :wrong-credentials))
            (when-let [deleted-at (:deleted-at profile)]
              (when (ct/is-after? (ct/now) deleted-at)
                (ex/raise :type :validation
                          :code :wrong-credentials)))

            profile)

          (login [{:keys [::db/conn] :as cfg}]
            (let [profile    (->> (profile/clean-email email)
                                  (profile/get-profile-by-email conn)
                                  (validate-profile cfg)
                                  (profile/strip-private-attrs))

                  invitation (when-let [token (:invitation-token params)]
                               (tokens/verify cfg {:token token :iss :team-invitation}))

                  ;; If invitation member-id does not matches the profile-id, we just proceed to ignore the
                  ;; invitation because invitations matches exactly; and user can't login with other email and
                  ;; accept invitation with other email
                  response   (if (and (some? invitation) (= (:id profile) (:member-id invitation)))
                               {:invitation-token (:invitation-token params)}
                               (assoc profile :is-admin (let [admins (cf/get :admins)]
                                                          (contains? admins (:email profile)))))]
              (-> response
                  (rph/with-transform (session/create-fn cfg profile))
                  (rph/with-meta {::audit/props (audit/profile->props profile)
                                  ::audit/profile-id (:id profile)}))))]

    (db/tx-run! cfg login)))

(def schema:login-with-password
  [:map {:title "login-with-password"}
   [:email ::sm/email]
   [:password schema:password]
   [:invitation-token {:optional true} schema:token]])

(sv/defmethod ::login-with-password
  "Performs authentication using penpot password."
  {::rpc/auth false
   ::doc/added "1.15"
   ::climit/id :auth/global
   ::sm/params schema:login-with-password}
  [cfg params]
  (login-with-password cfg params))

;; ---- COMMAND: Logout

(def ^:private schema:logout
  [:map {:title "logoug"}
   [:profile-id {:optional true} ::sm/uuid]])

(sv/defmethod ::logout
  "Clears the authentication cookie and logout the current session."
  {::rpc/auth false
   ::doc/changes [["2.1" "Now requires profile-id passed in the body"]]
   ::doc/added "1.0"
   ::sm/params schema:logout}
  [cfg params]
  (if (= (:profile-id params)
         (::rpc/profile-id params))
    (let [{:keys [claims]}
          (rph/get-auth-data params)

          provider
          (some->> (get claims :sso-provider-id)
                   (oidc/get-provider cfg))

          response
          (if (and provider (:logout-uri provider))
            (let [params {"logout_hint" (get claims :sso-session-id)
                          "client_id" (get provider :client-id)
                          "post_logout_redirect_uri" (str (cf/get :public-uri))}
                  uri    (-> (u/uri (:logout-uri provider))
                             (assoc :query (u/map->query-string params)))]
              {:redirect-uri uri})
            {})]

      (rph/with-transform response (session/delete-fn cfg)))
    {}))

;; ---- COMMAND: Recover Profile

(defn recover-profile
  [{:keys [::db/conn] :as cfg} {:keys [token password]}]
  (letfn [(validate-token [token]
            (let [tdata (tokens/verify cfg {:token token :iss :password-recovery})]
              (:profile-id tdata)))

          (update-password [conn profile-id]
            (let [pwd (auth/derive-password password)]
              (db/update! conn :profile {:password pwd :is-active true} {:id profile-id})
              nil))]

    (passwords/validate-password password)
    (->> (validate-token token)
         (update-password conn))

    nil))

(def schema:recover-profile
  [:map {:title "recover-profile"}
   [:token schema:token]
   [:password schema:password]])

(sv/defmethod ::recover-profile
  {::rpc/auth false
   ::doc/added "1.15"
   ::sm/params schema:recover-profile
   ::climit/id :auth/global
   ::db/transaction true}
  [cfg params]
  (recover-profile cfg params))

;; ---- COMMAND: Prepare Register

(defn- validate-register-attempt!
  [cfg params]

  (when (or (not (contains? cf/flags :registration))
            (not (contains? cf/flags :login-with-password)))
    (ex/raise :type :restriction
              :code :registration-disabled
              :hint "registration disabled"))

  (when (contains? params :invitation-token)
    (let [invitation (tokens/verify cfg
                                    {:token (:invitation-token params)
                                     :iss :team-invitation})]
      (when-not (= (:email params) (:member-email invitation))
        (ex/raise :type :restriction
                  :code :email-does-not-match-invitation
                  :hint "email should match the invitation"))))

  (when (and (email.blacklist/enabled? cfg)
             (email.blacklist/contains? cfg (:email params)))
    (ex/raise :type :restriction
              :code :email-domain-is-not-allowed
              :hint "email domain in blacklist"))

  (when (and (email.whitelist/enabled? cfg)
             (not (email.whitelist/contains? cfg (:email params))))
    (ex/raise :type :restriction
              :code :email-domain-is-not-allowed
              :hint "email domain not in whitelist"))

  ;; Perform a basic validation of email & password
  (when (= (str/lower (:email params))
           (str/lower (:password params)))
    (ex/raise :type :validation
              :code :email-as-password
              :hint "you can't use your email as password"))

  ;; Validate password strength against common password dictionary
  (passwords/validate-password (:password params))

  (when (eml/has-bounce-reports? cfg (:email params))
    (ex/raise :type :restriction
              :code :email-has-permanent-bounces
              :email (:email params)
              :hint "email has bounce reports"))

  (when (eml/has-complaint-reports? cfg (:email params))
    (ex/raise :type :restriction
              :code :email-has-complaints
              :email (:email params)
              :hint "email has complaint reports")))

(defn prepare-register
  [{:keys [::db/pool] :as cfg} {:keys [fullname email] :as params}]

  (validate-register-attempt! cfg params)

  (let [email   (profile/clean-email email)
        profile (profile/get-profile-by-email pool email)
        fullname (d/normalize-string fullname)]

    ;; SECURITY: refuse to issue a prepared-register token when an active
    ;; profile already exists for this email.
    ;;
    ;; Active accounts must use the standard login flow; existing-but-
    ;; not-yet-active profiles fall through to the duplicate-detection branch in
    ;; `register-profile`, which never creates a session.
    (when (and (some? profile)
               (true? (:is-active profile)))
      (ex/raise :type :validation
                :code :email-already-exists
                :hint "email already exists"))

    (let [props  (-> (audit/extract-utm-params params)
                     (cond-> (:accept-newsletter-updates params)
                       (assoc :newsletter-updates true)))
          ;; SECURITY: do NOT embed `:profile-id` of an existing
          ;; profile into the prepared-register JWE. Doing so would
          ;; let an anonymous caller, in possession of a valid
          ;; team-invitation JWE, ask `register-profile` to load that
          ;; profile by id and mint a session for it without password
          ;; verification. `register-profile` independently re-detects
          ;; duplicates by email and handles them in the
          ;; "repeated-registry" branch.
          params {:email email
                  :fullname fullname
                  :password (:password params)
                  :invitation-token (:invitation-token params)
                  :backend "penpot"
                  :iss :prepared-register
                  :exp (ct/in-future {:days 7})
                  :props props}
          params (d/without-nils params)
          token  (tokens/generate cfg params)]

      (-> {:token token}
          (with-meta {::audit/profile-id uuid/zero})))))

(def schema:prepare-register-profile
  [:map {:title "prepare-register-profile"}
   [:fullname ::sm/text]
   [:email ::sm/email]
   [:password schema:password]
   [:create-welcome-file {:optional true} :boolean]
   [:accept-newsletter-updates {:optional true} :boolean]
   [:invitation-token {:optional true} schema:token]])

(sv/defmethod ::prepare-register-profile
  {::rpc/auth false
   ::doc/added "1.15"
   ::sm/params schema:prepare-register-profile}
  [cfg params]
  (prepare-register cfg params))

;; ---- COMMAND: Register Profile

(defn import-profile-picture
  [cfg uri]
  (try
    (let [storage (sto/resolve cfg)
          input   (media/download-image cfg uri)
          input   (media/run cfg {:cmd :info :input input})
          hash    (sto/calculate-hash (:path input))
          content (-> (sto/content (:path input) (:size input))
                      (sto/wrap-with-hash hash))
          sobject (sto/put-object! storage {::sto/content content
                                            ::sto/deduplicate? true
                                            :bucket "profile"
                                            :content-type (:mtype input)})]
      (:id sobject))
    (catch Throwable cause
      (l/wrn :hint "unable to import profile picture"
             :uri uri
             :cause cause)
      nil)))

(defn create-profile
  "Create the profile entry on the database with limited set of input
  attrs (all the other attrs are filled with default values)."
  [{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
  (let [id        (or (:id params) (uuid/next))
        props     (-> (:props params)
                      (merge {:viewed-tutorial? false
                              :viewed-walkthrough? false
                              :nudge {:big 10 :small 1}
                              :v2-info-shown true
                              :release-notes-viewed (:main cf/version)}))

        password  (or (:password params) "!")

        locale    (:locale params)
        locale    (when (and (string? locale) (not (str/blank? locale)))
                    locale)

        backend   (:backend params "penpot")
        is-demo   (:is-demo params false)
        is-muted  (:is-muted params false)
        is-active (:is-active params false)
        theme     (:theme params nil)
        email     (str/lower email)
        fullname  (d/normalize-string (:fullname params))
        locale    (d/normalize-string locale)
        theme     (some-> theme d/normalize-string not-empty)

        photo-id  (some->> (or (:oidc/picture props)
                               (:google/picture props)
                               (:github/picture props)
                               (:gitlab/picture props))
                           (import-profile-picture cfg))

        params    {:id id
                   :fullname fullname
                   :email email
                   :auth-backend backend
                   :lang locale
                   :password password
                   :deleted-at (:deleted-at params)
                   :props (db/tjson props)
                   :theme theme
                   :photo-id photo-id
                   :is-active is-active
                   :is-muted is-muted
                   :is-demo is-demo}]

    (try
      (-> (db/insert! conn :profile params)
          (profile/decode-row))
      (catch org.postgresql.util.PSQLException cause
        (if (db/duplicate-key-error? cause)
          (ex/raise :type :validation
                    :code :email-already-exists
                    :hint "email already exists"
                    :cause cause)
          (throw cause))))))

(defn create-profile-rels
  [{:keys [::db/conn] :as cfg} {:keys [id] :as profile}]
  (assert (db/connection-map? cfg)
          "expected cfg with valid connection")
  (let [features (cfeat/get-enabled-features cf/flags)
        team     (teams/create-team cfg
                                    {:profile-id id
                                     :name "Default"
                                     :features features
                                     :is-default true})]
    (-> (db/update! conn :profile
                    {:default-team-id (:id team)
                     :default-project-id  (:default-project-id team)}
                    {:id id}
                    {::db/return-keys true})
        (profile/decode-row))))

(defn send-email-verification!
  ([cfg profile] (send-email-verification! cfg profile nil))
  ([{:keys [::db/conn] :as cfg} profile invitation-token]
   (let [vclaims (cond-> {:iss :verify-email
                          :exp (ct/in-future "72h")
                          :profile-id (:id profile)
                          :email (:email profile)}
                   ;; If the user registered through a team-invitation flow but
                   ;; their profile is not yet active, we carry the invitation
                   ;; token inside the verify-email JWE so the team-invitation
                   ;; flow can resume after the user clicks the email link.
                   (some? invitation-token)
                   (assoc :invitation-token invitation-token))
         vtoken  (tokens/generate cfg vclaims)
         ;; NOTE: this token is mainly used for possible complains
         ;; identification on the sns webhook
         ptoken  (tokens/generate cfg
                                  {:iss :profile-identity
                                   :profile-id (:id profile)
                                   :exp (ct/in-future {:days 30})})]
     (eml/send! {::eml/conn conn
                 ::eml/factory eml/register
                 :public-uri (cf/get :public-uri)
                 :to (:email profile)
                 :name (:fullname profile)
                 :token vtoken
                 :extra-data ptoken}))))

(defn register-profile
  [{:keys [::db/conn ::wrk/executor] :as cfg} {:keys [token] :as params}]
  (let [claims     (tokens/verify cfg {:token token :iss :prepared-register})
        params     (cond-> claims
                     (:accept-newsletter-updates params)
                     (update :props assoc :newsletter-updates true))

        profile    (or (profile/get-profile-by-email conn (:email claims))
                       (let [is-active (or (boolean (:is-active claims))
                                           (boolean (:email-verified claims))
                                           (not (contains? cf/flags :email-verification)))
                             params    (-> params
                                           (assoc :is-active is-active)
                                           (update :password auth/derive-password))
                             profile   (->> (create-profile cfg params)
                                            (create-profile-rels cfg))]
                         (vary-meta profile assoc :created true)))

        created?   (-> profile meta :created true?)

        invitation (when-let [token (:invitation-token params)]
                     (tokens/verify cfg {:token token :iss :team-invitation}))

        props      (-> (audit/profile->props profile)
                       (assoc :from-invitation (some? invitation)))


        create-welcome-file-when-needed
        (fn []
          (when (:create-welcome-file params)
            (let [cfg (dissoc cfg ::db/conn)]
              (wrk/submit! executor (create-welcome-file cfg profile)))))]

    (cond
      ;; When profile is blocked, we just ignore it and return plain data
      (:is-blocked profile)
      (do
        (l/wrn :hint "register attempt for already blocked profile"
               :profile-id (str  (:id profile))
               :profile-email (:email profile))
        (rph/with-meta {:id (:id profile)
                        :email (:email profile)}
          {::audit/replace-props props
           ::audit/context {:action "ignore-because-blocked"}
           ::audit/profile-id (:id profile)
           ::audit/name "register-profile-retry"}))

      ;; A profile was just created in this call. Invitation handling is a
      ;; sub-case of "newly created profile": we never honor invitations for
      ;; pre-existing profiles via this anonymous RPC. The split below mirrors
      ;; the non-invitation branches but threads the invitation through the
      ;; appropriate path:
      ;;
      ;;   - active     + matching invitation → mint session and
      ;;     return :invitation-token. The frontend redirects to
      ;;     :auth-verify-token, which immediately accepts the
      ;;     invitation.
      ;;   - active     + no/mismatched invitation → mint session
      ;;     ("login" action). New profile, no further action.
      ;;   - not-active + matching invitation → send the
      ;;     verify-email mail with the invitation token EMBEDDED
      ;;     into the verify-email JWE. No session yet. When the
      ;;     user clicks the link, verify-token activates the
      ;;     profile, mints a session, and propagates the
      ;;     invitation token to the frontend so it can complete
      ;;     the team-invitation flow.
      ;;   - not-active + no/mismatched invitation → standard
      ;;     "check your email" verification flow.
      created?
      (let [accept-invitation? (and (some? invitation)
                                    (= (:email profile)
                                       (:member-email invitation)))]
        (cond
          (and (:is-active profile) accept-invitation?)
          (let [invitation (assoc invitation :member-id (:id profile))
                token      (tokens/generate cfg invitation)]
            (-> {:id (:id profile)
                 :email (:email profile)
                 :invitation-token token}
                (rph/with-transform (session/create-fn cfg profile claims))
                (rph/with-defer create-welcome-file-when-needed)
                (rph/with-meta {::audit/replace-props props
                                ::audit/context {:action "accept-invitation"}
                                ::audit/profile-id (:id profile)})))

          (:is-active profile)
          (-> (profile/strip-private-attrs profile)
              (rph/with-transform (session/create-fn cfg profile claims))
              (rph/with-defer create-welcome-file-when-needed)
              (rph/with-meta
                {::audit/replace-props props
                 ::audit/context {:action "login"}
                 ::audit/profile-id (:id profile)}))

          :else
          (do
            (when-not (eml/has-reports? conn (:email profile))
              (send-email-verification! cfg profile
                                        (when accept-invitation?
                                          (:invitation-token params))))

            (-> {:id (:id profile)
                 :email (:email profile)}
                (rph/with-defer create-welcome-file-when-needed)
                (rph/with-meta
                  {::audit/replace-props props
                   ::audit/context {:action "email-verification"}
                   ::audit/profile-id (:id profile)})))))

      ;; When email verification is disabled and an inactive profile already
      ;; exists, reject the registration — the email is already taken.
      (not (contains? cf/flags :email-verification))
      (ex/raise :type :validation
                :code :email-already-exists)

      :else
      (let [elapsed? (elapsed-verify-threshold? profile)
            reports? (eml/has-reports? conn (:email profile))
            action   (if reports?
                       "ignore-because-complaints"
                       (if elapsed?
                         "resend-email-verification"
                         "ignore"))]

        (l/wrn :hint "repeated registry detected"
               :profile-id (str (:id profile))
               :profile-email (:email profile)
               :context-action action)

        (when (= action "resend-email-verification")
          (db/update! conn :profile
                      {:modified-at (ct/now)}
                      {:id (:id profile)})
          (send-email-verification! cfg profile))

        (rph/with-meta {:email (:email profile)
                        :id (:id profile)}
          {::audit/replace-props (audit/profile->props profile)
           ::audit/context {:action action}
           ::audit/profile-id (:id profile)
           ::audit/name "register-profile-retry"})))))

(def schema:register-profile
  [:map {:title "register-profile"}
   [:token schema:token]
   [:accept-newsletter-updates {:optional true} :boolean]])

(sv/defmethod ::register-profile
  {::rpc/auth false
   ::doc/added "1.15"
   ::sm/params schema:register-profile
   ::climit/id :auth/global}
  [cfg params]
  (db/tx-run! cfg register-profile params))

;; ---- COMMAND: Request Profile Recovery

(defn- request-profile-recovery
  [{:keys [::db/conn] :as cfg} {:keys [email] :as params}]
  (letfn [(create-recovery-token [{:keys [id] :as profile}]
            (let [token (tokens/generate cfg
                                         {:iss :password-recovery
                                          :exp (ct/in-future "15m")
                                          :profile-id id})]
              (assoc profile :token token)))

          (send-email-notification [conn profile]
            (let [ptoken (tokens/generate cfg
                                          {:iss :profile-identity
                                           :profile-id (:id profile)
                                           :exp (ct/in-future {:days 30})})]
              (eml/send! {::eml/conn conn
                          ::eml/factory eml/password-recovery
                          :public-uri (cf/get :public-uri)
                          :to (:email profile)
                          :token (:token profile)
                          :name (:fullname profile)
                          :extra-data ptoken})
              nil))]

    (let [profile (->> (profile/clean-email email)
                       (profile/get-profile-by-email conn))]

      (cond
        (not profile)
        (l/wrn :hint "attempt of profile recovery: no profile found"
               :profile-email email)

        (not (eml/allow-send-emails? conn profile))
        (l/wrn :hint "attempt of profile recovery: profile is muted"
               :profile-id (str (:id profile))
               :profile-email (:email profile))

        (eml/has-bounce-reports? conn (:email profile))
        (l/wrn :hint "attempt of profile recovery: email has bounces"
               :profile-id (str (:id profile))
               :profile-email (:email profile))

        (eml/has-complaint-reports? conn (:email profile))
        (l/wrn :hint "attempt of profile recovery: email has complaints"
               :profile-id (str (:id profile))
               :profile-email (:email profile))

        (not (elapsed-verify-threshold? profile))
        (l/wrn :hint "attempt of profile recovery: retry attempt threshold not elapsed"
               :profile-id (str (:id profile))
               :profile-email (:email profile))

        :else
        (do
          (db/update! conn :profile
                      {:modified-at (ct/now)}
                      {:id (:id profile)})
          (->> profile
               (create-recovery-token)
               (send-email-notification conn)))))))

(def schema:request-profile-recovery
  [:map {:title "request-profile-recovery"}
   [:email ::sm/email]])

(sv/defmethod ::request-profile-recovery
  {::rpc/auth false
   ::doc/added "1.15"
   ::sm/params schema:request-profile-recovery}
  [cfg params]
  (db/tx-run! cfg request-profile-recovery params))

;; --- COMMAND: get-sso-config

(defn- extract-domain
  "Extract the domain part from email"
  [email]
  (let [at (str/last-index-of email "@")]
    (when (and (>= at 0)
               (< at (dec (count email))))
      (-> (subs email (inc at))
          (str/trim)
          (str/lower)))))

(def ^:private schema:get-sso-provider
  [:map {:title "get-sso-config"}
   [:email ::sm/email]])

(def ^:private schema:get-sso-provider-result
  [:map {:title "SSOProvider"}
   [:id ::sm/uuid]])

(sv/defmethod ::get-sso-provider
  {::rpc/auth false
   ::doc/added "2.12"
   ::sm/params schema:get-sso-provider
   ::sm/result schema:get-sso-provider-result}
  [cfg {:keys [email]}]
  (when-let [domain (extract-domain email)]
    (when-let [config (db/get* cfg :sso-provider {:domain domain})]
      (select-keys config [:id]))))
