# Copyright 2010-2025 Gentoo Authors
# Distributed under the terms of the GNU General Public License v2

__all__ = ["movefile"]

import errno
import fnmatch
import os as _os
import stat
import textwrap
import tempfile

import portage
from portage import (
    bsd_chflags,
    _encodings,
    _os_overrides,
    _selinux,
    _unicode_decode,
    _unicode_encode,
    _unicode_func_wrapper,
    _unicode_module_wrapper,
)
from portage.const import MOVE_BINARY
from portage.eapi import eapi_rewrites_symlinks
from portage.exception import OperationNotSupported
from portage.localization import _
from portage.process import spawn
from portage.util import writemsg
from portage.util._xattr import xattr
from portage.util.file_copy import copyfile


def _apply_stat(src_stat, dest):
    _os.chown(dest, src_stat.st_uid, src_stat.st_gid)
    _os.chmod(dest, stat.S_IMODE(src_stat.st_mode))


_xattr_excluder_cache = {}


def _get_xattr_excluder(pattern):
    try:
        value = _xattr_excluder_cache[pattern]
    except KeyError:
        value = _xattr_excluder(pattern)
        _xattr_excluder_cache[pattern] = value

    return value


class _xattr_excluder:
    __slots__ = ("_pattern_split",)

    def __init__(self, pattern):
        if pattern is None:
            self._pattern_split = None
        else:
            pattern = pattern.split()
            if not pattern:
                self._pattern_split = None
            else:
                pattern.sort()
                self._pattern_split = tuple(pattern)

    def __call__(self, attr):
        if self._pattern_split is None:
            return False

        match = fnmatch.fnmatch
        for x in self._pattern_split:
            if match(attr, x):
                return True

        return False


def _copyxattr(src, dest, exclude=None):
    """Copy the extended attributes from |src| to |dest|"""
    try:
        attrs = xattr.list(src)
    except OSError as e:
        if e.errno != OperationNotSupported.errno:
            raise
        attrs = ()

    if attrs:
        if exclude is not None and isinstance(attrs[0], bytes):
            exclude = exclude.encode(_encodings["fs"])
        exclude = _get_xattr_excluder(exclude)

    for attr in attrs:
        if exclude(attr):
            continue
        try:
            xattr.set(dest, attr, xattr.get(src, attr))
            raise_exception = False
        except OSError:
            raise_exception = True
        if raise_exception:
            raise OperationNotSupported(
                _(
                    "Filesystem containing file '%s' "
                    "does not support extended attribute '%s'"
                )
                % (_unicode_decode(dest), _unicode_decode(attr))
            )


def _cmpxattr(src: bytes, dest: bytes, exclude=None) -> bool:
    """
    Compares extended attributes between |src| and |dest| and returns True
    if they are equal or xattrs are not supported, False otherwise.
    Assumes all given paths are UTF-8 encoded.
    """
    try:
        src_attrs = xattr.list(src)
        dest_attrs = xattr.list(dest)
    except OSError as e:
        if e.errno != OperationNotSupported.errno:
            raise
        return True

    if src_attrs:
        if exclude is not None and isinstance(src_attrs[0], bytes):
            exclude = exclude.encode(_encodings["fs"])
    exclude = _get_xattr_excluder(exclude)

    src_attrs = {attr for attr in src_attrs if not exclude(attr)}
    dest_attrs = {attr for attr in dest_attrs if not exclude(attr)}
    if src_attrs != dest_attrs:
        return False

    for attr in src_attrs:
        if xattr.get(src, attr) != xattr.get(dest, attr):
            return False
    return True


def movefile(
    src,
    dest,
    newmtime=None,
    sstat=None,
    mysettings=None,
    hardlink_candidates=None,
    encoding=_encodings["fs"],
):
    """moves a file from src to dest, preserving all permissions and attributes; mtime will
    be preserved even when moving across filesystems.  Returns mtime as integer on success
    and None on failure.  mtime is expressed in seconds in Python <3.3 and nanoseconds in
    Python >=3.3.  Move is atomic."""

    if mysettings is None:
        mysettings = portage.settings

    src_bytes = _unicode_encode(src, encoding=encoding, errors="strict")
    dest_bytes = _unicode_encode(dest, encoding=encoding, errors="strict")
    xattr_enabled = "xattr" in mysettings.features
    selinux_enabled = mysettings.selinux_enabled()
    if selinux_enabled:
        selinux = _unicode_module_wrapper(_selinux, encoding=encoding)
        _copyfile = selinux.copyfile
        _rename = selinux.rename
    else:
        _copyfile = copyfile
        _rename = _os.rename

    lchown = _unicode_func_wrapper(portage.data.lchown, encoding=encoding)
    os = _unicode_module_wrapper(_os, encoding=encoding, overrides=_os_overrides)

    try:
        if not sstat:
            sstat = os.lstat(src)

    except SystemExit as e:
        raise
    except Exception as e:
        writemsg(
            f"!!! {_('Stating source file failed... movefile()')}\n", noiselevel=-1
        )
        writemsg(f"!!! {e}\n", noiselevel=-1)
        return None

    destexists = 1
    try:
        dstat = os.lstat(dest)
    except OSError:
        dstat = os.lstat(os.path.dirname(dest))
        destexists = 0

    if bsd_chflags:
        if destexists and dstat.st_flags != 0:
            bsd_chflags.lchflags(dest, 0)
        # Use normal stat/chflags for the parent since we want to
        # follow any symlinks to the real parent directory.
        pflags = os.stat(os.path.dirname(dest)).st_flags
        if pflags != 0:
            bsd_chflags.chflags(os.path.dirname(dest), 0)

    if destexists:
        if not stat.S_ISLNK(sstat[stat.ST_MODE]) and stat.S_ISLNK(dstat[stat.ST_MODE]):
            try:
                os.unlink(dest)
                destexists = 0
            except SystemExit as e:
                raise
            except Exception as e:
                pass

    if stat.S_ISLNK(sstat[stat.ST_MODE]):
        try:
            target = os.readlink(src)
            if (
                mysettings
                and "EAPI" in mysettings
                and eapi_rewrites_symlinks(mysettings["EAPI"])
                and "D" in mysettings
                and target.startswith(mysettings["D"])
            ):
                writemsg(
                    f"!!! {_('Absolute symlink points to image directory.')}\n",
                    noiselevel=-1,
                )
                writemsg(f"!!! {dest} -> {target}\n", noiselevel=-1)
                target = target[len(mysettings["D"]) - 1 :]
            # Atomically update the path if it exists.
            try:
                os.rename(src, dest)
                return sstat.st_mtime_ns
            except OSError:
                # If it failed due to cross-link device, fallthru below.
                # Clear the target first so we can create it.
                try:
                    os.unlink(dest)
                except FileNotFoundError:
                    pass

            try:
                if selinux_enabled:
                    selinux.symlink(target, dest, src)
                else:
                    os.symlink(target, dest)
            except OSError as e:
                # Some programs will create symlinks automatically, so we have
                # to tolerate these links being recreated during the merge
                # process. In any case, if the link is pointing at the right
                # place, we're in good shape.
                if e.errno not in (errno.ENOENT, errno.EEXIST) or target != os.readlink(
                    dest
                ):
                    raise
            lchown(dest, sstat[stat.ST_UID], sstat[stat.ST_GID])

            try:
                _os.unlink(src_bytes)
            except OSError:
                pass

            try:
                os.utime(
                    dest,
                    ns=(sstat.st_mtime_ns, sstat.st_mtime_ns),
                    follow_symlinks=False,
                )
            except NotImplementedError:
                # utimensat() and lutimes() missing in libc.
                return os.stat(dest, follow_symlinks=False).st_mtime_ns
            else:
                return sstat.st_mtime_ns
        except SystemExit as e:
            raise
        except Exception as e:
            writemsg(f"!!! {_('failed to properly create symlink:')}\n", noiselevel=-1)
            writemsg(f"!!! {dest} -> {target}\n", noiselevel=-1)
            writemsg(f"!!! {e}\n", noiselevel=-1)
            return None

    hardlinked = False
    # Since identical files might be merged to multiple filesystems,
    # so os.link() calls might fail for some paths, so try them all.
    # For atomic replacement, first create the link as a temp file
    # and them use os.rename() to replace the destination.
    if hardlink_candidates:
        head, tail = os.path.split(dest)
        with tempfile.NamedTemporaryFile(
            dir=head, prefix=f".{tail}._portage_merge_.{portage.getpid()}"
        ) as hardlink_tmp_file:
            hardlink_tmp = hardlink_tmp_file.name
        try:
            os.unlink(hardlink_tmp)
        except OSError as e:
            if e.errno != errno.ENOENT:
                writemsg(
                    _("!!! Failed to remove hardlink temp file: %s\n")
                    % (hardlink_tmp,),
                    noiselevel=-1,
                )
                writemsg(f"!!! {e}\n", noiselevel=-1)
                return None
            del e
        for hardlink_src in hardlink_candidates:
            try:
                os.link(hardlink_src, hardlink_tmp)
            except OSError:
                continue
            else:
                try:
                    os.rename(hardlink_tmp, dest)
                except OSError as e:
                    writemsg(
                        _("!!! Failed to rename %s to %s\n") % (hardlink_tmp, dest),
                        noiselevel=-1,
                    )
                    writemsg(f"!!! {e}\n", noiselevel=-1)
                    return None
                hardlinked = True
                try:
                    _os.unlink(src_bytes)
                except OSError:
                    pass
                break

    renamefailed = 1
    if hardlinked:
        renamefailed = False
    if not hardlinked and (selinux_enabled or sstat.st_dev == dstat.st_dev):
        try:
            if selinux_enabled:
                selinux.rename(src, dest)
            else:
                os.rename(src, dest)
            renamefailed = 0
        except OSError as e:
            if e.errno != errno.EXDEV:
                # Some random error.
                writemsg(
                    f"!!! {_('Failed to move %(src)s to %(dest)s')}\n"
                    % {"src": src, "dest": dest},
                    noiselevel=-1,
                )
                writemsg(f"!!! {e}\n", noiselevel=-1)
                return None
            # Invalid cross-device-link 'bind' mounted or actually Cross-Device
    if renamefailed:
        if stat.S_ISREG(sstat[stat.ST_MODE]):
            dest_tmp = dest + "#new"
            dest_tmp_bytes = _unicode_encode(
                dest_tmp, encoding=encoding, errors="strict"
            )
            success = False
            try:  # For safety copy then move it over.
                _copyfile(src_bytes, dest_tmp_bytes)
                _apply_stat(sstat, dest_tmp_bytes)
                if xattr_enabled:
                    try:
                        _copyxattr(
                            src_bytes,
                            dest_tmp_bytes,
                            exclude=mysettings.get("PORTAGE_XATTR_EXCLUDE", ""),
                        )
                    except SystemExit:
                        raise
                    except:
                        msg = _(
                            "Failed to copy extended attributes. "
                            "In order to avoid this error, set "
                            'FEATURES="-xattr" in make.conf.'
                        )
                        msg = textwrap.wrap(msg, 65)
                        for line in msg:
                            writemsg(f"!!! {line}\n", noiselevel=-1)
                        raise
                _rename(dest_tmp_bytes, dest_bytes)
                _os.unlink(src_bytes)
                success = True
            except Exception as e:
                writemsg(
                    f"!!! {_('copy %(src)s -> %(dest)s failed.')}\n"
                    % {"src": src, "dest": dest},
                    noiselevel=-1,
                )
                writemsg(f"!!! {e}\n", noiselevel=-1)
                return None
            finally:
                if not success:
                    try:
                        _os.unlink(dest_tmp_bytes)
                    except OSError:
                        pass
        else:
            # we don't yet handle special, so we need to fall back to /bin/mv
            a = spawn([MOVE_BINARY, "-f", src, dest], env=os.environ)
            if a != os.EX_OK:
                writemsg(_("!!! Failed to move special file:\n"), noiselevel=-1)
                writemsg(
                    _("!!! '%(src)s' to '%(dest)s'\n")
                    % {
                        "src": _unicode_decode(src, encoding=encoding),
                        "dest": _unicode_decode(dest, encoding=encoding),
                    },
                    noiselevel=-1,
                )
                writemsg(f"!!! {a}\n", noiselevel=-1)
                return None  # failure

    # In Python <3.3 always use stat_obj[stat.ST_MTIME] for the integral timestamp
    # which is returned, since the stat_obj.st_mtime float attribute rounds *up*
    # if the nanosecond part of the timestamp is 999999881 ns or greater.
    try:
        if hardlinked:
            newmtime = os.stat(dest).st_mtime_ns
        else:
            # Note: It is not possible to preserve nanosecond precision
            # (supported in POSIX.1-2008 via utimensat) with the IEEE 754
            # double precision float which only has a 53 bit significand.
            if newmtime is not None:
                os.utime(dest, ns=(newmtime, newmtime))
            else:
                newmtime = sstat.st_mtime_ns
                if renamefailed:
                    # If rename succeeded then timestamps are automatically
                    # preserved with complete precision because the source
                    # and destination inodes are the same. Otherwise, manually
                    # update timestamps with nanosecond precision.
                    os.utime(dest, ns=(newmtime, newmtime))
    except OSError:
        # The utime can fail here with EPERM even though the move succeeded.
        # Instead of failing, use stat to return the mtime if possible.
        try:
            newmtime = os.stat(dest).st_mtime_ns
        except OSError as e:
            writemsg(_("!!! Failed to stat in movefile()\n"), noiselevel=-1)
            writemsg(f"!!! {dest}\n", noiselevel=-1)
            writemsg(f"!!! {str(e)}\n", noiselevel=-1)
            return None

    if bsd_chflags:
        # Restore the flags we saved before moving
        if pflags:
            bsd_chflags.chflags(os.path.dirname(dest), pflags)

    return newmtime
