// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements.  See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership.  The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License.  You may obtain a copy of the License at
//
//   http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.

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

use arrow_array::{ArrayRef, RecordBatch, RecordBatchOptions};
use arrow_schema::{DataType, Field, Schema};

use crate::bucket_reader::{read_typed_value, read_variable_value, BucketReader, ColumnPageReader};
use crate::schema::MosaicSchema;
use crate::spec::*;
use crate::stats::{self, ColumnStats};
use crate::types;
use crate::values::Value;
use crate::varint;

const COALESCE_GAP: u64 = 1024 * 1024;
const COALESCE_MAX_RANGE: u64 = 32 * 1024 * 1024;

#[derive(Clone)]
pub struct ReadRangeBuffer {
    data: Arc<Vec<u8>>,
    start: usize,
    len: usize,
}

impl ReadRangeBuffer {
    pub fn new(data: Arc<Vec<u8>>, start: usize, len: usize) -> io::Result<Self> {
        if start.checked_add(len).is_none_or(|end| end > data.len()) {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "read range buffer bounds exceed backing data",
            ));
        }

        Ok(Self { data, start, len })
    }

    pub fn as_slice(&self) -> &[u8] {
        &self.data[self.start..self.start + self.len]
    }

    pub fn len(&self) -> usize {
        self.len
    }

    pub fn is_empty(&self) -> bool {
        self.len == 0
    }
}

/// A random-access file abstraction for reading Mosaic files.
///
/// The `Sync` bound is required because the reader may call `read_at` from
/// multiple threads in parallel (e.g. when coalescing IO ranges).
/// Implementations must ensure that concurrent `read_at` calls are safe.
pub trait InputFile: Sync {
    /// Read `buf.len()` bytes starting at `offset`.
    ///
    /// # Thread safety
    /// This method must be safe to call concurrently from multiple threads.
    fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>;

    fn read_ranges_shared(&self, ranges: &[(u64, usize)]) -> io::Result<Vec<ReadRangeBuffer>> {
        let (merged, fetched) = read_merged_ranges(self, ranges)?;
        let fetched: Vec<Arc<Vec<u8>>> = fetched.into_iter().map(Arc::new).collect();

        // Distribute views back to original order
        let mut results: Vec<Option<ReadRangeBuffer>> = Vec::with_capacity(ranges.len());
        results.resize_with(ranges.len(), || None);
        for (mi, mr) in merged.iter().enumerate() {
            let data = fetched[mi].clone();
            for &idx in &mr.members {
                let (offset, len) = ranges[idx];
                let rel_start = (offset - mr.start) as usize;
                results[idx] = Some(ReadRangeBuffer::new(data.clone(), rel_start, len)?);
            }
        }

        results
            .into_iter()
            .collect::<Option<Vec<_>>>()
            .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing range buffer"))
    }

    fn read_ranges(&self, ranges: &[(u64, usize)]) -> io::Result<Vec<Vec<u8>>> {
        let (merged, fetched) = read_merged_ranges(self, ranges)?;

        // Distribute slices back to original order
        let mut results: Vec<Vec<u8>> = Vec::with_capacity(ranges.len());
        results.resize_with(ranges.len(), Vec::new);
        for (mi, mr) in merged.iter().enumerate() {
            let buf = &fetched[mi];
            for &idx in &mr.members {
                let (offset, len) = ranges[idx];
                let rel_start = (offset - mr.start) as usize;
                results[idx] = buf[rel_start..rel_start + len].to_vec();
            }
        }

        Ok(results)
    }
}

struct MergedRange {
    start: u64,
    end: u64,
    members: Vec<usize>,
}

fn read_merged_ranges<I: InputFile + ?Sized>(
    input: &I,
    ranges: &[(u64, usize)],
) -> io::Result<(Vec<MergedRange>, Vec<Vec<u8>>)> {
    if ranges.is_empty() {
        return Ok((Vec::new(), Vec::new()));
    }

    let mut indices: Vec<usize> = (0..ranges.len()).collect();
    indices.sort_unstable_by_key(|&i| ranges[i].0);

    let mut merged: Vec<MergedRange> = Vec::new();
    for &idx in &indices {
        let (offset, len) = ranges[idx];
        let range_end = offset + len as u64;

        let should_merge = if let Some(last) = merged.last() {
            offset >= last.start
                && offset.saturating_sub(last.end) <= COALESCE_GAP
                && (range_end - last.start) <= COALESCE_MAX_RANGE
        } else {
            false
        };

        if should_merge {
            let last = merged.last_mut().unwrap();
            last.end = last.end.max(range_end);
            last.members.push(idx);
        } else {
            merged.push(MergedRange {
                start: offset,
                end: range_end,
                members: vec![idx],
            });
        }
    }

    let fetched: Vec<io::Result<Vec<u8>>> = std::thread::scope(|s| {
        let handles: Vec<_> = merged
            .iter()
            .map(|mr| {
                s.spawn(|| {
                    let len = (mr.end - mr.start) as usize;
                    let mut buf = vec![0u8; len];
                    input.read_at(mr.start, &mut buf)?;
                    Ok(buf)
                })
            })
            .collect();
        handles.into_iter().map(|h| h.join().unwrap()).collect()
    });
    let fetched = fetched.into_iter().collect::<io::Result<Vec<_>>>()?;

    Ok((merged, fetched))
}

pub struct RowGroupMeta {
    pub num_rows: usize,
    pub bucket_offsets: Vec<u64>,
    pub bucket_layouts: Vec<BucketLayout>,
    pub stats: Vec<ColumnStats>,
}

pub trait ReaderAccess {
    fn schema(&self) -> &MosaicSchema;
    fn num_row_groups(&self) -> usize;
    fn row_group_reader(&self, rg_index: usize) -> io::Result<RowGroupReader>;
    fn row_group_reader_projected(
        &self,
        rg_index: usize,
        columns: &[usize],
    ) -> io::Result<RowGroupReader>;
    fn row_group_reader_by_names(
        &self,
        rg_index: usize,
        column_names: &[&str],
    ) -> io::Result<RowGroupReader> {
        let schema = self.schema();
        let mut indices = Vec::with_capacity(column_names.len());
        for name in column_names {
            let idx = schema
                .columns
                .iter()
                .position(|c| c.name == *name)
                .ok_or_else(|| {
                    io::Error::new(
                        io::ErrorKind::InvalidInput,
                        format!("column '{}' not found in schema", name),
                    )
                })?;
            indices.push(idx);
        }
        self.row_group_reader_projected(rg_index, &indices)
    }
    fn project(&mut self, column_names: &[&str]) -> io::Result<()>;
    fn row_group_stats(&self, rg_index: usize) -> io::Result<&[ColumnStats]>;
    fn row_group_num_rows(&self, rg_index: usize) -> io::Result<usize>;
}

pub struct MosaicReader<I: InputFile> {
    input: I,
    schema: MosaicSchema,
    row_group_metas: Vec<RowGroupMeta>,
    compression: u8,
    num_buckets: usize,
    projected_columns: Option<Vec<usize>>,
}

fn read_range(input: &dyn InputFile, offset: u64, len: usize) -> io::Result<Vec<u8>> {
    let mut buf = vec![0u8; len];
    input.read_at(offset, &mut buf)?;
    Ok(buf)
}

const TAIL_PREFETCH_SIZE: u64 = 64 * 1024;

impl<I: InputFile> MosaicReader<I> {
    pub fn new(input: I, file_len: u64) -> io::Result<Self> {
        if (file_len as usize) < FOOTER_SIZE {
            return Err(io::Error::new(io::ErrorKind::InvalidData, "file too small"));
        }

        // Read a tail chunk that likely covers all metadata in one IO
        let tail_size = file_len.min(TAIL_PREFETCH_SIZE) as usize;
        let tail_offset = file_len - tail_size as u64;
        let tail = read_range(&input, tail_offset, tail_size)?;

        let footer = &tail[tail_size - FOOTER_SIZE..];

        if footer[28] != MAGIC[0]
            || footer[29] != MAGIC[1]
            || footer[30] != MAGIC[2]
            || footer[31] != MAGIC[3]
        {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                "bad magic bytes",
            ));
        }

        let version = footer[25];
        if version != VERSION {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                format!("unsupported version: {}", version),
            ));
        }

        let index_offset = u64::from_be_bytes(footer[0..8].try_into().unwrap());
        let schema_block_offset = u64::from_be_bytes(footer[8..16].try_into().unwrap());
        let num_buckets = u32::from_be_bytes(footer[16..20].try_into().unwrap()) as usize;
        let num_row_groups = u32::from_be_bytes(footer[20..24].try_into().unwrap()) as usize;
        let compression = footer[24];

        let schema_data_start = schema_block_offset.checked_add(4).ok_or_else(|| {
            io::Error::new(io::ErrorKind::InvalidData, "corrupted footer offsets")
        })?;
        let footer_start = file_len - FOOTER_SIZE as u64;
        if !(schema_data_start <= index_offset
            && index_offset <= footer_start
            && footer_start <= file_len)
        {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                "corrupted footer offsets",
            ));
        }

        // All metadata starts at schema_block_offset. Check if our tail covers it.
        let meta_buf = if schema_block_offset >= tail_offset {
            // Tail covers all metadata — zero additional IO
            let local_start = (schema_block_offset - tail_offset) as usize;
            let local_end = tail_size - FOOTER_SIZE;
            tail[local_start..local_end].to_vec()
        } else {
            // Metadata is larger than our tail prefetch — one more IO
            let meta_len = (footer_start - schema_block_offset) as usize;
            read_range(&input, schema_block_offset, meta_len)?
        };

        // Parse schema block from meta_buf
        let schema_uncompressed_size =
            u32::from_be_bytes(meta_buf[0..4].try_into().unwrap()) as usize;
        let schema_compressed_len = (index_offset - schema_block_offset - 4) as usize;
        let schema_compressed = &meta_buf[4..4 + schema_compressed_len];

        let schema_raw = match compression {
            COMPRESSION_NONE => schema_compressed.to_vec(),
            COMPRESSION_ZSTD => zstd::bulk::decompress(schema_compressed, schema_uncompressed_size)
                .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
            _ => {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidData,
                    format!("unsupported compression: {}", compression),
                ))
            }
        };

        let schema = MosaicSchema::deserialize(&schema_raw)?;

        if schema.num_buckets != num_buckets {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                "footer num_buckets does not match schema",
            ));
        }

        if num_buckets == 0 {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                "num_buckets must be > 0",
            ));
        }

        // Parse row group index from meta_buf
        let index_local_start = (index_offset - schema_block_offset) as usize;
        let index_data = &meta_buf[index_local_start..];
        let mut pos = 0usize;
        let mut row_group_metas = Vec::with_capacity(num_row_groups);

        for _ in 0..num_row_groups {
            let num_rows = varint::decode(index_data, &mut pos)? as usize;
            let non_empty = varint::decode(index_data, &mut pos)? as usize;

            if non_empty > num_buckets {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidData,
                    "non_empty count exceeds num_buckets",
                ));
            }

            let mut bucket_offsets = vec![0u64; num_buckets];
            let mut bucket_layouts = vec![BucketLayout::Empty; num_buckets];
            let mut seen_buckets = vec![false; num_buckets];

            for _ in 0..non_empty {
                let bucket_id = varint::decode(index_data, &mut pos)? as usize;
                if bucket_id >= num_buckets || pos + 8 > index_data.len() {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "corrupted row group index",
                    ));
                }
                if seen_buckets[bucket_id] {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "duplicate bucket_id in row group index",
                    ));
                }
                seen_buckets[bucket_id] = true;
                bucket_offsets[bucket_id] =
                    u64::from_be_bytes(index_data[pos..pos + 8].try_into().unwrap());
                pos += 8;
                let compressed_size = varint::decode(index_data, &mut pos)? as usize;
                let bulk_decompress_size = varint::decode(index_data, &mut pos)? as usize;
                bucket_layouts[bucket_id] =
                    BucketLayout::decode(compressed_size, bulk_decompress_size)
                        .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

                let end = bucket_offsets[bucket_id]
                    .checked_add(compressed_size as u64)
                    .ok_or_else(|| {
                        io::Error::new(io::ErrorKind::InvalidData, "bucket offset overflow")
                    })?;
                if end > schema_block_offset {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "bucket data extends past schema block",
                    ));
                }
            }

            let rg_stats =
                stats::deserialize_stats(index_data, &mut pos, &schema.columns, num_rows)?;

            row_group_metas.push(RowGroupMeta {
                num_rows,
                bucket_offsets,
                bucket_layouts,
                stats: rg_stats,
            });
        }

        if pos != index_data.len() {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                "trailing bytes in row group index",
            ));
        }

        Ok(MosaicReader {
            input,
            schema,
            row_group_metas,
            compression,
            num_buckets,
            projected_columns: None,
        })
    }

    pub fn project(&mut self, column_names: &[&str]) -> io::Result<()> {
        let mut indices = Vec::with_capacity(column_names.len());
        for name in column_names {
            let idx = self
                .schema
                .columns
                .iter()
                .position(|c| c.name == *name)
                .ok_or_else(|| {
                    io::Error::new(
                        io::ErrorKind::InvalidInput,
                        format!("column '{}' not found in schema", name),
                    )
                })?;
            indices.push(idx);
        }
        self.projected_columns = Some(indices);
        Ok(())
    }

    pub fn input(&self) -> &I {
        &self.input
    }

    fn parse_column_slot(
        slot_data: &[u8],
        col_type: &DataType,
        num_rows: usize,
    ) -> io::Result<ColumnPageReader> {
        let mut spos = 0usize;
        let uncompressed_size = varint::decode(slot_data, &mut spos)? as usize;
        let compressed_data = &slot_data[spos..];
        let page_content = zstd::bulk::decompress(compressed_data, uncompressed_size)
            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;

        if page_content.len() < 2 {
            return Err(io::Error::new(
                io::ErrorKind::InvalidData,
                "paged bucket: page_content too short",
            ));
        }
        let encoding = page_content[0];
        let flags = page_content[1];
        let has_nulls = (flags & 1) != 0;
        let mut ppos = 2usize;

        let mut const_value = Value::Null;
        if encoding == ENCODING_CONST {
            let w = types::fixed_width(col_type);
            if w > 0 {
                if ppos + w as usize > page_content.len() {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        "paged bucket: page_content truncated at const value",
                    ));
                }
                const_value = read_typed_value(col_type, &page_content, ppos, w);
                ppos += w as usize;
            } else {
                let (value, size) = read_variable_value(col_type, &page_content, ppos)?;
                const_value = value;
                ppos += size;
            }
        }

        ColumnPageReader::new_with_page_data_start(
            col_type.clone(),
            encoding,
            has_nulls,
            const_value,
            page_content,
            ppos,
            num_rows,
        )
    }
}

impl<I: InputFile> ReaderAccess for MosaicReader<I> {
    fn schema(&self) -> &MosaicSchema {
        &self.schema
    }

    fn num_row_groups(&self) -> usize {
        self.row_group_metas.len()
    }

    fn row_group_stats(&self, rg_index: usize) -> io::Result<&[ColumnStats]> {
        if rg_index >= self.row_group_metas.len() {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                format!(
                    "row group index {} out of range (num_row_groups={})",
                    rg_index,
                    self.row_group_metas.len()
                ),
            ));
        }
        Ok(&self.row_group_metas[rg_index].stats)
    }

    fn row_group_num_rows(&self, rg_index: usize) -> io::Result<usize> {
        if rg_index >= self.row_group_metas.len() {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                format!(
                    "row group index {} out of range (num_row_groups={})",
                    rg_index,
                    self.row_group_metas.len()
                ),
            ));
        }
        Ok(self.row_group_metas[rg_index].num_rows)
    }

    fn row_group_reader(&self, rg_index: usize) -> io::Result<RowGroupReader> {
        match &self.projected_columns {
            Some(cols) => self.row_group_reader_projected(rg_index, cols),
            None => self.row_group_reader_projected(rg_index, &self.schema.original_order),
        }
    }

    #[allow(clippy::needless_range_loop)]
    fn row_group_reader_projected(
        &self,
        rg_index: usize,
        columns: &[usize],
    ) -> io::Result<RowGroupReader> {
        if rg_index >= self.row_group_metas.len() {
            return Err(io::Error::new(
                io::ErrorKind::InvalidInput,
                "row group index out of range",
            ));
        }

        let meta = &self.row_group_metas[rg_index];
        let num_cols = self.schema.columns.len();

        let mut projected = vec![false; num_cols];
        for &c in columns {
            if c >= num_cols {
                return Err(io::Error::new(
                    io::ErrorKind::InvalidInput,
                    format!(
                        "projected column index {} out of range (num_columns={})",
                        c, num_cols
                    ),
                ));
            }
            projected[c] = true;
        }

        let mut needed_buckets = vec![false; self.num_buckets];
        let mut all_projected_in_bucket = vec![false; self.num_buckets];
        for b in 0..self.num_buckets {
            let mut any = false;
            let mut all = true;
            for &gi in &self.schema.bucket_to_global[b] {
                if projected[gi] {
                    any = true;
                } else {
                    all = false;
                }
            }
            needed_buckets[b] = any;
            all_projected_in_bucket[b] = any && all;
        }

        // Classify buckets and collect Round 1 ranges:
        // - Monolithic buckets: read entire compressed blob
        // - Paged buckets with all columns projected: read entire bucket (skip round 2)
        // - Paged buckets with partial projection: read directory only (round 2 fetches slots)
        let mut bucket_kinds = Vec::with_capacity(self.num_buckets);
        let mut r1_ranges: Vec<(u64, usize)> = Vec::new();
        let mut r1_bucket_ids: Vec<usize> = Vec::new();

        for b in 0..self.num_buckets {
            let layout = if needed_buckets[b] {
                meta.bucket_layouts[b]
            } else {
                BucketLayout::Empty
            };
            match layout {
                BucketLayout::Empty => {
                    bucket_kinds.push(BucketLayout::Empty);
                }
                BucketLayout::Paged { total_size } => {
                    if self.compression != COMPRESSION_ZSTD {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            "paged bucket requires ZSTD compression",
                        ));
                    }
                    let dir_size = self.schema.bucket_to_global[b].len() * 4;
                    if dir_size > total_size {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            format!(
                                "paged bucket {}: directory size {} exceeds total size {}",
                                b, dir_size, total_size
                            ),
                        ));
                    }
                    if all_projected_in_bucket[b] {
                        r1_ranges.push((meta.bucket_offsets[b], total_size));
                    } else {
                        r1_ranges.push((meta.bucket_offsets[b], dir_size));
                    }
                    r1_bucket_ids.push(b);
                    bucket_kinds.push(layout);
                }
                BucketLayout::Monolithic {
                    compressed_size, ..
                } => {
                    r1_ranges.push((meta.bucket_offsets[b], compressed_size));
                    r1_bucket_ids.push(b);
                    bucket_kinds.push(layout);
                }
            }
        }

        // Round 1: batch read all directories + monolithic blobs
        let r1_buffers = self.input.read_ranges_shared(&r1_ranges)?;

        // Process Round 1 results, build Round 2 ranges for paged bucket slots
        let mut bucket_states: Vec<Option<BucketState>> =
            (0..self.num_buckets).map(|_| None).collect();
        let mut r2_ranges: Vec<(u64, usize)> = Vec::new();
        // Track which paged bucket each merged range group belongs to,
        // and which columns within that bucket
        struct PagedSlotInfo {
            bucket_id: usize,
            col_idx: usize,
        }
        let mut r2_group_infos: Vec<Vec<PagedSlotInfo>> = Vec::new();

        // Per-bucket directory parse results (slot_sizes, slot_file_offsets) for paged buckets
        let mut paged_dir_info: Vec<Option<(Vec<usize>, Vec<u64>)>> = vec![None; self.num_buckets];
        let mut partial_paged_buckets: Vec<usize> = Vec::new();

        for (ri, &b) in r1_bucket_ids.iter().enumerate() {
            let buf = r1_buffers[ri].as_slice();
            match bucket_kinds[b] {
                BucketLayout::Monolithic {
                    uncompressed_size, ..
                } => {
                    let global_indices = &self.schema.bucket_to_global[b];
                    let bucket_data = match self.compression {
                        COMPRESSION_NONE => buf.to_vec(),
                        COMPRESSION_ZSTD => zstd::bulk::decompress(buf, uncompressed_size)
                            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?,
                        _ => {
                            return Err(io::Error::new(
                                io::ErrorKind::InvalidData,
                                "unsupported compression",
                            ))
                        }
                    };
                    let col_types: Vec<DataType> = global_indices
                        .iter()
                        .map(|&gi| self.schema.columns[gi].data_type.clone())
                        .collect();
                    let reader =
                        Box::new(BucketReader::new(col_types, bucket_data, meta.num_rows)?);
                    bucket_states[b] = Some(BucketState::Monolithic { reader });
                }
                BucketLayout::Paged { total_size } => {
                    let global_indices = &self.schema.bucket_to_global[b];
                    let num_columns = global_indices.len();

                    // Parse directory
                    let mut slot_sizes = Vec::with_capacity(num_columns);
                    for i in 0..num_columns {
                        let off = i * 4;
                        let size =
                            u32::from_le_bytes(buf[off..off + 4].try_into().unwrap()) as usize;
                        slot_sizes.push(size);
                    }

                    // Validate: directory + slots must exactly equal total_size
                    let dir_size = num_columns * 4;
                    let slot_total: usize = slot_sizes.iter().sum();
                    if dir_size + slot_total != total_size {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            format!(
                                "paged bucket {}: directory ({}) + slots ({}) != total size ({})",
                                b, dir_size, slot_total, total_size
                            ),
                        ));
                    }

                    if all_projected_in_bucket[b] {
                        // All columns projected — we already read the full bucket in round 1,
                        // parse all slots directly without a second read_ranges call.
                        let mut column_readers: Vec<Option<ColumnPageReader>> =
                            Vec::with_capacity(num_columns);
                        let mut data_offset = dir_size;
                        for i in 0..num_columns {
                            let gi = global_indices[i];
                            let col_type = self.schema.columns[gi].data_type.clone();

                            if slot_sizes[i] == 0 {
                                column_readers.push(Some(ColumnPageReader::new(
                                    col_type,
                                    ENCODING_ALL_NULL,
                                    false,
                                    Value::Null,
                                    Vec::new(),
                                    meta.num_rows,
                                )?));
                            } else {
                                let slot_data = &buf[data_offset..data_offset + slot_sizes[i]];
                                let column_reader =
                                    Self::parse_column_slot(slot_data, &col_type, meta.num_rows)?;
                                column_readers.push(Some(column_reader));
                            }
                            data_offset += slot_sizes[i];
                        }
                        bucket_states[b] = Some(BucketState::Paged { column_readers });
                    } else {
                        // Partial projection — only directory was read in round 1,
                        // collect ranges for round 2.
                        let bucket_offset = meta.bucket_offsets[b];
                        let mut slot_file_offsets = Vec::with_capacity(num_columns);
                        let mut foff = bucket_offset + dir_size as u64;
                        for &size in &slot_sizes {
                            slot_file_offsets.push(foff);
                            foff += size as u64;
                        }

                        let mut projected_cols: Vec<usize> = Vec::new();
                        for i in 0..num_columns {
                            let gi = global_indices[i];
                            if projected[gi] && slot_sizes[i] > 0 {
                                projected_cols.push(i);
                            }
                        }

                        for &col_idx in &projected_cols {
                            let col_offset = slot_file_offsets[col_idx];
                            let col_size = slot_sizes[col_idx];

                            if let Some(last_range) = r2_ranges.last_mut() {
                                let last_end = last_range.0 + last_range.1 as u64;
                                if col_offset == last_end {
                                    last_range.1 += col_size;
                                    r2_group_infos.last_mut().unwrap().push(PagedSlotInfo {
                                        bucket_id: b,
                                        col_idx,
                                    });
                                    continue;
                                }
                            }
                            r2_ranges.push((col_offset, col_size));
                            r2_group_infos.push(vec![PagedSlotInfo {
                                bucket_id: b,
                                col_idx,
                            }]);
                        }

                        paged_dir_info[b] = Some((slot_sizes, slot_file_offsets));
                        partial_paged_buckets.push(b);
                    }
                }
                BucketLayout::Empty => {}
            }
        }

        // Round 2: batch read all paged column slots.
        if !partial_paged_buckets.is_empty() {
            let r2_buffers = if r2_ranges.is_empty() {
                Vec::new()
            } else {
                self.input.read_ranges_shared(&r2_ranges)?
            };

            struct SlotLocation {
                group_idx: usize,
                start: usize,
                len: usize,
            }

            let mut slot_locations: Vec<Vec<Option<SlotLocation>>> =
                Vec::with_capacity(self.num_buckets);
            for b in 0..self.num_buckets {
                let n = self.schema.bucket_to_global[b].len();
                slot_locations.push((0..n).map(|_| None).collect());
            }

            for (group_idx, group) in r2_group_infos.iter().enumerate() {
                let buf = r2_buffers[group_idx].as_slice();
                let group_base = r2_ranges[group_idx].0;
                for info in group {
                    let (slot_sizes, slot_file_offsets) =
                        paged_dir_info[info.bucket_id].as_ref().unwrap();
                    let rel_start = (slot_file_offsets[info.col_idx] - group_base) as usize;
                    let slot_len = slot_sizes[info.col_idx];
                    let Some(rel_end) = rel_start.checked_add(slot_len) else {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            "paged bucket: slot range overflows read buffer",
                        ));
                    };
                    if rel_end > buf.len() {
                        return Err(io::Error::new(
                            io::ErrorKind::InvalidData,
                            "paged bucket: slot range exceeds read buffer",
                        ));
                    }
                    slot_locations[info.bucket_id][info.col_idx] = Some(SlotLocation {
                        group_idx,
                        start: rel_start,
                        len: slot_len,
                    });
                }
            }

            // Build ColumnPageReaders for partial paged buckets. ALL_NULL slots do not
            // need round 2 IO, but still need readers when projected.
            for &b in &partial_paged_buckets {
                let global_indices = &self.schema.bucket_to_global[b];
                let num_columns = global_indices.len();
                let (slot_sizes, _) = paged_dir_info[b].as_ref().unwrap();

                let mut column_readers: Vec<Option<ColumnPageReader>> =
                    Vec::with_capacity(num_columns);
                for i in 0..num_columns {
                    let gi = global_indices[i];
                    if !projected[gi] {
                        column_readers.push(None);
                        continue;
                    }

                    let col_type = self.schema.columns[gi].data_type.clone();

                    if slot_sizes[i] == 0 {
                        column_readers.push(Some(ColumnPageReader::new(
                            col_type,
                            ENCODING_ALL_NULL,
                            false,
                            Value::Null,
                            Vec::new(),
                            meta.num_rows,
                        )?));
                        continue;
                    }

                    let location = slot_locations[b][i].as_ref().ok_or_else(|| {
                        io::Error::new(
                            io::ErrorKind::InvalidData,
                            "paged bucket: missing projected slot data",
                        )
                    })?;
                    let group_buffer = r2_buffers[location.group_idx].as_slice();
                    let slot_data = &group_buffer[location.start..location.start + location.len];
                    let column_reader =
                        Self::parse_column_slot(slot_data, &col_type, meta.num_rows)?;
                    column_readers.push(Some(column_reader));
                }
                bucket_states[b] = Some(BucketState::Paged { column_readers });
            }
        }

        Ok(RowGroupReader::new(
            bucket_states,
            self.schema.bucket_to_global.clone(),
            self.schema.clone(),
            num_cols,
            meta.num_rows,
            projected,
            columns.to_vec(),
        ))
    }

    fn project(&mut self, column_names: &[&str]) -> io::Result<()> {
        let mut indices = Vec::with_capacity(column_names.len());
        for name in column_names {
            let idx = self
                .schema
                .columns
                .iter()
                .position(|c| c.name == *name)
                .ok_or_else(|| {
                    io::Error::new(
                        io::ErrorKind::InvalidInput,
                        format!("column '{}' not found in schema", name),
                    )
                })?;
            indices.push(idx);
        }
        self.projected_columns = Some(indices);
        Ok(())
    }
}

enum BucketState {
    Monolithic {
        reader: Box<BucketReader>,
    },
    Paged {
        column_readers: Vec<Option<ColumnPageReader>>,
    },
}

pub struct RowGroupReader {
    bucket_states: Vec<Option<BucketState>>,
    bucket_to_global: Vec<Vec<usize>>,
    active_buckets: Vec<usize>,
    schema: MosaicSchema,
    num_rows: usize,
    num_columns: usize,
    projected_columns: Vec<bool>,
    output_order: Vec<usize>,
}

impl RowGroupReader {
    fn new(
        bucket_states: Vec<Option<BucketState>>,
        bucket_to_global: Vec<Vec<usize>>,
        schema: MosaicSchema,
        num_columns: usize,
        num_rows: usize,
        projected_columns: Vec<bool>,
        output_order: Vec<usize>,
    ) -> Self {
        let active_buckets: Vec<usize> = bucket_states
            .iter()
            .enumerate()
            .filter_map(|(i, s)| if s.is_some() { Some(i) } else { None })
            .collect();
        RowGroupReader {
            bucket_states,
            bucket_to_global,
            active_buckets,
            schema,
            num_rows,
            num_columns,
            projected_columns,
            output_order,
        }
    }

    pub fn num_rows(&self) -> usize {
        self.num_rows
    }

    pub fn read_columns(&mut self) -> io::Result<RecordBatch> {
        let num_cols = self.num_columns;
        let mut arrays: Vec<Option<ArrayRef>> = vec![None; num_cols];

        for &bucket_id in &self.active_buckets {
            let global_indices = &self.bucket_to_global[bucket_id];
            let state = self.bucket_states[bucket_id].as_ref().unwrap();

            match state {
                BucketState::Paged { column_readers } => {
                    for (local_idx, &global_idx) in global_indices.iter().enumerate() {
                        if !self.projected_columns[global_idx] {
                            continue;
                        }
                        if let Some(ref cr) = column_readers[local_idx] {
                            arrays[global_idx] = Some(cr.read_all()?);
                        }
                    }
                }
                BucketState::Monolithic { reader } => {
                    let columns = reader.read_all_columns()?;
                    for (local_idx, &global_idx) in global_indices.iter().enumerate() {
                        if !self.projected_columns[global_idx] {
                            continue;
                        }
                        if local_idx < columns.len() {
                            arrays[global_idx] = Some(columns[local_idx].clone());
                        }
                    }
                }
            }
        }

        let mut fields = Vec::new();
        let mut batch_arrays = Vec::new();
        for &i in &self.output_order {
            if let Some(arr) = arrays[i].take() {
                let col_meta = &self.schema.columns[i];
                fields.push(Field::new(
                    &col_meta.name,
                    col_meta.data_type.clone(),
                    col_meta.nullable,
                ));
                batch_arrays.push(arr);
            }
        }

        let arrow_schema = std::sync::Arc::new(Schema::new(fields));
        let batch = if batch_arrays.is_empty() {
            RecordBatch::try_new_with_options(
                arrow_schema,
                batch_arrays,
                &RecordBatchOptions::new().with_row_count(Some(self.num_rows)),
            )
        } else {
            RecordBatch::try_new(arrow_schema, batch_arrays)
        };
        batch.map_err(|e| io::Error::other(e.to_string()))
    }
}

#[cfg(test)]
#[path = "reader_tests.rs"]
mod tests;
