{-# LANGUAGE DeriveGeneric            #-}
{-# LANGUAGE DisambiguateRecordFields #-}
{-# LANGUAGE NamedFieldPuns           #-}
{-# LANGUAGE OverloadedStrings        #-}

-- | Data types for curl-runnings tests

module Testing.CurlRunnings.Types
  ( AssertionFailure(..)
  , Authentication(..)
  , CaseResult(..)
  , CurlCase(..)
  , CurlRunningsState(..)
  , CurlSuite(..)
  , FullQueryText
  , Header(..)
  , HeaderMatcher(..)
  , Headers(..)
  , HttpMethod(..)
  , Index(..)
  , InterpolatedQuery(..)
  , JsonMatcher(..)
  , JsonSubExpr(..)
  , KeyValuePair(..)
  , KeyValuePairs(..)
  , PartialHeaderMatcher(..)
  , Payload(..)
  , Query(..)
  , QueryError(..)
  , SingleQueryText
  , StatusCodeMatcher(..)
  , TLSCheckType(..)

  , isFailing
  , isPassing
  , logger
  , unsafeLogger

  ) where

import           Data.Aeson
import           Data.Aeson.Types
import qualified Data.Char                                   as C
import           Data.Hashable                               (Hashable)
import qualified Data.HashMap.Strict                         as H
import           Data.Maybe
import           Data.Monoid
import qualified Data.Text                                   as T
import qualified Data.Vector                                 as V
import           GHC.Generics
import           Testing.CurlRunnings.Internal
import           Testing.CurlRunnings.Internal.Headers
import           Testing.CurlRunnings.Internal.KeyValuePairs
import           Testing.CurlRunnings.Internal.Payload
import           Text.Printf

-- | A basic enum for supported HTTP verbs
data HttpMethod
  = GET
  | POST
  | PUT
  | PATCH
  | DELETE
  deriving (Show, Generic)

instance FromJSON HttpMethod

instance ToJSON HttpMethod

-- | A predicate to apply to the json body from the response
data JsonMatcher
  -- | Performs `==`
  = Exactly Value
  -- | A list of matchers to make assertions that contains values exist in the response
  | Contains [JsonSubExpr]
  -- | A list of matchers to make assertions that contains values do not exist in the response
  | NotContains [JsonSubExpr]
  -- | We're specifiying both Contains and NotContains matchers
  | MixedContains [JsonMatcher]
  deriving (Show, Generic)

instance ToJSON JsonMatcher

instance FromJSON JsonMatcher where
  parseJSON (Object v)
    | justAndNotEmpty "exactly" v = Exactly <$> v .: "exactly"
    | justAndNotEmpty "contains" v && justAndNotEmpty "notContains" v = do
      c <- Contains <$> v .: "contains"
      n <- NotContains <$> v .: "notContains"
      return $ MixedContains [c, n]
    | justAndNotEmpty "contains" v = Contains <$> v .: "contains"
    | justAndNotEmpty "notContains" v = NotContains <$> v .: "notContains"
  parseJSON invalid = typeMismatch "JsonMatcher" invalid

justAndNotEmpty :: (Eq k, Hashable k) => k -> H.HashMap k Value -> Bool
justAndNotEmpty key obj =
  (isJust $ H.lookup key obj) && (H.lookup key obj /= Just Null)

-- | Simple predicate to check value constructor type
isContains :: JsonMatcher -> Bool
isContains (Contains _) = True
isContains _            = False

-- | Simple predicate to check value constructor type
isNotContains :: JsonMatcher -> Bool
isNotContains (NotContains _) = True
isNotContains _               = False

-- | Specify a key, value, or both to match against in the returned headers of a
-- response.
data PartialHeaderMatcher =
  PartialHeaderMatcher (Maybe T.Text)
                       (Maybe T.Text)
  deriving (Show, Generic)
instance ToJSON PartialHeaderMatcher

-- | Collection of matchers to run against a single curl response
data HeaderMatcher =
  HeaderMatcher [PartialHeaderMatcher]
  deriving (Show, Generic)

instance ToJSON HeaderMatcher

-- | Different errors relating to querying json from previous test cases
data QueryError
  -- | The query was malformed and couldn't be parsed
  = QueryParseError T.Text
                    T.Text
  -- | The retrieved a value of the wrong type or was otherwise operating on the
  -- wrong type of thing
  | QueryTypeMismatch T.Text
                      Value
  -- | The query was parse-able
  | QueryValidationError T.Text
  -- | Tried to access a value in a null object.
  | NullPointer T.Text -- full query
                T.Text -- message
                deriving (Generic)

instance Show QueryError where
  show (QueryParseError t q) = printf "error parsing query %s: %s" q $ T.unpack t
  show (NullPointer full part) = printf "null pointer in %s at %s" (T.unpack full) $ T.unpack part
  show (QueryTypeMismatch message val) = printf "type error: %s %s" message $ show val
  show (QueryValidationError message) = printf "invalid query: %s" message

instance ToJSON QueryError

instance FromJSON HeaderMatcher where
  parseJSON o@(String v) =
    either
      (\s -> typeMismatch ("HeaderMatcher: " ++ T.unpack s) o)
      (\(HeaderSet parsed) ->
         return . HeaderMatcher $
         map
           (\(Header key val) -> PartialHeaderMatcher (Just key) (Just val))
           parsed)
      (parseHeaders v)
  parseJSON (Object v) = do
    partial <- PartialHeaderMatcher <$> v .:? "key" <*> v .:? "value"
    return $ HeaderMatcher [partial]
  parseJSON (Array v) = mconcat . V.toList $ V.map parseJSON v
  parseJSON invalid = typeMismatch "HeaderMatcher" invalid

-- | A matcher for a subvalue of a json payload
data JsonSubExpr
  -- | Assert some value anywhere in the json has a value equal to a given
  --  value. The motivation for this field is largely for checking contents of a
  --  top level array. It's also useful if you don't know the key ahead of time.
  = ValueMatch Value
  -- | Assert a key exists anywhere in the json
  | KeyMatch T.Text
  -- | Assert the key value pair can be found somewhere the json.
  | KeyValueMatch { matchKey   :: T.Text
                  , matchValue :: Value }
  deriving (Show, Generic)

instance FromJSON JsonSubExpr where
  parseJSON (Object v)
    | justAndNotEmpty "keyValueMatch" v =
      let toParse = fromJust $ H.lookup "keyValueMatch" v
      in case toParse of
           Object o -> KeyValueMatch <$> o .: "key" <*> o .: "value"
           _        -> typeMismatch "JsonSubExpr" toParse
    | justAndNotEmpty "keyMatch" v =
      let toParse = fromJust $ H.lookup "keyMatch" v
      in case toParse of
           String s -> return $ KeyMatch s
           _        -> typeMismatch "JsonSubExpr" toParse
    | justAndNotEmpty "valueMatch" v = ValueMatch <$> v .: "valueMatch"
  parseJSON invalid = typeMismatch "JsonSubExpr" invalid

instance ToJSON JsonSubExpr

-- | Check the status code of a response. You can specify one or many valid codes.
data StatusCodeMatcher
  = ExactCode Int
  | AnyCodeIn [Int]
  deriving (Show, Generic)

instance ToJSON StatusCodeMatcher

instance FromJSON StatusCodeMatcher where
  parseJSON obj@(Number _) = ExactCode <$> parseJSON obj
  parseJSON obj@(Array _)  = AnyCodeIn <$> parseJSON obj
  parseJSON invalid        = typeMismatch "StatusCodeMatcher" invalid

data Authentication =
  BasicAuthentication T.Text T.Text
  deriving (Show, Generic)

instance FromJSON Authentication where
  parseJSON (Object o) = BasicAuthentication <$> (o .: "basic" >>= (.: "username")) <*> (o .: "basic" >>= (.: "password"))
  parseJSON invalid    = typeMismatch "Authentication" invalid
instance ToJSON Authentication

-- | A single curl test case, the basic foundation of a curl-runnings test.
data CurlCase = CurlCase
  { name             :: T.Text -- ^ The name of the test case
  , url              :: T.Text -- ^ The target url to test
  , requestMethod    :: HttpMethod -- ^ Verb to use for the request
  , requestData      :: Maybe Payload -- ^ Payload to send with the request, if any
  , queryParameters  :: Maybe KeyValuePairs -- ^ Query parameters to set in the request, if any
  , headers          :: Maybe Headers -- ^ Headers to send with the request, if any
  , auth             :: Maybe Authentication -- ^ Authentication to add to the request, if any
  , expectData       :: Maybe JsonMatcher -- ^ The assertions to make on the response payload, if any
  , expectStatus     :: StatusCodeMatcher -- ^ Assertion about the status code returned by the target
  , expectHeaders    :: Maybe HeaderMatcher -- ^ Assertions to make about the response headers, if any
  , allowedRedirects :: Maybe Int -- ^ Number of redirects to follow. Defaults to 10
  } deriving (Show, Generic)

instance FromJSON CurlCase

instance ToJSON CurlCase

-- | Represents the different type of test failures we can have. A single test case
-- | might return many assertion failures.
data AssertionFailure
  -- | The json we got back was wrong. We include this redundant field (it's
  -- included in the CurlCase field above) in order to enforce at the type
  -- level that we have to be expecting some data in order to have this type of
  -- failure.
  = DataFailure CurlCase
                JsonMatcher
                (Maybe Value)
  -- | The status code we got back was wrong
  | StatusFailure CurlCase
                  Int
  -- | The headers we got back were wrong
  | HeaderFailure CurlCase
                  HeaderMatcher
                  Headers
  -- | Something went wrong with a test case json query
  | QueryFailure CurlCase
                 QueryError
  -- | Something else
  | UnexpectedFailure deriving (Generic)

instance ToJSON AssertionFailure

colorizeExpects :: String -> String
colorizeExpects t =
  let expectedColor = makeRed "Expected:"
      actualColor = makeRed "Actual:"
      replacedExpected = T.replace "Expected:" expectedColor (T.pack t)
  in T.unpack $ T.replace "Actual:" actualColor replacedExpected

instance Show AssertionFailure where
  show (StatusFailure c receivedCode) =
    case expectStatus c of
      ExactCode code ->
        colorizeExpects $
        printf
          "[%s] Incorrect status code from %s. Expected: %s. Actual: %s"
          (name c)
          (url c)
          (show code)
          (show receivedCode)
      AnyCodeIn codes ->
        colorizeExpects $
        printf
          "[%s] Incorrect status code from %s. Expected: %s. Actual: %s"
          (name c)
          (url c)
          (show codes)
          (show receivedCode)
  show (DataFailure curlCase expected receivedVal) =
    case expected of
      Exactly expectedVal ->
        colorizeExpects $
        printf
          "[%s] JSON response from %s didn't match spec. Expected: %s. Actual: %s"
          (name curlCase)
          (url curlCase)
          (T.unpack (pShow expectedVal))
          (T.unpack (pShow receivedVal))
      (Contains expectedVals) ->
        colorizeExpects $
        printf
          "[%s] JSON response from %s didn't contain the matcher. Expected: %s to each be subvalues in: %s"
          (name curlCase)
          (url curlCase)
          (T.unpack (pShow expectedVals))
          (T.unpack (pShow receivedVal))
      (NotContains expectedVals) ->
        colorizeExpects $
        printf
          "[%s] JSON response from %s did contain the matcher. Expected: %s not to be subvalues in: %s"
          (name curlCase)
          (url curlCase)
          (T.unpack (pShow expectedVals))
          (T.unpack (pShow receivedVal))
      (MixedContains expectedVals) ->
        colorizeExpects $
        printf
          "[%s] JSON response from %s didn't satisfy the matcher. Expected: %s to each be subvalues and %s not to be subvalues in: %s"
          (name curlCase)
          (url curlCase)
          (T.unpack (pShow (filter isContains expectedVals)))
          (T.unpack (pShow (filter isNotContains expectedVals)))
          (T.unpack (pShow receivedVal))
  show (HeaderFailure curlCase expected receivedHeaders) =
    colorizeExpects $
    printf
      "[%s] Headers from %s didn't contain expected headers. Expected: %s. Actual: %s"
      (name curlCase)
      (url curlCase)
      (show expected)
      (show receivedHeaders)
  show (QueryFailure curlCase queryErr) =
    colorizeExpects $
    printf "JSON query error in spec %s: %s" (name curlCase) (show queryErr)
  show UnexpectedFailure = "Unexpected Error D:"

-- | A type representing the result of a single curl, and all associated
-- assertions
data CaseResult
  = CasePass
      { curlCase            :: CurlCase
      , caseResponseHeaders :: Maybe Headers
      , caseResponseValue   :: Maybe Value
      , elapsedTime         :: Integer -- ^ Elapsed time
      }
  | CaseFail
      { curlCase            :: CurlCase
      , caseResponseHeaders :: Maybe Headers
      , caseResponseValue   :: Maybe Value
      , failures            :: [AssertionFailure]
      , elapsedTime         :: Integer -- ^ Elapsed time
      } deriving (Generic)

instance Show CaseResult where
  show CasePass{curlCase, elapsedTime} = T.unpack . makeGreen $ "[PASS] " <> (T.pack $ printf "%s (%0.2f seconds)" (name curlCase) (millisToS elapsedTime))
  show CaseFail{curlCase, failures, elapsedTime} =
    T.unpack $ makeRed "[FAIL] " <>
    name curlCase <>
    (T.pack $ printf " (%0.2f seconds) " (millisToS elapsedTime)) <>
    "\n" <>
    mconcat (map ((\s -> "\nAssertion failed: " <> s) . (<> "\n") . (T.pack . show)) failures)

instance ToJSON CaseResult where
  toJSON CasePass {curlCase, caseResponseHeaders, caseResponseValue, elapsedTime} =
    object
      [ "testPassed" .= (Bool True)
      , "case" .= curlCase
      , "responseHeaders" .= caseResponseHeaders
      , "responseValue" .= caseResponseValue
      , "elapsedTimeSeconds" .= millisToS elapsedTime
      ]
  toJSON CaseFail {curlCase, caseResponseHeaders, caseResponseValue, elapsedTime, failures} =
    object
      [ "testPassed" .= (Bool False)
      , "case" .= curlCase
      , "responseHeaders" .= caseResponseHeaders
      , "responseValue" .= caseResponseValue
      , "elapsedTimeSeconds" .= millisToS elapsedTime
      , "failures" .= failures
      ]

-- | A wrapper type around a set of test cases. This is the top level spec type
-- that we parse a test spec file into
data CurlSuite = CurlSuite
  { suiteCases      :: [CurlCase]
  , suiteCaseFilter :: Maybe T.Text
  } deriving (Show, Generic)

noFilterSuite :: [CurlCase] -> CurlSuite
noFilterSuite = flip CurlSuite Nothing

instance ToJSON CurlSuite

instance FromJSON CurlSuite where
  parseJSON (Object v)  = noFilterSuite <$> v .: "cases"
  parseJSON a@(Array _) = noFilterSuite <$> parseJSON a
  parseJSON invalid     = typeMismatch "JsonMatcher" invalid

-- | Simple predicate that checks if the result is passing
isPassing :: CaseResult -> Bool
isPassing CasePass {} = True
isPassing CaseFail {} = False

-- | Simple predicate that checks if the result is failing
isFailing :: CaseResult -> Bool
isFailing = not . isPassing

-- | A map of the system environment
type Environment = H.HashMap T.Text T.Text

data TLSCheckType = SkipTLSCheck | DoTLSCheck deriving (Show, Eq)

-- | The state of a suite. Tracks environment variables, and all the test results so far
data CurlRunningsState = CurlRunningsState Environment [CaseResult] LogLevel TLSCheckType

logger :: CurlRunningsState -> CurlRunningsLogger
logger (CurlRunningsState _ _ l _) = makeLogger l

unsafeLogger :: Show a => CurlRunningsState -> CurlRunningsUnsafeLogger a
unsafeLogger (CurlRunningsState _ _ l _) = makeUnsafeLogger l

-- | A single lookup operation in a json query
data Index
  -- | Drill into the json of a specific test case. The RESPONSES object is
  -- accessible as an array of values that have come back from previous test
  -- cases
  = CaseResultIndex Integer
  -- | A standard json key lookup.
  | KeyIndex T.Text
  -- | A standard json array index lookup.
  | ArrayIndex Integer
  deriving (Show)

printOriginalQuery :: Index -> String
printOriginalQuery (CaseResultIndex t) = "RESPONSES[" ++ show t ++ "]"
printOriginalQuery (KeyIndex key)      = "." ++ T.unpack key
printOriginalQuery (ArrayIndex i)      = printf "[%d]" i

-- | A single entity to be queries from a json value
data Query =
  -- | A single query contains a list of discrete index operations
  Query [Index] |
  -- | Lookup a string in the environment
  EnvironmentVariable T.Text
  deriving (Show)

-- | A distinct parsed unit in a query
data InterpolatedQuery
  -- | Regular text, no query
  = LiteralText T.Text
  -- | Some leading text, then a query
  | InterpolatedQuery T.Text
                      Query
  -- | Just a query, no leading text
  | NonInterpolatedQuery Query
  deriving (Show)

printQueryString :: InterpolatedQuery -> String
printQueryString (LiteralText t) = show t
printQueryString (InterpolatedQuery raw (Query indexes)) =
  printf "%s$<%s>" raw $ concatMap show indexes
printQueryString (NonInterpolatedQuery (Query indexes)) = printf "$<%s>" (concatMap show indexes)

-- | The full string in which a query appears, eg "prefix-${{RESPONSES[0].key.another_key[0].last_key}}"
type FullQueryText = T.Text
-- | The string for one query given the FullQueryText above, the single query text would be RESPONSES[0].key.another_key[0].last_key
type SingleQueryText = T.Text