-- SPDX-FileCopyrightText: 2023-2024 Sarah Vaupel <sarah.vaupel@uniworx.de>, David Mosbach <david.mosbach@uniworx.de>
--
-- SPDX-License-Identifier: AGPL-3.0-or-later

{-# OPTIONS_GHC -fno-warn-orphans #-}
{-# OPTIONS_GHC -fno-warn-redundant-constraints #-}

module Auth.OAuth2
  ( apAzure
  , azurePrimaryKey, azureUserPrincipalName, azureUserDisplayName, azureUserGivenName, azureUserSurname, azureUserMail, azureUserTelephone, azureUserMobile, azureUserPreferredLanguage
  -- , azureUser, azureUser'
  , AzureUserException(..), _AzureUserError, _AzureUserNoResult, _AzureUserAmbiguous
  , apAzureMock
  , azureMockServer
  , queryOAuth2User
  , refreshOAuth2Token
  , singleSignOut
  ) where

-- import qualified Data.CaseInsensitive as CI
import Data.Maybe (fromJust)
import Data.Text

import Import.NoFoundation hiding (pack, unpack)

import Network.HTTP.Simple (httpJSONEither, getResponseBody, JSONException)

import System.Environment (lookupEnv)

import Yesod.Auth.OAuth2
import Yesod.Auth.OAuth2.Prelude hiding (encodeUtf8)

-- | Plugin name of the OAuth2 yesod plugin for Azure ADv2
apAzure :: Text
apAzure = "AzureADv2"


-- TODO: deprecate in favour of FetchUserDataException
data AzureUserException = AzureUserError
                        | AzureUserNoResult
                        | AzureUserAmbiguous
                        deriving (Show, Eq, Generic)

instance Exception AzureUserException

makePrisms ''AzureUserException


azurePrimaryKey, azureUserPrincipalName, azureUserDisplayName, azureUserGivenName, azureUserSurname, azureUserMail, azureUserTelephone, azureUserMobile, azureUserPreferredLanguage :: Text
azurePrimaryKey = "id"
azureUserPrincipalName = "userPrincipalName"
azureUserDisplayName = "displayName"
azureUserGivenName = "givenName"
azureUserSurname = "surname"
azureUserMail = "mail"
azureUserTelephone = "businessPhones"
azureUserMobile = "mobilePhone"
azureUserPreferredLanguage = "preferredLanguage"


-- | User lookup in Microsoft Graph with given credentials
-- TODO: deprecate in favour of fetchUserData
-- azureUser :: ( MonadMask m
--              , MonadHandler m
--              -- , HandlerSite m ~ site
--              -- , BackendCompatible SqlBackend (YesodPersistBackend site)
--              -- , BaseBackend (YesodPersistBackend site) ~ SqlBackend
--              -- , YesodPersist site
--              -- , PersistUniqueWrite (YesodPersistBackend site)
--              )
--              => AzureConf
--              -> Creds site
--              -> m [(Text, [ByteString])] -- (Either AzureUserException [(Text, [ByteString])])
-- azureUser AzureConf{..} Creds{..} = fmap throwLeft . runExceptT $ do
--   now <- liftIO getCurrentTime
--   results <- queryOAuth2User @[(Text, [ByteString])] credsIdent
--   case results of
--     Right [res] -> do
--       -- void . liftHandler . runDB $ upsert ExternalUser
--       --   { externalUserUser     =  error "no userid" -- TODO: use azureUserPrimaryKey once UserIdent is referenced instead of UserId
--       --   , externalUserSource   =  AuthSourceIdAzure azureConfClientId
--       --   , externalUserData     =  toJSON res
--       --   , externalUserLastSync =  now
--       --   }
--       --   [ ExternalUserData     =. toJSON res
--       --   , ExternalUserLastSync =. now
--       --   ]
--       return res
--     Right _multiple -> throwE AzureUserAmbiguous
--     Left _ -> throwE AzureUserNoResult

-- | User lookup in Microsoft Graph with given user
-- azureUser' :: ( MonadMask m
--               , MonadHandler m
--               , HandlerSite m ~ site
--               , BaseBackend (YesodPersistBackend site) ~ SqlBackend
--               , YesodPersist site
--               , PersistUniqueWrite (YesodPersistBackend site)
--               )
--              => AzureConf
--              -> User
--              -> ReaderT (YesodPersistBackend site) m (Maybe [(Text, [ByteString])]) -- (Either AzureUserException [(Text, [ByteString])])
-- azureUser' conf User{userIdent}
--   = runMaybeT . catchIfMaybeT (is _AzureUserNoResult) $ azureUser conf (Creds apAzure (CI.original userIdent) [])


-----------------------------------------------
---- OAuth2 + OIDC development auth plugin ----
-----------------------------------------------

apAzureMock :: Text
apAzureMock = "uniworx_dev"

newtype UserID = UserID Text
instance FromJSON UserID where
  parseJSON = withObject "UserID" $ \o ->
    UserID <$> o .: "id"

azureMockServer :: YesodAuth m => String -> AuthPlugin m
azureMockServer port =
  let oa = OAuth2
            { oauthClientId = "42"
            , oauthClientSecret = Just "shhh"
            , oauthOAuthorizeEndpoint = fromString (mockServerURL <> "/auth")
              `withQuery` [ scopeParam " " ["openid", "profile", "email", "offline_access"] -- TODO read scopes from config
                          , ("response_type", "code id_token")
                          , ("nonce", "Foo") -- TODO generate meaningful value
                          ]
            , oauthAccessTokenEndpoint = fromString $ mockServerURL <> "/token"
            , oauthCallback = Nothing
            }
      mockServerURL = "http://localhost:" <> fromString port
      profileSrc = fromString $ mockServerURL <> "/users/me"
  in  authOAuth2 apAzureMock oa $ \manager token -> do
        (UserID userID, userResponse) <- authGetProfile apAzureMock manager token profileSrc
        return Creds
                { credsPlugin = apAzureMock
                , credsIdent = userID
                , credsExtra = setExtra token userResponse
                }


----------------------
---- User Queries ----
----------------------

data UserDataException = UserDataJSONException JSONException
                       | UserDataInternalException Text
                       deriving Show

instance Exception UserDataException

queryOAuth2User :: forall j m.
                   ( FromJSON j
                   , MonadHandler m
                   , HasAppSettings (HandlerSite m)
                   , MonadThrow m
                   )
                => Text -- ^ User identifier (arbitrary needle)
                -> m (Either UserDataException j)
queryOAuth2User userID = runExceptT $ do
  (queryUrl, tokenUrl) <- mkBaseUrls
  req <- parseRequest $ "GET " ++ queryUrl ++ unpack userID
  mTokens <- lookupSessionJson SessionOAuth2Token
  unless (isJust mTokens) . throwE $ UserDataInternalException "Tried to load session Oauth2 tokens, but there are none"
# ifdef DEVELOPMENT
  let secure = False
# else
  let secure = True
# endif
  newTokens <- refreshOAuth2Token @m (fromJust mTokens) tokenUrl secure
  setSessionJson SessionOAuth2Token (Just $ accessToken newTokens, refreshToken newTokens)
  eResult <- lift $ getResponseBody <$> httpJSONEither @m @j (req
    { secure = secure
    , requestHeaders = [("Authorization", encodeUtf8 . ("Bearer " <>) . atoken $ accessToken newTokens)]
    })
  case eResult of
    Left x -> throwE $ UserDataJSONException x
    Right x -> return x


mkBaseUrls :: (MonadHandler m, HasAppSettings (HandlerSite m)) => m (String, String)
mkBaseUrls = do
# ifndef DEVELOPMENT
  tenantID <- fmap (maybe (throwM $ UserDataInternalException "Could not determine tenant ID from current app configuration") show) . getsYesod . preview $ _appUserAuthConf . _userAuthConfSingleSource . _AuthSourceConfAzureAdV2 . _azureConfTenantId
  return ( "https://graph.microsoft.com/v1.0/users/"
         , "https://login.microsoftonline.com/" ++ tenantID ++ "/oauth2/v2.0" )
# else
  port :: String <- liftIO $ maybe (throwM $ UserDataInternalException "Development environment variable OAUTH2_SERVER_PORT is unset") id <$> lookupEnv "OAUTH2_SERVER_PORT"
  let base = "http://localhost:" ++ port
  return ( base ++ "/users/query?id="
         , base ++ "/token" )
# endif


refreshOAuth2Token :: forall m.
                      ( MonadHandler m
                      , MonadThrow m
                      )
                   => (Maybe AccessToken, Maybe RefreshToken)
                   -> String
                   -> Bool
                   -> ExceptT UserDataException m OAuth2Token
refreshOAuth2Token (_, rToken) url secure
  | isJust rToken = do
    req <- parseRequest $ "POST " ++ url
    let
      body =
        [ ("grant_type", "refresh_token")
        , ("refresh_token", encodeUtf8 . rtoken $ fromJust rToken)
        ]
    body' <- if secure then do
        clientID <- liftIO $ fromJust <$> lookupEnv "CLIENT_ID"
        clientSecret <- liftIO $ fromJust <$> lookupEnv "CLIENT_SECRET"
        return $ body ++ [("client_id", fromString clientID), ("client_secret", fromString clientSecret), scopeParam " " ["openid","profile"," offline_access"]] -- TODO read from config
      else return $ scopeParam " " ["openid","profile","offline_access"] : body -- TODO read from config
    $logDebugS "\27[31mAdmin Handler\27[0m" $ tshow (requestBody $ urlEncodedBody body' req{ secure = secure })
    eResult <- lift $ getResponseBody <$> httpJSONEither @m @OAuth2Token (urlEncodedBody body' req{ secure = secure })
    case eResult of
      Left x -> throwE $ UserDataJSONException x
      Right x -> return x
  | otherwise = throwE $ UserDataInternalException "Could not refresh access token. Refresh token is missing."

instance Show RequestBody where
  show (RequestBodyLBS x) = show x
  show _ = error ":("


 -----------------------
---- Single Sign-Out ----
 -----------------------
 
singleSignOut :: forall a m. (MonadHandler m)
              => Maybe Text -- ^ redirect uri
              -> m a
singleSignOut mRedirect = do
# ifdef DEVELOPMENT
  port <- liftIO $ fromJust <$> lookupEnv "OAUTH2_SERVER_PORT"
  let base = "http://localhost:" <> pack port <> "/logout"
# else
  let base = "" -- TODO find out fraport oidc end_session_endpoint
# endif
      endpoint = case mRedirect of
        Just r -> base <> "?post_logout_redirect_uri=" <> r
        Nothing -> base
  $logDebugS "\n\27[31mSSO\27[0m" endpoint 
  redirect endpoint

