{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ScopedTypeVariables #-}

{-|
    Module      : Network.Wai.Middleware.Rollbar
    Description : WAI middleware for interfacing with Rollbar
    Copyright   : (c) Hardy Jones, 2017
    License     : BSD3
    Maintainer  : jones3.hardy@gmail.com
    Stability   : experimental

    Provides middleware for communicating with Rollbar.

    Currently has middleware for sending all server errors to Rollbar.
    More to come shortly.
-}

module Network.Wai.Middleware.Rollbar (Settings(..), exceptions, requests) where

import Control.Concurrent (forkIO)
import Control.Exception
    (Handler(Handler), SomeException, catches, displayException, throwIO)
import Control.Monad      (when)

import Data.Aeson   (ToJSON, defaultOptions, genericToEncoding, toEncoding)
import Data.Functor (void)
import Data.Maybe   (fromMaybe)
import Data.Time    (getCurrentTime)
import Data.UUID.V4 (nextRandom)

import GHC.Generics (Generic)
import GHC.TypeLits (Symbol)

import Network.HostName          (getHostName)
import Network.HTTP.Client       (HttpException)
import Network.HTTP.Simple
    ( JSONException
    , Request
    , defaultRequest
    , httpNoBody
    , setRequestBodyJSON
    , setRequestHost
    , setRequestIgnoreStatus
    , setRequestMethod
    , setRequestPath
    , setRequestPort
    , setRequestSecure
    )
import Network.HTTP.Types.Status
    (Status(Status), statusCode, statusIsServerError, statusMessage)
import Network.Wai               (Middleware, ResponseReceived)

import System.Environment (getExecutablePath)
import System.IO          (hPutStrLn, stderr)

import qualified Data.ByteString    as BS
import qualified Data.Text          as T
import qualified Data.Text.Encoding as TE
import qualified Network.Wai        as NW
import qualified Rollbar.Item       as RI

-- | Set up the middleware properly
--  The `headers` are  what you want removed from
--  the request headers sent to Rollbar.
data Settings (headers :: [Symbol])
    = Settings
        { accessToken :: RI.AccessToken
        -- ^ Should have a scope "post_server_item".
        , branch :: Maybe RI.Branch
        -- ^ Should be the branch of the running application.
        --
        -- Will default to `master` if not set.
        , codeVersion :: Maybe RI.CodeVersion
        -- ^ Should be the version of the running application.
        , environment :: RI.Environment
        -- ^ Should be something meaningful to your program
        --
        -- E.g. "development" or "production"
        }

-- | Middleware that catches exceptions, sends an item to Rollbar,
--  and rethrows the exception.
--
--  Sends additional metadata including the request information.
exceptions :: RI.RemoveHeaders headers => Settings headers -> Middleware
exceptions settings app req handler = app req handler `catches`
    [ Handler $ handleSomeException settings req
    ]

-- | Middleware that watches responses
--  and sends an item to Rollbar if it is a server error (5xx).
--
--  Sends additional metadata including the request information.
requests :: RI.RemoveHeaders headers => Settings headers -> Middleware
requests settings app req handler' = app req handler
    where
    handler :: NW.Response -> IO ResponseReceived
    handler res = do
        _ <- forkIO $ handle500 settings req res
        handler' res

handle500
    :: RI.RemoveHeaders headers
    => Settings headers
    -> NW.Request
    -> NW.Response
    -> IO ()
handle500 settings req res =
    when (statusIsServerError $ NW.responseStatus res) (send settings req res)
        `catches`
            [ Handler handleHttpException
            , Handler handleJSONException
            ]

send
    :: forall headers
    . RI.RemoveHeaders headers
    => Settings headers
    -> NW.Request
    -> NW.Response
    -> IO ()
send settings req res = do
    rReq <- mkRollbarRequest settings req payload messageBody
    void $ httpNoBody rReq
    where
    Status{..} = NW.responseStatus res
    messageBody = RI.MessageBody <$> myDecodeUtf8 statusMessage
    referer = myDecodeUtf8 =<< NW.requestHeaderReferer req
    range = myDecodeUtf8 =<< NW.requestHeaderRange req
    userAgent = myDecodeUtf8 =<< NW.requestHeaderUserAgent req
    payload = RequestPayload
        { statusMessage = myDecodeUtf8' statusMessage
        , ..
        }

handleHttpException :: HttpException -> IO ()
handleHttpException e = do
    hPutStrLn stderr "Ran into an exception while sending a request to Rollbar:"
    hPutStrLn stderr $ displayException e

handleJSONException :: JSONException -> IO ()
handleJSONException e = do
    hPutStrLn stderr "Ran into an exception while parsing JSON response from Rollbar:"
    hPutStrLn stderr $ displayException e

handleSomeException
    :: forall a headers
    . RI.RemoveHeaders headers
    => Settings headers
    -> NW.Request
    -> SomeException
    -> IO a
handleSomeException settings req e = do
    rReq <- mkRollbarRequest settings req payload messageBody
    _ <- httpNoBody rReq
    throwIO e
    where
    exception = T.pack $ displayException e
    messageBody = Just $ RI.MessageBody $
        T.intercalate " "
            [ "Uncaught exception at:"
            , myDecodeUtf8' $ NW.requestMethod req
            , T.intercalate "/" $ NW.pathInfo req
            ]
    referer = myDecodeUtf8 =<< NW.requestHeaderReferer req
    range = myDecodeUtf8 =<< NW.requestHeaderRange req
    userAgent = myDecodeUtf8 =<< NW.requestHeaderUserAgent req
    payload = ExceptionPayload {..}

mkRollbarRequest
    :: forall headers
    . RI.RemoveHeaders headers
    => Settings headers
    -> NW.Request
    -> Payload
    -> Maybe RI.MessageBody
    -> IO Request
mkRollbarRequest Settings{..} req payload messageBody = do
    uuid <- Just . RI.UUID4 <$> nextRandom
    timestamp <- Just <$> getCurrentTime
    host <- Just <$> getHostName
    root <- Just . RI.Root . T.pack <$> getExecutablePath
    let request = Just RI.Request {..}
    let server = Just RI.Server { RI.serverCodeVersion = codeVersion, .. }
    let itemData = (RI.error environment messageBody payload)
            { RI.codeVersion, RI.request, RI.server, RI.timestamp, RI.uuid }
    pure $ rollbarRequest RI.Item{..}
    where
    headers :: RI.MissingHeaders headers
    headers = RI.MissingHeaders $ NW.requestHeaders req
    rawBody = ""
    get = RI.Get $ NW.queryString req
    method = RI.Method $ NW.requestMethod req
    queryString = RI.QueryString $ NW.rawQueryString req
    url = RI.URL (NW.requestHeaderHost req, NW.pathInfo req)
    userIP = RI.IP $ NW.remoteHost req

myDecodeUtf8 :: BS.ByteString -> Maybe T.Text
myDecodeUtf8 = either (const Nothing) Just . TE.decodeUtf8'

myDecodeUtf8' :: BS.ByteString -> T.Text
myDecodeUtf8' = fromMaybe "" . myDecodeUtf8

rollbarRequest
    :: (RI.RemoveHeaders headers, ToJSON a)
    => RI.Item a headers
    -> Network.HTTP.Simple.Request
rollbarRequest payload =
    setRequestMethod "POST"
    . setRequestSecure True
    . setRequestHost "api.rollbar.com"
    . setRequestPort 443
    . setRequestPath "api/1/item/"
    . setRequestBodyJSON payload
    . setRequestIgnoreStatus
    $ defaultRequest

data Payload
    = RequestPayload
        { statusCode    :: Int
        , statusMessage :: T.Text
        , userAgent     :: Maybe T.Text
        , range         :: Maybe T.Text
        , referer       :: Maybe T.Text
        }
    | ExceptionPayload
        { exception :: T.Text
        , userAgent :: Maybe T.Text
        , range     :: Maybe T.Text
        , referer   :: Maybe T.Text
        }
    deriving (Generic, Show)

instance ToJSON Payload where
    toEncoding = genericToEncoding defaultOptions