-- This file is part of the Wire Server implementation.
--
-- Copyright (C) 2023 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 Wire.GalleyAPIAccess.Rpc where

import Bilge hiding (head, options, requestId)
import Control.Lens
import Data.Aeson
import Data.Aeson.Lens
import Data.ByteString.Conversion
import Data.ByteString.Lazy qualified as BL
import Data.Coerce (coerce)
import Data.Currency qualified as Currency
import Data.Id
import Data.Json.Util (UTCTimeMillis)
import Data.LegalHold (UserLegalHoldStatus)
import Data.Qualified
import Data.Range
import Imports
import Network.HTTP.Client qualified as HTTP
import Network.HTTP.Types qualified as HTTP
import Network.HTTP.Types.Method
import Network.HTTP.Types.Status
import Network.Wai.Utilities.Error qualified as Wai
import Polysemy
import Polysemy.Error
import Polysemy.Input
import Polysemy.TinyLog (TinyLog, debug)
import Servant.API (toHeader)
import System.Logger.Message
import Util.Options
import Wire.API.Conversation hiding (Member)
import Wire.API.Routes.Internal.Brig.EJPD (EJPDConvInfo)
import Wire.API.Routes.Internal.Galley.TeamsIntra
import Wire.API.Routes.Internal.Galley.TeamsIntra qualified as Team
import Wire.API.Routes.Version
import Wire.API.Team
import Wire.API.Team.Conversation qualified as Conv
import Wire.API.Team.Feature
import Wire.API.Team.LegalHold
import Wire.API.Team.Member as Member
import Wire.API.Team.Member.Info
import Wire.API.Team.Role
import Wire.API.Team.SearchVisibility
import Wire.API.User (UserIds (UserIds))
import Wire.API.User.Client (UserClients)
import Wire.ClientSubsystem.Interpreter (ClientError (..))
import Wire.GalleyAPIAccess (GalleyAPIAccess (..), MLSOneToOneEstablished (..), ShowOrHideInvitationUrl (..))
import Wire.ParseException
import Wire.Rpc

interpretGalleyAPIAccessToRpc ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Error ClientError) r,
    Member TinyLog r
  ) =>
  Set Version ->
  Endpoint ->
  Sem (GalleyAPIAccess ': r) a ->
  Sem r a
interpretGalleyAPIAccessToRpc disabledVersions galleyEndpoint =
  let v = fromMaybe (error "service can't run with undefined API version") $ maxAvailableVersion disabledVersions
   in interpret $
        runInputConst galleyEndpoint . \case
          CreateSelfConv id' -> createSelfConv v id'
          GetConv id' id'' -> getConv v id' id''
          GetTeamConv id' id'' id'2 -> getTeamConv v id' id'' id'2
          NewClient id' ci -> newClient id' ci
          CheckUserCanJoinTeam id' -> checkUserCanJoinTeam id'
          AddTeamMember id' id'' a b -> addTeamMember id' id'' a b
          CreateTeam id' bnt id'' -> createTeam id' bnt id''
          GetTeamMember id' id'' -> getTeamMember id' id''
          GetTeamMembersWithLimit tid maxResults -> getTeamMembersWithLimit tid maxResults
          SelectTeamMemberInfos tid uids -> selectTeamMemberInfos tid uids
          SelectTeamMembers tid uids -> selectTeamMembers tid uids
          GetTeamId id' -> getTeamId id'
          GetTeam id' -> getTeam id'
          FindTeam id' -> findTeam id'
          GetTeamName id' -> getTeamName id'
          GetTeamLegalHoldStatus id' -> getTeamLegalHoldStatus id'
          GetTeamSearchVisibility id' -> getTeamSearchVisibility id'
          GetFeatureConfigForTeam tid -> getFeatureConfigForTeam tid
          GetUserLegalholdStatus id' tid -> getUserLegalholdStatus id' tid
          ChangeTeamStatus id' ts m_al -> changeTeamStatus id' ts m_al
          FinalizeDeleteTeam lusr mconn tid -> finalizeDeleteTeam lusr mconn tid
          MemberIsTeamOwner id' id'' -> memberIsTeamOwner id' id''
          GetAllTeamFeaturesForUser m_id' -> getAllTeamFeaturesForUser m_id'
          GetVerificationCodeEnabled id' -> getVerificationCodeEnabled id'
          GetExposeInvitationURLsToTeamAdmin id' -> getTeamExposeInvitationURLsToTeamAdmin id'
          IsMLSOne2OneEstablished lusr qother -> checkMLSOne2OneEstablished lusr qother
          UnblockConversation lusr mconn qcnv -> unblockConversation v lusr mconn qcnv
          GetEJPDConvInfo uid -> getEJPDConvInfo uid
          GetTeamAdmins tid -> getTeamAdmins tid
          InternalGetConversation id' -> internalGetConversation id'
          GetTeamContacts uid -> getTeamContacts uid
          GuardLegalHold protectee userClient -> guardLegalhold protectee userClient
          GetUserLHStatus mtid uid -> getUserLHStatus mtid uid
          GetUsersLHStatus uids -> getUsersLHStatus uids
          UpdateTeamMember uid tid role -> updateTeamMember uid tid role
          IsEmailValidationEnabledTeam tid -> isEmailValidationEnabledTeam tid

getUserLegalholdStatus ::
  ( Member (Error ParseException) r,
    Member Rpc r
  ) =>
  Local UserId ->
  TeamId ->
  Sem (Input Endpoint : r) (Maybe UserLegalHoldStatusResponse)
getUserLegalholdStatus luid tid = do
  rs <- galleyRequest do
    method GET
      . paths ["teams", toByteString' tid, "legalhold", toByteString' (tUnqualified luid)]
      . zUser (tUnqualified luid)
      . expect2xxOr404
  case Bilge.statusCode rs of
    200 -> Just <$> decodeBodyOrThrow "galley" rs
    _ -> pure Nothing

galleyRequest :: (Member Rpc r, Member (Input Endpoint) r) => (Request -> Request) -> Sem r (Response (Maybe LByteString))
galleyRequest req = do
  ep <- input
  rpcWithRetries "galley" ep req

-- | Calls 'Wire.ConversationSubsystem.createSelfConversationH'.
createSelfConv ::
  ( Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  Version ->
  UserId ->
  Sem r ()
createSelfConv v u = do
  void $
    galleyRequest $
      method POST
        . paths [toHeader v, "conversations", "self"]
        . zUser u
        . expect2xx

-- | Calls 'Wire.ConversationSubsystem.getConversationH'.
getConv ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  Version ->
  UserId ->
  Local ConvId ->
  Sem r (Maybe (OwnConversation GroupConvType))
getConv v usr lcnv = do
  rs <- galleyRequest req
  case Bilge.statusCode rs of
    200 -> Just <$> decodeBodyOrThrow "galley" rs
    _ -> pure Nothing
  where
    req =
      method GET
        . paths
          [ toHeader v,
            "conversations",
            toByteString' (tDomain lcnv),
            toByteString' (tUnqualified lcnv)
          ]
        . zUser usr
        . expect [status200, status404]

-- | Calls 'Wire.ConversationSubsystem.getTeamConversationH'.
getTeamConv ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  Version ->
  UserId ->
  TeamId ->
  ConvId ->
  Sem r (Maybe Conv.TeamConversation)
getTeamConv v usr tid cnv = do
  rs <- galleyRequest req
  case Bilge.statusCode rs of
    200 -> Just <$> decodeBodyOrThrow "galley" rs
    _ -> pure Nothing
  where
    req =
      method GET
        . paths
          [ toHeader v,
            "teams",
            toByteString' tid,
            "conversations",
            toByteString' cnv
          ]
        . zUser usr
        . expect [status200, status404]

-- | Calls 'Wire.ConversationSubsystem.addClientH'.
newClient ::
  ( Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  UserId ->
  ClientId ->
  Sem r ()
newClient u c = do
  void . galleyRequest $
    method POST
      . paths ["i", "clients", toByteString' c]
      . zUser u
      . expect2xx

-- | Calls 'Wire.ConversationSubsystem.canUserJoinTeamH'.
checkUserCanJoinTeam ::
  ( Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  Sem r (Maybe Wai.Error)
checkUserCanJoinTeam tid = do
  rs <- galleyRequest req
  pure $ case Bilge.statusCode rs of
    200 -> Nothing
    _ -> case responseJsonMaybe rs of
      Just (e :: Wai.Error) -> pure e
      Nothing -> error ("Invalid response from galley: " <> show rs)
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid, "members", "check"]
        . header "Content-Type" "application/json"

-- | Calls 'Wire.ConversationSubsystem.uncheckedAddTeamMemberH'.
addTeamMember ::
  ( Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  UserId ->
  TeamId ->
  Maybe (UserId, UTCTimeMillis) ->
  Role ->
  Sem r Bool
addTeamMember u tid minvmeta role = do
  rs <- galleyRequest req
  pure $ case Bilge.statusCode rs of
    200 -> True
    _ -> False
  where
    prm = Member.rolePermissions role
    bdy = Member.mkNewTeamMember u prm minvmeta
    req =
      method POST
        . paths ["i", "teams", toByteString' tid, "members"]
        . header "Content-Type" "application/json"
        . zUser u
        . expect [status200, status403]
        . lbytes (encode bdy)

-- | Calls 'Wire.ConversationSubsystem.createBindingTeamH'.
createTeam ::
  ( Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  UserId ->
  NewTeam ->
  TeamId ->
  Sem r ()
createTeam u t teamid = do
  void $ galleyRequest $ req teamid
  where
    req tid =
      method PUT
        . paths ["i", "teams", toByteString' tid]
        . header "Content-Type" "application/json"
        . zUser u
        . expect2xx
        . lbytes (encode t)

-- | Calls 'Wire.ConversationSubsystem.uncheckedGetTeamMemberH'.
getTeamMember ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  UserId ->
  TeamId ->
  Sem r (Maybe TeamMember)
getTeamMember u tid = do
  rs <- galleyRequest req
  case Bilge.statusCode rs of
    200 -> Just <$> decodeBodyOrThrow "galley" rs
    _ -> pure Nothing
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid, "members", toByteString' u]
        . zUser u
        . expect [status200, status404]

-- | Calls 'Wire.ConversationSubsystem.uncheckedGetTeamMembersH'.
--
-- | TODO: is now truncated.  this is (only) used for team suspension / unsuspension, which
-- means that only the first 2000 members of a team (according to some arbitrary order) will
-- be suspended, and the rest will remain active.
getTeamMembersWithLimit ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  Maybe (Range 1 HardTruncationLimit Int32) ->
  Sem r TeamMemberList
getTeamMembersWithLimit tid maxResults = do
  galleyRequest req >>= decodeBodyOrThrow "galley"
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid, "members"]
        . maybe id (queryItem "maxResults" . toByteString') maxResults
        . expect2xx

selectTeamMemberInfos ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  [UserId] ->
  Sem r TeamMemberInfoList
selectTeamMemberInfos tid uids = do
  let bdy = UserIds uids
  galleyRequest (req bdy) >>= decodeBodyOrThrow "galley"
  where
    req bdy =
      method GET
        . paths ["i", "teams", toByteString' tid, "members", "by-ids"]
        . header "Content-Type" "application/json"
        . lbytes (encode bdy)
        . expect2xx

selectTeamMembers ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  [UserId] ->
  Sem r [TeamMember]
selectTeamMembers tid uids = do
  let bdy = UserIds uids
  galleyRequest (req bdy) >>= decodeBodyOrThrow "galley"
  where
    req bdy =
      method POST
        . paths ["i", "teams", toByteString' tid, "members", "get-by-ids"]
        . header "Content-Type" "application/json"
        . lbytes (encode bdy)
        . expect2xx

getTeamAdmins ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  Sem r TeamMemberList
getTeamAdmins tid = do
  galleyRequest req >>= decodeBodyOrThrow "galley"
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid, "members", "admins"]
        . expect2xx

memberIsTeamOwner ::
  (Member Rpc r, Member (Input Endpoint) r) =>
  TeamId ->
  UserId ->
  Sem r Bool
memberIsTeamOwner tid uid = do
  r <-
    galleyRequest $
      method GET
        . paths ["i", "teams", toByteString' tid, "is-team-owner", toByteString' uid]
  pure $ responseStatus r /= status403

-- | Calls 'Wire.ConversationSubsystem.getBindingTeamIdH'.
getTeamId ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  UserId ->
  Sem r (Maybe TeamId)
getTeamId u = do
  rs <- galleyRequest req
  case Bilge.statusCode rs of
    200 -> Just <$> decodeBodyOrThrow "galley" rs
    _ -> pure Nothing
  where
    req =
      method GET
        . paths ["i", "users", toByteString' u, "team"]
        . expect [status200, status404]

-- | Calls 'Wire.ConversationSubsystem.getTeamInternalH'.
getTeam ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  Sem r Team.TeamData
getTeam tid = do
  galleyRequest req >>= decodeBodyOrThrow "galley"
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid]
        . expect2xx

-- | Like 'getTeam' but returns 'Nothing' on 404 instead of throwing.
findTeam ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  Sem r (Maybe Team.TeamData)
findTeam tid = do
  rs <- galleyRequest req
  case Bilge.statusCode rs of
    200 -> Just <$> decodeBodyOrThrow "galley" rs
    _ -> pure Nothing
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid]
        . expect [status200, status404]

-- | Calls 'Wire.ConversationSubsystem.getTeamInternalH'.
getTeamName ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  Sem r Team.TeamName
getTeamName tid = do
  galleyRequest req >>= decodeBodyOrThrow "galley"
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid, "name"]
        . expect2xx

-- | Calls 'Wire.ConversationSubsystem.getTeamFeatureStatusH'.
getTeamLegalHoldStatus ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  Sem r (LockableFeature LegalholdConfig)
getTeamLegalHoldStatus tid = do
  galleyRequest req >>= decodeBodyOrThrow "galley"
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid, "features", featureNameBS @LegalholdConfig]
        . expect2xx

-- | Calls 'Wire.ConversationSubsystem.getSearchVisibilityInternalH'.
getTeamSearchVisibility ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  Sem r TeamSearchVisibility
getTeamSearchVisibility tid =
  coerce @TeamSearchVisibilityView @TeamSearchVisibility <$> do
    galleyRequest req >>= decodeBodyOrThrow "galley"
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid, "search-visibility"]
        . expect2xx

getFeatureConfigForTeam ::
  forall feature r.
  ( IsFeatureConfig feature,
    Typeable feature,
    Member Rpc r,
    Member (Error ParseException) r
  ) =>
  TeamId ->
  Sem (Input Endpoint : r) (LockableFeature feature)
getFeatureConfigForTeam tid = do
  galleyRequest req >>= decodeBodyOrThrow "galley"
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid, "features", featureNameBS @feature]
        . expect2xx

getVerificationCodeEnabled ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  Sem r Bool
getVerificationCodeEnabled tid = do
  response <- galleyRequest req
  status <- (.status) <$> decodeBodyOrThrow @(LockableFeature SndFactorPasswordChallengeConfig) "galley" response
  case status of
    FeatureStatusEnabled -> pure True
    FeatureStatusDisabled -> pure False
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid, "features", featureNameBS @SndFactorPasswordChallengeConfig]
        . expect2xx

decodeBodyOrThrow :: forall a r. (Typeable a, FromJSON a, Member (Error ParseException) r) => Text -> Response (Maybe BL.ByteString) -> Sem r a
decodeBodyOrThrow ctx r = either (throw . ParseException ctx) pure (responseJsonEither r)

getAllTeamFeaturesForUser ::
  (Member Rpc r, Member (Input Endpoint) r) =>
  Maybe UserId ->
  Sem r AllTeamFeatures
getAllTeamFeaturesForUser mbUserId =
  responseJsonUnsafe
    <$> galleyRequest
      ( method GET
          . paths ["i", "feature-configs"]
          . maybe id (queryItem "user_id" . toByteString') mbUserId
      )

-- | Calls 'Wire.ConversationSubsystem.updateTeamStatusH'.
changeTeamStatus ::
  ( Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  Team.TeamStatus ->
  Maybe Currency.Alpha ->
  Sem r ()
changeTeamStatus tid s cur = do
  void $ galleyRequest req
  where
    req =
      method PUT
        . paths ["i", "teams", toByteString' tid, "status"]
        . header "Content-Type" "application/json"
        . expect2xx
        . lbytes (encode $ Team.TeamStatusUpdate s cur)

finalizeDeleteTeam ::
  ( Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  Local UserId ->
  Maybe ConnId ->
  TeamId ->
  Sem r ()
finalizeDeleteTeam lusr mconn tid = do
  void $ galleyRequest req
  where
    req =
      method POST
        . paths ["i", "teams", toByteString' tid, "finalize-delete"]
        . zUser (tUnqualified lusr)
        . maybe id (header "Z-Connection" . fromConnId) mconn
        . expect2xx

getTeamExposeInvitationURLsToTeamAdmin ::
  ( Member Rpc r,
    Member (Input Endpoint) r,
    Member (Error ParseException) r
  ) =>
  TeamId ->
  Sem r ShowOrHideInvitationUrl
getTeamExposeInvitationURLsToTeamAdmin tid = do
  response <- galleyRequest req
  status <- (.status) <$> decodeBodyOrThrow @(LockableFeature ExposeInvitationURLsToTeamAdminConfig) "galley" response
  case status of
    FeatureStatusEnabled -> pure ShowInvitationUrl
    FeatureStatusDisabled -> pure HideInvitationUrl
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid, "features", featureNameBS @ExposeInvitationURLsToTeamAdminConfig]
        . expect2xx

checkMLSOne2OneEstablished ::
  ( Member (Error ParseException) r,
    Member (Input Endpoint) r,
    Member Rpc r
  ) =>
  Local UserId ->
  Qualified UserId ->
  Sem r MLSOneToOneEstablished
checkMLSOne2OneEstablished self (Qualified other otherDomain) = do
  responseSelf <- galleyRequest req
  case HTTP.statusCode (HTTP.responseStatus responseSelf) of
    200 -> do
      established <- decodeBodyOrThrow @Bool "galley" responseSelf
      pure $ if established then Established else NotEstablished
    403 -> pure NotAMember
    400 -> pure NotEstablished
    _ -> pure NotEstablished
  where
    req =
      method GET
        . paths
          [ "i",
            "mls-one2one-conversations",
            toByteString' otherDomain,
            toByteString' other,
            "established"
          ]
        . zUser (tUnqualified self)

unblockConversation ::
  ( Member (Error ParseException) r,
    Member (Input Endpoint) r,
    Member Rpc r
  ) =>
  Version ->
  Local UserId ->
  Maybe ConnId ->
  Qualified ConvId ->
  Sem r (OwnConversation GroupConvType)
unblockConversation v lusr mconn (Qualified cnv cdom) = do
  void $ galleyRequest putReq
  galleyRequest getReq >>= decodeBodyOrThrow @(OwnConversation GroupConvType) "galley"
  where
    putReq =
      method PUT
        . paths ["i", "conversations", toByteString' cdom, toByteString' cnv, "unblock"]
        . zUser (tUnqualified lusr)
        . maybe id (header "Z-Connection" . fromConnId) mconn
        . expect2xx
    getReq =
      method GET
        . paths [toHeader v, "conversations", toByteString' cdom, toByteString' cnv]
        . zUser (tUnqualified lusr)
        . expect2xx

remote :: ByteString -> Msg -> Msg
remote = field "remote"

getEJPDConvInfo ::
  forall r.
  ( Member (Error ParseException) r,
    Member (Input Endpoint) r,
    Member Rpc r
  ) =>
  UserId ->
  Sem r [EJPDConvInfo]
getEJPDConvInfo uid = do
  decodeBodyOrThrow "galley" =<< galleyRequest getReq
  where
    getReq =
      method GET
        . paths ["i", "user", toByteString' uid, "all-conversations"]

internalGetConversation ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  ConvId ->
  Sem r (Maybe (Conversation GroupConvType))
internalGetConversation convId = do
  rs <- galleyRequest req
  case Bilge.statusCode rs of
    200 -> Just <$> decodeBodyOrThrow "galley" rs
    _ -> pure Nothing
  where
    req =
      method GET
        . paths ["i", "conversations", toByteString' convId]
        . expect [status200, status404]

getTeamContacts ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  UserId ->
  Sem r (Maybe Member.TeamMemberList)
getTeamContacts uid = do
  rs <- galleyRequest req
  case Bilge.statusCode rs of
    200 -> Just <$> decodeBodyOrThrow "galley" rs
    _ -> pure Nothing
  where
    req =
      method GET
        . paths ["i", "users", toByteString' uid, "team", "members"]
        . expect [status200, status404]

guardLegalhold ::
  ( Member Rpc r,
    Member (Input Endpoint) r,
    Member (Error ClientError) r,
    Member (Error ParseException) r
  ) =>
  LegalholdProtectee ->
  UserClients ->
  Sem r ()
guardLegalhold protectee userClients = do
  res <- galleyRequest req
  case Bilge.statusCode res of
    200 -> pure ()
    403 -> case Bilge.responseJsonMaybe @Value res >>= (^? key "label") of
      Just "missing-legalhold-consent" -> throw ClientMissingLegalholdConsent
      Just "missing-legalhold-consent-old-clients" -> throw ClientMissingLegalholdConsentOldClients
      _ ->
        -- only happens if galley misbehaves (fisx: this could also be a parse error if we
        -- used a more constraining type to send back & forth between brig and galley, but
        -- merging brig and galley would make this train of thought go away more naturally).
        throw ClientMissingLegalholdConsent
    404 -> pure () -- allow for galley not to be ready, so the set of valid deployment orders is non-empty.
    sc -> throw $ ParseException "galley" ("expected status codes 200, 403, or 404, but got: " <> show sc)
  where
    req =
      method PUT
        . paths ["i", "guard-legalhold-policy-conflicts"]
        . header "Content-Type" "application/json"
        . lbytes (encode $ GuardLegalholdPolicyConflicts protectee userClients)

getUserLHStatus ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r,
    Member TinyLog r
  ) =>
  Maybe TeamId ->
  UserId ->
  Sem r UserLegalHoldStatus
getUserLHStatus mtid uid = do
  debug $
    remote "galley"
      . field "user" (toByteString uid)
      . msg (val "Get user legalhold status")
  galleyRequest req >>= decodeBodyOrThrow "galley"
  where
    req =
      method GET
        . paths ["i", "users", toByteString' uid, "lh-status"]
        . maybe id (queryItem "team_id" . toByteString') mtid
        . expect2xx

getUsersLHStatus ::
  ( Member (Error ParseException) r,
    Member Rpc r,
    Member (Input Endpoint) r,
    Member TinyLog r
  ) =>
  [UserId] ->
  Sem r [(UserId, UserLegalHoldStatus)]
getUsersLHStatus uids = do
  debug $
    remote "galley"
      . msg (val "Get users legalhold status")
  let bdy = UserIds uids
  entries :: [UserLegalHoldStatusEntry] <- galleyRequest (req bdy) >>= decodeBodyOrThrow "galley"
  pure $ map (\e -> (e.ulhseUser, e.ulhseStatus)) entries
  where
    req bdy =
      method POST
        . paths ["i", "users", "lh-status"]
        . header "Content-Type" "application/json"
        . lbytes (encode bdy)
        . expect2xx

updateTeamMember ::
  ( Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  UserId ->
  TeamId ->
  Role ->
  Sem r ()
updateTeamMember uid tid role = do
  let reqBody = mkNewTeamMember uid (rolePermissions role) Nothing
  void $
    galleyRequest $
      method PUT
        . paths ["i", "teams", toByteString' tid, "members"]
        . header "Content-Type" "application/json"
        . lbytes (encode reqBody)

isEmailValidationEnabledTeam ::
  ( Member Rpc r,
    Member (Input Endpoint) r
  ) =>
  TeamId ->
  Sem r Bool
isEmailValidationEnabledTeam tid = do
  rs <- galleyRequest req
  pure
    ( Bilge.statusCode rs == 200
        && ( ((.status) <$> responseJsonMaybe @(LockableFeature RequireExternalEmailVerificationConfig) rs)
               == Just FeatureStatusEnabled
           )
    )
  where
    req =
      method GET
        . paths ["i", "teams", toByteString' tid, "features", featureNameBS @RequireExternalEmailVerificationConfig]
