-- |
-- Module:      Data.Cache
-- Copyright:   (c) 2016 Henri Verroken
-- License:     BSD3
-- Maintainer:  Henri Verroken <henriverroken@gmail.com>
-- Stability:   stable
--
-- An in-memory key/value store with expiration support, similar
-- to patrickmn/go-cache for Go.
--
-- The cache is a shared mutable HashMap implemented using STM. It
-- supports item expiration.

module Data.Cache (
    -- * How to use this library
    -- $use

    -- * Creating a cache
    Cache
  , newCache
  , newCacheSTM

    -- * Cache properties
  , defaultExpiration
  , setDefaultExpiration
  , copyCache
  , copyCacheSTM

    -- * Managing items
    -- ** Insertion
  , insert
  , insert'
  , insertSTM
    -- ** Querying
  , lookup
  , lookup'
  , lookupSTM
  , keys
  , keysSTM
    -- ** Deletion
  , delete
  , deleteSTM
  , filterWithKey
  , purge
  , purgeExpired
  , purgeExpiredSTM
    -- ** Combined actions
  , fetchWithCache

    -- * Cache information
  , size
  , sizeSTM
  , toList
) where

import Prelude hiding (lookup)

import Control.Concurrent.STM
import Control.Monad
import Control.Monad.Trans.Maybe
import Control.Monad.IO.Class
import Data.Cache.Internal
import qualified Data.HashMap.Strict as HM
import Data.Hashable
import Data.Maybe
import System.Clock

-- | Change the default expiration value of newly added cache items.
--
-- See 'newCache' for more information on the default expiration value.
setDefaultExpiration :: Cache k v -> Maybe TimeSpec -> Cache k v
setDefaultExpiration c t = c { defaultExpiration = t }


isExpired :: TimeSpec -> CacheItem v -> Bool
isExpired t i = fromMaybe False (itemExpiration i >>= f t)
    where f now' e
            | e < now'   = Just True
            | otherwise = Just False

-- | Create a new cache with a default expiration value for newly
-- added cache items.
--
-- Items that are added to the cache without an explicit expiration value
-- (using 'insert') will be inserted with the default expiration value.
--
-- If the specified default expiration value is `Nothing`, items inserted
-- by 'insert' will never expire.
newCache :: Maybe TimeSpec -> IO (Cache k v)
newCache d = do
    m <- newTVarIO HM.empty
    return Cache { container = m, defaultExpiration = d }

-- | STM variant of 'newCache'
newCacheSTM :: Maybe TimeSpec -> STM (Cache k v)
newCacheSTM d = do
    m <- newTVar HM.empty
    return Cache { container = m, defaultExpiration = d }

copyCacheSTM :: Cache k v -> STM (Cache k v)
copyCacheSTM c = do
    m <- newTVar =<< readTVar (container c)
    return c { container = m }

-- | Create a deep copy of the cache.
copyCache :: Cache k v -> IO (Cache k v)
copyCache = atomically . copyCacheSTM

-- | STM variant of 'size'
sizeSTM :: Cache k v -> STM Int
sizeSTM c = HM.size <$> readTVar (container c)

-- | Return the size of the cache, including expired items.
size :: Cache k v -> IO Int
size = atomically . sizeSTM

-- | STM variant of 'delete'.
deleteSTM :: (Eq k, Hashable k) => k -> Cache k v -> STM ()
deleteSTM k c = writeTVar v =<< (HM.delete k <$> readTVar v) where v = container c

-- | Delete an item from the cache. Won't do anything if the item is not present.
delete :: (Eq k, Hashable k) => Cache k v -> k -> IO ()
delete c k = atomically $ deleteSTM k c

lookupItem' :: (Eq k, Hashable k) => k -> Cache k v -> STM (Maybe (CacheItem v))
lookupItem' k c = HM.lookup k <$> readTVar (container c)

lookupItemT :: (Eq k, Hashable k) => Bool -> k -> Cache k v -> TimeSpec -> STM (Maybe (CacheItem v))
lookupItemT del k c t = runMaybeT $ do
        i <- MaybeT (lookupItem' k c)
        let e = isExpired t i
        _ <- when (e && del) (MaybeT $ Just <$> deleteSTM k c)
        if e then MaybeT $ return Nothing else MaybeT $ return (Just i)

lookupItem :: (Eq k, Hashable k) => Bool -> k -> Cache k v -> IO (Maybe (CacheItem v))
lookupItem del k c = (atomically . lookupItemT del k c) =<< now

-- | Lookup an item with the given key, but don't delete it if it is expired.
--
-- The function will only return a value if it is present in the cache and if
-- the item is not expired.
--
-- The function will not delete the item from the cache.
lookup' :: (Eq k, Hashable k) => Cache k v -> k -> IO (Maybe v)
lookup' c k = runMaybeT $ item <$> MaybeT (lookupItem False k c)

-- | Lookup an item with the given key, and delete it if it is expired.
--
-- The function will only return a value if it is present in the cache and if
-- the item is not expired.
--
-- The function will eagerly delete the item from the cache if it is expired.
lookup :: (Eq k, Hashable k) => Cache k v -> k -> IO (Maybe v)
lookup c k = runMaybeT $ item <$> MaybeT (lookupItem True k c)

-- | Lookup an item with a given key in the 'STM' monad, given the current 'Monotonic' time.
--
-- STM variant of 'lookup' and 'lookup''
lookupSTM :: (Eq k, Hashable k) => Bool             -- ^ Whether or not to eagerly delete the item if its expired
                                -> k                -- ^ The key to lookup
                                -> Cache k v        -- ^ The cache
                                -> TimeSpec         -- ^ The current 'Monotonic' time, i.e. @getTime Monotonic@
                                -> STM (Maybe v)
lookupSTM f k c t = do
    mv <- lookupItemT f k c t
    return $! item <$> mv

insertItem :: (Eq k, Hashable k) => k -> CacheItem v -> Cache k v -> STM ()
insertItem k a c = writeTVar v =<< (HM.insert k a <$> readTVar v) where v = container c

-- | Insert an item in the cache, with an explicit expiration value, in the
-- 'STM' monad.
--
-- If the expiration value is 'Nothing', the item will never expire. The
-- default expiration value of the cache is ignored.
--
-- The expiration value is the absolute 'Monotonic' time the item expires. You
-- should manually construct the absolute 'Monotonic' time, as opposed to the
-- behaviour of 'insert''.
--
-- E.g.
--
-- > action :: Cache -> IO ()
-- > action c = do
-- >     t <- getTime Monotonic
-- >     let t' = t + (defaultExpiration c)
-- >     atomically $ insertSTM 0 0 c (Just t')
--
insertSTM :: (Eq k, Hashable k) => k -> v -> Cache k v -> Maybe TimeSpec -> STM ()
insertSTM k a c t = insertItem k (CacheItem a t) c

-- | Insert an item in the cache, with an explicit expiration value.
--
-- If the expiration value is 'Nothing', the item will never expire. The
-- default expiration value of the cache is ignored.
--
-- The expiration value is relative to the current 'Monotonic' time, i.e. it
-- will be automatically added to the result of @getTime Monotonic@.
insert' :: (Eq k, Hashable k) =>  Cache k v -> Maybe TimeSpec -> k -> v -> IO ()
insert' c Nothing k a  = atomically $ insertSTM k a c Nothing
insert' c (Just d) k a = atomically . insertSTM k a c =<< Just . (d +) <$> now

-- | Insert an item in the cache, using the default expiration value of
-- the cache.
insert :: (Eq k, Hashable k) => Cache k v -> k -> v -> IO ()
insert c = insert' c (defaultExpiration c)

-- | Get a value from cache. If not available from cache, use the provided action and update the cache.
-- Note that the cache check and conditional execution of the action is not one atomic action.
fetchWithCache :: (Eq k, Hashable k, MonadIO m) => Cache k v -> k -> (k -> m v) -> m v
fetchWithCache c k f = do
  mv <- liftIO $ lookup c k
  case mv of
    Just v -> return v
    Nothing -> do
       v <- f k
       liftIO $ insert c k v
       return v

-- | STM variant of 'keys'.
keysSTM :: Cache k v -> STM [k]
keysSTM c = HM.keys <$> readTVar (container c)

-- | Return all keys present in the cache.
keys :: Cache k v -> IO [k]
keys = atomically . keysSTM

now :: IO TimeSpec
now = getTime Monotonic

-- | Keeps elements that satify a predicate (used for cache invalidation).
-- Note that the predicate might be called for expired items.
filterWithKey :: (Eq k, Hashable k) => (k -> v -> Bool) -> Cache k v -> IO ()
filterWithKey f c = atomically $ writeTVar c' =<< (HM.filterWithKey (\k (CacheItem v _) -> f k v) <$> readTVar c') where c' = container c

-- | Delete all elements (cache invalidation).
purge :: (Eq k, Hashable k) => Cache k v -> IO ()
purge c = atomically $ writeTVar v HM.empty where v = container c

-- | STM variant of 'purgeExpired'.
--
-- The 'TimeSpec' argument should be the current 'Monotonic' time, i.e.
-- @getTime Monotonic@.
purgeExpiredSTM :: (Eq k, Hashable k) => Cache k v -> TimeSpec -> STM ()
purgeExpiredSTM c t = mapM_ (\k -> lookupItemT True k c t) =<< keysSTM c

-- | Delete all items that are expired.
--
-- This is one big atomic operation.
purgeExpired :: (Eq k, Hashable k) => Cache k v -> IO ()
purgeExpired c = (atomically . purgeExpiredSTM c) =<< now

-- | Returns the cache content as a list.
-- The third element of the tuple is the expiration date. Nothing means that it doesn't expire.
toList :: Cache k v -> IO [(k, v, Maybe TimeSpec)]
toList c = atomically $ do
  m <- readTVar $ container c
  let l = HM.toList m
  return $ map (\(k, (CacheItem v i)) -> (k, v, i)) l

-- $use
--
-- All operations are automically executed in the IO monad. The
-- underlying data structure is @Data.HashMap.Strict@.
--
-- First create a cache using 'newCache' and possibly a default
-- expiration value. Items can now be inserted using 'insert' and
-- 'insert''.
--
-- 'lookup' and 'lookup'' are used to query items. These functions
-- only return a value when the item is in the cache and it is not
-- expired. The 'lookup' function will automatically delete the
-- item if it is expired, while 'lookup'' won't delete the item.
--
-- Note that items are __not purged automatically__ in the background when they
-- expire. You have to manually call 'lookup' to purge a single item, or call
-- 'purgeExpired' to purge all expired items.
--
-- > >>> c <- newCache Nothing :: IO (Cache String String)
-- > >>> insert c "key" "value"
-- > >>> lookup c "key"
-- > Just "value"
-- > >>> delete c "key"
-- > >>> lookup c "key"
-- > Nothing