{-# LANGUAGE RecordWildCards #-}
-- | Reading/writing bundles of embedded files and other data from/to
--   executables.
--
--   A bundle has three parts: the static header, which identifies
--   a string of bytes as a bundle using a particular version of the format
--   and gives the size of the dynamic header; the dynamic header which
--   describes all files and directories contained in the bundle; and the data
--   part, where the data for all files is located.
--   All words are stored in little endian format.
--
--   The static header comprises the last 'bundleHeaderStaticSize' bytes of the
--   file, with the dynamic header coming immediately before, and the data
--   section coming immediately before the dynamic header.
--
--   The dynamic header is stored as a tuple of the number of files in the
--   bundle (Word32), and the .
--   Each file is stored as a triple of the file's UTF-8 encoded path,
--   its offset from the start of the data section, and its size.
--   The path is prepended with a Word32 giving its length in bytes; the
--   offset is given as a Word32 and the size is given as a Word32.
--
--   The layout of the bundle format is given in the following table:
--
--   > [file data]
--   > 
--   > [files] * hdrNumFiles
--   >   pathLen       : Word32
--   >   path          : pathLen * Word8
--   >   offset        : Word64
--   >   size          : Word32
--   > 
--   > [static header]
--   >   hdrDataOffset : Word64
--   >   hdrNumFiles   : Word32
--   >   hdrDynSize    : Word32
--   >   hdrVersion    : Word8
--   >   "BNDLLDNB"    : Word64
--
--   The included @embedtool@ program offers a command line interface
--   for manipulating and inspecting bundles.
module Data.Embed.File (
    Bundle,

    -- * Reading bundles
    hasBundle, openBundle, withBundle, closeBundle, readBundleFile, readBundle,
    listBundleFiles,

    -- * Creating bundles
    File (..), appendBundle, eraseBundle, replaceBundle
  ) where
import Control.Concurrent
import Control.Exception
import Control.Monad
import qualified Data.ByteString as BS
import Data.Hashable
import qualified Data.IntMap as M
import Data.Serialize
import Data.String
import System.Directory
import System.IO
import Data.Embed.Header

-- | A file to be included in a bundle. May be either the path to a file on
--   disk or an actual (path, data) pair.
--
--   If a file path refers to a directory, all non-dotfile files and
--   subdirectories of that directory will be included in the bundle.
--   File paths also have a strip number: the number of leading directories
--   to strip from file names when adding them to a bundle.
--   For instance, adding @File 1 "foo/bar"@ to a bundle will add the file
--   @foo/bar@, under the name @bar@ within the bundle.
--
--   If a file name would "disappear" entirely due to stripping, for instance
--   when stripping two directories from @foo/bar@, @bar@ will "disappear"
--   entirely and so will be silently ignored.
data File = FilePath Int FilePath | FileData FilePath BS.ByteString

instance IsString File where
  fromString = FilePath 0

-- | A handle to a file bundle. Bundle handles are obtained using 'openBundle'
--   or 'withBundle' and start out as open. That is, files may be read from
--   them. An open bundle contains an open file handle to the bundle's backing
--   file, ensuring that the file data will not disappear from under it.
--   When a bundle becomes unreachable, its corresponding file handle is
--   closed by the bundle's associated finalizer.
--
--   However, as finalizers are not guaranteed to run promptly - or even
--   at all - bundles may also be closed before becoming unreachable using
--   'closeBundle'. If you expect to perform other operations on a bundle's
--   backing file, you should always close the bundle manually first.
data Bundle = Bundle {
    -- | The header of the bundle.
    bundleHeader     :: !BundleHeader,

    -- | An open handle to the bundle's file, for reading file data.
    bundleHandle     :: !Handle,

    -- | The offset from the end of the file to the start of the data section.
    bundleDataOffset :: !Integer,

    -- | Lock to ensure thread safety of read/close operations. Iff 'True', then
    --   the bundle is still alive, i.e. the handle is still open.
    bundleLock       :: !(MVar Bool)
  }

-- | Read a bundle static header from the end of a file.
readStaticHeader :: Handle -> IO (Either String StaticHeader)
readStaticHeader hdl = do
  hSeek hdl SeekFromEnd (negate bundleHeaderStaticSize)
  ehdr <- decode <$> BS.hGet hdl (fromInteger bundleHeaderStaticSize)
  case ehdr of
    Left e ->
        pure (Left $ "unable to parse static header: " ++ e)
    Right hdr
      | hdrMagicNumber hdr /= bundleMagicNumber ->
        pure (Left "not a bundle")
      | hdrVersion hdr > bundleCurrentVersion ->
        pure (Left "unsupported bundle version")
      | otherwise ->
        pure (Right hdr)

-- | Open a file bundle. The bundle will keep an open handle to its backing
--   file. The handle will be closed when the bundle is garbage collected.
--   Use 'closeBundle' to close the handle before the 
openBundle :: FilePath -> IO (Either String Bundle)
openBundle fp = flip catch (\(SomeException e) -> pure (Left $ show e)) $ do
  -- Read static header
  hdl <- openBinaryFile fp ReadMode
  ehdr <- readStaticHeader hdl
  case ehdr of
    Left err        -> pure (Left err)
    Right statichdr -> do
      -- Read dynamic header
      let dynsize = fromIntegral (hdrDynSize statichdr)
          dynoffset = bundleHeaderStaticSize + dynsize
      hSeek hdl SeekFromEnd (negate dynoffset)
      bytes <- BS.hGet hdl (fromInteger dynsize)

      -- Parse dynamic header and create bundle
      case runGet (getHdrFiles (hdrNumFiles statichdr)) bytes of
        Left e        -> fail $ "unable to parse file list: " ++ e
        Right filehdr -> do
          let hdr = BundleHeader {
                hdrFiles = filehdr,
                hdrStatic = statichdr
              }
          lock <- newMVar True
          let b = Bundle hdr hdl (fromIntegral (hdrDataOffset statichdr)) lock
          _ <- mkWeakMVar lock (closeBundle b)
          pure $! Right $! b

-- | Close a bundle before it becomes unreachable. After a bundle is closed,
--   any read operations performed on it will fail as though the requested
--   file could not be found.
--   Subsequent close operations on it will have no effect.
closeBundle :: Bundle -> IO ()
closeBundle b = do
  alive <- takeMVar (bundleLock b)
  when alive $ hClose (bundleHandle b)
  putMVar (bundleLock b) False

-- | Perform a computation over a bundle, returning an error if either the
--   computation failed or the bundle could not be loaded.
--   The bundle is always closed before this function returns, regardless of
--   whether an error occurred.
withBundle :: FilePath -> (Bundle -> IO a) -> IO (Either String a)
withBundle fp f = do
    eb <- openBundle fp
    case eb of
      Right b -> fmap Right (f b) `catch` handler <* closeBundle b
      Left e  -> return (Left e)
  where
    handler (SomeException e) = pure (Left $ show e)

-- | Write a bundle to a file. If the given file already has a bundle, the new
--   bundle will be written *after* the old one. The old bundle will thus still
--   be present in the file, but only the new one will be recognized by
--   'openBundle' and friends.
appendBundle :: FilePath -> [File] -> IO ()
appendBundle fp fs = withBinaryFile fp AppendMode $ \hdl -> do
    (datasize, metadata) <- foldM (packFile hdl) (0, M.empty) fs
    let mdbytes = putHdrFiles metadata
        mdlen   = BS.length mdbytes
        dataoff = datasize + fromIntegral mdlen
                           + fromIntegral bundleHeaderStaticSize 
        hdr = mkStaticHdr (M.size metadata) mdlen dataoff
    BS.hPut hdl mdbytes
    BS.hPut hdl (encode hdr)
  where
    isDotFile ('.':_) = True
    isDotFile _       = False

    p </> f = p ++ "/" ++ f

    stripLeading 0 f = f
    stripLeading n f = stripLeading (n-1) (drop 1 (dropWhile (/= '/') f))

    packFile hdl acc (FilePath n p) = do
      let stripped = stripLeading n p
      if null stripped
        then return acc
        else do
          isDir <- doesDirectoryExist p
          if isDir
            then do
              files <- filter (not . isDotFile) <$> getDirectoryContents p
              foldM (packFile hdl) acc (map (FilePath n . (p </>)) files)
            else BS.readFile p >>= packFile hdl acc . FileData stripped
    packFile hdl (off, m) (FileData p d) = do
      BS.hPut hdl d
      let len = fromIntegral (BS.length d)
          off' = off + fromIntegral len
      off' `seq` return (off', (M.alter (ins p off len) (hash p) m))

    ins p off len Nothing   = Just [(p, (off, len))]
    ins p off len (Just xs) = Just ((p, (off, len)) : xs)

-- | Read a file from a previously opened bundle. Will fail of the given path
--   is not found within the bundle, or if the bundle is no longer alive, i.e.
--   it has been closed using 'closeBundle'.
readBundleFile :: Bundle -> FilePath -> IO (Either String BS.ByteString)
readBundleFile (Bundle {..}) fp =
  flip catch (\(SomeException e) -> pure (Left $ show e)) $ do
    withMVar bundleLock $ \v -> do
      when (v == False) $ fail "bundle already closed"
      case M.lookup (hash fp) (hdrFiles bundleHeader) >>= lookup fp of
        Nothing        -> fail "no such file"
        Just (off, sz) -> do
          hSeek bundleHandle SeekFromEnd (fromIntegral off - bundleDataOffset)
          Right <$> BS.hGet bundleHandle (fromIntegral sz)

-- | List all files in the given bundle. Will succeed even on closed bundles.
listBundleFiles :: Bundle -> [FilePath]
listBundleFiles = map fst . concat . M.elems . hdrFiles . bundleHeader

-- | Like 'readBundleFile', but attempts to decode the file's contents into an
--   appropriate Haskell value.
readBundle :: Serialize a => Bundle -> FilePath -> IO (Either String a)
readBundle b fp = do
  ebytes <- readBundleFile b fp
  pure (ebytes >>= decode)

-- | Does the given file contain a bundle or not?
hasBundle :: FilePath -> IO Bool
hasBundle fp = do
  ehdr <- withBinaryFile fp ReadMode readStaticHeader
  case ehdr of
    Right _ -> pure True
    _       -> pure False

-- | Remove a bundle from an existing file. Does nothing if the given file
--   does not have a bundle. The given file is *not* removed, even if it only
--   contains the bundle.
eraseBundle :: FilePath -> IO ()
eraseBundle fp = do
  withBinaryFile fp ReadWriteMode $ \hdl -> do
    ehdr <- readStaticHeader hdl
    case ehdr of
      Right hdr -> do
        sz <- hFileSize hdl
        hSetFileSize hdl (sz - fromIntegral (hdrDataOffset hdr))
      _         -> return ()

-- | Replace the bundle currently attached to the given file. Equivalent to
--   'appendBundle' if the given file does not already have a bundle attached.
replaceBundle :: FilePath -> [File] -> IO ()
replaceBundle fp fs = eraseBundle fp >> appendBundle fp fs