{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}

module Simplex.Messaging.Server.StoreLog
  ( StoreLog, -- constructors are not exported
    StoreLogRecord (..), -- used in tests
    openWriteStoreLog,
    openReadStoreLog,
    storeLogFilePath,
    closeStoreLog,
    writeStoreLogRecord,
    logCreateQueue,
    logCreateLink,
    logDeleteLink,
    logSecureQueue,
    logUpdateKeys,
    logAddNotifier,
    logSuspendQueue,
    logBlockQueue,
    logUnblockQueue,
    logDeleteQueue,
    logDeleteNotifier,
    logUpdateQueueTime,
    logNewService,
    logQueueService,
    readWriteStoreLog,
    readLogLines,
    foldLogLines,
  )
where

import Control.Applicative (optional, (<|>))
import qualified Control.Exception as E
import Control.Logger.Simple
import Control.Monad
import qualified Data.Attoparsec.ByteString.Char8 as A
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Functor (($>))
import Data.List (sort, stripPrefix)
import Data.List.NonEmpty (NonEmpty)
import Data.Maybe (mapMaybe)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime, addUTCTime, getCurrentTime, nominalDay)
import Data.Time.Format.ISO8601 (iso8601Show, iso8601ParseM)
import GHC.IO (catchAny)
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String
import Simplex.Messaging.Protocol
import Simplex.Messaging.Server.QueueStore
import Simplex.Messaging.Server.StoreLog.Types
import Simplex.Messaging.SystemTime
import Simplex.Messaging.Util (ifM, tshow, unlessM, whenM)
import System.Directory (doesFileExist, listDirectory, removeFile, renameFile)
import System.IO
import System.FilePath (takeDirectory, takeFileName)

data StoreLogRecord
  = CreateQueue RecipientId QueueRec
  | CreateLink RecipientId LinkId QueueLinkData
  | DeleteLink RecipientId
  | SecureQueue QueueId SndPublicAuthKey
  | UpdateKeys RecipientId (NonEmpty RcvPublicAuthKey)
  | AddNotifier QueueId NtfCreds
  | SuspendQueue QueueId
  | BlockQueue QueueId BlockingInfo
  | UnblockQueue QueueId
  | DeleteQueue QueueId
  | DeleteNotifier QueueId
  | UpdateTime QueueId SystemDate
  | NewService ServiceRec
  | QueueService RecipientId ASubscriberParty (Maybe ServiceId)
  deriving (Show)

data SLRTag
  = CreateQueue_
  | CreateLink_
  | DeleteLink_
  | SecureQueue_
  | UpdateKeys_
  | AddNotifier_
  | SuspendQueue_
  | BlockQueue_
  | UnblockQueue_
  | DeleteQueue_
  | DeleteNotifier_
  | UpdateTime_
  | NewService_
  | QueueService_

instance StrEncoding QueueRec where
  strEncode QueueRec {recipientKeys, rcvDhSecret, rcvServiceId, senderId, senderKey, queueMode, queueData, notifier, status, updatedAt} =
    B.concat
      [ p "rk=" recipientKeys,
        p " rdh=" rcvDhSecret,
        p " sid=" senderId,
        p " sk=" senderKey,
        maybe "" ((" queue_mode=" <>) . smpEncode) queueMode,
        opt " link_id=" (fst <$> queueData),
        opt " queue_data=" (snd <$> queueData),
        opt " notifier=" notifier,
        opt " updated_at=" updatedAt,
        statusStr,
        opt " rsrv=" rcvServiceId
      ]
    where
      p :: StrEncoding a => ByteString -> a -> ByteString
      p param = (param <>) . strEncode
      opt :: StrEncoding a => ByteString -> Maybe a -> ByteString
      opt = maybe "" . p
      statusStr = case status of
        EntityActive -> ""
        _ -> " status=" <> strEncode status

  strP = do
    recipientKeys <- "rk=" *> strP_
    rcvDhSecret <- "rdh=" *> strP_
    senderId <- "sid=" *> strP_
    senderKey <- "sk=" *> strP
    queueMode <-
      toQueueMode <$> (" sndSecure=" *> strP)
        <|> Just <$> (" queue_mode=" *> smpP)
        <|> pure Nothing -- unknown queue mode, we cannot imply that it is contact address
    queueData <- optional $ (,) <$> (" link_id=" *> strP) <*> (" queue_data=" *> strP)
    notifier <- optional $ " notifier=" *> strP
    updatedAt <- optional $ " updated_at=" *> strP
    status <- (" status=" *> strP) <|> pure EntityActive
    rcvServiceId <- optional $ " rsrv=" *> strP
    pure
      QueueRec
        { recipientKeys,
          rcvDhSecret,
          senderId,
          senderKey,
          queueMode,
          queueData,
          notifier,
          status,
          updatedAt,
          rcvServiceId
        }
    where
      toQueueMode sndSecure = Just $ if sndSecure then QMMessaging else QMContact

instance StrEncoding SLRTag where
  strEncode = \case
    CreateQueue_ -> "CREATE"
    CreateLink_ -> "LINK"
    DeleteLink_ -> "LDELETE"
    SecureQueue_ -> "SECURE"
    UpdateKeys_ -> "KEYS"
    AddNotifier_ -> "NOTIFIER"
    SuspendQueue_ -> "SUSPEND"
    BlockQueue_ -> "BLOCK"
    UnblockQueue_ -> "UNBLOCK"
    DeleteQueue_ -> "DELETE"
    DeleteNotifier_ -> "NDELETE"
    UpdateTime_ -> "TIME"
    NewService_ -> "NEW_SERVICE"
    QueueService_ -> "QUEUE_SERVICE"

  strP =
    A.choice
      [ "CREATE" $> CreateQueue_,
        "LINK" $> CreateLink_,
        "LDELETE" $> DeleteLink_,
        "SECURE" $> SecureQueue_,
        "KEYS" $> UpdateKeys_,
        "NOTIFIER" $> AddNotifier_,
        "SUSPEND" $> SuspendQueue_,
        "BLOCK" $> BlockQueue_,
        "UNBLOCK" $> UnblockQueue_,
        "DELETE" $> DeleteQueue_,
        "NDELETE" $> DeleteNotifier_,
        "TIME" $> UpdateTime_,
        "NEW_SERVICE" $> NewService_,
        "QUEUE_SERVICE" $> QueueService_
      ]

instance StrEncoding StoreLogRecord where
  strEncode = \case
    CreateQueue rId q -> B.unwords [strEncode CreateQueue_, "rid=" <> strEncode rId, strEncode q]
    CreateLink rId lnkId d -> strEncode (CreateLink_, rId, lnkId, d)
    DeleteLink rId -> strEncode (DeleteLink_, rId)
    SecureQueue rId sKey -> strEncode (SecureQueue_, rId, sKey)
    UpdateKeys rId rKeys -> strEncode (UpdateKeys_, rId, rKeys)
    AddNotifier rId ntfCreds -> strEncode (AddNotifier_, rId, ntfCreds)
    SuspendQueue rId -> strEncode (SuspendQueue_, rId)
    BlockQueue rId info -> strEncode (BlockQueue_, rId, info)
    UnblockQueue rId -> strEncode (UnblockQueue_, rId)
    DeleteQueue rId -> strEncode (DeleteQueue_, rId)
    DeleteNotifier rId -> strEncode (DeleteNotifier_, rId)
    UpdateTime rId t ->  strEncode (UpdateTime_, rId, t)
    NewService sr -> strEncode (NewService_, sr)
    QueueService rId party serviceId -> strEncode (QueueService_, rId, party, serviceId)

  strP =
    strP_ >>= \case
      CreateQueue_ -> CreateQueue <$> ("rid=" *> strP_) <*> strP
      CreateLink_ -> CreateLink <$> strP_ <*> strP_ <*> strP
      DeleteLink_ -> DeleteLink <$> strP
      SecureQueue_ -> SecureQueue <$> strP_ <*> strP
      UpdateKeys_ -> UpdateKeys <$> strP_ <*> strP
      AddNotifier_ -> AddNotifier <$> strP_ <*> strP
      SuspendQueue_ -> SuspendQueue <$> strP
      BlockQueue_ -> BlockQueue <$> strP_ <*> strP
      UnblockQueue_ -> UnblockQueue <$> strP
      DeleteQueue_ -> DeleteQueue <$> strP
      DeleteNotifier_ -> DeleteNotifier <$> strP
      UpdateTime_ -> UpdateTime <$> strP_ <*> strP
      NewService_ -> NewService <$> strP
      QueueService_ -> QueueService <$> strP_ <*> strP_ <*> strP

openWriteStoreLog :: Bool -> FilePath -> IO (StoreLog 'WriteMode)
openWriteStoreLog append f = do
  h <- openFile f $ if append then AppendMode else WriteMode
  hSetBuffering h LineBuffering
  pure $ WriteStoreLog f h

openReadStoreLog :: FilePath -> IO (StoreLog 'ReadMode)
openReadStoreLog f = do
  unlessM (doesFileExist f) (writeFile f "")
  ReadStoreLog f <$> openFile f ReadMode

storeLogFilePath :: StoreLog a -> FilePath
storeLogFilePath = \case
  WriteStoreLog f _ -> f
  ReadStoreLog f _ -> f

closeStoreLog :: StoreLog a -> IO ()
closeStoreLog = \case
  WriteStoreLog _ h -> close_ h
  ReadStoreLog _ h -> close_ h
  where
    close_ h = hClose h `catchAny` \e -> logError ("STORE: closeStoreLog, error closing, " <> tshow e)

writeStoreLogRecord :: StrEncoding r => StoreLog 'WriteMode -> r -> IO ()
writeStoreLogRecord (WriteStoreLog _ h) r = E.uninterruptibleMask_ $ do
  B.hPut h $ strEncode r `B.snoc` '\n' -- hPutStrLn makes write non-atomic for length > 1024
  hFlush h

logCreateQueue :: StoreLog 'WriteMode -> RecipientId -> QueueRec -> IO ()
logCreateQueue s rId q = writeStoreLogRecord s $ CreateQueue rId q

logCreateLink :: StoreLog 'WriteMode -> RecipientId -> LinkId -> QueueLinkData -> IO ()
logCreateLink s rId lnkId d = writeStoreLogRecord s $ CreateLink rId lnkId d

logDeleteLink :: StoreLog 'WriteMode -> RecipientId -> IO ()
logDeleteLink s = writeStoreLogRecord s . DeleteLink

logSecureQueue :: StoreLog 'WriteMode -> QueueId -> SndPublicAuthKey -> IO ()
logSecureQueue s qId sKey = writeStoreLogRecord s $ SecureQueue qId sKey

logUpdateKeys :: StoreLog 'WriteMode -> QueueId -> NonEmpty RcvPublicAuthKey -> IO ()
logUpdateKeys s rId rKeys = writeStoreLogRecord s $ UpdateKeys rId rKeys

logAddNotifier :: StoreLog 'WriteMode -> QueueId -> NtfCreds -> IO ()
logAddNotifier s qId ntfCreds = writeStoreLogRecord s $ AddNotifier qId ntfCreds

logSuspendQueue :: StoreLog 'WriteMode -> QueueId -> IO ()
logSuspendQueue s = writeStoreLogRecord s . SuspendQueue

logBlockQueue :: StoreLog 'WriteMode -> QueueId -> BlockingInfo -> IO ()
logBlockQueue s qId info = writeStoreLogRecord s $ BlockQueue qId info

logUnblockQueue :: StoreLog 'WriteMode -> QueueId -> IO ()
logUnblockQueue s = writeStoreLogRecord s . UnblockQueue

logDeleteQueue :: StoreLog 'WriteMode -> QueueId -> IO ()
logDeleteQueue s = writeStoreLogRecord s . DeleteQueue

logDeleteNotifier :: StoreLog 'WriteMode -> QueueId -> IO ()
logDeleteNotifier s = writeStoreLogRecord s . DeleteNotifier

logUpdateQueueTime :: StoreLog 'WriteMode -> QueueId -> SystemDate -> IO ()
logUpdateQueueTime s qId t = writeStoreLogRecord s $ UpdateTime qId t

logNewService :: StoreLog 'WriteMode -> ServiceRec -> IO ()
logNewService s  = writeStoreLogRecord s . NewService

logQueueService :: (PartyI p, ServiceParty p) => StoreLog 'WriteMode -> RecipientId -> SParty p -> Maybe ServiceId -> IO ()
logQueueService s rId party = writeStoreLogRecord s . QueueService rId (ASP party)

readWriteStoreLog :: (FilePath -> s -> IO ()) -> (StoreLog 'WriteMode -> s -> IO ()) -> FilePath -> s -> IO (StoreLog 'WriteMode)
readWriteStoreLog readStore writeStore f st =
  ifM
    (doesFileExist tempBackup)
    (useTempBackup >> readWriteLog)
    (ifM (doesFileExist f) readWriteLog (writeLog "creating store log..."))
  where
    f' = T.pack f
    tempBackup = f <> ".start"
    useTempBackup = do
      -- preserve current file, use temp backup
      logWarn $ "Server terminated abnormally on last start, restoring state from " <> T.pack tempBackup
      whenM (doesFileExist f) $ do
        renameFile f (f <> ".bak")
        logNote $ "preserved incomplete state " <> f' <> " as " <> (f' <> ".bak")
      renameFile tempBackup f
    readWriteLog = do
      -- log backup is made in two steps to mitigate the crash during the compacting.
      -- Temporary backup file .start will be used when it is present.
      readStore f st
      renameFile f tempBackup -- 1) make temp backup
      s <- writeLog "compacting store log (do not terminate)..." -- 2) save state
      renameBackup -- 3) timed backup
      removeStoreLogBackups f
      pure s
    writeLog msg = do
      s <- openWriteStoreLog False f
      logNote msg
      writeStore s st
      pure s
    renameBackup = do
      ts <- getCurrentTime
      let timedBackup = f <> "." <> iso8601Show ts
      renameFile tempBackup timedBackup
      logNote $ "original state preserved as " <> T.pack timedBackup

removeStoreLogBackups :: FilePath -> IO ()
removeStoreLogBackups f = do
  ts <- getCurrentTime
  times <- sort . mapMaybe backupPathTime <$> listDirectory (takeDirectory f)
  let new = addUTCTime (- nominalDay) ts
      old = addUTCTime (- oldBackupTTL) ts
      times1 = filter (< new) times -- exclude backups newer than 24 hours
      times2 = take (length times1 - minOldBackups) times1 -- keep 3 backups older than 24 hours
      toDelete = filter (< old) times2 -- remove all backups older than 21 day
  mapM_ (removeFile . backupPath) toDelete
  when (length toDelete > 0) $ do
    putStrLn $ "Removed " <> show (length toDelete) <> " backups:"
    mapM_ (putStrLn . backupPath) toDelete
  where
    backupPathTime :: FilePath -> Maybe UTCTime
    backupPathTime = iso8601ParseM <=< stripPrefix backupPathPfx
    backupPath :: UTCTime -> FilePath
    backupPath ts = f <> "." <> iso8601Show ts
    backupPathPfx = takeFileName f <> "."
    minOldBackups = 3
    oldBackupTTL = 21 * nominalDay

readLogLines :: Bool -> FilePath -> (Bool -> B.ByteString -> IO ()) -> IO ()
readLogLines tty f action = foldLogLines tty f (const action) ()

foldLogLines :: Bool -> FilePath -> (a -> Bool -> B.ByteString -> IO a) -> a -> IO a
foldLogLines tty f action initValue = do
  (count :: Int, acc) <- withFile f ReadMode $ \h -> ifM (hIsEOF h) (pure (0, initValue)) (loop h 0 initValue)
  putStrLn $ progress count
  pure acc
  where
    loop h !i !acc = do
      s <- B.hGetLine h
      eof <- hIsEOF h
      acc' <- action acc eof s
      let i' = i + 1
      when (tty && i' `mod` 100000 == 0) $ putStr (progress i' <> "\r") >> hFlush stdout
      if eof then pure (i', acc') else loop h i' acc'
    progress i = "Processed: " <> show i <> " log lines"
