packages feed

wireform-arrow-0.1.0.0: test-interop/Main.hs

{-# LANGUAGE GADTs #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}

{- | Bidirectional pyarrow interop suite for wireform-arrow.

Both sides define the same case matrix: this module as wireform-arrow
'Schema' plus 'ColumnArray' batches, @pyarrow_interop.py@ (next to this
file) as pyarrow record batches built with pyarrow's own constructors.
Case names tie the two together.

* Direction A: wireform-arrow writes every case as an IPC stream and an
  IPC file (plus zstd / lz4 body-compressed variants when the package is
  built with @-f+zstd@ / @-f+lz4@). pyarrow opens each one, runs
  @validate(full=True)@ on every batch, and compares schema (with custom
  metadata) and values against its own expected batches.
* Direction B: pyarrow writes every case (plain, sliced at a non-zero
  offset, zstd, lz4; stream and file). wireform-arrow decodes each with
  'decodeArrowStream' / 'decodeArrowFile' and compares the schema and the
  logical row values against the batches defined here.

Gating: the Python interpreter comes from @WIREFORM_ARROW_PYTHON@
(default @python3@). When @import pyarrow@ fails the suite prints SKIP
and exits 0, unless @WIREFORM_ARROW_REQUIRE_PYARROW=1@.

@--write DIR@ only writes the direction A files into @DIR@ (no Python
needed), for other readers such as @interop/arrow-rs@.
-}
module Main (main) where

import Arrow.Column (
  ChildRange (..),
  Validity,
  ColumnArray,
  Float16 (..),
  IntervalDayTime (..),
  IntervalMonthDayNano (..),
  PrimType (..),
  asPrim,
  columnLength,
  decimal128FromInteger,
  decimal128ToInteger,
  decimal256FromInteger,
  decimal256ToInteger,
  dictKeyAt,
  expandDictionary,
  fromBools,
  fromByteStrings,
  fromMaybeBinaryView,
  fromMaybeBools,
  fromMaybeByteStrings,
  fromMaybeFixedSizeBinary,
  fromMaybeLargeByteStrings,
  fromMaybeLargeTexts,
  fromMaybeTexts,
  fromMaybeUtf8View,
  fromMaybes,
  fromTexts,
  isValidAt,
  listRange,
  mkDenseUnion,
  mkDictionary,
  mkFixedSizeList,
  mkLargeList,
  mkLargeListView,
  mkList,
  mkListView,
  mkMap,
  mkRunEndEncoded,
  mkSparseUnion,
  mkStruct,
  primColumn,
  toBoolVector,
  toBytesVector,
  toMaybeVector,
  toTextVector,
  validityFromBools,
  withPrim,
  pattern ColBinary,
  pattern ColBinaryView,
  pattern ColBool,
  pattern ColDenseUnion,
  pattern ColDictionary,
  pattern ColFixedSizeBinary,
  pattern ColFixedSizeList,
  pattern ColLargeBinary,
  pattern ColLargeList,
  pattern ColLargeListView,
  pattern ColLargeUtf8,
  pattern ColList,
  pattern ColListView,
  pattern ColMap,
  pattern ColNull,
  pattern ColPrim,
  pattern ColRunEndEncoded,
  pattern ColSparseUnion,
  pattern ColStruct,
  pattern ColUtf8,
  pattern ColUtf8View,
 )
import Arrow.Read.Columns (defaultReadOptions)
import Arrow.Stream (
  DictHandling (..),
  WriteOptions (..),
  decodeArrowFile,
  decodeArrowStream,
  defaultWriteOptions,
  encodeArrowFile,
  encodeArrowStream,
 )
import Arrow.Types (
  ArrowType (..),
  BodyCompressionCodec (..),
  DateUnit (..),
  DictionaryEncoding (..),
  Endianness (..),
  Field (..),
  IntervalUnit (..),
  Precision (..),
  Schema (..),
  TimeUnit (..),
  UnionMode (..),
 )
import Arrow.Vector qualified as AV
import Control.DeepSeq (force)
import Control.Exception (SomeException, evaluate, try)
import Control.Monad (forM, forM_, unless, when)
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.Int (Int32, Int64, Int8)
import Data.IORef (IORef, modifyIORef', newIORef, readIORef)
import Data.List (isSuffixOf, sortOn, stripPrefix)
import Data.Either (partitionEithers)
import Data.Map.Strict qualified as Map
import Data.Maybe (catMaybes, fromMaybe, isJust, mapMaybe)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Vector qualified as V
import Data.Vector.Generic qualified as VG
import Data.Vector.Storable qualified as VS
import GHC.Float (float2Double)
import System.Directory (createDirectoryIfMissing, doesFileExist)
import System.Environment (getArgs, lookupEnv)
import System.Exit (ExitCode (..), exitFailure, exitSuccess)
import System.FilePath ((</>))
import System.IO (hSetEncoding, stdout, utf8)
import System.IO.Temp (withSystemTempDirectory)
import System.Process (readProcessWithExitCode)


-- ============================================================
-- Case matrix. Keep in sync with pyarrow_interop.py.
-- ============================================================

data Case = Case
  { caseName :: String
  , caseSchema :: Schema
  , caseBatches :: [V.Vector ColumnArray]
  , caseStreamOnly :: Bool
  -- ^ Replacement / delta dictionaries are only legal in the stream format.
  , caseReplaceDicts :: Bool
  -- ^ Write with 'DictReplaceOnChange' instead of the default 'DictEmitOnce'.
  }


field :: Text -> Bool -> ArrowType -> [Field] -> Field
field n nullable ty kids = Field n nullable ty (V.fromList kids) Nothing V.empty


dictField :: Text -> Bool -> ArrowType -> Int64 -> ArrowType -> Field
dictField n nullable valueTy did indexTy =
  Field n nullable valueTy V.empty (Just (DictionaryEncoding did indexTy False)) V.empty


schemaOf :: [Field] -> Schema
schemaOf fs = Schema (V.fromList fs) Little V.empty V.empty


simple :: String -> Field -> [ColumnArray] -> Case
simple n f cols = Case n (schemaOf [f]) (map V.singleton cols) False False


-- | Unwrap a checked fixture constructor.
ok :: Either String ColumnArray -> ColumnArray
ok = either (error . ("bad fixture: " ++)) id


{- | A non-nullable case @name@ (nulls dropped from the first batch) and a
nullable case @name_nullable@ (first batch with nulls, second batch with
none, so pyarrow omits the validity bitmap there).
-}
pair
  :: String
  -> ArrowType
  -> [Field]
  -> ([a] -> ColumnArray)
  -> ([Maybe a] -> ColumnArray)
  -> [Maybe a]
  -> [a]
  -> [Case]
pair n ty kids plain nullable b1 b2 =
  [ simple n (field "x" False ty kids) [plain (catMaybes b1), plain b2]
  , simple (n ++ "_nullable") (field "x" True ty kids) [nullable b1, nullable (map Just b2)]
  ]


-- | 'pair' for a fixed-width element type.
primPair :: String -> ArrowType -> PrimType a -> [Maybe a] -> [a] -> [Case]
primPair n ty p = withPrim p (pair n ty [] (primColumn p . VS.fromList) (fromMaybes p . V.fromList))


-- | 'pair' for a column built from a boxed vector of rows.
boxedPair :: String -> ArrowType -> (V.Vector a -> ColumnArray) -> (V.Vector (Maybe a) -> ColumnArray) -> [Maybe a] -> [a] -> [Case]
boxedPair n ty plain nullable = pair n ty [] (plain . V.fromList) (nullable . V.fromList)


-- | 'boxedPair' for constructors that only take optional rows.
maybePair :: String -> ArrowType -> (V.Vector (Maybe a) -> ColumnArray) -> [Maybe a] -> [a] -> [Case]
maybePair n ty nullable = boxedPair n ty (nullable . V.map Just) nullable


i32, i16, i64 :: [Int32] -> ColumnArray
i32 = primColumn PInt32 . VS.fromList
i16 = primColumn PInt16 . VS.fromList . map fromIntegral
i64 = primColumn PInt64 . VS.fromList . map fromIntegral


i32m :: [Maybe Int32] -> ColumnArray
i32m = fromMaybes PInt32 . V.fromList


utf8m :: [Maybe Text] -> ColumnArray
utf8m = fromMaybeTexts . V.fromList


-- | Validity from per-row flags (True = valid).
valid :: [Bool] -> Maybe Validity
valid = validityFromBools . V.fromList


cases :: [Case]
cases =
  concat
    [ primPair "int8" (AInt 8 True) PInt8 [Just 0, Just 1, Nothing, Just (-1), Just 127, Just (-128)] [42, -42]
    , primPair "int16" (AInt 16 True) PInt16 [Just 0, Just 1, Nothing, Just (-1), Just 32767, Just (-32768)] [1000, -1000]
    , primPair "int32" (AInt 32 True) PInt32 [Just 0, Just 1, Nothing, Just (-1), Just maxBound, Just minBound] [7, -7]
    , primPair "int64" (AInt 64 True) PInt64 [Just 0, Just 1, Nothing, Just (-1), Just maxBound, Just minBound] [123456789012, -5]
    , primPair "uint8" (AInt 8 False) PUInt8 [Just 0, Just 1, Nothing, Just maxBound] [128]
    , primPair "uint16" (AInt 16 False) PUInt16 [Just 0, Just 1, Nothing, Just maxBound] [256]
    , primPair "uint32" (AInt 32 False) PUInt32 [Just 0, Just 1, Nothing, Just maxBound] [65536]
    , primPair "uint64" (AInt 64 False) PUInt64 [Just 0, Just 1, Nothing, Just maxBound] [4294967296]
    , primPair "float16" (AFloatingPoint Half) PFloat16 (map (fmap Float16) [Just 0x0000, Just 0x3C00, Nothing, Just 0xC000, Just 0x7BFF, Just 0x7C00]) [Float16 0x3800]
    , primPair "float32" (AFloatingPoint Single) PFloat [Just 0, Just 1.5, Nothing, Just (-2.5), Just (1 / 0), Just 3.4028234663852886e38] [0.25]
    , primPair "float64" (AFloatingPoint DoublePrecision) PDouble [Just 0, Just 1.5, Nothing, Just (-2.5), Just (-1 / 0), Just 1e300, Just 5e-324] [3.141592653589793]
    , boxedPair "bool" ABool fromBools fromMaybeBools (map Just [True, False] ++ [Nothing] ++ map Just [True, True, False, False, True, False]) [False, True]
    , boxedPair "utf8" AUtf8 fromTexts fromMaybeTexts strs ["second batch"]
    , maybePair "large_utf8" ALargeUtf8 fromMaybeLargeTexts strs ["second batch"]
    , boxedPair "binary" ABinary fromByteStrings fromMaybeByteStrings bins ["z"]
    , maybePair "large_binary" ALargeBinary fromMaybeLargeByteStrings bins ["z"]
    , maybePair "fixed_size_binary4" (AFixedSizeBinary 4) (ok . fromMaybeFixedSizeBinary 4) [Just "abcd", Just "\0\0\0\0", Nothing, Just "\xff\xfe\xfd\xfc"] ["wxyz"]
    , maybePair "utf8_view" AUtf8View fromMaybeUtf8View views ["inline", "an out of line string value"]
    , maybePair "binary_view" ABinaryView fromMaybeBinaryView viewBytes ["inline", BS.replicate 13 0]
    , primPair "date32" (ADate DateDay) PDate32 [Just 0, Just 18000, Nothing, Just (-1), Just 19000] [20000]
    , primPair "date64" (ADate DateMillisecond) PDate64 [Just 0, Just 1555200000000, Nothing, Just (-86400000)] [86400000]
    , primPair "time32_s" (ATime Second 32) PTime32 [Just 0, Just 3600, Nothing, Just 86399] [60]
    , primPair "time32_ms" (ATime Millisecond 32) PTime32 [Just 0, Just 1000, Nothing, Just 86399999] [1]
    , primPair "time64_us" (ATime Microsecond 64) PTime64 [Just 0, Just 12345000000, Nothing, Just 86399999999] [1]
    , primPair "time64_ns" (ATime Nanosecond 64) PTime64 [Just 0, Just 1, Nothing, Just 86399999999999] [2]
    , concatMap timeUnitCases [(Second, "s"), (Millisecond, "ms"), (Microsecond, "us"), (Nanosecond, "ns")]
    , primPair "timestamp_us_new_york" (ATimestamp Microsecond (Just "America/New_York")) PTimestamp [Just 0, Just 1700000000000000, Nothing] [5]
    , decimalPair "decimal32_9_2" (ADecimal32 9 2) (PDecimal32 9 2) fromInteger [Just (-999999999), Just 123, Nothing, Just 0, Just 999999999] [1]
    , decimalPair "decimal64_18_2" (ADecimal64 18 2) (PDecimal64 18 2) fromInteger [Just (-999999999999999999), Just 123, Nothing, Just 0, Just 999999999999999999] [1]
    , decimalPair "decimal128_18_2" (ADecimal 18 2) (PDecimal128 18 2) decimal128FromInteger [Just 0, Just 123, Nothing, Just (-123), Just 999999999999999999] [1]
    , decimalPair "decimal128_38_10" (ADecimal 38 10) (PDecimal128 38 10) decimal128FromInteger [Just 123456789012345678901234567890, Nothing, Just (-1)] [10000000000]
    , decimalPair "decimal256_76_5" (ADecimal256 76 5) (PDecimal256 76 5) decimal256FromInteger [Just 100000, Nothing, Just (-1234567890123456789012345678901234567890123456789012345)] [1]
    , primPair
        "interval_month_day_nano"
        (AInterval MonthDayNano)
        PIntervalMonthDayNano
        [Just (IntervalMonthDayNano 1 2 3), Just (IntervalMonthDayNano 0 0 0), Nothing, Just (IntervalMonthDayNano (-1) (-2) (-3))]
        [IntervalMonthDayNano 12 30 1000000000]
    , primPair "interval_year_month" (AInterval YearMonth) PIntervalYearMonth [Just 0, Just 14, Nothing, Just (-3)] [5]
    , primPair
        "interval_day_time"
        (AInterval DayTime)
        PIntervalDayTime
        [Just (IntervalDayTime 0 0), Just (IntervalDayTime 1 1000), Nothing, Just (IntervalDayTime (-1) (-1))]
        [IntervalDayTime 2 2]
    , [simple "null" (field "x" True ANull []) [ColNull 4, ColNull 1]]
    , structCases
    , listCases
    , listViewCases
    , unionCases
    , dictCases
    , reeCases
    , [metadataCase]
    , mixedCases
    ]
  where
    strs = [Just "", Just "a", Nothing, Just "h\233llo w\246rld", Just "\128512 emoji"]
    bins = [Just "", Just "\0\1\2", Nothing, Just (BS.replicate 20 0xff)]
    views =
      [ Just ""
      , Just "short"
      , Nothing
      , Just "exactly12byt"
      , Just "thirteen byte"
      , Just "this string is definitely longer than twelve bytes"
      ]
    viewBytes = [Just "", Just "short", Nothing, Just "exactly12byt", Just "thirteen byte", Just "this string is definitely longer than twelve bytes"]
    timeUnitCases (u, s) =
      concat
        [ primPair ("timestamp_" ++ s) (ATimestamp u Nothing) PTimestamp ts [42]
        , primPair ("timestamp_" ++ s ++ "_utc") (ATimestamp u (Just "UTC")) PTimestamp ts [42]
        , primPair ("duration_" ++ s) (ADuration u) PDuration [Just 0, Just 60, Nothing, Just (-1)] [1]
        ]
    ts = [Just 0, Just 1700000000, Nothing, Just (-1)]
    decimalPair :: String -> ArrowType -> PrimType d -> (Integer -> d) -> [Maybe Integer] -> [Integer] -> [Case]
    decimalPair n ty p conv b1 b2 = primPair n ty p (map (fmap conv) b1) (map conv b2)


structCases :: [Case]
structCases =
  [ simple
      "struct"
      (field "x" False AStruct kids)
      [ ok (mkStruct 3 Nothing (V.fromList [("i", i32 [1, 2, 3]), ("s", utf8m [Just "a", Nothing, Just "c"])]))
      , ok (mkStruct 1 Nothing (V.fromList [("i", i32 [4]), ("s", utf8m [Just "d"])]))
      ]
  , simple
      "struct_nullable"
      (field "x" True AStruct kids)
      [ ok
          ( mkStruct
              4
              (valid [True, False, True, True])
              (V.fromList [("i", i32 [1, 0, 2, 3]), ("s", utf8m [Just "a", Nothing, Nothing, Just "c"])])
          )
      , ok (mkStruct 1 (valid [True]) (V.fromList [("i", i32 [4]), ("s", utf8m [Just "d"])]))
      ]
  , -- pa.struct([]): the row count is all a fieldless struct carries.
    simple "struct_empty" (field "x" False AStruct []) [ok (mkStruct 3 Nothing V.empty), ok (mkStruct 1 Nothing V.empty)]
  , simple
      "struct_empty_nullable"
      (field "x" True AStruct [])
      [ok (mkStruct 4 (valid [True, False, True, True]) V.empty), ok (mkStruct 1 (valid [True]) V.empty)]
  ]
  where
    kids = [field "i" False (AInt 32 True) [], field "s" True AUtf8 []]


listCases :: [Case]
listCases =
  [ simple
      "list_int32"
      (field "x" False AList [item (AInt 32 True)])
      [ ok (mkList Nothing (VS.fromList [0, 2, 2, 5]) (i32m [Just 1, Just 2, Just 3, Nothing, Just 5]))
      , ok (mkList Nothing (VS.fromList [0, 1, 3]) (i32m [Just 6, Just 7, Just 8]))
      ]
  , simple
      "list_int32_nullable"
      (field "x" True AList [item (AInt 32 True)])
      [ ok (mkList (valid [True, False, True, True]) (VS.fromList [0, 2, 2, 2, 5]) (i32m [Just 1, Just 2, Just 3, Nothing, Just 5]))
      , ok (mkList (valid [True, True]) (VS.fromList [0, 1, 3]) (i32m [Just 6, Just 7, Just 8]))
      ]
  , simple
      "large_list_utf8"
      (field "x" False ALargeList [item AUtf8])
      [ ok (mkLargeList Nothing (VS.fromList [0, 2, 2, 3]) (utf8m [Just "a", Just "b", Just "c"]))
      , ok (mkLargeList Nothing (VS.fromList [0, 1]) (utf8m [Just "d"]))
      ]
  , simple
      "large_list_utf8_nullable"
      (field "x" True ALargeList [item AUtf8])
      [ ok (mkLargeList (valid [True, False, True, True]) (VS.fromList [0, 2, 2, 2, 3]) (utf8m [Just "a", Just "b", Just "c"]))
      , ok (mkLargeList (valid [True]) (VS.fromList [0, 1]) (utf8m [Just "d"]))
      ]
  , simple
      "fixed_size_list_int16_3"
      (field "x" False (AFixedSizeList 3) [item (AInt 16 True)])
      [ ok (mkFixedSizeList 3 2 Nothing (i16m [Just 1, Just 2, Just 3, Just 4, Nothing, Just 6]))
      , ok (mkFixedSizeList 3 1 Nothing (i16m [Just 7, Just 8, Just 9]))
      ]
  , simple
      "fixed_size_list_int16_3_nullable"
      (field "x" True (AFixedSizeList 3) [item (AInt 16 True)])
      [ ok (mkFixedSizeList 3 3 (valid [True, False, True]) (i16m [Just 1, Just 2, Just 3, Nothing, Nothing, Nothing, Just 4, Nothing, Just 6]))
      , ok (mkFixedSizeList 3 1 (valid [True]) (i16m [Just 7, Just 8, Just 9]))
      ]
  , -- pa.list_(pa.int32(), 0): rows of zero elements over an empty child.
    simple
      "fixed_size_list_int32_0"
      (field "x" False (AFixedSizeList 0) [item (AInt 32 True)])
      [ok (mkFixedSizeList 0 3 Nothing (i32m [])), ok (mkFixedSizeList 0 2 Nothing (i32m []))]
  , simple
      "fixed_size_list_int32_0_nullable"
      (field "x" True (AFixedSizeList 0) [item (AInt 32 True)])
      [ ok (mkFixedSizeList 0 4 (valid [True, False, True, True]) (i32m []))
      , ok (mkFixedSizeList 0 2 (valid [True, True]) (i32m []))
      ]
  , simple
      "map_utf8_int32"
      (field "x" False (AMap False) [entries])
      [ ok (mkMap Nothing (VS.fromList [0, 2, 2, 3]) (fromTexts (V.fromList ["a", "b", "c"])) (i32m [Just 1, Nothing, Just 3]))
      , ok (mkMap Nothing (VS.fromList [0, 1]) (fromTexts (V.fromList ["d"])) (i32m [Just 4]))
      ]
  , simple
      "map_utf8_int32_nullable"
      (field "x" True (AMap False) [entries])
      [ ok (mkMap (valid [True, False, True, True]) (VS.fromList [0, 2, 2, 2, 3]) (fromTexts (V.fromList ["a", "b", "c"])) (i32m [Just 1, Nothing, Just 3]))
      , ok (mkMap (valid [True]) (VS.fromList [0, 1]) (fromTexts (V.fromList ["d"])) (i32m [Just 4]))
      ]
  ]
  where
    i16m = fromMaybes PInt16 . V.fromList
    entries =
      field "entries" False AStruct [field "key" False AUtf8 [], field "value" True (AInt 32 True) []]


item :: ArrowType -> Field
item ty = field "item" True ty []


listViewCases :: [Case]
listViewCases =
  [ simple
      "list_view_int32"
      (field "x" False AListView [item (AInt 32 True)])
      [ ok (mkListView Nothing (VS.fromList [4, 0, 1, 0]) (VS.fromList [2, 3, 0, 1]) child)
      , ok (mkListView Nothing (VS.fromList [0]) (VS.fromList [1]) child2)
      ]
  , simple
      "list_view_int32_nullable"
      (field "x" True AListView [item (AInt 32 True)])
      [ ok (mkListView (valid [True, False, True, True, True]) (VS.fromList [4, 0, 1, 0, 2]) (VS.fromList [2, 0, 0, 1, 2]) child)
      , ok (mkListView (valid [True]) (VS.fromList [0]) (VS.fromList [1]) child2)
      ]
  , simple
      "large_list_view_int32"
      (field "x" False ALargeListView [item (AInt 32 True)])
      [ ok (mkLargeListView Nothing (VS.fromList [4, 0, 1, 0]) (VS.fromList [2, 3, 0, 1]) child)
      , ok (mkLargeListView Nothing (VS.fromList [0]) (VS.fromList [1]) child2)
      ]
  , simple
      "large_list_view_int32_nullable"
      (field "x" True ALargeListView [item (AInt 32 True)])
      [ ok (mkLargeListView (valid [True, False, True, True, True]) (VS.fromList [4, 0, 1, 0, 2]) (VS.fromList [2, 0, 0, 1, 2]) child)
      , ok (mkLargeListView (valid [True]) (VS.fromList [0]) (VS.fromList [1]) child2)
      ]
  ]
  where
    child = i32m [Just 10, Just 20, Just 30, Nothing, Just 50, Just 60]
    child2 = i32m [Just 99]


{- | Union columns store per-row /child indices/; the field's type codes
map them to wire type ids.
-}
unionCases :: [Case]
unionCases =
  [ dense "dense_union" [0, 1] False
  , dense "dense_union_nullable" [0, 1] True
  , sparse "sparse_union" [0, 1] False
  , sparse "sparse_union_nullable" [0, 1] True
  , dense "dense_union_type_codes" [3, 7] True
  , sparse "sparse_union_type_codes" [5, 2] True
  ]
  where
    kids = [field "i" True (AInt 32 True) [], field "s" True AUtf8 []]
    ufield mode codes nullable = field "x" nullable (AUnion mode (V.fromList codes)) kids
    tids = VS.fromList :: [Int8] -> VS.Vector Int8
    dense n codes nullable =
      simple
        n
        (ufield Dense codes nullable)
        [ ok
            ( mkDenseUnion
                (tids [0, 1, 0, 0, 1])
                (VS.fromList [0, 0, 1, 2, 1])
                (V.fromList [i32m [Just 1, Just 2, Nothing], utf8m [Just "a", Just "b"]])
            )
        , ok (mkDenseUnion (tids [1]) (VS.fromList [0]) (V.fromList [i32m [], utf8m [Just "z"]]))
        ]
    sparse n codes nullable =
      simple
        n
        (ufield Sparse codes nullable)
        [ ok
            ( mkSparseUnion
                (tids [0, 1, 0, 1])
                (V.fromList [i32m [Just 1, Just 0, Nothing, Just 0], utf8m [Nothing, Just "a", Just "x", Just "b"]])
            )
        , ok (mkSparseUnion (tids [1]) (V.fromList [i32m [Just 0], utf8m [Just "z"]]))
        ]


abc :: ColumnArray
abc = fromTexts (V.fromList ["a", "b", "c"])


-- | A dictionary column over @vals@ with the given keys column (at the index type's wire width).
dict :: Int64 -> ColumnArray -> ColumnArray -> ColumnArray
dict did keys vals = ok (mkDictionary did keys vals)


dictCases :: [Case]
dictCases =
  [ simple
      "dict_utf8"
      (dictField "x" False AUtf8 0 i32t)
      [dict 0 (ix [0, 1, 0, 2, 1]) abc, dict 0 (ix [2, 2, 0]) abc]
  , simple
      "dict_utf8_nullable"
      (dictField "x" True AUtf8 0 i32t)
      [ dict 0 (i32m [Just 0, Nothing, Just 1, Just 2]) abc
      , dict 0 (i32m [Just 1, Just 0]) abc
      ]
  , simple
      "dict_int8_index"
      (dictField "x" False AUtf8 0 (AInt 8 True))
      [dict 0 (ix8 [1, 0, 1]) xy, dict 0 (ix8 [0]) xy]
  , simple
      "dict_int64_values"
      (dictField "x" False (AInt 64 True) 0 i32t)
      [dict 0 (ix [1, 0, 1]) (i64 [100, 200]), dict 0 (ix [0]) (i64 [100, 200])]
  , (simple "dict_replacement" (dictField "x" False AUtf8 0 i32t) [dict 0 (ix [0, 1, 1]) ab, dict 0 (ix [2, 0, 1]) xyz])
      { caseStreamOnly = True
      , caseReplaceDicts = True
      }
  , (simple "dict_delta" (dictField "x" False AUtf8 0 i32t) [dict 0 (ix [0, 1]) ab, dict 0 (ix [2, 0]) abc])
      { caseStreamOnly = True
      , caseReplaceDicts = True
      }
  , simple
      "dict_in_struct"
      (field "x" False AStruct [dictField "d" True AUtf8 0 i32t])
      [ ok (mkStruct 3 Nothing (V.singleton ("d", dict 0 (i32m [Just 2, Just 0, Just 1]) abc)))
      , ok (mkStruct 1 Nothing (V.singleton ("d", dict 0 (i32m [Just 1]) abc)))
      ]
  , -- Dictionaries nested in dictionary values. pyarrow numbers dictionary
    -- ids in schema pre-order, so the outer dictionary is 0 and the inner 1.
    simple
      "dict_struct_of_dict"
      (nestedDict "x" False AStruct (dictField "d" True AUtf8 1 (AInt 16 True)) (AInt 8 True))
      [dict 0 (ix8 [2, 0, 1, 0]) (structOfDict [1, 0, 2] abc), dict 0 (ix8 [1]) (structOfDict [1, 0, 2] abc)]
  , simple
      "dict_list_of_dict"
      (nestedDict "x" True AList (dictField "item" True AUtf8 1 (AInt 8 True)) i32t)
      [ dict 0 (i32m [Just 1, Nothing, Just 0, Just 2]) listOfDict
      , dict 0 (i32m [Just 2]) listOfDict
      ]
  , -- The inner dictionary changes between batches, so both dictionaries are replaced.
    (simple
      "dict_nested_replacement"
      (nestedDict "x" False AStruct (dictField "d" True AUtf8 1 (AInt 16 True)) (AInt 8 True))
      [dict 0 (ix8 [0, 1]) (structOfDict [0, 1] abc), dict 0 (ix8 [1, 0]) (structOfDict [0, 0] xy)])
      { caseStreamOnly = True
      , caseReplaceDicts = True
      }
  , -- A nullable dictionary column whose rows are all null over an empty dictionary.
    simple
      "dict_all_null_empty"
      (dictField "x" True AUtf8 0 i32t)
      [dict 0 (i32m (replicate 3 Nothing)) (fromTexts V.empty), dict 0 (i32m [Nothing]) (fromTexts V.empty)]
  ]
  where
    i32t = AInt 32 True
    ix = primColumn PInt32 . VS.fromList
    ix8 = primColumn PInt8 . VS.fromList
    ix16 = primColumn PInt16 . VS.fromList
    xy = fromTexts (V.fromList ["x", "y"])
    ab = fromTexts (V.fromList ["a", "b"])
    xyz = fromTexts (V.fromList ["x", "y", "z"])
    nestedDict n nullable container inner indexTy =
      Field n nullable container (V.singleton inner) (Just (DictionaryEncoding 0 indexTy False)) V.empty
    structOfDict inner vals = ok (mkStruct (length inner) Nothing (V.singleton ("d", dict 1 (ix16 inner) vals)))
    listOfDict = ok (mkList Nothing (VS.fromList [0, 2, 2, 3]) (dict 1 (ix8 [0, 1, 1]) xy))


reeCases :: [Case]
reeCases =
  [ ree "ree_int32_int64" False (AInt 32 True) (AInt 64 True) [(i32 [3, 5, 8], int64m [Just 100, Just 200, Just 300]), (i32 [2], int64m [Just 7])]
  , ree "ree_int32_int64_nullable" True (AInt 32 True) (AInt 64 True) [(i32 [3, 5, 8], int64m [Just 100, Nothing, Just 300]), (i32 [2], int64m [Just 7])]
  , ree "ree_int16_utf8" True (AInt 16 True) AUtf8 [(i16 [1, 4], utf8m [Just "a", Just "bb"]), (i16 [3], utf8m [Just "c"])]
  , ree "ree_int64_float64" True (AInt 64 True) (AFloatingPoint DoublePrecision) [(i64 [2, 3], dblm [Just 1.5, Nothing]), (i64 [1], dblm [Just 2.5])]
  ]
  where
    int64m = fromMaybes PInt64 . V.fromList
    dblm = fromMaybes PDouble . V.fromList
    ree n nullable reTy valTy batches =
      simple
        n
        (field "x" nullable ARunEndEncoded [field "run_ends" False reTy [], field "values" True valTy []])
        (map (ok . uncurry mkRunEndEncoded) batches)


metadataCase :: Case
metadataCase =
  Case
    { caseName = "custom_metadata"
    , caseSchema =
        Schema
          { arrowFields =
              V.fromList
                [ (field "a" False (AInt 32 True) []) {fieldMetadata = V.fromList [("unit", "ms"), ("note", "field level")]}
                , (field "b" True AUtf8 []) {fieldMetadata = V.fromList [("k", "v")]}
                ]
          , arrowEndianness = Little
          , arrowMetadata = V.fromList [("origin", "wireform-arrow interop"), ("k2", "v2")]
          , arrowFeatures = V.empty
          }
    , caseBatches =
        [ V.fromList [i32 [1, 2], utf8m [Just "x", Nothing]]
        , V.fromList [i32 [3], utf8m [Just "y"]]
        ]
    , caseStreamOnly = False
    , caseReplaceDicts = False
    }


mixedCases :: [Case]
mixedCases =
  [ multi "mixed" [m1, m2]
  , multi "zero_row_batches" [m1, m0, m2]
  , multi "only_zero_row_batch" [m0]
  , multi "no_batches" []
  ]
  where
    multi n bs = Case n mixedSchema bs False False
    mixedSchema =
      schemaOf
        [ field "i" False (AInt 64 True) []
        , field "s" True AUtf8 []
        , field "b" True ABool []
        , field "l" True AList [item (AInt 32 True)]
        , field "st" True AStruct [field "f" True (AFloatingPoint DoublePrecision) []]
        , dictField "d" True AUtf8 0 (AInt 32 True)
        , field "v" True AUtf8View []
        ]
    mk i s b (lv, lo, lc) (sv, sf) d v =
      V.fromList
        [ i64 i
        , utf8m s
        , fromMaybeBools (V.fromList b)
        , ok (mkList (valid lv) (VS.fromList lo) (i32m lc))
        , ok (mkStruct (length sv) (valid sv) (V.singleton ("f", fromMaybes PDouble (V.fromList sf))))
        , dict 0 (i32m d) abc
        , fromMaybeUtf8View (V.fromList v)
        ]
    m1 =
      mk
        [10, 20, 30]
        [Just "hello", Nothing, Just "!"]
        [Just True, Nothing, Just False]
        ([True, False, True], [0, 1, 1, 3], [Just 1, Just 2, Just 3])
        ([True, False, True], [Just 1.5, Nothing, Just 2.5])
        [Just 0, Just 2, Nothing]
        [Just "v", Nothing, Just "a view longer than twelve"]
    m0 = mk [] [] [] ([], [0], []) ([], []) [] []
    m2 =
      mk
        [40]
        [Just "w"]
        [Just True]
        ([True], [0, 0], [])
        ([True], [Just 0.5])
        [Just 1]
        [Just "z"]


-- ============================================================
-- Logical values: layout-independent row contents, used to compare
-- what wireform-arrow decoded with what the case expects.
-- ============================================================

data LV
  = LNull
  | LInt !Integer
  | LDouble !Double
  | LBool !Bool
  | LText !Text
  | LBytes !ByteString
  | LList [LV]
  | LStruct [(Text, LV)]
  | LMap [(LV, LV)]
  | LUnion !Int32 LV
  deriving stock (Eq, Show)


type Rows = V.Vector LV


-- | Converted rows, through a boxed vector ('VG.convert').
opt :: AV.Element a => (a -> LV) -> AV.Vector (Maybe a) -> Rows
opt f = V.map (maybe LNull f) . VG.convert


childField :: Field -> Int -> Either String Field
childField f i =
  maybe (Left ("field " ++ show (fieldName f) ++ " has no child " ++ show i)) Right (fieldChildren f V.!? i)


sliceRows :: Rows -> Int -> Int -> Either String [LV]
sliceRows rs o n
  | o < 0 || n < 0 || o + n > V.length rs =
      Left ("slice [" ++ show o ++ ", +" ++ show n ++ ") outside child of length " ++ show (V.length rs))
  | otherwise = Right (V.toList (V.slice o n rs))


at :: Rows -> Int -> Either String LV
at rs i = maybe (Left ("index " ++ show i ++ " outside child of length " ++ show (V.length rs))) Right (rs V.!? i)


-- | Rows of a fixed-width column, read through its typed view.
primRows :: PrimType a -> ColumnArray -> Either String Rows
primRows p col = case p of
  PInt8 -> go (LInt . toInteger)
  PInt16 -> go (LInt . toInteger)
  PInt32 -> go (LInt . toInteger)
  PInt64 -> go (LInt . toInteger)
  PUInt8 -> go (LInt . toInteger)
  PUInt16 -> go (LInt . toInteger)
  PUInt32 -> go (LInt . toInteger)
  PUInt64 -> go (LInt . toInteger)
  PFloat16 -> go (\(Float16 w) -> LInt (toInteger w))
  PFloat -> go (LDouble . float2Double)
  PDouble -> go LDouble
  PDate32 -> go (LInt . toInteger)
  PDate64 -> go (LInt . toInteger)
  PTime32 -> go (LInt . toInteger)
  PTime64 -> go (LInt . toInteger)
  PTimestamp -> go (LInt . toInteger)
  PDuration -> go (LInt . toInteger)
  PIntervalYearMonth -> go (LInt . toInteger)
  PIntervalDayTime -> go (\(IntervalDayTime d m) -> LList [LInt (toInteger d), LInt (toInteger m)])
  PIntervalMonthDayNano -> go (\(IntervalMonthDayNano m d n) -> LList [LInt (toInteger m), LInt (toInteger d), LInt (toInteger n)])
  PDecimal32 _ _ -> go (LInt . toInteger)
  PDecimal64 _ _ -> go (LInt . toInteger)
  PDecimal128 _ _ -> go (LInt . decimal128ToInteger)
  PDecimal256 _ _ -> go (LInt . decimal256ToInteger)
  where
    go conv = withPrim p (maybe (Left ("asPrim failed on " ++ show col)) (Right . opt conv . toMaybeVector) (asPrim p col))


rowsOf :: Field -> ColumnArray -> Either String Rows
rowsOf f col = case col of
  ColNull n -> Right (V.replicate n LNull)
  ColPrim p _ _ -> primRows p col
  ColBool {} -> opt LBool <$> toBoolVector col
  ColUtf8 {} -> texts
  ColLargeUtf8 {} -> texts
  ColUtf8View {} -> texts
  ColBinary {} -> bytes
  ColLargeBinary {} -> bytes
  ColFixedSizeBinary {} -> bytes
  ColBinaryView {} -> bytes
  ColStruct n v cs -> do
    crs <- V.imapM (\i (nm, c) -> (,) nm <$> childRows i c) cs
    V.generateM n $ \j ->
      if isValidAt v j
        then LStruct . V.toList <$> traverse (\(nm, rs) -> (,) nm <$> at rs j) crs
        else Right LNull
  ColList _ _ c -> lists c
  ColLargeList _ _ c -> lists c
  ColListView _ _ _ c -> lists c
  ColLargeListView _ _ _ c -> lists c
  ColFixedSizeList _ _ _ c -> lists c
  ColMap _ _ ks vs -> do
    entries <- childField f 0
    kf <- childField entries 0
    vf <- childField entries 1
    kr <- rowsOf kf ks
    vr <- rowsOf vf vs
    unless (V.length kr == V.length vr) (Left "map keys and values differ in length")
    perRow $ \(ChildRange s l) -> do
      kl <- sliceRows kr s l
      vl <- sliceRows vr s l
      Right (LMap (zip kl vl))
  ColDenseUnion tids offs cs -> do
    crs <- V.imapM childRows cs
    V.fromList
      <$> traverse
        (\(t, o) -> do
            rs <- maybe (Left ("union child index " ++ show t ++ " out of range")) Right (crs V.!? fromIntegral t)
            LUnion (typeCode t) <$> at rs (fromIntegral o)
        )
        (zip (VS.toList tids) (VS.toList offs))
  ColSparseUnion tids cs -> do
    crs <- V.imapM childRows cs
    V.fromList
      <$> traverse
        (\(i, t) -> do
            rs <- maybe (Left ("union child index " ++ show t ++ " out of range")) Right (crs V.!? fromIntegral t)
            LUnion (typeCode t) <$> at rs i
        )
        (zip [0 ..] (VS.toList tids))
  ColDictionary _ _ vals -> do
    vr <- rowsOf (f {fieldDictionary = Nothing}) vals
    V.generateM (columnLength col) (maybe (Right LNull) (at vr) . dictKeyAt col)
  ColRunEndEncoded off len ends vals -> do
    er <- childRows 0 ends
    vr <- childRows 1 vals
    endsI <- traverse (\case LInt e -> Right (fromInteger e); other -> Left ("non-integer run end " ++ show other)) (V.toList er)
    let expand _ [] _ = Right []
        expand prev (e : es) k
          | e < prev = Left "run ends are not ascending"
          | otherwise = do
              v <- at vr k
              rest <- expand e es (k + 1)
              Right (replicate (e - prev) v ++ rest)
    full <- V.fromList <$> expand (0 :: Int) endsI 0
    V.fromList <$> sliceRows full off len
  where
    texts = opt LText <$> toTextVector col
    bytes = opt LBytes <$> toBytesVector col
    childRows i c = do
      cf <- childField f i
      rowsOf cf c
    -- One row per list slot; 'listRange' answers Nothing for a null row.
    perRow row = V.generateM (columnLength col) (maybe (Right LNull) row . listRange col)
    lists c = do
      cr <- childRows 0 c
      perRow (\(ChildRange s l) -> LList <$> sliceRows cr s l)
    typeCode :: Int8 -> Int32
    typeCode t = case fieldType f of
      AUnion _ codes | not (V.null codes) -> fromMaybe (-1) (codes V.!? fromIntegral t)
      _ -> fromIntegral t


-- | Schemas compare equal up to the order of custom-metadata pairs.
normaliseSchema :: Schema -> Schema
normaliseSchema s =
  s
    { arrowFields = V.map normaliseField (arrowFields s)
    , arrowMetadata = sortMeta (arrowMetadata s)
    }
  where
    normaliseField fl =
      fl
        { fieldChildren = V.map normaliseField (fieldChildren fl)
        , fieldMetadata = sortMeta (fieldMetadata fl)
        }
    sortMeta = V.fromList . sortOn fst . V.toList


{- | Compare a decoded file with the case's expectation. Top-level
dictionary columns must also expand ('expandDictionary') to the same
rows.
-}
compareDecoded :: Case -> Schema -> [V.Vector ColumnArray] -> Either String ()
compareDecoded c sch batches = do
  unless (normaliseSchema sch == normaliseSchema (caseSchema c)) $
    Left ("schema mismatch: got " ++ show sch ++ " want " ++ show (caseSchema c))
  unless (length batches == length (caseBatches c)) $
    Left ("got " ++ show (length batches) ++ " batches, want " ++ show (length (caseBatches c)))
  forM_ (zip3 [0 :: Int ..] batches (caseBatches c)) $ \(bi, got, want) -> do
    let fields = arrowFields (caseSchema c)
    unless (V.length got == V.length fields) $
      Left ("batch " ++ show bi ++ ": got " ++ show (V.length got) ++ " columns")
    forM_ (zip3 (V.toList fields) (V.toList got) (V.toList want)) $ \(fl, g, w) -> do
      let ctx = "batch " ++ show bi ++ " column " ++ show (fieldName fl) ++ ": "
      gr <- either (\e -> Left (ctx ++ "decoded column is malformed: " ++ e ++ " (" ++ show g ++ ")")) Right (rowsOf fl g)
      wr <- either (\e -> Left (ctx ++ "expected column is malformed: " ++ e)) Right (rowsOf fl w)
      unless (gr == wr) $
        Left (ctx ++ "got " ++ show (V.toList gr) ++ " want " ++ show (V.toList wr) ++ " (decoded " ++ show g ++ ")")
      when (isJust (fieldDictionary fl)) $ do
        expanded <- either (\e -> Left (ctx ++ "expandDictionary failed: " ++ e)) Right (expandDictionary g)
        er <- either (\e -> Left (ctx ++ "expanded column is malformed: " ++ e)) Right (rowsOf fl {fieldDictionary = Nothing} expanded)
        unless (er == gr) $
          Left (ctx ++ "expanded rows " ++ show (V.toList er) ++ " differ from " ++ show (V.toList gr))


-- ============================================================
-- Driver
-- ============================================================

data Tally = Tally {tPass :: !Int, tFail :: !Int, tSkip :: !Int, tFailures :: [String]}


report :: IORef Tally -> String -> String -> Either String () -> IO ()
report ref dir name = \case
  Right () -> do
    putStrLn ("[" ++ dir ++ "] PASS " ++ name)
    modifyIORef' ref (\t -> t {tPass = tPass t + 1})
  Left e -> do
    let line = "[" ++ dir ++ "] FAIL " ++ name ++ ": " ++ truncateMsg e
    putStrLn line
    modifyIORef' ref (\t -> t {tFail = tFail t + 1, tFailures = line : tFailures t})


skip :: IORef Tally -> String -> String -> String -> IO ()
skip ref dir name why = do
  putStrLn ("[" ++ dir ++ "] SKIP " ++ name ++ ": " ++ why)
  modifyIORef' ref (\t -> t {tSkip = tSkip t + 1})


truncateMsg :: String -> String
truncateMsg s = if length s > 2000 then take 2000 s ++ " ..." else s


-- | Body-compression variants this build of wireform-arrow supports.
compressionVariants :: [(String, Maybe BodyCompressionCodec)]
compressionVariants =
  [("plain", Nothing)]
#ifdef HAVE_ZSTD
    ++ [("zstd", Just BodyZstd)]
#endif
#ifdef HAVE_LZ4
    ++ [("lz4", Just LZ4Frame)]
#endif


-- | Whether this build can decode files pyarrow wrote with @variant@.
variantSupported :: String -> Bool
variantSupported v = case lookup v compressionVariants of
  Just _ -> True
  Nothing -> v == "sliced"


{- | Write every case into @dir@ as @<case>.<variant>.arrows@ (and
@.arrow@ unless stream-only). Returns the file names written plus the
cases whose encoder threw.
-}
writeHaskellFiles :: Endianness -> FilePath -> IO ([String], [(String, String)])
writeHaskellFiles endian dir = do
  createDirectoryIfMissing True dir
  results <- forM (map (\c -> c {caseSchema = (caseSchema c) {arrowEndianness = endian}}) cases) $ \c -> forM compressionVariants $ \(variant, codec) -> do
    let opts =
          defaultWriteOptions
            { writeBodyCompression = codec
            , writeDictHandling = if caseReplaceDicts c then DictReplaceOnChange else DictEmitOnce
            }
        base = caseName c ++ "." ++ variant
        outputs =
          (base ++ ".arrows", encodeArrowStream opts (caseSchema c) (caseBatches c))
            : if caseStreamOnly c then [] else [(base ++ ".arrow", encodeArrowFile opts (caseSchema c) (caseBatches c))]
    forM outputs $ \(name, bytes) -> do
      r <- try (evaluate (force bytes)) :: IO (Either SomeException (Either String ByteString))
      case r of
        Right (Right bs) -> do
          let decode = if ".arrow" `isSuffixOf` name then decodeArrowFile defaultReadOptions else decodeArrowStream defaultReadOptions
          case decode bs >>= uncurry (compareDecoded c) of
            Left e -> pure (Left (name, "native writer/reader roundtrip: " ++ e))
            Right () -> do
              BS.writeFile (dir </> name) bs
              pure (Right name)
        Right (Left e) -> pure (Left (name, "wireform-arrow encoder rejected the case: " ++ e))
        Left e -> pure (Left (name, "wireform-arrow encoder threw: " ++ show e))
  let (failures, written) = partitionEithers (concat (concat results))
  pure (written, failures)


runPython :: FilePath -> FilePath -> [String] -> IO (Either String [String])
runPython python script args = do
  r <- try (readProcessWithExitCode python (script : args) "") :: IO (Either SomeException (ExitCode, String, String))
  pure $ case r of
    Left e -> Left ("could not run " ++ python ++ ": " ++ show e)
    Right (ExitSuccess, out, _) -> Right (lines out)
    Right (ExitFailure n, out, err) ->
      Left (python ++ " " ++ unwords (script : args) ++ " exited " ++ show n ++ ": " ++ err ++ out)


-- | Direction A: wireform-arrow writes, pyarrow reads.
directionA :: Endianness -> IORef Tally -> FilePath -> FilePath -> FilePath -> IO ()
directionA endian ref python script tmp = do
  let dir = tmp </> ("wireform-" ++ show endian)
  (written, encodeFailures) <- writeHaskellFiles endian dir
  forM_ encodeFailures $ \(name, e) -> report ref "A" name (Left e)
  out <- runPython python script ["check", dir]
  case out of
    Left e -> report ref "A" "pyarrow checker" (Left e)
    Right ls -> do
      forM_ (filter (\line -> take 8 line == "ADAPTER ") ls) putStrLn
      let verdicts =
            Map.fromList
              ( mapMaybe
                  ( \l -> case stripPrefix "PASS " l of
                      Just n -> Just (n, Right ())
                      Nothing -> case stripPrefix "FAIL " l of
                        Just rest -> let (n, msg) = break (== ':') rest in Just (n, Left (drop 2 msg))
                        Nothing -> Nothing
                  )
                  ls
              )
      forM_ written $ \name ->
        report ref "A" name (fromMaybe (Left ("pyarrow checker printed no verdict; output: " ++ unlines ls)) (Map.lookup name verdicts))


-- | Direction B: pyarrow writes, wireform-arrow reads.
directionB :: IORef Tally -> FilePath -> FilePath -> FilePath -> IO ()
directionB ref python script tmp = do
  out <- runPython python script ["write", dir]
  case out of
    Left e -> report ref "B" "pyarrow writer" (Left e)
    Right ls -> forM_ ls $ \l -> case words l of
      ("WROTE" : name : _) -> checkFile name
      ("SKIP" : name : _) -> skip ref "B" (takeWhile (/= ':') name) (drop 2 (dropWhile (/= ':') l))
      ("FAIL" : name : _) -> report ref "B" (takeWhile (/= ':') name) (Left (drop 2 (dropWhile (/= ':') l)))
      _ -> pure ()
  where
    byName = Map.fromList (map (\c -> (caseName c, c)) cases)
    checkFile name = case splitName name of
      Nothing -> report ref "B" name (Left "unexpected file name")
      Just (cn, variant, ext)
        | not (variantSupported variant) ->
            skip ref "B" name ("wireform-arrow built without -f+" ++ variant)
        | otherwise -> case Map.lookup cn byName of
            Nothing -> report ref "B" name (Left ("no Haskell-side case named " ++ cn))
            Just c -> do
              bytes <- BS.readFile (dir </> name)
              let decode = if ext == "arrow" then decodeArrowFile defaultReadOptions else decodeArrowStream defaultReadOptions
              r <- try (evaluate (force (decode bytes))) :: IO (Either SomeException (Either String (Schema, [V.Vector ColumnArray])))
              report ref "B" name $ case r of
                Left e -> Left ("wireform-arrow decoder threw: " ++ show e)
                Right (Left e) -> Left ("wireform-arrow failed to decode: " ++ e)
                Right (Right (sch, batches)) -> compareDecoded c sch batches
    dir = tmp </> "pyarrow"


splitName :: String -> Maybe (String, String, String)
splitName name = case splitOn '.' name of
  [c, v, e] -> Just (c, v, e)
  _ -> Nothing
  where
    splitOn ch s = case break (== ch) s of
      (a, []) -> [a]
      (a, _ : rest) -> a : splitOn ch rest


-- | Case names must agree between the two definitions of the matrix.
checkCaseNames :: IORef Tally -> FilePath -> FilePath -> IO ()
checkCaseNames ref python script = do
  out <- runPython python script ["cases"]
  case out of
    Left e -> report ref "matrix" "case list" (Left e)
    Right ls -> do
      let py = Set.fromList (filter (not . null) ls)
          hs = Set.fromList (map caseName cases)
      forM_ (Set.toList (Set.difference py hs)) $ \n ->
        report ref "matrix" n (Left "case exists in pyarrow_interop.py but not in Main.hs")
      forM_ (Set.toList (Set.difference hs py)) $ \n ->
        report ref "matrix" n (Left "case exists in Main.hs but not in pyarrow_interop.py")
      when (py == hs) $ putStrLn ("[matrix] " ++ show (Set.size hs) ++ " cases defined on both sides")


findScript :: IO (Maybe FilePath)
findScript = do
  override <- lookupEnv "WIREFORM_ARROW_INTEROP_SCRIPT"
  let candidates =
        maybe [] pure override
          ++ ["test-interop/pyarrow_interop.py", "wireform-arrow/test-interop/pyarrow_interop.py"]
  firstExisting candidates
  where
    firstExisting [] = pure Nothing
    firstExisting (p : ps) = do
      exists <- doesFileExist p
      if exists then pure (Just p) else firstExisting ps


main :: IO ()
main = do
  hSetEncoding stdout utf8
  args <- getArgs
  case args of
    [mode, dir] | mode == "--write" || mode == "--write-big" -> do
      (written, failures) <- writeHaskellFiles (if mode == "--write-big" then Big else Little) dir
      putStrLn ("wrote " ++ show (length written) ++ " files to " ++ dir)
      forM_ failures $ \(n, e) -> putStrLn ("FAIL " ++ n ++ ": " ++ e)
      if null failures then exitSuccess else exitFailure
    [] -> runSuite
    _ -> do
      putStrLn "usage: wireform-arrow-pyarrow-interop [--write DIR | --write-big DIR]"
      exitFailure


runSuite :: IO ()
runSuite = do
  python <- fromMaybe "python3" <$> lookupEnv "WIREFORM_ARROW_PYTHON"
  required <- (== Just "1") <$> lookupEnv "WIREFORM_ARROW_REQUIRE_PYARROW"
  probe <- try (readProcessWithExitCode python ["-c", "import pyarrow; print(pyarrow.__version__)"] "") :: IO (Either SomeException (ExitCode, String, String))
  let unavailable why =
        if required
          then do
            putStrLn ("FAIL: pyarrow is required (WIREFORM_ARROW_REQUIRE_PYARROW=1) but unavailable: " ++ why)
            exitFailure
          else do
            putStrLn ("SKIP: pyarrow interop suite (" ++ why ++ "); set WIREFORM_ARROW_PYTHON to a Python with pyarrow, and WIREFORM_ARROW_REQUIRE_PYARROW=1 to make this a failure")
            exitSuccess
  version <- case probe of
    Left e -> unavailable ("cannot run " ++ python ++ ": " ++ show e)
    Right (ExitSuccess, out, _) -> pure (concat (take 1 (lines out)))
    Right (_, _, err) -> unavailable (python ++ " cannot import pyarrow: " ++ concat (take 1 (reverse (lines err))))
  script <-
    findScript >>= \case
      Just s -> pure s
      Nothing -> do
        putStrLn "FAIL: cannot find test-interop/pyarrow_interop.py (set WIREFORM_ARROW_INTEROP_SCRIPT)"
        exitFailure
  putStrLn ("pyarrow " ++ version ++ " via " ++ python ++ "; compression variants: " ++ unwords (map fst compressionVariants))
  ref <- newIORef (Tally 0 0 0 [])
  checkCaseNames ref python script
  withSystemTempDirectory "wireform-arrow-interop" $ \tmp -> do
    directionA Little ref python script tmp
    directionA Big ref python script tmp
    directionB ref python script tmp
  t <- readIORef ref
  putStrLn ""
  putStrLn ("pyarrow interop: " ++ show (tPass t) ++ " passed, " ++ show (tFail t) ++ " failed, " ++ show (tSkip t) ++ " skipped")
  unless (null (tFailures t)) $ do
    putStrLn "Failures:"
    mapM_ putStrLn (reverse (tFailures t))
    exitFailure