{-# LANGUAGE OverloadedStrings #-}
-------------------------------------------------------------------------------
-- |
-- Module      : Web.HZulip
-- Copyright   : Pedro Tacla Yamada
-- License     : MIT (see LICENSE)
--
-- Maintainer  : Pedro Tacla Yamada <tacla.yamada@gmail.com>
-- Stability   : unstable
-- Portability : unportable
--
-- A Wrapper library for the Zulip API. Works on top of a 'ReaderT' monad
-- transformer, holding a 'ZulipOptions' object, which should hold the
-- state and configuration for the API client.
--
-- Using the library is made easier through a set of helper functions. This
-- design is more concise and than passing around configuration variables;
-- one could easily bypass it with the use of 'runZulip', though that isn't
-- recommended.
--
-- Examples are available at the github repository for this project at:
-- https://github.com/yamadapc/hzulip
module Web.HZulip ( Event(..)
                  , Message(..)
                  , Queue(..)
                  , User(..)
                  , ZulipOptions(..)
                  , ZulipM
                  , EventCallback
                  , MessageCallback
                  , addSubscriptions
                  , addAllSubscriptions
                  , defaultBaseUrl
                  , eventTypes
                  , getEvents
                  , getStreams
                  , getStreamSubscribers
                  , getSubscriptions
                  , onNewEvent
                  , onNewMessage
                  , registerQueue
                  , removeSubscriptions
                  , runZulip
                  , sendMessage
                  , sendPrivateMessage
                  , sendStreamMessage
                  , sinkZulipMessages
                  , sourceZulipEvents
                  , sourceZulipMessages
                  , withZulip
                  , withZulipCreds
                  , zulipOptions

                  , lift
                  , ask
                  )
  where

import Control.Arrow (second)
import Control.Concurrent.STM (TBQueue, atomically, writeTBQueue)
import Control.Lens ((^..))
import Control.Monad (void)
import Control.Monad.Catch (catch, throwM)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans (lift)
import Control.Monad.Trans.Reader (ask, runReaderT)
import Data.Aeson (decode)
import Data.Aeson.Lens (key, values, _String)
import qualified Data.ByteString.Lazy as BL (ByteString)
import qualified Data.ByteString.Char8 as C (pack)
import qualified Data.ByteString.Lazy.Char8 as CL (unpack)
import Data.Conduit (Sink, Source, await)
import Data.Conduit.Async (gatherFrom)
import Data.List (intercalate)
import Data.Text as T (Text, unpack)
import Data.Text.Encoding as T (encodeUtf8)
import Network.HTTP.Client (Request, HttpException(..), applyBasicAuth, httpLbs,
                            method, newManager, parseUrl, responseBody,
                            setQueryString)
import Network.HTTP.Client.MultipartFormData (formDataBody, partBS)
import Network.HTTP.Client.TLS (tlsManagerSettings)
import Network.HTTP.Types (Method, methodGet, methodPatch, methodPost)

import Web.HZulip.Types as ZT

-- Public functions:
-------------------------------------------------------------------------------

-- |
-- Helper for creating a `ZulipOptions` object with the `baseUrl` set to
-- `defaultBaseUrl`
zulipOptions :: String -> String -> IO ZulipOptions
zulipOptions e k = do
    manager <- newManager tlsManagerSettings
    return $ ZulipOptions e k defaultBaseUrl manager

-- |
-- Helper to run Actions in the Zulip Monad
runZulip :: ZulipM a -> ZulipOptions -> IO a
runZulip = runReaderT

-- |
-- Flipped version of 'runZulip'
withZulip :: ZulipOptions -> ZulipM a -> IO a
withZulip = flip runZulip

-- |
-- Helper for creating a minimal 'ZulipOptions' object and running an action
-- in the 'ZulipM' monad
withZulipCreds :: String -> String -> ZulipM a -> IO a
withZulipCreds e k action = do
    opts <- zulipOptions e k
    runZulip action opts

-- |
-- The default zulip API URL
defaultBaseUrl :: String
defaultBaseUrl = "https://api.zulip.com/v1"

-- |
-- The list of all avaiable event types
eventTypes :: [String]
eventTypes = ["message", "subscriptions", "realm_user", "pointer"]

-- |
-- This wraps `POST https://api.zulip.com/v1/messages` with a nicer root
-- API. Simpler helpers for each specific case of this somewhat overloaded
-- endpoint will also be provided in the future.
--
-- It takes the message `mtype`, `mrecipients`, `msubject` and `mcontent`
-- and returns the created message's `id` in the `ZulipM` monad.
sendMessage :: String -> [String] -> String -> String -> ZulipM Int
sendMessage mtype mrecipients msubject mcontent = do
    let form = [ ("type"   , mtype)
               , ("content", mcontent)
               , ("to"     , show mrecipients)
               , ("subject", msubject)
               ]

    body <- zulipMakeRequest Messages methodPost form >>= decodeResponse
    let Just mid = responseMessageId body in return mid

-- |
-- Helper for sending private messages. Takes the list of recipients and
-- the message's content.
sendPrivateMessage :: [String] -> String -> ZulipM Int
sendPrivateMessage mrs = sendMessage "private" mrs ""

-- |
-- Helper for sending stream messages. Takes the stream name, the subject
-- and the message.
sendStreamMessage :: String -> String -> String -> ZulipM Int
sendStreamMessage s = sendMessage "stream" [s]

-- |
-- This registers a new event queue with the zulip API. It's a lower level
-- function, which shouldn't be used unless you know what you're doing. It
-- takes a list of names of the events you want to listen for and whether
-- you'd like for the content to be rendered in HTML format
-- (if you set the last parameter to `False` it will be kept as typed, in
-- markdown format)
registerQueue :: [String] -> Bool -> ZulipM Queue
registerQueue evTps mdn = do
    let form = [ ("event_types"   , show evTps)
               , ("apply_markdown", if mdn then "true" else "false")
               ]

    body <- zulipMakeRequest Register methodPost form >>= decodeResponse

    let Just qid = responseQueueId body
        Just lid = responseLastEventId body
      in return $ Queue qid lid

-- |
-- Get a list of all the public streams
getStreams :: ZulipM [String]
getStreams = do
    r <- zulipMakeRequest Streams methodGet []
    return $ map T.unpack $ r ^.. key "streams" . values
                                . key "name" . _String

-- |
-- Get all the user emails subscribed to a stream
getStreamSubscribers :: String -> ZulipM [String]
getStreamSubscribers s = do
    r <- zulipMakeRequest' ("/streams/" ++ s ++ "/members") methodGet []
    return $ map T.unpack $ r ^.. key "subscribers" . values . _String

-- |
-- Get a list of the streams the client is currently subscribed to.
getSubscriptions :: ZulipM [String]
getSubscriptions = do
    r <- zulipMakeRequest Subscriptions methodGet []
    return $ map T.unpack $ r ^.. key "subscriptions" . values
                                . key "name" . _String

-- |
-- Subscribes the client to all available streams and returns all the
-- stream names
addAllSubscriptions :: ZulipM [String]
addAllSubscriptions = do
    ss <- getStreams
    addSubscriptions ss
    return ss

-- |
-- Add new Stream subscriptions to the client.
addSubscriptions :: [String] -> ZulipM ()
addSubscriptions sbs = do
    let sbs' = intercalate "," $ map (\s -> "{\"name\":" ++ show s ++ "}") sbs
        form = [ ("add", "[" ++ sbs' ++ "]") ]
    void $ zulipMakeRequest Subscriptions methodPatch form

-- |
-- Remove one or more Stream subscriptions from the client
removeSubscriptions :: [String] -> ZulipM ()
removeSubscriptions sbs = do
    let form = [ ("delete", show sbs ) ]
    void $ zulipMakeRequest Subscriptions methodPatch form

-- |
-- Fetches new set of events from a `Queue`.
getEvents :: Queue -> Bool -> ZulipM (Queue, [Event])
getEvents q b = do
    let qs = [ ("queue_id"     , queueId q)
             , ("last_event_id", show $ lastEventId q)
             , ("dont_block"   , if b then "true" else "false")
             ]

    body <- zulipMakeRequest Events methodGet qs >>= decodeResponse
    let Just evs = responseEvents body
        -- Get the last event id and pass it back with the `Queue`
        lEvId = maximum $ map eventId evs
      in return (q { lastEventId = lEvId }, evs)

-- |
-- Registers an event callback for specified events and keeps executing it
-- over events as they come in
onNewEvent :: [String] -> EventCallback -> ZulipM ()
onNewEvent etypes f = do
    q <- registerQueue etypes False
    -- We let it fail here, so that failures can be catched and handled by
    -- the user
    loop q
  where getEvents' q = catch (getEvents q False) (onTimeout q)
        loop q = do
            (q', evts) <- getEvents' q
            mapM_ f evts
            loop q'
        onTimeout q ResponseTimeout = getEvents' q
        onTimeout _ ex = throwM ex

-- |
-- Registers a callback to be executed whenever a message comes in. Will
-- loop forever
onNewMessage :: MessageCallback -> ZulipM ()
onNewMessage f = onNewEvent ["message"] $ \evt ->
  -- I could just pattern match here, as I did in other places and simply
  -- expect the Zulip API not to give us correct responses, but I think
  -- this is more reasonable.
  maybe (return ()) f (eventMessage evt)

-- Higher-level conduit interface:
-------------------------------------------------------------------------------

-- |
-- A sink representation of the zulip messaging API, takes a tuple with the
-- arguments for 'sendMessage' and sends it
sinkZulipMessages :: Sink (String, [String], String, String) ZulipM ()
sinkZulipMessages = loop
  where loop = await >>= maybe (return ())
                               (\(w, x, y, z) -> do
                                    void $ lift $ sendMessage w x y z
                                    loop)

-- |
-- Creates a conduit 'Source' of zulip events
sourceZulipEvents :: Int      -- ^ The size of the event buffer
                  -> [String] -- ^ A list of event types to subscribe to
                  -> Source ZulipM Event
sourceZulipEvents bufSize evts = gatherFrom bufSize $
    onNewEvent evts . zulipWriteTBQueueIO

-- |
-- Creates a conduit 'Source' of zulip messages
sourceZulipMessages :: Int -- ^ The size of the event buffer
                    -> Source ZulipM Message
sourceZulipMessages bufSize = gatherFrom bufSize $
    onNewMessage . zulipWriteTBQueueIO

-- Private functions:
-------------------------------------------------------------------------------

data Endpoint = Messages | Register | Events | Subscriptions | Streams

-- |
-- Key-value pair abstraction for working with querystrings or form-data
type RequestData = [(T.Text, String)]

-- |
-- Makes a request to some @Endpoint@ in the zulip API
zulipMakeRequest :: Endpoint -> Method -> RequestData -> ZulipM BL.ByteString
zulipMakeRequest e = zulipMakeRequest' (endpointSuffix e)

-- |
-- Makes a request to some untyped URL in the zulip API. Serializes the
-- data as a QueryString on GET requests and as form-data otherwise
zulipMakeRequest' :: String -> Method -> RequestData -> ZulipM BL.ByteString
zulipMakeRequest' u m d = do
    z <- ask
    req  <- liftIO $ parseUrl (clientBaseUrl z ++ u)
    req' <- prepareRequest d req m
    res  <- liftIO $ httpLbs req' { method = m } $ clientManager z
    return $ responseBody res

-- |
-- A helper for decoding a response in the Zulip monad
decodeResponse :: BL.ByteString -> ZulipM ZT.Response
decodeResponse b = case decode b of
    Just r -> if wasSuccessful r then return r
                                 else fail $ responseMsg r
    _ -> fail $ "Unexpected response from the Zulip API: " ++ CL.unpack b

-- |
-- Adds a QueryString or FormData body, represented by a list of tuples,
-- and authenticates the request, with the current zulip state's
-- credentials.
prepareRequest :: RequestData -> Request -> Method -> ZulipM Request
prepareRequest [] r _ = applyAuth r
prepareRequest d r m | m == methodGet =
    applyAuth $ setQueryString (map helper d) r
  where helper (k, v) = (encodeUtf8 k, Just $ C.pack v)
prepareRequest d r _ =
    applyAuth =<< formDataBody (map (uncurry partBS . second C.pack) d) r

-- |
-- Adds authentication to a 'Request' with the configuration in the 'ZulipM'
-- monad
applyAuth :: Request -> ZulipM Request
applyAuth req = do
      ZulipOptions e k _ _ <- ask
      return $ applyBasicAuth (C.pack e) (C.pack k) req

-- |
-- Returns `True` if a response indicates success
wasSuccessful :: ZT.Response -> Bool
wasSuccessful = (== ResponseSuccess) . responseResult

-- |
-- Gets the suffix for some endpoint
endpointSuffix :: Endpoint -> String
endpointSuffix Messages      = "/messages"
endpointSuffix Events        = "/events"
endpointSuffix Register      = "/register"
endpointSuffix Subscriptions = "/users/me/subscriptions"
endpointSuffix Streams       = "/streams"

-- |
-- Lifted IO version of 'writeTBQueue'
zulipWriteTBQueueIO :: TBQueue a -> a -> ZulipM ()
zulipWriteTBQueueIO q x = lift $ atomically $ writeTBQueue q x