//! Block-order copy engine with large file buffers and tar streaming.
//!
//! Large files are copied with platform-optimal syscalls (`clonefile` on macOS
//! APFS, `copy_file_range` on Linux, buffered copy elsewhere). Small files are
//! batched into tar streams for reduced syscall overhead. Duplicates are
//! hard-linked after their canonical file is copied.
//!
//! Supports resumable copies via an optional manifest that tracks per-file
//! completion status. When a manifest is provided, already-completed files
//! are skipped and the manifest is updated atomically after each file.

use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};

use tracing::{debug, warn};

use crate::adapters::manifest::CopyManifest;
use crate::domain::{CopyError, CopyPlan};
use crate::ports::{ProgressPort, ProgressUpdate};

/// Default large-file buffer size (64 MiB).
const DEFAULT_BUFFER_SIZE: usize = 64 * 1024 * 1024;

/// Threshold below which files are considered "small" and batched into tar (1 MiB).
const SMALL_FILE_THRESHOLD: u64 = 1024 * 1024;

/// Strategy for resolving duplicate files during the dedup phase.
///
/// Both strategies produce zero extra disk space on creation; they differ in
/// inode semantics and what happens when one of the files is later written.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LinkStrategy {
    /// Hard-link duplicates to the canonical file (default).
    ///
    /// Files share a single inode — writes to one are visible on all.
    /// Best for backups where divergence is never expected.
    #[default]
    HardLink,
    /// Copy-on-write clone where the filesystem supports it (`APFS`, `btrfs`, `XFS`, `ReFS`).
    ///
    /// Separate inodes that start identical; each can diverge independently.
    /// Falls back to hard-link when `CoW` is unavailable on the destination filesystem.
    Clone,
}

/// Configuration for the copy engine.
#[derive(Debug, Clone)]
pub struct CopyConfig {
    /// Buffer size for large file copies (default: 64 MiB).
    pub buffer_size: usize,
    /// Threshold below which files are tar-batched (default: 1 MiB).
    pub small_file_threshold: u64,
    /// Whether to attempt macOS `APFS` clonefile for canonical copies.
    pub try_clonefile: bool,
    /// Strategy for resolving duplicate files in the dedup phase.
    pub link_strategy: LinkStrategy,
}

impl Default for CopyConfig {
    fn default() -> Self {
        Self {
            buffer_size: DEFAULT_BUFFER_SIZE,
            small_file_threshold: SMALL_FILE_THRESHOLD,
            try_clonefile: cfg!(target_os = "macos"),
            link_strategy: LinkStrategy::HardLink,
        }
    }
}

/// Result of a copy operation.
#[derive(Debug)]
pub struct CopyResult {
    /// Number of files copied.
    pub files_copied: u64,
    /// Number of files hard-linked.
    pub files_linked: u64,
    /// Number of duplicate files resolved via `CoW` clone (`--link-strategy clone`).
    pub files_cloned: u64,
    /// Number of duplicate files resolved via relative symlink (cross-device fallback).
    pub files_symlinked: u64,
    /// Number of duplicate files that required a full independent copy (last-resort fallback).
    pub files_fallback_copied: u64,
    /// Total bytes physically written (includes fallback copies of dedup'd files).
    pub bytes_copied: u64,
    /// Non-fatal errors encountered.
    pub errors: Vec<CopyError>,
}

/// Execute a copy plan: copy unique files, hard-link duplicates.
///
/// # Errors
///
/// Returns `CopyError::InsufficientSpace` if the destination doesn't have
/// enough free space. Per-file errors are collected in `CopyResult.errors`.
pub fn execute_copy_plan(
    plan: &CopyPlan,
    config: &CopyConfig,
    progress: &dyn ProgressPort,
) -> Result<CopyResult, CopyError> {
    execute_copy_plan_resumable(plan, config, progress, None)
}

/// Execute a copy plan with optional resume support via a manifest.
///
/// When a manifest is provided, already-completed files are skipped and the
/// manifest is updated atomically after each file is copied or linked.
///
/// # Errors
///
/// Returns `CopyError::InsufficientSpace` if the destination doesn't have
/// enough free space. Per-file errors are collected in `CopyResult.errors`.
pub fn execute_copy_plan_resumable(
    plan: &CopyPlan,
    config: &CopyConfig,
    progress: &dyn ProgressPort,
    mut manifest: Option<&mut CopyManifest>,
) -> Result<CopyResult, CopyError> {
    // Pre-flight space check.
    let needed_bytes = estimate_copy_bytes(plan);
    check_free_space(&plan.dest_root, needed_bytes)?;

    let mut result = CopyResult {
        files_copied: 0,
        files_linked: 0,
        files_cloned: 0,
        files_symlinked: 0,
        files_fallback_copied: 0,
        bytes_copied: 0,
        errors: Vec::new(),
    };

    let files_total = plan.entries.len() as u64;
    let bytes_total: u64 = plan.entries.iter().map(|e| e.size).sum();

    // Phase 1: Copy unique/canonical files (in disk order — plan.entries is
    // already sorted by physical_offset from the scan engine).
    let mut small_files: Vec<(usize, PathBuf, PathBuf)> = Vec::new();
    let mut files_skipped = 0_u64;

    for (idx, entry) in plan.entries.iter().enumerate() {
        if is_duplicate(idx, plan) {
            continue; // Will be hard-linked in phase 2.
        }

        let dest_path = map_source_to_dest(&entry.path, &plan.source_root, &plan.dest_root);

        if let Some(ref m) = manifest {
            let relative = relative_path_str(&entry.path, &plan.source_root);
            let hash_str = entry.hash.as_ref().map(std::string::ToString::to_string);
            if m.is_completed(&relative, entry.size, hash_str.as_deref()) {
                files_skipped += 1;
                debug!("skipping already-completed: {relative}");
                continue;
            }
        }

        if !ensure_parent_dir(&dest_path, &mut result.errors) {
            continue;
        }

        if entry.size < config.small_file_threshold {
            small_files.push((idx, entry.path.clone(), dest_path));
        } else {
            copy_large_canonical_entry(
                entry,
                &dest_path,
                config,
                &mut result,
                progress,
                manifest.as_deref_mut(),
                plan,
                files_total,
                bytes_total,
                files_skipped,
            );
        }
    }

    // Batch-copy small files via tar stream.
    if !small_files.is_empty() {
        copy_small_files_tar(
            &small_files,
            &plan.source_root,
            &mut result,
            progress,
            files_total,
            bytes_total,
            plan,
        );
        mark_small_files_in_manifest(manifest.as_deref_mut(), plan, &small_files);
    }

    // Phase 2: Hard-link duplicates.
    create_hard_links_resumable(
        plan,
        &mut result,
        progress,
        files_total,
        bytes_total,
        files_skipped,
        manifest,
        config.link_strategy,
    );

    progress.update(&ProgressUpdate::PhaseComplete { phase: "copy" });

    if files_skipped > 0 {
        debug!("resumed: {files_skipped} files skipped from previous run");
    }

    Ok(result)
}

/// Check whether a plan-entry index is a duplicate (not the canonical copy).
fn is_duplicate(idx: usize, plan: &CopyPlan) -> bool {
    plan.dedup_groups
        .iter()
        .any(|g| g.duplicates.contains(&idx))
}

/// Create the parent directory of `dest`, recording any error into `errors`.
/// Returns `true` on success.
fn ensure_parent_dir(dest: &Path, errors: &mut Vec<CopyError>) -> bool {
    if let Some(parent) = dest.parent()
        && let Err(e) = fs::create_dir_all(parent)
    {
        let err = CopyError::CreateDir {
            path: parent.to_path_buf(),
            source: e,
        };
        warn!("{err}");
        errors.push(err);
        return false;
    }
    true
}

/// Copy one large canonical entry, updating manifest and emitting progress on success.
#[expect(
    clippy::too_many_arguments,
    reason = "Phase-1 entry helper carries its full call-site context inline; further bundling hides state coupling."
)]
fn copy_large_canonical_entry(
    entry: &crate::domain::FileEntry,
    dest_path: &Path,
    config: &CopyConfig,
    result: &mut CopyResult,
    progress: &dyn ProgressPort,
    manifest: Option<&mut CopyManifest>,
    plan: &CopyPlan,
    files_total: u64,
    bytes_total: u64,
    files_skipped: u64,
) {
    let relative = relative_path_str(&entry.path, &plan.source_root);
    let hash_str = entry.hash.as_ref().map(std::string::ToString::to_string);

    match copy_large_file(&entry.path, dest_path, config) {
        Ok(()) => {
            preserve_permissions(&entry.path, dest_path, entry.permissions);
            result.files_copied += 1;
            result.bytes_copied += entry.size;

            if let Some(m) = manifest {
                m.mark_copied(&relative, entry.size, hash_str.as_deref());
                if let Err(e) = m.save(&plan.dest_root) {
                    warn!("failed to update manifest: {e}");
                }
            }

            progress.update(&ProgressUpdate::CopyProgress {
                files_copied: result.files_copied + result.files_linked + files_skipped,
                files_total,
                bytes_copied: result.bytes_copied,
                bytes_total,
            });
        }
        Err(e) => {
            warn!("{e}");
            result.errors.push(e);
        }
    }
}

/// Record the tar-batched small files as copied in the manifest.
fn mark_small_files_in_manifest(
    manifest: Option<&mut CopyManifest>,
    plan: &CopyPlan,
    small_files: &[(usize, PathBuf, PathBuf)],
) {
    let Some(m) = manifest else { return };
    for (idx, _, _) in small_files {
        if let Some(entry) = plan.entries.get(*idx) {
            let rel = relative_path_str(&entry.path, &plan.source_root);
            let h = entry.hash.as_ref().map(std::string::ToString::to_string);
            m.mark_copied(&rel, entry.size, h.as_deref());
        }
    }
    if let Err(e) = m.save(&plan.dest_root) {
        warn!("failed to update manifest after small files: {e}");
    }
}

/// Returns `true` if `e` indicates a cross-device operation (EXDEV / `ERROR_NOT_SAME_DEVICE`).
///
/// [`io::ErrorKind::CrossesDevices`] maps to both error codes on all supported platforms
/// and was stabilised in Rust 1.75.
fn is_cross_device(e: &io::Error) -> bool {
    e.kind() == io::ErrorKind::CrossesDevices
}

/// Compute a relative path from `from_dir` to `target`.
///
/// Both paths are expected to be absolute. Works by finding the longest common
/// prefix, then prepending `..` components to climb out of `from_dir`.
#[cfg(unix)]
fn make_relative_path(target: &Path, from_dir: &Path) -> PathBuf {
    let target_comps: Vec<_> = target.components().collect();
    let from_comps: Vec<_> = from_dir.components().collect();

    let common = target_comps
        .iter()
        .zip(from_comps.iter())
        .take_while(|(a, b)| a == b)
        .count();

    let mut rel = PathBuf::new();
    for _ in 0..(from_comps.len() - common) {
        rel.push("..");
    }
    for comp in target_comps.iter().skip(common) {
        rel.push(comp);
    }
    rel
}

/// Attempt to create a relative symlink at `dup` pointing at `canonical`.
///
/// Returns `true` only if the symlink was created *and* resolves to an
/// accessible file. Cleans up a dangling symlink before returning `false`.
#[cfg(unix)]
fn try_relative_symlink(canonical: &Path, dup: &Path) -> bool {
    use std::os::unix::fs as unix_fs;

    let Some(dup_parent) = dup.parent() else {
        return false;
    };
    let rel = make_relative_path(canonical, dup_parent);
    if unix_fs::symlink(&rel, dup).is_err() {
        return false;
    }
    // Follow the symlink and confirm it resolves to a real file.
    if dup.is_file() {
        return true;
    }
    // Dangling — remove and signal failure so the caller falls through to a
    // full copy.
    let _ = fs::remove_file(dup);
    false
}

/// Cross-device dedup fallback: relative symlink on Unix, skipped on Windows.
///
/// Windows symlinks require elevation or Developer Mode, so we skip straight
/// to the full-copy last resort there.
#[cfg(unix)]
fn attempt_symlink_fallback(canonical: &Path, dup: &Path) -> bool {
    try_relative_symlink(canonical, dup)
}

/// Cross-device dedup fallback: no-op on non-Unix targets.
#[cfg(not(unix))]
const fn attempt_symlink_fallback(_canonical: &Path, _dup: &Path) -> bool {
    false
}

/// Persist a link event to the manifest.
fn update_manifest_linked(
    manifest: Option<&mut CopyManifest>,
    plan: &CopyPlan,
    relative: &str,
    size: u64,
    hash: Option<&str>,
) {
    let Some(m) = manifest else { return };
    m.mark_linked(relative, size, hash);
    if let Err(e) = m.save(&plan.dest_root) {
        warn!("failed to update manifest: {e}");
    }
}

/// Persist a copy event to the manifest.
fn update_manifest_copied(
    manifest: Option<&mut CopyManifest>,
    plan: &CopyPlan,
    relative: &str,
    size: u64,
    hash: Option<&str>,
) {
    let Some(m) = manifest else { return };
    m.mark_copied(relative, size, hash);
    if let Err(e) = m.save(&plan.dest_root) {
        warn!("failed to update manifest: {e}");
    }
}

/// Outcome of a single dedup-link attempt (`CoW` clone → hard link → symlink → full copy).
enum DupLinkOutcome {
    Cloned,
    Linked,
    Symlinked,
    FallbackCopied,
    Error(CopyError),
}

/// Attempt a platform-native copy-on-write clone.
///
/// Returns `Ok(true)` on success, `Ok(false)` when `CoW` is unavailable on the
/// destination filesystem (caller should fall through), or `Err` on unexpected
/// failures.
#[cfg(target_os = "macos")]
fn try_platform_clone(src: &Path, dst: &Path) -> Result<bool, CopyError> {
    super::macos_io::try_clonefile(src, dst).map(|()| true)
}

#[cfg(target_os = "linux")]
fn try_platform_clone(src: &Path, dst: &Path) -> Result<bool, CopyError> {
    super::linux_io::try_reflink(src, dst)
}

#[cfg(target_os = "windows")]
fn try_platform_clone(src: &Path, dst: &Path) -> Result<bool, CopyError> {
    super::windows_io::try_refs_block_clone(src, dst).map_err(|e| CopyError::FileCopy {
        src: src.to_path_buf(),
        dst: dst.to_path_buf(),
        source: e,
    })
}

#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
fn try_platform_clone(_src: &Path, _dst: &Path) -> Result<bool, CopyError> {
    Ok(false)
}

/// Try to resolve one duplicate via the tiered chain.
///
/// For `LinkStrategy::Clone`, `CoW` is attempted first; both strategies share
/// the EXDEV fallback path (symlink → full copy).
fn apply_dedup_link(
    canonical_dest: &Path,
    dup_dest: &Path,
    link_strategy: LinkStrategy,
) -> DupLinkOutcome {
    // CoW clone tier — only when explicitly requested.
    if link_strategy == LinkStrategy::Clone
        && matches!(try_platform_clone(canonical_dest, dup_dest), Ok(true))
    {
        return DupLinkOutcome::Cloned;
    }

    match fs::hard_link(canonical_dest, dup_dest) {
        Ok(()) => DupLinkOutcome::Linked,
        Err(e) if is_cross_device(&e) => {
            warn!(
                "hard link crossed mount boundary ({} \u{2192} {}); trying fallback",
                canonical_dest.display(),
                dup_dest.display()
            );
            if attempt_symlink_fallback(canonical_dest, dup_dest) {
                DupLinkOutcome::Symlinked
            } else {
                match fs::copy(canonical_dest, dup_dest) {
                    Ok(_) => DupLinkOutcome::FallbackCopied,
                    Err(copy_err) => DupLinkOutcome::Error(CopyError::FileCopy {
                        src: canonical_dest.to_path_buf(),
                        dst: dup_dest.to_path_buf(),
                        source: copy_err,
                    }),
                }
            }
        }
        Err(e) => DupLinkOutcome::Error(CopyError::HardLink {
            src: canonical_dest.to_path_buf(),
            dst: dup_dest.to_path_buf(),
            source: e,
        }),
    }
}

/// Create hard links for all duplicate files, degrading gracefully on EXDEV:
///
/// 1. `CoW` clone (`LinkStrategy::Clone` only) — same-device, metadata-only.
/// 2. `fs::hard_link` — same-device fast path.
/// 3. Relative symlink (Unix only) — crosses mount boundaries; verified to
///    resolve before being committed.
/// 4. Full copy — last resort; bytes counted in `result.bytes_copied`.
#[expect(
    clippy::too_many_arguments,
    reason = "Phase-2 link helper carries its full call-site context; further bundling hides state coupling."
)]
fn create_hard_links_resumable(
    plan: &CopyPlan,
    result: &mut CopyResult,
    progress: &dyn ProgressPort,
    files_total: u64,
    bytes_total: u64,
    files_skipped: u64,
    mut manifest: Option<&mut CopyManifest>,
    link_strategy: LinkStrategy,
) {
    for group in &plan.dedup_groups {
        let Some(canonical) = plan.entries.get(group.canonical) else {
            continue;
        };
        let canonical_dest =
            map_source_to_dest(&canonical.path, &plan.source_root, &plan.dest_root);

        for &dup_idx in &group.duplicates {
            let Some(dup_entry) = plan.entries.get(dup_idx) else {
                continue;
            };

            let relative = relative_path_str(&dup_entry.path, &plan.source_root);
            let hash_str = dup_entry
                .hash
                .as_ref()
                .map(std::string::ToString::to_string);

            if let Some(ref manifest) = manifest
                && manifest.is_completed(&relative, dup_entry.size, hash_str.as_deref())
            {
                debug!("skipping already-linked: {relative}");
                continue;
            }

            let dup_dest = map_source_to_dest(&dup_entry.path, &plan.source_root, &plan.dest_root);

            if !ensure_parent_dir(&dup_dest, &mut result.errors) {
                continue;
            }

            match apply_dedup_link(&canonical_dest, &dup_dest, link_strategy) {
                DupLinkOutcome::Cloned => {
                    result.files_cloned += 1;
                    update_manifest_linked(
                        manifest.as_deref_mut(),
                        plan,
                        &relative,
                        dup_entry.size,
                        hash_str.as_deref(),
                    );
                }
                DupLinkOutcome::Linked => {
                    result.files_linked += 1;
                    update_manifest_linked(
                        manifest.as_deref_mut(),
                        plan,
                        &relative,
                        dup_entry.size,
                        hash_str.as_deref(),
                    );
                }
                DupLinkOutcome::Symlinked => {
                    result.files_symlinked += 1;
                    update_manifest_linked(
                        manifest.as_deref_mut(),
                        plan,
                        &relative,
                        dup_entry.size,
                        hash_str.as_deref(),
                    );
                }
                DupLinkOutcome::FallbackCopied => {
                    result.files_fallback_copied += 1;
                    result.bytes_copied += dup_entry.size;
                    update_manifest_copied(
                        manifest.as_deref_mut(),
                        plan,
                        &relative,
                        dup_entry.size,
                        hash_str.as_deref(),
                    );
                }
                DupLinkOutcome::Error(err) => {
                    warn!("{err}");
                    result.errors.push(err);
                    continue;
                }
            }

            let files_done = result.files_copied
                + result.files_linked
                + result.files_cloned
                + result.files_symlinked
                + result.files_fallback_copied
                + files_skipped;
            progress.update(&ProgressUpdate::CopyProgress {
                files_copied: files_done,
                files_total,
                bytes_copied: result.bytes_copied,
                bytes_total,
            });
        }
    }
}

/// Map a source path to its corresponding destination path.
#[must_use]
pub fn map_source_to_dest(source_path: &Path, source_root: &Path, dest_root: &Path) -> PathBuf {
    let relative = source_path.strip_prefix(source_root).unwrap_or(source_path);
    dest_root.join(relative)
}

/// Get the relative path string of a file within a root directory.
fn relative_path_str(path: &Path, root: &Path) -> String {
    path.strip_prefix(root)
        .unwrap_or(path)
        .to_string_lossy()
        .into_owned()
}

/// Estimate how many bytes need to be physically copied (after dedup).
fn estimate_copy_bytes(plan: &CopyPlan) -> u64 {
    let mut duplicate_indices = std::collections::HashSet::new();
    for group in &plan.dedup_groups {
        for &dup_idx in &group.duplicates {
            duplicate_indices.insert(dup_idx);
        }
    }

    plan.entries
        .iter()
        .enumerate()
        .filter(|(idx, _)| !duplicate_indices.contains(idx))
        .map(|(_, e)| e.size)
        .sum()
}

/// Check available free space at the destination.
fn check_free_space(dest: &Path, needed: u64) -> Result<(), CopyError> {
    // Try to check the existing destination or its nearest existing ancestor.
    let check_path = find_existing_ancestor(dest);

    match get_available_space(&check_path) {
        Ok(available) => {
            if available < needed {
                Err(CopyError::InsufficientSpace { needed, available })
            } else {
                Ok(())
            }
        }
        Err(e) => {
            // If we can't determine space, log warning but proceed.
            warn!(
                "could not check free space at {}: {e}",
                check_path.display()
            );
            Ok(())
        }
    }
}

/// Walk up to find the nearest existing ancestor directory.
fn find_existing_ancestor(path: &Path) -> PathBuf {
    let mut current = path.to_path_buf();
    while !current.exists() {
        match current.parent() {
            Some(parent) => current = parent.to_path_buf(),
            None => break,
        }
    }
    current
}

/// Get available disk space (platform-specific).
#[cfg(unix)]
fn get_available_space(path: &Path) -> Result<u64, io::Error> {
    use std::ffi::CString;

    let c_path = CString::new(path.to_string_lossy().as_bytes())
        .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;

    // SAFETY: statvfs is a standard POSIX call; we pass a valid C string and
    // read from a properly-initialized struct.
    unsafe {
        let mut stat: libc::statvfs = std::mem::zeroed();
        if libc::statvfs(c_path.as_ptr(), &raw mut stat) == 0 {
            // f_bavail * f_frsize = bytes available to non-root users.
            // Field widths differ between Linux (both `u64`) and macOS
            // (f_bavail `u32`, f_frsize `u64`), so use `u128` as a
            // width-neutral intermediate that compiles cleanly under
            // `clippy::pedantic` on every supported target.
            let result = u128::from(stat.f_bavail) * u128::from(stat.f_frsize);
            u64::try_from(result).map_err(|e| io::Error::other(format!("statvfs overflow: {e}")))
        } else {
            Err(io::Error::last_os_error())
        }
    }
}

#[cfg(not(unix))]
fn get_available_space(path: &Path) -> Result<u64, io::Error> {
    // On Windows, use the windows_io module; on other non-Unix, skip.
    #[cfg(target_os = "windows")]
    {
        super::windows_io::get_available_space_windows(path)
    }
    #[cfg(not(target_os = "windows"))]
    {
        Ok(u64::MAX)
    }
}

/// Copy a large file using platform-optimal methods.
fn copy_large_file(src: &Path, dest: &Path, config: &CopyConfig) -> Result<(), CopyError> {
    // On macOS, try clonefile → fcopyfile → buffered copy.
    #[cfg(target_os = "macos")]
    if matches!(
        super::macos_io::try_macos_optimal_copy(src, dest, config.try_clonefile),
        Ok(true)
    ) {
        return Ok(());
    }

    // On Linux, try copy_file_range (kernel-to-kernel zero-copy).
    #[cfg(target_os = "linux")]
    if matches!(super::linux_io::try_copy_file_range(src, dest), Ok(true)) {
        return Ok(());
    }

    // On Windows, try ReFS block clone → CopyFileExW → buffered copy.
    #[cfg(target_os = "windows")]
    if matches!(
        super::windows_io::try_windows_optimal_copy(src, dest),
        Ok(true)
    ) {
        return Ok(());
    }

    // Suppress unused warning on platforms without optimised copy.
    let _ = config.try_clonefile;

    // Fall back to buffered copy.
    buffered_copy(src, dest, config.buffer_size)
}

/// Buffered file copy with configurable buffer size.
fn buffered_copy(src: &Path, dest: &Path, buffer_size: usize) -> Result<(), CopyError> {
    let mut reader = fs::File::open(src).map_err(|e| CopyError::FileCopy {
        src: src.to_path_buf(),
        dst: dest.to_path_buf(),
        source: e,
    })?;

    let mut writer = fs::File::create(dest).map_err(|e| CopyError::FileCopy {
        src: src.to_path_buf(),
        dst: dest.to_path_buf(),
        source: e,
    })?;

    let mut buf = vec![0u8; buffer_size];
    loop {
        let n = reader.read(&mut buf).map_err(|e| CopyError::FileCopy {
            src: src.to_path_buf(),
            dst: dest.to_path_buf(),
            source: e,
        })?;
        if n == 0 {
            break;
        }
        let chunk = buf.get(..n).unwrap_or(&buf);
        writer.write_all(chunk).map_err(|e| CopyError::FileCopy {
            src: src.to_path_buf(),
            dst: dest.to_path_buf(),
            source: e,
        })?;
    }

    Ok(())
}

/// Batch-copy small files using a tar stream (pack → unpack in memory).
fn copy_small_files_tar(
    files: &[(usize, PathBuf, PathBuf)],
    source_root: &Path,
    result: &mut CopyResult,
    progress: &dyn ProgressPort,
    files_total: u64,
    bytes_total: u64,
    plan: &CopyPlan,
) {
    // Pack all small files into a tar archive in memory.
    let mut archive_buf = Vec::new();
    {
        let mut builder = tar::Builder::new(&mut archive_buf);
        for (_, src, _) in files {
            let relative = src.strip_prefix(source_root).unwrap_or(src);
            if let Err(e) = builder.append_path_with_name(src, relative) {
                let err = CopyError::TarPack {
                    path: src.clone(),
                    source: e,
                };
                warn!("{err}");
                result.errors.push(err);
            }
        }
        if let Err(e) = builder.finish() {
            warn!("tar finish error: {e}");
        }
    }

    // Unpack the tar archive to the destination.
    let mut archive = tar::Archive::new(archive_buf.as_slice());
    match archive.unpack(&plan.dest_root) {
        Ok(()) => {
            for (idx, _, _) in files {
                let entry_size = plan.entries.get(*idx).map_or(0, |e| e.size);
                result.files_copied += 1;
                result.bytes_copied += entry_size;
            }

            progress.update(&ProgressUpdate::CopyProgress {
                files_copied: result.files_copied + result.files_linked,
                files_total,
                bytes_copied: result.bytes_copied,
                bytes_total,
            });
        }
        Err(e) => {
            let err = CopyError::TarUnpack {
                path: plan.dest_root.clone(),
                source: e,
            };
            warn!("{err}");
            result.errors.push(err);
        }
    }

    // Preserve permissions for tar-copied files.
    for (idx, _, dest) in files {
        if let Some(entry) = plan.entries.get(*idx) {
            preserve_permissions(&entry.path, dest, entry.permissions);
        }
    }
}

/// Preserve file permissions from source on destination.
#[cfg(unix)]
fn preserve_permissions(src: &Path, dest: &Path, permissions: u32) {
    use std::os::unix::fs::PermissionsExt;

    if permissions != 0 {
        let perms = fs::Permissions::from_mode(permissions);
        if let Err(e) = fs::set_permissions(dest, perms) {
            debug!("could not set permissions on {}: {e}", dest.display());
        }
    } else if let Ok(src_meta) = fs::metadata(src)
        && let Err(e) = fs::set_permissions(dest, src_meta.permissions())
    {
        debug!("could not copy permissions to {}: {e}", dest.display());
    }
}

#[cfg(not(unix))]
const fn preserve_permissions(_src: &Path, _dest: &Path, _permissions: u32) {
    // No-op on non-Unix platforms.
}

#[cfg(test)]
mod tests {
    use std::fs;

    use tempfile::TempDir;

    use super::*;
    use crate::domain::{CopyPlan, DedupGroup, FileEntry};
    use crate::ports::NullProgress;

    fn make_plan(
        src: &Path,
        dest: &Path,
        entries: Vec<FileEntry>,
        dedup_groups: Vec<DedupGroup>,
    ) -> CopyPlan {
        CopyPlan {
            source_root: src.to_path_buf(),
            dest_root: dest.to_path_buf(),
            entries,
            dedup_groups,
        }
    }

    #[test]
    fn copy_simple_directory_tree() -> Result<(), Box<dyn std::error::Error>> {
        let src_dir = TempDir::new()?;
        let dest_dir = TempDir::new()?;
        let src = src_dir.path();
        let dest = dest_dir.path();

        // Create source structure.
        fs::create_dir_all(src.join("subdir"))?;
        fs::write(src.join("a.txt"), "hello")?;
        fs::write(src.join("subdir/b.txt"), "world")?;

        let entries = vec![
            FileEntry::new(src.join("a.txt"), 5),
            FileEntry::new(src.join("subdir/b.txt"), 5),
        ];
        let plan = make_plan(src, dest, entries, vec![]);
        let config = CopyConfig {
            buffer_size: 4096,
            small_file_threshold: 0, // Force large-file path.
            ..CopyConfig::default()
        };

        let result = execute_copy_plan(&plan, &config, &NullProgress)?;

        assert_eq!(result.files_copied, 2);
        assert_eq!(result.errors.len(), 0);

        // Verify contents.
        assert_eq!(fs::read_to_string(dest.join("a.txt"))?, "hello");
        assert_eq!(fs::read_to_string(dest.join("subdir/b.txt"))?, "world");
        Ok(())
    }

    #[test]
    fn copy_with_hard_links_for_duplicates() -> Result<(), Box<dyn std::error::Error>> {
        let src_dir = TempDir::new()?;
        let dest_dir = TempDir::new()?;
        let src = src_dir.path();
        let dest = dest_dir.path();

        let content = "duplicate content";
        fs::write(src.join("canonical.txt"), content)?;
        fs::write(src.join("dupe.txt"), content)?;

        let entries = vec![
            FileEntry::new(src.join("canonical.txt"), content.len() as u64),
            FileEntry::new(src.join("dupe.txt"), content.len() as u64),
        ];
        let dedup_groups = vec![DedupGroup {
            canonical: 0,
            duplicates: vec![1],
        }];
        let plan = make_plan(src, dest, entries, dedup_groups);
        let config = CopyConfig {
            buffer_size: 4096,
            small_file_threshold: 0,
            ..CopyConfig::default()
        };

        let result = execute_copy_plan(&plan, &config, &NullProgress)?;

        assert_eq!(result.files_copied, 1);
        assert_eq!(result.files_linked, 1);

        // Both files exist with same content.
        assert_eq!(fs::read_to_string(dest.join("canonical.txt"))?, content);
        assert_eq!(fs::read_to_string(dest.join("dupe.txt"))?, content);

        // Verify they are hard-linked (same inode).
        #[cfg(unix)]
        {
            use std::os::unix::fs::MetadataExt;
            let m1 = fs::metadata(dest.join("canonical.txt"))?;
            let m2 = fs::metadata(dest.join("dupe.txt"))?;
            assert_eq!(m1.ino(), m2.ino());
        }
        Ok(())
    }

    #[test]
    fn copy_small_files_via_tar() -> Result<(), Box<dyn std::error::Error>> {
        let src_dir = TempDir::new()?;
        let dest_dir = TempDir::new()?;
        let src = src_dir.path();
        let dest = dest_dir.path();

        fs::write(src.join("small1.txt"), "tiny")?;
        fs::write(src.join("small2.txt"), "also tiny")?;

        let entries = vec![
            FileEntry::new(src.join("small1.txt"), 4),
            FileEntry::new(src.join("small2.txt"), 9),
        ];
        let plan = make_plan(src, dest, entries, vec![]);
        let config = CopyConfig {
            buffer_size: 4096,
            small_file_threshold: u64::MAX, // Everything is "small".
            ..CopyConfig::default()
        };

        let result = execute_copy_plan(&plan, &config, &NullProgress)?;

        assert_eq!(result.files_copied, 2);
        assert_eq!(fs::read_to_string(dest.join("small1.txt"))?, "tiny");
        assert_eq!(fs::read_to_string(dest.join("small2.txt"))?, "also tiny");
        Ok(())
    }

    #[test]
    fn map_source_to_dest_works() {
        let src_root = Path::new("/src/root");
        let dest_root = Path::new("/dest/root");
        let src_path = Path::new("/src/root/subdir/file.txt");

        let result = map_source_to_dest(src_path, src_root, dest_root);
        assert_eq!(result, PathBuf::from("/dest/root/subdir/file.txt"));
    }

    #[cfg(unix)]
    #[test]
    fn copy_preserves_permissions() -> Result<(), Box<dyn std::error::Error>> {
        use std::os::unix::fs::PermissionsExt;

        let src_dir = TempDir::new()?;
        let dest_dir = TempDir::new()?;
        let src = src_dir.path();
        let dest = dest_dir.path();

        let file_path = src.join("exec.sh");
        fs::write(&file_path, "#!/bin/sh\necho hi")?;
        fs::set_permissions(&file_path, fs::Permissions::from_mode(0o755))?;

        let mut entry = FileEntry::new(file_path, 18);
        entry.permissions = 0o755;

        let plan = make_plan(src, dest, vec![entry], vec![]);
        let config = CopyConfig {
            buffer_size: 4096,
            small_file_threshold: 0,
            ..CopyConfig::default()
        };

        execute_copy_plan(&plan, &config, &NullProgress)?;

        let dest_meta = fs::metadata(dest.join("exec.sh"))?;
        let mode = dest_meta.permissions().mode() & 0o777;
        assert_eq!(mode, 0o755);
        Ok(())
    }

    #[test]
    fn empty_plan_succeeds() -> Result<(), Box<dyn std::error::Error>> {
        let src_dir = TempDir::new()?;
        let dest_dir = TempDir::new()?;

        let plan = make_plan(src_dir.path(), dest_dir.path(), vec![], vec![]);
        let config = CopyConfig::default();

        let result = execute_copy_plan(&plan, &config, &NullProgress)?;
        assert_eq!(result.files_copied, 0);
        assert_eq!(result.files_linked, 0);
        assert_eq!(result.bytes_copied, 0);
        Ok(())
    }

    // --- make_relative_path + symlink fallback ---

    #[cfg(unix)]
    #[test]
    fn make_relative_path_same_parent() {
        let target = Path::new("/a/b/canonical.txt");
        let from_dir = Path::new("/a/b");
        assert_eq!(
            make_relative_path(target, from_dir),
            PathBuf::from("canonical.txt")
        );
    }

    #[cfg(unix)]
    #[test]
    fn make_relative_path_sibling_dir() {
        let target = Path::new("/a/b/canonical.txt");
        let from_dir = Path::new("/a/c");
        assert_eq!(
            make_relative_path(target, from_dir),
            PathBuf::from("../b/canonical.txt")
        );
    }

    #[cfg(unix)]
    #[test]
    fn make_relative_path_deeper_nesting() {
        let target = Path::new("/data/store/file.dat");
        let from_dir = Path::new("/data/mnt/sub/dir");
        assert_eq!(
            make_relative_path(target, from_dir),
            PathBuf::from("../../../store/file.dat")
        );
    }

    #[cfg(unix)]
    #[test]
    fn attempt_symlink_fallback_creates_resolving_link() -> Result<(), Box<dyn std::error::Error>> {
        let dir = TempDir::new()?;
        let canonical = dir.path().join("canonical.txt");
        let dup_dir = dir.path().join("sub");
        let dup = dup_dir.join("dup.txt");

        fs::write(&canonical, "content")?;
        fs::create_dir_all(&dup_dir)?;

        assert!(attempt_symlink_fallback(&canonical, &dup));
        assert!(dup.is_file(), "symlink should resolve to a file");
        assert_eq!(fs::read_to_string(&dup)?, "content");
        Ok(())
    }

    #[cfg(unix)]
    #[test]
    fn attempt_symlink_fallback_cleans_up_dangling_link() -> Result<(), Box<dyn std::error::Error>>
    {
        let dir = TempDir::new()?;
        // canonical does not exist — symlink will be dangling.
        let canonical = dir.path().join("ghost.txt");
        let dup = dir.path().join("dup.txt");

        let result = attempt_symlink_fallback(&canonical, &dup);

        assert!(!result, "should return false for a dangling symlink");
        assert!(
            !dup.exists(),
            "dangling symlink should have been cleaned up"
        );
        Ok(())
    }
}
