{-# LANGUAGE CPP               #-}
{-# LANGUAGE NamedFieldPuns    #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes        #-}
{-# LANGUAGE ViewPatterns      #-}

module Dhall.Test.Util
    ( code
    , codeWith
    , equivalent
    , load
    , loadRelativeTo
    , loadWith
    , normalize'
    , normalizeWith'
    , assertNormalizesTo
    , assertNormalizesToWith
    , assertNormalized
    , assertTypeChecks
    , assertDoesntTypeCheck
    , discover
    , Dhall.Test.Util.testCase
    , toDhallPath
    , managedTestEnvironment
    ) where

import Control.Applicative              (liftA2, (<|>))
import Control.Exception                (tryJust)
import Control.Monad                    (guard)
import Control.Monad.Trans.State.Strict (StateT)
import Data.Bifunctor                   (first)
import Data.Text                        (Text)
import Data.Void                        (Void)
import Dhall.Context                    (Context)
import Dhall.Core
    ( Chunks (..)
    , Expr (..)
    , Import
    , Normalizer
    , ReifiedNormalizer (..)
    )
import Dhall.Import                     (SemanticCacheMode (..), Status (..))
import Dhall.Parser                     (Src)
import Prelude                          hiding (FilePath)
import System.IO.Error                  (isDoesNotExistError)
import Test.Tasty                       (TestTree)
import Test.Tasty.HUnit
import Turtle                           (FilePath, Pattern, Shell, fp)

import qualified Control.Exception
import qualified Control.Foldl                    as Foldl
import qualified Control.Monad.Trans.State.Strict as State
import qualified Data.Functor
import qualified Data.Text                        as Text
import qualified Data.Text.IO                     as Text.IO
import qualified Dhall.Context
import qualified Dhall.Core
import qualified Dhall.Import
import qualified Dhall.Map
import qualified Dhall.Parser
import qualified Dhall.TypeCheck
import qualified System.FilePath                  as FilePath
import qualified Test.Tasty                       as Tasty
import qualified Test.Tasty.ExpectedFailure       as Tasty.ExpectedFailure
import qualified Turtle

#if defined(WITH_HTTP) && defined(NETWORK_TESTS)
import qualified Data.Foldable
#else
import Control.Monad.IO.Class   (MonadIO (..))
import Dhall.Core               (URL (..), File (..), Directory (..))
import Lens.Family.State.Strict (zoom)

import qualified Data.Foldable
import qualified Data.Text.Encoding
import qualified Data.Text.IO
#endif

normalize' :: Expr Src Void -> Text
normalize' = Dhall.Core.pretty . Dhall.Core.normalize

normalizeWith' :: Normalizer Void -> Expr Src Void -> Text
normalizeWith' ctx t =
  Dhall.Core.pretty (Dhall.Core.normalizeWith (Just (ReifiedNormalizer ctx)) t)

code :: Text -> IO (Expr Src Void)
code = codeWith Dhall.Context.empty

codeWith :: Context (Expr Src Void) -> Text -> IO (Expr Src Void)
codeWith ctx expr = do
    expr0 <- case Dhall.Parser.exprFromText mempty expr of
        Left parseError -> Control.Exception.throwIO parseError
        Right expr0     -> return expr0
    expr1 <- load expr0
    case Dhall.TypeCheck.typeWith ctx expr1 of
        Left typeError -> Control.Exception.throwIO typeError
        Right _        -> return ()
    return expr1

load :: Expr Src Import -> IO (Expr Src Void)
load = loadRelativeTo "." UseSemanticCache

loadRelativeTo :: FilePath.FilePath -> SemanticCacheMode -> Expr Src Import -> IO (Expr Src Void)
loadRelativeTo rootDirectory semanticCacheMode expression =
    State.evalStateT
        (loadWith expression)
        (Dhall.Import.emptyStatus rootDirectory) { _semanticCacheMode = semanticCacheMode }

#if defined(WITH_HTTP) && defined(NETWORK_TESTS)
loadWith :: Expr Src Import -> StateT Status IO (Expr Src Void)
loadWith = Dhall.Import.loadWith

#else
loadWith :: Expr Src Import -> StateT Status IO (Expr Src Void)
loadWith expr = do
    zoom Dhall.Import.remote (State.put mockRemote)
    Dhall.Import.loadWith expr

mockRemote :: Dhall.Core.URL -> StateT Status IO Data.Text.Text
mockRemote
    url@URL
        { authority = "raw.githubusercontent.com"
        , path = File (Directory components) file
        } = do
    let localDir = case reverse components of
            "dhall-lang" : "dhall-lang" : _ : rest ->
                reverse ("dhall-lang" : rest)
            "Nadrieril" : "dhall-rust" : _ : "dhall" : rest ->
                reverse ("dhall-lang" : rest)
            _ -> do
                fail ("Unable to mock URL: " <> Text.unpack (Dhall.Core.pretty url))

    localPath <- Dhall.Import.localToPath Dhall.Core.Here (File (Directory localDir) file)

    liftIO (Data.Text.IO.readFile localPath)

mockRemote
    URL { authority = "prelude.dhall-lang.org"
        , path = File (Directory components) file
        } = do
    let localDir = components ++ [ "Prelude", "dhall-lang" ]

    localPath <- Dhall.Import.localToPath Dhall.Core.Here (File (Directory localDir) file)

    liftIO (Data.Text.IO.readFile localPath)

mockRemote url@URL{ authority = "test.dhall-lang.org", path, headers } =
    case (path, fmap Dhall.Import.toHeaders headers) of
        (File (Directory []) "foo", Just [("test", _)]) ->
            return "./bar"
        (File (Directory []) "bar", Just [("test", _)]) ->
            return "True"
        (File (Directory ["cors"]) "AllowedAll.dhall", _) ->
            return "42"
        (File (Directory ["cors"]) "OnlyGithub.dhall", _) ->
            return "42"
        (File (Directory ["cors"]) "OnlySelf.dhall", _) ->
            return "42"
        (File (Directory ["cors"]) "OnlyOther.dhall", _) ->
            return "42"
        (File (Directory ["cors"]) "Empty.dhall", _) ->
            return "42"
        (File (Directory ["cors"]) "NoCORS.dhall", _) ->
            return "42"
        (File (Directory ["cors"]) "Null.dhall", _) ->
            return "42"
        (File (Directory ["cors"]) "SelfImportAbsolute.dhall", _) ->
            return "https://test.dhall-lang.org/cors/NoCORS.dhall"
        (File (Directory ["cors"]) "SelfImportRelative.dhall", _) ->
            return "./NoCORS.dhall"
        (File (Directory ["cors"]) "TwoHopsFail.dhall", _) ->
            return "https://raw.githubusercontent.com/dhall-lang/dhall-lang/5ff7ecd2411894dd9ce307dc23020987361d2d43/tests/import/data/cors/OnlySelf.dhall"
        (File (Directory ["cors"]) "TwoHopsSuccess.dhall", _) ->
            return "https://raw.githubusercontent.com/dhall-lang/dhall-lang/5ff7ecd2411894dd9ce307dc23020987361d2d43/tests/import/data/cors/OnlyGithub.dhall"
        _ -> do
            fail ("Unable to mock URL: " <> Text.unpack (Dhall.Core.pretty url))

mockRemote url@URL{ authority = "httpbin.org", path, headers } =
    case (path, fmap Dhall.Import.toHeaders headers) of
        (File (Directory []) "user-agent", Just [("user-agent", userAgent)]) -> do
            let agentText = Data.Text.Encoding.decodeUtf8 userAgent

            return ("{\n  \"user-agent\": \"" <> agentText <> "\"\n}\n")
        (File (Directory []) "user-agent", Nothing) -> do
            return ("{\n  \"user-agent\": \"Dhall\"\n}\n")
        _ -> do
            fail ("Unable to mock URL: " <> Text.unpack (Dhall.Core.pretty url))

mockRemote url = do
    let urlString = Text.unpack (Dhall.Core.pretty url)
    fail ("(mock http) Url does not match any of the hard-coded rules: "
        <> urlString)
#endif

{- Given a test prefix, returns a managed resource
   which sets / reverts relevant environment variables based
   on `prefix <> "ENV.dhall"` (if present)
 -}
managedTestEnvironment :: Text -> Turtle.Managed [(Text, Maybe Text)]
managedTestEnvironment prefix = Turtle.managed (Control.Exception.bracket setup cleanup)
  where
    envPath = Text.unpack (prefix <> "ENV.dhall")

    setup :: IO [(Text, Maybe Text)]
    setup = do
        envFileContents <-
            tryJust (guard . isDoesNotExistError) (Text.IO.readFile envPath)

        testEnv <- case envFileContents of
            Right contents -> do
                resolved <- code contents
                return (convertEnvExpr (Dhall.Core.normalize resolved))
            Left _ -> return []

        traverse setEnv testEnv

    cleanup :: [(Text, Maybe Text)] -> IO ()
    cleanup = Data.Foldable.traverse_ restoreEnv

    convertEnvExpr :: Expr Src Void -> [(Text, Text)]
    convertEnvExpr (ListLit _ hs) = Data.Foldable.toList (Data.Foldable.fold maybePairs)
      where
          maybePairs = mapM toPair hs

          toPair :: Expr s a -> Maybe (Text, Text)
          toPair (RecordLit m) = do
              (Dhall.Core.recordFieldValue -> TextLit (Chunks [] key), Dhall.Core.recordFieldValue -> TextLit (Chunks [] value))
                  <- lookupHeader <|> lookupMapKey
              return (key, value)
                where
                  lookupHeader = liftA2 (,) (Dhall.Map.lookup "header" m) (Dhall.Map.lookup "value" m)
                  lookupMapKey = liftA2 (,) (Dhall.Map.lookup "mapKey" m) (Dhall.Map.lookup "mapValue" m)
          toPair _ = Nothing
    convertEnvExpr _ = []

    setEnv :: (Text, Text) -> IO (Text, Maybe Text)
    setEnv (k, v) = do
        old <- Turtle.need k
        Turtle.export k v
        return (k, old)

    restoreEnv :: (Text, Maybe Text) -> IO ()
    restoreEnv (k, Just old) = Turtle.export k old
    restoreEnv (k, Nothing) = Turtle.unset k

equivalent :: Text -> Text -> IO ()
equivalent text0 text1 = do
    expr0 <- fmap Dhall.Core.normalize (code text0) :: IO (Expr Void Void)
    expr1 <- fmap Dhall.Core.normalize (code text1) :: IO (Expr Void Void)
    assertEqual "Expressions are not equivalent" expr0 expr1

assertNormalizesTo :: Expr Src Void -> Text -> IO ()
assertNormalizesTo e expected = do
  assertBool msg (not $ Dhall.Core.isNormalized e)
  normalize' e @?= expected
  where msg = "Given expression is already in normal form"

assertNormalizesToWith :: Normalizer Void -> Expr Src Void -> Text -> IO ()
assertNormalizesToWith ctx e expected = do
  assertBool msg (not $ Dhall.Core.isNormalizedWith ctx (first (const ()) e))
  normalizeWith' ctx e @?= expected
  where msg = "Given expression is already in normal form"

assertNormalized :: Expr Src Void -> IO ()
assertNormalized e = do
  assertBool msg1 (Dhall.Core.isNormalized e)
  assertEqual msg2 (normalize' e) (Dhall.Core.pretty e)
  where msg1 = "Expression was not in normal form"
        msg2 = "Normalization is not supposed to change the expression"

assertTypeChecks :: Text -> IO ()
assertTypeChecks text = Data.Functor.void (code text)

assertDoesntTypeCheck :: Text -> IO ()
assertDoesntTypeCheck text = do
    expr0 <- case Dhall.Parser.exprFromText mempty text of
        Left parseError -> Control.Exception.throwIO parseError
        Right e         -> return e
    expr1 <- load expr0
    case Dhall.TypeCheck.typeOf expr1 of
        Left _      -> return ()
        Right type_ -> fail ("Bad type for " <> Text.unpack text <> "\n  " <> show type_)

{-| Automatically run a test on all files in a directory tree that end in
    @A.dhall@
-}
discover :: Pattern Text -> (Text -> TestTree) -> Shell FilePath -> IO TestTree
discover pattern buildTest paths = do
    let shell = do
            path_ <- paths

            let pathText = Turtle.format fp path_

            prefix : _ <- return (Turtle.match pattern pathText)

            return (buildTest prefix)

    tests <- Turtle.fold shell Foldl.list

    return (Tasty.testGroup "discover" tests)

testCase :: Text -> [ FilePath ] -> Assertion -> TestTree
testCase prefix expectedFailures assertion =
    if prefix `elem` map (Turtle.format fp) expectedFailures
    then Tasty.ExpectedFailure.expectFail test
    else test
  where
    test = Test.Tasty.HUnit.testCase (Text.unpack prefix) assertion

{-| Path names on Windows are not valid Dhall paths due to using backslashes
    instead of forwardslashes to separate path components.  This utility fixes
    them if necessary
-}
toDhallPath :: Text -> Text
toDhallPath = Text.replace "\\" "/"
