-- 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 Wire.NotificationSubsystem.Interpreter where

import Control.Concurrent.Async (Async)
import Control.Lens (set, (.~))
import Data.Aeson
import Data.Id
import Data.List.NonEmpty (NonEmpty)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Range
import Data.Set qualified as Set
import Data.Time.Clock.DiffTime
import Imports
import Numeric.Natural (Natural)
import Polysemy
import Polysemy.Async (async, sequenceConcurrently)
import Polysemy.Async qualified as P
import Polysemy.Error
import Polysemy.Input
import Polysemy.TinyLog qualified as P
import System.Logger.Class as Log
import Wire.API.Push.V2 hiding (Push (..), Recipient, newPush)
import Wire.API.Push.V2 qualified as V2
import Wire.API.Team.FeatureFlags (defaultFanoutLimit)
import Wire.API.Team.HardTruncationLimit (HardTruncationLimit)
import Wire.GundeckAPIAccess (GundeckAPIAccess)
import Wire.GundeckAPIAccess qualified as GundeckAPIAccess
import Wire.NotificationSubsystem as NS
import Wire.Sem.Delay

-- | We interpret this using 'GundeckAPIAccess' so we can mock it out for testing.
runNotificationSubsystemGundeck ::
  ( Member GundeckAPIAccess r,
    Member P.Async r,
    Member Delay r,
    Member (Final IO) r,
    Member P.TinyLog r
  ) =>
  NotificationSubsystemConfig ->
  Sem (NotificationSubsystem : r) a ->
  Sem r a
runNotificationSubsystemGundeck cfg = interpret $ \case
  PushNotifications ps -> runInputConst cfg $ pushImpl ps
  PushNotificationsSlowly ps -> runInputConst cfg $ pushSlowlyImpl ps
  PushNotificationAsync ps -> runInputConst cfg $ pushAsyncImpl ps
  CleanupUser uid -> GundeckAPIAccess.userDeleted uid
  UnregisterPushClient uid cid -> GundeckAPIAccess.unregisterPushClient uid cid
  GetPushTokens uid -> GundeckAPIAccess.getPushTokens uid
  SetupConsumableNotifications uid cid -> GundeckAPIAccess.registerConsumableNotificationsClient uid cid

data NotificationSubsystemConfig = NotificationSubsystemConfig
  { fanoutLimit :: Range 1 HardTruncationLimit Int32,
    chunkSize :: Natural,
    slowPushDelay :: DiffTime,
    requestId :: RequestId
  }

defaultNotificationSubsystemConfig :: RequestId -> NotificationSubsystemConfig
defaultNotificationSubsystemConfig reqId =
  NotificationSubsystemConfig defaultFanoutLimit defaultChunkSize defaultSlowPushDelay reqId

defaultChunkSize :: Natural
defaultChunkSize = 128

defaultSlowPushDelay :: DiffTime
defaultSlowPushDelay = millisecondsToDiffTime 20

pushAsyncImpl ::
  forall r.
  ( Member GundeckAPIAccess r,
    Member (Input NotificationSubsystemConfig) r,
    Member P.Async r,
    Member (Final IO) r,
    Member P.TinyLog r
  ) =>
  Push ->
  Sem r (Async (Maybe ()))
pushAsyncImpl p = async $ pushBestEffort p

pushBestEffort ::
  forall r.
  ( Member GundeckAPIAccess r,
    Member (Input NotificationSubsystemConfig) r,
    Member P.Async r,
    Member (Final IO) r,
    Member P.TinyLog r
  ) =>
  Push ->
  Sem r ()
pushBestEffort p = do
  reqId <- inputs requestId
  errorToIOFinal @SomeException (fromExceptionSem @SomeException $ pushImpl [p]) >>= \case
    Left e ->
      P.err $
        Log.msg (Log.val "Error while pushing notifications")
          . Log.field "requestId" reqId
          . Log.field "error" (displayException e)
    Right _ -> pure ()

pushImpl ::
  forall r.
  ( Member GundeckAPIAccess r,
    Member (Input NotificationSubsystemConfig) r,
    Member P.Async r
  ) =>
  [Push] ->
  Sem r ()
pushImpl ps = do
  currentFanoutLimit <- inputs fanoutLimit
  pushChunkSize <- inputs chunkSize

  let pushes :: [[V2.Push]] =
        mkPushes pushChunkSize $
          removeIfLargeFanout currentFanoutLimit ps
  void $
    sequenceConcurrently $
      GundeckAPIAccess.pushV2 <$> pushes

removeIfLargeFanout :: Range n m Int32 -> [Push] -> [Push]
removeIfLargeFanout limit =
  filter \p ->
    length p.recipients <= fromIntegral (fromRange limit)

mkPushes :: Natural -> [Push] -> [[V2.Push]]
mkPushes chunkSize = map (map toV2Push) . chunkPushes chunkSize

{-# INLINE [1] toV2Push #-}
toV2Push :: Push -> V2.Push
toV2Push p =
  (V2.newPush p.origin (Set.fromList recipients) pload)
    & V2.pushOriginConnection .~ p.conn
    & V2.pushTransient .~ p.transient
    & maybe id (set V2.pushNativePriority) p.nativePriority
    & V2.pushIsCellsEvent .~ p.isCellsEvent
  where
    pload :: NonEmpty Object
    pload = NonEmpty.singleton p.json
    recipients :: [V2.Recipient]
    recipients = map toV2Recipient $ toList p.recipients
    toV2Recipient :: Recipient -> V2.Recipient
    toV2Recipient r =
      (recipient r.recipientUserId p.route)
        { V2._recipientClients = r.recipientClients
        }

{-# INLINE [1] chunkPushes #-}
chunkPushes :: Natural -> [Push] -> [[Push]]
chunkPushes maxRecipients
  | maxRecipients > 0 = go 0 []
  | otherwise = const []
  where
    go _ [] [] = []
    go _ acc [] = [acc]
    go n acc (y : ys)
      | n >= maxRecipients = acc : go 0 [] (y : ys)
      | otherwise =
          let totalLength = (n + fromIntegral (length y.recipients))
           in if totalLength > maxRecipients
                then
                  let (y1, y2) = splitPush (maxRecipients - n) y
                   in go maxRecipients (y1 : acc) (y2 : ys)
                else go totalLength (y : acc) ys

    -- n must be strictly > 0 and < length (_pushRecipients p)
    splitPush :: Natural -> Push -> (Push, Push)
    splitPush n p =
      let (r1, r2) = splitAt (fromIntegral n) (toList p.recipients)
       in (p {NS.recipients = r1}, p {NS.recipients = r2})

pushSlowlyImpl ::
  ( Member Delay r,
    Member (Input NotificationSubsystemConfig) r,
    Member GundeckAPIAccess r,
    Member P.Async r
  ) =>
  [Push] ->
  Sem r ()
pushSlowlyImpl ps =
  for_ ps \p -> do
    delay =<< inputs (diffTimeToFullMicroseconds . slowPushDelay)
    pushImpl [p]
