module Data.ByteString.SuperBuffer.Pure
    ( SuperBuffer, withBuffer, appendBuffer, appendBufferT, size )
where

import Control.Concurrent.MVar
import Control.Exception
import Data.Bits
import Data.IORef
import Data.Word
import Foreign.Marshal.Alloc
import Foreign.Marshal.Utils
import Foreign.Ptr
import qualified Data.ByteString as BS
import qualified Data.ByteString.Unsafe as BS

-- | The buffer data structure.
data SuperBuffer
    = SuperBuffer
    { sb_buffer :: {-# UNPACK #-}!(IORef (Ptr Word8))
    , sb_currentSize :: {-# UNPACK #-}!(IORef Int)
    , sb_maxSize :: {-# UNPACK #-}!(IORef Int)
    , sb_lock :: {-# UNPACK #-}!(MVar ())
    }

-- | Allocate a new buffer with a given initial size. The perfect starting point
-- depends on the expected total size and the average size for a single chunk
-- written with 'appendBuffer'. You can always start with 1024 and optimize from
-- there with benchmarks. Please note that the SuperBuffer will no longer be
-- valid after this function terminates, so do NOT pass it to some other
-- thread without waiting for it to finish in the action.
withBuffer :: Int -> (SuperBuffer -> IO ()) -> IO BS.ByteString
withBuffer sz action =
    do ptr <- mallocBytes sz
       ptrRef <- newIORef ptr
       go ptrRef `onException` freeOnException ptrRef
    where
        freeOnException ref =
            do ptr <- readIORef ref
               free ptr
        go ptrRef =
            do sizeRef <- newIORef 0
               maxSizeRef <- newIORef sz
               lock <- newEmptyMVar
               let sb = SuperBuffer ptrRef sizeRef maxSizeRef lock
               action sb
               readBuffer sb
{-# INLINE withBuffer #-}

-- | Write a bytestring to the buffer and grow the buffer if needed. Note that only
-- one thread at any given time may call this function. Use 'appendBufferT' when
-- accessing 'SuperBuffer' from multiple threads.
appendBuffer :: SuperBuffer -> BS.ByteString -> IO ()
appendBuffer sb bs
    | BS.null bs = pure ()
    | otherwise =
          BS.unsafeUseAsCStringLen bs $ \(cstr, len) ->
          do currentSize <- readIORef (sb_currentSize sb)
             maxSize <- readIORef (sb_maxSize sb)
             let nextSize = currentSize + len
             writePtr <-
                 if nextSize > maxSize
                 then do let maxSize' = nextSize + unsafeShiftR nextSize 1
                         writeIORef (sb_maxSize sb) maxSize'
                         buff <- readIORef (sb_buffer sb)
                         buff' <- reallocBytes buff maxSize'
                         writeIORef (sb_buffer sb) buff'
                         pure buff'
                 else readIORef (sb_buffer sb)
             let copyTarget = writePtr `plusPtr` currentSize
             copyBytes copyTarget cstr len
             writeIORef (sb_currentSize sb) (currentSize + len)
{-# INLINE appendBuffer #-}

-- | Write a bytestring to the buffer and grow the buffer if needed. This function
-- can be used accross different threads, but is slower than 'appendBuffer'.
appendBufferT :: SuperBuffer -> BS.ByteString -> IO ()
appendBufferT sb bs =
    bracket_ (putMVar (sb_lock sb) ()) (takeMVar (sb_lock sb)) $
    appendBuffer sb bs
{-# INLINE appendBufferT #-}

-- | Read the final buffer contents. This must only be called once
readBuffer :: SuperBuffer -> IO BS.ByteString
readBuffer sb =
    do (buff, currentSize, maxSize) <-
           (,,)
           <$> readIORef (sb_buffer sb)
           <*> readIORef (sb_currentSize sb)
           <*> readIORef (sb_maxSize sb)
       finalPtr <-
           if currentSize < maxSize
           then reallocBytes buff currentSize
           else pure buff
       BS.unsafePackCStringFinalizer finalPtr currentSize (free finalPtr)
{-# INLINE readBuffer #-}

-- | Get current (filled) size of the buffer
size :: SuperBuffer -> IO Int
size sb = readIORef $ sb_currentSize sb
{-# INLINE size #-}