# py2exe.py - Functionality for performing py2exe builds.
#
# Copyright 2019 Gregory Szorc <gregory.szorc@gmail.com>
# Copyright 2022 Matt Harbison <mharbison72@gmail.com>
#
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.

# no-check-code because Python 3 native.

import distutils.sysconfig
import os
import pathlib
import shutil
import subprocess
import sys

from .downloads import download_entry
from .util import (
    extract_tar_to_directory,
    extract_zip_to_directory,
    find_vc_runtime_dll,
    get_qt_dependencies,
    process_install_rules,
    python_exe_info,
    SourceDirs,
)


STAGING_RULES = [
    ('{hg_dir}/contrib/bash_completion', 'contrib/'),
    ('{hg_dir}/contrib/hgk', 'contrib/hgk'),  # TODO: is named 'contrib/hgk.tcl' in hg
    ('{hg_dir}/contrib/hgweb.fcgi', 'contrib/'),
    ('{hg_dir}/contrib/hgweb.wsgi', 'contrib/'),
    ('{hg_dir}/contrib/logo-droplets.svg', 'contrib/'),
    ('{hg_dir}/contrib/mercurial.el', 'contrib/'),
    ('{hg_dir}/contrib/mq.el', 'contrib/'),  # not in thg/contrib.wsx
    ('{hg_dir}/contrib/tcsh_completion', 'contrib/'),
    ('{hg_dir}/contrib/tcsh_completion_build.sh', 'contrib/'),
    ('{hg_dir}/contrib/vim/*', 'contrib/vim/'),
    # These are not in thg/contrib.wsx
    #('{hg_dir}/contrib/win32/postinstall.txt', 'ReleaseNotes.txt'),
    #('{hg_dir}/contrib/win32/ReadMe.html', 'ReadMe.html'),
    ('{hg_dir}/contrib/xml.rnc', 'contrib/'),
    ('{hg_dir}/contrib/zsh_completion', 'contrib/'),
    ('{hg_dir}/doc/*.html', 'doc/'),
    ('{hg_dir}/doc/style.css', 'doc/'),
    ('{hg_dir}/mercurial/helptext/**/*.txt', 'helptext/'),
    ('{hg_dir}/mercurial/configitems.toml', './'),
    # *.rc files come from TortoiseHg only
    #('{hg_dir}/mercurial/defaultrc/*.rc', 'defaultrc/'),
    ('{hg_dir}/mercurial/locale/**/*', 'locale/'),
    ('{hg_dir}/mercurial/templates/**/*', 'templates/'),
    ('{thg_dir}/COPYING.txt', 'COPYING.txt'),
    ('{thg_dir}/contrib/*.rc', 'defaultrc/'),
    ('{thg_dir}/dist/*.exe', './'),
    ('{thg_dir}/dist/*.dll', './'),
    ('{thg_dir}/dist/imageformats/*.dll', './imageformats/'),
    ('{thg_dir}/dist/lib/*.dll', 'lib/'),
    ('{thg_dir}/dist/lib/*.pyd', 'lib/'),
    ('{thg_dir}/dist/lib/library.zip', 'lib/'),
    ('{thg_dir}/dist/platforms/*.dll', './platforms/'),
    ('{thg_dir}/dist/python*.dll', './'),
    ('{thg_dir}/dist/styles/*.dll', './styles/'),
    ('{thg_dir}/icons/*.ico', 'icons/'),
    ('{thg_dir}/icons/README.txt', 'icons/'),
    ('{thg_dir}/locale/**/*', 'locale/'),
    ('{thg_dir}/win32/*.rc', 'defaultrc/'),
]

# List of paths to exclude from the staging area.
STAGING_EXCLUDES = [
    'doc/hg-ssh.8.html',
    'helptext/common.txt',
    'helptext/hg.1.txt',
    'helptext/hgignore.5.txt',
    'helptext/hgrc.5.txt',
    'helptext/hg-ssh.8.txt',
]


def build_py2exe(
    source_dirs: SourceDirs,
    build_dir: pathlib.Path,
    python_exe: pathlib.Path,
    build_name: str,
    venv_requirements_txt: pathlib.Path,
    extra_packages=None,
    extra_excludes=None,
    extra_dll_excludes=None,
    extra_packages_script=None,
    extra_includes=None,
):
    """Build TortoiseHg with py2exe.

    Build files will be placed in ``build_dir``.
    """

    env = dict(os.environ)
    env["HGPLAIN"] = "1"
    env["HGRCPATH"] = ""

    # The terminal opened with Ctrl+Shift+T has these defined, even when they
    # aren't defined in the system, and their presence causes py2exe to fail to
    # find Tcl.
    env.pop("TCL_LIBRARY", None)
    env.pop("TK_LIBRARY", None)

    site_packages_path = distutils.sysconfig.get_python_lib()

    included_packages = ['mercurial']

    # make sure that packages globally installed with e.g. easy_install
    # don't override packages we want to bundle and build ourselves
    err = set()
    for m in included_packages:
        try:
            mod = __import__(m)
        except ImportError:
            continue
        fm = mod.__file__
        if fm.lower().startswith(site_packages_path.lower()):
            print(
                "Error: '%s' overrides included package '%s'"
                % (os.path.dirname(fm), m)
            )
            err.add(m)
    if err:
        print(
            "(uninstall or hide these installed packages: %s)" % ', '.join(err)
        )
        sys.exit(1)

    # Locate the HTML Help Workshop installation
    if "PROGRAMW6432" in env:
        programfiles_x86 = env.get("PROGRAMFILES(X86)")
    else:
        programfiles_x86 = env.get("PROGRAMFILES")

    if programfiles_x86:
        env['PATH'] = "%s%s%s" % (
            env['PATH'],
            os.pathsep,
            os.path.join(programfiles_x86, 'HTML Help Workshop')
        )

    # If HTML Help Workshop needs to be installed, it can be found here:
    # https://download.microsoft.com/download/OfficeXPProf/Install/4.71.1015.0/W98NT42KMe/EN-US/HTMLHELP.EXE
    hhc = shutil.which("hhc.exe", path=env['PATH'])
    if not hhc:
        raise Exception("Unable to find HTML Help Workshop")

    # The thg/doc/build.bat task fails unless this is set explicitly.
    env['hhc_compiler'] = hhc

    py_info = python_exe_info(python_exe)

    vc_x64 = py_info['arch'] == '64bit'

    build_dir.mkdir(exist_ok=True)

    gettext_pkg, gettext_entry = download_entry('gettext', build_dir)
    gettext_dep_pkg = download_entry('gettext-dep', build_dir)[0]

    venv_path = build_dir / (
        'venv-%s-%s' % (build_name, 'x64' if vc_x64 else 'x86')
    )

    gettext_root = build_dir / ('gettext-win-%s' % gettext_entry['version'])

    if not gettext_root.exists():
        extract_zip_to_directory(gettext_pkg, gettext_root)
        extract_zip_to_directory(gettext_dep_pkg, gettext_root)

    if not venv_path.exists():
        print('creating virtualenv with dependencies')
        subprocess.run(
            [str(python_exe), "-m", "venv", str(venv_path)], check=True
        )

    venv_python = venv_path / 'Scripts' / 'python.exe'
    venv_pip = venv_path / 'Scripts' / 'pip.exe'

    subprocess.run(
        [str(venv_pip), 'install', '-r', str(venv_requirements_txt)], check=True
    )

    if extra_packages_script:
        more_packages = set(
            subprocess.check_output(extra_packages_script, cwd=build_dir)
            .split(b'\0')[-1]
            .strip()
            .decode('utf-8')
            .splitlines()
        )
        if more_packages:
            if not extra_packages:
                extra_packages = more_packages
            else:
                extra_packages |= more_packages

    if extra_packages:
        env['HG_PY2EXE_EXTRA_PACKAGES'] = ' '.join(sorted(extra_packages))
        hgext3rd_extras = sorted(
            e for e in extra_packages if e.startswith('hgext3rd.')
        )
        if hgext3rd_extras:
            env['HG_PY2EXE_EXTRA_INSTALL_PACKAGES'] = ' '.join(hgext3rd_extras)
    if extra_includes:
        env['HG_PY2EXE_EXTRA_INCLUDES'] = ' '.join(sorted(extra_includes))
    if extra_excludes:
        env['HG_PY2EXE_EXTRA_EXCLUDES'] = ' '.join(sorted(extra_excludes))
    if extra_dll_excludes:
        env['HG_PY2EXE_EXTRA_DLL_EXCLUDES'] = ' '.join(
            sorted(extra_dll_excludes)
        )

    # Register location of msgfmt and other binaries.
    env['PATH'] = '%s%s%s' % (
        env['PATH'],
        os.pathsep,
        str(gettext_root / 'bin'),
    )

    print('building Mercurial')
    hg_dir = source_dirs.hg

    # Clear files added by previous installer runs
    subprocess.run(
        ["hg.exe", "--config", "extensions.purge=", "purge", "--all"],
        cwd=str(hg_dir),
        env=env,
        check=True,
    )

    oldpath = env.get('PYTHONPATH', '').split(os.pathsep)
    path = [str(hg_dir), str(source_dirs.evolve)]
    path.extend(oldpath)
    env['PYTHONPATH'] = os.pathsep.join(path)

    subprocess.run(
        # Need to use POSIX paths to prevent MSYS from dropping the '\'
        # separators in the normal path.
        ["make", "local", f"PYTHON={str(venv_python.as_posix())}"],
        cwd=str(hg_dir),
        env=env,
        check=True,
    )
    subprocess.run(
        # Use python_exe instead of venv_python so it has access to the docutils
        # module in the bootstrap venv.
        # Need to use POSIX paths to prevent MSYS from dropping the '\'
        # separators in the normal path.
        ["make", "-C", "doc", "html", f"PYTHON={str(python_exe.as_posix())}"],
        cwd=str(hg_dir),
        env=env,
        check=True,
    )

    print('building TortoiseHg')
    thg_dir = source_dirs.thg

    if thg_dir.exists():
        shutil.rmtree(thg_dir)
    thg_dir.mkdir()

    # Select wdir if dirty, otherwise . to get the change count correct for
    # tagged builds, while still copying uncommitted changes to the build tree.
    subprocess.run(
        [
            'hg.exe',
            'archive',
            '-r',
            "heads(. or wdir() & file('relglob:**'))",
            str(thg_dir)
        ],
        cwd=str(source_dirs.original),
        env=env,
        check=True,
    )

    subprocess.run(
        [str(venv_python), 'setup.py', '--version'],
        cwd=str(thg_dir),
        env=env,
        check=True,
    )
    subprocess.run(  # Build locales
        [str(venv_python), 'setup.py', 'build_mo'],
        cwd=str(thg_dir),
        env=env,
        check=True,
    )
    subprocess.run(  # Build cmenu translation registry files
        [str(venv_python), 'reggen.py'],
        cwd=str(thg_dir / "win32"),
        env=env,
        check=True,
    )

    # Use build helpers from win32
    dest_config_py = thg_dir / 'tortoisehg' / 'util' / 'config.py'
    shutil.copyfile(thg_dir / 'win32' / 'config.py', dest_config_py)

    with open(thg_dir / "setup.cfg", "w") as fp:
        fp.write('''
[py2exe]
includes = PyQt5.sip, PyQt5.QtPrintSupport, PyQt5.QtSvg, PyQt5.QtXml,
           mercurial_keyring, pygit2, _curses, _curses_panel, site

packages = ctypes, curses, dulwich, email, encodings, iniparse, json, keyring,
           keyring.backends, nntplib, pygments, sspi, sqlite3, six,

           hgext,
           hgext.convert, hgext.fastannotate, hgext.fsmonitor,
           hgext.fsmonitor.pywatchman, hgext.git, hgext.highlight,
           hgext.hooklib, hgext.largefiles,
           hgext.lfs, hgext.narrow, hgext.remotefilelog, hgext.zeroconf,
           mercurial.cext, mercurial.pure, mercurial.defaultrc,

           hgext3rd, hgext3rd.evolve, hgext3rd.topic, hggit,

           tortoisehg.hgqt, tortoisehg.util
'''.strip())

    # py2exe has trouble finding hgext3rd packages unless they're dumped into
    # the build directory.
    evolve_hgext3rd_dir = source_dirs.evolve / 'hgext3rd'
    shutil.copytree(evolve_hgext3rd_dir / 'evolve', thg_dir / 'hgext3rd' / 'evolve')
    shutil.copytree(evolve_hgext3rd_dir / 'topic', thg_dir / 'hgext3rd' / 'topic')
    shutil.rmtree(thg_dir / 'hgext3rd' / 'evolve' / 'hack')

    # Note: hgextindex was regenerated here in the winbuild script.

    subprocess.run(  # Build docs
        ['build', 'chm'],
        shell=True,
        cwd=str(thg_dir / "doc"),
        env=env,
        check=True,
    )

    env['MERCURIAL_PATH'] = str(hg_dir / "mercurial")
    env['HGEXT_PATH'] = str(hg_dir / "hgext")

    # Put pyrcc5.exe installed by pip on PATH
    env['PATH'] = "%s;%s" % (venv_python.parent, env['PATH'])

    # The py2exe target generates these in the same location for both py2 and
    # py3 builds.  But for some reason, the generated *_rc.py files aren't put
    # into library.zip as part of the py3 build process.  So generate the files
    # early and copy them to the source tree where they will be picked up.
    subprocess.run(  # Build the resource files
        [str(venv_python), 'setup.py', 'build_qrc'],
        cwd=str(thg_dir),
        env=env,
        check=True,
    )
    for f in ("icons_rc.py", "translations_rc.py"):
        shutil.copyfile(
            thg_dir / "build" / "lib" / "tortoisehg" / "hgqt" / f,
            thg_dir / "tortoisehg" / "hgqt" / f
        )

    subprocess.run(  # Build the executables
        [str(venv_python), 'setup.py', 'py2exe', '-b3'],
        cwd=str(thg_dir),
        env=env,
        check=True,
    )

    dist_dir = thg_dir / "dist"

    # TODO: stage the newest vcruntime140.dll.  As of now, the CRT bundled with
    #  Qt will overwrite the CRT from the local build environment, which is
    #  currently fine because the Qt version appears newer than even what comes
    #  from VS 2019.

    # Add vcruntimeXXX.dll next to executable.
    vc_runtime_dll = find_vc_runtime_dll(x64=vc_x64)
    shutil.copy(vc_runtime_dll, dist_dir / vc_runtime_dll.name)

    # In addition to the architecture specific C runtime files, the 64-bit
    # builds include libcrypto-1_1-x64.dll and libssl-1_1-x64.dll.  These need
    # to be next to the executable in order to connect to the website with https
    # and check for updates in the AboutBox.  Assume the other CRT files also
    # need to be next to the executable to load.
    for dll in sorted(get_qt_dependencies(venv_python, dist_dir)):
        STAGING_RULES.append((str(dll), "./"))

    # On both 32-bit and 64-bit builds, py2exe stages libcrypto-1_1.dll and
    # libssl-1_1.dll under dist/lib/.  For 32-bit builds, they need to be moved
    # up a level to be next to the executable in order to connect to the website
    # with https.  For 64-bit builds, the update check works when they are under
    # lib/ or up a level, next to the executable.  So move them here for the
    # 32-bit build, but leave them alone for the 64-bit build- if they can't be
    # loaded, _ssl.pyd fails to load on the 64-bit build, and the app fails to
    # start.
    #
    # The check for this working properly from ``hg debugshell`` is:
    #
    # >>> from PyQt5 import QtNetwork
    # >>> print(QtNetwork.QSslSocket.sslLibraryBuildVersionString())
    # OpenSSL 1.1.1g  21 Apr 2020
    # >>> print(QtNetwork.QSslSocket.sslLibraryVersionString())
    # OpenSSL 1.1.1n  15 Mar 2022
    #
    # When not properly positioned, the output of the last line is empty.
    if not vc_x64:
        libpath = pathlib.Path('lib')
        dlls = [libpath / 'libcrypto-1_1.dll', libpath / 'libssl-1_1.dll']
        STAGING_RULES.extend((str(dist_dir / dll), './') for dll in dlls)
        STAGING_EXCLUDES.extend(str(dll) for dll in dlls)


def stage_install(
    source_dirs: SourceDirs, staging_dir: pathlib.Path, lower_case=False
):
    """Copy all files to be installed to a directory.

    This allows packaging to simply walk a directory tree to find source
    files.
    """
    if lower_case:
        rules = []
        for source, dest in STAGING_RULES:
            # Only lower directory names.
            if '/' in dest:
                parent, leaf = dest.rsplit('/', 1)
                dest = '%s/%s' % (parent.lower(), leaf)
            rules.append((source, dest))
    else:
        rules = STAGING_RULES

    dirs = {
        "hg_dir": str(source_dirs.hg),
        "thg_dir": str(source_dirs.thg),
        "shellext_dir": str(source_dirs.shellext),
        "winbuild_dir": str(source_dirs.winbuild)
    }

    rules = [(src.format(**dirs), dest) for (src, dest) in rules]

    # The source_dir doesn't matter much here because the paths are absolute.
    process_install_rules(rules, source_dirs.winbuild, staging_dir)

    # Purge any files we don't want to be there.
    for f in STAGING_EXCLUDES:
        p = staging_dir / f
        if p.exists():
            print('removing %s' % p)
            p.unlink()
