'''
Make strings safe for use as ASCII filenames, while trying to preserve as much
meaning as possible.
'''

import errno
import ntpath
import os
import shutil
import time
from contextlib import closing, suppress
from math import ceil

from calibre import force_unicode, isbytestring, prints, sanitize_file_name
from calibre.constants import filesystem_encoding, ismacos, iswindows, preferred_encoding
from calibre.utils.localization import _, get_udc


def ascii_text(orig):
    udc = get_udc()
    try:
        ascii = udc.decode(orig)
    except Exception:
        if isinstance(orig, str):
            orig = orig.encode('ascii', 'replace')
        ascii = orig.decode(preferred_encoding, 'replace')
    if isinstance(ascii, bytes):
        ascii = ascii.decode('ascii', 'replace')
    return ascii


def ascii_filename(orig, substitute='_'):
    if isinstance(substitute, bytes):
        substitute = substitute.decode(filesystem_encoding)
    orig = ascii_text(orig).replace('?', '_')
    ans = ''.join(x if ord(x) >= 32 else substitute for x in orig)
    return sanitize_file_name(ans, substitute=substitute)


def shorten_component(s, by_what):
    l = len(s)
    if l < by_what:
        return s
    l = (l - by_what)//2
    if l <= 0:
        return s
    return s[:l] + s[-l:]


def limit_component(x, limit=254):
    # windows and macs use ytf-16 codepoints for length, linux uses arbitrary
    # binary data, but we will assume utf-8
    filename_encoding_for_length = 'utf-16' if iswindows or ismacos else 'utf-8'

    def encoded_length():
        q = x if isinstance(x, bytes) else x.encode(filename_encoding_for_length)
        return len(q)

    while encoded_length() > limit:
        delta = encoded_length() - limit
        x = shorten_component(x, max(2, delta // 2))

    return x


def shorten_components_to(length, components, more_to_take=0, last_has_extension=True):
    components = [limit_component(cx) for cx in components]
    filepath = os.sep.join(components)
    extra = len(filepath) - (length - more_to_take)
    if extra < 1:
        return components
    deltas = []
    for x in components:
        pct = len(x)/float(len(filepath))
        deltas.append(ceil(pct*extra))
    ans = []

    for i, x in enumerate(components):
        delta = deltas[i]
        if delta > len(x):
            r = x[0] if x is components[-1] else ''
        else:
            if last_has_extension and x is components[-1]:
                b, e = os.path.splitext(x)
                if e == '.':
                    e = ''
                r = shorten_component(b, delta)+e
                if r.startswith('.'):
                    r = x[0]+r
            else:
                r = shorten_component(x, delta)
            r = r.strip()
            if not r:
                r = x.strip()[0] if x.strip() else 'x'
        ans.append(r)
    if len(os.sep.join(ans)) > length:
        return shorten_components_to(length, components, more_to_take+2)
    return ans


def find_executable_in_path(name, path=None):
    if path is None:
        path = os.environ.get('PATH', '')
    exts = '.exe .cmd .bat'.split() if iswindows and not name.endswith('.exe') else ('',)
    path = path.split(os.pathsep)
    for x in path:
        for ext in exts:
            q = os.path.abspath(os.path.join(x, name)) + ext
            if os.access(q, os.X_OK):
                return q


def is_case_sensitive(path):
    '''
    Return True if the filesystem is case sensitive.

    path must be the path to an existing directory. You must have permission
    to create and delete files in this directory. The results of this test
    apply to the filesystem containing the directory in path.
    '''
    is_case_sensitive = False
    if not iswindows:
        name1, name2 = ('calibre_test_case_sensitivity.txt',
                        'calibre_TesT_CaSe_sensitiVitY.Txt')
        f1, f2 = os.path.join(path, name1), os.path.join(path, name2)
        with suppress(OSError):
            os.remove(f1)
        open(f1, 'w').close()
        is_case_sensitive = not os.path.exists(f2)
        os.remove(f1)
    return is_case_sensitive


def case_ignoring_open_file(path, mode='r'):
    '''
    Open an existing file case insensitively, even on case sensitive file systems
    '''
    try:
        return open(path, mode)
    except FileNotFoundError as err:
        original_err = err

    def next_component(final_path, components):
        if not components:
            return final_path
        component = components.pop()
        cl = component.lower()
        try:
            matches = {x for x in os.listdir(final_path) if x.lower() == cl}
        except OSError:
            raise original_err from None
        for x in matches:
            current = os.path.join(final_path, x)
            try:
                return next_component(current, list(components))
            except Exception:
                continue
        raise original_err

    if isbytestring(path):
        path = path.decode(filesystem_encoding)
    if path.endswith(os.sep):
        path = path[:-1]
    if not path:
        raise ValueError('Path must not point to root')

    components = path.split(os.sep)
    if len(components) <= 1:
        raise ValueError(f'Invalid path: {path}')
    final_path = (components[0].upper() + os.sep) if iswindows else '/'
    components = list(reversed(components))[:-1]
    return open(next_component(final_path, components), mode)


def case_preserving_open_file(path, mode='wb', mkdir_mode=0o777):
    '''
    Open the file pointed to by path with the specified mode. If any
    directories in path do not exist, they are created. Returns the
    opened file object and the path to the opened file object. This path is
    guaranteed to have the same case as the on disk path. For case insensitive
    filesystems, the returned path may be different from the passed in path.
    The returned path is always unicode and always an absolute path.

    If mode is None, then this function assumes that path points to a directory
    and return the path to the directory as the file object.

    mkdir_mode specifies the mode with which any missing directories in path
    are created.
    '''
    if isbytestring(path):
        path = path.decode(filesystem_encoding)

    path = os.path.abspath(path)

    sep = force_unicode(os.sep, 'ascii')

    if path.endswith(sep):
        path = path[:-1]
    if not path:
        raise ValueError('Path must not point to root')

    components = path.split(sep)
    if not components:
        raise ValueError(f'Invalid path: {path!r}')

    cpath = sep
    if iswindows:
        # Always upper case the drive letter and add a trailing slash so that
        # the first os.listdir works correctly
        cpath = components[0].upper() + sep

    bdir = path if mode is None else os.path.dirname(path)
    if not os.path.exists(bdir):
        os.makedirs(bdir, mkdir_mode)

    # Walk all the directories in path, putting the on disk case version of
    # the directory into cpath
    dirs = components[1:] if mode is None else components[1:-1]
    for comp in dirs:
        cdir = os.path.join(cpath, comp)
        cl = comp.lower()
        try:
            candidates = [c for c in os.listdir(cpath) if c.lower() == cl]
        except Exception:
            # Don't have permission to do the listdir, assume the case is
            # correct as we have no way to check it.
            pass
        else:
            if len(candidates) == 1:
                cdir = os.path.join(cpath, candidates[0])
            # else: We are on a case sensitive file system so cdir must already
            # be correct
        cpath = cdir

    if mode is None:
        ans = fpath = cpath
    else:
        fname = components[-1]
        ans = open(os.path.join(cpath, fname), mode)
        # Ensure file and all its metadata is written to disk so that subsequent
        # listdir() has file name in it. I don't know if this is actually
        # necessary, but given the diversity of platforms, best to be safe.
        ans.flush()
        os.fsync(ans.fileno())

        cl = fname.lower()
        try:
            candidates = [c for c in os.listdir(cpath) if c.lower() == cl]
        except OSError:
            # The containing directory, somehow disappeared?
            candidates = []
        if len(candidates) == 1:
            fpath = os.path.join(cpath, candidates[0])
        else:
            # We are on a case sensitive filesystem
            fpath = os.path.join(cpath, fname)
    return ans, fpath


def windows_get_fileid(path):
    ''' The fileid uniquely identifies actual file contents (it is the same for
    all hardlinks to a file). Similar to inode number on linux. '''
    from calibre_extensions.winutil import get_file_id
    if isbytestring(path):
        path = path.decode(filesystem_encoding)
    with suppress(OSError):
        return get_file_id(path)


def samefile_windows(src, dst):
    samestring = (os.path.normcase(os.path.abspath(src)) ==
            os.path.normcase(os.path.abspath(dst)))
    if samestring:
        return True

    a, b = windows_get_fileid(src), windows_get_fileid(dst)
    if a is None and b is None:
        return False
    return a == b


def samefile(src, dst):
    '''
    Check if two paths point to the same actual file on the filesystem. Handles
    symlinks, case insensitivity, mapped drives, etc.

    Returns True iff both paths exist and point to the same file on disk.

    Note: On windows will return True if the two string are identical (up to
    case) even if the file does not exist. This is because I have no way of
    knowing how reliable the GetFileInformationByHandle method is.
    '''
    if iswindows:
        return samefile_windows(src, dst)

    if hasattr(os.path, 'samefile'):
        # Unix
        try:
            return os.path.samefile(src, dst)
        except OSError:
            return False

    # All other platforms: check for same pathname.
    samestring = (os.path.normcase(os.path.abspath(src)) ==
            os.path.normcase(os.path.abspath(dst)))
    return samestring


def windows_get_size(path):
    ''' On windows file sizes are only accurately stored in the actual file,
    not in the directory entry (which could be out of date). So we open the
    file, and get the actual size. '''
    from calibre_extensions import winutil
    if isbytestring(path):
        path = path.decode(filesystem_encoding)
    with closing(winutil.create_file(
        path, 0, winutil.FILE_SHARE_READ | winutil.FILE_SHARE_WRITE | winutil.FILE_SHARE_DELETE,
        winutil.OPEN_EXISTING, 0)
    ) as h:
        return winutil.get_file_size(h)


def windows_hardlink(src, dest):
    from calibre_extensions import winutil
    winutil.create_hard_link(dest, src)
    src_size = os.path.getsize(src)
    # We open and close dest, to ensure its directory entry is updated
    # see http://blogs.msdn.com/b/oldnewthing/archive/2011/12/26/10251026.aspx
    for i in range(10):
        # If we are on a network filesystem, we have to wait for some indeterminate time, since
        # network file systems are the best thing since sliced bread
        try:
            if windows_get_size(dest) == src_size:
                return
        except OSError:
            pass
        time.sleep(0.3)

    sz = windows_get_size(dest)
    if sz != src_size:
        msg = f'Creating hardlink from {src} to {dest} failed: '
        raise OSError(msg + (f'hardlink size: {sz} not the same as source size'))


def windows_fast_hardlink(src, dest):
    from calibre_extensions import winutil
    winutil.create_hard_link(dest, src)
    ssz, dsz = windows_get_size(src), windows_get_size(dest)
    if ssz != dsz:
        msg = f'Creating hardlink from {src} to {dest} failed: '
        raise OSError(msg + (f'hardlink size: {dsz} not the same as source size: {ssz}'))


def windows_nlinks(path):
    from calibre_extensions import winutil
    if isbytestring(path):
        path = path.decode(filesystem_encoding)
    return winutil.nlinks(path)


class WindowsAtomicFolderMove:
    '''
    Move all the files inside a specified folder in an atomic fashion,
    preventing any other process from locking a file while the operation is
    incomplete. Raises an IOError if another process has locked a file before
    the operation starts. Note that this only operates on the files in the
    folder, not any sub-folders.
    '''

    def __init__(self, path):
        from collections import defaultdict

        from calibre_extensions import winutil
        self.handle_map = {}

        if isbytestring(path):
            path = path.decode(filesystem_encoding)

        if not os.path.exists(path):
            return

        names = os.listdir(path)
        name_to_fileid = {x:windows_get_fileid(os.path.join(path, x)) for x in names}
        fileid_to_names = defaultdict(set)
        for name, fileid in name_to_fileid.items():
            fileid_to_names[fileid].add(name)

        for x in names:
            f = os.path.normcase(os.path.abspath(os.path.join(path, x)))
            if not os.path.isfile(f):
                continue
            with suppress(OSError):
                # Ensure the file is not read-only
                winutil.set_file_attributes(f, winutil.FILE_ATTRIBUTE_NORMAL)

            try:
                h = winutil.create_file(f, winutil.GENERIC_READ,
                        winutil.FILE_SHARE_DELETE,
                        winutil.OPEN_EXISTING, winutil.FILE_FLAG_SEQUENTIAL_SCAN)
            except OSError as e:
                if e.winerror == winutil.ERROR_SHARING_VIOLATION:
                    # The file could be a hardlink to an already opened file,
                    # in which case we use the same handle for both files
                    fileid = name_to_fileid[x]
                    found = False
                    if fileid is not None:
                        for other in fileid_to_names[fileid]:
                            other = os.path.normcase(os.path.abspath(os.path.join(path, other)))
                            if other in self.handle_map:
                                self.handle_map[f] = self.handle_map[other]
                                found = True
                                break
                    if found:
                        continue

                self.close_handles()
                if e.winerror == winutil.ERROR_SHARING_VIOLATION:
                    err = OSError(errno.EACCES,
                            _('File is open in another process'))
                    err.filename = f
                    raise err
                prints(f'CreateFile failed for: {f!r}')
                raise
            except Exception:
                self.close_handles()
                prints(f'CreateFile failed for: {f!r}')
                raise
            self.handle_map[f] = h

    def copy_path_to(self, path, dest):
        from calibre_extensions import winutil
        handle = None
        for p, h in self.handle_map.items():
            if samefile_windows(path, p):
                handle = h
                break
        if handle is None:
            if os.path.exists(path):
                raise ValueError(f'The file {path!r} did not exist when this move'
                        ' operation was started')
            else:
                raise ValueError(f'The file {path!r} does not exist')

        with suppress(OSError):
            windows_hardlink(path, dest)
            return

        winutil.set_file_pointer(handle, 0, winutil.FILE_BEGIN)
        with open(dest, 'wb') as f:
            sz = 1024 * 1024
            while True:
                raw = winutil.read_file(handle, sz)
                if not raw:
                    break
                f.write(raw)

    def release_file(self, path):
        ' Release the lock on the file pointed to by path. Will also release the lock on any hardlinks to path '
        key = None
        for p, h in self.handle_map.items():
            if samefile_windows(path, p):
                key = (p, h)
                break
        if key is not None:
            key[1].close()
            remove = [f for f, h in self.handle_map.items() if h is key[1]]
            for x in remove:
                self.handle_map.pop(x)

    def close_handles(self):
        for h in self.handle_map.values():
            h.close()
        self.handle_map = {}

    def delete_originals(self):
        from calibre_extensions import winutil
        for path in self.handle_map:
            winutil.delete_file(path)
        self.close_handles()


def hardlink_file(src, dest):
    src, dest = make_long_path_useable(src), make_long_path_useable(dest)
    if iswindows:
        windows_hardlink(src, dest)
        return
    os.link(src, dest)


def nlinks_file(path):
    ' Return number of hardlinks to the file '
    if iswindows:
        return windows_nlinks(path)
    return os.stat(path).st_nlink


def retry_on_fail(func, *args, count=10, sleep_time=0.2):
    for i in range(count):
        try:
            func(*args)
            break
        except OSError:
            if i > count - 2:
                raise
            # Try the operation repeatedly in case something like a virus
            # scanner has opened one of the files (I love windows)
            time.sleep(sleep_time)


def atomic_rename(oldpath, newpath):
    '''Replace the file newpath with the file oldpath. Can fail if the files
    are on different volumes. If succeeds, guaranteed to be atomic. newpath may
    or may not exist. If it exists, it is replaced. '''
    if iswindows:
        oldpath, newpath = make_long_path_useable(oldpath), make_long_path_useable(newpath)
        retry_on_fail(os.replace, oldpath, newpath)
    else:
        os.replace(oldpath, newpath)


def remove_dir_if_empty(path, ignore_metadata_caches=False):
    ''' Remove a directory if it is empty or contains only the folder metadata
    caches from different OSes. To delete the folder if it contains only
    metadata caches, set ignore_metadata_caches to True.'''
    try:
        os.rmdir(path)
    except OSError as e:
        try:
            entries = os.listdir(path)
        except FileNotFoundError:  # something deleted path out from under us
            return
        if e.errno == errno.ENOTEMPTY or len(entries) > 0:
            # Some linux systems appear to raise an EPERM instead of an
            # ENOTEMPTY, see https://bugs.launchpad.net/bugs/1240797
            if ignore_metadata_caches:
                try:
                    found = False
                    for x in entries:
                        if x.lower() in {'.ds_store', 'thumbs.db'}:
                            found = True
                            x = os.path.join(path, x)
                            with suppress(FileNotFoundError):
                                if os.path.isdir(x):
                                    import shutil
                                    shutil.rmtree(x)
                                else:
                                    os.remove(x)
                except Exception:  # We could get an error, if, for example, windows has locked Thumbs.db
                    found = False
                if found:
                    remove_dir_if_empty(path)
            return
        raise


expanduser = os.path.expanduser


def format_permissions(st_mode):
    import stat
    for func, letter in (x.split(':') for x in 'REG:- DIR:d BLK:b CHR:c FIFO:p LNK:l SOCK:s'.split()):
        if getattr(stat, 'S_IS' + func)(st_mode):
            break
    else:
        letter = '?'
    rwx = ('---', '--x', '-w-', '-wx', 'r--', 'r-x', 'rw-', 'rwx')
    ans = [letter] + list(rwx[(st_mode >> 6) & 7]) + list(rwx[(st_mode >> 3) & 7]) + list(rwx[(st_mode & 7)])
    if st_mode & stat.S_ISUID:
        ans[3] = 's' if (st_mode & stat.S_IXUSR) else 'S'
    if st_mode & stat.S_ISGID:
        ans[6] = 's' if (st_mode & stat.S_IXGRP) else 'l'
    if st_mode & stat.S_ISVTX:
        ans[9] = 't' if (st_mode & stat.S_IXUSR) else 'T'
    return ''.join(ans)


def copyfile(src, dest):
    shutil.copyfile(src, dest)
    try:
        shutil.copystat(src, dest)
    except Exception:
        pass


def get_hardlink_function(src, dest):
    if not iswindows:
        return os.link
    from calibre_extensions import winutil
    src = src.removeprefix(long_path_prefix)
    dest = dest.removeprefix(long_path_prefix)
    root = dest[0] + ':\\'
    if src[0].lower() == dest[0].lower() and winutil.supports_hardlinks(root):
        return windows_fast_hardlink


def copyfile_using_links(path, dest, dest_is_dir=True, filecopyfunc=copyfile):
    path, dest = os.path.abspath(path), os.path.abspath(dest)
    if dest_is_dir:
        dest = os.path.join(dest, os.path.basename(path))
    hardlink = get_hardlink_function(path, dest)
    path, dest = make_long_path_useable(path), make_long_path_useable(dest)
    try:
        hardlink(path, dest)
    except Exception:
        filecopyfunc(path, dest)


def copytree_using_links(path, dest, dest_is_parent=True, filecopyfunc=copyfile):
    path, dest = os.path.abspath(path), os.path.abspath(dest)
    if dest_is_parent:
        dest = os.path.join(dest, os.path.basename(path))
    hardlink = get_hardlink_function(path, dest)
    try:
        os.makedirs(dest)
    except OSError as e:
        if e.errno != errno.EEXIST:
            raise
    for dirpath, dirnames, filenames in os.walk(path):
        base = os.path.relpath(dirpath, path)
        dest_base = os.path.join(dest, base)
        for dname in dirnames:
            try:
                os.mkdir(os.path.join(dest_base, dname))
            except OSError as e:
                if e.errno != errno.EEXIST:
                    raise
        for fname in filenames:
            src, df = os.path.join(dirpath, fname), os.path.join(dest_base, fname)
            try:
                hardlink(src, df)
            except Exception:
                filecopyfunc(src, df)


def _normalize_path_for_containment(path, case_sensitive=True):
    ans = os.path.abspath(path)
    return ans if case_sensitive else os.path.normcase(ans).lower()


def is_path_inside(parent: str, child: str, allow_parent: bool = False, case_sensitive: bool = True) -> bool:
    ' Check if child is under parent, using lexical path component boundaries. '
    parent = _normalize_path_for_containment(parent, case_sensitive=case_sensitive)
    child = _normalize_path_for_containment(child, case_sensitive=case_sensitive)
    try:
        if os.path.commonpath((parent, child)) != parent:
            return False
    except ValueError:
        return False
    return allow_parent or child != parent


def path_from_root(
    root: str, path: str, allow_root: bool = False, reject_colon: bool = False, case_sensitive: bool = True
) -> str:
    '''
    Resolve a relative path under root. Raises ValueError for absolute paths,
    drive-qualified paths, traversal components, or paths outside root.
    '''
    if not isinstance(path, str):
        raise ValueError('path must be text')
    if reject_colon and ':' in path:
        raise ValueError('colon not allowed in path')
    if not path:
        if allow_root:
            return os.path.abspath(root)
        raise ValueError('empty path not allowed')
    if os.path.isabs(path) or ntpath.isabs(path) or os.path.splitdrive(path)[0] or ntpath.splitdrive(path)[0]:
        raise ValueError('absolute paths are not allowed')
    parts = path.replace('\\', '/').split('/')
    if any(x in ('', '.', '..') for x in parts):
        raise ValueError('invalid path component')
    ans = os.path.abspath(os.path.join(root, *parts))
    if not is_path_inside(root, ans, allow_parent=allow_root, case_sensitive=case_sensitive):
        raise ValueError('path is outside root')
    return ans


def is_existing_subpath(child: str, parent: str) -> bool:
    ' Check if child is under parent. If either child or parent dont exist, returns False. '
    try:
        parent = os.path.realpath(parent, strict=True)  # resolve symlinks
        child = os.path.realpath(child, strict=True)
    except OSError:
        return False
    return is_path_inside(parent, child)


rmtree = shutil.rmtree


if iswindows:
    long_path_prefix = '\\\\?\\'

    def make_long_path_useable(path, threshold=200):
        if len(path) > threshold and os.path.isabs(path) and not path.startswith(long_path_prefix):
            path = long_path_prefix + os.path.normpath(path)
        return path

    def is_fat_filesystem(path):
        try:
            from calibre_extensions.winutil import filesystem_type_name
        except ImportError:
            return False
        if not path:
            return False
        drive = os.path.abspath(path)[0].upper()
        try:
            tn = filesystem_type_name(f'{drive}:\\')
        except OSError:
            return False
        # Values I have seen: FAT32, exFAT, NTFS
        return tn.upper().startswith('FAT')

    def get_long_path_name(path):
        from calibre_extensions.winutil import get_long_path_name
        lpath = path
        if os.path.isabs(lpath) and not lpath.startswith(long_path_prefix):
            lpath = long_path_prefix + lpath
        try:
            return get_long_path_name(lpath)
        except FileNotFoundError:
            return path
        except OSError as e:
            if e.winerror == 123:  # ERR_INVALID_NAME
                return path
            raise

else:
    def make_long_path_useable(path, threshold=200):
        return path

    def get_long_path_name(path):
        return path

    def is_fat_filesystem(path):
        # TODO: Implement for Linux and macOS
        return False


def clone_file_metadata(src_fd: int, dest_fd: int, dest_name: str = '') -> None:
    dest_name = dest_name or 'temp file'
    if hasattr(os, 'fchown'):
        import errno
        import stat

        from calibre.utils.filenames import format_permissions
        st = os.stat(src_fd)
        try:
            os.fchmod(dest_fd, st.st_mode | stat.S_IWUSR)
        except OSError as err:
            if err.errno != errno.EPERM:
                raise
            raise OSError(f'Failed to change permissions of {dest_name} to {oct(st.st_mode)} ({format_permissions(st.st_mode)}), '
                        f'with error: {errno.errorcode[err.errno]}. Most likely the directory it is in has a restrictive umask')
        try:
            os.fchown(dest_fd, st.st_uid, st.st_gid)
        except OSError as err:
            if err.errno not in (errno.EPERM, errno.EACCES):
                # ignore chown failure as user could be modifying a file belonging
                # to a different user, in which case we really can't do anything
                # about it short of making the file update non-atomic
                raise
