{-# LANGUAGE CPP #-}
{-# LANGUAGE DeriveDataTypeable #-}
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE FunctionalDependencies #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Console line fuzzy search as a library.
--
-- It's probably easier to toy with the library through
-- the @wybor@ executable, although this example uses @ghci@, which is
-- also a pretty capable environment for toying
--
-- First, we need a bit of setup. Both extensions aren't strictly necessary
-- but they make our life considerably easier. Imports, on the other hand, are
-- essential.
--
-- /Note:/ @-XOverloadedLists@ requires GHC >= 7.8
--
-- @
-- >>> :set -XOverloadedStrings
-- >>> :set -XOverloadedLists
-- >>> import "Control.Lens"
-- >>> import Wybor
-- @
--
-- So, this starts a sligtly customized selection process (more settings are avaliable,
-- please see 'HasWybor' class):
--
-- @
-- >>> 'select' ('fromTexts' ["foo", "bar", "baz"] & 'prefix' .~ "λ> ")
-- λ>
-- __foo__
-- bar
-- baz
-- @
--
-- This new prompt (@λ>@) expects us to enter symbols to narrow the list of outcomes, e.g.:
--
-- @
-- λ> b
-- __bar__
-- baz
-- @
--
-- @
-- λ> bz
-- __baz__
-- @
--
-- At this point there is only one acceptable outcome, so we press @Enter@ and end up with:
--
-- @
-- Right (Just "baz")
-- @
--
-- Of course, we can press @Enter@ at any point of the process, selecting
-- the focused alternative (marked bold here). Other hotkeys:
--
--   - @C-j@ selects the focused alternative (like @Enter@)
--   - @C-u@ clears the line
--   - @C-w@ deletes last word
--   - @C-h@ deletes last character (as does @Backspace@)
--   - @C-n@ focuses the next alternative
--   - @C-p@ focuses the previous alternative
--   - @C-d@ aborts the selection
--
module Wybor
  ( select
  , selections
  , Wybor
  , fromAssoc
  , fromTexts
  , fromIO
  , HasWybor(..)
  , TTYException(..)
#ifdef TEST
  , pipeline
#endif
  -- * A bunch of helpers to use with 'focused' and 'normal'
  , module Ansi
  ) where

import           Control.Exception (try)
import           Control.Lens hiding (lined)
import           Control.Monad
import           Control.Monad.IO.Class (MonadIO(..))
import           Control.Monad.Trans.Resource (MonadResource, runResourceT)
import           Data.Conduit (Source, Conduit, (=$=), ($$))
import qualified Data.Conduit as C
import           Data.Char (isSpace)
import           Data.Data (Typeable)
import           Data.Foldable (Foldable, toList)
import           Data.Function (on)
import           Data.List (sortBy)
import           Data.List.NonEmpty (NonEmpty)
import qualified Data.List.NonEmpty as NonEmpty
import           Data.Monoid (Monoid(..), (<>))
import           Data.Sequence (Seq, (><))
import qualified Data.Sequence as Seq
import           Data.Sequence.Lens (seqOf)
import           Data.Text (Text)
import qualified Data.Text as Text
import           Prelude hiding (unlines)
import qualified System.Console.ANSI as Ansi

import           Score (score, Input(..), Choice(..))
import qualified Score
import           TTY (TTY, TTYException)
import qualified TTY
import           Zipper (Zipper, focus, zipperN)
import qualified Zipper
import           Ansi


-- | Select an item from 'Wybor' once
--
-- The user can interrupt the process with @C-d@ and then you get 'Nothing'.
-- Exceptions result in @'Left' _@
select :: Wybor a -> IO (Either TTYException (Maybe a))
select c = try . runResourceT $ selections c $$ C.await

-- | Continuously select items from 'Wybor'
--
-- Exceptions (see 'TTYException') aren't caught
selections :: MonadResource m => Wybor a -> Source m a
selections = TTY.withTTY . pipeline

-- | The description of the alternative choices, see 'HasWybor'
data Wybor a = Wybor
  { _alts :: Alternatives (NonEmpty (Text, a))
  , _conf :: Conf Text
  } deriving (
    Functor
#if __GLASGOW_HASKELL__ >= 708
  , Typeable
#endif
  )

alts :: Lens (Wybor a) (Wybor b) (Alternatives (NonEmpty (Text, a))) (Alternatives (NonEmpty (Text, b)))
alts f x = f (_alts x) <&> \y -> x { _alts = y }
{-# INLINE alts #-}

conf :: Lens' (Wybor a) (Conf Text)
conf f x = f (_conf x) <&> \y -> x { _conf = y }
{-# INLINE conf #-}

data Alternatives a
  = Static a
  | Dynamic (IO (Maybe (Maybe a)))

instance Functor Alternatives where
  fmap f (Static a) = Static (f a)
  fmap f (Dynamic k) = Dynamic (fmap (fmap (fmap f)) k)

data Conf a = Conf
  { _visible, _height :: Int
  , _initial, _prefix :: a
  , _focused, _normal :: a -> a
  } deriving (Typeable)

-- | A bunch of lenses to pick and configure Wybor
class HasWybor t a | t -> a where
  wybor :: Lens' t (Wybor a)

  -- | How many alternative choices to show at once? (default: @10@)
  visible :: Lens' t Int
  visible = wybor.conf. \f x -> f (_visible x) <&> \y -> x { _visible = y }
  {-# INLINE visible #-}

  -- | How many lines every alternative takes on the screen? (default: @1@)
  height :: Lens' t Int
  height = wybor.conf. \f x -> f (_height x) <&> \y -> x { _height = y }
  {-# INLINE height #-}

  -- | Initial search string (default: @""@)
  initial :: Lens' t Text
  initial = wybor.conf. \f x -> f (_initial x) <&> \y -> x { _initial = y }
  {-# INLINE initial #-}

  -- | Prompt prefix (default: @">>> "@)
  prefix :: Lens' t Text
  prefix = wybor.conf. \f x -> f (_prefix x) <&> \y -> x { _prefix = y }
  {-# INLINE prefix #-}

  -- | Decoration applied to the focused item (swaps foreground and background colors by default)
  --
  -- /Note:/ should not introduce any printable symbols
  focused :: Lens' t (Text -> Text)
  focused = wybor.conf. \f x -> f (_focused x) <&> \y -> x { _focused = y }
  {-# INLINE focused #-}

  -- | Decoration applied to other items (is @id@ by default)
  --
  -- /Note:/ should not introduce any printable symbols
  normal :: Lens' t (Text -> Text)
  normal = wybor.conf. \f x -> f (_normal x) <&> \y -> x { _normal = y }
  {-# INLINE normal #-}

instance HasWybor (Wybor a) a where
  wybor = id
  {-# INLINE wybor #-}

-- | Construct 'Wybor' from the nonempty list of strings
--
-- The strings are used both as keys and values
fromTexts :: NonEmpty Text -> Wybor Text
fromTexts = fromAssoc . fmap (\x -> (x, x))

-- | Construct 'Wybor' from the nonempty list of key-value pairs
fromAssoc :: NonEmpty (Text, a) -> Wybor a
fromAssoc = fromAlternatives . Static

-- | Construct 'Wybor' from the 'IO' action that streams choices
--
-- It's useful when the list of alternatives is populated over
-- time from multiple sources (for instance, from HTTP responses)
--
-- The interface is tailored for the use with closeable queues from the "stm-chans" package:
--
-- >>> q <- newTMQueueIO
-- >>> ... {- a bunch of threads populating and eventually closing the queue -}
-- >>> c <- 'select' ('fromIO' (atomically (tryReadTMQueue q)))
-- >>> print c
--
-- That is, if the 'IO' action returns @'Nothing'@ the queue will never be read from again
-- and it can return @'Just' 'Nothing'@ when there's nothing to add to the choices __yet__
--
-- It's still possible to use non-fancy queues:
--
-- >>> q <- newTQueueIO
-- >>> ... {- a bunch of threads populating the queue -}
-- >>> c <- 'select' ('fromIO' (fmap Just (atomically (tryReadTQueue q))))
-- >>> print c
--
-- If choices are static, you will be served better by 'fromAssoc' and 'fromTexts'
fromIO :: IO (Maybe (Maybe (NonEmpty (Text, a)))) -> Wybor a
fromIO = fromAlternatives . Dynamic

fromAlternatives :: Alternatives (NonEmpty (Text, a)) -> Wybor a
fromAlternatives xs = Wybor
  { _alts = xs
  , _conf = defaultConf
  }

defaultConf :: Conf Text
defaultConf = Conf
  { _visible = 10
  , _height  = 1
  , _prefix  = ">>> "
  , _initial = ""
  , _focused = \t -> Text.concat [swap, t, unswap]
  , _normal  = id
  }

pipeline :: MonadIO m => Wybor a -> TTY -> Source m a
pipeline w tty = do
  pos <- prerenderUI w tty
  sourceInput (view alts w) tty =$= renderUI tty pos w

sourceInput :: MonadIO m => Alternatives (NonEmpty (Text, a)) -> TTY -> Source m (Event a)
sourceInput (Static p)   tty = do yieldChoices p; loop where loop = keyEvent tty loop
sourceInput (Dynamic io) tty = interleaving
 where
  interleaving = liftIO io >>= \case
    Nothing       -> let loop = keyEvent tty loop in loop
    Just Nothing  -> keyEvent tty interleaving
    Just (Just p) -> do yieldChoices p; keyEvent tty interleaving

keyEvent :: MonadIO m => TTY -> Source m (Event a) -> Source m (Event a)
keyEvent tty c = TTY.getKey tty >>= \case
  Nothing -> c
  Just k  -> case parseKey k of
    Nothing       -> return ()
    Just Nothing  ->                     c
    Just (Just e) -> do yieldKeyEvent e; c

yieldChoices :: MonadIO m => NonEmpty (Text, a) -> Source m (Event a)
yieldChoices = C.yield . Left . AppendChoices

yieldKeyEvent :: MonadIO m => KeyEvent -> Source m (Event a)
yieldKeyEvent = C.yield . Right

type Event a = Either (GenEvent a) KeyEvent

newtype GenEvent a = AppendChoices (NonEmpty (Text, a)) deriving (Show, Eq, Functor)

data KeyEvent =
    Done
  | Abort
  | Down
  | Up
  | Clear
  | DeleteWord
  | DeleteChar
  | AppendChar Char
    deriving (Show, Eq)

parseKey :: TTY.Key -> Maybe (Maybe KeyEvent)
parseKey = \case
  TTY.Ctrl 'J'  -> Just (Just Done)
  TTY.Ctrl 'D'  -> Nothing
  TTY.Ctrl 'N'  -> Just (Just Down)
  TTY.ArrowDown -> Just (Just Down)
  TTY.Ctrl 'P'  -> Just (Just Up)
  TTY.ArrowUp   -> Just (Just Up)
  TTY.Ctrl 'U'  -> Just (Just Clear)
  TTY.Ctrl 'W'  -> Just (Just DeleteWord)
  TTY.Bksp      -> Just (Just DeleteChar)
  TTY.Ctrl 'H'  -> Just (Just DeleteChar)
  TTY.Print c   -> Just (Just (AppendChar c))
  _             -> Just Nothing

newtype Choices a = Choices
  { unChoices :: Seq (Text, a)
  }

choices :: Iso (Choices a) (Choices b) (Seq (Text, a)) (Seq (Text, b))
choices = iso unChoices Choices

data Query a b = Query
  { _matches :: Maybe (Zipper (a, b))
  , _input   :: a
  } deriving (Show, Eq)

matches :: Lens' (Query a b) (Maybe (Zipper (a, b)))
matches f x = f (_matches x) <&> \y -> x { _matches = y }

input :: Lens' (Query a b) a
input f x = f (_input x) <&> \y -> x { _input = y }

handleEvent :: Choices a -> Query Text a -> KeyEvent -> Maybe (Either a (Query Text a))
handleEvent c s = \case
  Done         -> Just (maybe (Right s) Left (done s))
  Abort        -> Nothing
  Down         -> Just (Right (down s))
  Up           -> Just (Right (up s))
  Clear        -> Just (Right (clear c))
  DeleteChar   -> Just (Right (deleteChar c s))
  DeleteWord   -> Just (Right (deleteWord c s))
  AppendChar x -> Just (Right (append s x))

done :: Query a b -> Maybe b
done = preview (matches.traverse.focus._2)

down :: Query a b -> Query a b
down = over (matches.traverse) Zipper.right

up :: Query a b -> Query a b
up = over (matches.traverse) Zipper.left

clear :: Choices a -> Query Text a
clear c = fromInput c mempty

append :: Query Text a -> Char -> Query Text a
append s x = fromInput (Choices (seqOf (matches.folded.folded) s)) (view input s |> x)

deleteChar :: Choices a -> Query Text a -> Query Text a
deleteChar c = maybe (clear c) (fromInput c . fst) . unsnoc . view input

deleteWord :: Choices a -> Query Text a -> Query Text a
deleteWord c = fromInput c . view (input.to (Text.dropWhileEnd (not . isSpace) . Text.stripEnd))

fromInput :: Choices a -> Text -> Query Text a
fromInput c q = Query { _matches = view (choices.to (computeMatches q)) c, _input = q }

fromNothing :: Wybor a -> Query Text b
fromNothing c = Query { _matches = Nothing, _input = view initial c }

computeMatches :: Foldable f => Text -> f (Text, a) -> Maybe (Zipper (Text, a))
computeMatches "" = Zipper.fromList . toList
computeMatches q  = Zipper.fromList . sortOnByOf choiceScore (flip compare) Score.positive . toList
 where
  choiceScore = score (Input q) . Choice . fst

sortOnByOf :: (Foldable f, Ord b) => (a -> b) -> (b -> b -> Ordering) -> (b -> Bool) -> f a -> [a]
sortOnByOf f c p = map fst . sortBy (c `on` snd) . filter (p . snd) . map (\x -> (x, f x)) . toList


prerenderUI :: MonadIO m => Wybor a -> TTY -> m Int
prerenderUI c tty = do
  row <- TTY.getCursorRow tty
  let offset = max 0 (linesTaken c - (TTY.winHeight tty - row))
  replicateM_ offset (TTY.putLine tty)
  return (row - offset)

renderUI :: MonadIO m => TTY -> Int -> Wybor a -> Conduit (Event b) m b
renderUI tty p c = rendering (Choices Seq.empty) (fromNothing c)
 where
  rendering cs s = renderQuery tty c p s >> C.await >>= \case
    Nothing ->
      cleanUp
    Just (Left (AppendChoices xs)) ->
      let
        f   = NonEmpty.filter (\x -> Score.positive (score (Input (view input s)) (Choice (fst x))))
        cs' = over choices (>< Seq.fromList (toList xs)) cs
        s'  = over matches (maybe (Zipper.fromList (f xs)) (Just . Zipper.append (f xs))) s
      in
        rendering cs' s'
    Just (Right e) -> case handleEvent cs s e of
      Nothing         -> cleanUp
      Just (Left x)   -> do cleanUp; C.yield x; rendering cs s
      Just (Right s') -> rendering cs s'

  cleanUp = TTY.clearScreenBottom tty

renderQuery :: MonadIO m => TTY -> Wybor a -> Int -> Query Text b -> m ()
renderQuery tty c top s =
  liftIO . TTY.withHiddenCursor tty $ renderContent tty top (columnsTaken c s) (content tty c s)

renderContent :: TTY -> Int -> Int -> Text -> IO ()
renderContent tty x y t = do
  TTY.moveCursor tty x 0
  TTY.putText tty t
  TTY.moveCursor tty x y

content :: TTY -> Wybor a -> Query Text b -> Text
content tty c = review lined
  . map (text . unline . clean . line . decorate c . unline . expand tty c . line) . items c

data Item s = Plain s | Chosen s | Prefix s deriving (Functor)

item :: (s -> a) -> (s -> a) -> (s -> a) -> Item s -> a
item f _ _ (Plain s)  = f s
item _ g _ (Chosen s) = g s
item _ _ h (Prefix s) = h s

text :: Item s -> s
text = item id id id

lined :: Iso' Text [Text]
lined = iso Text.lines (Text.intercalate "\n")

line :: Item Text -> Item [Text]
line = fmap (view lined)

expand :: TTY -> Wybor a -> Item [Text] -> Item [Text]
expand tty c = \case
  Plain  xs -> Plain  (compose h w xs)
  Chosen xs -> Chosen (compose h w xs)
  Prefix xs -> Prefix (compose 1 w xs)
 where
  compose x y xs = take x (map (crop w y) (map notabs xs ++ repeat ""))
  notabs  = Text.replace "\t" (Text.replicate 8 " ")
  w = TTY.winWidth tty
  h = view height c

crop :: Int -> Int -> Text -> Text
crop w n = Text.pack . go 0 . Text.unpack
 where
  go k (x : xs) = let k' = k + wcwidth x in if k' <= n then x : go k' xs else replicate (w - k) ' '
  go k []       = replicate (w - k) ' '

foreign import ccall unsafe "wybor_mk_wcwidth" wcwidth :: Char -> Int

clean :: Item [Text] -> Item [Text]
clean = fmap (map (\l -> l <> Text.pack Ansi.clearFromCursorToLineEndCode))

decorate :: Wybor a -> Item Text -> Item Text
decorate c (Plain xs)  = Plain  (view normal c xs)
decorate c (Chosen xs) = Chosen (view focused c xs)
decorate _ (Prefix xs) = Prefix xs

unline :: Item [Text] -> Item Text
unline = fmap (review lined)

items :: Wybor a -> Query Text b -> [Item Text]
items c s = take (view visible c + 1) $
     Prefix (view prefix c <> view input s)
  :  maybe [] (zipperN (view visible c) combine . fmap fst) (preview (matches.traverse) s)
  ++ repeat (Plain "")
 where
  combine xs y zs = map Plain xs ++ [Chosen y] ++ map Plain zs

linesTaken :: Wybor a -> Int
linesTaken c = view visible c * view height c + 1

columnsTaken :: Wybor a -> Query Text b -> Int
columnsTaken c s = lengthOf (beside (prefix.each) (input.each)) (c, s)