use std::{
    fs::{self, File, Metadata, OpenOptions},
    io::{ErrorKind, Write},
    path::{Path, PathBuf},
};

#[cfg(unix)]
use std::os::unix::fs::OpenOptionsExt;

use anyhow::{Context, Result, ensure};

use crate::pipeline::OutputFormat;

#[derive(Debug)]
pub struct OutputPaths {
    pub final_path: PathBuf,
    pub temp_path: PathBuf,
    overwrite: bool,
}

#[derive(Debug)]
pub struct FinalizedRecording {
    pub path: PathBuf,
    pub bytes: u64,
}

pub fn prepare(
    output: Option<&Path>,
    dir: &Path,
    overwrite: bool,
    format: OutputFormat,
) -> Result<OutputPaths> {
    let mut final_path =
        output.map_or_else(|| dir.join(default_file_name(format)), Path::to_path_buf);
    ensure_extension(&mut final_path, format)?;
    ensure_not_existing_directory(&final_path)?;

    let parent = parent_dir(&final_path);
    fs::create_dir_all(parent)
        .with_context(|| format!("failed to create output directory `{}`", parent.display()))?;

    if !overwrite {
        final_path = unique_path(final_path)?;
    }

    let temp_path = reserve_unique_temp_path(&final_path)?;

    Ok(OutputPaths {
        final_path,
        temp_path,
        overwrite,
    })
}

pub fn validate_request(output: Option<&Path>, dir: &Path, format: OutputFormat) -> Result<()> {
    let Some(output) = output else {
        preflight_output_directory(dir)?;
        return Ok(());
    };

    let mut output = output.to_path_buf();
    ensure_extension(&mut output, format)?;
    let parent = parent_dir(&output);
    preflight_output_directory(parent)?;
    ensure_not_existing_directory(&output)?;

    Ok(())
}

pub fn finalize(paths: OutputPaths) -> Result<FinalizedRecording> {
    let metadata = temp_recording_metadata(&paths.temp_path).with_context(|| {
        format!(
            "temporary recording `{}` was not created",
            paths.temp_path.display()
        )
    })?;
    ensure!(
        metadata.len() > 0,
        "temporary recording `{}` is empty",
        paths.temp_path.display()
    );

    sync_file(&paths.temp_path)?;

    let final_path = if paths.overwrite {
        promote_with_overwrite(&paths.temp_path, paths.final_path)?
    } else {
        promote_without_overwrite(&paths.temp_path, paths.final_path)?
    };

    Ok(FinalizedRecording {
        path: final_path,
        bytes: metadata.len(),
    })
}

pub fn finalize_nonempty(paths: OutputPaths) -> Result<Option<FinalizedRecording>> {
    let metadata = match temp_recording_metadata(&paths.temp_path) {
        Ok(metadata) => metadata,
        Err(err) if is_not_found_error(&err) => return Ok(None),
        Err(err) => {
            return Err(err).with_context(|| {
                format!(
                    "temporary recording `{}` could not be inspected",
                    paths.temp_path.display()
                )
            });
        }
    };

    if metadata.len() == 0 {
        discard_temp_file(&paths.temp_path)?;
        return Ok(None);
    }

    finalize(paths).map(Some)
}

pub fn discard(paths: OutputPaths) -> Result<()> {
    discard_temp_file(&paths.temp_path)
}

fn default_file_name(format: OutputFormat) -> String {
    let stamp = glib::DateTime::now_local()
        .and_then(|time| time.format("%Y-%m-%d-%H-%M-%S"))
        .map(|stamp| stamp.to_string())
        .unwrap_or_else(|_| "unknown-time".to_string());
    format!("Ocula-{stamp}.{}", format.extension())
}

fn ensure_extension(path: &mut PathBuf, format: OutputFormat) -> Result<()> {
    ensure_output_file_name(path)?;

    if path.extension().is_none() {
        path.set_extension(format.extension());
        return Ok(());
    }

    let extension = path.extension().and_then(|extension| extension.to_str());
    ensure!(
        extension.is_some_and(|extension| extension.eq_ignore_ascii_case(format.extension())),
        "output path must use .{} for the selected format",
        format.extension()
    );

    Ok(())
}

fn ensure_output_file_name(path: &Path) -> Result<()> {
    let file_name = path.file_name().and_then(|file_name| file_name.to_str());
    ensure!(
        file_name.is_some_and(|file_name| !matches!(file_name, "." | "..")),
        "output path must include a file name"
    );

    Ok(())
}

fn unique_path(path: PathBuf) -> Result<PathBuf> {
    if !path_exists_or_symlink(&path)? {
        return Ok(path);
    }

    let parent = parent_dir(&path);
    let stem = path
        .file_stem()
        .and_then(|stem| stem.to_str())
        .unwrap_or("recording");
    let extension = path.extension().and_then(|extension| extension.to_str());

    for index in 1..10_000 {
        let file_name = match extension {
            Some(extension) => format!("{stem}-{index:03}.{extension}"),
            None => format!("{stem}-{index:03}"),
        };
        let candidate = parent.join(file_name);
        if !path_exists_or_symlink(&candidate)? {
            return Ok(candidate);
        }
    }

    anyhow::bail!(
        "could not find an unused output path near `{}`",
        path.display()
    )
}

fn reserve_unique_temp_path(final_path: &Path) -> Result<PathBuf> {
    let parent = parent_dir(final_path);
    let file_name = final_path
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("recording.mkv");

    for index in 0..10_000 {
        let temp_name = if index == 0 {
            format!(".{file_name}.part")
        } else {
            format!(".{file_name}.{index:03}.part")
        };
        let candidate = parent.join(temp_name);
        match try_reserve_temp_file(&candidate) {
            Ok(()) => return Ok(candidate),
            Err(err) if err.kind() == ErrorKind::AlreadyExists => continue,
            Err(err) => {
                return Err(err).with_context(|| {
                    format!("failed to reserve temporary file `{}`", candidate.display())
                });
            }
        }
    }

    anyhow::bail!(
        "could not find an unused temporary path near `{}`",
        final_path.display()
    )
}

fn try_reserve_temp_file(path: &Path) -> std::io::Result<()> {
    OpenOptions::new()
        .write(true)
        .create_new(true)
        .open(path)
        .and_then(|file| file.sync_all())
}

fn path_exists_or_symlink(path: &Path) -> Result<bool> {
    match fs::symlink_metadata(path) {
        Ok(_) => Ok(true),
        Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
        Err(err) => {
            Err(err).with_context(|| format!("failed to inspect output path `{}`", path.display()))
        }
    }
}

fn temp_recording_metadata(path: &Path) -> Result<Metadata> {
    let metadata = fs::symlink_metadata(path)?;
    ensure!(
        metadata.file_type().is_file(),
        "temporary recording `{}` is not a regular file",
        path.display()
    );

    Ok(metadata)
}

fn promote_with_overwrite(temp_path: &Path, final_path: PathBuf) -> Result<PathBuf> {
    fs::rename(temp_path, &final_path).with_context(|| {
        format!(
            "failed to rename `{}` to `{}`",
            temp_path.display(),
            final_path.display()
        )
    })?;
    sync_parent_dir(&final_path)?;

    Ok(final_path)
}

fn promote_without_overwrite(temp_path: &Path, requested_final_path: PathBuf) -> Result<PathBuf> {
    for _ in 0..10_000 {
        let final_path = unique_path(requested_final_path.clone())?;
        match fs::hard_link(temp_path, &final_path) {
            Ok(()) => {
                sync_parent_dir(&final_path)?;
                match discard_temp_file(temp_path) {
                    Ok(()) => sync_parent_dir(&final_path)?,
                    Err(err) => eprintln!(
                        "warning: saved recording but failed to remove temporary link `{}`: {err:#}",
                        temp_path.display()
                    ),
                }
                return Ok(final_path);
            }
            Err(err) if err.kind() == ErrorKind::AlreadyExists => continue,
            Err(err) => {
                return Err(err).with_context(|| {
                    format!(
                        "failed to promote `{}` to `{}` without overwriting",
                        temp_path.display(),
                        final_path.display()
                    )
                });
            }
        }
    }

    anyhow::bail!(
        "could not promote recording without overwriting near `{}`",
        requested_final_path.display()
    )
}

fn sync_file(path: &Path) -> Result<()> {
    open_file_for_sync(path)
        .and_then(|file| file.sync_all())
        .with_context(|| format!("failed to sync temporary file `{}`", path.display()))
}

#[cfg(unix)]
fn open_file_for_sync(path: &Path) -> std::io::Result<File> {
    OpenOptions::new()
        .read(true)
        .custom_flags(libc::O_NOFOLLOW)
        .open(path)
}

#[cfg(not(unix))]
fn open_file_for_sync(path: &Path) -> std::io::Result<File> {
    OpenOptions::new().read(true).open(path)
}

fn is_not_found_error(err: &anyhow::Error) -> bool {
    err.downcast_ref::<std::io::Error>()
        .is_some_and(|err| err.kind() == ErrorKind::NotFound)
}

fn sync_parent_dir(path: &Path) -> Result<()> {
    let parent = parent_dir(path);
    File::open(parent)
        .and_then(|file| file.sync_all())
        .with_context(|| format!("failed to sync output directory `{}`", parent.display()))
}

fn parent_dir(path: &Path) -> &Path {
    match path.parent() {
        Some(parent) if !parent.as_os_str().is_empty() => parent,
        _ => Path::new("."),
    }
}

fn preflight_output_directory(path: &Path) -> Result<()> {
    let existing = nearest_existing_output_directory(path)?;
    probe_writable_existing_directory(&existing)
}

fn nearest_existing_output_directory(path: &Path) -> Result<PathBuf> {
    let mut current = if path.as_os_str().is_empty() {
        PathBuf::from(".")
    } else {
        path.to_path_buf()
    };

    loop {
        match fs::metadata(&current) {
            Ok(metadata) => {
                ensure!(
                    metadata.is_dir(),
                    "output directory ancestor `{}` is not a directory",
                    current.display()
                );
                return Ok(current);
            }
            Err(err) if err.kind() == ErrorKind::NotFound => {
                ensure!(
                    !path_exists_or_symlink(&current)?,
                    "output directory ancestor `{}` is a dangling symlink",
                    current.display()
                );
                if !current.pop() || current.as_os_str().is_empty() {
                    current = PathBuf::from(".");
                }
            }
            Err(err) => {
                return Err(err).with_context(|| {
                    format!("failed to inspect output directory `{}`", current.display())
                });
            }
        }
    }
}

fn ensure_not_existing_directory(path: &Path) -> Result<()> {
    if path.try_exists()? {
        ensure!(
            !path.is_dir(),
            "output path `{}` is a directory",
            path.display()
        );
    }

    Ok(())
}

fn discard_temp_file(path: &Path) -> Result<()> {
    match fs::remove_file(path) {
        Ok(()) => Ok(()),
        Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
        Err(err) => Err(err)
            .with_context(|| format!("failed to remove temporary file `{}`", path.display())),
    }
}

fn probe_writable_existing_directory(dir: &Path) -> Result<()> {
    if !dir.try_exists()? {
        return Ok(());
    }

    for index in 0..100 {
        let probe_path = dir.join(format!(
            ".ocula-write-test-{}-{index}.tmp",
            std::process::id()
        ));
        let mut file = match OpenOptions::new()
            .write(true)
            .create_new(true)
            .open(&probe_path)
        {
            Ok(file) => file,
            Err(err) if err.kind() == ErrorKind::AlreadyExists => continue,
            Err(err) => {
                return Err(err).with_context(|| {
                    format!("output directory `{}` is not writable", dir.display())
                });
            }
        };

        let write_result = file
            .write_all(b"ocula output preflight\n")
            .and_then(|()| file.sync_all())
            .with_context(|| format!("failed to write output probe in `{}`", dir.display()));
        drop(file);

        let cleanup_result = discard_temp_file(&probe_path);
        return match (write_result, cleanup_result) {
            (Ok(()), Ok(())) => Ok(()),
            (Err(err), Ok(())) => Err(err),
            (Ok(()), Err(err)) => Err(err),
            (Err(err), Err(cleanup_err)) => {
                eprintln!("warning: failed to clean up output probe: {cleanup_err:#}");
                Err(err)
            }
        };
    }

    anyhow::bail!(
        "could not create an output writability probe in `{}`",
        dir.display()
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::{
        env,
        time::{SystemTime, UNIX_EPOCH},
    };

    #[test]
    fn extension_added_when_missing() {
        let mut path = PathBuf::from("capture");
        ensure_extension(&mut path, OutputFormat::Mkv).unwrap();
        assert_eq!(path, PathBuf::from("capture.mkv"));
    }

    #[test]
    fn selected_extension_is_allowed_case_insensitively() {
        let mut path = PathBuf::from("capture.MKV");
        ensure_extension(&mut path, OutputFormat::Mkv).unwrap();
        assert_eq!(path, PathBuf::from("capture.MKV"));

        let mut path = PathBuf::from("capture.MP4");
        ensure_extension(&mut path, OutputFormat::Mp4).unwrap();
        assert_eq!(path, PathBuf::from("capture.MP4"));

        let mut path = PathBuf::from("capture.GIF");
        ensure_extension(&mut path, OutputFormat::Gif).unwrap();
        assert_eq!(path, PathBuf::from("capture.GIF"));
    }

    #[test]
    fn wrong_extension_is_rejected() {
        let mut path = PathBuf::from("capture.webm");
        assert!(ensure_extension(&mut path, OutputFormat::Mkv).is_err());
        let mut path = PathBuf::from("capture.mkv");
        assert!(ensure_extension(&mut path, OutputFormat::Mp4).is_err());
    }

    #[test]
    fn output_path_must_have_real_file_name() {
        assert!(validate_request(Some(Path::new(".")), Path::new("."), OutputFormat::Mkv).is_err());
        assert!(
            validate_request(Some(Path::new("..")), Path::new("."), OutputFormat::Mkv).is_err()
        );
    }

    #[test]
    fn validate_request_accepts_selected_format_output() {
        assert!(
            validate_request(
                Some(Path::new("capture.mp4")),
                Path::new("."),
                OutputFormat::Mkv
            )
            .is_err()
        );
        assert!(
            validate_request(
                Some(Path::new("capture.mkv")),
                Path::new("."),
                OutputFormat::Mkv
            )
            .is_ok()
        );
        assert!(
            validate_request(
                Some(Path::new("capture.mp4")),
                Path::new("."),
                OutputFormat::Mp4
            )
            .is_ok()
        );
        assert!(
            validate_request(
                Some(Path::new("capture.gif")),
                Path::new("."),
                OutputFormat::Gif
            )
            .is_ok()
        );
        assert!(validate_request(None, Path::new("."), OutputFormat::Mkv).is_ok());
    }

    #[test]
    fn validate_request_rejects_existing_file_as_output_dir() {
        let dir = test_dir("file-as-output-dir");
        fs::create_dir_all(&dir).unwrap();
        let file = dir.join("not-a-dir");
        fs::write(&file, b"not a directory").unwrap();

        assert!(validate_request(None, &file, OutputFormat::Mkv).is_err());

        fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn validate_request_rejects_existing_file_as_output_parent() {
        let dir = test_dir("file-as-output-parent");
        fs::create_dir_all(&dir).unwrap();
        let file = dir.join("not-a-dir");
        fs::write(&file, b"not a directory").unwrap();

        assert!(
            validate_request(
                Some(&file.join("capture.mkv")),
                Path::new("."),
                OutputFormat::Mkv
            )
            .is_err()
        );

        fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn validate_request_rejects_existing_file_as_output_ancestor() {
        let dir = test_dir("file-as-output-ancestor");
        fs::create_dir_all(&dir).unwrap();
        let file = dir.join("not-a-dir");
        fs::write(&file, b"not a directory").unwrap();

        assert!(
            validate_request(
                Some(&file.join("child/capture.mkv")),
                Path::new("."),
                OutputFormat::Mkv
            )
            .is_err()
        );

        fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn validate_request_rejects_existing_directory_as_output_path() {
        let dir = test_dir("directory-as-output");
        let output = dir.join("capture.mkv");
        fs::create_dir_all(&output).unwrap();

        assert!(validate_request(Some(&output), Path::new("."), OutputFormat::Mkv).is_err());

        fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn validate_request_probes_writable_output_parent_without_leaving_files() {
        let dir = test_dir("writable-output-parent");
        fs::create_dir_all(&dir).unwrap();

        validate_request(
            Some(&dir.join("capture.mkv")),
            Path::new("."),
            OutputFormat::Mkv,
        )
        .unwrap();

        assert!(!contains_output_probe(&dir));
        fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn validate_request_probes_nearest_existing_parent_without_creating_missing_dirs() {
        let dir = test_dir("missing-output-parent");
        fs::create_dir_all(&dir).unwrap();
        let output = dir.join("missing/child/capture.mkv");

        validate_request(Some(&output), Path::new("."), OutputFormat::Mkv).unwrap();

        assert!(!dir.join("missing").exists());
        assert!(!contains_output_probe(&dir));
        fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn validate_request_probes_writable_default_dir_without_leaving_files() {
        let dir = test_dir("writable-default-dir");
        fs::create_dir_all(&dir).unwrap();

        validate_request(None, &dir, OutputFormat::Mkv).unwrap();

        assert!(!contains_output_probe(&dir));
        fs::remove_dir_all(dir).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn prepare_without_overwrite_avoids_dangling_output_symlink() {
        use std::os::unix::fs::symlink;

        let dir = test_dir("dangling-output-symlink");
        fs::create_dir_all(&dir).unwrap();
        let output = dir.join("capture.mkv");
        symlink(dir.join("missing-target.mkv"), &output).unwrap();

        let paths = prepare(Some(&output), Path::new("."), false, OutputFormat::Mkv).unwrap();

        assert_eq!(paths.final_path, dir.join("capture-001.mkv"));
        assert_eq!(paths.temp_path, dir.join(".capture-001.mkv.part"));
        assert!(
            fs::symlink_metadata(&output)
                .unwrap()
                .file_type()
                .is_symlink()
        );
        discard(paths).unwrap();
        fs::remove_file(output).unwrap();
        fs::remove_dir_all(dir).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn prepare_skips_dangling_temp_symlink() {
        use std::os::unix::fs::symlink;

        let dir = test_dir("dangling-temp-symlink");
        fs::create_dir_all(&dir).unwrap();
        let temp_symlink = dir.join(".capture.mkv.part");
        symlink(dir.join("missing-temp-target.part"), &temp_symlink).unwrap();

        let paths = prepare(
            Some(&dir.join("capture.mkv")),
            Path::new("."),
            false,
            OutputFormat::Mkv,
        )
        .unwrap();

        assert_eq!(paths.temp_path, dir.join(".capture.mkv.001.part"));
        assert!(paths.temp_path.exists());
        assert!(
            fs::symlink_metadata(&temp_symlink)
                .unwrap()
                .file_type()
                .is_symlink()
        );
        discard(paths).unwrap();
        fs::remove_file(temp_symlink).unwrap();
        fs::remove_dir_all(dir).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn validate_request_rejects_dangling_default_dir_symlink() {
        use std::os::unix::fs::symlink;

        let dir = test_dir("dangling-default-dir-symlink");
        fs::create_dir_all(&dir).unwrap();
        let link = dir.join("output-dir");
        symlink(dir.join("missing-output-dir"), &link).unwrap();

        let err = validate_request(None, &link, OutputFormat::Mkv)
            .unwrap_err()
            .to_string();

        assert!(err.contains("dangling symlink"));
        fs::remove_file(link).unwrap();
        fs::remove_dir_all(dir).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn validate_request_rejects_dangling_output_parent_symlink() {
        use std::os::unix::fs::symlink;

        let dir = test_dir("dangling-output-parent-symlink");
        fs::create_dir_all(&dir).unwrap();
        let link = dir.join("parent");
        symlink(dir.join("missing-parent"), &link).unwrap();

        let err = validate_request(
            Some(&link.join("capture.mkv")),
            Path::new("."),
            OutputFormat::Mkv,
        )
        .unwrap_err()
        .to_string();

        assert!(err.contains("dangling symlink"));
        fs::remove_file(link).unwrap();
        fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn bare_relative_output_parent_is_current_directory() {
        assert_eq!(parent_dir(Path::new("capture.mkv")), Path::new("."));
        assert_eq!(parent_dir(Path::new("./capture.mkv")), Path::new("."));
        assert_eq!(
            parent_dir(Path::new("recordings/capture.mkv")),
            Path::new("recordings")
        );
    }

    #[test]
    fn finalize_nonempty_discards_empty_temp_file() {
        let dir = test_dir("empty-temp");
        fs::create_dir_all(&dir).unwrap();
        let paths = test_paths(&dir);
        File::create(&paths.temp_path).unwrap();

        let saved = finalize_nonempty(paths).unwrap();

        assert!(saved.is_none());
        assert!(!dir.join(".capture.mkv.part").exists());
        assert!(!dir.join("capture.mkv").exists());
        fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn finalize_nonempty_promotes_nonempty_temp_file() {
        let dir = test_dir("nonempty-temp");
        fs::create_dir_all(&dir).unwrap();
        let paths = test_paths(&dir);
        fs::write(&paths.temp_path, b"recording").unwrap();

        let saved = finalize_nonempty(paths).unwrap().unwrap();

        assert_eq!(saved.bytes, 9);
        assert_eq!(saved.path, dir.join("capture.mkv"));
        assert!(dir.join("capture.mkv").exists());
        assert!(!dir.join(".capture.mkv.part").exists());
        fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn discard_removes_reserved_temp_file() {
        let dir = test_dir("discard-temp");
        fs::create_dir_all(&dir).unwrap();
        let paths = test_paths(&dir);
        File::create(&paths.temp_path).unwrap();

        discard(paths).unwrap();

        assert!(!dir.join(".capture.mkv.part").exists());
        assert!(!dir.join("capture.mkv").exists());
        fs::remove_dir_all(dir).unwrap();
    }

    #[test]
    fn finalize_without_overwrite_preserves_existing_output() {
        let dir = test_dir("preserve-existing");
        fs::create_dir_all(&dir).unwrap();
        let paths = test_paths(&dir);
        fs::write(&paths.final_path, b"existing").unwrap();
        fs::write(&paths.temp_path, b"new recording").unwrap();

        let saved = finalize(paths).unwrap();

        assert_eq!(fs::read(dir.join("capture.mkv")).unwrap(), b"existing");
        assert_eq!(saved.path, dir.join("capture-001.mkv"));
        assert_eq!(
            fs::read(dir.join("capture-001.mkv")).unwrap(),
            b"new recording"
        );
        assert!(!dir.join(".capture.mkv.part").exists());
        fs::remove_dir_all(dir).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn finalize_rejects_symlinked_temp_recording() {
        use std::os::unix::fs::symlink;

        let dir = test_dir("symlink-temp-finalize");
        fs::create_dir_all(&dir).unwrap();
        let paths = test_paths(&dir);
        let target = dir.join("target.mkv");
        fs::write(&target, b"recording").unwrap();
        symlink(&target, &paths.temp_path).unwrap();

        let err = format!("{:#}", finalize(paths).unwrap_err());

        assert!(err.contains("not a regular file"));
        assert_eq!(fs::read(target).unwrap(), b"recording");
        fs::remove_dir_all(dir).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn finalize_nonempty_rejects_symlinked_temp_recording() {
        use std::os::unix::fs::symlink;

        let dir = test_dir("symlink-temp-finalize-nonempty");
        fs::create_dir_all(&dir).unwrap();
        let paths = test_paths(&dir);
        let target = dir.join("target.mkv");
        fs::write(&target, b"recording").unwrap();
        symlink(&target, &paths.temp_path).unwrap();

        let err = format!("{:#}", finalize_nonempty(paths).unwrap_err());

        assert!(err.contains("not a regular file"));
        assert_eq!(fs::read(target).unwrap(), b"recording");
        fs::remove_dir_all(dir).unwrap();
    }

    #[cfg(unix)]
    #[test]
    fn sync_file_does_not_follow_symlink() {
        use std::os::unix::fs::symlink;

        let dir = test_dir("symlink-temp-sync");
        fs::create_dir_all(&dir).unwrap();
        let target = dir.join("target.mkv");
        let link = dir.join("link.part");
        fs::write(&target, b"recording").unwrap();
        symlink(&target, &link).unwrap();

        assert!(sync_file(&link).is_err());

        fs::remove_dir_all(dir).unwrap();
    }

    fn test_paths(dir: &Path) -> OutputPaths {
        OutputPaths {
            final_path: dir.join("capture.mkv"),
            temp_path: dir.join(".capture.mkv.part"),
            overwrite: false,
        }
    }

    fn test_dir(name: &str) -> PathBuf {
        let nanos = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_nanos();
        env::temp_dir().join(format!(
            "ocula-output-test-{name}-{}-{nanos}",
            std::process::id()
        ))
    }

    fn contains_output_probe(dir: &Path) -> bool {
        fs::read_dir(dir).unwrap().any(|entry| {
            entry
                .unwrap()
                .file_name()
                .to_string_lossy()
                .starts_with(".ocula-write-test-")
        })
    }
}
