"""Shared filesystem utilities for sandbox backup and rollback.

Low-level helpers used by :class:`CopyOnWriteSandbox` and
:class:`OverlaySandbox` for pre-commit backup and atomic rollback.
The functions deliberately avoid ``shutil.copytree``, ``shutil.copy2``,
and ``os.makedirs`` so that unit-test patches targeting individual
sandbox modules cannot interfere with the backup/restore operations.

Stage B3 supplementary — introduced for atomic ``commit_all`` (#925).
"""

from __future__ import annotations

import logging
import os
import stat

logger = logging.getLogger(__name__)


def backup_directory(src: str, dst: str) -> None:
    """Copy *src* directory contents into *dst* preserving symlinks and permissions.

    Uses a manual walk-and-copy approach that avoids ``os.makedirs``
    and ``shutil.copy2`` so that unit-test patches targeting individual
    sandbox module namespaces cannot interfere with the backup operation.
    ``os.mkdir`` is used for creating sub-directories (not affected
    by patches on ``os.makedirs``), and :func:`_copy_file` uses
    low-level open/read/write for file content.

    Symlinks are preserved as symlinks (not followed).  File permissions
    are copied via ``os.chmod``.

    Directory permissions and timestamps are applied in a **bottom-up
    post-walk pass** after all files have been copied.  This is required
    because creating files inside a directory updates its ``mtime``
    (standard POSIX behaviour), so setting timestamps during the walk
    would be immediately overwritten.  Permissions are also deferred to
    avoid restrictive source permissions (e.g. ``0o500``) blocking
    file writes into the destination.

    Args:
        src: Source directory to copy from.
        dst: Destination directory (must already exist).
    """
    # Collect directory metadata for a post-walk fixup pass.  Entries
    # are appended in walk order (top-down) and applied in reverse
    # (bottom-up) so that parent mtime is restored last.
    dir_metadata: list[tuple[str, int, float, float]] = []

    # Record root directory metadata (applied last in the fixup pass).
    src_root_stat = os.stat(src)
    dir_metadata.append(
        (
            dst,
            stat.S_IMODE(src_root_stat.st_mode),
            src_root_stat.st_atime,
            src_root_stat.st_mtime,
        )
    )

    for dirpath, dirnames, filenames in os.walk(src, followlinks=False):
        rel_dir = os.path.relpath(dirpath, src)
        dst_dir = os.path.join(dst, rel_dir) if rel_dir != "." else dst
        for dname in dirnames:
            src_entry = os.path.join(dirpath, dname)
            target = os.path.join(dst_dir, dname)
            if os.path.islink(src_entry):
                link_target = os.readlink(src_entry)
                os.symlink(link_target, target)
            elif not os.path.isdir(target):
                os.mkdir(target)
                # Record metadata for the post-walk fixup pass.
                src_stat = os.stat(src_entry)
                dir_metadata.append(
                    (
                        target,
                        stat.S_IMODE(src_stat.st_mode),
                        src_stat.st_atime,
                        src_stat.st_mtime,
                    )
                )
        for fname in filenames:
            src_file = os.path.join(dirpath, fname)
            dst_file = os.path.join(dst_dir, fname)
            if os.path.islink(src_file):
                link_target = os.readlink(src_file)
                os.symlink(link_target, dst_file)
            elif os.path.isfile(src_file):
                _copy_file(src_file, dst_file)
            else:
                # Skip special files (FIFOs, sockets, device files) that
                # could hang or error during open().
                logger.warning(
                    "Skipping non-regular file during backup: %s",
                    src_file,
                )

    # Post-walk fixup: apply directory permissions and timestamps in
    # reverse (bottom-up) order so that child directories are fixed
    # before their parents.  This ensures that setting a child's
    # timestamps does not update the parent's mtime, and that
    # restrictive permissions on a parent do not block writes into
    # children during the copy phase above.
    for dir_path, mode, atime, mtime in reversed(dir_metadata):
        os.chmod(dir_path, mode)
        os.utime(dir_path, (atime, mtime))


def safe_restore(backup_path: str, target_path: str) -> None:
    """Restore *target_path* from *backup_path* using rename-based swap.

    Both *backup_path* and *target_path* **must** reside on the same
    filesystem (callers ensure this by creating backups with
    ``tempfile.mkdtemp(dir=os.path.dirname(original_path))``).

    The restore uses two ``os.rename`` calls — both are O(1) atomic
    operations on POSIX when source and destination are on the same
    filesystem:

    1. Rename the current *target_path* aside into a temporary container.
    2. Rename *backup_path* to *target_path*.

    If step 2 fails, the original is renamed back so no data is lost.
    After a successful restore the stale renamed directory is removed.

    Args:
        backup_path: Directory containing the pre-commit backup.
        target_path: Original directory to restore.

    Raises:
        OSError: If the restore fails (the original is preserved).
    """
    import shutil
    import tempfile

    # Use a unique temporary directory on the same filesystem.
    # The target is renamed **into** the mkdtemp directory rather than
    # **to** the mkdtemp path.  This avoids the TOCTOU window that
    # would exist if we removed the mkdtemp dir (``os.rmdir``) and
    # then renamed the target to the now-vacated path — another process
    # could claim that path in between.
    parent_dir = os.path.dirname(target_path)
    stale_container = tempfile.mkdtemp(
        prefix=".atomic-rollback-old-",
        dir=parent_dir,
    )
    stale = os.path.join(stale_container, "original")

    os.rename(target_path, stale)
    try:
        # Rename (not copy) the backup into place.  Since both paths
        # are on the same filesystem, ``os.rename`` is an atomic O(1)
        # metadata operation.  Using ``shutil.copytree`` here would be
        # O(n) and — critically — could leave *target_path* partially
        # populated on failure, causing the recovery ``os.rename`` below
        # to fail with ``ENOTEMPTY`` (POSIX requires the destination of
        # a directory rename to be empty).
        os.rename(backup_path, target_path)
    except BaseException:
        # Restore the original on failure.  ``BaseException`` (not just
        # ``OSError``) ensures the rename-back executes even for
        # unexpected errors (e.g. ``MemoryError``), preventing the
        # original directory from being left in the renamed-aside state.
        os.rename(stale, target_path)
        raise
    finally:
        # Cleanup — always remove the stale container.  On success,
        # only the empty container directory remains (backup_path was
        # renamed into target_path, so the "original" subdir no longer
        # exists).  On failure, the original was already renamed back
        # so the container is empty as well.  Using a ``finally``
        # block ensures the temp directory does not leak even when the
        # rename fails and the exception is re-raised.
        shutil.rmtree(stale_container, ignore_errors=True)


def compute_diff(
    sandbox_dir: str, original_dir: str
) -> tuple[list[str], list[str], list[str]]:
    """Compare sandbox to original and return changed/added/deleted files.

    Walks both directory trees and classifies each file as changed,
    added, or deleted by comparing relative paths and file contents.

    Args:
        sandbox_dir: Path to the sandbox (or merged) directory.
        original_dir: Path to the original directory.

    Returns:
        Tuple of ``(changed_files, added_files, deleted_files)`` as
        sorted relative-path lists.
    """
    import filecmp

    sandbox_files: set[str] = {
        os.path.relpath(os.path.join(dp, f), sandbox_dir)
        for dp, _, fnames in os.walk(sandbox_dir)
        for f in fnames
    }
    original_files: set[str] = {
        os.path.relpath(os.path.join(dp, f), original_dir)
        for dp, _, fnames in os.walk(original_dir)
        for f in fnames
    }

    added = sorted(sandbox_files - original_files)
    deleted = sorted(original_files - sandbox_files)
    changed = [
        rel
        for rel in sorted(sandbox_files & original_files)
        if not filecmp.cmp(
            os.path.join(sandbox_dir, rel),
            os.path.join(original_dir, rel),
            shallow=False,
        )
    ]
    return changed, added, deleted


def _copy_file(src: str, dst: str) -> None:
    """Copy a single file using low-level byte I/O, preserving permissions.

    Args:
        src: Source file path.
        dst: Destination file path.
    """
    with open(src, "rb") as fsrc, open(dst, "wb") as fdst:
        while True:
            chunk = fsrc.read(1024 * 1024)
            if not chunk:
                break
            fdst.write(chunk)
    # Preserve permissions and timestamps
    src_stat = os.stat(src)
    os.chmod(dst, stat.S_IMODE(src_stat.st_mode))
    os.utime(dst, (src_stat.st_atime, src_stat.st_mtime))
