--  Copyright (C) 2009-2011 Petr Rockai
--
--  BSD3

{- | A monad transformer for 'Tree' mutation. The main idea is to simulate
IO-ish manipulation of real filesystem (that's the state part of the monad),
and to keep memory usage down by reasonably often dumping the intermediate
data to disk and forgetting it.

The implementation is configured by passing a procedure of type 'DumpItem'
to 'runTreeMonad'.

This module provides the pre-configured 'virtualTreeIO' that never
writes any changes, but may trigger filesystem reads as appropriate. -}

module Darcs.Util.Tree.Monad
    ( -- * 'TreeMonad'
      TreeMonad
    , TreeState(tree)
    , runTreeMonad
    , virtualTreeMonad
      -- * Specializing to 'IO'
    , TreeIO
    , virtualTreeIO
      -- * Read actions
    , readFile
    , exists
    , directoryExists
    , fileExists
      -- * Write actions
    , writeFile
    , createDirectory
    , unlink
    , rename
    , copy
      -- * Other actions
    , findM, findFileM, findTreeM
    ) where

import Darcs.Prelude hiding ( readFile, writeFile )

import Darcs.Util.Path ( AnchoredPath, anchoredRoot, displayPath, movedirfilename )
import Darcs.Util.Tree

import Data.List( sortBy )
import Data.Int( Int64 )
import Data.Maybe( isNothing, isJust )

import qualified Data.ByteString.Lazy as BL
import Control.Monad ( forM_, when, unless )
import Control.Monad.Catch ( MonadThrow(..) )
import Control.Monad.RWS.Strict (RWST, runRWST, ask, gets, lift, modify)
import qualified Data.Map as M

import System.IO.Error ( ioeSetErrorString, mkIOError )
import GHC.IO.Exception ( IOErrorType(..) )

-- | Keep track of the size and age of changes to the tree.
type Changed = M.Map AnchoredPath (Int64, Int64) -- size, age

-- | Internal state of the 'TreeMonad'. Keeps track of the current 'Tree'
-- content and unsync'd changes.
data TreeState m = TreeState
  { tree :: !(Tree m)
  , changed :: !Changed
  , changesize :: !Int64
  , maxage :: !Int64
  }

-- | A procedure for dumping a single 'TreeItem' to disk. If the implementation
-- uses item 'Hash'es, it is also responsible to ensure that its 'Hash' is
-- up-to-date. It is not allowed to make any changes to the actual content of
-- the 'TreeItem'.
type DumpItem m = AnchoredPath -> TreeItem m -> m (TreeItem m)

-- | A monad transformer that adds state of type 'TreeState' and an environment
-- of type 'DumpItem'.
type TreeMonad m = RWST (DumpItem m) () (TreeState m) m

-- | 'TreeMonad' specialized to 'IO'
type TreeIO = TreeMonad IO

initialState :: Tree m -> TreeState m
initialState t =
  TreeState {tree = t, changed = M.empty, changesize = 0, maxage = 0}

flush :: Monad m => TreeMonad m ()
flush = do changed' <- map fst . M.toList <$> gets changed
           dirs' <- gets tree >>= \t -> return [ path | (path, SubTree _) <- list t ]
           modify $ \st -> st { changed = M.empty, changesize = 0 }
           forM_ (changed' ++ dirs' ++ [anchoredRoot]) flushItem

runTreeMonad' :: Monad m => TreeMonad m a -> DumpItem m -> TreeState m -> m (a, Tree m)
runTreeMonad' action initEnv initState = do
  (out, final, _) <- runRWST action initEnv initState
  return (out, tree final)

runTreeMonad :: Monad m => TreeMonad m a -> Tree m -> DumpItem m -> m (a, Tree m)
runTreeMonad action t dump = do
  let action' = do x <- action
                   flush
                   return x
  runTreeMonad' action' dump (initialState t)

-- | Run a 'TreeMonad' action without storing any changes. This is useful for
-- running monadic tree mutations for obtaining the resulting 'Tree' (as opposed
-- to their effect of writing a modified tree to disk). The actions can do both
-- read and write -- reads are passed through to the actual filesystem, but the
-- writes are held in memory in the form of a modified 'Tree'.
virtualTreeMonad :: Monad m => TreeMonad m a -> Tree m -> m (a, Tree m)
virtualTreeMonad action t = runTreeMonad action t (const return)

-- | 'virtualTreeMonad' specialized to 'IO'
virtualTreeIO :: TreeIO a -> Tree IO -> IO (a, Tree IO)
virtualTreeIO = virtualTreeMonad

-- | Modifies an item in the current Tree. This action keeps an account of the
-- modified data, in changed and changesize, for subsequent flush
-- operations. Any modifications (as in "modifyTree") are allowed.
modifyItem :: Monad m
            => AnchoredPath -> Maybe (TreeItem m) -> TreeMonad m ()
modifyItem path item = do
  age <- gets maxage
  changed' <- gets changed
  let getsize (Just (File b)) = lift (BL.length <$> readBlob b)
      getsize _ = return 0
  size <- getsize item
  let change = case M.lookup path changed' of
        Nothing -> size
        Just (oldsize, _) -> size - oldsize

  modify $ \st -> st { tree = modifyTree (tree st) path item
                     , changed = M.insert path (size, age) (changed st)
                     , maxage = age + 1
                     , changesize = changesize st + change }

renameChanged :: Monad m
              => AnchoredPath -> AnchoredPath -> TreeMonad m ()
renameChanged from to = modify $ \st -> st {changed = rename' $ changed st}
  where
    rename' = M.mapKeys (movedirfilename from to)

-- | Replace an item with a new version without modifying the content of the
-- tree. This does not do any change tracking. Ought to be only used from a
-- 'sync' implementation for a particular storage format. The presumed use-case
-- is that an existing in-memory Blob is replaced with a one referring to an
-- on-disk file.
replaceItem :: Monad m
            => AnchoredPath -> TreeItem m -> TreeMonad m ()
replaceItem path item = do
  modify $ \st -> st { tree = modifyTree (tree st) path (Just item) }

-- | Flush a single item to disk. This is the only procedure that (directly)
-- uses the Reader part of the environment (the procedure of type @'DumpItem' m@).
flushItem :: forall m . Monad m => AnchoredPath -> TreeMonad m ()
flushItem path = do
  current <- gets tree
  dumpItem <- ask
  case find current path of
    Nothing -> return () -- vanished, do nothing
    Just item -> lift (dumpItem path item) >>= replaceItem path

-- | If buffers are becoming large, sync, otherwise do nothing.
flushSome :: Monad m => TreeMonad m ()
flushSome = do x <- gets changesize
               when (x > megs 100) $ do
                 remaining <- go =<< sortBy age . M.toList <$> gets changed
                 modify $ \s -> s { changed = M.fromList remaining }
  where go [] = return []
        go ((path, (size, _)):chs) = do
          x <- subtract size <$> gets changesize
          flushItem path
          modify $ \s -> s { changesize = x }
          if  x > megs 50  then go chs
                           else return chs
        megs = (* (1024 * 1024))
        age (_, (_, a)) (_, (_, b)) = compare a b

-- read only actions

expandTo :: Monad m => AnchoredPath -> TreeMonad m ()
expandTo p =
    do t <- gets tree
       t' <- lift $ expandPath t p
       modify $ \st -> st { tree = t' }

findItem :: Monad m => AnchoredPath -> TreeMonad m (Maybe (TreeItem m))
findItem path = do
  expandTo path
  tr <- gets tree
  return $ find tr path

-- | Check for existence of a file.
fileExists :: Monad m => AnchoredPath -> TreeMonad m Bool
fileExists p = do
  item <- findItem p
  case item of
    Just (File{}) -> return True
    _ -> return False

-- | Check for existence of a directory.
directoryExists :: Monad m => AnchoredPath -> TreeMonad m Bool
directoryExists p = do
  item <- findItem p
  case item of
    Just (SubTree{}) -> return True
    _ -> return False

-- | Check for existence of a node (file or directory, doesn't matter).
exists :: MonadThrow m => AnchoredPath -> TreeMonad m Bool
exists p = isJust <$> findItem p

-- | Grab content of a file in the current Tree at the given path.
readFile :: MonadThrow m => AnchoredPath -> TreeMonad m BL.ByteString
readFile p = do
  f <- findItem p
  case f of
    Just (File x) -> lift (readBlob x)
    Just _ ->
      throwM $
        flip ioeSetErrorString "is a directory" $
        mkIOError InappropriateType "readFile" Nothing (Just (displayPath p))
    Nothing ->
      throwM $
        mkIOError NoSuchThing "readFile" Nothing (Just (displayPath p))

-- | Change content of a file at a given path. The change will be
-- eventually flushed to disk, but might be buffered for some time.
writeFile :: MonadThrow m => AnchoredPath -> BL.ByteString -> TreeMonad m ()
writeFile p con = do
  item <- findItem p
  case item of
    Just (SubTree _) ->
      throwM $
      flip ioeSetErrorString "is a directory" $
      mkIOError InappropriateType "writeFile" Nothing (Just (displayPath p))
    _ ->
      -- note that writing to a non-existing file is allowed,
      -- in fact there is no primitive for creating a file
      modifyItem p (Just blob)
  flushSome
  where
    blob = File $ Blob (return con) Nothing
    -- we would like to say "sha256 con" here, but due to strictness of Hash in
    -- Blob, this would often lead to unnecessary computation which would then
    -- be discarded anyway; we rely on the sync implementation to fix up any
    -- Nothing occurrences

-- | Create a directory.
createDirectory :: Monad m => AnchoredPath -> TreeMonad m ()
createDirectory p =
    do expandTo p
       modifyItem p $ Just $ SubTree emptyTree

-- | Remove the item at a path.
unlink :: Monad m => AnchoredPath -> TreeMonad m ()
unlink p =
    do expandTo p
       modifyItem p Nothing

-- | Rename the item at a path.
rename :: MonadThrow m => AnchoredPath -> AnchoredPath -> TreeMonad m ()
rename from to = do
  item <- findItem from
  found_to <- findItem to
  unless (isJust item) $
    throwM $
      mkIOError NoSuchThing "rename" Nothing (Just (displayPath from))
  unless (isNothing found_to) $
    throwM $
      mkIOError AlreadyExists "rename" Nothing (Just (displayPath to))
  modifyItem from Nothing
  modifyItem to item
  renameChanged from to

-- | Copy an item from some path to another path.
-- Doing this with a SubTree is weird... it means copy recursively,
-- but with lazy copy-on-write semantics. What happens when we flush that?
-- Seems to work, though, as it is used in Darcs.UI.Commands.Convert.Import
copy :: MonadThrow m => AnchoredPath -> AnchoredPath -> TreeMonad m ()
copy from to = do
  expandTo to
  item <- findItem from
  when (isNothing item) $
    throwM $
    mkIOError NoSuchThing "copy" Nothing (Just (displayPath from))
  modifyItem to item

findM' :: forall m a . Monad m
       => (Tree m -> AnchoredPath -> a) -> Tree m -> AnchoredPath -> m a
findM' what t path = fst <$> virtualTreeMonad (look path) t
  where look :: AnchoredPath -> TreeMonad m a
        look p = expandTo p >> flip what p <$> gets tree

findM :: Monad m => Tree m -> AnchoredPath -> m (Maybe (TreeItem m))
findM = findM' find

findTreeM :: Monad m => Tree m -> AnchoredPath -> m (Maybe (Tree m))
findTreeM = findM' findTree

findFileM :: Monad m => Tree m -> AnchoredPath -> m (Maybe (Blob m))
findFileM = findM' findFile
