{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Di.Core
 ( Di(..)
 , mkDi
 , log
 , flush
 , push
 , filter
 , contralevel
 , contrapath
 , contramsg
 ) where

import Control.Concurrent (forkFinally, myThreadId)
import Control.Concurrent.STM
import qualified Control.Exception as Ex
import Control.Monad (when)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Function (fix)
import Data.Monoid (mconcat, (<>))
import qualified Data.Time as Time
import Prelude hiding (log, filter)
import qualified System.IO as IO

--------------------------------------------------------------------------------

-- | @'Di' level path msg@ allows you to to log messages of type @msg@, with a
-- particular importance @level@, under a scope identified by @path@ (think of
-- @path@ as a filesystem path that you can use to group together related log
-- messages).
--
-- Each @msg@ gets logged together with its @level@, @path@ and the
-- 'Time.UTCTime' stating the instant when the logging requests was made.
--
-- Even though logging is usually associated with rendering text, 'Di' makes no
-- assumption about the types of the @msg@ values being logged, nor the @path@
-- values that convey their scope, nor the @level@ values that convey their
-- importance. Instead, it delays conversion from these precise types into the
-- ultimately desired raw representation (if any) as much as possible. This
-- makes it possible to log more precise information (for example, logging a
-- datatype of your own without having to convert it to text first), richer
-- scope paths (for example, the scope could be a 'Data.Map.Strict.Map' that
-- gets enriched with more information as we 'push' down the @path@), and
-- importance @level@s that are never too broad nor too narrow. This improves
-- type safety, as well as the composability of the @level@, @path@ and @msg@
-- values. In particular, all of @level@, @path@ and @msg@ are contravariant
-- values, which in practice means including a precise 'Di' into a more general
-- 'Di' is always possible (see the 'contralevel@, 'contrapath' and 'contramsg'
-- functions).
--
-- Messages of undesired importance levels can be muted by using 'filter'.
--
-- Contrary to other logging approaches based on monad transformers, a 'Di' is a
-- value that is expected to be passed around explicitly. Of course, if
-- necessary you can always put a 'Di' in some internal monad state or
-- environment and provide a custom API for it. That's a choice you can make.
--
-- A 'Di' can be safely used concurrently, and messages are rendered in the
-- absolute order they were submitted for logging.
--
-- 'Di' is pronounced as \"dee" (not \"die" nor \"dye" nor \"day"). \"Di" is
-- the spanish word for an imperative form of the verb \"decir", which in
-- english means "to say".
data Di level path msg = Di
  { _diLog :: Time.UTCTime -> level -> path -> msg -> IO ()
    -- ^ Low level logging function.
  , _diFilter :: level -> Bool
    -- ^ Whether a particular message @level@ should be logged or not.
  , _diLogs :: TQueue (IO ())
    -- ^ Work queue. This queue keeps fully applied '_diLog' calls.
  }

-- | Build a new 'Di' from a logging function.
--
-- /Note:/ If the passed in 'IO' function throws a exception, it will be
-- just logged to 'IO.stderr' and then ignored.
--
-- /Note:/ There's no need to "release" the obtained 'Di'.
mkDi
  :: MonadIO m
  => (Time.UTCTime -> level -> path -> msg -> IO ())
  -> m (Di level path msg)  -- ^
mkDi f = liftIO $ do
   di <- Di f (const True) <$> newTQueueIO
   me <- myThreadId
   _ <- forkFinally (worker di) (either (Ex.throwTo me) pure)
   pure di
 where
   worker :: Di level path msg -> IO ()
   worker di = fix $ \k -> do
     eio <- Ex.try $ atomically $ do
        io <- peekTQueue (_diLogs di)
        pure (Ex.finally io (atomically (readTQueue (_diLogs di))))
     case eio of
        Left (_ :: Ex.BlockedIndefinitelyOnSTM) -> do
           pure ()  -- Nobody writes to '_diLogs' anymore, so we can just stop.
        Right io -> do
           catchSync io $ \se -> do
              ts <- fmap renderIso8601 Time.getCurrentTime
              IO.hPutStrLn IO.stderr $ mconcat
                 [ "Logging error at ", ts
                 , ": could not log message at due to "
                 , Ex.displayException se, ". Ignoring." ]
           k

-- | Log a message with the given importance @level@.
--
-- This function returns immediately after queing the message for logging in a
-- different thread. If you want to explicitly wait for the message to be
-- logged, then call 'flush' afterwards.
--
-- /Note:/ No exceptions from the underlying logging backend (i.e., the 'IO'
-- action given to 'mkDi') will be thrown from 'log'. Instead, those will be
-- recorded to 'IO.stderr' and ignored.
log :: (MonadIO m, Monoid path) => Di level path msg -> level -> msg -> m ()
log (Di dLog dFilter dLogs) !l = \(!m) ->
   when (dFilter l) $ liftIO $ do
      ts <- Time.getCurrentTime
      atomically $ writeTQueue dLogs $! dLog ts l mempty m
{-# INLINABLE log #-}


-- | Block until all messages being logged have finished processing.
--
-- Mabually calling 'flush' is not usually necessary, but, if at some point you
-- want to ensure that all messages logged until then have properly rendered to
-- the underlying backend, then 'flush' will block until that happens.
flush :: MonadIO m => Di level path msg -> m ()
flush di = liftIO (atomically (check =<< isEmptyTQueue (_diLogs di)))
{-# INLINABLE flush #-}


-- | Returns a new 'Di' on which only messages with a @level@ satisfying the
-- given predicate—in addition to any previous 'filter's—are ever logged.
--
-- Identity:
--
-- @
-- 'filter' ('const' 'True')    ==   'id'
-- @
--
-- Composition:
--
-- @
-- 'filter' ('Control.Applicative.liftA2' (&&) f g)   ==   'filter' f . 'filter' g
-- @
--
-- Conmutativity:
--
-- @
-- 'filter' f . 'filter' g    ==    'filter' g . 'filter' f
-- @
filter :: (level -> Bool) -> Di level path msg -> Di level path msg
filter f = \di -> di { _diFilter = \l -> f l && _diFilter di l }
{-# INLINABLE filter #-}


-- | Push a new @path@ to the 'Di'.
--
-- Identity:
--
-- @
-- 'push' 'mempty'   ==   'id'
-- @
--
-- Composition:
--
-- @
-- 'push' (a <> b)   ==   'push' b . 'push' a
-- @
push :: Monoid path => path -> Di level path msg -> Di level path msg
push ps = \(Di dLog dFilter dLogs) ->
  Di (\ts l p1 m -> dLog ts l (ps <> p1) m) dFilter dLogs
{-# INLINABLE push #-}


-- | A 'Di' is contravariant in its @level@ argument.
--
-- This function is used to go from a /more general/ to a /less general/ type
-- of @level@. For example, @data Level = Info | Error@ is a less general type
-- than @data Level' = Info' | Warning' | Error'@, since the former can only
-- convey two logging levels, whereas the latter can convey three. We can
-- convert from the more general to the less general @level@ type using this
-- 'contralevel' function:
--
-- @
-- 'contralevel' (\\case { Info -> Info'; Error -> Error' }) (di :: 'Di' Level' ['String'] msg)
--     :: 'Di' Level ['Int'] msg
-- @
--
-- Identity:
--
-- @
-- 'contralevel' 'id'   ==   'id'
-- @
--
-- Composition:
--
-- @
-- 'contralevel' (f . g)   ==   'contralevel' g . 'contralevel' f
-- @
contralevel :: (level -> level') -> Di level' path msg  -> Di level path msg
contralevel f = \(Di dLog dFilter dLogs) ->
  Di (\ts l p m -> dLog ts (f l) p m) (\l -> dFilter (f l)) dLogs
{-# INLINABLE contralevel #-}

-- | A 'Di' is contravariant in its @path@ argument.
--
-- This function is used to go from a /more general/ to a /less specific/ type
-- of @path@. For example, @['Int']@ is a less general type than @['String']@,
-- since the former clearly conveys the idea of a list of numbers, whereas the
-- latter could be a list of anything that is representable as 'String', such as
-- names of fruits and poems. We can convert from the more general to the less
-- general @path@ type using this 'contrapath' function:
--
-- @
-- 'contrapath' ('map' 'show') (di :: 'Di' level ['String'] msg)
--     :: 'Di' ['Int'] msg
-- @
--
-- Identity:
--
-- @
-- 'contrapath' 'id'   ==   'id'
-- @
--
-- Composition:
--
-- @
-- 'contrapath' (f . g)   ==   'contrapath' g . 'contrapath' f
-- @
contrapath :: (path -> path') -> Di level path' msg  -> Di level path msg
contrapath f = \(Di dLog dFilter dLogs) ->
  Di (\ts l p m -> dLog ts l (f p) m) dFilter dLogs
{-# INLINABLE contrapath #-}

-- | A 'Di' is contravariant in its @msg@ argument.
--
-- This function is used to go from a /more general/ to a /less general/ type
-- of @msg@. For example, @'Int'@ is a less general type than @'String'@, since
-- the former clearly conveys the idea of a numbers, whereas the latter could be
-- a anything that is representable as 'String', such as names of painters and
-- colors. We can convert from the more general to the less general @msg@ type
-- using this 'contramsg' function:
--
-- @
-- 'contramsg' 'show' (di :: 'Di' level path 'String')
--     :: 'Di' level path 'Int'
-- @
--
-- Identity:
--
-- @
-- 'contramsg' 'id'   ==   'id'
-- @
--
-- Composition:
--
-- @
-- 'contramsg' (f . g)   ==   'contramsg' g . 'contramsg' f
-- @
contramsg :: (msg -> msg') -> Di level path msg' -> Di level path msg
contramsg f = \(Di dLog dFilter dLogs) ->
  Di (\ts l p m -> dLog ts l p (f m)) dFilter dLogs
{-# INLINABLE contramsg #-}

--------------------------------------------------------------------------------

renderIso8601 :: Time.UTCTime -> String
renderIso8601 = Time.formatTime Time.defaultTimeLocale "%Y-%m-%dT%H:%M:%S.%qZ"

catchSync :: IO a -> (Ex.SomeException -> IO a) -> IO a
catchSync m f = Ex.catch m $ \se -> case Ex.asyncExceptionFromException se of
   Just ae -> Ex.throwIO (ae :: Ex.AsyncException)
   Nothing -> f se