use std::fs;
#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
use std::path::Path;
#[cfg(windows)]
use std::path::PathBuf;

mod common;

#[cfg(windows)]
fn steam_dry_run_json_output(
    temp: &tempfile::TempDir,
    case: &common::steam::SteamRuleCase,
) -> serde_json::Value {
    let steam = temp.path().join("Steam");
    case.write_fixture(&steam);

    let mut command = common::isolated::isolated_rebecca(temp);
    command.env("REBECCA_STEAM_DISCOVERY_PATH", &steam).args([
        "clean",
        "--dry-run",
        "--format",
        "json",
        "--allow-warning",
        "source-boundary",
        "--rule",
        case.rule_id,
    ]);
    if case.allow_moderate {
        command.args(["--allow-moderate"]);
    }

    let output = command.output().unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    common::support::api_data(&output.stdout)
}

#[cfg(any(windows, target_os = "linux", target_os = "macos"))]
fn write_fixture_file(path: impl AsRef<Path>, bytes: &[u8]) {
    let path = path.as_ref();
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).unwrap();
    }
    fs::write(path, bytes).unwrap();
}

#[cfg(windows)]
fn write_slack_cache_fixture(temp: &tempfile::TempDir) {
    let slack = temp.path().join("roaming").join("Slack");
    write_fixture_file(slack.join("Cache").join("cache.bin"), b"ab");
    write_fixture_file(slack.join("Code Cache").join("code.bin"), b"cde");
    write_fixture_file(slack.join("GPUCache").join("gpu.bin"), b"fghi");
    write_fixture_file(
        slack.join("Local Storage").join("leveldb").join("LOG"),
        b"keep",
    );
    write_fixture_file(
        slack
            .join("IndexedDB")
            .join("indexeddb.leveldb")
            .join("LOG"),
        b"keep",
    );
    write_fixture_file(
        slack
            .join("Service Worker")
            .join("CacheStorage")
            .join("index.bin"),
        b"keep",
    );
}

#[cfg(windows)]
fn write_wechat_cache_fixture(temp: &tempfile::TempDir) {
    let wechat = temp.path().join("roaming").join("Tencent").join("WeChat");
    write_fixture_file(wechat.join("radium").join("cache").join("cache.bin"), b"ab");
    write_fixture_file(wechat.join("WmpfCache").join("wmpf.bin"), b"cde");
    write_fixture_file(
        wechat
            .join("radium")
            .join("web")
            .join("profiles")
            .join("multitab_abc")
            .join("Cache")
            .join("Cache_Data")
            .join("multitab.bin"),
        b"fghi",
    );
    write_fixture_file(
        wechat
            .join("radium")
            .join("web")
            .join("profiles")
            .join("web_shell")
            .join("Cache")
            .join("Cache_Data")
            .join("webshell.bin"),
        b"jklmn",
    );
    write_fixture_file(wechat.join("bak").join("backup.bin"), b"keep");
}

#[test]
fn clean_dry_run_json_builds_plan_without_deleting() {
    let temp = tempfile::tempdir().unwrap();
    let temp_cache = temp.path().join("temp");
    fs::create_dir_all(&temp_cache).unwrap();
    let file = temp_cache.join("cache.tmp");
    fs::write(&file, b"cache").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env("REBECCA_STEAM_DISCOVERY", "none")
        .env("TEMP", &temp_cache)
        .env("TMPDIR", &temp_cache)
        .env("LOCALAPPDATA", temp.path().join("local"))
        .env("APPDATA", temp.path().join("roaming"))
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--category",
            "system",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );
    assert!(file.exists(), "dry-run must not delete files");

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["request"]["mode"], "dry-run");
    assert!(value["summary"]["allowed_targets"].as_u64().unwrap() >= 1);
}

#[test]
fn clean_dry_run_accepts_windows_native_scan_backend() {
    let temp = tempfile::tempdir().unwrap();
    let temp_cache = temp.path().join("temp");
    fs::create_dir_all(&temp_cache).unwrap();
    fs::write(temp_cache.join("cache.tmp"), b"cache").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env("REBECCA_STEAM_DISCOVERY", "none")
        .env("TEMP", &temp_cache)
        .env("TMPDIR", &temp_cache)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--category",
            "system",
            "--scan-backend",
            "windows-native",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["request"]["mode"], "dry-run");
    assert!(value["summary"]["allowed_targets"].as_u64().unwrap() >= 1);
}

#[test]
fn clean_dry_run_accepts_windows_ntfs_mft_experimental_scan_backend() {
    let temp = tempfile::tempdir().unwrap();
    let temp_cache = temp.path().join("temp");
    fs::create_dir_all(&temp_cache).unwrap();
    fs::write(temp_cache.join("cache.tmp"), b"cache").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env("REBECCA_STEAM_DISCOVERY", "none")
        .env("TEMP", &temp_cache)
        .env("TMPDIR", &temp_cache)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--category",
            "system",
            "--scan-backend",
            "windows-ntfs-mft-experimental",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["request"]["mode"], "dry-run");
    assert!(value["summary"]["allowed_targets"].as_u64().unwrap() >= 1);
}

#[test]
fn clean_defaults_to_preview_without_deleting() {
    let temp = tempfile::tempdir().unwrap();
    let temp_cache = temp.path().join("temp");
    fs::create_dir_all(&temp_cache).unwrap();
    let file = temp_cache.join("cache.tmp");
    fs::write(&file, b"cache").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env("REBECCA_STEAM_DISCOVERY", "none")
        .env("TEMP", &temp_cache)
        .env("TMPDIR", &temp_cache)
        .args(["clean", "--format", "json", "--category", "system"])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );
    assert!(file.exists(), "clean should preview by default");

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["request"]["mode"], "dry-run");
    assert!(value["summary"]["allowed_targets"].as_u64().unwrap() >= 1);
}

#[cfg(windows)]
#[test]
fn clean_dry_run_json_reports_slack_cache_rule() {
    let temp = tempfile::tempdir().unwrap();
    write_slack_cache_fixture(&temp);

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--rule",
            "windows.slack-cache",
            "--allow-warning",
            "active-process",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["allowed_targets"], 3);
    assert_eq!(value["summary"]["skipped_targets"], 0);
    assert_eq!(value["summary"]["estimated_bytes"], 9);

    let targets = value["targets"].as_array().unwrap();
    let target_paths = targets
        .iter()
        .map(|target| PathBuf::from(target["path"].as_str().unwrap()))
        .collect::<Vec<_>>();

    for expected in [
        Path::new("Slack").join("Cache"),
        Path::new("Slack").join("Code Cache"),
        Path::new("Slack").join("GPUCache"),
    ] {
        assert!(
            target_paths.iter().any(|path| path.ends_with(&expected)),
            "missing Slack target {expected:?}"
        );
    }

    assert!(targets.iter().all(|target| {
        target["rule_id"] == "windows.slack-cache"
            && !target["path"].as_str().unwrap().contains("Local Storage")
            && !target["path"].as_str().unwrap().contains("IndexedDB")
            && !target["path"].as_str().unwrap().contains("Service Worker")
    }));
}

#[cfg(windows)]
#[test]
fn clean_dry_run_json_skips_warning_rule_without_named_gate() {
    let temp = tempfile::tempdir().unwrap();
    write_slack_cache_fixture(&temp);

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--rule",
            "windows.slack-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["allowed_targets"], 0);
    assert_eq!(value["summary"]["skipped_targets"], 3);
    assert_eq!(
        value["summary"]["issue_matrix"][0]["reason_code"],
        "warning-gate-required"
    );
    assert_eq!(
        value["summary"]["warning_matrix"][0]["warning"],
        "active-process"
    );
    assert!(value["targets"].as_array().unwrap().iter().all(|target| {
        target["reason_code"] == "warning-gate-required"
            && target["warnings"].as_array().unwrap()[0] == "active-process"
            && target["evidence"][0]["kind"] == "issue"
            && target["evidence"][0]["reason_code"] == "warning-gate-required"
            && target["evidence"][1]["kind"] == "warning"
            && target["evidence"][1]["warning"] == "active-process"
    }));
}

#[cfg(windows)]
#[test]
fn clean_dry_run_json_reports_wechat_cache_rule() {
    let temp = tempfile::tempdir().unwrap();
    write_wechat_cache_fixture(&temp);

    let output = common::isolated::isolated_rebecca(&temp)
        .env("APPDATA", temp.path().join("roaming"))
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--rule",
            "windows.wechat-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["total_targets"], 4);
    assert_eq!(value["summary"]["allowed_targets"], 4);
    assert_eq!(value["summary"]["skipped_targets"], 0);
    assert_eq!(value["summary"]["estimated_bytes"], 14);

    let targets = value["targets"].as_array().unwrap();
    assert_eq!(targets.len(), 4);
    assert!(targets.iter().all(|target| {
        target["rule_id"] == "windows.wechat-cache"
            && target["status"] == "allowed"
            && !target["path"].as_str().unwrap().contains("bak")
    }));

    for expected in [
        Path::new("Tencent")
            .join("WeChat")
            .join("radium")
            .join("cache"),
        Path::new("Tencent").join("WeChat").join("WmpfCache"),
        Path::new("Tencent")
            .join("WeChat")
            .join("radium")
            .join("web")
            .join("profiles")
            .join("multitab_abc")
            .join("Cache")
            .join("Cache_Data"),
        Path::new("Tencent")
            .join("WeChat")
            .join("radium")
            .join("web")
            .join("profiles")
            .join("web_shell")
            .join("Cache")
            .join("Cache_Data"),
    ] {
        assert!(
            targets.iter().any(|target| {
                PathBuf::from(target["path"].as_str().unwrap()).ends_with(&expected)
            }),
            "missing WeChat target {expected:?}"
        );
    }
}

#[cfg(windows)]
#[test]
fn clean_dry_run_json_honors_exclude_flag() {
    let temp = tempfile::tempdir().unwrap();
    write_slack_cache_fixture(&temp);
    let protected_cache = temp.path().join("roaming").join("Slack").join("Cache");

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--rule",
            "windows.slack-cache",
            "--allow-warning",
            "active-process",
            "--exclude",
            protected_cache.to_str().unwrap(),
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["total_targets"], 3);
    assert_eq!(value["summary"]["allowed_targets"], 2);
    assert_eq!(value["summary"]["blocked_targets"], 1);
    assert_eq!(value["summary"]["estimated_bytes"], 7);

    let blocked = value["targets"]
        .as_array()
        .unwrap()
        .iter()
        .find(|target| target["status"] == "blocked")
        .expect("expected excluded target to be blocked");
    assert_eq!(blocked["rule_id"], "windows.slack-cache");
    assert_eq!(blocked["reason_code"], "safety-policy-blocked");
    assert!(
        blocked["reason"]
            .as_str()
            .unwrap()
            .contains("user-protected path")
    );
    assert!(
        PathBuf::from(blocked["path"].as_str().unwrap())
            .ends_with(Path::new("Slack").join("Cache"))
    );
}

#[cfg(windows)]
#[test]
fn clean_dry_run_json_honors_config_protected_paths() {
    let temp = tempfile::tempdir().unwrap();
    write_slack_cache_fixture(&temp);
    let config_dir = temp.path().join("rebecca-config");
    fs::create_dir_all(&config_dir).unwrap();
    let protected_cache = temp.path().join("roaming").join("Slack").join("GPUCache");
    fs::write(
        config_dir.join("config.toml"),
        format!(
            r#"
version = 1

[protection]
protected_paths = ['{}']
"#,
            protected_cache.display()
        ),
    )
    .unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--rule",
            "windows.slack-cache",
            "--allow-warning",
            "active-process",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["allowed_targets"], 2);
    assert_eq!(value["summary"]["blocked_targets"], 1);
    assert!(value["targets"].as_array().unwrap().iter().any(|target| {
        target["status"] == "blocked"
            && target["reason"]
                .as_str()
                .unwrap()
                .contains("user-protected path")
            && PathBuf::from(target["path"].as_str().unwrap())
                .ends_with(Path::new("Slack").join("GPUCache"))
    }));
}

#[test]
fn clean_exclude_rejects_relative_paths() {
    let temp = tempfile::tempdir().unwrap();
    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--rule",
            common::support::current_platform_user_temp_rule_id(),
            "--exclude",
            "relative/cache",
        ])
        .output()
        .unwrap();

    assert!(!output.status.success());
    let stderr = common::support::stderr(&output);
    assert!(stderr.contains("invalid protected path"));
    assert!(stderr.contains("path must be absolute"));
}

#[test]
fn clean_dry_run_json_deduplicates_overlapping_system_targets() {
    let temp = tempfile::tempdir().unwrap();
    let local = temp.path().join("local");
    let temp_cache = local.join("Temp");
    fs::create_dir_all(&temp_cache).unwrap();
    fs::write(temp_cache.join("cache.tmp"), b"cache").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env("TEMP", &temp_cache)
        .env("TMPDIR", &temp_cache)
        .env("LOCALAPPDATA", &local)
        .env("APPDATA", temp.path().join("roaming"))
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--rule",
            common::support::current_platform_user_temp_rule_id(),
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["total_targets"], 2);
    assert_eq!(value["summary"]["allowed_targets"], 1);
    assert_eq!(value["summary"]["skipped_targets"], 1);
    assert_eq!(value["summary"]["estimated_bytes"], 5);
    assert_eq!(value["summary"]["issue_matrix"][0]["status"], "skipped");
    assert_eq!(
        value["summary"]["issue_matrix"][0]["reason_code"],
        "duplicate-target-path"
    );
    assert_eq!(value["summary"]["issue_matrix"][0]["targets"], 1);
    assert!(
        value["targets"]
            .as_array()
            .unwrap()
            .iter()
            .any(|target| target["reason"] == "duplicate target path already covered")
    );
    assert!(
        value["targets"]
            .as_array()
            .unwrap()
            .iter()
            .any(|target| target["reason_code"] == "duplicate-target-path")
    );
}

#[test]
fn clean_dry_run_blocks_rebecca_storage_targets_from_config() {
    let temp = tempfile::tempdir().unwrap();
    let temp_cache = temp.path().join("temp");
    let config_dir = temp.path().join("rebecca-config");
    fs::create_dir_all(&temp_cache).unwrap();
    fs::create_dir_all(&config_dir).unwrap();
    fs::write(temp_cache.join("cache.tmp"), b"cache").unwrap();
    fs::write(
        config_dir.join("config.toml"),
        format!(
            r#"
version = 1

[app_paths]
cache_dir = '{}'
"#,
            temp_cache.display()
        ),
    )
    .unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env_remove("REBECCA_CACHE_DIR")
        .env("TEMP", &temp_cache)
        .env("TMPDIR", &temp_cache)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--rule",
            common::support::current_platform_user_temp_rule_id(),
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["blocked_targets"], 1);
    assert!(
        value["summary"]["issue_matrix"]
            .as_array()
            .unwrap()
            .iter()
            .any(|issue| issue["status"] == "blocked"
                && issue["reason_code"] == "safety-policy-blocked"
                && issue["targets"] == 1)
    );

    let blocked = value["targets"]
        .as_array()
        .unwrap()
        .iter()
        .find(|target| target["status"] == "blocked")
        .expect("expected a blocked Rebecca-owned storage target");
    assert_eq!(blocked["reason_code"], "safety-policy-blocked");
    assert!(
        blocked["reason"]
            .as_str()
            .unwrap()
            .contains("Rebecca-owned Cache dir")
    );
    assert!(temp_cache.join("cache.tmp").exists());
}

#[test]
fn clean_human_output_reports_issue_matrix_for_skipped_targets() {
    let temp = tempfile::tempdir().unwrap();
    let local = temp.path().join("local");
    let temp_cache = local.join("Temp");
    fs::create_dir_all(&temp_cache).unwrap();
    fs::write(temp_cache.join("cache.tmp"), b"cache").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env("TEMP", &temp_cache)
        .env("TMPDIR", &temp_cache)
        .env("LOCALAPPDATA", &local)
        .env("APPDATA", temp.path().join("roaming"))
        .args(["clean", "--dry-run", "--category", "system"])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Decision: preview only; no files were deleted."));
    assert!(stdout.contains("Next command: rebecca clean --yes --category system"));
    assert!(stdout.contains("Issue matrix:"));
    assert!(stdout.contains("- skipped duplicate-target-path: 1 target, 0 (0 B)"));
}

#[cfg(windows)]
#[test]
fn clean_human_output_reports_slack_cache_rule() {
    let temp = tempfile::tempdir().unwrap();
    write_slack_cache_fixture(&temp);

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-progress",
            "--rule",
            "windows.slack-cache",
            "--allow-warning",
            "active-process",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Reclaimable now: 9 (9 B)"));
    assert!(stdout.contains("Execution: would move allowed targets to recoverable trash."));
    assert!(stdout.contains(
        "Next command: rebecca clean --yes --rule windows.slack-cache --allow-warning active-process"
    ));
    assert!(stdout.contains("Required opt-ins in next command: --allow-warning active-process."));
    assert!(stdout.contains("Warning gates in plan: active-process."));
    assert!(stdout.contains("Doctor hint: rebecca doctor active-processes"));
    assert!(stdout.contains("Targets: 3"));
    assert!(stdout.contains("Allowed: 3"));
    assert!(stdout.contains("Target details:"));
    assert!(stdout.contains("allowed (3)"));
    assert!(stdout.contains("windows.slack-cache"));
    assert!(stdout.contains("Warning matrix:"));
    assert!(stdout.contains("active-process: 3 targets, 9 (9 B)"));
    assert!(stdout.contains("[warnings: active-process]"));
    assert!(stdout.contains(&Path::new("Slack").join("Cache").display().to_string()));
    assert!(!stdout.contains("Local Storage"));
    assert!(!stdout.contains("IndexedDB"));
    assert!(!stdout.contains("Service Worker"));
}

#[cfg(windows)]
#[test]
fn clean_human_output_explains_skipped_safety_opt_in() {
    let temp = tempfile::tempdir().unwrap();
    let local = temp.path().join("local");
    let roaming = temp.path().join("roaming");
    let local_npm_cache = local.join("npm-cache").join("_cacache");
    let npm_cache = roaming.join("npm-cache").join("_cacache");
    fs::create_dir_all(&local_npm_cache).unwrap();
    fs::create_dir_all(&npm_cache).unwrap();
    fs::write(local_npm_cache.join("index.bin"), b"abcd").unwrap();
    fs::write(npm_cache.join("index.bin"), b"abcd").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env("LOCALAPPDATA", &local)
        .env("APPDATA", &roaming)
        .args([
            "clean",
            "--dry-run",
            "--no-progress",
            "--rule",
            "windows.npm-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Execution: no eligible target would be deleted."));
    assert!(stdout.contains("Resolve before execution:"));
    assert!(stdout.contains(
        "- skipped safety-opt-in-required: add --allow-moderate or --allow-risky after reviewing the rule."
    ));
    assert!(stdout.contains("Issue matrix:"));
    assert!(stdout.contains("- skipped safety-opt-in-required: 2 targets, 0 (0 B)"));
    assert!(!stdout.contains("Next command:"));
}

#[test]
fn clean_human_output_reports_blocked_target_details() {
    let temp = tempfile::tempdir().unwrap();
    let temp_cache = temp.path().join("temp");
    let config_dir = temp.path().join("rebecca-config");
    fs::create_dir_all(&temp_cache).unwrap();
    fs::create_dir_all(&config_dir).unwrap();
    fs::write(temp_cache.join("cache.tmp"), b"cache").unwrap();
    fs::write(
        config_dir.join("config.toml"),
        format!(
            r#"
version = 1

[app_paths]
cache_dir = '{}'
"#,
            temp_cache.display()
        ),
    )
    .unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env_remove("REBECCA_CACHE_DIR")
        .env("TEMP", &temp_cache)
        .env("TMPDIR", &temp_cache)
        .args([
            "clean",
            "--dry-run",
            "--no-progress",
            "--rule",
            common::support::current_platform_user_temp_rule_id(),
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Issue matrix:"));
    assert!(stdout.contains("- blocked safety-policy-blocked: 1 target, 0 (0 B)"));
    assert!(stdout.contains("Target details:"));
    assert!(stdout.contains("blocked (1)"));
    assert!(stdout.contains(common::support::current_platform_user_temp_rule_id()));
    assert!(stdout.contains(&temp_cache.display().to_string()));
    assert!(stdout.contains("Rebecca-owned Cache dir"));
}

#[cfg(windows)]
#[test]
fn clean_human_output_highlights_largest_targets_by_size() {
    let temp = tempfile::tempdir().unwrap();
    let local = temp.path().join("local");
    let edge_cache = local.join("Microsoft/Edge/User Data/Default/Cache");
    let chrome_profile_code_cache = local.join("Google/Chrome/User Data/Profile 1/Code Cache");
    let chrome_default_cache = local.join("Google/Chrome/User Data/Default/Cache");

    fs::create_dir_all(&edge_cache).unwrap();
    fs::create_dir_all(&chrome_profile_code_cache).unwrap();
    fs::create_dir_all(&chrome_default_cache).unwrap();
    fs::write(edge_cache.join("edge.bin"), b"1234567890").unwrap();
    fs::write(chrome_profile_code_cache.join("code.bin"), b"123456").unwrap();
    fs::write(chrome_default_cache.join("chrome.bin"), b"1234").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env("LOCALAPPDATA", &local)
        .args(["clean", "--dry-run", "--category", "browser"])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Estimated bytes: 20 (20 B)"));
    assert!(stdout.contains("Largest estimated targets:"));
    assert!(stdout.contains("Target details:"));
    assert!(stdout.contains("allowed (3)"));
    assert!(stdout.contains("skipped ("));

    let largest_section = stdout
        .split("Largest estimated targets:")
        .nth(1)
        .expect("largest section should be present")
        .split("Target details:")
        .next()
        .expect("target details section should follow largest section");
    let edge_position = largest_section
        .find("windows.edge-cache")
        .expect("edge target should be in largest section");
    let chrome_position = largest_section
        .find("windows.chrome-cache")
        .expect("chrome target should be in largest section");

    assert!(
        edge_position < chrome_position,
        "largest section should sort targets by estimated bytes"
    );
}

#[test]
fn clean_dry_run_accepts_no_progress_flag() {
    let temp = tempfile::tempdir().unwrap();
    let temp_cache = temp.path().join("temp");
    fs::create_dir_all(&temp_cache).unwrap();
    fs::write(temp_cache.join("cache.tmp"), b"cache").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env("TEMP", &temp_cache)
        .env("TMPDIR", &temp_cache)
        .args([
            "clean",
            "--dry-run",
            "--no-progress",
            "--rule",
            common::support::current_platform_user_temp_rule_id(),
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("Cleanup mode: dry-run"));
    assert!(stdout.contains("Target details:"));
}

#[test]
fn clean_help_lists_scan_cache_opt_out() {
    let output = common::command::rebecca()
        .args(["clean", "--help"])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(stdout.contains("--scan-cache"));
    assert!(stdout.contains("--no-scan-cache"));
    assert!(stdout.contains("--scan-backend"));
    assert!(stdout.contains("windows-native"));
    assert!(stdout.contains("windows-ntfs-mft-experimental"));
}

#[test]
fn clean_scan_cache_flags_conflict() {
    let output = common::command::rebecca()
        .args(["clean", "--scan-cache", "--no-scan-cache"])
        .output()
        .unwrap();

    assert!(!output.status.success());
    let stderr = common::support::stderr(&output);
    assert!(stderr.contains("--scan-cache"));
    assert!(stderr.contains("--no-scan-cache"));
}

#[cfg(windows)]
#[test]
fn clean_dry_run_writes_scan_cache_by_default_for_file_targets() {
    let temp = tempfile::tempdir().unwrap();
    let explorer = temp
        .path()
        .join("local")
        .join("Microsoft")
        .join("Windows")
        .join("Explorer");
    fs::create_dir_all(&explorer).unwrap();
    fs::write(explorer.join("thumbcache_96.db"), b"thumb").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--rule",
            "windows.thumbnail-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["estimated_bytes"], 5);

    let scan_cache_dir = temp.path().join("rebecca-cache").join("scan");
    let cache_entries = fs::read_dir(scan_cache_dir).unwrap().count();
    assert_eq!(cache_entries, 1);
}

#[cfg(windows)]
#[test]
fn clean_dry_run_no_scan_cache_does_not_write_file_target_cache() {
    let temp = tempfile::tempdir().unwrap();
    let explorer = temp
        .path()
        .join("local")
        .join("Microsoft")
        .join("Windows")
        .join("Explorer");
    fs::create_dir_all(&explorer).unwrap();
    fs::write(explorer.join("thumbcache_96.db"), b"thumb").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--no-scan-cache",
            "--rule",
            "windows.thumbnail-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["estimated_bytes"], 5);
    assert!(!temp.path().join("rebecca-cache").join("scan").exists());
}

#[cfg(windows)]
#[test]
fn clean_yes_does_not_write_scan_cache_by_default() {
    let temp = tempfile::tempdir().unwrap();
    let explorer = temp
        .path()
        .join("local")
        .join("Microsoft")
        .join("Windows")
        .join("Explorer");
    fs::create_dir_all(&explorer).unwrap();
    fs::write(explorer.join("thumbcache_96.db"), b"thumb").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--yes",
            "--format",
            "json",
            "--rule",
            "windows.thumbnail-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["request"]["mode"], "recoverable-delete");
    assert_eq!(value["execution_report"]["summary"]["completed_actions"], 1);
    assert_eq!(
        value["execution_report"]["summary"]["pending_reclaim_bytes"],
        5
    );
    assert!(!temp.path().join("rebecca-cache").join("scan").exists());
}

#[cfg(windows)]
#[test]
fn clean_dry_run_scan_cache_flag_writes_file_target_cache() {
    let temp = tempfile::tempdir().unwrap();
    let explorer = temp
        .path()
        .join("local")
        .join("Microsoft")
        .join("Windows")
        .join("Explorer");
    fs::create_dir_all(&explorer).unwrap();
    fs::write(explorer.join("thumbcache_96.db"), b"thumb").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--scan-cache",
            "--rule",
            "windows.thumbnail-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["estimated_bytes"], 5);

    let scan_cache_dir = temp.path().join("rebecca-cache").join("scan");
    let cache_entries = fs::read_dir(scan_cache_dir).unwrap().count();
    assert_eq!(cache_entries, 1);
}

#[cfg(windows)]
#[test]
fn clean_dry_run_scan_cache_flag_reuses_directory_target_cache() {
    let temp = tempfile::tempdir().unwrap();
    let edge_cache = temp
        .path()
        .join("local")
        .join("Microsoft")
        .join("Edge")
        .join("User Data")
        .join("Default")
        .join("Cache");
    fs::create_dir_all(&edge_cache).unwrap();
    fs::write(edge_cache.join("cache.bin"), b"edge").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--scan-cache",
            "--rule",
            "windows.edge-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["estimated_bytes"], 4);
    assert_eq!(value["targets"][0]["estimate_source"], "fresh-scan");
    assert_eq!(
        value["targets"][0]["estimate_backend"],
        "portable-recursive"
    );
    assert_eq!(value["targets"][0]["estimate_confidence"], "exact");
    assert_eq!(
        value["targets"][0]["estimate_backend_evidence"]["cache_events"][0]["cache"],
        "scan-cache"
    );
    assert_eq!(
        value["targets"][0]["estimate_backend_evidence"]["cache_events"][0]["outcome"],
        "miss"
    );
    assert_eq!(
        value["targets"][0]["estimate_backend_evidence"]["cache_events"][0]["reason"],
        "missing"
    );
    assert!(
        value["targets"][0]
            .get("estimate_fallback_reason")
            .is_none()
    );

    let scan_cache_dir = temp.path().join("rebecca-cache").join("scan");
    let cache_files = fs::read_dir(scan_cache_dir)
        .unwrap()
        .map(|entry| entry.unwrap().path())
        .collect::<Vec<_>>();
    assert_eq!(cache_files.len(), 1);

    let cache_file = &cache_files[0];
    let mut record: serde_json::Value =
        serde_json::from_slice(&fs::read(cache_file).unwrap()).unwrap();
    record["report"]["bytes_scanned"] = serde_json::json!(99);
    fs::write(cache_file, serde_json::to_vec_pretty(&record).unwrap()).unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--scan-cache",
            "--rule",
            "windows.edge-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["estimated_bytes"], 99);
    assert_eq!(value["targets"][0]["estimate_source"], "scan-cache");
    assert_eq!(
        value["targets"][0]["estimate_backend"],
        "portable-recursive"
    );
    assert_eq!(value["targets"][0]["estimate_confidence"], "exact");
    assert!(
        value["targets"][0]
            .get("estimate_fallback_reason")
            .is_none()
    );
    assert!(value.get("scan_cache").is_none());
    assert!(value["summary"].get("scan_cache").is_none());
}

#[cfg(windows)]
#[test]
fn clean_dry_run_scan_cache_policy_expires_directory_records_from_config() {
    let temp = tempfile::tempdir().unwrap();
    let config_dir = temp.path().join("rebecca-config");
    fs::create_dir_all(&config_dir).unwrap();
    fs::write(
        config_dir.join("config.toml"),
        r#"
version = 1

[scan_cache]
directory_record_max_age_seconds = 1
"#,
    )
    .unwrap();
    let edge_cache = temp
        .path()
        .join("local")
        .join("Microsoft")
        .join("Edge")
        .join("User Data")
        .join("Default")
        .join("Cache");
    fs::create_dir_all(&edge_cache).unwrap();
    fs::write(edge_cache.join("cache.bin"), b"edge").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--scan-cache",
            "--rule",
            "windows.edge-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let scan_cache_dir = temp.path().join("rebecca-cache").join("scan");
    let cache_files = fs::read_dir(scan_cache_dir)
        .unwrap()
        .map(|entry| entry.unwrap().path())
        .collect::<Vec<_>>();
    assert_eq!(cache_files.len(), 1);

    let cache_file = &cache_files[0];
    let mut record: serde_json::Value =
        serde_json::from_slice(&fs::read(cache_file).unwrap()).unwrap();
    let stale_written_at = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs()
        .saturating_sub(2);
    record["report"]["bytes_scanned"] = serde_json::json!(99);
    record["written_at_unix_seconds"] = serde_json::json!(stale_written_at);
    fs::write(cache_file, serde_json::to_vec_pretty(&record).unwrap()).unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--scan-cache",
            "--rule",
            "windows.edge-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["estimated_bytes"], 4);
    assert_eq!(value["targets"][0]["estimate_source"], "fresh-scan");
}

#[cfg(windows)]
#[test]
fn clean_dry_run_scan_cache_reports_invalid_policy_config() {
    let temp = tempfile::tempdir().unwrap();
    let config_dir = temp.path().join("rebecca-config");
    fs::create_dir_all(&config_dir).unwrap();
    fs::write(
        config_dir.join("config.toml"),
        r#"
[scan_cache]
directory_record_max_age_seconds = 0
"#,
    )
    .unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--scan-cache",
            "--rule",
            "windows.edge-cache",
        ])
        .output()
        .unwrap();

    assert!(!output.status.success());

    let stderr = common::support::stderr(&output);
    assert!(stderr.contains("config parse failed"));
    assert!(stderr.contains("scan_cache.directory_record_max_age_seconds must be at least 1"));
    assert!(stderr.contains("config.toml"));
}

#[cfg(windows)]
#[test]
fn clean_human_output_summarizes_scan_cache_activity() {
    let temp = tempfile::tempdir().unwrap();
    let edge_cache = temp
        .path()
        .join("local")
        .join("Microsoft")
        .join("Edge")
        .join("User Data")
        .join("Default")
        .join("Cache");
    fs::create_dir_all(&edge_cache).unwrap();
    fs::write(edge_cache.join("cache.bin"), b"edge").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--scan-cache",
            "--rule",
            "windows.edge-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-progress",
            "--scan-cache",
            "--rule",
            "windows.edge-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("Scan cache summary: 1 hit, 0 misses, 0 skipped writes, 0 pruned records")
    );
    assert!(stdout.contains("[estimate: scan-cache, portable-recursive, exact]"));
}

#[cfg(windows)]
#[test]
fn clean_human_output_reports_scan_cache_prune_activity() {
    let temp = tempfile::tempdir().unwrap();
    let config_dir = temp.path().join("rebecca-config");
    fs::create_dir_all(&config_dir).unwrap();
    fs::write(
        config_dir.join("config.toml"),
        r#"
version = 1

[scan_cache]
directory_record_max_age_seconds = 1
"#,
    )
    .unwrap();
    let edge_cache = temp
        .path()
        .join("local")
        .join("Microsoft")
        .join("Edge")
        .join("User Data")
        .join("Default")
        .join("Cache");
    fs::create_dir_all(&edge_cache).unwrap();
    fs::write(edge_cache.join("cache.bin"), b"edge").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--scan-cache",
            "--rule",
            "windows.edge-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let scan_cache_dir = temp.path().join("rebecca-cache").join("scan");
    let cache_files = fs::read_dir(&scan_cache_dir)
        .unwrap()
        .map(|entry| entry.unwrap().path())
        .collect::<Vec<_>>();
    assert_eq!(cache_files.len(), 1);

    let cache_file = &cache_files[0];
    let mut record: serde_json::Value =
        serde_json::from_slice(&fs::read(cache_file).unwrap()).unwrap();
    record["written_at_unix_seconds"] = serde_json::json!(0);
    fs::write(cache_file, serde_json::to_vec_pretty(&record).unwrap()).unwrap();

    let stale_root = temp.path().join("stale-cache");
    fs::create_dir_all(&stale_root).unwrap();
    fs::write(stale_root.join("old.bin"), b"old").unwrap();
    let stale_root_str = stale_root.display().to_string().replace('\\', "\\\\");
    let stale_cache_file = scan_cache_dir.join("ffffffffffffffff.json");
    fs::write(
        &stale_cache_file,
        format!(
            r#"{{
  "version": 1,
  "root": "{stale_root_str}",
  "fingerprint": {{
    "file_type": "directory",
    "len": 0,
    "modified_unix_seconds": 0
  }},
  "report": {{
    "bytes_scanned": 3,
    "files_scanned": 1,
    "directories_scanned": 1
  }},
  "written_at_unix_seconds": 0
}}"#
        ),
    )
    .unwrap();
    fs::remove_dir_all(&stale_root).unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-progress",
            "--scan-cache",
            "--rule",
            "windows.edge-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let stdout = String::from_utf8_lossy(&output.stdout);
    assert!(
        stdout.contains("Scan cache summary: 0 hits, 1 miss, 0 skipped writes, 2 pruned records")
    );
}

#[cfg(windows)]
#[test]
fn clean_dry_run_json_expands_steam_rules_with_discovery_override() {
    for case in common::steam::STEAM_INSTALL_RULE_CASES {
        let temp = tempfile::tempdir().unwrap();
        let value = steam_dry_run_json_output(&temp, case);
        assert_eq!(value["summary"]["total_targets"], 1, "{}", case.rule_id);
        assert_eq!(value["summary"]["allowed_targets"], 1, "{}", case.rule_id);
        assert_eq!(value["summary"]["skipped_targets"], 0, "{}", case.rule_id);
        assert_eq!(
            value["summary"]["estimated_bytes"],
            case.bytes.len() as u64,
            "{}",
            case.rule_id
        );

        let targets = value["targets"].as_array().unwrap();
        assert_eq!(targets.len(), 1, "{}", case.rule_id);
        assert_eq!(targets[0]["rule_id"], case.rule_id);
        assert_eq!(targets[0]["status"], "allowed");
        assert_eq!(
            targets[0]["restore_hint"].as_str().unwrap(),
            case.expected_restore_hint.unwrap()
        );
        assert!(
            targets[0]["path"]
                .as_str()
                .unwrap()
                .ends_with(case.relative_path)
        );
    }
}

#[cfg(windows)]
#[test]
fn clean_dry_run_json_uses_install_root_when_libraryfolders_is_unreadable() {
    let temp = tempfile::tempdir().unwrap();
    let steam = temp.path().join("Steam");
    let steamapps = steam.join("steamapps");
    let httpcache = steam.join("appcache").join("httpcache");
    std::fs::create_dir_all(steamapps.join("libraryfolders.vdf")).unwrap();
    fs::create_dir_all(&httpcache).unwrap();
    fs::write(httpcache.join("cache.bin"), b"abcd").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env("REBECCA_STEAM_DISCOVERY_PATH", &steam)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--allow-warning",
            "source-boundary",
            "--rule",
            "windows.steam-install-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["total_targets"], 1);
    assert_eq!(value["summary"]["allowed_targets"], 1);
    assert_eq!(value["summary"]["skipped_targets"], 0);
    assert_eq!(value["summary"]["estimated_bytes"], 4);

    let targets = value["targets"].as_array().unwrap();
    assert_eq!(targets.len(), 1);
    assert_eq!(targets[0]["rule_id"], "windows.steam-install-cache");
    assert_eq!(targets[0]["status"], "allowed");
    assert!(
        targets[0]["path"]
            .as_str()
            .unwrap()
            .ends_with("appcache/httpcache")
    );
}

#[cfg(windows)]
#[test]
fn clean_dry_run_json_allows_moderate_rules_with_opt_in() {
    let temp = tempfile::tempdir().unwrap();
    let local = temp.path().join("local");
    let roaming = temp.path().join("roaming");
    let local_npm_cache = local.join("npm-cache").join("_cacache");
    let npm_cache = roaming.join("npm-cache").join("_cacache");
    fs::create_dir_all(&local_npm_cache).unwrap();
    fs::create_dir_all(&npm_cache).unwrap();
    fs::write(local_npm_cache.join("index.bin"), b"abcd").unwrap();
    fs::write(npm_cache.join("index.bin"), b"abcd").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--allow-moderate",
            "--rule",
            "windows.npm-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["total_targets"], 2);
    assert_eq!(value["summary"]["allowed_targets"], 2);
    assert_eq!(value["summary"]["skipped_targets"], 0);
    assert_eq!(value["summary"]["estimated_bytes"], 8);

    let targets = value["targets"].as_array().unwrap();
    assert_eq!(targets.len(), 2);
    assert!(targets.iter().all(|target| {
        target["rule_id"] == "windows.npm-cache" && target["status"] == "allowed"
    }));
}

#[test]
fn clean_dry_run_json_gates_linux_developer_cache_rules() {
    if !cfg!(target_os = "linux") {
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let pip_cache = temp.path().join("cache").join("pip");
    fs::create_dir_all(&pip_cache).unwrap();
    fs::write(pip_cache.join("http-cache.bin"), b"abcd").unwrap();

    let skipped = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--rule",
            "linux.pip-cache",
        ])
        .output()
        .unwrap();

    assert!(
        skipped.status.success(),
        "stderr: {}",
        common::support::stderr(&skipped)
    );
    let skipped_value: serde_json::Value = common::support::api_data(&skipped.stdout);
    assert_eq!(skipped_value["summary"]["total_targets"], 1);
    assert_eq!(skipped_value["summary"]["allowed_targets"], 0);
    assert_eq!(skipped_value["summary"]["skipped_targets"], 1);
    assert_eq!(skipped_value["targets"][0]["rule_id"], "linux.pip-cache");
    assert_eq!(skipped_value["targets"][0]["status"], "skipped");

    let allowed = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--allow-moderate",
            "--rule",
            "linux.pip-cache",
        ])
        .output()
        .unwrap();

    assert!(
        allowed.status.success(),
        "stderr: {}",
        common::support::stderr(&allowed)
    );
    let allowed_value: serde_json::Value = common::support::api_data(&allowed.stdout);
    assert_eq!(allowed_value["summary"]["total_targets"], 1);
    assert_eq!(allowed_value["summary"]["allowed_targets"], 1);
    assert_eq!(allowed_value["summary"]["skipped_targets"], 0);
    assert_eq!(allowed_value["summary"]["estimated_bytes"], 4);
    assert_eq!(allowed_value["targets"][0]["rule_id"], "linux.pip-cache");
    assert_eq!(allowed_value["targets"][0]["status"], "allowed");
}

#[test]
fn clean_dry_run_json_gates_linux_package_manager_cache_rules() {
    if !cfg!(target_os = "linux") {
        return;
    }

    let temp = tempfile::tempdir().unwrap();

    let without_moderate = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--rule",
            "linux.apt-cache",
        ])
        .output()
        .unwrap();

    assert!(
        without_moderate.status.success(),
        "stderr: {}",
        common::support::stderr(&without_moderate)
    );
    let without_moderate_value: serde_json::Value =
        common::support::api_data(&without_moderate.stdout);
    assert_eq!(without_moderate_value["summary"]["total_targets"], 1);
    assert_eq!(without_moderate_value["summary"]["allowed_targets"], 0);
    assert_eq!(without_moderate_value["summary"]["skipped_targets"], 1);
    assert_eq!(without_moderate_value["targets"][0]["status"], "skipped");
    assert_eq!(
        without_moderate_value["targets"][0]["reason_code"],
        "safety-opt-in-required"
    );

    let without_warning = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--allow-moderate",
            "--rule",
            "linux.apt-cache",
        ])
        .output()
        .unwrap();

    assert!(
        without_warning.status.success(),
        "stderr: {}",
        common::support::stderr(&without_warning)
    );
    let without_warning_value: serde_json::Value =
        common::support::api_data(&without_warning.stdout);
    assert_eq!(without_warning_value["summary"]["total_targets"], 1);
    assert_eq!(without_warning_value["summary"]["allowed_targets"], 0);
    assert_eq!(without_warning_value["summary"]["skipped_targets"], 1);
    assert_eq!(
        without_warning_value["targets"][0]["reason_code"],
        "warning-gate-required"
    );
}

#[test]
fn clean_dry_run_json_gates_linux_browser_cache_warnings() {
    if !cfg!(target_os = "linux") {
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let chrome_cache = temp
        .path()
        .join("cache")
        .join("google-chrome")
        .join("Default")
        .join("Cache");
    let chrome_history = temp
        .path()
        .join("config")
        .join("google-chrome")
        .join("Default")
        .join("History");
    fs::create_dir_all(&chrome_cache).unwrap();
    fs::create_dir_all(chrome_history.parent().unwrap()).unwrap();
    fs::write(chrome_cache.join("data.bin"), b"abcd").unwrap();
    fs::write(&chrome_history, b"keep").unwrap();

    let skipped = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--rule",
            "linux.chrome-cache",
        ])
        .output()
        .unwrap();

    assert!(
        skipped.status.success(),
        "stderr: {}",
        common::support::stderr(&skipped)
    );
    let skipped_value: serde_json::Value = common::support::api_data(&skipped.stdout);
    let skipped_targets = skipped_value["targets"].as_array().unwrap();
    assert_eq!(
        skipped_value["summary"]["total_targets"],
        skipped_targets.len()
    );
    assert_eq!(skipped_value["summary"]["allowed_targets"], 0);
    assert_eq!(
        skipped_value["summary"]["skipped_targets"],
        skipped_targets.len()
    );
    let skipped_cache_target = skipped_targets
        .iter()
        .find(|target| {
            target["path"].as_str().unwrap() == "%XDG_CACHE_HOME%/google-chrome/Default/Cache"
        })
        .expect("Chrome cache template target should be present before warning opt-in");
    assert_eq!(skipped_cache_target["status"], "skipped");
    assert_eq!(skipped_cache_target["reason_code"], "warning-gate-required");

    let allowed = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--allow-warning",
            "active-process",
            "--rule",
            "linux.chrome-cache",
        ])
        .output()
        .unwrap();

    assert!(
        allowed.status.success(),
        "stderr: {}",
        common::support::stderr(&allowed)
    );
    let allowed_value: serde_json::Value = common::support::api_data(&allowed.stdout);
    let allowed_targets = allowed_value["targets"].as_array().unwrap();
    assert_eq!(
        allowed_value["summary"]["total_targets"],
        allowed_targets.len()
    );
    assert_eq!(allowed_value["summary"]["allowed_targets"], 1);
    assert_eq!(
        allowed_value["summary"]["skipped_targets"],
        allowed_targets.len() - 1
    );
    assert_eq!(allowed_value["summary"]["estimated_bytes"], 4);
    let allowed_cache_target = allowed_targets
        .iter()
        .find(|target| {
            target["path"]
                .as_str()
                .unwrap()
                .ends_with("/cache/google-chrome/Default/Cache")
        })
        .expect("existing Chrome cache target should be allowed after warning opt-in");
    assert_eq!(allowed_cache_target["rule_id"], "linux.chrome-cache");
    assert_eq!(allowed_cache_target["status"], "allowed");
    assert_eq!(allowed_cache_target["estimated_bytes"], 4);
    assert!(
        chrome_history.exists(),
        "private browser data must not be targeted"
    );
}

#[test]
fn clean_dry_run_json_gates_linux_desktop_app_cache_warnings() {
    if !cfg!(target_os = "linux") {
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let native_cache = temp
        .path()
        .join("config")
        .join("Slack")
        .join("Cache")
        .join("cache.bin");
    let flatpak_cache = temp
        .path()
        .join(".var")
        .join("app")
        .join("com.slack.Slack")
        .join("cache")
        .join("flatpak.bin");
    let snap_cache = temp
        .path()
        .join("snap")
        .join("slack")
        .join("common")
        .join(".cache")
        .join("snap.bin");
    let flatpak_data = temp
        .path()
        .join(".var")
        .join("app")
        .join("com.slack.Slack")
        .join("data")
        .join("state.db");
    let snap_config = temp
        .path()
        .join("snap")
        .join("slack")
        .join("common")
        .join(".config")
        .join("settings.json");

    write_fixture_file(&native_cache, b"ab");
    write_fixture_file(&flatpak_cache, b"cde");
    write_fixture_file(&snap_cache, b"fghi");
    write_fixture_file(&flatpak_data, b"keep");
    write_fixture_file(&snap_config, b"keep");

    let skipped = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--rule",
            "linux.slack-cache",
        ])
        .output()
        .unwrap();

    assert!(
        skipped.status.success(),
        "stderr: {}",
        common::support::stderr(&skipped)
    );
    let skipped_value: serde_json::Value = common::support::api_data(&skipped.stdout);
    let skipped_targets = skipped_value["targets"].as_array().unwrap();
    assert_eq!(
        skipped_value["summary"]["total_targets"],
        skipped_targets.len()
    );
    assert_eq!(skipped_value["summary"]["allowed_targets"], 0);
    assert_eq!(
        skipped_value["summary"]["skipped_targets"],
        skipped_targets.len()
    );
    assert!(
        skipped_targets
            .iter()
            .all(|target| target["status"] == "skipped")
    );
    for expected_path in [
        "%XDG_CONFIG_HOME%/Slack/Cache",
        "%HOME%/.var/app/com.slack.Slack/cache",
        "%HOME%/snap/slack/common/.cache",
    ] {
        let target = skipped_targets
            .iter()
            .find(|target| target["path"].as_str().unwrap() == expected_path)
            .unwrap_or_else(|| {
                panic!("Slack cache template target missing before warning opt-in: {expected_path}")
            });
        assert_eq!(target["reason_code"], "warning-gate-required");
    }

    let allowed = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--allow-warning",
            "active-process",
            "--rule",
            "linux.slack-cache",
        ])
        .output()
        .unwrap();

    assert!(
        allowed.status.success(),
        "stderr: {}",
        common::support::stderr(&allowed)
    );
    let allowed_value: serde_json::Value = common::support::api_data(&allowed.stdout);
    let allowed_targets = allowed_value["targets"].as_array().unwrap();
    assert_eq!(
        allowed_value["summary"]["total_targets"],
        allowed_targets.len()
    );
    assert_eq!(allowed_value["summary"]["allowed_targets"], 3);
    assert_eq!(
        allowed_value["summary"]["skipped_targets"],
        allowed_targets.len() - 3
    );
    assert_eq!(allowed_value["summary"]["estimated_bytes"], 9);
    for suffix in [
        "/config/Slack/Cache",
        "/.var/app/com.slack.Slack/cache",
        "/snap/slack/common/.cache",
    ] {
        let target = allowed_targets
            .iter()
            .find(|target| target["path"].as_str().unwrap().ends_with(suffix))
            .unwrap_or_else(|| panic!("existing Slack cache target missing: {suffix}"));
        assert_eq!(target["status"], "allowed");
    }

    let target_paths = allowed_targets
        .iter()
        .map(|target| target["path"].as_str().unwrap())
        .collect::<Vec<_>>();
    assert!(
        target_paths.iter().all(|path| {
            !path.contains("/.var/app/com.slack.Slack/data")
                && !path.contains("/snap/slack/common/.config")
        }),
        "durable Linux app state must not be targeted: {target_paths:?}"
    );
    assert!(
        flatpak_data.exists(),
        "Flatpak durable data must remain untouched"
    );
    assert!(
        snap_config.exists(),
        "Snap durable config must remain untouched"
    );
}

#[test]
fn clean_dry_run_json_discovers_linux_steam_install_cache() {
    if !cfg!(target_os = "linux") {
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let steam = temp.path().join(".steam").join("steam");
    let httpcache = steam.join("appcache").join("httpcache").join("cache.bin");
    let userdata = steam.join("userdata").join("123").join("config.vdf");
    write_fixture_file(&httpcache, b"abcdef");
    write_fixture_file(&userdata, b"keep");

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--allow-warning",
            "source-boundary",
            "--rule",
            "linux.steam-install-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["total_targets"], 1);
    assert_eq!(value["summary"]["allowed_targets"], 1);
    assert_eq!(value["summary"]["skipped_targets"], 0);
    assert_eq!(value["summary"]["estimated_bytes"], 6);
    assert_eq!(value["targets"][0]["rule_id"], "linux.steam-install-cache");
    assert_eq!(value["targets"][0]["status"], "allowed");
    assert!(
        value["targets"][0]["path"]
            .as_str()
            .unwrap()
            .contains("/.steam/steam/appcache/httpcache")
    );
    assert!(userdata.exists(), "Steam userdata must remain untouched");
}

#[test]
fn clean_dry_run_json_gates_macos_developer_cache_rules() {
    if !cfg!(target_os = "macos") {
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let pip_cache = temp.path().join("Library").join("Caches").join("pip");
    write_fixture_file(pip_cache.join("http-cache.bin"), b"abcd");

    let skipped = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--rule",
            "macos.pip-cache",
        ])
        .output()
        .unwrap();

    assert!(
        skipped.status.success(),
        "stderr: {}",
        common::support::stderr(&skipped)
    );
    let skipped_value: serde_json::Value = common::support::api_data(&skipped.stdout);
    assert_eq!(skipped_value["summary"]["total_targets"], 1);
    assert_eq!(skipped_value["summary"]["allowed_targets"], 0);
    assert_eq!(skipped_value["summary"]["skipped_targets"], 1);
    assert_eq!(skipped_value["targets"][0]["rule_id"], "macos.pip-cache");
    assert_eq!(skipped_value["targets"][0]["status"], "skipped");

    let allowed = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--allow-moderate",
            "--rule",
            "macos.pip-cache",
        ])
        .output()
        .unwrap();

    assert!(
        allowed.status.success(),
        "stderr: {}",
        common::support::stderr(&allowed)
    );
    let allowed_value: serde_json::Value = common::support::api_data(&allowed.stdout);
    assert_eq!(allowed_value["summary"]["total_targets"], 1);
    assert_eq!(allowed_value["summary"]["allowed_targets"], 1);
    assert_eq!(allowed_value["summary"]["skipped_targets"], 0);
    assert_eq!(allowed_value["summary"]["estimated_bytes"], 4);
    assert_eq!(allowed_value["targets"][0]["rule_id"], "macos.pip-cache");
    assert_eq!(allowed_value["targets"][0]["status"], "allowed");
}

#[test]
fn clean_dry_run_json_gates_macos_browser_cache_warnings() {
    if !cfg!(target_os = "macos") {
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let chrome_profile = temp
        .path()
        .join("Library")
        .join("Application Support")
        .join("Google")
        .join("Chrome")
        .join("Default");
    let chrome_cache = chrome_profile.join("Cache");
    let chrome_history = chrome_profile.join("History");
    write_fixture_file(chrome_cache.join("data.bin"), b"abcd");
    write_fixture_file(&chrome_history, b"keep");

    let skipped = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--rule",
            "macos.chrome-cache",
        ])
        .output()
        .unwrap();

    assert!(
        skipped.status.success(),
        "stderr: {}",
        common::support::stderr(&skipped)
    );
    let skipped_value: serde_json::Value = common::support::api_data(&skipped.stdout);
    let skipped_targets = skipped_value["targets"].as_array().unwrap();
    assert_eq!(
        skipped_value["summary"]["total_targets"],
        skipped_targets.len()
    );
    assert_eq!(skipped_value["summary"]["allowed_targets"], 0);
    assert_eq!(
        skipped_value["summary"]["skipped_targets"],
        skipped_targets.len()
    );
    let skipped_cache_target = skipped_targets
        .iter()
        .find(|target| {
            target["path"].as_str().unwrap()
                == "%MACOS_APPLICATION_SUPPORT_HOME%/Google/Chrome/Default/Cache"
        })
        .expect("Chrome cache template target should be present before warning opt-in");
    assert_eq!(skipped_cache_target["status"], "skipped");
    assert_eq!(skipped_cache_target["reason_code"], "warning-gate-required");

    let allowed = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--allow-warning",
            "active-process",
            "--rule",
            "macos.chrome-cache",
        ])
        .output()
        .unwrap();

    assert!(
        allowed.status.success(),
        "stderr: {}",
        common::support::stderr(&allowed)
    );
    let allowed_value: serde_json::Value = common::support::api_data(&allowed.stdout);
    let allowed_targets = allowed_value["targets"].as_array().unwrap();
    assert_eq!(
        allowed_value["summary"]["total_targets"],
        allowed_targets.len()
    );
    assert_eq!(allowed_value["summary"]["allowed_targets"], 1);
    assert_eq!(
        allowed_value["summary"]["skipped_targets"],
        allowed_targets.len() - 1
    );
    assert_eq!(allowed_value["summary"]["estimated_bytes"], 4);
    let allowed_cache_target = allowed_targets
        .iter()
        .find(|target| {
            target["path"]
                .as_str()
                .unwrap()
                .ends_with("/Library/Application Support/Google/Chrome/Default/Cache")
        })
        .expect("existing Chrome cache target should be allowed after warning opt-in");
    assert_eq!(allowed_cache_target["rule_id"], "macos.chrome-cache");
    assert_eq!(allowed_cache_target["status"], "allowed");
    assert_eq!(allowed_cache_target["estimated_bytes"], 4);
    assert!(
        chrome_history.exists(),
        "private browser data must not be targeted"
    );
}

#[test]
fn clean_dry_run_json_gates_macos_desktop_app_cache_warnings() {
    if !cfg!(target_os = "macos") {
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let slack = temp
        .path()
        .join("Library")
        .join("Application Support")
        .join("Slack");
    write_fixture_file(slack.join("Cache").join("cache.bin"), b"ab");
    write_fixture_file(slack.join("Code Cache").join("code.bin"), b"cde");
    write_fixture_file(slack.join("GPUCache").join("gpu.bin"), b"fghi");
    write_fixture_file(
        slack.join("Local Storage").join("leveldb").join("LOG"),
        b"keep",
    );

    let skipped = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--rule",
            "macos.slack-cache",
        ])
        .output()
        .unwrap();

    assert!(
        skipped.status.success(),
        "stderr: {}",
        common::support::stderr(&skipped)
    );
    let skipped_value: serde_json::Value = common::support::api_data(&skipped.stdout);
    let skipped_targets = skipped_value["targets"].as_array().unwrap();
    assert_eq!(
        skipped_value["summary"]["total_targets"],
        skipped_targets.len()
    );
    assert_eq!(skipped_value["summary"]["allowed_targets"], 0);
    assert_eq!(
        skipped_value["summary"]["skipped_targets"],
        skipped_targets.len()
    );
    for expected_path in [
        "%MACOS_APPLICATION_SUPPORT_HOME%/Slack/Cache",
        "%MACOS_APPLICATION_SUPPORT_HOME%/Slack/Code Cache",
        "%MACOS_APPLICATION_SUPPORT_HOME%/Slack/GPUCache",
    ] {
        let target = skipped_targets
            .iter()
            .find(|target| target["path"].as_str().unwrap() == expected_path)
            .unwrap_or_else(|| {
                panic!("Slack cache template target missing before warning opt-in: {expected_path}")
            });
        assert_eq!(target["reason_code"], "warning-gate-required");
    }

    let allowed = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--allow-warning",
            "active-process",
            "--rule",
            "macos.slack-cache",
        ])
        .output()
        .unwrap();

    assert!(
        allowed.status.success(),
        "stderr: {}",
        common::support::stderr(&allowed)
    );
    let allowed_value: serde_json::Value = common::support::api_data(&allowed.stdout);
    let allowed_targets = allowed_value["targets"].as_array().unwrap();
    assert_eq!(
        allowed_value["summary"]["total_targets"],
        allowed_targets.len()
    );
    assert_eq!(allowed_value["summary"]["allowed_targets"], 3);
    assert_eq!(
        allowed_value["summary"]["skipped_targets"],
        allowed_targets.len() - 3
    );
    assert_eq!(allowed_value["summary"]["estimated_bytes"], 9);
    for suffix in [
        "/Library/Application Support/Slack/Cache",
        "/Library/Application Support/Slack/Code Cache",
        "/Library/Application Support/Slack/GPUCache",
    ] {
        let target = allowed_targets
            .iter()
            .find(|target| target["path"].as_str().unwrap().ends_with(suffix))
            .unwrap_or_else(|| panic!("existing Slack cache target missing: {suffix}"));
        assert_eq!(target["status"], "allowed");
    }

    let target_paths = allowed_targets
        .iter()
        .map(|target| target["path"].as_str().unwrap())
        .collect::<Vec<_>>();
    assert!(
        target_paths
            .iter()
            .all(|path| !path.contains("/Library/Application Support/Slack/Local Storage")),
        "durable macOS app state must not be targeted: {target_paths:?}"
    );
    assert!(
        slack
            .join("Local Storage")
            .join("leveldb")
            .join("LOG")
            .exists(),
        "Slack durable data must remain untouched"
    );
}

#[test]
fn clean_dry_run_json_discovers_macos_curated_developer_rules() {
    if !cfg!(target_os = "macos") {
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let homebrew_cache = temp.path().join("Library").join("Caches").join("Homebrew");
    write_fixture_file(
        homebrew_cache.join("downloads").join("bottle.tar.gz"),
        b"abcd",
    );
    write_fixture_file(homebrew_cache.join("api").join("formula.json"), b"ef");
    write_fixture_file(homebrew_cache.join("Cask").join("app.dmg"), b"ghij");
    let homebrew_tap = temp
        .path()
        .join("Library")
        .join("Application Support")
        .join("Homebrew")
        .join("Library")
        .join("Taps")
        .join("keep.rb");
    write_fixture_file(&homebrew_tap, b"keep");

    let cocoapods_cache = temp.path().join("Library").join("Caches").join("CocoaPods");
    write_fixture_file(cocoapods_cache.join("Pods").join("Alamofire.zip"), b"klm");

    let xcode = temp.path().join("Library").join("Developer").join("Xcode");
    write_fixture_file(
        xcode
            .join("DerivedData")
            .join("Rebecca-abc123")
            .join("Build")
            .join("cache.bin"),
        b"nopq",
    );
    write_fixture_file(
        xcode
            .join("Archives")
            .join("2026-07-07")
            .join("Rebecca.xcarchive")
            .join("Info.plist"),
        b"keep",
    );
    write_fixture_file(
        xcode
            .join("iOS DeviceSupport")
            .join("18.0")
            .join("Symbols")
            .join("keep.bin"),
        b"keep",
    );
    write_fixture_file(
        temp.path()
            .join("Library")
            .join("MobileDevice")
            .join("Provisioning Profiles")
            .join("example.mobileprovision"),
        b"keep",
    );
    let xcode_cache = temp
        .path()
        .join("Library")
        .join("Caches")
        .join("com.apple.dt.Xcode");
    write_fixture_file(xcode_cache.join("cache.db"), b"rst");

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--allow-moderate",
            "--allow-warning",
            "active-process",
            "--allow-warning",
            "permission-sensitive",
            "--rule",
            "macos.homebrew-cache",
            "--rule",
            "macos.cocoapods-cache",
            "--rule",
            "macos.xcode-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    let targets = value["targets"].as_array().unwrap();
    assert_eq!(value["summary"]["allowed_targets"], 6);

    let allowed_paths = targets
        .iter()
        .filter(|target| target["status"] == "allowed")
        .map(|target| target["path"].as_str().unwrap())
        .collect::<Vec<_>>();
    let target_paths = targets
        .iter()
        .map(|target| target["path"].as_str().unwrap())
        .collect::<Vec<_>>();
    for expected in [
        "/Library/Caches/Homebrew/downloads",
        "/Library/Caches/Homebrew/api",
        "/Library/Caches/Homebrew/Cask",
        "/Library/Caches/CocoaPods",
        "/Library/Developer/Xcode/DerivedData",
        "/Library/Caches/com.apple.dt.Xcode",
    ] {
        assert!(
            allowed_paths.iter().any(|path| path.ends_with(expected)),
            "missing allowed macOS developer cache target {expected}: {allowed_paths:?}"
        );
    }

    for forbidden in [
        "/Library/Application Support/Homebrew",
        "/Library/Developer/Xcode/Archives",
        "/Library/Developer/Xcode/iOS DeviceSupport",
        "/Library/MobileDevice/Provisioning Profiles",
    ] {
        assert!(
            target_paths.iter().all(|path| !path.contains(forbidden)),
            "durable macOS developer state must not be targeted: {target_paths:?}"
        );
    }
    assert!(homebrew_tap.exists(), "Homebrew taps must remain untouched");
}

#[test]
fn clean_dry_run_json_discovers_macos_steam_install_cache() {
    if !cfg!(target_os = "macos") {
        return;
    }

    let temp = tempfile::tempdir().unwrap();
    let steam = temp
        .path()
        .join("Library")
        .join("Application Support")
        .join("Steam");
    let httpcache = steam.join("appcache").join("httpcache").join("cache.bin");
    let userdata = steam.join("userdata").join("123").join("config.vdf");
    write_fixture_file(&httpcache, b"abcdef");
    write_fixture_file(&userdata, b"keep");

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--no-scan-cache",
            "--format",
            "json",
            "--allow-warning",
            "source-boundary",
            "--rule",
            "macos.steam-install-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["total_targets"], 1);
    assert_eq!(value["summary"]["allowed_targets"], 1);
    assert_eq!(value["summary"]["skipped_targets"], 0);
    assert_eq!(value["summary"]["estimated_bytes"], 6);
    assert_eq!(value["targets"][0]["rule_id"], "macos.steam-install-cache");
    assert_eq!(value["targets"][0]["status"], "allowed");
    assert!(
        value["targets"][0]["path"]
            .as_str()
            .unwrap()
            .contains("/Library/Application Support/Steam/appcache/httpcache")
    );
    assert!(userdata.exists(), "Steam userdata must remain untouched");
}

#[cfg(windows)]
#[test]
fn clean_dry_run_json_accepts_allow_risky_flag() {
    let temp = tempfile::tempdir().unwrap();
    let local = temp.path().join("local");
    let roaming = temp.path().join("roaming");
    let local_npm_cache = local.join("npm-cache").join("_cacache");
    let npm_cache = roaming.join("npm-cache").join("_cacache");
    fs::create_dir_all(&local_npm_cache).unwrap();
    fs::create_dir_all(&npm_cache).unwrap();
    fs::write(local_npm_cache.join("index.bin"), b"abcd").unwrap();
    fs::write(npm_cache.join("index.bin"), b"abcd").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--allow-risky",
            "--rule",
            "windows.npm-cache",
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["summary"]["total_targets"], 2);
    assert_eq!(value["summary"]["allowed_targets"], 2);
    assert_eq!(value["summary"]["skipped_targets"], 0);
    assert_eq!(value["summary"]["estimated_bytes"], 8);
    assert_eq!(value["request"]["allow_risky"], true);

    let targets = value["targets"].as_array().unwrap();
    assert_eq!(targets.len(), 2);
    assert!(targets.iter().all(|target| {
        target["rule_id"] == "windows.npm-cache" && target["status"] == "allowed"
    }));
}

#[test]
fn clean_unknown_rule_returns_clear_error() {
    let temp = tempfile::tempdir().unwrap();
    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--rule",
            "missing.rule",
        ])
        .output()
        .unwrap();

    assert!(!output.status.success());
    assert!(common::support::stderr(&output).contains("invalid rule id"));
}

#[test]
fn clean_unknown_category_returns_clear_error() {
    let temp = tempfile::tempdir().unwrap();
    let output = common::isolated::isolated_rebecca(&temp)
        .args([
            "clean",
            "--dry-run",
            "--format",
            "json",
            "--category",
            "missing",
        ])
        .output()
        .unwrap();

    assert!(!output.status.success());
    assert!(common::support::stderr(&output).contains("invalid category"));
}

#[cfg(not(windows))]
#[test]
fn non_windows_execution_uses_recoverable_trash_backend() {
    let temp = tempfile::tempdir().unwrap();
    let temp_cache = temp.path().join("temp");
    fs::create_dir_all(&temp_cache).unwrap();
    let file = temp_cache.join("cache.tmp");
    fs::write(&file, b"cache").unwrap();

    let output = common::isolated::isolated_rebecca(&temp)
        .env("TEMP", &temp_cache)
        .env("TMPDIR", &temp_cache)
        .args([
            "clean",
            "--yes",
            "--format",
            "json",
            "--rule",
            common::support::current_platform_user_temp_rule_id(),
        ])
        .output()
        .unwrap();

    assert!(
        output.status.success(),
        "stderr: {}",
        common::support::stderr(&output)
    );
    assert!(temp_cache.exists(), "clean should preserve the temp root");
    assert!(!file.exists(), "clean should move temp contents to trash");

    let value: serde_json::Value = common::support::api_data(&output.stdout);
    assert_eq!(value["request"]["mode"], "recoverable-delete");
    assert_eq!(value["execution_report"]["summary"]["completed_actions"], 1);
    assert_eq!(
        value["execution_report"]["summary"]["pending_reclaim_bytes"],
        5
    );
}
