use std::{fs, path::PathBuf};

use dotr_dear::{
    cli::{DeployArgs, ImportArgs, InitArgs, UpdateArgs, run_cli},
    config::Config,
    utils::SYMLINK_FOLDER,
};

mod common;

const PLAYGROUND_DIR: &str = "tests/playground";
const BASHRC_PATH: &str = "src/.bashrc";
const NVIM_PATH: &str = "src/nvim";
const ZSHRC_PATH: &str = "src/.zshrc";
const TMUX_PATH: &str = "src/tmux";

struct TestFixture {
    cwd: PathBuf,
}

impl TestFixture {
    fn new() -> Self {
        let cwd = PathBuf::from(PLAYGROUND_DIR);
        common::setup(&cwd);
        Self { cwd }
    }

    fn get_cli(&self, command: Option<dotr_dear::cli::Command>) -> dotr_dear::cli::Cli {
        dotr_dear::cli::Cli {
            command,
            working_dir: Some(PLAYGROUND_DIR.to_string()),
        }
    }

    fn init(&self) {
        run_cli(self.get_cli(Some(dotr_dear::cli::Command::Init(InitArgs {}))))
            .expect("Init failed");
    }

    fn import(&self, path: &str, symlink: bool) {
        run_cli(
            self.get_cli(Some(dotr_dear::cli::Command::Import(ImportArgs {
                path: path.to_string(),
                symlink,
                ..Default::default()
            }))),
        )
        .expect("Import failed");
    }

    fn deploy(&self, packages: Option<Vec<String>>) {
        run_cli(
            self.get_cli(Some(dotr_dear::cli::Command::Deploy(DeployArgs {
                packages,
                profile: None,
                ignore_errors: false,
                clean: Some(false),
                dry_run: false,
                ..Default::default()
            }))),
        )
        .expect("Deploy failed");
    }

    fn update(&self, packages: Option<Vec<String>>) {
        run_cli(
            self.get_cli(Some(dotr_dear::cli::Command::Update(UpdateArgs {
                packages,
                profile: None,
                ignore_errors: false,
                clean: Some(false),
                dry_run: false,
            }))),
        )
        .expect("Update failed");
    }

    fn get_config(&self) -> Config {
        Config::from_path(&self.cwd).expect("Failed to load config")
    }

    fn assert_file_exists(&self, path: &str, message: &str) {
        assert!(self.cwd.join(path).exists(), "{}", message);
    }

    fn assert_file_not_exists(&self, path: &str, message: &str) {
        assert!(!self.cwd.join(path).exists(), "{}", message);
    }

    fn assert_is_symlink(&self, path: &str, message: &str) {
        let full_path = self.cwd.join(path);
        assert!(
            full_path.exists(),
            "Path {} should exist for {}",
            path,
            message
        );
        assert!(full_path.is_symlink(), "{}", message);
    }

    fn assert_not_symlink(&self, path: &str, message: &str) {
        let full_path = self.cwd.join(path);
        if full_path.exists() {
            assert!(!full_path.is_symlink(), "{}", message);
        }
    }

    fn assert_symlink_target(&self, symlink_path: &str, expected_target: &str, message: &str) {
        let full_path = self.cwd.join(symlink_path);
        assert!(
            full_path.is_symlink(),
            "Path {} should be a symlink",
            symlink_path
        );

        let target = fs::read_link(&full_path)
            .unwrap_or_else(|_| panic!("Failed to read symlink: {}", symlink_path));

        // Convert expected_target to absolute path for comparison
        let expected_relative = self.cwd.join(expected_target);
        let expected_absolute = std::path::absolute(&expected_relative)
            .unwrap_or_else(|_| panic!("Failed to get absolute path for: {}", expected_target));
        assert_eq!(target, expected_absolute, "{}", message);
    }

    fn write_file(&self, path: &str, content: &str) {
        let file_path = self.cwd.join(path);
        if let Some(parent) = file_path.parent() {
            let _ = fs::create_dir_all(parent);
        }
        fs::write(file_path, content).unwrap_or_else(|_| panic!("Failed to write file: {}", path));
    }

    fn read_file(&self, path: &str) -> String {
        let file_path = self.cwd.join(path);
        fs::read_to_string(&file_path).unwrap_or_else(|_| panic!("Failed to read file: {}", path))
    }
}

impl Drop for TestFixture {
    fn drop(&mut self) {
        // Clean up the deployed directory before teardown (unless NO_CLEANUP is set)
        if std::env::var("NO_CLEANUP").is_err() {
            let deployed_dir = self.cwd.join(SYMLINK_FOLDER);
            if deployed_dir.exists() {
                let _ = fs::remove_dir_all(&deployed_dir);
            }
            // Ad-hoc foreign files created by unfold_symlink tests aren't known
            // to `common::teardown`'s restore list; remove them explicitly so a
            // panicking test can't leave a dangling symlink for the next run.
            // `remove_file` works on both real files and (possibly broken)
            // symlinks, so this is safe even if nothing was ever created.
            let _ = fs::remove_file(self.cwd.join("src/tmux/foreign.txt"));
        }
        common::teardown(&self.cwd);
    }
}

#[test]
fn test_import_with_symlink_flag() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, true);

    let config = fixture.get_config();
    let package = config
        .packages
        .values()
        .next()
        .expect("Should have a package");

    assert_eq!(
        package.symlink,
        Some(true),
        "Package should have symlink flag enabled"
    );
}

#[test]
fn test_import_without_symlink_flag() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, false);

    let config = fixture.get_config();
    let package = config
        .packages
        .values()
        .next()
        .expect("Should have a package");

    assert_eq!(
        package.symlink, None,
        "Package should not have an explicit symlink flag set"
    );
}

#[test]
fn test_deploy_with_symlink_creates_deployed_folder() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, true);

    // Import with symlink flag automatically deploys
    fixture.assert_file_exists(
        SYMLINK_FOLDER,
        "deployed folder should be created after import with symlink",
    );
}

#[test]
fn test_deploy_with_symlink_creates_symlink_at_dest() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, true);

    // Import with symlink flag automatically deploys and creates symlink
    fixture.assert_is_symlink(
        BASHRC_PATH,
        "Destination should be a symlink after import with symlink",
    );
}

#[test]
fn test_deploy_with_symlink_points_to_deployed_folder() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, true);

    let config = fixture.get_config();
    let package_name = config
        .packages
        .keys()
        .next()
        .expect("Should have a package");

    // Import with symlink flag automatically deploys and creates symlink
    let expected_target = format!("{}/{}", SYMLINK_FOLDER, package_name);
    fixture.assert_symlink_target(
        BASHRC_PATH,
        &expected_target,
        "Symlink should point to deployed folder after import with symlink",
    );
}

#[test]
fn test_deploy_with_symlink_deploys_to_deployed_folder() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, true);

    let config = fixture.get_config();
    let package_name = config
        .packages
        .keys()
        .next()
        .expect("Should have a package");

    // Import with symlink flag automatically deploys, so deployed folder should already exist
    let deployed_file = format!("{}/{}", SYMLINK_FOLDER, package_name);
    fixture.assert_file_exists(
        &deployed_file,
        "File should be deployed to deployed folder after import with symlink",
    );
}

#[test]
fn test_deploy_with_symlink_directory() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(NVIM_PATH, true);

    let config = fixture.get_config();
    let package_name = config
        .packages
        .keys()
        .next()
        .expect("Should have a package");

    // Import with symlink flag automatically deploys
    // Check that the deployed directory contains the files
    let deployed_init = format!("{}/{}/init.lua", SYMLINK_FOLDER, package_name);
    fixture.assert_file_exists(
        &deployed_init,
        "init.lua should be in deployed folder after import with symlink",
    );

    // Check that destination is a symlink
    fixture.assert_is_symlink(
        NVIM_PATH,
        "nvim directory should be a symlink after import with symlink",
    );
}

#[test]
fn test_deploy_without_symlink_does_not_create_deployed_folder() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, false);

    fixture.deploy(None);

    fixture.assert_file_not_exists(
        SYMLINK_FOLDER,
        "deployed folder should not be created for non-symlink packages",
    );
}

#[test]
fn test_deploy_without_symlink_creates_regular_file() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, false);

    fixture.deploy(None);

    fixture.assert_not_symlink(
        BASHRC_PATH,
        "Destination should be a regular file, not a symlink",
    );
}

#[test]
fn test_symlink_replaces_existing_file() {
    let fixture = TestFixture::new();
    fixture.init();

    // File already exists from setup in src/.bashrc
    // Import with symlink should replace it with a symlink
    fixture.import(BASHRC_PATH, true);

    fixture.assert_is_symlink(
        BASHRC_PATH,
        "Existing file should be replaced with symlink after import with symlink",
    );
}

#[test]
fn test_symlink_replaces_existing_directory() {
    let fixture = TestFixture::new();
    fixture.init();

    // Directory already exists from setup in src/nvim/
    // Import with symlink should replace it with a symlink
    fixture.import(NVIM_PATH, true);

    fixture.assert_is_symlink(
        NVIM_PATH,
        "Existing directory should be replaced with symlink after import with symlink",
    );
}

#[test]
fn test_symlink_replaces_existing_symlink() {
    let fixture = TestFixture::new();
    fixture.init();

    // Create an existing symlink at the destination
    let bashrc = fixture.cwd.join(BASHRC_PATH);
    let temp_target = fixture.cwd.join("src/.bashrc.old");

    // Create target file for the old symlink
    fs::write(&temp_target, "# Old target\n").expect("Failed to write temp file");

    // Remove existing file first
    if bashrc.exists() {
        fs::remove_file(&bashrc).expect("Failed to remove existing file");
    }

    // Create old symlink with relative path
    std::os::unix::fs::symlink(".bashrc.old", &bashrc).expect("Failed to create symlink");

    // Import with symlink should replace the old symlink with a new one
    fixture.import(BASHRC_PATH, true);

    // Should still be a symlink but pointing to new location
    fixture.assert_is_symlink(
        BASHRC_PATH,
        "Should remain a symlink after import with symlink",
    );

    let config = fixture.get_config();
    let package_name = config
        .packages
        .keys()
        .next()
        .expect("Should have a package");
    let expected_target = format!("{}/{}", SYMLINK_FOLDER, package_name);

    fixture.assert_symlink_target(
        BASHRC_PATH,
        &expected_target,
        "Symlink should point to new deployed folder after import with symlink",
    );
}

#[test]
fn test_multiple_symlink_packages() {
    let fixture = TestFixture::new();
    fixture.init();

    fixture.import(BASHRC_PATH, true);
    fixture.import(ZSHRC_PATH, true);
    fixture.import(NVIM_PATH, true);

    // All should be symlinked after import with symlink flag
    fixture.assert_is_symlink(BASHRC_PATH, "bashrc should be symlinked");
    fixture.assert_is_symlink(ZSHRC_PATH, "zshrc should be symlinked");
    fixture.assert_is_symlink(NVIM_PATH, "nvim should be symlinked");

    // Verify deployed folder has all packages
    let config = fixture.get_config();
    for package_name in config.packages.keys() {
        let deployed_path = fixture.cwd.join(SYMLINK_FOLDER).join(package_name);
        assert!(
            deployed_path.exists(),
            "Package {} should be in deployed folder",
            package_name
        );
    }
}

#[test]
fn test_mixed_symlink_and_regular_packages() {
    let fixture = TestFixture::new();
    fixture.init();

    fixture.import(BASHRC_PATH, true); // symlink
    fixture.import(ZSHRC_PATH, false); // regular

    // Deploy regular package manually
    fixture.deploy(Some(vec!["f_zshrc".to_string()]));

    fixture.assert_is_symlink(BASHRC_PATH, "bashrc should be symlinked");
    fixture.assert_not_symlink(ZSHRC_PATH, "zshrc should be a regular file");
}

#[test]
fn test_update_symlink_package() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, true);

    // Modify the file through the symlink (which modifies deployed/f_bashrc)
    fixture.write_file(BASHRC_PATH, "# Modified through symlink\n");

    // Update the package (backs up from dest to dotfiles)
    fixture.update(None);

    // Verify the dotfiles source was updated
    let config = fixture.get_config();
    let package = config
        .packages
        .values()
        .next()
        .expect("Should have a package");
    let dotfiles_content = fixture.read_file(&package.src);
    assert!(
        dotfiles_content.contains("Modified through symlink"),
        "Dotfiles source should be updated"
    );

    // Verify symlink still exists and points correctly
    fixture.assert_is_symlink(BASHRC_PATH, "bashrc should still be a symlink after update");
}

#[test]
fn test_symlink_config_serialization() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, true);

    // Read the config file
    let config_content = fixture.read_file("config.toml");

    // Verify symlink field is present
    assert!(
        config_content.contains("symlink = true"),
        "Config should contain symlink = true"
    );
}

#[test]
fn test_symlink_not_backed_up() {
    let fixture = TestFixture::new();
    fixture.init();

    // Import with symlink - existing file is backed up to dotfiles during import
    fixture.import(BASHRC_PATH, true);

    // Verify no .dotrbak backup was created at destination (since we create symlink)
    fixture.assert_file_not_exists(
        "src/.bashrc.dotrbak",
        "Symlink deployment should not create backups at destination",
    );
}

#[test]
fn test_symlink_with_nested_directory() {
    let fixture = TestFixture::new();
    fixture.init();

    // Create nested directory structure (already exists from common setup)
    let nested_path = "src/config/alacritty";
    fixture.import(nested_path, true);

    // Import with symlink automatically creates symlink
    fixture.assert_is_symlink(
        nested_path,
        "Nested directory should be symlinked after import with symlink",
    );

    // Verify parent directories are created
    let parent_dir = fixture.cwd.join("src/config");
    assert!(parent_dir.exists(), "Parent directory should exist");
}

#[test]
fn test_gitignore_includes_deployed_folder() {
    let fixture = TestFixture::new();
    fixture.init();

    let gitignore_content = fixture.read_file(".gitignore");
    assert!(
        gitignore_content.contains("deployed"),
        "gitignore should contain 'deployed' folder"
    );
}

#[test]
fn test_symlink_dry_run() {
    let fixture = TestFixture::new();
    fixture.init();

    // Import without symlink first
    fixture.import(BASHRC_PATH, false);

    // Manually update config to add symlink flag
    let mut config = fixture.get_config();
    let pkg_name = config
        .packages
        .keys()
        .next()
        .expect("Should have package")
        .clone();
    config.packages.get_mut(&pkg_name).unwrap().symlink = Some(true);
    config.save(&fixture.cwd).expect("Failed to save config");

    // Deploy with dry run
    run_cli(
        fixture.get_cli(Some(dotr_dear::cli::Command::Deploy(DeployArgs {
            packages: None,
            profile: None,
            ignore_errors: false,
            clean: Some(false),
            dry_run: true,
            ..Default::default()
        }))),
    )
    .expect("Dry run deploy failed");

    // Verify nothing was actually created
    fixture.assert_file_not_exists(
        SYMLINK_FOLDER,
        "deployed folder should not be created in dry run",
    );

    fixture.assert_not_symlink(BASHRC_PATH, "Symlink should not be created in dry run");
}

#[test]
fn test_symlink_preserves_file_through_link() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, true);

    // Read original content through symlink
    let original_content = fixture.read_file(BASHRC_PATH);

    // Modify through symlink
    fixture.write_file(BASHRC_PATH, "# Modified through symlink\n");

    // Verify modification persists
    let modified_content = fixture.read_file(BASHRC_PATH);
    assert_eq!(modified_content, "# Modified through symlink\n");
    assert_ne!(original_content, modified_content);
}

#[test]
fn test_symlink_directory_preserves_structure() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(NVIM_PATH, true);

    // Verify symlinked directory has correct structure
    let init_lua = fixture.cwd.join(NVIM_PATH).join("init.lua");
    assert!(
        init_lua.exists(),
        "init.lua should be accessible through symlink"
    );

    let content = fs::read_to_string(&init_lua).expect("Failed to read init.lua");
    assert!(!content.is_empty(), "File should have content");
}

#[test]
fn test_global_symlink_flag_defaults_to_false() {
    let fixture = TestFixture::new();
    fixture.init();

    let config = fixture.get_config();
    assert!(
        !config.symlink,
        "Global symlink flag should default to false"
    );
}

#[test]
fn test_global_symlink_flag_deploys_package_without_own_flag() {
    let fixture = TestFixture::new();
    fixture.init();

    // Import without the package-level symlink flag
    fixture.import(BASHRC_PATH, false);

    // Enable symlinking globally instead
    let mut config = fixture.get_config();
    config.symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);

    // The package itself was never marked for symlinking...
    let config = fixture.get_config();
    let package = config
        .packages
        .values()
        .next()
        .expect("Should have a package");
    assert_eq!(
        package.symlink, None,
        "Package-level symlink flag should remain untouched by the global setting"
    );

    // ...yet the global flag should still cause it to be symlinked.
    fixture.assert_is_symlink(
        BASHRC_PATH,
        "Destination should be a symlink when the global symlink flag is enabled",
    );
}

#[test]
fn test_global_symlink_flag_creates_deployed_folder() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, false);

    let mut config = fixture.get_config();
    config.symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);

    fixture.assert_file_exists(
        SYMLINK_FOLDER,
        "deployed folder should be created when the global symlink flag is enabled",
    );
}

#[test]
fn test_global_symlink_flag_target_points_to_deployed_folder() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, false);

    let mut config = fixture.get_config();
    config.symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);

    let config = fixture.get_config();
    let package_name = config
        .packages
        .keys()
        .next()
        .expect("Should have a package");
    let expected_target = format!("{}/{}", SYMLINK_FOLDER, package_name);

    fixture.assert_symlink_target(
        BASHRC_PATH,
        &expected_target,
        "Symlink created via the global flag should point to the deployed folder",
    );
}

#[test]
fn test_global_symlink_flag_applies_to_directory_package() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(NVIM_PATH, false);

    let mut config = fixture.get_config();
    config.symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);

    fixture.assert_is_symlink(
        NVIM_PATH,
        "Directory package should be symlinked when the global symlink flag is enabled",
    );
}

#[test]
fn test_global_symlink_flag_persists_after_save() {
    let fixture = TestFixture::new();
    fixture.init();

    let mut config = fixture.get_config();
    config.symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    let reloaded = fixture.get_config();
    assert!(
        reloaded.symlink,
        "Global symlink flag should persist across a config reload"
    );
}

#[test]
fn test_global_symlink_flag_does_not_leak_into_package_config() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, false);

    let mut config = fixture.get_config();
    config.symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");
    fixture.deploy(None);

    // Only one top-level `symlink = true` should exist (the global one) -
    // the package itself must not have been mutated to carry its own flag.
    let config_content = fixture.read_file("config.toml");
    let occurrences = config_content.matches("symlink = true").count();
    assert_eq!(
        occurrences, 1,
        "Only the global symlink flag should be set, not a package-level one"
    );
}

#[test]
fn test_global_symlink_flag_skips_backup_on_redeploy() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, false);

    let mut config = fixture.get_config();
    config.symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    // First deploy creates the symlink and populates the deployed folder.
    fixture.deploy(None);

    let config = fixture.get_config();
    let package_name = config
        .packages
        .keys()
        .next()
        .expect("Should have a package")
        .clone();

    // Change the source so the next deploy has to overwrite the deployed copy.
    fixture.write_file(&package_name_src(&config), "# Changed content\n");

    fixture.deploy(None);

    fixture.assert_file_not_exists(
        &format!("{}/{}.dotrbak", SYMLINK_FOLDER, package_name),
        "Redeploying via the global symlink flag should not create a backup in the deployed folder",
    );
    fixture.assert_file_not_exists(
        &format!("{}.dotrbak", BASHRC_PATH),
        "Redeploying via the global symlink flag should not create a backup at the destination",
    );
}

fn package_name_src(config: &Config) -> String {
    config
        .packages
        .values()
        .next()
        .expect("Should have a package")
        .src
        .clone()
}

#[test]
fn test_unfold_symlink_defaults_to_false() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(TMUX_PATH, false);

    let config = fixture.get_config();
    let package = config
        .packages
        .values()
        .next()
        .expect("Should have a package");
    assert!(
        !package.unfold_symlink,
        "unfold_symlink should default to false"
    );
}

#[test]
fn test_unfold_symlink_creates_real_directory_with_symlinked_files() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(TMUX_PATH, false);

    let mut config = fixture.get_config();
    let pkg_name = config
        .packages
        .keys()
        .next()
        .expect("Should have package")
        .clone();
    config.packages.get_mut(&pkg_name).unwrap().symlink = Some(true);
    config.packages.get_mut(&pkg_name).unwrap().unfold_symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);

    fixture.assert_not_symlink(
        TMUX_PATH,
        "The destination directory itself should remain real, not a symlink",
    );
    assert!(
        fixture.cwd.join(TMUX_PATH).is_dir(),
        "Destination should exist as a real directory"
    );
    fixture.assert_is_symlink(
        "src/tmux/tmux.conf",
        "Individual file should be symlinked when unfolded",
    );
    fixture.assert_is_symlink(
        "src/tmux/theme.conf",
        "Individual file should be symlinked when unfolded",
    );
}

#[test]
fn test_unfold_symlink_target_points_to_staged_file() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(TMUX_PATH, false);

    let mut config = fixture.get_config();
    let pkg_name = config
        .packages
        .keys()
        .next()
        .expect("Should have package")
        .clone();
    config.packages.get_mut(&pkg_name).unwrap().symlink = Some(true);
    config.packages.get_mut(&pkg_name).unwrap().unfold_symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);

    let expected_target = format!("{}/{}/tmux.conf", SYMLINK_FOLDER, pkg_name);
    fixture.assert_symlink_target(
        "src/tmux/tmux.conf",
        &expected_target,
        "Unfolded symlink should point at the staged file, not the whole staged directory",
    );
}

#[test]
fn test_unfold_symlink_preserves_foreign_file_on_redeploy() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(TMUX_PATH, false);

    let mut config = fixture.get_config();
    let pkg_name = config
        .packages
        .keys()
        .next()
        .expect("Should have package")
        .clone();
    config.packages.get_mut(&pkg_name).unwrap().symlink = Some(true);
    config.packages.get_mut(&pkg_name).unwrap().unfold_symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);

    // Drop an untracked file directly into the (now real) destination directory.
    fixture.write_file("src/tmux/foreign.txt", "not managed by dotr\n");

    fixture.deploy(None);

    fixture.assert_not_symlink(
        "src/tmux/foreign.txt",
        "Foreign file should never become a symlink",
    );
    assert_eq!(
        fixture.read_file("src/tmux/foreign.txt"),
        "not managed by dotr\n",
        "Foreign file content should survive a redeploy untouched"
    );
    // Managed files should still be correctly symlinked alongside it.
    fixture.assert_is_symlink("src/tmux/tmux.conf", "Managed file should remain symlinked");
}

#[test]
fn test_ignore_non_empty_implies_unfold_without_explicit_flag() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(TMUX_PATH, false);

    let mut config = fixture.get_config();
    let pkg_name = config
        .packages
        .keys()
        .next()
        .expect("Should have package")
        .clone();
    let package = config.packages.get_mut(&pkg_name).unwrap();
    package.symlink = Some(true);
    package.ignore = vec!["theme.conf".to_string()];
    // unfold_symlink deliberately left at its default (false).
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);

    fixture.assert_not_symlink(
        TMUX_PATH,
        "A non-empty ignore list should imply unfolding even without the explicit flag",
    );
    fixture.assert_is_symlink("src/tmux/tmux.conf", "Non-ignored file should be symlinked");
    fixture.assert_not_symlink(
        "src/tmux/theme.conf",
        "Ignored file should be left as the original real file, never symlinked",
    );
}

#[test]
fn test_unfold_symlink_removes_stale_symlink_after_source_file_removed() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(TMUX_PATH, false);

    let mut config = fixture.get_config();
    let pkg_name = config
        .packages
        .keys()
        .next()
        .expect("Should have package")
        .clone();
    config.packages.get_mut(&pkg_name).unwrap().symlink = Some(true);
    config.packages.get_mut(&pkg_name).unwrap().unfold_symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);
    fixture.assert_is_symlink("src/tmux/theme.conf", "Should be symlinked initially");

    // Remove the source file from the repository and redeploy with cleaning
    // enabled (the `deploy` helper hardcodes `clean: Some(false)`, so call
    // run_cli directly here).
    let config = fixture.get_config();
    let src = package_name_src(&config);
    fs::remove_file(fixture.cwd.join(&src).join("theme.conf"))
        .expect("Failed to remove source file");

    run_cli(
        fixture.get_cli(Some(dotr_dear::cli::Command::Deploy(DeployArgs {
            packages: None,
            profile: None,
            ignore_errors: false,
            clean: Some(true),
            dry_run: false,
            ..Default::default()
        }))),
    )
    .expect("Deploy failed");

    fixture.assert_file_not_exists(
        "src/tmux/theme.conf",
        "Stale symlink should be removed once its source file is gone",
    );
    fixture.assert_is_symlink(
        "src/tmux/tmux.conf",
        "Unrelated managed file should be unaffected",
    );
}

#[test]
fn test_unfold_symlink_clean_does_not_remove_foreign_file() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(TMUX_PATH, false);

    let mut config = fixture.get_config();
    let pkg_name = config
        .packages
        .keys()
        .next()
        .expect("Should have package")
        .clone();
    config.packages.get_mut(&pkg_name).unwrap().symlink = Some(true);
    config.packages.get_mut(&pkg_name).unwrap().unfold_symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);
    fixture.write_file("src/tmux/foreign.txt", "not managed by dotr\n");

    // Remove a source file so clean has a stale managed symlink to remove.
    // Deploy with cleaning enabled directly (the `deploy` helper hardcodes
    // `clean: Some(false)`).
    let config = fixture.get_config();
    let src = package_name_src(&config);
    fs::remove_file(fixture.cwd.join(&src).join("theme.conf"))
        .expect("Failed to remove source file");

    run_cli(
        fixture.get_cli(Some(dotr_dear::cli::Command::Deploy(DeployArgs {
            packages: None,
            profile: None,
            ignore_errors: false,
            clean: Some(true),
            dry_run: false,
            ..Default::default()
        }))),
    )
    .expect("Deploy failed");

    fixture.assert_file_not_exists(
        "src/tmux/theme.conf",
        "Stale managed symlink should still be cleaned up",
    );
    fixture.assert_file_exists(
        "src/tmux/foreign.txt",
        "Clean must never remove foreign, unmanaged files",
    );
}

#[test]
fn test_unfold_symlink_update_pulls_changes_without_absorbing_foreign_file() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(TMUX_PATH, false);

    let mut config = fixture.get_config();
    let pkg_name = config
        .packages
        .keys()
        .next()
        .expect("Should have package")
        .clone();
    config.packages.get_mut(&pkg_name).unwrap().symlink = Some(true);
    config.packages.get_mut(&pkg_name).unwrap().unfold_symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);

    // A foreign file living alongside the managed symlinks...
    fixture.write_file("src/tmux/foreign.txt", "not managed by dotr\n");
    // ...and an edit made through one of the managed symlinks.
    fixture.write_file("src/tmux/tmux.conf", "# Modified through symlink\n");

    fixture.update(None);

    let config = fixture.get_config();
    let src = package_name_src(&config);
    let repo_content = fixture.read_file(&format!("{}/tmux.conf", src));
    assert_eq!(
        repo_content, "# Modified through symlink\n",
        "Edits made through a managed symlink should be pulled back into the repo"
    );
    fixture.assert_file_not_exists(
        &format!("{}/foreign.txt", src),
        "Foreign files must never be pulled into the tracked repository",
    );
}

#[test]
fn test_unfold_symlink_dry_run_makes_no_changes() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(TMUX_PATH, false);

    let mut config = fixture.get_config();
    let pkg_name = config
        .packages
        .keys()
        .next()
        .expect("Should have package")
        .clone();
    config.packages.get_mut(&pkg_name).unwrap().symlink = Some(true);
    config.packages.get_mut(&pkg_name).unwrap().unfold_symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    run_cli(
        fixture.get_cli(Some(dotr_dear::cli::Command::Deploy(DeployArgs {
            packages: None,
            profile: None,
            ignore_errors: false,
            clean: Some(false),
            dry_run: true,
            ..Default::default()
        }))),
    )
    .expect("Dry run deploy failed");

    fixture.assert_file_not_exists(
        SYMLINK_FOLDER,
        "Dry run should not create the deployed staging folder",
    );
    fixture.assert_not_symlink(
        "src/tmux/tmux.conf",
        "Dry run should not create any symlinks",
    );
}

#[test]
fn test_unfold_symlink_field_omitted_when_false() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(TMUX_PATH, false);

    let config_content = fixture.read_file("config.toml");
    assert!(
        !config_content.contains("unfold_symlink"),
        "unfold_symlink should be omitted from serialized config when false"
    );
}

#[test]
fn test_unfold_symlink_field_present_when_true() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(TMUX_PATH, false);

    let mut config = fixture.get_config();
    let pkg_name = config
        .packages
        .keys()
        .next()
        .expect("Should have package")
        .clone();
    config.packages.get_mut(&pkg_name).unwrap().symlink = Some(true);
    config.packages.get_mut(&pkg_name).unwrap().unfold_symlink = true;
    config.save(&fixture.cwd).expect("Failed to save config");

    let config_content = fixture.read_file("config.toml");
    assert!(
        config_content.contains("unfold_symlink = true"),
        "unfold_symlink should be present in serialized config when true"
    );
}

#[test]
fn test_package_can_opt_out_of_global_symlink_flag() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, false);

    let mut config = fixture.get_config();
    config.symlink = true;
    let pkg_name = config
        .packages
        .keys()
        .next()
        .expect("Should have package")
        .clone();
    // Explicitly opt this package out of the global symlink setting.
    config.packages.get_mut(&pkg_name).unwrap().symlink = Some(false);
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);

    fixture.assert_not_symlink(
        BASHRC_PATH,
        "A package that explicitly sets symlink = false should opt out of the global symlink flag",
    );
    fixture.assert_file_not_exists(
        SYMLINK_FOLDER,
        "An opted-out package should never stage anything under the deployed folder",
    );
}

#[test]
fn test_package_without_explicit_symlink_follows_global_flag() {
    let fixture = TestFixture::new();
    fixture.init();
    fixture.import(BASHRC_PATH, false);

    let mut config = fixture.get_config();
    config.symlink = true;
    // Leave the package's own `symlink` field unset entirely.
    config.save(&fixture.cwd).expect("Failed to save config");

    fixture.deploy(None);

    fixture.assert_is_symlink(
        BASHRC_PATH,
        "A package with no explicit symlink setting should follow the global flag",
    );
}
