{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE MultiParamTypeClasses      #-}
{-# LANGUAGE ScopedTypeVariables        #-}
{-# LANGUAGE StandaloneDeriving         #-}
module Control.Monad.TimeMachine.Engine (
    -- * MonadTime Class
      MonadTime(..)
    , getTimeZone
    , getCurrentTimeZone
    , utcToLocalZonedTime
    , getZonedTime
    , loadLocalTZ
    -- * Functions
    , departFor
    , travelTo
    , backTo
    , jumpTo
    , accelerate
    , halt
    -- * Configurations
    , TimeScale(..)
    , Destination(..)
    , TimeInterval(..)
    , TimeZoneName
    , Acceleration(..)
    -- * Monad Transformer
    , TimeMachineT
    ) where

import           Control.Exception    ( IOException, catch )
import           Control.Monad.Reader ( ReaderT, ask, runReaderT )
import           Control.Monad.Trans  ( MonadIO, liftIO, MonadTrans )
import           Data.Maybe           ( fromMaybe )
import qualified Data.Time            as T
import qualified Data.Time.Zones      as TZ

-- | A data type to represents the speed of time.
-- It corresponds how many seconds are in the real second,
-- i.e. @TimeScale 1@ is equivalent to the real speed of time.
newtype TimeScale = TimeScale { unTimeScale :: T.NominalDiffTime }
    deriving ( Eq, Show, Ord, Num )

-- | A class of monads in which you can obrain the mocked current time
-- and relevant information.
class (Monad m) => MonadTime m where
    getCurrentTime      :: m T.UTCTime -- ^ foo
    getCurrentTZ        :: m TZ.TZ
    getCurrentTimeScale :: m TimeScale

-- | Returns the mocked time zone at the given point of time.
getTimeZone :: (MonadTime m) => T.UTCTime -> m T.TimeZone
getTimeZone ut = do
    tz <- getCurrentTZ
    return $ TZ.timeZoneForUTCTime tz ut

-- | Returns the mocked time zone at the mocked current time.
getCurrentTimeZone :: (MonadTime m) => m T.TimeZone
getCurrentTimeZone = getCurrentTime >>= getTimeZone

-- | Returns the mocked local time at the given point of time.
utcToLocalZonedTime :: (MonadTime m) => T.UTCTime -> m T.ZonedTime
utcToLocalZonedTime ut = do
    tz   <- getCurrentTZ
    zone <- getTimeZone ut
    return $ T.ZonedTime (TZ.utcToLocalTimeTZ tz ut) zone

-- | Returns the mocked local time at the mocked current time.
getZonedTime :: (MonadTime m) => m T.ZonedTime
getZonedTime = getCurrentTime >>= utcToLocalZonedTime

-- | An alias of 'getCurrentTZ'.
loadLocalTZ :: (MonadTime m) => m TZ.TZ
loadLocalTZ = getCurrentTZ

instance MonadTime IO where
    getCurrentTime      = T.getCurrentTime
    getCurrentTZ        = TZ.loadLocalTZ
    getCurrentTimeScale = return $ TimeScale 1

data Spacetime = Spacetime {
      stSimulatedOrigin :: T.UTCTime
    , stRealOrigin      :: T.UTCTime
    , stTZ              :: TZ.TZ
    , stTimeScale       :: TimeScale
    } deriving ( Eq, Show )

-- | A monad transformer to stack the 'MonadTime' contexts.
newtype TimeMachineT m a =
    TimeMachineT { timeMachineT :: ReaderT Spacetime m a }

deriving instance (Functor m)     => Functor     (TimeMachineT m)
deriving instance (Applicative m) => Applicative (TimeMachineT m)
deriving instance (Monad m)       => Monad       (TimeMachineT m)
deriving instance (MonadIO m)     => MonadIO     (TimeMachineT m)

deriving instance MonadTrans TimeMachineT

instance (MonadIO m) => MonadTime (TimeMachineT m) where
    getCurrentTime = TimeMachineT $ do
        realCurr <- liftIO T.getCurrentTime
        Spacetime simOrigin realOrigin _ scale <- ask
        let diff = scaledDiffUTCTime scale realCurr realOrigin
        return $ T.addUTCTime diff simOrigin

    getCurrentTZ        = TimeMachineT $ ask >>= return . stTZ
    getCurrentTimeScale = TimeMachineT $ ask >>= return . stTimeScale

scaledDiffUTCTime :: TimeScale -> T.UTCTime -> T.UTCTime -> T.NominalDiffTime
scaledDiffUTCTime scale t1 t0 = (unTimeScale scale) * T.diffUTCTime t1 t0

runTimeMachineT :: TimeMachineT m a -> Spacetime -> m a
runTimeMachineT = runReaderT . timeMachineT

-- | A data type to represent a point of time for mocking.
data Destination =
      None                   -- ^ Nothing to mock.
    | Absolute T.UTCTime     -- ^ An absolute point in UTC.
    | Zoned    T.LocalTime   -- ^ A local time in the mocked current time zone.
    | Relative TimeInterval  -- ^ An interval from the mocked current time.
    deriving ( Eq, Show )

-- | A data type to represent intervals for constructing a 'Destination'.
data TimeInterval =
      Minutes Integer
    | Hours   Integer
    | Days    Integer
    | Weeks   Integer
    | Months  Integer
    | Years   Integer
    deriving ( Show )

instance Eq TimeInterval where
    x == y = case (normarizeToMinutes x, normarizeToMinutes y) of
        (Minutes n1, Minutes n2) -> n1 == n2
        (Months  n1, Months  n2) -> n1 == n2
        (Years   n1, Years   n2) -> n1 == n2
        (_         , _         ) -> False

normarizeToMinutes :: TimeInterval -> TimeInterval
normarizeToMinutes (Hours n) = Minutes $ n * 60
normarizeToMinutes (Days  n) = Minutes $ n * 60 * 24
normarizeToMinutes (Weeks n) = Minutes $ n * 60 * 24 * 7
normarizeToMinutes ti        = ti

-- | A data type to represent how to change the mocked speed of time.
data Acceleration =
      Keep                -- ^ Nothing to change.
    | Velocity TimeScale  -- ^ Sets the speed to the given scale.
    | Factor   TimeScale  -- ^ Sets the speed acccording to the current speed.
    deriving ( Eq, Show )

-- | Names of time zones, e.g. @"Asia/Tokyo"@ or @"Europe/Paris"@.
type TimeZoneName = String

-- | Switches the 'MonadTime' contexts.
-- You can specify all of the point of time, the time zone and
-- the setting of speed for mocking at once.
departFor :: (MonadIO m, MonadTime m)
          => Destination -> TimeZoneName -> Acceleration
          -> TimeMachineT m a -> m a
departFor dest zoneName acc act = do
    realCurr <- liftIO T.getCurrentTime
    simCurr  <- getCurrentTime
    tz       <- getCurrentTZ
    scale    <- getCurrentTimeScale
    mTZ      <- liftIO $ safeLoadTZFromDB zoneName
    let newTZ    = fromMaybe tz mTZ
        newSim   = calcSimulatedOrigin dest newTZ simCurr
        newScale = calcTimeScale acc scale
        newST    = Spacetime newSim realCurr newTZ newScale
    runTimeMachineT act newST

safeLoadTZFromDB :: TimeZoneName -> IO (Maybe TZ.TZ)
safeLoadTZFromDB zoneName =
    (do
            tz <- liftIO $ TZ.loadTZFromDB zoneName
            return $ Just tz
        ) `catch` (\(e :: IOException) -> do
            return Nothing
        )

calcSimulatedOrigin :: Destination -> TZ.TZ -> T.UTCTime -> T.UTCTime
calcSimulatedOrigin None          _  t0 = t0
calcSimulatedOrigin (Absolute t)  _  _  = t
calcSimulatedOrigin (Zoned lt)    tz _ = TZ.localTimeToUTCTZ tz lt
calcSimulatedOrigin (Relative ti) tz t0 = addTimeInterval ti tz t0

addTimeInterval :: TimeInterval -> TZ.TZ -> T.UTCTime -> T.UTCTime
addTimeInterval (Minutes n) _  = T.addUTCTime $ fromIntegral (60     * n)
addTimeInterval (Hours n)   _  = T.addUTCTime $ fromIntegral (3600   * n)
addTimeInterval (Days n)    _  = T.addUTCTime $ fromIntegral (86400  * n)
addTimeInterval (Weeks n)   _  = T.addUTCTime $ fromIntegral (604800 * n)
addTimeInterval (Months n)  tz = calcTargetDay (T.addGregorianMonthsClip n) tz
addTimeInterval (Years n)   tz = calcTargetDay (T.addGregorianYearsClip  n) tz

calcTargetDay :: (T.Day -> T.Day) -> TZ.TZ -> T.UTCTime -> T.UTCTime
calcTargetDay f tz t0 =
    let T.LocalTime d tod = TZ.utcToLocalTimeTZ tz t0
    in  TZ.localTimeToUTCTZ tz $ T.LocalTime (f d) tod

calcTimeScale :: Acceleration -> TimeScale -> TimeScale
calcTimeScale Keep         s0 = s0
calcTimeScale (Velocity v) s0 = v
calcTimeScale (Factor f)   s0 = f * s0

-- | Switches the mocked current time in the context.
travelTo :: (MonadIO m, MonadTime m) => Destination -> TimeMachineT m a -> m a
travelTo dest = departFor dest "" Keep

-- | An alias of 'travelTo'.
backTo :: (MonadIO m, MonadTime m) => Destination -> TimeMachineT m a -> m a
backTo = travelTo

-- | Switches the mocked current time zone in the context.
jumpTo :: (MonadIO m, MonadTime m) => TimeZoneName -> TimeMachineT m a -> m a
jumpTo zoneName = departFor None zoneName Keep

-- | Changes the mocked speed of time in the context.
accelerate :: (MonadIO m, MonadTime m)
           => Acceleration -> TimeMachineT m a -> m a
accelerate acc = departFor None "" acc

-- | Stops the time to advence in the context.
halt :: (MonadIO m, MonadTime m) => TimeMachineT m a -> m a
halt = accelerate (Velocity 0)