use crate::{VfsDevice, VfsEntry, VfsError};
use std::io::{Read, Seek, SeekFrom};

/// XISO disc image device. Parses Xbox 360 disc images (GDFX/XISO format).
///
/// Caches the fully-resolved entry list at open() — GDFX is a directory
/// tree, and resolving any nested path (`dat/tables.pak`, `media/x.wav`)
/// requires descending into subdirectories. A prior version only scanned
/// the root buffer, so any file under a subdirectory was reported as
/// missing. We read each directory's buffer from disk once at open time
/// and emit full paths into `entries`.
pub struct DiscImageDevice {
    name: String,
    path: std::path::PathBuf,
    game_offset: u64,
    /// Flattened file + directory tree, each with its full path relative
    /// to the partition root ("dat/tables.pak", etc.). Populated once at
    /// `open()` so lookups are O(n) over a cached vec instead of rereading
    /// the tree on every NtCreateFile.
    entries: Vec<VfsEntry>,
}

/// XISO sector size
pub const SECTOR_SIZE: u64 = 0x800;

/// GDFX magic string
const GDFX_MAGIC: &[u8; 20] = b"MICROSOFT*XBOX*MEDIA";

/// File attribute: directory
const FILE_ATTRIBUTE_DIRECTORY: u8 = 0x10;

/// File attribute: read-only. Canary OR's this into every GDFX entry's
/// attribute byte because a pressed disc is inherently read-only
/// (`disc_image_device.cc:154`: `attributes | kFileAttributeReadOnly`).
const FILE_ATTRIBUTE_READONLY: u8 = 0x01;

/// Known game partition offsets to try
const LIKELY_OFFSETS: &[u64] = &[
    0x0000_0000,
    0x0000_FB20,
    0x0002_0600,
    0x0208_0000,
    0x0FD9_0000,
];

impl DiscImageDevice {
    pub fn open(name: impl Into<String>, path: &std::path::Path) -> Result<Self, VfsError> {
        let mut file = std::fs::File::open(path)?;

        // Find the game partition by locating the GDFX magic at sector 32
        let mut game_offset = 0u64;
        let mut magic_found = false;
        let mut magic_buf = [0u8; 20];

        for &offset in LIKELY_OFFSETS {
            let magic_pos = offset + 32 * SECTOR_SIZE;
            if file.seek(SeekFrom::Start(magic_pos)).is_ok()
                && file.read_exact(&mut magic_buf).is_ok()
                && magic_buf == *GDFX_MAGIC
            {
                game_offset = offset;
                magic_found = true;
                break;
            }
        }

        if !magic_found {
            return Err(VfsError::InvalidFormat(
                "GDFX magic not found - not a valid XISO disc image".into(),
            ));
        }

        // Read root directory info from sector 32 header
        let fs_ptr = game_offset + 32 * SECTOR_SIZE;
        file.seek(SeekFrom::Start(fs_ptr + 20))?;
        let mut buf4 = [0u8; 4];
        file.read_exact(&mut buf4)?;
        let root_sector = u32::from_le_bytes(buf4) as u64;
        file.read_exact(&mut buf4)?;
        let root_size = u32::from_le_bytes(buf4) as u64;

        let root_byte_offset = game_offset + root_sector * SECTOR_SIZE;

        // Read the root directory buffer into memory (typically small)
        file.seek(SeekFrom::Start(root_byte_offset))?;
        let mut root_buffer = vec![0u8; root_size as usize];
        file.read_exact(&mut root_buffer)?;

        let mut dev = Self {
            name: name.into(),
            path: path.to_path_buf(),
            game_offset,
            entries: Vec::new(),
        };
        dev.collect_entries(&mut file, &root_buffer, 0, "")?;
        Ok(dev)
    }

    /// Walk one directory's B-tree buffer, emit each file/directory into
    /// `out` with its full relative path, and recurse into subdirectory
    /// buffers on disk.
    ///
    /// `prefix` is the current parent path (empty at the root). Names
    /// concatenate as `<prefix>/<name>` so the final path matches what
    /// guest callers like `NtCreateFile("dat/tables.pak")` expect.
    ///
    /// `file` is the already-open disc image handle, reused for every
    /// subdirectory read so we don't pay a fresh open per directory on
    /// deep trees.
    fn collect_entries(
        &mut self,
        file: &mut std::fs::File,
        buffer: &[u8],
        ordinal: u16,
        prefix: &str,
    ) -> Result<(), VfsError> {
        let p = ordinal as usize * 4;
        if p + 14 > buffer.len() {
            return Ok(());
        }

        let node_l = u16::from_le_bytes([buffer[p], buffer[p + 1]]);
        let node_r = u16::from_le_bytes([buffer[p + 2], buffer[p + 3]]);
        let sector = u32::from_le_bytes([buffer[p + 4], buffer[p + 5], buffer[p + 6], buffer[p + 7]]) as u64;
        let length = u32::from_le_bytes([buffer[p + 8], buffer[p + 9], buffer[p + 10], buffer[p + 11]]) as u64;
        let attributes = buffer[p + 12];
        let name_length = buffer[p + 13] as usize;

        if p + 14 + name_length > buffer.len() {
            return Ok(());
        }

        if node_l != 0 && node_l != 0xFFFF {
            self.collect_entries(file, buffer, node_l, prefix)?;
        }

        let name = String::from_utf8_lossy(&buffer[p + 14..p + 14 + name_length]).to_string();
        let is_directory = (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0;
        // Match canary: the on-disc attribute byte (DIRECTORY/HIDDEN/SYSTEM/
        // ARCHIVE/NORMAL bits as authored) OR the implicit READONLY bit for
        // pressed media. We forward the FULL byte, not a path-shape guess, so
        // attribute queries report exactly what the disc records.
        let attributes = (attributes | FILE_ATTRIBUTE_READONLY) as u32;
        let file_offset = self.game_offset + sector * SECTOR_SIZE;
        let full_path = if prefix.is_empty() {
            name.clone()
        } else {
            format!("{}/{}", prefix, name)
        };

        self.entries.push(VfsEntry {
            name: full_path.clone(),
            is_directory,
            size: length,
            offset: file_offset,
            attributes,
        });

        // Descend into subdirectories. Zero-length directory entries exist
        // (empty dirs) and must be skipped to avoid `read_exact` on 0 bytes.
        if is_directory && length > 0 {
            file.seek(SeekFrom::Start(file_offset))?;
            let mut sub_buffer = vec![0u8; length as usize];
            file.read_exact(&mut sub_buffer)?;
            self.collect_entries(file, &sub_buffer, 0, &full_path)?;
        }

        if node_r != 0 && node_r != 0xFFFF {
            self.collect_entries(file, buffer, node_r, prefix)?;
        }
        Ok(())
    }
}

impl VfsDevice for DiscImageDevice {
    fn name(&self) -> &str {
        &self.name
    }

    fn list_root(&self) -> Result<Vec<VfsEntry>, VfsError> {
        // Return the full flattened tree. Callers of this method are
        // dump/debug paths (see `xenia-rs dumpxiso`), which want to see
        // every file — root-only was the old flat-enumeration bug.
        Ok(self.entries.clone())
    }

    fn read_file(&self, path: &str) -> Result<Vec<u8>, VfsError> {
        let entry = self
            .entries
            .iter()
            .find(|e| e.name.eq_ignore_ascii_case(path) && !e.is_directory)
            .ok_or_else(|| VfsError::NotFound(path.to_string()))?;

        let offset = entry.offset;
        let size = entry.size as usize;

        // Read from file using seek
        let mut file = std::fs::File::open(&self.path)?;
        let file_len = file.seek(SeekFrom::End(0))?;
        if offset + size as u64 > file_len {
            return Err(VfsError::NotFound(format!(
                "File data extends past end of image: {} (offset={:#x}, size={:#x}, image_len={:#x})",
                path, offset, size, file_len
            )));
        }
        file.seek(SeekFrom::Start(offset))?;
        let mut buf = vec![0u8; size];
        let bytes_read = file.read(&mut buf)?;
        if bytes_read < size {
            // Try reading the rest
            let mut total = bytes_read;
            while total < size {
                let n = file.read(&mut buf[total..])?;
                if n == 0 {
                    return Err(VfsError::NotFound(format!(
                        "Short read: got {} of {} bytes for {}",
                        total, size, path
                    )));
                }
                total += n;
            }
        }
        Ok(buf)
    }

    fn stat(&self, path: &str) -> Result<VfsEntry, VfsError> {
        self.entries
            .iter()
            .find(|e| e.name.eq_ignore_ascii_case(path))
            .cloned()
            .ok_or_else(|| VfsError::NotFound(path.to_string()))
    }
}

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

    /// Regression: the XISO reader used to only enumerate the root directory,
    /// so any nested path (`dat/tables.pak`, `media/stream.xma`) failed to
    /// open. Verified end-to-end by `browse` on the Sylpheed disc which
    /// now lists 358 entries including `dat/*` files.
    ///
    /// This test runs only if an XISO is available in the parent of the repo
    /// root — matches the developer's local layout for the real disc. CI
    /// machines without the disc simply skip the test (early-return Ok).
    #[test]
    fn nested_file_resolves_when_disc_present() {
        let disc_path = std::path::Path::new(
            "../../../Project Sylpheed - Arc of Deception (USA, Europe) (En,Ja).iso",
        );
        if !disc_path.exists() {
            eprintln!("skipping: disc image not present at {:?}", disc_path);
            return;
        }
        let dev = DiscImageDevice::open("disc", disc_path).expect("open xiso");
        // Both a top-level and a nested file must be visible.
        assert!(
            dev.entries.iter().any(|e| e.name == "default.xex"),
            "default.xex must be at the root"
        );
        assert!(
            dev.entries
                .iter()
                .any(|e| e.name.eq_ignore_ascii_case("dat/tables.pak")),
            "nested entry dat/tables.pak missing — subdirectory enumeration broken",
        );
        // And read_file must be able to fetch the nested bytes.
        let bytes = dev
            .read_file("dat/tables.pak")
            .expect("read_file on nested path");
        assert!(!bytes.is_empty(), "nested read returned empty buffer");
    }

    /// Build a one-node GDFX directory buffer in memory and parse it with
    /// `collect_entries`, asserting the real on-disc attribute byte is
    /// forwarded into `VfsEntry.attributes` (with READONLY OR'd in, matching
    /// canary `disc_image_device.cc:154`) rather than synthesised from the
    /// path shape.
    fn parse_single_entry(name: &str, on_disc_attr: u8) -> VfsEntry {
        // GDFX dirent: node_l(u16) node_r(u16) sector(u32) length(u32)
        // attributes(u8) name_length(u8) name(bytes). The directory bit
        // gates subdirectory descent; use length=0 so a "directory" entry
        // is treated as an empty leaf and we don't recurse off the buffer.
        let mut buf = Vec::new();
        buf.extend_from_slice(&0u16.to_le_bytes()); // node_l
        buf.extend_from_slice(&0u16.to_le_bytes()); // node_r
        buf.extend_from_slice(&0u32.to_le_bytes()); // sector
        buf.extend_from_slice(&0u32.to_le_bytes()); // length (0 => leaf)
        buf.push(on_disc_attr); // attributes
        buf.push(name.len() as u8); // name_length
        buf.extend_from_slice(name.as_bytes());

        let mut dev = DiscImageDevice {
            name: "test".into(),
            path: std::path::PathBuf::new(),
            game_offset: 0,
            entries: Vec::new(),
        };
        // `file` is only touched when descending into a non-empty directory;
        // our length=0 entries never recurse, so a dummy handle is fine.
        let mut file = std::fs::File::open("/dev/null").expect("open /dev/null");
        dev.collect_entries(&mut file, &buf, 0, "").expect("parse");
        assert_eq!(dev.entries.len(), 1);
        dev.entries.into_iter().next().unwrap()
    }

    #[test]
    fn directory_entry_reports_directory_attribute() {
        // On-disc 0x10 (DIRECTORY) -> attributes carries 0x10 and READONLY.
        let e = parse_single_entry("dat", FILE_ATTRIBUTE_DIRECTORY);
        assert!(e.is_directory, "directory bit not decoded");
        assert_ne!(
            e.attributes & 0x10,
            0,
            "FILE_ATTRIBUTE_DIRECTORY must be set for a directory entry"
        );
        assert_ne!(e.attributes & 0x01, 0, "READONLY must be OR'd in (canary)");
    }

    #[test]
    fn file_entry_has_no_directory_attribute() {
        // On-disc 0x80 (NORMAL) -> not a directory; READONLY still OR'd in.
        let e = parse_single_entry("default.xex", 0x80);
        assert!(!e.is_directory, "non-directory misdecoded as directory");
        assert_eq!(
            e.attributes & 0x10,
            0,
            "FILE_ATTRIBUTE_DIRECTORY must be clear for a file entry"
        );
        assert_ne!(e.attributes & 0x80, 0, "NORMAL bit must be preserved");
        assert_ne!(e.attributes & 0x01, 0, "READONLY must be OR'd in (canary)");
    }

    #[test]
    fn archive_and_hidden_bits_are_preserved() {
        // ARCHIVE(0x20) | HIDDEN(0x02) authored on disc must survive intact.
        let e = parse_single_entry("save.dat", 0x20 | 0x02);
        assert_eq!(e.attributes & 0x20, 0x20, "ARCHIVE bit dropped");
        assert_eq!(e.attributes & 0x02, 0x02, "HIDDEN bit dropped");
        assert_eq!(e.attributes & 0x10, 0, "spurious DIRECTORY bit");
    }
}
