packages feed

dataframe-csv-1.0.0.0: src/DataFrame/IO/CSV.hs

{-# LANGUAGE BangPatterns #-}
{-# LANGUAGE ExplicitNamespaces #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE NumericUnderscores #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}

module DataFrame.IO.CSV where

import qualified Data.ByteString as BS
import qualified Data.ByteString.Char8 as C
import qualified Data.ByteString.Lazy as BL
import qualified Data.Map.Strict as M
import qualified Data.Proxy as P
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.Text.IO as TIO
import qualified Data.Vector as V
import qualified Data.Vector.Mutable as VM
import qualified Data.Vector.Unboxed as VU
import qualified Data.Vector.Unboxed.Mutable as VUM

import Data.Csv.Streaming (Records (..))
import qualified Data.Csv.Streaming as CsvStream

import Control.Exception (SomeException, catch)
import Control.Monad
import Control.Monad.ST (runST)
import Data.Char
import qualified Data.Csv as Csv
import Data.Either
import Data.Functor
import Data.IORef
import Data.Maybe
import Data.Type.Equality (TestEquality (testEquality))
import Data.Word (Word8)
import DataFrame.Internal.Column
import DataFrame.Internal.DataFrame (
    DataFrame (..),
    forceDataFrame,
    toSeparated,
 )
import DataFrame.Internal.Parsing
import DataFrame.Internal.Schema
import DataFrame.Operations.Typing
import System.IO
import Type.Reflection
import Prelude hiding (concat, takeWhile)

chunkSize :: Int
chunkSize = 16_384

data PagedVector a = PagedVector
    { pvChunks :: !(IORef [V.Vector a])
    -- ^ Finished chunks (reverse order)
    , pvActive :: !(IORef (VM.IOVector a))
    -- ^ Current mutable chunk
    , pvCount :: !(IORef Int)
    -- ^ Items written in current chunk
    }

data PagedUnboxedVector a = PagedUnboxedVector
    { puvChunks :: !(IORef [VU.Vector a])
    , puvActive :: !(IORef (VUM.IOVector a))
    , puvCount :: !(IORef Int)
    }

data BuilderColumn
    = BuilderInt !(PagedUnboxedVector Int) !(PagedUnboxedVector Word8)
    | BuilderDouble !(PagedUnboxedVector Double) !(PagedUnboxedVector Word8)
    | BuilderText !(PagedVector T.Text) !(PagedUnboxedVector Word8)
    | BuilderBS !(PagedVector BS.ByteString) !(PagedUnboxedVector Word8)

newPagedVector :: IO (PagedVector a)
newPagedVector = do
    active <- VM.unsafeNew chunkSize
    PagedVector <$> newIORef [] <*> newIORef active <*> newIORef 0

newPagedUnboxedVector :: (VUM.Unbox a) => IO (PagedUnboxedVector a)
newPagedUnboxedVector = do
    active <- VUM.unsafeNew chunkSize
    PagedUnboxedVector <$> newIORef [] <*> newIORef active <*> newIORef 0

appendPagedVector :: PagedVector a -> a -> IO ()
appendPagedVector (PagedVector chunksRef activeRef countRef) !val = do
    count <- readIORef countRef
    active <- readIORef activeRef

    if count < chunkSize
        then do
            VM.unsafeWrite active count val
            writeIORef countRef $! count + 1
        else do
            frozen <- V.unsafeFreeze active
            modifyIORef' chunksRef (frozen :)

            newActive <- VM.unsafeNew chunkSize
            VM.unsafeWrite newActive 0 val

            writeIORef activeRef newActive
            writeIORef countRef 1
{-# INLINE appendPagedVector #-}

appendPagedUnboxedVector :: (VUM.Unbox a) => PagedUnboxedVector a -> a -> IO ()
appendPagedUnboxedVector (PagedUnboxedVector chunksRef activeRef countRef) !val = do
    count <- readIORef countRef
    active <- readIORef activeRef

    if count < chunkSize
        then do
            VUM.unsafeWrite active count val
            writeIORef countRef $! count + 1
        else do
            frozen <- VU.unsafeFreeze active
            modifyIORef' chunksRef (frozen :)

            newActive <- VUM.unsafeNew chunkSize
            VUM.unsafeWrite newActive 0 val

            writeIORef activeRef newActive
            writeIORef countRef 1
{-# INLINE appendPagedUnboxedVector #-}

freezePagedVector :: PagedVector a -> IO (V.Vector a)
freezePagedVector (PagedVector chunksRef activeRef countRef) = do
    count <- readIORef countRef
    active <- readIORef activeRef
    chunks <- readIORef chunksRef

    writeIORef chunksRef [] -- release chunk references
    let frozenChunks = reverse chunks
        totalLen = count + sum (map V.length frozenChunks)

    mv <- VM.unsafeNew totalLen

    let copyChunk !offset chunk = do
            V.copy (VM.slice offset (V.length chunk) mv) chunk
            pure (offset + V.length chunk)

    offset <- foldM copyChunk 0 frozenChunks
    VM.copy (VM.slice offset count mv) (VM.slice 0 count active)

    V.unsafeFreeze mv

freezePagedUnboxedVector ::
    (VUM.Unbox a) => PagedUnboxedVector a -> IO (VU.Vector a)
freezePagedUnboxedVector (PagedUnboxedVector chunksRef activeRef countRef) = do
    count <- readIORef countRef
    active <- readIORef activeRef
    chunks <- readIORef chunksRef

    writeIORef chunksRef [] -- release chunk references
    let frozenChunks = reverse chunks
        totalLen = count + sum (map VU.length frozenChunks)

    mv <- VUM.unsafeNew totalLen

    let copyChunk !offset chunk = do
            VU.copy (VUM.slice offset (VU.length chunk) mv) chunk
            pure (offset + VU.length chunk)

    offset <- foldM copyChunk 0 frozenChunks
    VUM.copy (VUM.slice offset count mv) (VUM.slice 0 count active)

    VU.unsafeFreeze mv

-- | STANDARD CONFIG TYPES
data HeaderSpec = NoHeader | UseFirstRow | ProvideNames [T.Text]
    deriving (Eq, Show)

data TypeSpec
    = InferFromSample Int
    | SpecifyTypes [(T.Text, SchemaType)] TypeSpec
    | NoInference

{- | How the fast reader should treat a row whose field count does not
match the header row.  Only consulted by @dataframe-fastcsv@; the pure
Haskell reader has its own semantics.
-}
data RaggedRowPolicy
    = {- | Fill missing cells with nulls; silently drop extras.
      Matches pandas / polars lenient defaults.
      -}
      PadWithNull
    | {- | Fill missing cells with nulls; silently drop extras.  Alias
      kept for ergonomic naming when the caller only cares about the
      "don't raise, just forget the extras" half of 'PadWithNull'.
      -}
      Truncate
    | {- | Raise a 'DataFrame.IO.CSV.Fast.CsvParseError' on any row whose
      field count differs from the header.  Matches polars' strict
      schema-bound mode.
      -}
      RaiseOnRagged
    deriving (Eq, Show)

-- | How the fast reader should treat an unclosed quoted field at EOF.
data UnclosedQuotePolicy
    = -- | Raise a 'DataFrame.IO.CSV.Fast.CsvParseError'.  Default.
      RaiseOnUnclosedQuote
    | {- | Return whatever rows were parsed before the stray quote; the
      remainder is silently dropped.
      -}
      BestEffort
    deriving (Eq, Show)

-- | CSV read parameters.
data ReadOptions = ReadOptions
    { headerSpec :: HeaderSpec
    -- ^ Where to get the headers from. (default: UseFirstRow)
    , typeSpec :: TypeSpec
    -- ^ Whether/how to infer types. (default: InferFromSample 100)
    , safeRead :: SafeReadMode
    {- ^ Default 'SafeReadMode' for columns without an entry in
    'safeReadOverrides'. (default: 'NoSafeRead')
    -}
    , safeReadOverrides :: [(T.Text, SafeReadMode)]
    -- ^ Per-column 'SafeReadMode' overrides; takes precedence over 'safeRead'.
    , dateFormat :: String
    {- ^ Format of date fields as recognized by the Data.Time.Format module.

    __Examples:__

    @
    > parseTimeM True defaultTimeLocale "%Y/%-m/%-d" "2010/3/04" :: Maybe Day
    Just 2010-03-04
    > parseTimeM True defaultTimeLocale "%d/%-m/%-Y" "04/3/2010" :: Maybe Day
    Just 2010-03-04
    @
    -}
    , columnSeparator :: Char
    -- ^ Character that separates column values.
    , numColumns :: Maybe Int
    -- ^ Number of columns to read.
    , missingIndicators :: [T.Text]
    -- ^ Values that should be read as `Nothing`.
    , fastCsvOnRaggedRow :: RaggedRowPolicy
    {- ^ @dataframe-fastcsv@: how to treat rows with a non-header field count.
    (default: 'PadWithNull')
    -}
    , fastCsvOnUnclosedQuote :: UnclosedQuotePolicy
    {- ^ @dataframe-fastcsv@: how to treat an unclosed quoted field at EOF.
    (default: 'RaiseOnUnclosedQuote')
    -}
    , fastCsvTrimUnquoted :: Bool
    {- ^ @dataframe-fastcsv@: if 'True', leading/trailing whitespace is
    stripped from unquoted fields after decoding.  RFC 4180 preserves
    this whitespace, and that is the default ('False').
    -}
    }

shouldInferFromSample :: TypeSpec -> Bool
shouldInferFromSample (InferFromSample _) = True
shouldInferFromSample (SpecifyTypes _ fallback) = shouldInferFromSample fallback
shouldInferFromSample _ = False

schemaTypeMap :: TypeSpec -> M.Map T.Text SchemaType
schemaTypeMap (SpecifyTypes xs _) = M.fromList xs
schemaTypeMap _ = M.empty

typeInferenceSampleSize :: TypeSpec -> Int
typeInferenceSampleSize (InferFromSample n) = n
typeInferenceSampleSize (SpecifyTypes _ fallback) = typeInferenceSampleSize fallback
typeInferenceSampleSize _ = 0

defaultReadOptions :: ReadOptions
defaultReadOptions =
    ReadOptions
        { headerSpec = UseFirstRow
        , typeSpec = InferFromSample 100
        , safeRead = NoSafeRead
        , safeReadOverrides = []
        , dateFormat = "%Y-%m-%d"
        , columnSeparator = ','
        , numColumns = Nothing
        , missingIndicators =
            ["Nothing", "NULL", "", " ", "nan", "null", "N/A", "NaN", "NAN", "NA"]
        , fastCsvOnRaggedRow = PadWithNull
        , fastCsvOnUnclosedQuote = RaiseOnUnclosedQuote
        , fastCsvTrimUnquoted = False
        }

{- | Read CSV file from path and load it into a dataframe.

==== __Example__
@
ghci> D.readCsv ".\/data\/taxi.csv"

@
-}
readCsv :: FilePath -> IO DataFrame
readCsv = readSeparated defaultReadOptions

type CsvReader = Schema -> FilePath -> IO DataFrame

{- | Schema-driven attoparsec CSV reader.  Coerces each column to the
type declared in 'Schema'; columns absent from the schema fall back to
the default inference path.  Defined in terms of 'readSeparated' with
the 'TypeSpec' filled in.

@
import qualified DataFrame as D
df <- D.readCsvWithSchema schema "input.csv"
@
-}
readCsvWithSchema :: CsvReader
readCsvWithSchema schema =
    readSeparated
        defaultReadOptions
            { typeSpec =
                SpecifyTypes
                    (M.toList (elements schema))
                    (typeSpec defaultReadOptions)
            }

{- | Read CSV file from path and load it into a dataframe.

==== __Example__
@
ghci> D.readCsvWithOpts ".\/data\/taxi.csv" (D.defaultReadOptions { dateFormat = "%d/%-m/%-Y" })

@
-}
readCsvWithOpts :: ReadOptions -> FilePath -> IO DataFrame
readCsvWithOpts = readSeparated

{- | Read TSV (tab separated) file from path and load it into a dataframe.

==== __Example__
@
ghci> D.readTsv ".\/data\/taxi.tsv"

@
-}
readTsv :: FilePath -> IO DataFrame
readTsv = readSeparated (defaultReadOptions{columnSeparator = '\t'})

{- | Read text file with specified delimiter into a dataframe.

==== __Example__
@
ghci> D.readSeparated (D.defaultReadOptions { columnSeparator = ';' }) ".\/data\/taxi.txt"

@
-}
readSeparated :: ReadOptions -> FilePath -> IO DataFrame
readSeparated opts !path = do
    let stripUtf8Bom bs = fromMaybe bs (BL.stripPrefix "\xEF\xBB\xBF" bs)
    csvData <- stripUtf8Bom <$> BL.readFile path
    fmap forceDataFrame (decodeSeparated opts csvData)

decodeSeparated :: ReadOptions -> BL.ByteString -> IO DataFrame
decodeSeparated !opts csvData = do
    let sep = columnSeparator opts
    let decodeOpts = Csv.defaultDecodeOptions{Csv.decDelimiter = fromIntegral (ord sep)}
    let stream = CsvStream.decodeWith decodeOpts Csv.NoHeader csvData

    let peekStream (Cons (Right row) rest) = return (row, rest)
        peekStream (Cons (Left err) _) = error $ "Error parsing CSV header: " ++ err
        peekStream (Nil Nothing _) = error "Empty CSV file"
        peekStream (Nil (Just err) _) = error err

    (firstRowRaw, dataStream) <- peekStream stream

    let (columnNames, rowsToProcess) = case headerSpec opts of
            NoHeader ->
                ( map (T.pack . show) [0 .. V.length firstRowRaw - 1]
                , Cons (Right firstRowRaw) dataStream
                )
            UseFirstRow ->
                ( map (T.strip . TE.decodeUtf8Lenient . BL.toStrict) (V.toList firstRowRaw)
                , dataStream
                )
            ProvideNames ns ->
                ( ns ++ drop (length ns) (map (T.pack . show) [0 .. V.length firstRowRaw - 1])
                , Cons (Right firstRowRaw) dataStream
                )

    (sampleRow, _) <- peekStream rowsToProcess
    builderCols <- initializeColumns columnNames (V.toList sampleRow) opts
    let !builderColsV = V.fromList builderCols
    let colNamesV = V.fromList columnNames
        resolveMode =
            effectiveSafeRead
                (safeRead opts)
                (safeReadOverrides opts)
        -- If ANY column is EitherRead we keep every raw cell (including
        -- "N/A" etc.) verbatim; otherwise the missing-indicator list applies.
        anyEither =
            any (\n -> resolveMode n == EitherRead) columnNames
        missing = if anyEither then [] else missingIndicators opts
    processStream missing rowsToProcess builderColsV (numColumns opts)

    frozenCols <-
        V.zipWithM
            (\name bc -> finalizeBuilderColumn (resolveMode name) opts bc)
            colNamesV
            builderColsV
    let numRows = maybe 0 columnLength (frozenCols V.!? 0)

    let df =
            DataFrame
                frozenCols
                (M.fromList (zip columnNames [0 ..]))
                (numRows, V.length frozenCols)
                M.empty -- TODO give typed column references
    pure $ parseWithTypes resolveMode (schemaTypeMap (typeSpec opts)) df

initializeColumns ::
    [T.Text] -> [BL.ByteString] -> ReadOptions -> IO [BuilderColumn]
initializeColumns names _row opts = zipWithM initColumn names (map lookupType names)
  where
    typeMap = schemaTypeMap (typeSpec opts)
    -- Return Nothing for columns that should be inferred from BS
    shouldInfer = case typeSpec opts of
        InferFromSample _ -> True
        SpecifyTypes _ fallback -> shouldInferFromSample fallback
        NoInference -> False
    lookupType name = M.lookup name typeMap
    resolveMode =
        effectiveSafeRead (safeRead opts) (safeReadOverrides opts)
    initColumn :: T.Text -> Maybe SchemaType -> IO BuilderColumn
    initColumn name _ | resolveMode name == EitherRead = do
        validityRef <- newPagedUnboxedVector
        BuilderBS <$> newPagedVector <*> pure validityRef
    initColumn _ Nothing | shouldInfer = do
        validityRef <- newPagedUnboxedVector
        BuilderBS <$> newPagedVector <*> pure validityRef
    initColumn _ mtype = do
        validityRef <- newPagedUnboxedVector
        let t = fromMaybe (schemaType @T.Text) mtype
        case t of
            SType (_ :: P.Proxy a) -> case testEquality (typeRep @a) (typeRep @Int) of
                Just Refl -> BuilderInt <$> newPagedUnboxedVector <*> pure validityRef
                Nothing -> case testEquality (typeRep @a) (typeRep @Double) of
                    Just Refl -> BuilderDouble <$> newPagedUnboxedVector <*> pure validityRef
                    Nothing -> BuilderText <$> newPagedVector <*> pure validityRef

processStream ::
    [T.Text] ->
    CsvStream.Records (V.Vector BL.ByteString) ->
    V.Vector BuilderColumn ->
    Maybe Int ->
    IO ()
processStream _ _ _ (Just 0) = return ()
processStream missing (Cons (Right row) rest) cols n =
    processRow missing row cols
        >> processStream missing rest cols (fmap (flip (-) 1) n)
processStream _missing (Cons (Left err) _) _ _ = error ("CSV Parse Error: " ++ err)
processStream _missing (Nil _ _) _ _ = return ()

processRow ::
    [T.Text] -> V.Vector BL.ByteString -> V.Vector BuilderColumn -> IO ()
processRow missing !vals !cols = V.zipWithM_ processValue vals cols
  where
    processValue !bs !col = do
        let !bs' = BL.toStrict bs
        case col of
            BuilderInt gv valid -> case readByteStringInt bs' of
                Just !i -> appendPagedUnboxedVector gv i >> appendPagedUnboxedVector valid 1
                Nothing -> appendPagedUnboxedVector gv 0 >> appendPagedUnboxedVector valid 0
            BuilderDouble gv valid -> case readByteStringDouble bs' of
                Just !d -> appendPagedUnboxedVector gv d >> appendPagedUnboxedVector valid 1
                Nothing -> appendPagedUnboxedVector gv 0.0 >> appendPagedUnboxedVector valid 0
            BuilderText gv valid -> do
                let !val = T.strip (TE.decodeUtf8Lenient bs')
                appendPagedVector gv val
                appendPagedUnboxedVector valid (if val `elem` missing then 0 else 1)
            BuilderBS gv valid -> do
                let !bs'' = C.strip bs'
                appendPagedVector gv bs''
                appendPagedUnboxedVector
                    valid
                    (if TE.decodeUtf8Lenient bs'' `elem` missing then 0 else 1)

freezeBuilderColumn :: BuilderColumn -> IO Column
freezeBuilderColumn (BuilderInt gv validRef) = do
    vec <- freezePagedUnboxedVector gv
    valid <- freezePagedUnboxedVector validRef
    if VU.all (== 1) valid
        then return $! UnboxedColumn Nothing vec
        else constructOptional vec valid
freezeBuilderColumn (BuilderDouble gv validRef) = do
    vec <- freezePagedUnboxedVector gv
    valid <- freezePagedUnboxedVector validRef
    if VU.all (== 1) valid
        then return $! UnboxedColumn Nothing vec
        else constructOptional vec valid
freezeBuilderColumn (BuilderText gv validRef) = do
    vec <- freezePagedVector gv
    valid <- freezePagedUnboxedVector validRef
    if VU.all (== 1) valid
        then return $! BoxedColumn Nothing vec
        else constructOptionalBoxed vec valid
freezeBuilderColumn (BuilderBS _ _) =
    error
        "freezeBuilderColumn: BuilderBS must be finalized via finalizeBuilderColumn"

finalizeBuilderColumn ::
    SafeReadMode -> ReadOptions -> BuilderColumn -> IO Column
finalizeBuilderColumn mode opts bc = do
    col <- case bc of
        BuilderBS gv validRef -> do
            vec <- freezePagedVector gv
            valid <- freezePagedUnboxedVector validRef
            return $! inferColumnFromBS mode opts vec valid
        _ -> freezeBuilderColumn bc
    return $! case mode of
        NoSafeRead -> col
        MaybeRead -> ensureOptional col
        EitherRead -> col

inferColumnFromBS ::
    SafeReadMode ->
    ReadOptions ->
    V.Vector BS.ByteString ->
    VU.Vector Word8 ->
    Column
inferColumnFromBS mode opts vec valid =
    let sampleN = let n = typeInferenceSampleSize (typeSpec opts) in if n == 0 then 100 else n
        dfmt = dateFormat opts
        -- The sample IS still Maybe-wrapped; it's bounded at 100 rows
        -- by default, so the allocation is ignorable.  The previous
        -- full-column `asMaybeFull = V.generate ...` allocation is
        -- gone — handlers walk (vec, valid) directly.
        samples = V.generate (min sampleN (V.length vec)) $ \i ->
            if valid VU.! i == 1 then Just (vec V.! i) else Nothing
        assumption = makeParsingAssumptionBS dfmt samples
     in case mode of
            EitherRead -> handleBSEither dfmt assumption vec valid
            _ -> case assumption of
                IntAssumption -> handleBSInt dfmt vec valid
                DoubleAssumption -> handleBSDouble vec valid
                BoolAssumption -> handleBSBool vec valid
                DateAssumption -> handleBSDate dfmt vec valid
                TextAssumption -> handleBSText vec valid
                NoAssumption -> handleBSNo dfmt vec valid

{- | 'EitherRead' wrap for the ByteString inference path: produce an
@Either Text a@ column. Raw input bytes are preserved verbatim; rows that were
marked invalid by the builder (e.g. empty cells before EitherRead disabled
missing-indicator detection) become @Left \"\"@.
-}
handleBSEither ::
    String ->
    ParsingAssumption ->
    V.Vector BS.ByteString ->
    VU.Vector Word8 ->
    Column
handleBSEither dfmt assumption vec valid = case assumption of
    BoolAssumption -> wrap readByteStringBool
    IntAssumption -> wrap readByteStringInt
    DoubleAssumption -> wrap readByteStringDouble
    DateAssumption -> wrap (readByteStringDate dfmt)
    -- Text / No assumption: column is Either Text Text; empty cells become
    -- Left "" to keep the "Left means missing/failure" convention.
    TextAssumption -> fromVector (V.imap textEither vec)
    NoAssumption -> fromVector (V.imap textEither vec)
  where
    wrap ::
        forall a. (Columnable a) => (BS.ByteString -> Maybe a) -> Column
    wrap p = fromVector (V.imap (toEither p) vec)

    toEither ::
        forall a.
        (BS.ByteString -> Maybe a) ->
        Int ->
        BS.ByteString ->
        Either T.Text a
    toEither p i bs
        | valid VU.! i == 0 = Left (TE.decodeUtf8Lenient bs)
        | otherwise = case p bs of
            Just v -> Right v
            Nothing -> Left (TE.decodeUtf8Lenient bs)

    textEither :: Int -> BS.ByteString -> Either T.Text T.Text
    textEither i bs =
        let t = TE.decodeUtf8Lenient bs
         in if valid VU.! i == 0 || T.null t then Left t else Right t

makeParsingAssumptionBS ::
    String -> V.Vector (Maybe BS.ByteString) -> ParsingAssumption
makeParsingAssumptionBS dfmt asMaybe
    | V.all (== Nothing) asMaybe = NoAssumption
    | vecSameConstructor asMaybe asMaybeBool = BoolAssumption
    | vecSameConstructor asMaybe asMaybeInt
        && vecSameConstructor asMaybe asMaybeDouble =
        IntAssumption
    | vecSameConstructor asMaybe asMaybeDouble = DoubleAssumption
    | vecSameConstructor asMaybe asMaybeDate = DateAssumption
    | otherwise = TextAssumption
  where
    asMaybeBool = V.map (>>= readByteStringBool) asMaybe
    asMaybeInt = V.map (>>= readByteStringInt) asMaybe
    asMaybeDouble = V.map (>>= readByteStringDouble) asMaybe
    asMaybeDate = V.map (>>= readByteStringDate dfmt) asMaybe

-- All @handleBS*@ helpers now take the raw @V.Vector BS.ByteString@
-- plus the builder's @VU.Vector Word8@ validity vector, fusing the
-- parse + validity check into a single pass via
-- 'parseUnboxedColumnWithValid'.  The previous @V.Vector (Maybe
-- BS.ByteString)@ intermediate — allocated upstream in
-- 'inferColumnFromBS' — is gone, along with the paired Int/Double
-- parses and the two 'V.zipWith' Bool vectors from
-- 'vecSameConstructor'.

handleBSBool ::
    V.Vector BS.ByteString -> VU.Vector Word8 -> Column
handleBSBool vec valid =
    case parseUnboxedColumnWithValid False readByteStringBool vec valid of
        Just (mbm, out) -> UnboxedColumn mbm out
        Nothing -> handleBSText vec valid

handleBSInt ::
    String -> V.Vector BS.ByteString -> VU.Vector Word8 -> Column
handleBSInt _dfmt vec valid =
    case parseUnboxedColumnWithValid 0 readByteStringInt vec valid of
        Just (mbm, out) -> UnboxedColumn mbm out
        Nothing -> case parseUnboxedColumnWithValid 0 readByteStringDouble vec valid of
            Just (mbm, out) -> UnboxedColumn mbm out
            Nothing -> handleBSText vec valid

handleBSDouble ::
    V.Vector BS.ByteString -> VU.Vector Word8 -> Column
handleBSDouble vec valid =
    case parseUnboxedColumnWithValid 0 readByteStringDouble vec valid of
        Just (mbm, out) -> UnboxedColumn mbm out
        Nothing -> handleBSText vec valid

-- Dates are boxed ('Day' isn't 'VU.Unbox'), so fuse into a V.Vector
-- (Maybe Day) in one pass.  Bails on the first non-null cell that
-- fails to parse.
handleBSDate ::
    String -> V.Vector BS.ByteString -> VU.Vector Word8 -> Column
handleBSDate dfmt vec valid =
    case parseBoxedMaybeBSColumn valid (readByteStringDate dfmt) vec of
        Just (anyNull, out)
            | anyNull -> fromVector out
            | otherwise -> fromVector (V.mapMaybe id out)
        Nothing -> handleBSText vec valid

-- Fused Text handler: decode each cell's UTF-8 bytes once, mark nulls
-- directly from the validity vector.  Replaces the two-pass
-- `V.map (fmap decodeUtf8Lenient) ... sequenceA ...` pattern.
handleBSText ::
    V.Vector BS.ByteString -> VU.Vector Word8 -> Column
handleBSText vec valid
    | VU.any (== 0) valid =
        fromVector
            ( V.imap
                ( \i bs ->
                    if valid VU.! i == 0
                        then Nothing
                        else Just (TE.decodeUtf8Lenient bs)
                )
                vec
            )
    | otherwise = fromVector (V.map TE.decodeUtf8Lenient vec)

handleBSNo ::
    String -> V.Vector BS.ByteString -> VU.Vector Word8 -> Column
handleBSNo dfmt vec valid
    | VU.all (== 0) valid =
        fromVector (V.map (const (Nothing :: Maybe T.Text)) vec)
    | Just (mbm, out) <-
        parseUnboxedColumnWithValid False readByteStringBool vec valid =
        UnboxedColumn mbm out
    | Just (mbm, out) <- parseUnboxedColumnWithValid 0 readByteStringInt vec valid =
        UnboxedColumn mbm out
    | Just (mbm, out) <- parseUnboxedColumnWithValid 0 readByteStringDouble vec valid =
        UnboxedColumn mbm out
    | otherwise = case parseBoxedMaybeBSColumn valid (readByteStringDate dfmt) vec of
        Just (anyNull, out)
            | anyNull -> fromVector out
            | otherwise -> fromVector (V.mapMaybe id out)
        Nothing -> handleBSText vec valid

-- Boxed counterpart to 'parseUnboxedColumnWithValid' for types that
-- aren't 'VU.Unbox' (e.g. 'Day').  Same one-pass + early-bail shape.
parseBoxedMaybeBSColumn ::
    VU.Vector Word8 ->
    (BS.ByteString -> Maybe a) ->
    V.Vector BS.ByteString ->
    Maybe (Bool, V.Vector (Maybe a))
parseBoxedMaybeBSColumn valid parser vec = runST $ do
    let n = V.length vec
    out <- VM.new n
    let loop !i !anyNull
            | i >= n = do
                frozen <- V.unsafeFreeze out
                return (Just (anyNull, frozen))
            | VU.unsafeIndex valid i == 0 = do
                VM.unsafeWrite out i Nothing
                loop (i + 1) True
            | otherwise = case parser (V.unsafeIndex vec i) of
                Just v -> do
                    VM.unsafeWrite out i (Just v)
                    loop (i + 1) anyNull
                Nothing -> return Nothing
    loop 0 False

{- | One-pass fused parse into a typed unboxed column.  Avoids the
@V.Vector (Maybe a)@ intermediate that the "parse then 'sequenceA'"
idiom requires, and avoids the upstream @V.Vector (Maybe src)@
classification by reading nullability from a precomputed validity
vector (produced by the CSV builder alongside the raw cells).

Returns @Just (mbm, vec)@ only when every non-null cell parses.  The
first unparseable cell short-circuits to @Nothing@ so the caller can
fall back to the next assumption (Int → Double → Text).  @mbm@ is
@Nothing@ when no nulls exist (the column is non-nullable) and
@Just bm@ otherwise.

Null slots are filled with @nullValue@; downstream consumers only see
them through the bitmap, so the sentinel never escapes.

Memory shape for a length-@n@ input:

  * 1 × 'VUM.STVector' of @a@        (final data, @sizeOf a × n@ bytes)
  * 1 × 'VUM.STVector' of 'Word8'    (per-element validity, @n@ bytes)
  * 1 × 'Bitmap' (bit-packed, @⌈n\/8⌉@ bytes) — only when nulls exist.
-}
parseUnboxedColumnWithValid ::
    forall src a.
    (VU.Unbox a) =>
    a ->
    (src -> Maybe a) ->
    V.Vector src ->
    VU.Vector Word8 ->
    Maybe (Maybe Bitmap, VU.Vector a)
parseUnboxedColumnWithValid nullValue parser vec valid = runST $ do
    let n = V.length vec
    values <- VUM.unsafeNew n
    vmask <- VUM.unsafeNew n
    let go !i !anyNull
            | i >= n = finalizeParseResult values vmask anyNull
            | VU.unsafeIndex valid i == 0 = do
                VUM.unsafeWrite vmask i 0
                VUM.unsafeWrite values i nullValue
                go (i + 1) True
            | otherwise = case parser (V.unsafeIndex vec i) of
                Just v -> do
                    VUM.unsafeWrite vmask i 1
                    VUM.unsafeWrite values i v
                    go (i + 1) anyNull
                Nothing -> return Nothing
    go 0 False
{-# INLINE parseUnboxedColumnWithValid #-}

constructOptional ::
    (VU.Unbox a, Columnable a) => VU.Vector a -> VU.Vector Word8 -> IO Column
constructOptional vec valid = do
    let bm = buildBitmapFromValid valid
    pure $ UnboxedColumn (Just bm) vec

constructOptionalBoxed :: V.Vector T.Text -> VU.Vector Word8 -> IO Column
constructOptionalBoxed vec valid = do
    let bm = buildBitmapFromValid valid
    pure $ BoxedColumn (Just bm) vec

writeCsv :: FilePath -> DataFrame -> IO ()
writeCsv = writeSeparated ','

writeTsv :: FilePath -> DataFrame -> IO ()
writeTsv = writeSeparated '\t'

writeSeparated ::
    -- | Separator
    Char ->
    -- | Path to write to
    FilePath ->
    DataFrame ->
    IO ()
writeSeparated c filepath df = TIO.writeFile filepath (toSeparated c df)

-- | Parse a CSV string into a DataFrame using default options.
fromCsv :: String -> IO (Either String DataFrame)
fromCsv s = do
    let bs = BL.fromStrict (TE.encodeUtf8 (T.pack s))
    (Right <$> decodeSeparated defaultReadOptions bs)
        `catch` (\(e :: SomeException) -> pure (Left (show e)))

-- | Parse a lazy 'ByteString' containing CSV data into a DataFrame using default options.
fromCsvBytes :: BL.ByteString -> IO DataFrame
fromCsvBytes = decodeSeparated defaultReadOptions

stripQuotes :: T.Text -> T.Text
stripQuotes txt =
    case T.uncons txt of
        Just ('"', rest) ->
            case T.unsnoc rest of
                Just (middle, '"') -> middle
                _ -> txt
        _ -> txt