;; 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.management.nitrate
  "Internal Nitrate HTTP RPC API. Provides authenticated access to
  organization management and token validation endpoints."
  (:require
   [app.auth :as aauth]
   [app.auth.oidc :as oidc]
   [app.common.data :as d]
   [app.common.exceptions :as ex]
   [app.common.media :as cm]
   [app.common.schema :as sm]
   [app.common.time :as ct]
   [app.common.types.organization :as cto]
   [app.common.types.profile :refer [schema:profile, schema:basic-profile]]
   [app.common.types.team :refer [schema:team]]
   [app.common.uri :as u]
   [app.common.uuid :as uuid]
   [app.config :as cf]
   [app.db :as db]
   [app.email :as eml]
   [app.http :as-alias http]
   [app.http.session :as session]
   [app.loggers.audit :as audit]
   [app.media.validation :as media.v]
   [app.nitrate :as nitrate]
   [app.rpc :as rpc]
   [app.rpc.commands.auth :as auth]
   [app.rpc.commands.files :as files]
   [app.rpc.commands.nitrate :as cnit]
   [app.rpc.commands.profile :as profile]
   [app.rpc.commands.teams :as teams]
   [app.rpc.commands.teams-invitations :as ti]
   [app.rpc.doc :as doc]
   [app.rpc.nitrate.emails-helper :as neh]
   [app.rpc.nitrate.organization-helper :as noh]
   [app.rpc.notifications :as notifications]
   [app.storage :as sto]
   [app.util.services :as sv]
   [app.worker :as wrk]
   [cuerdas.core :as str]))


(defn- profile-to-map [profile]
  {:id            (:id profile)
   :name          (:fullname profile)
   :email         (:email profile)
   :created-at    (:created-at profile)
   :photo-url     (files/resolve-public-uri (get profile :photo-id))})

;; ---- API: authenticate

(sv/defmethod ::authenticate
  "Authenticate the current user"
  {::doc/added "2.18"
   ::sm/params [:map]
   ::sm/result schema:profile
   ::nitrate/sso false}
  [cfg {:keys [::rpc/profile-id] :as params}]
  (let [profile            (profile/get-profile cfg profile-id)]
    (-> (profile-to-map profile)
        (assoc :theme (:theme profile))
        (assoc :lang (:lang profile)))))

;; ---- API: get-teams

(def ^:private sql:get-teams
  "SELECT t.*
     FROM team AS t
     JOIN team_profile_rel AS tpr ON t.id = tpr.team_id
    WHERE tpr.profile_id = ?
      AND tpr.is_owner IS TRUE
      AND t.is_default IS FALSE
      AND t.deleted_at IS NULL;")

;; ---- API: get-penpot-version

(def ^:private schema:get-penpot-version-result
  [:map
   [:version
    [:map
     [:full [:maybe ::sm/text]]
     [:branch [:maybe ::sm/text]]
     [:base [:maybe ::sm/text]]
     [:main [:maybe ::sm/text]]
     [:major [:maybe ::sm/text]]
     [:minor [:maybe ::sm/text]]
     [:patch [:maybe ::sm/text]]
     [:modifier [:maybe ::sm/text]]
     [:commit [:maybe ::sm/text]]
     [:commit-hash [:maybe ::sm/text]]]]])

(sv/defmethod ::get-penpot-version
  "Get the current Penpot version"
  {::doc/added "2.18"
   ::sm/params [:map]
   ::sm/result schema:get-penpot-version-result
   ::rpc/auth false}
  [_cfg _params]
  {:version cf/version})

(def ^:private schema:get-teams-result
  [:vector schema:team])

(sv/defmethod ::get-teams
  "List teams for which current user is owner"
  {::doc/added "2.18"
   ::sm/params [:map]
   ::sm/result schema:get-teams-result
   ::nitrate/sso false}
  [cfg {:keys [::rpc/profile-id]}]
  (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
    (->> (db/exec! cfg [sql:get-teams current-user-id])
         (map #(select-keys % [:id :name])))))

;; ---- API: upload-organization-logo

(def ^:private schema:upload-organization-logo
  [:map
   [:content media.v/schema:upload]
   [:organization-id ::sm/uuid]
   [:previous-id {:optional true} ::sm/uuid]])

(def ^:private schema:upload-organization-logo-result
  [:map [:id ::sm/uuid]])

(sv/defmethod ::upload-organization-logo
  "Store an organization logo in penpot storage and return its ID.
  Accepts an optional previous-id to mark the old logo for garbage
  collection when replacing an existing one."
  {::doc/added "2.18"
   ::sm/params schema:upload-organization-logo
   ::sm/result schema:upload-organization-logo-result
   ::nitrate/sso false}
  [{:keys [::sto/storage]} {:keys [content organization-id previous-id]}]
  (media.v/validate-media-type! content cm/image-types)
  (when previous-id
    (sto/touch-object! storage previous-id))
  (let [hash (sto/calculate-hash (:path content))
        data (-> (sto/content (:path content))
                 (sto/wrap-with-hash hash))
        obj  (sto/put-object! storage {::sto/content      data
                                       ::sto/deduplicate? true
                                       :bucket            "organization"
                                       :content-type      (:mtype content)
                                       :organization-id   organization-id})]
    {:id (:id obj)}))

;; ---- API: notify-team-change

(sv/defmethod ::notify-team-change
  "Notify to Penpot a team change from nitrate"
  {::doc/added "2.18"
   ::sm/params cto/schema:team-with-organization
   ::rpc/auth false}
  [cfg team]
  (notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil)
  nil)

;; ---- API: notify-user-added-to-organization

(def ^:private schema:notify-user-added-to-organization
  [:map
   [:profile-id ::sm/uuid]
   [:organization-id ::sm/uuid]
   [:role ::sm/text]])

(sv/defmethod ::notify-user-added-to-organization
  "Notify to Penpot that an user has joined an organization from nitrate"
  {::doc/added "2.18"
   ::sm/params schema:notify-user-added-to-organization
   ::rpc/auth false}
  [cfg {:keys [profile-id organization-id]}]
  (db/tx-run! cfg teams/create-default-organization-team profile-id organization-id))


;; ---- API: get-managed-profiles

(def ^:private sql:get-managed-profiles
  "SELECT DISTINCT p.id, p.fullname as name, p.email
     FROM profile p
     JOIN team_profile_rel tpr_member
       ON tpr_member.profile_id = p.id
    WHERE p.id <> ?
      AND EXISTS (
        SELECT 1
          FROM team_profile_rel tpr_owner
          JOIN team t
            ON t.id = tpr_owner.team_id
         WHERE tpr_owner.profile_id = ?
           AND tpr_owner.team_id = tpr_member.team_id
           AND tpr_owner.is_owner IS TRUE
           AND t.is_default IS FALSE
           AND t.deleted_at IS NULL);")

(def schema:managed-profile-result
  [:vector schema:basic-profile])

(sv/defmethod ::get-managed-profiles
  "List profiles that belong to teams for which current user is owner"
  {::doc/added "2.18"
   ::sm/params [:map]
   ::sm/result schema:managed-profile-result
   ::nitrate/sso false}
  [cfg {:keys [::rpc/profile-id]}]
  (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)]
    (db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id])))

;; ---- API: get-teams-summary

(def ^:private sql:get-teams-summary
  "SELECT t.id, t.name, t.is_default
     FROM team AS t
    WHERE t.id = ANY(?)
      AND t.deleted_at IS NULL;")

(def ^:private sql:get-files-count
  "SELECT COUNT(f.*) AS count
     FROM file AS f
     JOIN project AS p ON f.project_id = p.id
     JOIN team AS t ON t.id = p.team_id
    WHERE p.team_id = ANY(?)
      AND t.deleted_at IS NULL
      AND p.deleted_at IS NULL
      AND f.deleted_at IS NULL;")

(def ^:private schema:get-teams-summary-params
  [:map
   [:ids [:or ::sm/uuid [:vector ::sm/uuid]]]])

(def ^:private schema:get-teams-summary-result
  [:map
   [:teams [:vector [:map
                     [:id ::sm/uuid]
                     [:name ::sm/text]
                     [:is-default ::sm/boolean]]]]
   [:num-files ::sm/int]])

(sv/defmethod ::get-teams-summary
  "Get summary information for a list of teams"
  {::doc/added "2.18"
   ::sm/params schema:get-teams-summary-params
   ::sm/result schema:get-teams-summary-result
   ::nitrate/sso false}
  [cfg {:keys [ids]}]
  (let [;; Handle one or multiple params
        ids (cond
              (uuid? ids)
              [ids]

              (and (vector? ids) (every? uuid? ids))
              ids

              :else
              [])]
    (db/run! cfg (fn [{:keys [::db/conn]}]
                   (let [ids-array     (db/create-array conn "uuid" ids)
                         teams         (db/exec! conn [sql:get-teams-summary ids-array])
                         files-count   (-> (db/exec-one! conn [sql:get-files-count ids-array]) :count)]
                     {:teams teams
                      :num-files files-count})))))


;; ---- API: delete-teams-keeping-your-penpot-projects

(def ^:private sql:prefix-teams-name-and-unset-default
  "UPDATE team
      SET name = ? || name,
          is_default = FALSE
    WHERE id = ANY(?)
RETURNING id, name;")

(def ^:private sql:get-teams-files-counts
  "SELECT p.team_id, COUNT(f.*) AS total
     FROM file AS f
     JOIN project AS p ON (p.id = f.project_id)
     JOIN team AS t ON (t.id = p.team_id)
    WHERE t.id = ANY(?)
      AND t.deleted_at IS NULL
      AND p.deleted_at IS NULL
      AND f.deleted_at IS NULL
 GROUP BY p.team_id;")

(def ^:private sql:soft-delete-teams
  "UPDATE team
      SET deleted_at = ?
    WHERE id = ANY(?)
RETURNING id, deleted_at;")


;; ---- API: notify-organization-deletion

(def ^:private schema:notify-organization-deletion
  [:map
   [:organization-id ::sm/uuid]])


(defn- soft-delete-teams!
  "Soft-delete the provided team ids and submit a delete task per team."
  [{:keys [::db/conn] :as cfg} team-ids]
  (when (seq team-ids)
    (let [delay      (cf/get-deletion-delay)
          deleted-at (ct/in-future delay)
          updated    (db/exec! conn [sql:soft-delete-teams
                                     deleted-at
                                     (db/create-array conn "uuid" team-ids)])]
      (doseq [{:keys [id deleted-at]} updated]
        (wrk/submit! {::db/conn conn
                      ::wrk/task :delete-object
                      ::wrk/params {:object :team
                                    :deleted-at deleted-at
                                    :id id}}))))
  nil)

(defn manage-deleted-organization-teams
  "For a deleted organization, preserve organization teams unchanged and only prefix or
  delete member Your Penpot teams depending on whether they still contain files."
  [cfg {:keys [organization-id organization-name teams]}]
  (let [all-team-ids (->> teams
                          (map :id)
                          (filter uuid?)
                          distinct
                          (into []))
        your-penpot-team-ids (->> teams
                                  (filter :is-your-penpot)
                                  (map :id)
                                  (filter uuid?)
                                  distinct
                                  (into []))]
    (when (seq all-team-ids)
      (let [organization-prefix (str "[" (d/sanitize-string organization-name) "] ")]
        (db/tx-run!
         cfg
         (fn [{:keys [::db/conn] :as cfg}]
           (let [teams-with-files (if (seq your-penpot-team-ids)
                                    (->> (db/exec! conn [sql:get-teams-files-counts
                                                         (db/create-array conn "uuid" your-penpot-team-ids)])
                                         (filter (fn [{:keys [total]}] (pos? total)))
                                         (map :team-id)
                                         (into #{}))
                                    #{})
                 teams-to-prefix  (->> your-penpot-team-ids (filter teams-with-files) (into []))
                 teams-to-delete  (->> your-penpot-team-ids (remove teams-with-files) (into []))]

             ;; Organization teams move to the fallback organization unchanged. Only imported
             ;; Your Penpot teams keep the organization prefix when they still have files.
             (when (seq teams-to-prefix)
               (db/exec! conn [sql:prefix-teams-name-and-unset-default
                               organization-prefix
                               (db/create-array conn "uuid" teams-to-prefix)]))

             ;; Empty imported Your Penpot teams disappear entirely.
             (soft-delete-teams! cfg teams-to-delete)

             (notifications/notify-organization-deletion cfg organization-id organization-name all-team-ids teams-to-delete)
             nil)))))))


(sv/defmethod ::notify-organization-deletion
  "For a deleted organization, preserve organization teams and only prefix or delete
   imported Your Penpot teams before notifying connected users."
  {::doc/added "2.18"
   ::sm/params schema:notify-organization-deletion
   ::rpc/auth false}
  [cfg {:keys [organization-id]}]
  (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
        teams       (:teams organization-summary)]
    (manage-deleted-organization-teams cfg {:organization-name (:name organization-summary)
                                            :organization-id (:id organization-summary)
                                            :teams teams})
    nil))

;; ---- API: notify-user-organizations-deletion

(def ^:private schema:notify-user-organizations-deletion
  [:map
   [:profile-id ::sm/uuid]])

(sv/defmethod ::notify-user-organizations-deletion
  "For a given user, find all owned organizations and apply the deleted-organization
   transfer rules to their imported Your Penpot teams."
  {::doc/added "2.18"
   ::sm/params schema:notify-user-organizations-deletion
   ::nitrate/sso false}
  [cfg {:keys [profile-id]}]
  (let [owned-organizations (nitrate/call cfg :get-owned-organizations {:profile-id profile-id})]
    (doseq [organization owned-organizations]
      (let [organization-name (:name organization)
            teams             (:teams organization)]
        (manage-deleted-organization-teams cfg {:organization-name organization-name
                                                :organization-id (:id organization)
                                                :teams teams}))))
  nil)




;; ---- API: get-profile-by-email

(def ^:private sql:get-profile-by-email
  "SELECT DISTINCT id, fullname, email, photo_id
     FROM profile
    WHERE email = ?
      AND deleted_at IS NULL;")

(sv/defmethod ::get-profile-by-email
  "Get profile by email"
  {::doc/added "2.18"
   ::sm/params [:map [:email ::sm/email]]
   ::sm/result schema:profile
   ::nitrate/sso false}
  [cfg {:keys [email]}]
  (let [profile (db/exec-one! cfg [sql:get-profile-by-email email])]
    (when-not profile
      (ex/raise :type :not-found
                :code :profile-not-found
                :hint "profile does not exist"
                :email email))
    (profile-to-map profile)))


;; ---- API: get-profile-by-id

(def ^:private sql:get-profile-by-id
  "SELECT DISTINCT id, fullname, email, photo_id
     FROM profile
    WHERE id = ?
      AND deleted_at IS NULL;")

(sv/defmethod ::get-profile-by-id
  "Get profile by email"
  {::doc/added "2.18"
   ::sm/params [:map [:id ::sm/uuid]]
   ::sm/result schema:profile
   ::nitrate/sso false}
  [cfg {:keys [id]}]
  (let [profile (db/exec-one! cfg [sql:get-profile-by-id id])]
    (when-not profile
      (ex/raise :type :not-found
                :code :profile-not-found
                :hint "profile does not exist"
                :id id))
    (profile-to-map profile)))


;; ---- API: get-organization-member-team-counts

(def ^:private sql:get-organization-member-team-counts
  "SELECT tpr.profile_id, COUNT(DISTINCT t.id) AS team_count
     FROM team_profile_rel AS tpr
     JOIN team AS t ON t.id = tpr.team_id
    WHERE t.id = ANY(?)
      AND t.deleted_at IS NULL
      AND t.is_default IS FALSE
    GROUP BY tpr.profile_id;")

(def ^:private schema:get-organization-member-team-counts-params
  [:map [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]])

(def ^:private schema:get-organization-member-team-counts-result
  [:vector [:map
            [:profile-id ::sm/uuid]
            [:team-count ::sm/int]]])

(sv/defmethod ::get-organization-member-team-counts
  "Get the number of non-default teams each profile belongs to within a set of teams."
  {::doc/added "2.18"
   ::sm/params schema:get-organization-member-team-counts-params
   ::sm/result schema:get-organization-member-team-counts-result
   ::rpc/auth false}
  [cfg {:keys [team-ids]}]
  (let [team-ids (cond
                   (uuid? team-ids)
                   [team-ids]

                   (and (vector? team-ids) (every? uuid? team-ids))
                   team-ids

                   :else
                   [])]
    (if (empty? team-ids)
      []
      (db/run! cfg (fn [{:keys [::db/conn]}]
                     (let [ids-array (db/create-array conn "uuid" team-ids)]
                       (db/exec! conn [sql:get-organization-member-team-counts ids-array])))))))


;; API: invite-to-organization

(defn- get-invitation-organization
  [cfg profile-id organization-id]
  (let [{:keys [id name owner-id logo-id avatar-bg-url sso-active]}
        (nitrate/call cfg :get-organization-summary {:organization-id organization-id})]
    (when-not (= profile-id owner-id)
      (ex/raise :type :not-found
                :code :object-not-found
                :hint "not found"))
    {:id            id
     :name          name
     :initials      (if logo-id "" (d/get-initials name))
     :logo          (when logo-id (u/uri (files/resolve-public-uri logo-id)))
     :avatar-bg-url (when-not logo-id avatar-bg-url)
     :sso-active    (true? sso-active)}))

(sv/defmethod ::invite-to-organization
  "Invite to organization"
  {::doc/added "2.18"
   ::sm/params [:map
                [:email ::sm/email]
                [:organization cto/schema:organization-with-avatar]]
   ::nitrate/sso false}
  [cfg {profile-id ::rpc/profile-id
        :keys [organization]
        :as params}]
  (let [organization (get-invitation-organization cfg profile-id (:id organization))]
    (db/tx-run! cfg ti/create-organization-invitation (assoc params :organization organization)))
  nil)


;; API: get-organization-invitations

(def ^:private schema:get-organization-invitations-params
  [:map
   [:organization-id ::sm/uuid]])

(def ^:private schema:get-organization-invitations-result
  [:vector
   [:map
    [:id ::sm/uuid]
    [:organization-id {:optional true} [:maybe ::sm/uuid]]
    [:email ::sm/email]
    [:sent-at ::sm/inst]
    [:name {:optional true} [:maybe ::sm/text]]
    [:profile-id {:optional true} [:maybe ::sm/uuid]]
    [:photo-url {:optional true} ::sm/uri]]])

(sv/defmethod ::get-organization-invitations
  "Get valid invitations for an organization, returning at most one invitation per email."
  {::doc/added "2.18"
   ::sm/params schema:get-organization-invitations-params
   ::sm/result schema:get-organization-invitations-result
   ::nitrate/sso false}
  [cfg {:keys [organization-id]}]
  (let [team-ids (noh/get-organization-team-ids cfg organization-id)]
    (db/run! cfg (fn [{:keys [::db/conn]}]
                   (->> (noh/get-organization-invitations conn organization-id team-ids)
                        (mapv (fn [{:keys [photo-id] :as invitation}]
                                (cond-> (dissoc invitation :photo-id)
                                  photo-id
                                  (assoc :photo-url (files/resolve-public-uri photo-id))))))))))


;; API: delete-organization-invitations

(def ^:private sql:delete-organization-invitations
  "DELETE FROM team_invitation AS ti
    WHERE ti.email_to = ?
      AND (ti.org_id = ? OR ti.team_id = ANY(?));")

(def ^:private schema:delete-organization-invitations-params
  [:map
   [:organization-id ::sm/uuid]
   [:email ::sm/email]])

(sv/defmethod ::delete-organization-invitations
  "Delete all invitations for one email in an organization scope (organization + organization teams)."
  {::doc/added "2.18"
   ::sm/params schema:delete-organization-invitations-params
   ::nitrate/sso false}
  [cfg {:keys [organization-id email]}]
  (let [clean-email (profile/clean-email email)
        team-ids    (noh/get-organization-team-ids cfg organization-id)]
    (db/run! cfg (fn [{:keys [::db/conn]}]
                   (let [ids-array (db/create-array conn "uuid" team-ids)]
                     (db/exec! conn [sql:delete-organization-invitations clean-email organization-id ids-array]))))
    nil))


;; API: delete-all-organization-invitations

(def ^:private sql:delete-all-organization-invitations
  "DELETE FROM team_invitation AS ti
    WHERE ti.org_id = ?
       OR ti.team_id = ANY(?);")

(def ^:private schema:delete-all-organization-invitations-params
  [:map
   [:organization-id ::sm/uuid]])

(sv/defmethod ::delete-all-organization-invitations
  "Delete every pending invitation associated with an organization (organization-level + team-level).
   Called from Nitrate when an organization is about to be deleted, so users that click
   their invitation token hit the existing invalid-token landing page."
  {::doc/added "2.18"
   ::sm/params schema:delete-all-organization-invitations-params
   ::rpc/auth false}
  [cfg {:keys [organization-id]}]
  (let [team-ids (noh/get-organization-team-ids cfg organization-id)]
    (db/run! cfg (fn [{:keys [::db/conn]}]
                   (let [ids-array (db/create-array conn "uuid" team-ids)]
                     (db/exec! conn [sql:delete-all-organization-invitations organization-id ids-array]))))
    nil))


;; API: remove-from-organization

(def ^:private sql:get-reassign-to
  "SELECT tpr.profile_id
     FROM team_profile_rel AS tpr
    WHERE tpr.team_id = ?
      AND tpr.profile_id <> ?
      AND tpr.is_owner IS NOT TRUE
    ORDER BY CASE
               WHEN tpr.is_admin IS TRUE THEN 1
               ELSE 2
             END,
             tpr.created_at,
             tpr.profile_id
    LIMIT 1;")

(defn add-reassign-to [cfg profile-id team-to-transfer]
  (let [reassign-to (-> (db/exec-one! cfg [sql:get-reassign-to (:id team-to-transfer) profile-id])
                        :profile-id)]
    (when-not reassign-to
      (ex/raise :type :validation
                :code :nobody-to-reassign-team))

    (assoc team-to-transfer :reassign-to reassign-to)))

(sv/defmethod ::remove-from-organization
  "Remove an user from an organization"
  {::doc/added "2.18"
   ::sm/params [:map
                [:profile-id ::sm/uuid]
                [:organization-id ::sm/uuid]
                [:organization-name ::sm/text]
                [:default-team-id ::sm/uuid]]
   ::db/transaction true
   ::nitrate/sso false}
  [cfg {actor-profile-id ::rpc/profile-id
        :keys [profile-id organization-id organization-name default-team-id]
        :as params}]
  (let [actor-profile-id (when-not (= actor-profile-id uuid/zero)
                           actor-profile-id)
        {:keys [valid-teams-to-delete-ids
                valid-teams-to-transfer
                valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)
        add-reassign-to (partial add-reassign-to cfg profile-id)

        valid-teams-to-leave (into valid-teams-to-exit
                                   (map add-reassign-to valid-teams-to-transfer))]

    (cnit/leave-organization cfg (assoc params
                                        :id organization-id
                                        :name organization-name
                                        :teams-to-delete valid-teams-to-delete-ids
                                        :teams-to-leave valid-teams-to-leave
                                        :skip-validation true
                                        :user-who-delete-member actor-profile-id
                                        :deleted-by-role (when actor-profile-id
                                                           "organization-owner")))
    (notifications/notify-user-organization-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-organization")
    nil))

;; API: get-remove-from-organization-summary

(def ^:private schema:get-remove-from-organization-summary-result
  [:map
   [:teams-to-delete ::sm/int]
   [:teams-to-transfer ::sm/int]
   [:teams-to-exit ::sm/int]
   [:teams-to-detach ::sm/int]])

(sv/defmethod ::get-remove-from-organization-summary
  "Get a summary of the teams that would be deleted, transferred, or exited
   if the user were removed from the organization"
  {::doc/added "2.18"
   ::sm/params [:map
                [:profile-id ::sm/uuid]
                [:organization-id ::sm/uuid]
                [:default-team-id ::sm/uuid]]
   ::sm/result schema:get-remove-from-organization-summary-result
   ::db/transaction true
   ::nitrate/sso false}
  [cfg {:keys [profile-id organization-id default-team-id]}]
  (let [{:keys [valid-teams-to-delete-ids
                valid-teams-to-transfer
                valid-teams-to-exit
                valid-default-team]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)]
    (when-not valid-default-team
      (ex/raise :type :validation
                :code :not-valid-teams))
    (cnit/get-leave-organization-summary cfg
                                         default-team-id
                                         valid-teams-to-delete-ids
                                         (count valid-teams-to-transfer)
                                         (count valid-teams-to-exit))))

;; API: send-renewal-email

(def ^:private schema:send-renewal-email-params
  [:map
   [:profile-id ::sm/uuid]
   [:user-email ::sm/email]
   [:user-name [:maybe ::sm/text]]
   [:renewal-date :string]
   [:estimated-amount :double]
   [:organizations [:vector cto/schema:organization-with-avatar]]])

(sv/defmethod ::send-renewal-email
  "Send an Enterprise subscription renewal notice email to a user."
  {::doc/added "2.18"
   ::sm/params schema:send-renewal-email-params
   ::rpc/auth false}
  [cfg {:keys [profile-id user-email user-name renewal-date estimated-amount organizations]}]
  (let [amount-str (format "$%.2f" estimated-amount)
        user-name  (if (str/empty? user-name)
                     (:fullname (profile/get-profile cfg profile-id))
                     user-name)]
    (db/tx-run! cfg (fn [{:keys [::db/conn]}]
                      (eml/send! {::eml/conn    conn
                                  ::eml/factory eml/renewal-notice
                                  :public-uri   (cf/get :public-uri)
                                  :to           user-email
                                  :user-name    user-name
                                  :renewal-date renewal-date
                                  :estimated-amount amount-str
                                  :organizations organizations}))))
  nil)

;; API: exists-organization-team-invitations-for-non-members /
;;      delete-organization-team-invitations-for-non-members

(def ^:private sql:get-profile-emails-by-ids
  "SELECT email
     FROM profile
    WHERE id = ANY(?)
      AND deleted_at IS NULL")

(def ^:private sql:exists-non-member-organization-team-invitations
  "SELECT EXISTS (
            SELECT 1
              FROM team_invitation
             WHERE team_id = ANY(?)
               AND email_to <> ALL(?)
         ) AS non_member")

(def ^:private sql:delete-non-member-organization-team-invitations
  "DELETE FROM team_invitation
    WHERE team_id = ANY(?)
      AND email_to <> ALL(?)
   RETURNING email_to")

(def ^:private schema:organization-team-invitations-for-non-members-params
  [:map
   [:team-ids [:vector ::sm/uuid]]
   [:member-ids [:vector ::sm/uuid]]])

(def ^:private schema:exists-organization-team-invitations-for-non-members-result
  [:map [:exists ::sm/boolean]])

(defn- organization-team-invitations-for-non-members-arrays
  "Member emails and PG arrays used by exists/delete organization team invitation endpoints."
  [conn {:keys [team-ids member-ids]}]
  (let [member-ids-array (db/create-array conn "uuid" member-ids)
        member-emails    (->> (db/exec! conn [sql:get-profile-emails-by-ids member-ids-array])
                              (map :email)
                              (into #{}))]
    {:emails-array (db/create-array conn "text" (vec member-emails))
     :teams-array  (db/create-array conn "uuid" team-ids)}))

(defn- non-member-organization-team-invitations-exist?
  [conn params]
  (let [{:keys [emails-array teams-array]}
        (organization-team-invitations-for-non-members-arrays conn params)]
    (-> (db/exec-one! conn [sql:exists-non-member-organization-team-invitations
                            teams-array
                            emails-array])
        :non-member)))

(sv/defmethod ::exists-organization-team-invitations-for-non-members
  "Return if there are any team invitations for emails that are not organization members."
  {::doc/added "2.18"
   ::sm/params schema:organization-team-invitations-for-non-members-params
   ::sm/result schema:exists-organization-team-invitations-for-non-members-result
   ::nitrate/sso false}
  [cfg params]
  (db/run! cfg (fn [{:keys [::db/conn]}]
                 {:exists (boolean (non-member-organization-team-invitations-exist? conn params))})))

(sv/defmethod ::delete-organization-team-invitations-for-non-members
  "Delete team invitations for emails that are not organization members."
  {::doc/added "2.18"
   ::sm/params schema:organization-team-invitations-for-non-members-params
   ::db/transaction true
   ::nitrate/sso false}
  [cfg params]
  (db/run! cfg (fn [{:keys [::db/conn]}]
                 (let [{:keys [emails-array teams-array]}
                       (organization-team-invitations-for-non-members-arrays conn params)]
                   (db/exec! conn [sql:delete-non-member-organization-team-invitations
                                   teams-array
                                   emails-array])
                   nil))))

;; ---- API: push-audit-events

(def ^:private schema:nitrate-audit-event
  [:map {:title "NitrateAuditEvent"}
   [:name [:and [:string {:max 250}]
           [:re #"[\d\w-]{1,50}"]]]
   [:type {:optional true} ::sm/text]
   [:profile-id ::sm/uuid]
   [:props {:optional true} [:map-of :keyword :any]]
   [:context {:optional true} [:map-of :keyword :any]]])

(def ^:private schema:push-audit-events-params
  [:map {:title "PushAuditEventsParams"}
   [:events [:vector schema:nitrate-audit-event]]])

(sv/defmethod ::push-audit-events
  "Push audit events from nitrate (strictly for nitrate backend
  events)"

  {::doc/added "2.18"
   ::audit/skip true
   ::sm/params schema:push-audit-events-params
   ::rpc/auth false}
  [cfg {:keys [::rpc/request-at events] :as params}]
  (let [request  (-> params meta ::http/request)
        context' (-> (audit/prepare-context-from-request request)
                     (assoc :request-id (::rpc/request-id params)))

        ip-addr  (::rpc/ip-addr params)]

    (run! (fn [{:keys [type name profile-id props context] :as event}]
            (let [context (-> (merge context (d/without-nils context'))
                              (d/without-nils))]
              (audit/submit cfg {:type (d/nilv type "action")
                                 :name name
                                 :profile-id profile-id
                                 :props (or props {})
                                 :context context
                                 :tracked-at request-at
                                 :ip-addr ip-addr})))
          events)

    nil))


;; ---- API: get-teams-detail

(def ^:private sql:get-teams-detail
  "SELECT
     t.id,
     t.name,
     t.photo_id,
     t.created_at,
       (SELECT MAX(activity.modified_at)
          FROM (
            SELECT p2.modified_at
              FROM project AS p2
             WHERE p2.team_id = t.id
               AND p2.deleted_at IS NULL
               AND p2.is_default IS FALSE
            UNION ALL
            SELECT f.modified_at
              FROM file AS f
              JOIN project AS p ON p.id = f.project_id
             WHERE p.team_id = t.id
               AND p.deleted_at IS NULL
               AND f.deleted_at IS NULL
            UNION ALL
            SELECT tpr2.created_at
              FROM team_profile_rel AS tpr2
             WHERE tpr2.team_id = t.id
               AND tpr2.is_owner IS NOT TRUE
            UNION ALL
            SELECT ti.updated_at
              FROM team_invitation AS ti
             WHERE ti.team_id = t.id
          ) AS activity) AS last_activity_at,
     owner_tpr.profile_id AS owner_profile_id,
     owner_p.fullname AS owner_name,
     owner_p.photo_id AS owner_photo_id,
     (SELECT COUNT(*)
        FROM project AS p3
       WHERE p3.team_id = t.id
         AND p3.deleted_at IS NULL
         AND p3.is_default IS FALSE) AS num_projects,
     (SELECT COUNT(*)
        FROM file AS f
        JOIN project AS p4 ON p4.id = f.project_id
       WHERE p4.team_id = t.id
         AND f.deleted_at IS NULL
         AND p4.deleted_at IS NULL) AS num_files,
     (SELECT COUNT(*)
        FROM team_profile_rel AS tpr
       WHERE tpr.team_id = t.id) AS num_members
   FROM team AS t
   LEFT JOIN team_profile_rel AS owner_tpr
     ON owner_tpr.team_id = t.id AND owner_tpr.is_owner IS TRUE
   LEFT JOIN profile AS owner_p
     ON owner_p.id = owner_tpr.profile_id
   WHERE t.id = ANY(?)
     AND t.deleted_at IS NULL
     AND t.is_default IS FALSE
   ORDER BY last_activity_at DESC NULLS LAST")

(def ^:private schema:get-teams-detail-params
  [:map
   [:organization-id ::sm/uuid]])

(def ^:private schema:get-teams-detail-result
  [:vector
   [:map
    [:id ::sm/uuid]
    [:name ::sm/text]
    [:photo-url {:optional true} ::sm/uri]
    [:created-at ::sm/inst]
    [:last-activity-at {:optional true} [:maybe ::sm/inst]]
    [:owner-profile-id {:optional true} [:maybe ::sm/uuid]]
    [:owner-name {:optional true} [:maybe ::sm/text]]
    [:owner-photo-url {:optional true} ::sm/uri]
    [:num-projects ::sm/int]
    [:num-files ::sm/int]
    [:num-members ::sm/int]]])

(sv/defmethod ::get-teams-detail
  "Get detailed information for all non-deleted teams in an organization,
   including owner info and project/file/member counts."
  {::doc/added "2.18"
   ::sm/params schema:get-teams-detail-params
   ::sm/result schema:get-teams-detail-result
   ::nitrate/sso false}
  [cfg {:keys [organization-id]}]
  (let [organization-summary (nitrate/call cfg :get-organization-summary {:organization-id organization-id})
        team-ids    (into [] (comp d/xf:map-id (filter uuid?)) (:teams organization-summary))]
    (if (empty? team-ids)
      []
      (db/run! cfg
               (fn [{:keys [::db/conn]}]
                 (let [ids-array (db/create-array conn "uuid" team-ids)]
                   (->> (db/exec! conn [sql:get-teams-detail ids-array])
                        (mapv (fn [{:keys [photo-id owner-photo-id] :as row}]
                                (cond-> (dissoc row :photo-id :owner-photo-id)
                                  photo-id       (assoc :photo-url       (files/resolve-public-uri photo-id))
                                  owner-photo-id (assoc :owner-photo-url (files/resolve-public-uri owner-photo-id))))))))))))

;; ---- API: check-organization-sso

(def ^:private schema:check-organization-sso-result
  [:map
   [:valid ::sm/boolean]])

(sv/defmethod ::check-organization-sso
  "Validate an organization SSO configuration by generating a login redirect URL.
  Nitrate calls this while configuring SSO to verify client credentials and OIDC
  discovery before saving the settings."
  {::doc/added "2.18"
   ::sm/params cto/schema:nitrate-sso
   ::sm/result schema:check-organization-sso-result
   ::rpc/auth false}
  [cfg params]
  {:valid (oidc/is-organization-sso-config-valid? cfg params)})

;; ---- API: notify-organization-sso-change
(sv/defmethod ::notify-organization-sso-change
  "Nitrate notifies that an organization sso values have changed"
  {::doc/added "2.18"
   ::sm/params [:map
                [:organization-id ::sm/uuid]
                [:updated-props ::sm/boolean]
                [:announce-activation ::sm/boolean]]
   ::rpc/auth false}
  [{:keys [::db/pool] :as cfg} {:keys [organization-id updated-props announce-activation]}]
  (when updated-props
    (rpc/invalidate-organization-sso-cache-by-organization! organization-id)
    (session/clear-organization-sso-sessions! pool organization-id))
  (notifications/notify-organization-change-sso cfg organization-id)
  (when announce-activation
    (neh/send-organization-setup-sso-emails! cfg organization-id))
  nil)

;; ---- API: bulk-create-profiles

(def ^:private schema:bulk-create-profiles-params
  [:map
   [:password [::sm/word-string {:max 500}]]
   [:emails [:vector ::sm/email]]])

(def ^:private schema:bulk-create-profiles-result
  [:map
   [:created [:vector ::sm/email]]
   [:skipped [:vector ::sm/email]]])

(defn- create-active-profile!
  "Create a single already-active profile (email pre-verified, onboarding
   skipped) plus its default team. Returns nil; existence checks happen in the
   caller so duplicates are skipped instead of aborting the whole batch."
  [cfg email password]
  (let [fullname (-> (str/split email "@") first)]
    (->> {:email email
          :fullname fullname
          :password password
          :is-active true
          :props {:onboarding-viewed true}}
         (auth/create-profile cfg)
         (auth/create-profile-rels cfg))
    nil))

(sv/defmethod ::bulk-create-profiles
  "Create multiple already-active profiles that share a single password. The
   created users skip email verification and onboarding. Emails that already
   belong to an existing profile are skipped. Intended for the Nitrate admin
   bulk-creation screen; access is gated by the shared key and, in Nitrate, an
   email allow-list. Requires the `admin-console-bulk-create-profiles` flag, disabled
   by default so it is only available on test environments."
  {::doc/added "2.18"
   ::sm/params schema:bulk-create-profiles-params
   ::sm/result schema:bulk-create-profiles-result
   ::rpc/auth false}
  [cfg {:keys [password emails]}]

  (when-not (contains? cf/flags :admin-console-bulk-create-profiles)
    (ex/raise :type :restriction
              :code :bulk-create-profiles-not-allowed
              :hint "Bulk profile creation is disabled by config."))

  (let [derived (aauth/derive-password password)]
    (db/tx-run!
     cfg
     (fn [{:keys [::db/conn] :as cfg}]
       (reduce
        (fn [acc email]
          (let [email (eml/clean email)]
            (if (profile/get-profile-by-email conn email)
              (update acc :skipped conj email)
              (do
                (create-active-profile! cfg email derived)
                (update acc :created conj email)))))
        {:created [] :skipped []}
        emails)))))
