use std::io::Seek;
use std::sync::Arc;

use pyo3::exceptions::PyValueError;
use pyo3::prelude::*;
use pyo3::types::PyDict;
use pyo3::IntoPyObjectExt;
use pyo3_arrow::PyRecordBatchReader;
use pyo3_arrow::PySchema;

use noodles::bgzf::io::Seek as _;
use noodles::core::Region;

use crate::error::{err_on_unwind, to_py};
use crate::util::{
    pyobject_to_bufreader, resolve_cram_index, resolve_fasta_repository, resolve_fields,
    resolve_index, PyVirtualPosition, Reader,
};
use oxbow::alignment::{BamScanner, CramScanner, SamScanner};
use oxbow::util::batches_to_ipc;
use oxbow::util::index::IndexType;

/// A SAM file scanner.
///
/// Parameters
/// ----------
/// src : str or file-like
///     The path to the SAM file or a file-like object.
/// compressed : bool, optional [default: False]
///     Whether the source is BGZF-compressed.
/// fields : str or list[str] or None, optional [default: "*"]
///     Standard SAM fields to include. ``"*"`` for all, ``None`` to omit,
///     or a list of field names.
/// tag_defs : list[tuple[str, str]], optional [default: None]
///     Tag definitions for the ``"tags"`` struct column. ``None`` omits the
///     tags column. Use the ``tag_defs()`` method to discover definitions.
#[pyclass(module = "oxbow.oxbow")]
pub struct PySamScanner {
    src: Py<PyAny>,
    reader: Reader,
    scanner: SamScanner,
    compressed: bool,
}

#[pymethods]
impl PySamScanner {
    #[new]
    #[pyo3(signature = (src, compressed=false, fields=None, tag_defs=None))]
    fn new(
        py: Python,
        src: Py<PyAny>,
        compressed: bool,
        fields: Option<Py<PyAny>>,
        tag_defs: Option<Vec<(String, String)>>,
    ) -> PyResult<Self> {
        let fields = resolve_fields(fields, py)?;
        let reader = pyobject_to_bufreader(py, src.clone_ref(py), compressed)?;
        let mut fmt_reader = noodles::sam::io::Reader::new(reader);
        let header = fmt_reader.read_header()?;
        let reader = fmt_reader.into_inner();
        let scanner = SamScanner::new(header, fields, tag_defs).map_err(to_py)?;
        Ok(Self {
            src,
            reader,
            scanner,
            compressed,
        })
    }

    fn __getstate__(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
        Ok(py.None())
    }

    fn __getnewargs_ex__(&self, py: Python<'_>) -> PyResult<(Py<PyAny>, Py<PyAny>)> {
        let args = (self.src.clone_ref(py),);
        let kwargs = PyDict::new(py);
        kwargs.set_item("compressed", self.compressed)?;
        let model = self.scanner.model();
        kwargs.set_item("fields", model.field_names())?;
        if let Some(tag_defs) = model.tag_defs() {
            let tag_defs_raw = tag_defs
                .iter()
                .map(|def| def.to_tuple())
                .collect::<Vec<_>>();
            kwargs.set_item("tag_defs", tag_defs_raw)?;
        }
        Ok((args.into_py_any(py)?, kwargs.into_py_any(py)?))
    }

    /// Return the string representation of the alignment model.
    fn model(&self) -> String {
        self.scanner.model().to_string()
    }

    /// Return the names of the reference sequences.
    fn chrom_names(&self) -> Vec<String> {
        self.scanner.chrom_names()
    }

    /// Return the names of the reference sequences and their lengths in bp.
    fn chrom_sizes(&self) -> Vec<(String, u32)> {
        self.scanner.chrom_sizes()
    }

    /// Return the names of the standard SAM fields.
    fn field_names(&self) -> Vec<String> {
        self.scanner.field_names()
    }

    /// Discover tag definitions by sniffing `scan_rows` records.
    ///
    /// The reader stream is reset to its original position after scanning.
    ///
    /// Parameters
    /// ----------
    /// scan_rows : int, optional [default: 1024]
    ///    The number of records to scan. If None, all records are scanned.
    ///
    /// Returns
    /// -------
    /// list[tuple[str, str]]
    ///     A list of tag definitions, where each definition is a tuple of the
    ///     tag name and the SAM tag type code.
    #[pyo3(signature = (scan_rows=1024))]
    fn tag_defs(&mut self, scan_rows: Option<usize>) -> PyResult<Vec<(String, String)>> {
        let mut reader = self.reader.clone();
        match &mut reader {
            Reader::BgzfFile(bgzf_reader) => {
                let pos = bgzf_reader.virtual_position();
                let mut fmt_reader = noodles::sam::io::Reader::new(bgzf_reader);
                let defs = SamScanner::tag_defs(&mut fmt_reader, scan_rows).map_err(to_py)?;
                fmt_reader.into_inner().seek_to_virtual_position(pos)?;
                Ok(defs)
            }
            Reader::BgzfPyFileLike(bgzf_reader) => {
                let pos = bgzf_reader.virtual_position();
                let mut fmt_reader = noodles::sam::io::Reader::new(bgzf_reader);
                let defs = SamScanner::tag_defs(&mut fmt_reader, scan_rows).map_err(to_py)?;
                fmt_reader.into_inner().seek_to_virtual_position(pos)?;
                Ok(defs)
            }
            _ => {
                let pos = reader.stream_position()?;
                let mut fmt_reader = noodles::sam::io::Reader::new(reader);
                let defs = SamScanner::tag_defs(&mut fmt_reader, scan_rows).map_err(to_py)?;
                fmt_reader
                    .into_inner()
                    .seek(std::io::SeekFrom::Start(pos))?;
                Ok(defs)
            }
        }
    }

    /// Return the Arrow schema.
    ///
    /// Returns
    /// -------
    /// arro3 Schema (pycapsule)
    fn schema(&self) -> PySchema {
        PySchema::new(Arc::new(self.scanner.schema().clone()))
    }

    /// Scan batches of records from the file.
    ///
    /// Parameters
    /// ----------
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, records are scanned
    ///     until EOF.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (columns=None, batch_size=1024, limit=None))]
    fn scan(
        &mut self,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        let reader = self.reader.clone();
        let fmt_reader = noodles::sam::io::Reader::new(reader);
        let batch_reader = self
            .scanner
            .scan(fmt_reader, columns, batch_size, limit)
            .map_err(to_py)?;
        Ok(PyRecordBatchReader::new(err_on_unwind(batch_reader)))
    }

    /// Scan batches of records from specified byte ranges in the file.
    ///
    /// The byte positions must align with record boundaries.
    ///
    /// Parameters
    /// ----------
    /// byte_ranges : list[tuple[int, int]]
    ///     List of (start, end) byte position tuples to read from.
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, all records
    ///     in the specified ranges are scanned.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (byte_ranges, columns=None, batch_size=1024, limit=None))]
    fn scan_byte_ranges(
        &mut self,
        byte_ranges: Vec<(u64, u64)>,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        let reader = self.reader.clone();
        let fmt_reader = noodles::sam::io::Reader::new(reader);
        let batch_reader = self
            .scanner
            .scan_byte_ranges(fmt_reader, byte_ranges, columns, batch_size, limit)
            .map_err(to_py)?;
        Ok(PyRecordBatchReader::new(err_on_unwind(batch_reader)))
    }

    /// Scan batches of records from virtual position ranges in a BGZF file.
    ///
    /// The virtual positions must align with record boundaries. That means
    /// that the compressed offset must point to the beginning of a BGZF block
    /// and the uncompressed offset must point to the beginning or end of a
    /// record decoded within the block.
    ///
    /// Parameters
    /// ----------
    /// vpos_ranges : list[tuple[vpos, vpos]]
    ///     List of virtual position ranges as pairs. Each virtual position can
    ///     be given as either a packed virtual position (int), or an unpacked
    ///     tuple of ints ``(c, u)`` specifying the compressed and uncompressed
    ///     offsets, respectively.
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, all records
    ///     in the specified ranges are scanned.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (vpos_ranges, columns=None, batch_size=1024, limit=None))]
    fn scan_virtual_ranges(
        &mut self,
        vpos_ranges: Vec<(PyVirtualPosition, PyVirtualPosition)>,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        let vpos_ranges = vpos_ranges
            .into_iter()
            .map(|(start, end)| Ok((start.to_virtual_position()?, end.to_virtual_position()?)))
            .collect::<PyResult<Vec<_>>>()?;
        match self.reader.clone() {
            Reader::BgzfFile(bgzf_reader) => {
                let fmt_reader = noodles::sam::io::Reader::new(bgzf_reader);
                let batch_reader = self
                    .scanner
                    .scan_virtual_ranges(fmt_reader, vpos_ranges, columns, batch_size, limit)
                    .map_err(to_py)?;
                Ok(PyRecordBatchReader::new(err_on_unwind(batch_reader)))
            }
            Reader::BgzfPyFileLike(bgzf_reader) => {
                let fmt_reader = noodles::sam::io::Reader::new(bgzf_reader);
                let batch_reader = self
                    .scanner
                    .scan_virtual_ranges(fmt_reader, vpos_ranges, columns, batch_size, limit)
                    .map_err(to_py)?;
                Ok(PyRecordBatchReader::new(err_on_unwind(batch_reader)))
            }
            _ => Err(PyErr::new::<PyValueError, _>(
                "Scanning virtual position ranges is only supported for bgzf-compressed sources.",
            )),
        }
    }

    /// Scan batches of records from a genomic range query on a BGZF-encoded file.
    ///
    /// This operation requires an index file.
    ///
    /// Parameters
    /// ----------
    /// region : str
    ///     Genomic region in the format "chr:start-end".
    /// index : path or file-like, optional
    ///     The index file to use for querying the region. If None and the
    ///     source was provided as a path, we will attempt to load the index
    ///     from the same path with an additional extension.
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, all records
    ///     intersecting the query range are scanned.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (region, index=None, columns=None, batch_size=1024, limit=None))]
    fn scan_query(
        &mut self,
        py: Python,
        region: String,
        index: Option<Py<PyAny>>,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        let region = region
            .parse::<Region>()
            .map_err(|e| PyErr::new::<PyValueError, _>(e.to_string()))?;

        match self.reader.clone() {
            Reader::BgzfFile(bgzf_reader) => {
                let fmt_reader = noodles::sam::io::Reader::new(bgzf_reader);
                let index = resolve_index(py, &self.src, index)?;
                let py_batch_reader = match index {
                    IndexType::Linear(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_query(fmt_reader, region, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                    IndexType::Binned(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_query(fmt_reader, region, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                };
                Ok(py_batch_reader)
            }
            Reader::BgzfPyFileLike(bgzf_reader) => {
                let fmt_reader = noodles::sam::io::Reader::new(bgzf_reader);
                let index = resolve_index(py, &self.src, index)?;
                let py_batch_reader = match index {
                    IndexType::Linear(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_query(fmt_reader, region, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                    IndexType::Binned(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_query(fmt_reader, region, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                };
                Ok(py_batch_reader)
            }
            _ => Err(PyErr::new::<PyValueError, _>(
                "Scanning query ranges is only supported for bgzf-compressed sources.",
            )),
        }
    }

    /// Scan batches of records from the set of unaligned reads.
    ///
    /// This operation requires an index file.
    ///
    /// Parameters
    /// ----------
    /// index : path or file-like, optional
    ///     The index file to use for querying the region. If None and the
    ///     source was provided as a path, we will attempt to load the index
    ///     from the same path with an additional extension.
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, records are scanned
    ///     until EOF.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (index=None, columns=None, batch_size=1024, limit=None))]
    fn scan_unmapped(
        &mut self,
        py: Python,
        index: Option<Py<PyAny>>,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        match self.reader.clone() {
            Reader::BgzfFile(bgzf_reader) => {
                let fmt_reader = noodles::sam::io::Reader::new(bgzf_reader);
                let index = resolve_index(py, &self.src, index)?;
                let py_batch_reader = match index {
                    IndexType::Linear(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_unmapped(fmt_reader, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                    IndexType::Binned(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_unmapped(fmt_reader, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                };
                Ok(py_batch_reader)
            }
            Reader::BgzfPyFileLike(bgzf_reader) => {
                let fmt_reader = noodles::sam::io::Reader::new(bgzf_reader);
                let index = resolve_index(py, &self.src, index)?;
                let py_batch_reader = match index {
                    IndexType::Linear(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_unmapped(fmt_reader, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                    IndexType::Binned(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_unmapped(fmt_reader, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                };
                Ok(py_batch_reader)
            }
            _ => Err(PyErr::new::<PyValueError, _>(
                "Scanning unmapped reads is only supported for bgzf-compressed sources.",
            )),
        }
    }
}

/// A BAM file scanner.
///
/// Parameters
/// ----------
/// src : str or file-like
///     The path to the BAM file or a file-like object.
/// compressed : bool, optional [default: True]
///     Whether the source is BGZF-compressed.
/// fields : str or list[str] or None, optional [default: "*"]
///     Standard SAM fields to include. ``"*"`` for all, ``None`` to omit,
///     or a list of field names.
/// tag_defs : list[tuple[str, str]], optional [default: None]
///     Tag definitions for the ``"tags"`` struct column. ``None`` omits the
///     tags column. Use the ``tag_defs()`` method to discover definitions.
#[pyclass(module = "oxbow.oxbow")]
pub struct PyBamScanner {
    src: Py<PyAny>,
    reader: Reader,
    scanner: BamScanner,
    compressed: bool,
}

#[pymethods]
impl PyBamScanner {
    #[new]
    #[pyo3(signature = (src, compressed=true, fields=None, tag_defs=None))]
    fn new(
        py: Python,
        src: Py<PyAny>,
        compressed: bool,
        fields: Option<Py<PyAny>>,
        tag_defs: Option<Vec<(String, String)>>,
    ) -> PyResult<Self> {
        let fields = resolve_fields(fields, py)?;
        let reader = pyobject_to_bufreader(py, src.clone_ref(py), compressed)?;
        let mut fmt_reader = noodles::bam::io::Reader::from(reader);
        let header = fmt_reader.read_header()?;
        let reader = fmt_reader.into_inner();
        let scanner = BamScanner::new(header, fields, tag_defs).map_err(to_py)?;
        Ok(Self {
            src,
            reader,
            scanner,
            compressed,
        })
    }

    fn __getstate__(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
        Ok(py.None())
    }

    fn __getnewargs_ex__(&self, py: Python<'_>) -> PyResult<(Py<PyAny>, Py<PyAny>)> {
        let args = (self.src.clone_ref(py),);
        let kwargs = PyDict::new(py);
        kwargs.set_item("compressed", self.compressed)?;
        let model = self.scanner.model();
        kwargs.set_item("fields", model.field_names())?;
        if let Some(tag_defs) = model.tag_defs() {
            let tag_defs_raw = tag_defs
                .iter()
                .map(|def| def.to_tuple())
                .collect::<Vec<_>>();
            kwargs.set_item("tag_defs", tag_defs_raw)?;
        }
        Ok((args.into_py_any(py)?, kwargs.into_py_any(py)?))
    }

    /// Return the string representation of the alignment model.
    fn model(&self) -> String {
        self.scanner.model().to_string()
    }

    /// Return the names of the reference sequences.
    fn chrom_names(&self) -> Vec<String> {
        self.scanner.chrom_names()
    }

    /// Return the names of the reference sequences and their lengths in bp.
    fn chrom_sizes(&self) -> Vec<(String, u32)> {
        self.scanner.chrom_sizes()
    }

    /// Return the names of the standard SAM fields.
    fn field_names(&self) -> Vec<String> {
        self.scanner.field_names()
    }

    /// Return the Arrow schema.
    ///
    /// Returns
    /// -------
    /// arro3 Schema (pycapsule)
    fn schema(&self) -> PySchema {
        PySchema::new(Arc::new(self.scanner.schema().clone()))
    }

    /// Discover tag definitions by sniffing `scan_rows` records.
    ///
    /// The reader stream is reset to its original position after scanning.
    ///
    /// Parameters
    /// ----------
    /// scan_rows : int, optional [default: 1024]
    ///    The number of records to scan. If None, all records are scanned.
    ///
    /// Returns
    /// -------
    /// list[tuple[str, str]]
    ///     A list of tag definitions, where each definition is a tuple of the
    ///     tag name and the SAM tag type code.
    #[pyo3(signature = (scan_rows=1024))]
    fn tag_defs(&mut self, scan_rows: Option<usize>) -> PyResult<Vec<(String, String)>> {
        let mut reader = self.reader.clone();
        match &mut reader {
            Reader::BgzfFile(bgzf_reader) => {
                let pos = bgzf_reader.virtual_position();
                let mut fmt_reader = noodles::bam::io::Reader::from(bgzf_reader);
                let defs = BamScanner::tag_defs(&mut fmt_reader, scan_rows).map_err(to_py)?;
                fmt_reader.into_inner().seek_to_virtual_position(pos)?;
                Ok(defs)
            }
            Reader::BgzfPyFileLike(bgzf_reader) => {
                let pos = bgzf_reader.virtual_position();
                let mut fmt_reader = noodles::bam::io::Reader::from(bgzf_reader);
                let defs = BamScanner::tag_defs(&mut fmt_reader, scan_rows).map_err(to_py)?;
                fmt_reader.into_inner().seek_to_virtual_position(pos)?;
                Ok(defs)
            }
            _ => {
                let pos = reader.stream_position()?;
                let mut fmt_reader = noodles::bam::io::Reader::from(reader);
                let defs = BamScanner::tag_defs(&mut fmt_reader, scan_rows).map_err(to_py)?;
                fmt_reader
                    .into_inner()
                    .seek(std::io::SeekFrom::Start(pos))?;
                Ok(defs)
            }
        }
    }

    /// Scan batches of records from the file.
    ///
    /// Parameters
    /// ----------
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, records are scanned
    ///     until EOF.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (columns=None, batch_size=1024, limit=None))]
    fn scan(
        &mut self,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        let reader = self.reader.clone();
        let fmt_reader = noodles::bam::io::Reader::from(reader);
        let batch_reader = self
            .scanner
            .scan(fmt_reader, columns, batch_size, limit)
            .map_err(to_py)?;
        Ok(PyRecordBatchReader::new(err_on_unwind(batch_reader)))
    }

    /// Scan batches of records from specified byte ranges in the file.
    ///
    /// The byte positions must align with record boundaries.
    ///
    /// Parameters
    /// ----------
    /// byte_ranges : list[tuple[int, int]]
    ///     List of (start, end) byte position tuples to read from.
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, all records
    ///     in the specified ranges are scanned.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (byte_ranges, columns=None, batch_size=1024, limit=None))]
    fn scan_byte_ranges(
        &mut self,
        byte_ranges: Vec<(u64, u64)>,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        let reader = self.reader.clone();
        let fmt_reader = noodles::bam::io::Reader::from(reader);
        let batch_reader = self
            .scanner
            .scan_byte_ranges(fmt_reader, byte_ranges, columns, batch_size, limit)
            .map_err(to_py)?;
        Ok(PyRecordBatchReader::new(err_on_unwind(batch_reader)))
    }

    /// Scan batches of records from virtual position ranges in a BGZF file.
    ///
    /// The virtual positions must align with record boundaries. That means
    /// that the compressed offset must point to the beginning of a BGZF block
    /// and the uncompressed offset must point to the beginning or end of a
    /// record decoded within the block.
    ///
    /// Parameters
    /// ----------
    /// vpos_ranges : list[tuple[vpos, vpos]]
    ///     List of virtual position ranges as pairs. Each virtual position can
    ///     be given as either a packed virtual position (int), or an unpacked
    ///     tuple of ints ``(c, u)`` specifying the compressed and uncompressed
    ///     offsets, respectively.
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, all records
    ///     in the specified ranges are scanned.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (vpos_ranges, columns=None, batch_size=1024, limit=None))]
    fn scan_virtual_ranges(
        &mut self,
        vpos_ranges: Vec<(PyVirtualPosition, PyVirtualPosition)>,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        let vpos_ranges = vpos_ranges
            .into_iter()
            .map(|(start, end)| Ok((start.to_virtual_position()?, end.to_virtual_position()?)))
            .collect::<PyResult<Vec<_>>>()?;
        match self.reader.clone() {
            Reader::BgzfFile(bgzf_reader) => {
                let fmt_reader = noodles::bam::io::Reader::from(bgzf_reader);
                let batch_reader = self
                    .scanner
                    .scan_virtual_ranges(fmt_reader, vpos_ranges, columns, batch_size, limit)
                    .map_err(to_py)?;
                Ok(PyRecordBatchReader::new(err_on_unwind(batch_reader)))
            }
            Reader::BgzfPyFileLike(bgzf_reader) => {
                let fmt_reader = noodles::bam::io::Reader::from(bgzf_reader);
                let batch_reader = self
                    .scanner
                    .scan_virtual_ranges(fmt_reader, vpos_ranges, columns, batch_size, limit)
                    .map_err(to_py)?;
                Ok(PyRecordBatchReader::new(err_on_unwind(batch_reader)))
            }
            _ => Err(PyErr::new::<PyValueError, _>(
                "Scanning virtual position ranges is only supported for bgzf-compressed sources.",
            )),
        }
    }

    /// Scan batches of records from a genomic range query on a BGZF-encoded file.
    ///
    /// This operation requires an index file.
    ///
    /// Parameters
    /// ----------
    /// region : str
    ///     Genomic region in the format "chr:start-end".
    /// index : path or file-like, optional
    ///     The index file to use for querying the region. If None and the
    ///     source was provided as a path, we will attempt to load the index
    ///     from the same path with an additional extension.
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, all records
    ///     intersecting the query range are scanned.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (region, index=None, columns=None, batch_size=1024, limit=None))]
    fn scan_query(
        &mut self,
        py: Python,
        region: String,
        index: Option<Py<PyAny>>,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        let region = region
            .parse::<Region>()
            .map_err(|e| PyErr::new::<PyValueError, _>(e.to_string()))?;

        match self.reader.clone() {
            Reader::BgzfFile(bgzf_reader) => {
                let fmt_reader = noodles::bam::io::Reader::from(bgzf_reader);
                let index = resolve_index(py, &self.src, index)?;
                let py_batch_reader = match index {
                    IndexType::Linear(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_query(fmt_reader, region, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                    IndexType::Binned(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_query(fmt_reader, region, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                };
                Ok(py_batch_reader)
            }
            Reader::BgzfPyFileLike(bgzf_reader) => {
                let fmt_reader = noodles::bam::io::Reader::from(bgzf_reader);
                let index = resolve_index(py, &self.src, index)?;
                let py_batch_reader = match index {
                    IndexType::Linear(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_query(fmt_reader, region, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                    IndexType::Binned(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_query(fmt_reader, region, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                };
                Ok(py_batch_reader)
            }
            _ => Err(PyErr::new::<PyValueError, _>(
                "Scanning query ranges is only supported for indexed bgzf-compressed sources.",
            )),
        }
    }

    /// Scan batches of records from the set of unaligned reads.
    ///
    /// This operation requires an index file.
    ///
    /// Parameters
    /// ----------
    /// index : path or file-like, optional
    ///     The index file to use for querying the region. If None and the
    ///     source was provided as a path, we will attempt to load the index
    ///     from the same path with an additional extension.
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, records are scanned
    ///     until EOF.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (index=None, columns=None, batch_size=1024, limit=None))]
    fn scan_unmapped(
        &mut self,
        py: Python,
        index: Option<Py<PyAny>>,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        match self.reader.clone() {
            Reader::BgzfFile(bgzf_reader) => {
                let fmt_reader = noodles::bam::io::Reader::from(bgzf_reader);
                let index = resolve_index(py, &self.src, index)?;
                let py_batch_reader = match index {
                    IndexType::Linear(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_unmapped(fmt_reader, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                    IndexType::Binned(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_unmapped(fmt_reader, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                };
                Ok(py_batch_reader)
            }
            Reader::BgzfPyFileLike(bgzf_reader) => {
                let fmt_reader = noodles::bam::io::Reader::from(bgzf_reader);
                let index = resolve_index(py, &self.src, index)?;
                let py_batch_reader = match index {
                    IndexType::Linear(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_unmapped(fmt_reader, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                    IndexType::Binned(index) => {
                        let batch_reader = self
                            .scanner
                            .scan_unmapped(fmt_reader, index, columns, batch_size, limit)
                            .map_err(to_py)?;
                        PyRecordBatchReader::new(err_on_unwind(batch_reader))
                    }
                };
                Ok(py_batch_reader)
            }
            _ => Err(PyErr::new::<PyValueError, _>(
                "Scanning unmapped reads is only supported for indexed bgzf-compressed sources.",
            )),
        }
    }
}

/// A CRAM file scanner.
///
/// Parameters
/// ----------
/// src : str or file-like
///     The path to the CRAM file or a file-like object.
/// fields : str or list[str] or None, optional [default: "*"]
///     Standard SAM fields to include. ``"*"`` for all, ``None`` to omit,
///     or a list of field names.
/// tag_defs : list[tuple[str, str]], optional [default: None]
///     Tag definitions for the ``"tags"`` struct column. ``None`` omits the
///     tags column. Use the ``tag_defs()`` method to discover definitions.
#[pyclass]
pub struct PyCramScanner {
    src: Py<PyAny>,
    reader: Reader,
    scanner: CramScanner,
    reference: Option<Py<PyAny>>,
    reference_index: Option<Py<PyAny>>,
}

#[pymethods]
impl PyCramScanner {
    #[new]
    #[allow(unused_variables)]
    #[pyo3(signature = (src, compressed=None, fields=None, tag_defs=None, reference=None, reference_index=None))]
    fn new(
        py: Python,
        src: Py<PyAny>,
        compressed: Option<bool>,
        fields: Option<Py<PyAny>>,
        tag_defs: Option<Vec<(String, String)>>,
        reference: Option<Py<PyAny>>,
        reference_index: Option<Py<PyAny>>,
    ) -> PyResult<Self> {
        let fields = resolve_fields(fields, py)?;
        let reader = pyobject_to_bufreader(py, src.clone_ref(py), false)?;
        let mut fmt_reader = noodles::cram::io::Reader::new(reader);
        let header = fmt_reader.read_header()?;
        let reader = fmt_reader.into_inner();
        let repo = resolve_fasta_repository(
            py,
            reference.as_ref().map(|r| r.clone_ref(py)),
            reference_index.as_ref().map(|r| r.clone_ref(py)),
        )?;
        let scanner = CramScanner::new(header, fields, tag_defs, repo).map_err(to_py)?;
        Ok(Self {
            src,
            reader,
            scanner,
            reference,
            reference_index,
        })
    }

    fn __getstate__(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
        Ok(py.None())
    }

    fn __getnewargs_ex__(&self, py: Python<'_>) -> PyResult<(Py<PyAny>, Py<PyAny>)> {
        let args = (self.src.clone_ref(py),);
        let kwargs = PyDict::new(py);
        let model = self.scanner.model();
        kwargs.set_item("fields", model.field_names())?;
        if let Some(defs) = model.tag_defs() {
            let tag_defs: Vec<_> = defs.iter().map(|def| def.to_tuple()).collect();
            kwargs.set_item("tag_defs", tag_defs)?;
        }
        if let Some(ref reference) = self.reference {
            kwargs.set_item("reference", reference)?;
        }
        if let Some(ref reference_index) = self.reference_index {
            kwargs.set_item("reference_index", reference_index)?;
        }
        Ok((args.into_py_any(py)?, kwargs.into_py_any(py)?))
    }

    /// Return the string representation of the alignment model.
    fn model(&self) -> String {
        self.scanner.model().to_string()
    }

    /// Return the names of the reference sequences.
    fn chrom_names(&self) -> Vec<String> {
        self.scanner.chrom_names()
    }

    /// Return the names of the reference sequences and their lengths in bp.
    fn chrom_sizes(&self) -> Vec<(String, u32)> {
        self.scanner.chrom_sizes()
    }

    /// Return the names of the standard SAM fields.
    fn field_names(&self) -> Vec<String> {
        self.scanner.field_names()
    }

    /// Return the Arrow schema.
    ///
    /// Returns
    /// -------
    /// arro3 Schema (pycapsule)
    fn schema(&self) -> PySchema {
        PySchema::new(Arc::new(self.scanner.schema().clone()))
    }

    /// Discover tag definitions by sniffing `scan_rows` records.
    ///
    /// The reader stream is reset to its original position after scanning.
    ///
    /// Parameters
    /// ----------
    /// scan_rows : int, optional [default: 1024]
    ///    The number of records to scan.
    ///
    /// Returns
    /// -------
    /// list[tuple[str, str]]
    ///     A list of tag definitions, where each definition is a tuple of the
    ///     tag name and the SAM tag type code.
    #[pyo3(signature = (scan_rows=1024))]
    fn tag_defs(&mut self, scan_rows: Option<usize>) -> PyResult<Vec<(String, String)>> {
        let mut reader = self.reader.clone();
        match &mut reader {
            Reader::File(_) | Reader::PyFileLike(_) => {
                let pos = reader.stream_position()?;
                let header = self.scanner.header().clone();
                let mut fmt_reader = noodles::cram::io::reader::Builder::default()
                    .set_reference_sequence_repository(self.scanner.repo().clone())
                    .build_from_reader(reader);
                let defs =
                    CramScanner::tag_defs(&mut fmt_reader, &header, scan_rows).map_err(to_py)?;
                fmt_reader
                    .into_inner()
                    .seek(std::io::SeekFrom::Start(pos))?;
                Ok(defs)
            }
            _ => {
                unreachable!()
            }
        }
    }

    /// Scan batches of records from the file.
    ///
    /// Parameters
    /// ----------
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, records are scanned
    ///     until EOF.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (columns=None, batch_size=1024, limit=None))]
    fn scan(
        &mut self,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        let reader = self.reader.clone();
        let fmt_reader = noodles::cram::io::Reader::new(reader);
        let batch_reader = self
            .scanner
            .scan(fmt_reader, columns, batch_size, limit)
            .map_err(to_py)?;
        Ok(PyRecordBatchReader::new(err_on_unwind(batch_reader)))
    }

    /// Scan batches of records from a genomic range.
    ///
    /// This operation requires an index file.
    ///
    /// Parameters
    /// ----------
    /// region : str
    ///     Genomic region in the format "chr:start-end".
    /// index : path or file-like, optional
    ///     The index file to use for querying the region. If None and the
    ///     source was provided as a path, we will attempt to load the index
    ///     from the same path with an additional extension.
    /// columns : list[str], optional
    ///     Names of the top-level columns to project.
    /// batch_size : int, optional [default: 1024]
    ///     The number of records to include in each batch.
    /// limit : int, optional
    ///     The maximum number of records to scan. If None, all records
    ///     intersecting the query range are scanned.
    ///
    /// Returns
    /// -------
    /// arro3 RecordBatchReader (pycapsule)
    ///     An iterator yielding Arrow record batches.
    #[pyo3(signature = (region, index=None, columns=None, batch_size=1024, limit=None))]
    fn scan_query(
        &mut self,
        py: Python,
        region: String,
        index: Option<Py<PyAny>>,
        columns: Option<Vec<String>>,
        batch_size: Option<usize>,
        limit: Option<usize>,
    ) -> PyResult<PyRecordBatchReader> {
        let region = region
            .parse::<Region>()
            .map_err(|e| PyErr::new::<PyValueError, _>(e.to_string()))?;
        let index = resolve_cram_index(py, &self.src, index)?;

        match self.reader.clone() {
            Reader::File(reader) => {
                let fmt_reader = noodles::cram::io::Reader::new(reader);
                let batch_reader = self
                    .scanner
                    .scan_query(fmt_reader, region, index, columns, batch_size, limit)
                    .map_err(to_py)?;
                Ok(PyRecordBatchReader::new(err_on_unwind(batch_reader)))
            }
            Reader::PyFileLike(reader) => {
                let fmt_reader = noodles::cram::io::Reader::new(reader);
                let batch_reader = self
                    .scanner
                    .scan_query(fmt_reader, region, index, columns, batch_size, limit)
                    .map_err(to_py)?;
                Ok(PyRecordBatchReader::new(err_on_unwind(batch_reader)))
            }
            _ => {
                unreachable!()
            }
        }
    }
}

/// Return Arrow IPC format from a SAM file.
///
/// Parameters
/// ----------
/// src : str or file-like
///     The path to the source file or a file-like object.
/// fields : str or list[str] or None, optional
///     Standard SAM fields to project.
/// tag_defs : list[tuple[str, str]], optional
///    Tag definitions. None means no tags column.
/// compressed : bool, optional [default: False]
///     Whether the source is BGZF-compressed.
///
/// Returns
/// -------
/// bytes
///     Arrow IPC
#[pyfunction]
#[pyo3(signature = (src, region=None, index=None, fields=None, tag_defs=None, compressed=false))]
pub fn read_sam(
    py: Python,
    src: Py<PyAny>,
    region: Option<String>,
    index: Option<Py<PyAny>>,
    fields: Option<Py<PyAny>>,
    tag_defs: Option<Vec<(String, String)>>,
    compressed: bool,
) -> PyResult<Vec<u8>> {
    let fields = resolve_fields(fields, py)?;
    let reader = pyobject_to_bufreader(py, src.clone_ref(py), compressed)?;
    let mut fmt_reader = noodles::sam::io::Reader::new(reader);
    let header = fmt_reader.read_header()?;
    let scanner = SamScanner::new(header, fields, tag_defs).map_err(to_py)?;
    let reader = fmt_reader.into_inner();

    let ipc = if let Some(region) = region {
        let region = region
            .parse::<Region>()
            .map_err(|e| PyErr::new::<PyValueError, _>(e.to_string()))?;

        match reader {
            Reader::BgzfFile(bgzf_reader) => {
                let fmt_reader = noodles::sam::io::Reader::new(bgzf_reader);
                let index = resolve_index(py, &src, index)?;
                let batches = scanner
                    .scan_query(fmt_reader, region, index.into_boxed(), None, None, None)
                    .map_err(to_py)?;
                batches_to_ipc(batches)
            }
            Reader::BgzfPyFileLike(bgzf_reader) => {
                let fmt_reader = noodles::sam::io::Reader::new(bgzf_reader);
                let index = resolve_index(py, &src, index)?;
                let batches = scanner
                    .scan_query(fmt_reader, region, index.into_boxed(), None, None, None)
                    .map_err(to_py)?;
                batches_to_ipc(batches)
            }
            _ => {
                return Err(PyErr::new::<PyValueError, _>(
                    "Scanning query ranges is only supported for indexed BGZF-encoded sources.",
                ));
            }
        }
    } else {
        let fmt_reader = noodles::sam::io::Reader::new(reader);
        let batches = scanner.scan(fmt_reader, None, None, None).map_err(to_py)?;
        batches_to_ipc(batches)
    };

    ipc.map_err(|e| PyErr::new::<PyValueError, _>(e.to_string()))
}

/// Return Arrow IPC format from a BAM file.
///
/// Parameters
/// ----------
/// src : str or file-like
///     The path to the source file or a file-like object.
/// fields : str or list[str] or None, optional
///     Standard SAM fields to project.
/// tag_defs : list[tuple[str, str]], optional
///    Tag definitions. None means no tags column.
/// compressed : bool, optional [default: True]
///     Whether the source is BGZF-compressed.
///
/// Returns
/// -------
/// bytes
///     Arrow IPC
#[pyfunction]
#[pyo3(signature = (src, region=None, index=None, fields=None, tag_defs=None, compressed=true))]
pub fn read_bam(
    py: Python,
    src: Py<PyAny>,
    region: Option<String>,
    index: Option<Py<PyAny>>,
    fields: Option<Py<PyAny>>,
    tag_defs: Option<Vec<(String, String)>>,
    compressed: bool,
) -> PyResult<Vec<u8>> {
    let fields = resolve_fields(fields, py)?;
    let reader = pyobject_to_bufreader(py, src.clone_ref(py), compressed)?;
    let mut fmt_reader = noodles::bam::io::Reader::from(reader);
    let header = fmt_reader.read_header()?;
    let scanner = BamScanner::new(header, fields, tag_defs).map_err(to_py)?;
    let reader = fmt_reader.into_inner();

    let ipc = if let Some(region) = region {
        let region = region
            .parse::<Region>()
            .map_err(|e| PyErr::new::<PyValueError, _>(e.to_string()))?;

        match reader {
            Reader::BgzfFile(bgzf_reader) => {
                let fmt_reader = noodles::bam::io::Reader::from(bgzf_reader);
                let index = resolve_index(py, &src, index)?;
                let batches = scanner
                    .scan_query(fmt_reader, region, index.into_boxed(), None, None, None)
                    .map_err(to_py)?;
                batches_to_ipc(batches)
            }
            Reader::BgzfPyFileLike(bgzf_reader) => {
                let fmt_reader = noodles::bam::io::Reader::from(bgzf_reader);
                let index = resolve_index(py, &src, index)?;
                let batches = scanner
                    .scan_query(fmt_reader, region, index.into_boxed(), None, None, None)
                    .map_err(to_py)?;
                batches_to_ipc(batches)
            }
            _ => {
                return Err(PyErr::new::<PyValueError, _>(
                    "Scanning query ranges is only supported for indexed BGZF-encoded sources.",
                ));
            }
        }
    } else {
        let fmt_reader = noodles::bam::io::Reader::from(reader);
        let batches = scanner.scan(fmt_reader, None, None, None).map_err(to_py)?;
        batches_to_ipc(batches)
    };

    ipc.map_err(|e| PyErr::new::<PyValueError, _>(e.to_string()))
}

/// Return Arrow IPC format from a CRAM file.
///
/// Parameters
/// ----------
/// src : str or file-like
///     The path to the source file or a file-like object.
/// fields : str or list[str] or None, optional
///     Standard SAM fields to project.
/// tag_defs : list[tuple[str, str]], optional
///    Tag definitions. None means no tags column.
///
/// Returns
/// -------
/// bytes
///     Arrow IPC
#[pyfunction]
#[pyo3(signature = (src, region=None, index=None, reference=None, reference_index=None, fields=None, tag_defs=None))]
#[allow(clippy::too_many_arguments)]
pub fn read_cram(
    py: Python,
    src: Py<PyAny>,
    region: Option<String>,
    index: Option<Py<PyAny>>,
    reference: Option<Py<PyAny>>,
    reference_index: Option<Py<PyAny>>,
    fields: Option<Py<PyAny>>,
    tag_defs: Option<Vec<(String, String)>>,
) -> PyResult<Vec<u8>> {
    let fields = resolve_fields(fields, py)?;
    let reader = pyobject_to_bufreader(py, src.clone_ref(py), false)?;
    let mut fmt_reader = noodles::cram::io::Reader::new(reader);
    let header = fmt_reader.read_header()?;
    let repo = resolve_fasta_repository(py, reference, reference_index)?;
    let scanner = CramScanner::new(header, fields, tag_defs, repo).map_err(to_py)?;
    let reader = fmt_reader.into_inner();

    let ipc = if let Some(region) = region {
        let region = region
            .parse::<Region>()
            .map_err(|e| PyErr::new::<PyValueError, _>(e.to_string()))?;

        match reader {
            Reader::File(reader) => {
                let fmt_reader = noodles::cram::io::Reader::new(reader);
                let index = resolve_cram_index(py, &src, index)?;
                let batches = scanner
                    .scan_query(fmt_reader, region, index, None, None, None)
                    .map_err(to_py)?;
                batches_to_ipc(batches)
            }
            Reader::PyFileLike(reader) => {
                let fmt_reader = noodles::cram::io::Reader::new(reader);
                let index = resolve_cram_index(py, &src, index)?;
                let batches = scanner
                    .scan_query(fmt_reader, region, index, None, None, None)
                    .map_err(to_py)?;
                batches_to_ipc(batches)
            }
            _ => {
                unreachable!()
            }
        }
    } else {
        match reader {
            Reader::File(reader) => {
                let fmt_reader = noodles::cram::io::Reader::new(reader);
                let batches = scanner.scan(fmt_reader, None, None, None).map_err(to_py)?;
                batches_to_ipc(batches)
            }
            Reader::PyFileLike(reader) => {
                let fmt_reader = noodles::cram::io::Reader::new(reader);
                let batches = scanner.scan(fmt_reader, None, None, None).map_err(to_py)?;
                batches_to_ipc(batches)
            }
            _ => {
                unreachable!()
            }
        }
    };

    ipc.map_err(|e| PyErr::new::<PyValueError, _>(e.to_string()))
}
