# -*- coding: utf-8 -*-
#
# Copyright (C) 2013-2015 Vinay Sajip.
# Licensed to the Python Software Foundation under a contributor agreement.
# See LICENSE.txt and CONTRIBUTORS.txt.
#
from io import BytesIO
import logging
import os
import re
import struct
import sys
import time
from zipfile import ZipInfo

from .compat import sysconfig, detect_encoding, ZipFile
from .resources import finder
from .util import (
    FileOperator,
    get_export_entry,
    convert_path,
    get_executable,
    get_platform,
    in_venv,
)

logger = logging.getLogger(__name__)

_DEFAULT_MANIFEST = """
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
 <assemblyIdentity version="1.0.0.0"
 processorArchitecture="X86"
 name="%s"
 type="win32"/>

 <!-- Identify the application security requirements. -->
 <trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
 <security>
 <requestedPrivileges>
 <requestedExecutionLevel level="asInvoker" uiAccess="false"/>
 </requestedPrivileges>
 </security>
 </trustInfo>
</assembly>""".strip()

# check if Python is called on the first line with this expression
FIRST_LINE_RE = re.compile(b"^#!.*pythonw?[0-9.]*([ \t].*)?$")
SCRIPT_TEMPLATE = r"""# -*- coding: utf-8 -*-
import re
import sys
from %(module)s import %(import_name)s
if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
    sys.exit(%(func)s())
"""


def enquote_executable(executable):
    if " " in executable:
        # make sure we quote only the executable in case of env
        # for example /usr/bin/env "/dir with spaces/bin/jython"
        # instead of "/usr/bin/env /dir with spaces/bin/jython"
        # otherwise whole
        if executable.startswith("/usr/bin/env "):
            env, _executable = executable.split(" ", 1)
            if " " in _executable and not _executable.startswith('"'):
                executable = '%s "%s"' % (env, _executable)
        else:
            if not executable.startswith('"'):
                executable = '"%s"' % executable
    return executable


# Keep the old name around (for now), as there is at least one project using it!
_enquote_executable = enquote_executable


class ScriptMaker(object):
    """
    A class to copy or create scripts from source scripts or callable
    specifications.
    """

    script_template = SCRIPT_TEMPLATE

    executable = None  # for shebangs

    def __init__(
        self, source_dir, target_dir, add_launchers=True, dry_run=False, fileop=None
    ):
        self.source_dir = source_dir
        self.target_dir = target_dir
        self.add_launchers = add_launchers
        self.force = False
        self.clobber = False
        # It only makes sense to set mode bits on POSIX.
        self.set_mode = (os.name == "posix") or (
            os.name == "java" and os._name == "posix"
        )
        self.variants = set(("", "X.Y"))
        self._fileop = fileop or FileOperator(dry_run)

        self._is_nt = os.name == "nt" or (os.name == "java" and os._name == "nt")
        self.version_info = sys.version_info

    def _get_alternate_executable(self, executable, options):
        if options.get("gui", False) and self._is_nt:  # pragma: no cover
            dn, fn = os.path.split(executable)
            fn = fn.replace("python", "pythonw")
            executable = os.path.join(dn, fn)
        return executable

    if sys.platform.startswith("java"):  # pragma: no cover

        def _is_shell(self, executable):
            """
            Determine if the specified executable is a script
            (contains a #! line)
            """
            try:
                with open(executable) as fp:
                    return fp.read(2) == "#!"
            except (OSError, IOError):
                logger.warning("Failed to open %s", executable)
                return False

        def _fix_jython_executable(self, executable):
            if self._is_shell(executable):
                # Workaround for Jython is not needed on Linux systems.
                import java

                if java.lang.System.getProperty("os.name") == "Linux":
                    return executable
            elif executable.lower().endswith("jython.exe"):
                # Use wrapper exe for Jython on Windows
                return executable
            return "/usr/bin/env %s" % executable

    def _build_shebang(self, executable, post_interp):
        """
        Build a shebang line. In the simple case (on Windows, or a shebang line
        which is not too long or contains spaces) use a simple formulation for
        the shebang. Otherwise, use /bin/sh as the executable, with a contrived
        shebang which allows the script to run either under Python or sh, using
        suitable quoting. Thanks to Harald Nordgren for his input.

        See also: http://www.in-ulm.de/~mascheck/various/shebang/#length
                  https://hg.mozilla.org/mozilla-central/file/tip/mach
        """
        if os.name != "posix":
            simple_shebang = True
        else:
            # Add 3 for '#!' prefix and newline suffix.
            shebang_length = len(executable) + len(post_interp) + 3
            if sys.platform == "darwin":
                max_shebang_length = 512
            else:
                max_shebang_length = 127
            simple_shebang = (b" " not in executable) and (
                shebang_length <= max_shebang_length
            )

        if simple_shebang:
            result = b"#!" + executable + post_interp + b"\n"
        else:
            result = b"#!/bin/sh\n"
            result += b"'''exec' " + executable + post_interp + b' "$0" "$@"\n'
            result += b"' '''"
        return result

    def _get_shebang(self, encoding, post_interp=b"", options=None):
        enquote = True
        if self.executable:
            executable = self.executable
            enquote = False  # assume this will be taken care of
        elif not sysconfig.is_python_build():
            executable = get_executable()
        elif in_venv():  # pragma: no cover
            executable = os.path.join(
                sysconfig.get_path("scripts"),
                "python%s" % sysconfig.get_config_var("EXE"),
            )
        else:  # pragma: no cover
            executable = os.path.join(
                sysconfig.get_config_var("BINDIR"),
                "python%s%s"
                % (
                    sysconfig.get_config_var("VERSION"),
                    sysconfig.get_config_var("EXE"),
                ),
            )
            if not os.path.isfile(executable):
                # for Python builds from source on Windows, no Python executables with
                # a version suffix are created, so we use python.exe
                executable = os.path.join(
                    sysconfig.get_config_var("BINDIR"),
                    "python%s" % (sysconfig.get_config_var("EXE")),
                )
        if options:
            executable = self._get_alternate_executable(executable, options)

        if sys.platform.startswith("java"):  # pragma: no cover
            executable = self._fix_jython_executable(executable)

        # Normalise case for Windows - COMMENTED OUT
        # executable = os.path.normcase(executable)
        # N.B. The normalising operation above has been commented out: See
        # issue #124. Although paths in Windows are generally case-insensitive,
        # they aren't always. For example, a path containing a ẞ (which is a
        # LATIN CAPITAL LETTER SHARP S - U+1E9E) is normcased to ß (which is a
        # LATIN SMALL LETTER SHARP S' - U+00DF). The two are not considered by
        # Windows as equivalent in path names.

        # If the user didn't specify an executable, it may be necessary to
        # cater for executable paths with spaces (not uncommon on Windows)
        if enquote:
            executable = enquote_executable(executable)
        # Issue #51: don't use fsencode, since we later try to
        # check that the shebang is decodable using utf-8.
        executable = executable.encode("utf-8")
        # in case of IronPython, play safe and enable frames support
        if (
            sys.platform == "cli"
            and "-X:Frames" not in post_interp
            and "-X:FullFrames" not in post_interp
        ):  # pragma: no cover
            post_interp += b" -X:Frames"
        shebang = self._build_shebang(executable, post_interp)
        # Python parser starts to read a script using UTF-8 until
        # it gets a #coding:xxx cookie. The shebang has to be the
        # first line of a file, the #coding:xxx cookie cannot be
        # written before. So the shebang has to be decodable from
        # UTF-8.
        try:
            shebang.decode("utf-8")
        except UnicodeDecodeError:  # pragma: no cover
            raise ValueError("The shebang (%r) is not decodable from utf-8" % shebang)
        # If the script is encoded to a custom encoding (use a
        # #coding:xxx cookie), the shebang has to be decodable from
        # the script encoding too.
        if encoding != "utf-8":
            try:
                shebang.decode(encoding)
            except UnicodeDecodeError:  # pragma: no cover
                raise ValueError(
                    "The shebang (%r) is not decodable "
                    "from the script encoding (%r)" % (shebang, encoding)
                )
        return shebang

    def _get_script_text(self, entry):
        return self.script_template % dict(
            module=entry.prefix,
            import_name=entry.suffix.split(".")[0],
            func=entry.suffix,
        )

    manifest = _DEFAULT_MANIFEST

    def get_manifest(self, exename):
        base = os.path.basename(exename)
        return self.manifest % base

    def _write_script(self, names, shebang, script_bytes, filenames, ext):
        use_launcher = self.add_launchers and self._is_nt
        linesep = os.linesep.encode("utf-8")
        if not shebang.endswith(linesep):
            shebang += linesep
        if not use_launcher:
            script_bytes = shebang + script_bytes
        else:  # pragma: no cover
            if ext == "py":
                launcher = self._get_launcher("t")
            else:
                launcher = self._get_launcher("w")
            stream = BytesIO()
            with ZipFile(stream, "w") as zf:
                source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH")
                if source_date_epoch:
                    date_time = time.gmtime(int(source_date_epoch))[:6]
                    zinfo = ZipInfo(filename="__main__.py", date_time=date_time)
                    zf.writestr(zinfo, script_bytes)
                else:
                    zf.writestr("__main__.py", script_bytes)
            zip_data = stream.getvalue()
            script_bytes = launcher + shebang + zip_data
        for name in names:
            outname = os.path.join(self.target_dir, name)
            if use_launcher:  # pragma: no cover
                n, e = os.path.splitext(outname)
                if e.startswith(".py"):
                    outname = n
                outname = "%s.exe" % outname
                try:
                    self._fileop.write_binary_file(outname, script_bytes)
                except Exception:
                    # Failed writing an executable - it might be in use.
                    logger.warning(
                        "Failed to write executable - trying to " "use .deleteme logic"
                    )
                    dfname = "%s.deleteme" % outname
                    if os.path.exists(dfname):
                        os.remove(dfname)  # Not allowed to fail here
                    os.rename(outname, dfname)  # nor here
                    self._fileop.write_binary_file(outname, script_bytes)
                    logger.debug("Able to replace executable using " ".deleteme logic")
                    try:
                        os.remove(dfname)
                    except Exception:
                        pass  # still in use - ignore error
            else:
                if self._is_nt and not outname.endswith("." + ext):  # pragma: no cover
                    outname = "%s.%s" % (outname, ext)
                if os.path.exists(outname) and not self.clobber:
                    logger.warning("Skipping existing file %s", outname)
                    continue
                self._fileop.write_binary_file(outname, script_bytes)
                if self.set_mode:
                    self._fileop.set_executable_mode([outname])
            filenames.append(outname)

    variant_separator = "-"

    def get_script_filenames(self, name):
        result = set()
        if "" in self.variants:
            result.add(name)
        if "X" in self.variants:
            result.add("%s%s" % (name, self.version_info[0]))
        if "X.Y" in self.variants:
            result.add(
                "%s%s%s.%s"
                % (
                    name,
                    self.variant_separator,
                    self.version_info[0],
                    self.version_info[1],
                )
            )
        return result

    def _make_script(self, entry, filenames, options=None):
        post_interp = b""
        if options:
            args = options.get("interpreter_args", [])
            if args:
                args = " %s" % " ".join(args)
                post_interp = args.encode("utf-8")
        shebang = self._get_shebang("utf-8", post_interp, options=options)
        script = self._get_script_text(entry).encode("utf-8")
        scriptnames = self.get_script_filenames(entry.name)
        if options and options.get("gui", False):
            ext = "pyw"
        else:
            ext = "py"
        self._write_script(scriptnames, shebang, script, filenames, ext)

    def _copy_script(self, script, filenames):
        adjust = False
        script = os.path.join(self.source_dir, convert_path(script))
        outname = os.path.join(self.target_dir, os.path.basename(script))
        if not self.force and not self._fileop.newer(script, outname):
            logger.debug("not copying %s (up-to-date)", script)
            return

        # Always open the file, but ignore failures in dry-run mode --
        # that way, we'll get accurate feedback if we can read the
        # script.
        try:
            f = open(script, "rb")
        except IOError:  # pragma: no cover
            if not self.dry_run:
                raise
            f = None
        else:
            first_line = f.readline()
            if not first_line:  # pragma: no cover
                logger.warning("%s is an empty file (skipping)", script)
                return

            match = FIRST_LINE_RE.match(first_line.replace(b"\r\n", b"\n"))
            if match:
                adjust = True
                post_interp = match.group(1) or b""

        if not adjust:
            if f:
                f.close()
            self._fileop.copy_file(script, outname)
            if self.set_mode:
                self._fileop.set_executable_mode([outname])
            filenames.append(outname)
        else:
            logger.info("copying and adjusting %s -> %s", script, self.target_dir)
            if not self._fileop.dry_run:
                encoding, lines = detect_encoding(f.readline)
                f.seek(0)
                shebang = self._get_shebang(encoding, post_interp)
                if b"pythonw" in first_line:  # pragma: no cover
                    ext = "pyw"
                else:
                    ext = "py"
                n = os.path.basename(outname)
                self._write_script([n], shebang, f.read(), filenames, ext)
            if f:
                f.close()

    @property
    def dry_run(self):
        return self._fileop.dry_run

    @dry_run.setter
    def dry_run(self, value):
        self._fileop.dry_run = value

    if os.name == "nt" or (os.name == "java" and os._name == "nt"):  # pragma: no cover
        # Executable launcher support.
        # Launchers are from https://bitbucket.org/vinay.sajip/simple_launcher/

        def _get_launcher(self, kind):
            if struct.calcsize("P") == 8:  # 64-bit
                bits = "64"
            else:
                bits = "32"
            platform_suffix = "-arm" if get_platform() == "win-arm64" else ""
            name = "%s%s%s.exe" % (kind, bits, platform_suffix)
            # Issue 31: don't hardcode an absolute package name, but
            # determine it relative to the current package
            distlib_package = __name__.rsplit(".", 1)[0]
            resource = finder(distlib_package).find(name)
            if not resource:
                msg = "Unable to find resource %s in package %s" % (
                    name,
                    distlib_package,
                )
                raise ValueError(msg)
            return resource.bytes

    # Public API follows

    def make(self, specification, options=None):
        """
        Make a script.

        :param specification: The specification, which is either a valid export
                              entry specification (to make a script from a
                              callable) or a filename (to make a script by
                              copying from a source location).
        :param options: A dictionary of options controlling script generation.
        :return: A list of all absolute pathnames written to.
        """
        filenames = []
        entry = get_export_entry(specification)
        if entry is None:
            self._copy_script(specification, filenames)
        else:
            self._make_script(entry, filenames, options=options)
        return filenames

    def make_multiple(self, specifications, options=None):
        """
        Take a list of specifications and make scripts from them,
        :param specifications: A list of specifications.
        :return: A list of all absolute pathnames written to,
        """
        filenames = []
        for specification in specifications:
            filenames.extend(self.make(specification, options))
        return filenames
