# Copyright (C) 2025 Breezy Contributors
#
# 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

"""OS utilities for bzrformats using only standard library."""

import os
import shutil
import sys
import unicodedata

from ._bzr_rs import osutils as _osutils_rs


def split(path):
    """Split a pathname into directory and basename parts."""
    if isinstance(path, bytes):
        return os.path.split(path)
    else:
        # For unicode strings, encode to UTF-8, split, then decode
        encoded = path.encode("utf-8")
        dirname, basename = os.path.split(encoded)
        return dirname.decode("utf-8"), basename.decode("utf-8")


def pathjoin(*args):
    """Join paths together."""
    if not args:
        return b"" if isinstance(args[0], bytes) else ""

    # Check if we're dealing with bytes or strings
    if isinstance(args[0], bytes):
        return os.path.join(*args)
    else:
        # For unicode strings, encode to UTF-8, join, then decode
        encoded_args = [arg.encode("utf-8") for arg in args]
        result = os.path.join(*encoded_args)
        return result.decode("utf-8")


def pumpfile(from_file, to_file, buffer_size=65536):
    """Copy data from one file-like object to another.

    Returns the number of bytes copied.
    """
    initial_pos = to_file.tell() if hasattr(to_file, "tell") else 0
    shutil.copyfileobj(from_file, to_file, buffer_size)
    if hasattr(to_file, "tell"):
        return to_file.tell() - initial_pos
    else:
        # If we can't tell the position, we can't return accurate byte count
        return 0


def chunks_to_lines(chunks):
    """Convert chunks to lines."""
    from ._bzr_rs import osutils as _osutils_rs

    return _osutils_rs.chunks_to_lines(chunks)


def normalized_filename(filename):
    """Return the normalized form of a filename.

    Returns (normalized_name, can_access) tuple.
    """
    if isinstance(filename, bytes):
        # For bytes, try to decode as UTF-8 first
        try:
            unicode_filename = filename.decode("utf-8")
        except UnicodeDecodeError:
            # If it's not valid UTF-8, return as-is
            return filename, True
    else:
        unicode_filename = filename

    # Normalize using NFC (Canonical Decomposition, followed by Canonical Composition)
    normalized = unicodedata.normalize("NFC", unicode_filename)

    if isinstance(filename, bytes):
        try:
            return normalized.encode("utf-8"), True
        except UnicodeEncodeError:
            return filename, True
    else:
        return normalized, True


def splitpath(path):
    """Split a path into a list of components."""
    from ._bzr_rs import osutils as _osutils_rs

    return _osutils_rs.splitpath(path)


def file_kind_from_stat_mode(mode):
    """Return the file kind based on the stat mode."""
    from ._bzr_rs import osutils as _osutils_rs

    return _osutils_rs.file_kind_from_stat_mode(mode)


def contains_whitespace(s):
    """Return True if the string contains whitespace characters."""
    from ._bzr_rs import osutils as _osutils_rs

    if isinstance(s, bytes):
        s = s.decode("utf-8")
    return _osutils_rs.contains_whitespace(s)


def sha_strings(strings):
    """Return the sha1 of concatenated strings."""
    from ._bzr_rs import osutils as _osutils_rs

    return _osutils_rs.sha_strings(strings)


def sha_string(string):
    """Return the sha1 of a single string."""
    from ._bzr_rs import osutils as _osutils_rs

    return _osutils_rs.sha_string(string)


def dirname(path):
    """Return the directory part of a path."""
    if isinstance(path, bytes):
        return os.path.dirname(path)
    else:
        # For unicode strings, encode to UTF-8, get dirname, then decode
        encoded = path.encode("utf-8")
        result = os.path.dirname(encoded)
        return result.decode("utf-8")


def basename(path):
    """Return the basename part of a path."""
    if isinstance(path, bytes):
        return os.path.basename(path)
    else:
        # For unicode strings, encode to UTF-8, get basename, then decode
        encoded = path.encode("utf-8")
        result = os.path.basename(encoded)
        return result.decode("utf-8")


def chunks_to_lines_iter(chunks_iter):
    """Convert an iterator of chunks to an iterator of lines."""
    buffer = b""
    for chunk in chunks_iter:
        buffer += chunk
        while b"\n" in buffer:
            line, buffer = buffer.split(b"\n", 1)
            yield line + b"\n"

    # Yield any remaining data as the last line (without newline)
    if buffer:
        yield buffer


def file_iterator(file_obj, chunk_size=65536):
    """Iterate over the contents of a file in chunks."""
    while True:
        chunk = file_obj.read(chunk_size)
        if not chunk:
            break
        yield chunk


def normalizes_filenames():
    """Check if the filesystem normalizes filenames (e.g. Mac OS X)."""
    from ._bzr_rs import osutils as _osutils_rs

    return _osutils_rs.normalizes_filenames()


def rand_chars(length):
    """Generate a string of random characters."""
    from ._bzr_rs import osutils as _osutils_rs

    return _osutils_rs.rand_chars(length)


class DirReader:
    """An interface for reading directories."""

    def top_prefix_to_starting_dir(self, top, prefix=""):
        """Converts top and prefix to a starting dir entry.

        :param top: A utf8 path
        :param prefix: An optional utf8 path to prefix output relative paths
            with.
        :return: A tuple starting with prefix, and ending with the native
            encoding of top.
        """
        raise NotImplementedError(self.top_prefix_to_starting_dir)

    def read_dir(self, prefix, top):
        """Read a specific dir.

        :param prefix: A utf8 prefix to be preprended to the path basenames.
        :param top: A natively encoded path to read.
        :return: A list of the directories contents. Each item contains:
            (utf8_relpath, utf8_name, kind, lstatvalue, native_abspath)
        """
        raise NotImplementedError(self.read_dir)


_selected_dir_reader = None


def safe_unicode(unicode_or_utf8_string):
    """Coerce unicode_or_utf8_string into unicode.

    If it is unicode, it is returned.
    Otherwise it is decoded from utf-8. If decoding fails, the exception is
    wrapped in a TypeError exception.
    """
    from ._bzr_rs import osutils as _osutils_rs

    return _osutils_rs.safe_unicode(unicode_or_utf8_string)


def safe_utf8(unicode_or_utf8_string):
    """Coerce unicode_or_utf8_string to a utf8 string.

    If it is a str, it is returned.
    If it is Unicode, it is encoded into a utf-8 string.
    """
    from ._bzr_rs import osutils as _osutils_rs

    return _osutils_rs.safe_utf8(unicode_or_utf8_string)


def _walkdirs_utf8(top, prefix="", fs_enc=None):
    """Yield data about all the directories in a tree.

    This yields the same information as walkdirs() only each entry is yielded
    in utf-8. On platforms which have a filesystem encoding of utf8 the paths
    are returned as exact byte-strings.

    :return: yields a tuple of (dir_info, [file_info])
        dir_info is (utf8_relpath, path-from-top)
        file_info is (utf8_relpath, utf8_name, kind, lstat, path-from-top)
        if top is an absolute path, path-from-top is also an absolute path.
        path-from-top might be unicode or utf8, but it is the correct path to
        pass to os functions to affect the file in question. (such as os.lstat)
    """
    global _selected_dir_reader
    if _selected_dir_reader is None:
        if fs_enc is None:
            fs_enc = sys.getfilesystemencoding()
        # Always use the python version for bzrformats
        _selected_dir_reader = UnicodeDirReader()

    # 0 - relpath, 1- basename, 2- kind, 3- stat, 4-toppath
    # But we don't actually uses 1-3 in pending, so set them to None
    pending = [[_selected_dir_reader.top_prefix_to_starting_dir(top, prefix)]]
    read_dir = _selected_dir_reader.read_dir
    _directory = "directory"
    while pending:
        relroot, _, _, _, top = pending[-1].pop()
        if not pending[-1]:
            pending.pop()
        dirblock = sorted(read_dir(relroot, top))
        yield (relroot, top), dirblock
        # push the user specified dirs from dirblock
        next = [d for d in reversed(dirblock) if d[2] == _directory]
        if next:
            pending.append(next)


class UnicodeDirReader(DirReader):
    """A dir reader for non-utf8 file systems, which transcodes."""

    __slots__ = ["_utf8_encode"]

    def __init__(self):
        """Initialize the UTF-8 directory reader."""
        import codecs

        self._utf8_encode = codecs.getencoder("utf8")

    def top_prefix_to_starting_dir(self, top, prefix=""):
        """See DirReader.top_prefix_to_starting_dir."""
        return (safe_utf8(prefix), None, None, None, safe_unicode(top))

    def read_dir(self, prefix, top):
        """Read a single directory from a non-utf8 file system.

        top, and the abspath element in the output are unicode, all other paths
        are utf8. Local disk IO is done via unicode calls to listdir etc.

        This is currently the fallback code path when the filesystem encoding is
        not UTF-8. It may be better to implement an alternative so that we can
        safely handle paths that are not properly decodable in the current
        encoding.

        See DirReader.read_dir for details.
        """
        _utf8_encode = self._utf8_encode

        relprefix = prefix + b"/" if prefix else b""
        top_slash = top + "/"

        dirblock = []
        append = dirblock.append
        for entry in os.scandir(top):
            name = os.fsdecode(entry.name)
            abspath = top_slash + name
            name_utf8 = _utf8_encode(name, "surrogateescape")[0]
            statvalue = entry.stat(follow_symlinks=False)
            kind = file_kind_from_stat_mode(statvalue.st_mode)
            append((relprefix + name_utf8, name_utf8, kind, statvalue, abspath))
        return sorted(dirblock)


def is_inside(dir, fname):
    """Check if fname is inside dir.

    The empty string as dir is considered to contain everything.
    A path is considered to be inside itself.

    :param dir: Directory path (bytes or str)
    :param fname: File path to check (bytes or str)
    :return: True if fname is inside dir
    """
    from ._bzr_rs import osutils as _osutils_rs

    if isinstance(dir, bytes):
        dir = dir.decode("utf-8")
    if isinstance(fname, bytes):
        fname = fname.decode("utf-8")
    return _osutils_rs.is_inside(dir, fname)


def is_inside_any(dir_list, fname):
    """Check if fname is inside any of the directories in dir_list.

    :param dir_list: List of directory paths
    :param fname: File path to check
    :return: True if fname is inside any directory in dir_list
    """
    from ._bzr_rs import osutils as _osutils_rs

    dir_list = [(d.decode("utf-8") if isinstance(d, bytes) else d) for d in dir_list]
    if isinstance(fname, bytes):
        fname = fname.decode("utf-8")
    return _osutils_rs.is_inside_any(dir_list, fname)


def parent_directories(filename):
    """Return a list of parent directories of filename.

    :param filename: Path (bytes or str)
    :return: List of parent directory paths
    """
    from ._bzr_rs import osutils as _osutils_rs

    if isinstance(filename, bytes):
        filename = filename.decode("utf-8")
    return _osutils_rs.parent_directories(filename)


def split_lines(text):
    r"""Split text into lines, keeping line endings.

    Args:
        text: bytes to split

    Returns:
        List of byte strings, each ending with \\n where appropriate
    """
    from ._bzr_rs import osutils as _osutils_rs

    return _osutils_rs.split_lines(text)


# IterableFile is a Rust pyclass (a file-like object over an iterator of byte
# chunks); re-exported here.
IterableFile = _osutils_rs.IterableFile
