// Copyright 2023-Present Jonas Kruckenberg
//
// Licensed under the Apache License, Version 2.0, <LICENSE-APACHE or
// http://apache.org/licenses/LICENSE-2.0> or the MIT license <LICENSE-MIT or
// http://opensource.org/licenses/MIT>, at your option. This file may not be
// copied, modified, or distributed except according to those terms.

//! Code for laying out an image on disk

use std::borrow::Cow;
use std::collections::VecDeque;
use std::io::{self, SeekFrom};
use std::mem::size_of;

use zerocopy::byteorder::{BigEndian, U16, U32};
use zerocopy::{ByteOrder, FromZeros, IntoBytes, LittleEndian};

use super::directory::{Directory, File};
use crate::build::boot::BootEntry;
use crate::build::directory::Entry;
use crate::build::{BootConfig, FileSource};
use crate::eltorito::{InitialEntry, SectionEntry, SectionHeaderEntry, ValidationEntry};
use crate::raw;
use crate::raw::{
    BothEndianU16, BothEndianU32, DirDateTime, DirectoryRecordHeader, FileFlags,
    PathTableRecordHeader, RootDirectoryRecord, SECTOR_SIZE, VIRTUAL_SECTOR_SIZE,
    VolumeDescriptorHeader,
};

pub(super) struct Layout<'a> {
    pub(super) dirs: Vec<DirNode<'a>>,
    pub(super) files: Vec<FileNode<'a>>,
    pub(super) path_table_size: u32,
    pub(super) path_table_l_lba: u32,
    pub(super) path_table_m_lba: u32,
    pub(super) boot_catalog_lba: Option<u32>,
    pub(super) total_sectors: u32,
}

impl<'a> Layout<'a> {
    pub(super) fn flatten(root: Directory<'a>) -> Self {
        let mut dirs = Vec::new();
        let mut files: Vec<FileNode<'a>> = Vec::new();
        let mut path_table_size = 0;

        // Queue holds (parent_index_in_dirs, directory_to_process).
        let mut queue: VecDeque<(u16, Directory<'a>)> = VecDeque::new();

        // NB: indices are 0-based in memory; the +1 to match the ECMA-119
        // 1-based path table numbering is applied at serialize time only.
        // Root's parent is itself (the wire value 1 becomes 0 + 1).
        let dir = DirNode::new(Cow::Borrowed("\0"), 0, root.entries.len());
        path_table_size += dir.required_path_table_record_size();
        dirs.push(dir);
        queue.push_back((0, root));

        while let Some((parent_idx, dir)) = queue.pop_front() {
            for (name, entry) in dir.entries {
                match entry {
                    Entry::Subdir(subdir) => {
                        let child_idx = u16::try_from(dirs.len()).expect("too many directories");

                        let entries_hint = subdir.entries.len();
                        let child = DirNode::new(name.clone(), parent_idx, entries_hint);

                        // update the required path table size to hold this directory
                        path_table_size += child.required_path_table_record_size();
                        // update our parent so it knows where this directory is
                        dirs[parent_idx as usize].attach_child_directory(name, child_idx);
                        // and push it to the flattened list
                        dirs.push(child);

                        // also push it to the queue so we continue to process this directories
                        // children
                        queue.push_back((child_idx, subdir));
                    }
                    Entry::File(File { source }) => {
                        let file_idx = u32::try_from(files.len()).expect("too many files in image");
                        files.push(FileNode::new(name.clone(), source));
                        dirs[parent_idx as usize].attach_file(name, file_idx);
                    }
                }
            }
        }

        // `sorted_entry_ids` must be sorted according to ECMA-119 §6.8.1.1.
        // We rely on the BTreeMap to automatically do this, but its easy to mess this
        // up
        #[cfg(debug_assertions)]
        for d in &dirs {
            debug_assert!(
                d.sorted_entry_ids.windows(2).all(|w| w[0].0 <= w[1].0),
                "DirNode::sorted_entry_ids must be sorted by identifier (dir={:?})",
                d.identifier,
            );
        }

        Self {
            dirs,
            files,
            path_table_size,
            path_table_l_lba: 0,
            path_table_m_lba: 0,
            boot_catalog_lba: None,
            total_sectors: 0,
        }
    }

    pub(super) fn assign_lbas(
        &mut self,
        boot_config: Option<&mut BootConfig<'_>>,
    ) -> io::Result<()> {
        // On-disk layout:
        //
        //   sectors 0..16   system area (zeroed)
        //   16              PVD
        //   17              Boot Record VD (if boot)
        //   17 or 18        VD Set Terminator
        //   ...             boot catalog (if boot)
        //   ...             Path Table L, then Path Table M
        //   ...             every directory extent, in BFS order
        //   ...             every file's data, in BFS order
        let mut lba: u32 = 16 + 1 + 1; // system area + PVD + terminator
        if let Some(ref boot_config) = boot_config {
            lba += 1; // Boot Record VD

            self.boot_catalog_lba = Some(lba);
            lba += sectors_for(u32::try_from(boot_config.required_size()).unwrap());
        }

        // account for both the L and M path tables
        self.path_table_l_lba = lba;
        lba += sectors_for(self.path_table_size);
        self.path_table_m_lba = lba;
        lba += sectors_for(self.path_table_size);

        // every directory extent, back to back
        for dir in &mut self.dirs {
            let extent_len = dir.required_extent_size();
            dir.extent_lba = lba;
            dir.extent_len = extent_len;
            lba += sectors_for(extent_len);
        }

        // Boot images go last in the file area, in catalog order. UEFI
        // §13.3.2.1 reads an EFI no-emulation entry with `sector_count` < 2
        // as "extends to end of CD", so the boot image must physically end
        // the volume; we make that invariant unconditional.
        let boot_image_indices = match boot_config.as_deref() {
            Some(bc) => self.collect_boot_image_indices(bc)?,
            None => Vec::new(),
        };

        // Non-boot files first, back to back. Zero-byte files get LBA 0
        // (libisofs convention) and consume no sectors.
        for (i, file) in self.files.iter_mut().enumerate() {
            if boot_image_indices.contains(&i) {
                continue;
            }
            if file.len == 0 {
                file.lba = 0;
                continue;
            }
            file.lba = lba;
            lba += sectors_for(file.len as u32);
        }

        // Boot images last, in the order they were collected.
        for &i in &boot_image_indices {
            let file = &mut self.files[i];
            if file.len == 0 {
                file.lba = 0;
                continue;
            }
            file.lba = lba;
            lba += sectors_for(file.len);
        }

        self.total_sectors = lba;

        // Now that all file LBAs are assigned, resolve each boot entry's path
        // to the LBA + virtual-sector size of the corresponding file.
        if let Some(boot_config) = boot_config {
            let resolve = |entry: &mut BootEntry<'_>| -> io::Result<()> {
                let idx = self.find_file_index(entry.boot_image_path).ok_or_else(|| {
                    io::Error::new(
                        io::ErrorKind::InvalidInput,
                        format!(
                            "boot image path {:?} not found in directory tree",
                            entry.boot_image_path
                        ),
                    )
                })?;
                let boot_image = &self.files[idx];

                entry.boot_image_lba = boot_image.lba;

                // Auto-compute sector_count (in 512-byte virtual sectors).
                // Callers whose image exceeds u16::MAX can opt into the
                // UEFI §13.3.2.1 "extends to end-of-CD" rule by passing
                // `set_load_size(0)` — boot images are placed last in the
                // file area precisely so that's safe.
                if entry.load_size.is_none() {
                    let virtual_sectors = boot_image.len.div_ceil(VIRTUAL_SECTOR_SIZE);
                    entry.boot_image_sector_count =
                        u16::try_from(virtual_sectors).map_err(|_| {
                            io::Error::new(
                                io::ErrorKind::InvalidInput,
                                format!(
                                    "boot image {:?} requires {} virtual sectors (> u16::MAX); set BootEntry::load_size to override",
                                    entry.boot_image_path, virtual_sectors,
                                ),
                            )
                        })?;
                }

                Ok(())
            };

            resolve(&mut boot_config.default_entry)?;
            for section in &mut boot_config.sections {
                for entry in &mut section.entries {
                    resolve(entry)?;
                }
            }
        }

        Ok(())
    }

    /// Resolve every boot entry's path to a file index in `self.files`,
    /// preserving catalog order (default entry first, then sections in
    /// declaration order). Duplicate paths collapse so the file is only
    /// placed once.
    fn collect_boot_image_indices(&self, bc: &BootConfig<'_>) -> io::Result<Vec<usize>> {
        let paths = std::iter::once(bc.default_entry.boot_image_path).chain(
            bc.sections
                .iter()
                .flat_map(|s| s.entries.iter().map(|e| e.boot_image_path)),
        );

        let mut indices = Vec::new();
        for path in paths {
            let idx = self.find_file_index(path).ok_or_else(|| {
                io::Error::new(
                    io::ErrorKind::InvalidInput,
                    format!("boot image path {path:?} not found in directory tree"),
                )
            })?;
            if !indices.contains(&idx) {
                indices.push(idx);
            }
        }
        Ok(indices)
    }

    pub(crate) fn serialize(
        self,
        mut w: impl io::Write + io::Seek,
        mut pvd: raw::PrimaryVolumeDescriptor,
        boot_config: Option<&BootConfig>,
    ) -> io::Result<()> {
        // Patch the PVD with the layout-computed fields that can only be
        // known after assign_lbas has run.
        let root = &self.dirs[0];
        let mut root_record = RootDirectoryRecord::new_zeroed();
        root_record.header = build_dir_record_header(root.extent_lba, root.extent_len, true, 1);

        pvd.logical_block_size = BothEndianU16::new(u16::try_from(SECTOR_SIZE).unwrap());
        pvd.volume_set_size = BothEndianU16::new(1);
        pvd.volume_sequence_number = BothEndianU16::new(1);
        pvd.file_structure_version = 1;
        pvd.path_table_size = BothEndianU32::new(self.path_table_size);
        pvd.path_table_l_lba = U32::new(self.path_table_l_lba);
        pvd.path_table_m_lba = U32::new(self.path_table_m_lba);
        pvd.volume_space_size = BothEndianU32::new(self.total_sectors);
        pvd.root_directory_record = root_record;

        // On-disk layout:
        //
        //   sectors 0..16   system area (zeroed)
        //   16              PVD
        //   17              Boot Record VD  (only if boot)
        //   17 or 18        VD Set Terminator
        //   ...             boot catalog    (only if boot)
        //   ...             Path Table L, then Path Table M
        //   ...             every directory extent, in BFS order
        //   ...             every file's data, in BFS order

        // system area: sectors 0–15 are skipped (left zeroed by the writer)
        #[expect(
            clippy::cast_possible_wrap,
            reason = "2048 * 16 trivially fits into i64"
        )]
        w.seek_relative(16 * SECTOR_SIZE as i64)?;

        // sector 16: Primary Volume Descriptor
        write_descriptor(&mut w, 1, &pvd)?;

        // sector 17: Boot Record VD (El Torito), then VD Set Terminator at 18.
        // Without boot the terminator sits at 17.
        if let Some(boot_config) = boot_config {
            let boot_catalog_lba = self.boot_catalog_lba.expect("boot_catalog_lba must be set");
            let mut boot_record = raw::BootRecord::new_zeroed();
            boot_record.boot_system_id.0[..23].copy_from_slice(b"EL TORITO SPECIFICATION");
            boot_record.boot_catalog = U32::new(boot_catalog_lba);
            write_descriptor(&mut w, 0, &boot_record)?;

            write_vd_terminator(&mut w)?;

            // Boot catalog
            w.seek(SeekFrom::Start(
                u64::from(boot_catalog_lba) * SECTOR_SIZE as u64,
            ))?;
            write_boot_catalog(&mut w, boot_config)?;
        } else {
            write_vd_terminator(&mut w)?;
        }

        // Path Table L (little-endian)
        w.seek(SeekFrom::Start(
            u64::from(self.path_table_l_lba) * SECTOR_SIZE as u64,
        ))?;
        write_path_table::<LittleEndian>(&mut w, &self.dirs)?;
        pad_to_sector(&mut w)?;

        // Path Table M (big-endian)
        w.seek(SeekFrom::Start(
            u64::from(self.path_table_m_lba) * SECTOR_SIZE as u64,
        ))?;
        write_path_table::<BigEndian>(&mut w, &self.dirs)?;
        pad_to_sector(&mut w)?;

        // Directory extents, in BFS order (which is the order flatten appended them)
        for i in 0..self.dirs.len() {
            serialize_dir_extent(&mut w, &self.dirs[i], &self.dirs, &self.files)?;
        }

        // File data, in BFS order. Consuming self.dirs lets us move FileSource out.
        for file in self.files.into_iter() {
            if file.len == 0 {
                continue; // zero-byte files occupy no sectors (LBA stays 0)
            }
            w.seek(SeekFrom::Start(u64::from(file.lba) * SECTOR_SIZE as u64))?;
            match file.source {
                FileSource::InMemory { bytes, .. } => w.write_all(bytes.as_bytes())?,
                FileSource::OnDisk { mut reader, .. } => {
                    io::copy(&mut reader, &mut w)?;
                }
            }
            pad_to_sector(&mut w)?;
        }

        Ok(())
    }

    /// Walk the directory tree to find a file by its `/`-separated path and
    /// return its index into `self.files`. Returns `None` if the path does
    /// not exist. A leading `/` is accepted and ignored (both `"EFI/BOOT/FILE"`
    /// and `"/EFI/BOOT/FILE"` resolve identically).
    fn find_file_index(&self, path: &str) -> Option<usize> {
        let mut parts = path.trim_start_matches('/').split('/').peekable();
        let mut dir_idx = 0usize; // start at root

        loop {
            let component = parts.next()?;

            if parts.peek().is_none() {
                // last component — must be a file in the current directory
                let pos = self.dirs[dir_idx]
                    .sorted_entry_ids
                    .binary_search_by_key(&component, |(id, _)| id.as_ref())
                    .ok()?;

                let (_, DirNodeEntry::File(file_idx)) = &self.dirs[dir_idx].sorted_entry_ids[pos]
                else {
                    return None; // path component exists but is a directory, not a file
                };

                return Some(*file_idx as usize);
            }

            // directory component — binary-search sorted_entry_ids and descend
            let entries = &self.dirs[dir_idx].sorted_entry_ids;
            let pos = entries
                .binary_search_by_key(&component, |(id, _)| id.as_ref())
                .ok()?;
            let child_idx = match entries[pos].1 {
                DirNodeEntry::ChildDirectory(idx) => idx as usize,
                DirNodeEntry::File(_) => return None, // path component is a file, not a dir
            };
            dir_idx = child_idx;
        }
    }
}

/// Intermediate representation of a directory for layout purposes.
/// We create this from the hierarchical tree the caller gave us and pass this
/// through the layout and serialization stages.
#[derive(Debug)]
pub(super) struct DirNode<'a> {
    pub(super) extent_lba: u32, // 0 until LBA assignment pass
    pub(super) extent_len: u32,

    pub(super) identifier: Cow<'a, str>, /* used to construct the PathTableRecord and
                                          * DirectoryRecord */
    pub(super) parent_directory: u16, // used to construct the PathTableRecord

    /// Identifiers of `children` and `files` merged and sorted by name.
    /// Built once during flatten so `required_extent_size` doesn't have
    /// to re-sort on every call.
    sorted_entry_ids: Vec<(Cow<'a, str>, DirNodeEntry)>,
}

#[derive(Debug)]
enum DirNodeEntry {
    // this is a direct index into the flat list of directories
    ChildDirectory(u16),
    // index into Layout::files
    File(u32),
}

/// Intermediate representation of a file for layout purposes.
/// We create this from the hierarchical tree the caller gave us and pass this
/// through the layout and serialization stages.
#[derive(Debug)]
pub(super) struct FileNode<'a> {
    pub(super) lba: u32, // 0 until LBA pass
    pub(super) len: u32,
    pub(super) identifier: Cow<'a, str>,
    pub(super) source: FileSource<'a>,
}

impl<'a> FileNode<'a> {
    fn new(identifier: Cow<'a, str>, source: FileSource<'a>) -> Self {
        Self {
            lba: 0,
            len: source.len(),
            identifier,
            source,
        }
    }
}

impl<'a> DirNode<'a> {
    fn new(identifier: Cow<'a, str>, parent_directory: u16, children_size_hint: usize) -> Self {
        Self {
            extent_lba: 0,
            extent_len: 0,
            identifier,
            parent_directory,
            sorted_entry_ids: Vec::with_capacity(children_size_hint),
        }
    }

    fn attach_child_directory(&mut self, name: Cow<'a, str>, child_idx: u16) {
        self.sorted_entry_ids
            .push((name, DirNodeEntry::ChildDirectory(child_idx)));
    }

    fn attach_file(&mut self, name: Cow<'a, str>, file_idx: u32) {
        self.sorted_entry_ids
            .push((name, DirNodeEntry::File(file_idx)));
    }

    fn required_path_table_record_size(&self) -> u32 {
        let header_size =
            u32::try_from(size_of::<raw::PathTableRecordHeader<LittleEndian>>()).unwrap();
        let id_len = u32::try_from(self.identifier.len()).unwrap();

        // round up so the size is always even
        header_size + id_len + (id_len & 1)
    }

    fn files<'l>(&self, all_files: &'l [FileNode<'a>]) -> impl Iterator<Item = &'l FileNode<'a>> {
        self.sorted_entry_ids.iter().filter_map(|(_, e)| {
            if let DirNodeEntry::File(idx) = e {
                Some(&all_files[*idx as usize])
            } else {
                None
            }
        })
    }

    fn directories<'l>(
        &self,
        all_dirs: &'l [DirNode<'a>],
    ) -> impl Iterator<Item = &'l DirNode<'a>> {
        self.sorted_entry_ids.iter().filter_map(|(_, e)| {
            if let DirNodeEntry::ChildDirectory(idx) = e {
                Some(&all_dirs[*idx as usize])
            } else {
                None
            }
        })
    }

    fn required_extent_size(&self) -> u32 {
        let mut offset_in_sector = 0;
        let mut total = 0;

        let mut add_record = |id: &str| {
            let record_size = dir_record_size(u8::try_from(id.len()).unwrap());

            // no record may straddle a logical block boundary.
            // so we pad out to the end of the sector and continue
            if offset_in_sector as usize + record_size as usize > SECTOR_SIZE {
                total += u32::try_from(SECTOR_SIZE).unwrap() - offset_in_sector; // pad rest of sector
                offset_in_sector = 0;
            }

            total += u32::from(record_size);
            offset_in_sector =
                (offset_in_sector + u32::from(record_size)) % u32::try_from(SECTOR_SIZE).unwrap();
        };

        // account for the `.` and `..`  special directories
        add_record("\x00");
        add_record("\x01");

        // ECMA-119 §6.8.1.1: subdirectory and file records share one
        // sequence sorted by file identifier within the dir extent.
        for (id, _) in &self.sorted_entry_ids {
            add_record(id);
        }

        total.next_multiple_of(u32::try_from(SECTOR_SIZE).unwrap())
    }
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

static ZEROS: [u8; SECTOR_SIZE] = [0u8; SECTOR_SIZE];

/// Converts a byte count to the number of whole sectors needed to hold it.
#[inline]
fn sectors_for(bytes: u32) -> u32 {
    bytes.div_ceil(u32::try_from(SECTOR_SIZE).unwrap())
}

/// Writes the three-field `VolumeDescriptorHeader` (type + "CD001" + version).
fn write_descriptor_header(w: &mut impl io::Write, ty: u8) -> io::Result<()> {
    VolumeDescriptorHeader {
        volume_descriptor_ty: ty,
        standard_id: *b"CD001",
        volume_descriptor_version: 1,
    }
    .write_to_io(w)
}

/// Writes a `VolumeDescriptorHeader` followed by the descriptor body.
/// Together they must fill exactly one sector; the const assertions on each
/// descriptor type in `raw.rs` guarantee this.
fn write_descriptor<T: IntoBytes + zerocopy::Immutable>(
    w: &mut impl io::Write,
    ty: u8,
    body: &T,
) -> io::Result<()> {
    write_descriptor_header(w, ty)?;
    body.write_to_io(&mut *w)
}

/// Writes a Volume Descriptor Set Terminator (type 255): a header followed by
/// zeros to fill the rest of the sector.
fn write_vd_terminator(w: &mut impl io::Write) -> io::Result<()> {
    write_descriptor_header(w, 255)?;
    w.write_all(&ZEROS[..SECTOR_SIZE - size_of::<VolumeDescriptorHeader>()])
}

/// Zero-fills the writer up to the next sector boundary.
fn pad_to_sector(w: &mut (impl io::Write + io::Seek)) -> io::Result<()> {
    let pos = usize::try_from(w.stream_position()?).unwrap();
    let rem = pos % SECTOR_SIZE;
    if rem != 0 {
        w.write_all(&ZEROS[..SECTOR_SIZE - rem])?;
    }
    Ok(())
}

#[inline]
fn dir_record_size(id_len: u8) -> u8 {
    // DirectoryRecordHeader is 33 bytes (odd). A 1-byte pad is added when
    // id_len is even so the total record length is always even.
    let pad: u8 = 1 - (id_len & 1);
    u8::try_from(size_of::<DirectoryRecordHeader>()).unwrap() + id_len + pad
}

/// Builds a `DirectoryRecordHeader` for a file or subdirectory record.
///
/// `id_len` is the byte length of the file identifier that immediately follows
/// the header on disk. The record length (`len` field) is computed to always
/// be even, matching the calculation in `DirNode::required_extent_size`.
fn build_dir_record_header(
    extent_lba: u32,
    data_length: u32,
    is_dir: bool,
    id_len: u8,
) -> DirectoryRecordHeader {
    let len = dir_record_size(id_len);

    DirectoryRecordHeader {
        len,
        extended_attribute_record_len: 0,
        extent_lba: BothEndianU32::new(extent_lba),
        data_length: BothEndianU32::new(data_length),
        recording_date: DirDateTime {
            year: 0,
            month: 0,
            day: 0,
            hour: 0,
            minute: 0,
            second: 0,
            timezone_offset: 0,
        },
        flags: if is_dir {
            FileFlags::DIRECTORY.bits()
        } else {
            0
        },
        interleaved_file_unit_size: 0,
        interleaved_gap_size: 0,
        volume_sequence_number: BothEndianU16::new(1),
        file_identifier_len: id_len,
    }
}

/// Writes a single path table (L or M) for all directories in BFS order.
///
/// Caller is responsible for seeking to the correct LBA and for calling
/// `pad_to_sector` afterwards.
fn write_path_table<O>(w: &mut impl io::Write, dirs: &[DirNode<'_>]) -> io::Result<()>
where
    O: ByteOrder,
    PathTableRecordHeader<O>: IntoBytes + zerocopy::Immutable,
{
    for dir in dirs {
        let id_bytes = dir.identifier.as_bytes();
        let id_len = u8::try_from(id_bytes.len()).unwrap();

        // ECMA-119 1-based parent index: our in-memory indices are 0-based,
        // so root's parent wire value becomes 0 + 1 = 1 (pointing at itself).
        let header = PathTableRecordHeader::<O> {
            len: id_len,
            extended_attribute_record_len: 0,
            extent_lba: U32::<O>::new(dir.extent_lba),
            parent_directory: U16::<O>::new(dir.parent_directory + 1),
        };
        header.write_to_io(&mut *w)?;
        w.write_all(id_bytes)?;
        if id_len % 2 != 0 {
            w.write_all(&[0u8])?; // pad to even record length
        }
    }
    Ok(())
}

/// Writes a single directory record into an already-open writer positioned
/// anywhere within the directory extent.
///
/// `offset_in_sector` tracks the byte offset within the current sector so
/// that sector-boundary straddling can be detected and prevented with padding.
fn write_dir_record(
    w: &mut impl io::Write,
    offset_in_sector: &mut u32,
    id: &[u8],
    extent_lba: u32,
    data_length: u32,
    is_dir: bool,
) -> io::Result<()> {
    let id_len = u8::try_from(id.len()).unwrap();
    let record_size = u32::from(dir_record_size(id_len));
    let pad = 1 - (id_len & 1);

    // ECMA-119 §9.1: no directory record may straddle a sector boundary.
    let sector_size_u32 = u32::try_from(SECTOR_SIZE).unwrap();
    if *offset_in_sector + record_size > sector_size_u32 {
        let pad_len = usize::try_from(sector_size_u32 - *offset_in_sector).unwrap();
        w.write_all(&ZEROS[..pad_len])?;
        *offset_in_sector = 0;
    }

    let header = build_dir_record_header(extent_lba, data_length, is_dir, id_len);
    header.write_to_io(&mut *w)?;
    w.write_all(id)?;
    if pad > 0 {
        w.write_all(&[0u8])?;
    }
    *offset_in_sector = (*offset_in_sector + record_size) % sector_size_u32;
    Ok(())
}

/// Writes the full directory extent for `dir` at its pre-assigned LBA.
///
/// The extent contains:
///  - `.`  record (this directory)
///  - `..` record (parent, or self for root)
///  - one record per entry in `sorted_entry_ids` (subdirs and files
///    interleaved)
///
/// Records that would straddle a sector boundary are preceded by zero padding
/// to push them to the start of the next sector, mirroring
/// `required_extent_size`.
fn serialize_dir_extent(
    w: &mut (impl io::Write + io::Seek),
    dir: &DirNode<'_>,
    dirs: &[DirNode<'_>],
    files: &[FileNode<'_>],
) -> io::Result<()> {
    w.seek(SeekFrom::Start(
        u64::from(dir.extent_lba) * SECTOR_SIZE as u64,
    ))?;

    let mut offset: u32 = 0;

    // "." — this directory
    write_dir_record(
        &mut *w,
        &mut offset,
        &[0x00],
        dir.extent_lba,
        dir.extent_len,
        true,
    )?;

    // ".." — parent (root's parent is itself)
    let parent = &dirs[dir.parent_directory as usize];
    write_dir_record(
        &mut *w,
        &mut offset,
        &[0x01],
        parent.extent_lba,
        parent.extent_len,
        true,
    )?;

    // All entries (subdirs and files) in the pre-sorted order.
    for (_, entry) in &dir.sorted_entry_ids {
        match entry {
            DirNodeEntry::ChildDirectory(idx) => {
                let child = &dirs[*idx as usize];

                write_dir_record(
                    &mut *w,
                    &mut offset,
                    child.identifier.as_bytes(),
                    child.extent_lba,
                    child.extent_len,
                    true,
                )?;
            }
            DirNodeEntry::File(idx) => {
                let file = &files[*idx as usize];

                write_dir_record(
                    &mut *w,
                    &mut offset,
                    file.identifier.as_bytes(),
                    file.lba,
                    file.len,
                    false,
                )?;
            }
        }
    }

    pad_to_sector(w)
}

/// Writes the El Torito boot catalog at the current writer position.
///
/// Layout:
///   1. `ValidationEntry`  — header_id=1, platform_id from
///      `boot_config.validation_platform`, 16-bit word-sum checksum
///   2. `InitialEntry`     — the default boot entry
///   3. For each section: `SectionHeaderEntry` + one `SectionEntry` per entry
fn write_boot_catalog(w: &mut impl io::Write, boot_config: &BootConfig) -> io::Result<()> {
    // ValidationEntry — checksum ensures the 16-bit word sum of the 32-byte record
    // is zero.
    let validation = {
        let mut e = ValidationEntry::new_zeroed();
        e.header_id = 1;
        e.platform_id = u8::from(boot_config.validation_platform);
        e.key = [0x55, 0xAA];
        // Compute checksum with all other fields set; negate so the total sum is zero.
        let bytes = e.as_bytes();
        let sum = bytes
            .chunks_exact(2)
            .map(|w| u16::from_le_bytes([w[0], w[1]]))
            .fold(0u16, u16::wrapping_add);
        e.checksum = U16::new(0u16.wrapping_sub(sum));
        e
    };
    validation.write_to_io(&mut *w)?;

    // InitialEntry — the default boot entry (required).
    write_initial_entry(w, &boot_config.default_entry)?;

    // Section headers + entries.
    let section_count = boot_config.sections.len();
    for (i, section) in boot_config.sections.iter().enumerate() {
        let is_last = i == section_count - 1;
        let mut header = SectionHeaderEntry::new_zeroed();
        // 0x91 = last section header; 0x90 = more sections follow
        header.header_indicator = if is_last { 0x91 } else { 0x90 };
        header.platform_id = u8::from(section.platform);
        header.entries = U16::new(u16::try_from(section.entries.len()).unwrap());
        header.id = section.id;
        header.write_to_io(&mut *w)?;

        for entry in &section.entries {
            let mut se = SectionEntry::new_zeroed();
            se.bootable = if entry.bootable { 0x88 } else { 0x00 };
            se.boot_media_ty = u8::from(entry.emulation);
            se.sector_count = U16::new(entry.resolved_sector_count());
            se.load_rba = U32::new(entry.boot_image_lba);
            se.write_to_io(&mut *w)?;
        }
    }

    Ok(())
}

fn write_initial_entry(w: &mut impl io::Write, entry: &BootEntry<'_>) -> io::Result<()> {
    let mut ie = InitialEntry::new_zeroed();
    ie.boot_indicator = if entry.bootable { 0x88 } else { 0x00 };
    ie.boot_media_ty = u8::from(entry.emulation);
    ie.sector_count = U16::new(entry.resolved_sector_count());
    ie.load_rba = U32::new(entry.boot_image_lba);
    ie.write_to_io(w)
}
