{-# LANGUAGE DeriveDataTypeable  #-}
{-# LANGUAGE FlexibleContexts    #-}
{-# LANGUAGE ScopedTypeVariables #-}

-- | Module containing circuit breaker functionality, which is the ability to open a circuit once a number of failures have occurred, thereby preventing later calls from attempting to make unsuccessful calls.
-- | Often this is useful if the underlying service were to repeatedly time out, so as to reduce the number of calls inflight holding up upstream callers.
module Glue.CircuitBreaker(
    CircuitBreakerOptions
  , CircuitBreakerState
  , CircuitBreakerException(..)
  , combineCircuitBreakerStates
  , isCircuitBreakerOpen
  , isCircuitBreakerClosed
  , defaultCircuitBreakerOptions
  , circuitBreaker
  , maxBreakerFailures
  , resetTimeoutSecs
  , breakerDescription
) where

import           Control.Exception.Lifted
import           Control.Monad.Base
import           Control.Monad.Trans.Control
import           Data.IORef.Lifted
import           Data.Time.Clock.POSIX
import           Data.Typeable
import           Glue.Types

-- | Options for determining behaviour of circuit breaking services.
data CircuitBreakerOptions = CircuitBreakerOptions {
    maxBreakerFailures :: Int        -- ^ How many times the underlying service must fail in the given window before the circuit opens.
  , resetTimeoutSecs   :: Int        -- ^ The window of time in which the underlying service must fail for the circuit to open.
  , breakerDescription :: String     -- ^ Description that is attached to the failure so as to identify the particular circuit.
}

-- | Defaulted options for the circuit breaker with 3 failures over 60 seconds.
defaultCircuitBreakerOptions :: CircuitBreakerOptions
defaultCircuitBreakerOptions = CircuitBreakerOptions { maxBreakerFailures = 3, resetTimeoutSecs = 60, breakerDescription = "Circuit breaker open." }

-- | Status indicating if the circuit is open.
data BreakerStatus = BreakerClosed Int | BreakerOpen Int deriving (Eq, Show)

-- | Representation of the state the circuit breaker is currently in.
data CircuitBreakerState = CircuitBreakerState [IORef BreakerStatus]

-- | Exception thrown when the circuit is open.
data CircuitBreakerException = CircuitBreakerException String deriving (Eq, Show, Typeable)
instance Exception CircuitBreakerException

-- | Combines multiple states together.
combineCircuitBreakerStates :: (Foldable t) => t CircuitBreakerState -> CircuitBreakerState
combineCircuitBreakerStates states = CircuitBreakerState $ foldMap (\(CircuitBreakerState refs) -> refs) states

-- | Determines if a specific status is open.
isStatusOpen :: BreakerStatus -> Bool
isStatusOpen (BreakerOpen _)   = True
isStatusOpen (BreakerClosed _) = False

-- | Determines if a specific status is closed.
isStatusClosed :: BreakerStatus -> Bool
isStatusClosed (BreakerOpen _)   = False
isStatusClosed (BreakerClosed _) = True

-- | Determines if a circuit breaker is open.
isCircuitBreakerOpen :: (MonadBaseControl IO m) => CircuitBreakerState -> m Bool
isCircuitBreakerOpen (CircuitBreakerState states) = fmap or $ traverse (\ref -> fmap isStatusOpen $ readIORef ref) states

-- | Determines if a circuit breaker is closed.
isCircuitBreakerClosed :: (MonadBaseControl IO m) => CircuitBreakerState -> m Bool
isCircuitBreakerClosed (CircuitBreakerState states) = fmap and $ traverse (\ref -> fmap isStatusClosed $ readIORef ref) states

-- TODO: Check that values within m aren't lost on a successful call.
-- | Circuit breaking services can be constructed with this function.
circuitBreaker :: (MonadBaseControl IO m, MonadBaseControl IO n)
               => CircuitBreakerOptions       -- ^ Options for specifying the circuit breaker behaviour.
               -> BasicService m a b          -- ^ Service to protect with the circuit breaker.
               -> n (CircuitBreakerState, BasicService m a b)
circuitBreaker options service =
  let retrieveTime                = liftBase $ round `fmap` getPOSIXTime
      failureMax                  = maxBreakerFailures options
      callIfClosed request ref    = bracketOnError (return ()) (\_ -> incErrors ref) (\_ -> service request)
      canaryCall request ref      = do
                                      result <- callIfClosed request ref
                                      writeIORef ref $ BreakerClosed 0
                                      return result
      incErrors ref               = do
                                      currentTime <- retrieveTime
                                      atomicModifyIORef' ref $ \status -> case status of
                                        (BreakerClosed errorCount) -> (if errorCount >= failureMax then BreakerOpen (currentTime + (resetTimeoutSecs options)) else BreakerClosed (errorCount + 1), ())
                                        other                             -> (other, ())
      failingCall                 = throw $ CircuitBreakerException $ breakerDescription options
      callIfOpen request ref      = do
                                      currentTime <- retrieveTime
                                      canaryRequest <- atomicModifyIORef' ref $ \status -> case status of
                                                              (BreakerClosed _)  -> (status, False)
                                                              (BreakerOpen time) -> if currentTime > time then ((BreakerOpen (currentTime + (resetTimeoutSecs options))), True) else (status, False)
                                      if canaryRequest then canaryCall request ref else failingCall
      breakerService ref request  = do
                                      status <- readIORef ref
                                      case status of
                                        (BreakerClosed _)  -> callIfClosed request ref
                                        (BreakerOpen _)    -> callIfOpen request ref

  in do
        ref <- newIORef $ BreakerClosed 0
        return (CircuitBreakerState [ref], breakerService ref)