{-# LANGUAGE LambdaCase                 #-}
{-# LANGUAGE OverloadedStrings          #-}
{-# LANGUAGE ViewPatterns               #-}

{-|
Functionality we're currently missing (an assuredly incomplete list):

* @.git@ textfile (gitdir: some-path)
* using $GIT_DIRECTORY
* @objects/info/alternates@ or $GIT_ALTERNATE_OBJECT_DIRECTORIES
-}

module Data.Git.Monad
    (
    -- * The Git Monad
      MonadGit(..)
    , GitT
    , Git
    , runGit
    , runGitT

    -- * Repository Management
    , initRepo
    , repoPath

    -- * Object Reading
    , findBlob
    , findTag
    , findTree
    , findTreeish
    , findCommit
    , grepCommit
    , resolveSha
    , resolveBlob

    -- * Object Writing
    , writeBlob
    , writeTree
    , writeCommit
    , writeTag

    -- * Writing Packfiles
    , packing

    -- * Ref Handling
    , readBranch
    , readHead
    , writeBranch
    , writeHead
    , detachHead
    , listBranches
    , readPackedRefs
    , peelRef
    , peeled
    ) where

import           Prelude hiding (fail)
import           Codec.Compression.Zlib
import           Control.Monad.Catch
import           Control.Monad.Fail
import           Control.Monad.State hiding (fail)
import           Control.Monad.Trans.Maybe
import qualified Data.ByteString               as B
import qualified Data.ByteString.Builder       as BB
import qualified Data.ByteString.Lazy          as BL
import           Data.Foldable
import           Data.Map                      (Map)
import qualified Data.Map                      as M
import qualified Data.Set                      as S
import           System.IO.Error               (isDoesNotExistError)
import           System.Posix.Directory.Traversals (traverseDirectory)
import           System.Posix.FilePath
import           System.Posix.Files.ByteString

import Data.Git.Formats
import Data.Git.Hash
import Data.Git.Internal.FileUtil
import Data.Git.Internal.Object (parseObject)
import Data.Git.Internal.Pack (isPackIndex)
import Data.Git.Internal.Parsers
import Data.Git.Internal.Types  (GitConf(..), GitT(..))
import Data.Git.Object
import Data.Git.Pack
import Data.Git.Paths
import Data.Git.Ref
import Data.Git.RefName
import Data.Git.Types

-- | Monads that let you work with git repositories.
class Monad m => MonadGit m where
    -- | Try to look up an object by its 'Sha1'.
    lookupSha    :: Sha1 -> m (Maybe Object)
    -- | Write an 'Object' to storage, returning its 'Sha1'.  We should have the law:
    --   @
    --     writeObject o >>= \s -> flushObjects >> lookupSha s == return (Just o)
    --   @
    writeObject  :: Object -> m Sha1
    -- | Flush written 'Object's to disk.  Defaults to a no-op.
    flushObjects :: m ()
    flushObjects = return ()
    -- | Try to get the 'Sha1' from a 'Ref'.
    lookupRef    :: Ref -> m (Maybe Sha1)
    -- | A 'Map' from 'Ref's to the 'Sha1's at which they point, optionally.  An instance may choose
    --   not to provide hashes for some 'Ref's (e.g., only providing hashes for packed refs, but not
    --   loose ones).
    listRefs     :: m (Map Ref (Maybe Sha1))
    -- | Write a 'Ref' to the given 'Sha1'.
    writeRef     :: Ref -> Sha1 -> m ()
    -- | Write a symref from the first argument to the second.
    writeSymRef :: Ref -- ^ write a symref here
                -> Ref -- ^ pointing to here
                -> m ()
    -- | Register a packfile with git so it knows to search it.
    --   Primarily a support function for PackingT, and perhaps should be seperated out.
    registerPack :: PackFile -> m ()

findThing :: MonadGit m => (Object -> Maybe a) -> Sha1 -> m (Maybe a)
findThing thing s = lookupSha s >>= return . maybe Nothing thing

-- | Lookup a 'Sha1'.  'Nothing' when the object does not exist or is not a 'Blob'.
findBlob :: MonadGit m => Sha1 -> m (Maybe Blob)
findBlob = findThing asBlob

-- | Lookup a 'Sha1'.  'Nothing' when the object does not exist or is not a 'Tree'.
findTree :: MonadGit m => Sha1 -> m (Maybe Tree)
findTree = findThing asTree

-- | Lookup a 'Sha1'.  'Nothing' when the object does not exist or is not a 'Commit'.
findCommit :: MonadGit m => Sha1 -> m (Maybe Commit)
findCommit = findThing asCommit

-- | Lookup a 'Sha1'.  'Nothing' when the object does not exist or is not a 'Tag'.
findTag :: MonadGit m => Sha1 -> m (Maybe Tag)
findTag = findThing asTag

findTreeishSha :: MonadGit m => Sha1 -> m (Maybe Sha1)
findTreeishSha s = lookupSha s >>= \case
                                   Just (TreeObj   _) -> return $ Just s
                                   Just (CommitObj c) -> return . Just $ commitTree c
                                   Just (TagObj    t) -> findTreeishSha $ tagObject t
                                   _                  -> return Nothing

-- | A "treeish" is an object that can be recursively dereferenced to a 'Tree'.  This includes
--   'Tree's themselves, 'Commit's, and (usually) 'Tag's.
findTreeish :: MonadGit m => Sha1 -> m (Maybe Tree)
findTreeish s = lookupSha s >>= \case
                                Just (TreeObj   t) -> return      $ Just t
                                Just (CommitObj c) -> findTree    $ commitTree c
                                Just (TagObj    t) -> findTreeish $ tagObject t
                                _                  -> return Nothing

-- | Given a 'Sha1' that refers to a tree-ish (see 'findTreeish') and a list of path components,
--   find the 'Sha1' of the object in the tree at that path.
resolveSha :: (MonadFail m, MonadGit m) => Sha1 -> [PathComponent] -> m (Maybe Sha1)
resolveSha s [] = findTreeishSha s
resolveSha s p  = runMaybeT $ do t <- MaybeT (findTreeish s)
                                 go t p
    where go _ []            = fail "empty path should not occur in this part of resolveSha, wasn't a Treeish?"
          go (Tree t) [b]    = MaybeT . return $ lookupThing b t
          go (Tree t) (d:bs) = do dir  <- MaybeT . return $ M.lookup (Entry d TreeMode) t
                                  t'   <- MaybeT $ findTree dir
                                  go t' bs
          lookupThing b t = msum [ M.lookup (Entry b m) t | m <- modes ]
          modes           = [ BlobMode, ExecMode, TreeMode, SubmMode, LinkMode ]

-- | As 'resolveSha', expecting a 'Blob' at the given location.
resolveBlob :: (MonadFail m, MonadGit m) => Sha1 -> [PathComponent] -> m (Maybe Blob)
resolveBlob s p = runMaybeT $ do b <- MaybeT $ resolveSha s p
                                 MaybeT $ findBlob b

-- | Search through the 'Commit' with the given 'Sha1' and its ancestors, for the first commit
--   satisfying the given predicate.
grepCommit :: MonadGit m => (Commit -> Bool) -> Sha1 -> m (Maybe Sha1)
grepCommit f ref = do
  jc <- findCommit ref
  case jc of
    Just c -> do
      ps <- grepCommit f `mapM` commitParents c
      return $ if f c then Just ref else asum ps
    -- TODO: Shallow checkout?
    Nothing -> pure Nothing

-- | Write a 'Blob', returning its 'Sha1'.
writeBlob :: MonadGit m => Blob -> m Sha1
writeBlob = writeObject . BlobObj

-- | Write a 'Tree', returning its 'Sha1'.
writeTree :: MonadGit m => Tree -> m Sha1
writeTree = writeObject . TreeObj

-- | Write a 'Commit', returning its 'Sha1'.
writeCommit :: MonadGit m => Commit -> m Sha1
writeCommit = writeObject . CommitObj

-- | Write a 'Tag', returning its 'Sha1'.
writeTag :: MonadGit m => Tag -> m Sha1
writeTag = writeObject . TagObj

-- | Read a 'Sha1' out of a branch (in @refs/heads/@)
readBranch :: MonadGit m => RefName -> m (Maybe Sha1)
readBranch = lookupRef . Branch

-- | Set a branch (in @refs/heads/@) to a particular 'Sha1'.
writeBranch :: MonadIO m => RefName -> Sha1 -> GitT m ()
writeBranch p = writeRef (Branch p)

-- | Read the 'Sha1' in @HEAD@
readHead :: MonadGit m => m (Maybe Sha1)
readHead = lookupRef HEAD

-- | Set @HEAD@ to a specific 'Sha1'.  Leaves the repo in a "detached HEAD" state.
detachHead :: MonadGit m => Sha1 -> m ()
detachHead = writeRef HEAD

-- | Point @HEAD@ at a 'Ref'
writeHead :: MonadGit m => Ref -> m ()
writeHead = writeSymRef HEAD

-- | List all branches.
listBranches :: MonadGit m => m (S.Set RefName)
listBranches = listRefs >>= \rs -> return $ S.fromList [b | (Branch b, _) <- M.toList rs]

-- | Attempt to peel (recursively dereference) a ref (usually a tag) down to the 'Sha1' of a non-tag
--   object.  TODO: improve this documentation.
peelRef :: MonadGit m => Ref -> m (Maybe Sha1)
peelRef (TagRef _ (Just s)) = return (Just s)
peelRef r                   = do
  s <- lookupRef r
  o <- maybe (pure Nothing) findTag s
  maybe (pure s) chaseTag o
      where chaseTag Tag {tagType=tt, tagObject=to}
                | tt == TagType = findTag to >>= maybe (pure Nothing) chaseTag
                | otherwise    = return $ Just to

-- | The peeled version of a 'Ref'.
peeled :: MonadGit m => Ref -> m Ref
peeled t@(TagRef rn Nothing) = TagRef rn <$> peelRef t
peeled t                     = return t

-- | A concrete 'MonadGit' instance writing loose objects.
instance MonadIO m => MonadGit (GitT m) where
    lookupSha s  = do
      s' <- getLooseSha s
      maybe (gets $ msum . fmap (`findPackSha` s) . packs) (return . parseMaybe parseObject) s'

    writeObject  = writeLooseObject
    lookupRef r  = do
      path <- repoPath r
      ref  <- liftIO $ readRefFile path
      case ref of
        Nothing          -> (join . fmap (lookup r)) <$> readPackedRefs
        Just (ShaRef s)  -> return $ Just s
        Just (SymRef r') -> lookupRef r'
    listRefs     = do
      prs <- maybe mempty M.fromList <$> readPackedRefs
      lrs <- looseRefs
      return $ lrs `M.union` (Just <$> prs)
    writeRef r s = do
      path <- repoPath r
      liftIO . createRawDirectoryIfMissing True . takeDirectory $ path
      liftIO . writeRawFileS path . flip B.snoc 10 . getSha1Hex . toHex $ s
    writeSymRef from to = do
      fromPath <- repoPath from
      liftIO . createRawDirectoryIfMissing True . takeDirectory $ fromPath
      liftIO . writeRawFileS fromPath . flip B.snoc 10 . B.append "ref: " $ inRepo to
    registerPack p = GitT $ modify (\g -> g { packs = p:(packs g) })

getLooseSha :: MonadIO m => Sha1 -> GitT m (Maybe BL.ByteString)
getLooseSha s = do
  p <- repoPath $ looseObjectPath s
  mwhenFileExists p (liftIO . fmap decompress . readRawFileL $ p)

writeLooseSha :: MonadIO m => Sha1 -> BL.ByteString -> GitT m Sha1
writeLooseSha s bs = do p <- repoPath $ looseObjectPath s
                        liftIO $ createRawDirectoryIfMissing True (takeDirectory p)
                                   >> writeRawFileL p bs
                                   >> return s

writeLooseObject :: MonadIO m => Object -> GitT m Sha1
writeLooseObject o = writeLooseSha (sha1 b) (compress b)
    where b = BB.toLazyByteString . buildLooseObject $ o

packIndices :: MonadIO m => GitT m [RawFilePath]
packIndices = do
  pd <- repoPath packDir
  liftIO . handleJust (guard . isDoesNotExistError) (const $ return []) .
         fmap (filter isPackIndex) . getRawDirectoryContents $ pd

addPackFile :: MonadIO m => RawFilePath -> GitT m ()
addPackFile fp = do gc <- get
                    pd <- repoPath packDir
                    pf <- liftIO $ readPackFile (pd </> dropExtension fp)
                    put gc { packs = pf : packs gc }

loadPackFiles :: MonadIO m => GitT m ()
loadPackFiles = packIndices >>= mapM_ addPackFile

-- | Read this repository's @packed-refs@ file, if it's there.
--
--   NB: Loose refs have priority over packed refs, so if (for example) a branch exists both loose
--   and packed in the repository and is associated with different hashes, it points to whatever the
--   loose one says.  *However*, this function intentionally  does *not* honor that.
readPackedRefs :: MonadIO m => GitT m (Maybe [(Ref, Sha1)])
readPackedRefs = repoPath packedRefsPath >>= liftIO . readPackedRefsFile

looseBranches, looseTags, looseRemotes, looseRefs :: MonadIO m => GitT m (Map Ref (Maybe Sha1))
looseBranches = findLooseRefs "refs/heads"
looseTags     = findLooseRefs "refs/tags"
looseRemotes  = findLooseRefs "refs/remotes"
looseRefs     = fold <$> sequence [looseBranches, looseTags, looseRemotes]

findLooseRefs :: MonadIO m => RawFilePath -> GitT m (Map Ref (Maybe Sha1))
findLooseRefs p = do path <- gets gitDir
                     whenFileExists (path </> p) mempty $ do
                       liftIO . withRawCurrentDirectory path $
                           traverseDirectory addBranch mempty p
    where addBranch acc (mkRef -> Just ref) = do -- strip of the leading "./"
            reg <- isRegularFile <$> getFileStatus (inRepo ref)
            return $ if reg then M.insert ref Nothing acc else acc
          addBranch acc _ = return acc

-- | Like the instance for 'GitT', but writing packfiles.
instance (MonadGit m, MonadIO m) => MonadGit (PackingT m) where
    lookupSha           = lift . lookupSha
    writeObject         = packObject
    lookupRef           = lift . lookupRef
    listRefs            = lift listRefs
    writeRef r s        = lift $ writeRef r s
    writeSymRef from to = lift $ writeSymRef from to
    flushObjects        = flushPackFile registerPack
    registerPack        = lift . registerPack

-- | Run a 'GitT' computation, writing objects to a packfile instead of loose.
--   Currently objects are not findable until flushObjects is called.
packing :: MonadIO m => PackingT (GitT m) a -> GitT m a
packing git = do
  template <- repoPath $ packDir </> "pack-"
  runPackingT registerPack template git

-- | Minimal rendition of @git init@.  When given 'Nothing', creates a @.git/@ the current working
--   directory.  When given @'Just' path@, initializes a repository at @path@.  Thus, @initRepo Nothing
--   == initRepo (Just ".git")@.
initRepo :: Maybe RawFilePath -> IO ()
initRepo Nothing  = initRepo $ Just ".git"
initRepo (Just d) = do
  createRawDirectoryIfMissing True d
  withRawCurrentDirectory d $ do
    createRawDirectoryIfMissing False "refs"
    createRawDirectoryIfMissing False "objects"
    writeRawFileS "HEAD" "ref: refs/heads/master"

-- | Do some git computations in the given git directory.
runGitT :: MonadIO m => RawFilePath -> GitT m a -> m a
runGitT p git = evalStateT (unGitT $ loadPackFiles >> git) conf
    where conf = GitConf p []

-- | Do some git computations in the given git directory.
runGit :: RawFilePath -> Git a -> IO a
runGit = runGitT