{-# LANGUAGE LambdaCase #-}

-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2025 Wire Swiss GmbH <opensource@wire.com>
--
-- This program is free software: you can redistribute it and/or modify it under
-- the terms of the GNU Affero General Public License as published by the Free
-- Software Foundation, either version 3 of the License, or (at your option) any
-- later version.
--
-- This program is distributed in the hope that it will be useful, but WITHOUT
-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
-- details.
--
-- You should have received a copy of the GNU Affero General Public License along
-- with this program. If not, see <https://www.gnu.org/licenses/>.

module Galley.API.Teams
  ( createBindingTeam,
    createNonBindingTeamH,
    updateTeamH,
    updateTeamStatus,
    getTeamH,
    getTeamInternalH,
    getTeamNameInternalH,
    getBindingTeamMembers,
    getManyTeams,
    deleteTeam,
    addTeamMember,
    getTeamConversationRoles,
    getTeamMembers,
    bulkGetTeamMembers,
    getTeamMember,
    deleteTeamMember,
    deleteNonBindingTeamMember,
    updateTeamMember,
    getTeamConversations,
    getTeamConversation,
    deleteTeamConversation,
    getSearchVisibility,
    setSearchVisibility,
    getSearchVisibilityInternal,
    setSearchVisibilityInternal,
    uncheckedAddTeamMember,
    uncheckedGetTeamMember,
    uncheckedDeleteTeamMember,
    uncheckedUpdateTeamMember,
    userIsTeamOwner,
    canUserJoinTeam,
    ensureNotTooLargeForLegalHold,
    ensureNotTooLargeToActivateLegalHold,
    internalDeleteBindingTeam,
    updateTeamCollaborator,
    removeTeamCollaborator,
  )
where

import Cassandra (PageWithState (pwsResults), pwsHasMore)
import Cassandra qualified as C
import Control.Lens
import Data.ByteString.Conversion (toByteString)
import Data.ByteString.Conversion qualified as BSC
import Data.ByteString.Lazy qualified as LBS
import Data.Default
import Data.HashMap.Strict qualified as HM
import Data.Id
import Data.Json.Util
import Data.LegalHold qualified as LH
import Data.Map qualified as Map
import Data.Proxy
import Data.Qualified
import Data.Range as Range
import Data.Set qualified as Set
import Data.Time.Clock (UTCTime)
import Galley.API.LegalHold.Team
import Galley.API.Teams.Notifications qualified as APITeamQueue
import Galley.App
import Galley.Types.Error as Galley
import Imports hiding (forkIO)
import Polysemy
import Polysemy.Error
import Polysemy.Input
import Polysemy.TinyLog qualified as P
import System.Logger qualified as Log
import Wire.API.Conversation.Role (wireConvRoles)
import Wire.API.Conversation.Role qualified as Public
import Wire.API.Error
import Wire.API.Error.Galley
import Wire.API.Event.Team
import Wire.API.Push.V2 (RecipientClients (RecipientClientsAll))
import Wire.API.Routes.Internal.Galley.TeamsIntra
import Wire.API.Routes.MultiTablePaging (MultiTablePage (..), MultiTablePagingState (..))
import Wire.API.Routes.Public.Galley.TeamMember
import Wire.API.Team
import Wire.API.Team qualified as Public
import Wire.API.Team.Collaborator qualified as Collaborator
import Wire.API.Team.Collaborator qualified as TeamCollaborator
import Wire.API.Team.Conversation
import Wire.API.Team.Conversation qualified as Public
import Wire.API.Team.Feature
import Wire.API.Team.FeatureFlags
import Wire.API.Team.Member
import Wire.API.Team.Member qualified as M
import Wire.API.Team.Member qualified as Public
import Wire.API.Team.Permission (Perm (..), Permissions (..), SPerm (..), copy, fullPermissions, self)
import Wire.API.Team.Role
import Wire.API.Team.SearchVisibility
import Wire.API.Team.SearchVisibility qualified as Public
import Wire.API.Team.Size
import Wire.API.User qualified as U
import Wire.API.User.Search
import Wire.BoundedQueue qualified as E
import Wire.BrigAPIAccess
import Wire.BrigAPIAccess qualified as Brig
import Wire.BrigAPIAccess qualified as E
import Wire.ConversationStore (ConversationStore)
import Wire.ConversationStore qualified as E
import Wire.ConversationSubsystem
import Wire.FeaturesConfigSubsystem
import Wire.LegalHoldStore (LegalHoldStore)
import Wire.ListItems
import Wire.ListItems qualified as E
import Wire.NotificationSubsystem
import Wire.Options.Galley (Opts, maxTeamSize, settings)
import Wire.Sem.Now
import Wire.Sem.Now qualified as Now
import Wire.Sem.Paging.Cassandra
import Wire.TeamCollaboratorsSubsystem
import Wire.TeamJournal (TeamJournal)
import Wire.TeamJournal qualified as Journal
import Wire.TeamMemberStore qualified as E
import Wire.TeamNotificationStore (TeamNotificationStore)
import Wire.TeamStore (TeamStore)
import Wire.TeamStore qualified as E
import Wire.TeamSubsystem (TeamSubsystem)
import Wire.TeamSubsystem qualified as TeamSubsystem
import Wire.Util

getTeamH ::
  forall r.
  ( Member (ErrorS 'TeamNotFound) r,
    Member (E.BoundedQueue DeleteItem) r,
    Member TeamStore r,
    Member TeamSubsystem r
  ) =>
  UserId ->
  TeamId ->
  Sem r Public.Team
getTeamH zusr tid =
  maybe (throwS @'TeamNotFound) pure =<< lookupTeam zusr tid

getTeamInternalH ::
  ( Member (ErrorS 'TeamNotFound) r,
    Member TeamStore r
  ) =>
  TeamId ->
  Sem r TeamData
getTeamInternalH tid =
  E.getTeam tid >>= noteS @'TeamNotFound

getTeamNameInternalH ::
  ( Member (ErrorS 'TeamNotFound) r,
    Member TeamStore r
  ) =>
  TeamId ->
  Sem r TeamName
getTeamNameInternalH tid =
  getTeamNameInternal tid >>= noteS @'TeamNotFound

getTeamNameInternal :: (Member TeamStore r) => TeamId -> Sem r (Maybe TeamName)
getTeamNameInternal = fmap (fmap TeamName) . E.getTeamName

-- | DEPRECATED.
--
-- The endpoint was designed to query non-binding teams. However, non-binding teams is a feature
-- that has never been adopted by clients, but the endpoint also returns the binding team of a user and it is
-- possible that this is being used by a client, even though unlikely.
--
-- The following functionality has been changed: query parameters will be ignored, which has the effect
-- that regardless of the parameters the response will always contain the binding team of the user if
-- it exists. Even though they are ignored, the use of query parameters will not result in an error.
--
-- (If you want to be pedantic, the `size` parameter is still honored: its allowed range is
-- between 1 and 100, and that will always be an upper bound of the result set of size 0 or
-- one.)
getManyTeams ::
  ( Member TeamStore r,
    Member (E.BoundedQueue DeleteItem) r,
    Member (ListItems LegacyPaging TeamId) r,
    Member TeamSubsystem r
  ) =>
  UserId ->
  Sem r Public.TeamList
getManyTeams zusr =
  withTeamIds zusr Nothing (toRange (Proxy @100)) $ \more ids -> do
    teams <- mapM (lookupTeam zusr) ids
    pure (Public.newTeamList (catMaybes teams) more)

lookupTeam ::
  ( Member TeamStore r,
    Member (E.BoundedQueue DeleteItem) r,
    Member TeamSubsystem r
  ) =>
  UserId ->
  TeamId ->
  Sem r (Maybe Public.Team)
lookupTeam zusr tid = do
  tm <- TeamSubsystem.internalGetTeamMember zusr tid
  if isJust tm
    then do
      t <- E.getTeam tid
      when (Just PendingDelete == (tdStatus <$> t)) $ do
        void $ E.tryPush (TeamItem tid zusr Nothing)
      pure (tdTeam <$> t)
    else pure Nothing

createNonBindingTeamH ::
  (Member (ErrorS InvalidAction) r) =>
  UserId ->
  ConnId ->
  a ->
  Sem r TeamId
createNonBindingTeamH _ _ _ = do
  -- non-binding teams are not supported anymore
  throwS @InvalidAction

createBindingTeam ::
  ( Member NotificationSubsystem r,
    Member Now r,
    Member TeamStore r
  ) =>
  TeamId ->
  UserId ->
  NewTeam ->
  Sem r TeamId
createBindingTeam tid zusr body = do
  let owner = Public.mkTeamMember zusr fullPermissions Nothing LH.defUserLegalHoldStatus
  team <-
    E.createTeam (Just tid) zusr body.newTeamName body.newTeamIcon body.newTeamIconKey Binding

  E.createTeamMember tid owner
  now <- Now.get
  let e = newEvent tid now (EdTeamCreate team)
  pushNotifications
    [ def
        { origin = Just zusr,
          json = toJSONObject e,
          recipients = [userRecipient zusr]
        }
    ]
  pure tid

updateTeamStatus ::
  ( Member E.BrigAPIAccess r,
    Member (ErrorS 'TeamNotFound) r,
    Member Now r,
    Member TeamStore r,
    Member TeamJournal r,
    Member (ErrorS InvalidTeamStatusUpdate) r
  ) =>
  TeamId ->
  TeamStatusUpdate ->
  Sem r ()
updateTeamStatus tid (TeamStatusUpdate newStatus cur) = do
  oldStatus <- fmap tdStatus $ E.getTeam tid >>= noteS @'TeamNotFound
  valid <- validateTransition (oldStatus, newStatus)
  when valid $ do
    runJournal newStatus cur
    E.setTeamStatus tid newStatus
  where
    runJournal Suspended _ = Journal.teamSuspend tid
    runJournal Active c = do
      teamCreationTime <- E.getTeamCreationTime tid
      -- When teams are created, they are activated immediately. In this situation, Brig will
      -- most likely report team size as 0 due to ES taking some time to index the team creator.
      -- This is also very difficult to test, so is not tested.
      -- We could also write `updateTeamSize 1 size 0` here, but it seems clearer to do it
      -- inline.
      teamSize <- do
        (TeamSize numRegulars numApps) <- E.getSize tid
        pure $ TeamSize (max 1 numRegulars) numApps
      Journal.teamActivate tid teamSize c teamCreationTime
    runJournal _ _ = throwS @'InvalidTeamStatusUpdate
    validateTransition :: (Member (ErrorS 'InvalidTeamStatusUpdate) r) => (TeamStatus, TeamStatus) -> Sem r Bool
    validateTransition = \case
      (PendingActive, Active) -> pure True
      (Active, Active) -> pure False
      (Active, Suspended) -> pure True
      (Suspended, Active) -> pure True
      (Suspended, Suspended) -> pure False
      (_, _) -> throwS @'InvalidTeamStatusUpdate

updateTeamH ::
  ( Member NotificationSubsystem r,
    Member Now r,
    Member TeamStore r,
    Member TeamSubsystem r,
    Member (ErrorS (MissingPermission (Just SetTeamData))) r,
    Member (ErrorS NotATeamMember) r
  ) =>
  UserId ->
  ConnId ->
  TeamId ->
  Public.TeamUpdateData ->
  Sem r ()
updateTeamH zusr zcon tid updateData = do
  zusrMembership <- TeamSubsystem.internalGetTeamMember zusr tid
  void $ TeamSubsystem.permissionCheckS SSetTeamData zusrMembership
  E.setTeamData tid updateData
  now <- Now.get
  admins <- E.getTeamAdmins tid
  let e = newEvent tid now (EdTeamUpdate updateData)
  let r = userRecipient zusr : map userRecipient (filter (/= zusr) admins)
  pushNotifications
    [ def
        { origin = Just zusr,
          json = toJSONObject e,
          recipients = r,
          conn = Just zcon,
          transient = True
        }
    ]

deleteTeam ::
  forall r.
  ( Member (ErrorS 'DeleteQueueFull) r,
    Member (ErrorS 'TeamNotFound) r,
    Member (ErrorS OperationDenied) r,
    Member (Error AuthenticationError) r,
    Member (E.BoundedQueue DeleteItem) r,
    Member (ErrorS NotATeamMember) r,
    Member TeamStore r,
    Member TeamSubsystem r,
    Member E.BrigAPIAccess r
  ) =>
  UserId ->
  ConnId ->
  TeamId ->
  Public.TeamDeleteData ->
  Sem r ()
deleteTeam zusr zcon tid body = do
  team <- E.getTeam tid >>= noteS @'TeamNotFound
  case tdStatus team of
    Deleted -> throwS @'TeamNotFound
    PendingDelete ->
      queueTeamDeletion tid zusr (Just zcon)
    _ -> do
      checkPermissions team
      queueTeamDeletion tid zusr (Just zcon)
  where
    checkPermissions team = do
      void $ TeamSubsystem.permissionCheck DeleteTeam =<< TeamSubsystem.internalGetTeamMember zusr tid
      when (tdTeam team ^. teamBinding == Binding) $ do
        ensureReAuthorised zusr (body ^. tdAuthPassword) (body ^. tdVerificationCode) (Just U.DeleteTeam)

-- This can be called by stern
internalDeleteBindingTeam ::
  ( Member (ErrorS 'NoBindingTeam) r,
    Member (ErrorS 'TeamNotFound) r,
    Member (ErrorS 'NotAOneMemberTeam) r,
    Member (ErrorS 'DeleteQueueFull) r,
    Member (E.BoundedQueue DeleteItem) r,
    Member TeamStore r,
    Member TeamSubsystem r
  ) =>
  TeamId ->
  Bool ->
  Sem r ()
internalDeleteBindingTeam tid force = do
  mbTeamData <- E.getTeam tid
  case tdTeam <$> mbTeamData of
    Nothing -> throwS @'TeamNotFound
    Just team | team ^. teamBinding /= Binding -> throwS @'NoBindingTeam
    Just team -> do
      mems <- TeamSubsystem.internalGetTeamMembersWithLimit tid (Just (unsafeRange 2))
      case mems ^. teamMembers of
        [mem] -> queueTeamDeletion tid (mem ^. userId) Nothing
        -- if the team has more than one member (and deletion is forced) or no members we use the team creator's userId for deletion events
        xs | null xs || force -> queueTeamDeletion tid (team ^. teamCreator) Nothing
        _ -> throwS @'NotAOneMemberTeam

getTeamConversationRoles ::
  ( Member (ErrorS 'NotATeamMember) r,
    Member TeamSubsystem r
  ) =>
  UserId ->
  TeamId ->
  Sem r Public.ConversationRolesList
getTeamConversationRoles zusr tid = do
  void $ TeamSubsystem.internalGetTeamMember zusr tid >>= noteS @'NotATeamMember
  -- NOTE: If/when custom roles are added, these roles should
  --       be merged with the team roles (if they exist)
  pure $ Public.ConversationRolesList wireConvRoles

getTeamMembers ::
  ( Member (ErrorS 'NotATeamMember) r,
    Member E.BrigAPIAccess r,
    Member (E.TeamMemberStore CassandraPaging) r,
    Member P.TinyLog r,
    Member TeamSubsystem r
  ) =>
  Local UserId ->
  TeamId ->
  Maybe (Range 1 Public.HardTruncationLimit Int32) ->
  Maybe TeamMembersPagingState ->
  Sem r TeamMembersPage
getTeamMembers lzusr tid mbMaxResults mbPagingState = do
  let uid = tUnqualified lzusr
  member <- TeamSubsystem.internalGetTeamMember uid tid >>= noteS @'NotATeamMember
  let mState = C.PagingState . LBS.fromStrict <$> (mbPagingState >>= mtpsState)
  let mLimit = fromMaybe (unsafeRange Public.hardTruncationLimit) mbMaxResults
  if member `hasPermission` SearchContacts
    then do
      pws :: PageWithState Void TeamMember <- E.listTeamMembers @CassandraPaging tid mState mLimit
      -- FUTUREWORK: Remove this via-Brig filtering when user and
      -- team_member tables are migrated to Postgres. We currently
      -- can't filter in the database because Cassandra doesn't
      -- support joins. The SQL could otherwise be (pseudocode):
      -- `select team_member.* from team_member, user where
      -- team_member.user = user.id where searchable`.
      let pwsResults0 = pwsResults pws
      users <-
        HM.fromList . map (\user -> (qUnqualified (U.userQualifiedId user), user))
          <$> E.getUsers (map (^. userId) pwsResults0)

      let results = flip mapMaybe pwsResults0 $ \tm ->
            let uid' = tm ^. userId
                mapSearchable user = Just tm <* guard (U.userSearchable user)
             in maybe (Just $ Left uid') (fmap Right . mapSearchable) $ HM.lookup uid' users
      let notFoundUserUids = lefts results
      unless (null notFoundUserUids) $
        P.err $
          Log.field "targets" (show notFoundUserUids)
            . Log.field "action" (Log.val "Teams.getTeamMembers")
            . Log.msg (Log.val "Could not find team members with target uids from Brig")
      pure $ toTeamMembersPage member $ pws {pwsResults = rights results}
    else do
      -- If the user does not have the SearchContacts permission (e.g. the external partner),
      -- we only return the person who invited them and the self user.
      let invitee = member ^. invitation <&> fst
      let uids = uid : maybeToList invitee
      TeamSubsystem.internalSelectTeamMembers tid uids <&> toTeamSingleMembersPage member
  where
    toTeamMembersPage :: TeamMember -> C.PageWithState Void TeamMember -> TeamMembersPage
    toTeamMembersPage member p =
      let withPerms = (member `canSeePermsOf`)
       in TeamMembersPage $
            MultiTablePage
              { mtpResults = map (setOptionalPerms withPerms) $ pwsResults p,
                mtpHasMore = pwsHasMore p,
                mtpPagingState = teamMemberPagingState p
              }

    toTeamSingleMembersPage :: TeamMember -> [TeamMember] -> TeamMembersPage
    toTeamSingleMembersPage member =
      mkSingleTeamMembersPage . map (setOptionalPerms (member `canSeePermsOf`))

-- | like 'getTeamMembers', but with an explicit list of users we are to return.
bulkGetTeamMembers ::
  ( Member (ErrorS 'BulkGetMemberLimitExceeded) r,
    Member (ErrorS 'NotATeamMember) r,
    Member TeamSubsystem r
  ) =>
  Local UserId ->
  TeamId ->
  Maybe (Range 1 HardTruncationLimit Int32) ->
  U.UserIdList ->
  Sem r TeamMemberListOptPerms
bulkGetTeamMembers lzusr tid mbMaxResults uids = do
  unless (length (U.mUsers uids) <= fromIntegral (fromRange (fromMaybe (unsafeRange Public.hardTruncationLimit) mbMaxResults))) $
    throwS @'BulkGetMemberLimitExceeded
  m <- TeamSubsystem.internalGetTeamMember (tUnqualified lzusr) tid >>= noteS @'NotATeamMember
  mems <- TeamSubsystem.internalSelectTeamMembers tid (U.mUsers uids)
  let withPerms = (m `canSeePermsOf`)
      hasMore = ListComplete
  pure $ setOptionalPermsMany withPerms (newTeamMemberList mems hasMore)

getTeamMember ::
  ( Member (ErrorS 'TeamMemberNotFound) r,
    Member (ErrorS 'NotATeamMember) r,
    Member TeamSubsystem r
  ) =>
  Local UserId ->
  TeamId ->
  UserId ->
  Sem r TeamMemberOptPerms
getTeamMember lzusr tid uid = do
  m <-
    TeamSubsystem.internalGetTeamMember (tUnqualified lzusr) tid
      >>= noteS @'NotATeamMember
  let withPerms = (m `canSeePermsOf`)
  member <- TeamSubsystem.internalGetTeamMember uid tid >>= noteS @'TeamMemberNotFound
  pure $ setOptionalPerms withPerms member

uncheckedGetTeamMember ::
  ( Member (ErrorS 'TeamMemberNotFound) r,
    Member TeamSubsystem r
  ) =>
  TeamId ->
  UserId ->
  Sem r TeamMember
uncheckedGetTeamMember tid uid =
  TeamSubsystem.internalGetTeamMember uid tid >>= noteS @'TeamMemberNotFound

addTeamMember ::
  forall r.
  ( Member E.BrigAPIAccess r,
    Member NotificationSubsystem r,
    Member (ErrorS 'InvalidPermissions) r,
    Member (ErrorS 'NoAddToBinding) r,
    Member (ErrorS 'TeamNotFound) r,
    Member (ErrorS 'TooManyTeamMembers) r,
    Member (ErrorS 'TooManyTeamAdmins) r,
    Member (ErrorS 'UserBindingExists) r,
    Member (ErrorS 'TooManyTeamMembersOnTeamWithLegalhold) r,
    Member (ErrorS OperationDenied) r,
    Member (ErrorS 'NotATeamMember) r,
    Member (ErrorS 'NotConnected) r,
    Member (Input Opts) r,
    Member Now r,
    Member LegalHoldStore r,
    Member TeamNotificationStore r,
    Member TeamStore r,
    Member P.TinyLog r,
    Member (Input FanoutLimit) r,
    Member (Input (FeatureDefaults LegalholdConfig)) r,
    Member TeamSubsystem r,
    Member FeaturesConfigSubsystem r
  ) =>
  Local UserId ->
  ConnId ->
  TeamId ->
  Public.NewTeamMember ->
  Sem r ()
addTeamMember lzusr zcon tid nmem = do
  let zusr = tUnqualified lzusr
  let uid = nmem ^. nUserId
  P.debug $
    Log.field "targets" (toByteString uid)
      . Log.field "action" (Log.val "Teams.addTeamMember")
  -- verify permissions
  zusrMembership <-
    TeamSubsystem.internalGetTeamMember zusr tid
      >>= TeamSubsystem.permissionCheck AddTeamMember
  let targetPermissions = nmem ^. nPermissions
  targetPermissions `ensureNotElevated` zusrMembership
  ensureNonBindingTeam tid
  ensureUnboundUsers [uid]
  E.ensureConnectedToLocals zusr [uid]
  void $ addTeamMemberInternal tid (Just zusr) (Just zcon) nmem

-- This function is "unchecked" because there is no need to check for user binding (invite only).
uncheckedAddTeamMember ::
  forall r.
  ( Member E.BrigAPIAccess r,
    Member NotificationSubsystem r,
    Member (ErrorS 'TooManyTeamMembers) r,
    Member (ErrorS 'TooManyTeamAdmins) r,
    Member (ErrorS 'TooManyTeamMembersOnTeamWithLegalhold) r,
    Member (Input Opts) r,
    Member Now r,
    Member LegalHoldStore r,
    Member P.TinyLog r,
    Member TeamNotificationStore r,
    Member TeamStore r,
    Member (Input FanoutLimit) r,
    Member (Input (FeatureDefaults LegalholdConfig)) r,
    Member TeamJournal r,
    Member FeaturesConfigSubsystem r
  ) =>
  TeamId ->
  NewTeamMember ->
  Sem r ()
uncheckedAddTeamMember tid nmem = do
  newTeamSize <- addTeamMemberInternal tid Nothing Nothing nmem
  owners <- E.getBillingTeamMembers tid
  Journal.teamUpdate tid newTeamSize owners

uncheckedUpdateTeamMember ::
  forall r.
  ( Member E.BrigAPIAccess r,
    Member (ErrorS 'TeamNotFound) r,
    Member (ErrorS 'TeamMemberNotFound) r,
    Member (ErrorS 'TooManyTeamAdmins) r,
    Member NotificationSubsystem r,
    Member Now r,
    Member P.TinyLog r,
    Member TeamStore r,
    Member TeamJournal r,
    Member TeamSubsystem r
  ) =>
  Maybe (Local UserId) ->
  Maybe ConnId ->
  TeamId ->
  NewTeamMember ->
  Sem r ()
uncheckedUpdateTeamMember mlzusr mZcon tid newMem = do
  let mZusr = tUnqualified <$> mlzusr
  let targetMember = ntmNewTeamMember newMem
  let targetId = targetMember ^. userId
      targetPermissions = targetMember ^. permissions
  P.debug $
    Log.field "targets" (toByteString targetId)
      . Log.field "action" (Log.val "Teams.updateTeamMember")

  team <- fmap tdTeam $ E.getTeam tid >>= noteS @'TeamNotFound

  previousMember <-
    TeamSubsystem.internalGetTeamMember targetId tid >>= noteS @'TeamMemberNotFound

  admins <- E.getTeamAdmins tid
  let admins' = [targetId | isAdminOrOwner targetPermissions] <> filter (/= targetId) admins
  checkAdminLimit (length admins')

  -- update target in Cassandra
  E.setTeamMemberPermissions (previousMember ^. permissions) tid targetId targetPermissions

  when (team ^. teamBinding == Binding) $ do
    teamSize <- E.getSize tid
    owners <- E.getBillingTeamMembers tid
    Journal.teamUpdate tid teamSize owners

  now <- Now.get
  let event = newEvent tid now (EdMemberUpdate targetId (Just targetPermissions))
  pushNotifications
    [ def
        { origin = mZusr,
          json = toJSONObject event,
          recipients = map userRecipient admins',
          conn = mZcon,
          transient = True
        }
    ]
  Brig.updateSearchIndex targetId

updateTeamMember ::
  forall r.
  ( Member E.BrigAPIAccess r,
    Member (ErrorS 'AccessDenied) r,
    Member (ErrorS 'InvalidPermissions) r,
    Member (ErrorS 'TeamNotFound) r,
    Member (ErrorS 'TeamMemberNotFound) r,
    Member (ErrorS 'TooManyTeamAdmins) r,
    Member (ErrorS OperationDenied) r,
    Member (ErrorS 'NotATeamMember) r,
    Member NotificationSubsystem r,
    Member Now r,
    Member P.TinyLog r,
    Member TeamStore r,
    Member TeamJournal r,
    Member TeamSubsystem r
  ) =>
  Local UserId ->
  ConnId ->
  TeamId ->
  NewTeamMember ->
  Sem r ()
updateTeamMember lzusr zcon tid newMem = do
  let zusr = tUnqualified lzusr
  let targetMember = ntmNewTeamMember newMem
  let targetId = targetMember ^. userId
      targetPermissions = targetMember ^. permissions
  P.debug $
    Log.field "targets" (toByteString targetId)
      . Log.field "action" (Log.val "Teams.updateTeamMember")

  -- get the team and verify permissions
  user <-
    TeamSubsystem.internalGetTeamMember zusr tid
      >>= TeamSubsystem.permissionCheck SetMemberPermissions

  -- user may not elevate permissions
  targetPermissions `ensureNotElevated` user
  previousMember <-
    TeamSubsystem.internalGetTeamMember targetId tid >>= noteS @'TeamMemberNotFound
  when
    ( downgradesOwner previousMember targetPermissions
        && not (canDowngradeOwner user previousMember)
    )
    $ throwS @'AccessDenied

  uncheckedUpdateTeamMember (Just lzusr) (Just zcon) tid newMem
  where
    canDowngradeOwner = canDeleteMember

    downgradesOwner :: TeamMember -> Permissions -> Bool
    downgradesOwner previousMember targetPermissions =
      permissionsRole (previousMember ^. permissions) == Just RoleOwner
        && permissionsRole targetPermissions /= Just RoleOwner

deleteTeamMember ::
  ( Member E.BrigAPIAccess r,
    Member (Error InvalidInput) r,
    Member (ErrorS 'AccessDenied) r,
    Member (ErrorS 'TeamMemberNotFound) r,
    Member (ErrorS 'TeamNotFound) r,
    Member (ErrorS 'NotATeamMember) r,
    Member (ErrorS OperationDenied) r,
    Member (Error AuthenticationError) r,
    Member ConversationSubsystem r,
    Member TeamSubsystem r,
    Member NotificationSubsystem r,
    Member FeaturesConfigSubsystem r,
    Member TeamStore r,
    Member P.TinyLog r,
    Member TeamJournal r,
    Member Now r
  ) =>
  Local UserId ->
  ConnId ->
  TeamId ->
  UserId ->
  Public.TeamMemberDeleteData ->
  Sem r TeamMemberDeleteResult
deleteTeamMember lusr zcon tid remove body = deleteTeamMember' lusr zcon tid remove (Just body)

deleteNonBindingTeamMember ::
  ( Member E.BrigAPIAccess r,
    Member (Error InvalidInput) r,
    Member (ErrorS 'AccessDenied) r,
    Member (ErrorS 'TeamMemberNotFound) r,
    Member (ErrorS 'TeamNotFound) r,
    Member (ErrorS 'NotATeamMember) r,
    Member (ErrorS OperationDenied) r,
    Member (Error AuthenticationError) r,
    Member ConversationSubsystem r,
    Member TeamSubsystem r,
    Member NotificationSubsystem r,
    Member FeaturesConfigSubsystem r,
    Member TeamStore r,
    Member P.TinyLog r,
    Member TeamJournal r,
    Member Now r
  ) =>
  Local UserId ->
  ConnId ->
  TeamId ->
  UserId ->
  Sem r TeamMemberDeleteResult
deleteNonBindingTeamMember lusr zcon tid remove = deleteTeamMember' lusr zcon tid remove Nothing

-- | 'TeamMemberDeleteData' is only required for binding teams
deleteTeamMember' ::
  ( Member E.BrigAPIAccess r,
    Member (Error InvalidInput) r,
    Member (ErrorS 'AccessDenied) r,
    Member (ErrorS 'TeamMemberNotFound) r,
    Member (ErrorS 'TeamNotFound) r,
    Member (ErrorS 'NotATeamMember) r,
    Member (ErrorS OperationDenied) r,
    Member (Error AuthenticationError) r,
    Member ConversationSubsystem r,
    Member NotificationSubsystem r,
    Member FeaturesConfigSubsystem r,
    Member TeamStore r,
    Member P.TinyLog r,
    Member TeamJournal r,
    Member TeamSubsystem r,
    Member Now r
  ) =>
  Local UserId ->
  ConnId ->
  TeamId ->
  UserId ->
  Maybe Public.TeamMemberDeleteData ->
  Sem r TeamMemberDeleteResult
deleteTeamMember' lusr zcon tid remove mBody = do
  P.debug $
    Log.field "targets" (toByteString remove)
      . Log.field "action" (Log.val "Teams.deleteTeamMember")
  zusrMember <- TeamSubsystem.internalGetTeamMember (tUnqualified lusr) tid
  targetMember <- TeamSubsystem.internalGetTeamMember remove tid
  void $ TeamSubsystem.permissionCheck RemoveTeamMember zusrMember
  do
    dm <- noteS @'NotATeamMember zusrMember
    tm <- noteS @'TeamMemberNotFound targetMember
    unless (canDeleteMember dm tm) $ throwS @'AccessDenied
  team <- fmap tdTeam $ E.getTeam tid >>= noteS @'TeamNotFound
  if team ^. teamBinding == Binding && isJust targetMember
    then do
      body <- mBody & note (InvalidPayload "missing request body")
      ensureReAuthorised (tUnqualified lusr) (body ^. tmdAuthPassword) Nothing Nothing
      uType <-
        E.getUser remove <&> \case
          Just u | u.userType == U.UserTypeApp -> UserTypeFilterApp
          _ -> UserTypeFilterRegular
      teamSizeAfterDelete <- do
        before <- E.getSize tid
        pure $ updateTeamSize uType before (-1)
      E.deleteUser remove
      case uType of
        UserTypeFilterRegular -> pure ()
        UserTypeFilterApp -> E.deleteApp tid remove
      owners <- E.getBillingTeamMembers tid
      Journal.teamUpdate tid teamSizeAfterDelete $ filter (/= remove) owners
      pure TeamMemberDeleteAccepted
    else do
      (feat :: LockableFeature LimitedEventFanoutConfig) <- getFeatureForTeam tid
      case feat.status of
        FeatureStatusEnabled -> do
          admins <- E.getTeamAdmins tid
          uncheckedDeleteTeamMember lusr (Just zcon) tid remove (Left admins)
        FeatureStatusDisabled -> do
          mems <- TeamSubsystem.getTeamMembersForFanout tid
          uncheckedDeleteTeamMember lusr (Just zcon) tid remove (Right mems)
      pure TeamMemberDeleteCompleted

-- This function is "unchecked" because it does not validate that the user has the `RemoveTeamMember` permission.
uncheckedDeleteTeamMember ::
  forall r.
  ( Member NotificationSubsystem r,
    Member ConversationSubsystem r,
    Member Now r,
    Member TeamStore r
  ) =>
  Local UserId ->
  Maybe ConnId ->
  TeamId ->
  UserId ->
  Either [UserId] TeamMemberList ->
  Sem r ()
uncheckedDeleteTeamMember lusr zcon tid remove (Left admins) = do
  now <- Now.get
  pushMemberLeaveEvent now
  E.deleteTeamMember tid remove
  -- notify all conversation members not in this team.
  deleteUserFromTeamConversations lusr zcon tid remove
  where
    -- notify team admins
    pushMemberLeaveEvent :: UTCTime -> Sem r ()
    pushMemberLeaveEvent now = do
      let e = newEvent tid now (EdMemberLeave remove)
      let recipients =
            userRecipient
              <$> (tUnqualified lusr : filter (/= (tUnqualified lusr)) admins)
      pushNotifications
        [ def
            { origin = Just (tUnqualified lusr),
              json = toJSONObject e,
              recipients,
              conn = zcon,
              transient = True
            }
        ]
uncheckedDeleteTeamMember lusr zcon tid remove (Right mems) = do
  now <- Now.get
  pushMemberLeaveEventToAll now
  E.deleteTeamMember tid remove
  -- notify all conversation members not in this team.
  deleteUserFromTeamConversations lusr zcon tid remove
  where
    -- notify all team members. This is to maintain compatibility with clients
    -- relying on these events, but eventually they will catch up and this
    -- function, and the corresponding feature flag, will be ready for removal.
    pushMemberLeaveEventToAll :: UTCTime -> Sem r ()
    pushMemberLeaveEventToAll now = do
      let e = newEvent tid now (EdMemberLeave remove)
      let recipients = userRecipient (tUnqualified lusr) : membersToRecipients (Just (tUnqualified lusr)) (mems ^. teamMembers)
      when (mems ^. teamMemberListType == ListComplete) $ do
        pushNotifications
          [ def
              { origin = Just (tUnqualified lusr),
                json = toJSONObject e,
                recipients,
                transient = True
              }
          ]

getTeamConversations ::
  ( Member (ErrorS 'NotATeamMember) r,
    Member (ErrorS OperationDenied) r,
    Member ConversationStore r,
    Member TeamSubsystem r
  ) =>
  UserId ->
  TeamId ->
  Sem r Public.TeamConversationList
getTeamConversations zusr tid = do
  tm <-
    TeamSubsystem.internalGetTeamMember zusr tid
      >>= noteS @'NotATeamMember
  unless (tm `hasPermission` GetTeamConversations) $
    throwS @OperationDenied
  Public.newTeamConversationList . fmap newTeamConversation <$> E.getTeamConversations tid

getTeamConversation ::
  ( Member (ErrorS 'ConvNotFound) r,
    Member (ErrorS 'NotATeamMember) r,
    Member (ErrorS OperationDenied) r,
    Member ConversationStore r,
    Member TeamSubsystem r
  ) =>
  UserId ->
  TeamId ->
  ConvId ->
  Sem r Public.TeamConversation
getTeamConversation zusr tid cid = do
  tm <-
    TeamSubsystem.internalGetTeamMember zusr tid
      >>= noteS @'NotATeamMember
  unless (tm `hasPermission` GetTeamConversations) $
    throwS @OperationDenied
  teamConv <- E.getTeamConversation tid cid >>= noteS @'ConvNotFound
  pure $ newTeamConversation teamConv

deleteTeamConversation ::
  (Member ConversationSubsystem r) =>
  Local UserId ->
  ConnId ->
  TeamId ->
  ConvId ->
  Sem r ()
deleteTeamConversation lusr zcon _tid cid = do
  let lconv = qualifyAs lusr cid
  void $ deleteLocalConversation lusr zcon lconv

getSearchVisibility ::
  ( Member TeamStore r,
    Member (ErrorS OperationDenied) r,
    Member (ErrorS 'NotATeamMember) r,
    Member TeamSubsystem r
  ) =>
  Local UserId ->
  TeamId ->
  Sem r TeamSearchVisibilityView
getSearchVisibility luid tid = do
  zusrMembership <- TeamSubsystem.internalGetTeamMember (tUnqualified luid) tid
  void $ TeamSubsystem.permissionCheck ViewTeamSearchVisibility zusrMembership
  getSearchVisibilityInternal tid

setSearchVisibility ::
  forall r.
  ( Member TeamStore r,
    Member (ErrorS 'TeamSearchVisibilityNotEnabled) r,
    Member (ErrorS OperationDenied) r,
    Member (ErrorS 'NotATeamMember) r,
    Member TeamSubsystem r
  ) =>
  (TeamId -> Sem r Bool) ->
  Local UserId ->
  TeamId ->
  Public.TeamSearchVisibilityView ->
  Sem r ()
setSearchVisibility availableForTeam luid tid req = do
  zusrMembership <- TeamSubsystem.internalGetTeamMember (tUnqualified luid) tid
  void $ TeamSubsystem.permissionCheck ChangeTeamSearchVisibility zusrMembership
  setSearchVisibilityInternal availableForTeam tid req

-- Internal -----------------------------------------------------------------

-- | Invoke the given continuation 'k' with a list of team IDs
-- which are looked up based on:
--
-- * just limited by size
-- * an (exclusive) starting point (team ID) and size
-- * a list of team IDs
--
-- The last case returns those team IDs which have an associated
-- user. Additionally 'k' is passed in a 'hasMore' indication (which is
-- always false if the third lookup-case is used).
--
-- FUTUREWORK: avoid CPS
withTeamIds ::
  (Member TeamStore r, Member (ListItems LegacyPaging TeamId) r) =>
  UserId ->
  Maybe (Either (Range 1 32 (BSC.List TeamId)) TeamId) ->
  Range 1 100 Int32 ->
  (Bool -> [TeamId] -> Sem r a) ->
  Sem r a
withTeamIds usr range size k = case range of
  Nothing -> do
    r <- E.listItems usr Nothing (rcast size)
    k (resultSetType r == ResultSetTruncated) (resultSetResult r)
  Just (Right c) -> do
    r <- E.listItems usr (Just c) (rcast size)
    k (resultSetType r == ResultSetTruncated) (resultSetResult r)
  Just (Left (fromRange -> cc)) -> do
    ids <- E.selectTeams usr (BSC.fromList cc)
    k False ids
{-# INLINE withTeamIds #-}

ensureUnboundUsers ::
  ( Member (ErrorS 'UserBindingExists) r,
    Member TeamStore r
  ) =>
  [UserId] ->
  Sem r ()
ensureUnboundUsers uids = do
  -- We check only 1 team because, by definition, users in binding teams
  -- can only be part of one team.
  teams <- Map.elems <$> E.getUsersTeams uids
  binds <- E.getTeamsBindings teams
  when (Binding `elem` binds) $
    throwS @'UserBindingExists

ensureNonBindingTeam ::
  ( Member (ErrorS 'TeamNotFound) r,
    Member (ErrorS 'NoAddToBinding) r,
    Member TeamStore r
  ) =>
  TeamId ->
  Sem r ()
ensureNonBindingTeam tid = do
  team <- noteS @'TeamNotFound =<< E.getTeam tid
  when (tdTeam team ^. teamBinding == Binding) $
    throwS @'NoAddToBinding

-- ensure that the permissions are not "greater" than the user's copy permissions
-- this is used to ensure users cannot "elevate" permissions
ensureNotElevated :: (Member (ErrorS 'InvalidPermissions) r) => Permissions -> TeamMember -> Sem r ()
ensureNotElevated targetPermissions member =
  unless
    ( targetPermissions.self
        `Set.isSubsetOf` (member ^. permissions).copy
    )
    $ throwS @'InvalidPermissions

-- | Ensure that a team doesn't exceed the member count limit for the LegalHold
-- feature. A team with more members than the fanout limit is too large, because
-- the fanout limit would prevent turning LegalHold feature _off_ again (for
-- details see 'Galley.API.LegalHold.removeSettings').
--
-- If LegalHold is configured for whitelisted teams only we consider the team
-- size unlimited, because we make the assumption that these teams won't turn
-- LegalHold off after activation.
--  FUTUREWORK: Find a way around the fanout limit.
ensureNotTooLargeForLegalHold ::
  forall r.
  ( Member LegalHoldStore r,
    Member (ErrorS 'TooManyTeamMembersOnTeamWithLegalhold) r,
    Member (Input FanoutLimit) r,
    Member (Input (FeatureDefaults LegalholdConfig)) r,
    Member FeaturesConfigSubsystem r
  ) =>
  TeamId ->
  TeamSize ->
  Sem r ()
ensureNotTooLargeForLegalHold tid teamSize =
  whenM (isLegalHoldEnabledForTeam tid) $
    unlessM (teamSizeBelowLimit teamSize) $
      throwS @'TooManyTeamMembersOnTeamWithLegalhold

addTeamMemberInternal ::
  ( Member E.BrigAPIAccess r,
    Member (ErrorS 'TooManyTeamMembers) r,
    Member (ErrorS 'TooManyTeamAdmins) r,
    Member (ErrorS 'TooManyTeamMembersOnTeamWithLegalhold) r,
    Member NotificationSubsystem r,
    Member (Input Opts) r,
    Member Now r,
    Member LegalHoldStore r,
    Member TeamNotificationStore r,
    Member TeamStore r,
    Member P.TinyLog r,
    Member (Input FanoutLimit) r,
    Member (Input (FeatureDefaults LegalholdConfig)) r,
    Member FeaturesConfigSubsystem r
  ) =>
  TeamId ->
  Maybe UserId ->
  Maybe ConnId ->
  NewTeamMember ->
  Sem r TeamSize
addTeamMemberInternal tid origin originConn (ntmNewTeamMember -> new) = do
  P.debug $
    Log.field "targets" (toByteString (new ^. userId))
      . Log.field "action" (Log.val "Teams.addTeamMemberInternal")
  sizeAfterAdd <- do
    n <- ensureNotTooLarge tid
    uType <-
      E.getUser (new ^. userId) <&> \case
        Just u | u.userType == U.UserTypeApp -> UserTypeFilterApp
        _ -> UserTypeFilterRegular
    pure $ updateTeamSize uType n 1
  ensureNotTooLargeForLegalHold tid sizeAfterAdd

  admins <- E.getTeamAdmins tid
  let admins' = [new ^. userId | isAdminOrOwner (new ^. M.permissions)] <> admins
  checkAdminLimit (length admins')

  E.createTeamMember tid new

  now <- Now.get
  appIds <- getAppIdsForTeam tid
  let e = newEvent tid now (EdMemberJoin (new ^. userId))
  let recipients = case origin of
        Just o -> userRecipient <$> o : filter (/= o) ((new ^. userId) : admins' ++ appIds)
        Nothing -> userRecipient <$> (new ^. userId) : admins' ++ appIds
  pushNotifications
    [ def
        { origin = Just (new ^. userId),
          json = toJSONObject e,
          recipients,
          conn = originConn,
          transient = True
        }
    ]

  APITeamQueue.pushTeamEvent tid e
  pure sizeAfterAdd
  where
    ensureNotTooLarge ::
      ( Member E.BrigAPIAccess r,
        Member (ErrorS 'TooManyTeamMembers) r,
        Member (Input Opts) r
      ) =>
      TeamId ->
      Sem r TeamSize
    ensureNotTooLarge teamid = do
      o <- input
      teamSize <- E.getSize teamid
      unless (teamSizeTotal teamSize < fromIntegral (o ^. settings . maxTeamSize)) $
        throwS @'TooManyTeamMembers
      pure teamSize

getBindingTeamMembers ::
  ( Member (ErrorS 'TeamNotFound) r,
    Member (ErrorS 'NonBindingTeam) r,
    Member TeamStore r,
    Member TeamSubsystem r
  ) =>
  UserId ->
  Sem r TeamMemberList
getBindingTeamMembers zusr = do
  tid <- E.lookupBindingTeam zusr
  TeamSubsystem.getTeamMembersForFanout tid

-- This could be extended for more checks, for now we test only legalhold
--
-- Brig's `POST /register` endpoint throws the errors returned by this endpoint
-- verbatim.
--
-- FUTUREWORK: When this enpoint gets Servantified, it should have a more
-- precise list of errors, LegalHoldError is too wide, currently this can
-- actaully only error with TooManyTeamMembersOnTeamWithLegalhold. Once we have
-- a more precise list of errors and the endpoint is servantified, we can use
-- those to enrich 'Wire.API.User.RegisterError' and ensure that these errors
-- also show up in swagger. Currently, the error returned by this endpoint is
-- thrown in IO, we could then refactor that to be thrown in `ExceptT
-- RegisterError`.
canUserJoinTeam ::
  forall r.
  ( Member E.BrigAPIAccess r,
    Member LegalHoldStore r,
    Member (ErrorS 'TooManyTeamMembersOnTeamWithLegalhold) r,
    Member (Input FanoutLimit) r,
    Member (Input (FeatureDefaults LegalholdConfig)) r,
    Member FeaturesConfigSubsystem r
  ) =>
  TeamId ->
  Sem r ()
canUserJoinTeam tid = do
  lhEnabled <- isLegalHoldEnabledForTeam tid
  when lhEnabled $ do
    sizeBeforeJoin <- E.getSize tid
    let uType =
          -- We do not have a `UserId` to check here.  Also,
          -- `canUserJoinTeam` is called by Brig during user
          -- registration via invitation (POST /register), where apps
          -- never go.  So it is safe to assume "regular"
          UserTypeFilterRegular
    let sizeAfterJoin = updateTeamSize uType sizeBeforeJoin 1
    ensureNotTooLargeForLegalHold tid sizeAfterJoin

-- | Modify and get visibility type for a team (internal, no user permission checks)
getSearchVisibilityInternal ::
  (Member TeamStore r) =>
  TeamId ->
  Sem r TeamSearchVisibilityView
getSearchVisibilityInternal =
  fmap TeamSearchVisibilityView
    . E.getSearchVisibility

setSearchVisibilityInternal ::
  forall r.
  ( Member (ErrorS 'TeamSearchVisibilityNotEnabled) r,
    Member TeamStore r
  ) =>
  (TeamId -> Sem r Bool) ->
  TeamId ->
  TeamSearchVisibilityView ->
  Sem r ()
setSearchVisibilityInternal availableForTeam tid (TeamSearchVisibilityView searchVisibility) = do
  unlessM (availableForTeam tid) $
    throwS @'TeamSearchVisibilityNotEnabled
  E.setSearchVisibility tid searchVisibility

userIsTeamOwner ::
  ( Member (ErrorS 'TeamMemberNotFound) r,
    Member (ErrorS 'AccessDenied) r,
    Member (ErrorS 'NotATeamMember) r,
    Member TeamSubsystem r,
    Member (Input (Local ())) r
  ) =>
  TeamId ->
  UserId ->
  Sem r ()
userIsTeamOwner tid uid = do
  asking <- qualifyLocal uid
  mem <- getTeamMember asking tid uid
  unless (isTeamOwner mem) $ throwS @'AccessDenied

-- Queues a team for async deletion
queueTeamDeletion ::
  ( Member (ErrorS 'DeleteQueueFull) r,
    Member (E.BoundedQueue DeleteItem) r
  ) =>
  TeamId ->
  UserId ->
  Maybe ConnId ->
  Sem r ()
queueTeamDeletion tid zusr zcon = do
  ok <- E.tryPush (TeamItem tid zusr zcon)
  unless ok $ throwS @'DeleteQueueFull

checkAdminLimit :: (Member (ErrorS 'TooManyTeamAdmins) r) => Int -> Sem r ()
checkAdminLimit adminCount =
  when (adminCount > 2000) $
    throwS @'TooManyTeamAdmins

-- | Updating a team collaborator permissions eventually cleaning their conversations
updateTeamCollaborator ::
  forall r.
  ( Member P.TinyLog r,
    Member (ErrorS OperationDenied) r,
    Member (ErrorS 'NotATeamMember) r,
    Member TeamCollaboratorsSubsystem r,
    Member ConversationSubsystem r,
    Member TeamSubsystem r
  ) =>
  Local UserId ->
  TeamId ->
  UserId ->
  Set TeamCollaborator.CollaboratorPermission ->
  Sem r ()
updateTeamCollaborator lusr tid rusr perms = do
  P.debug $
    Log.field "targets" (toByteString rusr)
      . Log.field "action" (Log.val "Teams.updateTeamCollaborator")
  zusrMember <- TeamSubsystem.internalGetTeamMember (tUnqualified lusr) tid
  void $ TeamSubsystem.permissionCheck UpdateTeamCollaborator zusrMember
  when (Set.null $ Set.intersection (Set.fromList [Collaborator.CreateTeamConversation, Collaborator.ImplicitConnection]) perms) $
    deleteUserFromTeamConversations lusr Nothing tid rusr
  internalUpdateTeamCollaborator rusr tid perms

-- | Removing a team collaborator and clean their conversations
removeTeamCollaborator ::
  forall r.
  ( Member NotificationSubsystem r,
    Member ConversationSubsystem r,
    Member Now r,
    Member P.TinyLog r,
    Member (ErrorS OperationDenied) r,
    Member (ErrorS 'NotATeamMember) r,
    Member FeaturesConfigSubsystem r,
    Member TeamStore r,
    Member TeamCollaboratorsSubsystem r,
    Member TeamSubsystem r
  ) =>
  Local UserId ->
  TeamId ->
  UserId ->
  Sem r ()
removeTeamCollaborator lusr tid rusr = do
  P.debug $
    Log.field "targets" (toByteString rusr)
      . Log.field "action" (Log.val "Teams.removeTeamCollaborator")
  zusrMember <- TeamSubsystem.internalGetTeamMember (tUnqualified lusr) tid
  void $ TeamSubsystem.permissionCheck RemoveTeamCollaborator zusrMember
  toNotify <-
    (getFeatureForTeam @_ @LimitedEventFanoutConfig tid)
      >>= ( \case
              FeatureStatusEnabled -> Left <$> E.getTeamAdmins tid
              FeatureStatusDisabled -> Right <$> TeamSubsystem.getTeamMembersForFanout tid
          )
        . (.status)
  uncheckedDeleteTeamMember lusr Nothing tid rusr toNotify
  internalRemoveTeamCollaborator rusr tid
  now <- Now.get
  let e = newEvent tid now (EdCollaboratorRemove rusr)
  admins <- E.getTeamAdmins tid
  pushNotifications
    [ def
        { origin = Just $ tUnqualified lusr,
          json = toJSONObject e,
          recipients = userRecipient rusr : map (`Recipient` RecipientClientsAll) admins
        }
    ]
