chdlady-disc 0.2.1

Disc geometries and physical media formats for chdlady
//! CUE sheet parser and formatter for CD-ROM disc images.
use crate::error::DiscError;
use crate::types::{CdToc, CdTrack, CdTrackType, DiscType, Msf};
use std::path::Path;

/// Temporary staging structure for tracks while parsing CUE sheets.
struct StagedTrack {
    track_num: u32,
    track_type: CdTrackType,
    index0: Option<u32>,
    index1: u32,
    pregap: u32,
    postgap: u32,
    file_name: String,
}

/// Parser and generator for CDRWIN-compatible CUE sheets.
#[derive(Debug, Default)]
pub struct CueSheet;

impl CueSheet {
    /// Parses a CUE sheet from a string given an optional parent directory for resolving track files.
    pub fn parse(content: &str, parent_dir: Option<&Path>) -> Result<CdToc, DiscError> {
        let mut tracks = Vec::new();
        let mut current_file: Option<String> = None;
        let mut current_track_num: Option<u32> = None;
        let mut current_track_type: Option<CdTrackType> = None;
        let mut current_pregap: u32 = 0;
        let mut current_postgap: u32 = 0;
        let mut current_index0: Option<u32> = None;
        let mut current_index1: Option<u32> = None;
        let mut file_tracks: Vec<StagedTrack> = Vec::new();

        let flush_current_track = |file_tracks: &mut Vec<StagedTrack>,
                                   current_file: &Option<String>,
                                   current_track_num: Option<u32>,
                                   current_track_type: Option<CdTrackType>,
                                   current_index0: Option<u32>,
                                   current_index1: Option<u32>,
                                   current_pregap: u32,
                                   current_postgap: u32|
         -> Result<(), DiscError> {
            if let (Some(track_num), Some(track_type), Some(index1)) =
                (current_track_num, current_track_type, current_index1)
            {
                let file_name = current_file.clone().unwrap_or_default();
                file_tracks.push(StagedTrack {
                    track_num,
                    track_type,
                    index0: current_index0,
                    index1,
                    pregap: current_pregap,
                    postgap: current_postgap,
                    file_name,
                });
            }
            Ok(())
        };

        for raw_line in content.lines() {
            let line = raw_line.trim();
            if line.is_empty() || line.starts_with("REM") {
                continue;
            }

            let tokens = tokenize_line(line);
            if tokens.is_empty() {
                continue;
            }

            match tokens[0].to_uppercase().as_str() {
                "FILE" => {
                    flush_current_track(
                        &mut file_tracks,
                        &current_file,
                        current_track_num,
                        current_track_type,
                        current_index0,
                        current_index1,
                        current_pregap,
                        current_postgap,
                    )?;
                    current_track_num = None;
                    current_track_type = None;
                    current_index0 = None;
                    current_index1 = None;
                    current_pregap = 0;
                    current_postgap = 0;

                    if tokens.len() < 2 {
                        return Err(DiscError::InvalidCue("missing FILE path".to_string()));
                    }
                    crate::types::validate_descriptor_path(&tokens[1])?;
                    current_file = Some(tokens[1].clone());
                }
                "TRACK" => {
                    flush_current_track(
                        &mut file_tracks,
                        &current_file,
                        current_track_num,
                        current_track_type,
                        current_index0,
                        current_index1,
                        current_pregap,
                        current_postgap,
                    )?;
                    current_index0 = None;
                    current_index1 = None;
                    current_pregap = 0;
                    current_postgap = 0;

                    if tokens.len() < 3 {
                        return Err(DiscError::InvalidCue("incomplete TRACK line".to_string()));
                    }
                    let num = tokens[1].parse::<u32>().map_err(|_| {
                        DiscError::InvalidCue(format!("invalid track number: {}", tokens[1]))
                    })?;
                    let t_type = CdTrackType::parse_cue_or_metadata(&tokens[2])?;
                    current_track_num = Some(num);
                    current_track_type = Some(t_type);
                }
                "INDEX" => {
                    if tokens.len() < 3 {
                        return Err(DiscError::InvalidCue("incomplete INDEX line".to_string()));
                    }
                    let idx_num = tokens[1].parse::<u32>().map_err(|_| {
                        DiscError::InvalidCue(format!("invalid INDEX number: {}", tokens[1]))
                    })?;
                    let msf = Msf::parse(&tokens[2])?;
                    let lba = msf.to_lba();
                    if idx_num == 0 {
                        current_index0 = Some(lba);
                    } else if idx_num == 1 {
                        current_index1 = Some(lba);
                    }
                }
                "PREGAP" => {
                    if tokens.len() < 2 {
                        return Err(DiscError::InvalidCue("missing PREGAP value".to_string()));
                    }
                    let msf = Msf::parse(&tokens[1])?;
                    current_pregap += msf.to_lba();
                }
                "POSTGAP" => {
                    if tokens.len() < 2 {
                        return Err(DiscError::InvalidCue("missing POSTGAP value".to_string()));
                    }
                    let msf = Msf::parse(&tokens[1])?;
                    current_postgap += msf.to_lba();
                }
                _ => {}
            }
        }

        flush_current_track(
            &mut file_tracks,
            &current_file,
            current_track_num,
            current_track_type,
            current_index0,
            current_index1,
            current_pregap,
            current_postgap,
        )?;

        if file_tracks.is_empty() {
            return Err(DiscError::InvalidCue(
                "no tracks found in CUE sheet".to_string(),
            ));
        }

        // Measure track durations and file offsets
        for i in 0..file_tracks.len() {
            let staged = &file_tracks[i];
            let mut track_pregap = staged.pregap;
            if let Some(i0) = staged.index0 {
                if staged.index1 > i0 {
                    track_pregap += staged.index1 - i0;
                }
            }

            let is_same_file_as_prev = i > 0 && file_tracks[i - 1].file_name == staged.file_name;
            let is_same_file_as_next =
                i + 1 < file_tracks.len() && file_tracks[i + 1].file_name == staged.file_name;

            let cur_start = staged.index0.unwrap_or(staged.index1);

            let (frames, byte_offset) = if !is_same_file_as_prev && !is_same_file_as_next {
                // Standalone file for this track
                let total_frames = if let Some(parent) = parent_dir {
                    let path = parent.join(&staged.file_name);
                    measure_file_frames(&path, staged.track_type)?
                } else {
                    0
                };
                (total_frames, 0u64)
            } else if is_same_file_as_next {
                // Shared file with next track
                let next_start = file_tracks[i + 1]
                    .index0
                    .unwrap_or(file_tracks[i + 1].index1);
                if next_start < cur_start {
                    return Err(DiscError::InvalidCue(format!(
                        "track {} index overlaps next track in file '{}'",
                        staged.track_num, staged.file_name
                    )));
                }
                let span = next_start - cur_start;
                let offset = cur_start as u64 * staged.track_type.sector_size() as u64;
                (span, offset)
            } else {
                // Last track in a shared file
                let total_frames = if let Some(parent) = parent_dir {
                    let path = parent.join(&staged.file_name);
                    measure_file_frames(&path, staged.track_type)?
                } else {
                    0
                };
                let span = total_frames.saturating_sub(cur_start);
                let offset = cur_start as u64 * staged.track_type.sector_size() as u64;
                (span, offset)
            };

            let mut track = CdTrack::new(staged.track_num, staged.track_type, frames);
            track.pregap_frames = track_pregap;
            track.postgap_frames = staged.postgap;
            track.file_name = Some(staged.file_name.clone());
            track.file_offset = byte_offset;
            tracks.push(track);
        }

        Ok(CdToc {
            disc_type: DiscType::Cd,
            tracks,
        })
    }

    /// Generates CUE sheet text for a single binary file.
    pub fn format_single_bin(toc: &CdToc, bin_filename: &str) -> String {
        let mut out = String::new();
        out.push_str(&format!("FILE \"{}\" BINARY\n", bin_filename));

        let mut cumulative_lba: u32 = 0;
        for track in &toc.tracks {
            out.push_str(&format!(
                "  TRACK {:02} {}\n",
                track.track_num,
                track.track_type.cue_mode_str()
            ));

            if track.pregap_frames > 0 {
                let idx0 = Msf::from_lba(cumulative_lba);
                cumulative_lba += track.pregap_frames;
                let idx1 = Msf::from_lba(cumulative_lba);
                out.push_str(&format!("    INDEX 00 {}\n", idx0));
                out.push_str(&format!("    INDEX 01 {}\n", idx1));
            } else {
                let idx1 = Msf::from_lba(cumulative_lba);
                out.push_str(&format!("    INDEX 01 {}\n", idx1));
            }

            cumulative_lba += track.frames + track.postgap_frames;
        }

        out
    }

    /// Generates CUE sheet text for split-binary files (one file per track).
    pub fn format_split_bin(toc: &CdToc, base_name: &str) -> String {
        let mut out = String::new();

        for track in &toc.tracks {
            let track_bin = format!("{} (Track {}).bin", base_name, track.track_num);
            out.push_str(&format!("FILE \"{}\" BINARY\n", track_bin));
            out.push_str(&format!(
                "  TRACK {:02} {}\n",
                track.track_num,
                track.track_type.cue_mode_str()
            ));

            if track.pregap_frames > 0 {
                let idx0 = Msf::from_lba(0);
                let idx1 = Msf::from_lba(track.pregap_frames);
                out.push_str(&format!("    INDEX 00 {}\n", idx0));
                out.push_str(&format!("    INDEX 01 {}\n", idx1));
            } else {
                out.push_str("    INDEX 01 00:00:00\n");
            }
        }

        out
    }
}

/// Tokenizes a line handling quoted strings.
fn tokenize_line(line: &str) -> Vec<String> {
    let mut tokens = Vec::new();
    let mut current = String::new();
    let mut in_quotes = false;

    for ch in line.chars() {
        match ch {
            '"' => {
                in_quotes = !in_quotes;
            }
            c if c.is_whitespace() && !in_quotes => {
                if !current.is_empty() {
                    tokens.push(current.clone());
                    current.clear();
                }
            }
            c => {
                current.push(c);
            }
        }
    }
    if !current.is_empty() {
        tokens.push(current);
    }
    tokens
}

/// Measures the frame duration of an external file (binary or WAV).
fn measure_file_frames(path: &Path, track_type: CdTrackType) -> Result<u32, DiscError> {
    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
        if ext.eq_ignore_ascii_case("wav") {
            let reader = hound::WavReader::open(path)?;
            let spec = reader.spec();
            let samples = reader.duration();
            let channels = spec.channels as u32;
            let sample_rate = spec.sample_rate;
            if sample_rate != 44100 || channels != 2 {
                return Err(DiscError::InvalidCue(format!(
                    "WAV file '{}' must be 44100Hz 16-bit stereo (found {}Hz, {} channels)",
                    path.display(),
                    sample_rate,
                    channels
                )));
            }
            // 588 stereo samples per CD audio frame
            return Ok(samples / 588);
        }
    }

    let meta = std::fs::metadata(path)?;
    let bytes = meta.len();
    let sec_size = track_type.sector_size() as u64;
    Ok((bytes / sec_size) as u32)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_cue_single_bin_format() {
        let mut toc = CdToc::new(DiscType::Cd);
        let t1 = CdTrack::new(1, CdTrackType::Mode1Raw, 4);
        let mut t2 = CdTrack::new(2, CdTrackType::Audio, 4);
        t2.pregap_frames = 150;
        toc.tracks.push(t1);
        toc.tracks.push(t2);

        let cue = CueSheet::format_single_bin(&toc, "out.bin");
        assert!(cue.contains("FILE \"out.bin\" BINARY"));
        assert!(cue.contains("  TRACK 01 MODE1/2352\n    INDEX 01 00:00:00\n"));
        assert!(cue.contains("  TRACK 02 AUDIO\n    INDEX 00 00:00:04\n    INDEX 01 00:02:04\n"));
    }

    #[test]
    fn test_cue_split_bin_format() {
        let mut toc = CdToc::new(DiscType::Cd);
        let t1 = CdTrack::new(1, CdTrackType::Mode1, 10);
        let t2 = CdTrack::new(2, CdTrackType::Audio, 20);
        toc.tracks.push(t1);
        toc.tracks.push(t2);

        let cue = CueSheet::format_split_bin(&toc, "disc");
        assert!(cue.contains("FILE \"disc (Track 1).bin\" BINARY"));
        assert!(cue.contains("  TRACK 01 MODE1/2048\n    INDEX 01 00:00:00\n"));
        assert!(cue.contains("FILE \"disc (Track 2).bin\" BINARY"));
        assert!(cue.contains("  TRACK 02 AUDIO\n    INDEX 01 00:00:00\n"));
    }
}