module Hedgehog.Servant
  ( GList(..)
  , HasGen(..)
  , GenRequest(..)
  ) where

import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import qualified Data.ByteString.Internal as BS (c2w)
import qualified Data.CaseInsensitive as CI
import           Data.Proxy (Proxy(..))
import           Data.Text.Encoding (encodeUtf8, decodeUtf8)
import qualified Data.Text as Text
import           Data.String.Conversions (ConvertibleStrings, cs)
import           GHC.TypeLits (KnownSymbol, symbolVal)
import           Hedgehog
import qualified Hedgehog.Gen as Gen
import qualified Hedgehog.Range as Range
import           Network.HTTP.Media (renderHeader)
import           Network.HTTP.Client (Request(..), RequestBody(..))
import           Network.HTTP.Client (defaultRequest)
import           Network.HTTP.Types (HeaderName)
import           Servant.API (ToHttpApiData(..))
import           Servant.API (Capture', CaptureAll, Header', Description, Summary)
import           Servant.API (QueryParam', QueryParams, QueryFlag)
import           Servant.API (ReqBody', Verb, ReflectMethod)
import           Servant.API (BasicAuth, HttpVersion, IsSecure, RemoteHost, Vault)
import           Servant.API (WithNamedContext)
import           Servant.API ((:>), (:<|>))
import           Servant.API (reflectMethod)
import           Servant.API.ContentTypes (AllMimeRender(..))
import           Servant.Client (BaseUrl(..), Scheme(..))

-- | Data structure used in order to specify generators for API
--
-- Example usage:
--
-- @
-- type Api = "cats" :> ReqBody '[JSON] Cat :> Post '[JSON] ()
--
-- catGen :: Gen Cat
-- catGen = _
--
-- genApi :: Gen (BaseUrl -> Request)
-- genApi = genRequest (Proxy @Api) (catGen :*: GNil)
-- @
data GList (a :: [*]) where
  GNil :: GList '[]
  (:*:) :: Gen x -> GList xs -> GList (Gen x ': xs)

infixr 6 :*:

-- | Simple getter from a GList of possible generators
class HasGen (g :: *) (gens :: [*]) where
  getGen :: GList gens -> Gen g

instance {-# OVERLAPPING #-} HasGen h (Gen h ': rest) where
  getGen (ha :*: _) = ha

instance {-# OVERLAPPABLE #-} (HasGen h rest) => HasGen h (first ': rest) where
  getGen (_ :*: hs) = getGen hs

-- | Type class used to generate requests from a 'GList gens' for API @api@
class GenRequest (api :: *) (gens :: [*]) where
  genRequest :: Proxy api -> GList gens -> Gen (BaseUrl -> Request)

-- | Instance for composite APIs
instance
  ( GenRequest a reqs
  , GenRequest b reqs
  ) => GenRequest (a :<|> b) reqs where
  genRequest _ gens =
    Gen.choice
      [ genRequest (Proxy @a) gens
      , genRequest (Proxy @b) gens
      ]

-- | Instance for description
instance
  ( GenRequest api reqs
  ) => GenRequest (Description d :> api) reqs where
  genRequest _ = genRequest (Proxy @api)

-- | Instance for summary
instance
  ( GenRequest api reqs
  ) => GenRequest (Summary s :> api) reqs where
  genRequest _ = genRequest (Proxy @api)

-- | Instance for path part of API
instance
  ( KnownSymbol path
  , GenRequest api reqs
  ) => GenRequest (path :> api) reqs where
  genRequest _ gens = do
    makeRequest <- genRequest (Proxy @api) gens
    pure $ prependPath (symbolVal $ Proxy @path) . makeRequest

-- | Instance for path capture
instance
  ( ToHttpApiData a
  , HasGen a gens
  , GenRequest api gens
  ) => GenRequest (Capture' modifiers sym a :> api) gens where
    genRequest _ gens = do
      capture <- toUrlPiece <$> getGen @a @gens gens
      makeRequest <- genRequest (Proxy @api) gens
      pure $ prependPath capture . makeRequest

-- | Instance for capture rest of path, e.g:
--
-- @
-- type Api = "cats" :> CaptureAll "rest" Text :> Get '[JSON] [Cat]
-- @
--
-- For simplicity this will generate a number of paths from 0 to 10 linearly
--
instance
  ( ToHttpApiData a
  , HasGen a gens
  , GenRequest api gens
  ) => GenRequest (CaptureAll sym a :> api) gens where
    genRequest _ gens = do
      captures <- Gen.list (Range.linear 0 10) (getGen @a @gens gens)
      makeRequest <- genRequest (Proxy @api) gens
      pure $ \baseUrl ->
        foldr (prependPath . toUrlPiece) (makeRequest baseUrl) captures

-- | Instance for headers
--
-- /Note: this instance currently makes all headers mandatory/
instance
  ( HasGen header gens
  , KnownSymbol headerName
  , ToHttpApiData header
  , GenRequest api gens
  ) => GenRequest (Header' mods headerName header :> api) gens where
    genRequest _ gens = do
      let headerName = CI.mk . cs . symbolVal $ Proxy @headerName
      header <- getGen @header @gens gens
      makeRequest <- genRequest (Proxy @api) gens
      pure $ addHeader headerName (toHeader header) . makeRequest

-- | Instance for setting query flag
--
-- /Note: this instance currently makes all query flags mandatory/
instance
  ( KnownSymbol name
  , GenRequest api gens
  ) => GenRequest (QueryFlag name :> api) gens where
    genRequest _ gens = do
      let paramName = toUrlPiece . symbolVal $ Proxy @name
      makeRequest <- genRequest (Proxy @api) gens
      pure $ \baseUrl ->
        let
          partialReq = makeRequest baseUrl
          oldQuery = decodeUtf8 $ queryString partialReq
          newQuery =
            if Text.null oldQuery then paramName
            else paramName <> "&" <> oldQuery
        in
          partialReq { queryString = encodeUtf8 newQuery }

-- | Instance for setting query parameters
--
-- /Note: this instance currently makes all query params mandatory/
instance
  ( KnownSymbol paramName
  , ToHttpApiData param
  , HasGen param gens
  , GenRequest api gens
  ) => GenRequest (QueryParam' mods paramName param :> api) gens where
    genRequest _ gens = do
      queryParam <- toUrlPiece <$> getGen @param @gens gens

      let
        paramName = toUrlPiece . symbolVal $ Proxy @paramName
        query = paramName <> "=" <> queryParam

      makeRequest <- genRequest (Proxy @api) gens
      pure $ \baseUrl ->
        let
          partialReq = makeRequest baseUrl
          oldQuery = decodeUtf8 $ queryString partialReq
          newQuery =
            if Text.null oldQuery then query
            else query <> "&" <> oldQuery
        in
          partialReq { queryString = encodeUtf8 newQuery }

-- | Instance for generating query parameters for arrays of values
instance
  ( KnownSymbol paramName
  , HasGen param gens
  , ToHttpApiData param
  , GenRequest api gens
  ) => GenRequest (QueryParams paramName param :> api) gens where
    genRequest _ gens = do
      params <- Gen.list (Range.linear 1 20) (getGen @param @gens gens)

      let
        paramName = toUrlPiece . symbolVal $ Proxy @paramName
        params' = fmap (((paramName <> "[]=") <>) . toUrlPiece) params
        queryParams = Text.intercalate "&" params'

      makeRequest <- genRequest (Proxy @api) gens
      pure $ \baseUrl ->
        let
          partialReq = makeRequest baseUrl
          oldQuery = decodeUtf8 $ queryString partialReq
          newQuery =
            if Text.null oldQuery then queryParams
            else queryParams <> "&" <> oldQuery
        in
          partialReq { queryString = encodeUtf8 newQuery }

-- | Instance for request body
instance
  ( AllMimeRender contentTypes body
  , HasGen body gens
  , GenRequest api gens
  ) => GenRequest (ReqBody' mods contentTypes body :> api) gens where
    genRequest _ gens = do
      newBody <- getGen @body @gens gens

      (contentType, body) <-
        Gen.element $ allMimeRender (Proxy @contentTypes) newBody

      makeRequest <- genRequest (Proxy @api) gens

      pure $ setBody body
           . addHeader "Content-Type" (renderHeader contentType)
           . makeRequest

-- | Instnace for capturing verb e.g. @POST@ or @GET@
instance
  ( ReflectMethod method
  ) => GenRequest (Verb method status contentTypes body) gens where
    genRequest _ _ =
      pure $ \baseUrl -> defaultRequest
        { host = cs . baseUrlHost $ baseUrl
        , port = baseUrlPort baseUrl
        , secure = baseUrlScheme baseUrl == Https
        , method = reflectMethod (Proxy @method)
        }

-- | This instance doees not do anything right now
--
-- /Note:/ in order to use features provided by this type in the API, you'll
-- need to manually adjust the generated request.
instance
  ( GenRequest api gens
  ) => GenRequest (BasicAuth x y :> api) gens where
    genRequest _ gens = genRequest (Proxy @api) gens

-- | This instance doees not do anything right now
--
-- /Note:/ in order to use features provided by this type in the API, you'll
-- need to manually adjust the generated request.
instance
  ( GenRequest api gens
  ) => GenRequest (HttpVersion :> api) gens where
    genRequest _ gens = genRequest (Proxy @api) gens

-- | This instance doees not do anything right now
--
-- /Note:/ in order to use features provided by this type in the API, you'll
-- need to manually adjust the generated request.
instance
  ( GenRequest api gens
  ) => GenRequest (IsSecure :> api) gens where
    genRequest _ gens = genRequest (Proxy @api) gens

-- | This instance doees not do anything right now
--
-- /Note:/ in order to use features provided by this type in the API, you'll
-- need to manually adjust the generated request.
instance
  ( GenRequest api gens
  ) => GenRequest (RemoteHost :> api) gens where
    genRequest _ gens = genRequest (Proxy @api) gens

-- | This instance doees not do anything right now
--
-- /Note:/ in order to use features provided by this type in the API, you'll
-- need to manually adjust the generated request.
instance
  ( GenRequest api gens
  ) => GenRequest (Vault :> api) gens where
    genRequest _ gens = genRequest (Proxy @api) gens

-- | This instance doees not do anything right now
--
-- /Note:/ in order to use features provided by this type in the API, you'll
-- need to manually adjust the generated request.
instance
  ( GenRequest api gens
  ) => GenRequest (WithNamedContext x y api) gens where
    genRequest _ gens = genRequest (Proxy @api) gens

setBody :: LBS.ByteString -> Request -> Request
setBody body oldReq = oldReq { requestBody = RequestBodyLBS body }

addHeader :: HeaderName -> BS.ByteString -> Request -> Request
addHeader name value oldReq =
  let
    headers = (name, value) : requestHeaders oldReq
  in
    oldReq { requestHeaders = headers }

-- | Helper function for prepending a new URL piece
prependPath :: ConvertibleStrings s BS.ByteString => s -> Request -> Request
prependPath new oldReq =
  let
    partialUrl = BS.dropWhile (== BS.c2w '/') . path $ oldReq
    urlPieces = filter (not . BS.null) [cs new, partialUrl]
  in
    oldReq { path = "/" <> BS.intercalate "/" urlPieces }