main
Drew Owens 0.9.3 (93ef8bb): attached is not ready (readiness gate, host_app_gone, lock files); a declined quit on a never-had-a-do… 8483ed6 20d ago
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357
//! The bridge's DATA — and why it lives in a directory nothing else touches.
//!
//! Everything this bridge writes on its own initiative lands under one root:
//! the recovery folders, the action ledger, the ownership registry, launch
//! state, verb timings, settings, and the scratch files it gives its own
//! documents.
//!
//! ```text
//! %LOCALAPPDATA%\adom-solidworks-bridge\
//! ```
//!
//! A directory ONLY THIS BRIDGE writes to, whose only deleter is a person. That
//! property is the whole design, and it had to be learned twice:
//!
//! **1. SEV-1, MEASURED 2026-09-30 (fixed in 0.9.0).** Until 0.9.0 the root was
//! `%LOCALAPPDATA%\Adom Bridge\bridges-cache\solidworks\` — the directory ab
//! INSTALLS the bridge into. ab's updater re-extracts that whole directory: on
//! Drew's machine it was found CREATED 2026-09-18 09:42 by the auto-install of
//! 0.8.9, and every recovery folder from 09-17, the ledger, the ownership
//! registry and the scratch documents were gone. Only test documents were in
//! them — but the recovery folder is by design the ONLY copy of work the user had
//! not saved.
//!
//! **2. SEV-1, CONDITIONAL (adversary pass 39; fixed in 0.9.1).** 0.9.0 moved the
//! root to `%LOCALAPPDATA%\Adom Bridge\solidworks-data\`: out of the directory ab
//! re-extracts, and INTO the directory ab's own installer owns. What ab's docs
//! say, as CLAIMS: the installer "installs to %LOCALAPPDATA%\Adom Bridge\", the
//! uninstaller "lives at %LOCALAPPDATA%\Adom Bridge\uninstall.exe", and "ab
//! removes its own bridges-cache copy of you". What NOBODY has documented or
//! measured: what an ab uninstall, a repair, or the commonest troubleshooting
//! move there is — "delete the Adom Bridge folder and reinstall" — does to
//! anything else in that directory. The only copy of unsaved work cannot hang on
//! another product's undocumented behaviour; that is the shape of problem 1, one
//! level up. So the root is now a SIBLING of `Adom Bridge\`, named for this
//! bridge alone.
//!
//! Both earlier locations are LEGACY ROOTS (`legacy_roots`, oldest first), and on
//! start `migrate_legacy` copies what they hold into the root: under a temporary
//! name, verified byte for byte, renamed into place, never over an existing file,
//! the originals LEFT where they were, and each file RECORDED by (legacy root,
//! path, size, mtime, sha256). A legacy file that CHANGES after its copy — a
//! rollback appending to the old ledger, rewriting the old ownership registry —
//! is copied AGAIN, beside the first copy (`<name>.legacy-<stamp>`), never over
//! it, and reported `changedSinceCopy`; "changed" means a different size or
//! mtime, or — for a file up to 16 MiB whose size and mtime match — a different
//! sha256, because a restore can put different bytes of the same length back
//! under the same mtime (adversary pass 40). `swx_recover_list` reads the root
//! and every legacy root.
//!
//! TWO VERSIONS OF THE LEDGER OR THE REGISTRY ARE MERGED, NOT PICKED (adversary
//! pass 40). Same-named files in the two legacy roots, or a legacy file that
//! changed after its copy, are two versions of one thing — and for an append-only
//! ledger and an ownership registry both versions are partly right. "The older
//! root keeps the name" handed the live ledger the STALE copy on exactly Drew's
//! timeline (the cache re-created by 0.8.9 after 0.9.0 had copied it and kept
//! appending), and "newest mtime wins" fails the mirror case. So the live
//! `ledger.jsonl` becomes the union of both — a multiset: each distinct line as
//! many times as the version that holds it most, ordered by `at` — and the live `owned-docs.json` the union of both claim lists (restore
//! re-validates every claim against the live session). Both versions are kept
//! beside the merged file, and the merge is recorded in the migration record and
//! the ledger. Any other file keeps the beside rule.
//!
//! `refuse_inside_install` stops a recovery folder ever being placed under the
//! directory the bridge runs from, under ab's own directory, or under a legacy
//! root. And when there is no user profile location at all, the root falls back
//! to the OS temp directory — which Storage Sense and Disk Cleanup empty BY
//! DESIGN — so every sweep then REFUSES (`data_root_is_temp`) rather than put the
//! only copy of unsaved work there, and the start-up log says so.
//!
//! WHAT THE MIGRATION CANNOT DO: a bridge update re-extracts
//! `bridges-cache\solidworks` BEFORE the new bridge first runs, so whatever an
//! older bridge wrote THERE is most likely gone by the time the migration looks.
//! `Adom Bridge\solidworks-data` is outside the bridge cache, so a BRIDGE update
//! should leave 0.9.0's data for 0.9.1 to migrate (inferred from where ab
//! extracts, not measured).
//!
//! Resolution order: `SWX_SCRATCH_DIR` (tests and deliberate overrides — honoured
//! as given), then `%LOCALAPPDATA%\adom-solidworks-bridge`, then
//! `%USERPROFILE%\adom-solidworks-bridge`, and only then the temp directory.
//! `SWX_LEGACY_DIR` (a path list) and `SWX_INSTALL_DIR` override the legacy roots
//! and the install directory for the same reason. `swx_uninstall` removes the
//! bridge's own bookkeeping and `exports/` under this root, and NEVER the
//! recovery folders or the ledger: a recovery file is the user's only copy of
//! work they had not saved, and the ledger is the record of what was done.

use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, SystemTime};

/// The data directory's own name, under `%LOCALAPPDATA%` (or `%USERPROFILE%`).
/// Named for this bridge alone, so that nothing else ever has a reason to write
/// into it or to clean it up.
pub const DATA_DIR_NAME: &str = "adom-solidworks-bridge";

/// Where the root came from. Only `TempFallback` is a refusal: an explicit
/// override is the operator's choice (the test harness points it at a temp
/// directory on purpose), and the two profile locations are the design.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RootSource {
    Override,
    LocalAppData,
    UserProfile,
    TempFallback,
}

impl RootSource {
    pub fn name(self) -> &'static str {
        match self {
            RootSource::Override => "SWX_SCRATCH_DIR",
            RootSource::LocalAppData => "LOCALAPPDATA",
            RootSource::UserProfile => "USERPROFILE",
            RootSource::TempFallback => "tempFallback",
        }
    }
}

fn env_path(key: &str) -> Option<String> {
    std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}

/// The root, and how it was chosen. Never inside an install location — see the
/// module comment for what that cost, twice.
pub fn root_and_source() -> (PathBuf, RootSource) {
    if let Some(dir) = env_path("SWX_SCRATCH_DIR") {
        return (PathBuf::from(dir), RootSource::Override);
    }
    if let Some(local) = env_path("LOCALAPPDATA") {
        // A SIBLING of `Adom Bridge\`, never inside it.
        return (Path::new(&local).join(DATA_DIR_NAME), RootSource::LocalAppData);
    }
    // A PROFILE DIRECTORY BEFORE A TEMP ONE. `%TEMP%` is emptied on a schedule by
    // design; the user's profile is not.
    if let Some(profile) = env_path("USERPROFILE") {
        return (Path::new(&profile).join(DATA_DIR_NAME), RootSource::UserProfile);
    }
    (std::env::temp_dir().join(DATA_DIR_NAME), RootSource::TempFallback)
}

/// Everything the bridge creates lives under here — and nothing else does,
/// which is what makes `swx_uninstall` safe to run blind.
pub fn root() -> PathBuf {
    root_and_source().0
}

/// Did the root fall all the way back to the OS temp directory? Then no sweep may
/// write recovery files into it: that is the one standard location cleaned on
/// purpose.
pub fn root_is_temp_fallback() -> bool {
    root_and_source().1 == RootSource::TempFallback
}

/// ab's OWN directory — its installer's, its uninstaller's, and the parent of its
/// bridge cache. Nothing of this bridge's may live under it.
pub fn ab_dir() -> Option<PathBuf> {
    env_path("LOCALAPPDATA").map(|l| Path::new(&l).join("Adom Bridge"))
}

/// WHERE OLDER BRIDGES KEPT THEIR DATA, OLDEST FIRST: 0.8.x's install cache
/// (`Adom Bridge\bridges-cache\solidworks`), then 0.9.0's
/// `Adom Bridge\solidworks-data`. Read for migration and by `swx_recover_list`;
/// never written. `SWX_LEGACY_DIR` replaces the list (a path list, in order).
pub fn legacy_roots() -> Vec<PathBuf> {
    if let Some(list) = env_path("SWX_LEGACY_DIR") {
        return std::env::split_paths(&list).filter(|p| !p.as_os_str().is_empty()).collect();
    }
    match ab_dir() {
        Some(ab) => vec![ab.join("bridges-cache").join("solidworks"), ab.join("solidworks-data")],
        None => Vec::new(),
    }
}

/// The directory the running bridge was INSTALLED into — whatever ab extracted.
/// Anything placed under it is gone at the next update.
pub fn install_dir() -> Option<PathBuf> {
    if let Some(dir) = env_path("SWX_INSTALL_DIR") {
        return Some(PathBuf::from(dir));
    }
    std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf()))
}

/// A path as the filesystem would resolve it, so that a comparison cannot be
/// talked out of its answer by spelling: `.` and `..` are folded first, then the
/// nearest ancestor that EXISTS is canonicalized (which resolves symlinks,
/// junctions and 8.3 short names) and the not-yet-created remainder is put back.
/// A recovery folder usually does not exist yet when it is checked, which is why
/// canonicalizing the whole path is not enough.
fn resolved(p: &Path) -> PathBuf {
    use std::path::Component;
    let mut lex = PathBuf::new();
    for c in p.components() {
        match c {
            Component::CurDir => {}
            Component::ParentDir => {
                lex.pop();
            }
            other => lex.push(other.as_os_str()),
        }
    }
    let mut base = lex.clone();
    let mut tail: Vec<std::ffi::OsString> = Vec::new();
    loop {
        if let Ok(c) = std::fs::canonicalize(&base) {
            let mut out = c;
            for t in tail.iter().rev() {
                out.push(t);
            }
            return out;
        }
        match (base.file_name().map(|f| f.to_os_string()), base.parent()) {
            (Some(name), Some(parent)) => {
                tail.push(name);
                base = parent.to_path_buf();
            }
            _ => return lex,
        }
    }
}

/// Is `inner` the same as, or inside, `outer`? Both are RESOLVED first (see
/// `resolved`), then compared case-insensitively with both separators, which is
/// how Windows matches a path. Erring towards "inside" is the safe direction for
/// every caller: it can only refuse a location, never approve one.
pub fn is_within(inner: &Path, outer: &Path) -> bool {
    let norm = |p: &Path| {
        let s = resolved(p).to_string_lossy().replace('/', "\\").to_ascii_lowercase();
        // A canonical Windows path comes back in its verbatim form.
        let s = s.strip_prefix("\\\\?\\unc\\").map(|r| format!("\\\\{r}")).unwrap_or(s);
        let s = s.strip_prefix("\\\\?\\").map(|r| r.to_string()).unwrap_or(s);
        s.trim_end_matches('\\').to_string()
    };
    let (i, o) = (norm(inner), norm(outer));
    !o.is_empty() && (i == o || i.starts_with(&format!("{o}\\")))
}

/// REFUSE A PATH WHOSE LIFECYCLE BELONGS TO SOMETHING ELSE.
///
/// A recovery folder is the user's only copy of work they had not saved. It must
/// never be placed under the directory the bridge is installed in (re-extracted
/// on update), under ab's own directory (its installer and uninstaller own it,
/// and what they do to the rest of it is undocumented), or under a location an
/// older bridge used (both of which are one of those two). The root is outside
/// all of them by construction; this is the check that keeps it that way if an
/// override, a future fallback or an ab change ever points it back inside.
pub fn refuse_inside_install(path: &Path) -> Result<(), String> {
    let mut places: Vec<(&'static str, PathBuf)> = Vec::new();
    if let Some(d) = install_dir() {
        places.push(("the bridge's install directory", d));
    }
    if let Some(ab) = ab_dir() {
        places.push(("ab's own directory", ab));
    }
    for l in legacy_roots() {
        places.push(("a location an older bridge kept its data in", l));
    }
    for (what, d) in places {
        // INSIDE OR EQUAL: a location is re-extracted — or uninstalled — just the
        // same as anything under it.
        if is_within(path, &d) {
            return Err(format!(
                "refusing to write {} under {what} ({}): what happens to that directory is \
                 decided by something other than this bridge. ab re-extracts its bridge cache \
                 on every update — measured 2026-09-30: the 2026-09-18 auto-install of 0.8.9 \
                 wiped every recovery folder written the day before — and what ab's uninstaller, \
                 a repair or a reinstall does to the rest of ab's directory is undocumented. A \
                 recovery file is the user's only copy of unsaved work, so it goes in a directory \
                 only this bridge writes to (by default %LOCALAPPDATA%\\{DATA_DIR_NAME}).",
                path.display(),
                d.display(),
            ));
        }
    }
    Ok(())
}

/// A second-granular timestamp, `yyyymmdd-hhmmss` (UTC), for names that must sort
/// and must not collide with an earlier copy.
pub fn stamp_now() -> String {
    let now = SystemTime::now()
        .duration_since(SystemTime::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    let (days, secs) = (now / 86_400, now % 86_400);
    let (mut y, mut d) = (1970u64, days);
    loop {
        let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
        let len = if leap { 366 } else { 365 };
        if d < len {
            break;
        }
        d -= len;
        y += 1;
    }
    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
    let months = [31, if leap { 29 } else { 28 }, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
    let (mut m, mut day) = (0usize, d);
    while m < 12 && day >= months[m] {
        day -= months[m];
        m += 1;
    }
    format!(
        "{y:04}{:02}{:02}-{:02}{:02}{:02}",
        m + 1,
        day + 1,
        secs / 3600,
        (secs % 3600) / 60,
        secs % 60
    )
}

/// THE PERSISTENT STATE, named. Not a whole legacy directory: the install cache
/// also holds the bridge's own executable and resources, which are not data.
/// Each registry's previous generation (`.bak`, kept by `write_atomic`) goes with
/// it: it is what `owned::restore` falls back to when the live file cannot be
/// read. (`migrated-from-legacy.json` is deliberately absent: every root keeps
/// its own record of what IT copied.)
const MIGRATED_ITEMS: &[&str] = &[
    "recovery",
    "ledger.jsonl",
    "ledger.bak",
    "owned-docs.json",
    "owned-docs.bak",
    "com-host-created.json",
    "com-host-created.bak",
    "launch-state.json",
    "launch-state.bak",
    "timings.json",
    "timings.bak",
    "settings.json",
    "settings.bak",
    "exports",
];

/// The name a copy takes when its own name is taken — by an earlier copy of the
/// same file that has since CHANGED, or by a different file of the same name from
/// another legacy root. Never an existing name.
fn beside_name(dest: &Path, rel: &Path, stamp: &str) -> PathBuf {
    let base = dest.join(rel);
    let name = base.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
    let mut n = 1u32;
    loop {
        let candidate = base.with_file_name(if n == 1 {
            format!("{name}.legacy-{stamp}")
        } else {
            format!("{name}.legacy-{stamp}-{n}")
        });
        if !candidate.exists() && !candidate.with_file_name(format!(
            "{}.migrating",
            candidate.file_name().map(|c| c.to_string_lossy().to_string()).unwrap_or_default()
        )).exists()
        {
            return candidate;
        }
        n += 1;
    }
}

/// ONE COPY, the only way this migration makes one: under a temporary name,
/// verified byte for byte, then renamed into place — so a start that dies
/// mid-copy (a crash, a power cut, ab killing a bridge slow to answer `/status`)
/// never leaves a truncated file under the REAL name, where never-overwrite would
/// protect it forever. Returns the sha256 of what landed. On failure the
/// temporary is removed, and any folder that leaves empty goes too, so the
/// listing never shows an empty rescue beside the real one.
fn copy_verified(from: &Path, to: &Path, dest: &Path) -> Result<String, String> {
    if let Some(parent) = to.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let tmp = to.with_file_name(format!(
        "{}.migrating",
        to.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
    ));
    // A leftover from an interrupted start: its original is right here, so it
    // is simply replaced.
    let _ = std::fs::remove_file(&tmp);
    let copied_ok = copy_for_migration(from, &tmp, dest);
    // TEST-ONLY FAULT, mock mode only: a copy that lands DIFFERENT from its
    // original — what a failing disk, or a sync client rewriting the file
    // mid-copy, looks like. Nothing in production creates this file, and outside
    // mock mode it is not even looked for.
    if copied_ok && crate::is_mock() && dest.join("mock-migrate-corrupts-copy").exists() {
        use std::io::Write;
        if let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(&tmp) {
            let _ = f.write_all(b"!");
        }
    }
    let why = if !copied_ok {
        Some("the copy could not be made")
    } else if !same_bytes(from, &tmp) {
        Some("copy did not verify byte for byte")
    } else if to.exists() || std::fs::rename(&tmp, to).is_err() {
        Some("the verified copy could not be renamed into place")
    } else {
        None
    };
    match why {
        None => crate::sha256::hex_of_file(to).ok_or_else(|| "the copy could not be read back".into()),
        Some(why) => {
            // A COPY THAT DOES NOT MATCH IS NOT A COPY. The original is still in
            // the legacy location, untouched, and the next start tries it again.
            let _ = std::fs::remove_file(&tmp);
            let mut dir = to.parent().map(|p| p.to_path_buf());
            while let Some(d) = dir {
                if d == dest || !d.starts_with(dest) || std::fs::remove_dir(&d).is_err() {
                    break;
                }
                dir = d.parent().map(|p| p.to_path_buf());
            }
            Err(why.to_string())
        }
    }
}

/// A file this size or smaller is HASHED on every start when its size and mtime
/// match its record, because a restore can put different bytes of the same
/// length back under the same mtime (adversary pass 40). Larger files are
/// compared by size and mtime only, and their records say so (`comparedBy`).
pub const MIGRATION_HASH_CAP: u64 = 16 * 1024 * 1024;

/// The files whose versions are MERGED rather than picked between. An
/// append-only ledger and an ownership registry are the two files whose
/// versions in two places are both partly right — two histories, two sets of
/// claims — so a rule that keeps one copy under the real name drops the other's
/// half from the live state (adversary pass 40: "the older root keeps the name"
/// gave the live ledger the STALE copy on Drew's timeline).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Merge {
    Ledger,
    Registry,
}

pub fn merge_kind(rel: &Path) -> Option<Merge> {
    match rel.to_str()? {
        "ledger.jsonl" => Some(Merge::Ledger),
        "owned-docs.json" => Some(Merge::Registry),
        _ => None,
    }
}

/// THE LEDGER, MERGED — AS A MULTISET (adversary pass 41). A line in BOTH files
/// is one event copied, so it appears once; but two identical lines WITHIN one
/// file are two events (a ledger line before 0.9.3 has no sequence number, `at`
/// is seconds, and `document_closed` from swx_reset_workspace says only
/// {title, by}). So each distinct line appears as many times as the file that
/// holds it MORE times — max(count in live, count in incoming) — never fewer.
/// The result is stable-sorted by `at`; a line with no readable `at` takes the
/// `at` of the line before it in its own file, so it stays beside the entries it
/// was written among. Returns the merged text and its line count.
pub fn merge_ledger_lines(live: &str, incoming: &str) -> (String, usize) {
    use std::collections::HashMap;
    fn parse(text: &str) -> Vec<(u64, String)> {
        let mut last_at = 0u64;
        let mut out = Vec::new();
        for line in text.lines() {
            let line = line.trim_end_matches('\r');
            if line.trim().is_empty() {
                continue;
            }
            let at = serde_json::from_str::<serde_json::Value>(line)
                .ok()
                .and_then(|v| v.get("at").and_then(|a| a.as_u64()))
                .unwrap_or(last_at);
            last_at = at;
            out.push((at, line.to_string()));
        }
        out
    }
    let mut lines: Vec<(u64, String)> = Vec::new();
    let mut live_counts: HashMap<String, usize> = HashMap::new();
    for (at, l) in parse(live) {
        *live_counts.entry(l.clone()).or_insert(0) += 1;
        lines.push((at, l));
    }
    let mut seen_incoming: HashMap<String, usize> = HashMap::new();
    for (at, l) in parse(incoming) {
        let c = seen_incoming.entry(l.clone()).or_insert(0);
        *c += 1;
        if *c > live_counts.get(&l).copied().unwrap_or(0) {
            lines.push((at, l));
        }
    }
    lines.sort_by_key(|(at, _)| *at);
    let n = lines.len();
    let mut out = lines.into_iter().map(|(_, l)| l).collect::<Vec<_>>().join("\n");
    if n > 0 {
        out.push('\n');
    }
    (out, n)
}

/// THE REGISTRY, MERGED: every claim in either, each once. A claim is
/// re-validated against the live SOLIDWORKS session when the registry is
/// restored (`owned::restore`), so a union can only ever PROPOSE ownership, never
/// grant it. `None` when either file is not a registry this bridge wrote.
pub fn merge_registry(live: &str, incoming: &str) -> Option<(String, usize)> {
    let docs = |t: &str| -> Option<Vec<serde_json::Value>> {
        serde_json::from_str::<serde_json::Value>(t).ok()?.get("documents")?.as_array().cloned()
    };
    let (a, b) = (docs(live)?, docs(incoming)?);
    let mut out: Vec<serde_json::Value> = Vec::new();
    for d in a.into_iter().chain(b) {
        if !out.contains(&d) {
            out.push(d);
        }
    }
    let n = out.len();
    Some((serde_json::json!({"documents": out}).to_string(), n))
}

/// MERGE a legacy version INTO the live file. Both originals are kept BESIDE the
/// merged file, verified, BEFORE anything is rewritten: the live file as it was,
/// and the incoming version. The merged text holds every line (or claim) of
/// both, so the rewrite loses nothing; it is written to a temporary name and
/// renamed into place — never through `write_atomic`, whose `.bak` would
/// overwrite a `.bak` the migration may have just copied. Returns the record of
/// the merge; on any error nothing has been rewritten.
fn merge_into(
    kind: Merge,
    from: &Path,
    to: &Path,
    dest: &Path,
    rel: &Path,
    stamp: &str,
) -> Result<serde_json::Value, String> {
    use serde_json::json;
    let live = std::fs::read_to_string(to).map_err(|e| format!("the live file could not be read: {e}"))?;
    let incoming =
        std::fs::read_to_string(from).map_err(|e| format!("the legacy file could not be read: {e}"))?;
    let (text, entries) = match kind {
        Merge::Ledger => merge_ledger_lines(&live, &incoming),
        Merge::Registry => merge_registry(&live, &incoming).ok_or_else(|| {
            "one of the two is not a registry this bridge wrote (JSON with a `documents` list)".to_string()
        })?,
    };
    let rel_of = |p: &Path| p.strip_prefix(dest).unwrap_or(p).to_string_lossy().to_string();
    let before = beside_name(dest, rel, stamp);
    let before_sha = copy_verified(to, &before, dest)?;
    let incoming_copy = beside_name(dest, rel, stamp);
    let incoming_sha = copy_verified(from, &incoming_copy, dest)?;
    let tmp = to.with_file_name(format!(
        "{}.merging",
        to.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
    ));
    std::fs::write(&tmp, text.as_bytes())
        .and_then(|_| std::fs::rename(&tmp, to))
        .map_err(|e| {
            let _ = std::fs::remove_file(&tmp);
            format!("the merged file could not be written: {e}")
        })?;
    Ok(json!({
        "kind": match kind { Merge::Ledger => "ledger", Merge::Registry => "registry" },
        "into": rel_of(to),
        "liveBefore": {"copiedAs": rel_of(&before), "sha256": before_sha, "bytes": live.len()},
        "incoming": {"copiedAs": rel_of(&incoming_copy), "sha256": incoming_sha, "bytes": incoming.len()},
        "result": {
            "bytes": text.len(),
            "sha256": crate::sha256::hex_of(text.as_bytes()),
            "entries": entries,
        },
    }))
}

/// COPY WHAT THE LEGACY ROOTS HOLD INTO THE DATA ROOT.
///
/// Legacy roots are taken OLDEST FIRST, files in a fixed order. Every copy goes
/// through `copy_verified`. The originals are LEFT where they were: this runs
/// against directories somebody else is about to overwrite or uninstall, but it
/// is not this code's place to delete anything from them.
///
/// EACH FILE IS RECORDED, by (legacy root, path, size, mtime, sha256 of the
/// copy), in `migrated-from-legacy.json` — cumulative, so a copy the user deletes
/// stays deleted. On a later start a recorded file is compared, not skipped:
///
///   * the same size and mtime → settled, nothing to do;
///   * DIFFERENT → it CHANGED SINCE ITS COPY (0.9.0 settled by name alone, and
///     lost exactly the two files that change by nature: a rollback's appends to
///     the old ledger, a rewritten ownership registry). It is copied AGAIN,
///     beside the first copy as `<name>.legacy-<stamp>` — never over it — and
///     reported under `changedSinceCopy`, in the marker, the ledger and
///     `swx_recover_list`.
///
/// A file NOT recorded yet whose name is already taken in the data root — by a
/// byte-identical file, it is recorded as present; by a DIFFERENT one (a
/// same-named file from the other legacy root), it is copied beside it, never
/// over it and never dropped. A copy that fails is not recorded, so the next
/// start tries it again. Returns the report of THIS run, or `None` when there
/// was nothing new to do.
pub fn migrate_legacy() -> Option<serde_json::Value> {
    use serde_json::{json, Value};
    let dest = root();
    let legacies: Vec<PathBuf> = legacy_roots()
        .into_iter()
        .filter(|l| l.is_dir() && !is_within(&dest, l) && !is_within(l, &dest))
        .collect();
    if legacies.is_empty() {
        return None;
    }
    let marker = dest.join("migrated-from-legacy.json");
    let previous: Value = std::fs::read_to_string(&marker)
        .ok()
        .and_then(|t| serde_json::from_str::<Value>(&t).ok())
        .unwrap_or(Value::Null);
    let list = |key: &str| -> Vec<Value> {
        previous.get(key).and_then(|v| v.as_array()).cloned().unwrap_or_default()
    };
    // THE SETTLED RECORDS, one per (legacy root, file). A record written before
    // the root was recorded belongs to the first legacy root — the only one there
    // was.
    let first_root = legacies[0].to_string_lossy().to_string();
    let mut records: Vec<Value> = list("copied")
        .into_iter()
        .map(|mut r| {
            if r.get("outcome").is_none() {
                r["outcome"] = json!("copied");
            }
            r
        })
        .chain(list("skipped").into_iter().map(|mut r| {
            if r.get("outcome").is_none() {
                r["outcome"] = json!("present");
            }
            r
        }))
        .collect();
    let mut changed_history: Vec<Value> = list("changedSinceCopy");

    // Bounded: a link that points back up the tree must not recurse forever.
    fn walk(src: &Path, rel: &Path, out: &mut Vec<PathBuf>, depth: u32) {
        let here = src.join(rel);
        if here.is_file() {
            out.push(rel.to_path_buf());
        } else if depth < 16 {
            if let Ok(rd) = std::fs::read_dir(&here) {
                for e in rd.flatten() {
                    walk(src, &rel.join(e.file_name()), out, depth + 1);
                }
            }
        }
    }
    let mut candidates: Vec<(PathBuf, PathBuf)> = Vec::new();
    for legacy in &legacies {
        let mut files: Vec<PathBuf> = Vec::new();
        for item in MIGRATED_ITEMS {
            if legacy.join(item).exists() {
                walk(legacy, Path::new(item), &mut files, 0);
            }
        }
        // IN A FIXED ORDER, so what an interrupted start leaves behind does not
        // depend on the order a directory listing happened to come back in.
        files.sort();
        candidates.extend(files.into_iter().map(|f| (legacy.clone(), f)));
    }

    let stamp = stamp_now();
    let now_ms = crate::launchos::epoch_ms() as u64;
    let (mut copied, mut skipped, mut failed, mut changed, mut merged) = (
        Vec::<Value>::new(),
        Vec::<Value>::new(),
        Vec::<Value>::new(),
        Vec::<Value>::new(),
        Vec::<Value>::new(),
    );
    let mut merged_history: Vec<Value> = list("merged");
    let mut merge_failed: Vec<Value> = Vec::new();
    let rel_of = |p: &Path| p.strip_prefix(&dest).unwrap_or(p).to_string_lossy().to_string();
    let compared_by = |bytes: u64| -> &'static str {
        if bytes <= MIGRATION_HASH_CAP {
            "size+mtime+sha256"
        } else {
            "size+mtime (over the 16 MiB hash cap)"
        }
    };
    for (legacy, rel) in candidates {
        let from = legacy.join(&rel);
        let name = rel.to_string_lossy().to_string();
        let lroot = legacy.to_string_lossy().to_string();
        let Ok(meta) = std::fs::metadata(&from) else {
            failed.push(json!({"legacyRoot": lroot, "file": name, "why": "the original could not be read"}));
            continue;
        };
        let bytes = meta.len();
        let mtime = modified_ms(&from.to_string_lossy());
        let at = records.iter().position(|r| {
            r.get("file").and_then(|f| f.as_str()) == Some(name.as_str())
                && r.get("legacyRoot").and_then(|v| v.as_str()).unwrap_or(&first_root) == lroot
        });
        let to = dest.join(&rel);
        match at {
            Some(i) => {
                let was_bytes = records[i].get("bytes").and_then(|v| v.as_u64());
                let was_mtime = records[i].get("modifiedMs").and_then(|v| v.as_u64());
                // A record that measured nothing cannot be compared: it stays
                // settled, as the record that wrote it settled it.
                let meta_differs = matches!(was_bytes, Some(b) if b != bytes)
                    || matches!((was_mtime, mtime), (Some(a), Some(b)) if a != b);
                // THE SAME SIZE AND MTIME IS NOT THE SAME FILE (adversary pass 40).
                // A restore that puts timestamps back — File History, robocopy
                // /COPY:DAT, a sync client — can return different bytes of the
                // same length, and equal length is NORMAL for the ownership
                // registry (`Part<N>-<13-digit epoch>`). So a small file that
                // matches on both is HASHED and compared with the digest
                // recorded when it was copied. A file over the cap keeps size and
                // mtime, and its record says so (`comparedBy`).
                let differs = meta_differs
                    || (bytes <= MIGRATION_HASH_CAP && {
                        let was = records[i]
                            .get("legacySha256")
                            .or_else(|| records[i].get("sha256"))
                            .and_then(|v| v.as_str())
                            .map(|v| v.to_string());
                        match (was, crate::sha256::hex_of_file(&from)) {
                            (Some(w), Some(now)) => w != now,
                            // Nothing to compare against, or unreadable now: the
                            // record stays as it was, as before this check.
                            _ => false,
                        }
                    });
                if !differs {
                    continue;
                }
                let was = json!({
                    "bytes": was_bytes,
                    "modifiedMs": was_mtime,
                    "sha256": records[i].get("legacySha256").or_else(|| records[i].get("sha256")).cloned().unwrap_or(Value::Null),
                    "copiedAs": records[i].get("copiedAs").cloned().unwrap_or(json!(name)),
                });
                // CHANGED SINCE ITS COPY. A ledger or a registry is MERGED into
                // the live file — both versions' copies kept beside it — so the
                // live file holds every history (adversary pass 40); anything else
                // is copied again, beside, never over.
                let merge = match merge_kind(&rel).filter(|_| to.is_file()) {
                    Some(kind) => match merge_into(kind, &from, &to, &dest, &rel, &stamp) {
                        Ok(m) => Some(m),
                        // A version that cannot be merged is still never dropped:
                        // it goes beside, below, and the failure is reported.
                        Err(why) => {
                            merge_failed.push(json!({
                                "legacyRoot": lroot, "file": name,
                                "why": format!("could not be merged, so it was copied beside instead: {why}"),
                            }));
                            None
                        }
                    },
                    None => None,
                };
                let outcome = match merge {
                    Some(m) => Ok((m, true)),
                    None => {
                        let beside = beside_name(&dest, &rel, &stamp);
                        copy_verified(&from, &beside, &dest)
                            .map(|sha| (json!({"incoming": {"copiedAs": rel_of(&beside), "sha256": sha}}), false))
                    }
                };
                match outcome {
                    Ok((m, was_merged)) => {
                        let incoming_as = m["incoming"]["copiedAs"].clone();
                        let incoming_sha = m["incoming"]["sha256"].clone();
                        let entry = json!({
                            "legacyRoot": lroot,
                            "file": name,
                            "was": was,
                            "now": {"bytes": bytes, "modifiedMs": mtime, "sha256": incoming_sha},
                            "copiedAs": incoming_as,
                            "mergedInto": if was_merged { json!(name) } else { Value::Null },
                            "atMs": now_ms,
                        });
                        changed.push(entry.clone());
                        changed_history.push(entry);
                        if was_merged {
                            let mut m = m;
                            m["file"] = json!(name);
                            m["legacyRoot"] = json!(lroot);
                            m["why"] = json!("changed since its copy");
                            m["atMs"] = json!(now_ms);
                            merged.push(m.clone());
                            merged_history.push(m);
                        }
                        // The record now describes the LATEST copy, so the next
                        // start compares against what it last saw.
                        let r = &mut records[i];
                        r["bytes"] = json!(bytes);
                        r["modifiedMs"] = json!(mtime);
                        r["sha256"] = incoming_sha.clone();
                        r["legacySha256"] = incoming_sha;
                        r["copiedAs"] = incoming_as;
                        r["comparedBy"] = json!(compared_by(bytes));
                        r["outcome"] = json!(if was_merged { "mergedAfterChange" } else { "copiedBesideAfterChange" });
                    }
                    Err(why) => failed.push(json!({
                        "legacyRoot": lroot, "file": name, "why": why, "changedSinceCopy": true,
                    })),
                }
            }
            None => {
                if to.exists() && same_bytes(&from, &to) {
                    let sha = crate::sha256::hex_of_file(&to);
                    let entry = json!({
                        "legacyRoot": lroot, "file": name, "bytes": bytes, "modifiedMs": mtime,
                        "sha256": sha, "legacySha256": sha, "copiedAs": name,
                        "comparedBy": compared_by(bytes),
                        "outcome": "present",
                        "why": "already present in the data root, byte for byte",
                    });
                    skipped.push(entry.clone());
                    records.push(entry);
                    continue;
                }
                // SAME NAME, DIFFERENT BYTES — two versions of one file, usually
                // from the two legacy roots. A ledger or a registry is MERGED: any
                // rule that PICKS one drops a history, and "the older root keeps
                // the name" picked the stale one on exactly Drew's timeline
                // (adversary pass 40). Anything else is copied beside, never over
                // it and never dropped.
                if let Some(kind) = merge_kind(&rel).filter(|_| to.is_file()) {
                    let live_sha_before = crate::sha256::hex_of_file(&to);
                    match merge_into(kind, &from, &to, &dest, &rel, &stamp) {
                        Ok(mut m) => {
                            // The copy that held the real name until now keeps a
                            // name of its own: if it is still exactly the copy a
                            // record describes, that record follows it beside.
                            if let Some(r) = records.iter_mut().find(|r| {
                                r["copiedAs"] == json!(name)
                                    && live_sha_before.is_some()
                                    && r["sha256"].as_str() == live_sha_before.as_deref()
                            }) {
                                r["copiedAs"] = m["liveBefore"]["copiedAs"].clone();
                            }
                            let entry = json!({
                                "legacyRoot": lroot, "file": name, "bytes": bytes, "modifiedMs": mtime,
                                "sha256": m["incoming"]["sha256"], "legacySha256": m["incoming"]["sha256"],
                                "copiedAs": m["incoming"]["copiedAs"],
                                "comparedBy": compared_by(bytes),
                                "outcome": "merged",
                                "why": "a different version of this file was already in the data root; the two were merged, and both are kept beside the merged file",
                            });
                            copied.push(entry.clone());
                            records.push(entry);
                            m["file"] = json!(name);
                            m["legacyRoot"] = json!(lroot);
                            m["why"] = json!("two versions of one file");
                            m["atMs"] = json!(now_ms);
                            merged.push(m.clone());
                            merged_history.push(m);
                            continue;
                        }
                        // A version that cannot be merged (not the shape a merge
                        // reads) is still never dropped: it goes beside, below.
                        Err(why) => merge_failed.push(json!({
                            "legacyRoot": lroot, "file": name,
                            "why": format!("could not be merged, so it was copied beside instead: {why}"),
                        })),
                    }
                }
                let (target, outcome, why) = if !to.exists() {
                    (to.clone(), "copied", None)
                } else {
                    (
                        beside_name(&dest, &rel, &stamp),
                        "copiedBeside",
                        Some("a different file of this name was already in the data root; copied beside it, never over it"),
                    )
                };
                match copy_verified(&from, &target, &dest) {
                    Ok(sha) => {
                        let mut entry = json!({
                            "legacyRoot": lroot, "file": name, "bytes": bytes, "modifiedMs": mtime,
                            "sha256": sha, "legacySha256": sha, "copiedAs": rel_of(&target),
                            "comparedBy": compared_by(bytes), "outcome": outcome,
                        });
                        if let Some(why) = why {
                            entry["why"] = json!(why);
                        }
                        copied.push(entry.clone());
                        records.push(entry);
                    }
                    Err(why) => failed.push(json!({"legacyRoot": lroot, "file": name, "why": why})),
                }
            }
        }
    }
    if copied.is_empty() && skipped.is_empty() && failed.is_empty() && changed.is_empty()
        && merged.is_empty()
    {
        return None;
    }
    let why = "older bridges kept their data inside ab's directories: 0.8.x in the bridge cache, \
               which every update re-extracts (measured 2026-09-30: the 2026-09-18 auto-install \
               of 0.8.9 deleted every recovery folder from the day before), and 0.9.0 in \
               Adom Bridge\\solidworks-data, which ab's installer and uninstaller own. Data now \
               lives in a directory only this bridge writes to.";
    let legacy_list: Vec<String> = legacies.iter().map(|l| l.to_string_lossy().to_string()).collect();
    let report = json!({
        "from": legacy_list,
        "to": dest.to_string_lossy(),
        "copied": copied,
        "failed": failed,
        "skipped": skipped,
        "changedSinceCopy": changed,
        "merged": merged,
        "mergeFailed": merge_failed,
        "originalsLeftInPlace": true,
        "why": why,
    });
    // THE CUMULATIVE RECORD: everything ever settled, every change ever seen, and
    // what is still failing.
    let record = json!({
        "markerVersion": 2,
        "from": legacy_list,
        "to": dest.to_string_lossy(),
        "copied": records.iter().filter(|r| r["outcome"] != "present").cloned().collect::<Vec<_>>(),
        "skipped": records.iter().filter(|r| r["outcome"] == "present").cloned().collect::<Vec<_>>(),
        "changedSinceCopy": changed_history,
        "merged": merged_history,
        "mergeFailed": merge_failed,
        "failed": failed,
        "originalsLeftInPlace": true,
        "hashCapBytes": MIGRATION_HASH_CAP,
        "lastRunAtMs": now_ms,
        "why": why,
    });
    if let Ok(text) = serde_json::to_string_pretty(&record) {
        let _ = std::fs::create_dir_all(&dest);
        let _ = crate::launch::write_atomic(&marker, &text);
    }
    Some(report)
}

/// The copy itself. Separate so the one test-only fault that models an
/// INTERRUPTED start can sit exactly where an interruption would: mid-copy, into
/// whatever path the migration copies to. In mock mode only, with
/// `mock-migrate-dies-mid-copy` in the data root (its content: how many copies to
/// let through first, default none), it writes the first half of the file and
/// the process exits — nothing in production creates that file, and outside mock
/// mode it is not even looked for.
fn copy_for_migration(from: &Path, to: &Path, dest: &Path) -> bool {
    if crate::is_mock() {
        if let Ok(spec) = std::fs::read_to_string(dest.join("mock-migrate-dies-mid-copy")) {
            use std::sync::atomic::{AtomicUsize, Ordering};
            static DONE: AtomicUsize = AtomicUsize::new(0);
            let allowed: usize = spec.trim().parse().unwrap_or(0);
            if DONE.fetch_add(1, Ordering::SeqCst) >= allowed {
                if let Ok(bytes) = std::fs::read(from) {
                    let _ = std::fs::write(to, &bytes[..bytes.len() / 2]);
                }
                eprintln!("[swx] mock-migrate-dies-mid-copy: exiting mid-copy of {}", from.display());
                std::process::exit(3);
            }
        }
    }
    std::fs::copy(from, to).is_ok()
}

/// BYTE FOR BYTE, streamed: a recovery file can be an assembly of hundreds of
/// megabytes, and reading two of them whole to compare them would be the way a
/// migration on a machine short of memory fails. Anything unreadable is "not the
/// same", which is the direction that keeps the original.
pub fn same_bytes(a: &Path, b: &Path) -> bool {
    use std::io::Read;
    let (Ok(fa), Ok(fb)) = (std::fs::File::open(a), std::fs::File::open(b)) else { return false };
    match (fa.metadata(), fb.metadata()) {
        (Ok(ma), Ok(mb)) if ma.len() == mb.len() => {}
        _ => return false,
    }
    let (mut ra, mut rb) = (std::io::BufReader::new(fa), std::io::BufReader::new(fb));
    let (mut ba, mut bb) = (vec![0u8; 1 << 20], vec![0u8; 1 << 20]);
    loop {
        let na = match ra.read(&mut ba) {
            Ok(n) => n,
            Err(_) => return false,
        };
        if na == 0 {
            // The lengths matched, so b must be exhausted too.
            return matches!(rb.read(&mut bb[..1]), Ok(0));
        }
        if rb.read_exact(&mut bb[..na]).is_err() || ba[..na] != bb[..na] {
            return false;
        }
    }
}

/// Where generated files go. Created on demand.
/// Where BRIDGE-CREATED DOCUMENTS are given a file of their own.
///
/// Under `exports/`, so the uninstall wipe already removes it and nothing new
/// has to be remembered about where the bridge writes.
/// A per-process counter for scratch document names, seeded from the clock so
/// two bridge runs in one session cannot collide.
/// Is this path a file the BRIDGE gave one of its own documents, rather than a
/// save the USER asked for?
///
/// The distinction is load-bearing from 0.6.0, because every bridge-created
/// document now has a file. `swx_reset_workspace` refuses to close a scratch
/// document that "has been saved to disk" without `force` — a guard that exists
/// to protect a document the user deliberately put somewhere. A file the bridge
/// wrote into its OWN cache, unasked, is not that; treating it as one would have
/// made the sweep refuse to clean up anything it had ever made.
pub fn is_bridge_scratch_path(path: &str) -> bool {
    let want = scratch_docs_dir().to_string_lossy().replace('/', "\\").to_ascii_lowercase();
    if want.is_empty() {
        return false;
    }
    let got = path.replace('/', "\\").to_ascii_lowercase();
    got.starts_with(&format!("{}\\", want.trim_end_matches('\\')))
}

pub fn next_scratch_seq() -> u64 {
    use std::sync::atomic::{AtomicU64, Ordering};
    static SEQ: AtomicU64 = AtomicU64::new(0);
    let n = SEQ.fetch_add(1, Ordering::SeqCst);
    let base = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs())
        .unwrap_or(0);
    base.wrapping_mul(1000).wrapping_add(n)
}

pub fn scratch_docs_dir() -> PathBuf {
    let d = exports_dir().join("scratch");
    let _ = std::fs::create_dir_all(&d);
    d
}

pub fn exports_dir() -> PathBuf {
    let d = root().join("exports");
    let _ = std::fs::create_dir_all(&d);
    d
}

/// Remove ONLY what the bridge itself wrote — the `exports/` subtree and its own
/// bookkeeping files — best effort, one item at a time. Never the root itself,
/// never `recovery/`, never the ledger. (Until 0.9.0 the root WAS the
/// `bridges-cache/solidworks` folder ab unzipped the running exe into, which is
/// why this was scoped from the start; since 0.9.0 it is a data directory of its
/// own, and the scoping now protects the user's recovery files.)
///
/// Returns (removed, failed) as display strings for `swx_uninstall`.
pub fn remove_artifacts() -> (Vec<String>, Vec<String>) {
    remove_artifacts_under(&root())
}

/// The scoped, best-effort removal with the root made explicit (so it is
/// testable without touching the process environment).
pub fn remove_artifacts_under(root: &Path) -> (Vec<String>, Vec<String>) {
    let exports = root.join("exports");
    let mut removed = Vec::new();
    let mut failed = Vec::new();
    // The bridge's own bookkeeping files (v0.3) live at the root next to
    // `exports/`, so they are removed by name — never by wiping the root, which
    // is ab's cache directory holding the running exe.
    // NOTE the absence of `ledger.jsonl`. `swx_uninstall` takes no gate — ab
    // calls it on app uninstall — so removing the ledger here would let a caller
    // erase the record of what it did by asking the bridge to tidy up. The
    // audit log is the one artifact that outlives the bridge; a human removes it
    // by deleting the folder, which is theirs to do.
    for name in [
        "timings.json",
        "settings.json",
        "com-host-created.json",
        // The SERVER's half of the ownership record (bug #5) and the launch
        // machine's persisted attempt. Both are the bridge's own bookkeeping;
        // neither is an audit log, so both go on uninstall.
        "owned-docs.json",
        "launch-state.json",
        "mock-solidworks-session",
        "mock-solidworks-open-docs.json",
    ] {
        let f = root.join(name);
        if f.exists() {
            match std::fs::remove_file(&f) {
                Ok(()) => removed.push(f.to_string_lossy().to_string()),
                Err(e) => failed.push(format!("{}: {e}", f.display())),
            }
        }
    }
    if !exports.exists() {
        return (removed, failed);
    }
    // Per-item first, so one locked file (an export still being pulled) cannot
    // abort the whole cleanup — the SDK's best-effort rule.
    if let Ok(entries) = std::fs::read_dir(&exports) {
        for e in entries.flatten() {
            let p = e.path();
            let r = if p.is_dir() {
                std::fs::remove_dir_all(&p)
            } else {
                std::fs::remove_file(&p)
            };
            match r {
                Ok(()) => removed.push(p.to_string_lossy().to_string()),
                Err(err) => failed.push(format!("{}: {err}", p.display())),
            }
        }
    }
    // Then the now-(hopefully-)empty exports dir, but only if everything under
    // it went — never force it over a file we could not remove.
    if failed.is_empty() {
        match std::fs::remove_dir_all(&exports) {
            Ok(()) => removed.push(exports.to_string_lossy().to_string()),
            Err(err) => failed.push(format!("{}: {err}", exports.display())),
        }
    }
    (removed, failed)
}

static LAST_PRUNE: Mutex<Option<SystemTime>> = Mutex::new(None);

const MAX_AGE: Duration = Duration::from_secs(48 * 3600);
const MAX_FILES: usize = 300;
const PRUNE_EVERY: Duration = Duration::from_secs(600);

/// Age/count-capped cleanup of the exports dir, rate-limited to once per 10
/// minutes so a burst of exports does not turn into a burst of directory scans.
pub fn prune() {
    {
        let mut last = LAST_PRUNE.lock().unwrap_or_else(|e| e.into_inner());
        let now = SystemTime::now();
        if let Some(prev) = *last {
            if now.duration_since(prev).unwrap_or(PRUNE_EVERY) < PRUNE_EVERY {
                return;
            }
        }
        *last = Some(now);
    }
    prune_dir(&exports_dir(), SystemTime::now(), MAX_AGE, MAX_FILES);
}

/// The prune, with every input explicit (so it is testable without waiting 48h).
pub fn prune_dir(dir: &Path, now: SystemTime, max_age: Duration, max_files: usize) -> usize {
    let mut entries: Vec<(SystemTime, PathBuf)> = Vec::new();
    let read = match std::fs::read_dir(dir) {
        Ok(r) => r,
        Err(_) => return 0,
    };
    for e in read.flatten() {
        let path = e.path();
        let meta = match e.metadata() {
            Ok(m) if m.is_file() => m,
            _ => continue,
        };
        let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
        entries.push((mtime, path));
    }
    let doomed = select_doomed(&mut entries, now, max_age, max_files);
    let mut removed = 0;
    for p in &doomed {
        if std::fs::remove_file(p).is_ok() {
            removed += 1;
        }
    }
    removed
}

/// Which files to evict: everything past `max_age`, then oldest-first until the
/// survivors fit `max_files`. Sorting oldest-first is what keeps an in-flight
/// brand-new export from ever being the one evicted.
pub fn select_doomed(
    entries: &mut Vec<(SystemTime, PathBuf)>,
    now: SystemTime,
    max_age: Duration,
    max_files: usize,
) -> Vec<PathBuf> {
    entries.sort_by(|a, b| a.0.cmp(&b.0));
    let too_old = |t: &SystemTime| now.duration_since(*t).map(|age| age > max_age).unwrap_or(false);
    let mut doomed: Vec<PathBuf> =
        entries.iter().filter(|(t, _)| too_old(t)).map(|(_, p)| p.clone()).collect();
    let survivors: Vec<&PathBuf> =
        entries.iter().filter(|(t, _)| !too_old(t)).map(|(_, p)| p).collect();
    if survivors.len() > max_files {
        let overflow = survivors.len() - max_files;
        doomed.extend(survivors.into_iter().take(overflow).cloned());
    }
    doomed
}

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

    fn at(secs_ago: u64) -> SystemTime {
        SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000 - secs_ago)
    }

    #[test]
    fn evicts_by_age() {
        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
        let mut e = vec![
            (at(3600), PathBuf::from("fresh")),
            (at(200_000), PathBuf::from("ancient")),
        ];
        let doomed = select_doomed(&mut e, now, MAX_AGE, MAX_FILES);
        assert_eq!(doomed, vec![PathBuf::from("ancient")]);
    }

    #[test]
    fn evicts_oldest_first_over_the_cap() {
        let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
        let mut e: Vec<(SystemTime, PathBuf)> =
            (0..5).map(|i| (at(i * 10), PathBuf::from(format!("f{i}")))).collect();
        // cap of 2 => the 3 oldest go, newest survive
        let doomed = select_doomed(&mut e, now, MAX_AGE, 2);
        assert_eq!(doomed.len(), 3);
        assert!(doomed.contains(&PathBuf::from("f4")));
        assert!(!doomed.contains(&PathBuf::from("f0")));
    }

    #[test]
    fn remove_artifacts_deletes_only_exports_never_the_cache_root() {
        let root = std::env::temp_dir().join(format!("swx-uninst-{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&root);
        let exports = root.join("exports");
        std::fs::create_dir_all(&exports).unwrap();
        std::fs::write(exports.join("a.step"), b"x").unwrap();
        std::fs::write(exports.join("b-bom.csv"), b"x").unwrap();
        // A sibling standing in for ab's own files in the cache root.
        std::fs::write(root.join("bridge.json"), b"{}").unwrap();

        let (removed, failed) = remove_artifacts_under(&root);

        assert!(failed.is_empty(), "{failed:?}");
        assert!(!exports.exists(), "the exports subtree must be gone");
        assert!(root.join("bridge.json").exists(), "the cache root's own files must survive");
        assert!(root.exists(), "the cache root itself must never be deleted");
        assert!(!removed.is_empty());
        let _ = std::fs::remove_dir_all(&root);
    }

    #[test]
    fn a_merged_ledger_holds_every_line_of_both_once_in_at_order() {
        let live = "{\"at\":30,\"action\":\"c\"}\n{\"at\":10,\"action\":\"a\"}\n";
        let incoming = "{\"at\":20,\"action\":\"b\"}\r\nnot json, after b\n{\"at\":10,\"action\":\"a\"}\n\n";
        let (merged, n) = merge_ledger_lines(live, incoming);
        assert_eq!(n, 4, "the shared line once, the blank line dropped: {merged:?}");
        let lines: Vec<&str> = merged.lines().collect();
        assert_eq!(
            lines,
            vec![
                "{\"at\":10,\"action\":\"a\"}",
                "{\"at\":20,\"action\":\"b\"}",
                // No `at` of its own: it stays beside the line written before it.
                "not json, after b",
                "{\"at\":30,\"action\":\"c\"}",
            ]
        );
        assert!(merged.ends_with('\n'));
        assert_eq!(merge_ledger_lines("", ""), (String::new(), 0));
    }

    #[test]
    fn a_merged_registry_holds_every_claim_of_both_once() {
        let a = r#"{"documents":[{"title":"Part1","session":7},{"title":"Part2","session":7}]}"#;
        let b = r#"{"documents":[{"title":"Part2","session":7},{"title":"Part9","session":9}]}"#;
        let (merged, n) = merge_registry(a, b).expect("both are registries");
        assert_eq!(n, 3);
        let v: serde_json::Value = serde_json::from_str(&merged).unwrap();
        let titles: Vec<&str> = v["documents"].as_array().unwrap().iter().map(|d| d["title"].as_str().unwrap()).collect();
        assert_eq!(titles, vec!["Part1", "Part2", "Part9"]);
        assert!(merge_registry(a, "not json").is_none(), "an unreadable registry is not merged");
        assert!(merge_registry(a, r#"{"rows":[]}"#).is_none(), "nor one of another shape");
    }

    #[test]
    fn is_within_matches_by_components_not_by_prefix() {
        let t = std::env::temp_dir().join(format!("swx-within-{}", std::process::id()));
        let inst = t.join("inst");
        std::fs::create_dir_all(&inst).unwrap();
        // Inside, and equal.
        assert!(is_within(&inst.join("data").join("recovery"), &inst));
        assert!(is_within(&inst, &inst));
        // A SIBLING SHARING A PREFIX is not inside.
        assert!(!is_within(&t.join("inst-data"), &inst));
        assert!(!is_within(&t, &inst), "a parent is not inside its child");
        // Spelling does not get a path out: `..`, case, the other separator.
        assert!(is_within(&t.join("x").join("..").join("inst").join("data"), &inst));
        assert!(is_within(&Path::new(&inst.to_string_lossy().to_uppercase()).join("d"), &inst));
        assert!(is_within(Path::new(&format!("{}\\data", inst.display())), &inst));
        let _ = std::fs::remove_dir_all(&t);
    }

    #[test]
    fn same_bytes_is_byte_for_byte() {
        let t = std::env::temp_dir().join(format!("swx-samebytes-{}", std::process::id()));
        std::fs::create_dir_all(&t).unwrap();
        // Larger than one read buffer, so the loop is exercised past its first
        // chunk — and the difference is placed in the SECOND chunk.
        let a: Vec<u8> = (0..(3 << 20)).map(|i| (i % 251) as u8).collect();
        let mut b = a.clone();
        std::fs::write(t.join("a"), &a).unwrap();
        std::fs::write(t.join("b"), &b).unwrap();
        assert!(same_bytes(&t.join("a"), &t.join("b")));
        b[(1 << 20) + 17] ^= 1;
        std::fs::write(t.join("b"), &b).unwrap();
        assert!(!same_bytes(&t.join("a"), &t.join("b")), "one flipped bit is not the same file");
        std::fs::write(t.join("b"), &a[..a.len() - 1]).unwrap();
        assert!(!same_bytes(&t.join("a"), &t.join("b")), "a truncated copy is not the same file");
        assert!(!same_bytes(&t.join("a"), &t.join("missing")), "a missing copy is not the same file");
        let _ = std::fs::remove_dir_all(&t);
    }

    #[test]
    fn prune_dir_actually_removes_files_and_survives_a_missing_dir() {
        let dir = std::env::temp_dir().join(format!("swx-prune-test-{}", std::process::id()));
        let _ = std::fs::create_dir_all(&dir);
        let old = dir.join("old.step");
        std::fs::write(&old, b"x").unwrap();
        // now = far in the future, so everything is past max_age
        let far_future = SystemTime::now() + Duration::from_secs(1_000_000);
        assert_eq!(prune_dir(&dir, far_future, MAX_AGE, MAX_FILES), 1);
        assert!(!old.exists());
        let _ = std::fs::remove_dir_all(&dir);
        assert_eq!(prune_dir(&dir, far_future, MAX_AGE, MAX_FILES), 0);
    }
}

// ── sticky settings ─────────────────────────────────────────────────────────

/// The bridge's own sticky settings (currently just the driving badge), stored
/// beside its exports so `swx_uninstall`'s scope statement stays true and a
/// preference survives a bridge restart without living in anybody's conversation.
fn settings_path() -> PathBuf {
    root().join("settings.json")
}

fn settings() -> serde_json::Map<String, serde_json::Value> {
    std::fs::read_to_string(settings_path())
        .ok()
        .and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
        .and_then(|v| v.as_object().cloned())
        .unwrap_or_default()
}

pub fn setting_bool(key: &str) -> Option<bool> {
    settings().get(key).and_then(|v| v.as_bool())
}

pub fn set_setting_bool(key: &str, value: bool) {
    let mut s = settings();
    s.insert(key.to_string(), serde_json::Value::Bool(value));
    let path = settings_path();
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    let _ = crate::launch::write_atomic(&path, &serde_json::Value::Object(s).to_string());
}

/// A file's modification time in milliseconds since the epoch, or `None` when it
/// cannot be read. Read-only. Used to record the state of a user's own file at
/// the moment a sweep closed its document in place, so a later listing can tell
/// "untouched since" from "something wrote to it since".
pub fn modified_ms(path: &str) -> Option<u64> {
    if path.trim().is_empty() {
        return None;
    }
    std::fs::metadata(path)
        .ok()
        .and_then(|m| m.modified().ok())
        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
        .map(|d| d.as_millis() as u64)
}