{-# LANGUAGE OverloadedStrings #-}

{-

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 git://pmade.com/byline/LICENSE. No part of the
byline package, including this file, may be copied, modified,
propagated, or distributed except according to the terms contained in
the LICENSE file.

-}

--------------------------------------------------------------------------------
-- | Functions and types for working with menus.
module System.Console.Byline.Menu
       ( Menu
       , Choice (..)
       , Matcher
       , menu
       , banner
       , prefix
       , suffix
       , matcher
       , askWithMenu
       , askWithMenuRepeatedly
       ) where

--------------------------------------------------------------------------------
-- Library imports:
import Control.Applicative
import Control.Monad
import Control.Monad.IO.Class
import qualified Control.Monad.Reader as Reader
import Data.IORef
import Data.Map (Map)
import qualified Data.Map as Map
import Data.Maybe
import Data.Text (Text)
import qualified Data.Text as Text
import Text.Printf (printf)

--------------------------------------------------------------------------------
-- Byline imports:
import System.Console.Byline.Internal.Byline
import System.Console.Byline.Internal.Completion
import System.Console.Byline.Internal.Render
import System.Console.Byline.Primitive
import System.Console.Byline.Stylized

--------------------------------------------------------------------------------
-- | Opaque type representing a menu containing items of type @a@.
data Menu a = Menu
  { menuItems        :: [a]              -- ^ Menu items.
  , menuBanner       :: Maybe Stylized   -- ^ Banner printed before menu.
  , menuDisplay      :: a -> Stylized    -- ^ Stylize a menu item.
  , menuItemPrefix   :: Int -> Stylized  -- ^ Stylize an item's index.
  , menuItemSuffix   :: Stylized         -- ^ Printed after an item's index.
  , menuBeforePrompt :: Maybe Stylized   -- ^ Printed before the prompt.
  , menuMatcher      :: Matcher a        -- ^ Matcher function.
  }

--------------------------------------------------------------------------------
-- | A type representing the choice made by a user while working with
-- a menu.
data Choice a = NoItems    -- ^ Menu has no items to choose from.
              | Match a    -- ^ User picked a menu item.
              | Other Text -- ^ User entered text that doesn't match an item.
              deriving Show

--------------------------------------------------------------------------------
-- | A function that is given the input from a user while working in a
-- menu and should translate that into a 'Choice'.
--
-- The @Map@ contains the menu item indexes/prefixes (numbers or
-- letters) and the items themselves.
--
-- The default matcher function allows the user to select a menu item
-- by typing its index or part of its textual representation.  As long
-- as input from the user is a unique prefix of one of the menu items
-- then that item will be returned.
type Matcher a = Menu a -> Map Text a -> Text -> Choice a

--------------------------------------------------------------------------------
-- | Default prefix generator.  Creates numbers aligned for two-digit
-- prefixes.
numbered :: Int -> Stylized
numbered = text . Text.pack . printf "%2d"

--------------------------------------------------------------------------------
-- | Helper function to produce a list of menu items matching the
-- given user input.
matchOnPrefix :: Menu a -> Text -> [a]
matchOnPrefix config input = filter prefixCheck (menuItems config)
  where
    asText i      = renderText Plain (menuDisplay config i)
    prefixCheck i = input `Text.isPrefixOf` asText i

--------------------------------------------------------------------------------
-- | Default 'Matcher' function.  Checks to see if the user has input
-- a unique prefix for a menu item (matches the item text) or selected
-- one of the generated item prefixes (such as those generated by the
-- internal @numbered@ function).
defaultMatcher :: Matcher a
defaultMatcher config prefixes input =
  case uniquePrefix <|> Map.lookup cleanInput prefixes of
    Nothing    -> Other input
    Just match -> Match match

  where
    cleanInput = Text.strip input

    -- uniquePrefix :: Maybe a
    uniquePrefix = let matches = matchOnPrefix config cleanInput
                   in if length matches == 1
                        then listToMaybe matches
                        else Nothing

--------------------------------------------------------------------------------
-- | Default completion function.  Matches all of the menu items.
defaultCompFunc :: Menu a -> CompletionFunc
defaultCompFunc config (left, _) = return ("", completions matches)
  where
    -- All matching menu items.
    matches = if Text.null left
                then menuItems config
                else matchOnPrefix config left

    -- Convert a menu item to a String.
    asText i = renderText Plain (menuDisplay config i)

    -- Convert menu items into Completion values.
    completions = map (\i -> Completion (asText i) (asText i) True)

--------------------------------------------------------------------------------
-- | Create a 'Menu' by giving a list of menu items and a function
-- that can convert those items into stylized text.
menu :: [a] -> (a -> Stylized) -> Menu a
menu items displayF =
  Menu { menuItems        = items
       , menuBanner       = Nothing
       , menuDisplay      = displayF
       , menuItemPrefix   = numbered
       , menuItemSuffix   = text ") "
       , menuBeforePrompt = Nothing
       , menuMatcher      = defaultMatcher
       }

--------------------------------------------------------------------------------
-- | Change the banner of a menu.  The banner is printed just before
-- the menu items are displayed.
banner :: Stylized -> Menu a -> Menu a
banner b m = m {menuBanner = Just b}

--------------------------------------------------------------------------------
-- | Change the prefix function.  The prefix function should generate
-- unique, stylized text that the user can use to select a menu item.
-- The default prefix function numbers the menu items starting with 1.
prefix :: (Int -> Stylized) -> Menu a -> Menu a
prefix f m = m {menuItemPrefix = f}

--------------------------------------------------------------------------------
-- | Change the menu item suffix.  It is displayed directly after the
-- menu item prefix and just before the menu item itself.
--
-- Default: @") "@
suffix :: Stylized -> Menu a -> Menu a
suffix s m = m {menuItemSuffix = s}

--------------------------------------------------------------------------------
-- | Change the 'Matcher' function.  The matcher function should
-- compare the user's input to the menu items and their assigned
-- prefix values and return a 'Choice'.
matcher :: Matcher a -> Menu a -> Menu a
matcher f m = m {menuMatcher = f}

--------------------------------------------------------------------------------
-- | Ask the user to choose an item from a menu.  The menu will only
-- be shown once and the user's choice will be returned in a 'Choice'
-- value.
--
-- If you want to force the user to only choose from the displayed
-- menu items you should use 'askWithMenuRepeatedly' instead.
askWithMenu :: (MonadIO m)
            => Menu a           -- ^ The 'Menu' to display.
            -> Stylized         -- ^ The prompt.
            -> Byline m (Choice a)
askWithMenu m prompt = do
  currCompFunc <- Reader.asks compFunc >>= liftIO . readIORef

  if null (menuItems m)
    then return NoItems
    else go currCompFunc

  where
    -- Use the default completion function for menus, but not if another
    -- completion function is already active.
    go comp = withCompletionFunc (fromMaybe (defaultCompFunc m) comp) $ do
      prefixes <- displayMenu
      answer   <- ask prompt (Just firstItem)
      return (menuMatcher m m prefixes answer)

    -- The default menu item.
    firstItem = Text.strip $ renderText Plain (menuItemPrefix m 1)

    -- Print the entire menu.
    displayMenu = do
      case menuBanner m of
        Nothing -> return ()
        Just br -> sayLn (br <> "\n")

      cache <- foldM listItem Map.empty $ zip  [1..] (menuItems m)

      case menuBeforePrompt m of
        Nothing -> sayLn mempty -- Just for the newline.
        Just bp -> sayLn ("\n" <> bp)

      return cache

    -- Print a menu item and cache its prefix in a Map.
    listItem cache (index, item) = do
      let bullet   = menuItemPrefix m index
          rendered = renderText Plain bullet

      sayLn $ mconcat [ text "  "          -- Indent.
                      , bullet             -- Unique identifier.
                      , menuItemSuffix m   -- Spacer or marker.
                      , menuDisplay m item -- The item.
                      ]

      return (Map.insert (Text.strip rendered) item cache)

--------------------------------------------------------------------------------
-- | Like 'askWithMenu' except that arbitrary input is not allowed.
-- If the user doesn't correctly select a menu item then the menu will
-- be repeated and an error message will be displayed.
askWithMenuRepeatedly :: (MonadIO m)
           => Menu a            -- ^ The 'Menu' to display.
           -> Stylized          -- ^ The prompt.
           -> Stylized          -- ^ Error message.
           -> Byline m (Choice a)
askWithMenuRepeatedly m prompt errprompt = go m
  where
    go config = do
      answer <- askWithMenu config prompt

      case answer of
        Other _ -> go (config {menuBeforePrompt = Just errprompt})
        _       -> return answer