#!/usr/bin/env python3
# Copyright (C) 2005-2024 Canonical Ltd
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA

"""Operating system utilities for dromedary transport layer."""

import errno
import os
import random
import stat
import string
import sys
from collections.abc import Callable
from typing import IO

if sys.platform != "win32":
    import fcntl
else:
    fcntl = None  # type: ignore[assignment]


def pumpfile(
    from_file: IO[bytes],
    to_file: IO[bytes],
    read_length: int = -1,
    buff_size: int = 32768,
) -> int:
    """Copy bytes from from_file to to_file, optionally limiting total length.

    Args:
        from_file: File-like object to read from.
        to_file: File-like object to write to.
        read_length: Total number of bytes to copy. -1 (the default) means
            copy to EOF.
        buff_size: Size of each individual read.

    Returns:
        The total number of bytes copied.
    """
    written = 0
    if read_length is not None and read_length >= 0:
        # Read exactly read_length bytes total, in buff_size chunks.
        bytes_left = read_length
        while bytes_left > 0:
            chunk = from_file.read(min(buff_size, bytes_left))
            if not chunk:
                break
            to_file.write(chunk)
            bytes_left -= len(chunk)
            written += len(chunk)
    else:
        while True:
            chunk = from_file.read(buff_size)
            if not chunk:
                break
            to_file.write(chunk)
            written += len(chunk)
    return written


def pump_string_file(
    string: bytes | str, to_file: IO[bytes], segment_size: int = 8192
) -> None:
    """Write a string to a file efficiently.

    Args:
        string: String or bytes to write
        to_file: File-like object to write to
        segment_size: Size of chunks to write
    """
    if isinstance(string, str):
        string = string.encode("utf-8")

    offset = 0
    while offset < len(string):
        segment = string[offset : offset + segment_size]
        to_file.write(segment)
        offset += len(segment)


def fancy_rename(
    old: str,
    new: str,
    rename_func: Callable[[str, str], None],
    unlink_func: Callable[[str], None],
) -> None:
    """A fancy rename, when you don't have atomic rename.

    :param old: The old path, to rename from
    :param new: The new path, to rename to
    :param rename_func: The potentially non-atomic rename function
    :param unlink_func: A way to delete the target file if the full rename
        succeeds
    """
    import time

    from dromedary.errors import NoSuchFile

    # sftp rename doesn't allow overwriting, so play tricks:
    base = os.path.basename(new)
    dirname = os.path.dirname(new)
    tmp_name = "tmp.%s.%.9f.%d.%s" % (base, time.time(), os.getpid(), rand_chars(10))
    tmp_name = pathjoin(dirname, tmp_name)

    # Rename the file out of the way, but keep track if it didn't exist
    file_existed = False
    try:
        rename_func(new, tmp_name)
    except NoSuchFile:
        pass
    except (FileNotFoundError, NotADirectoryError):
        pass
    except OSError as e:
        # paramiko SFTP rename raises IOError with errno=None on failure.
        if e.errno not in (errno.ENOENT, errno.ENOTDIR, None):
            raise
    else:
        file_existed = True

    success = False
    try:
        rename_func(old, new)
        success = True
    except FileNotFoundError:
        # source and target may be aliases of each other on a
        # case-insensitive filesystem
        if file_existed and old.lower() == new.lower():
            pass
        else:
            raise
    finally:
        if file_existed:
            if success:
                unlink_func(tmp_name)
            else:
                rename_func(tmp_name, new)


def fdatasync(fileno: int | IO[bytes]) -> None:
    """Force data to be written to disk.

    Args:
        fileno: File descriptor or file object with fileno() method
    """
    if hasattr(fileno, "fileno"):
        fileno = fileno.fileno()

    if hasattr(os, "fdatasync"):
        os.fdatasync(fileno)
    elif hasattr(os, "fsync"):
        os.fsync(fileno)
    # If neither is available, do nothing (some platforms don't support this)


def file_kind_from_stat_mode(stat_mode: int) -> str:
    """Determine file type from stat mode bits.

    Args:
        stat_mode: Mode from os.stat()

    Returns:
        String describing file type: 'file', 'directory', 'symlink'
    """
    if stat.S_ISREG(stat_mode):
        return "file"
    elif stat.S_ISDIR(stat_mode):
        return "directory"
    elif stat.S_ISLNK(stat_mode):
        return "symlink"
    elif stat.S_ISCHR(stat_mode):
        return "chardev"
    elif stat.S_ISBLK(stat_mode):
        return "block"
    elif stat.S_ISFIFO(stat_mode):
        return "fifo"
    elif stat.S_ISSOCK(stat_mode):
        return "socket"
    else:
        return "unknown"


def set_fd_cloexec(fd: int | IO[bytes]) -> None:
    """Set the close-on-exec flag for a file descriptor.

    Args:
        fd: File descriptor or file object with fileno() method
    """
    if hasattr(fd, "fileno"):
        fd = fd.fileno()

    if fcntl is not None and hasattr(fcntl, "FD_CLOEXEC"):
        flags = fcntl.fcntl(fd, fcntl.F_GETFD)
        fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC)


def rand_chars(n: int) -> str:
    """Generate random characters.

    Args:
        n: Number of characters to generate

    Returns:
        String of random alphanumeric characters
    """
    chars = string.ascii_letters + string.digits
    return "".join(random.choice(chars) for _ in range(n))  # noqa: S311


def splitpath(path: str) -> list[str]:
    """Split a path into components.

    Args:
        path: Path to split

    Returns:
        List of path components
    """
    if not path or path == "/":
        return []

    # Remove leading slash for consistent behavior
    if path.startswith("/"):
        path = path[1:]

    # Remove trailing slash
    if path.endswith("/"):
        path = path[:-1]

    if not path:
        return []

    return path.split("/")


def pathjoin(*args: str) -> str:
    """Join path components, handling various edge cases.

    Args:
        *args: Path components to join

    Returns:
        Joined path
    """
    if not args:
        return ""

    # Filter out empty components
    components = [arg for arg in args if arg and arg != "."]

    if not components:
        return ""

    # Join with forward slashes (transport paths use forward slashes)
    result = "/".join(components)

    # Handle absolute paths
    if args[0].startswith("/"):
        result = "/" + result

    return result


def get_terminal_encoding() -> str:
    """Get the terminal's character encoding.

    Returns:
        String name of encoding, defaults to 'utf-8'
    """
    import locale

    # Try to get the terminal encoding
    encoding = None

    if hasattr(sys.stdout, "encoding") and sys.stdout.encoding:
        encoding = sys.stdout.encoding

    if not encoding:
        try:
            encoding = locale.getpreferredencoding()
        except Exception:
            pass

    if not encoding:
        encoding = "utf-8"  # Safe default

    return encoding


def getcwd() -> str:
    """Return the current working directory as a unicode string."""
    return os.getcwd()


def abspath(path: str) -> str:
    """Return the absolute version of a path.

    On Windows the returned path uses forward slashes so that callers can
    compare it directly against URL-derived paths (which are constructed
    with forward slashes throughout the codebase).
    """
    result = os.path.abspath(path)
    if sys.platform == "win32":
        result = result.replace("\\", "/")
    return result


def get_umask() -> int:
    """Return the current umask."""
    umask = os.umask(0)
    os.umask(umask)
    return umask


def supports_symlinks(path: str | None = None) -> bool:
    """Return True if the filesystem supports symlinks."""
    return getattr(os, "symlink", None) is not None


def get_user_encoding() -> str:
    """Return the encoding used for user-facing text."""
    return get_terminal_encoding()


def _posix_normpath(path: str) -> str:
    return os.path.normpath(path)


normpath = os.path.normpath
split = os.path.split
MIN_ABS_PATHLENGTH = 3 if sys.platform == "win32" else 1


def _win32_abspath(path: str) -> str:
    return os.path.abspath(path).replace("\\", "/")


def _win32_normpath(path: str) -> str:
    """Normalize a Windows path.

    This is used on Windows to normalize path separators and handle
    drive letters properly.

    Args:
        path: Path to normalize

    Returns:
        Normalized path string
    """
    if sys.platform == "win32":
        # For UNC paths we do our own `..` collapse before delegating to
        # `ntpath.normpath`. Python treats the share as the UNC root and
        # refuses to walk above it (so `\\HOST\share\..` stays
        # `\\HOST\share\`), but `EmulatedWin32LocalTransport.clone` needs
        # `..` from the share to land at `\\HOST` so callers can keep
        # walking up to the host root.
        if path.startswith("//") or path.startswith("\\\\"):
            unified = path.replace("\\", "/")
            # Collapse runs of `/` (except the leading `//` that marks UNC)
            # so callers like `osutils.pathjoin("//HOST/", "..")` which emit
            # `///HOST//..` still parse the host correctly.
            squeezed = "//"
            for ch in unified[2:]:
                if not (ch == "/" and squeezed.endswith("/")):
                    squeezed += ch
            unified = squeezed
            host_end = unified.find("/", 2)
            host_part = unified if host_end == -1 else unified[:host_end]
            tail = "" if host_end == -1 else unified[host_end + 1 :]
            stack = []
            for segment in tail.split("/"):
                if segment == "..":
                    if stack:
                        stack.pop()
                    # else: silently absorbed; can't go above the host
                elif segment and segment != ".":
                    stack.append(segment)
            if not stack:
                return host_part
            return host_part + "/" + "/".join(stack)

        import os.path

        return os.path.normpath(path).replace("\\", "/")
    else:
        # On non-Windows, just return the path as-is
        return path
