-- | A more type-safe version of file paths
--
-- This module is intended to replace imports of System.FilePath, and
-- additionally exports thin wrappers around common IO functions.  To facilitate
-- importing this module unqualified we also re-export some  definitions from
-- System.IO (importing both would likely lead to name clashes).
--
-- Note that his module does not import any other modules from Hackage.Security;
-- everywhere else we use Path instead of FilePath directly.
module Hackage.Security.Util.Path (
    -- * Path fragments
    Fragment  -- opaque
  , mkFragment
  , unFragment
    -- * Paths
  , Path
  , Unrooted
  , Rooted(..)
  , UnrootedPath
  , IsRoot(..)
  -- ** Construcion and destruction
  , fragment
  , fragment'
  , (</>)
  , rootPath
  , unrootPath
  , unrootPath'
  , castRoot
  -- ** Unrooted paths
  , joinFragments
  , splitFragments
  , toUnrootedFilePath
  , fromUnrootedFilePath
  , isPathPrefixOf
  -- ** FilePath-like operations
  , takeDirectory
  , takeFileName
  , (<.>)
  , splitExtension
  -- * File-system paths
  , IsFileSystemRoot
  , Relative
  , Absolute
  , HomeDir
  , AbsolutePath
  , RelativePath
  , FileSystemPath(..)
  -- ** Conversions
  , toFilePath
  , fromFilePath
  , makeAbsolute
  , toAbsoluteFilePath
  , fromAbsoluteFilePath
  -- ** Wrappers around System.IO
  , openTempFile
  , withFileInReadMode
  -- ** Wrappers around Data.ByteString.*
  , readLazyByteString
  , readStrictByteString
  -- ** Wrappers around System.Directory
  , createDirectoryIfMissing
  , doesDirectoryExist
  , doesFileExist
  , getCurrentDirectory
  , getDirectoryContents
  , getRecursiveContents
  , getTemporaryDirectory
  , removeFile
  , renameFile
  -- ** Wrappers around Codec.Archive.Tar.*
  , TarballRoot
  , TarballPath
  , tarIndexLookup
  , tarAppend
  -- * Paths in URIs
  , WebRoot
  , URIPath
  , uriPath
  , modifyUriPath
    -- * Re-exports
  , IO.IOMode(..)
  , IO.BufferMode(..)
  , IO.Handle
  , IO.hSetBuffering
  , IO.hClose
  , IO.hFileSize
  ) where

import Control.Monad
import Data.Function (on)
import qualified Data.ByteString         as BS
import qualified Data.ByteString.Lazy    as BS.L
import qualified System.FilePath         as FilePath hiding (splitPath)
import qualified System.IO               as IO
import qualified System.Directory        as Dir
import qualified Codec.Archive.Tar       as Tar
import qualified Codec.Archive.Tar.Index as TarIndex
import qualified Network.URI             as URI

import Hackage.Security.Util.Pretty

{-------------------------------------------------------------------------------
  Fragments
-------------------------------------------------------------------------------}

-- | Path fragments
--
-- Path fragments must be non-empty and not contain any path delimiters.
newtype Fragment = Fragment { unFragment :: String }
  deriving (Show, Eq, Ord)

instance Pretty Fragment where
  pretty = unFragment

mkFragment :: String -> Fragment
mkFragment str
    | hasSep str = invalid "fragment contains path separators"
    | null str   = invalid "empty fragment"
    | otherwise  = Fragment str
  where
    invalid :: String -> a
    invalid msg = error $ "mkFragment: " ++ show str ++ ": " ++ msg

    hasSep :: String -> Bool
    hasSep = any FilePath.isPathSeparator

{-------------------------------------------------------------------------------
  Paths
-------------------------------------------------------------------------------}

-- | Unrooted paths
--
-- Unrooted paths need a root before they can be interpreted.
data Unrooted

-- | Rooted paths
--
-- The 'a' parameter is a phantom argument; 'Rooted' is effectively a proxy.
data Rooted a = Rooted
  deriving (Show)

-- | Paths
--
-- A path consists of an optional root and a list of fragments.
-- Alternatively, think of it as a list with two kinds of nil-constructors.
data Path a where
    PathRoot :: Rooted root -> Path (Rooted root)
    PathNil  :: Path Unrooted
    PathSnoc :: Path a -> Fragment -> Path a

deriving instance Show (Path a)

class IsRoot root where
  showRoot :: Rooted root -> String

type UnrootedPath = Path Unrooted

instance Eq (Path a) where
  (==) = (==) `on` (splitFragments . unrootPath')

instance Ord (Path a) where
  (<=) = (<=) `on` (splitFragments . unrootPath')

-- | Turn a path into a human-readable string
instance IsRoot root => Pretty (Path (Rooted root)) where
  pretty path = showRoot root FilePath.</> toUnrootedFilePath unrooted
    where
      (root, unrooted) = unrootPath path

{-------------------------------------------------------------------------------
  Constructing and destructing paths
-------------------------------------------------------------------------------}

fragment :: Fragment -> UnrootedPath
fragment = PathSnoc PathNil

-- | For convenience: combine `fragment` and `mkFragment`
--
-- This can therefore throw the same runtime errors as `mkFragment`.
fragment' :: String -> UnrootedPath
fragment' = fragment . mkFragment

(</>) :: Path a -> UnrootedPath -> Path a
ps </> PathNil       = ps
ps </> PathSnoc qs q = PathSnoc (ps </> qs) q

rootPath :: forall root. Rooted root -> UnrootedPath -> Path (Rooted root)
rootPath root = go
  where
    go :: UnrootedPath -> Path (Rooted root)
    go PathNil         = PathRoot root
    go (PathSnoc qs q) = PathSnoc (go qs) q

unrootPath :: Path (Rooted root) -> (Rooted root, UnrootedPath)
unrootPath (PathRoot root) = (root, PathNil)
unrootPath (PathSnoc qs q) = let (root, unrooted) = unrootPath qs
                             in (root, PathSnoc unrooted q)

unrootPath' :: Path a -> UnrootedPath
unrootPath' (PathRoot _)    = PathNil
unrootPath' PathNil         = PathNil
unrootPath' (PathSnoc qs q) = PathSnoc (unrootPath' qs) q

-- | Reinterpret the root of a path
castRoot :: Path (Rooted root) -> Path (Rooted root')
castRoot (PathRoot _)    = PathRoot Rooted
castRoot (PathSnoc qs q) = PathSnoc (castRoot qs) q

{-------------------------------------------------------------------------------
  Unrooted paths
-------------------------------------------------------------------------------}

joinFragments :: [Fragment] -> UnrootedPath
joinFragments = go PathNil
  where
    go :: UnrootedPath -> [Fragment] -> UnrootedPath
    go acc []     = acc
    go acc (p:ps) = go (PathSnoc acc p) ps

splitFragments :: UnrootedPath -> [Fragment]
splitFragments = go []
  where
    go :: [Fragment] -> UnrootedPath -> [Fragment]
    go acc PathNil         = acc
    go acc (PathSnoc ps p) = go (p:acc) ps

toUnrootedFilePath :: UnrootedPath -> FilePath
toUnrootedFilePath = FilePath.joinPath . map unFragment . splitFragments

fromUnrootedFilePath :: FilePath -> UnrootedPath
fromUnrootedFilePath = joinFragments . map mkFragment . splitPath

isPathPrefixOf :: UnrootedPath -> UnrootedPath -> Bool
isPathPrefixOf = go `on` splitFragments
  where
    go :: [Fragment] -> [Fragment] -> Bool
    go []     _      = True
    go _      []     = False
    go (p:ps) (q:qs) = p == q && go ps qs

{-------------------------------------------------------------------------------
  FilePath-like operations
-------------------------------------------------------------------------------}

takeDirectory :: Path a -> Path a
takeDirectory (PathRoot root) = PathRoot root
takeDirectory PathNil         = PathNil
takeDirectory (PathSnoc ps _) = ps

takeFileName :: Path a -> Fragment
takeFileName (PathRoot _)   = error "takeFileName: empty path"
takeFileName PathNil        = error "takeFileName: empty path"
takeFileName (PathSnoc _ p) = p

(<.>) :: Path a -> String -> Path a
PathRoot _    <.> _   = error "(<.>): empty path"
PathNil       <.> _   = error "(<.>): empty path"
PathSnoc ps p <.> ext = PathSnoc ps p'
  where
    p' = mkFragment $ unFragment p FilePath.<.> ext

splitExtension :: Path a -> (Path a, String)
splitExtension (PathRoot _)    = error "splitExtension: empty path"
splitExtension PathNil         = error "splitExtension: empty path"
splitExtension (PathSnoc ps p) =
    let (p', ext) = FilePath.splitExtension (unFragment p)
    in (PathSnoc ps (mkFragment p'), ext)

{-------------------------------------------------------------------------------
  File-system paths
-------------------------------------------------------------------------------}

-- | A file system root can be interpreted as an (absolute) FilePath
class IsRoot root => IsFileSystemRoot root where
    interpretRoot :: Rooted root -> IO FilePath

data Relative
data Absolute
data HomeDir

type AbsolutePath = Path (Rooted Absolute)
type RelativePath = Path (Rooted Relative)

instance IsRoot Relative where showRoot _ = "."
instance IsRoot Absolute where showRoot _ = "/"
instance IsRoot HomeDir  where showRoot _ = "~"

instance IsFileSystemRoot Relative where
    interpretRoot _ = Dir.getCurrentDirectory

instance IsFileSystemRoot Absolute where
    interpretRoot _ = return "/"

instance IsFileSystemRoot HomeDir where
    interpretRoot _ = Dir.getHomeDirectory

-- | Abstract over a file system root
--
-- see 'fromFilePath'
data FileSystemPath where
    FileSystemPath :: IsFileSystemRoot root => Path (Rooted root) -> FileSystemPath

{-------------------------------------------------------------------------------
  Conversions
-------------------------------------------------------------------------------}

toFilePath :: AbsolutePath -> FilePath
toFilePath path = "/" FilePath.</> toUnrootedFilePath (unrootPath' path)

fromFilePath :: FilePath -> FileSystemPath
fromFilePath ('/':path) = FileSystemPath $
    rootPath (Rooted :: Rooted Absolute) (fromUnrootedFilePath path)
fromFilePath ('~':'/':path) = FileSystemPath $
    rootPath (Rooted :: Rooted HomeDir)  (fromUnrootedFilePath path)
fromFilePath path = FileSystemPath $
    rootPath (Rooted :: Rooted Relative) (fromUnrootedFilePath path)

makeAbsolute :: FileSystemPath -> IO AbsolutePath
makeAbsolute (FileSystemPath path) = do
    let (root, unrooted) = unrootPath path
    rootFilePath <- fromUnrootedFilePath <$> interpretRoot root
    return $ rootPath Rooted (rootFilePath </> unrooted)

toAbsoluteFilePath :: IsFileSystemRoot root => Path (Rooted root) -> IO FilePath
toAbsoluteFilePath = fmap toFilePath . makeAbsolute . FileSystemPath

fromAbsoluteFilePath :: FilePath -> AbsolutePath
fromAbsoluteFilePath ('/':path) = rootPath Rooted (fromUnrootedFilePath path)
fromAbsoluteFilePath _ = error "fromAbsoluteFilePath: not an absolute path"

{-------------------------------------------------------------------------------
  Wrappers around System.IO
-------------------------------------------------------------------------------}

-- | Open a file in read mode
--
-- We don't wrap the general 'withFile' to encourage using atomic file ops.
withFileInReadMode :: IsFileSystemRoot root
                   => Path (Rooted root) -> (IO.Handle -> IO r) -> IO r
withFileInReadMode path callback = do
    filePath <- toAbsoluteFilePath path
    IO.withFile filePath IO.ReadMode callback

-- | Wrapper around 'openBinaryTempFileWithDefaultPermissions'
openTempFile :: forall root. IsFileSystemRoot root
             => Path (Rooted root) -> String -> IO (AbsolutePath, IO.Handle)
openTempFile path template = do
    filePath <- toAbsoluteFilePath path
    (tempFilePath, h) <- IO.openBinaryTempFileWithDefaultPermissions filePath template
    return (fromAbsoluteFilePath tempFilePath, h)

{-------------------------------------------------------------------------------
  Wrappers around Data.ByteString.*
-------------------------------------------------------------------------------}

readLazyByteString :: IsFileSystemRoot root
                   => Path (Rooted root) -> IO BS.L.ByteString
readLazyByteString path = do
    filePath <- toAbsoluteFilePath path
    BS.L.readFile filePath

readStrictByteString :: IsFileSystemRoot root
                     => Path (Rooted root) -> IO BS.ByteString
readStrictByteString path = do
    filePath <- toAbsoluteFilePath path
    BS.readFile filePath

{-------------------------------------------------------------------------------
  Wrappers around System.Directory
-------------------------------------------------------------------------------}

createDirectoryIfMissing :: IsFileSystemRoot root
                         => Bool -> Path (Rooted root) -> IO ()
createDirectoryIfMissing createParents path = do
    filePath <- toAbsoluteFilePath path
    Dir.createDirectoryIfMissing createParents filePath

doesFileExist :: IsFileSystemRoot root => Path (Rooted root) -> IO Bool
doesFileExist path = do
    filePath <- toAbsoluteFilePath path
    Dir.doesFileExist filePath

doesDirectoryExist :: IsFileSystemRoot root => Path (Rooted root) -> IO Bool
doesDirectoryExist path = do
    filePath <- toAbsoluteFilePath path
    Dir.doesDirectoryExist filePath

removeFile :: IsFileSystemRoot root => Path (Rooted root) -> IO ()
removeFile path = do
    filePath <- toAbsoluteFilePath path
    Dir.removeFile filePath

getTemporaryDirectory :: IO AbsolutePath
getTemporaryDirectory = fromAbsoluteFilePath <$> Dir.getTemporaryDirectory

-- | Return the immediate children of a directory
--
-- Filters out @"."@ and @".."@.
getDirectoryContents :: IsFileSystemRoot root
                     => Path (Rooted root) -> IO [UnrootedPath]
getDirectoryContents path = do
    filePath <- toAbsoluteFilePath path
    fragments <$> Dir.getDirectoryContents filePath
  where
    fragments :: [String] -> [UnrootedPath]
    fragments = map fragment' . filter (not . skip)

    skip :: String -> Bool
    skip "."  = True
    skip ".." = True
    skip _    = False

-- | Recursive traverse a directory structure
--
-- Returns a set of paths relative to the directory specified.
-- TODO: Not sure about the memory behaviour with large file systems.
getRecursiveContents :: IsFileSystemRoot root
                     => Path (Rooted root)
                     -> IO [UnrootedPath]
getRecursiveContents root = go PathNil
  where
    go :: UnrootedPath -> IO [UnrootedPath]
    go subdir = do
      entries <- getDirectoryContents (root </> subdir)
      liftM concat $ forM entries $ \entry -> do
        let path = subdir </> entry
        isDirectory <- doesDirectoryExist (root </> path)
        if isDirectory then go path
                       else return [path]

renameFile :: (IsFileSystemRoot root, IsFileSystemRoot root1)
           => Path (Rooted root)  -- ^ Old
           -> Path (Rooted root1) -- ^ New
           -> IO ()
renameFile old new = do
    old' <- toAbsoluteFilePath old
    new' <- toAbsoluteFilePath new
    Dir.renameFile old' new'

getCurrentDirectory :: IO AbsolutePath
getCurrentDirectory = do
    cwd <- Dir.getCurrentDirectory
    makeAbsolute $ fromFilePath cwd

{-------------------------------------------------------------------------------
  Wrappers around Codec.Archive.Tar.*
-------------------------------------------------------------------------------}

data TarballRoot
type TarballPath = Path (Rooted TarballRoot)

instance Show (Rooted TarballRoot) where show _ = "<tarball>"

tarIndexLookup :: TarIndex.TarIndex -> TarballPath -> Maybe TarIndex.TarIndexEntry
tarIndexLookup index path = TarIndex.lookup index path'
  where
    path' :: FilePath
    path' = toUnrootedFilePath $ unrootPath' path

tarAppend :: (IsFileSystemRoot root, IsFileSystemRoot root')
          => Path (Rooted root)  -- ^ Path of the @.tar@ file
          -> Path (Rooted root') -- ^ Base directory
          -> [TarballPath]       -- ^ Files to add, relative to the base dir
          -> IO ()
tarAppend tarFile baseDir contents = do
    tarFile' <- toAbsoluteFilePath tarFile
    baseDir' <- toAbsoluteFilePath baseDir
    Tar.append tarFile' baseDir' contents'
  where
    contents' :: [FilePath]
    contents' = map (toUnrootedFilePath . unrootPath') contents

{-------------------------------------------------------------------------------
  Wrappers around Network.URI
-------------------------------------------------------------------------------}

data WebRoot

type URIPath = Path (Rooted WebRoot)

toURIPath :: FilePath -> URIPath
toURIPath = rootPath Rooted . fromUnrootedFilePath

fromURIPath :: URIPath -> FilePath
fromURIPath = toUnrootedFilePath . unrootPath'

uriPath :: URI.URI -> URIPath
uriPath = toURIPath . URI.uriPath

modifyUriPath :: URI.URI -> (URIPath -> URIPath) -> URI.URI
modifyUriPath uri f = uri { URI.uriPath = f' (URI.uriPath uri) }
  where
    f' :: FilePath -> FilePath
    f' = fromURIPath . f . toURIPath

{-------------------------------------------------------------------------------
  Auxiliary: operations on raw FilePaths
-------------------------------------------------------------------------------}

-- | Split a path into its components
--
-- Unlike 'FilePath.splitPath' this satisfies the invariants required by
-- 'mkFragment'. That is, the fragments do NOT contain any path separators.
--
-- Multiple consecutive path separators are considered to be the same as a
-- single path separator, and leading and trailing separators are ignored.
splitPath :: FilePath -> [FilePath]
splitPath = go []
  where
    go :: [FilePath] -> FilePath -> [FilePath]
    go acc fp = case break FilePath.isPathSeparator fp of
                  ("", [])    -> reverse acc
                  (fr, [])    -> reverse (fr:acc)
                  ("", _:fp') -> go acc      fp'
                  (fr, _:fp') -> go (fr:acc) fp'