-- |
--
-- Copyright:
--   This file is part of the package byline. It is subject to the
--   license terms in the LICENSE file found in the top-level
--   directory of this distribution and at:
--
--     https://github.com/pjones/byline
--
--   No part of this package, including this file, may be copied,
--   modified, propagated, or distributed except according to the
--   terms contained in the LICENSE file.
--
-- License: BSD-2-Clause
--
-- Interactive shells.
module Byline.Shell
  ( -- * Shell-like Interfaces
    Shell (..),
    runShell,
    shellHelp,
    shellCompletion,

    -- * Re-exports
    module Byline.Completion,
  )
where

import Byline
import Byline.Completion
import qualified Data.Attoparsec.Text as Atto
import Data.Char
import qualified Data.Text as Text
import qualified Options.Applicative as O
import qualified Options.Applicative.Common as O
import qualified Options.Applicative.Types as O
import Relude.Extra.Map

-- | A type that describes how to process user-entered shells.
--
-- @since 1.1.0.0
data Shell a = Shell
  { -- | Optparse-applicative parser preferences.  If you don't have
    -- any specific needs you can use 'O.defaultPrefs' to get the
    -- default parser preferences.
    shellPrefs :: O.ParserPrefs,
    -- | The shell parser wrapped in a 'O.ParserInfo'.  This is
    -- generally created with the 'O.info' function.
    shellInfo :: O.ParserInfo a,
    -- | The prompt to display.
    shellPrompt :: Stylized Text
  }

-- | Run a single iteration of the shell.
--
-- @since 1.1.0.0
runShell ::
  MonadByline m =>
  (a -> m ()) ->
  Shell a ->
  m ()
runShell dispatch Shell {..} = do
  input <- askLn shellPrompt Nothing
  words <- shellSplit input
  unless (null words) (go (map toString words))
  where
    go words = do
      case O.execParserPure shellPrefs shellInfo words of
        O.Success a ->
          dispatch a
        O.Failure help -> do
          let str = fst (O.renderFailure help "")
          sayLn (text $ toText str)
        O.CompletionInvoked _ ->
          pure ()

-- | Print a list of shell commands.
--
-- @since 1.1.0.0
shellHelp ::
  MonadByline m =>
  Shell a ->
  m ()
shellHelp Shell {..} = do
  let h = O.parserFailure shellPrefs shellInfo (O.ShowHelpText Nothing) mempty
      s = fst (O.renderFailure h "")
  sayLn (text $ toText s)

-- | A completion function for shell commands.
--
-- Adds completion for subcommand names and their flags.
--
-- @since 1.1.0.0
shellCompletion :: Applicative m => Shell a -> CompletionFunc m
shellCompletion shell input@(left, _) = do
  if Text.null left || Text.all (isSpace >>> not) left
    then completionFromList CompHead (keys commands) input
    else completionFromList CompTail flags input
  where
    -- Get a list of flags for the current subcommand.
    flags :: [Text]
    flags = fromMaybe [] $ do
      cmd <- Text.words left & viaNonEmpty head
      names <- lookup cmd commands
      pure $
        flip map names $ \case
          O.OptShort c -> toText ['-', c]
          O.OptLong s -> "--" <> toText s

    -- A map of command names and their flags.
    commands :: HashMap Text [O.OptName]
    commands =
      fromList $
        concat $
          O.mapParser
            (const nameAndFlags)
            (O.infoParser $ shellInfo shell)
      where
        nameAndFlags opt =
          case O.optMain opt of
            O.CmdReader _ cmds p -> (`map` cmds) $ \cmd ->
              ( toText cmd,
                maybe
                  []
                  ( O.infoParser
                      >>> O.mapParser (const optnames)
                      >>> concat
                  )
                  (p cmd)
              )
            _ -> mempty
        optnames opt =
          case O.optMain opt of
            O.OptReader ns _ _ -> ns
            O.FlagReader ns _ -> ns
            _ -> mempty

-- | Internal function to split user input into words similar to what
-- a POSIX shell does.
shellSplit :: MonadByline m => Text -> m [Text]
shellSplit t =
  let input = Text.strip t
   in if Text.null input
        then pure []
        else case Atto.parseOnly go input of
          Left e -> do
            sayLn (("invalid input" <> fg red) <> ": " <> text (toText e))
            pure []
          Right ws ->
            pure ws
  where
    go :: Atto.Parser [Text]
    go = Atto.many1 (bare <|> quoted) <* expectEndOfInput

    expectEndOfInput :: Atto.Parser ()
    expectEndOfInput = (Atto.endOfInput <|>) $ do
      leftover <- Atto.many1 Atto.anyChar
      fail ("unexpected input: " <> leftover)

    -- A bare word (not wrapped in quotes).
    bare :: Atto.Parser Text
    bare = (Atto.<?> "unquoted word") $ do
      word <- Atto.many1 bareChar
      void (Atto.many1 Atto.space) <|> Atto.endOfInput
      pure (toText word)

    -- A run of characters that may have quoted characters.
    --
    -- Just like with the POSIX shell, the quotes don't have to be on
    -- the outsides of the final string.
    quoted :: Atto.Parser Text
    quoted = do
      prefix <- many bareChar
      quote <- Atto.satisfy (\c -> c == '\'' || c == '"') Atto.<?> "quote"
      (_, ScanState {..}) <-
        Atto.runScanner (ScanState [] False) (quoteScanner quote)
      when scanEscape (fail "expecting a character after a backslash")
      _ <- Atto.char quote Atto.<?> "closing quotation character"
      let str = toText prefix <> toText (reverse scanResult)
      end <-
        (Atto.many1 Atto.space $> True)
          <|> (Atto.endOfInput $> True)
          <|> pure False
      if end then pure str else (str <>) <$> quoted

    -- Parse a single character that might be escaped.
    bareChar :: Atto.Parser Char
    bareChar = do
      char <-
        Atto.satisfy
          ( \c ->
              not (isSpace c)
                && c /= '\''
                && c /= '"'
                && isPrint c
          )
      if char == '\\'
        then Atto.anyChar Atto.<?> "escaped character"
        else pure char

-- | State needed to scan input looking for a closing quote.
data ScanState = ScanState
  { scanResult :: [Char],
    scanEscape :: Bool
  }

-- | A scanning function that looks for a terminating quote.
quoteScanner ::
  -- | The quote character we are searching for.
  Char ->
  -- | The output of the last invocation.
  ScanState ->
  -- | The current input character.
  Char ->
  -- | 'Just' to continue, 'Nothing' to stop.
  Maybe ScanState
quoteScanner quote ScanState {..} input
  | scanEscape = Just (ScanState (input : scanResult) False)
  | input == '\\' = Just (ScanState scanResult True)
  | input == quote = Nothing
  | otherwise = Just (ScanState (input : scanResult) False)