//! High-level `$MFT` aggregator for path resolution and timestomping triage.
//!
//! Parses raw `$MFT` bytes into typed [`MftEntry`] records — entry/sequence
//! numbers, the best `$FILE_NAME`, parent references, `$SI`/`$FN` timestamps,
//! and a resolved `full_path` — and exposes [`MftData`] lookups plus a
//! [`crate::rewind::RewindEngine`] seed. `detect_timestomping` returns the raw
//! entries whose `$SI` timestamps predate their `$FN` timestamps; grading that
//! signal into a finding is left to the analyzer layer.

use std::collections::HashMap;

use chrono::{DateTime, Utc};

use crate::error::Result;
use crate::rewind::{EntryKey, RewindEngine};
use crate::{
    apply_fixup, parse_attributes, AttributeBody, FileName, Filetime, MftRecordHeader,
    StandardInformation,
};

/// Parsed MFT entry with fields relevant to USN Journal correlation.
#[derive(Debug, Clone)]
pub struct MftEntry {
    pub entry_number: u64,
    pub sequence_number: u16,
    pub filename: String,
    pub parent_entry: u64,
    pub parent_sequence: u16,
    pub is_directory: bool,
    pub is_in_use: bool,
    /// `$STANDARD_INFORMATION` timestamps (user-modifiable).
    pub si_created: Option<DateTime<Utc>>,
    pub si_modified: Option<DateTime<Utc>>,
    pub si_mft_modified: Option<DateTime<Utc>>,
    pub si_accessed: Option<DateTime<Utc>>,
    /// `$FILE_NAME` timestamps (harder to modify, more trustworthy).
    pub fn_created: Option<DateTime<Utc>>,
    pub fn_modified: Option<DateTime<Utc>>,
    pub fn_mft_modified: Option<DateTime<Utc>>,
    pub fn_accessed: Option<DateTime<Utc>>,
    /// Full path resolved from MFT parent chain.
    pub full_path: String,
    /// File size from `$DATA` attribute.
    pub file_size: u64,
    /// Whether this entry has alternate data streams.
    pub has_ads: bool,
}

/// Parsed `$MFT` data for correlation.
pub struct MftData {
    /// All parsed entries.
    pub entries: Vec<MftEntry>,
    /// Map: `entry_number` -> index in entries vec (for current allocation).
    pub by_entry: HashMap<u64, usize>,
    /// Map: (entry, sequence) -> index (sequence-aware lookup).
    pub by_key: HashMap<EntryKey, usize>,
}

impl MftData {
    /// Parse raw `$MFT` data.
    ///
    /// # Errors
    ///
    /// Currently infallible — malformed records are skipped rather than
    /// surfaced as errors — but the signature is fallible to leave room for
    /// future fatal conditions.
    pub fn parse(data: &[u8]) -> Result<Self> {
        const REC: usize = 1024;
        let mut entries = Vec::new();
        let mut by_entry = HashMap::new();
        let mut by_key = HashMap::new();

        for chunk in data.chunks(REC) {
            // Skip BAAD/zeroed/short trailing slots.
            if chunk.len() < REC || chunk.get(0..4) != Some(b"FILE") {
                continue;
            }
            let mut buf = chunk.to_vec();
            if apply_fixup(&mut buf, 512).is_err() {
                continue;
            }
            let Ok(header) = MftRecordHeader::parse(&buf) else {
                continue; // cov:unreachable: the line-75 guard ensures chunk.len() == REC (1024 ≥ HEADER_LEN 0x30) and a "FILE" signature, which apply_fixup leaves intact at offset 0, so MftRecordHeader::parse always succeeds here
            };
            let Ok(attrs) = parse_attributes(&buf, header.first_attribute_offset as usize) else {
                continue;
            };

            // $STANDARD_INFORMATION timestamps.
            let (mut si_created, mut si_modified, mut si_mft_modified, mut si_accessed) =
                (None, None, None, None);
            for a in attrs.iter().filter(|a| a.type_code == 0x10) {
                if let Some(si) = a
                    .resident_content(&buf)
                    .and_then(|c| StandardInformation::parse(c).ok())
                {
                    si_created = to_datetime(si.created);
                    si_modified = to_datetime(si.modified);
                    si_mft_modified = to_datetime(si.mft_modified);
                    si_accessed = to_datetime(si.accessed);
                }
            }

            // Best $FILE_NAME: prefer Win32 / Win32+DOS over DOS over POSIX.
            let mut best: Option<(u8, FileName)> = None;
            for a in attrs.iter().filter(|a| a.type_code == 0x30) {
                if let Some(fnm) = a
                    .resident_content(&buf)
                    .and_then(|c| FileName::parse(c).ok())
                {
                    let priority = match fnm.namespace {
                        1 | 3 => 3,
                        2 => 1,
                        _ => 2,
                    };
                    if best.as_ref().is_none_or(|(p, _)| priority > *p) {
                        best = Some((priority, fnm));
                    }
                } // cov:unreachable: the if-not-taken short-circuit region of is_none_or(|(p,_)| priority > *p) leaves this join uncovered (no observable statement; the priority-not-greater path is exercised by parse_keeps_higher_priority_filename)
            }
            let Some((_, best_fn)) = best else {
                continue;
            };

            // Alternate data streams = named $DATA. file_size = unnamed $DATA size.
            let has_ads = attrs
                .iter()
                .any(|a| a.type_code == 0x80 && a.name.is_some());
            let file_size = attrs
                .iter()
                .filter(|a| a.type_code == 0x80 && a.name.is_none())
                .map(|a| match &a.body {
                    AttributeBody::NonResident { real_size, .. } => *real_size,
                    AttributeBody::Resident { content_length, .. } => u64::from(*content_length),
                })
                .next()
                .unwrap_or(0);

            let entry_number = u64::from(header.record_number);
            let sequence_number = header.sequence_number;
            let idx = entries.len();
            entries.push(MftEntry {
                entry_number,
                sequence_number,
                filename: best_fn.name.clone(),
                parent_entry: best_fn.parent.record_number,
                parent_sequence: best_fn.parent.sequence,
                is_directory: header.is_directory(),
                is_in_use: header.is_in_use(),
                si_created,
                si_modified,
                si_mft_modified,
                si_accessed,
                fn_created: to_datetime(best_fn.created),
                fn_modified: to_datetime(best_fn.modified),
                fn_mft_modified: to_datetime(best_fn.mft_modified),
                fn_accessed: to_datetime(best_fn.accessed),
                full_path: String::new(),
                file_size,
                has_ads,
            });
            by_entry.insert(entry_number, idx);
            by_key.insert(EntryKey::new(entry_number, sequence_number), idx);
        }

        // Second pass: resolve full paths once every entry is in the map.
        let paths: Vec<String> = (0..entries.len())
            .map(|i| resolve_full_path(&entries, &by_entry, i))
            .collect();
        for (entry, path) in entries.iter_mut().zip(paths) {
            entry.full_path = path;
        }

        Ok(Self {
            entries,
            by_entry,
            by_key,
        })
    }

    /// Seed a [`RewindEngine`] with the current MFT state.
    #[must_use]
    pub fn seed_rewind(&self) -> RewindEngine {
        let mft_iter = self.entries.iter().map(|e| {
            (
                e.entry_number,
                e.sequence_number,
                e.filename.clone(),
                e.parent_entry,
                e.parent_sequence,
            )
        });
        RewindEngine::from_mft(mft_iter)
    }

    /// Detect potential timestomping: `$SI` created before `$FN` created.
    #[must_use]
    pub fn detect_timestomping(&self) -> Vec<&MftEntry> {
        self.entries
            .iter()
            .filter(|e| {
                if let (Some(si_c), Some(fn_c)) = (e.si_created, e.fn_created) {
                    si_c < fn_c || {
                        if let Some(si_m) = e.si_modified {
                            si_m < fn_c
                        } else {
                            false
                        }
                    }
                } else {
                    false
                }
            })
            .collect()
    }

    /// Get entry by entry number (current allocation).
    #[must_use]
    pub fn get_by_entry(&self, entry_number: u64) -> Option<&MftEntry> {
        self.by_entry
            .get(&entry_number)
            .and_then(|&idx| self.entries.get(idx))
    }

    /// Get entry by (entry, sequence) pair.
    #[must_use]
    pub fn get_by_key(&self, key: &EntryKey) -> Option<&MftEntry> {
        self.by_key.get(key).and_then(|&idx| self.entries.get(idx))
    }
}

/// Convert an NTFS FILETIME to a chrono UTC datetime; `None` for the unset (zero) value.
fn to_datetime(ft: Filetime) -> Option<DateTime<Utc>> {
    if ft.is_zero() {
        return None;
    }
    let secs = ft.to_unix_seconds();
    // Sub-second remainder in [0, 1e9); rem_euclid keeps it non-negative for pre-epoch times.
    let sub_nanos = ft.to_unix_nanos().rem_euclid(1_000_000_000);
    let nsec = u32::try_from(sub_nanos).unwrap_or(0);
    DateTime::from_timestamp(secs, nsec)
}

/// Resolve an entry's full path (`.\dir\file`) by walking the parent chain to the root (entry 5).
fn resolve_full_path(entries: &[MftEntry], by_entry: &HashMap<u64, usize>, idx: usize) -> String {
    let mut parts = Vec::new();
    let mut cur = idx;
    // Bound the walk so a cyclic/corrupt parent chain cannot loop forever.
    for _ in 0..256 {
        let Some(e) = entries.get(cur) else {
            break; // cov:unreachable: cur starts at the valid idx and only ever moves to a by_entry value (an index built from entries), so entries.get(cur) is always Some
        };
        parts.push(e.filename.clone());
        if e.parent_entry == 5 || e.parent_entry == e.entry_number {
            break;
        }
        match by_entry.get(&e.parent_entry) {
            Some(&p) if p != cur => cur = p,
            _ => break,
        }
    }
    parts.reverse();
    format!(".\\{}", parts.join("\\"))
}

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

    #[test]
    fn test_mft_data_empty() {
        let result = MftData::parse(&[]);
        assert!(result.is_err() || result.unwrap().entries.is_empty());
    }

    #[test]
    fn test_entry_key_equality() {
        let k1 = EntryKey::new(100, 3);
        let k2 = EntryKey::new(100, 3);
        let k3 = EntryKey::new(100, 4);
        assert_eq!(k1, k2);
        assert_ne!(k1, k3);
    }

    #[test]
    fn test_mft_data_get_by_entry_not_found() {
        // Empty MftData should return None for any entry lookup
        let mft_data = MftData {
            entries: Vec::new(),
            by_entry: HashMap::new(),
            by_key: HashMap::new(),
        };
        assert!(mft_data.get_by_entry(100).is_none());
    }

    #[test]
    fn test_mft_data_get_by_key_not_found() {
        let mft_data = MftData {
            entries: Vec::new(),
            by_entry: HashMap::new(),
            by_key: HashMap::new(),
        };
        let key = EntryKey::new(100, 3);
        assert!(mft_data.get_by_key(&key).is_none());
    }

    fn make_mft_entry(
        entry_number: u64,
        sequence_number: u16,
        filename: &str,
        parent_entry: u64,
        parent_sequence: u16,
        is_dir: bool,
        is_in_use: bool,
    ) -> MftEntry {
        MftEntry {
            entry_number,
            sequence_number,
            filename: filename.to_string(),
            parent_entry,
            parent_sequence,
            is_directory: is_dir,
            is_in_use,
            si_created: None,
            si_modified: None,
            si_mft_modified: None,
            si_accessed: None,
            fn_created: None,
            fn_modified: None,
            fn_mft_modified: None,
            fn_accessed: None,
            full_path: format!(".\\{filename}"),
            file_size: 0,
            has_ads: false,
        }
    }

    #[test]
    fn test_mft_data_get_by_entry_found() {
        let entry = make_mft_entry(100, 3, "test.txt", 5, 5, false, true);
        let mut by_entry = HashMap::new();
        by_entry.insert(100u64, 0usize);
        let mut by_key = HashMap::new();
        by_key.insert(EntryKey::new(100, 3), 0usize);

        let mft_data = MftData {
            entries: vec![entry],
            by_entry,
            by_key,
        };

        let found = mft_data.get_by_entry(100);
        assert!(found.is_some());
        assert_eq!(found.unwrap().filename, "test.txt");
    }

    #[test]
    fn parse_extracts_entry_fields_via_ntfs_forensic() {
        let data = build_mft_entry_bytes(100, 1, 5, 5, "testfile.txt", 0x01);
        let mft = MftData::parse(&data).expect("parse");
        assert_eq!(mft.entries.len(), 1);
        let e = &mft.entries[0];
        assert_eq!(e.entry_number, 100);
        assert_eq!(e.sequence_number, 1);
        assert_eq!(e.filename, "testfile.txt");
        assert_eq!(e.parent_entry, 5);
        assert_eq!(e.parent_sequence, 5);
        assert!(!e.is_directory);
        assert!(e.is_in_use);
        assert!(e.si_created.is_some());
        assert!(e.fn_created.is_some());
        assert!(!e.has_ads);
        assert!(mft.by_entry.contains_key(&100));
        assert!(mft.by_key.contains_key(&EntryKey::new(100, 1)));
    }

    #[test]
    fn test_mft_data_get_by_key_found() {
        let entry = make_mft_entry(100, 3, "test.txt", 5, 5, false, true);
        let mut by_entry = HashMap::new();
        by_entry.insert(100u64, 0usize);
        let mut by_key = HashMap::new();
        by_key.insert(EntryKey::new(100, 3), 0usize);

        let mft_data = MftData {
            entries: vec![entry],
            by_entry,
            by_key,
        };

        let key = EntryKey::new(100, 3);
        let found = mft_data.get_by_key(&key);
        assert!(found.is_some());
        assert_eq!(found.unwrap().filename, "test.txt");
    }

    #[test]
    fn test_detect_timestomping_si_before_fn() {
        use chrono::DateTime;
        let mut entry = make_mft_entry(100, 1, "suspicious.exe", 5, 5, false, true);
        // SI created is before FN created -> timestomped
        entry.si_created = Some(DateTime::from_timestamp(1_700_000_000, 0).unwrap());
        entry.fn_created = Some(DateTime::from_timestamp(1_700_001_000, 0).unwrap());

        let mut by_entry = HashMap::new();
        by_entry.insert(100u64, 0usize);
        let mft_data = MftData {
            entries: vec![entry],
            by_entry,
            by_key: HashMap::new(),
        };

        let stomped = mft_data.detect_timestomping();
        assert_eq!(stomped.len(), 1);
        assert_eq!(stomped[0].filename, "suspicious.exe");
    }

    #[test]
    fn test_detect_timestomping_si_modified_before_fn_created() {
        use chrono::DateTime;
        let mut entry = make_mft_entry(100, 1, "modified.exe", 5, 5, false, true);
        // SI created is same as FN, but SI modified is before FN created
        entry.si_created = Some(DateTime::from_timestamp(1_700_001_000, 0).unwrap());
        entry.si_modified = Some(DateTime::from_timestamp(1_700_000_000, 0).unwrap());
        entry.fn_created = Some(DateTime::from_timestamp(1_700_001_000, 0).unwrap());

        let mft_data = MftData {
            entries: vec![entry],
            by_entry: HashMap::new(),
            by_key: HashMap::new(),
        };

        let stomped = mft_data.detect_timestomping();
        assert_eq!(stomped.len(), 1);
    }

    #[test]
    fn test_detect_timestomping_none_when_consistent() {
        use chrono::DateTime;
        let mut entry = make_mft_entry(100, 1, "normal.txt", 5, 5, false, true);
        let ts = DateTime::from_timestamp(1_700_001_000, 0).unwrap();
        entry.si_created = Some(ts);
        entry.si_modified = Some(ts);
        entry.fn_created = Some(ts);

        let mft_data = MftData {
            entries: vec![entry],
            by_entry: HashMap::new(),
            by_key: HashMap::new(),
        };

        let stomped = mft_data.detect_timestomping();
        assert_eq!(stomped.len(), 0);
    }

    #[test]
    fn test_detect_timestomping_no_timestamps() {
        let entry = make_mft_entry(100, 1, "no_ts.txt", 5, 5, false, true);

        let mft_data = MftData {
            entries: vec![entry],
            by_entry: HashMap::new(),
            by_key: HashMap::new(),
        };

        let stomped = mft_data.detect_timestomping();
        assert_eq!(stomped.len(), 0);
    }

    #[test]
    fn test_seed_rewind() {
        let entry = make_mft_entry(100, 1, "test.txt", 5, 5, false, true);
        let mut by_entry = HashMap::new();
        by_entry.insert(100u64, 0usize);
        let mut by_key = HashMap::new();
        by_key.insert(EntryKey::new(100, 1), 0usize);

        let mft_data = MftData {
            entries: vec![entry],
            by_entry,
            by_key,
        };

        let engine = mft_data.seed_rewind();
        assert_eq!(engine.lookup_len(), 1);
        let path = engine.resolve_path(&EntryKey::new(100, 1));
        assert_eq!(path, ".\\test.txt");
    }

    #[test]
    fn test_mft_data_multiple_entries() {
        let e1 = make_mft_entry(100, 1, "file1.txt", 5, 5, false, true);
        let e2 = make_mft_entry(200, 2, "file2.txt", 100, 1, false, true);
        let e3 = make_mft_entry(300, 1, "dir1", 5, 5, true, true);

        let mut by_entry = HashMap::new();
        by_entry.insert(100u64, 0usize);
        by_entry.insert(200u64, 1usize);
        by_entry.insert(300u64, 2usize);

        let mut by_key = HashMap::new();
        by_key.insert(EntryKey::new(100, 1), 0usize);
        by_key.insert(EntryKey::new(200, 2), 1usize);
        by_key.insert(EntryKey::new(300, 1), 2usize);

        let mft_data = MftData {
            entries: vec![e1, e2, e3],
            by_entry,
            by_key,
        };

        assert_eq!(mft_data.entries.len(), 3);
        assert_eq!(mft_data.get_by_entry(200).unwrap().filename, "file2.txt");
        assert_eq!(
            mft_data
                .get_by_key(&EntryKey::new(300, 1))
                .unwrap()
                .filename,
            "dir1"
        );
        assert!(
            mft_data
                .get_by_key(&EntryKey::new(300, 1))
                .unwrap()
                .is_directory
        );
    }

    #[test]
    fn test_detect_timestomping_si_modified_none() {
        use chrono::DateTime;
        let mut entry = make_mft_entry(100, 1, "check.exe", 5, 5, false, true);
        // SI created == FN created, SI modified is None
        let ts = DateTime::from_timestamp(1_700_001_000, 0).unwrap();
        entry.si_created = Some(ts);
        entry.si_modified = None;
        entry.fn_created = Some(ts);

        let mft_data = MftData {
            entries: vec![entry],
            by_entry: HashMap::new(),
            by_key: HashMap::new(),
        };

        let stomped = mft_data.detect_timestomping();
        assert_eq!(stomped.len(), 0);
    }

    #[test]
    fn test_mft_entry_has_ads_field() {
        let mut entry = make_mft_entry(100, 1, "ads.txt", 5, 5, false, true);
        entry.has_ads = true;
        assert!(entry.has_ads);
    }

    #[test]
    fn test_mft_entry_file_size() {
        let mut entry = make_mft_entry(100, 1, "big.bin", 5, 5, false, true);
        entry.file_size = 1_048_576;
        assert_eq!(entry.file_size, 1_048_576);
    }

    #[test]
    fn test_mft_data_parse_invalid_data() {
        // Random garbage data that is not a valid MFT - should error or return empty
        let garbage = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04];
        let result = MftData::parse(&garbage);
        // Either it errors out or it parses with zero valid entries
        if let Ok(mft_data) = result {
            assert!(mft_data.entries.is_empty());
        } // cov:unreachable: MftData::parse skips non-FILE chunks rather than erroring, so it always returns Ok here ⇒ the implicit Err arm is never taken
    }

    #[test]
    fn test_mft_data_parse_short_data() {
        // Data shorter than one MFT entry (1024 bytes)
        let data = vec![0xAA; 512];
        let result = MftData::parse(&data);
        if let Ok(mft_data) = result {
            assert!(mft_data.entries.is_empty());
        } // cov:unreachable: MftData::parse skips short trailing chunks rather than erroring, so it always returns Ok here ⇒ the implicit Err arm is never taken
    }

    #[test]
    fn test_mft_data_parse_corrupt_entries_skipped() {
        // The `mft` crate can panic on malformed entries, so we use catch_unwind
        // to verify that MftData::parse either succeeds (with empty entries for
        // corrupt data), errors out, or panics without crashing the test suite.
        //
        // Build a minimal valid-looking MFT entry structure:
        // - FILE signature at offset 0
        // - Update sequence offset at 0x04 (u16) = 0x30 (after the header)
        // - Update sequence size at 0x06 (u16) = 3 (1 + number of sectors)
        // - Allocated size of entry at 0x1C (u32) = 1024
        // - Flags at 0x16 (u16) = 0x01 (in-use)
        // - First attribute offset at 0x14 (u16) = 0x38
        // - Bytes used at 0x18 (u32) = 0x38 (just the header, no attributes)
        let mut data = vec![0u8; 1024 * 4];
        for i in 0..4 {
            let o = i * 1024;
            data[o..o + 4].copy_from_slice(b"FILE");
            data[o + 0x04..o + 0x06].copy_from_slice(&0x30u16.to_le_bytes()); // update seq offset
            data[o + 0x06..o + 0x08].copy_from_slice(&3u16.to_le_bytes()); // update seq size
            data[o + 0x14..o + 0x16].copy_from_slice(&0x38u16.to_le_bytes()); // first attr offset
            data[o + 0x16..o + 0x18].copy_from_slice(&0x01u16.to_le_bytes()); // flags: in-use
            data[o + 0x18..o + 0x1C].copy_from_slice(&0x38u32.to_le_bytes()); // bytes used
            data[o + 0x1C..o + 0x20].copy_from_slice(&1024u32.to_le_bytes()); // allocated size
                                                                              // Write end-of-attributes marker (0xFFFF_FFFF) at first attribute offset
            data[o + 0x38..o + 0x3C].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
        }
        let mft_data = MftData::parse(&data).unwrap();
        // All entries lack $FILE_NAME, so should be skipped via `continue`
        assert!(mft_data.entries.is_empty());
    }

    #[test]
    fn test_mft_entry_ads_detection_field() {
        // Test that the has_ads field works correctly with manually constructed entries
        let mut entry = make_mft_entry(100, 1, "file_with_ads.txt", 5, 5, false, true);
        assert!(!entry.has_ads);
        entry.has_ads = true;
        assert!(entry.has_ads);

        // Verify ADS entry shows up in detect_timestomping correctly (no false positives)
        let mft_data = MftData {
            entries: vec![entry],
            by_entry: HashMap::new(),
            by_key: HashMap::new(),
        };
        // ADS alone should not trigger timestomping
        assert_eq!(mft_data.detect_timestomping().len(), 0);
    }

    #[test]
    fn test_mft_data_seed_rewind_multiple() {
        // Test seeding rewind with multiple entries and verify path resolution
        let e1 = make_mft_entry(10, 1, "Users", 5, 5, true, true);
        let e2 = make_mft_entry(20, 1, "admin", 10, 1, true, true);
        let e3 = make_mft_entry(30, 1, "Desktop", 20, 1, true, true);

        let mut by_entry = HashMap::new();
        by_entry.insert(10u64, 0usize);
        by_entry.insert(20u64, 1usize);
        by_entry.insert(30u64, 2usize);

        let mut by_key = HashMap::new();
        by_key.insert(EntryKey::new(10, 1), 0usize);
        by_key.insert(EntryKey::new(20, 1), 1usize);
        by_key.insert(EntryKey::new(30, 1), 2usize);

        let mft_data = MftData {
            entries: vec![e1, e2, e3],
            by_entry,
            by_key,
        };

        let engine = mft_data.seed_rewind();
        assert_eq!(engine.lookup_len(), 3);
        let path = engine.resolve_path(&EntryKey::new(30, 1));
        assert_eq!(path, ".\\Users\\admin\\Desktop");
    }

    #[test]
    fn test_mft_data_full_path_field() {
        let entry = make_mft_entry(100, 1, "test.txt", 5, 5, false, true);
        assert_eq!(entry.full_path, ".\\test.txt");
    }

    /// Build a synthetic MFT record binary that the `mft` crate can parse.
    /// This constructs:
    /// - FILE record header (0x38 bytes, fixup at 0x30)
    /// - $`STANDARD_INFORMATION` attribute (type 0x10, 96 bytes of data)
    /// - $FILE_NAME attribute (type 0x30, variable size)
    /// - End marker (`0xFFFF_FFFF`)
    ///   Covers lines 70-72, 92-97, 104, 108-114, 117-118, 120-123, 129-130, 136, 158-160
    fn build_mft_entry_bytes(
        entry_number: u32,
        sequence: u16,
        parent_entry: u64,
        parent_seq: u16,
        filename: &str,
        flags: u16, // 0x01 = in-use, 0x02 = directory
    ) -> Vec<u8> {
        let name_utf16: Vec<u16> = filename.encode_utf16().collect();
        let fn_name_len = name_utf16.len();

        // $STANDARD_INFORMATION attribute:
        // attr header: type(4) + size(4) + non_resident(1) + name_len(1) + name_off(2) + flags(2) + attr_id(2) + content_size(4) + content_off(2) + padding(2) = 24 bytes
        // attr data: 72 bytes (4 timestamps x 8 bytes + class_id + owner_id + security_id + quota_charged + usn)
        let si_data_size: u32 = 72;
        let si_attr_header_size: u16 = 24;
        let si_total_size: u32 = u32::from(si_attr_header_size) + si_data_size;
        let si_total_aligned = (si_total_size + 7) & !7;

        // $FILE_NAME attribute:
        // attr header: 24 bytes
        // FN data: parent_ref(8) + created(8) + modified(8) + mft_mod(8) + accessed(8) + alloc_size(8) + real_size(8) + flags(4) + reparse(4) + name_len(1) + name_type(1) + name(fn_name_len*2)
        let fn_data_size: u32 = 66 + (fn_name_len as u32 * 2);
        let fn_attr_header_size: u16 = 24;
        let fn_total_size: u32 = u32::from(fn_attr_header_size) + fn_data_size;
        let fn_total_aligned = (fn_total_size + 7) & !7;

        // Total record size (must be multiple of 8)
        let first_attr_offset: u16 = 0x38; // standard for NTFS
        let bytes_used: u32 =
            u32::from(first_attr_offset) + si_total_aligned + fn_total_aligned + 8; // +8 for end marker + padding
        let alloc_size: u32 = 1024;
        let mut buf = vec![0u8; alloc_size as usize];

        // FILE record header
        buf[0..4].copy_from_slice(b"FILE"); // signature
        buf[0x04..0x06].copy_from_slice(&0x30u16.to_le_bytes()); // update sequence offset
        buf[0x06..0x08].copy_from_slice(&3u16.to_le_bytes()); // update sequence size (1 + 2 sectors for 1024-byte entry)
        buf[0x08..0x10].copy_from_slice(&0u64.to_le_bytes()); // $LogFile LSN
        buf[0x10..0x12].copy_from_slice(&sequence.to_le_bytes()); // sequence number
        buf[0x12..0x14].copy_from_slice(&0u16.to_le_bytes()); // hard link count
        buf[0x14..0x16].copy_from_slice(&first_attr_offset.to_le_bytes()); // first attribute offset
        buf[0x16..0x18].copy_from_slice(&flags.to_le_bytes()); // flags
        buf[0x18..0x1C].copy_from_slice(&bytes_used.to_le_bytes()); // bytes used
        buf[0x1C..0x20].copy_from_slice(&alloc_size.to_le_bytes()); // allocated size
        buf[0x20..0x28].copy_from_slice(&0u64.to_le_bytes()); // base record
        buf[0x28..0x2C].copy_from_slice(&0u32.to_le_bytes()); // next attribute id + padding
        buf[0x2C..0x30].copy_from_slice(&entry_number.to_le_bytes()); // MFT record number (XP+)
                                                                      // Update sequence array at 0x30: value(2) + entry1(2) + entry2(2) = 6 bytes
        buf[0x30..0x32].copy_from_slice(&0x0001u16.to_le_bytes()); // update sequence value
        buf[0x32..0x34].copy_from_slice(&0x0000u16.to_le_bytes()); // fixup for sector 1
        buf[0x34..0x36].copy_from_slice(&0x0000u16.to_le_bytes()); // fixup for sector 2

        // Apply fixup: write update sequence value at last 2 bytes of each 512-byte sector
        buf[0x1FE..0x200].copy_from_slice(&0x0001u16.to_le_bytes());
        buf[0x3FE..0x400].copy_from_slice(&0x0001u16.to_le_bytes());

        let mut off = first_attr_offset as usize;

        // $STANDARD_INFORMATION attribute (type 0x10)
        buf[off..off + 4].copy_from_slice(&0x10u32.to_le_bytes()); // type
        buf[off + 4..off + 8].copy_from_slice(&si_total_aligned.to_le_bytes()); // total size
        buf[off + 8] = 0; // non-resident flag (resident)
        buf[off + 9] = 0; // name length
        buf[off + 10..off + 12].copy_from_slice(&0u16.to_le_bytes()); // name offset
        buf[off + 12..off + 14].copy_from_slice(&0u16.to_le_bytes()); // flags
        buf[off + 14..off + 16].copy_from_slice(&0u16.to_le_bytes()); // attribute id
        buf[off + 16..off + 20].copy_from_slice(&si_data_size.to_le_bytes()); // content size
        buf[off + 20..off + 22].copy_from_slice(&si_attr_header_size.to_le_bytes()); // content offset
        buf[off + 22..off + 24].copy_from_slice(&0u16.to_le_bytes()); // padding

        // SI data: 4 timestamps (Windows FILETIME, 8 bytes each)
        let ts: i64 = 133_500_480_000_000_000; // 2024-01-15 12:00:00 UTC
        let si_data_off = off + si_attr_header_size as usize;
        buf[si_data_off..si_data_off + 8].copy_from_slice(&ts.to_le_bytes()); // created
        buf[si_data_off + 8..si_data_off + 16].copy_from_slice(&ts.to_le_bytes()); // modified
        buf[si_data_off + 16..si_data_off + 24].copy_from_slice(&ts.to_le_bytes()); // mft modified
        buf[si_data_off + 24..si_data_off + 32].copy_from_slice(&ts.to_le_bytes()); // accessed

        off += si_total_aligned as usize;

        // $FILE_NAME attribute (type 0x30)
        buf[off..off + 4].copy_from_slice(&0x30u32.to_le_bytes()); // type
        buf[off + 4..off + 8].copy_from_slice(&fn_total_aligned.to_le_bytes()); // total size
        buf[off + 8] = 0; // non-resident flag
        buf[off + 9] = 0; // name length
        buf[off + 10..off + 12].copy_from_slice(&0u16.to_le_bytes()); // name offset
        buf[off + 12..off + 14].copy_from_slice(&0u16.to_le_bytes()); // flags
        buf[off + 14..off + 16].copy_from_slice(&1u16.to_le_bytes()); // attribute id
        buf[off + 16..off + 20].copy_from_slice(&fn_data_size.to_le_bytes()); // content size
        buf[off + 20..off + 22].copy_from_slice(&fn_attr_header_size.to_le_bytes()); // content offset
        buf[off + 22..off + 24].copy_from_slice(&0u16.to_le_bytes()); // padding

        let fn_data_off = off + fn_attr_header_size as usize;
        // Parent directory MFT reference (6 bytes entry + 2 bytes sequence)
        let parent_ref = parent_entry | (u64::from(parent_seq) << 48);
        buf[fn_data_off..fn_data_off + 8].copy_from_slice(&parent_ref.to_le_bytes());
        // Timestamps in FN (4 x 8 bytes)
        buf[fn_data_off + 8..fn_data_off + 16].copy_from_slice(&ts.to_le_bytes()); // created
        buf[fn_data_off + 16..fn_data_off + 24].copy_from_slice(&ts.to_le_bytes()); // modified
        buf[fn_data_off + 24..fn_data_off + 32].copy_from_slice(&ts.to_le_bytes()); // mft modified
        buf[fn_data_off + 32..fn_data_off + 40].copy_from_slice(&ts.to_le_bytes()); // accessed
                                                                                    // Allocated size and real size
        buf[fn_data_off + 40..fn_data_off + 48].copy_from_slice(&0u64.to_le_bytes());
        buf[fn_data_off + 48..fn_data_off + 56].copy_from_slice(&0u64.to_le_bytes());
        // Flags and reparse
        buf[fn_data_off + 56..fn_data_off + 60].copy_from_slice(&0u32.to_le_bytes());
        buf[fn_data_off + 60..fn_data_off + 64].copy_from_slice(&0u32.to_le_bytes());
        // Name length (in characters)
        buf[fn_data_off + 64] = fn_name_len as u8;
        // Name type (0x03 = Win32 & DOS)
        buf[fn_data_off + 65] = 0x03;
        // Name UTF-16LE
        for (i, &ch) in name_utf16.iter().enumerate() {
            let name_off = fn_data_off + 66 + i * 2;
            buf[name_off..name_off + 2].copy_from_slice(&ch.to_le_bytes());
        }

        off += fn_total_aligned as usize;

        // End of attributes marker
        buf[off..off + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());

        buf
    }

    #[test]
    fn test_mft_data_parse_valid_entry() {
        // Build a valid MFT entry with SI and FN attributes
        // This should exercise lines 70-72 (Err skip path not hit),
        // 92-97 (SI timestamps parsed), 104 (find_best_name_attribute),
        // 108-114 (FN fields extracted), 117-118 (ADS check loop),
        // 120-123 (ADS check), 129-130 (full_path), 136 (idx),
        // 158-160 (by_entry/by_key/entries.push)
        let entry_data = build_mft_entry_bytes(
            100, // entry number
            1,   // sequence
            5,   // parent entry
            5,   // parent sequence
            "testfile.txt",
            0x01, // flags: in-use
        );

        let mft_data = MftData::parse(&entry_data).unwrap();
        // If parsing succeeded, verify the entry was extracted
        if !mft_data.entries.is_empty() {
            let e = &mft_data.entries[0];
            assert_eq!(e.filename, "testfile.txt");
            assert_eq!(e.parent_entry, 5);
            assert!(e.si_created.is_some());
            assert!(e.fn_created.is_some());
            assert!(!e.has_ads);
            // The mft crate may use position-based entry number (0)
            // rather than the header field (100), so check by actual entry number
            let entry_num = e.entry_number;
            assert!(mft_data.by_entry.contains_key(&entry_num));
            assert!(mft_data
                .by_key
                .contains_key(&EntryKey::new(entry_num, e.sequence_number)));
        } // cov:unreachable: the valid fixture always parses to a non-empty entries vec, so the if-empty false branch is never taken
    }

    #[test]
    fn test_mft_data_parse_entry_with_ads() {
        // Build an MFT entry and manually add a $DATA attribute with a name
        // to test ADS detection (lines 117-123)
        let mut entry_data = build_mft_entry_bytes(200, 1, 5, 5, "ads_file.txt", 0x01);

        // Find end marker location and replace it with a named $DATA attribute
        // then add end marker after
        let first_attr_offset = 0x38usize;
        let mut off = first_attr_offset;
        // Skip through attributes to find end marker
        loop {
            if off + 4 > entry_data.len() {
                break; // cov:unreachable: the 0xFFFF_FFFF end-of-attributes marker is reached well within the 1024-byte fixture, so the off+4 bounds break is never taken
            }
            let attr_type = u32::from_le_bytes([
                entry_data[off],
                entry_data[off + 1],
                entry_data[off + 2],
                entry_data[off + 3],
            ]);
            if attr_type == 0xFFFF_FFFF {
                break;
            }
            let attr_size = u32::from_le_bytes([
                entry_data[off + 4],
                entry_data[off + 5],
                entry_data[off + 6],
                entry_data[off + 7],
            ]) as usize;
            if attr_size == 0 || off + attr_size > entry_data.len() {
                break; // cov:unreachable: the builder's SI/FN attributes have non-zero sizes within the 1024-byte fixture and the end marker is hit first, so this break is never taken
            }
            off += attr_size;
        }

        // Insert a named $DATA attribute (for ADS) at `off`
        // Resident $DATA attr with name "Zone.Identifier"
        let ads_name = "Zone.Identifier";
        let ads_name_utf16: Vec<u16> = ads_name.encode_utf16().collect();
        let ads_name_bytes = ads_name_utf16.len() * 2;
        let ads_attr_header_size = 24u16;
        let ads_content_size = 0u32; // empty content
                                     // Name offset is right after content_offset field (at header + 0)
                                     // For named attrs, name_offset points within the attr header
        let ads_name_offset = ads_attr_header_size;
        let ads_total =
            (u32::from(ads_attr_header_size) + ads_name_bytes as u32 + ads_content_size + 7) & !7;

        if off + ads_total as usize + 8 <= entry_data.len() {
            entry_data[off..off + 4].copy_from_slice(&0x80u32.to_le_bytes()); // $DATA type
            entry_data[off + 4..off + 8].copy_from_slice(&ads_total.to_le_bytes());
            entry_data[off + 8] = 0; // resident
            entry_data[off + 9] = ads_name_utf16.len() as u8; // name length in chars
            entry_data[off + 10..off + 12].copy_from_slice(&ads_name_offset.to_le_bytes());
            entry_data[off + 12..off + 14].copy_from_slice(&0u16.to_le_bytes());
            entry_data[off + 14..off + 16].copy_from_slice(&2u16.to_le_bytes()); // attr id
            entry_data[off + 16..off + 20].copy_from_slice(&ads_content_size.to_le_bytes());
            let content_off = ads_name_offset + ads_name_bytes as u16;
            entry_data[off + 20..off + 22].copy_from_slice(&content_off.to_le_bytes());

            // Write name
            let name_start = off + ads_name_offset as usize;
            for (i, &ch) in ads_name_utf16.iter().enumerate() {
                let pos = name_start + i * 2;
                if pos + 2 <= entry_data.len() {
                    entry_data[pos..pos + 2].copy_from_slice(&ch.to_le_bytes());
                }
            }

            let end_off = off + ads_total as usize;
            if end_off + 4 <= entry_data.len() {
                entry_data[end_off..end_off + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
            }

            // Update bytes used
            let new_bytes_used = (end_off + 8) as u32;
            entry_data[0x18..0x1C].copy_from_slice(&new_bytes_used.to_le_bytes());
        } // cov:unreachable: the named $DATA attribute fits well within the 1024-byte fixture, so the off+ads_total+8 bounds check is always true and its false branch is never taken

        let mft_data = MftData::parse(&entry_data).unwrap();
        if !mft_data.entries.is_empty() {
            let e = &mft_data.entries[0];
            assert_eq!(e.filename, "ads_file.txt");
            // ADS detection depends on mft crate's attribute iteration
            // If it works, has_ads should be true
            // If it doesn't parse the named $DATA attr, has_ads stays false
            // Either way, we've exercised the ADS detection loop
        } // cov:unreachable: the valid ADS fixture always parses to a non-empty entries vec, so the if-empty false branch is never taken
    }

    #[test]
    fn test_mft_data_parse_multiple_entries() {
        // Build multiple MFT entries to test parsing loop and indexing
        let entry0 = build_mft_entry_bytes(0, 1, 5, 5, "root", 0x03); // root dir, entry 0
        let entry1 = build_mft_entry_bytes(1, 1, 0, 1, "file1.txt", 0x01);
        let entry2 = build_mft_entry_bytes(2, 1, 0, 1, "file2.doc", 0x01);

        let mut data = Vec::new();
        data.extend_from_slice(&entry0);
        data.extend_from_slice(&entry1);
        data.extend_from_slice(&entry2);

        let mft_data = MftData::parse(&data).unwrap();
        // Should have parsed at least some entries
        // (depends on mft crate behavior with synthetic data)
        assert!(mft_data.entries.len() <= 3);
    }

    #[test]
    fn test_mft_data_parse_entry_without_filename_skipped() {
        // Build an entry with only $SI (no $FN) - should be skipped via line 104-105
        let mut buf = vec![0u8; 1024];

        buf[0..4].copy_from_slice(b"FILE");
        buf[0x04..0x06].copy_from_slice(&0x30u16.to_le_bytes());
        buf[0x06..0x08].copy_from_slice(&3u16.to_le_bytes());
        buf[0x10..0x12].copy_from_slice(&1u16.to_le_bytes()); // sequence
        buf[0x14..0x16].copy_from_slice(&0x38u16.to_le_bytes()); // first attr
        buf[0x16..0x18].copy_from_slice(&0x01u16.to_le_bytes()); // in-use
        let si_size = 96u32;
        let si_aligned = (si_size + 7) & !7;
        buf[0x18..0x1C].copy_from_slice(&(0x38u32 + si_aligned + 8).to_le_bytes()); // bytes used
        buf[0x1C..0x20].copy_from_slice(&1024u32.to_le_bytes()); // allocated
        buf[0x28..0x2C].copy_from_slice(&50u32.to_le_bytes()); // entry number

        // Fixups
        buf[0x30..0x32].copy_from_slice(&0x0001u16.to_le_bytes());
        buf[0x1FE..0x200].copy_from_slice(&0x0001u16.to_le_bytes());
        buf[0x3FE..0x400].copy_from_slice(&0x0001u16.to_le_bytes());

        // $SI attribute only
        let off = 0x38;
        buf[off..off + 4].copy_from_slice(&0x10u32.to_le_bytes());
        buf[off + 4..off + 8].copy_from_slice(&si_aligned.to_le_bytes());
        buf[off + 8] = 0;
        buf[off + 16..off + 20].copy_from_slice(&72u32.to_le_bytes());
        buf[off + 20..off + 22].copy_from_slice(&24u16.to_le_bytes());

        let end_off = off + si_aligned as usize;
        buf[end_off..end_off + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());

        let mft_data = MftData::parse(&buf).unwrap();
        // Entry without $FILE_NAME should be skipped (line 104-105)
        assert!(mft_data.entries.is_empty());
    }

    #[test]
    fn test_mft_data_is_directory_and_in_use() {
        let dir_entry = make_mft_entry(100, 1, "Documents", 5, 5, true, true);
        assert!(dir_entry.is_directory);
        assert!(dir_entry.is_in_use);

        let deleted_entry = make_mft_entry(200, 1, "deleted.txt", 5, 5, false, false);
        assert!(!deleted_entry.is_directory);
        assert!(!deleted_entry.is_in_use);
    }

    /// Build a raw 1024-byte MFT entry with FILE signature and valid header.
    fn build_raw_mft_entry_buf(seq: u16, flags: u16) -> Vec<u8> {
        let mut buf = vec![0u8; 1024];

        // FILE signature
        buf[0..4].copy_from_slice(b"FILE");
        // usa_offset: 0x30 (just past the header)
        buf[0x04..0x06].copy_from_slice(&0x30u16.to_le_bytes());
        // usa_size: 3 (1 marker + 2 sector fixups for 1024 bytes / 512 byte sectors)
        buf[0x06..0x08].copy_from_slice(&3u16.to_le_bytes());
        // logfile_sequence_number
        buf[0x08..0x10].copy_from_slice(&0u64.to_le_bytes());
        // sequence number
        buf[0x10..0x12].copy_from_slice(&seq.to_le_bytes());
        // hard_link_count
        buf[0x12..0x14].copy_from_slice(&1u16.to_le_bytes());
        // first_attribute_offset: 0x38 (after USA)
        buf[0x14..0x16].copy_from_slice(&0x38u16.to_le_bytes());
        // flags (0x01 = IN_USE, 0x02 = IS_DIRECTORY)
        buf[0x16..0x18].copy_from_slice(&flags.to_le_bytes());
        // used_entry_size
        buf[0x18..0x1C].copy_from_slice(&512u32.to_le_bytes());
        // total_entry_size (allocated)
        buf[0x1C..0x20].copy_from_slice(&1024u32.to_le_bytes());
        // base_reference (8 bytes of zero = no base)
        // first_attribute_id
        buf[0x28..0x2A].copy_from_slice(&0u16.to_le_bytes());

        // USA: write update sequence array at offset 0x30
        let marker: u16 = 0x0001;
        buf[0x30..0x32].copy_from_slice(&marker.to_le_bytes());
        buf[0x32..0x34].copy_from_slice(&marker.to_le_bytes());
        buf[0x34..0x36].copy_from_slice(&marker.to_le_bytes());

        // Write the marker at the end of each sector so fixup validation passes
        buf[510..512].copy_from_slice(&marker.to_le_bytes());
        buf[1022..1024].copy_from_slice(&marker.to_le_bytes());

        // Write an end-of-attributes marker (0xFFFF_FFFF) at first_attribute_offset
        buf[0x38..0x3C].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());

        buf
    }

    #[test]
    fn test_mft_parse_with_corrupt_entry() {
        // Cover lines 70-72: the Err(e) branch in MFT parsing.
        // Create MFT data with a valid first entry, then an entry with an
        // invalid signature that the mft crate will report as an error.

        // First entry: valid FILE entry (parser reads total_entry_size from this)
        let entry0 = build_raw_mft_entry_buf(1, 0x01);

        // Second entry: invalid signature (not FILE, BAAD, or zero)
        // This will cause MftEntry::from_buffer to return InvalidEntrySignature error.
        let mut entry1 = vec![0u8; 1024];
        entry1[0..4].copy_from_slice(b"DEAD"); // Invalid signature
        entry1[0x1C..0x20].copy_from_slice(&1024u32.to_le_bytes());

        let mut data = Vec::new();
        data.extend_from_slice(&entry0);
        data.extend_from_slice(&entry1);

        // The corrupt second entry is skipped; parsing still succeeds.
        let mft_data = MftData::parse(&data).unwrap();
        let _ = mft_data.entries.len();
    }

    #[test]
    fn parse_skips_entry_with_fixup_mismatch() {
        // Corrupt the update-sequence tail so apply_fixup rejects the record.
        let mut e = build_mft_entry_bytes(202, 1, 5, 5, "x.txt", 0x01);
        e[0x1FE] = 0xFF;
        e[0x1FF] = 0xFF;
        let m = MftData::parse(&e).unwrap();
        assert!(m.entries.is_empty());
    }

    #[test]
    fn parse_covers_filename_namespace_priorities() {
        // name_type byte sits at a fixed offset (0xF1) for this fixture layout.
        const NS_OFF: usize = 0xF1;

        // namespace 2 (DOS) -> priority arm `2 => 1`
        let mut dos = build_mft_entry_bytes(210, 1, 5, 5, "DOS~1.TXT", 0x01);
        dos[NS_OFF] = 0x02;
        let m = MftData::parse(&dos).unwrap();
        assert_eq!(m.entries.len(), 1);
        assert_eq!(m.entries[0].filename, "DOS~1.TXT");

        // namespace 0 (POSIX) -> default arm `_ => 2`
        let mut posix = build_mft_entry_bytes(211, 1, 5, 5, "posix.txt", 0x01);
        posix[NS_OFF] = 0x00;
        let m2 = MftData::parse(&posix).unwrap();
        assert_eq!(m2.entries[0].filename, "posix.txt");
    }

    #[test]
    fn parse_full_path_stops_on_missing_parent() {
        // parent 999 is absent from the table, so resolve_full_path takes the
        // `_ => break` arm of the parent-chain walk.
        let e = build_mft_entry_bytes(300, 1, 999, 1, "orphan.txt", 0x01);
        let m = MftData::parse(&e).unwrap();
        assert_eq!(m.entries.len(), 1);
        assert!(m.entries[0].full_path.contains("orphan.txt"));
    }

    /// Append an UNNAMED `$DATA` attribute (resident or non-resident) to a fresh
    /// MFT record, so `MftData::parse` extracts a `file_size` from it.
    fn entry_with_unnamed_data(
        entry_num: u32,
        name: &str,
        non_resident: bool,
        size: u64,
    ) -> Vec<u8> {
        let mut buf = build_mft_entry_bytes(entry_num, 1, 5, 5, name, 0x01);
        // Walk to the end-of-attributes marker.
        let mut off = 0x38usize;
        loop {
            let t = u32::from_le_bytes(buf[off..off + 4].try_into().unwrap());
            if t == 0xFFFF_FFFF {
                break;
            }
            let sz = u32::from_le_bytes(buf[off + 4..off + 8].try_into().unwrap()) as usize;
            off += sz;
        }
        let total: u32 = if non_resident {
            // Non-resident header is 0x40 bytes; a single 0x00 runlist terminator follows.
            let total = ((0x40 + 8 + 7) & !7) as u32;
            buf[off..off + 4].copy_from_slice(&0x80u32.to_le_bytes());
            buf[off + 4..off + 8].copy_from_slice(&total.to_le_bytes());
            buf[off + 8] = 1; // non-resident
            buf[off + 9] = 0; // unnamed
            buf[off + 14..off + 16].copy_from_slice(&3u16.to_le_bytes()); // attr id
            buf[off + 0x20..off + 0x22].copy_from_slice(&0x40u16.to_le_bytes()); // runs_offset
            buf[off + 0x28..off + 0x30].copy_from_slice(&4096u64.to_le_bytes()); // allocated
            buf[off + 0x30..off + 0x38].copy_from_slice(&size.to_le_bytes()); // real_size
            buf[off + 0x38..off + 0x40].copy_from_slice(&size.to_le_bytes()); // initialized
            total
        } else {
            let content = size as usize;
            let total = ((24 + content + 7) & !7) as u32;
            buf[off..off + 4].copy_from_slice(&0x80u32.to_le_bytes());
            buf[off + 4..off + 8].copy_from_slice(&total.to_le_bytes());
            buf[off + 8] = 0; // resident
            buf[off + 9] = 0; // unnamed
            buf[off + 14..off + 16].copy_from_slice(&3u16.to_le_bytes());
            buf[off + 16..off + 20].copy_from_slice(&(content as u32).to_le_bytes()); // content_length
            buf[off + 20..off + 22].copy_from_slice(&24u16.to_le_bytes()); // content_offset
            total
        };
        let end = off + total as usize;
        buf[end..end + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
        buf[0x18..0x1C].copy_from_slice(&((end + 8) as u32).to_le_bytes()); // bytes used
        buf
    }

    #[test]
    fn parse_extracts_file_size_from_unnamed_data() {
        // Resident unnamed $DATA -> file_size from content_length.
        let res = entry_with_unnamed_data(400, "res.txt", false, 42);
        let m = MftData::parse(&res).unwrap();
        assert_eq!(m.entries.len(), 1);
        assert_eq!(m.entries[0].file_size, 42);
        // Non-resident unnamed $DATA -> file_size from real_size.
        let nr = entry_with_unnamed_data(401, "nr.txt", true, 123_456);
        let m2 = MftData::parse(&nr).unwrap();
        assert_eq!(m2.entries.len(), 1);
        assert_eq!(m2.entries[0].file_size, 123_456);
    }

    #[test]
    fn parse_handles_out_of_bounds_attribute_offset_without_panic() {
        // first_attribute_offset points past the 1024-byte record. parse_attributes
        // returns no attributes (rather than erroring), so the entry is skipped for
        // lacking a $FILE_NAME — and parsing never panics on the bad offset.
        let mut e = build_mft_entry_bytes(310, 1, 5, 5, "x.txt", 0x01);
        e[0x14..0x16].copy_from_slice(&0x0410u16.to_le_bytes());
        let m = MftData::parse(&e).unwrap();
        assert!(m.entries.is_empty());
    }

    #[test]
    fn parse_keeps_higher_priority_filename_over_later_lower_priority() {
        // Build an entry whose single $FILE_NAME is Win32+DOS (namespace 3 ⇒
        // priority 3), then append a SECOND $FILE_NAME that is DOS-only
        // (namespace 2 ⇒ priority 1). The second filename parses successfully but
        // `is_none_or(|(p, _)| priority > *p)` is false (1 > 3 is false), so it does
        // NOT replace `best` — exercising the if-not-taken join after the priority
        // check. The retained name must remain the first (Win32+DOS) one.
        let base = build_mft_entry_bytes(330, 1, 5, 5, "WIN32.TXT", 0x01);
        let mut e = base.clone();

        // Walk attributes to locate the $FILE_NAME (0x30) block and the end marker.
        let mut off = 0x38usize;
        let mut fn_off = None;
        let mut fn_len = 0usize;
        loop {
            let t = u32::from_le_bytes(e[off..off + 4].try_into().unwrap());
            if t == 0xFFFF_FFFF {
                break;
            }
            let sz = u32::from_le_bytes(e[off + 4..off + 8].try_into().unwrap()) as usize;
            if t == 0x30 {
                fn_off = Some(off);
                fn_len = sz;
            }
            off += sz;
        }
        let fn_off = fn_off.unwrap();
        let end_marker = off;

        // Copy the $FILE_NAME attribute and append it after the first one.
        let mut second: Vec<u8> = e[fn_off..fn_off + fn_len].to_vec();
        // name_type byte sits at attr-header(24) + FN-data offset 65 within the copy.
        second[24 + 65] = 0x02; // DOS namespace ⇒ priority 1
        e[end_marker..end_marker + fn_len].copy_from_slice(&second);
        // New end-of-attributes marker after the appended attribute.
        let new_end = end_marker + fn_len;
        e[new_end..new_end + 4].copy_from_slice(&0xFFFF_FFFFu32.to_le_bytes());
        e[0x18..0x1C].copy_from_slice(&((new_end + 8) as u32).to_le_bytes()); // bytes used

        let m = MftData::parse(&e).unwrap();
        assert_eq!(m.entries.len(), 1);
        assert_eq!(m.entries[0].filename, "WIN32.TXT");
    }

    #[test]
    fn parse_skips_entry_when_parse_attributes_errors() {
        // Corrupt the first attribute's length field (at first_attr_offset + 4 = 0x3C)
        // to below HEADER_MIN (0x10). parse_attributes returns Err("length below
        // header minimum") rather than Ok, driving the `else { continue }` arm that
        // skips the malformed entry. The fixup tails at 0x1FE/0x3FE are untouched, so
        // the FILE record still validates up to the attribute walk.
        let mut e = build_mft_entry_bytes(320, 1, 5, 5, "x.txt", 0x01);
        e[0x3C..0x40].copy_from_slice(&0x0000_0001u32.to_le_bytes());
        let m = MftData::parse(&e).unwrap();
        assert!(m.entries.is_empty());
    }
}
