//! Heat2 field tags: four characters packed into three bytes.
//!
//! Each character contributes six bits (`(c - 0x20) & 0x3F`), concatenated
//! MSB-first. Labels shorter than four characters are space-padded, and code 0
//! decodes back to a space, so `"ENV"` survives a round trip as `"ENV"`.
//!
//! Evidence: byte-exact round trip against the FIFA 17 preAuth capture, via
//! `fifa17-recon/tools/heat2.py::encode_tag`.

use std::fmt;

/// A packed three-byte Heat2 tag.
///
/// Ordering is by packed bytes, which is exactly the member ordering Blaze
/// requires on the wire — so `sort()` on a slice of `Tag` is the wire rule.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Tag(pub [u8; 3]);

impl Tag {
    /// Pack a label. Labels longer than four characters are TRUNCATED, not
    /// rejected — matching the oracle. Note the hazard: `"LSTR"` and `"LSTR2"`
    /// are the same wire tag, so they collide inside one struct.
    pub fn from_label(label: &str) -> Tag {
        let mut c = [0u8; 4];
        for (i, ch) in label.chars().take(4).enumerate() {
            // Non-ASCII cannot appear in a real tag; masking keeps this total
            // instead of panicking on hostile input.
            c[i] = ((ch as u32).wrapping_sub(0x20) & 0x3F) as u8;
        }
        Tag([
            (c[0] << 2) | (c[1] >> 4),
            ((c[1] & 0x0F) << 4) | (c[2] >> 2),
            ((c[2] & 0x03) << 6) | c[3],
        ])
    }

    /// Unpack to a label with trailing padding stripped.
    pub fn to_label(self) -> String {
        let [a, b, c] = self.0;
        let v = [
            (a >> 2) & 0x3F,
            ((a & 0x03) << 4) | ((b >> 4) & 0x0F),
            ((b & 0x0F) << 2) | ((c >> 6) & 0x03),
            c & 0x3F,
        ];
        let s: String = v
            .iter()
            .map(|&x| if x == 0 { ' ' } else { (x + 0x20) as char })
            .collect();
        s.trim_end().to_string()
    }

    pub fn as_bytes(self) -> [u8; 3] {
        self.0
    }
}

impl fmt::Display for Tag {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.to_label())
    }
}

impl From<&str> for Tag {
    fn from(s: &str) -> Tag {
        Tag::from_label(s)
    }
}

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

    #[test]
    fn round_trips_common_labels() {
        for label in ["CDAT", "CINF", "ENV", "A", "LOC", "BSDK", "PTVR"] {
            assert_eq!(Tag::from_label(label).to_label(), label);
        }
    }

    #[test]
    fn packs_to_three_bytes_msb_first() {
        // "ENV" pads to "ENV ": codes 0x25 0x2E 0x36 0x00.
        assert_eq!(Tag::from_label("ENV").as_bytes(), [0x96, 0xed, 0x80]);
    }

    #[test]
    fn ordering_is_packed_byte_order() {
        let mut tags = [Tag::from("ZZZZ"), Tag::from("AAAA"), Tag::from("MMMM")];
        tags.sort();
        assert_eq!(
            tags.map(|t| t.to_label()),
            ["AAAA".to_string(), "MMMM".to_string(), "ZZZZ".to_string()]
        );
    }

    #[test]
    fn over_long_labels_truncate_and_collide() {
        assert_eq!(Tag::from("LSTR2"), Tag::from("LSTR"));
    }
}
