//! EXL reader — the master Excel sheet list (`exd/root.exl`).
//!
//! An EXL file is a small CSV-like text file that enumerates every sheet in
//! the game. The first line is a version header (`EXLT,2`); each subsequent
//! line is `<SheetName>,<id>` where `id` is `-1` for un-keyed sheets and a
//! non-negative integer for keyed sheets. [`ExcelList`] parses and holds this
//! registry for use by [`crate::sqpack::SqPack`] to resolve sheet names.

use std::collections::HashMap;

use tracing::{debug, debug_span};

use crate::error::{Error, Result};

/// Master registry of every Excel sheet in the install.
///
/// Parsed from `exd/root.exl`. Maps sheet names to optional numeric IDs;
/// an `id` of `None` means the sheet is not keyed (variant -1 on disk).
///
/// Use [`SqPack::excel_list`](crate::sqpack::SqPack::excel_list) rather than
/// calling [`ExcelList::parse`] directly.
#[derive(Debug, Clone)]
pub struct ExcelList {
    version: u32,
    entries: Vec<(String, Option<u32>)>,
    by_name: HashMap<String, usize>,
}

impl ExcelList {
    /// EXL format version; always `2` for currently supported files.
    #[must_use]
    pub fn version(&self) -> u32 {
        self.version
    }

    /// Number of sheet entries.
    #[must_use]
    pub fn len(&self) -> usize {
        self.entries.len()
    }

    /// `true` if no sheet entries are present.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Look up a sheet by name.
    ///
    /// Returns `Some(Some(id))` for a keyed sheet, `Some(None)` for an
    /// un-keyed sheet, and `None` if the sheet is not in this list.
    #[must_use]
    pub fn get(&self, name: &str) -> Option<Option<u32>> {
        self.by_name.get(name).map(|&idx| self.entries[idx].1)
    }

    /// Returns `true` if a sheet with `name` is present, regardless of whether it is keyed.
    #[must_use]
    pub fn contains(&self, name: &str) -> bool {
        self.by_name.contains_key(name)
    }

    /// Iterate over all entries in file order, yielding `(name, id)` pairs.
    pub fn iter(&self) -> impl Iterator<Item = (&str, Option<u32>)> {
        self.entries.iter().map(|(name, id)| (name.as_str(), *id))
    }

    /// Return the entry at the given file-order index, or `None` if out of range.
    #[must_use]
    pub(crate) fn entry(&self, idx: usize) -> Option<(&str, Option<u32>)> {
        let (name, id) = self.entries.get(idx)?;
        Some((name.as_str(), *id))
    }

    /// Parse an EXL file from its raw bytes.
    ///
    /// Accepts LF, CRLF, and mixed line endings. A single trailing blank line
    /// is tolerated. Returns an error on duplicate sheet names, invalid IDs,
    /// or an unrecognised version.
    pub fn parse(bytes: &[u8]) -> Result<Self> {
        let span = debug_span!("exl.parse", bytes = bytes.len());
        let _enter = span.enter();

        let mut lines =
            bytes
                .split(|&b| b == b'\n')
                .map(strip_cr)
                .enumerate()
                .map(|(idx, line)| {
                    #[allow(clippy::cast_possible_truncation)]
                    let line_no = (idx as u32).saturating_add(1);
                    (line_no, line)
                });

        let (_, header_line) = lines.next().ok_or_else(|| Error::InvalidExlHeader {
            found: String::new(),
        })?;
        let version = parse_header(header_line)?;

        let mut entries: Vec<(String, Option<u32>)> = Vec::new();
        let mut by_name: HashMap<String, usize> = HashMap::new();
        let mut trailing_blank_seen = false;

        for (line_no, raw) in lines {
            if raw.is_empty() {
                trailing_blank_seen = true;
                continue;
            }
            if trailing_blank_seen {
                return Err(Error::MalformedExlEntry {
                    line: line_no,
                    reason: "blank line between entries",
                });
            }

            let (name, id) = parse_entry(line_no, raw)?;
            if by_name.contains_key(&name) {
                return Err(Error::MalformedExlEntry {
                    line: line_no,
                    reason: "duplicate sheet name",
                });
            }
            let idx = entries.len();
            by_name.insert(name.clone(), idx);
            entries.push((name, id));
        }

        debug!(version, entries = entries.len(), "parsed EXL",);

        Ok(Self {
            version,
            entries,
            by_name,
        })
    }
}

fn strip_cr(line: &[u8]) -> &[u8] {
    if let Some((&b'\r', rest)) = line.split_last() {
        rest
    } else {
        line
    }
}

fn parse_header(line: &[u8]) -> Result<u32> {
    let header_string = || String::from_utf8_lossy(line).into_owned();

    let (magic, version_field) =
        split_once_byte(line, b',').ok_or_else(|| Error::InvalidExlHeader {
            found: header_string(),
        })?;
    if magic != b"EXLT" {
        return Err(Error::InvalidExlHeader {
            found: header_string(),
        });
    }
    let version_str = std::str::from_utf8(version_field).map_err(|_| Error::InvalidExlHeader {
        found: header_string(),
    })?;
    let version: u32 = version_str.parse().map_err(|_| Error::InvalidExlHeader {
        found: header_string(),
    })?;
    if version != 2 {
        return Err(Error::UnsupportedExlVersion { version });
    }
    Ok(version)
}

fn parse_entry(line_no: u32, raw: &[u8]) -> Result<(String, Option<u32>)> {
    let (name_bytes, id_bytes) = split_once_byte(raw, b',').ok_or(Error::MalformedExlEntry {
        line: line_no,
        reason: "missing comma separator",
    })?;

    if name_bytes.is_empty() {
        return Err(Error::MalformedExlEntry {
            line: line_no,
            reason: "empty sheet name",
        });
    }
    let name = std::str::from_utf8(name_bytes)
        .map_err(|_| Error::MalformedExlEntry {
            line: line_no,
            reason: "sheet name is not valid UTF-8",
        })?
        .to_owned();

    let id_str = std::str::from_utf8(id_bytes).map_err(|_| Error::MalformedExlEntry {
        line: line_no,
        reason: "sheet id is not valid UTF-8",
    })?;
    if id_str.is_empty() {
        return Err(Error::MalformedExlEntry {
            line: line_no,
            reason: "empty sheet id",
        });
    }
    let id_i32: i32 = id_str.parse().map_err(|_| Error::MalformedExlEntry {
        line: line_no,
        reason: "sheet id is not a decimal i32",
    })?;
    let id = match id_i32 {
        -1 => None,
        v if v < 0 => {
            return Err(Error::MalformedExlEntry {
                line: line_no,
                reason: "negative sheet id other than -1",
            });
        }
        #[allow(clippy::cast_sign_loss)]
        v => Some(v as u32),
    };

    Ok((name, id))
}

fn split_once_byte(slice: &[u8], needle: u8) -> Option<(&[u8], &[u8])> {
    let pos = slice.iter().position(|&b| b == needle)?;
    Some((&slice[..pos], &slice[pos + 1..]))
}

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

    const HAPPY_LF: &str = "EXLT,2\nItem,1\nQuest,2\nUnkeyed,-1\nPlaceName,3\n";

    #[test]
    fn parses_happy_path_lf() {
        let list = ExcelList::parse(HAPPY_LF.as_bytes()).expect("parse");
        assert_eq!(list.version(), 2);
        assert_eq!(list.len(), 4);
        assert!(!list.is_empty());
        let pairs: Vec<_> = list.iter().collect();
        assert_eq!(
            pairs,
            vec![
                ("Item", Some(1)),
                ("Quest", Some(2)),
                ("Unkeyed", None),
                ("PlaceName", Some(3)),
            ]
        );
    }

    #[test]
    fn parses_happy_path_crlf() {
        let crlf = HAPPY_LF.replace('\n', "\r\n");
        let list = ExcelList::parse(crlf.as_bytes()).expect("parse");
        assert_eq!(list.len(), 4);
        assert_eq!(list.get("Item"), Some(Some(1)));
        assert_eq!(list.get("Unkeyed"), Some(None));
    }

    #[test]
    fn parses_mixed_line_endings() {
        let mixed = "EXLT,2\r\nItem,1\nQuest,2\r\nUnkeyed,-1\n".as_bytes();
        let list = ExcelList::parse(mixed).expect("parse");
        assert_eq!(list.len(), 3);
        assert_eq!(list.get("Quest"), Some(Some(2)));
    }

    #[test]
    fn tolerates_trailing_blank_line() {
        let with_blank = "EXLT,2\nItem,1\nQuest,2\n\n";
        let list = ExcelList::parse(with_blank.as_bytes()).expect("parse");
        assert_eq!(list.len(), 2);
    }

    #[test]
    fn tolerates_no_trailing_newline() {
        let no_trailing = "EXLT,2\nItem,1";
        let list = ExcelList::parse(no_trailing.as_bytes()).expect("parse");
        assert_eq!(list.len(), 1);
        assert_eq!(list.get("Item"), Some(Some(1)));
    }

    #[test]
    fn get_distinguishes_present_keyed_present_unkeyed_absent() {
        let list = ExcelList::parse(HAPPY_LF.as_bytes()).expect("parse");
        assert_eq!(list.get("Item"), Some(Some(1)));
        assert_eq!(list.get("Unkeyed"), Some(None));
        assert_eq!(list.get("Nope"), None);
    }

    #[test]
    fn contains_covers_keyed_and_unkeyed() {
        let list = ExcelList::parse(HAPPY_LF.as_bytes()).expect("parse");
        assert!(list.contains("Item"));
        assert!(list.contains("Unkeyed"));
        assert!(!list.contains("Nope"));
    }

    #[test]
    fn missing_magic_errors() {
        let bytes = b"XYZ,2\nItem,1\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(err, Error::InvalidExlHeader { ref found } if found == "XYZ,2"));
    }

    #[test]
    fn missing_header_comma_errors() {
        let bytes = b"EXLT2\nItem,1\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(err, Error::InvalidExlHeader { .. }));
    }

    #[test]
    fn header_non_numeric_version_errors() {
        let bytes = b"EXLT,two\nItem,1\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(err, Error::InvalidExlHeader { .. }));
    }

    #[test]
    fn empty_input_errors() {
        let err = ExcelList::parse(b"").unwrap_err();
        assert!(matches!(err, Error::InvalidExlHeader { .. }));
    }

    #[test]
    fn unsupported_version_errors() {
        let bytes = b"EXLT,3\nItem,1\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(err, Error::UnsupportedExlVersion { version: 3 }));
    }

    #[test]
    fn version_one_is_unsupported() {
        let bytes = b"EXLT,1\nItem,1\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(err, Error::UnsupportedExlVersion { version: 1 }));
    }

    #[test]
    fn entry_missing_comma_errors() {
        let bytes = b"EXLT,2\nItem\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::MalformedExlEntry {
                line: 2,
                reason: "missing comma separator"
            }
        ));
    }

    #[test]
    fn entry_empty_name_errors() {
        let bytes = b"EXLT,2\n,1\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::MalformedExlEntry {
                line: 2,
                reason: "empty sheet name"
            }
        ));
    }

    #[test]
    fn entry_non_numeric_id_errors() {
        let bytes = b"EXLT,2\nItem,abc\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::MalformedExlEntry {
                line: 2,
                reason: "sheet id is not a decimal i32"
            }
        ));
    }

    #[test]
    fn entry_empty_id_errors() {
        let bytes = b"EXLT,2\nItem,\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::MalformedExlEntry {
                line: 2,
                reason: "empty sheet id"
            }
        ));
    }

    #[test]
    fn entry_negative_non_minus_one_id_errors() {
        let bytes = b"EXLT,2\nItem,-2\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::MalformedExlEntry {
                line: 2,
                reason: "negative sheet id other than -1"
            }
        ));
    }

    #[test]
    fn blank_line_between_entries_errors() {
        let bytes = b"EXLT,2\nItem,1\n\nQuest,2\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::MalformedExlEntry {
                line: 4,
                reason: "blank line between entries"
            }
        ));
    }

    #[test]
    fn line_number_reflects_position_after_crlf() {
        let bytes = b"EXLT,2\r\nItem,1\r\nQuest,abc\r\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(err, Error::MalformedExlEntry { line: 3, .. }));
    }

    #[test]
    fn allows_slash_and_underscore_in_names() {
        let bytes = b"EXLT,2\nfoo/bar,1\nbaz_qux,2\n";
        let list = ExcelList::parse(bytes).expect("parse");
        assert_eq!(list.get("foo/bar"), Some(Some(1)));
        assert_eq!(list.get("baz_qux"), Some(Some(2)));
    }

    #[test]
    fn id_zero_is_some() {
        let bytes = b"EXLT,2\nItem,0\n";
        let list = ExcelList::parse(bytes).expect("parse");
        assert_eq!(list.get("Item"), Some(Some(0)));
    }

    #[test]
    fn header_only_file_parses_empty() {
        let with_newline = ExcelList::parse(b"EXLT,2\n").expect("parse");
        assert_eq!(with_newline.version(), 2);
        assert_eq!(with_newline.len(), 0);
        assert!(with_newline.is_empty());

        let no_newline = ExcelList::parse(b"EXLT,2").expect("parse");
        assert_eq!(no_newline.version(), 2);
        assert_eq!(no_newline.len(), 0);
        assert!(no_newline.is_empty());
    }

    #[test]
    fn duplicate_sheet_name_errors() {
        let bytes = b"EXLT,2\nItem,1\nItem,2\n";
        let err = ExcelList::parse(bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::MalformedExlEntry {
                line: 3,
                reason: "duplicate sheet name"
            }
        ));
    }

    #[test]
    fn entry_returns_by_file_order_index_and_none_oob() {
        let list = ExcelList::parse(HAPPY_LF.as_bytes()).expect("parse");
        assert_eq!(list.entry(0), Some(("Item", Some(1))));
        assert_eq!(list.entry(1), Some(("Quest", Some(2))));
        assert_eq!(list.entry(2), Some(("Unkeyed", None)));
        assert_eq!(list.entry(3), Some(("PlaceName", Some(3))));
        assert_eq!(list.entry(4), None);
        assert_eq!(list.entry(usize::MAX), None);
    }

    #[test]
    fn entry_name_preserves_leading_whitespace() {
        let bytes = b"EXLT,2\n Item,5\n";
        let list = ExcelList::parse(bytes).expect("parse");
        assert_eq!(list.get(" Item"), Some(Some(5)));
        assert_eq!(list.get("Item"), None);
    }

    #[test]
    fn id_i32_max_is_accepted_as_some() {
        let line = "EXLT,2\nBig,2147483647\n";
        let list = ExcelList::parse(line.as_bytes()).expect("parse");
        assert_eq!(list.get("Big"), Some(Some(2_147_483_647u32)));
    }
}
