"""Build NSIS installers for Python applications.
"""
import errno
import io
import logging
import ntpath
import operator
import os
import re
import shutil
from subprocess import call
import sys

PY2 = sys.version_info[0] == 2

if os.name == 'nt':
    if PY2:
        import _winreg as winreg
    else:
        import winreg
else:
    winreg = None

from .copymodules import copy_modules
from .nsiswriter import NSISFileWriter
from .util import download

__version__ = '1.0'

pjoin = os.path.join
logger = logging.getLogger(__name__)

_PKGDIR = os.path.abspath(os.path.dirname(__file__))
DEFAULT_PY_VERSION = '2.7.8' if PY2 else '3.4.1'
DEFAULT_BUILD_DIR = pjoin('build', 'nsis')
DEFAULT_NSI_TEMPLATE = pjoin(_PKGDIR, 'template.nsi')
DEFAULT_ICON = pjoin(_PKGDIR, 'glossyorb.ico')
if os.name == 'nt' and sys.maxsize == (2**63)-1:
    DEFAULT_BITNESS = 64
else:
    DEFAULT_BITNESS = 32

def find_makensis_win():
    """Locate makensis.exe on Windows by querying the registry"""
    try:
        nsis_install_dir = winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\NSIS')
    except OSError:
        nsis_install_dir = winreg.QueryValue(winreg.HKEY_LOCAL_MACHINE, 'SOFTWARE\\Wow6432Node\\NSIS')

    return pjoin(nsis_install_dir, 'makensis.exe')

class InputError(ValueError):
    def __init__(self, param, value, expected):
        self.param = param
        self.value = value
        self.expected = expected

    def __str__(self):
        return "{e.value!r} is not valid for {e.param}, expected {e.expected}".format(e=self)

class InstallerBuilder(object):
    """Controls building an installer. This includes three main steps:
    
    1. Arranging the necessary files in the build directory.
    2. Filling out the template NSI file to control NSIS.
    3. Running ``makensis`` to build the installer.
    
    :param str appname: Application name
    :param str version: Application version
    :param list shortcuts: List of dictionaries, with keys matching
            :ref:`shortcut_config` in the config file
    :param str icon: Path to an icon for the application
    :param list packages: List of strings for importable packages to include
    :param list extra_files: List of 2-tuples (file, destination) of files to include
    :param str py_version: Full version of Python to bundle
    :param int py_bitness: Bitness of bundled Python (32 or 64)
    :param str build_dir: Directory to run the build in
    :param str installer_name: Filename of the installer to produce
    :param str nsi_template: Path to a template NSI file to use
    """
    def __init__(self, appname, version, shortcuts, icon=DEFAULT_ICON, 
                packages=None, extra_files=None, py_version=DEFAULT_PY_VERSION,
                py_bitness=DEFAULT_BITNESS, build_dir=DEFAULT_BUILD_DIR,
                installer_name=None, nsi_template=DEFAULT_NSI_TEMPLATE):
        self.appname = appname
        self.version = version
        self.shortcuts = shortcuts
        self.icon = icon
        self.packages = packages or []
        self.extra_files = extra_files or []
        self.py_version = py_version
        if not self._py_version_pattern.match(py_version):
            raise InputError('py_version', py_version, "a full Python version like '3.4.0'")
        self.py_bitness = py_bitness
        if py_bitness not in {32, 64}:
            raise InputError('py_bitness', py_bitness, "32 or 64")
        self.build_dir = build_dir
        self.installer_name = installer_name or self.make_installer_name()
        self.nsi_template = nsi_template
        self.nsi_file = pjoin(self.build_dir, 'installer.nsi')
        self.py_major_version = self.py_qualifier = '.'.join(self.py_version.split('.')[:2])
        if self.py_bitness == 32:
            self.py_qualifier += '-32'
        
        # To be filled later
        self.install_files = []
        self.install_dirs = []
    
    _py_version_pattern = re.compile(r'\d\.\d+\.\d+$')

    def make_installer_name(self):
        """Generate the filename of the installer exe
        
        e.g. My_App_1.0.exe
        """
        s = self.appname + '_' + self.version + '.exe'
        return s.replace(' ', '_')

    def fetch_python(self):
        """Fetch the MSI for the specified version of Python.
        
        It will be placed in the build directory.
        if possible.
        """
        version = self.py_version
        arch_tag = '.amd64' if (self.py_bitness==64) else ''
        url = 'https://python.org/ftp/python/{0}/python-{0}{1}.msi'.format(version, arch_tag)
        target = pjoin(self.build_dir, 'python-{0}{1}.msi'.format(version, arch_tag))
        if os.path.isfile(target):
            logger.info('Python MSI already in build directory.')
            return
        logger.info('Downloading Python MSI...')
        download(url, target)

    def fetch_pylauncher(self):
        """Fetch the MSI for PyLauncher (required for Python2.x).
    
        It will be placed in the build directory.
        """
        arch_tag = '.amd64' if (self.py_bitness == 64) else ''
        url = ("https://bitbucket.org/vinay.sajip/pylauncher/downloads/"
               "launchwin{0}.msi".format(arch_tag))
        target = pjoin(self.build_dir, 'launchwin{0}.msi'.format(arch_tag))
        if os.path.isfile(target):
            logger.info('PyLauncher MSI already in build directory.')
            return
        logger.info('Downloading PyLauncher MSI...')
        download(url, target)

    SCRIPT_TEMPLATE = """#!python{qualifier}
import sys, os
scriptdir, script = os.path.split(__file__)
pkgdir = os.path.join(scriptdir, 'pkgs')
sys.path.insert(0, pkgdir)
os.environ['PYTHONPATH'] = pkgdir + os.pathsep + os.environ.get('PYTHONPATH', '')

appdata = os.environ.get('APPDATA', None)
def excepthook(etype, value, tb):
    "Write unhandled exceptions to a file rather than exiting silently."
    import traceback
    with open(os.path.join(appdata, script+'.log'), 'w') as f:
        traceback.print_exception(etype, value, tb, file=f)

if appdata:
    sys.excepthook = excepthook

{extra_preamble}

if __name__ == '__main__':
    from {module} import {func}
    {func}()
"""
    
    def write_script(self, entrypt, target, extra_preamble=''):
        """Write a launcher script from a 'module:function' entry point
        
        python_version and bitness are used to write an appropriate shebang line
        for the PEP 397 Windows launcher.
        """
        module, func = entrypt.split(":")
        with open(target, 'w') as f:
            f.write(self.SCRIPT_TEMPLATE.format(qualifier=self.py_qualifier,
                    module=module, func=func, extra_preamble=extra_preamble))

        pkg = module.split('.')[0]
        if pkg not in self.packages:
            self.packages.append(pkg)

    def prepare_shortcuts(self):
        """Prepare shortcut files in the build directory.
        
        If entry_point is specified, write the script. If script is specified,
        copy to the build directory. Prepare target and parameters for these
        shortcuts.
        
        Also copies shortcut icons
        """
        files = set()
        for scname, sc in self.shortcuts.items():
            if not sc.get('target'):
                if sc.get('entry_point'):
                    sc['script'] = script = scname.replace(' ', '_') + '.launch.py' \
                                                + ('' if sc['console'] else 'w')

                    specified_preamble = sc.get('extra_preamble', None)
                    if isinstance(specified_preamble, str):
                        # Filename
                        extra_preamble = io.open(specified_preamble, encoding='utf-8')
                    elif specified_preamble is None:
                        extra_preamble = io.StringIO()  # Empty
                    else:
                        # Passed a StringIO or similar object
                        extra_preamble = specified_preamble

                    self.write_script(sc['entry_point'], pjoin(self.build_dir, script),
                                      extra_preamble.read().rstrip())
                else:
                    shutil.copy2(sc['script'], self.build_dir)

                sc['target'] = 'py' if sc['console'] else 'pyw'
                sc['parameters'] = '"%s"' % ntpath.join('$INSTDIR', sc['script'])
                files.add(os.path.basename(sc['script']))

            shutil.copy2(sc['icon'], self.build_dir)
            sc['icon'] = os.path.basename(sc['icon'])
            files.add(sc['icon'])
    
        self.install_files.extend([(f, '$INSTDIR') for f in files])
    
    def prepare_packages(self):
        """Move requested packages into the build directory.
        
        If a pynsist_pkgs directory exists, it is copied into the build
        directory as pkgs/ . Any packages not already there are found on
        sys.path and copied in.
        """
        logger.info("Copying packages into build directory...")
        build_pkg_dir = pjoin(self.build_dir, 'pkgs')
        if os.path.isdir(build_pkg_dir):
            shutil.rmtree(build_pkg_dir)
        if os.path.isdir('pynsist_pkgs'):
            shutil.copytree('pynsist_pkgs', build_pkg_dir)
        else:
            os.mkdir(build_pkg_dir)
        copy_modules(self.packages, build_pkg_dir, py_version=self.py_version)

    def copy_extra_files(self):
        """Copy a list of files into the build directory, and add them to
        install_files or install_dirs as appropriate.
        """
        for file, destination in self.extra_files:
            file = file.rstrip('/\\')
            basename = os.path.basename(file)

            if not destination:
                destination = '$INSTDIR'
    
            if os.path.isdir(file):
                target_name = pjoin(self.build_dir, basename)
                if os.path.isdir(target_name):
                    shutil.rmtree(target_name)
                elif os.path.exists(target_name):
                    os.unlink(target_name)
                shutil.copytree(file, target_name)
                self.install_dirs.append((basename, destination))
            else:
                shutil.copy2(file, self.build_dir)
                self.install_files.append((basename, destination))
    
    def write_nsi(self):
        """Write the NSI file to define the NSIS installer.
        
        Most of the details of this are in the template and the
        :class:`nsist.nsiswriter.NSISFileWriter` class.
        """
        nsis_writer = NSISFileWriter(self.nsi_template, installerbuilder=self,
            definitions = {'PRODUCT_NAME': self.appname,
                           'PRODUCT_VERSION': self.version,
                           'PY_VERSION': self.py_version,  # e.g. 3.4.1
                           'PY_MAJOR_VERSION': self.py_major_version,  #e.g. 3.4
                           'PY_QUALIFIER': self.py_qualifier,
                           'BITNESS' : str(self.py_bitness),
                           'PRODUCT_ICON': os.path.basename(self.icon),
                           'INSTALLER_NAME': self.installer_name,
                           'ARCH_TAG': '.amd64' if (self.py_bitness==64) else '',
                          },
            )

        logger.info('Writing NSI file to %s', self.nsi_file)
        # Sort by destination directory, so we can group them effectively
        self.install_files.sort(key=operator.itemgetter(1))
        nsis_writer.write(self.nsi_file)    

    def run_nsis(self):
        """Runs makensis using the specified .nsi file
        
        Returns the exit code.
        """
        try:
            if os.name == 'nt':
                makensis = find_makensis_win()
            else:
                makensis = 'makensis'
            return call([makensis, self.nsi_file])
        except OSError as e:
            # This should catch either the registry key or makensis being absent
            if e.errno == errno.ENOENT:
                print("makensis was not found. Install NSIS and try again.")
                print("http://nsis.sourceforge.net/Download")
                return 1

    def run(self):
        """Run all the steps to build an installer.
        """
        try:
            os.makedirs(self.build_dir)
        except OSError as e:
            if e.errno != errno.EEXIST:
                raise e
        self.fetch_python()
        if self.py_version < '3.3':
            self.fetch_pylauncher()
        
        self.prepare_shortcuts()
        
        # Packages
        self.prepare_packages()
        
        # Extra files
        self.copy_extra_files()

        self.write_nsi()
    
        exitcode = self.run_nsis()
        
        if not exitcode:
            logger.info('Installer written to %s', pjoin(self.build_dir, self.installer_name))

def main(argv=None):
    """Make an installer from the command line.
    
    This parses command line arguments and a config file, and calls
    :func:`all_steps` with the extracted information.
    """
    logger.setLevel(logging.INFO)
    logger.handlers = [logging.StreamHandler()]
    
    import argparse
    argp = argparse.ArgumentParser(prog='pynsist')
    argp.add_argument('config_file')
    options = argp.parse_args(argv)
    
    dirname, config_file = os.path.split(options.config_file)
    if dirname:
        os.chdir(dirname)
    
    try:
        from . import configreader
        cfg = configreader.read_and_validate(config_file)
        shortcuts = configreader.read_shortcuts_config(cfg)
    except configreader.InvalidConfig as e:
        logger.error('Error parsing configuration file:')
        logger.error(str(e))
        sys.exit(1)
    appcfg = cfg['Application']

    try:
        InstallerBuilder(
            appname = appcfg['name'],
            version = appcfg['version'],
            icon = appcfg.get('icon', DEFAULT_ICON),
            shortcuts = shortcuts,
            packages = cfg.get('Include', 'packages', fallback='').splitlines(),
            extra_files = configreader.read_extra_files(cfg),
            py_version = cfg.get('Python', 'version', fallback=DEFAULT_PY_VERSION),
            py_bitness = cfg.getint('Python', 'bitness', fallback=DEFAULT_BITNESS),
            build_dir = cfg.get('Build', 'directory', fallback=DEFAULT_BUILD_DIR),
            installer_name = cfg.get('Build', 'installer_name', fallback=None),
            nsi_template = cfg.get('Build', 'nsi_template', fallback=DEFAULT_NSI_TEMPLATE),
        ).run()
    except InputError as e:
        logger.error("Error in config values:")
        logger.error(str(e))
        sys.exit(1)
