"""
Safe file operations for SLTK.

Provides atomic writes, versioning, and backup strategies to ensure
ELAN exports and other file operations never overwrite existing files
without explicit user consent.

Key principles:
- NEVER overwrite existing files without explicit request
- Use atomic operations (write to temp, then rename)
- Support automatic versioning and timestamped backups
- Handle concurrent access safely with file locking
"""

from __future__ import annotations

import fcntl
import logging
import os
import re
import shutil
import tempfile
from collections.abc import Callable
from contextlib import contextmanager
from datetime import datetime
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)


class SafeWriteError(Exception):
    """Base exception for safe write operations."""

    pass


class FileExistsError(SafeWriteError):
    """Raised when attempting to overwrite an existing file without permission."""

    pass


class AtomicWriteError(SafeWriteError):
    """Raised when atomic write operation fails."""

    pass


class BackupError(SafeWriteError):
    """Raised when backup operation fails."""

    pass


# ============================================================================
# File Locking
# ============================================================================


@contextmanager
def file_lock(path: Path, exclusive: bool = True, timeout: float = 10.0):
    """
    Context manager for file locking to handle concurrent access.

    Uses fcntl for Unix-compatible file locking. The lock is advisory
    and requires cooperation from other processes.

    Args:
        path: Path to the file to lock (creates .lock file)
        exclusive: If True, acquire exclusive (write) lock; otherwise shared (read)
        timeout: Maximum time to wait for lock in seconds

    Yields:
        None when lock is acquired

    Raises:
        SafeWriteError: If lock cannot be acquired within timeout

    Example:
        >>> with file_lock(Path("output.eaf"), exclusive=True):
        ...     # Perform safe write operations
        ...     pass
    """
    lock_path = path.with_suffix(path.suffix + ".lock")
    lock_file = None

    try:
        # Create parent directory if needed
        lock_path.parent.mkdir(parents=True, exist_ok=True)

        # Open lock file
        lock_file = open(lock_path, "w")

        # Determine lock type
        lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH

        # Try to acquire lock with non-blocking first to check availability
        try:
            fcntl.flock(lock_file.fileno(), lock_type | fcntl.LOCK_NB)
        except OSError:
            # Lock not available, wait with timeout
            import time

            start_time = time.time()
            while time.time() - start_time < timeout:
                try:
                    fcntl.flock(lock_file.fileno(), lock_type | fcntl.LOCK_NB)
                    break
                except OSError:
                    time.sleep(0.1)
            else:
                raise SafeWriteError(
                    f"Could not acquire lock on {path} within {timeout}s. "
                    "Another process may be writing to this file."
                )

        yield

    finally:
        if lock_file is not None:
            try:
                fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
                lock_file.close()
            except OSError:
                pass

            # Clean up lock file if we created it
            try:
                if lock_path.exists():
                    lock_path.unlink()
            except OSError:
                pass


# ============================================================================
# Atomic Write Operations
# ============================================================================


def atomic_write(
    path: str | Path,
    content: str | bytes,
    encoding: str = "utf-8",
    overwrite: bool = False,
) -> Path:
    """
    Atomically write content to a file using temp file + rename.

    This ensures that the file is never in a partial/corrupted state.
    The write happens to a temporary file first, then is renamed to
    the target path atomically.

    Args:
        path: Target file path
        content: String or bytes content to write
        encoding: Text encoding (only used for string content)
        overwrite: If False, raises error if file exists

    Returns:
        Path to the written file

    Raises:
        FileExistsError: If file exists and overwrite=False
        AtomicWriteError: If write operation fails

    Example:
        >>> atomic_write("output.eaf", xml_content, overwrite=False)
        PosixPath('output.eaf')
    """
    path = Path(path)

    # Check if file exists
    if path.exists() and not overwrite:
        raise FileExistsError(
            f"File already exists: {path}. "
            "Use overwrite=True to replace, or use versioned_save() for auto-versioning."
        )

    # Create parent directory if needed
    path.parent.mkdir(parents=True, exist_ok=True)

    # Write to temp file in same directory (for atomic rename on same filesystem)
    temp_fd = None
    temp_path = None

    try:
        temp_fd, temp_path = tempfile.mkstemp(
            suffix=path.suffix,
            prefix=f".{path.stem}_tmp_",
            dir=path.parent,
        )

        # Write content
        if isinstance(content, str):
            content = content.encode(encoding)

        os.write(temp_fd, content)
        os.fsync(temp_fd)  # Ensure data is on disk
        os.close(temp_fd)
        temp_fd = None

        # Set permissions similar to target (or sensible default)
        if path.exists():
            # Copy permissions from existing file
            os.chmod(temp_path, path.stat().st_mode)
        else:
            # Default permissions (readable/writable by owner and group)
            os.chmod(temp_path, 0o664)

        # Atomic rename
        os.replace(temp_path, path)
        temp_path = None

        logger.debug(f"Atomically wrote {len(content)} bytes to {path}")
        return path

    except Exception as e:
        raise AtomicWriteError(f"Failed to atomically write to {path}: {e}")

    finally:
        # Clean up on failure
        if temp_fd is not None:
            try:
                os.close(temp_fd)
            except OSError:
                pass

        if temp_path is not None:
            try:
                os.unlink(temp_path)
            except OSError:
                pass


def atomic_write_with_callback(
    path: str | Path,
    write_func: Callable[[Path], None],
    overwrite: bool = False,
) -> Path:
    """
    Atomically write a file using a callback function.

    Useful when the write operation is more complex than just writing
    content (e.g., using external libraries that write directly to files).

    Args:
        path: Target file path
        write_func: Function that takes a Path and writes to it
        overwrite: If False, raises error if file exists

    Returns:
        Path to the written file

    Raises:
        FileExistsError: If file exists and overwrite=False
        AtomicWriteError: If write operation fails

    Example:
        >>> def write_xml(p):
        ...     tree.write(p)
        >>> atomic_write_with_callback("output.eaf", write_xml)
    """
    path = Path(path)

    # Check if file exists
    if path.exists() and not overwrite:
        raise FileExistsError(
            f"File already exists: {path}. "
            "Use overwrite=True to replace, or use versioned_save() for auto-versioning."
        )

    # Create parent directory if needed
    path.parent.mkdir(parents=True, exist_ok=True)

    # Create temp file
    temp_path = None

    try:
        with tempfile.NamedTemporaryFile(
            suffix=path.suffix,
            prefix=f".{path.stem}_tmp_",
            dir=path.parent,
            delete=False,
        ) as temp_file:
            temp_path = Path(temp_file.name)

        # Call the write function
        write_func(temp_path)

        # Set permissions
        if path.exists():
            os.chmod(temp_path, path.stat().st_mode)
        else:
            os.chmod(temp_path, 0o664)

        # Atomic rename
        os.replace(temp_path, path)
        temp_path = None

        logger.debug(f"Atomically wrote file via callback to {path}")
        return path

    except FileExistsError:
        raise

    except Exception as e:
        raise AtomicWriteError(f"Failed to atomically write to {path}: {e}")

    finally:
        if temp_path is not None:
            try:
                os.unlink(temp_path)
            except OSError:
                pass


# ============================================================================
# Versioned Save Operations
# ============================================================================


def versioned_save(
    base_path: str | Path,
    content: str | bytes,
    encoding: str = "utf-8",
    max_versions: int = 100,
) -> Path:
    """
    Save content with automatic version numbering.

    If base_path exists, finds the next available version number.
    Version pattern: filename_v001.ext, filename_v002.ext, etc.

    Args:
        base_path: Base file path (version number will be inserted)
        content: String or bytes content to write
        encoding: Text encoding (only used for string content)
        max_versions: Maximum version number to try

    Returns:
        Path to the written file (includes version number)

    Raises:
        SafeWriteError: If all version slots are taken

    Example:
        >>> versioned_save("output.eaf", xml_content)
        PosixPath('output_v001.eaf')  # If output.eaf exists
        >>> versioned_save("output.eaf", xml_content)
        PosixPath('output_v002.eaf')  # Next call
    """
    base_path = Path(base_path)

    # If base path doesn't exist, use it directly
    if not base_path.exists():
        return atomic_write(base_path, content, encoding, overwrite=False)

    # Find next available version
    stem = base_path.stem
    suffix = base_path.suffix
    parent = base_path.parent

    # Check for existing versioned files to find highest version
    version_pattern = re.compile(rf"^{re.escape(stem)}_v(\d{{3}}){re.escape(suffix)}$")
    highest_version = 0

    for existing in parent.iterdir():
        match = version_pattern.match(existing.name)
        if match:
            version_num = int(match.group(1))
            highest_version = max(highest_version, version_num)

    # Generate next version path
    for version in range(highest_version + 1, max_versions + 1):
        versioned_name = f"{stem}_v{version:03d}{suffix}"
        versioned_path = parent / versioned_name

        if not versioned_path.exists():
            return atomic_write(versioned_path, content, encoding, overwrite=False)

    raise SafeWriteError(
        f"All version slots taken for {base_path} (up to v{max_versions:03d}). "
        "Please clean up old versions or use a different filename."
    )


def versioned_save_with_callback(
    base_path: str | Path,
    write_func: Callable[[Path], None],
    max_versions: int = 100,
) -> Path:
    """
    Save file with automatic version numbering using a callback function.

    Args:
        base_path: Base file path (version number will be inserted)
        write_func: Function that takes a Path and writes to it
        max_versions: Maximum version number to try

    Returns:
        Path to the written file (includes version number)

    Raises:
        SafeWriteError: If all version slots are taken
    """
    base_path = Path(base_path)

    # If base path doesn't exist, use it directly
    if not base_path.exists():
        return atomic_write_with_callback(base_path, write_func, overwrite=False)

    # Find next available version
    stem = base_path.stem
    suffix = base_path.suffix
    parent = base_path.parent

    # Check for existing versioned files
    version_pattern = re.compile(rf"^{re.escape(stem)}_v(\d{{3}}){re.escape(suffix)}$")
    highest_version = 0

    for existing in parent.iterdir():
        match = version_pattern.match(existing.name)
        if match:
            version_num = int(match.group(1))
            highest_version = max(highest_version, version_num)

    # Generate next version path
    for version in range(highest_version + 1, max_versions + 1):
        versioned_name = f"{stem}_v{version:03d}{suffix}"
        versioned_path = parent / versioned_name

        if not versioned_path.exists():
            return atomic_write_with_callback(versioned_path, write_func, overwrite=False)

    raise SafeWriteError(
        f"All version slots taken for {base_path} (up to v{max_versions:03d}). "
        "Please clean up old versions or use a different filename."
    )


# ============================================================================
# Backup Operations
# ============================================================================


def backup_file(
    path: str | Path,
    backup_dir: str | Path | None = None,
    timestamp_format: str = "%Y%m%d_%H%M%S",
) -> Path:
    """
    Create a timestamped backup of an existing file.

    Args:
        path: Path to the file to backup
        backup_dir: Directory for backups. If None, uses same directory as original.
        timestamp_format: strftime format for timestamp in backup filename

    Returns:
        Path to the backup file

    Raises:
        BackupError: If backup operation fails
        FileNotFoundError: If source file doesn't exist

    Example:
        >>> backup_file("important.eaf")
        PosixPath('important_backup_20260202_143022.eaf')
    """
    path = Path(path)

    if not path.exists():
        raise FileNotFoundError(f"Cannot backup non-existent file: {path}")

    # Determine backup directory
    if backup_dir is None:
        backup_dir = path.parent
    else:
        backup_dir = Path(backup_dir)
        backup_dir.mkdir(parents=True, exist_ok=True)

    # Generate backup filename with timestamp
    timestamp = datetime.now().strftime(timestamp_format)
    backup_name = f"{path.stem}_backup_{timestamp}{path.suffix}"
    backup_path = backup_dir / backup_name

    # Ensure unique backup path (in case of rapid successive backups)
    counter = 1
    while backup_path.exists():
        backup_name = f"{path.stem}_backup_{timestamp}_{counter}{path.suffix}"
        backup_path = backup_dir / backup_name
        counter += 1
        if counter > 100:
            raise BackupError(
                f"Too many backups with timestamp {timestamp} for {path}. "
                "Please clean up old backups."
            )

    try:
        shutil.copy2(path, backup_path)
        logger.info(f"Created backup: {backup_path}")
        return backup_path
    except Exception as e:
        raise BackupError(f"Failed to create backup of {path}: {e}")


def backup_before_modify(
    path: str | Path,
    backup_dir: str | Path | None = None,
    skip_if_missing: bool = False,
) -> Path | None:
    """
    Create a backup before modifying a file (if it exists).

    Convenience function that handles the common pattern of backing up
    before modification.

    Args:
        path: Path to the file to potentially backup
        backup_dir: Directory for backups
        skip_if_missing: If True, return None when file doesn't exist
                        instead of raising an error

    Returns:
        Path to backup file, or None if file doesn't exist and skip_if_missing=True

    Example:
        >>> backup = backup_before_modify("data.eaf", skip_if_missing=True)
        >>> if backup:
        ...     print(f"Backed up to {backup}")
    """
    path = Path(path)

    if not path.exists():
        if skip_if_missing:
            return None
        raise FileNotFoundError(f"Cannot backup non-existent file: {path}")

    return backup_file(path, backup_dir)


# ============================================================================
# Path Generation
# ============================================================================


def generate_export_path(
    base_name: str | Path,
    suffix: str = "_AI",
    timestamp: bool = True,
    timestamp_format: str = "%Y%m%d_%H%M%S",
    output_dir: str | Path | None = None,
) -> Path:
    """
    Generate a safe export path with suffix and optional timestamp.

    This is the recommended way to generate paths for AI-generated
    exports to avoid confusion with human-created annotations.

    Args:
        base_name: Original filename or path
        suffix: Suffix to add before extension (e.g., "_AI", "_auto")
        timestamp: Whether to include timestamp
        timestamp_format: strftime format for timestamp
        output_dir: Output directory. If None, uses same directory as base_name.

    Returns:
        Generated path that doesn't conflict with existing files

    Example:
        >>> generate_export_path("video.eaf", suffix="_AI")
        PosixPath('video_AI_20260202_143022.eaf')
        >>> generate_export_path("/data/video.mp4", suffix="_segmented", output_dir="/exports")
        PosixPath('/exports/video_segmented_20260202_143022.mp4')
    """
    base_name = Path(base_name)
    stem = base_name.stem
    ext = base_name.suffix or ".eaf"  # Default to .eaf for ELAN exports

    # Determine output directory
    if output_dir is not None:
        output_dir = Path(output_dir)
        output_dir.mkdir(parents=True, exist_ok=True)
    else:
        output_dir = base_name.parent if base_name.parent != Path(".") else Path.cwd()

    # Build filename
    if timestamp:
        ts = datetime.now().strftime(timestamp_format)
        filename = f"{stem}{suffix}_{ts}{ext}"
    else:
        filename = f"{stem}{suffix}{ext}"

    result_path = output_dir / filename

    # Ensure uniqueness
    counter = 1
    original_path = result_path
    while result_path.exists():
        if timestamp:
            ts = datetime.now().strftime(timestamp_format)
            filename = f"{stem}{suffix}_{ts}_{counter}{ext}"
        else:
            filename = f"{stem}{suffix}_{counter}{ext}"
        result_path = output_dir / filename
        counter += 1
        if counter > 1000:
            raise SafeWriteError(
                f"Could not generate unique path based on {original_path}. "
                "Too many files with similar names."
            )

    return result_path


def suggest_safe_path(
    original_path: str | Path,
    purpose: str = "export",
) -> dict[str, Any]:
    """
    Suggest safe paths for various operations given an original path.

    Returns multiple options for the user to choose from.

    Args:
        original_path: The original file path
        purpose: Purpose of the operation ("export", "backup", "version")

    Returns:
        Dictionary with suggested paths and metadata

    Example:
        >>> suggest_safe_path("annotations.eaf", purpose="export")
        {
            "original": "annotations.eaf",
            "exists": True,
            "suggestions": {
                "ai_export": "annotations_AI_20260202_143022.eaf",
                "versioned": "annotations_v001.eaf",
                "backup": "annotations_backup_20260202_143022.eaf"
            },
            "recommended": "annotations_AI_20260202_143022.eaf"
        }
    """
    path = Path(original_path)
    exists = path.exists()

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    stem = path.stem
    suffix = path.suffix

    suggestions = {
        "ai_export": str(path.parent / f"{stem}_AI_{timestamp}{suffix}"),
        "timestamped": str(path.parent / f"{stem}_{timestamp}{suffix}"),
    }

    # Add versioned suggestion
    if exists:
        # Find next version
        version_pattern = re.compile(rf"^{re.escape(stem)}_v(\d{{3}}){re.escape(suffix)}$")
        highest_version = 0
        for existing in path.parent.iterdir():
            match = version_pattern.match(existing.name)
            if match:
                highest_version = max(highest_version, int(match.group(1)))
        next_version = highest_version + 1
        suggestions["versioned"] = str(path.parent / f"{stem}_v{next_version:03d}{suffix}")
        suggestions["backup"] = str(path.parent / f"{stem}_backup_{timestamp}{suffix}")

    # Determine recommended path based on purpose
    if purpose == "export":
        recommended = suggestions["ai_export"]
    elif purpose == "backup":
        recommended = suggestions.get("backup", suggestions["timestamped"])
    elif purpose == "version":
        recommended = suggestions.get("versioned", suggestions["timestamped"])
    else:
        recommended = suggestions["timestamped"]

    return {
        "original": str(original_path),
        "exists": exists,
        "suggestions": suggestions,
        "recommended": recommended,
    }


# ============================================================================
# Safe ELAN Export Integration
# ============================================================================


def safe_write_eaf(
    segments: Any,  # SegmentList
    output_path: str | Path,
    video_path: str | Path | None = None,
    author: str = "SLTK",
    tier_types: dict[str, str] | None = None,
    overwrite: bool = False,
    auto_version: bool = True,
) -> Path:
    """
    Safely write segments to an ELAN .eaf file.

    Wraps the standard write_eaf function with safe write operations.
    By default, will auto-version if the file exists rather than failing.

    Args:
        segments: SegmentList to export
        output_path: Output .eaf file path
        video_path: Optional path to linked video file
        author: Author name for ELAN file metadata
        tier_types: Optional mapping of tier names to linguistic types
        overwrite: If True, allow overwriting existing files
        auto_version: If True and file exists (and not overwrite),
                     automatically create versioned file

    Returns:
        Path to created .eaf file

    Raises:
        FileExistsError: If file exists, overwrite=False, and auto_version=False

    Example:
        >>> safe_write_eaf(segments, "output.eaf", video_path="video.mp4")
        PosixPath('output.eaf')  # or 'output_v001.eaf' if original exists
    """
    from sltk.io.elan import write_eaf

    output_path = Path(output_path)

    # Check if file exists and handle accordingly
    if output_path.exists() and not overwrite:
        if auto_version:
            # Generate versioned path
            output_path = generate_export_path(
                output_path,
                suffix="",
                timestamp=False,
                output_dir=output_path.parent,
            )
            # Actually use versioned naming
            stem = output_path.stem
            parent = output_path.parent
            ext = output_path.suffix

            version_pattern = re.compile(rf"^{re.escape(stem)}_v(\d{{3}}){re.escape(ext)}$")
            highest_version = 0
            for existing in parent.iterdir():
                match = version_pattern.match(existing.name)
                if match:
                    highest_version = max(highest_version, int(match.group(1)))

            next_version = highest_version + 1
            output_path = parent / f"{stem}_v{next_version:03d}{ext}"
            logger.info(f"File exists, auto-versioning to: {output_path}")
        else:
            raise FileExistsError(
                f"File already exists: {output_path}. "
                "Set overwrite=True to replace or auto_version=True for automatic versioning."
            )

    # Use atomic write with callback
    def _write(path: Path) -> None:
        write_eaf(segments, path, video_path=video_path, author=author, tier_types=tier_types)

    return atomic_write_with_callback(output_path, _write, overwrite=True)


def safe_add_tier_to_eaf(
    source_path: str | Path,
    segments: Any,  # SegmentList
    output_path: str | Path | None = None,
    tier_name: str = "AI_Segmentation",
    linguistic_type: str = "default-lt",
    backup_original: bool = True,
) -> dict[str, Path | None]:
    """
    Safely add a new tier to an existing ELAN file.

    Creates a new file with the added tier, never modifying the original.
    Optionally creates a backup of the original file.

    Args:
        source_path: Path to existing .eaf file
        segments: SegmentList with annotations to add as new tier
        output_path: Path for new .eaf file. If None, auto-generates path.
        tier_name: Name for the new tier
        linguistic_type: Linguistic type reference for the tier
        backup_original: Whether to create a backup of the source file

    Returns:
        Dictionary with paths: {"output": Path, "backup": Path | None}

    Example:
        >>> result = safe_add_tier_to_eaf(
        ...     "original.eaf",
        ...     ai_segments,
        ...     tier_name="AI_Predictions"
        ... )
        >>> print(result["output"])
        PosixPath('original_AI_20260202_143022.eaf')
    """
    from sltk.io.elan import add_tier_to_eaf

    source_path = Path(source_path)
    result: dict[str, Path | None] = {"output": None, "backup": None}

    # Create backup if requested
    if backup_original and source_path.exists():
        result["backup"] = backup_file(source_path)

    # Generate output path if not provided
    if output_path is None:
        output_path = generate_export_path(
            source_path,
            suffix="_AI",
            timestamp=True,
        )
    else:
        output_path = Path(output_path)

        # Ensure we don't overwrite the source
        if output_path.resolve() == source_path.resolve():
            output_path = generate_export_path(
                source_path,
                suffix="_AI",
                timestamp=True,
            )
            logger.warning(f"Output path same as source, auto-generating: {output_path}")

        # Check if output exists
        if output_path.exists():
            output_path = generate_export_path(
                output_path,
                suffix="",
                timestamp=True,
            )

    # Perform the tier addition with atomic write
    def _add_tier(path: Path) -> None:
        add_tier_to_eaf(
            source_path,
            segments,
            path,
            tier_name=tier_name,
            linguistic_type=linguistic_type,
        )

    result["output"] = atomic_write_with_callback(output_path, _add_tier, overwrite=False)

    return result


# ============================================================================
# Cleanup Utilities
# ============================================================================


def cleanup_old_versions(
    base_path: str | Path,
    keep_versions: int = 5,
    dry_run: bool = True,
) -> list[Path]:
    """
    Clean up old versioned files, keeping only the most recent.

    Args:
        base_path: Base file path (without version number)
        keep_versions: Number of versions to keep (plus the original if it exists)
        dry_run: If True, only report what would be deleted without deleting

    Returns:
        List of paths that were (or would be) deleted

    Example:
        >>> cleanup_old_versions("output.eaf", keep_versions=3, dry_run=True)
        [PosixPath('output_v001.eaf'), PosixPath('output_v002.eaf')]
    """
    base_path = Path(base_path)
    stem = base_path.stem
    suffix = base_path.suffix
    parent = base_path.parent

    # Find all versioned files
    version_pattern = re.compile(rf"^{re.escape(stem)}_v(\d{{3}}){re.escape(suffix)}$")
    versioned_files: list[tuple[int, Path]] = []

    if parent.exists():
        for existing in parent.iterdir():
            match = version_pattern.match(existing.name)
            if match:
                version_num = int(match.group(1))
                versioned_files.append((version_num, existing))

    # Sort by version number (newest first)
    versioned_files.sort(key=lambda x: x[0], reverse=True)

    # Determine which to delete
    to_delete = [path for _, path in versioned_files[keep_versions:]]

    if not dry_run:
        for path in to_delete:
            try:
                path.unlink()
                logger.info(f"Deleted old version: {path}")
            except OSError as e:
                logger.warning(f"Failed to delete {path}: {e}")

    return to_delete


def cleanup_old_backups(
    base_path: str | Path,
    keep_days: int = 7,
    dry_run: bool = True,
) -> list[Path]:
    """
    Clean up old backup files older than specified days.

    Args:
        base_path: Base file path to find backups for
        keep_days: Number of days to keep backups
        dry_run: If True, only report what would be deleted

    Returns:
        List of paths that were (or would be) deleted
    """
    import time

    base_path = Path(base_path)
    stem = base_path.stem
    suffix = base_path.suffix
    parent = base_path.parent

    # Find all backup files
    backup_pattern = re.compile(
        rf"^{re.escape(stem)}_backup_\d{{8}}_\d{{6}}(_\d+)?{re.escape(suffix)}$"
    )
    to_delete: list[Path] = []

    cutoff_time = time.time() - (keep_days * 24 * 60 * 60)

    if parent.exists():
        for existing in parent.iterdir():
            if backup_pattern.match(existing.name):
                try:
                    if existing.stat().st_mtime < cutoff_time:
                        to_delete.append(existing)
                except OSError:
                    pass

    if not dry_run:
        for path in to_delete:
            try:
                path.unlink()
                logger.info(f"Deleted old backup: {path}")
            except OSError as e:
                logger.warning(f"Failed to delete {path}: {e}")

    return to_delete
