{-# LANGUAGE DataKinds #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
-- |
-- Module      : SMSAero.API
-- Copyright   : (c) 2015, GetShopTV
-- License     : BSD3
-- Maintainer  : nickolay@getshoptv.com
-- Stability   : experimental
--
-- This module describes SMSAero API and defines corresponding types.
module SMSAero.API (
  -- * API
  SMSAeroAPI,
  SendApi,
  StatusApi,
  -- * Combinators
  SmsAeroJson,
  AnswerJson,
  RequireAuth,
  RequiredQueryParam,
  SmsAeroGet,
  -- * Types
  SMSAeroAuth(..),
  Signature(..),
  MessageId(..),
  MessageBody(..),
  Phone(..),
  SMSAeroDate(..),
  -- * Responses
  SmsAeroResponse(..),
  SendResponse(..),
  StatusResponse(..),
  BalanceResponse(..),
  SendersResponse(..),
  SignResponse(..),
) where

import Data.Aeson
import Data.Proxy

import Data.Time (UTCTime(UTCTime))
import Data.Time.Calendar (fromGregorian)
import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds, posixSecondsToUTCTime)

import Data.Text (Text)
import qualified Data.Text as Text

import Control.Applicative
import GHC.TypeLits (Symbol, KnownSymbol)
import Text.Read (readMaybe)

import Servant.API
import Servant.Client
import Servant.Docs
import Control.Lens (over, (|>))

import GHC.Generics

-- | Content type for SMSAero JSON answer (it has JSON body but "text/plain" Content-Type).
data SmsAeroJson

instance Accept SmsAeroJson where
  contentType _ = contentType (Proxy :: Proxy PlainText)

instance FromJSON a => MimeUnrender SmsAeroJson a where
  mimeUnrender _ = mimeUnrender (Proxy :: Proxy JSON)

instance ToJSON a => MimeRender SmsAeroJson a where
  mimeRender _ = mimeRender (Proxy :: Proxy JSON)

-- | Like 'QueryParam', but always required.
data RequiredQueryParam (sym :: Symbol) a

instance (HasClient sub, KnownSymbol sym, ToText a) => HasClient (RequiredQueryParam sym a :> sub) where
  type Client (RequiredQueryParam sym a :> sub) = a -> Client sub
  clientWithRoute _ req baseurl param = clientWithRoute (Proxy :: Proxy (QueryParam sym a :> sub)) req baseurl (Just param)

instance (KnownSymbol sym, ToParam (QueryParam sym a), HasDocs sub) => HasDocs (RequiredQueryParam sym a :> sub) where
  docsFor _ (endpoint, action) =
    docsFor subP (endpoint, action')

    where subP = Proxy :: Proxy sub
          paramP = Proxy :: Proxy (QueryParam sym a)
          action' = over params (|> toParam paramP) action

-- | SMSAero sender's signature. This is used for the "from" field.
newtype Signature = Signature { getSignature :: Text } deriving (Show, FromJSON, ToJSON, ToText, FromText)

-- | SMSAero sent message id.
newtype MessageId = MessageId Integer deriving (Show, FromJSON, ToJSON, ToText, FromText)

-- | SMSAero message body.
newtype MessageBody = MessageBody Text deriving (Show, FromJSON, ToJSON, ToText, FromText)

-- | SMSAero authentication data.
data SMSAeroAuth = SMSAeroAuth
  { authUser      :: Text   -- ^ Username.
  , authPassword  :: Text   -- ^ MD5 hash of a password.
  }

instance FromJSON SMSAeroAuth where
  parseJSON (Object o) = SMSAeroAuth
    <$> o .: "user"
    <*> o .: "password"
  parseJSON _ = empty

instance ToJSON SMSAeroAuth where
  toJSON SMSAeroAuth{..} = object
    [ "user"     .= authUser
    , "password" .= authPassword ]

-- | Phone number.
newtype Phone = Phone { getPhone :: Integer } deriving (Show, ToText, FromText)

-- | Date. Textually @SMSAeroDate@ is represented as a number of seconds since 01 Jan 1970.
newtype SMSAeroDate = SMSAeroDate { getSMSAeroDate :: UTCTime } deriving (Show)

instance ToText SMSAeroDate where
  toText (SMSAeroDate dt) = Text.pack (show (utcTimeToPOSIXSeconds dt))

instance FromText SMSAeroDate where
  fromText s = do
    n <- fromInteger <$> readMaybe (Text.unpack s)
    return (SMSAeroDate (posixSecondsToUTCTime n))

-- | SMSAero authentication credentials.
data RequireAuth

instance HasClient sub => HasClient (RequireAuth :> sub) where
  type Client (RequireAuth :> sub) = SMSAeroAuth -> Client sub

  clientWithRoute _ req baseurl SMSAeroAuth{..} =
    clientWithRoute
      (Proxy :: Proxy (RequiredQueryParam "user"     Text :>
                       RequiredQueryParam "password" Text :>
                       sub))
      req
      baseurl
      authUser
      authPassword

instance HasDocs sub => HasDocs (RequireAuth :> sub) where
  docsFor _ (endpoint, action) =
    docsFor subP (endpoint, action')

    where subP = Proxy :: Proxy sub
          userP = DocQueryParam "user"
                    ["alice@example.com", "bob@example.com"]
                    "SMSAero username (email) for authentication."
                    Normal
          passP = DocQueryParam "password"
                    ["5f4dcc3b5aa765d61d8327deb882cf99", "d8578edf8458ce06fbc5bb76a58c5ca4"]
                    "MD5 hash of a password."
                    Normal
          action' = over params ((|> passP) . (|> userP)) action

-- | Implicit parameter that tells SMSAero to respond with JSON.
data AnswerJson

instance HasClient sub => HasClient (AnswerJson :> sub) where
    type Client (AnswerJson :> sub) = Client sub
    clientWithRoute _ req baseurl = clientWithRoute (Proxy :: Proxy (RequiredQueryParam "answer" Text :> sub)) req baseurl "json"

instance HasDocs sub => HasDocs (AnswerJson :> sub) where
  docsFor _ (endpoint, action) = docsFor subP (endpoint, action')
    where
      subP = Proxy :: Proxy sub
      answerP = DocQueryParam "answer"
                  ["json"]
                  "When present makes SMSAero REST API to respond with JSON."
                  Normal
      action' = over params (|> answerP) action

-- | Regular SMSAero GET API.
type SmsAeroGet a = Get '[SmsAeroJson] (SmsAeroResponse a)

-- | SMSAero API.
type SMSAeroAPI = RequireAuth :> AnswerJson :>
      ("send"     :> SendApi
  :<|> "status"   :> StatusApi
  :<|> "balance"  :> SmsAeroGet BalanceResponse
  :<|> "senders"  :> SmsAeroGet SendersResponse
  :<|> "sign"     :> SmsAeroGet SignResponse)

-- | SMSAero API to send a message.
type SendApi =
  RequiredQueryParam "to"   Phone       :>
  RequiredQueryParam "text" MessageBody :>
  RequiredQueryParam "from" Signature   :>
  QueryParam "date" SMSAeroDate :>
  SmsAeroGet SendResponse

instance ToParam (QueryParam "to" Phone) where
  toParam _ = DocQueryParam "to"
                ["74951234567"]
                "Recipient phone number."
                Normal

instance ToParam (QueryParam "text" MessageBody) where
  toParam _ = DocQueryParam "text"
                ["Hello, world!"]
                "Message content."
                Normal

instance ToParam (QueryParam "from" Signature) where
  toParam _ = DocQueryParam "from"
                ["My Company"]
                "Sender's signature."
                Normal

instance ToParam (QueryParam "date" SMSAeroDate) where
  toParam _ = DocQueryParam "date"
                [show (utcTimeToPOSIXSeconds (UTCTime (fromGregorian 2015 01 31) 0))]
                "Requested datetime of delivery as number of seconds since 01 Jan 1970."
                Normal

-- | SMSAero API to check message status.
type StatusApi = RequiredQueryParam "id" MessageId :> SmsAeroGet StatusResponse

instance ToParam (QueryParam "id" MessageId) where
  toParam _ = DocQueryParam "id"
                ["12345"]
                "Message ID, returned previously by SMSAero."
                Normal

-- | Every SMSAero response is either rejected or provides some info.
data SmsAeroResponse a
  = ResponseOK a        -- ^ Some useful payload.
  | ResponseReject Text -- ^ Rejection reason.
  deriving (Show, Generic)

-- | SMSAero response to a send request.
data SendResponse
  = SendAccepted MessageId  -- ^ Message accepted.
  | SendNoCredits           -- ^ No credits to send a message.
  deriving (Show, Generic)

instance ToSample (SmsAeroResponse SendResponse) (SmsAeroResponse SendResponse) where
  toSamples _ =
    [ ("When message is sent successfully.", ResponseOK (SendAccepted (MessageId 12345)))
    , ("When SMSAero account does not have enough credit.", ResponseOK SendNoCredits)
    , ("When message sender is incorrect.", ResponseReject "incorrect sender name") ]

-- | SMSAero response to a status request.
data StatusResponse
  = StatusDeliverySuccess   -- ^ Message is successfully delivered.
  | StatusDeliveryFailure   -- ^ Message delivery has failed.
  | StatusSmscSubmit        -- ^ Message submitted to SMSC.
  | StatusSmscReject        -- ^ Message rejected by SMSC.
  | StatusQueue             -- ^ Message queued.
  | StatusWaitStatus        -- ^ Wait for message status.
  deriving (Enum, Bounded, Show, Generic)

instance ToSample (SmsAeroResponse StatusResponse) (SmsAeroResponse StatusResponse) where
  toSamples _ =
    [ ("When message has been delivered successfully.", ResponseOK StatusDeliverySuccess)
    , ("When message has been queued.", ResponseOK StatusQueue) ]

-- | SMSAero response to a balance request.
-- This is a number of available messages to send.
newtype BalanceResponse = BalanceResponse Double deriving (Show)

instance ToSample (SmsAeroResponse BalanceResponse) (SmsAeroResponse BalanceResponse) where
  toSamples _ =
    [ ("Just balance.", ResponseOK (BalanceResponse 247))
    , ("When auth credentials are incorrect.", ResponseReject "incorrect user or password") ]

-- | SMSAero response to a senders request.
-- This is just a list of available signatures.
newtype SendersResponse = SendersResponse [Signature] deriving (Show, FromJSON, ToJSON)

instance ToSample (SmsAeroResponse SendersResponse) (SmsAeroResponse SendersResponse) where
  toSample _ = Just (ResponseOK (SendersResponse [Signature "TEST", Signature "My Company"]))

-- | SMSAero response to a sign request.
data SignResponse
  = SignApproved  -- ^ Signature is approved.
  | SignRejected  -- ^ Signature is rejected.
  | SignPending   -- ^ Signature is pending.
  deriving (Enum, Bounded, Show, Generic)

instance ToSample (SmsAeroResponse SignResponse) (SmsAeroResponse SignResponse) where
  toSamples _ =
    [ ("When a new signature is approved.", ResponseOK SignApproved)
    , ("When a new signature is rejected.", ResponseOK SignRejected) ]

instance FromJSON a => FromJSON (SmsAeroResponse a) where
  parseJSON (Object o) = do
    result :: Maybe Text <- o .:? "result"
    case result of
      Just "reject" -> ResponseReject <$> o .: "reason"
      _ -> ResponseOK <$> parseJSON (Object o)
  parseJSON j = ResponseOK <$> parseJSON j

instance ToJSON a => ToJSON (SmsAeroResponse a) where
  toJSON (ResponseOK x) = toJSON x
  toJSON (ResponseReject reason) = object
    [ "result" .= ("reject" :: Text)
    , "reason" .= reason ]

instance FromJSON SendResponse where
  parseJSON (Object o) = do
    result :: Text <- o .: "result"
    case result of
      "accepted"    -> SendAccepted <$> o .: "id"
      "no credits"  -> pure SendNoCredits
      _ -> empty
  parseJSON _ = empty

instance ToJSON SendResponse where
  toJSON (SendAccepted n) = object
    [ "result" .= ("accepted" :: Text)
    , "id"     .= toJSON n ]
  toJSON SendNoCredits = object
    [ "result" .= ("no credits" :: Text)]

-- | Helper to define @fromText@ matching @toText@.
boundedFromText :: (Enum a, Bounded a, ToText a) => Text -> Maybe a
boundedFromText = flip lookup xs
  where
    vals = [minBound..maxBound]
    xs = zip (map toText vals) vals

instance FromText StatusResponse where
  fromText = boundedFromText

instance ToText StatusResponse where
  toText StatusDeliverySuccess  = "delivery success"
  toText StatusDeliveryFailure  = "delivery failure"
  toText StatusSmscSubmit       = "smsc submit"
  toText StatusSmscReject       = "smsc reject"
  toText StatusQueue            = "queue"
  toText StatusWaitStatus       = "wait status"

instance FromJSON StatusResponse where
  parseJSON (Object o) = do
    result :: Text <- o .: "result"
    maybe empty pure (fromText result)
  parseJSON _ = empty

instance ToJSON StatusResponse where
  toJSON status = object [ "result" .= toText status ]

instance FromJSON BalanceResponse where
  parseJSON (Object o) = BalanceResponse <$> o .: "balance"
  parseJSON _ = empty

instance ToJSON BalanceResponse where
  toJSON (BalanceResponse n) = object [ "balance" .= n ]

instance ToText SignResponse where
  toText SignApproved = "approved"
  toText SignRejected = "rejected"
  toText SignPending  = "pending"

instance FromText SignResponse where
  fromText = boundedFromText

instance FromJSON SignResponse where
  parseJSON (Object o) = do
    accepted :: Text <- o .: "accepted"
    maybe empty pure (fromText accepted)
  parseJSON _ = empty

instance ToJSON SignResponse where
  toJSON s = object [ "accepted" .= toText s ]