//! Per-file sync status engine — a port of `computeStatuses` in
//! `TauriClient/src/data/fileStatus.js` (lines ~232-316). Pure: no I/O, no
//! rendering concerns (icons/colors/display names are a later task).
//!
//! Rule order (verbatim from fileStatus.js, the JS source of truth):
//!   1. path in `changelist.del` -> `MarkedForDelete`.
//!   2. lock exists: same user+device -> `CheckedOutByMe`; same user, other
//!      device -> `LockedByMeElsewhere`; else -> `LockedByOther` (username
//!      compared case-insensitively).
//!   3. on server AND local, both hashes present and different: manifest
//!      base == local hash -> `Outdated(head_revision)`, else `Modified`.
//!   4. local-only: ignored -> no status; `unsafe_reason` or
//!      `collision_in_index` -> `NamingIssue`; in `changelist.add` ->
//!      `MarkedForAdd`; else -> `NotAdded`.
//!   5. Vanished-file rule (port of `useFileControl.js`'s external-deletion
//!      effect): a path with a manifest entry, present on the server, but
//!      missing locally and not already in `changelist.del`, is auto-marked
//!      `MarkedForDelete` and reported back via `vanished` so the caller
//!      (a later task's executor) can persist it into the changelist. This
//!      engine stays pure — it does not write to disk.
//!
//! The JS `serverReady`/`pullingPaths` guards on rule 4 don't apply here:
//! the CLI always scans a fresh tree and never has an in-flight pull to hide
//! a status behind.

use std::collections::{BTreeMap, BTreeSet};

use crate::api::LockInfo;
use crate::config::Changelist;
use crate::ignore::IgnoreMatcher;
use crate::safenames::{build_folded_index, collision_in_index, unsafe_reason};

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileStatus {
    Modified,
    Outdated { head_revision: Option<u64> },
    CheckedOutByMe,
    LockedByOther { holder: String },
    LockedByMeElsewhere { device_name: String },
    MarkedForAdd,
    MarkedForDelete,
    NotAdded,
    NamingIssue { reason: String },
}

/// One entry of the flattened depot tree (`GET /api/depot/tree`).
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ServerFile {
    pub hash: Option<String>,
    pub size_bytes: u64,
    pub head_revision: Option<u64>,
}

/// One entry of the flattened workspace scan.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LocalFile {
    pub hash: Option<String>,
    pub writable: bool,
    pub size_bytes: u64,
}

pub struct StatusInputs<'a> {
    pub server_files: &'a BTreeMap<String, ServerFile>,
    pub local_files: &'a BTreeMap<String, LocalFile>,
    pub locks: &'a BTreeMap<String, LockInfo>,
    pub changelist: &'a Changelist,
    /// The manifest: path -> depot hash the local copy was last synced to
    /// (`None` = base unknown).
    pub base_hashes: &'a BTreeMap<String, Option<String>>,
    /// (username, device_id) of the caller.
    pub me: (&'a str, &'a str),
    pub ignore: &'a IgnoreMatcher,
}

/// `compute_statuses`'s return value. The brief's interface names the
/// function `compute_statuses` returning a status map; this wraps that map
/// plus the newly-detected vanished paths (rule 5) since the engine must stay
/// pure and cannot itself persist them into the changelist — see the
/// module doc and the task report's PARITY section for the rationale.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StatusResult {
    pub statuses: BTreeMap<String, FileStatus>,
    /// Paths newly detected as vanished this call (on server + in manifest,
    /// missing locally, not yet in `changelist.del`). Also reflected as
    /// `MarkedForDelete` in `statuses`.
    pub vanished: Vec<String>,
}

pub fn compute_statuses(i: &StatusInputs) -> StatusResult {
    let mut statuses: BTreeMap<String, FileStatus> = BTreeMap::new();
    let (me_username, me_device) = i.me;

    // Every path we know about (server ∪ workspace ∪ locks ∪ del).
    let mut paths: BTreeSet<&str> = BTreeSet::new();
    paths.extend(i.server_files.keys().map(String::as_str));
    paths.extend(i.local_files.keys().map(String::as_str));
    paths.extend(i.locks.keys().map(String::as_str));
    paths.extend(i.changelist.del.iter().map(String::as_str));

    // Folded index over everything we know about, so a local-only file can
    // be checked for case/normalization collisions against the depot and
    // each other. Matches JS: built from serverFiles ∪ localFiles keys only
    // (not locks/del).
    let folded_index = build_folded_index(
        i.server_files
            .keys()
            .chain(i.local_files.keys())
            .map(String::as_str),
    );

    for path in paths {
        if i.changelist.del.contains(path) {
            statuses.insert(path.to_string(), FileStatus::MarkedForDelete);
            continue;
        }

        if let Some(lock) = i.locks.get(path) {
            let same_user = lock.username.to_lowercase() == me_username.to_lowercase();
            if same_user && lock.device_id == me_device {
                statuses.insert(path.to_string(), FileStatus::CheckedOutByMe);
                continue;
            }
            if same_user {
                statuses.insert(
                    path.to_string(),
                    FileStatus::LockedByMeElsewhere {
                        device_name: lock.device_name.clone(),
                    },
                );
                continue;
            }
            statuses.insert(
                path.to_string(),
                FileStatus::LockedByOther {
                    holder: lock.username.clone(),
                },
            );
            continue;
        }

        let server = i.server_files.get(path);
        let local = i.local_files.get(path);
        match (server, local) {
            (Some(server), Some(local)) => {
                if let (Some(local_hash), Some(server_hash)) = (&local.hash, &server.hash) {
                    if local_hash != server_hash {
                        let base = i.base_hashes.get(path).and_then(|b| b.as_ref());
                        let status = if base == Some(local_hash) {
                            FileStatus::Outdated {
                                head_revision: server.head_revision,
                            }
                        } else {
                            FileStatus::Modified
                        };
                        statuses.insert(path.to_string(), status);
                    }
                }
                // hashes equal (or either absent): in sync, no status.
            }
            (None, Some(_local)) => {
                if i.ignore.is_ignored(path) {
                    continue;
                }
                let reason = unsafe_reason(path).or_else(|| collision_in_index(path, &folded_index));
                if let Some(reason) = reason {
                    statuses.insert(path.to_string(), FileStatus::NamingIssue { reason });
                } else if i.changelist.add.contains(path) {
                    statuses.insert(path.to_string(), FileStatus::MarkedForAdd);
                } else {
                    statuses.insert(path.to_string(), FileStatus::NotAdded);
                }
            }
            // server-only (not yet pulled) or neither: no status.
            _ => {}
        }
    }

    // Rule 5: vanished-file detection. A path with a manifest entry, that's
    // still on the server, but missing from the local scan and not already
    // in changelist.del — port of useFileControl.js's external-deletion
    // effect (`missing` / `newDeletes`).
    let mut vanished = Vec::new();
    for path in i.base_hashes.keys() {
        if i.server_files.contains_key(path)
            && !i.local_files.contains_key(path)
            && !i.changelist.del.contains(path)
        {
            vanished.push(path.clone());
            statuses.insert(path.clone(), FileStatus::MarkedForDelete);
        }
    }

    StatusResult { statuses, vanished }
}

/// Walk a raw depot tree (`GET /api/depot/tree` response) into a flattened
/// path -> `ServerFile` map. Port of `flattenServerFiles` in fileStatus.js.
pub fn flatten_depot_tree(tree: &serde_json::Value) -> BTreeMap<String, ServerFile> {
    let mut out = BTreeMap::new();
    flatten_children(tree.get("children"), "", &mut out);
    out
}

fn flatten_children(children: Option<&serde_json::Value>, prefix: &str, out: &mut BTreeMap<String, ServerFile>) {
    let Some(children) = children.and_then(|c| c.as_array()) else {
        return;
    };
    for node in children {
        let name = node.get("name").and_then(|v| v.as_str()).unwrap_or("");
        let rel = if prefix.is_empty() {
            name.to_string()
        } else {
            format!("{prefix}/{name}")
        };
        match node.get("type").and_then(|v| v.as_str()) {
            Some("file") => {
                let hash = node.get("hash").and_then(|v| v.as_str()).map(str::to_string);
                let size_bytes = node.get("sizeBytes").and_then(|v| v.as_u64()).unwrap_or(0);
                let head_revision = node.get("headRevision").and_then(|v| v.as_u64());
                out.insert(
                    rel,
                    ServerFile {
                        hash,
                        size_bytes,
                        head_revision,
                    },
                );
            }
            Some("folder") => flatten_children(node.get("children"), &rel, out),
            _ => {}
        }
    }
}

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

    fn matcher(text: &str) -> IgnoreMatcher {
        IgnoreMatcher::compile(text)
    }

    fn lock(username: &str, device_id: &str, device_name: &str) -> LockInfo {
        LockInfo {
            username: username.to_string(),
            device_id: device_id.to_string(),
            device_name: device_name.to_string(),
        }
    }

    struct Fixture {
        server_files: BTreeMap<String, ServerFile>,
        local_files: BTreeMap<String, LocalFile>,
        locks: BTreeMap<String, LockInfo>,
        changelist: Changelist,
        base_hashes: BTreeMap<String, Option<String>>,
        ignore: IgnoreMatcher,
    }

    impl Fixture {
        fn new() -> Self {
            Fixture {
                server_files: BTreeMap::new(),
                local_files: BTreeMap::new(),
                locks: BTreeMap::new(),
                changelist: Changelist::default(),
                base_hashes: BTreeMap::new(),
                ignore: matcher(""),
            }
        }

        fn run(&self, username: &str, device_id: &str) -> StatusResult {
            compute_statuses(&StatusInputs {
                server_files: &self.server_files,
                local_files: &self.local_files,
                locks: &self.locks,
                changelist: &self.changelist,
                base_hashes: &self.base_hashes,
                me: (username, device_id),
                ignore: &self.ignore,
            })
        }
    }

    fn server(hash: &str) -> ServerFile {
        ServerFile {
            hash: Some(hash.to_string()),
            size_bytes: 10,
            head_revision: Some(3),
        }
    }

    fn local(hash: &str) -> LocalFile {
        LocalFile {
            hash: Some(hash.to_string()),
            writable: false,
            size_bytes: 10,
        }
    }

    // ---- rule 1: marked for delete -----------------------------------------

    #[test]
    fn marked_for_delete_beats_everything_else() {
        let mut f = Fixture::new();
        f.server_files.insert("a.txt".into(), server("h1"));
        f.local_files.insert("a.txt".into(), local("h1"));
        f.changelist.del.insert("a.txt".into());
        let result = f.run("alice", "dev1");
        assert_eq!(result.statuses.get("a.txt"), Some(&FileStatus::MarkedForDelete));
    }

    // ---- rule 2: locks ------------------------------------------------------

    #[test]
    fn checked_out_by_me_same_user_same_device() {
        let mut f = Fixture::new();
        f.locks.insert("a.txt".into(), lock("alice", "dev1", "Alice's PC"));
        let result = f.run("alice", "dev1");
        assert_eq!(result.statuses.get("a.txt"), Some(&FileStatus::CheckedOutByMe));
    }

    #[test]
    fn locked_by_me_elsewhere_same_user_other_device() {
        let mut f = Fixture::new();
        f.locks.insert("a.txt".into(), lock("alice", "dev2", "Alice's Laptop"));
        let result = f.run("alice", "dev1");
        assert_eq!(
            result.statuses.get("a.txt"),
            Some(&FileStatus::LockedByMeElsewhere {
                device_name: "Alice's Laptop".to_string()
            })
        );
    }

    #[test]
    fn locked_by_other_different_user() {
        let mut f = Fixture::new();
        f.locks.insert("a.txt".into(), lock("dana", "dev9", "Dana's PC"));
        let result = f.run("alice", "dev1");
        assert_eq!(
            result.statuses.get("a.txt"),
            Some(&FileStatus::LockedByOther {
                holder: "dana".to_string()
            })
        );
    }

    #[test]
    fn lock_username_comparison_is_case_insensitive() {
        let mut f = Fixture::new();
        f.locks.insert("a.txt".into(), lock("Alice", "dev1", "Alice's PC"));
        let result = f.run("alice", "dev1");
        assert_eq!(result.statuses.get("a.txt"), Some(&FileStatus::CheckedOutByMe));

        let mut f2 = Fixture::new();
        f2.locks.insert("b.txt".into(), lock("ALICE", "dev2", "Other"));
        let result2 = f2.run("alice", "dev1");
        assert_eq!(
            result2.statuses.get("b.txt"),
            Some(&FileStatus::LockedByMeElsewhere {
                device_name: "Other".to_string()
            })
        );
    }

    // ---- rule 3: modified vs outdated ---------------------------------------

    #[test]
    fn modified_when_hashes_differ_and_no_matching_base() {
        let mut f = Fixture::new();
        f.server_files.insert("a.txt".into(), server("server-hash"));
        f.local_files.insert("a.txt".into(), local("local-hash"));
        // no base recorded at all
        let result = f.run("alice", "dev1");
        assert_eq!(result.statuses.get("a.txt"), Some(&FileStatus::Modified));
    }

    #[test]
    fn modified_when_base_present_but_does_not_match_local() {
        let mut f = Fixture::new();
        f.server_files.insert("a.txt".into(), server("server-hash"));
        f.local_files.insert("a.txt".into(), local("local-hash"));
        f.base_hashes.insert("a.txt".into(), Some("some-other-hash".into()));
        let result = f.run("alice", "dev1");
        assert_eq!(result.statuses.get("a.txt"), Some(&FileStatus::Modified));
    }

    #[test]
    fn outdated_when_base_equals_local_hash_and_server_differs() {
        let mut f = Fixture::new();
        f.server_files.insert("a.txt".into(), server("server-hash"));
        f.local_files.insert("a.txt".into(), local("local-hash"));
        f.base_hashes.insert("a.txt".into(), Some("local-hash".into()));
        let result = f.run("alice", "dev1");
        assert_eq!(
            result.statuses.get("a.txt"),
            Some(&FileStatus::Outdated {
                head_revision: Some(3)
            })
        );
    }

    #[test]
    fn no_status_when_hashes_match() {
        let mut f = Fixture::new();
        f.server_files.insert("a.txt".into(), server("same-hash"));
        f.local_files.insert("a.txt".into(), local("same-hash"));
        let result = f.run("alice", "dev1");
        assert_eq!(result.statuses.get("a.txt"), None);
    }

    #[test]
    fn no_status_change_when_either_hash_absent() {
        let mut f = Fixture::new();
        f.server_files.insert(
            "a.txt".into(),
            ServerFile {
                hash: None,
                size_bytes: 0,
                head_revision: None,
            },
        );
        f.local_files.insert("a.txt".into(), local("local-hash"));
        let result = f.run("alice", "dev1");
        // server hash missing -> rule 3 does not fire; falls through with no
        // status (this is neither the "both present" branch nor the
        // "local-only" branch since server IS present, just hashless).
        assert_eq!(result.statuses.get("a.txt"), None);
    }

    // ---- rule 4: local-only --------------------------------------------------

    #[test]
    fn not_added_for_a_plain_local_only_file() {
        let mut f = Fixture::new();
        f.local_files.insert("New/thing.txt".into(), local("h1"));
        let result = f.run("alice", "dev1");
        assert_eq!(result.statuses.get("New/thing.txt"), Some(&FileStatus::NotAdded));
    }

    #[test]
    fn marked_for_add_for_a_local_only_file_in_the_changelist() {
        let mut f = Fixture::new();
        f.local_files.insert("New/thing.txt".into(), local("h1"));
        f.changelist.add.insert("New/thing.txt".into());
        let result = f.run("alice", "dev1");
        assert_eq!(result.statuses.get("New/thing.txt"), Some(&FileStatus::MarkedForAdd));
    }

    #[test]
    fn ignored_local_only_file_has_no_status() {
        let mut f = Fixture::new();
        f.ignore = matcher("*.tmp");
        f.local_files.insert("New/thing.tmp".into(), local("h1"));
        let result = f.run("alice", "dev1");
        assert_eq!(result.statuses.get("New/thing.tmp"), None);
    }

    #[test]
    fn naming_issue_beats_marked_for_add() {
        let mut f = Fixture::new();
        f.local_files.insert("New/CON".into(), local("h1"));
        f.changelist.add.insert("New/CON".into());
        let result = f.run("alice", "dev1");
        assert!(matches!(
            result.statuses.get("New/CON"),
            Some(FileStatus::NamingIssue { .. })
        ));
    }

    #[test]
    fn naming_issue_from_collision_beats_not_added() {
        let mut f = Fixture::new();
        f.server_files.insert("Art/Old.txt".into(), server("h0"));
        f.local_files.insert("art/New.txt".into(), local("h1"));
        let result = f.run("alice", "dev1");
        assert!(matches!(
            result.statuses.get("art/New.txt"),
            Some(FileStatus::NamingIssue { .. })
        ));
    }

    // ---- rule 5: vanished files ----------------------------------------------

    #[test]
    fn vanished_synced_file_is_marked_for_delete_and_reported() {
        let mut f = Fixture::new();
        f.server_files.insert("Assets/gone.txt".into(), server("h1"));
        f.base_hashes.insert("Assets/gone.txt".into(), Some("h1".into()));
        // not in local_files: vanished from disk
        let result = f.run("alice", "dev1");
        assert_eq!(
            result.statuses.get("Assets/gone.txt"),
            Some(&FileStatus::MarkedForDelete)
        );
        assert_eq!(result.vanished, vec!["Assets/gone.txt".to_string()]);
    }

    #[test]
    fn vanished_file_already_in_del_is_not_re_reported() {
        let mut f = Fixture::new();
        f.server_files.insert("Assets/gone.txt".into(), server("h1"));
        f.base_hashes.insert("Assets/gone.txt".into(), Some("h1".into()));
        f.changelist.del.insert("Assets/gone.txt".into());
        let result = f.run("alice", "dev1");
        assert_eq!(
            result.statuses.get("Assets/gone.txt"),
            Some(&FileStatus::MarkedForDelete)
        );
        assert!(result.vanished.is_empty());
    }

    #[test]
    fn manifest_entry_for_file_still_present_locally_is_not_vanished() {
        let mut f = Fixture::new();
        f.server_files.insert("Assets/here.txt".into(), server("h1"));
        f.local_files.insert("Assets/here.txt".into(), local("h1"));
        f.base_hashes.insert("Assets/here.txt".into(), Some("h1".into()));
        let result = f.run("alice", "dev1");
        assert!(result.vanished.is_empty());
        assert_eq!(result.statuses.get("Assets/here.txt"), None);
    }

    // ---- flatten_depot_tree ----------------------------------------------------

    #[test]
    fn flatten_depot_tree_walks_nested_folders() {
        let tree = serde_json::json!({
            "children": [
                {
                    "type": "folder",
                    "name": "Assets",
                    "children": [
                        { "type": "file", "name": "a.txt", "hash": "h1", "sizeBytes": 12, "headRevision": 4 }
                    ]
                },
                { "type": "file", "name": "root.txt", "hash": "h2", "sizeBytes": 3, "headRevision": 1 }
            ]
        });
        let flat = flatten_depot_tree(&tree);
        assert_eq!(
            flat.get("Assets/a.txt"),
            Some(&ServerFile {
                hash: Some("h1".to_string()),
                size_bytes: 12,
                head_revision: Some(4)
            })
        );
        assert_eq!(
            flat.get("root.txt"),
            Some(&ServerFile {
                hash: Some("h2".to_string()),
                size_bytes: 3,
                head_revision: Some(1)
            })
        );
    }

    // ---- rule-agnostic property tests (trial A12) ----------------------------
    //
    // Randomized server/local/lock/changelist/manifest states, checking the
    // status engine's cross-cutting invariants over thousands of seeds. A tiny
    // hand-rolled LCG keeps this dependency-free (no proptest/rand) and every
    // case is reproducible by its seed, which the assertions print on failure.

    // splitmix64 — good bit distribution in every bit (a plain LCG's low bits
    // have a period-2 cycle, which would correlate consecutive `% 2` draws and
    // silently starve some branches; the coverage asserts below guard against
    // exactly that).
    struct Rng(u64);
    impl Rng {
        fn new(seed: u64) -> Self {
            Rng(seed ^ 0x9E37_79B9_7F4A_7C15)
        }
        fn next(&mut self) -> u64 {
            self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15);
            let mut z = self.0;
            z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
            z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
            z ^ (z >> 31)
        }
        fn chance(&mut self, pct: u64) -> bool {
            self.next() % 100 < pct
        }
        fn pick(&mut self, n: u64) -> usize {
            (self.next() % n) as usize
        }
    }

    #[test]
    fn property_invariants_hold_over_random_inputs() {
        // A small pool so overlaps/collisions between the input sets are common;
        // includes ignored (.tmp) and reserved-name (CON) paths and a subdir.
        let pool = ["a.txt", "b.txt", "c.tmp", "d/e.txt", "f.tmp", "CON", "g.txt"];
        let hashes = ["h1", "h2"]; // small set => equal AND differing hashes both occur
        let ignore = matcher("*.tmp\n");
        let me = ("@me", "dev-me");

        // Coverage counters so the properties are provably non-vacuous.
        let mut saw_vanished = 0u32;
        let mut saw_ignore_suppression = 0u32;
        let mut distinct_status_kinds: BTreeSet<&'static str> = BTreeSet::new();

        for seed in 0..3000u64 {
            let mut rng = Rng::new(seed);
            let mut server: BTreeMap<String, ServerFile> = BTreeMap::new();
            let mut local: BTreeMap<String, LocalFile> = BTreeMap::new();
            let mut locks: BTreeMap<String, LockInfo> = BTreeMap::new();
            let mut cl = Changelist::default();
            let mut base: BTreeMap<String, Option<String>> = BTreeMap::new();

            for p in pool {
                if rng.chance(50) {
                    server.insert(
                        p.to_string(),
                        ServerFile {
                            hash: Some(hashes[rng.pick(2)].to_string()),
                            size_bytes: 10,
                            head_revision: Some(3),
                        },
                    );
                }
                if rng.chance(50) {
                    local.insert(
                        p.to_string(),
                        LocalFile {
                            hash: Some(hashes[rng.pick(2)].to_string()),
                            writable: rng.chance(50),
                            size_bytes: 10,
                        },
                    );
                }
                if rng.chance(25) {
                    let (u, d, dn) = match rng.pick(3) {
                        0 => ("@me", "dev-me", "MyBox"),      // -> CheckedOutByMe
                        1 => ("@me", "dev-other", "OtherBox"), // -> LockedByMeElsewhere
                        _ => ("@buddy", "dev-b", "BuddyBox"),  // -> LockedByOther
                    };
                    locks.insert(p.to_string(), lock(u, d, dn));
                }
                if rng.chance(25) {
                    cl.add.insert(p.to_string());
                }
                if rng.chance(25) {
                    cl.del.insert(p.to_string());
                }
                if rng.chance(50) {
                    let h = if rng.chance(50) {
                        Some(hashes[rng.pick(2)].to_string())
                    } else {
                        None
                    };
                    base.insert(p.to_string(), h);
                }
            }

            let make = || StatusInputs {
                server_files: &server,
                local_files: &local,
                locks: &locks,
                changelist: &cl,
                base_hashes: &base,
                me,
                ignore: &ignore,
            };
            let r1 = compute_statuses(&make());
            let r2 = compute_statuses(&make());

            // Invariant: determinism — identical inputs give identical output.
            assert_eq!(r1, r2, "non-deterministic output at seed {seed}");

            // Invariant: vanished ⊆ (server ∩ manifest) ∖ local (and not already del).
            for v in &r1.vanished {
                assert!(
                    server.contains_key(v)
                        && base.contains_key(v)
                        && !local.contains_key(v)
                        && !cl.del.contains(v),
                    "vanished {v:?} violates subset rule at seed {seed}"
                );
            }

            // Invariant: no phantom paths — every status path came from an input set.
            for k in r1.statuses.keys() {
                let known = server.contains_key(k)
                    || local.contains_key(k)
                    || locks.contains_key(k)
                    || cl.del.contains(k);
                assert!(known, "phantom status path {k:?} at seed {seed}");
            }

            // Invariant: an ignored, local-only path (not on server, not locked,
            // not marked-for-delete) never surfaces a status.
            for p in pool {
                if ignore.is_ignored(p)
                    && !server.contains_key(p)
                    && !locks.contains_key(p)
                    && !cl.del.contains(p)
                {
                    assert!(
                        !r1.statuses.contains_key(p),
                        "ignored local-only path {p:?} surfaced at seed {seed}"
                    );
                    if local.contains_key(p) {
                        saw_ignore_suppression += 1; // an ignored local file was in fact suppressed
                    }
                }
            }

            if !r1.vanished.is_empty() {
                saw_vanished += 1;
            }
            for st in r1.statuses.values() {
                distinct_status_kinds.insert(match st {
                    FileStatus::Modified => "modified",
                    FileStatus::Outdated { .. } => "outdated",
                    FileStatus::CheckedOutByMe => "checkedOutByMe",
                    FileStatus::LockedByOther { .. } => "lockedByOther",
                    FileStatus::LockedByMeElsewhere { .. } => "lockedByMeElsewhere",
                    FileStatus::MarkedForAdd => "markedForAdd",
                    FileStatus::MarkedForDelete => "markedForDelete",
                    FileStatus::NotAdded => "notAdded",
                    FileStatus::NamingIssue { .. } => "namingIssue",
                });
            }
        }

        // Non-vacuity: the random space actually exercised each property.
        assert!(saw_vanished > 0, "no seed ever produced a vanished path");
        assert!(saw_ignore_suppression > 0, "ignore-suppression never exercised");
        assert!(
            distinct_status_kinds.len() >= 8,
            "expected nearly every FileStatus kind across the random space, saw {:?}",
            distinct_status_kinds
        );
    }
}
