{-# LANGUAGE TemplateHaskell, LambdaCase #-}
module Puppet.Testing
    ( module Control.Lens
    , module Data.Monoid
    , module Puppet.PP
    , module Puppet.Interpreter.Types
    , module Puppet.Lens
    , H.hspec
    , basicTest
    , usersGroupsDefined
    , testingDaemon
    , defaultDaemon
    , testCatalog
    , describeCatalog
    , it
    , shouldBe
    , PSpec
    , PSpecM
    , lCatalog
    , lModuledir
    , lPuppetdir
    , withResource
    , withParameter
    , withParameters
    , withFileContent
    ) where

import Prelude hiding (notElem,all)
import Control.Lens
import Data.Foldable hiding (forM_,mapM_)
import Data.Maybe
import Data.Monoid
import Control.Monad.Error
import Control.Monad.Reader
import Control.Applicative
import System.Posix.Files
import qualified Data.HashSet as HS
import qualified Data.Either.Strict as S
import qualified Data.Text as T
import qualified System.Log.Logger as LOG
import qualified Test.Hspec as H
import qualified Test.Hspec.Formatters as H
import qualified Test.Hspec.Runner as H
import qualified Test.Hspec.Core as HC
import Facter
import PuppetDB.Common

import Puppet.Preferences
import Puppet.PP hiding ((<$>))
import Puppet.Daemon
import Puppet.Lens
import Puppet.Interpreter.Types
import Puppet.Interpreter.PrettyPrinter ()

-- | The state of the reader monad the tests run in
data TestEnv = TestEnv { _lCatalog   :: FinalCatalog
                       , _lModuledir :: FilePath
                       , _lPuppetdir :: FilePath
                       }
makeClassy ''TestEnv

type PSpecM = ReaderT TestEnv HC.SpecM
type PSpec = PSpecM ()

testCatalog ::  Nodename -> FilePath -> FinalCatalog -> PSpec -> IO H.Summary
testCatalog nd pdir catlg test = H.hspecWith (H.defaultConfig { H.configFormatter = H.silent { H.failedFormatter = fform } })
                                             (describeCatalog nd pdir catlg test)
    where
        fform = do
            failures <- H.getFailMessages
            forM_ failures $ \(H.FailureRecord path reason) -> do
                H.write ("[" ++ T.unpack nd ++ "] ")
                H.writeLine (snd path)
                let err = either (("uncaught exception: " ++) . H.formatException) id reason
                H.withFailColor $ unless (null err) $ H.writeLine err
            unless (null failures) H.newParagraph

describeCatalog :: Nodename -> FilePath -> FinalCatalog -> PSpec -> H.Spec
describeCatalog nd pdir catlg test = H.describe (T.unpack nd) $ runReaderT test (TestEnv catlg (pdir <> "/modules") pdir)

-- | This tests that file sources are valid.
basicTest :: PSpec
basicTest = hTestFileSources

-- | This tests that all users and groups used as resource parameters are
-- defined
usersGroupsDefined :: PSpec
usersGroupsDefined = do
    c <- view lCatalog
    let getResourceType t = c ^.. traverse . filtered (\r -> r ^. rid . itype == t && r ^. rattributes . at "ensure" /= Just "absent")
        users = getResourceType "user"
        groups = getResourceType "group"
        knownUsers = HS.fromList $ map (view (rid . iname)) users ++ ["root","","syslog","mysql","puppet","vagrant","nginx","www-data","nagios", "postgres"]
        knownGroups = HS.fromList $ map (view (rid . iname)) groups ++ ["root", "adm", "syslog", "mysql", "nagios","puppet","","www-data", "postgres"]
        checkResource lensU lensG = mapM_ (checkResource' lensU lensG)
        checkResource' lensU lensG res = do
            let d = "Resource " <> show (pretty res) <> " should have a valid "
            case lensU of
                Just lensU' -> do
                    let u = res ^. rattributes . lensU' . _PString
                    H.it (d <> "username (" ++ T.unpack u ++ ")") (HS.member u knownUsers)
                Nothing -> return ()
            case lensG of
                Just lensG' -> do
                    let g = res ^. rattributes . lensG' . _PString
                    H.it (d <> "group (" ++ T.unpack g ++ ")") (HS.member g knownGroups)
                Nothing -> return ()
    lift $ do
        checkResource (Just $ ix "owner") (Just $ ix "group") (getResourceType "file")
        checkResource (Just $ ix "user")  (Just $ ix "group") (getResourceType "exec")
        checkResource (Just $ ix "user")  Nothing             (getResourceType "cron")
        checkResource (Just $ ix "user")  Nothing             (getResourceType "ssh_authorized_key")
        checkResource (Just $ ix "user")  Nothing             (getResourceType "ssh_authorized_key_secure")
        checkResource (Nothing)           (Just $ ix "gid") users

it :: HC.Example a => String -> PSpecM a -> PSpec
it n tst = tst >>= lift . H.it n

shouldBe :: (Show a, Eq a) => a -> a -> PSpecM H.Expectation
shouldBe a b = return (a `H.shouldBe` b)

-- | Run tests on a specific resource
withResource :: String -- ^ The test description (the thing that goes after should)
             -> T.Text -- ^ Resource type
             -> T.Text -- ^ Resource name
             -> (Resource -> H.Expectation) -- ^ Testing function
             -> PSpec
withResource desc t n o = do
    let ridentifier = RIdentifier t n
    mr <- view (lCatalog . at ridentifier)
    lift $ case mr of
        Nothing -> H.it ("Should have resource " ++ show (pretty ridentifier)) (H.expectationFailure "Resource not found")
        Just v -> H.it ("Resource " ++ show (pretty ridentifier) ++ " should " ++ desc) (o v)

-- | Tests a specific parameter
withParameter :: T.Text   -- ^ The parameter name
              -> Resource -- ^ The resource to test
              -> (PValue -> H.Expectation) -- ^ Testing function
              -> H.Expectation
withParameter prm r o = do
    case r ^. rattributes . at prm of
        Nothing -> H.expectationFailure ("Parameter " ++ T.unpack prm ++ " not found")
        Just v -> o v

-- | Test a serie of parameters
withParameters :: [(T.Text, PValue)] -- ^ The parameter names and values
               -> Resource -- ^ The resource to test
               -> H.Expectation
withParameters prmlist r = forM_ prmlist $ \(nm, vl) -> withParameter nm r (`H.shouldBe` vl)

-- | Retrieves a given file content, and runs a test on it. It works on the
-- explicit "content" parameter, or can resolve the "source" parameter to
-- open the file.
withFileContent :: String -- ^ Test description (the thing that goes after should)
                -> T.Text -- ^ The file path
                -> (T.Text -> H.Expectation) -- ^ Testing function
                -> PSpec
withFileContent desc fn action = withResource desc "file" fn $ \r ->
    case r ^? rattributes . ix "content" . _PString of
        Just v  -> action v
        Nothing -> H.expectationFailure "Content not found"

hTestFileSources :: PSpec
hTestFileSources = do
    let getFiles = filter presentFile . toList
        presentFile r | r ^. rid . itype /= "file" = False
                      | (r ^. rattributes . at "ensure") `notElem` [Nothing, Just "present"] = False
                      | r ^. rattributes . at "source" == Just PUndef = False
                      | otherwise = True
        getSource = mapMaybe (\r -> (,) `fmap` pure r <*> r ^. rattributes . at "source")
    files <- fmap (getSource . getFiles) $ view lCatalog
    pdir <- view lPuppetdir
    forM_ files $ \(r,filesource) -> it ("should have a source for " ++ r ^. rid . iname . to T.unpack) $ do
        let
            testFile :: FilePath -> ErrorT PrettyError IO ()
            testFile fp = liftIO (fileExist fp) >>= (`unless` (throwError $ PrettyError $ "Searched in" <+> string fp))
            checkFile :: PValue -> ErrorT PrettyError IO ()
            checkFile res@(PArray ar) = asum [checkFile x | x <- toList ar] <|> throwError (PrettyError $ "Could not find the file in" <+> pretty res)
            checkFile (PString f) =
                case (T.stripPrefix "puppet:///" f, T.stripPrefix "file:///" f) of
                    (Just stringdir, _) -> case T.splitOn "/" stringdir of
                                               ("modules":modulename:rest) -> testFile (pdir <> "/modules/" <> T.unpack modulename <> "/files/" <> T.unpack (T.intercalate "/" rest))
                                               ("files":rest) -> testFile (pdir <> "/files/" <> T.unpack (T.intercalate "/" rest))
                                               ("private":_) -> return ()
                                               _ -> throwError (PrettyError $ "Invalid file source:" <+> ttext f)
                    (Nothing, Just _) -> return ()
                    _ -> throwError (PrettyError $ "The source does not start with puppet:///, but is" <+> ttext f)
            checkFile x = throwError (PrettyError $ "Source was not a string, but" <+> pretty x)
        return $ do
            rs <- runErrorT (checkFile filesource)
            case rs of
                Right () -> return ()
                Left rr -> fail (show (getError rr))

-- | Initializes a daemon made for running tests, using the specific test
-- puppetDB
testingDaemon :: PuppetDBAPI IO -- ^ Contains the puppetdb API functions
              -> FilePath -- ^ Path to the manifests
              -> (T.Text -> IO Facts) -- ^ The facter function
              -> IO (T.Text -> IO (S.Either PrettyError (FinalCatalog, EdgeMap, FinalCatalog, [Resource])))
testingDaemon pdb pdir allFacts = do
    LOG.updateGlobalLogger "Puppet.Daemon" (LOG.setLevel LOG.WARNING)
    prefs <- setupPreferences pdir (prefPDB.~pdb)
    q <- initDaemon prefs
    return (\nodname -> allFacts nodname >>= _dGetCatalog q nodname)

-- | A default testing daemon.
defaultDaemon :: FilePath -> IO (T.Text -> IO (S.Either PrettyError (FinalCatalog, EdgeMap, FinalCatalog, [Resource])))
defaultDaemon pdir = do
    pdb <- getDefaultDB PDBTest >>= \case
                S.Left x -> error (show (getError x))
                S.Right y -> return y
    testingDaemon pdb pdir (flip puppetDBFacts pdb)