{-# LANGUAGE RecordWildCards #-}

--------------------------------------------------------------------------------
-- |
--  Module      :  Internal
--  License     :  Public Domain
--
--  Maintainer  :  Douglas Burke
--  Stability   :  experimental
--  Portability :  RecordWildCards
--
-- Internal types for connecting to the Raspberry-PI version
-- of MineCraft. Most users are expected to use "Network.MineCraft.Pi.Client"
-- rather than this module, but it is provided in case the former
-- is not sufficient.
--
--------------------------------------------------------------------------------

module Network.MineCraft.Pi.Client.Internal
    ( MCPI
    , runMCPI
    , runMCPI'
    , query
    , command
    ) where

import qualified Control.Exception as CE

import Control.Exception (bracket)
import Control.Monad (unless, when)
import Control.Monad.IO.Class
import Control.Monad.Trans.Reader

import Data.Char (isSpace)
import Data.List (intercalate)

import Foreign
import Foreign.C.String

import Network.BSD
import Network.Socket

import System.Exit
import System.IO

-- | Represent a program that communicates with a MineCraft PI
--   server.
--
--   /TODO:/ run a computation without automatically opening
--         and closing the handle.
--
type MCPI = ReaderT ConnInfo IO

-- | Connection information.
--
-- /TODO:/ Should the buffer used by @flushChannel@ be stored here, to
--         avoid repeated allocation/de-allocation?  It is unlikely (I
--         speculate) to be a major optimisation in time or space.
--
data ConnInfo = ConnInfo {
      _ciHandle :: Handle  -- ^ Connection to the MineCraft program
    , _ciDebug :: Bool     -- ^ Should messages to and from MineCraft
                           --   be printed to @stderr@.
    }

-- | Commands do not return anything, queries do.
type Command = String
type Query = String
type Argument = String

-- | The port used by MineCraft is fixed.
mcPort :: String
mcPort = "4711"

-- | Open a connection to the Minecraft server or call
--   exitFailure, after displaying an error message to @stderr@.
--
--   /TODO:/ Change the retun value to @Maybe ConnInfo@ and
--           make this public.
openMCPI ::
  Bool  -- ^ Set to @True@ to get debugging messages printed to @stderr@.
  -> IO ConnInfo
openMCPI flag = do
    let ehdl :: CE.IOException -> IO ()
        ehdl _ = 
           hPutStrLn stderr "ERROR: Unable to connect to MineCraft-PI. Is it running?" >>
           exitFailure
       
    as <- getAddrInfo Nothing Nothing (Just mcPort)
    let a = head as -- note: getAddrInfo never returns an empty list
    sock <- socket (addrFamily a) Stream defaultProtocol
    setSocketOption sock KeepAlive 1
    setSocketOption sock NoDelay 1
    connect sock (addrAddress a) `CE.catch` ehdl
    h <- socketToHandle sock ReadWriteMode
    hSetBuffering h LineBuffering

    return $ ConnInfo h flag

-- | Close the connection.
--
--   /TODO:/ Make this public.
closeMCPI :: ConnInfo -> IO ()
closeMCPI = hClose . _ciHandle

-- | Write a debug message to @stderr@ if the debug flag is set. The
--   format is \"[type] msg\".
--
logMsg ::
  ConnInfo
  -> String  -- ^ The type of message (ideally 8 characters or less to
             --   keep the output aligned, but can be larger). Trailing
             --   white space is ignored.
  -> String  -- ^ The message to display.
  -> IO ()
logMsg ConnInfo {..} hdr msg = 
  when _ciDebug $ hPutStrLn stderr 
                $ "[" ++ hdr ++ replicate (8 - length hdr) ' ' ++
                  "] " ++ reverse (dropWhile isSpace (reverse msg))

-- | Add arguments to a query or command to create the
--   string to send to MineCraft.
addArgs :: String -> [Argument] -> String
addArgs a bs = a ++ "(" ++ intercalate "," bs ++ ")"

-- | At present the only "error" message I have seen is "Fail\0",
--   so we use a small buffer size.
bufSize :: Int
bufSize = 16

-- | Return from MineCraft that indicates an error.
connectionError :: String
connectionError = "Fail"

-- | Remove any output from the handle. A debug message is written
--   to let the user know this has happened, presumably because of
--   a previous invalid call. The previous call could be stored in
--   ConnInfo to make the message more useful, but leave that for now.
--
flushChannel :: ConnInfo -> IO ()
flushChannel ci@ConnInfo {..} = do
  -- Should the buffer be stored in ConnInfo so we don't have
  -- to repeatedly allocate it? It's only small (at present),
  -- so probably not an issue.
  fbuf <- mallocForeignPtrBytes bufSize 
  withForeignPtr fbuf $ \bufPtr -> 
    let loop store = do
          nrec <- hGetBufNonBlocking _ciHandle bufPtr bufSize
          if nrec > 0
            then peekCStringLen (bufPtr, nrec) >>= \str -> loop (store ++ str)
            else unless (null store) $ logMsg ci "FLUSH" store
    in loop []

-- | Run a MineCraft command.
command :: Command -> [Argument] -> MCPI ()
command comm args = do
  ci <- ask
  let commstr = addArgs comm args
  liftIO $ 
    flushChannel ci
    >> logMsg ci "COMMAND" commstr
    >> hPutStrLn (_ciHandle ci) commstr

-- | Run a MineCraft query, returning the response. An
--   @IOError@ is raised if the response is @Fail@ (this
--   might be due to a previous command failing, depending
--   on the time taken by MineCraft to respond).
--
query :: Query -> [Argument] -> MCPI String
query qry args = do
  ci <- ask
  let qryStr = addArgs qry args
  liftIO $ 
    flushChannel ci
    >> logMsg ci "QUERY" qryStr
    >> hPutStrLn (_ciHandle ci) qryStr
  ans <- liftIO $ hGetLine (_ciHandle ci)
  liftIO $ logMsg ci "RESPONSE" ans
  when (ans == connectionError) $
    liftIO $ ioError $ userError $ "Query failed: " ++ qryStr
  return ans

-- | Run a Raspberry-PI program. The flag determines whether the
--   messages sent to, and received from, the server, are
--   printed to @stderr@ as a diagnostic.
--
--   An exception is raised if the server is not running, or
--   can not be contacted.
runMCPI' :: Bool -> MCPI a -> IO a
runMCPI' flag p = 
  bracket
    (openMCPI flag)
    closeMCPI
    (runReaderT p)

-- | Run a Raspberry-PI program.
--
--   An exception is raised if the server is not running, or
--   can not be contacted.
runMCPI :: MCPI a -> IO a
runMCPI = runMCPI' False