{-# LANGUAGE GeneralizedNewtypeDeriving, DeriveGeneric, OverloadedStrings, ScopedTypeVariables #-}
module Database.PlistBuddy 
        ( -- * Remote Monad
          PlistBuddy()
        , openPlist
        , Plist()
        , send
        , throwPlistError
        , catchPlistError
        -- * The Remote Monad operators
        , help
        , exit
        , save
        , revert
        , clear
        , get
        , set
        , add
        , delete
        -- * Other types
        , Value(..)
        , valueType
        -- * Debugging
        , debugOn
         -- * Exception
        , PlistBuddyException(..)
         -- * Audit
        , Trail(..)
        , AuditTrail(..)
        , auditOn
        , auditOff
        , replay
        , recover
        , hashcode
        , findTrail
         -- * Background version of Plist
        , BackgroundPlist
        , backgroundPlist
        , bgSend
        , bgAutoSave
        ) where

import Control.Concurrent
import Control.Concurrent.MVar
import Control.Exception
import Control.Monad
import Control.Monad.Reader
import Control.Monad.Except

import Data.Char (ord,isSpace,isDigit)
import Data.IORef
import Data.Text(Text)
import qualified Data.Text as T
import Data.Text.Encoding as E
import Database.PlistBuddy.Audit
import Database.PlistBuddy.Command
import Database.PlistBuddy.Open
import Database.PlistBuddy.Types as Types

import qualified Data.ByteString as BS
import Data.ByteString (ByteString)
import qualified Data.ByteString.Base64 as B64
import Data.List ()
import Data.Monoid ((<>))

import System.Directory (removeFile)
import System.Process
import System.IO
import System.Posix.Pty
import System.Timeout

import Text.XML.Light as X

import Data.Time
import Data.Either(either)

import GHC.Generics
import Debug.Trace
import System.IO.Error (catchIOError)


------------------------------------------------------------------------------

-- | Generate a version of Plist that outputs debuging information.
debugOn :: Plist -> Plist
debugOn p = p { plist_debug = True }

-- | Send the (remote) 'PlistBuddy' monad to given 'Plist'.
send :: Plist -> PlistBuddy a -> IO a
send dev (PlistBuddy m) = bracket (takeMVar lock) (putMVar lock) $ \ () -> do
        d <- readIORef (plist_dirty dev)
        case d of
          Just {} -> do
            v <- runReaderT (runExceptT m) dev
            case v of
              Left (PlistError msg) -> fail msg  -- an unhandled PlistError turns into an IO fail
              Right val -> return val
          Nothing -> throw $ PlistBuddyException $ "plist handle has been closed with exit"
  where lock = plist_lock dev

-- | Returns Help Text
help :: PlistBuddy Text
help = do
        plist <- ask
        res <- liftIO $ command plist "Help"
        return $ E.decodeUtf8 $ res

-- | Exits the program, changes are not saved to the file
exit :: PlistBuddy ()
exit = do
        plist <- ask
        liftIO $ plist_trail plist Exit
        liftIO $ do
            (void $ command plist "Exit") `catch` \ (e :: IOException) -> do { return () }
        debug ("waiting for Process on exit")
        r <- liftIO $ do
            waitForProcess (plist_proc plist)
        debug ("closing pty after process closed",r)
        liftIO $ do
            closePty (plist_pty plist)
        debug ("done with exit, including closing pty")
        liftIO $ writeIORef (plist_dirty plist) $ Nothing -- closed
        return ()

-- | Saves the current changes to the file
save :: PlistBuddy ()
save = do
        plist <- ask
        res <- liftIO $ command plist "Save"
        case res of
          "Saving..." -> do
                bs <- liftIO $ hashcode (plist_file plist)
                liftIO $ plist_trail plist $ Save bs
                dirty False
                return ()
          _ -> error $ "save failed: " <> show res


-- | Reloads the last saved version of the file
revert :: PlistBuddy ()
revert = do
        plist <- ask
        res <- liftIO $ command plist "Revert"
        case res of
          "Reverting to last saved state..." -> do
            liftIO $ plist_trail plist Revert
            dirty False
            return ()
          _ -> error $ "revert failed: " ++ show res

-- | Clear Type - Clears out all existing entries, and creates root of a value,
-- where the value is an empty Dict or Array.
clear :: Value -> PlistBuddy ()
clear value = do
        plist <- ask
        ty <- case value of
                     Array [] -> return $ valueType value
                     Array _  -> error "add: array not empty"
                     Dict []  -> return $ valueType value
                     Dict _   -> error "add: dict not empty"
                     _        -> error "adding a non dict/array to the root path"
        res <- liftIO $ command plist $ "Clear " <> ty
        case res of
          "Initializing Plist..." -> do
            liftIO $ plist_trail plist $ Clear value
            dirty True
            return ()
          _  -> fail $ "add failed: " ++ show res

-- | Print Entry - Gets value of Entry.
get :: [Text] -> PlistBuddy Value
get entry = do
        debug ("get",entry)
        plist <- ask
        res <- liftIO $ command plist $ "Print" <>  BS.concat [ ":" <> quoteText e | e <- entry ]
        if "Print: Entry, " `BS.isPrefixOf` res && ", Does Not Exist" `BS.isSuffixOf` res
        then throwPlistError $ PlistError $ "value not found"
        else case parseXMLDoc (BS.filter (/= fromIntegral (ord '\r')) res) of
          Nothing -> error "get: early parse error"
          Just (Element _ _ xml _) -> case parse (onlyElems xml) of
                                        Nothing -> error ("get: late parse error : " ++ show (onlyElems xml))
                                        Just v -> return $  v
  where
        parse :: [Element] -> Maybe Value
        parse [] = Nothing
        parse (Element nm attr cs _:_) = 
                        case showQName nm of
                          "integer" -> Integer <$> parseInteger cs
                          "string"  -> String  <$> parseString cs
                          "dict"    -> Dict    <$> parseDict cs
                          "array"   -> Array   <$> parseArray cs
                          "false"   -> return $ Bool False
                          "true"    -> return $ Bool True
                          "real"    -> Real    <$> parseReal cs
                          "data"    -> Data    <$> parseData cs
                          "date"    -> Date    <$> parseDate cs
                          x -> error $ show ("other",x,cs)

        parseInteger :: [Content] -> Maybe Integer
        parseInteger = return . read . concatMap showContent 

        parseReal :: [Content] -> Maybe Double
        parseReal = return . read . concatMap showContent 

        -- The content must be encoded as an ISO-8601 string in the UTC timezone
        -- https://code.google.com/p/networkpx/wiki/PlistSpec#date
        parseDate :: [Content] -> Maybe UTCTime
        parseDate = parseTimeM True defaultTimeLocale "%FT%XZ"
                  . concatMap showContent 

        parseData :: [Content] -> Maybe ByteString
        parseData = either (const Nothing)
                           (Just) 
                  . B64.decode
                  . E.encodeUtf8
                  . T.filter (not . isSpace)
                  . T.pack
                  . showContents

        -- "\t" messes up
        parseString :: [Content] -> Maybe Text
        parseString = return . T.pack . showContents 

        showContents :: [Content] -> String
        showContents = concatMap showContent
          where        
            showContent :: Content -> String
            showContent (Elem e) = error "internal Elem"
            showContent (Text e) = case cdVerbatim e of
              CDataText     -> cdData e
              CDataVerbatim -> error "internal CDataVerbatim"
              CDataRaw      -> error "internal CDataRaw"
            showContent (CRef e) = error "internal CRef"

        parseDict :: [Content] -> Maybe [(Text,Value)]
        parseDict cs = parseDict' (onlyElems cs)
          where
                  parseDict' :: [Element] -> Maybe [(Text,Value)]
                  parseDict' [] = return []
                  parseDict' (Element nm attr cs _
                             : e
                             : rest) | showQName nm == "key"
                     = do v <- parse [e]
                          ivs <- parseDict' rest
                          return $ (T.pack $ concatMap showContent $ cs, v) : ivs
                  parseDict' _ = Nothing

        parseArray :: [Content] -> Maybe [Value]
        parseArray cs = parseArray' (onlyElems cs)
          where
                  parseArray' :: [Element] -> Maybe [Value]
                  parseArray' [] = return []
                  parseArray' (e : rest)
                     = do v <- parse [e]
                          vs <- parseArray' rest
                          return $ v : vs
                  parseDict' _ = Nothing


-- | Set Entry Value - Sets the value at Entry to Value
-- You can not set dictionaries or arrays.
set :: [Text] -> Value -> PlistBuddy ()
set []    value = error "Can not set empty path"
set entry (Date d) = mergeDate entry d (Set entry $ Date $ d)
set entry (Data d) = importData entry d (Set entry $ Data $ d)
set entry (Dict xs) = error "set: dict not allowed"
set entry (Array xs) = error "set: array not allowed"
set entry value = do
        debug ("set",entry,value,valueType value)
        plist <- ask
        dirty True
        res <- liftIO $ command plist $ "Set " <> BS.concat [ ":" <> quoteText e | e <- entry ]
                                      <> " " <> quoteValue value
        case res of
          "" -> do 
            liftIO $ plist_trail plist $ Set entry value
            return ()
          "Unrecognized Date Format" -> error $ "Unrecognized"
          _  -> throwPlistError $ PlistError $ "set failed: " ++ show res
    
-- | Add Entry Type [Value] - Adds Entry to the plist, with value Value
-- You can add *empty* dictionaries or arrays.
add :: [Text] -> Value -> PlistBuddy ()
add [] value = error "Can not add to an empty path"
add entry (Date d) = mergeDate entry d (Add entry $ Date $ d)
add entry (Data d) = importData entry d (Add entry $ Data $ d)
add entry (Dict xs) | not (null xs) = error "add: dict not empty"
add entry (Array xs) | not (null xs) = error "add: array not empty"
add entry value = do
        debug ("add",entry,value,valueType value)
        plist <- ask
        dirty True
        res <- liftIO $ command plist $ "Add "  <> BS.concat [ ":" <> quoteText e | e <- entry ]
                                      <> " " <> valueType value <> " "
                                      <> quoteValue value
        case res of
          "" -> do
            liftIO $ plist_trail plist $ Add entry value
            return ()
          _  -> throwPlistError $ PlistError $ "add failed: " ++ show res

-- | Delete Entry - Deletes Entry from the plist
delete :: [Text] -> PlistBuddy ()
delete entry = do
        debug ("delete",entry)
        plist <- ask
        dirty True
        res <- liftIO $ command plist $ "delete " <>  BS.concat [ ":" <> quoteText e | e <- entry ]
        case res of
          "" -> do
            liftIO $ plist_trail plist $ Delete entry
            return ()
          _  -> throwPlistError $ PlistError $ "delete failed: " ++ show res


importData :: [Text] -> ByteString -> Trail -> PlistBuddy ()
importData entry d t = do
  debug ("import(add/set)",entry,d)
  plist <- ask
  dirty True
  nm <- liftIO $ do
    (nm,h) <- openBinaryTempFile "/tmp" "plist-data-.tmp"
    BS.hPutStr h d -- write temp file with the binary data
    hClose h
    return nm
  res <- liftIO $ command plist $ "Import "  <> BS.concat [ ":" <> quoteText e | e <- entry ]
                                <> " "
                                <> (quoteText $ T.pack $ nm)
  liftIO $ removeFile nm
  case res of
    "" -> do
      liftIO $ plist_trail plist t
      return ()
    _  -> throwPlistError $ PlistError $ "import(add/set) failed: " ++ show res


-- a version of add/set that uses merge, because the date writing format
-- has a missing hour in Fall, because of timezones.
mergeDate :: [Text] -> UTCTime -> Trail -> PlistBuddy ()
mergeDate entry d t = do
  debug ("merge(set/get)",entry,d)
  plist <- ask
  dirty True
  v <- get (init entry) -- 'orable way of doing this. Best of bad options
  case v of
    Dict env -> do
      res <- liftIO $ do
            (nm,h) <- openBinaryTempFile "/tmp" "plist-date-.tmp"
            hPutStr h $ showTopElement $ 
                          unode "dict" $
                              [ unode "key"  $ T.unpack $ last entry
                              , unode "date" $ formatTime defaultTimeLocale "%FT%XZ" d
                              ]
            hClose h
            when (last entry `elem` map fst env) $ do
              void $ command plist $ "delete " <>  BS.concat [ ":" <> quoteText e | e <- entry ]
            res <- command plist $ "merge " <> quoteText (T.pack nm) <> " "
                                            <> BS.concat [ ":" <> quoteText e | e <- (init entry) ]
            removeFile nm
            return res
      case res of
        "" -> do
          liftIO $ plist_trail plist t
          return ()
        _  -> throwPlistError $ PlistError $ "merge(set/get) failed: " ++ show res
    Array vs | T.all isDigit (last entry) -> do
      res <- liftIO $ do
            (nm,h) <- openBinaryTempFile "/tmp" "plist-date-.tmp"
            hPutStr h $ showTopElement $ 
                          unode "array" $
                              [ unode "date" $ formatTime defaultTimeLocale "%FT%XZ" d
                              ]
            hClose h
            -- add to end of list
            res <- command plist $ "merge " <> quoteText (T.pack nm) <> " "
                                            <> BS.concat [ ":" <> quoteText e | e <- (init entry) ]
            removeFile nm
            -- now, move the inserted value to the correct place
            let n = if T.null (last entry)
                    then length vs  -- "" inserts at the end
                    else read $ T.unpack $ last $ entry

            when (n < length vs) $ do
              -- We need to move it
             let path x = BS.concat [ ":" <> quoteText e 
                                    | e <- init entry ++ [T.pack $ show $ x]
                                    ] 
             void $ command plist $ "copy " <> path (length vs) <> " " <> path n
             void $ command plist $ "delete " <> path (length vs)
            return res
      case res of
        "" -> return ()
        _  -> throwPlistError $ PlistError $ "merge(set/get) failed: " ++ show res

    _ -> error $ "add/set error for date; path type error"
  

dirty :: Bool -> PlistBuddy ()
dirty b = do
  plist <- ask
  liftIO $ writeIORef (plist_dirty plist) $ Just b


{-                
-- Not (yet) supported
--    Copy <EntrySrc> <EntryDst> - Copies the EntrySrc property to EntryDst
--    Merge <file.plist> [<Entry>] - Adds the contents of file.plist to Entry
--    Import <Entry> <file> - Creates or sets Entry the contents of file
-}

quoteText :: Text -> ByteString
quoteText = quoteBS . E.encodeUtf8

quoteBS :: ByteString -> ByteString
quoteBS q = "'" <> BS.concatMap esc q <> "'"
  where esc 39 = "\\'"
        esc 92 = "\\\\"  -- RTT moment
        esc 10 = "\\n"
        esc 34 = "\\\""
        esc c  = BS.pack [c]


quoteValue :: Value -> ByteString
quoteValue (String txt) = quoteBS $ E.encodeUtf8 $ txt
quoteValue (Array {})   = ""
quoteValue (Dict {})    = ""
quoteValue (Bool True)  = "true"
quoteValue (Bool False) = "false"
quoteValue (Real r)     = E.encodeUtf8 $ T.pack $ show r
quoteValue (Integer i)  = E.encodeUtf8 $ T.pack $ show i
quoteValue other        = error $ "can not quote " ++ show other

valueType :: Value -> ByteString
valueType (String txt) = "string"
valueType (Array {})   = "array"
valueType (Dict {})    = "dict"
valueType (Bool True)  = "bool"
valueType (Bool False) = "bool"
valueType (Real r)     = "real"
valueType (Integer i)  = "integer"
valueType (Date {})    = "date"
valueType (Data {})    = "data"

------------------------------------------------------------------------------

debug :: (Show a) => a -> PlistBuddy ()
debug a = do
        plist <- ask
        when (plist_debug plist) $ do
                liftIO $ do
                  tid <- myThreadId
                  print (tid,a)


------------------------------------------------------------------------------


-- | 'replay' invokes the respective 'PlistBuddy' function. It is uses
--  when replying an audit replay.
replay :: Trail -> PlistBuddy ()
replay (Save {})  = save
replay Revert     = revert
replay Exit       = exit
replay (Clear v)  = clear v
replay (Set p v)  = set p v
replay (Add p v)  = add p v
replay (Delete p) = delete p
replay (Types.Start {}) = return ()


--------------------------------------

-- | This is a version of Plist that saves the database on regular occasions,
--   and suspends itself when not used.
data BackgroundPlist = BackgroundPlist Int (IO Plist) (MVar BackgroundState)

data BackgroundState
  = Sleeping
  | Awake Plist

-- | This creates a background Plist. The 'Int' argument is the number of seconds
-- to wait before saveing and sleeping the Plist. The 'IO Plist' may be called many times.  
backgroundPlist :: Int -> IO Plist -> IO BackgroundPlist
backgroundPlist n p = do
  v <- newMVar Sleeping
  return $ BackgroundPlist n p v

-- | Send a command to a background Plist.
--   The semantics of bgSend is the same as saving after every command, provided you wait long enough.
bgSend :: BackgroundPlist -> PlistBuddy a -> IO a
bgSend bg@(BackgroundPlist n p v) m = do
  st <- takeMVar v
  case st of
    Sleeping -> do
        dev <- p
        forkIO $ do
          threadDelay (n * 1000 * 1000)
          bgAutoSave bg
        r <- send dev m -- TODO: handle exceptions here
        putMVar v $ Awake dev  
        return r
    Awake dev -> do
        r <- send dev m -- TODO: handle exceptions here
        putMVar v $ Awake dev  
        return r

-- | Save (if needed) and exit. The BackgroundPlist goes to sleep. 
bgAutoSave :: BackgroundPlist -> IO ()
bgAutoSave bg@(BackgroundPlist n p v) = do
  st <- takeMVar v
  case st of
    Sleeping -> putMVar v Sleeping
    Awake dev -> do
        -- assumes no one else is read/writing. The MVar BackgroundState is acting as a lock
        d <- readIORef (plist_dirty dev)
        (case d of
          Nothing    -> return ()
          Just True  -> send dev $ do { save ; exit } 
          Just False -> send dev $ do { exit }) `finally` putMVar v Sleeping