{-# LANGUAGE MultiParamTypeClasses, FlexibleInstances, FlexibleContexts,
    ScopedTypeVariables #-}
-- | This is a simple persistent multi-user adventure game, to demonstrate the
-- use of Berkeley DB/XML combined with hexpat-pickle picklers.
-- The HXT library is required for this, which can be downloaded from Hackage.
--
-- To log in, you need to use a telnet client.  The command is
--
--     telnet localhost 1888
--
-- It is assumed that your telnet client will echo and buffer the text you type.
-- This is usually the case on Unix systems.
module Main where

import Database.Berkeley.Db
import Database.Berkeley.DbXml
import Text.XML.Expat.Tree
import Text.XML.Expat.Format
import Text.XML.Expat.Pickle
import Network
import Control.Concurrent
import Control.Exception
import Control.Monad
import System.IO
import qualified Data.ByteString as B
import Data.List
import Data.Char
import Data.Maybe
import Prelude hiding (catch)

data Player = Player {
        playerName :: String,
        playerLocation :: String
    }
    deriving (Show)

instance XmlPickler [UNode String] Player where
    xpickle = xpPlayer

xpPlayer :: PU [UNode String] Player
xpPlayer =
    xpElemAttrs "player" $
    xpWrap (
            (\(nam, loc) -> Player nam loc),
            (\(Player nam loc) -> (nam, loc))
        ) $
    xpPair
        (xpAttr "name" $ xpText0)
        (xpAttr "location" $ xpText0)

data Room = Room {
        roomKey :: String,
        roomDescription :: String,
        roomExits :: [(String, String)]
    }
    deriving (Show)

instance XmlPickler [UNode String] Room where
    xpickle = xpRoom

xpRoom :: PU [UNode String] Room
xpRoom =
   xpWrap (
           (\(key, (des, exi)) -> Room key des exi),
           (\(Room key des exi) -> (key, (des, exi)))
       ) $
   xpElem "room" (xpAttr "key" xpText0) $
   xpPair
       (xpElemNodes "descr" $ xpContent xpText0)
       (xpElemNodes "exits" $
               xpList $
                   xpElemAttrs "exit" $
                        xpPair
                            (xpAttr "name" $ xpText0)
                            (xpAttr "room" $ xpText0)
           )

data Item = Item {
        itemKey :: String,
        itemLocation :: String,
        itemDescription :: String,
        itemNames :: [String],
        itemPortable :: Bool
    }
    deriving (Show)

instance XmlPickler [UNode String] Item where
    xpickle = xpItem

xpItem :: PU [UNode String] Item
xpItem =
    xpWrap (
            (\((key, loc, por), (des, nam)) -> Item key loc des nam (read por)),
            (\(Item key loc des nam por) -> ((key, loc, show por), (des, nam)))
        ) $
    xpElem "item" (
            xpTriple
                (xpAttr "key" $ xpText0)
                (xpAttr "location" $ xpText0)
                (xpAttr "portable" $ xpText0)
        ) (
            xpPair
                (xpElemNodes "descr" $ xpContent xpText0)
                (xpElemNodes "names" $ xpList $ xpElemAttrs "name" $ xpAttr "name" $ xpText0)
        )

initialRooms = [
        Room "beach"
            ("You are on a wide, white sandy beach. A bright blue ocean stretches to the horizon. "++
             "Along the beach to the north you can see some large rocks. There is thick jungle to the west.")
            [("west", "jungle"),("north", "rocks1")],
        Room "jungle"
            "You are in a dense jungle."
            [("west", "jungle"),("east", "beach"),("north", "jungle"),("south", "jungle"),("up","tree")],
        Room "tree"
            "You are up in a tree. To the south, you can see mountains."
            [("down", "jungle")],
        Room "rocks1"
            ("The beach here is strewn with large boulders. It gets more rocky to the north. "++
             "The sea is to the east.")
            [("south", "beach"),("north", "rocks2")],
        Room "rocks2"
            ("You are in a passage between huge rocks. To the west you can see the entrance to a cave. "++
             "The beach is to the south.")
            [("south", "beach"),("west", "cave")],
        Room "cave"
            ("You are in a cave, exit to the east.")
            [("east", "rocks2")]
    ]

initialItems = [
        Item "starfish" "beach" "a starfish" ["starfish","fish"] True,
        Item "tree" "jungle" "a tall, twisty tree" ["tree"] False,
        Item "nest" "tree" "an empty bird's nest" ["nest", "bird's nest", "bird nest"] True,
        Item "shell" "rocks1" "a beautiful shell" ["shell"] True,
        Item "troll" "cave" "a fierce-looking troll" ["troll"] False,
        Item "coin" "cave" "a gold coin" ["coin", "gold", "gold coin"] True
    ]

-- Get a non-empty line and strip leading and trailing whitespace
prompt :: Handle -> IO String
prompt h = do
    hPutStr h "> "
    hFlush h
    l <- hGetLine h
    let strip = dropWhile isSpace
    let l' = (reverse . strip . reverse . strip) l
    if l' == []
        then prompt h
        else return l'

-- Execute the specified code within a database transaction, automatically
-- re-trying if a deadlock is detected.
inTransaction :: XmlManager -> (XmlTransaction -> IO a) -> IO a
inTransaction mgr code = inTransaction_ mgr code 0
    where
        inTransaction_ mgr code retryCount = do
            trans <- xmlManager_createTransaction mgr []
            catch
                (do
                        result <- code trans
                        xmlTransaction_commit trans
                        return result
                    )
                (\exc -> do
                        hPutStrLn stderr $ "EXCEPTION "++show exc
                        xmlTransaction_abort trans
                        case fromException exc of
                            Just (DbException _ DB_LOCK_DEADLOCK) | retryCount < 20 -> do
                                 hPutStrLn stderr "<<retry deadlocked thread>>"
                                 inTransaction_ mgr code (retryCount+1)
                            _ -> throwIO exc)

collectM :: Monad m => m (Maybe a) -> m [a]
collectM valueM = do
    value <- valueM
    case value of
        Just item -> do
            rest <- collectM valueM
            return (item:rest)
        Nothing -> do
            return []

query_ :: (XmlManager, XmlContainer, XmlTransaction) -> PU [UNode String] p
      -> String -> [(String, XmlValue)] -> [DbXmlFlag] -> IO [(XmlDocument, p)]
query_ (mgr, cont, trans) pickler queryText params flags = do
    qctx <- xmlManager_createQueryContext mgr LiveValues Eager
    let collection = xmlContainer_getName cont
    xmlQueryContext_setDefaultCollection qctx collection
    forM params $ \(name, value) -> do
        xmlQueryContext_setVariableValue qctx name value
    res <- xmlManager_query mgr (Just trans) queryText qctx flags
    docs <- collectM (xmlResults_next res)
    records <- forM docs $ \doc -> do
                text <- xmlDocument_getContent doc
                value <- case unpickleXML' defaultParseOptions (xpRoot pickler) text of
                    Left err -> fail $ "unpickle failed: "++err
                    Right value -> return value
                return (doc, value)
    return records

query :: XmlPickler [UNode String] p => (XmlManager, XmlContainer, XmlTransaction) -> PU [UNode String] p
      -> String -> [(String, XmlValue)] -> IO [p]
query ctx pickler queryText params = liftM (map snd) $ query_ ctx pickler queryText params []

-- | Query with write lock. Returned document allows the document to be updated
-- without having to specify its document name.
queryUpdate :: XmlPickler [UNode String] p => (XmlManager, XmlContainer, XmlTransaction) -> PU [UNode String] p
      -> String -> [(String, XmlValue)] -> IO [(XmlDocument, p)]
queryUpdate ctx pickler queryText params = query_ ctx pickler queryText params [DB_FLAG DB_RMW]

create :: forall p . XmlPickler [UNode String] p =>
          (XmlManager, XmlContainer, XmlTransaction)
       -> p
       -> IO ()
create (mgr, cont, trans) p = do
    doc <- xmlManager_createDocument mgr
    xmlDocument_setContent doc (pickleXML' (xpRoot xpickle :: PU (UNode String) p) p)
    uctx <- xmlManager_createUpdateContext mgr
    xmlContainer_putDocument cont (Just trans) doc uctx [DBXML_GEN_NAME]

update :: forall p . XmlPickler [UNode String] p =>
          (XmlManager, XmlContainer, XmlTransaction)
       -> XmlDocument
       -> p
       -> IO ()
update (mgr, cont, trans) doc p = do
    xmlDocument_setContent doc (pickleXML' (xpRoot xpickle :: PU (UNode String) p) p)
    uctx <- xmlManager_createUpdateContext mgr
    xmlContainer_updateDocument cont (Just trans) doc uctx

-- | Create an item for the player so other players can see them.
putPlayer :: (XmlManager, XmlContainer, XmlTransaction) -> String -> String -> IO ()
putPlayer db name room = do
    items <- queryUpdate db xpItem "collection()/item[@key=$key]" [("key", xmlString$ "player_"++name)]
    case items of
        ((doc, p):_) -> do
            let p' = p {itemLocation = room}
            update db doc p'
        _ -> do
            create db$ Item ("player_"++name) room name [] False

deletePlayer :: (XmlManager, XmlContainer, XmlTransaction) -> String -> IO ()
deletePlayer db@(mgr, cont, trans) name = do
    items <- queryUpdate db xpItem "collection()/item[@key=$key]" [("key", xmlString$ "player_"++name)]
    case items of
        ((doc, p):_) -> do
            uctx <- xmlManager_createUpdateContext mgr
            xmlContainer_deleteDocument cont (Just trans) doc uctx
        _ -> return ()

initGame :: XmlManager -> XmlContainer -> IO ()
initGame mgr cont = do
    inTransaction mgr$ \trans -> do
        let db = (mgr, cont, trans)
        beaches <- query db xpRoom "collection()/room[@key=$key]" [("key", xmlString "beach")]
        if null beaches
            then do
                hPutStrLn stderr $ "Creating the game world..."
                forM_ initialRooms$ \room -> do
                    create db room
                forM_ initialItems$ \item -> do
                    create db item
            else return ()

look :: (XmlManager, XmlContainer, XmlTransaction) -> String -> IO [String]
look db name = do
    -- Not very good error checking here.
    player <- liftM head$ query db xpPlayer "collection()/player[@name=$name]"
        [("name", xmlString name)]
    let loc = playerLocation player
    room <- liftM head$ query db xpRoom "collection()/room[@key=$loc]"
        [("loc", xmlString loc)]
    items <- query db xpItem "collection()/item[@location=$loc]"
        [("loc", xmlString loc)]
    let notMe item =   -- True if this item doesn't describe the player
            (itemKey item /= "player_"++name)
    let itemsOtherThanMe = filter notMe items
    return$
        (roomDescription room):
        (if null itemsOtherThanMe
            then []
            else "You can see":map (\i -> "  "++(itemDescription i)) itemsOtherThanMe)

go :: (XmlManager, XmlContainer, XmlTransaction) -> String -> String -> IO [String]
go db name dir = do
    (playerDoc, player) <- liftM head$ queryUpdate db xpPlayer "collection()/player[@name=$name]"
        [("name", xmlString name)]
    let loc = playerLocation player
    room <- liftM head$ query db xpRoom "collection()/room[@key=$loc]"
        [("loc", xmlString loc)]
    let mExit = dir `lookup` (roomExits room)
    case mExit of
        Just newRoom -> do
            putPlayer db name newRoom
            update db playerDoc player {
                        playerLocation = newRoom
                    }
            look db name
        Nothing -> do
            return ["You can't go that way."]

get :: (XmlManager, XmlContainer, XmlTransaction) -> String -> String -> IO [String]
get db name noun = do
    player <- liftM head$ query db xpPlayer "collection()/player[@name=$name]"
        [("name", xmlString name)]
    let loc = playerLocation player
    room <- liftM head$ query db xpRoom "collection()/room[@key=$loc]"
        [("loc", xmlString loc)]
    items <- queryUpdate db xpItem "collection()/item[@location=$loc]"
        [("loc", xmlString loc)]
    let yes = filter (\(iDoc, i) -> noun `elem` itemNames i) items
    case yes of
        [] -> return ["I can't see one of those here."]
        ((iDoc, i):_) -> do
            if itemPortable i
                then do
                    update db iDoc i {itemLocation = "player_"++name}
                    return ["You pick up "++(itemDescription i)++"."]
                else
                    return ["Hurrrrgh. No, it isn't portable."]

drop :: (XmlManager, XmlContainer, XmlTransaction) -> String -> String -> IO [String]
drop db name noun = do
    player <- liftM head$ query db xpPlayer "collection()/player[@name=$name]"
        [("name", xmlString name)]
    let loc = playerLocation player
    items <- queryUpdate db xpItem "collection()/item[@location=$loc]"
        [("loc", xmlString$ "player_"++name)]
    let yes = filter (\(iDoc, i) -> noun `elem` itemNames i) items
    case yes of
        [] -> return ["I am not carrying one of those."]
        ((iDoc, i):_) -> do
            update db iDoc i {itemLocation = loc}
            return ["You drop "++(itemDescription i)++"."]

inventory :: (XmlManager, XmlContainer, XmlTransaction) -> String -> IO [String]
inventory db name = do
    items <- query db xpItem "collection()/item[@location=$loc]"
        [("loc", xmlString$ "player_"++name)]
    return$
        (if null items
            then ["You are not carrying anything."]
            else "You are carrying":map (\i -> "  "++(itemDescription i)) items)
            
help :: [String]
help = [
        "These are the only commands I understand:",
        "  get <item>",
        "  drop <item>",
        "  inventory",
        "  look",
        "  north, east, west, south, up, down",
        "  quit"
    ]

process :: (XmlManager, XmlContainer, XmlTransaction) -> String -> String -> IO [String]
process db name cmd = do
    case words cmd of
        [] -> return []
        (verb:nouns) -> case (verb, unwords nouns) of
            ("look", _) -> look db name
            ("quit", _) -> fail "User typed 'quit'"
            (dir, _) | dir `elem` ["north", "east", "west", "south", "up", "down"] ->
                go db name dir
            ("get", noun) -> get db name noun
            ("inventory", _) -> inventory db name
            ("drop", noun) -> Main.drop db name noun
            ("help", _) -> return help
            otherwise   -> return ["I don't understand that."]

session :: XmlManager -> XmlContainer -> Handle -> IO ()
session mgr cont h = do
    initGame mgr cont
    hPutStrLn h "Welcome to 'DB/XML Haskell binding' adventure by Stephen Blackheath"
    hPutStrLn h "Please enter your name."
    name <- prompt h
    created <- inTransaction mgr$ \trans -> do
        let db = (mgr, cont, trans)
        mPlayer <- liftM listToMaybe$ query db xpPlayer "collection()/player[@name=$name]"
            [("name", xmlString name)]
        (created, player) <- case mPlayer of
            Just player ->
                return (False, player)
            Nothing -> do
                let player = Player name "beach"
                create db player
                return (True, player)
        -- We create an item for the player to make it so other players can see them.
        putPlayer db name (playerLocation player)
        return created
    if created
        then hPutStrLn h$ "Welcome for the first time, "++name++"."
        else hPutStrLn h$ "Welcome back, "++name++"."
    hPutStrLn h$ ""
    hPutStrLn h$ "For help, please type \"help\"."
    hPutStrLn h$ ""

    mapM_ (hPutStrLn h) =<< inTransaction mgr (\trans -> do
            let db = (mgr, cont, trans)
            look db name
        )
    catch (
            forever$ do
                cmd <- prompt h
                mapM_ (hPutStrLn h) =<< inTransaction mgr (\trans -> do
                        let db = (mgr, cont, trans)
                        process db name cmd
                    )
        )
        (\err -> do
            hPutStrLn h$ "Bye!"
            inTransaction mgr$ \trans -> do
                let db = (mgr, cont, trans)
                deletePlayer db name
            ioError err)

deleteCadavers :: (XmlManager, XmlContainer, XmlTransaction) -> IO ()
deleteCadavers db@(mgr, cont, trans) = do
    cadavers <- queryUpdate db xpItem "collection()/item[substring(@key,1,7)='player_']" []
    hPutStrLn stderr$ "tidying up " ++ (show$ length cadavers) ++ " cadavers"
    forM_ cadavers$ \(doc, p) -> do
        uctx <- xmlManager_createUpdateContext mgr
        xmlContainer_deleteDocument cont (Just trans) doc uctx

main = do
    let portNo = 1888
    server <- listenOn (PortNumber portNo)

    dbenv <- dbEnv_create []

    -- Enable automatic deadlock detection.  Deadlock detection is required for
    -- multi-threaded applications.  Deadlock detection must be started on only
    -- one process in a Berkeley DB environment.
    dbEnv_set_lk_detect dbenv DB_LOCK_DEFAULT

    -- Note that we are doing DB_RECOVER here.  This should always be done before
    -- running the application but must be done with no other processes using the
    -- environment at the same time.
    dbEnv_open [DB_CREATE,DB_INIT_LOCK,DB_INIT_LOG,DB_INIT_MPOOL,
        DB_INIT_TXN,DB_THREAD,DB_RECOVER] 0 dbenv "."
    mgr <- xmlManager_create dbenv []
    cont <- xmlManager_openContainer mgr "adventure.dbxml"
        [DBXML_TRANSACTIONAL,DB_FLAG DB_THREAD,DB_FLAG DB_CREATE]
        WholedocContainer 0

    putStrLn $ "Adventure server - please telnet into port "++show portNo

    inTransaction mgr$ \trans -> do
        let db = (mgr, cont, trans)
        deleteCadavers db
    
    forever $ do
        (client, host, port) <- accept server
        forkIO $ do
            catch
                (session mgr cont client >> hClose client)
                (\exc -> do
                    hPutStrLn stderr $ show (exc::SomeException)
                    hClose client)

