# ----------------------------------------------------------------------
# |
# |  WindowsShell.py
# |
# |  David Brownell <db@DavidBrownell.com>
# |      2018-04-30 21:12:38
# |
# ----------------------------------------------------------------------
# |
# |  Copyright David Brownell 2018-22.
# |  Distributed under the Boost Software License, Version 1.0.
# |  (See accompanying file LICENSE_1_0.txt or copy at http://www.boost.org/LICENSE_1_0.txt)
# |
# ----------------------------------------------------------------------
"""Contains the WindowsShell object."""

import os
import re
import shlex
import sys
import textwrap
import uuid

from collections import OrderedDict

import CommonEnvironment
from CommonEnvironment.Interface import staticderived, clsinit, override, DerivedProperty
from CommonEnvironment.Shell import Shell
from CommonEnvironment.Shell.Commands import Set, Augment, ExitOnError
from CommonEnvironment.Shell.Commands.Visitor import Visitor

# ----------------------------------------------------------------------
_script_fullpath                            = CommonEnvironment.ThisFullpath()
_script_dir, _script_name                   = os.path.split(_script_fullpath)
# ----------------------------------------------------------------------


@staticderived
@clsinit
class WindowsShell(Shell):

    # ----------------------------------------------------------------------
    # |
    # |  Public Types
    # |
    # ----------------------------------------------------------------------
    @staticderived
    @override
    class CommandVisitor(Visitor):

        # <Parameters differ from overridden '<...>' method> pylint: disable = W0221

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnComment(command):
            return "REM {}".format(command.Value)

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnMessage(command):
            output = []

            for line in command.Value.split("\n"):
                if not line.strip():
                    output.append("echo.")
                else:
                    output.append(
                        "echo {}".format(
                            WindowsShell._ProcessEscapedChars(
                                line,
                                OrderedDict(
                                    [
                                        ("%", "%%"),
                                        ("^", "^^"),
                                        ("&", "^&"),
                                        ("<", "^<"),
                                        (">", "^>"),
                                        ("|", "^|"),
                                        (",", "^,"),
                                        (";", "^;"),
                                        ("(", "^("),
                                        (")", "^)"),
                                        ("[", "^["),
                                        ("]", "^]"),
                                        ('"', '"'),
                                    ],
                                ),
                                escape_char="__None__",
                            ),
                        ),
                    )
            return " && ".join(output)

        # ----------------------------------------------------------------------
        @classmethod
        @override
        def OnCall(cls, command):
            result = "call {}".format(command.CommandLine)
            if command.ExitOnError:
                result += "\n{}\n".format(cls.Accept(ExitOnError()))

            return result

        # ----------------------------------------------------------------------
        @classmethod
        @override
        def OnExecute(cls, command):
            # Execute the command line with a special prefix if the command line invokes a .bat or .cmd file
            commands = shlex.split(command.CommandLine)

            if commands[0].endswith(".bat") or commands[0].endswith(".cmd"):
                result = "cmd /c {}".format(command.CommandLine)
            else:
                result = command.CommandLine

            if command.ExitOnError:
                result += "\n{}\n".format(cls.Accept(ExitOnError()))

            return result

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnSymbolicLink(command):
            d = {"link": command.LinkFilename, "target": command.Target}

            return textwrap.dedent(
                """\
                {remove}mklink{dir_flag} "{link}" "{target}" > NUL
                """,
            ).format(
                remove="" if not command.RemoveExisting else 'if exist "{link}" ({remove} "{link}")\n'.format(
                    remove="rmdir" if command.IsDir else "del /Q",
                    **d
                ),
                dir_flag=" /D /J" if command.IsDir else "",
                **d
            )

        # ----------------------------------------------------------------------
        @classmethod
        @override
        def OnPath(cls, command):
            return cls.OnSet(Set("PATH", command.Values))

        # ----------------------------------------------------------------------
        @classmethod
        @override
        def OnAugmentPath(cls, command):
            return cls.OnAugment(command)

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnSet(command):
            if command.Values is None:
                return "SET {}=".format(command.Name)

            return "SET {}={}".format(command.Name, os.pathsep.join(command.Values)) # <Class '<name>' has no '<attr>' member> pylint: disable = E1101

        # ----------------------------------------------------------------------
        @classmethod
        @override
        def OnAugment(cls, command):
            statements = []

            if command.IsSpaceDelimitedString:
                sep = " "
            else:
                sep = os.pathsep

            if command.AppendValues:
                add_statement_template = "%{name}%{sep}{value}"
            else:
                add_statement_template = "{value}{sep}%{name}%"

            statement_template = 'set {name}={add_statement}'

            if not command.SimpleFormat:
                statement_template = textwrap.dedent(
                    """\
                    echo "{{sep}}%{{name}}%{{sep}}" | findstr /C:"{{sep}}{{value}}{{sep}}" >nul
                    if %ERRORLEVEL% == 0 goto skip_{{unique_id}}

                    {statement_template}

                    :skip_{{unique_id}}

                    """,
                ).format(
                    statement_template=statement_template,
                )

            statements = [
                statement_template.format(
                    name=command.Name,
                    value=value,
                    sep=sep,
                    unique_id=str(uuid.uuid4()).replace("-", ""),
                    add_statement=add_statement_template.format(
                        name=command.Name,
                        value=value,
                        sep=sep,
                    ),
                ) for value in command.Values
            ]

            return "\n".join(statements)

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnExit(command):
            return textwrap.dedent(
                """\
                {success}
                {error}
                exit /B {return_code}
                """,
            ).format(
                success="if %ERRORLEVEL% EQ 0 ( pause )" if command.PauseOnSuccess else "",
                error="if %ERRORLEVEL% NEQ 0 ( pause )" if command.PauseOnError else "",
                return_code=command.ReturnCode or 0,
            )

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnExitOnError(command):
            variable_name = command.VariableName or "ERRORLEVEL"

            return "if %{}% NEQ 0 (exit /B {})".format(variable_name, command.ReturnCode or "%{}%".format(variable_name))

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnRaw(command):
            return command.Value

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnEchoOff(command):
            return "@echo off"

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnCommandPrompt(command):
            return "set PROMPT=({}) $P$G".format(command.Prompt)

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnDelete(command):
            if command.IsDir:
                return 'rmdir /S /Q "{}"'.format(command.FilenameOrDirectory)

            return 'del "{}"'.format(command.FilenameOrDirectory)

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnCopy(command):
            return 'copy /T "{source}" "{dest}"'.format(
                source=command.Source,
                dest=command.Dest,
            )

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnMove(command):
            return 'move /Y "{source}" "{dest}"'.format(
                source=command.Source,
                dest=command.Dest,
            )

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnPersistError(command):
            return "set {}=%ERRORLEVEL%".format(command.VariableName)

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnPushDirectory(command):
            directory = command.Directory

            if directory is None:
                directory = "%~dp0"

            return 'pushd "{}"'.format(directory)

        # ----------------------------------------------------------------------
        @staticmethod
        @override
        def OnPopDirectory(command):
            return "popd"

    # ----------------------------------------------------------------------
    # |
    # |  Public Properties
    # |
    # ----------------------------------------------------------------------
    Name                                    = DerivedProperty("Windows")
    CategoryName                            = DerivedProperty("Windows")
    ScriptExtension                         = DerivedProperty(".cmd")
    ExecutableExtension                     = DerivedProperty(".exe")
    CompressionExtensions                   = DerivedProperty([".zip"])
    AllArgumentsScriptVariable              = DerivedProperty("%*")
    HasCaseSensitiveFileSystem              = DerivedProperty(False)
    Architecture                            = DerivedProperty("x64" if os.getenv("ProgramFiles(x86)") else "x86")
    TempDirectory                           = DerivedProperty(os.getenv("TMP"))
    UserDirectory                           = None      # Set in __clsinit__

    # ----------------------------------------------------------------------
    # |
    # |  Public Methods
    # |
    # ----------------------------------------------------------------------
    @classmethod
    def __clsinit__(cls):
        try:
            from win32com.shell import shellcon, shell  # <Unable to import> pylint: disable = F0401, E0611
            import unicodedata

            homedir = shell.SHGetFolderPath(0, shellcon.CSIDL_APPDATA, 0, 0)
            homedir = unicodedata.normalize("NFKD", homedir)

            if sys.version_info[0] == 2:
                homedir = homedir.encode("ascii", "ignore")

            cls.UserDirectory = DerivedProperty(homedir)

        except:
            cls.UserDirectory = DerivedProperty(None)

    # ----------------------------------------------------------------------
    @staticmethod
    @override
    def IsActive(platform_name):
        return "windows" in platform_name or platform_name == "nt"

    # ----------------------------------------------------------------------
    @staticmethod
    @override
    def RemoveDir(path):
        if os.path.isdir(path):
            os.system('rmdir /S /Q "{}"'.format(path))

    # ----------------------------------------------------------------------
    @staticmethod
    @override
    def DecorateEnvironmentVariable(var_name):
        return "%{}%".format(var_name)

    # ----------------------------------------------------------------------

    # We are using ctypes here rather than win32api for better compatibility,
    # especially when it comes to running on nanoserver.
    _kernel32                               = None
    _GetFileAttributesW                     = None

    @classmethod
    @override
    def IsSymLink(cls, filename):
        if cls._GetFileAttributesW is None:
            import ctypes
            import ctypes.wintypes

            cls._kernel32 = ctypes.WinDLL("kernel32")
            cls._GetFileAttributesW = cls._kernel32.GetFileAttributesW
            cls._GetFileAttributesW.restype = ctypes.wintypes.DWORD
            cls._GetFileAttributesW.argtypes = (ctypes.wintypes.LPCWSTR,)

        FILE_ATTRIBUTE_REPARSE_POINT = 1024

        return os.path.exists(filename) and cls._GetFileAttributesW(filename) & FILE_ATTRIBUTE_REPARSE_POINT == FILE_ATTRIBUTE_REPARSE_POINT

    # ----------------------------------------------------------------------
    if sys.version_info[0] == 2:
        # Python 2.+ doesn't support symlinks on Windows, so we have to provide
        # much of its functionality manually.

        # ----------------------------------------------------------------------
        @classmethod
        @override
        def ResolveSymLink(cls, filename):
            # Python 2.+ doesn't support symlinks on Windows and there doesn't seem to be
            # a way to resolve a symlink without parsing the file, and libraries mentioned
            # http://stackoverflow.com/questions/1447575/symlinks-on-windows/7924557#7924557
            # and https://github.com/sid0/ntfs seem to have problems. The only way I have found
            # to reliabaly get this info is to parse the output of 'dir' and extact the info.
            # This is horribly painful code.

            import re

            # <No name in module> pylint: disable = E0611
            # <Unable to import> pylint: disable = F0401
            import six.moves.cPickle as pickle

            from CommonEnvironment import Process

            filename = filename.replace("/", os.path.sep)

            assert cls.IsSymLink(filename)

            if not hasattr(cls, "_symlink_lookup"):
                cls._symlink_lookup = {}                # <Attribute defined outside __init__> pylint: disable = W0201
                cls._symlink_redirection_maps = {}      # <Attribute defined outside __init__> pylint: disable = W0201

            if filename in cls._symlink_lookup:
                return cls._symlink_lookup[filename]

            # Are there any redirection maps that reside in the filename's path?
            path = os.path.split(filename)[0]
            while True:
                potential_map_filename = os.path.join(path, "symlink.redirection_map")
                if os.path.isfile(potential_map_filename):
                    if potential_map_filename not in cls._symlink_redirection_maps:
                        cls._symlink_redirection_maps[potential_map_filename] = pickle.loads(open(potential_map_filename).read())

                    if filename in cls._symlink_redirection_maps[potential_map_filename]:
                        return cls._symlink_redirection_maps[potential_map_filename][filename]

                new_path = os.path.split(path)[0]
                if new_path == path:
                    break

                path = new_path

            # If here, there isn't a map filename so we have to do things the hard way.
            if os.path.isfile(filename):
                command_line = 'dir /AL "%s"' % filename
                is_match = lambda name: True
            else:
                command_line = 'dir /AL "%s"' % os.path.dirname(filename)
                is_match = lambda name: name == os.path.basename(filename)

            result, output = Process.Execute(command_line)
            assert result == 0

            regexp = re.compile(r".+<(?P<type>.+?)>\s+(?P<link>.+?)\s+\[(?P<filename>.+?)\]\s*")

            for line in output.split("\n"):
                match = regexp.match(line)
                if match:
                    link = match.group("link")
                    if not is_match(link):
                        continue

                    target_filename = match.group("filename")
                    assert os.path.exists(target_filename), target_filename

                    cls._symlink_lookup[filename] = target_filename
                    return target_filename

            assert False, output

        # ----------------------------------------------------------------------
        @classmethod
        @override
        def DeleteSymLink(
            cls,
            filename,
            command_only=False,
        ):
            assert cls.IsSymLink(filename), filename

            if os.path.isdir(filename):
                command_line = 'rmdir "{}"'.format(filename)
            elif os.path.isfile(filename):
                command_line = 'del /Q "{}"'.format(filename)
            else:
                assert False, filename

            if command_only:
                return command_line

            os.system(command_line)
            return None
    else:
        # ----------------------------------------------------------------------
        _ResolveSymLinkRegex                = None

        @classmethod
        @override
        def ResolveSymLink(cls, filename):
            """os.realpath still doesn't work on Windows. os.readlink does."""

            # Actually, os.readline works for symlinks but not for junctions.
            # Assume that the content is a symlink and then take more extreme
            # measures if it looks like we are looking at a junction.
            assert cls.IsSymLink(filename)

            try:
                return os.readlink(filename)
            except ValueError as ex:
                if not os.path.isdir(filename):
                    raise

            # If here, we are looking at a junction.
            dirname, basename = os.path.split(filename)

            # Execute the dir command
            from io import StringIO
            from CommonEnvironment import Process

            sink = StringIO()

            result = Process.Execute('dir /AL "{}"'.format(dirname), sink)
            assert result == 0, result

            sink = sink.getvalue()

            # Process the output

            # This is annoying in that we have to search the parent dir to get a display
            # of what the junction points to. However, we can't use the exact directory name,
            # as that will display the contents of the junction.
            if cls._ResolveSymLinkRegex is None:
                cls._ResolveSymLinkRegex = re.compile(
                    textwrap.dedent(
                        r"""(?#
                        Start of line       )^(?#
                        Date                )\d{2}/\d{2}/\d{2,4}\s+(?#
                        Time                )\d{2}:\d{2} [AP]M\s+(?#
                        Junction            )<JUNCTION>\s+(?#
                        Name                )(?P<name>.+)\s+(?#
                        Target              )\[(?P<target>.+)\]\s*(?#
                        End of Line         )$(?#
                        )""",
                    ),
                )

            for line in sink.split("\n"):
                match = cls._ResolveSymLinkRegex.match(line)
                if match and match.group("name") == basename:
                    return match.group("target")

            raise Exception("'{}' is not a junction".format(filename))
