{-# LANGUAGE CPP,
             ImplicitParams,
             FlexibleContexts,
             FlexibleInstances,
             RecordWildCards,
             TupleSections,
             TypeSynonymInstances #-}
-----------------------------------------------------------------------------
{- |
Module: System.Console.StructuredCLI
Description: Application library for building interactive console CLIs
Copyright: (c) Erick Gonzalez, 2017-2018
License: BSD3
Maintainer: erick@codemonkeylabs.de

This module provides the tools to build a complete "structured" CLI application, similar
to those found in systems like Cisco IOS or console configuration utilities etc. It aims
to be easy for implementors to use.

-}
module System.Console.StructuredCLI (
-- * How to use this module:
-- |
-- It is often the case that a simple example is the best user guide, at least for the
-- experienced programmer. The following code illustrates a basic but functioning CLI application
--
-- @
-- module Main where
--
-- import Control.Monad                 (void)
-- import Control.Monad.IO.Class        (liftIO)
-- import Data.Default                  (def)
-- import System.Console.StructuredCLI
--
-- root :: Commands ()
-- root = do
--   world >+ do
--     hello
--     bye
--     command "exit" "return to previous level" exit
--
-- world :: Commands ()
-- world = command "world" "enter into the world" $ return NewLevel
--
-- hello :: Commands ()
-- hello = command "hello" "prints a greeting" $ do
--           liftIO . putStrLn $ "Hello world!"
--           return NoAction
--
-- bye :: Commands ()
-- bye = command "bye" "say goodbye" $ do
--         liftIO . putStrLn $ "Sayonara!"
--         return NoAction
--
-- main :: IO ()
-- main = void $ runCLI "Hello CLI" def root
-- @
--
-- resulting example CLI session:
--
-- >>> Hello CLI > ?
-- - world: enter into the world
--
-- >>> Hello CLI > world
-- >>> Hello CLI world > ?
-- - exit: return to previous level
-- - bye: say goodbye
-- - hello: prints a greeting
--
-- >>> Hello CLI world > hello
-- Hello world!
-- >>> Hello CLI world > bye
-- Sayonara!
-- >>> Hello CLI world > exit
-- >>> Hello CLI >
--
-- A good way to get you started is to grab the example code available under <http://gitlab.com/codemonkeylabs/structured-cli/blob/master/example/Main.hs example/Main.hs> and modify it to suit your needs.
                                     Action(..),
                                     CLIException(..),
                                     Commands,
                                     CommandsT,
                                     Handler,
                                     Node,
                                     Parser,
                                     ParseResult(..),
                                     Settings(..),
                                     Validator,
                                     (>+),
                                     command,
                                     command',
                                     custom,
                                     exit,
                                     isCompleted,
                                     isIncomplete,
                                     isNoResult,
                                     labelParser,
                                     newLevel,
                                     noAction,
                                     param,
                                     param',
                                     runCLI,
                                     top) where

import Control.Applicative        (liftA2)
import Control.Monad              (mapM, replicateM_, void, when)
import Control.Monad.Except       (ExceptT(..), catchError, runExceptT, throwError)
import Control.Monad.IO.Class     (MonadIO, liftIO)
import Control.Monad.Trans        (MonadTrans, lift)
import Control.Monad.Trans.Maybe  (MaybeT(..), runMaybeT)
import Control.Monad.State.Strict (StateT, evalStateT, get, gets, modify, put)
import Data.Char                  (isSpace)
import Data.Default               (Default, def)
import Data.List                  (intercalate, isPrefixOf, sort, span)
import Data.Monoid                ((<>))
import System.Console.Haskeline   (MonadException)

import qualified System.Console.Haskeline as HL

#ifdef __DEBUG__
import Debug.Trace
debugM :: (Applicative f) => String -> f ()
debugM = traceM
#else
debugM :: (Applicative f) => String -> f ()
debugM _ = pure ()
#endif

data State m    = State { stack :: [ Level m ] }

type Level m = ( String, Node m )

type StateM m = StateT (State m) m

type Handler m  = String -> m Action

-- | An 'Action' is returned as the result of a command handler provided by the user and
-- it instructs the CLI of any changes in the CLI state
data Action
    -- | The command executed is "entered" into, creating a new CLI level.
    = NewLevel
    -- | Do not enter a new level.
    | NoAction
    -- | Reset the CLI state up to a given number of levels.
    | LevelUp Int
    -- | Go back up all the way to the top (root) of the CLI.
    | ToRoot
      deriving (Show)

-- | The 'Node' type contains the internal representation of a command. Normally there is no
-- need to be concerned with it other than perhaps passing it opaquely to any utility parsers
-- (like 'labelParser' for example), when writing a custom parser
data Node m = Node { getLabel    :: String,
                     getHint     :: String,
                     getBranches :: [Node m],
                     runParser   :: Parser m,
                     isEnabled   :: m Bool,
                     handle      :: Handler m }

type Parser m = Node m -> String -> m ParseResult

-- | A 'Validator' is a function to which a parsed string is given in order to perform
-- any checks for validity that may be applicable, or even transforming the argument if
-- necessary. Note that the validator runs in the "user" monad
type Validator m = String -> m (Maybe String)

type ExceptionHandler m = CLIException -> m (Either CLIException ())

-- | There is no need to concern oneself with the 'ParseResult' type unless one is writing
-- a custom parser, which should actually be rarer than not.
data ParseResult =
    Done {
      -- | Output string to be fed to the command action handler
      getOutput :: String,
      -- | Part of the string matched during parsing of a command
      getDoneMatched :: String,
      -- | Remaining input data
      getDoneRemaining :: String }
  | Partial {
      -- | List of possible completions along with a corresponding help string
      getPartialHints :: [(String, String)],
      -- | Remaining input data
      getPartialRemaining :: String }
  | Fail {
      -- | A message string containing a possible hint for correct useage
      getFailMessage :: String,
      -- | Remaining input data
      getFailRemaining :: String }
  -- | Parsing provided input doesnt match this command. The difference between 'Fail' and
  -- 'NoMatch' is a fine but important one. Failure should be used for example when a command
  -- keyword is correct but a required parameter is invalid or contains an error for example.
  -- A 'NoMatch' should be exclusively used when a command keyword does not correspond to the
  -- given input
  | NoMatch
    deriving Show

data Settings m
    -- | CLI Settings provided upon launching the CLI. It is recommended to modify
    -- the settings provided by the 'Default' instance: i.e:
    -- @
    -- def { getBanner = "My CLI" }
    -- @
    -- that way you can use for example the default exception handler which should suit
    -- usual needs, etc.
    = Settings {
      -- | An optional filename to activate and store the CLI command history function
      getHistory      :: Maybe FilePath,
      -- | Text to display upon start of the CLI application
      getBanner       :: String,
      -- | Prompt characters to display to the right of the current command "stack"
      getPrompt       :: String,
      -- | Disable prompt for use with batch scripts
      isBatch         :: Bool,
      -- | Exception handler
      handleException :: ExceptionHandler m }

data CLIException = Exit
                  | InternalError String
                  | SyntaxError String
                  | UndecisiveInput String [String]
                  | HelpRequested [(String, String)]
                  | InvalidOperation String
                    deriving Show

-- | The 'CommandsT' transformer monad is the key to building a CLI tree. It is meant to
-- be used as a transformer wrapping an application specific "user" monad (for example, a 'State'
-- monad encapsulating application state). This monad is executed _once_ upon calling 'runCLI'
-- to build the command tree. Keep in mind however that any parsers or actions used in
-- any given command all run in the "user" monad and unlike the process of building the command
-- tree, they will be called multiple times as the user navigates the CLI at runtime.
-- Each 'CommandsT' monadic action corresponds to a single "node" (a.k.a. command) in the CLI.
-- Succesive actions simply add commands to the current "level". It is possible to "nest"
-- a new level to a command by using the '(>+)' operator. When properly indented (see example code
-- above) it provides a pretty self explanatory way to build the CLI tree.
newtype CommandsT m a = CommandsT { runCommandsT :: m (a, [Node m]) }

-- | An alias type for the case where CommandsT wraps IO only (i.e. no state, etc)
type Commands         = CommandsT IO

instance (Functor f) => Functor (CommandsT f) where
    fmap f = CommandsT . fmap (\(a, w) -> (f a, w)) . runCommandsT

instance (Applicative a) => Applicative (CommandsT a) where
    pure    = CommandsT . pure . (, mempty)
    x <*> y = CommandsT $ liftA2 f (runCommandsT x) (runCommandsT y)
        where f (a, v) (b, w) = (a b, v <> w)

instance (Monad m) => Monad (CommandsT m) where
    return = pure
    m >>= f  = CommandsT $ do
               (a, v)  <- runCommandsT m
               (b, w) <- runCommandsT $ f a
               return $ (b, v <> w)
    fail msg = CommandsT $ fail msg

instance MonadTrans CommandsT where
    lift m = CommandsT $ do
               a <- m
               return (a, mempty)

instance (MonadIO m) => MonadIO (CommandsT m) where
    liftIO = lift . liftIO

instance (MonadIO m) => Default (Settings m) where
    def = Settings Nothing "" " > " False defExceptionHandler

instance (Monad m) => Default (Parser m) where
    def = labelParser

instance (Monad m) => Default (Validator m) where
    def = return . pure . id

type ParserT m = ExceptT CLIException (HL.InputT (StateM m))

liftStateM :: (Monad m) => StateM m a -> ParserT m a
liftStateM = lift . lift

liftInputT :: (Monad m) => HL.InputT (StateM m) a -> ParserT m a
liftInputT = lift

liftUserM :: (Monad m) => m a -> ParserT m a
liftUserM = lift . lift . lift

execCommandsT :: (Monad m) => CommandsT m a -> m [Node m]
execCommandsT  = fmap snd . runCommandsT

data SearchResult m = Completed { completedNode      :: Node m,
                                  completedOutput    :: String,
                                  completedMatched   :: String,
                                  completedRemaining :: String }
                    | Incomplete { incompleteNode    :: Node m,
                                   incompleteHints   :: [(String, String)] }
                    | Failed { failedNode :: Node m,
                               failedMsg :: String,
                               failedRemaining :: String }
                    | NoResult

isCompleted :: (Monad m) => SearchResult m -> Bool
isCompleted Completed{..} = True
isCompleted _             = False

isIncomplete :: (Monad m) => SearchResult m -> Bool
isIncomplete Incomplete{..} = True
isIncomplete _              = False

isNoResult :: (Monad m) => SearchResult m -> Bool
isNoResult NoResult = True
isNoResult _        = False

-- | the CommandsT "nest" operation. It adds a new deeper CLI level to the command on the left
-- side with the commands on the right side, for example:
-- @
-- activate >+ do
--   foo
--   bar
--   baz
-- @
-- Would result in the following CLI command structure:
--
-- >>> > activate
-- >>> activate > ?
-- >>> - foo ..
-- >>> - bar ..
-- >>> - baz ..
(>+) :: (Monad m) => CommandsT m () -> CommandsT m () -> CommandsT m ()
node >+ descendents = do
  node' <- lift $ execCommandsT node
  case node' of
    [] ->
        error $ "Cannot branch off empty command"
    _:_:_ ->
        error $ "Cannot branch off more than one command"
    [predecessor] ->
        CommandsT $ do
               ns <- execCommandsT descendents
               return ((), [predecessor { getBranches = ns }])

-- | Build a command node that is always active and takes no parameters
command :: (Monad m) => String    -- ^ Command keyword
                     -> String    -- ^ Help text for this command
                     -> m Action  -- ^ Action in the "user" monad (i.e. @return NewLevel@)
                     -> CommandsT m ()
command label hint action = do
  command' label hint (return True) action

-- | A variation of 'command' that allows for "disabling" the command at runtime by
-- running the given "enable" monadic action (as always in the "user" monad) to check
-- if the command should be displayed as an option and/or accepted or not.
command' :: (Monad m) => String    -- ^ Command keyword
                      -> String    -- ^ Help text for this command
                      -> m Bool    -- ^ Enable action in the "user" monad
                      -> m Action  -- ^ Action in the "user" monad (i.e. @return NewLevel@)
                      -> CommandsT m ()
command' label hint enable action = do
  custom label hint labelParser enable $ const action

-- | Build a command node that takes one parameter (delimited by space). The parsed parameter
-- is fed to the validator monadic function (in the "user" monad) and the resulting string
-- if any is fed in turn as an argument to the handler action (also in the "user" monad).
param :: (Monad m) => String       -- ^ Command keyword
                   -> String       -- ^ Help text for this command (including argument description)
                   -> Validator m  -- ^ Monadic validator (in the "user" monad)
                   -> Handler m    -- ^ Handling action. Takes the validator output as argument
                   -> CommandsT m ()
param label hint validator handler =
    param' label hint validator (return True) handler

-- | A variation of 'param' that allows for "disabling" the command at runtime by
-- running the given "enable" monadic action (as always in the "user" monad) to check
-- if the command should be displayed as an option and/or accepted or not.
param' :: (Monad m) => String       -- ^ Command keyword
                    -> String       -- ^ Help text for this command (including argument description)
                    -> Validator m  -- ^ Monadic validator (in the "user" monad)
                    -> m Bool       -- ^ Enable action in the "user" monad
                    -> Handler m    -- ^ Handling action. Takes the validator output as argument
                    -> CommandsT m ()
param' label hint validator enable handler = do
  custom label hint parser enable handler
         where parser = paramParser hint validator

-- | Create a command using a custom parser, providing thus complete flexibility
custom :: (Monad m) => String     -- ^ Command keyword
                    -> String     -- ^ Help text for this command
                    -> Parser m   -- ^ Custom parser (runs in the "user" monad)
                    -> m Bool     -- ^ Enable action in the "user" monad
                    -> Handler m  -- ^ Handling action. Takes the validator output as argument
                    -> CommandsT m ()
custom label hint parser enable handler = do
  let node = Node { getLabel    = label,
                    getHint     = hint,
                    getBranches = [],
                    runParser   = parser,
                    isEnabled   = enable,
                    handle      = handler }
  CommandsT . return $ ((), [node])

-- | A utility action to reset the CLI tree to the root node . Equivalent to @return ToRoot@
top :: (Monad m) => m Action
top = return ToRoot

-- | A utility action to "leave" the current CLI level. Equivalent to @return $ LevelUp 1@
exit :: (Monad m) => m Action
exit = return $ LevelUp 1

-- | A utility action to "nest" into a new CLI level. Equivalent to @return NewLevel@
newLevel :: (Monad m) => m Action
newLevel = return NewLevel

-- | A utility action to leave the current CLI level untouched. Equivalent to @return NoAction@
noAction :: (Monad m) => m Action
noAction = return NoAction

-- | A utility parser that reads an input and parses a command label. It can be used as part of
-- custom parsers to first read the command keyword before parsing any arguments etc.
labelParser :: (Monad m) => Node m -> String -> m ParseResult
labelParser Node{..} input = do
    case nextWord input of
      ("?", remaining) ->
        return $ Fail getHint remaining
      (word, remaining) | word == getLabel ->
        return $ Done "" word remaining
      (word, remaining) | word `isPrefixOf` getLabel ->
        return $ Partial [(getLabel, getHint)] remaining
      (_, _) ->
        return $ NoMatch

infixr 9 -.-
(-.-) :: (b -> c) -> (a -> a1 -> b) -> a -> a1 -> c
(-.-) = (.).(.)

paramParser :: (Monad m) => String -> (String -> m (Maybe String)) -> Node m -> String -> m ParseResult
paramParser hint validator = parseParam -.- labelParser
    where parseParam  = flip (>>=) parseParam'
          parseParam' (Done _ matched rest) =
              case nextWord rest of
                ("?", _) ->
                  return $ Fail hint rest
                ("", remaining) ->
                  return $ Partial [] remaining
                (word, remaining) -> do
                  v <- validator word
                  return $ maybe (badArg rest) (\x -> Done x (matched ++ ' ':word) remaining) v
          parseParam' result =
              return result
          badArg = Fail hint

nextWord :: String -> (String, String)
nextWord = span (not.isSpace) . dropWhile isSpace

hLineSettingsFrom :: (MonadIO m) => Settings m -> HL.Settings (StateM m)
hLineSettingsFrom Settings{..} =
    HL.setComplete explorer HL.defaultSettings { HL.historyFile = getHistory }

-- | Launches the CLI application. It doesn't normally return unless an exception is thrown
-- or if it runs out of input in batch mode. Normal return value is that returned by the CommandsT
-- action that built the tree. Remember that 'Settings' is an instance of 'Default'
runCLI :: (MonadException m) => String -> Settings m -> CommandsT m a -> m (Either CLIException a)
runCLI name settings@Settings{..} commands = do
  (value, root) <- runCommandsT commands
  when (not isBatch) $ liftIO . putStrLn $ getBanner
  let ?settings = settings
  withStateM root . HL.runInputT hLineSettings . runExceptT $ do
    loop
    return value
    where hLineSettings      = hLineSettingsFrom settings
          withStateM root    = flip evalStateT $ state0 root
          processInput       = do
            let ?settings = settings
            state <- liftStateM get
            runLevel `catchError` \e -> do
                liftStateM $ put state
                throwError e
            processInput
          dummyParser _ = \_ input ->
            return $ Partial [] input
          state0 root        = State [(name, mkNode root)]
          mkNode root = Node {
                          getLabel    = name,
                          getHint     = mempty,
                          getBranches = root,
                          runParser   = dummyParser root,
                          isEnabled   = return True,
                          handle      = const . return $ NewLevel
                        }
          loop =  do
            void . catchError processInput $
                                  \e -> do
                                    exceptionResult <- liftUserM $ handleException e
                                    either throwError return exceptionResult
                                    loop

defExceptionHandler :: (MonadIO m) => CLIException -> m (Either CLIException ())
defExceptionHandler (SyntaxError str) = do
    fmap Right . liftIO . putStrLn $ "SyntaxError at or around " ++ str ++ "\n"
defExceptionHandler (HelpRequested hints) =
    fmap Right . liftIO $ do
      mapM_ display $ hints
      putStrLn ""
        where display (label, hint) =
                  putStrLn $ "- " ++ label ++ ": " ++ hint
defExceptionHandler e =
    return . Left $ e

runLevel :: (?settings::Settings m, MonadException m) => ParserT m ()
runLevel = do
  prompt  <- buildPrompt <$> withLabels
  stack0  <- getStack
  result  <- runMaybeT $ do
              line  <- MaybeT . liftInputT $ HL.getInputLine prompt
              process line
  case result of
    Nothing ->
      if isBatch ?settings
         then throwError Exit
         else restore stack0
    _ ->
      return ()

    where buildPrompt ns   = (intercalate " " . reverse $ ns) ++ getPrompt ?settings
          withLabels       = getStack >>= return . fmap fst
          restore stack    = liftStateM . modify $ \s -> s { stack = stack }

getStack :: (Monad m) => ParserT m [Level m]
getStack = liftStateM $ gets stack

process :: (Monad m) => String -> MaybeT (ParserT m) ()
process input = lift $ do
  stack0 <- getStack
  node <- getCurrentNode
  action <- process' input node NewLevel -- I believe it shouldn't actually matter since it will
                                        -- simply be overriden by the last action result but
                                        -- NewLevel als default action is correct in term of the
                                        -- expected behaviour when parsing a command. We keep
                                        -- nesting until done..

  case action of
    NewLevel ->
        return ()
    LevelUp n ->
        levelUp n stack0
    NoAction ->
        levelUp 0 stack0
    ToRoot ->
        levelUp (-maxBound) stack0
    where levelUp levels stack0 = do
                stack <- getStack
                let depth  = length stack
                    depth0 = length stack0
                    depth' = max 1 $ depth0 - levels -- there must always be at least a root node
                    to     = depth - depth'
                replicateM_ to pop

process' :: (Monad m) => String -> Node m -> Action ->  ParserT m Action
process' "" _ action =
    return action
process' (' ':remaining) node action =
    process' remaining node action
process' input currentNode _ = do
  debugM $ "processing " ++ show input ++ " on " ++ getLabel currentNode
  result <- liftStateM $ findNext currentNode input
  case result of
    [Completed{ completedNode=node@Node{..}, ..}] -> do
      push completedMatched node
      action <- liftUserM $ handle completedOutput
      process' completedRemaining node action
    _ ->
      if checkForHelp . dropWhile isSpace $ reverse input then do
          let hints = foldl getHelp [] result
          debugM $ "help requested: " ++ show hints
          throwError . HelpRequested $ hints
      else
          throwError . SyntaxError $ input
    where checkForHelp ('?':_)       = True
          checkForHelp _             = False
          getHelp acc Failed{..}     = (getLabel failedNode, failedMsg):acc
          getHelp acc Incomplete{..} = incompleteHints ++ acc
          getHelp acc Completed{..}  = help completedNode : acc
          getHelp acc _              = acc

help :: (Monad m) => Node m -> (String , String)
help Node{..} = (getLabel, getHint)

push :: (Monad m) => String -> Node m -> ParserT m ()
push label node =
  liftStateM . modify $ \s@State{..} ->
      s { stack = (label, node) : stack }

pop :: (Monad m) => ParserT m ()
pop = do
  stack <- liftStateM $ gets stack
  case stack of
    (_:remaining) ->
        liftStateM $ modify $ \s -> s { stack = remaining }
    [] ->
        throwError . InvalidOperation $ "Invalid attempt to pop element from empty command stack"

getCurrentNode :: (Monad m) => ParserT m (Node m)
getCurrentNode = do
  stack <- liftStateM $ gets stack
  case stack of
    ((_, node):_) -> return node
    []            -> throwError . InternalError $ "Empty command stack"

findNext :: (Monad m) => Node m -> String -> StateM m [SearchResult m]
findNext root input = do
  filter (not . isNoResult) <$> mapM matching branches
      where matching node@Node{..} = do
              enabled <- lift isEnabled
              if enabled then do
                  result <- lift $ runParser node input
                  debugM $ "ran " ++ getLabel ++ " parser on " ++ show input ++ ": " ++ show result
                  case result of
                    Done output matched rest ->
                        return Completed { completedNode      = node,
                                           completedOutput    = output,
                                           completedMatched   = matched,
                                           completedRemaining = rest }
                    Fail msg rest ->
                        return Failed { failedNode = node,
                                        failedMsg  = msg,
                                        failedRemaining = rest }
                    Partial hints _ ->
                        return Incomplete { incompleteNode  = node,
                                            incompleteHints = hints }
                    NoMatch ->
                        return NoResult
              else
                  return NoResult
            branches = getBranches root

explorer :: (Monad m) => HL.CompletionFunc (StateM m)
explorer input@(tfel, _) = do
  currentLevel  <- gets stack
  possibilities <- case currentLevel of
                    (_, currentNode):_ ->
                        sort <$> getPossibilities currentNode left
                    _ ->
                        return []
  let complete = HL.completeWord Nothing " " $ \str ->
                   return $ map HL.simpleCompletion $ filter (str `isPrefixOf`) possibilities
  complete input
      where left = reverse tfel

getPossibilities :: (Monad m) => Node m -> String -> StateM m [String]
getPossibilities root input = do
  results <- findNext root input
  case filter isCompleted results of
    (_:_:_) ->
      return []
    Completed{..}:[] ->
      getPossibilities completedNode completedRemaining
    _ ->
      return $ fst <$> foldl getPossibilities' [] results
    where getPossibilities' acc Incomplete{..} = filter notEmpty incompleteHints ++ acc
          getPossibilities' acc _              = acc
          notEmpty ("", _) = False
          notEmpty (_, _)  = True