//! EXH reader — the per-sheet Excel header (`exd/<Sheet>.exh`).
//!
//! An EXH file describes a single Excel sheet's schema: the column types and
//! offsets within each fixed-size row, the page ranges that partition the row
//! ID space into separate `.exd` files, and the language variants available.
//! It does **not** contain row data; that lives in [`crate::excel::ExcelData`].
//!
//! [`ExcelHeader::parse`] is the entry point. The resulting struct is consumed
//! by [`crate::excel::Sheet`] and forwarded to [`crate::excel::ExcelData::parse`]
//! to supply schema context during data decoding.

use std::io::{Cursor, Read};

use binrw::BinRead;
use tracing::{debug, debug_span};

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

const EXH_MAGIC: [u8; 4] = *b"EXHF";

const EXH_VERSION: u16 = 3;

const EXH_FIXED_HEADER_SIZE: usize = 32;

const VARIANT_DEFAULT: u8 = 1;
const VARIANT_SUBROWS: u8 = 2;

/// Per-sheet Excel header: column schema, page ranges, and language variants.
///
/// Parsed from `exd/<Sheet>.exh`. Pass the header to
/// [`ExcelData::parse`](crate::excel::ExcelData::parse) when loading a page,
/// and to [`Sheet`](crate::excel::Sheet) for ergonomic row access.
#[derive(Debug, Clone)]
pub struct ExcelHeader {
    version: u16,
    row_size: u16,
    kind: SheetKind,
    total_row_count: u32,
    columns: Vec<ColumnDefinition>,
    pages: Vec<PageDefinition>,
    languages: Vec<Language>,
}

impl ExcelHeader {
    /// EXH format version; always `3` for currently supported files.
    #[must_use]
    pub fn version(&self) -> u16 {
        self.version
    }

    /// Byte size of the fixed portion of each row's data struct.
    #[must_use]
    pub fn row_size(&self) -> u16 {
        self.row_size
    }

    /// Whether rows are simple ([`SheetKind::Default`]) or have sub-rows ([`SheetKind::SubRows`]).
    #[must_use]
    pub fn kind(&self) -> SheetKind {
        self.kind
    }

    /// Sum of all page row counts, i.e. the total number of rows in the sheet.
    #[must_use]
    pub fn total_row_count(&self) -> u32 {
        self.total_row_count
    }

    /// Column definitions in declaration order.
    ///
    /// Each entry specifies a [`ColumnDataType`] and the byte offset within
    /// the fixed row struct where that column's value starts.
    #[must_use]
    pub fn columns(&self) -> &[ColumnDefinition] {
        &self.columns
    }

    /// Page descriptors that partition the sheet's row-ID space.
    ///
    /// Each [`PageDefinition`] corresponds to one `.exd` file on disk.
    #[must_use]
    pub fn pages(&self) -> &[PageDefinition] {
        &self.pages
    }

    /// Language variants available for this sheet.
    ///
    /// [`Language::None`] means the sheet is language-independent (a single
    /// `.exd` per page, no language suffix in the filename).
    #[must_use]
    pub fn languages(&self) -> &[Language] {
        &self.languages
    }

    /// Parse an EXH file from its raw bytes.
    ///
    /// Validates the `EXHF` magic, checks the version, decodes columns/pages/
    /// languages, and verifies that page row counts sum to the declared total.
    pub fn parse(bytes: &[u8]) -> Result<Self> {
        let span = debug_span!("exh.parse", bytes = bytes.len());
        let _enter = span.enter();

        if bytes.len() < EXH_FIXED_HEADER_SIZE {
            return Err(Error::InvalidExhLength {
                found: bytes.len(),
                minimum: EXH_FIXED_HEADER_SIZE,
            });
        }

        let mut cursor = Cursor::new(bytes);
        let wire = ExhWireHeader::read(&mut cursor).map_err(|e| Error::decode("exh_header", &e))?;

        if wire.magic != EXH_MAGIC {
            return Err(Error::InvalidMagic);
        }
        if wire.version != EXH_VERSION {
            return Err(Error::UnsupportedExhVersion {
                version: wire.version,
            });
        }
        let kind = SheetKind::from_raw(wire.variant)?;

        let mut columns = Vec::with_capacity(wire.column_count as usize);
        for _ in 0..wire.column_count {
            let wire_col =
                ExhWireColumn::read(&mut cursor).map_err(|e| Error::decode("exh_column", &e))?;
            columns.push(ColumnDefinition {
                data_type: ColumnDataType::from_raw(wire_col.data_type)?,
                offset: wire_col.offset,
            });
        }

        let mut pages = Vec::with_capacity(wire.page_count as usize);
        for _ in 0..wire.page_count {
            let wire_page =
                ExhWirePage::read(&mut cursor).map_err(|e| Error::decode("exh_page", &e))?;
            pages.push(PageDefinition {
                start_id: wire_page.start_id,
                row_count: wire_page.row_count,
            });
        }

        let mut languages = Vec::with_capacity(wire.language_count as usize);
        for _ in 0..wire.language_count {
            let mut lang_byte = [0u8; 1];
            cursor
                .read_exact(&mut lang_byte)
                .map_err(|e| Error::decode("exh_language", &binrw::Error::Io(e)))?;
            languages.push(Language::from_raw(lang_byte[0])?);
            consume_cstr(&mut cursor)?;
        }

        let mut sum: u32 = 0;
        for p in &pages {
            match sum.checked_add(p.row_count) {
                Some(next) => sum = next,
                None => {
                    return Err(Error::InconsistentExhRowCount {
                        declared: wire.row_count,
                        sum,
                    });
                }
            }
        }
        if sum != wire.row_count {
            return Err(Error::InconsistentExhRowCount {
                declared: wire.row_count,
                sum,
            });
        }

        debug!(
            version = wire.version,
            columns = columns.len(),
            pages = pages.len(),
            languages = languages.len(),
            total_row_count = wire.row_count,
            "parsed EXH",
        );

        Ok(Self {
            version: wire.version,
            row_size: wire.data_offset,
            kind,
            total_row_count: wire.row_count,
            columns,
            pages,
            languages,
        })
    }
}

/// Type and byte offset of a single column within a row's fixed-size struct.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ColumnDefinition {
    /// Scalar type stored in this column.
    pub data_type: ColumnDataType,
    /// Byte offset from the start of the row's fixed struct to this column's value.
    pub offset: u16,
}

/// Describes one `.exd` page: the first row ID it contains and how many rows.
///
/// The page's filename is `exd/<Sheet>_<start_id>[_<lang>].exd`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PageDefinition {
    /// Row ID of the first row in this page.
    pub start_id: u32,
    /// Number of rows in this page.
    pub row_count: u32,
}

/// Whether a sheet uses simple rows or hierarchical sub-rows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SheetKind {
    /// Each row ID maps to exactly one flat row.
    Default,
    /// Each row ID contains a variable number of indexed sub-rows.
    SubRows,
}

impl SheetKind {
    fn from_raw(raw: u8) -> Result<Self> {
        match raw {
            VARIANT_DEFAULT => Ok(SheetKind::Default),
            VARIANT_SUBROWS => Ok(SheetKind::SubRows),
            other => Err(Error::MalformedExhSheetKind { found: other }),
        }
    }
}

/// Language variant for a sheet page.
///
/// Determines the `_<lang>` suffix appended to `.exd` filenames when loading
/// a specific language; [`Language::None`] means there is no suffix and the
/// sheet has a single language-independent page per range.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Language {
    /// Language-independent sheet; no suffix in the `.exd` filename.
    None,
    Japanese,
    English,
    German,
    French,
    ChineseSimplified,
    ChineseTraditional,
    Korean,
    TraditionalChinese,
}

impl Language {
    fn from_raw(raw: u8) -> Result<Self> {
        match raw {
            0 => Ok(Language::None),
            1 => Ok(Language::Japanese),
            2 => Ok(Language::English),
            3 => Ok(Language::German),
            4 => Ok(Language::French),
            5 => Ok(Language::ChineseSimplified),
            6 => Ok(Language::ChineseTraditional),
            7 => Ok(Language::Korean),
            8 => Ok(Language::TraditionalChinese),
            other => Err(Error::MalformedExhLanguage { found: other }),
        }
    }
}

/// Scalar type tag for a column, as declared in the EXH wire format.
///
/// The `PackedBoolN` variants share the same byte in the fixed struct; each
/// variant extracts one bit. Use [`ColumnDataType::packed_bool_bit`] to get
/// the bit index (`0`–`7`) for a packed-bool column.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ColumnDataType {
    /// NUL-terminated UTF-8 string; the fixed cell holds a `u32` offset into
    /// the row's trailing string area.
    String,
    Bool,
    Int8,
    UInt8,
    Int16,
    UInt16,
    Int32,
    UInt32,
    Float32,
    Int64,
    UInt64,
    /// Bit 0 of a packed-bool byte.
    PackedBool0,
    /// Bit 1 of a packed-bool byte.
    PackedBool1,
    /// Bit 2 of a packed-bool byte.
    PackedBool2,
    /// Bit 3 of a packed-bool byte.
    PackedBool3,
    /// Bit 4 of a packed-bool byte.
    PackedBool4,
    /// Bit 5 of a packed-bool byte.
    PackedBool5,
    /// Bit 6 of a packed-bool byte.
    PackedBool6,
    /// Bit 7 of a packed-bool byte.
    PackedBool7,
}

impl ColumnDataType {
    fn from_raw(raw: u16) -> Result<Self> {
        match raw {
            0x0000 => Ok(ColumnDataType::String),
            0x0001 => Ok(ColumnDataType::Bool),
            0x0002 => Ok(ColumnDataType::Int8),
            0x0003 => Ok(ColumnDataType::UInt8),
            0x0004 => Ok(ColumnDataType::Int16),
            0x0005 => Ok(ColumnDataType::UInt16),
            0x0006 => Ok(ColumnDataType::Int32),
            0x0007 => Ok(ColumnDataType::UInt32),
            0x0009 => Ok(ColumnDataType::Float32),
            0x000B => Ok(ColumnDataType::Int64),
            0x000C => Ok(ColumnDataType::UInt64),
            0x0019 => Ok(ColumnDataType::PackedBool0),
            0x001A => Ok(ColumnDataType::PackedBool1),
            0x001B => Ok(ColumnDataType::PackedBool2),
            0x001C => Ok(ColumnDataType::PackedBool3),
            0x001D => Ok(ColumnDataType::PackedBool4),
            0x001E => Ok(ColumnDataType::PackedBool5),
            0x001F => Ok(ColumnDataType::PackedBool6),
            0x0020 => Ok(ColumnDataType::PackedBool7),
            other => Err(Error::MalformedExhColumnType { found: other }),
        }
    }

    /// Returns the bit index (`0`–`7`) for packed-bool variants, or `None` for all other types.
    #[must_use]
    pub fn packed_bool_bit(self) -> Option<u8> {
        match self {
            ColumnDataType::PackedBool0 => Some(0),
            ColumnDataType::PackedBool1 => Some(1),
            ColumnDataType::PackedBool2 => Some(2),
            ColumnDataType::PackedBool3 => Some(3),
            ColumnDataType::PackedBool4 => Some(4),
            ColumnDataType::PackedBool5 => Some(5),
            ColumnDataType::PackedBool6 => Some(6),
            ColumnDataType::PackedBool7 => Some(7),
            _ => None,
        }
    }
}

#[derive(Debug, BinRead)]
#[brw(big)]
struct ExhWireHeader {
    magic: [u8; 4],
    version: u16,
    data_offset: u16,
    column_count: u16,
    page_count: u16,
    language_count: u16,
    _unknown1: u16,
    _unknown2: u8,
    variant: u8,
    _unknown3: u16,
    row_count: u32,
    _unknown4: [u32; 2],
}

#[derive(Debug, BinRead)]
#[brw(big)]
struct ExhWireColumn {
    data_type: u16,
    offset: u16,
}

#[derive(Debug, BinRead)]
#[brw(big)]
struct ExhWirePage {
    start_id: u32,
    row_count: u32,
}

fn consume_cstr(cursor: &mut Cursor<&[u8]>) -> Result<()> {
    loop {
        let mut byte = [0u8; 1];
        cursor
            .read_exact(&mut byte)
            .map_err(|e| Error::decode("exh_language_string", &binrw::Error::Io(e)))?;
        if byte[0] == 0 {
            return Ok(());
        }
    }
}

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

    fn build_exh(
        version: u16,
        data_offset: u16,
        variant: u8,
        columns: &[(u16, u16)],
        pages: &[(u32, u32)],
        languages: &[u8],
        row_count: u32,
    ) -> Vec<u8> {
        let mut buf = Vec::new();
        buf.extend_from_slice(&EXH_MAGIC);
        buf.extend_from_slice(&version.to_be_bytes());
        buf.extend_from_slice(&data_offset.to_be_bytes());
        let column_count =
            u16::try_from(columns.len()).expect("test fixture: column_count fits u16");
        let page_count = u16::try_from(pages.len()).expect("test fixture: page_count fits u16");
        let language_count =
            u16::try_from(languages.len()).expect("test fixture: language_count fits u16");
        buf.extend_from_slice(&column_count.to_be_bytes());
        buf.extend_from_slice(&page_count.to_be_bytes());
        buf.extend_from_slice(&language_count.to_be_bytes());
        buf.extend_from_slice(&0u16.to_be_bytes());
        buf.push(0);
        buf.push(variant);
        buf.extend_from_slice(&0u16.to_be_bytes());
        buf.extend_from_slice(&row_count.to_be_bytes());
        buf.extend_from_slice(&0u32.to_be_bytes());
        buf.extend_from_slice(&0u32.to_be_bytes());

        for (ty, off) in columns {
            buf.extend_from_slice(&ty.to_be_bytes());
            buf.extend_from_slice(&off.to_be_bytes());
        }
        for (start_id, rc) in pages {
            buf.extend_from_slice(&start_id.to_be_bytes());
            buf.extend_from_slice(&rc.to_be_bytes());
        }
        for &lang in languages {
            buf.push(lang);
            buf.push(0);
        }
        buf
    }

    #[test]
    fn parses_default_variant_happy_path() {
        let bytes = build_exh(
            3,
            16,
            VARIANT_DEFAULT,
            &[(0x0006, 0), (0x0001, 4), (0x0019, 5)],
            &[(0, 100), (100, 50)],
            &[1, 2, 3],
            150,
        );
        let exh = ExcelHeader::parse(&bytes).expect("parse");
        assert_eq!(exh.version(), 3);
        assert_eq!(exh.row_size(), 16);
        assert_eq!(exh.kind(), SheetKind::Default);
        assert_eq!(exh.total_row_count(), 150);
        assert_eq!(exh.columns().len(), 3);
        assert_eq!(exh.columns()[0].data_type, ColumnDataType::Int32);
        assert_eq!(exh.columns()[0].offset, 0);
        assert_eq!(exh.columns()[1].data_type, ColumnDataType::Bool);
        assert_eq!(exh.columns()[2].data_type, ColumnDataType::PackedBool0);
        assert_eq!(exh.pages().len(), 2);
        assert_eq!(
            exh.pages()[0],
            PageDefinition {
                start_id: 0,
                row_count: 100
            }
        );
        assert_eq!(
            exh.pages()[1],
            PageDefinition {
                start_id: 100,
                row_count: 50
            }
        );
        assert_eq!(
            exh.languages(),
            &[Language::Japanese, Language::English, Language::German],
        );
    }

    #[test]
    fn parses_subrows_variant_happy_path() {
        let bytes = build_exh(3, 4, VARIANT_SUBROWS, &[(0x0007, 0)], &[(0, 10)], &[0], 10);
        let exh = ExcelHeader::parse(&bytes).expect("parse");
        assert_eq!(exh.kind(), SheetKind::SubRows);
        assert_eq!(exh.languages(), &[Language::None]);
    }

    #[test]
    fn all_column_data_type_discriminants_round_trip() {
        let cases: &[(u16, ColumnDataType)] = &[
            (0x0000, ColumnDataType::String),
            (0x0001, ColumnDataType::Bool),
            (0x0002, ColumnDataType::Int8),
            (0x0003, ColumnDataType::UInt8),
            (0x0004, ColumnDataType::Int16),
            (0x0005, ColumnDataType::UInt16),
            (0x0006, ColumnDataType::Int32),
            (0x0007, ColumnDataType::UInt32),
            (0x0009, ColumnDataType::Float32),
            (0x000B, ColumnDataType::Int64),
            (0x000C, ColumnDataType::UInt64),
            (0x0019, ColumnDataType::PackedBool0),
            (0x001A, ColumnDataType::PackedBool1),
            (0x001B, ColumnDataType::PackedBool2),
            (0x001C, ColumnDataType::PackedBool3),
            (0x001D, ColumnDataType::PackedBool4),
            (0x001E, ColumnDataType::PackedBool5),
            (0x001F, ColumnDataType::PackedBool6),
            (0x0020, ColumnDataType::PackedBool7),
        ];
        for &(raw, expected) in cases {
            assert_eq!(
                ColumnDataType::from_raw(raw).expect("decode"),
                expected,
                "discriminant {raw:#06x}",
            );
        }
    }

    #[test]
    fn packed_bool_bit_returns_index_for_packed_variants() {
        assert_eq!(ColumnDataType::PackedBool0.packed_bool_bit(), Some(0));
        assert_eq!(ColumnDataType::PackedBool1.packed_bool_bit(), Some(1));
        assert_eq!(ColumnDataType::PackedBool2.packed_bool_bit(), Some(2));
        assert_eq!(ColumnDataType::PackedBool3.packed_bool_bit(), Some(3));
        assert_eq!(ColumnDataType::PackedBool4.packed_bool_bit(), Some(4));
        assert_eq!(ColumnDataType::PackedBool5.packed_bool_bit(), Some(5));
        assert_eq!(ColumnDataType::PackedBool6.packed_bool_bit(), Some(6));
        assert_eq!(ColumnDataType::PackedBool7.packed_bool_bit(), Some(7));
    }

    #[test]
    fn packed_bool_bit_returns_none_for_scalar_variants() {
        for ty in [
            ColumnDataType::String,
            ColumnDataType::Bool,
            ColumnDataType::Int8,
            ColumnDataType::UInt8,
            ColumnDataType::Int16,
            ColumnDataType::UInt16,
            ColumnDataType::Int32,
            ColumnDataType::UInt32,
            ColumnDataType::Float32,
            ColumnDataType::Int64,
            ColumnDataType::UInt64,
        ] {
            assert_eq!(ty.packed_bool_bit(), None, "type {ty:?}");
        }
    }

    #[test]
    fn all_language_discriminants_round_trip() {
        let cases: &[(u8, Language)] = &[
            (0, Language::None),
            (1, Language::Japanese),
            (2, Language::English),
            (3, Language::German),
            (4, Language::French),
            (5, Language::ChineseSimplified),
            (6, Language::ChineseTraditional),
            (7, Language::Korean),
            (8, Language::TraditionalChinese),
        ];
        for &(raw, expected) in cases {
            assert_eq!(
                Language::from_raw(raw).expect("decode"),
                expected,
                "lang {raw}"
            );
        }
    }

    #[test]
    fn rejects_bad_magic() {
        let mut bytes = build_exh(3, 4, VARIANT_DEFAULT, &[], &[], &[], 0);
        bytes[0] = b'X';
        let err = ExcelHeader::parse(&bytes).unwrap_err();
        assert!(matches!(err, Error::InvalidMagic));
    }

    #[test]
    fn rejects_unsupported_version() {
        let bytes = build_exh(4, 4, VARIANT_DEFAULT, &[], &[], &[], 0);
        let err = ExcelHeader::parse(&bytes).unwrap_err();
        assert!(matches!(err, Error::UnsupportedExhVersion { version: 4 }));
    }

    #[test]
    fn rejects_unknown_variant() {
        let bytes = build_exh(3, 4, 3, &[], &[], &[], 0);
        let err = ExcelHeader::parse(&bytes).unwrap_err();
        assert!(matches!(err, Error::MalformedExhSheetKind { found: 3 }));
    }

    #[test]
    fn rejects_zero_variant() {
        let bytes = build_exh(3, 4, 0, &[], &[], &[], 0);
        let err = ExcelHeader::parse(&bytes).unwrap_err();
        assert!(matches!(err, Error::MalformedExhSheetKind { found: 0 }));
    }

    #[test]
    fn rejects_unknown_column_type() {
        let bytes = build_exh(3, 4, VARIANT_DEFAULT, &[(0x0050, 0)], &[(0, 1)], &[1], 1);
        let err = ExcelHeader::parse(&bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::MalformedExhColumnType { found: 0x0050 }
        ));
    }

    #[test]
    fn rejects_reserved_column_type_gaps() {
        for raw in [0x0008u16, 0x000Au16] {
            let bytes = build_exh(3, 4, VARIANT_DEFAULT, &[(raw, 0)], &[(0, 1)], &[1], 1);
            let err = ExcelHeader::parse(&bytes).unwrap_err();
            assert!(
                matches!(err, Error::MalformedExhColumnType { found } if found == raw),
                "raw {raw:#06x} should be rejected",
            );
        }
    }

    #[test]
    fn rejects_unknown_language() {
        let bytes = build_exh(3, 4, VARIANT_DEFAULT, &[(0x0001, 0)], &[(0, 1)], &[99], 1);
        let err = ExcelHeader::parse(&bytes).unwrap_err();
        assert!(matches!(err, Error::MalformedExhLanguage { found: 99 }));
    }

    #[test]
    fn rejects_inconsistent_row_count() {
        let bytes = build_exh(
            3,
            4,
            VARIANT_DEFAULT,
            &[(0x0001, 0)],
            &[(0, 100), (100, 50)],
            &[1],
            42,
        );
        let err = ExcelHeader::parse(&bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::InconsistentExhRowCount {
                declared: 42,
                sum: 150,
            }
        ));
    }

    #[test]
    fn rejects_input_shorter_than_fixed_header() {
        let bytes = vec![0u8; 16];
        let err = ExcelHeader::parse(&bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::InvalidExhLength {
                found: 16,
                minimum: EXH_FIXED_HEADER_SIZE,
            }
        ));
    }

    #[test]
    fn truncated_column_table_surfaces_as_decode() {
        let mut bytes = build_exh(
            3,
            8,
            VARIANT_DEFAULT,
            &[(0x0001, 0), (0x0002, 1), (0x0006, 4)],
            &[(0, 1)],
            &[1],
            1,
        );
        bytes.truncate(EXH_FIXED_HEADER_SIZE + 4);
        let err = ExcelHeader::parse(&bytes).unwrap_err();
        assert!(matches!(err, Error::Decode { .. }), "got {err:?}");
    }

    #[test]
    fn row_count_overflow_surfaces_as_inconsistent() {
        let bytes = build_exh(
            3,
            4,
            VARIANT_DEFAULT,
            &[(0x0001, 0)],
            &[(0, u32::MAX), (0, 1)],
            &[1],
            0,
        );
        let err = ExcelHeader::parse(&bytes).unwrap_err();
        assert!(matches!(
            err,
            Error::InconsistentExhRowCount {
                declared: 0,
                sum: u32::MAX,
            },
        ));
    }

    #[test]
    fn empty_columns_pages_languages_round_trip() {
        let bytes = build_exh(3, 0, VARIANT_DEFAULT, &[], &[], &[], 0);
        let exh = ExcelHeader::parse(&bytes).expect("parse");
        assert_eq!(exh.columns().len(), 0);
        assert_eq!(exh.pages().len(), 0);
        assert_eq!(exh.languages().len(), 0);
        assert_eq!(exh.total_row_count(), 0);
    }

    #[test]
    fn truncates_mid_language_section_errors() {
        let mut bytes = build_exh(3, 4, VARIANT_DEFAULT, &[(0x0001, 0)], &[(0, 1)], &[1, 2], 1);
        let final_nul = bytes.pop();
        assert_eq!(final_nul, Some(0));
        let err = ExcelHeader::parse(&bytes).unwrap_err();
        assert!(
            matches!(err, Error::Decode { context, .. } if context == "exh_language_string"),
            "got {err:?}",
        );
    }
}
