from __future__ import generator_stop

import errno
import logging
import os
import os.path
import platform
import re
import shutil
import stat
from fnmatch import fnmatch
from functools import partial
from itertools import zip_longest
from operator import attrgetter
from os import DirEntry, PathLike, fspath
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterable, Iterator, List, Optional, Set, Tuple, Union

from ._files import BaseDirEntry, MyDirEntryT, entrysuffix
from .datetime import datetime_from_utc_timestamp
from .file import FILE_IO_BUFFER_SIZE, equal_files, iterfilelike
from .iter import is_empty
from .ops import logical_implication
from .os import _not_available, islink, uncabspath

if TYPE_CHECKING:
    from datetime import datetime

PathType = Union[str, PathLike]
EntryType = Union[Path, DirEntry]

logger = logging.getLogger(__name__)

ascii_control = {chr(i) for i in range(1, 32)}
ntfs_illegal_chars = {"\0", "/"}
fat_illegal_chars = {"\0", "/", "?", "<", ">", "\\", ":", "*", "|", '"', "^"}  # ^ really?

windows_ntfs_illegal_chars = ntfs_illegal_chars | {"\\", ":", "*", "?", '"', "<", ">", "|"} | ascii_control
windows_fat_illegal_chars = fat_illegal_chars

windows_illegal_chars = windows_ntfs_illegal_chars | windows_fat_illegal_chars
unix_illegal_chars = {"\0", "/"}
mac_illegal_chars = {"\0", "/", ":"}

windows_sep = r"\/"
linux_sep = r"/"
mac_sep = r"/:"

windows_reserved_names = {
    "CON",
    "PRN",
    "AUX",
    "NUL",
    "COM1",
    "COM2",
    "COM3",
    "COM4",
    "COM5",
    "COM6",
    "COM7",
    "COM8",
    "COM9",
    "LPT1",
    "LPT2",
    "LPT3",
    "LPT4",
    "LPT5",
    "LPT6",
    "LPT7",
    "LPT8",
    "LPT9",
}


class fileextensions:
    audio = ("wav", "mp3", "aac", "flac", "m4a", "m4b", "aiff", "ogg", "wma", "mka", "ac3", "dts")
    video = (
        "avi",
        "mp4",
        "m4v",
        "mkv",
        "mpg",
        "mpeg",
        "wmv",
        "mov",
        "flv",
        "f4v",
        "webm",
        "vob",
        "ogv",
        "m2ts",
        "rm",
        "rmvb",
        "asf",
        "3gp",
        "nsv",
        "divx",
        "ogm",
    )
    images = ("bmp", "jpg", "jpeg", "png", "gif", "tif", "tiff", "tga")

    archives = ("zip", "rar", "ace", "tar", "tgz", "tbz", "7z", "cab", "dmg", "wim")
    bytecode = ("pyc", "class")
    configuration = ("ini", "toml", "conf", "reg")
    database = ("cat", "sqlite", "db")
    documents = ("txt", "pdf", "doc", "docx", "ps", "rtf", "pgs")
    executables = ("exe", "dll", "so", "scr", "msi", "pyd", "sys", "cpl")  # native code
    image_archives = ("cbz", "cbr", "cb7", "cbt", "cba")
    scripts = ("bat", "ps1", "sh", "c", "cpp", "cs", "py", "php", "js", "lua", "pl")  # and source code files

    disc_images_binary = ("iso", "bin", "mdf", "img")
    disc_images_sidecar = ("cue", "mds", "ccd")
    disc_images = disc_images_binary + disc_images_sidecar

    game_images = ("gcn", "nds", "3ds", "wad", "xbx", "gba", "gb", "nes", "sfc", "n64", "z64")
    compressed = ("gz", "bz2", "lzma", "xz", "z")
    subtitles = ("srt", "sub", "idx", "sup", "ssa", "ass")
    scene = ("nfo", "sfv")

    windows = ("library-ms", "desklink", "lnk", "dmp")
    macos = ("crash",)
    os = windows + macos


class IllegalFilename(ValueError):
    pass


class WindowsIllegalFilename(IllegalFilename):
    pass


class FileProperties:

    __slots__ = ("relpath", "size", "isdir", "abspath", "id", "modtime", "hash")

    def __init__(self, relpath, size, isdir, abspath=None, id=None, modtime=None, hash=None):
        # type: (str, int, bool, Optional[str], Optional[Any], Optional[datetime], Optional[str]) -> None

        self.relpath = relpath
        self.size = size
        self.isdir = isdir
        self.abspath = abspath
        self.id = id
        self.modtime = modtime
        self.hash = hash

    @classmethod
    def keys(cls):
        return cls.__slots__

    def values(self):
        return attrgetter(*self.__slots__)(self)

    def __iter__(self):
        # type: () -> tuple

        return iter(self.values())

    def __eq__(self, other):
        # type: (Any, ) -> bool

        return self.values() == other.values()

    def __repr__(self):
        # type: () -> str

        args = (f"{k}={v!r}" for k, v in zip(self.keys(), self.values()) if v is not None)
        return "FileProperties({})".format(", ".join(args))


class DirEntryStub:
    __slots__ = ("name", "path")

    def __init__(self, name, path):
        self.name = name
        self.path = path


class MyDirEntry(BaseDirEntry):
    __slots__ = ("basepath", "_relpath", "follow")

    def __init__(self, entry):
        BaseDirEntry.__init__(self, entry)

        self.basepath = None  # type: Optional[str]
        self._relpath = None  # type: Optional[str]
        self.follow = True

    @property
    def relpath(self):
        # type: () -> str

        if self._relpath is not None:
            return self._relpath

        if self.basepath is None:
            raise RuntimeError("relpath cannot be returned, because basepath is not set")

        self._relpath = os.path.relpath(self.entry.path, self.basepath)
        return self._relpath

    @relpath.setter
    def relpath(self, path):
        # type: (str, ) -> None

        self._relpath = path


def mdatetime(path, aslocal=False):
    # type: (PathType, bool) -> datetime

    """Returns the last modified date of `path`
    as a timezone aware datetime object.

    If `aslocal=True` it will be formatted as local time,
    and UTC otherwise (the default).
    """

    if isinstance(path, (Path, DirEntry)):
        mtime = path.stat().st_mtime
    else:
        mtime = os.stat(path).st_mtime

    return datetime_from_utc_timestamp(mtime, aslocal)


def rename(old, new):
    # type: (PathType, PathType) -> None

    """Renames `old` to `new`. Fails if `new` already exists.
    This is the default behaviour of `os.rename` on Windows.
    This function should do the same cross-platform.
    It is however not race free.
    """

    # fixme: renaming a non-existing file in a non-existing folder yields a PermissionError not FileNotFoundError

    if os.path.exists(new):
        raise FileExistsError(new)

    os.renames(old, new)  # fixme: don't use rename*s*


def copy_file_generator(source, dest, buffer_size=FILE_IO_BUFFER_SIZE, overwrite_readonly=False):
    # type: (PathType, PathType, int, bool) -> Iterator[None]

    """Partial file is not deleted if exception gets raised anywhere."""

    try:
        os.makedirs(os.path.dirname(dest))
    except OSError as e:
        if e.errno != errno.EEXIST:
            raise

    if overwrite_readonly:
        try:
            make_writeable(dest)
        except OSError as e:
            if e.errno != errno.ENOENT:
                raise

    with open(source, "rb") as src, open(dest, "wb") as dst:
        for data in iterfilelike(src, chunk_size=buffer_size):
            dst.write(data)
            yield

    shutil.copystat(source, dest)


def st_mode_to_str(st_mode):
    # type: (int, ) -> str

    if stat.S_ISDIR(st_mode):
        return "directory"
    elif stat.S_ISREG(st_mode):
        return "regular file"
    elif stat.S_ISCHR(st_mode):
        return "character special device file"
    elif stat.S_ISBLK(st_mode):
        return "block special device file"
    elif stat.S_ISFIFO(st_mode):
        return "named pipe"
    elif stat.S_ISLNK(st_mode):
        return "symbolic link"
    elif stat.S_ISSOCK(st_mode):
        return "socket"
    else:
        return "unknown"


def append_to_filename(path, s):
    # type: (PathType, str) -> str

    """Add `s` to the filename given in `path`. It's added before the extension, not after."""

    root, ext = os.path.splitext(path)
    return root + s + ext


def scandir_error_log(entry, exception):
    logger.exception("Error in %s", entry.path, exc_info=exception)


def scandir_error_raise(entry, exception):
    raise exception


def scandir_error_ignore(entry, exception):
    pass


def _scandir_rec_skippable(rootentry, files=True, others=False, follow_symlinks=True, errorfunc=scandir_error_raise):
    # type: (DirEntry, bool, bool, bool, Callable[[DirEntry, Exception], None]) -> Iterator[MyDirEntry]

    try:
        with os.scandir(rootentry.path) as it:
            for entry in it:
                if files and entry.is_file(follow_symlinks=follow_symlinks):
                    yield MyDirEntry(entry)
                elif entry.is_dir(follow_symlinks=follow_symlinks):
                    if not follow_symlinks and islink(entry):  # must be a windows junction
                        continue
                    se = MyDirEntry(entry)
                    yield se
                    if se.follow:
                        for e in _scandir_rec_skippable(entry, files, others, follow_symlinks, errorfunc):
                            yield e
                else:
                    if others:
                        yield MyDirEntry(entry)
    except OSError as e:
        errorfunc(rootentry, e)


def _scandir_rec(
    rootentry, files=True, dirs=False, others=False, rec=True, follow_symlinks=True, errorfunc=scandir_error_raise
):
    # type: (DirEntry, bool, bool, bool, bool, bool, Callable[[DirEntry, Exception], None]) -> Iterator[DirEntry]

    try:
        with os.scandir(rootentry.path) as it:
            for entry in it:
                if files and entry.is_file(follow_symlinks=follow_symlinks):
                    yield entry
                elif entry.is_dir(follow_symlinks=follow_symlinks):
                    if not follow_symlinks and islink(
                        entry
                    ):  # must be a windows junction. entry.is_symlink() doesn't support junctions
                        continue
                    if dirs:
                        yield entry
                    if rec:
                        for e in _scandir_rec(entry, files, dirs, others, rec, follow_symlinks, errorfunc):
                            yield e
                else:
                    if others:
                        yield entry
    except OSError as e:
        errorfunc(rootentry, e)


def scandir_rec(
    path,
    files=True,
    dirs=False,
    others=False,
    rec=True,
    follow_symlinks=True,
    relative=False,
    allow_skip=False,
    errorfunc=scandir_error_log,
):
    # type: (PathType, bool, bool, bool, bool, bool, bool, bool, Callable[[MyDirEntryT, Exception], None]) -> Iterator[MyDirEntryT]

    if not logical_implication(allow_skip, dirs and rec):
        raise ValueError("allow_skip implies dirs and rec")

    if isinstance(path, PathLike):
        path = fspath(path)

    entry = DirEntryStub(os.path.basename(path), uncabspath(path))  # for python 2 compat. and long filename support

    if not allow_skip:
        it = _scandir_rec(entry, files, dirs, others, rec, follow_symlinks, errorfunc)
    else:
        it = _scandir_rec_skippable(entry, files, others, follow_symlinks, errorfunc)

    if not relative:
        return it
    else:
        basepath = uncabspath(path)
        if not allow_skip:
            # entry is a `os.DirEntry`
            def modpathrelative(entry):
                entry = MyDirEntry(entry)
                entry.basepath = basepath
                return entry

        else:
            # entry is a `MyDirEntry`
            def modpathrelative(entry):
                entry.basepath = basepath
                return entry

        return map(modpathrelative, it)


def scandir_ext(
    path: PathType,
    extensions: Set[str],
    rec: bool = True,
    follow_symlinks: bool = False,
    relative: bool = False,
    errorfunc: Callable = scandir_error_log,
) -> Iterator[DirEntry]:

    for entry in scandir_rec(
        path, files=True, dirs=False, rec=rec, follow_symlinks=follow_symlinks, relative=relative, errorfunc=errorfunc
    ):
        if entrysuffix(entry).lower() in extensions:
            yield entry


# fixme: benchmark and delete
def scandir_ext_2(path: str, extensions: Set[str], rec: bool = True) -> Iterator[Path]:

    if rec:
        paths = Path(uncabspath(path)).rglob("*")
    else:
        paths = Path(uncabspath(path)).glob("*")

    for p in paths:
        if p.suffix.lower() in extensions:
            yield p


def _scandir_depth(rootentry, depth, errorfunc):

    try:
        with os.scandir(rootentry.path) as it:
            for entry in it:
                if entry.is_dir():
                    yield depth, entry
                    yield from _scandir_depth(entry, depth + 1, errorfunc)

        with os.scandir(rootentry.path) as it:
            for entry in it:
                if entry.is_file():
                    yield depth, entry

    except OSError as e:
        errorfunc(rootentry, e)


def scandir_depth(path, depth=0, onerror=scandir_error_log):
    # type: (str, int, Callable) -> Iterator[Tuple[int, DirEntry]]

    """Recursive version of `scandir` which yields the current path depth
    along with the DirEntry.
    Directories are returned first, then files.
    The order is arbitrary otherwise.
    """

    entry = DirEntryStub(os.path.basename(path), uncabspath(path))  # for python 2 compat. and long filename support
    return _scandir_depth(entry, depth, onerror)


def make_readonly(path, stats=None):
    # type: (PathType, Optional[os.stat_result]) -> None

    """deletes all write flags"""
    if not stats:
        stats = os.stat(path)
    os.chmod(path, stat.S_IMODE(stats.st_mode) & ~stat.S_IWUSR & ~stat.S_IWGRP & ~stat.S_IWOTH)


def make_writeable(path, stats=None):
    # type: (PathType, Optional[os.stat_result]) -> None

    """set owner write flag"""
    if not stats:
        stats = os.stat(path)
    os.chmod(path, stat.S_IMODE(stats.st_mode) | stat.S_IWRITE)


def is_writeable(stats):
    # type: (os.stat_result, ) -> bool

    return stats.st_mode & stat.S_IWRITE != 0


def is_readable(stats):
    # type: (os.stat_result, ) -> bool

    return stats.st_mode & stat.S_IREAD != 0


def isfile(stats):
    # type: (os.stat_result, ) -> bool

    return stat.S_ISREG(stats.st_mode)


def isdir(stats):
    # type: (os.stat_result, ) -> bool

    return stat.S_ISDIR(stats.st_mode)


# was: islink
def statsislink(stats):
    # type: (os.stat_result, ) -> bool

    return stat.S_ISLNK(stats.st_mode)


def extract_basic_stat_info(stats):
    # type: (os.stat_result, ) -> tuple

    """Returns size, modification, creation time and access mode."""
    return (stats.st_size, stats.st_mtime, stats.st_ctime, stats.st_mode)


def safe_filename(filename, replacement=""):
    # type: (str, str) -> str

    WIN = windows_illegal_chars
    UNIX = unix_illegal_chars
    MAC = mac_illegal_chars
    bad = set.union(WIN, UNIX, MAC)

    safe_filename_translation_table = str.maketrans({c: replacement for c in bad})

    return filename.translate(safe_filename_translation_table)  # fixme: return callable which accepts only filename


safe_filename_simple = safe_filename


def _char_subber(s, illegal_chars, replacement):
    # type: (str, Set[str], str) -> str

    if set(replacement) & illegal_chars:
        raise ValueError("replace character cannot be a illegal character")

    regex = "[" + re.escape("".join(illegal_chars)) + "]"
    return re.sub(regex, replacement, s)


def _char_splitter(s, chars):
    # type: (str, Iterable[str]) -> List[str]

    regex = "[" + re.escape("".join(chars)) + "]"
    return re.split(regex, s)


def _special_subber(s, replacement="_"):
    # type: (str, str) -> str

    if s == "." or s == "..":
        return s.replace(".", replacement)

    return s


def windows_compliant_filename(filename, replacement="_"):
    # type: (str, str) -> str
    """https://msdn.microsoft.com/en-us/library/aa365247.aspx"""
    # reg. wikipedia: \x7f is valid!
    # trailing dots and spaces are ignored by windows

    ret = _char_subber(filename, windows_illegal_chars, replacement).rstrip(". ")  # strip or replace?, translate

    root, _ = os.path.splitext(ret)

    if root.upper() in windows_reserved_names:
        raise WindowsIllegalFilename("filename would result in reserved name")

    return ret


def windows_compliant_dirname(filename, replacement):
    # type: (str, str) -> str
    """https://msdn.microsoft.com/en-us/library/aa365247.aspx"""
    # reg. wikipedia: \x7f is valid!
    # trailing dots and spaces are ignored by windows

    ret = _char_subber(filename, windows_illegal_chars, replacement).rstrip(" ")

    root, _ = os.path.splitext(ret)

    if root.upper() in windows_reserved_names:
        raise WindowsIllegalFilename("filename would result in reserved name")

    return ret


def linux_compliant_filename(filename, replacement="_"):
    # type: (str, str) -> str

    filename = _special_subber(filename, replacement)
    return _char_subber(filename, unix_illegal_chars, replacement)


def linux_compliant_dirname(filename, replacement="_"):
    # type: (str, str) -> str

    return _char_subber(filename, unix_illegal_chars, replacement)


def mac_compliant_filename(filename, replacement="_"):
    # type: (str, str) -> str

    filename = _special_subber(filename, replacement)
    return _char_subber(filename, mac_illegal_chars, replacement)


def mac_compliant_dirname(filename, replacement="_"):
    # type: (str, str) -> str

    return _char_subber(filename, mac_illegal_chars, replacement)


def windows_split_path(s):
    # type: (str, ) -> List[str]

    return _char_splitter(s, windows_sep)


def linux_split_path(s):
    # type: (str, ) -> List[str]

    return _char_splitter(s, linux_sep)


def mac_split_path(s):
    # type: (str, ) -> List[str]

    return _char_splitter(s, mac_sep)


system = platform.system()

if system == "Windows":
    compliant_filename = windows_compliant_filename
    compliant_dirname = windows_compliant_dirname
    split_path = windows_split_path
elif system == "Linux":
    compliant_filename = linux_compliant_filename
    compliant_dirname = linux_compliant_dirname
    split_path = linux_split_path
elif system == "Darwin":
    compliant_filename = mac_compliant_filename
    compliant_dirname = mac_compliant_dirname
    split_path = mac_split_path
else:
    compliant_filename = _not_available("compliant_filename")
    compliant_dirname = _not_available("compliant_dirname")
    split_path = _not_available("split_path")


def compliant_path(path, replace="_"):
    # type: (str, str) -> str

    fn_func = partial(compliant_dirname, replace=replace)
    path_compontents = split_path(path)

    ret = os.sep.join(map(fn_func, path_compontents[:-1]))
    ret = os.path.join(ret, compliant_filename(path_compontents[-1]))
    return ret


def reldirname(path):
    # type: (PathType, ) -> str

    ret = os.path.dirname(path)
    if not ret:
        ret = "."

    return ret


def equal_dirs_iter(dir1, dir2, follow_symlinks=False):

    """Tests if two directories are equal. Doesn't handle links."""

    def entry_path_size(entry):
        return entry.path, entry.stat().st_size

    files1 = sorted(map(entry_path_size, scandir_rec(dir1, follow_symlinks=follow_symlinks)))  # get rel paths
    files2 = sorted(map(entry_path_size, scandir_rec(dir2, follow_symlinks=follow_symlinks)))  # get rel paths

    for (path1, size1), (path2, size2) in zip_longest(files1, files2):
        if path1 != path2:
            yield ("name", path1, path2)

        elif size1 != size2:
            yield ("size", path1, path2)

    for (path1, size1), (path2, size2) in zip_longest(files1, files2):
        if path1 == path2 and size1 == size2 and not equal_files(path1, path2):
            yield ("data", path1, path2)


def equal_dirs(dir1, dir2):
    return is_empty(equal_dirs_iter(dir1, dir2))


def realpath_win(path):
    # type: (PathType, ) -> str

    """fix for os.path.realpath() which doesn't work under Win7"""

    path = os.path.abspath(path)
    if islink(path):
        return os.path.normpath(os.path.join(os.path.dirname(path), os.readlink(path)))
    else:
        return path


def search(directories, pattern, dirs=True, files=True, rec=True, follow_symlinks=False):
    # type: (Iterable[PathType], str, bool, bool, bool, bool) -> Iterator[DirEntry]

    """Search for files and folders matching the wildcard `pattern`."""

    for directory in directories:
        for entry in scandir_rec(directory, dirs=dirs, files=files, rec=rec, follow_symlinks=follow_symlinks):
            if fnmatch(entry.name, pattern):
                yield entry


def rename_files_in_folder(path, pattern, transform, template, rec=True, follow_symlinks=False):
    # type: (PathType, str, Callable, str, bool, bool) -> None

    cpattern = re.compile(pattern)

    for entry in scandir_rec(path, files=True, dirs=False, rec=rec, follow_symlinks=follow_symlinks):
        filepath = entry.path
        base, name = os.path.split(filepath)

        m = cpattern.match(name)
        if not m:
            continue
        args = transform(*m.groups())

        to = template.format(*args)
        os.rename(filepath, os.path.join(base, to))


def clean_directory_by_extension(path, ext, rec=True, follow_symlinks=False):
    # type: (PathType, str, bool, bool) -> None

    """Deletes all files of type `ext` if the same file without the ext exists
    and rename if it does not exit."""

    # delete .ext if normal file exists
    for entry in scandir_rec(path, files=True, dirs=False, rec=rec, follow_symlinks=follow_symlinks):
        filepath = entry.path
        if not filepath.endswith(ext):
            file_to_delete = filepath + ext
            if os.path.isfile(file_to_delete):
                logger.info("Remove: %s", file_to_delete)
                try:
                    os.remove(file_to_delete)
                except OSError:
                    logger.exception("Could not delete: %s", file_to_delete)
                    break

    # rename all .ext to normal
    for entry in scandir_rec(path, files=True, dirs=False, rec=rec, follow_symlinks=follow_symlinks):
        filepath = entry.path
        if entry.name.endswith(ext):
            target = filepath[: -len(ext)]
            try:
                logger.info("Rename: %s -> %s", filepath, target)
                os.rename(filepath, target)
            except OSError:
                logger.exception("Should not have happened")
                break


def iter_links(path):
    # type: (PathType, ) -> Iterator[str]

    """Yields all directory symlinks (and junctions on windows)."""

    for dirpath, dirnames, filenames in os.walk(path):
        for dirname in dirnames:
            joined = os.path.join(dirpath, dirname)
            if islink(joined):
                yield joined


def normalize_seps(path):
    # type: (str, ) -> str

    """Converts \\ to /"""

    return path.replace("\\", "/")


class Counts:
    __slots__ = ("dirs", "files", "others")

    def __init__(self):
        self.dirs = 0
        self.files = 0
        self.others = 0

    def __iadd__(self, other):
        # type: (Counts, ) -> Counts

        self.dirs += other.dirs
        self.files += other.files
        self.others += other.others

        return self

    def null(self):
        # type: () -> bool

        return self.dirs == 0 and self.files == 0 and self.others == 0


def _scandir_counts(rootentry, files=True, others=True, rec=True, total=False, errorfunc=scandir_error_raise):
    # type: (MyDirEntryT, bool, bool, bool, bool, Callable) -> Iterator[Tuple[DirEntry, Optional[Counts]]]

    counts = Counts()

    try:
        with os.scandir(rootentry.path) as it:
            for entry in it:

                if entry.is_dir():
                    counts.dirs += 1
                    if rec:
                        for subentry, subcounts in _scandir_counts(entry, files, others, rec, total, errorfunc):
                            yield subentry, subcounts

                            if total:
                                assert subcounts
                                counts += subcounts

                elif entry.is_file():
                    counts.files += 1
                    if files:
                        yield entry, None

                else:
                    counts.others += 1
                    if others:
                        yield entry, None

        yield rootentry, counts  # yield after the loop

    except OSError as e:
        errorfunc(rootentry, e)


def scandir_counts(path, files=True, others=True, rec=True, total=False, onerror=scandir_error_log):
    # type: (PathType, bool, bool, bool, bool, Callable) -> Iterator[Tuple[DirEntry, Optional[Counts]]]

    """A recursive variant of scandir() which also returns the number of files/directories
    within directories.
    If total is True, the numbers will be calculated recursively as well.
    """

    if isinstance(path, PathLike):
        path = fspath(path)

    entry = DirEntryStub(os.path.basename(path), uncabspath(path))
    return _scandir_counts(entry, files, others, rec, total, onerror)


def shutil_onerror_remove_readonly(func, path, exc_info):

    """onerror function for the `shutil.rmtree` function.
    Makes read-only files writable and tries again.
    """

    stats = os.stat(path)
    if is_readable(stats):
        make_writeable(path, stats)
        func(path)
    else:
        raise
