{-# LANGUAGE RecordWildCards     #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TupleSections       #-}

-- | This module is best imported qualified.
-- Unless you are writing your own Oz endpoints, all you
-- will need for a normal application server is 'rsvp'.

module Network.Oz.Ticket
  ( rsvp
  , issue
  , reissue
  , parse
  ) where

import           Control.Monad          (liftM, void, when)
import           Control.Monad.IO.Class (MonadIO (..), liftIO)
import           Control.Applicative    ((<|>))
import           Data.Monoid            ((<>))
import           Crypto.Random
import           Data.Aeson             (Object (..), Value (..), object,
                                         toJSON)
import           Data.ByteString        (ByteString)
import qualified Data.ByteString        as BS
import qualified Data.ByteString.Base64 as B64
import           Data.List              (isInfixOf, nub)
import           Data.Maybe             (catMaybes, fromMaybe, isJust)
import           Data.Text              (Text)
import qualified Data.Text              as T
import           Data.Text.Encoding     (decodeUtf8)
import           Data.Time.Clock.POSIX  (POSIXTime, getPOSIXTime)

import           Network.Hawk.Server.Types
import qualified Network.Iron           as Iron
import           Network.Oz.JSON
import           Network.Oz.Types

-- | When the user authorizes the application access request, the
-- server issues an /rsvp/ which is an encoded string containing the
-- application identifier, the grant identifier, and an expiration.
--
-- This function generates the /rsvp/ string.
rsvp :: MonadIO m => OzAppId -> Maybe OzGrantId -> Key -> TicketOpts -> m (Maybe ByteString)
rsvp app grant (Key p) TicketOpts{..} = liftIO $ do
  now <- getPOSIXTime
  Iron.sealWith ticketOptsIron (Iron.password p) (envelope now)
  where
    envelope now = OzTicket
                   { ozTicketExp = now + ticketOptsRsvpTtl
                   , ozTicketApp = app
                   , ozTicketGrant = grant
                   , ozTicketUser = Nothing
                   , ozTicketScope = []
                   , ozTicketDelegate = False
                   , ozTicketDlg = Nothing
                   }

-- | Issues a new application or user ticket.
issue :: MonadIO m => Key -> OzApp -> Maybe OzGrant -> TicketOpts -> m (Either String OzSealedTicket)
issue p app mgrant opts = case checkGrant app mgrant of
                            Right scope -> issueTicket p mgrant
                                           (fromMaybe [] scope)
                                           (ozAppId app)
                                           Nothing True opts
                            Left e -> return (Left e)
  where
    checkGrant _ Nothing = Right Nothing
    checkGrant OzApp{..} (Just OzGrant{..}) = checkGrantScope ozAppScope ozGrantScope -- fixme: what else to check?

-- | Generates a ticket without any checking
issueTicket :: MonadIO m => Key -> Maybe OzGrant -> OzScope -> OzAppId
            -> Maybe OzAppId -> Bool -> TicketOpts
            -> m (Either String OzSealedTicket)
issueTicket p mgrant scope app dlg delegate opts = do
  exp <- getExpiry opts mgrant
  let ticket = OzTicket { ozTicketExp = exp
                        , ozTicketApp = app
                        , ozTicketScope = scope
                        , ozTicketGrant = ozGrantId <$> mgrant
                        , ozTicketUser = ozGrantUser <$> mgrant
                        , ozTicketDlg = dlg
                        , ozTicketDelegate = ticketOptsDelegate opts && delegate
                        }
  res <- liftIO $ generateTicket opts p ticket
  return $ maybe (Left "Could not issue ticket") Right res


-- | Reissues an application or user ticket.
reissue :: MonadIO m => Key -> OzApp -> Maybe OzGrant
        -> TicketOpts -> Maybe OzScope -> Maybe OzAppId
        -> OzSealedTicket -> m (Either String OzSealedTicket)
reissue p app mgrant opts@TicketOpts{..} mscope issueTo t = case checks of
    Right () -> issueTicket p mgrant
      (fromMaybe ozTicketScope mscope)
      (fromMaybe ozTicketApp issueTo)
      (issueTo <|> ozTicketDlg)
      ozTicketDelegate
      opts'
    Left e -> return (Left e)
  where
    checks :: Either String ()
    checks = do
      void $ checkParentScope (Just ozTicketScope) mscope
      when (ticketOptsDelegate && not ozTicketDelegate)
        $ Left "Cannot override ticket delegate restriction"
      when (isJust issueTo) $ do
        when (isJust ozTicketDlg) $ Left "Cannot re-delegate" -- fixme: http bad request
        when (not ozTicketDelegate) $ Left "Ticket does not allow delegation"
      when (ozTicketGrant /= fmap ozGrantId mgrant) $
        Left "Parent ticket grant does not match options.grant"

    OzTicket{..} = ozTicket t

    opts' = if ticketOptsExt == mempty && not (null (ozTicketExt t))
      then opts { ticketOptsExt = OzExt (ozTicketExt t) mempty }
      else opts


getExpiry :: MonadIO m => TicketOpts -> Maybe OzGrant -> m POSIXTime
getExpiry opts mgrant = do
  now <- liftIO getPOSIXTime
  return $ calc (ticketOptsTicketTtl opts) mgrant now
  where
    calc ttl mgrant now = maybe id (min . ozGrantExp) mgrant (now + ttl)

-- | Probably not a worthy function
checkPassword :: Key -> Either String ()
checkPassword (Key p) | BS.null p = Left "Invalid encryption password"
                      | otherwise = Right ()

-- | Validate a grant scope in comparison to an app scope.
checkGrantScope :: Maybe OzScope -> Maybe OzScope -> Either String (Maybe OzScope)
checkGrantScope app grant = mapLeft (const msg) (checkScopes app grant)
  where msg = "Grant scope is not a subset of the application scope"

checkParentScope :: Maybe OzScope -> Maybe OzScope -> Either String (Maybe OzScope)
checkParentScope parent scope = mapLeft (const msg) (checkScopes parent scope)
  where msg = "New scope is not a subset of the parent ticket scope"

checkScopes :: Maybe OzScope -> Maybe OzScope -> Either String (Maybe OzScope)
checkScopes Nothing    Nothing       = Right Nothing
checkScopes Nothing    (Just _)      = Left ""
checkScopes (Just big) Nothing       = Just <$> checkScope big
checkScopes (Just big) (Just little) | isInfixOf little big = Just <$> checkScope little
                                     | otherwise = Left "not a subset"

-- | Validate scope array strings.
checkScope :: OzScope -> Either String OzScope
checkScope scope | any T.null scope = Left "scope includes empty string value"
                 | length (nub scope) /= length scope = Left "scope includes duplicated item"
                 | otherwise = Right scope

mapLeft :: (a -> c) -> Either a b -> Either c b
mapLeft f (Left a) = Left (f a)
mapLeft _ (Right b) = Right b

randomKey :: TicketOpts -> IO ByteString
randomKey TicketOpts{..} = do
  -- fixme: check that this is seeded properly
  drg <- getSystemDRG
  return (fst $ withRandomBytes drg ticketOptsKeyBytes base64)

base64 :: ByteString -> ByteString
base64 = Iron.urlSafeBase64 . B64.encode

-- | Adds the cryptographic properties to a ticket and prepares it for
-- sending.
generateTicket :: TicketOpts -> Key -> OzTicket -> IO (Maybe OzSealedTicket)
generateTicket opts@TicketOpts{..} (Key p) t = do
  key <- randomKey opts
  let Object ext = toJSON ticketOptsExt
  let sealed = OzSealedTicket t (Key key) ticketOptsHmacAlgorithm ext ""
  mid <- Iron.sealWith ticketOptsIron (Iron.password p) sealed
  return (finishSeal ticketOptsExt sealed <$> mid)

-- | Removes the private ext part and adds the ticket ID.
finishSeal :: OzExt -> OzSealedTicket -> ByteString -> OzSealedTicket
finishSeal ext ticket ticketId = ticket { ozTicketId = decodeUtf8 ticketId
                                        , ozTicketExt = ozExtPublic ext
                                        }

-- | Decodes a Hawk "app" string into an Oz Ticket.
parse :: TicketOpts -> Key -> ByteString -> IO (Either String OzSealedTicket)
parse TicketOpts{..} (Key p) = Iron.unsealWith ticketOptsIron lookup
  where lookup = Iron.onePassword p