-- | Parsing options supplied on the command line
module Test.Tasty.CmdLine
  ( optionParser
  , suiteOptions
  , suiteOptionParser
  , parseOptions
  , defaultMainWithIngredients
  ) where

import Control.Arrow
import Control.Monad
import Data.Maybe
import Data.Proxy
import Data.Typeable (typeRep)
import Options.Applicative
import Options.Applicative.Common (evalParser)
import qualified Options.Applicative.Types as Applicative (Option(..))
import Options.Applicative.Types (Parser(..), OptProperties(..))
import Prelude  -- Silence AMP and FTP import warnings
import System.Exit
import System.IO
#if !MIN_VERSION_base(4,11,0)
import Data.Monoid
import Data.Foldable (foldMap)
#endif

import Test.Tasty.Core
import Test.Tasty.Runners.Utils
import Test.Tasty.Ingredients
import Test.Tasty.Options
import Test.Tasty.Options.Env
import Test.Tasty.Runners.Reducers

-- | Generate a command line parser from a list of option descriptions,
-- alongside any related warning messages.
--
-- @since 1.3
optionParser :: [OptionDescription] -> ([String], Parser OptionSet)
optionParser = second getApp . foldMap toSet where
  toSet :: OptionDescription -> ([String], Ap Parser OptionSet)
  toSet (Option p) = second
    (\parser -> Ap $ (singleOption <$> parser) <|> pure mempty)
    (finalizeCLParser p optionCLParser)

-- Do two things:
--
-- 1. Replace an `optionCLParser`'s 'propShowDefault' with 'showDefaultValue'
--    from the 'IsOption' class.
-- 2. Generate warning messages if the 'optionCLParser' does anything
--    suspicious. Currently, the only suspicious things we check for are
--    (a) if the 'Parser' defines multiple options and, (b) if the 'Parser'
--    assigns a default value outside of 'defaultValue'.
finalizeCLParser :: forall proxy v . IsOption v
                 => proxy v -> Parser v -> ([String], Parser v)
finalizeCLParser _ p = (warnings, setCLParserShowDefaultValue mbDef p)
  where
    mbDef :: Maybe String
    mbDef = showDefaultValue (defaultValue :: v)

    warnings :: [String]
    warnings = catMaybes [multipleOptPsWarning, badDefaultWarning]

    -- Warn if a Parser defines multiple options, as this breaks an assumption
    -- that setCLParserShowDefaultValue relies on.
    multipleOptPsWarning :: Maybe String
    multipleOptPsWarning
      | numOptPs p > 1
      = Just $ unlines
        [ prov
        , "optionCLParser defines multiple options. Consider only defining"
        , "a single option here, as defining multiple options does not play"
        , "well with how tasty displays default values."
        ]
      | otherwise
      = Nothing

    -- Warning if a Parser has a default value (outside of IsOption's
    -- defaultValue method, that is), as this interferes with tasty's ability
    -- to read arguments from environment variables. For more on this point,
    -- see the Haddocks for optionCLParser.
    badDefaultWarning :: Maybe String
    badDefaultWarning
      -- evalParser will only return Just if has a default value declared with
      -- e.g. the Options.Applicative.value function.
      | isJust (evalParser p)
      = Just $ unlines
        [ prov
        , "Using default values (e.g., with Options.Applicative.value) in"
        , "optionCLParser is prohibited, as it interferes with tasty's ability"
        , "to read environment variable options properly. Moreover, assigning"
        , "default values is unnecessary, as their functionality is subsumed"
        , "by the defaultValue method of IsOption."
        ]
      | otherwise
      = Nothing

    prov :: String
    prov = "WARNING (in the IsOption instance for "
             ++ show (typeRep (Proxy :: Proxy v)) ++ "):"

-- Replace an `optionCLParser`'s 'propShowDefault' with 'showDefaultValue' from
-- the 'IsOption' class. It's tempting to try doing this when constructing the
-- 'Parser' itself using 'optionMod', but @optparse-applicative@'s 'mkParser'
-- function always overrides the result of 'optionMod'. Ugh.
setCLParserShowDefaultValue :: Maybe String -> Parser a -> Parser a
setCLParserShowDefaultValue mbDef = go
  where
    go :: Parser a -> Parser a
    -- Note that we /always/ replace the Option's optProps, regardless of
    -- what type it may have. This can produce unexpected results if an
    -- optionCLParser defines multiple options, which is why we emit a warning
    -- (in finalizeCLParser) if a Parser does this.
    go (OptP o)      = OptP o{Applicative.optProps =
                              modifyDefault (Applicative.optProps o)}
    go p@NilP{}      = p
    go (MultP p1 p2) = MultP (go p1) (go p2)
    go (AltP  p1 p2) = AltP  (go p1) (go p2)
    go (BindP p1 p2) = BindP (go p1) (fmap go p2)

    modifyDefault :: OptProperties -> OptProperties
    modifyDefault op = op{propShowDefault = mbDef}

-- Note: this is a conservative estimate, since we cannot count the number
-- of OptPs in the continuation argument of BindP. But BindP is really only
-- used for ParserM purposes, and since ParserM is an internal
-- optparse-applicative definition, most optionCLParser instances are
-- unlikely to use it in practice.
numOptPs :: Parser a -> Int
numOptPs OptP{} = 1
numOptPs NilP{} = 0
numOptPs (MultP p1  p2) = numOptPs p1 + numOptPs p2
numOptPs (AltP  p1  p2) = numOptPs p1 + numOptPs p2
numOptPs (BindP p1 _p2) = numOptPs p1

-- | The command line parser for the test suite, alongside any related
-- warnings.
--
-- @since 1.3
suiteOptionParser :: [Ingredient] -> TestTree -> ([String], Parser OptionSet)
suiteOptionParser ins tree = optionParser $ suiteOptions ins tree

-- | Parse the command-line and environment options passed to tasty.
--
-- Useful if you need to get the options before 'defaultMain' is called.
--
-- Once within the test tree, 'askOption' should be used instead.
--
-- The arguments to this function should be the same as for
-- 'defaultMainWithIngredients'. If you don't use any custom ingredients,
-- pass 'defaultIngredients'.
parseOptions :: [Ingredient] -> TestTree -> IO OptionSet
parseOptions ins tree = do
  let (warnings, parser) = suiteOptionParser ins tree
  mapM_ (hPutStrLn stderr) warnings
  cmdlineOpts <- execParser $
    info (helper <*> parser)
    ( fullDesc <>
      header "Mmm... tasty test suite"
    )
  envOpts <- suiteEnvOptions ins tree
  return $ envOpts <> cmdlineOpts

-- | Parse the command line arguments and run the tests using the provided
-- ingredient list.
--
-- When the tests finish, this function calls 'exitWith' with the exit code
-- that indicates whether any tests have failed. See 'defaultMain' for
-- details.
defaultMainWithIngredients :: [Ingredient] -> TestTree -> IO ()
defaultMainWithIngredients ins testTree = do
  installSignalHandlers
  opts <- parseOptions ins testTree

  case tryIngredients ins opts testTree of
    Nothing -> do
      hPutStrLn stderr
        "No ingredients agreed to run. Something is wrong either with your ingredient set or the options."
      exitFailure
    Just act -> do
      ok <- act
      if ok then exitSuccess else exitFailure