{-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE OverloadedStrings #-}
-- |
--
-- Actions for working with any of Google's APIs
--
-- Note: this module may become a standalone package at some point.
--
module Network.Google.Api
    (
    -- * The Api monad
      Api
    , ApiError(..)
    , runApi
    , runApi_
    , throwApiError

    -- * HTTP-related types
    , URL
    , Path
    , Params

    -- * High-level requests
    , DownloadSink
    , getJSON
    , getSource
    , postJSON
    , putJSON

    -- * Lower-level requests
    , requestJSON
    , requestLbs

    -- * Api helpers
    , authorize
    , decodeBody

    -- * Request helpers
    , addHeader
    , allowStatus
    , setBody
    , setBodySource
    , setMethod

    -- * Re-exports
    , liftIO
    , throwError
    , catchError
    , sinkFile
    ) where

#if __GLASGOW_HASKELL__ < 710
import Control.Applicative ((<$>))
#endif

import Control.Monad.Except
import Control.Monad.Reader
import Control.Monad.Trans.Resource
import Data.Aeson (FromJSON(..), ToJSON(..), eitherDecode, encode)
import Data.ByteString (ByteString)
import Data.Conduit
import Data.Conduit.Binary (sinkFile)
import Data.Monoid ((<>))
import Data.Typeable
import GHC.Int (Int64)
import Network.HTTP.Conduit
    ( HttpException(..)
    , Request(..)
    , RequestBody(..)
    , Response(..)
    , Manager
    , http
    , httpLbs
    , newManager
    , parseUrl
    , requestBodySource
    , responseBody
    , setQueryString
    , tlsManagerSettings
    )
import Network.HTTP.Types
    ( Header
    , Method
    , Status
    , hAuthorization
    , hContentType
    )

import qualified Control.Exception as E
import qualified Data.ByteString.Char8 as C8
import qualified Data.ByteString.Lazy as BL

-- | Downloads use sinks for space efficiency and so that callers can implement
--   things like throttling or progress output themselves. If you just want to
--   download to a file, use the re-exported @'sinkFile'@
type DownloadSink a =
    ResumableSource (ResourceT IO) ByteString -> ResourceT IO a

data ApiError
    = HttpError HttpException -- ^ Exceptions raised by http-conduit
    | InvalidJSON String      -- ^ Failure to parse a response as JSON
    | GenericError String     -- ^ All other errors
    deriving Typeable

instance Show ApiError where
    show (HttpError ex) = "HTTP Exception: " <> show ex
    show (InvalidJSON msg) = "failure parsing JSON: " <> msg
    show (GenericError msg) = msg

instance E.Exception ApiError

-- | A transformer stack for providing the access token and rescuing errors
type Api = ReaderT (String, Manager) (ExceptT ApiError IO)

-- | Run an @Api@ computation with the given Access token
runApi :: String -- ^ OAuth2 access token
       -> Api a
       -> IO (Either ApiError a)
runApi token f = do
    manager <- newManager tlsManagerSettings
    runExceptT $ runReaderT f (token, manager)

-- | Like @runApi@ but discards the result and raises @ApiError@s as exceptions
runApi_ :: String -> Api a -> IO ()
runApi_ token f = either E.throw (const $ return ()) =<< runApi token f

-- | Abort an @Api@ computation with the given message
throwApiError :: String -> Api a
throwApiError = throwError . GenericError

type URL = String
type Path = String
type Params = [(ByteString, Maybe ByteString)]

-- | Make an authorized GET request for JSON
getJSON :: FromJSON a => URL -> Params -> Api a
getJSON url params = requestJSON url $ setQueryString params

-- | Make an authorized GET request, sending the response to the given sink
getSource :: URL -> Params -> DownloadSink a -> Api a
getSource url params withSource = do
    request <- setQueryString params <$> authorize url

    withManager' $ \manager -> do
        response <- http request manager
        withSource $ responseBody response

-- | Make an authorized POST request for JSON
postJSON :: (ToJSON a, FromJSON b) => URL -> Params -> a -> Api b
postJSON url params body =
    requestJSON url $
        addHeader (hContentType, "application/json") .
        setMethod "POST" .
        setQueryString params .
        setBody (encode body)

-- | Make an authorized PUT request for JSON
putJSON :: (ToJSON a, FromJSON b) => URL -> Params -> a -> Api b
putJSON url params body =
    requestJSON url $
        addHeader (hContentType, "application/json") .
        setMethod "PUT" .
        setQueryString params .
        setBody (encode body)

-- | Make an authorized request for JSON, first modifying it via the passed
--   function
requestJSON :: FromJSON a => URL -> (Request -> Request) -> Api a
requestJSON url modify = decodeBody =<< requestLbs url modify

-- | Make an authorized request, first modifying it via the passed function, and
--   returning the raw response content
requestLbs :: URL -> (Request -> Request) -> Api (Response BL.ByteString)
requestLbs url modify = do
    request <- authorize url

    withManager' $ httpLbs $ modify request

-- | Create an authorized request for the given URL
authorize :: URL -> Api Request
authorize url = do
    (token, _) <- ask

    request <- parseUrl' url

    let authorization = C8.pack $ "Bearer " <> token

    return $ addHeader (hAuthorization, authorization) request

addHeader :: Header -> Request -> Request
addHeader header request =
    request { requestHeaders = header:requestHeaders request }

setMethod :: Method -> Request -> Request
setMethod m request = request { method = m }

setBody :: BL.ByteString -> Request -> Request
setBody bs request = request { requestBody = RequestBodyLBS bs }

setBodySource :: Int64 -> Source (ResourceT IO) ByteString -> Request -> Request
setBodySource len source request =
    request { requestBody = requestBodySource len source }

-- | Modify the Request's status check to not treat the given status as an error
allowStatus :: Status -> Request -> Request
allowStatus status request =
    let original = checkStatus request
        override s r c
            | s == status = Nothing
            | otherwise = original s r c

    in request { checkStatus = override }

-- | Decode a JSON body, capturing failure as an @'ApiError'@
decodeBody :: FromJSON a => Response BL.ByteString -> Api a
decodeBody =
    either (throwError . InvalidJSON) return . eitherDecode . responseBody

parseUrl' :: URL -> Api Request
parseUrl' url = case parseUrl url of
    Just request -> return request
    Nothing -> throwApiError $ "Invalid URL: " <> url

withManager' :: (Manager -> ResourceT IO a) -> Api a
withManager' f = do
    (_, manager) <- ask

    result <- liftIO $ E.try $ runResourceT $ f manager

    either (throwError . HttpError) return result