// SPDX-License-Identifier: LGPL-2.1-or-later OR GPL-2.0-or-later OR MPL-2.0
// SPDX-FileCopyrightText: 2024 Gabriel Marcano <gabemarcano@yahoo.com>

use crate::error::Error;

use std::io::BufRead;
use std::io::BufReader;
use std::io::Read;
use std::io::Seek;
use std::io::SeekFrom;
use std::str;

use byteorder::BigEndian;
use byteorder::ReadBytesExt;

/// Represents the metadata held by the GCN disk internal header.
#[derive(Debug)]
pub struct Header {
    /// Console ID. The first character of the game code field on disk.
    pub console_id: String,
    /// Unique game code. The second and third characters of the game code field.
    pub game_code: String,
    /// Country code. The last character of the game code field.
    pub country_code: String,
    /// Make code.
    pub maker_code: String,
    /// The ID of the disk.
    pub disk_id: u8,
    /// The version of the disk.
    pub version: u8,
    /// Whether or not audio streaming is enabled.
    pub audio_streaming: u8,
    /// The size of the audio stream buffer. Apparently 0 means 10?
    pub stream_buffer_size: u8,
    /// GCN magic word.
    pub magic: u32,
    /// The name of the game. On disk it is padded with null characters.
    pub game_name: String,

    pub debug_monitor_offset: u32,
    pub debug_monitor_address: u32,

    /// The offset to the main executable DOL (bootfile)
    pub executable_offset: u32,
    /// Offset to the filesystem table (often referred to as fst.bin).
    pub fst_offset: u32,
    /// The size of the FST.
    pub fst_size: u32,
    /// The maximum size of the FST (may be more than [`Header::fst_size`] for multidisks?).
    pub fst_max_size: u32,

    pub user_position: u32,
    pub user_length: u32,
}

pub struct FileEntry {
    /// Index to the filename in the string table right after the FST.
    pub filename_index: u32, // It's actually 24 bits
    /// The offset of the file in the disk.
    pub offset: u32,
    /// Size of the file.
    pub size: u32,
}

pub struct DirectoryEntry {
    /// Index to the directory name in the string table right after the FST.
    pub filename_index: u32, // It's actually 24 bits
    /// Offset of the parent directory entry.
    pub parent_offset: u32,
    /// FIXME not sure. Offset to the next directory in the current level?
    pub next_offset: u32,
}

/// Represents an FST entry.
pub enum Entry {
    File(FileEntry),
    Directory(DirectoryEntry),
}

/// Filesystem structure.
pub struct FST {
    /// List of all entries in the filesystem.
    pub entries: Vec<Entry>,
    /// offset of the string table in the disk.
    pub string_table_offset: u32,
}

/// Represents a GCN disk.
pub struct Disk<T: Read + Seek> {
    pub header: Header,
    pub filesystem: FST,
    io: T,
}

pub trait HeaderRead {
    /// Parses the GCN ISO metadata from the object provided, returning a [`Header`] object with
    /// the GCN ISO metadata.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Parse`] if the header cannot be found or if a field in the header contains
    /// an unexpected value.
    /// Returns [`Error::IO`] if an IO error took place while reading from the file.
    fn read_gcn_header(&mut self) -> Result<Header, Error>;
}

impl<T: Read + Seek> Disk<T> {
    /// Returns the filename for the file entry at the given index.
    ///
    /// # Errors
    ///
    /// [`Error::IO`] if there was an underlying error reading or seeking from the IO object,
    /// [`Error::Utf8`] if the filename is not a valid UTF8 string FIXME is this... acceptable for
    /// GCN data? Are they ASCII? Or what's their expected encoding?
    pub fn get_filename(&mut self, index: u32) -> Result<String, Error> {
        let str_index = match &self.filesystem.entries[usize::try_from(index).unwrap()] {
            Entry::File(file) => file.filename_index,
            Entry::Directory(dir) => dir.filename_index,
        };
        let string_location: u64 = (self.filesystem.string_table_offset + str_index).into();
        self.io.seek(SeekFrom::Start(string_location))?;
        let mut buffered = BufReader::new(&mut self.io);

        let mut data = vec![];
        buffered.read_until(b'\0', &mut data)?;
        Ok(str::from_utf8(&data[0..(data.len() - 1)])?.to_string())
    }

    /// Returns the data belonging to the file specified.
    ///
    /// # Errors
    /// [`Error::IO`] if there was an underlying error reading or seeking from the IO object,
    /// and see [`get_filename`] for other errors.
    pub fn get_file(&mut self, filename: &str) -> Result<Vec<u8>, Error> {
        for i in 0..self.filesystem.get_number_of_entries() {
            let name = self.get_filename(i)?;
            if filename == name {
                match &self.filesystem.entries[usize::try_from(i).unwrap()] {
                    Entry::File(file) => {
                        self.io.seek(SeekFrom::Start(file.offset.into()))?;
                        let size = file.size;
                        let mut data = vec![0u8; size.try_into().unwrap()];
                        self.io.read_exact(&mut data)?;
                        return Ok(data);
                    }
                    Entry::Directory(_) => {
                        return Err(Error::Parse(
                            "requested index is a directory, not a file".into(),
                        ))
                    }
                }
            }
        }
        Ok(vec![])
    }

    /// Returns a new Disk instance.
    ///
    /// # Errors
    ///
    /// See [`HeaderRead::read_gcn_header`] and [`FSTRead::read_fst`] for details on possible
    /// errors.
    pub fn new(mut io: T) -> Result<Self, Error> {
        let header = io.read_gcn_header()?;
        let filesystem = io.read_fst()?;
        Ok(Self {
            header,
            filesystem,
            io,
        })
    }
}

pub trait FSTRead {
    /// Parses the FST from the object provided, returning an [`FST`] object with the filesystem
    /// information.
    ///
    /// # Errors
    ///
    /// Returns [`Error::Parse`] if the header cannot be found or if a field in the header contains
    /// an unexpected value.
    /// Returns [`Error::IO`] if an IO error took place while reading from the file.
    fn read_fst(&mut self) -> Result<FST, Error>;
}

impl<T: Read + Seek> FSTRead for T {
    fn read_fst(&mut self) -> Result<FST, Error> {
        // Check file header, and 2 magic bytes
        self.seek(SeekFrom::Start(0x0424))?;

        let fst_addr = self.read_u32::<BigEndian>()?;

        self.seek(SeekFrom::Start(fst_addr.into()))?;

        if self.read_u8()? == 0 {
            return Err(Error::Parse("invalid root filesystem entry".into()));
        }
        self.seek(SeekFrom::Current(7))?;
        let num_entries = self.read_u32::<BigEndian>()?;

        let mut fst_entries: Vec<Entry> = Vec::new();
        // Exclude root entry
        for _ in 0..(num_entries - 1) {
            let mut data = [0u8; 12];
            self.read_exact(&mut data)?;
            let entry = match data[0] {
                0 => Entry::File(FileEntry {
                    filename_index: u32::from_be_bytes([0, data[1], data[2], data[3]]),
                    offset: u32::from_be_bytes(data[4..8].try_into().unwrap()),
                    size: u32::from_be_bytes(data[8..12].try_into().unwrap()),
                }),
                1 => Entry::Directory(DirectoryEntry {
                    filename_index: u32::from_be_bytes([0, data[1], data[2], data[3]]),
                    parent_offset: u32::from_be_bytes(data[4..8].try_into().unwrap()),
                    next_offset: u32::from_be_bytes(data[8..12].try_into().unwrap()),
                }),
                _ => return Err(Error::Parse("invalid filesystem entry found".into())),
            };
            fst_entries.push(entry);
        }

        let fst_string_addr = fst_addr + 12 * num_entries;
        Ok(FST {
            entries: fst_entries,
            string_table_offset: fst_string_addr,
        })
    }
}

impl FST {
    /// Gets the number of entries (files and directories) in the FST.
    #[must_use]
    #[allow(clippy::cast_possible_truncation)]
    pub fn get_number_of_entries(&self) -> u32 {
        // This `as` use is OK because the GCN cannot have more than 24-bits worth of file
        // entries.
        self.entries.len() as u32
    }
}

/// Trims null and whitespace characters (in that order) from the given string.
fn trim(string: &str) -> &str {
    string.trim_matches('\0').trim()
}

impl<T: Read + Seek> HeaderRead for T {
    fn read_gcn_header(&mut self) -> Result<Header, Error> {
        // Check file header, and 2 magic bytes
        self.seek(SeekFrom::Start(0))?;
        let mut game_code = [0u8; 4];
        self.read_exact(&mut game_code)?;
        let game_code = str::from_utf8(&game_code)?;
        let maker_code = self.read_u16::<BigEndian>()?.to_be_bytes();
        let maker_code = str::from_utf8(&maker_code)?.to_string();
        let disk_id = self.read_u8()?;
        let version = self.read_u8()?;
        let audio_streaming = self.read_u8()?;
        let stream_buffer_size = self.read_u8()?;
        self.seek(SeekFrom::Current(0x12))?;
        let magic = self.read_u32::<BigEndian>()?;
        let mut game_name = [0u8; 0x3E0];
        self.read_exact(&mut game_name)?;
        let game_name = trim(str::from_utf8(&game_name)?).to_string();

        if game_code.len() != 4 {
            return Err(Error::Parse("bad game code string, wrong size".into()));
        }

        if !game_code.is_ascii() {
            return Err(Error::Parse("bad game code string, not ASCII".into()));
        }

        let debug_monitor_offset = self.read_u32::<BigEndian>()?;
        let debug_monitor_address = self.read_u32::<BigEndian>()?;
        self.seek(SeekFrom::Current(0x18))?;
        let executable_offset = self.read_u32::<BigEndian>()?;
        let fst_offset = self.read_u32::<BigEndian>()?;
        let fst_size = self.read_u32::<BigEndian>()?;
        let fst_max_size = self.read_u32::<BigEndian>()?;
        let user_position = self.read_u32::<BigEndian>()?;
        let user_length = self.read_u32::<BigEndian>()?;

        Ok(Header {
            console_id: game_code[0..1].to_string(),
            game_code: game_code[1..3].to_string(),
            country_code: game_code[3..4].to_string(),
            maker_code,
            disk_id,
            version,
            audio_streaming,
            stream_buffer_size,
            magic,
            game_name,
            debug_monitor_offset,
            debug_monitor_address,
            executable_offset,
            fst_offset,
            fst_size,
            fst_max_size,
            user_position,
            user_length,
        })
    }
}
