{-# LANGUAGE OverloadedStrings #-}
{-|
Module      : Foreign.Lua.Peek
Copyright   : © 2020 Albert Krewinkel
License     : MIT
Maintainer  : Albert Krewinkel <tarleb+hslua@zeitkraut.de>
Stability   : beta
Portability : Portable

Functions which unmarshal and retrieve Haskell values from Lua's stack.
-}
module Foreign.Lua.Peek
  ( Peeker
  , PeekError (..)
  , errorMsg
  , force
  , formatPeekError
  , pushMsg
  , toPeeker
  -- * Primitives
  , peekBool
  , peekIntegral
  , peekRealFloat
  -- * Strings
  , peekByteString
  , peekLazyByteString
  , peekString
  , peekText
  , peekStringy
  -- * Collections
  , peekKeyValuePairs
  , peekList
  , peekMap
  , peekSet
  -- * Combinators
  , optional
  ) where

import Control.Applicative ((<|>))
import Data.Bifunctor (first, second)
import Data.ByteString (ByteString)
import Data.List.NonEmpty (NonEmpty (..), (<|))
import Data.Map (Map)
import Data.Maybe (fromMaybe)
import Data.Set (Set)
import Data.String (IsString (fromString))
import Data.Text (Text)
import Foreign.Lua.Core as Lua
import Text.Read (readMaybe)

import qualified Data.ByteString.Lazy as BL
import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Map as Map
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Foreign.Lua.Utf8 as Utf8

-- | List of errors which occurred while retrieving a value from
-- the stack.
newtype PeekError = PeekError { fromPeekError :: NonEmpty Text }
  deriving (Eq, Show)

formatPeekError :: PeekError -> String
formatPeekError (PeekError msgs) = T.unpack $
  T.intercalate "\n\t" (NonEmpty.toList msgs)

-- | Function to retrieve a value from Lua's stack.
type Peeker a = StackIndex -> Lua (Either PeekError a)

-- | Create a peek error from an error message.
errorMsg :: Text -> PeekError
errorMsg  = PeekError . pure

-- | Add a message to the peek traceback stack.
pushMsg :: Text -> PeekError -> PeekError
pushMsg msg (PeekError lst) = PeekError $ msg <| lst

-- | Add context information to the peek traceback stack.
retrieving :: Text -> Either PeekError a -> Either PeekError a
retrieving msg = first $ pushMsg ("retrieving " <> msg)

-- | Force creation of a result, throwing an exception if that's
-- not possible.
force :: Either PeekError a -> Lua a
force = either (throwMessage . formatPeekError) return

-- | Convert an old peek funtion to a 'Peeker'.
toPeeker :: (StackIndex -> Lua a)
         -> Peeker a
toPeeker op idx =
  (Right <$> op idx) <|> return (Left $ errorMsg "retrieving failed")

-- | Use @test@ to check whether the value at stack index @n@ has
-- the correct type and use @peekfn@ to convert it to a Haskell
-- value if possible. A successfully received value is wrapped
-- using the 'Right' constructor, while a type mismatch results
-- in @Left PeekError@ with the given error message.
typeChecked :: Text                      -- ^ expected type
            -> (StackIndex -> Lua Bool)  -- ^ pre-condition checker
            -> Peeker a
            -> Peeker a
typeChecked expectedType test peekfn idx = do
  v <- test idx
  if v
    then peekfn idx
    else Left <$> mismatchError expectedType idx

-- | Report the expected and actual type of the value under the given index if
-- conversion failed.
reportValueOnFailure :: Text
                     -> (StackIndex -> Lua (Maybe a))
                     -> Peeker a
reportValueOnFailure expected peekMb idx = do
  res <- peekMb idx
  case res of
    Just x  -> return $ Right x
    Nothing -> Left  <$> mismatchError expected idx

-- | Return a Result error containing a message about the assertion failure.
mismatchError :: Text -> StackIndex -> Lua PeekError
mismatchError expected idx = do
  actualType  <- ltype idx >>= typename
  actualValue <- Utf8.toText <$> tostring' idx <* pop 1
  return . errorMsg $
    "expected " <> expected <> ", got '" <>
    actualValue <> "' (" <> T.pack actualType <> ")"

-- | Retrieves a 'Bool' as a Lua boolean.
peekBool :: Peeker Bool
peekBool = fmap Right . toboolean

--
-- Strings
--

-- | Like @'tostring', but ensures that the value at the given index is
-- not silently converted to a string, as would happen with numbers.
toByteString :: StackIndex -> Lua (Maybe ByteString)
toByteString idx = do
  -- copy value, as tostring converts numbers to strings *in-place*.
  pushvalue idx
  tostring stackTop <* pop 1

-- | Retrieves a 'ByteString' as a raw string.
peekByteString :: Peeker ByteString
peekByteString = reportValueOnFailure "string" toByteString

-- | Retrieves a lazy 'BL.ByteString' as a raw string.
peekLazyByteString :: Peeker BL.ByteString
peekLazyByteString = fmap (second BL.fromStrict) . peekByteString

-- | Retrieves a 'String' from an UTF-8 encoded Lua string.
peekString :: Peeker String
peekString = peekStringy

-- | Retrieves a String-like value from an UTF-8 encoded Lua string.
--
-- This should not be used to peek 'ByteString' values or other values
-- for which construction via 'fromString' can result in loss of
-- information.
peekStringy :: IsString a => Peeker a
peekStringy = fmap (second $ fromString . Utf8.toString) . peekByteString

-- | Retrieves a 'T.Text' value as an UTF-8 encoded string.
peekText :: Peeker T.Text
peekText = fmap (second Utf8.toText) . peekByteString

--
-- Numbers
--

-- | Retrieves an 'Integral' value from the Lua stack.
peekIntegral :: (Integral a, Read a) => Peeker a
peekIntegral idx =
  ltype idx >>= \case
    TypeNumber  -> second fromIntegral <$>
                   reportValueOnFailure "Integral" tointeger idx
    TypeString  -> do
      str <- Utf8.toString .
             fromMaybe (Prelude.error "programming error in peekIntegral")
             <$> tostring idx
      let msg = "expected Integral, got '" <> T.pack str <> "' (string)"
      return $ maybe (Left $ errorMsg msg) Right $ readMaybe str
    _ -> Left <$> mismatchError "Integral" idx

-- | Retrieve a 'RealFloat' (e.g., 'Float' or 'Double') from the stack.
peekRealFloat :: (RealFloat a, Read a) => Peeker a
peekRealFloat idx =
  ltype idx >>= \case
    TypeString  -> do
      str <- Utf8.toString .
             fromMaybe (Prelude.error "programming error in peekRealFloat")
             <$> tostring idx
      let msg = "expected RealFloat, got '" <> T.pack str <> "' (string)"
      return $ maybe (Left $ errorMsg msg) Right $ readMaybe str
    _ -> second realToFrac <$>
         reportValueOnFailure "RealFloat" tonumber idx

-- | Reads a numerically indexed table @t@ into a list, where the 'length' of
-- the list is equal to @#t@. The operation will fail if a numerical field @n@
-- with @1 ≤ n < #t@ is missing.
peekList :: Peeker a -> Peeker [a]
peekList peekElement = typeChecked "table" istable $ \idx -> do
  let elementsAt [] = return (Right [])
      elementsAt (i : is) = do
        eitherX <- rawgeti idx i *> peekElement (nthFromTop 1) <* pop 1
        case eitherX of
          Right x  -> second (x:) <$> elementsAt is
          Left err -> return . Left $
                      pushMsg ("in field " <> T.pack (show i)) err
  listLength <- fromIntegral <$> rawlen idx
  elementsAt [1..listLength]

-- | Retrieves a key-value Lua table as 'Map'.
peekMap :: Ord a => Peeker a -> Peeker b -> Peeker (Map a b)
peekMap keyPeeker valuePeeker =
    fmap (retrieving "Map" . second Map.fromList)
  . peekKeyValuePairs keyPeeker valuePeeker

-- | Read a table into a list of pairs.
peekKeyValuePairs :: Peeker a -> Peeker b -> Peeker [(a, b)]
peekKeyValuePairs keyPeeker valuePeeker =
  typeChecked "table" istable $ \idx -> do
    idx' <- absindex idx
    let remainingPairs = do
          res <- nextPair keyPeeker valuePeeker idx'
          case res of
            Left err       -> return $ Left err
            Right Nothing  -> return $ Right []
            Right (Just a) -> second (a:) <$> remainingPairs
    pushnil
    remainingPairs

-- | Get the next key-value pair from a table. Assumes the last
-- key to be on the top of the stack and the table at the given
-- index @idx@. The next key, if it exists, is left at the top of
-- the stack.
nextPair :: Peeker a -> Peeker b -> Peeker (Maybe (a, b))
nextPair keyPeeker valuePeeker idx = retrieving "key-value pair" <$> do
  hasNext <- next idx
  if not hasNext
    then return $ Right Nothing
    else do
      key   <- retrieving "key"   <$> keyPeeker   (nthFromTop 2)
      value <- retrieving "value" <$> valuePeeker (nthFromTop 1)
      pop 1    -- remove value, leave the key
      return $ curry Just <$> key <*> value

-- | Retrieves a 'Set' from an idiomatic Lua representation. A
-- set in Lua is idiomatically represented as a table with the
-- elements as keys. Elements with falsy values are omitted.
peekSet :: Ord a => Peeker a -> Peeker (Set a)
peekSet elementPeeker =
    fmap (retrieving "Set" .
          second (Set.fromList . map fst . filter snd))
  . peekKeyValuePairs elementPeeker peekBool

--
-- Combinators
--

-- | Makes a result optional. Returns 'Nothing' if the Lua value
-- is @nil@; otherwise applies the peeker and returns its result.
optional :: Peeker a -- ^ peeker
         -> Peeker (Maybe a)
optional peeker idx = do
  noValue <- Lua.isnoneornil idx
  if noValue
    then return $ Right Nothing
    else fmap Just <$> peeker idx
