{-# LANGUAGE BlockArguments #-}

module Hercules.Agent.Memo
  ( Memo,
    newMemo,
    query,
    doOnce,
    multiQuery,
  )
where

import Data.Map qualified as M
import Protolude hiding (atomically, bracket, throwIO)
import UnliftIO
  ( MonadUnliftIO,
    TVar,
    atomically,
    bracket,
    modifyTVar,
    newEmptyTMVarIO,
    newTVarIO,
    putTMVar,
    readTMVar,
    readTVar,
    throwIO,
    writeTVar,
  )

data Entry v
  = Result v
  | Promise (STM (Either SomeException v))

-- | An unbounded cache. No coherent function/action - passed by callers instead. Use with care.
--
-- * 'MonadUnliftIO'
-- * process multiple keys at once
-- * anti dogpiling
-- * input not restricted to key, for efficiency and practicality
newtype Memo k v = Memo (TVar (Map k (Entry v)))

newMemo :: (MonadIO m) => m (Memo k v)
newMemo = liftIO do
  Memo <$> newTVarIO M.empty

-- | Like 'query' but you pass the key through the lexical scope.
doOnce :: (Show k, Ord k, MonadUnliftIO m) => Memo k v -> k -> m v -> m v
doOnce memo key action = query memo (const action) key

query :: (Show k, Ord k, MonadUnliftIO m) => Memo k v -> (k -> m v) -> k -> m v
query memo handler k = do
  query'
    memo
    ( \m -> do
        unless (m == M.singleton k ()) do
          panic $ "query: key " <> show k <> " must be the only request"
        M.singleton k <$> handler k
    )
    k

query' :: (Show k, Ord k, MonadUnliftIO m) => Memo k v -> (Map k () -> m (Map k v)) -> k -> m v
query' memo handler k = do
  x <- multiQuery memo handler (M.singleton k ())
  case M.lookup k x of
    Nothing -> panic $ "query: key " <> show k <> " must occur in response"
    Just v -> pure v

-- | Perform a query such that no item is queried more than once.
multiQuery ::
  (Show k, Ord k, MonadUnliftIO m) =>
  Memo k v ->
  -- | Function to perform the query. This should always behave the same.
  (Map k () -> m (Map k v)) ->
  -- | The items to query
  Map k () ->
  -- | Deduplicated query action.
  m (Map k v)
multiQuery (Memo cacheVar) performQuery inputs = do
  -- Overview
  -- 1. Add promises to the map
  -- 2. Run the query for the new promises
  -- 3. Replace the promises by values so they're not removed by the bracket cleanup
  -- 4. Query the cache which now has the required entries
  multiQueryPromise <- newEmptyTMVarIO
  let makePromises = do
        M.mapWithKey \k _ -> Promise do
          mp <- readTMVar multiQueryPromise
          pure case M.lookup k mp of
            Nothing -> Left $ toException $ FatalError $ "multiQuery: input " <> show k <> " does not occur in query result"
            Just v -> Right v
      putPromises = do
        cache <- readTVar cacheVar
        let newInputs = M.difference inputs cache
            newPromises = makePromises newInputs
        writeTVar cacheVar (cache `M.union` newPromises)
        pure newPromises
      putResults rs = do
        cache <- readTVar cacheVar
        writeTVar cacheVar (fmap Result rs `M.union` cache)
      queryCache = do
        cache <- atomically $ readTVar cacheVar
        inputs & M.traverseWithKey \k _unit -> do
          case M.lookup k cache of
            Just (Result x) -> pure x
            Just (Promise x) -> do
              v' <- atomically x
              either throwIO pure v'
            Nothing -> panic $ "multiQuery: input " <> show k <> " was not saved to cache"
      clearPromises = \promises -> do
        modifyTVar cacheVar \c ->
          let cacheContainsPromise k _v = case M.lookup k c of
                Just (Promise _) -> True
                _ -> False
           in M.difference c (M.filterWithKey cacheContainsPromise promises)

  bracket
    (atomically putPromises)
    (atomically . clearPromises)
    \promises -> do
      when (not (null promises)) do
        response <- performQuery (void promises)
        atomically do
          putTMVar multiQueryPromise response
          putResults response
      queryCache
