#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
    nodeenv
    ~~~~~~~
    Node.js virtual environment

    :copyright: (c) 2014 by Eugene Kalinin
    :license: BSD, see LICENSE for more details.
"""

import contextlib
import io
import sys
import os
import re
import stat
import logging
import operator
import optparse
import subprocess
import tarfile
import pipes

try:  # pragma: no cover (py2 only)
    from ConfigParser import SafeConfigParser as ConfigParser
    from HTMLParser import HTMLParser
    from urllib2 import urlopen
    iteritems = operator.methodcaller('iteritems')
except ImportError:  # pragma: no cover (py3 only)
    from configparser import ConfigParser
    from html.parser import HTMLParser
    from urllib.request import urlopen
    iteritems = operator.methodcaller('items')

from pkg_resources import parse_version

nodeenv_version = '0.13.2'

join = os.path.join
abspath = os.path.abspath
src_domain = "nodejs.org"

is_PY3 = sys.version_info[0] == 3
if is_PY3:
    from functools import cmp_to_key
# ---------------------------------------------------------
# Utils


class Config(object):
    """
    Configuration namespace.
    """

    # Defaults
    node = 'latest'
    npm = 'latest'
    with_npm = False
    jobs = '2'
    without_ssl = False
    debug = False
    profile = False
    make = 'make'
    prebuilt = False

    @classmethod
    def _load(cls, configfiles, verbose=False):
        """
        Load configuration from the given files in reverse order,
        if they exist and have a [nodeenv] section.
        """
        for configfile in reversed(configfiles):
            configfile = os.path.expanduser(configfile)
            if not os.path.exists(configfile):
                continue

            ini_file = ConfigParser()
            ini_file.read(configfile)
            section = "nodeenv"
            if not ini_file.has_section(section):
                continue

            for attr, val in iteritems(vars(cls)):
                if attr.startswith('_') or not \
                   ini_file.has_option(section, attr):
                    continue

                if isinstance(val, bool):
                    val = ini_file.getboolean(section, attr)
                else:
                    val = ini_file.get(section, attr)

                if verbose:
                    print('CONFIG {0}: {1} = {2}'.format(
                        os.path.basename(configfile), attr, val))
                setattr(cls, attr, val)

    @classmethod
    def _dump(cls):
        """
        Print defaults for the README.
        """
        print("    [nodeenv]")
        print("    " + "\n    ".join(
            "%s = %s" % (k, v) for k, v in sorted(iteritems(vars(cls)))
            if not k.startswith('_')))


Config._default = dict(
    (attr, val) for attr, val in iteritems(vars(Config))
    if not attr.startswith('_')
)


def clear_output(out):
    """
    Remove new-lines and
    """
    return out.decode('utf-8').replace('\n', '')


def remove_env_bin_from_path(env, env_bin_dir):
    """
    Remove bin directory of the current environment from PATH
    """
    return env.replace(env_bin_dir + ':', '')


def node_version_from_opt(opt):
    """
    Parse the node version from the optparse options
    """
    if opt.node == 'system':
        out, err = subprocess.Popen(
            ["node", "--version"], stdout=subprocess.PIPE).communicate()
        return parse_version(clear_output(out).replace('v', ''))

    return parse_version(opt.node)


def create_logger():
    """
    Create logger for diagnostic
    """
    # create logger
    logger = logging.getLogger("nodeenv")
    logger.setLevel(logging.INFO)

    # monkey patch
    def emit(self, record):
        msg = self.format(record)
        fs = "%s" if getattr(record, "continued", False) else "%s\n"
        self.stream.write(fs % msg)
        self.flush()
    logging.StreamHandler.emit = emit

    # create console handler and set level to debug
    ch = logging.StreamHandler()
    ch.setLevel(logging.DEBUG)

    # create formatter
    formatter = logging.Formatter(fmt="%(message)s")

    # add formatter to ch
    ch.setFormatter(formatter)

    # add ch to logger
    logger.addHandler(ch)
    return logger
logger = create_logger()


def parse_args(check=True):
    """
    Parses command line arguments.

    Set `check` to False to skip validation checks.
    """
    parser = optparse.OptionParser(
        version=nodeenv_version,
        usage="%prog [OPTIONS] ENV_DIR")

    parser.add_option(
        '-n', '--node', dest='node', metavar='NODE_VER', default=Config.node,
        help='The node.js version to use, e.g., '
        '--node=0.4.3 will use the node-v0.4.3 '
        'to create the new environment. '
        'The default is last stable version (`latest`). '
        'Use `system` to use system-wide node.')

    parser.add_option(
        '-i', '--iojs',
        action='store_true', dest='io', default=False,
        help='Use iojs instead of nodejs.')

    parser.add_option(
        '-j', '--jobs', dest='jobs', default=Config.jobs,
        help='Sets number of parallel commands at node.js compilation. '
        'The default is 2 jobs.')

    parser.add_option(
        '--load-average', dest='load_average',
        help='Sets maximum load average for executing parallel commands '
             'at node.js compilation.')

    parser.add_option(
        '-v', '--verbose',
        action='store_true', dest='verbose', default=False,
        help="Verbose mode")

    parser.add_option(
        '-q', '--quiet',
        action='store_true', dest='quiet', default=False,
        help="Quiet mode")

    parser.add_option(
        '-C', '--config-file', dest='config_file', default=None,
        help="Load a different file than '~/.nodeenvrc'. "
        "Pass an empty string for no config (use built-in defaults).")

    parser.add_option(
        '-r', '--requirements',
        dest='requirements', default='', metavar='FILENAME',
        help='Install all the packages listed in the given requirements file.')

    parser.add_option(
        '--prompt', dest='prompt',
        help='Provides an alternative prompt prefix for this environment')

    parser.add_option(
        '-l', '--list', dest='list',
        action='store_true', default=False,
        help='Lists available node.js versions')

    parser.add_option(
        '--update', dest='update',
        action='store_true', default=False,
        help='Install npm packages from file without node')

    parser.add_option(
        '--without-ssl', dest='without_ssl',
        action='store_true', default=Config.without_ssl,
        help='Build node.js without SSL support')

    parser.add_option(
        '--debug', dest='debug',
        action='store_true', default=Config.debug,
        help='Build debug variant of the node.js')

    parser.add_option(
        '--profile', dest='profile',
        action='store_true', default=Config.profile,
        help='Enable profiling for node.js')

    parser.add_option(
        '--with-npm', dest='with_npm',
        action='store_true', default=Config.with_npm,
        help='Build without installing npm into the new virtual environment. '
        'Required for node.js < 0.6.3. By default, the npm included with '
        'node.js is used.')

    parser.add_option(
        '--npm', dest='npm',
        metavar='NPM_VER', default=Config.npm,
        help='The npm version to use, e.g., '
        '--npm=0.3.18 will use the npm-0.3.18.tgz '
        'tarball to install. '
        'The default is last available version (`latest`).')

    parser.add_option(
        '--no-npm-clean', dest='no_npm_clean',
        action='store_true', default=False,
        help='Skip the npm 0.x cleanup.  Cleanup is enabled by default.')

    parser.add_option(
        '--python-virtualenv', '-p', dest='python_virtualenv',
        action='store_true', default=False,
        help='Use current python virtualenv')

    parser.add_option(
        '--clean-src', '-c', dest='clean_src',
        action='store_true', default=False,
        help='Remove "src" directory after installation')

    parser.add_option(
        '--force', dest='force',
        action='store_true', default=False,
        help='Force installation in a pre-existing directory')

    parser.add_option(
        '--make', '-m', dest='make_path',
        metavar='MAKE_PATH',
        help='Path to make command',
        default=Config.make)

    parser.add_option(
        '--prebuilt', dest='prebuilt',
        action='store_true', default=Config.prebuilt,
        help='Install node.js from prebuilt package')

    options, args = parser.parse_args()
    if options.config_file is None:
        options.config_file = ["./setup.cfg", "~/.nodeenvrc"]
    elif not options.config_file:
        options.config_file = []
    else:
        # Make sure that explicitly provided files exist
        if not os.path.exists(options.config_file):
            parser.error("Config file '{0}' doesn't exist!".format(
                options.config_file))
        options.config_file = [options.config_file]

    if not check:
        return options, args

    if not options.list and not options.python_virtualenv:
        if not args:
            parser.error('You must provide a DEST_DIR or '
                         'use current python virtualenv')

        if len(args) > 1:
            parser.error('There must be only one argument: DEST_DIR '
                         '(you gave: {0})'.format(' '.join(args)))

    return options, args


def mkdir(path):
    """
    Create directory
    """
    if not os.path.exists(path):
        logger.debug(' * Creating: %s ... ', path, extra=dict(continued=True))
        os.makedirs(path)
        logger.debug('done.')
    else:
        logger.debug(' * Directory %s already exists', path)


def writefile(dest, content, overwrite=True, append=False):
    """
    Create file and write content in it
    """
    content = content.encode('utf-8')
    if not os.path.exists(dest):
        logger.debug(' * Writing %s ... ', dest, extra=dict(continued=True))
        with open(dest, 'wb') as f:
            f.write(content)
        logger.debug('done.')
        return
    else:
        with open(dest, 'rb') as f:
            c = f.read()
        if c == content:
            logger.debug(' * Content %s already in place', dest)
            return

        if not overwrite:
            logger.info(' * File %s exists with different content; '
                        ' not overwriting', dest)
            return

        if append:
            logger.info(' * Appending data to %s', dest)
            with open(dest, 'ab') as f:
                f.write(DISABLE_POMPT.encode('utf-8'))
                f.write(content)
                f.write(ENABLE_PROMPT.encode('utf-8'))
            return

        logger.info(' * Overwriting %s with new content', dest)
        with open(dest, 'wb') as f:
            f.write(content)


def callit(cmd, show_stdout=True, in_shell=False,
           cwd=None, extra_env=None):
    """
    Execute cmd line in sub-shell
    """
    all_output = []
    cmd_parts = []

    for part in cmd:
        if len(part) > 45:
            part = part[:20] + "..." + part[-20:]
        if ' ' in part or '\n' in part or '"' in part or "'" in part:
            part = '"%s"' % part.replace('"', '\\"')
        cmd_parts.append(part)
    cmd_desc = ' '.join(cmd_parts)
    logger.debug(" ** Running command %s" % cmd_desc)

    if in_shell:
        cmd = ' '.join(cmd)

    # output
    stdout = subprocess.PIPE

    # env
    if extra_env:
        env = os.environ.copy()
        if extra_env:
            env.update(extra_env)
    else:
        env = None

    # execute
    try:
        proc = subprocess.Popen(
            cmd, stderr=subprocess.STDOUT, stdin=None, stdout=stdout,
            cwd=cwd, env=env, shell=in_shell)
    except Exception:
        e = sys.exc_info()[1]
        logger.error("Error %s while executing command %s" % (e, cmd_desc))
        raise

    stdout = proc.stdout
    while stdout:
        line = stdout.readline()
        if not line:
            break
        line = line.decode('utf-8').rstrip()
        all_output.append(line)
        if show_stdout:
            logger.info(line)
    proc.wait()

    # error handler
    if proc.returncode:
        if show_stdout:
            for s in all_output:
                logger.critical(s)
        raise OSError("Command %s failed with error code %s"
                      % (cmd_desc, proc.returncode))

    return proc.returncode, all_output


def get_node_src_url(version, postfix=''):
    node_name = '%s-v%s%s' % (get_binary_prefix(), version, postfix)
    tar_name = '%s.tar.gz' % (node_name)
    if parse_version(version) > parse_version("0.5.0"):
        node_url = 'http://%s/dist/v%s/%s' % (src_domain, version, tar_name)
    else:
        node_url = 'http://%s/dist/%s' % (src_domain, tar_name)
    return node_url


@contextlib.contextmanager
def tarfile_open(*args, **kwargs):
    """Compatibility layer because py26."""
    tf = tarfile.open(*args, **kwargs)
    try:
        yield tf
    finally:
        tf.close()


def download_node(node_url, src_dir, env_dir, opt):
    """
    Download source code
    """
    tar_contents = io.BytesIO(urlopen(node_url).read())
    with tarfile_open(fileobj=tar_contents) as tarfile_obj:
        tarfile_obj.extractall(src_dir)
    logger.info(')', extra=dict(continued=True))


def get_node_src_url_postfix(opt):
    if not opt.prebuilt:
        return ''

    import platform
    postfix_system = platform.system().lower()
    arches = {'x86_64': 'x64', 'i686': 'x86'}
    postfix_arch = arches[platform.machine()]
    return '-{0}-{1}'.format(postfix_system, postfix_arch)

# ---------------------------------------------------------
# Virtual environment functions


def copy_node_from_prebuilt(env_dir, src_dir):
    """
    Copy prebuilt binaries into environment
    """
    logger.info('.', extra=dict(continued=True))
    prefix = get_binary_prefix()
    callit(['cp', '-a', src_dir + '/%s-v*/*' % prefix, env_dir], True, env_dir)
    logger.info('.', extra=dict(continued=True))


def build_node_from_src(env_dir, src_dir, node_src_dir, opt):
    env = {}
    make_param_names = ['load-average', 'jobs']
    make_param_values = map(
        lambda x: getattr(opt, x.replace('-', '_')),
        make_param_names)
    make_opts = [
        '--{0}={1}'.format(name, value)
        if len(value) > 0 else '--{0}'.format(name)
        for name, value in zip(make_param_names, make_param_values)
        if value is not None
    ]

    if getattr(sys.version_info, 'major', sys.version_info[0]) > 2:
        # Currently, the node.js build scripts are using python2.*,
        # therefore we need to temporarily point python exec to the
        # python 2.* version in this case.
        try:
            _, which_python2_output = callit(
                ['which', 'python2'], opt.verbose, True, node_src_dir, env
            )
            python2_path = which_python2_output[0]
        except (OSError, IndexError):
            raise OSError(
                'Python >=3.0 virtualenv detected, but no python2 '
                'command (required for building node.js) was found'
            )
        logger.debug(' * Temporarily pointing python to %s', python2_path)
        node_tmpbin_dir = join(src_dir, 'tmpbin')
        node_tmpbin_link = join(node_tmpbin_dir, 'python')
        mkdir(node_tmpbin_dir)
        if not os.path.exists(node_tmpbin_link):
            callit(['ln', '-s', python2_path, node_tmpbin_link])
        env['PATH'] = '{}:{}'.format(node_tmpbin_dir,
                                     os.environ.get('PATH', ''))

    conf_cmd = []
    conf_cmd.append('./configure')
    conf_cmd.append('--prefix=%s' % pipes.quote(env_dir))
    if opt.without_ssl:
        conf_cmd.append('--without-ssl')
    if opt.debug:
        conf_cmd.append('--debug')
    if opt.profile:
        conf_cmd.append('--profile')

    make_cmd = opt.make_path

    callit(conf_cmd, opt.verbose, True, node_src_dir, env)
    logger.info('.', extra=dict(continued=True))
    callit([make_cmd] + make_opts, opt.verbose, True, node_src_dir, env)
    logger.info('.', extra=dict(continued=True))
    callit([make_cmd + ' install'], opt.verbose, True, node_src_dir, env)


def get_binary_prefix():
    return 'node' if src_domain == 'nodejs.org' else 'iojs'


def install_node(env_dir, src_dir, opt):
    """
    Download source code for node.js, unpack it
    and install it in virtual environment.
    """
    prefix = get_binary_prefix()
    logger.info(' * Install %s (%s' % (prefix, opt.node),
                extra=dict(continued=True))

    node_url = get_node_src_url(opt.node, get_node_src_url_postfix(opt))
    node_src_dir = join(src_dir, '%s-v%s' % (prefix, opt.node))
    env_dir = abspath(env_dir)

    # get src if not downloaded yet
    if not os.path.exists(node_src_dir):
        download_node(node_url, src_dir, env_dir, opt)

    logger.info('.', extra=dict(continued=True))

    if opt.prebuilt:
        copy_node_from_prebuilt(env_dir, src_dir)
    else:
        build_node_from_src(env_dir, src_dir, node_src_dir, opt)

    logger.info(' done.')


def install_npm(env_dir, src_dir, opt):
    """
    Download source code for npm, unpack it
    and install it in virtual environment.
    """
    logger.info(' * Install npm.js (%s) ... ' % opt.npm,
                extra=dict(continued=True))
    npm_contents = urlopen('https://www.npmjs.org/install.sh').read()
    env = dict(
        os.environ,
        clean='no' if opt.no_npm_clean else 'yes',
        npm_install=opt.npm,
    )
    proc = subprocess.Popen(
        (
            'bash', '-c',
            '. {0} && exec bash'.format(
                pipes.quote(join(env_dir, 'bin', 'activate')),
            )
        ),
        env=env,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
    )
    out, _ = proc.communicate(npm_contents)
    if opt.verbose:
        logger.info(out)
    logger.info('done.')


def install_packages(env_dir, opt):
    """
    Install node.js packages via npm
    """
    logger.info(' * Install node.js packages ... ',
                extra=dict(continued=True))
    packages = [package.strip() for package in
                open(opt.requirements).readlines()]
    activate_path = join(env_dir, 'bin', 'activate')