{-|
Module      : System.Log.Caster
Description : Multicast, thread-safe, and not slow logger.
Copyright   : (c) Akihito KIRISAKI

License     : BSD3
Maintainer  : Akihito KIRISAKI
Stability   : experimental
-}
{-# LANGUAGE UndecidableInstances #-}
module System.Log.Caster
  ( -- * Basics
    LogMsg(..)
  , broadcastLog
  , LogQueue(..)
  , newLogQueue
  , LogChan(..)
  , newLogChan
  , Formatter
  , Listener
  , relayLog
  -- * Listeners
  , stdoutListener
  , stdoutListenerWith
  , terminalListener
  , handleListener
  , handleListenerFlush
  -- * Formatter
  , defaultFormatter
  , terminalFormatter
  -- * Log levels
  , LogLevel(..)
  , logAs
  , debug
  , info
  , notice
  , warn
  , err
  , critical
  , alert
  , emergency
  -- * Useful string class and operator
  , ToBuilder(..)
  , fix
  , ($:)
  , (<:>)
  ) where


import           Control.Concurrent.STM
import           Control.Monad
import           Control.Monad.IO.Class      (MonadIO (..))
import qualified Data.ByteString             as SBS
import qualified Data.ByteString.Builder     as BB
import qualified Data.ByteString.FastBuilder as FB
import qualified Data.ByteString.Lazy        as LBS
import           Data.Semigroup
import qualified Data.Text                   as ST
import qualified Data.Text.Encoding          as STE
import qualified Data.Text.Lazy              as LT
import qualified Data.Text.Lazy.Encoding     as LTE
import           Data.UnixTime               (Format, UnixTime (..),
                                              formatUnixTime, getUnixTime)
import           GHC.IO.Unsafe               (unsafePerformIO)
import           System.IO                   (Handle, hFlush, stdout)

-- | Types which are able to be converted into @'FB.Builder' Builder@
--   @toBuilde@ encodes @String@ and @Text@ as utf-8.
class ToBuilder a where
  toBuilder :: a -> FB.Builder

instance ToBuilder FB.Builder where
  toBuilder = id

instance ToBuilder String where
  toBuilder = FB.stringUtf8

instance ToBuilder ST.Text where
  toBuilder = FB.byteString . STE.encodeUtf8

instance ToBuilder LT.Text where
  toBuilder = FB.byteString . LBS.toStrict . LTE.encodeUtf8

instance ToBuilder SBS.ByteString where
  toBuilder = FB.byteString

instance ToBuilder LBS.ByteString where
  toBuilder = FB.byteString . LBS.toStrict

instance ToBuilder BB.Builder where
  toBuilder = FB.byteString . LBS.toStrict . BB.toLazyByteString

instance {-# OVERLAPPABLE #-} Show a => ToBuilder a where
  toBuilder = FB.stringUtf8 . show

-- | If you turn @OverloadedStrings@ extension on, GHC can't deduce the type of string literal.
--   This function fix the type to @'FB.Builder' Builder@ without type annotation.
fix :: FB.Builder -> FB.Builder
fix = id

-- | Infix version of @fix@.
infixr 0 $:
($:) :: ToBuilder b => (FB.Builder -> b) -> FB.Builder -> b
($:) = ($)

-- | Concat @ToBuilder@ strings as @'FB.Builder' Builder@.
infixr 6 <:>
(<:>) :: (ToBuilder a, ToBuilder b) => a -> b -> FB.Builder
a <:> b = toBuilder a <> toBuilder b

-- |Log levels. These are matched to syslog.
data LogLevel
  = LogDebug
  | LogInfo
  | LogNotice
  | LogWarn
  | LogError
  | LogCritical
  | LogAlert
  | LogEmergency
  deriving (Show, Eq, Ord)

-- | Log message.
data LogMsg = LogMsg
  { logMsgLevel   :: LogLevel
  , logMsgTime    :: UnixTime
  , logMsgBuilder :: FB.Builder
  }

-- | Queue of @LogMsg@.
newtype LogQueue = LogQueue (TQueue LogMsg)

-- | Channel of @LogMsg@.
newtype LogChan = LogChan (TChan LogMsg)

-- | Make new @LogQueue@
newLogQueue :: IO LogQueue
newLogQueue = LogQueue <$> newTQueueIO

-- | Make new @LogChan@.
newLogChan :: IO LogChan
newLogChan = LogChan <$> newBroadcastTChanIO

-- | Connect @LogQueue@ and @TChan@ @LogMsg@.
broadcastLog :: LogQueue -> LogChan -> IO ()
broadcastLog (LogQueue q) (LogChan c) =  forever $
  atomically $ readTQueue q >>= writeTChan c

-- | Formatter.
type Formatter = LogMsg -> FB.Builder

-- | IO function takes @LogMsg@.
type Listener = LogMsg -> IO ()

-- | Listen @LogChan@ and give the @LogMsg@ to given @Listener@.
relayLog :: LogChan -> LogLevel -> Listener -> IO ()
relayLog (LogChan bchan) logLevel listener = do
  chan <- atomically $ dupTChan bchan
  forever $ do
    msg <- atomically $ readTChan chan
    when (logMsgLevel msg >= logLevel) $ listener msg

-- | Make @Listener@ from @Handle@
handleListener :: Formatter -> Handle -> Listener
handleListener f h = FB.hPutBuilder h . f

-- | Make @Listener@ flushing buffer after getting @LogMsg@
handleListenerFlush :: Formatter -> Handle -> Listener
handleListenerFlush f h msg = FB.hPutBuilder h (f msg) >> hFlush h

-- | Stdout listener with @Formatter@.
stdoutListenerWith :: Formatter -> Listener
stdoutListenerWith f = handleListenerFlush f stdout

-- | Stdout listener.
stdoutListener :: Listener
stdoutListener = stdoutListenerWith defaultFormatter

-- | Terminal listener. Log levels are colored.
terminalListener :: Listener
terminalListener = stdoutListenerWith terminalFormatter

-- | Default log formatter.
defaultFormatter :: Formatter
defaultFormatter (LogMsg lev ut str) =
  formatTime ut <> " - [" <> logLevelToBuilder lev <> "] " <> str <> "\n"

-- | Formatter for term.
--   It provides colored logs.
terminalFormatter :: Formatter
terminalFormatter = terminalFormatterWith
                    "\ESC[32m"
                    "\ESC[36m"
                    "\ESC[4m\ESC[36m"
                    "\ESC[4m\ESC[33m"
                    "\ESC[4m\ESC[31m"
                    "\ESC[1m\ESC[31m"
                    "\ESC[1m\ESC[35m"
                    "\ESC[5m\ESC[35m"

-- | Formatter with specified colors for log levels.
--   Parameters are just @FB.Builder@, so you can decorate as you like with ansi escaping.
terminalFormatterWith :: FB.Builder -> FB.Builder -> FB.Builder -> FB.Builder -> FB.Builder -> FB.Builder -> FB.Builder -> FB.Builder -> Formatter
terminalFormatterWith fDebug fInfo fNotice fWarn fError fCritical fAlert fEmergency (LogMsg lev ut str) =
  formatTime ut <> " - " <> fmt <>  "[" <> logLevelToBuilder lev <> "]\ESC[0m " <> str <> "\n"
  where
    fmt = case lev of
      LogDebug     -> fDebug
      LogInfo      -> fInfo
      LogNotice    -> fNotice
      LogWarn      -> fWarn
      LogError     -> fError
      LogCritical  -> fCritical
      LogAlert     -> fAlert
      LogEmergency -> fEmergency


logLevelToBuilder :: LogLevel -> FB.Builder
logLevelToBuilder = \case
  LogDebug -> "DEBUG"
  LogInfo  -> "INFO"
  LogNotice  -> "NOTICE"
  LogWarn  -> "WARN"
  LogError -> "ERROR"
  LogCritical -> "CRITICAL"
  LogAlert -> "ALERT"
  LogEmergency -> "EMERGENCY"


{-# NOINLINE formatTime #-}
formatTime :: UnixTime -> FB.Builder
formatTime ut =
  let
    ut' = FB.byteString . unsafePerformIO $ formatUnixTime "%Y-%m-%d %T" ut
    utMilli = FB.string7 . tail . show $ utMicroSeconds ut `div` 1000 + 1000
  in
    ut' <> "." <> utMilli

-- | Push a message to @LogQueue@.
logAs :: (MonadIO m, ToBuilder s) => LogQueue -> LogLevel -> s -> m ()
logAs (LogQueue q) l s = liftIO $ do
  ut <- getUnixTime
  atomically $ writeTQueue q (LogMsg l ut (toBuilder s))

debug :: (MonadIO m, ToBuilder s) => LogQueue -> s -> m ()
debug q = logAs q LogDebug

info :: (MonadIO m, ToBuilder s) => LogQueue -> s -> m ()
info q = logAs q LogInfo

notice :: (MonadIO m, ToBuilder s) => LogQueue -> s -> m ()
notice q = logAs q LogNotice

warn :: (MonadIO m, ToBuilder s) => LogQueue -> s -> m ()
warn q = logAs q LogWarn

err :: (MonadIO m, ToBuilder s) => LogQueue -> s -> m ()
err q = logAs q LogError

critical :: (MonadIO m, ToBuilder s) => LogQueue -> s -> m ()
critical q = logAs q LogCritical

alert :: (MonadIO m, ToBuilder s) => LogQueue -> s -> m ()
alert q = logAs q LogAlert

emergency :: (MonadIO m, ToBuilder s) => LogQueue -> s -> m ()
emergency q = logAs q LogEmergency