1use jgenesis_native_config::{RecentOpen, RomSearchDirectory};
2use jgenesis_native_driver::extensions;
3use jgenesis_native_driver::extensions::{Console, ConsoleWithSize};
4use regex::Regex;
5use std::collections::HashSet;
6use std::ffi::OsStr;
7use std::path::{Path, PathBuf};
8use std::str::FromStr;
9use std::sync::atomic::{AtomicU32, Ordering};
10use std::sync::mpsc::Sender;
11use std::sync::{Arc, LazyLock, Mutex, mpsc};
12use std::{fs, io, thread};
13
14#[derive(Debug, Clone)]
15pub struct RomMetadata {
16    pub full_path: PathBuf,
17    pub file_name_no_ext: String,
18    pub console: Console,
19    pub file_size: u64,
20}
21
22pub fn build(rom_search_dirs: &[RomSearchDirectory]) -> Vec<RomMetadata> {
23    let mut searched = HashSet::new();
24    let mut metadata = Vec::new();
25    for rom_search_dir in rom_search_dirs {
26        let search_path = jgenesis_common::fix_appimage_relative_path(&rom_search_dir.path);
27        search_dir(&search_path, rom_search_dir.recursive, &mut searched, &mut metadata);
28    }
29
30    // Remove any files that are referenced in .cue files
31    let cd_bin_file_names = metadata
32        .iter()
33        .filter(|metadata| extensions::from_path(&metadata.full_path).as_deref() == Some("cue"))
34        .filter_map(|metadata| {
35            let path = Path::new(&metadata.full_path);
36
37            let cue_directory = path.parent()?;
38            let cue_contents = fs::read_to_string(path).ok()?;
39
40            let file_paths = parse_bin_file_names(&cue_contents)
41                .map(|file_name| cue_directory.join(file_name))
42                .collect::<Vec<_>>();
43            Some(file_paths)
44        })
45        .flatten()
46        .collect::<HashSet<_>>();
47
48    metadata.retain(|metadata| !cd_bin_file_names.contains(&metadata.full_path));
49
50    metadata.sort_by(|a, b| a.file_name_no_ext.cmp(&b.file_name_no_ext));
51    metadata
52}
53
54fn search_dir(
55    path: &Path,
56    recursive: bool,
57    searched: &mut HashSet<PathBuf>,
58    output: &mut Vec<RomMetadata>,
59) {
60    let Ok(canonical_path) = path.canonicalize() else { return };
61
62    if !searched.insert(canonical_path) {
63        // Prevent potential infinite loops due to symlinks, or avoid double-searching a path if
64        // it's contained within multiple search paths
65        return;
66    }
67
68    let Ok(read_dir) = path.read_dir() else { return };
69
70    for dir_entry in read_dir {
71        let Ok(dir_entry) = dir_entry else { continue };
72        let Ok(metadata) = dir_entry.metadata() else { continue };
73        let entry_path = dir_entry.path();
74
75        if metadata.is_file() {
76            let file_name = dir_entry.file_name();
77            if let Some(rom_metadata) = process_file(&file_name.to_string_lossy(), &entry_path) {
78                output.push(rom_metadata);
79            }
80        } else if recursive && metadata.is_dir() {
81            search_dir(&entry_path, recursive, searched, output);
82        }
83    }
84}
85
86fn process_file(file_name: &str, path: &Path) -> Option<RomMetadata> {
87    let ConsoleWithSize { console, file_size: raw_file_size } = Console::from_file(path)?;
88
89    let file_name_no_ext = Path::new(file_name).with_extension("").to_string_lossy().to_string();
90    let extension = extensions::from_path(path)?;
91
92    let file_size = match extension.as_str() {
93        "cue" => sega_cd_file_size(path).ok()?,
94        _ => raw_file_size,
95    };
96
97    Some(RomMetadata { full_path: path.into(), file_name_no_ext, console, file_size })
98}
99
100fn sega_cd_file_size(cue_path: &Path) -> io::Result<u64> {
101    let cue_contents = fs::read_to_string(cue_path)?;
102    let cue_directory =
103        Path::new(cue_path).parent().expect("Valid file should always have a parent dir");
104
105    let unique_file_names = parse_bin_file_names(&cue_contents).collect::<HashSet<_>>();
106
107    unique_file_names
108        .iter()
109        .map(|file_name| {
110            let full_path = cue_directory.join(file_name);
111            fs::metadata(full_path).map(|metadata| metadata.len())
112        })
113        .sum()
114}
115
116fn parse_bin_file_names(cue_contents: &str) -> impl Iterator<Item = &str> {
117    static LINE_RE: LazyLock<Regex> =
118        LazyLock::new(|| Regex::new(r#"FILE "(.*)" BINARY"#).unwrap());
119
120    cue_contents
121        .lines()
122        .filter_map(|line| LINE_RE.captures(line).map(|captures| captures.get(1).unwrap().as_str()))
123}
124
125pub fn read_metadata(path: &Path) -> Option<RomMetadata> {
126    let file_name = path.file_name().and_then(OsStr::to_str)?;
127    process_file(file_name, path)
128}
129
130pub fn from_recent_opens(recent_opens: &[RecentOpen]) -> Vec<Option<RomMetadata>> {
131    recent_opens
132        .iter()
133        .map(|RecentOpen { console, path: path_str, .. }| {
134            let console = Console::from_str(console).ok()?;
135            let path = Path::new(path_str);
136            let file_name_no_ext =
137                path.with_extension("").file_name()?.to_string_lossy().to_string();
138            let metadata = fs::metadata(path).ok()?;
139
140            let file_size = match extensions::from_path(path).as_deref() {
141                Some("cue") => sega_cd_file_size(path_str).ok()?,
142                _ => metadata.len(),
143            };
144
145            Some(RomMetadata { full_path: path_str.into(), file_name_no_ext, console, file_size })
146        })
147        .collect()
148}
149
150pub fn find_all_disc_paths(path: &Path) -> Vec<(String, PathBuf)> {
151    static DISC_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r" \(Disc (\d)\)").unwrap());
152
153    let Some(path_name_str) = path.file_name().and_then(OsStr::to_str) else { return Vec::new() };
154    let name_without_disc = DISC_RE.replace(path_name_str, "");
155    if name_without_disc.as_ref() == path_name_str {
156        return Vec::new();
157    }
158
159    let Some(parent) = path.parent() else { return Vec::new() };
160    let Ok(read_dir) = parent.read_dir() else { return Vec::new() };
161
162    let mut discs = Vec::new();
163
164    for entry in read_dir {
165        let Ok(entry) = entry else { continue };
166        let file_name = entry.file_name();
167        let Some(file_name) = file_name.to_str() else { continue };
168
169        if DISC_RE.replace(file_name, "") != name_without_disc.as_ref() {
170            continue;
171        }
172
173        let Some(captures) = DISC_RE.captures(file_name) else { continue };
174
175        let disc_number = captures.get(1).unwrap();
176        let disc_name = format!("Disc {}", disc_number.as_str());
177        discs.push((disc_name, entry.path()));
178    }
179
180    discs.sort_by(|a, b| a.0.cmp(&b.0));
181
182    discs
183}
184
185#[derive(Debug)]
186pub struct RomListThreadHandle {
187    scan_requests_sender: Sender<Vec<RomSearchDirectory>>,
188    scan_request_counter: Arc<AtomicU32>,
189}
190
191impl RomListThreadHandle {
192    pub fn spawn(rom_list: Arc<Mutex<Vec<RomMetadata>>>, egui_ctx: egui::Context) -> Self {
193        let (scan_requests_sender, scan_requests_receiver) =
194            mpsc::channel::<Vec<RomSearchDirectory>>();
195        let scan_request_counter = Arc::new(AtomicU32::new(0));
196        let scan_request_counter_handle = Arc::clone(&scan_request_counter);
197
198        thread::spawn(move || {
199            while let Ok(scan_request) = scan_requests_receiver.recv() {
200                let new_rom_list = build(&scan_request);
201                *rom_list.lock().unwrap() = new_rom_list;
202
203                scan_request_counter.fetch_sub(1, Ordering::SeqCst);
204                egui_ctx.request_repaint();
205            }
206        });
207
208        Self { scan_requests_sender, scan_request_counter: scan_request_counter_handle }
209    }
210
211    pub fn request_scan(&self, scan_request: Vec<RomSearchDirectory>) {
212        self.scan_request_counter.fetch_add(1, Ordering::SeqCst);
213        self.scan_requests_sender.send(scan_request).unwrap();
214    }
215
216    pub fn any_scans_in_progress(&self) -> bool {
217        self.scan_request_counter.load(Ordering::SeqCst) != 0
218    }
219}