"""
Strict path validation and sanitization. Path validation will treat self and parent directives as errors for example.
"""
import os
import re
import shutil
import socket
import string
import sys
import tempfile
from os import PathLike
from typing import Union


def _detect_maximum_path_length() -> int:
    """
    Detect the maximum path length supported by the current platform heuristically.
    Different filesystems may have narrower limitations.
    """
    temp_dir_path = tempfile.mkdtemp()
    if sys.platform.startswith("win"):
        # Detect the long-path length instead.
        temp_dir_path = f"\\\\?\\{temp_dir_path}"
    try:
        deepest_dir_path = os.path.realpath(temp_dir_path)
        tmp_ = "longtest" * 8
        # find deepest possible directory
        while True:
            try:
                os.makedirs(os.path.join(deepest_dir_path, tmp_))
            except OSError:
                break
            os.makedirs(deepest_dir_path := os.path.join(deepest_dir_path, tmp_[:-4]))
        # find deepest possible path by creating files in the longest directory
        for i in range(len(tmp_), 0, -1):
            try:
                with open(deepest_path := os.path.join(deepest_dir_path, "x" * i), "w", encoding="utf-8") as f:
                    f.write("long-success")
            except OSError:
                continue
            return len(deepest_path)
        return len(deepest_dir_path)
    finally:
        shutil.rmtree(temp_dir_path)


# NOTE: The maximum lengths are only correct for paths that have single-byte characters only. We have these to
# throw away anything that is surely too long, but shorter names or paths may actually be too long as well.
if sys.platform.startswith("win"):
    MAX_PATH_LEN = 32767  # Theoretical limit is not even reached by the simplest test which measured 32743 instead.
elif sys.platform.startswith("linux"):
    MAX_PATH_LEN = 4095
elif sys.platform.startswith("darwin"):
    MAX_PATH_LEN = 1023
else:
    MAX_PATH_LEN = _detect_maximum_path_length()  # FIXME: this could take a ~long time
MAX_NAME_LEN = 255  # correct for Linux and Windows as well

# TODO: $ is somewhat used for special things on windows... skip it?
COMMON_SANE_CHARACTERS = string.ascii_lowercase + string.ascii_uppercase + string.digits + " !#$%&'()+,-.;=@[]_{}~"
INSANE_CHARACTERS = "".join(c for i in range(128) if (c := chr(i)) not in COMMON_SANE_CHARACTERS)


class ValidationError(Exception):
    pass


if sys.platform.startswith("win"):
    # Win32 file naming conventions
    # https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file
    # The implementation of _WindowsFlavour.is_reserved() from pathlib is also used as reference.
    RELATIVE_DIRECTIVES_RE = re.compile(r"^[. ]*$")
    NAME_VALIDATE_RE = re.compile(r'[<>:"/\\|?*\x00-\x1F]|[. ]$', re.IGNORECASE)
    # NOTE: While "NUL :a" is reserved, we build on the fact that ":" is never valid in a name by the rule above, so
    # only "NUL .a" is checked as a special strip-rule for the reserved prefix.
    FILE_NAME_RESERVED_RE = re.compile(
        r"^(?:CON|CONIN\$|CONOUT\$|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?: *\..*)?$", re.IGNORECASE
    )
    # Path formats
    # https://docs.microsoft.com/en-us/dotnet/standard/io/file-path-formats
    # UNC formal definition
    # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/62e862f4-2a51-452e-8eeb-dc4ff5ee33cc
    # Interestingly the formal definition forbids 0x2B and 0x2C for folder names, while in practise they actually
    # work as expected. Not sure if this is obsolete or just incorrect. We'll fallback to the win32 naming conventions
    # for validation.
    ANY_UNC_RE = re.compile(r"^([/\\][/\\](?:(?![?.])|\?[/\\]UNC[/\\])([^/\\]+)[/\\]([^/\\]+))", re.IGNORECASE)
    UNC_LOCAL_HOST_NAME_RE = re.compile(r"^(::1|127\.0\.0\.1|LOCALHOST)$", re.IGNORECASE)
    UNC_DRIVE_SHARE_NAME_RE = re.compile(r"^([A-Z])\$$", re.IGNORECASE)
    LOCAL_DRIVE_RE = re.compile(r"^((?:[/\\][/\\]\?[/\\])?([A-Z]):)[/\\]", re.IGNORECASE)
    # TRAILING_SEPARATOR_RE = re.compile(r"(?<!^)[/\\]+$")  # TODO is this not better?
    TRAILING_SEPARATOR_RE = re.compile(r"[/\\]+$")

    # NTFS reserved names
    # https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-fscc/b04c3bd0-79dc-4e58-b8ed-74f19fc2ea0a
    # These are unique enough that forbidding them in a matching path is permissible universally.
    # We would focus on validating new-style UNC paths only, but resolving symlinks does not always preserve the UNC
    # prefix. This means long symlinks cannot be resolved if they are referred to by new-style UNC paths such as
    # "//host-name/share-name/object-name". They can only be resolved by legacy drive-prefixed paths or extended paths.
    # If we use the extended-UNC combined path "//?/UNC/host-name/share-name/object-name" the resolved will also miss
    # the UNC path.
    PATH_STRIP_VALID_PREFIX_RE = re.compile(
        r"^([/\\][/\\]\?[/\\](?:UNC[/\\][^/\\]+[/\\][^/\\]+|[A-Z]:(?=[/\\])))", re.IGNORECASE
    )
    PATH_INVALID_RE = re.compile(
        r"[. ]+(?:[/\\]|$)|[/\\][/\\]"  # relative directives and speace-period endings
        r'|[<>:"|?*\x00-\x1F]'  # reserved characters
        r"|^$"  # empty path
        r"|^[/\\]"  # reserved NTFS names in the root
        r"(?:\$Mft|\$MftMirr|\$LogFile|\$Volume|\$AttrDef|\$Bitmap|\$Boot|\$BadClus|\$Secure|\$UpCase|\$Extend)"
        r"(?:[/\\]|$)",
        re.IGNORECASE,
    )

    # sanitization
    assert all(c in INSANE_CHARACTERS for c in '<>:"/\\|?*')
    assert all(chr(i) in INSANE_CHARACTERS for i in range(32))
    NAME_SANITIZE_RE = re.compile(r"[" + re.escape(INSANE_CHARACTERS) + r"]")  # NOTE: trailing spaces/periods are left

    # extra
    DOS_ABSOLUTE_DRIVE_RE = re.compile(r"^([A-Z]):\\", re.IGNORECASE)

    def is_relative_directive(p: str):
        """
        Determine if argument would be a relative path directive like "parent" or "self".
        Windows strips all spaces and periods from the end of file names. When the name is neither "." or ".." exactly
        but there are only spaces and periods in the name it has a quirky behaviour. It collapses into an empty name
        which is not treated as an error, but like ".". So According to the windows API (GetFullPathName) " ", ".. ",
        " .." and " . . . " are all "here" directives that are functionally equivalent to ".". No comment.
        """
        return RELATIVE_DIRECTIVES_RE.match(p) is not None

    def validate_name(name: str, file: bool = True, dos: bool = True):
        """
        Validating a filesystem item name is complicated, but we make it manageable. Basically names for files
        when using a DOS or device-namespace path must be checked against an additional reserved rule. By default
        the name will be checked by the most strict rules. The caller must make sure to precisely manage loosening
        it by the path.
        """
        if not name:
            raise ValidationError("Name is empty")
        if len(name) > MAX_NAME_LEN:
            raise ValidationError("Name is too long")
        if is_relative_directive(name):
            raise ValidationError("Names of relative-directives are reserved")
        if NAME_VALIDATE_RE.search(name) is not None:
            raise ValidationError("Name contains reserved characters or ends with space/period")
        if file and dos and FILE_NAME_RESERVED_RE.match(name) is not None:
            raise ValidationError("Filename is reserved")

    def normalize_absolute_path(path: Union[PathLike, str]) -> str:
        r"""
        Normalize an absolute path. If the path is from an untrusted source the result must be checked by
        validate_path() before use!
        On Windows there are many different formats for an absolute path.
        For the best overall portability and features we normalize traditional DOS paths and local-drive UNC paths
        to be "//?/" prefixed aka extended paths. We chose the "//?/" prefix because the compatibility for mixed
        slashes is better than with "\\?\". This allows the application to use the long-path feature of Win10 without
        additional system settings. The legacy device names (CON, COM1, AUX, etc.) that are normally reserved are
        also usable for file names this way. Relative directives are forbidden by the path validation, but their
        implicit resolution is also disabled by the prefix.

        Normalizations:
            - The extended prefix and the drive letter is always normalized.
                C:\x        ->  //?/C:\x
                \\?\C:\x    ->  //?/C:\x
            - Trailing separators are stripped from the path.
                C:\             ->  //?/C:/
                C:\x\           ->  //?/C:\x
                C:\x//          ->  //?/C:\x
                \\?\C:\x\       ->  //?/C:\x
                \\LOCALHOST\C$  ->  //?/C:/
                \\REMOTE01\C$   ->  //?/UNC/REMOTE01/C$/
            - Extended prefixed UNC paths of local-drives are normalized to extended-path only.
                \\LOCALHOST\C$\x                    ->  //?/C:\x
                \\127.0.0.1\C$\x                    ->  //?/C:\x
                \\::1\C$\x                          ->  //?/C:\x
                \\<exact-name-of-localhost>\C$\x    ->  //?/C:\x
                \\?\UNC\LOCALHOST\C$\x              ->  //?/C:\x
            - Any UNC path that is not for a local-drive will be normalized to have the extended prefix and have
              forward slashes as separators until the object-name.
                \\REMOTE01\c$\x         ->  //?/UNC/REMOTE01/c$\x
                \\?\unc\REMOTE01\XY\x   ->  //?/UNC/REMOTE01/XY\x
                \\LOCALHOST\Share\x     ->  //?/UNC/LOCALHOST/Share\x

        Backslashes are not normalized in the path component, because that could be unnecessary work depending on
        the way the normalized path will be utilized. The Path objects from pathlib auto-normalize the mixed slashes
        for example. To get a string with consistent slashes manually, do either:
            - Forward slashes: path.replace("\\", "/") or Path(path).as_posix()
            - Backward slashes: path.replace("/", "\\") or str(Path(path))
        """
        if not isinstance(path, str):
            path = path.__fspath__()
        if m := ANY_UNC_RE.match(path):
            unc_host = m.group(2)
            if unc_host == socket.gethostname():
                unc_host = "LOCALHOST"  # normalize explicit host name to localhost
            unc_share = m.group(3)
            unc_object = TRAILING_SEPARATOR_RE.sub("", path[len(m.group(1)) :])
            if not unc_object:
                unc_object = "/"  # make root expression consistently end with a separator
            # Normalize UNC host and share names only if they are for a local drive.
            m = UNC_DRIVE_SHARE_NAME_RE.match(unc_share)
            if m is None or UNC_LOCAL_HOST_NAME_RE.match(unc_host) is None:
                path = f"//?/UNC/{unc_host}/{unc_share}{unc_object}"
            else:
                path = f"//?/{m.group(1).upper()}:{unc_object}"
        elif m := LOCAL_DRIVE_RE.match(path):
            path = TRAILING_SEPARATOR_RE.sub("", path[len(m.group(1)) :])
            if not path:
                path = "/"  # empty path components would not be valid
            path = f"//?/{m.group(2).upper()}:{path}"
        else:
            raise ValidationError("Path is not absolute or has invalid prefix")
        return path

    def validate_path(path: Union[PathLike, str]):
        r"""
        This validates either relative paths or extended-prefixed ("//?/") absolute paths.

        Disabled path features by the validation:
            - Drive-relative paths like "C:" and "C:xy\z".
            - UNC stream names and types.
            - The prefix for the device namespace "//./" is not treated as valid.
            - Absolute paths relative to the current drive like "/xy" are also invalid.

        # TODO: check component lengths
        # TODO: The host- and share-name path components in UNC are not validated strictly.
        """
        if not isinstance(path, str):
            path = path.__fspath__()
        if len(path) > MAX_PATH_LEN:
            raise ValidationError("Path is too long")
        if path.startswith("/") or path.startswith("\\"):
            # validate absolute path prefix
            if (unc_m := PATH_STRIP_VALID_PREFIX_RE.match(path)) is None:
                raise ValidationError("Absolute path prefix is invalid")
            path = path[len(unc_m.group(1)) :]
        # validate relative path (or path components after absolute prefix)
        if PATH_INVALID_RE.search(path) is not None:
            raise ValidationError("Path is invalid")

    def denormalize_absolute_path(path: Union[PathLike, str]) -> str:
        r"""
        Not all absolute path formats are well supported by everyone. If some component requires an old DOS-style
        path, this is here to help.

        Create a DOS-style path from the input like:
            c:\x                                ->  C:\x
            \\?\c:\x                            ->  C:\x
            \\LOCALHOST\c$\x                    ->  C:\x
            \\127.0.0.1\C$\x\                   ->  C:\x
            \\::1\c$\x                          ->  C:\x
            \\<exact-name-of-localhost>\C$\x    ->  C:\x
            \\?\c:\x\                           ->  C:\x
            \\?\UNC\LOCALHOST\c$\x              ->  C:\x

        UNC paths that are not for a local-drive cannot be converted and will raise ValidationError.
        The device namespace prefix is not accepted for conversion.
        Explicit and implicit drive-relative paths are not convertible.
        If the path is not absolute it will raise ValidationError.
        NOTE: All path separators will be normalised to be back-slashes!
        """
        if not isinstance(path, str):
            path = path.__fspath__()
        path = path.replace("/", "\\")
        # strip extended prefix
        if path.startswith("\\\\?\\"):
            if path[4:8].upper() == "UNC\\":
                path = f"\\{path[7:]}"  # convert to new-style UNC, it will be handled below
            else:
                path = path[4:]
        # parse and convert UNC if possible
        if path.startswith("\\\\"):
            parts = path.split("\\", 4)
            if len(parts) < 4:
                raise ValidationError("Broken UNC prefix")
            elif len(parts) == 4 or not parts[4]:
                unc_object = ""
            else:
                unc_object = parts[4]
            unc_host, unc_share = parts[2:4]
            if (m := UNC_DRIVE_SHARE_NAME_RE.match(unc_share)) is None:
                raise ValidationError("UNC share-name is not a drive")
            if unc_host != socket.gethostname() and UNC_LOCAL_HOST_NAME_RE.match(unc_host) is None:
                raise ValidationError("UNC host-name is not local")
            # UNC path can be expressed with DOS-style path.
            path = f"{m.group(1).upper()}:\\{unc_object}"
        # Check if path is absolute.
        if DOS_ABSOLUTE_DRIVE_RE.match(path) is None:
            raise ValidationError("Path is not absolute")
        if len(path) > 3:
            path = path.rstrip("\\")
        if PATH_INVALID_RE.search(path[2:]) is not None:
            raise ValidationError("Path is invalid")
        if path[0].islower():  # normalize drive letter
            path = f"{path[0].upper()}{path[1:]}"
        return path


elif sys.platform.startswith("linux") or sys.platform.startswith("darwin"):  # POSIX
    # NOTE: MacOSX has some magic internals that translate forward slashes and colons. The path separator used to be
    # colon in the HFS filesystem and the GUI applications seem to have retained this legacy behaviour. In other words
    # creating a file with the name "/" using a GUI app will result in a file named ":". Pretty strange, but luckily
    # OSX is POSIX-compliant. We operate on the POSIX level so there should be no need to be concerned with this issue.
    RELATIVE_DIRECTIVES_RE = re.compile(r"^\.{0,2}$")
    NAME_VALIDATE_RE = re.compile(r"[/\x00]")
    PATH_INVALID_RE = re.compile(r"^\.{1,2}(?:/|$)|/\.{1,2}$|/\.{0,2}/|\x00|^$")
    ABSOLUTE_PATH_RE = re.compile(r"^/(?:[^/]|$)")
    assert "/" in INSANE_CHARACTERS and "\x00" in INSANE_CHARACTERS
    NAME_SANITIZE_RE = re.compile(r"[" + re.escape(INSANE_CHARACTERS) + r"]")

    def is_relative_directive(p: str):
        """
        Determine if argument would be a relative path directive like "parent" or "self".
        """
        return RELATIVE_DIRECTIVES_RE.match(p) is not None

    def validate_name(name: str, **kwargs):
        """
        Valid names are straight forward here.
        """
        if not name:
            raise ValidationError("Name is empty")
        if len(name) > MAX_NAME_LEN:
            raise ValidationError("Name is too long")
        if is_relative_directive(name):
            raise ValidationError("Names of relative-directives are reserved")
        if NAME_VALIDATE_RE.search(name) is not None:
            raise ValidationError("Name contains reserved characters")

    def normalize_absolute_path(path: Union[PathLike, str]) -> str:
        """
        Normalize an absolute path. If the path is from an untrusted source the result must be checked by
        validate_path() before use!
        """
        if not isinstance(path, str):
            path = path.__fspath__()
        if ABSOLUTE_PATH_RE.match(path) is None:
            raise ValidationError("Path is not absolute")
        if len(path) > 1:
            path = path.rstrip("/")  # remove trailing path separators for consistency
        return path

    def validate_path(path: Union[PathLike, str]):
        """
        Check if the path is a valid absolute or relative path without self or parent directives and reserved chars.
        # TODO: check component lengths
        """
        if not isinstance(path, str):
            path = path.__fspath__()
        if len(path) > MAX_PATH_LEN:
            raise ValidationError("Path is too long")
        if PATH_INVALID_RE.search(path) is not None:
            raise ValidationError("Path is invalid")

    def denormalize_absolute_path(path: Union[PathLike, str]) -> str:
        """
        This function is mostly here to provide compatible API for all platforms.
        See the Windows implementation above for why it exists.
        This implementation behaves similarly to the Windows one. It expects an absolute path and returns an
        absolute path. However since no crazy conversions exist on POSIX to be done, it just normalizes and
        validates the path.
        """
        path = normalize_absolute_path(path)
        validate_path(path)
        return path


else:
    raise NotImplementedError()


def sanitize_name(name: str, replace: str = "_", **kwargs) -> str:
    """
    Replace ugly or not desired characters.
    NOTE: there are a lot of those in the unicode table, but we only deal with ascii here.
    """
    name = NAME_SANITIZE_RE.sub(replace, name)
    validate_name(name, **kwargs)
    return name
