//! Bidirectional conversion between Microsoft code pages and Unicode.
//!
//! MS-DOS and the FAT family of filesystems stored text in one of a family of
//! OEM code pages selected by country/locale, and Windows-era files add the
//! ANSI ("125x") family on top. This crate converts those bytes to and from
//! Unicode with emulator-grade fidelity: it owns the canonical tables rather
//! than linking ICU, so it stays small, self-contained, and cross-compiles
//! cleanly.
//!
//! The tables are **Microsoft's** mappings as published by the Unicode
//! Consortium (`VENDORS/MICSFT/`) — not the IBM or Oracle variants, which
//! differ in a handful of slots. Two families are exposed:
//!
//! - [`DosEncoding`] — the sixteen single-byte OEM pages, always available,
//!   plus the double-byte CJK pages (932 Shift-JIS, 936 GBK, 949 Korean,
//!   950 Big5) behind the on-by-default `dbcs` feature (their tables are
//!   large).
//! - [`WindowsEncoding`] — the ten single-byte ANSI pages (874 Thai and
//!   1250–1258), always available.
//!
//! Decoding is fallible: several pages leave byte values undefined, and the
//! double-byte pages add truncated/undefined pair failures. The `decode_lossy`
//! methods substitute U+FFFD instead. Anything a page decodes re-encodes to
//! the same bytes, except the handful of CP932/CP950 codes that share a
//! scalar — those re-encode to Microsoft's preferred code, exactly as Windows
//! does.
//!
//! ```
//! use msdos_encodings::{DosEncoding, WindowsEncoding};
//!
//! let enc = DosEncoding::default(); // code page 437
//! assert_eq!(enc.decode(b"Caf\x82").unwrap(), "Café");
//! assert_eq!(enc.encode("Café").unwrap(), b"Caf\x82");
//!
//! let enc = WindowsEncoding::default(); // code page 1252
//! assert_eq!(enc.decode(b"Caf\xE9").unwrap(), "Café");
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]

use std::fmt;

mod tables;

#[cfg(feature = "dbcs")]
use tables::{DbcsPage, SingleEntry};

/// An MS-DOS (OEM) code page.
///
/// Non-exhaustive: more code pages may be added without it being a breaking
/// change, so external matches must include a wildcard arm.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum DosEncoding {
    /// Code page 437 — DOS Latin US, the original IBM PC page.
    #[default]
    Cp437,
    /// Code page 737 — DOS Greek.
    Cp737,
    /// Code page 775 — DOS Baltic Rim.
    Cp775,
    /// Code page 850 — DOS Latin 1 (Western European "multilingual").
    Cp850,
    /// Code page 852 — DOS Latin 2 (Central European).
    Cp852,
    /// Code page 855 — DOS Cyrillic.
    Cp855,
    /// Code page 857 — DOS Turkish.
    Cp857,
    /// Code page 860 — DOS Portuguese.
    Cp860,
    /// Code page 861 — DOS Icelandic.
    Cp861,
    /// Code page 862 — DOS Hebrew.
    Cp862,
    /// Code page 863 — DOS French Canada.
    Cp863,
    /// Code page 864 — DOS Arabic. Note `0x25` is ARABIC PERCENT SIGN, not `%`.
    Cp864,
    /// Code page 865 — DOS Nordic.
    Cp865,
    /// Code page 866 — DOS Cyrillic Russian.
    Cp866,
    /// Code page 869 — DOS Greek 2.
    Cp869,
    /// Code page 874 — DOS Thai.
    Cp874,
    /// Code page 932 — Japanese Shift-JIS (double-byte).
    #[cfg(feature = "dbcs")]
    Cp932,
    /// Code page 936 — Simplified Chinese GBK (double-byte).
    #[cfg(feature = "dbcs")]
    Cp936,
    /// Code page 949 — Korean Unified Hangul (double-byte).
    #[cfg(feature = "dbcs")]
    Cp949,
    /// Code page 950 — Traditional Chinese Big5 (double-byte).
    #[cfg(feature = "dbcs")]
    Cp950,
}

/// How one code page turns bytes into characters.
enum Codec {
    /// Every byte stands alone, looked up in a 256-entry table.
    Single(&'static [Option<char>; 256]),
    /// Lead bytes pull in a trail byte; pairs are binary-searched.
    #[cfg(feature = "dbcs")]
    Dbcs(&'static DbcsPage),
}

impl DosEncoding {
    /// Every code page this crate implements, in numeric order.
    pub const ALL: &'static [DosEncoding] = &[
        Self::Cp437,
        Self::Cp737,
        Self::Cp775,
        Self::Cp850,
        Self::Cp852,
        Self::Cp855,
        Self::Cp857,
        Self::Cp860,
        Self::Cp861,
        Self::Cp862,
        Self::Cp863,
        Self::Cp864,
        Self::Cp865,
        Self::Cp866,
        Self::Cp869,
        Self::Cp874,
        #[cfg(feature = "dbcs")]
        Self::Cp932,
        #[cfg(feature = "dbcs")]
        Self::Cp936,
        #[cfg(feature = "dbcs")]
        Self::Cp949,
        #[cfg(feature = "dbcs")]
        Self::Cp950,
    ];

    /// Select an encoding from its numeric code page identifier (as found in
    /// e.g. the DOS `CHCP` command or BPB/registry locale data), or `None` if
    /// this crate does not implement it.
    #[must_use]
    pub fn from_code_page(code_page: u16) -> Option<Self> {
        Self::ALL
            .iter()
            .copied()
            .find(|enc| enc.code_page() == code_page)
    }

    /// The numeric code page identifier (437, 850, …).
    #[must_use]
    pub fn code_page(self) -> u16 {
        match self {
            Self::Cp437 => 437,
            Self::Cp737 => 737,
            Self::Cp775 => 775,
            Self::Cp850 => 850,
            Self::Cp852 => 852,
            Self::Cp855 => 855,
            Self::Cp857 => 857,
            Self::Cp860 => 860,
            Self::Cp861 => 861,
            Self::Cp862 => 862,
            Self::Cp863 => 863,
            Self::Cp864 => 864,
            Self::Cp865 => 865,
            Self::Cp866 => 866,
            Self::Cp869 => 869,
            Self::Cp874 => 874,
            #[cfg(feature = "dbcs")]
            Self::Cp932 => 932,
            #[cfg(feature = "dbcs")]
            Self::Cp936 => 936,
            #[cfg(feature = "dbcs")]
            Self::Cp949 => 949,
            #[cfg(feature = "dbcs")]
            Self::Cp950 => 950,
        }
    }

    /// A short human-readable name, e.g. "DOS Latin US".
    #[must_use]
    pub fn name(self) -> &'static str {
        match self {
            Self::Cp437 => "DOS Latin US",
            Self::Cp737 => "DOS Greek",
            Self::Cp775 => "DOS Baltic Rim",
            Self::Cp850 => "DOS Latin 1",
            Self::Cp852 => "DOS Latin 2",
            Self::Cp855 => "DOS Cyrillic",
            Self::Cp857 => "DOS Turkish",
            Self::Cp860 => "DOS Portuguese",
            Self::Cp861 => "DOS Icelandic",
            Self::Cp862 => "DOS Hebrew",
            Self::Cp863 => "DOS French Canada",
            Self::Cp864 => "DOS Arabic",
            Self::Cp865 => "DOS Nordic",
            Self::Cp866 => "DOS Cyrillic Russian",
            Self::Cp869 => "DOS Greek 2",
            Self::Cp874 => "DOS Thai",
            #[cfg(feature = "dbcs")]
            Self::Cp932 => "Japanese Shift-JIS",
            #[cfg(feature = "dbcs")]
            Self::Cp936 => "Simplified Chinese GBK",
            #[cfg(feature = "dbcs")]
            Self::Cp949 => "Korean Unified Hangul",
            #[cfg(feature = "dbcs")]
            Self::Cp950 => "Traditional Chinese Big5",
        }
    }

    /// Decode every byte of `bytes` to Unicode, faithfully (control bytes and
    /// a trailing `NUL` included). Use [`decode_cstr`](Self::decode_cstr) for
    /// `NUL`-terminated fixed-width fields.
    ///
    /// # Errors
    ///
    /// Returns [`DecodeError::Undefined`] for a byte value the code page
    /// leaves unmapped; the double-byte pages can also return
    /// [`DecodeError::TruncatedPair`] and [`DecodeError::UndefinedPair`].
    pub fn decode(self, bytes: &[u8]) -> Result<String, DecodeError> {
        match self.codec() {
            Codec::Single(table) => decode_single(table, bytes, self.into()),
            #[cfg(feature = "dbcs")]
            Codec::Dbcs(page) => self.decode_dbcs(page, bytes),
        }
    }

    /// Decode like [`decode`](Self::decode), substituting U+FFFD REPLACEMENT
    /// CHARACTER for anything undecodable: an undefined byte, a lead byte
    /// with no trail, or an undefined pair (one U+FFFD per failed pair, both
    /// bytes consumed).
    #[must_use]
    pub fn decode_lossy(self, bytes: &[u8]) -> String {
        match self.codec() {
            Codec::Single(table) => decode_single_lossy(table, bytes),
            #[cfg(feature = "dbcs")]
            Codec::Dbcs(page) => Self::decode_dbcs_lossy(page, bytes),
        }
    }

    /// Decode up to (but not including) the first `NUL` byte — the convention
    /// for fixed-width name fields in DOS-era structures. Safe for the
    /// double-byte pages too: `0x00` never occurs as a trail byte.
    ///
    /// # Errors
    ///
    /// Returns [`DecodeError`] as [`decode`](Self::decode) does.
    pub fn decode_cstr(self, bytes: &[u8]) -> Result<String, DecodeError> {
        self.decode(cstr_prefix(bytes))
    }

    /// Encode `text` back to this code page's bytes.
    ///
    /// # Errors
    ///
    /// Returns [`EncodeError::Unmappable`] for the first character with no
    /// representation in this code page (e.g. `é` under DOS Greek, or `%`
    /// under DOS Arabic, whose `0x25` is ARABIC PERCENT SIGN).
    pub fn encode(self, text: &str) -> Result<Vec<u8>, EncodeError> {
        match self.codec() {
            Codec::Single(table) => encode_single(table, text, self.into()),
            #[cfg(feature = "dbcs")]
            Codec::Dbcs(page) => self.encode_dbcs(page, text),
        }
    }

    /// Decode a double-byte page strictly.
    #[cfg(feature = "dbcs")]
    fn decode_dbcs(self, page: &DbcsPage, bytes: &[u8]) -> Result<String, DecodeError> {
        let mut out = String::with_capacity(bytes.len());
        let mut rest = bytes;
        while let [byte, tail @ ..] = rest {
            match page.single[usize::from(*byte)] {
                SingleEntry::Map(ch) => {
                    out.push(ch);
                    rest = tail;
                }
                SingleEntry::Undefined => {
                    return Err(DecodeError::Undefined {
                        byte: *byte,
                        encoding: self.into(),
                    })
                }
                SingleEntry::Lead => {
                    let [trail, tail @ ..] = tail else {
                        return Err(DecodeError::TruncatedPair {
                            lead: *byte,
                            encoding: self.into(),
                        });
                    };
                    let ch =
                        lookup_pair(page, *byte, *trail).ok_or(DecodeError::UndefinedPair {
                            lead: *byte,
                            trail: *trail,
                            encoding: self.into(),
                        })?;
                    out.push(ch);
                    rest = tail;
                }
            }
        }
        Ok(out)
    }

    /// Decode a double-byte page, substituting U+FFFD for failures.
    #[cfg(feature = "dbcs")]
    fn decode_dbcs_lossy(page: &DbcsPage, bytes: &[u8]) -> String {
        let mut out = String::with_capacity(bytes.len());
        let mut rest = bytes;
        while let [byte, tail @ ..] = rest {
            match page.single[usize::from(*byte)] {
                SingleEntry::Map(ch) => {
                    out.push(ch);
                    rest = tail;
                }
                SingleEntry::Undefined => {
                    out.push(REPLACEMENT);
                    rest = tail;
                }
                SingleEntry::Lead => {
                    let [trail, tail @ ..] = tail else {
                        out.push(REPLACEMENT);
                        break;
                    };
                    out.push(lookup_pair(page, *byte, *trail).unwrap_or(REPLACEMENT));
                    rest = tail;
                }
            }
        }
        out
    }

    /// Encode to a double-byte page: single-byte table first, then the pair
    /// table by binary search.
    #[cfg(feature = "dbcs")]
    fn encode_dbcs(self, page: &DbcsPage, text: &str) -> Result<Vec<u8>, EncodeError> {
        let mut out = Vec::with_capacity(text.len());
        for ch in text.chars() {
            let single = page
                .single
                .iter()
                .position(|&slot| slot == SingleEntry::Map(ch));
            if let Some(byte) = single {
                out.push(byte as u8);
            } else if let Ok(i) = page.encode.binary_search_by_key(&ch, |&(u, _)| u) {
                let (_, code) = page.encode[i];
                out.extend_from_slice(&code.to_be_bytes());
            } else {
                return Err(EncodeError::Unmappable {
                    ch,
                    encoding: self.into(),
                });
            }
        }
        Ok(out)
    }

    /// The lookup machinery for this code page.
    fn codec(self) -> Codec {
        match self {
            Self::Cp437 => Codec::Single(&tables::dos::CP437),
            Self::Cp737 => Codec::Single(&tables::dos::CP737),
            Self::Cp775 => Codec::Single(&tables::dos::CP775),
            Self::Cp850 => Codec::Single(&tables::dos::CP850),
            Self::Cp852 => Codec::Single(&tables::dos::CP852),
            Self::Cp855 => Codec::Single(&tables::dos::CP855),
            Self::Cp857 => Codec::Single(&tables::dos::CP857),
            Self::Cp860 => Codec::Single(&tables::dos::CP860),
            Self::Cp861 => Codec::Single(&tables::dos::CP861),
            Self::Cp862 => Codec::Single(&tables::dos::CP862),
            Self::Cp863 => Codec::Single(&tables::dos::CP863),
            Self::Cp864 => Codec::Single(&tables::dos::CP864),
            Self::Cp865 => Codec::Single(&tables::dos::CP865),
            Self::Cp866 => Codec::Single(&tables::dos::CP866),
            Self::Cp869 => Codec::Single(&tables::dos::CP869),
            Self::Cp874 => Codec::Single(&tables::dos::CP874),
            #[cfg(feature = "dbcs")]
            Self::Cp932 => Codec::Dbcs(&tables::cp932::CP932),
            #[cfg(feature = "dbcs")]
            Self::Cp936 => Codec::Dbcs(&tables::cp936::CP936),
            #[cfg(feature = "dbcs")]
            Self::Cp949 => Codec::Dbcs(&tables::cp949::CP949),
            #[cfg(feature = "dbcs")]
            Self::Cp950 => Codec::Dbcs(&tables::cp950::CP950),
        }
    }
}

/// A Windows ANSI code page.
///
/// These are the single-byte "125x" pages Windows uses for non-console text
/// (the `GetACP` family), as distinct from the OEM pages in [`DosEncoding`].
///
/// Non-exhaustive: more code pages may be added without it being a breaking
/// change, so external matches must include a wildcard arm.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
#[non_exhaustive]
pub enum WindowsEncoding {
    /// Code page 874 — Windows Thai. Microsoft publishes the identical table
    /// for DOS and Windows Thai, so this equals [`DosEncoding::Cp874`].
    Cp874,
    /// Code page 1250 — Windows Central European.
    Cp1250,
    /// Code page 1251 — Windows Cyrillic.
    Cp1251,
    /// Code page 1252 — Windows Western, the near-superset of Latin-1.
    #[default]
    Cp1252,
    /// Code page 1253 — Windows Greek.
    Cp1253,
    /// Code page 1254 — Windows Turkish.
    Cp1254,
    /// Code page 1255 — Windows Hebrew.
    Cp1255,
    /// Code page 1256 — Windows Arabic.
    Cp1256,
    /// Code page 1257 — Windows Baltic.
    Cp1257,
    /// Code page 1258 — Windows Vietnamese (uses combining diacritics).
    Cp1258,
}

impl WindowsEncoding {
    /// Every Windows code page this crate implements, in numeric order.
    pub const ALL: &'static [WindowsEncoding] = &[
        Self::Cp874,
        Self::Cp1250,
        Self::Cp1251,
        Self::Cp1252,
        Self::Cp1253,
        Self::Cp1254,
        Self::Cp1255,
        Self::Cp1256,
        Self::Cp1257,
        Self::Cp1258,
    ];

    /// Select an encoding from its numeric code page identifier (an ACP value
    /// from the `GetACP` family), or `None` if this crate does not implement
    /// it.
    #[must_use]
    pub fn from_code_page(code_page: u16) -> Option<Self> {
        Self::ALL
            .iter()
            .copied()
            .find(|enc| enc.code_page() == code_page)
    }

    /// The numeric code page identifier (1252, 874, …).
    #[must_use]
    pub fn code_page(self) -> u16 {
        match self {
            Self::Cp874 => 874,
            Self::Cp1250 => 1250,
            Self::Cp1251 => 1251,
            Self::Cp1252 => 1252,
            Self::Cp1253 => 1253,
            Self::Cp1254 => 1254,
            Self::Cp1255 => 1255,
            Self::Cp1256 => 1256,
            Self::Cp1257 => 1257,
            Self::Cp1258 => 1258,
        }
    }

    /// A short human-readable name, e.g. "Windows Western".
    #[must_use]
    pub fn name(self) -> &'static str {
        match self {
            Self::Cp874 => "Windows Thai",
            Self::Cp1250 => "Windows Central European",
            Self::Cp1251 => "Windows Cyrillic",
            Self::Cp1252 => "Windows Western",
            Self::Cp1253 => "Windows Greek",
            Self::Cp1254 => "Windows Turkish",
            Self::Cp1255 => "Windows Hebrew",
            Self::Cp1256 => "Windows Arabic",
            Self::Cp1257 => "Windows Baltic",
            Self::Cp1258 => "Windows Vietnamese",
        }
    }

    /// Decode every byte of `bytes` to Unicode, faithfully (control bytes and
    /// a trailing `NUL` included). Use [`decode_cstr`](Self::decode_cstr) for
    /// `NUL`-terminated fixed-width fields.
    ///
    /// # Errors
    ///
    /// Returns [`DecodeError::Undefined`] for a byte value the code page
    /// leaves unmapped (every page here except 1256 has a few undefined
    /// slots in the `0x80..=0x9F` region).
    pub fn decode(self, bytes: &[u8]) -> Result<String, DecodeError> {
        decode_single(self.table(), bytes, self.into())
    }

    /// Decode like [`decode`](Self::decode), substituting U+FFFD REPLACEMENT
    /// CHARACTER for any byte the code page leaves undefined.
    #[must_use]
    pub fn decode_lossy(self, bytes: &[u8]) -> String {
        decode_single_lossy(self.table(), bytes)
    }

    /// Decode up to (but not including) the first `NUL` byte.
    ///
    /// # Errors
    ///
    /// Returns [`DecodeError`] as [`decode`](Self::decode) does.
    pub fn decode_cstr(self, bytes: &[u8]) -> Result<String, DecodeError> {
        self.decode(cstr_prefix(bytes))
    }

    /// Encode `text` back to this code page's bytes.
    ///
    /// # Errors
    ///
    /// Returns [`EncodeError::Unmappable`] for the first character with no
    /// representation in this code page.
    pub fn encode(self, text: &str) -> Result<Vec<u8>, EncodeError> {
        encode_single(self.table(), text, self.into())
    }

    /// The full byte-to-scalar table for this code page.
    fn table(self) -> &'static [Option<char>; 256] {
        match self {
            Self::Cp874 => &tables::ansi::CP874,
            Self::Cp1250 => &tables::ansi::CP1250,
            Self::Cp1251 => &tables::ansi::CP1251,
            Self::Cp1252 => &tables::ansi::CP1252,
            Self::Cp1253 => &tables::ansi::CP1253,
            Self::Cp1254 => &tables::ansi::CP1254,
            Self::Cp1255 => &tables::ansi::CP1255,
            Self::Cp1256 => &tables::ansi::CP1256,
            Self::Cp1257 => &tables::ansi::CP1257,
            Self::Cp1258 => &tables::ansi::CP1258,
        }
    }
}

/// A code page from either Microsoft family, as carried in errors.
///
/// Non-exhaustive: more families may be added without it being a breaking
/// change, so external matches must include a wildcard arm.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Encoding {
    /// An OEM (DOS) code page.
    Dos(DosEncoding),
    /// A Windows ANSI code page.
    Windows(WindowsEncoding),
}

impl Encoding {
    /// Select an encoding from its numeric code page identifier, searching
    /// the DOS family first, then Windows. The only overlap is 874, whose
    /// two tables are identical in Microsoft's published data.
    #[must_use]
    pub fn from_code_page(code_page: u16) -> Option<Self> {
        DosEncoding::from_code_page(code_page)
            .map(Self::Dos)
            .or_else(|| WindowsEncoding::from_code_page(code_page).map(Self::Windows))
    }

    /// The numeric code page identifier.
    #[must_use]
    pub fn code_page(self) -> u16 {
        match self {
            Self::Dos(enc) => enc.code_page(),
            Self::Windows(enc) => enc.code_page(),
        }
    }

    /// A short human-readable name.
    #[must_use]
    pub fn name(self) -> &'static str {
        match self {
            Self::Dos(enc) => enc.name(),
            Self::Windows(enc) => enc.name(),
        }
    }
}

impl From<DosEncoding> for Encoding {
    fn from(enc: DosEncoding) -> Self {
        Self::Dos(enc)
    }
}

impl From<WindowsEncoding> for Encoding {
    fn from(enc: WindowsEncoding) -> Self {
        Self::Windows(enc)
    }
}

/// U+FFFD REPLACEMENT CHARACTER, used by the lossy decoders.
const REPLACEMENT: char = '\u{FFFD}';

/// Everything before the first `NUL` byte (or all of `bytes` if there is
/// none) — the shared trim behind the `decode_cstr` methods.
fn cstr_prefix(bytes: &[u8]) -> &[u8] {
    let end = bytes.iter().position(|&b| b == 0).unwrap_or(bytes.len());
    &bytes[..end]
}

/// Decode a single-byte page strictly.
fn decode_single(
    table: &[Option<char>; 256],
    bytes: &[u8],
    encoding: Encoding,
) -> Result<String, DecodeError> {
    bytes
        .iter()
        .map(|&byte| table[usize::from(byte)].ok_or(DecodeError::Undefined { byte, encoding }))
        .collect()
}

/// Decode a single-byte page, substituting U+FFFD for undefined bytes.
fn decode_single_lossy(table: &[Option<char>; 256], bytes: &[u8]) -> String {
    bytes
        .iter()
        .map(|&byte| table[usize::from(byte)].unwrap_or(REPLACEMENT))
        .collect()
}

/// Encode to a single-byte page by scanning the table for each character.
fn encode_single(
    table: &[Option<char>; 256],
    text: &str,
    encoding: Encoding,
) -> Result<Vec<u8>, EncodeError> {
    text.chars()
        .map(|ch| {
            table
                .iter()
                .position(|&slot| slot == Some(ch))
                .map(|i| i as u8)
                .ok_or(EncodeError::Unmappable { ch, encoding })
        })
        .collect()
}

/// Look up a lead/trail pair in a DBCS page's sorted decode table.
#[cfg(feature = "dbcs")]
fn lookup_pair(page: &DbcsPage, lead: u8, trail: u8) -> Option<char> {
    let code = u16::from_be_bytes([lead, trail]);
    page.decode
        .binary_search_by_key(&code, |&(c, _)| c)
        .ok()
        .map(|i| page.decode[i].1)
}

impl fmt::Display for DosEncoding {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "code page {} ({})", self.code_page(), self.name())
    }
}

impl fmt::Display for WindowsEncoding {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "code page {} ({})", self.code_page(), self.name())
    }
}

impl fmt::Display for Encoding {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "code page {} ({})", self.code_page(), self.name())
    }
}

/// A byte sequence could not be decoded from the source code page.
///
/// Non-exhaustive so future variants are not a breaking change.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum DecodeError {
    /// `byte` has no mapping in `encoding`.
    #[error("byte {byte:#04x} is undefined in {encoding}")]
    Undefined {
        /// The offending byte value.
        byte: u8,
        /// The code page that leaves it undefined.
        encoding: Encoding,
    },
    /// A lead byte of a double-byte page appeared with no trail byte after it.
    #[error("lead byte {lead:#04x} at end of input in {encoding}")]
    TruncatedPair {
        /// The dangling lead byte.
        lead: u8,
        /// The double-byte code page being decoded.
        encoding: Encoding,
    },
    /// A lead/trail pair is not a defined code of the page.
    #[error("byte pair {lead:#04x} {trail:#04x} is undefined in {encoding}")]
    UndefinedPair {
        /// The lead byte of the pair.
        lead: u8,
        /// The trail byte of the pair.
        trail: u8,
        /// The double-byte code page being decoded.
        encoding: Encoding,
    },
}

/// A character could not be encoded into the target code page.
///
/// Non-exhaustive so future variants are not a breaking change.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum EncodeError {
    /// `ch` has no representation in `encoding`.
    #[error("character {ch:?} has no mapping in {encoding}")]
    Unmappable {
        /// The offending character.
        ch: char,
        /// The code page that lacks a mapping for it.
        encoding: Encoding,
    },
}

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

    /// The 256-entry table of a single-byte page, `None` for double-byte pages.
    fn single_table(enc: DosEncoding) -> Option<&'static [Option<char>; 256]> {
        match enc.codec() {
            Codec::Single(table) => Some(table),
            #[cfg(feature = "dbcs")]
            Codec::Dbcs(_) => None,
        }
    }

    #[test]
    fn ascii_passthrough_cp437() {
        let enc = DosEncoding::Cp437;
        assert_eq!(enc.decode(b"AUTOEXEC.BAT").unwrap(), "AUTOEXEC.BAT");
        assert_eq!(enc.encode("AUTOEXEC.BAT").unwrap(), b"AUTOEXEC.BAT");
    }

    #[test]
    fn known_high_mappings() {
        // Golden values straight from the Microsoft tables.
        assert_eq!(DosEncoding::Cp437.decode(&[0x82]).unwrap(), "é");
        assert_eq!(DosEncoding::Cp437.decode(&[0xB0]).unwrap(), "░");
        assert_eq!(DosEncoding::Cp437.decode(&[0xE1]).unwrap(), "ß");
        assert_eq!(DosEncoding::Cp850.decode(&[0xD5]).unwrap(), "ı");
        assert_eq!(DosEncoding::Cp862.decode(&[0x80]).unwrap(), "א");
        assert_eq!(DosEncoding::Cp866.decode(&[0x80]).unwrap(), "А");
        assert_eq!(DosEncoding::Cp866.decode(&[0xF0]).unwrap(), "Ё");
    }

    #[test]
    fn cp864_percent_is_arabic() {
        // Microsoft's CP864 maps 0x25 to ARABIC PERCENT SIGN, so the low half
        // is not pure ASCII and '%' itself is unmappable.
        let enc = DosEncoding::Cp864;
        assert_eq!(enc.decode(&[0x25]).unwrap(), "\u{066A}");
        assert_eq!(enc.encode("\u{066A}").unwrap(), vec![0x25]);
        assert!(matches!(
            enc.encode("100%"),
            Err(EncodeError::Unmappable { ch: '%', .. })
        ));
    }

    #[test]
    fn undefined_bytes_error_and_lossy_substitutes() {
        // 0xFF is undefined in CP874 (DOS Thai).
        let enc = DosEncoding::Cp874;
        assert_eq!(
            enc.decode(b"a\xFFb"),
            Err(DecodeError::Undefined {
                byte: 0xFF,
                encoding: enc.into()
            })
        );
        assert_eq!(enc.decode_lossy(b"a\xFFb"), "a\u{FFFD}b");
    }

    #[test]
    fn every_page_round_trips_every_single_byte() {
        for &enc in DosEncoding::ALL {
            for byte in 0..=0xFFu8 {
                match enc.decode(&[byte]) {
                    Ok(decoded) => assert_eq!(
                        enc.encode(&decoded).unwrap(),
                        vec![byte],
                        "byte {byte:#04x} failed to round-trip under {enc}"
                    ),
                    Err(DecodeError::Undefined { .. } | DecodeError::TruncatedPair { .. }) => {}
                    Err(other) => panic!("unexpected error for {byte:#04x}: {other}"),
                }
            }
        }
    }

    #[test]
    fn single_byte_tables_are_bijections() {
        // No duplicate scalars, else encode would be ambiguous.
        for &enc in DosEncoding::ALL {
            let Some(table) = single_table(enc) else {
                continue;
            };
            let mut seen = std::collections::HashSet::new();
            for slot in table.iter().flatten() {
                assert!(seen.insert(*slot), "duplicate scalar {slot:?} in {enc}");
            }
        }
    }

    #[test]
    fn fully_defined_pages_have_no_undefined_slots() {
        let partial = [
            DosEncoding::Cp857,
            DosEncoding::Cp864,
            DosEncoding::Cp869,
            DosEncoding::Cp874,
        ];
        for &enc in DosEncoding::ALL {
            let Some(table) = single_table(enc) else {
                continue;
            };
            let undefined = table.iter().filter(|slot| slot.is_none()).count();
            if partial.contains(&enc) {
                assert!(undefined > 0, "{enc} expected undefined slots");
            } else {
                assert_eq!(undefined, 0, "{enc} unexpectedly has undefined slots");
            }
        }
    }

    #[test]
    fn unmappable_character_errors() {
        let err = DosEncoding::Cp437.encode("空").unwrap_err();
        assert!(matches!(err, EncodeError::Unmappable { ch: '空', .. }));
        // é exists in 437 but not in Greek 737.
        assert!(DosEncoding::Cp737.encode("é").is_err());
    }

    #[test]
    fn decode_cstr_stops_at_nul() {
        let enc = DosEncoding::Cp437;
        assert_eq!(enc.decode_cstr(b"GAME\x00garbage").unwrap(), "GAME");
        assert_eq!(enc.decode_cstr(b"GAME").unwrap(), "GAME"); // no NUL at all
        assert_eq!(enc.decode_cstr(b"\x00GAME").unwrap(), "");
        assert_eq!(enc.decode(b"GAME\x00").unwrap().len(), "GAME".len() + 1);
    }

    #[test]
    fn empty_input_is_empty_output() {
        let enc = DosEncoding::Cp437;
        assert_eq!(enc.decode(b"").unwrap(), "");
        assert_eq!(enc.decode_lossy(b""), "");
        assert_eq!(enc.encode("").unwrap(), Vec::<u8>::new());
    }

    #[test]
    fn from_code_page_round_trips() {
        for &enc in DosEncoding::ALL {
            assert_eq!(DosEncoding::from_code_page(enc.code_page()), Some(enc));
        }
        assert_eq!(DosEncoding::from_code_page(1252), None); // Windows, not DOS
    }

    #[test]
    fn display_names() {
        assert_eq!(
            DosEncoding::Cp437.to_string(),
            "code page 437 (DOS Latin US)"
        );
        assert_eq!(DosEncoding::Cp874.to_string(), "code page 874 (DOS Thai)");
    }

    #[test]
    fn box_drawing_survives_round_trip() {
        // The DOS aesthetic: single/double box drawing in CP437.
        let art = "╔══╗\n║░▒║\n╚══╝";
        let bytes = DosEncoding::Cp437.encode(art).unwrap();
        assert_eq!(DosEncoding::Cp437.decode(&bytes).unwrap(), art);
    }
}

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

    #[test]
    fn golden_ansi_mappings() {
        // Golden values straight from the Microsoft tables.
        assert_eq!(WindowsEncoding::Cp1252.decode(&[0x80]).unwrap(), "€");
        assert_eq!(WindowsEncoding::Cp1252.decode(&[0x93]).unwrap(), "\u{201C}");
        assert_eq!(WindowsEncoding::Cp1251.decode(&[0xC0]).unwrap(), "А");
        assert_eq!(WindowsEncoding::Cp1255.decode(&[0xE0]).unwrap(), "א");
        assert_eq!(WindowsEncoding::Cp1253.decode(&[0xDF]).unwrap(), "ί");
        // 1258 spells Vietnamese with combining diacritics.
        assert_eq!(WindowsEncoding::Cp1258.decode(&[0xCC]).unwrap(), "\u{0300}");
    }

    #[test]
    fn smart_quotes_round_trip() {
        let text = "\u{201C}Hello,\u{201D} she said \u{2014} twice\u{2026}";
        let bytes = WindowsEncoding::Cp1252.encode(text).unwrap();
        assert_eq!(WindowsEncoding::Cp1252.decode(&bytes).unwrap(), text);
    }

    #[test]
    fn undefined_c1_slots_error_and_lossy_substitutes() {
        // 0x98 is the lone undefined byte in CP1251.
        let enc = WindowsEncoding::Cp1251;
        assert_eq!(
            enc.decode(b"a\x98b"),
            Err(DecodeError::Undefined {
                byte: 0x98,
                encoding: enc.into()
            })
        );
        assert_eq!(enc.decode_lossy(b"a\x98b"), "a\u{FFFD}b");
    }

    #[test]
    fn cp1256_is_fully_defined() {
        for byte in 0..=0xFFu8 {
            assert!(WindowsEncoding::Cp1256.decode(&[byte]).is_ok());
        }
    }

    #[test]
    fn every_ansi_page_round_trips_every_byte() {
        for &enc in WindowsEncoding::ALL {
            for byte in 0..=0xFFu8 {
                match enc.decode(&[byte]) {
                    Ok(decoded) => assert_eq!(
                        enc.encode(&decoded).unwrap(),
                        vec![byte],
                        "byte {byte:#04x} failed to round-trip under {enc}"
                    ),
                    Err(DecodeError::Undefined { .. }) => {
                        assert_eq!(enc.decode_lossy(&[byte]), "\u{FFFD}");
                    }
                    Err(other) => panic!("unexpected error for {byte:#04x}: {other}"),
                }
            }
        }
    }

    #[test]
    fn every_ansi_table_is_a_bijection() {
        for &enc in WindowsEncoding::ALL {
            let mut seen = std::collections::HashSet::new();
            for slot in enc.table().iter().flatten() {
                assert!(seen.insert(*slot), "duplicate scalar {slot:?} in {enc}");
            }
        }
    }

    #[test]
    fn windows_thai_equals_dos_thai() {
        // Microsoft publishes the identical 874 table for both families.
        for byte in 0..=0xFFu8 {
            assert_eq!(
                WindowsEncoding::Cp874.decode(&[byte]).ok(),
                DosEncoding::Cp874.decode(&[byte]).ok(),
            );
        }
    }

    #[test]
    fn from_code_page_families() {
        for &enc in WindowsEncoding::ALL {
            assert_eq!(WindowsEncoding::from_code_page(enc.code_page()), Some(enc));
        }
        assert_eq!(WindowsEncoding::from_code_page(437), None);
        assert_eq!(DosEncoding::from_code_page(1252), None);
        // The unified lookup searches DOS first, then Windows.
        assert_eq!(
            Encoding::from_code_page(437),
            Some(Encoding::Dos(DosEncoding::Cp437))
        );
        assert_eq!(
            Encoding::from_code_page(1252),
            Some(Encoding::Windows(WindowsEncoding::Cp1252))
        );
        assert_eq!(
            Encoding::from_code_page(874),
            Some(Encoding::Dos(DosEncoding::Cp874))
        );
        assert_eq!(Encoding::from_code_page(65001), None); // UTF-8 is not ours
    }

    #[test]
    fn ansi_decode_cstr_stops_at_nul() {
        let enc = WindowsEncoding::Cp1252;
        assert_eq!(enc.decode_cstr(b"caf\xE9\x00junk").unwrap(), "café");
        assert_eq!(enc.decode_cstr(b"caf\xE9").unwrap(), "café");
    }

    #[test]
    fn display_and_error_name_the_family() {
        assert_eq!(
            WindowsEncoding::Cp1252.to_string(),
            "code page 1252 (Windows Western)"
        );
        let err = WindowsEncoding::Cp1253.encode("é").unwrap_err();
        assert_eq!(
            err.to_string(),
            "character 'é' has no mapping in code page 1253 (Windows Greek)"
        );
    }
}

#[cfg(all(test, not(feature = "dbcs")))]
mod no_dbcs_tests {
    use super::*;

    #[test]
    fn double_byte_pages_are_absent_without_the_feature() {
        for code_page in [932, 936, 949, 950] {
            assert_eq!(DosEncoding::from_code_page(code_page), None);
        }
        assert_eq!(DosEncoding::ALL.len(), 16);
    }
}

#[cfg(all(test, feature = "dbcs"))]
mod dbcs_tests {
    use super::*;

    #[test]
    fn golden_cjk_strings() {
        // Golden values straight from the Microsoft tables.
        assert_eq!(
            DosEncoding::Cp932.encode("日本語").unwrap(),
            b"\x93\xFA\x96\x7B\x8C\xEA"
        );
        assert_eq!(
            DosEncoding::Cp936.encode("中文").unwrap(),
            b"\xD6\xD0\xCE\xC4"
        );
        assert_eq!(
            DosEncoding::Cp949.encode("한국").unwrap(),
            b"\xC7\xD1\xB1\xB9"
        );
        assert_eq!(
            DosEncoding::Cp950.encode("中文").unwrap(),
            b"\xA4\xA4\xA4\xE5"
        );
        for (enc, bytes, text) in [
            (
                DosEncoding::Cp932,
                &b"\x93\xFA\x96\x7B\x8C\xEA"[..],
                "日本語",
            ),
            (DosEncoding::Cp936, &b"\xD6\xD0\xCE\xC4"[..], "中文"),
            (DosEncoding::Cp949, &b"\xC7\xD1\xB1\xB9"[..], "한국"),
            (DosEncoding::Cp950, &b"\xA4\xA4\xA4\xE5"[..], "中文"),
        ] {
            assert_eq!(enc.decode(bytes).unwrap(), text);
        }
    }

    #[test]
    fn cp932_halfwidth_katakana_is_single_byte() {
        // 0xA1-0xDF are single-byte halfwidth katakana in Shift-JIS.
        assert_eq!(DosEncoding::Cp932.decode(&[0xB1]).unwrap(), "\u{FF71}");
        assert_eq!(DosEncoding::Cp932.encode("\u{FF71}").unwrap(), vec![0xB1]);
    }

    #[test]
    fn ascii_mixes_with_pairs() {
        let enc = DosEncoding::Cp932;
        let bytes = b"C:\\\x93\xFA\x96\x7B\x8C\xEA\\README.TXT";
        assert_eq!(enc.decode(bytes).unwrap(), "C:\\日本語\\README.TXT");
    }

    #[test]
    fn duplicate_scalars_encode_as_windows_does() {
        // CP932: NEC selection (0xED/0xEE leads) loses to everything else.
        // Verified against Microsoft's bestfit932.txt WCTABLE.
        let enc = DosEncoding::Cp932;
        assert_eq!(enc.encode("\u{2170}").unwrap(), vec![0xFA, 0x40]); // not 0xEEEF
        assert_eq!(enc.encode("\u{2160}").unwrap(), vec![0x87, 0x54]); // not 0xFA4A
        assert_eq!(enc.encode("\u{2252}").unwrap(), vec![0x81, 0xE0]); // not 0x8790
                                                                       // Both duplicate codes still decode.
        assert_eq!(enc.decode(&[0xEE, 0xEF]).unwrap(), "\u{2170}");
        assert_eq!(enc.decode(&[0xFA, 0x40]).unwrap(), "\u{2170}");

        // CP950: the six known dups prefer the higher code, the box-drawing
        // corners prefer the lower. Verified against bestfit950.txt.
        let enc = DosEncoding::Cp950;
        assert_eq!(enc.encode("\u{2550}").unwrap(), vec![0xF9, 0xF9]); // not 0xA2A4
        assert_eq!(enc.encode("\u{256D}").unwrap(), vec![0xA2, 0x7E]); // not 0xF9FA
        assert_eq!(enc.encode("\u{5341}").unwrap(), vec![0xA4, 0x51]); // not 0xA2CC
        assert_eq!(enc.decode(&[0xA2, 0xA4]).unwrap(), "\u{2550}");
        assert_eq!(enc.decode(&[0xF9, 0xF9]).unwrap(), "\u{2550}");
    }

    #[test]
    fn truncated_pair_errors_and_lossy_substitutes() {
        let enc = DosEncoding::Cp932;
        assert_eq!(
            enc.decode(b"abc\x93"),
            Err(DecodeError::TruncatedPair {
                lead: 0x93,
                encoding: enc.into()
            })
        );
        assert_eq!(enc.decode_lossy(b"abc\x93"), "abc\u{FFFD}");
    }

    #[test]
    fn undefined_pair_errors_and_lossy_consumes_both_bytes() {
        let enc = DosEncoding::Cp932;
        // 0x81 is a lead byte but 0x81 0x00 is not a defined pair.
        assert_eq!(
            enc.decode(b"\x81\x00"),
            Err(DecodeError::UndefinedPair {
                lead: 0x81,
                trail: 0x00,
                encoding: enc.into()
            })
        );
        assert_eq!(enc.decode_lossy(b"\x81\x00A"), "\u{FFFD}A");
    }

    #[test]
    fn undefined_single_bytes_in_dbcs_pages() {
        // 0x80 is undefined in CP932 (not a lead, not mapped).
        let enc = DosEncoding::Cp932;
        assert_eq!(
            enc.decode(&[0x80]),
            Err(DecodeError::Undefined {
                byte: 0x80,
                encoding: enc.into()
            })
        );
        assert_eq!(enc.decode_lossy(&[0x80]), "\u{FFFD}");
    }

    #[test]
    fn every_defined_pair_decodes_and_round_trips_to_preferred_code() {
        for enc in [
            DosEncoding::Cp932,
            DosEncoding::Cp936,
            DosEncoding::Cp949,
            DosEncoding::Cp950,
        ] {
            let Codec::Dbcs(page) = enc.codec() else {
                panic!("{enc} should be double-byte");
            };
            for &(code, ch) in page.decode {
                let decoded = enc.decode(&code.to_be_bytes()).unwrap();
                assert_eq!(decoded, ch.to_string(), "{enc} {code:#06x}");
                // Re-encoding yields the Windows-preferred code for this char.
                let reencoded = enc.encode(&decoded).unwrap();
                let preferred = page
                    .encode
                    .binary_search_by_key(&ch, |&(u, _)| u)
                    .map(|i| page.encode[i].1)
                    .expect("every decodable char must be encodable");
                assert_eq!(reencoded, preferred.to_be_bytes().to_vec());
            }
        }
    }

    #[test]
    fn decode_and_encode_tables_are_sorted_and_unique() {
        for enc in [
            DosEncoding::Cp932,
            DosEncoding::Cp936,
            DosEncoding::Cp949,
            DosEncoding::Cp950,
        ] {
            let Codec::Dbcs(page) = enc.codec() else {
                panic!("{enc} should be double-byte");
            };
            assert!(page.decode.windows(2).all(|w| w[0].0 < w[1].0));
            assert!(page.encode.windows(2).all(|w| w[0].0 < w[1].0));
        }
    }

    #[test]
    fn dbcs_decode_cstr_stops_at_nul() {
        let enc = DosEncoding::Cp932;
        assert_eq!(enc.decode_cstr(b"\x93\xFA\x00garbage").unwrap(), "\u{65E5}");
    }

    #[test]
    fn astral_characters_are_unmappable() {
        // Nothing beyond the BMP exists in these pages; the encoder must not
        // truncate the scalar when searching.
        let err = DosEncoding::Cp936.encode("🐍").unwrap_err();
        assert!(matches!(err, EncodeError::Unmappable { ch: '🐍', .. }));
    }
}
