use std::fs;
use std::path::Path;
use std::path::PathBuf;

mod common;

use rebecca_core::applications::{
    InstalledApplication, NoopApplicationDiscovery, StaticApplicationDiscovery, SteamInstallation,
};
use rebecca_core::config::AppPaths;
use rebecca_core::environment::MapEnvironment;
use rebecca_core::plan::{CleanupPlan, CleanupTargetIssueReason};
use rebecca_core::planner::{
    PlanBuildContext, PlanProgressEvent, build_cleanup_plan_with_context,
    build_cleanup_plan_with_environment, build_cleanup_plan_with_environment_and_applications,
    build_cleanup_plan_with_environment_and_progress,
    build_cleanup_plan_with_environment_and_progress_and_cancellation,
};
use rebecca_core::scan::ScanCancellationToken;
use rebecca_core::scan::ScanReport;
use rebecca_core::scan_cache::{ScanCacheLookup, ScanCachePolicy, ScanCacheStore};
use rebecca_core::{
    CleanupWorkflow, DeleteMode, PlanRequest, Platform, RebeccaError, RuleDefinition,
    RuleProvenance, RuleSource, RuleTargetSpec, SafetyLevel, TargetStatus,
};

#[test]
fn category_filter_includes_only_matching_rules() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/a.tmp", b"abc");
    fixture.write("local/Temp/b.tmp", b"de");
    fixture.write(
        "local/Microsoft/Edge/User Data/Default/Cache/cache.bin",
        b"edge",
    );
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_categories = vec!["system".to_string()];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert!(
        plan.targets
            .iter()
            .any(|target| target.rule_id == "windows.user-temp")
    );
    assert!(plan.targets.iter().all(|target| {
        matches!(
            target.rule_id.as_str(),
            "windows.user-temp"
                | "windows.directx-shader-cache"
                | "windows.system-temp"
                | "windows.prefetch"
                | "windows.update-download-cache"
                | "windows.media-player-cache"
                | "windows.thumbnail-cache"
                | "windows.wer-reports"
        )
    }));
    assert!(
        !plan
            .targets
            .iter()
            .any(|target| target.rule_id == "windows.edge-cache")
    );
}

#[test]
fn rule_selection_matches_categories_and_rule_ids_case_insensitively() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/a.tmp", b"abc");
    fixture.write("local/Temp/b.tmp", b"de");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_categories = vec!["SYSTEM".to_string()];
    request.selected_rule_ids = vec!["WINDOWS.USER-TEMP".to_string()];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 2);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert!(
        plan.targets
            .iter()
            .any(|target| target.rule_id == "windows.user-temp")
    );
    assert!(
        plan.targets
            .iter()
            .all(|target| target.rule_id == "windows.user-temp")
    );
}

#[test]
fn overlapping_templates_are_deduplicated_before_sizing() {
    let fixture = PlannerFixture::with_local_temp_as_temp();
    fixture.write("local/Temp/a.tmp", b"abc");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.user-temp".to_string()];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.total_targets, 2);
    assert_eq!(plan.summary.allowed_targets, 1);
    assert_eq!(plan.summary.skipped_targets, 1);
    assert_eq!(plan.summary.estimated_bytes, 3);
    let duplicate_target = plan
        .targets
        .iter()
        .find(|target| target.reason.as_deref() == Some("duplicate target path already covered"))
        .expect("duplicate target should be skipped");
    assert_eq!(
        duplicate_target.reason_code,
        Some(CleanupTargetIssueReason::DuplicateTargetPath)
    );
    assert!(duplicate_target.restore_hint.is_some());
    assert_eq!(
        plan.summary.issue_matrix[0].reason_code,
        CleanupTargetIssueReason::DuplicateTargetPath
    );
}

#[test]
fn planner_blocks_targets_overlapping_rebecca_owned_storage() {
    let fixture = PlannerFixture::new();
    let app_paths = app_paths_for_fixture(&fixture);
    fixture.write("rebecca-cache/scan/cache.json", b"cached");
    let rules = vec![custom_exact_path_rule(
        "windows.custom-rebecca-cache",
        app_paths.cache_dir.join("scan"),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    let cancellation = ScanCancellationToken::new();
    let applications = NoopApplicationDiscovery::new();
    let storage_entries = app_paths.storage_entries();

    let plan = build_cleanup_plan_with_context(
        &request,
        &rules,
        &fixture.env,
        &applications,
        PlanBuildContext::new(&cancellation).with_protected_storage(&storage_entries),
        |_| {},
    )
    .unwrap();

    assert_eq!(plan.summary.total_targets, 1);
    assert_eq!(plan.summary.allowed_targets, 0);
    assert_eq!(plan.summary.blocked_targets, 1);
    assert_eq!(plan.summary.estimated_bytes, 0);
    assert_eq!(plan.targets[0].status, TargetStatus::Blocked);
    assert_eq!(
        plan.targets[0].reason_code,
        Some(CleanupTargetIssueReason::SafetyPolicyBlocked)
    );
    assert!(
        plan.targets[0]
            .reason
            .as_deref()
            .unwrap()
            .contains("Rebecca-owned Cache dir")
    );
    assert_eq!(
        plan.summary.issue_matrix[0].reason_code,
        CleanupTargetIssueReason::SafetyPolicyBlocked
    );
}

#[test]
fn planner_blocks_targets_from_custom_rebecca_storage_paths() {
    let fixture = PlannerFixture::new();
    let mut app_paths = app_paths_for_fixture(&fixture);
    app_paths.state_dir = fixture.root.join("custom-state");
    app_paths.history_file = app_paths.state_dir.join("history.jsonl");
    fixture.write("custom-state/history.jsonl", b"history");
    let rules = vec![custom_exact_path_rule(
        "windows.custom-rebecca-state",
        app_paths.state_dir.clone(),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    let cancellation = ScanCancellationToken::new();
    let applications = NoopApplicationDiscovery::new();
    let storage_entries = app_paths.storage_entries();

    let plan = build_cleanup_plan_with_context(
        &request,
        &rules,
        &fixture.env,
        &applications,
        PlanBuildContext::new(&cancellation).with_protected_storage(&storage_entries),
        |_| {},
    )
    .unwrap();

    assert_eq!(plan.summary.blocked_targets, 1);
    assert_eq!(plan.targets[0].status, TargetStatus::Blocked);
    let reason = plan.targets[0].reason.as_deref().unwrap();
    assert!(reason.contains("Rebecca-owned State dir"));
    assert!(reason.contains("custom-state"));
}

#[test]
fn planner_blocks_targets_overlapping_user_protected_paths() {
    let fixture = PlannerFixture::new();
    let cache_dir = fixture.root.join("roaming").join("Slack").join("Cache");
    fixture.write("roaming/Slack/Cache/cache.bin", b"abc");
    let rules = vec![custom_exact_path_rule(
        "windows.custom-slack-cache",
        cache_dir.clone(),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    let cancellation = ScanCancellationToken::new();
    let applications = NoopApplicationDiscovery::new();
    let protected_paths = vec![cache_dir];

    let plan = build_cleanup_plan_with_context(
        &request,
        &rules,
        &fixture.env,
        &applications,
        PlanBuildContext::new(&cancellation).with_protected_paths(&protected_paths),
        |_| {},
    )
    .unwrap();

    assert_eq!(plan.summary.total_targets, 1);
    assert_eq!(plan.summary.allowed_targets, 0);
    assert_eq!(plan.summary.blocked_targets, 1);
    assert_eq!(plan.targets[0].status, TargetStatus::Blocked);
    assert_eq!(
        plan.targets[0].reason_code,
        Some(CleanupTargetIssueReason::SafetyPolicyBlocked)
    );
    assert!(
        plan.targets[0]
            .reason
            .as_deref()
            .is_some_and(|reason| reason.contains("user-protected path"))
    );
}

#[test]
fn app_leftover_plan_allows_discovered_appdata_cache_paths() {
    let fixture = PlannerFixture::new();
    let local = fixture.root.join("AppData").join("Local");
    let roaming = fixture.root.join("AppData").join("Roaming");
    fixture.write("AppData/Local/Example App/Cache/cache.bin", b"abc");
    fixture.write("AppData/Local/Example App/Code Cache/code.bin", b"de");
    fixture.write("AppData/Local/Example App/Local Storage/state.bin", b"keep");
    let applications =
        StaticApplicationDiscovery::new().with_installed_applications([app("example")]);
    let env = fixture
        .env
        .clone()
        .with_var("LOCALAPPDATA", local.as_os_str().to_os_string())
        .with_var("APPDATA", roaming.as_os_str().to_os_string())
        .with_var("USERPROFILE", fixture.root.as_os_str().to_os_string());
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun)
        .with_workflow(CleanupWorkflow::AppLeftovers);
    let cancellation = ScanCancellationToken::new();

    let plan = build_cleanup_plan_with_context(
        &request,
        &[],
        &env,
        &applications,
        PlanBuildContext::new(&cancellation),
        |_| {},
    )
    .unwrap();

    assert_eq!(plan.request.workflow, CleanupWorkflow::AppLeftovers);
    assert_eq!(plan.summary.total_targets, 2);
    assert_eq!(plan.summary.allowed_targets, 2);
    assert_eq!(plan.summary.estimated_bytes, 5);
    assert!(plan.targets.iter().all(|target| {
        target.status == TargetStatus::Allowed
            && target.rule_id == "windows.app-leftover-local-cache"
            && target.restore_hint.is_some()
            && !target
                .path
                .as_os_str()
                .to_string_lossy()
                .contains("Local Storage")
    }));
}

#[test]
fn app_leftover_plan_blocks_rebecca_owned_storage_overlap() {
    let fixture = PlannerFixture::new();
    let app_paths = app_paths_for_fixture(&fixture);
    fixture.write(
        "rebecca-cache/AppData/Local/Example App/Cache/cache.bin",
        b"abc",
    );
    let env = fixture
        .env
        .clone()
        .with_var(
            "LOCALAPPDATA",
            app_paths
                .cache_dir
                .join("AppData")
                .join("Local")
                .into_os_string(),
        )
        .with_var(
            "APPDATA",
            fixture
                .root
                .join("AppData")
                .join("Roaming")
                .into_os_string(),
        )
        .with_var("USERPROFILE", fixture.root.as_os_str().to_os_string());
    let applications =
        StaticApplicationDiscovery::new().with_installed_applications([app("example")]);
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun)
        .with_workflow(CleanupWorkflow::AppLeftovers);
    let storage_entries = app_paths.storage_entries();
    let cancellation = ScanCancellationToken::new();

    let plan = build_cleanup_plan_with_context(
        &request,
        &[],
        &env,
        &applications,
        PlanBuildContext::new(&cancellation).with_protected_storage(&storage_entries),
        |_| {},
    )
    .unwrap();

    assert!(plan.summary.total_targets > 0);
    assert_eq!(plan.summary.allowed_targets, 0);
    assert_eq!(plan.summary.blocked_targets, plan.summary.total_targets);
    assert!(plan.targets.iter().all(|target| {
        target.status == TargetStatus::Blocked
            && target.reason_code == Some(CleanupTargetIssueReason::SafetyPolicyBlocked)
            && target
                .reason
                .as_deref()
                .is_some_and(|reason| reason.contains("Rebecca-owned Cache dir"))
    }));
}

#[test]
fn app_leftover_plan_deduplicates_duplicate_discovery_entries() {
    let fixture = PlannerFixture::new();
    let local = fixture.root.join("AppData").join("Local");
    fixture.write("AppData/Local/Example App/Cache/cache.bin", b"abc");
    let applications = StaticApplicationDiscovery::new()
        .with_installed_applications([app("example-one"), app("example-two")]);
    let env = fixture
        .env
        .clone()
        .with_var("LOCALAPPDATA", local.as_os_str().to_os_string())
        .with_var(
            "APPDATA",
            fixture
                .root
                .join("AppData")
                .join("Roaming")
                .into_os_string(),
        )
        .with_var("USERPROFILE", fixture.root.as_os_str().to_os_string());
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun)
        .with_workflow(CleanupWorkflow::AppLeftovers);
    let cancellation = ScanCancellationToken::new();

    let plan = build_cleanup_plan_with_context(
        &request,
        &[],
        &env,
        &applications,
        PlanBuildContext::new(&cancellation),
        |_| {},
    )
    .unwrap();

    assert_eq!(plan.summary.total_targets, 2);
    assert_eq!(plan.summary.allowed_targets, 1);
    assert_eq!(plan.summary.skipped_targets, 1);
    assert_eq!(
        plan.targets
            .iter()
            .find(|target| target.status == TargetStatus::Skipped)
            .and_then(|target| target.reason_code),
        Some(CleanupTargetIssueReason::DuplicateTargetPath)
    );
}

#[test]
fn app_leftover_plan_uses_source_based_rule_ids_for_all_appdata_roots() {
    let fixture = PlannerFixture::new();
    let local = fixture.root.join("AppData").join("Local");
    let roaming = fixture.root.join("AppData").join("Roaming");
    let local_low = fixture.root.join("AppData").join("LocalLow");

    for relative in [
        "AppData/Local/WeChat/Cache/cache.bin",
        "AppData/Local/WeChat/Code Cache/code.bin",
        "AppData/Local/WeChat/GPUCache/gpu.bin",
        "AppData/Local/WeChat/CachedData/data.bin",
        "AppData/Roaming/WeChat/Cache/cache.bin",
        "AppData/Roaming/WeChat/Code Cache/code.bin",
        "AppData/Roaming/WeChat/GPUCache/gpu.bin",
        "AppData/Roaming/WeChat/CachedData/data.bin",
        "AppData/LocalLow/WeChat/Cache/cache.bin",
        "AppData/Local/WeChat/Local Storage/state.bin",
    ] {
        fixture.write(relative, b"abc");
    }

    let applications =
        StaticApplicationDiscovery::new().with_installed_applications([InstalledApplication::new(
            "hklm/wechat",
            "WeChat",
            Vec::new(),
        )]);
    let env = fixture
        .env
        .clone()
        .with_var("LOCALAPPDATA", local.as_os_str().to_os_string())
        .with_var("APPDATA", roaming.as_os_str().to_os_string())
        .with_var("USERPROFILE", fixture.root.as_os_str().to_os_string());
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun)
        .with_workflow(CleanupWorkflow::AppLeftovers);
    let cancellation = ScanCancellationToken::new();

    let plan = build_cleanup_plan_with_context(
        &request,
        &[],
        &env,
        &applications,
        PlanBuildContext::new(&cancellation),
        |_| {},
    )
    .unwrap();

    assert_eq!(plan.request.workflow, CleanupWorkflow::AppLeftovers);
    assert_eq!(plan.summary.total_targets, 9);
    assert_eq!(plan.summary.allowed_targets, 9);
    assert_eq!(plan.summary.estimated_bytes, 27);
    assert!(
        plan.targets
            .iter()
            .all(|target| target.restore_hint.is_some()
                && !target.path.to_string_lossy().contains("Local Storage"))
    );
    assert_eq!(
        plan.targets
            .iter()
            .filter(|target| target.rule_id == "windows.app-leftover-local-cache")
            .count(),
        4
    );
    assert_eq!(
        plan.targets
            .iter()
            .filter(|target| target.rule_id == "windows.app-leftover-roaming-cache")
            .count(),
        4
    );
    assert_eq!(
        plan.targets
            .iter()
            .filter(|target| target.rule_id == "windows.app-leftover-local-low-cache")
            .count(),
        1
    );
    assert!(
        plan.targets
            .iter()
            .any(|target| target.path.ends_with(local.join("WeChat").join("Cache")))
    );
    assert!(plan.targets.iter().any(|target| {
        target
            .path
            .ends_with(local_low.join("WeChat").join("Cache"))
    }));
}

#[test]
fn planner_blocks_protected_category_targets_before_sizing() {
    let fixture = PlannerFixture::new();
    let rules = vec![custom_exact_path_rule(
        "windows.custom-browser-history",
        PathBuf::from("C:/Users/Alice/AppData/Local/Google/Chrome/User Data/Default/History"),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.total_targets, 1);
    assert_eq!(plan.summary.allowed_targets, 0);
    assert_eq!(plan.summary.blocked_targets, 1);
    assert_eq!(plan.targets[0].status, TargetStatus::Blocked);
    assert_eq!(
        plan.targets[0].reason_code,
        Some(CleanupTargetIssueReason::SafetyPolicyBlocked)
    );
    assert!(
        plan.targets[0]
            .reason
            .as_deref()
            .unwrap()
            .contains("browser private data")
    );
}

#[test]
fn planner_reports_target_scan_progress() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/a.tmp", b"abc");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.user-temp".to_string()];

    let mut events = Vec::new();
    let plan =
        build_cleanup_plan_with_environment_and_progress(&request, &rules, &fixture.env, |event| {
            match event {
                PlanProgressEvent::TargetScanning { rule_id, path } => {
                    events.push(format!("scanning:{rule_id}:{}", path.display()));
                }
                PlanProgressEvent::TargetFinished {
                    rule_id,
                    status,
                    estimated_bytes,
                    ..
                } => {
                    events.push(format!("finished:{rule_id}:{status:?}:{estimated_bytes}"));
                }
                PlanProgressEvent::FileMeasured {
                    rule_id,
                    files_scanned,
                    bytes_scanned,
                    ..
                } => {
                    events.push(format!("file:{rule_id}:{files_scanned}:{bytes_scanned}"));
                }
                PlanProgressEvent::ScanCacheHit {
                    rule_id,
                    estimated_bytes,
                    ..
                } => {
                    events.push(format!("cache-hit:{rule_id}:{estimated_bytes}"));
                }
                PlanProgressEvent::ScanCacheMiss {
                    rule_id, reason, ..
                } => {
                    events.push(format!("cache-miss:{rule_id}:{}", reason.label()));
                }
                PlanProgressEvent::ScanCacheWriteSkipped { rule_id, .. } => {
                    events.push(format!("cache-write-skipped:{rule_id}"));
                }
                PlanProgressEvent::ScanCachePruned { report } => {
                    events.push(format!(
                        "cache-pruned:{}:{}:{}",
                        report.inspected, report.pruned, report.retained
                    ));
                }
            }
        })
        .unwrap();

    assert_eq!(plan.summary.allowed_targets, 1);
    assert!(
        events
            .iter()
            .any(|event| event.starts_with("scanning:windows.user-temp:"))
    );
    assert!(
        events
            .iter()
            .any(|event| event == "finished:windows.user-temp:Allowed:3")
    );
    assert!(
        events
            .iter()
            .any(|event| event == "finished:windows.user-temp:Skipped:0")
    );
    assert!(
        events
            .iter()
            .any(|event| event == "file:windows.user-temp:1:3")
    );
}

#[test]
fn planner_reports_scan_cache_prune_activity() {
    let fixture = PlannerFixture::new();
    let fresh_root = fixture.root.join("fresh-target");
    let stale_root = fixture.root.join("stale-target");
    fixture.write("fresh-target/file.bin", b"fresh");
    fixture.write("stale-target/file.bin", b"stale");
    let cache = ScanCacheStore::new(fixture.root.join("cache").join("scan"));
    let policy = ScanCachePolicy::new(1);
    let report = ScanReport {
        bytes_scanned: 5,
        files_scanned: 1,
        directories_scanned: 1,
    };
    cache.store(&fresh_root, report).unwrap();
    let mut stale_record = cache.store(&stale_root, report).unwrap();
    stale_record.written_at_unix_seconds = 0;
    fs::write(
        cache.cache_file_for(&stale_root),
        serde_json::to_vec_pretty(&stale_record).unwrap(),
    )
    .unwrap();

    let rules = vec![custom_exact_path_rule(
        "windows.fresh-target",
        fresh_root.clone(),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    let cancellation = ScanCancellationToken::new();
    let applications = NoopApplicationDiscovery::new();
    let mut prune_reports = Vec::new();

    let plan = build_cleanup_plan_with_context(
        &request,
        &rules,
        &fixture.env,
        &applications,
        PlanBuildContext::new(&cancellation)
            .with_scan_cache(&cache)
            .with_scan_cache_policy(policy),
        |event| {
            if let PlanProgressEvent::ScanCachePruned { report } = event {
                prune_reports.push(report);
            }
        },
    )
    .unwrap();

    assert_eq!(plan.summary.allowed_targets, 1);
    assert_eq!(
        prune_reports,
        [rebecca_core::scan_cache::ScanCachePruneReport {
            inspected: 2,
            pruned: 1,
            retained: 1,
        }]
    );
}

#[test]
fn planner_cancellation_stops_plan_build_during_file_scan() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/a.tmp", b"abc");
    fixture.write("temp/b.tmp", b"de");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.user-temp".to_string()];

    let cancellation = ScanCancellationToken::new();
    let progress_cancellation = cancellation.clone();
    let err = build_cleanup_plan_with_environment_and_progress_and_cancellation(
        &request,
        &rules,
        &fixture.env,
        &cancellation,
        |event| {
            if matches!(event, PlanProgressEvent::FileMeasured { .. }) {
                progress_cancellation.cancel();
            }
        },
    )
    .unwrap_err();

    assert!(matches!(err, RebeccaError::OperationCancelled(_)));
}

#[test]
fn planner_does_not_use_scan_cache_unless_context_enables_it() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/cached.tmp", b"abc");
    let path = fixture.root.join("temp").join("cached.tmp");
    let rules = vec![custom_exact_path_rule(
        "windows.custom-file-cache",
        path.clone(),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    let cache = ScanCacheStore::new(fixture.root.join("cache").join("scan"));
    cache
        .store(
            &path,
            ScanReport {
                bytes_scanned: 99,
                files_scanned: 1,
                directories_scanned: 0,
            },
        )
        .unwrap();

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.estimated_bytes, 3);
}

#[test]
fn planner_uses_scan_cache_when_context_enables_it_for_file_targets() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/cached.tmp", b"abc");
    let path = fixture.root.join("temp").join("cached.tmp");
    let rules = vec![custom_exact_path_rule(
        "windows.custom-file-cache",
        path.clone(),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    let cache = ScanCacheStore::new(fixture.root.join("cache").join("scan"));
    cache
        .store(
            &path,
            ScanReport {
                bytes_scanned: 99,
                files_scanned: 1,
                directories_scanned: 0,
            },
        )
        .unwrap();
    let cancellation = ScanCancellationToken::new();
    let applications = NoopApplicationDiscovery::new();
    let mut file_events = 0;
    let mut cache_events = Vec::new();

    let plan = build_cleanup_plan_with_context(
        &request,
        &rules,
        &fixture.env,
        &applications,
        PlanBuildContext::new(&cancellation).with_scan_cache(&cache),
        |event| match event {
            PlanProgressEvent::FileMeasured { .. } => {
                file_events += 1;
            }
            PlanProgressEvent::ScanCacheHit {
                rule_id,
                estimated_bytes,
                ..
            } => cache_events.push(format!("hit:{rule_id}:{estimated_bytes}")),
            PlanProgressEvent::ScanCacheMiss { reason, .. } => {
                cache_events.push(format!("miss:{}", reason.label()));
            }
            PlanProgressEvent::ScanCacheWriteSkipped { .. } => {
                cache_events.push("write-skipped".to_string());
            }
            _ => {}
        },
    )
    .unwrap();

    assert_eq!(plan.summary.estimated_bytes, 99);
    assert_eq!(file_events, 0);
    assert_eq!(cache_events, ["hit:windows.custom-file-cache:99"]);
}

#[test]
fn planner_writes_scan_cache_when_context_enables_it_for_file_targets() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/cached.tmp", b"abc");
    let path = fixture.root.join("temp").join("cached.tmp");
    let rules = vec![custom_exact_path_rule(
        "windows.custom-file-cache",
        path.clone(),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    let cache = ScanCacheStore::new(fixture.root.join("cache").join("scan"));
    let cancellation = ScanCancellationToken::new();
    let applications = NoopApplicationDiscovery::new();
    let mut cache_events = Vec::new();

    let plan = build_cleanup_plan_with_context(
        &request,
        &rules,
        &fixture.env,
        &applications,
        PlanBuildContext::new(&cancellation).with_scan_cache(&cache),
        |event| match event {
            PlanProgressEvent::ScanCacheHit { .. } => {
                cache_events.push("hit".to_string());
            }
            PlanProgressEvent::ScanCacheMiss { reason, .. } => {
                cache_events.push(format!("miss:{}", reason.label()));
            }
            PlanProgressEvent::ScanCacheWriteSkipped { .. } => {
                cache_events.push("write-skipped".to_string());
            }
            _ => {}
        },
    )
    .unwrap();

    assert_eq!(plan.summary.estimated_bytes, 3);
    assert_eq!(cache_events, ["miss:missing"]);
    assert_eq!(
        cache.load(&path),
        ScanCacheLookup::Hit(ScanReport {
            bytes_scanned: 3,
            files_scanned: 1,
            directories_scanned: 0,
        })
    );
}

#[test]
fn planner_uses_scan_cache_when_context_enables_it_for_directory_targets() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/cached/a.tmp", b"abc");
    let path = fixture.root.join("temp").join("cached");
    let rules = vec![custom_exact_path_rule(
        "windows.custom-dir-cache",
        path.clone(),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    let cache = ScanCacheStore::new(fixture.root.join("cache").join("scan"));
    cache
        .store(
            &path,
            ScanReport {
                bytes_scanned: 99,
                files_scanned: 1,
                directories_scanned: 1,
            },
        )
        .unwrap();
    let cancellation = ScanCancellationToken::new();
    let applications = NoopApplicationDiscovery::new();
    let mut file_events = 0;
    let mut cache_events = Vec::new();

    let plan = build_cleanup_plan_with_context(
        &request,
        &rules,
        &fixture.env,
        &applications,
        PlanBuildContext::new(&cancellation).with_scan_cache(&cache),
        |event| match event {
            PlanProgressEvent::FileMeasured { .. } => {
                file_events += 1;
            }
            PlanProgressEvent::ScanCacheHit {
                rule_id,
                estimated_bytes,
                ..
            } => cache_events.push(format!("hit:{rule_id}:{estimated_bytes}")),
            PlanProgressEvent::ScanCacheMiss { reason, .. } => {
                cache_events.push(format!("miss:{}", reason.label()));
            }
            PlanProgressEvent::ScanCacheWriteSkipped { .. } => {
                cache_events.push("write-skipped".to_string());
            }
            _ => {}
        },
    )
    .unwrap();

    assert_eq!(plan.summary.estimated_bytes, 99);
    assert_eq!(file_events, 0);
    assert_eq!(cache_events, ["hit:windows.custom-dir-cache:99"]);
}

#[test]
fn planner_rebuilds_expired_scan_cache_for_directory_targets_with_policy() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/cached/a.tmp", b"abc");
    let path = fixture.root.join("temp").join("cached");
    let rules = vec![custom_exact_path_rule(
        "windows.custom-dir-cache",
        path.clone(),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    let cache = ScanCacheStore::new(fixture.root.join("cache").join("scan"));
    let policy = ScanCachePolicy::new(1);
    let mut record = cache
        .store(
            &path,
            ScanReport {
                bytes_scanned: 99,
                files_scanned: 1,
                directories_scanned: 1,
            },
        )
        .unwrap();
    record.written_at_unix_seconds = 0;
    fs::write(
        cache.cache_file_for(&path),
        serde_json::to_vec_pretty(&record).unwrap(),
    )
    .unwrap();
    let cancellation = ScanCancellationToken::new();
    let applications = NoopApplicationDiscovery::new();
    let mut file_events = 0;
    let mut cache_events = Vec::new();

    let plan = build_cleanup_plan_with_context(
        &request,
        &rules,
        &fixture.env,
        &applications,
        PlanBuildContext::new(&cancellation)
            .with_scan_cache(&cache)
            .with_scan_cache_policy(policy),
        |event| match event {
            PlanProgressEvent::FileMeasured { .. } => {
                file_events += 1;
            }
            PlanProgressEvent::ScanCacheHit {
                rule_id,
                estimated_bytes,
                ..
            } => cache_events.push(format!("hit:{rule_id}:{estimated_bytes}")),
            PlanProgressEvent::ScanCacheMiss { reason, .. } => {
                cache_events.push(format!("miss:{}", reason.label()));
            }
            PlanProgressEvent::ScanCacheWriteSkipped { .. } => {
                cache_events.push("write-skipped".to_string());
            }
            _ => {}
        },
    )
    .unwrap();

    assert_eq!(plan.summary.estimated_bytes, 3);
    assert_eq!(file_events, 1);
    assert_eq!(cache_events, ["miss:expired"]);
    assert_eq!(
        cache.load(&path),
        ScanCacheLookup::Hit(ScanReport {
            bytes_scanned: 3,
            files_scanned: 1,
            directories_scanned: 1,
        })
    );
}

#[test]
fn planner_prunes_stale_scan_cache_records_before_plan_building() {
    let fixture = PlannerFixture::new();
    let fresh_root = fixture.root.join("fresh-target");
    let stale_root = fixture.root.join("stale-target");
    fixture.write("fresh-target/file.bin", b"fresh");
    fixture.write("stale-target/file.bin", b"stale");
    let cache = ScanCacheStore::new(fixture.root.join("cache").join("scan"));
    let policy = ScanCachePolicy::new(1);
    let report = ScanReport {
        bytes_scanned: 5,
        files_scanned: 1,
        directories_scanned: 1,
    };
    cache.store(&fresh_root, report).unwrap();
    let mut stale_record = cache.store(&stale_root, report).unwrap();
    stale_record.written_at_unix_seconds = 0;
    fs::write(
        cache.cache_file_for(&stale_root),
        serde_json::to_vec_pretty(&stale_record).unwrap(),
    )
    .unwrap();

    let rules = vec![custom_exact_path_rule(
        "windows.fresh-target",
        fresh_root.clone(),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    let cancellation = ScanCancellationToken::new();
    let applications = NoopApplicationDiscovery::new();

    let plan = build_cleanup_plan_with_context(
        &request,
        &rules,
        &fixture.env,
        &applications,
        PlanBuildContext::new(&cancellation)
            .with_scan_cache(&cache)
            .with_scan_cache_policy(policy),
        |_| {},
    )
    .unwrap();

    assert_eq!(plan.summary.allowed_targets, 1);
    assert!(cache.cache_file_for(&fresh_root).exists());
    assert!(!cache.cache_file_for(&stale_root).exists());
}

#[test]
fn planner_treats_scan_cache_write_failure_as_soft() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/cached.tmp", b"abc");
    fixture.write("cache-file", b"not a directory");
    let path = fixture.root.join("temp").join("cached.tmp");
    let rules = vec![custom_exact_path_rule(
        "windows.custom-file-cache",
        path.clone(),
    )];
    let request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    let cache = ScanCacheStore::new(fixture.root.join("cache-file"));
    let cancellation = ScanCancellationToken::new();
    let applications = NoopApplicationDiscovery::new();
    let mut cache_events = Vec::new();

    let plan = build_cleanup_plan_with_context(
        &request,
        &rules,
        &fixture.env,
        &applications,
        PlanBuildContext::new(&cancellation).with_scan_cache(&cache),
        |event| match event {
            PlanProgressEvent::ScanCacheHit { .. } => {
                cache_events.push("hit".to_string());
            }
            PlanProgressEvent::ScanCacheMiss { reason, .. } => {
                cache_events.push(format!("miss:{}", reason.label()));
            }
            PlanProgressEvent::ScanCacheWriteSkipped { .. } => {
                cache_events.push("write-skipped".to_string());
            }
            _ => {}
        },
    )
    .unwrap();

    assert_eq!(plan.summary.estimated_bytes, 3);
    assert_eq!(cache_events, ["miss:missing", "write-skipped"]);
}

#[test]
fn glob_rules_expand_profile_and_file_patterns() {
    let fixture = PlannerFixture::new();
    fixture.write("roaming/Mozilla/Firefox/Profiles/alice/cache2/a.bin", b"a");
    fixture.write(
        "roaming/Mozilla/Firefox/Profiles/alice/startupCache/startup.bin",
        b"bc",
    );
    fixture.write("roaming/Mozilla/Firefox/Profiles/bob/cache2/b.bin", b"def");
    fixture.write(
        "local/Microsoft/Windows/Explorer/thumbcache_96.db",
        b"thumb",
    );
    fixture.write("local/Microsoft/Windows/Explorer/iconcache_32.db", b"icon");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec![
        "windows.firefox-profile-cache".to_string(),
        "windows.thumbnail-cache".to_string(),
    ];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 5);
    assert_eq!(plan.summary.estimated_bytes, 15);
    assert!(
        plan.targets
            .iter()
            .any(|target| target.path.ends_with("alice/cache2"))
    );
    assert!(
        plan.targets
            .iter()
            .any(|target| target.path.ends_with("bob/cache2"))
    );
    assert!(
        plan.targets
            .iter()
            .any(|target| target.path.ends_with("thumbcache_96.db"))
    );
}

#[test]
fn jetbrains_rules_expand_product_directories() {
    let fixture = PlannerFixture::new();
    fixture.write("local/JetBrains/IntelliJIdea2026.1/caches/index.bin", b"ab");
    fixture.write("local/JetBrains/Rider2025.3/caches/index.bin", b"cde");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.jetbrains-cache".to_string()];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 2);
    assert_eq!(plan.summary.skipped_targets, 7);
    assert_eq!(plan.summary.estimated_bytes, 5);
    assert!(plan.targets.iter().any(|target| {
        target
            .path
            .ends_with(Path::new("IntelliJIdea2026.1").join("caches"))
    }));
    assert!(plan.targets.iter().any(|target| {
        target
            .path
            .ends_with(Path::new("Rider2025.3").join("caches"))
    }));
}

#[test]
fn cargo_rule_targets_default_cargo_home_cache_directories() {
    let fixture = PlannerFixture::new();
    fixture.write("user/.cargo/registry/cache/index.crate", b"ab");
    fixture.write("user/.cargo/registry/index/index/.cache", b"xyz");
    fixture.write("user/.cargo/registry/src/package/lib.rs", b"cde");
    fixture.write("user/.cargo/git/db/repo/HEAD", b"fghi");
    fixture.write("user/.cargo/git/checkouts/repo/main.rs", b"jklmn");
    fixture.write("user/.cargo/bin/tool.exe", b"do not target binaries");
    fixture.write("user/.cargo/credentials.toml", b"do not target credentials");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.cargo-cache".to_string()];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();
    let allowed_paths = plan
        .targets
        .iter()
        .filter(|target| target.status == TargetStatus::Allowed)
        .map(|target| target.path.clone())
        .collect::<Vec<_>>();

    assert_eq!(plan.summary.allowed_targets, 5);
    assert_eq!(plan.summary.skipped_targets, 5);
    assert_eq!(plan.summary.estimated_bytes, 17);
    assert!(allowed_paths.iter().all(|path| {
        path.ends_with(Path::new("registry").join("cache"))
            || path.ends_with(Path::new("registry").join("index"))
            || path.ends_with(Path::new("registry").join("src"))
            || path.ends_with(Path::new("git").join("db"))
            || path.ends_with(Path::new("git").join("checkouts"))
    }));
}

#[test]
fn cargo_rule_targets_custom_cargo_home_cache_directories() {
    let fixture = PlannerFixture::with_cargo_home();
    fixture.write("cargo-home/registry/cache/index.crate", b"ab");
    fixture.write("cargo-home/registry/index/index/.cache", b"xyz");
    fixture.write("cargo-home/registry/src/package/lib.rs", b"cde");
    fixture.write("cargo-home/git/db/repo/HEAD", b"fghi");
    fixture.write("cargo-home/git/checkouts/repo/main.rs", b"jklmn");
    fixture.write("cargo-home/bin/tool.exe", b"do not target binaries");
    fixture.write("cargo-home/credentials.toml", b"do not target credentials");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.cargo-cache".to_string()];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 5);
    assert_eq!(plan.summary.skipped_targets, 5);
    assert_eq!(plan.summary.estimated_bytes, 17);
    assert!(
        plan.targets
            .iter()
            .filter(|target| target.status == TargetStatus::Allowed)
            .all(|target| target.path.starts_with(fixture.root.join("cargo-home")))
    );
}

#[test]
fn ccache_rule_targets_default_and_configured_cache_roots() {
    let fixture = PlannerFixture::with_ccache_dir();
    fixture.write("user/.ccache/0/a/default.bin", b"abc");
    fixture.write("user/.ccache/tmp/staging.tmp", b"tmp");
    fixture.write("local/ccache/1/b/local.bin", b"de");
    fixture.write("local/ccache/tmp/staging.tmp", b"tmp");
    fixture.write("roaming/ccache/2/c/roaming.bin", b"f");
    fixture.write("roaming/ccache/tmp/staging.tmp", b"tmp");
    fixture.write("ccache-dir/3/d/custom.bin", b"gh");
    fixture.write("ccache-dir/tmp/staging.tmp", b"tmp");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.ccache-cache".to_string()];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 8);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 20);
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy().replace('\\', "/");
        path.ends_with("/user/.ccache/0/a")
            || path.ends_with("/user/.ccache/tmp")
            || path.ends_with("/local/ccache/1/b")
            || path.ends_with("/local/ccache/tmp")
            || path.ends_with("/roaming/ccache/2/c")
            || path.ends_with("/roaming/ccache/tmp")
            || path.ends_with("/ccache-dir/3/d")
            || path.ends_with("/ccache-dir/tmp")
    }));
}

#[test]
fn conda_rule_targets_package_caches_only() {
    let fixture = PlannerFixture::new();
    fixture.write("user/.conda/pkgs/pkg1/data.bin", b"aa");
    fixture.write("user/anaconda3/pkgs/pkg2/data.bin", b"bbb");
    fixture.write("user/miniconda3/pkgs/pkg3/data.bin", b"cccc");
    fixture.write("user/miniforge3/pkgs/pkg4/data.bin", b"ddddd");
    fixture.write("user/mambaforge/pkgs/pkg5/data.bin", b"eeeeee");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.conda-cache".to_string()];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 5);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 20);
    assert!(plan.targets.iter().all(|target| {
        target
            .path
            .to_string_lossy()
            .replace('\\', "/")
            .ends_with("/pkgs")
    }));
}

#[test]
fn rustup_rule_targets_download_and_temp_caches_only() {
    let fixture = PlannerFixture::with_rustup_home();
    fixture.write("user/.rustup/downloads/stable.tar.xz", b"download");
    fixture.write("user/.rustup/tmp/install.tmp", b"tmp");
    fixture.write("rustup-home/downloads/nightly.tar.xz", b"custom");
    fixture.write("rustup-home/tmp/staging.tmp", b"stage");
    fixture.write("user/.rustup/toolchains/stable/bin/rustc.exe", b"keep");
    fixture.write("user/.rustup/settings.toml", b"keep");
    fixture.write("rustup-home/toolchains/nightly/bin/rustc.exe", b"keep");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.rustup-cache".to_string()];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 4);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 22);
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy().replace('\\', "/");
        !path.contains("/toolchains/") && !path.ends_with("/settings.toml")
    }));
}

#[test]
fn sccache_rule_targets_default_and_configured_cache_roots() {
    let fixture = PlannerFixture::with_sccache_dir();
    fixture.write("local/Mozilla/sccache/default.bin", b"abc");
    fixture.write("sccache-dir/custom.bin", b"de");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.sccache-cache".to_string()];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 2);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 5);
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy().replace('\\', "/");
        path.ends_with("/Mozilla/sccache") || path.ends_with("/sccache-dir")
    }));
}

#[test]
fn node_package_manager_rules_target_rebuildable_caches() {
    let fixture = PlannerFixture::new();
    fixture.write("local/npm-cache/_cacache/index.bin", b"npm");
    fixture.write("roaming/npm-cache/_cacache/legacy.bin", b"npm2");
    fixture.write("local/pnpm/store/v3/files/pkg", b"pnpm");
    fixture.write("local/Yarn/Cache/pkg.tgz", b"yarn");
    fixture.write("user/.bun/install/cache/pkg", b"bun");
    fixture.write("local/node/corepack/shim.json", b"corepack");
    fixture.write("user/.bun/bin/bun.exe", b"keep");
    fixture.write("local/Yarn/Data/global/package.json", b"keep");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec![
        "windows.npm-cache".to_string(),
        "windows.pnpm-cache".to_string(),
        "windows.yarn-cache".to_string(),
        "windows.bun-cache".to_string(),
        "windows.corepack-cache".to_string(),
    ];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 6);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 26);
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy();
        !path.contains(".bun/bin") && !path.contains("Yarn/Data")
    }));
}

#[test]
fn nuget_rule_targets_package_caches_only() {
    let fixture = PlannerFixture::new();
    fixture.write("user/.nuget/packages/example/lib.dll", b"pkg");
    fixture.write("local/NuGet/v3-cache/index.dat", b"v3");
    fixture.write("local/NuGet/plugins-cache/provider.dat", b"plug");
    fixture.write("local/NuGet/Cache/package.nupkg", b"http");
    fixture.write("roaming/NuGet/NuGet.Config", b"keep");
    fixture.write("user/.nuget/NuGet/NuGet.Config", b"keep");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.nuget-cache".to_string()];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 4);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 13);
    assert!(plan.targets.iter().all(|target| {
        target
            .path
            .file_name()
            .is_none_or(|name| name != "NuGet.Config")
    }));
}

#[test]
fn gradle_and_maven_rules_target_rebuildable_caches() {
    let fixture = PlannerFixture::new();
    fixture.write(
        "user/.gradle/caches/modules-2/files-2.1/example/lib.jar",
        b"gradle",
    );
    fixture.write("user/.gradle/notifications/notice.bin", b"notify");
    fixture.write(
        "user/.gradle/wrapper/dists/gradle-8.7-bin/gradle.zip",
        b"keep",
    );
    fixture.write(
        "user/.m2/repository/com/example/demo/1.0/demo.jar",
        b"maven",
    );
    fixture.write("user/.m2/settings.xml", b"keep");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec![
        "windows.gradle-cache".to_string(),
        "windows.maven-cache".to_string(),
    ];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 3);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 17);
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy();
        !path.contains("wrapper") && !path.ends_with("settings.xml")
    }));
}

#[test]
fn android_rule_targets_build_and_ide_caches_only() {
    let fixture = PlannerFixture::new();
    fixture.write("user/.android/cache/build.bin", b"cache");
    fixture.write("user/.android/build-cache/task.bin", b"build");
    fixture.write("android-home/cache/alt.bin", b"alt");
    fixture.write("android-home/build-cache/alt.bin", b"alt2");
    fixture.write("legacy-android-home/.android/cache/legacy.bin", b"old");
    fixture.write(
        "legacy-android-home/.android/build-cache/legacy.bin",
        b"old2",
    );
    fixture.write("local/Google/AndroidStudio2024.2/caches/index.bin", b"ide");
    fixture.write("user/.android/avd/Pixel.avd/userdata-qemu.img", b"keep");
    fixture.write("user/.android/adbkey", b"keep");
    fixture.write("user/.android/debug.keystore", b"keep");
    fixture.write(
        "user/Android/Sdk/system-images/android-35/google_apis/x86_64.img",
        b"keep",
    );
    fixture.write("user/Android/Sdk/platform-tools/adb.exe", b"keep");
    fixture.write(
        "local/Google/AndroidStudio2024.2/options/options.xml",
        b"keep",
    );
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.android-cache".to_string()];
    request.allow_moderate = true;

    let env = fixture
        .env
        .clone()
        .with_var(
            "ANDROID_USER_HOME",
            fixture.root.join("android-home").into_os_string(),
        )
        .with_var(
            "ANDROID_SDK_HOME",
            fixture.root.join("legacy-android-home").into_os_string(),
        );

    let plan = build_cleanup_plan_with_environment(&request, &rules, &env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 7);
    assert_eq!(plan.summary.estimated_bytes, 27);
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy().replace('\\', "/");
        !path.contains("/avd/")
            && !path.ends_with("/adbkey")
            && !path.ends_with("/debug.keystore")
            && !path.contains("/Android/Sdk/")
            && !path.contains("/options/")
    }));
}

#[test]
fn python_package_manager_rules_target_rebuildable_caches() {
    let fixture = PlannerFixture::new();
    fixture.write("local/pip/Cache/http/package.whl", b"pip");
    fixture.write("local/uv/cache/archive/package", b"uv");
    fixture.write(
        "local/pypoetry/Cache/cache/repositories/pypi/index",
        b"poetry",
    );
    fixture.write(
        "local/pypoetry/Cache/artifacts/aa/bb/package.whl",
        b"artifact",
    );
    fixture.write(
        "local/pypoetry/Cache/virtualenvs/project/Scripts/python.exe",
        b"keep",
    );
    fixture.write("local/pypoetry/config.toml", b"keep");
    fixture.write("local/uv/tools/tool/python.exe", b"keep");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec![
        "windows.pip-cache".to_string(),
        "windows.uv-cache".to_string(),
        "windows.poetry-cache".to_string(),
    ];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 4);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 19);
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy();
        !path.contains("virtualenvs") && !path.contains("config.toml") && !path.contains("uv/tools")
    }));
}

#[test]
fn huggingface_and_torch_rules_target_rebuildable_model_caches() {
    let fixture = PlannerFixture::new();
    fixture.write("user/.cache/huggingface/hub/models.bin", b"hub");
    fixture.write("user/.cache/huggingface/datasets/dataset.arrow", b"data");
    fixture.write("user/.cache/huggingface/assets/asset.bin", b"asset");
    fixture.write("user/.cache/huggingface/xet/xet.bin", b"xet");
    fixture.write("hf-hub-cache/model.bin", b"hh");
    fixture.write("hf-datasets-cache/dataset.arrow", b"hd");
    fixture.write("hf-assets-cache/asset.bin", b"ha");
    fixture.write("hf-xet-cache/xet.bin", b"hx");
    fixture.write("legacy-hf-hub-cache/legacy.bin", b"lh");
    fixture.write("legacy-hf-assets-cache/legacy.bin", b"la");
    fixture.write("hf-home/hub/alt-model.bin", b"alt");
    fixture.write("hf-home/datasets/alt-dataset.arrow", b"alt2");
    fixture.write("hf-home/assets/alt-asset.bin", b"alt3");
    fixture.write("hf-home/xet/alt-xet.bin", b"alt4");
    fixture.write("user/.cache/torch/hub/checkpoint.bin", b"torch");
    fixture.write("torch-home/hub/other.bin", b"torch2");
    fixture.write("user/.cache/torch/hub/checkpoints/checkpoint.bin", b"ck");
    fixture.write("torch-home/hub/checkpoints/other.bin", b"ck2");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec![
        "windows.huggingface-cache".to_string(),
        "windows.pytorch-cache".to_string(),
    ];
    request.allow_moderate = true;

    let env = fixture
        .env
        .clone()
        .with_var("HF_HOME", fixture.root.join("hf-home").into_os_string())
        .with_var(
            "HF_HUB_CACHE",
            fixture.root.join("hf-hub-cache").into_os_string(),
        )
        .with_var(
            "HF_DATASETS_CACHE",
            fixture.root.join("hf-datasets-cache").into_os_string(),
        )
        .with_var(
            "HF_ASSETS_CACHE",
            fixture.root.join("hf-assets-cache").into_os_string(),
        )
        .with_var(
            "HF_XET_CACHE",
            fixture.root.join("hf-xet-cache").into_os_string(),
        )
        .with_var(
            "HUGGINGFACE_HUB_CACHE",
            fixture.root.join("legacy-hf-hub-cache").into_os_string(),
        )
        .with_var(
            "HUGGINGFACE_ASSETS_CACHE",
            fixture.root.join("legacy-hf-assets-cache").into_os_string(),
        )
        .with_var(
            "TORCH_HOME",
            fixture.root.join("torch-home").into_os_string(),
        );

    let plan = build_cleanup_plan_with_environment(&request, &rules, &env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 16);
    assert_eq!(plan.summary.estimated_bytes, 58);
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy().replace('\\', "/");
        !path.contains("virtualenvs")
            && !path.contains("/config.toml")
            && !path.contains("/tools/")
            && !path.contains("/models/")
    }));
}

#[test]
fn go_rules_target_build_and_module_caches_only() {
    let fixture = PlannerFixture::new();
    fixture.write("local/go-build/action/cache.a", b"build");
    fixture.write("user/go/pkg/mod/cache/download/example.zip", b"mod");
    fixture.write(
        "user/go/pkg/mod/github.com/example/pkg@v1.0.0/file.go",
        b"src",
    );
    fixture.write("user/go/bin/tool.exe", b"keep");
    fixture.write("user/go/src/app/main.go", b"keep");
    fixture.write("user/go/pkg/windows_amd64/example.a", b"keep");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec![
        "windows.go-build-cache".to_string(),
        "windows.go-module-cache".to_string(),
    ];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 2);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 11);
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy().replace('\\', "/");
        !path.contains("/go/bin/")
            && !path.contains("/go/src/")
            && !path.contains("/go/pkg/windows_amd64/")
    }));
}

#[test]
fn discord_rule_targets_only_browser_cache_directories() {
    let fixture = PlannerFixture::new();
    fixture.write("roaming/discord/Cache/cache.bin", b"ab");
    fixture.write("roaming/discord/Code Cache/code.bin", b"cde");
    fixture.write("roaming/discord/GPUCache/gpu.bin", b"fghi");
    fixture.write("roaming/discordptb/Cache/cache.bin", b"jklmn");
    fixture.write("roaming/discordptb/Code Cache/code.bin", b"opqrst");
    fixture.write("roaming/discordptb/GPUCache/gpu.bin", b"uvwxyz1");
    fixture.write("roaming/discord/Local Storage/leveldb/LOG", b"keep");
    fixture.write("roaming/discord/IndexedDB/indexeddb.leveldb/LOG", b"keep");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.discord-cache".to_string()];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 6);
    assert_eq!(plan.summary.skipped_targets, 3);
    assert_eq!(plan.summary.estimated_bytes, 27);
    assert!(plan.targets.iter().all(|target| {
        target.path.ends_with(Path::new("Cache"))
            || target.path.ends_with(Path::new("Code Cache"))
            || target.path.ends_with(Path::new("GPUCache"))
    }));
    assert!(plan.targets.iter().all(|target| {
        !target.path.to_string_lossy().contains("Local Storage")
            && !target.path.to_string_lossy().contains("IndexedDB")
    }));
}

#[test]
fn slack_rule_targets_only_electron_cache_directories() {
    let fixture = PlannerFixture::new();
    fixture.write("roaming/Slack/Cache/cache.bin", b"ab");
    fixture.write("roaming/Slack/Code Cache/code.bin", b"cde");
    fixture.write("roaming/Slack/GPUCache/gpu.bin", b"fghi");
    fixture.write("roaming/Slack/Local Storage/leveldb/LOG", b"keep");
    fixture.write("roaming/Slack/IndexedDB/indexeddb.leveldb/LOG", b"keep");
    fixture.write(
        "roaming/Slack/Service Worker/CacheStorage/index.bin",
        b"keep",
    );
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.slack-cache".to_string()];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 3);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 9);
    assert!(plan.targets.iter().all(|target| {
        target.path.ends_with(Path::new("Cache"))
            || target.path.ends_with(Path::new("Code Cache"))
            || target.path.ends_with(Path::new("GPUCache"))
    }));
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy();
        !path.contains("Local Storage")
            && !path.contains("IndexedDB")
            && !path.contains("Service Worker")
    }));
}

#[test]
fn electron_app_rules_target_only_cache_directories() {
    let fixture = PlannerFixture::new();
    for app in ["Postman", "Notion", "Figma"] {
        fixture.write(format!("roaming/{app}/Cache/cache.bin"), b"ab");
        fixture.write(format!("roaming/{app}/Code Cache/code.bin"), b"cde");
        fixture.write(format!("roaming/{app}/GPUCache/gpu.bin"), b"fghi");
        fixture.write(format!("roaming/{app}/Local Storage/leveldb/LOG"), b"keep");
        fixture.write(
            format!("roaming/{app}/IndexedDB/indexeddb.leveldb/LOG"),
            b"keep",
        );
        fixture.write(
            format!("roaming/{app}/Service Worker/CacheStorage/index.bin"),
            b"keep",
        );
    }
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec![
        "windows.postman-cache".to_string(),
        "windows.notion-cache".to_string(),
        "windows.figma-cache".to_string(),
    ];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 9);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 27);
    assert!(plan.targets.iter().all(|target| {
        target.path.ends_with(Path::new("Cache"))
            || target.path.ends_with(Path::new("Code Cache"))
            || target.path.ends_with(Path::new("GPUCache"))
    }));
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy();
        !path.contains("Local Storage")
            && !path.contains("IndexedDB")
            && !path.contains("Service Worker")
    }));
}

#[test]
fn steam_rule_targets_only_client_browser_cache_directories() {
    let fixture = PlannerFixture::new();
    fixture.write("local/Steam/htmlcache/Default/Cache/cache.bin", b"ab");
    fixture.write("local/Steam/htmlcache/Default/Code Cache/code.bin", b"cde");
    fixture.write("local/Steam/htmlcache/Default/GPUCache/gpu.bin", b"fghi");
    fixture.write(
        "local/Steam/htmlcache/Default/Service Worker/CacheStorage/index.bin",
        b"keep",
    );
    fixture.write(
        "local/Steam/htmlcache/Default/Network/Cache/index.bin",
        b"keep",
    );
    fixture.write(
        "local/Steam/htmlcache/Default/Local Storage/leveldb/LOG",
        b"keep",
    );
    fixture.write(
        "local/Steam/htmlcache/Default/IndexedDB/indexeddb.leveldb/LOG",
        b"keep",
    );
    fixture.write("local/Steam/userdata/account/config.vdf", b"keep");
    fixture.write("local/Steam/steamapps/common/Game/game.exe", b"keep");
    fixture.write("local/Steam/steamapps/downloading/app/file.bin", b"keep");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.steam-cache".to_string()];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 3);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 9);
    assert!(plan.targets.iter().all(|target| {
        target.path.ends_with(Path::new("Cache"))
            || target.path.ends_with(Path::new("Code Cache"))
            || target.path.ends_with(Path::new("GPUCache"))
    }));
    assert!(plan.targets.iter().all(|target| {
        let path = target.path.to_string_lossy();
        !path.contains("Local Storage")
            && !path.contains("Service Worker")
            && !path.contains("Network")
            && !path.contains("IndexedDB")
            && !path.contains("userdata")
            && !path.contains("steamapps")
    }));
}

fn steam_plan(
    fixture: &PlannerFixture,
    rule_id: &str,
    allow_moderate: bool,
    library_paths: Vec<PathBuf>,
) -> CleanupPlan {
    let applications =
        StaticApplicationDiscovery::new().with_steam_installation(SteamInstallation::new(
            fixture.root.join(common::steam::STEAM_INSTALL_FIXTURE_ROOT),
            library_paths,
        ));
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec![rule_id.to_string()];
    request.allow_moderate = allow_moderate;

    build_cleanup_plan_with_environment_and_applications(
        &request,
        &rules,
        &fixture.env,
        &applications,
    )
    .unwrap()
}

#[test]
fn steam_install_rule_expands_from_application_discovery() {
    for case in common::steam::STEAM_INSTALL_RULE_CASES {
        let fixture = PlannerFixture::new();
        let install_path = fixture.root.join(common::steam::STEAM_INSTALL_FIXTURE_ROOT);
        case.write_fixture(&install_path);
        let plan = steam_plan(&fixture, case.rule_id, case.allow_moderate, Vec::new());

        assert_eq!(plan.summary.allowed_targets, 1, "{}", case.rule_id);
        assert_eq!(plan.summary.skipped_targets, 0, "{}", case.rule_id);
        assert_eq!(
            plan.summary.estimated_bytes,
            case.bytes.len() as u64,
            "{}",
            case.rule_id
        );
        assert_eq!(
            plan.targets[0].path,
            case.target_path(&install_path),
            "{}",
            case.rule_id
        );
        assert_eq!(
            plan.targets[0].restore_hint.as_deref(),
            case.expected_restore_hint,
            "{}",
            case.rule_id
        );
    }
}

#[test]
fn steam_rules_skip_without_application_discovery() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/a.tmp", b"abc");
    fixture.write("local/Temp/b.tmp", b"de");
    fixture.write(
        "local/Microsoft/Windows/Explorer/thumbcache_96.db",
        b"thumb",
    );
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec![
        "windows.user-temp".to_string(),
        "windows.steam-install-library-cache".to_string(),
    ];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 2);
    assert_eq!(plan.summary.skipped_targets, 1);
    assert_eq!(plan.summary.estimated_bytes, 5);
    assert!(
        plan.targets
            .iter()
            .any(|target| target.rule_id == "windows.user-temp")
    );
    assert!(
        plan.targets
            .iter()
            .any(|target| target.rule_id == "windows.steam-install-library-cache")
    );
    assert!(plan.targets.iter().any(|target| {
        target.rule_id == "windows.steam-install-library-cache"
            && target
                .reason
                .as_deref()
                .is_some_and(|reason| reason.contains("Steam installation was not discovered"))
    }));
    assert!(
        plan.targets
            .iter()
            .any(|target| target.status == TargetStatus::Skipped)
    );
}

#[test]
fn steam_library_rule_expands_from_application_discovery() {
    for case in common::steam::STEAM_LIBRARY_RULE_CASES {
        let fixture = PlannerFixture::new();
        let install_path = fixture.root.join(common::steam::STEAM_INSTALL_FIXTURE_ROOT);
        let library_path = fixture.root.join(common::steam::STEAM_LIBRARY_FIXTURE_ROOT);
        case.write_fixture(&install_path);
        case.write_fixture(&library_path);
        let plan = steam_plan(
            &fixture,
            case.rule_id,
            case.allow_moderate,
            vec![library_path.clone()],
        );

        assert_eq!(plan.summary.allowed_targets, 2, "{}", case.rule_id);
        assert_eq!(plan.summary.skipped_targets, 0, "{}", case.rule_id);
        assert_eq!(
            plan.summary.estimated_bytes,
            (case.bytes.len() * 2) as u64,
            "{}",
            case.rule_id
        );
        assert!(
            plan.targets
                .iter()
                .any(|target| { target.path.ends_with(install_path.join(case.relative_path)) })
        );
        assert!(
            plan.targets
                .iter()
                .any(|target| { target.path.ends_with(library_path.join(case.relative_path)) })
        );
        assert!(
            plan.targets
                .iter()
                .all(|target| { target.restore_hint.as_deref() == case.expected_restore_hint })
        );
    }
}

#[test]
fn planner_expands_steam_library_targets_from_application_discovery() {
    let fixture = PlannerFixture::new();
    let install_path = fixture.root.join("steam-install");
    let library_path = fixture.root.join("steam-library");
    fixture.write("steam-install/steamapps/shadercache/100/cache.bin", b"ab");
    fixture.write("steam-library/steamapps/shadercache/200/cache.bin", b"cde");
    fixture.write("steam-library/steamapps/common/Game/game.exe", b"keep");
    let applications = StaticApplicationDiscovery::new().with_steam_installation(
        SteamInstallation::new(install_path.clone(), vec![library_path.clone()]),
    );
    let rules = vec![RuleDefinition {
        id: "windows.steam-shader-cache".to_string(),
        platform: Platform::Windows,
        category: "application".to_string(),
        name: "Steam shader cache".to_string(),
        safety_level: SafetyLevel::Safe,
        path_templates: vec![RuleTargetSpec::steam_library_template(
            "steamapps\\shadercache",
        )],
        restore_hint: Some("Shader caches will be rebuilt.".to_string()),
        provenance: RuleProvenance {
            source: RuleSource::Owned,
            license: "project-owned".to_string(),
            notes: "test rule".to_string(),
        },
    }];

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.steam-shader-cache".to_string()];

    let plan = build_cleanup_plan_with_environment_and_applications(
        &request,
        &rules,
        &fixture.env,
        &applications,
    )
    .unwrap();

    assert_eq!(plan.summary.allowed_targets, 2);
    assert_eq!(plan.summary.estimated_bytes, 5);
    assert!(plan.targets.iter().any(|target| {
        target.path.ends_with(
            Path::new("steam-library")
                .join("steamapps")
                .join("shadercache"),
        )
    }));
    assert!(
        plan.targets
            .iter()
            .all(|target| !target.path.to_string_lossy().contains("common"))
    );
}

#[test]
fn chromium_rules_expand_profile_cache_patterns() {
    let fixture = PlannerFixture::new();
    fixture.write(
        "local/Google/Chrome/User Data/Default/Cache/default.bin",
        b"a",
    );
    fixture.write(
        "local/Google/Chrome/User Data/Default/Code Cache/default-code.bin",
        b"bb",
    );
    fixture.write(
        "local/Google/Chrome/User Data/Default/GPUCache/default-gpu.bin",
        b"ccc",
    );
    fixture.write(
        "local/Google/Chrome/User Data/Profile 1/Cache/profile-cache.bin",
        b"dddd",
    );
    fixture.write(
        "local/Google/Chrome/User Data/Profile 1/Code Cache/profile-code.bin",
        b"eeeee",
    );
    fixture.write(
        "local/Google/Chrome/User Data/Profile 1/GPUCache/profile-gpu.bin",
        b"ffffff",
    );
    fixture.write(
        "local/Microsoft/Edge/User Data/Default/Cache/default.bin",
        b"1234567",
    );
    fixture.write(
        "local/Microsoft/Edge/User Data/Default/Code Cache/default-code.bin",
        b"12345678",
    );
    fixture.write(
        "local/Microsoft/Edge/User Data/Default/GPUCache/default-gpu.bin",
        b"123456789",
    );
    fixture.write(
        "local/Microsoft/Edge/User Data/Profile 2/Cache/profile-cache.bin",
        b"1234567890",
    );
    fixture.write(
        "local/Microsoft/Edge/User Data/Profile 2/Code Cache/profile-code.bin",
        b"12345678901",
    );
    fixture.write(
        "local/Microsoft/Edge/User Data/Profile 2/GPUCache/profile-gpu.bin",
        b"123456789012",
    );
    fixture.write(
        "local/BraveSoftware/Brave-Browser/User Data/Default/Cache/default.bin",
        &[b'a'; 13],
    );
    fixture.write(
        "local/BraveSoftware/Brave-Browser/User Data/Default/Code Cache/default-code.bin",
        &[b'b'; 14],
    );
    fixture.write(
        "local/BraveSoftware/Brave-Browser/User Data/Default/GPUCache/default-gpu.bin",
        &[b'c'; 15],
    );
    fixture.write(
        "local/BraveSoftware/Brave-Browser/User Data/Profile 3/Cache/profile-cache.bin",
        &[b'd'; 16],
    );
    fixture.write(
        "local/BraveSoftware/Brave-Browser/User Data/Profile 3/Code Cache/profile-code.bin",
        &[b'e'; 17],
    );
    fixture.write(
        "local/BraveSoftware/Brave-Browser/User Data/Profile 3/GPUCache/profile-gpu.bin",
        &[b'f'; 18],
    );
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec![
        "windows.chrome-cache".to_string(),
        "windows.edge-cache".to_string(),
        "windows.brave-cache".to_string(),
    ];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 18);
    assert_eq!(plan.summary.estimated_bytes, 171);
    assert!(plan.targets.iter().any(|target| {
        target
            .path
            .ends_with(Path::new("Profile 1").join("Code Cache"))
    }));
    assert!(plan.targets.iter().any(|target| {
        target
            .path
            .ends_with(Path::new("Profile 2").join("GPUCache"))
    }));
    assert!(plan.targets.iter().any(|target| {
        target
            .path
            .ends_with(Path::new("Profile 3").join("GPUCache"))
    }));
}

#[test]
fn unknown_rule_id_is_an_error() {
    let fixture = PlannerFixture::new();
    let rules = rebecca_rules::builtin_rules().unwrap();
    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["missing.rule".to_string()];

    let err = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap_err();

    assert!(err.to_string().contains("invalid rule id"));
}

#[test]
fn unknown_category_is_an_error() {
    let fixture = PlannerFixture::new();
    let rules = rebecca_rules::builtin_rules().unwrap();
    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_categories = vec!["missing".to_string()];

    let err = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap_err();

    assert!(err.to_string().contains("invalid category"));
}

#[test]
fn category_selection_is_case_insensitive() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/a.tmp", b"abc");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_categories = vec!["SYSTEM".to_string()];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert!(plan.summary.allowed_targets > 0);
    assert!(plan.targets.iter().all(|target| matches!(
        target.rule_id.as_str(),
        "windows.user-temp"
            | "windows.directx-shader-cache"
            | "windows.system-temp"
            | "windows.prefetch"
            | "windows.update-download-cache"
            | "windows.media-player-cache"
            | "windows.thumbnail-cache"
            | "windows.wer-reports"
    )));
}

#[test]
fn moderate_rule_is_skipped_without_opt_in() {
    let fixture = PlannerFixture::new();
    fixture.write("local/npm-cache/_cacache/index.bin", b"npm");
    fixture.write("roaming/npm-cache/_cacache/legacy.bin", b"npm2");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.npm-cache".to_string()];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.skipped_targets, 2);
    assert!(
        plan.targets
            .iter()
            .all(|target| target.status == TargetStatus::Skipped)
    );
}

#[test]
fn moderate_rule_is_allowed_with_opt_in() {
    let fixture = PlannerFixture::new();
    fixture.write("local/npm-cache/_cacache/index.bin", b"npm");
    fixture.write("roaming/npm-cache/_cacache/legacy.bin", b"npm2");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.npm-cache".to_string()];
    request.allow_moderate = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 2);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 7);
    assert_eq!(plan.targets.len(), 2);
    assert!(
        plan.targets
            .iter()
            .all(|target| target.status == TargetStatus::Allowed)
    );
}

#[test]
fn risky_rule_is_skipped_without_opt_in() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/risky.tmp", b"risk");
    let rules = vec![custom_risky_rule()];

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.custom-risky".to_string()];

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.skipped_targets, 1);
    assert_eq!(plan.targets[0].status, TargetStatus::Skipped);
}

#[test]
fn risky_rule_is_allowed_with_opt_in() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/risky.tmp", b"risk");
    let rules = vec![custom_risky_rule()];

    let mut request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    request.selected_rule_ids = vec!["windows.custom-risky".to_string()];
    request.allow_risky = true;

    let plan = build_cleanup_plan_with_environment(&request, &rules, &fixture.env).unwrap();

    assert_eq!(plan.summary.allowed_targets, 1);
    assert_eq!(plan.summary.skipped_targets, 0);
    assert_eq!(plan.summary.estimated_bytes, 4);
    assert_eq!(plan.targets.len(), 1);
    assert_eq!(plan.targets[0].status, TargetStatus::Allowed);
}

#[test]
fn dry_run_and_recycle_bin_share_target_set() {
    let fixture = PlannerFixture::new();
    fixture.write("temp/a.tmp", b"abc");
    let rules = rebecca_rules::builtin_rules().unwrap();

    let mut dry_request = PlanRequest::for_platform(Platform::Windows, DeleteMode::DryRun);
    dry_request.selected_rule_ids = vec!["windows.user-temp".to_string()];

    let mut run_request = PlanRequest::for_platform(Platform::Windows, DeleteMode::RecycleBin);
    run_request.selected_rule_ids = vec!["windows.user-temp".to_string()];

    let dry_plan = build_cleanup_plan_with_environment(&dry_request, &rules, &fixture.env).unwrap();
    let run_plan = build_cleanup_plan_with_environment(&run_request, &rules, &fixture.env).unwrap();

    let dry_paths = dry_plan
        .targets
        .iter()
        .map(|target| target.path.clone())
        .collect::<Vec<_>>();
    let run_paths = run_plan
        .targets
        .iter()
        .map(|target| target.path.clone())
        .collect::<Vec<_>>();

    assert_eq!(dry_paths, run_paths);
    assert!(
        dry_plan
            .targets
            .iter()
            .all(|target| target.mode == DeleteMode::DryRun)
    );
    assert!(
        run_plan
            .targets
            .iter()
            .all(|target| target.mode == DeleteMode::RecycleBin)
    );
}

fn custom_exact_path_rule(id: &str, path: PathBuf) -> RuleDefinition {
    RuleDefinition {
        id: id.to_string(),
        platform: Platform::Windows,
        category: "system".to_string(),
        name: "Custom exact path rule".to_string(),
        safety_level: SafetyLevel::Safe,
        path_templates: vec![RuleTargetSpec::ExactPath(path)],
        restore_hint: Some("The target can be rebuilt.".to_string()),
        provenance: RuleProvenance {
            source: RuleSource::Owned,
            license: "project-owned".to_string(),
            notes: "test rule".to_string(),
        },
    }
}

fn custom_risky_rule() -> RuleDefinition {
    RuleDefinition {
        id: "windows.custom-risky".to_string(),
        platform: Platform::Windows,
        category: "system".to_string(),
        name: "Custom risky rule".to_string(),
        safety_level: SafetyLevel::Risky,
        path_templates: vec![RuleTargetSpec::template("%TEMP%")],
        restore_hint: Some("The target can be rebuilt.".to_string()),
        provenance: RuleProvenance {
            source: RuleSource::Owned,
            license: "project-owned".to_string(),
            notes: "test rule".to_string(),
        },
    }
}

fn app_paths_for_fixture(fixture: &PlannerFixture) -> AppPaths {
    AppPaths {
        config_dir: fixture.root.join("rebecca-config"),
        config_file: fixture.root.join("rebecca-config").join("config.toml"),
        state_dir: fixture.root.join("rebecca-state"),
        cache_dir: fixture.root.join("rebecca-cache"),
        history_file: fixture.root.join("rebecca-state").join("history.jsonl"),
    }
}

fn app(stable_suffix: &str) -> InstalledApplication {
    InstalledApplication::new(
        format!("hklm/example/{stable_suffix}"),
        "Example App",
        Vec::new(),
    )
}

struct PlannerFixture {
    _temp: tempfile::TempDir,
    root: std::path::PathBuf,
    env: MapEnvironment,
}

impl PlannerFixture {
    fn new() -> Self {
        let temp = tempfile::tempdir().unwrap();
        let root = temp.path().to_path_buf();
        let env = MapEnvironment::new()
            .with_var("TEMP", root.join("temp").into_os_string())
            .with_var("LOCALAPPDATA", root.join("local").into_os_string())
            .with_var("APPDATA", root.join("roaming").into_os_string())
            .with_var("USERPROFILE", root.join("user").into_os_string());

        Self {
            _temp: temp,
            root,
            env,
        }
    }

    fn with_local_temp_as_temp() -> Self {
        let temp = tempfile::tempdir().unwrap();
        let root = temp.path().to_path_buf();
        let env = MapEnvironment::new()
            .with_var("TEMP", root.join("local").join("Temp").into_os_string())
            .with_var("LOCALAPPDATA", root.join("local").into_os_string())
            .with_var("APPDATA", root.join("roaming").into_os_string())
            .with_var("USERPROFILE", root.join("user").into_os_string());

        Self {
            _temp: temp,
            root,
            env,
        }
    }

    fn with_cargo_home() -> Self {
        let fixture = Self::new();
        let env = fixture.env.clone().with_var(
            "CARGO_HOME",
            fixture.root.join("cargo-home").into_os_string(),
        );

        Self {
            _temp: fixture._temp,
            root: fixture.root,
            env,
        }
    }

    fn with_ccache_dir() -> Self {
        let fixture = Self::new();
        let env = fixture.env.clone().with_var(
            "CCACHE_DIR",
            fixture.root.join("ccache-dir").into_os_string(),
        );

        Self {
            _temp: fixture._temp,
            root: fixture.root,
            env,
        }
    }

    fn with_rustup_home() -> Self {
        let fixture = Self::new();
        let env = fixture.env.clone().with_var(
            "RUSTUP_HOME",
            fixture.root.join("rustup-home").into_os_string(),
        );

        Self {
            _temp: fixture._temp,
            root: fixture.root,
            env,
        }
    }

    fn with_sccache_dir() -> Self {
        let fixture = Self::new();
        let env = fixture.env.clone().with_var(
            "SCCACHE_DIR",
            fixture.root.join("sccache-dir").into_os_string(),
        );

        Self {
            _temp: fixture._temp,
            root: fixture.root,
            env,
        }
    }

    fn write(&self, relative: impl AsRef<Path>, bytes: &[u8]) {
        let path = self.root.join(relative);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(path, bytes).unwrap();
    }
}
