{-# LANGUAGE LambdaCase        #-}
{-# LANGUAGE NamedFieldPuns    #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE RecordWildCards   #-}
{-# LANGUAGE StrictData        #-}
{-# LANGUAGE CPP               #-}
module Main where

import           XPrelude                      hiding (option, (<>))

import qualified Control.Concurrent.Async      as Async
import qualified Data.Aeson                    as Aeson
import qualified Data.HashMap.Strict           as Map
import qualified Data.HashSet                  as HS
import qualified Data.List                     as List
import           Data.Semigroup                as Sem   hiding (option)
import qualified Data.Set                      as Set
import qualified Data.Text                     as Text
import           Data.Text.Strict.Lens         (unpacked)
import qualified Data.Vector                   as V
import qualified Data.Version                  (showVersion)
import qualified Network.HTTP.Client           as Http
import           Options.Applicative
import qualified Paths_language_puppet         as Meta
import qualified System.FilePath.Glob          as Glob
import           System.IO                     (hIsTerminalDevice)
import qualified System.Log.Logger             as Log
import qualified Text.Regex.PCRE.String        as Reg

import           Puppet.Parser                 hiding (Parser)
import           Puppet.Runner
import           PuppetDB                      (PuppetDBAPI, dummyPuppetDB, pdbConnect,
                                                puppetDBFacts)
import qualified PuppetDB

type QueryFunc = NodeName -> IO (Either PrettyError (FinalCatalog, EdgeMap, FinalCatalog, [Resource]))

data MultNodes
  = MultNodes [Text]
  | AllNodes
  deriving (Show)

readMultNodes:: String -> Maybe MultNodes
readMultNodes "allnodes" = Just AllNodes
readMultNodes n          = Just (MultNodes (Text.splitOn "," (toS n)))

data Options = Options
  { _optShowjson     :: Bool
  , _optShowContent  :: Bool
  , _optResourceType :: Maybe Text
  , _optResourceName :: Maybe Text
  , _optPuppetdir    :: Maybe FilePath
  , _optNodename     :: Maybe NodeName
  , _optMultnodes    :: Maybe MultNodes
  , _optDeadcode     :: Bool
  , _optPdburl       :: Maybe String
  , _optPdbfile      :: Maybe FilePath
  , _optLoglevel     :: Log.Priority
  , _optHieraFile    :: Maybe FilePath
  , _optCommitDB     :: Bool
  , _optCheckExport  :: Bool
  , _optIgnoredMods  :: Maybe (HS.HashSet Text)
  , _optParse        :: Maybe FilePath
  , _optStrictMode   :: Bool
  , _optNoExtraTests :: Bool
  , _optVersion      :: Bool
  , _optRebaseFile   :: Maybe FilePath
  } deriving (Show)

options :: Parser Options
options = Options
   <$> switch
       (  long "JSON"
       <> short 'j'
       <> help "Shows the output as a JSON document (useful for full catalog views)")
   <*> switch
       (  long "showcontent"
       <> short 'c'
       <> help "When specifying a file resource, only output its content (useful for testing templates)")
   <*> optional (Text.pack <$> strOption
       (  long "type"
       <> short 't'
       <> help "Filter the output by resource type (accepts a regular expression, ie '^file$')"))
   <*> optional (Text.pack <$> strOption
       (  long "name"
       <> short 'n'
       <> help "Filter the output by resource name (accepts a regular expression)"))
   <*> optional (strOption
       (  long "puppetdir"
       <> short 'p'
       <> help "Puppet directory"))
   <*> optional (Text.pack <$> strOption
            (  long "node"
            <> short 'o'
            <> help "The name of the node"))
   <*> optional (option (maybeReader readMultNodes)
       (  long "all"
       <> short 'a'
       <> help "Values are a list of nodes or \"allnodes\""))
   <*> switch
       (  long "deadcode"
       <> help "Show deadcode when the --all option is used")
   <*> optional (strOption
       (  long "pdburl"
       <> help "URL of the puppetdb (ie. http://localhost:8080/)."))
   <*> optional (strOption
       (  long "pdbfile"
       <> help "Path to the testing PuppetDB file."))
   <*> option auto
       (  long "loglevel"
       <> short 'v'
       <> help "Values are : DEBUG, INFO, NOTICE, WARNING, ERROR"
       <> value Log.WARNING)
   <*> optional (strOption
       (  long "hiera"
       <> help "Path to the Hiera configuration file (default hiera.yaml)"))
   <*> switch
       (  long "commitdb"
       <> help "Commit the computed catalogs in the puppetDB")
   <*> switch
       (  long "checkExported"
       <> help "Save exported resources in the puppetDB")
   <*> optional (HS.fromList . Text.splitOn "," . Text.pack <$>
       strOption
       (  long "ignoredmodules"
       <> help "Specify a comma-separated list of modules to ignore"))
   <*> optional (strOption
       (  long "parse"
       <> help "Parse a single file"))
   <*> switch
       (  long "strict"
       <> help "Strict mode diverges from vanillia Puppet and enforces good practices")
   <*> flag False True
       (  long "noextratests"
       <> help "Disable extra tests (eg.: check that files exist on local disk")
   <*> switch
       (  long "version"
       <> help "Output version information and exit")
   <*> optional (strOption
       (  long "rebasefile"
       <> help "Rebase all calls to the 'file' function so that absolute path are relative to the Puppet directory"))


-- | Like catMaybes, but it counts the Nothing values
catMaybesCount :: [Maybe a] -> ([a], Sum Int)
catMaybesCount = foldMap f
  where
    f Nothing  = ([ ], Sum 1)
    f (Just x) = ([x], Sum 0)

{-| Does all the work of initializing a daemon for querying.
Returns a 'QueryFunc' API, together with some stats.
Be aware it uses the locale computer to generate a set of `facts` (this is a bit hackish).
These facts can be overriden at the command line (see 'Options').
-}
initializedaemonWithPuppet :: FilePath
                           -> Options
                           -> IO (QueryFunc, PuppetDBAPI IO, MStats, MStats, MStats)
initializedaemonWithPuppet workingdir Options {..} = do
    mgr <- Http.newManager Http.defaultManagerSettings
    pdbapi <- case (_optPdburl, _optPdbfile) of
                (Nothing, Nothing) -> return dummyPuppetDB
                (Just _, Just _)   -> panic "You must choose between a testing PuppetDB and a remote one"
                (Just url, _)      -> pdbConnect mgr url >>= unwrapError "Error when connecting to the remote PuppetDB"
                (_, Just file)     -> PuppetDB.loadTestDB file >>= unwrapError "Error when initializing the PuppetDB API"
    pref <- dfPreferences workingdir <&> prefPDB .~ pdbapi
                                     <&> prefHieraPath .~ _optHieraFile
                                     <&> prefIgnoredmodules %~ (`fromMaybe` _optIgnoredMods)
                                     <&> (if _optStrictMode then prefStrictness .~ Strict else identity)
                                     <&> (if _optNoExtraTests then prefExtraTests .~ False else identity)
                                     <&> prefLogLevel .~ _optLoglevel
                                     <&> prefRebaseFile .~ _optRebaseFile
    d <- initDaemon pref
    let queryfunc = \node -> fmap (unifyFacts (pref ^. prefFactsDefault) (pref ^. prefFactsOverride)) (puppetDBFacts node pdbapi) >>= getCatalog d node
    pure (queryfunc, pdbapi, parserStats d, catalogStats d, templateStats d)
    where
      -- merge 3 sets of facts : some defaults, the original set and some override
      unifyFacts :: Container PValue -> Container PValue -> Container PValue -> Container PValue
      unifyFacts defaults override c = override `Map.union` c `Map.union` defaults

parseFile :: FilePath -> IO (V.Vector Statement)
parseFile fp = do
  s <- readFile fp
  case runPuppetParser fp s of
    Left err -> putDoc (red "ERROR:" <+> getError (prettyParseError err)) *> exitFailure
    Right r -> pure r

printContent :: Text -> FinalCatalog -> IO ()
printContent filename catalog =
  case Map.lookup (RIdentifier "file" filename) catalog of
    Nothing -> panic "File not found"
    Just r ->
      case Map.lookup "content" (r ^. rattributes) of
        Nothing          -> panic "This file has no content"
        Just (PString c) -> putText c
        Just x           -> print x

prepareForPuppetApply :: WireCatalog -> WireCatalog
prepareForPuppetApply wcat =
  let res = V.filter (\r -> r ^. rvirtuality == Normal) (wcat ^. wireCatalogResources) :: V.Vector Resource
      -- step 1 : capitalize resources types (and names in case of
      -- classes), and filter out exported stuff
      capi :: RIdentifier -> RIdentifier
      capi r = r & itype %~ capitalizeRT & if r ^. itype == "class"
                                             then iname %~ capitalizeRT
                                             else identity
      aliasMap :: HashMap RIdentifier Text
      aliasMap = Map.fromList $ concatMap genAliasList (res ^.. folded)
      genAliasList r = map (\n -> (RIdentifier (r ^. rid . itype) n, r ^. rid . iname)) (r ^. rid . iname : r ^.. ralias . folded)
      nr = V.map (rid %~ capi) res
      ne = V.map capEdge (wcat ^. wireCatalogEdges)
      capEdge (PuppetEdge a b x) = PuppetEdge (capi a) (capi b) x
      -- step 2 : replace all references with the resource title in case
      -- of aliases - yes this sucks
      knownEdge :: PuppetEdge -> Bool
      knownEdge (PuppetEdge s d _) = (aliasMap & has (ix s)) && (aliasMap & has (ix d))
      correctEdges = V.map correctEdge $ V.filter knownEdge ne
      correctResources = V.map correctResource nr
      correct :: RIdentifier -> RIdentifier
      correct ri = ri & iname %~ \n -> Map.lookupDefault n ri aliasMap
      correctEdge :: PuppetEdge -> PuppetEdge
      correctEdge (PuppetEdge s d x) = PuppetEdge (correct s) (correct d) x
      correctResource :: Resource -> Resource
      correctResource r = r & rrelations %~ Map.fromList . filter (\(x,_) -> aliasMap & has (ix x)) . map (_1 %~ correct) . Map.toList
  in wcat & (wireCatalogResources .~ correctResources)
          & (wireCatalogEdges     .~ correctEdges)


findDeadCode :: String -> [Resource] -> Set.Set FilePath -> IO ()
findDeadCode puppetdir catalogs allfiles = do
    -- first collect all files / positions from all the catalogs
    let allpositions = Set.fromList $ catalogs ^.. traverse . rpos
    -- now find all haskell files
    puppetfiles <- Set.fromList <$> Glob.globDir1 (Glob.compile "**/*.pp") (puppetdir <> "/modules")
    let deadfiles = Set.filter ("/manifests/" `List.isInfixOf`) $ puppetfiles `Set.difference`   allfiles
        usedfiles = puppetfiles `Set.intersection` allfiles
    unless (Set.null deadfiles) $ do
        putDoc ("The following files" <+> pretty (Set.size deadfiles) <+> "are not used: " <> list (map ppstring $ Set.toList deadfiles))
        putText ""
    allparses <- do
     let parsefp fp = runPuppetParser fp <$> readFile fp
     Async.mapConcurrently parsefp (Set.toList usedfiles)
    let (parseFailed, parseSucceeded) = partitionEithers allparses
    unless (null parseFailed) $ do
        putDoc ("The following" <+> pretty (length parseFailed) <+> "files could not be parsed:" <> softline <> indent 4 (vcat (map (ppstring . show) parseFailed)))
        putText ""
    let getSubStatements s@ResourceDeclaration{} = [s]
        getSubStatements (ConditionalDeclaration (ConditionalDecl conds _)) = conds ^.. traverse . _2 . tgt
        getSubStatements s@ClassDeclaration{} = extractPrism s
        getSubStatements s@DefineDeclaration{} = extractPrism s
        getSubStatements s@NodeDeclaration{} = extractPrism s
        getSubStatements s@HigherOrderLambdaDeclaration{} = extractPrism s
        getSubStatements (TopContainer v s) = getSubStatements s ++ v ^.. tgt
        getSubStatements _ = []
        tgt = folded . to getSubStatements . folded
        extractPrism = toListOf (_Statements . traverse . to getSubStatements . traverse)
        allResources = parseSucceeded ^.. folded . folded . to getSubStatements . folded
        deadResources = filter isDead allResources
        isDead (ResourceDeclaration (ResDecl _ _ _ _ pp)) = not $ Set.member pp allpositions
        isDead _                                          = True
    unless (null deadResources) $
        putDoc ("The following" <+> pretty (length deadResources) <+> "resource declarations are not used:" <> softline <> indent 4 (vcat (map pretty deadResources)) <> line )

newtype Maximum a = Maximum { getMaximum :: Maybe a }

instance (Ord a) => Semigroup (Maximum a) where
  Maximum Nothing <> m2                  = m2
  m1 <> Maximum Nothing                  = m1
  Maximum (Just a1) <> Maximum (Just a2) = Maximum (Just (max a1 a2))

instance (Ord a) => Monoid (Maximum a) where
  mempty = Maximum Nothing
#if !(MIN_VERSION_base(4,11,0))
  mappend = (Sem.<>)
#endif

-- For each node, queryfunc the catalog and return stats
computeStats :: FilePath -> Options -> QueryFunc -> (MStats, MStats, MStats) -> [NodeName] -> IO ()
computeStats workingdir Options {..}
             queryfunc (parsingStats, catalogStats, templateStats)
             topnodes = do
  -- the parsing statistics, so that we known which files
  (cats, Sum failures) <- catMaybesCount <$> Async.mapConcurrently (compute queryfunc) topnodes
  pStats <- getStats parsingStats
  cStats <- getStats catalogStats
  tStats <- getStats templateStats
  let allres = (cats ^.. folded . _1 . folded) ++ (cats ^.. folded . _2 . folded)
      allfiles = Set.fromList $ map Text.unpack $ Map.keys pStats
  when _optDeadcode $ findDeadCode workingdir allres allfiles
  -- compute statistics
  let (parsing,    Just (wPName, wPMean)) = worstAndSum pStats
      (cataloging, Just (wCName, wCMean)) = worstAndSum cStats
      (templating, Just (wTName, wTMean)) = worstAndSum tStats
      parserShare = 100 * parsing / cataloging
      templateShare = 100 * templating / cataloging
      formatDouble = Text.take 5 . show -- yeah, well ...
      nbnodes = length topnodes
      worstAndSum =   (_1 %~ getSum)
                    . (_2 %~ fmap swap . getMaximum)
                    . ifoldMap (\k (StatsPoint cnt total _ _) -> (Sum total, Maximum $ Just (total / fromIntegral cnt, k)))
  putStr ("\nTested " ++ show nbnodes ++ " nodes. ")
  unless (nbnodes == 0) $ do
    putText (formatDouble parserShare <> "% of total CPU time spent parsing, " <> formatDouble templateShare <> "% spent computing templates")
    when (_optLoglevel <= Log.NOTICE) $ do
      putText ("Slowest template:           " <> wTName <> ", taking " <> formatDouble wTMean <> "s on average")
      putText ("Slowest file to parse:      " <> wPName <> ", taking " <> formatDouble wPMean <> "s on average")
      putText ("Slowest catalog to compute: " <> wCName <> ", taking " <> formatDouble wCMean <> "s on average")

  if failures > 0
     then do {putDoc ("Found" <+> red (pretty failures) <+> "failure(s)." <> line) ; exitFailure}
     else do {putDoc (dullgreen "All green."  <> line) ; exitSuccess}

  where
    compute :: QueryFunc -> NodeName -> IO (Maybe (FinalCatalog, [Resource]))
    compute func node =
      func node >>= \case
        Left err -> putDoc (line <> red "ERROR:" <+> parens (ppline node) <+> ":" <+> getError err) >> return Nothing
        Right (rawcatalog, _ , rawexported, knownRes) -> return (Just (rawcatalog <> rawexported, knownRes))

-- Queryfunc the catalog for the node and PP the result
computeNodeCatalog :: Options -> QueryFunc -> PuppetDBAPI IO -> NodeName -> IO ()
computeNodeCatalog Options {..} queryfunc pdbapi node =
  queryfunc node >>= \case
    Left rr -> do
      putDoc (line <> red "ERROR:" <+> parens (ppline node) <+> getError rr)
      exitFailure
    Right (rawcatalog, edgemap, rawexported, _) -> do
      printFunc <- hIsTerminalDevice stdout >>= \isterm -> return $ \x ->
        if isterm
          then putDoc (x <> line)
          else displayIO stdout (renderCompact (x <> line))
      catalog  <- filterCatalog _optResourceType _optResourceName rawcatalog
      exported <- filterCatalog _optResourceType _optResourceName rawexported
      let wirecatalog    = PuppetDB.generateWireCatalog node (catalog    <> exported   ) edgemap
          rawWireCatalog = PuppetDB.generateWireCatalog node (rawcatalog <> rawexported) edgemap
      when _optCheckExport $ void $ runExceptT $ PuppetDB.replaceCatalog pdbapi rawWireCatalog
      case (_optShowContent, _optShowjson) of
        (_, True) -> putLByteString (Aeson.encode (prepareForPuppetApply wirecatalog))
        (True, _) -> do
          unless (_optResourceType == Just "file" || isNothing _optResourceType) $ do
              putDoc "Show content only works with resource of type file. It is an error to provide another filter type"
              exitFailure
          case _optResourceName of
            Just f  -> printContent f catalog
            Nothing -> die "You should supply a resource name when using showcontent"
        _       -> do
            printFunc (pretty (Map.elems catalog))
            unless (Map.null exported) $ do
              printFunc (mempty <+> dullyellow "Exported:" <+> mempty)
              printFunc (pretty (Map.elems exported))


-- Filter according to the type and name regex from the command line option
filterCatalog :: Maybe Text -> Maybe Text -> FinalCatalog -> IO FinalCatalog
filterCatalog typeFilter nameFilter =
  filterC typeFilter (_1 . itype . unpacked) >=> filterC nameFilter (_1 . iname . unpacked)
  where
    -- filter catalog using the adhoc lens
    filterC Nothing _ c = return c
    filterC (Just regexp) l c = Reg.compile Reg.compBlank Reg.execBlank (Text.unpack regexp) >>= \case
      Left rr   -> panic ("Error compiling regexp 're': "  <> show rr)
      Right reg -> Map.fromList <$> filterM (filterResource reg l) (Map.toList c)
    filterResource reg l v = Reg.execute reg (v ^. l) >>= \case
      Left rr -> panic ("Error when applying regexp: " <> show rr)
      Right Nothing -> return False
      _ -> return True


run :: Options -> IO ()
run Options {_optVersion = True, ..} = putStrLn ("language-puppet " ++ Data.Version.showVersion Meta.version)

-- Parse mode
run Options {_optParse = Just fp, ..} = do
  sx <- parseFile fp
  if _optLoglevel == Log.DEBUG
    then mapM_ print  sx
    else putDoc $ ppStatements sx

run Options {_optPuppetdir = Nothing, _optParse = Nothing } =
    panic "Without a puppet dir, only the `--parse` option can be supported"
run Options {_optPuppetdir = Just _, _optNodename = Nothing, _optMultnodes = Nothing} =
    panic "You need to choose between single or multiple node"

-- Single node mode (`--node` option)
run cmd@Options {_optNodename = Just node, _optPuppetdir = Just workingdir, ..} = do
  (queryfunc, pdbapi, _, _, _ ) <- initializedaemonWithPuppet workingdir cmd
  computeNodeCatalog cmd queryfunc pdbapi node
  when _optCommitDB $ void $ runExceptT $ PuppetDB.commitDB pdbapi

-- Multiple nodes mode (`--all`) option
run cmd@Options {_optNodename = Nothing, _optMultnodes = Just nodes, _optPuppetdir = Just workingdir} = do
  (queryfunc, _, mPStats, mCStats, mTStats) <- initializedaemonWithPuppet workingdir cmd
  computeStats workingdir cmd queryfunc (mPStats, mCStats, mTStats) =<< retrieveNodes nodes
  where
    retrieveNodes :: MultNodes -> IO [NodeName]
    retrieveNodes AllNodes = do
      allstmts <- parseFile (workingdir <> "/manifests/site.pp")
      let getNodeName (NodeDeclaration (NodeDecl (NodeName n) _ _ _)) = Just n
          getNodeName _                                               = Nothing
      return $ mapMaybe getNodeName (V.toList allstmts)
    retrieveNodes (MultNodes xs) = pure xs

main :: IO ()
main = execParser opts >>= run
  where
    opts :: ParserInfo Options
    opts = info (helper <*> options)
             (fullDesc
              <> progDesc "A program for parsing puppet files, generating and inspecting catalogs"
              <> header "puppetresources - a development tool for Puppet"
              <> failureCode 3)
