#! /usr/bin/env python

# Public Domain (-) 2010-2011 The Ampify Authors.
# See the Ampify UNLICENSE file for details.

"""
==========
Amp Engine
==========

Amp Engine powers Ampify -- the decentralised social platform.

::

                                   ___       
                              _  /'___)      
       _ _   ___ ___   _ _   (_)| (__  _   _ 
     /'_` )/' _ ` _ `\( '_`\ | || ,__)( ) ( )
    ( (_| || ( ) ( ) || (_) )| || |   | (_) |
    `\__,_)(_) (_) (_)| ,__/'(_)(_)   `\__, |
                      | |             ( )_| |
                      (_)             `\___/'


"""

import os
import sys
import tarfile
import traceback

from doctest import ELLIPSIS, testmod
from errno import EACCES, ENOENT
from glob import glob
from hashlib import sha1, sha256
from optparse import OptionParser
from os import chdir, getcwd, environ, execve, listdir, makedirs, remove, stat
from os.path import dirname, exists, expanduser, isabs, isdir, isfile, islink
from os.path import join, realpath, split
from shutil import copy, rmtree
from stat import ST_MTIME
from thread import start_new_thread
from time import sleep
from urllib import urlopen

from optcomplete import autocomplete, DirCompleter, ListCompleter
from optcomplete import make_autocompleter, parse_options
from pyutil.env import run_command, CommandNotFound
from simplejson import loads as decode_json
from yaml import safe_load as decode_yaml

try:
    from multiprocessing import cpu_count
except ImportError:
    cpu_count = lambda: 1

# ------------------------------------------------------------------------------
# Metadata
# ------------------------------------------------------------------------------

__version__ = (0, 0, 0)
__release__ = '.'.join(map(str, __version__))

__authors__ = {
    0x01: ('tav', 'tav@espians.com')
    }

# ------------------------------------------------------------------------------
# Print Functions
# ------------------------------------------------------------------------------

if os.name == 'posix' and not environ.get('AMPIFY_PLAIN'):
    ACTION = '\x1b[34;01m>> '
    INSTRUCTION = '\x1b[31;01m!! '
    ERROR = '\x1b[31;01m!! '
    NORMAL = '\x1b[0m'
    PROGRESS = '\x1b[30;01m## '
    SUCCESS = '\x1b[32;01m** '
    TERMTITLE = '\x1b]2;%s\x07'
else:
    INSTRUCTION = ACTION = '>> '
    ERROR = '!! '
    NORMAL = ''
    PROGRESS = '## '
    SUCCESS = '** '
    TERMTITLE = ''

# Pretty print the given ``message`` in nice colours.
def log(message, type=ACTION):
    print type + message + NORMAL

def error(message):
    print ERROR + message + NORMAL
    print ''

def exit(message):
    print ERROR + message + NORMAL
    sys.exit(1)

# ------------------------------------------------------------------------------
# Platform Detection
# ------------------------------------------------------------------------------

# Only certain UNIX-like platforms are currently supported. Most of the code is
# easily portable to other UNIX platforms. Unfortunately porting to Windows will
# be problematic as POSIX APIs are used a fair bit -- especially by dependencies
# like redis.
if sys.platform.startswith('darwin'):
    PLATFORM = 'darwin'
elif sys.platform.startswith('linux'):
    PLATFORM = 'linux'
elif sys.platform.startswith('freebsd'):
    PLATFORM = 'freebsd'
else:
    exit(
        "ERROR: Sorry, the %r operating system is not supported yet."
        % sys.platform
        )

NUMBER_OF_CPUS = cpu_count()

# ------------------------------------------------------------------------------
# Utility Functions
# ------------------------------------------------------------------------------

def do(*cmd, **kwargs):
    if 'redirect_stdout' not in kwargs:
        kwargs['redirect_stdout'] = False
    if 'redirect_stderr' not in kwargs:
        kwargs['redirect_stderr'] = False
    if 'exit_on_error' not in kwargs:
        kwargs['exit_on_error'] = True
    return run_command(cmd, **kwargs)

def query(question, options='Y/n', default='Y', alter=1):
    if alter:
        if options:
            question = "%s? [%s] " % (question, options)
        else:
            question = "%s? " % question
    print
    response = raw_input(question)
    if not response:
        return default
    return response

def sudo(*command, **kwargs):
    response = query(
        "\tsudo %s\n\nDo you want to run the above command" % ' '.join(command),
        )
    if response.lower().startswith('y'):
        return do('sudo', *command, **kwargs)

def mkdir(path, sudo=False):
    if not isdir(path):
        try:
            makedirs(path)
        except OSError, e:
            if (e.errno == EACCES) and sudo:
                error("ERROR: Permission denied to create %s" % path)
                done = sudo('mkdir', '-p', path, retcode=True)
                if not done:
                    raise e
                return 1
            raise
        return 1

def rmdir(path, name=None):
    try:
        rmtree(path)
    except IOError:
        pass
    except OSError, e:
        if e.errno != ENOENT:
            if not name:
                name = path
            exit("ERROR: Couldn't remove the %s directory." % name)

LOCKS = {}

def lock(path):
    LOCKS[path] = lock = open(path, 'w')
    try:
        from fcntl import flock, LOCK_EX, LOCK_NB
    except ImportError:
        exit("ERROR: Locking is not supported on this platform.")
    try:
        flock(lock.fileno(), LOCK_EX | LOCK_NB)
    except Exception:
        exit("ERROR: Another amp process is already running.")

def unlock(path):
    if path in LOCKS:
        LOCKS[path].close()
        del LOCKS[path]

# Collate the set of resources within the given ``directory``.
def gather_local_filelisting(directory, gathered=None):
    if gathered is None:
        if not isdir(directory):
            return set()
        gathered = set()
    for item in listdir(directory):
        path = join(directory, item)
        if isdir(path):
            gathered.add(path + '/')
            gather_local_filelisting(path, gathered)
        else:
            gathered.add(path)
    return gathered

# Strip the given ``prefix`` from the elements in the given ``listing`` set.
def strip_prefix(listing, prefix):
    new = set()
    lead = len(prefix) + 1
    for item in listing:
        new.add(item[lead:])
    return new

def get_listing():
    return strip_prefix(gather_local_filelisting(LOCAL), LOCAL)

def cleanup_partial_install(current_filelisting):
    new_filelisting = get_listing()
    diff = new_filelisting.difference(current_filelisting)
    for file in diff:
        file = join(LOCAL, file)
        remove(file)

# ------------------------------------------------------------------------------
# Constants
# ------------------------------------------------------------------------------

AMPIFY_ROOT = dirname(dirname(realpath(__file__)))
AMPIFY_ROOT_PARENT = dirname(AMPIFY_ROOT)

ROOT = AMPIFY_ROOT
ENVIRON = join(ROOT, 'environ')
LOCAL = join(ENVIRON, 'local')
BIN = join(LOCAL, 'bin')
INCLUDE = join(LOCAL, 'include')
LIB = join(LOCAL, 'lib')
SHARE = join(LOCAL, 'share')
INFO = join(SHARE, 'info')
MAN = join(SHARE, 'man')
RECEIPTS = join(ENVIRON, 'receipts')
TMP = join(LOCAL, 'tmp')
VAR = join(LOCAL, 'var')
SRC = join(ROOT, 'src')

DISTFILES_URL_BASE = environ.get(
    'AMPIFY_DISTFILES_URL_BASE',
    "http://cloud.github.com/downloads/tav/ampify/distfile."
    )

# DISTFILES_URL_BASE = environ.get(
#     'AMPIFY_DISTFILES_URL_BASE',
#     "http://github.s3.amazonaws.com/downloads/tav/ampify/distfile."
#     )

# Alternatively, could use the HTTPS URL -- note that this would be using S3 and
# not Amazon CloudFront:
# https://github.s3.amazonaws.com/downloads/tav/ampify/distfile.

BUILD_WORKING_DIRECTORY = '/tmp/amp-build-%s' % sha1(ROOT).hexdigest()[:8]
BUILD_LOCK = BUILD_WORKING_DIRECTORY + '.lock'

BUILD_RECIPES = [path for path in environ.get(
    'AMPIFY_BUILD_RECIPES', join(ENVIRON, 'buildrecipes')
    ).split(':') if isfile(path)]

ROLES_PATH = [path for path in environ.get(
    'AMPIFY_ROLES_PATH', join(ENVIRON, 'roles')
    ).split(':') if isdir(path)]

CURRENT_DIRECTORY = getcwd()
HOME = expanduser('~')

if PLATFORM == 'darwin':
    LIB_EXTENSION = '.dylib'
elif PLATFORM == 'windows':
    LIB_EXTENSION = '.dll'
else:
    LIB_EXTENSION = '.so'

if PLATFORM == 'freebsd':
    MAKE = 'gmake'
else:
    MAKE = 'make'

CPPFLAGS = "-I%s" % INCLUDE
LDFLAGS = "-L%s" % LIB

RECIPES = {}
BUILTINS = locals()

BASE_PACKAGES = set()
PACKAGES = {}
RECIPES_INITIALISED = []
VIRGIN_BUILD = not exists(join(LOCAL, 'bin', 'python'))

DEBUG = False

ERRMSG_GIT_NAME_DETECTION = (
    "ERROR: Couldn't detect the instance name from the Git URL.\n          "
    "Please provide an instance name parameter. Thanks!"
    )

# ------------------------------------------------------------------------------
# Distfiles Downloader
# ------------------------------------------------------------------------------

DOWNLOAD_QUEUE = []
DOWNLOAD_ERROR = []

class DownloadError(Exception):
    def __init__(self, msg):
        self.msg = msg

# Download the given distfile and ensure it has a matching digest. We try to
# capture and exit on all errors to avoid them being silently ignored in a
# separate thread.
def _download_distfile(distfile, url, hash, dest):
    try:
        try:
            distfile_obj = urlopen(url)
            distfile_source = distfile_obj.read()
        except Exception:
            raise DownloadError("Failed to download %s" % distfile)
        if sha256(distfile_source).hexdigest() != hash:
            raise DownloadError("Got an invalid hash digest for %s" % distfile)
        try:
            distfile_file = open(dest, 'wb')
            distfile_file.write(distfile_source)
            distfile_file.close()
            distfile_obj.close()
        except Exception:
            raise DownloadError("Writing %s" % distfile)
        DOWNLOAD_QUEUE.pop()
    except DownloadError, errmsg:
        DOWNLOAD_QUEUE.pop()
        DOWNLOAD_ERROR.append(errmsg)

# Check if there's an existing valid download. If not, fire off a fresh download
# in a separate thread if the ``fork`` parameter has been set.
def download_distfile(distfile, url, hash, fork=False):
    dest = join(BUILD_WORKING_DIRECTORY, distfile)
    if isfile(dest):
        log("Verifying existing %s" % distfile, PROGRESS)
        distfile_file = open(dest, 'rb')
        distfile_source = distfile_file.read()
        distfile_file.close()
        if sha256(distfile_source).hexdigest() == hash:
            return
        remove(dest)
    log("Downloading %s" % distfile, PROGRESS)
    DOWNLOAD_QUEUE.append(distfile)
    if fork:
        start_new_thread(_download_distfile, (distfile, url, hash, dest))
    else:
        _download_distfile(distfile, url, hash, dest)

# ------------------------------------------------------------------------------
# Instance Roles
# ------------------------------------------------------------------------------

ROLES = {}

def load_role(role):

    init_build_recipes()
    if role in ROLES:
        return ROLES[role]

    if VIRGIN_BUILD and role != 'base':
        build_base_and_reload()

    for path in ROLES_PATH:
        role_file = join(path, role) + '.yaml'
        if isfile(role_file):
            break
    else:
        exit("ERROR: Couldn't find a data file for the %r role." % role)

    try:
        role_file = open(role_file, 'rb')
    except IOError, error:
        exit("ERROR: %s: %s" % (error[1], error.filename))

    role_data = role_file.read()
    role_file.close()

    try:
        role_data = decode_yaml(role_data)
    except Exception:
        exit("ERROR: Couldn't decode the JSON input: %s" % role_file.name)

    packages = set(role_data['packages'])
    for package in packages:
        install_package(package)

    if 'requires' in role_data:
        packages.update(load_role(role_data['requires']))

    if role == 'base':
        for package in packages:
            BASE_PACKAGES.update([package])
            BASE_PACKAGES.update(get_dependencies(package))

    return ROLES.setdefault(role, packages)

def get_dependencies(package, gathered=None):
    if gathered is None:
        gathered = set()
    else:
        gathered.add(package)
    recipe = RECIPES[package][PACKAGES[package][0]]
    for dep in recipe.get('requires', []):
        get_dependencies(dep, gathered)
    return gathered

# ------------------------------------------------------------------------------
# Version Checkers
# ------------------------------------------------------------------------------

def ensure_gcc_version(version=(4, 0)):
    gcc = environ.get('CC', 'gcc')
    try:
        ver = do(
            gcc, '-dumpversion', redirect_stdout=True, reterror=True
            )
        ver = tuple(map(int, ver[0].strip().split('.')))
        if ver < version:
            raise RuntimeError("Invalid version")
    except Exception:
        exit('ERROR: GCC %s+ not found!' % '.'.join(map(str, version)))

def ensure_git_version(version=(1, 7)):
    try:
        ver = do(
            'git', '--version', redirect_stdout=True,
            redirect_stderr=True, reterror=True
            )
        ver = ver[0].splitlines()[0].split()[-1]
        ver = tuple(int(part) for part in ver.split('.'))
        if ver < version:
            raise RuntimeError("Invalid version")
    except Exception:
        exit('ERROR: Git %s+ not found!' % '.'.join(map(str, version)))

def ensure_java_version(version=(1, 6), title='Java 6+ runtime'):
    try:
        ver = do(
            'java', '-version', redirect_stdout=True,
            redirect_stderr=True, reterror=True
            )
        ver = ver[1].splitlines()[0].split()[-1][1:-1]
        if not ver >= '.'.join(map(str, version)):
            raise RuntimeError("Invalid version")
    except Exception:
        exit('ERROR: %s not found!' % title)

# ------------------------------------------------------------------------------
# Build Recipes Initialiser
# ------------------------------------------------------------------------------

def init_build_recipes():
    if RECIPES_INITIALISED:
        return
    # Try getting a lock to avoid concurrent builds.
    lock(BUILD_LOCK)
    mkdir(RECEIPTS)
    for recipe in BUILD_RECIPES:
        execfile(recipe, BUILTINS)
    for package in list(RECIPES):
        recipes = RECIPES[package]
        versions = []
        data = {}
        for recipe in recipes:
            recipe_type = recipe.get('type')
            if recipe_type == 'submodule':
                path = join(ROOT, recipe['path'])
                version = run_command(
                    ['git', 'rev-parse', 'HEAD'], cwd=path, exit_on_error=True
                    ).strip()
            elif recipe_type == 'makelike':
                contents = []
                latest = 0
                for pattern in recipe['depends']:
                    for file in glob(pattern):
                        dep_file = open(file, 'rb')
                        contents.append(dep_file.read())
                        dep_file.close()
                        dep_mtime = stat(file)[ST_MTIME]
                        if dep_mtime > latest:
                            latest = dep_mtime
                generate = 0
                for pattern in recipe['outputs']:
                    files = glob(pattern)
                    if not files:
                        generate = 1
                        break
                    for file in files:
                        if not isfile(file):
                            generate = 1
                            break
                        if stat(file)[ST_MTIME] <= latest:
                            generate = 1
                            break
                    if generate:
                        break
                if generate:
                    for file in listdir(RECEIPTS):
                        if file.startswith(package + '-'):
                            remove(join(RECEIPTS, file))
                version = sha1(''.join(contents)).hexdigest()
            else:
                version = recipe['version']
            versions.append(version)
            data[version] = recipe
        RECIPES[package] = data
        PACKAGES[package] = versions
    RECIPES_INITIALISED.append(1)

# ------------------------------------------------------------------------------
# Env Manipulation
# ------------------------------------------------------------------------------

def get_ampify_env(environ):
    new = {}
    for key in environ:
        if key.startswith('AMPIFY'):
            new[key] = environ[key]
        if key.startswith('JAVA') or key.endswith('JAVA'):
            new[key] = environ[key]
    for var in [
        'PATH', 'LD_LIBRARY_PATH',
        'DYLD_FALLBACK_LIBRARY_PATH',
        'MANPATH', 'PYTHONPATH'
        ]:
        pre_var = 'PRE_AMPENV_' + var
        if pre_var in environ:
            new[var] = environ[pre_var]
    return new

# ------------------------------------------------------------------------------
# Build Types
# ------------------------------------------------------------------------------

BASE_BUILD = {
    'after': None,
    'before': None,
    'commands': None,
    'distfile': "%(name)s-%(version)s.tar.bz2",
    'distfile_url_base': DISTFILES_URL_BASE,
    'env': None,
    }

def default_build_commands(package, info):
    commands = []; add = commands.append
    if info['config_command']:
        add([info['config_command']] + info['config_flags'])
    if info['separate_make_install']:
        add([MAKE])
    add([MAKE] + info['make_flags'])
    return commands

DEFAULT_BUILD = BASE_BUILD.copy()
DEFAULT_BUILD.update({
    'commands': default_build_commands,
    'config_command': './configure',
    'config_flags': ['--prefix=%s' % LOCAL],
    'make_flags': ['install'],
    'separate_make_install': False
    })

PYTHON_BUILD = BASE_BUILD.copy()
PYTHON_BUILD.update({
    'commands': [
        [sys.executable, 'setup.py', 'build_ext', '-i']
        ]
    })

def resource_build_commands(package, info):
    source = info['source'] or join(BUILD_WORKING_DIRECTORY, package)
    destination = info['destination'] or join(SHARE, package)
    return [['cp', '-R', source, destination]]

RESOURCE_BUILD = BASE_BUILD.copy()
RESOURCE_BUILD.update({
    'commands': resource_build_commands,
    'source': None,
    'destination': None,
    })

def jar_install(package, info):
    filename = info['distfile'] % {'name': package, 'version': info['version']}
    return [lambda: copy(filename, join(BIN, filename))]

JAR_BUILD = BASE_BUILD.copy()
JAR_BUILD.update({
    'distfile': '%(name)s-%(version)s.jar',
    'commands': jar_install
    })

SUBMODULE_BUILD = BASE_BUILD.copy()
SUBMODULE_BUILD.update({
    'distfile': ''
    })

def makelike_commands(package, info):
    return info['generators']

MAKELIKE_BUILD = BASE_BUILD.copy()
MAKELIKE_BUILD.update({
    'commands': makelike_commands,
    'distfile': ''
    })

BUILD_TYPES = {
    'default': DEFAULT_BUILD,
    'jar': JAR_BUILD,
    'makelike': MAKELIKE_BUILD,
    'python': PYTHON_BUILD,
    'resource': RESOURCE_BUILD,
    'submodule': SUBMODULE_BUILD
    }

# ------------------------------------------------------------------------------
# Core Install Functions
# ------------------------------------------------------------------------------

TO_INSTALL = {}

# Check and load the build recipe for the given package name and add it to the
# ``TO_INSTALL`` set.
def install_package(package):
    if package not in RECIPES:
        exit(
            "ERROR: Couldn't find a build recipe for the %s package."
            % package
            )
    version = PACKAGES[package][0]
    TO_INSTALL[package] = version
    for dependency in RECIPES[package][version].get('requires', []):
        install_package(dependency)

# Uninstall the given list of packages in uninstall.
def uninstall_packages(uninstall, installed):
    for name, version in uninstall.items():
        log("Uninstalling %s %s" % (name, version))
        installed_version = '%s-%s' % (name, version)
        receipt_path = join(RECEIPTS, installed_version)
        receipt = open(receipt_path, 'rb')
        directories = set()
        for path in receipt:
            if isabs(path):
                exit("ERROR: Got an absolute path in receipt %s" % receipt_path)
            path = path.strip()
            if not path:
                continue
            path = join(LOCAL, path)
            if not islink(path):
                if not exists(path):
                    continue
            if isdir(path):
                directories.add(path)
            else:
                print "Removing:", path
                os.remove(path)
        for path in reversed(sorted(directories)):
            if not listdir(path):
                print "Removing Directory:", path
                rmtree(path)
        receipt.close()
        os.remove(receipt_path)
        del installed[name]

# A utility function to uninstall a single package.
def uninstall_package(package):
    installed = dict(f.split('-', 1) for f in listdir(RECEIPTS))
    if package in installed:
        data = {package: installed[package]}
        uninstall_packages(data, data)

# Handle the actual installation/uninstallation of appropriate packages.
def install_packages(types=BUILD_TYPES):

    ensure_gcc_version()
    ensure_git_version()
    ensure_java_version()

    for directory in [
        BUILD_WORKING_DIRECTORY, LOCAL, BIN, SHARE, TMP
        ]:
        mkdir(directory)

    # We assume the invariant that all packages only have one version installed.
    installed = dict(f.split('-', 1) for f in listdir(RECEIPTS))
    uninstall = {}

    raw_types = ['submodule', 'makelike']
    def get_installed_dependencies(package, gathered=None, raw_types=raw_types):
        if gathered is None:
            gathered = set()
        else:
            gathered.add(package)
        installed_version = installed[package]
        recipes = RECIPES[package]
        if recipes.values()[0].get('type') in raw_types:
            recipe = recipes.values()[0]
        else:
            recipe = recipes[installed_version]
        for dep in recipe.get('requires', []):
            get_installed_dependencies(dep, gathered)
        return gathered

    inverse_dependencies = {}
    for package in installed:
        if package in PACKAGES:
            for dep in get_installed_dependencies(package):
                if dep not in inverse_dependencies:
                    inverse_dependencies[dep] = set()
                inverse_dependencies[dep].add(package)

    for package in TO_INSTALL:
        if package in installed:
            existing_version = installed[package]
            if TO_INSTALL[package] != existing_version:
                uninstall[package] = existing_version
                for inv_dep in inverse_dependencies.get(package, []):
                    uninstall[package] = installed[package]

    # If a base package needs to be uninstalled, just nuke environ/local and
    # rebuild everything from scratch.
    for package in BASE_PACKAGES:
        if package in uninstall:
            rmdir(LOCAL)
            rmdir(RECEIPTS)
            unlock(BUILD_LOCK)
            execve(join(ENVIRON, 'amp'), sys.argv, get_ampify_env(environ))
    else:
        uninstall_packages(uninstall, installed)

    to_install = set(TO_INSTALL) - set(installed)

    inverse_dependencies = {}
    for package in to_install:
        for dep in get_dependencies(package):
            if dep in to_install:
                if dep not in inverse_dependencies:
                    inverse_dependencies[dep] = set()
                inverse_dependencies[dep].add(package)

    to_install_list = []
    for package in to_install:
        index = len(to_install_list)
        for dep in inverse_dependencies.get(package, []):
            try:
                dep_index = to_install_list.index(dep)
            except:
                continue
            else:
                if dep_index < index:
                    index = dep_index
        to_install_list.insert(index, package)

    current_filelisting = get_listing()
    install_data = []
    install_items = len(to_install_list) - 1

    for idx, package in enumerate(to_install_list):

        version = TO_INSTALL[package]
        recipe = RECIPES[package][version]
        build_type = recipe.get('type', 'default')
        info = types[build_type].copy()
        info.update(recipe)

        distfile = info['distfile'] % {'name': package, 'version': version}
        if distfile:
            url = info['distfile_url_base'] + distfile
        else:
            url = ''

        install_data.append((idx, package, version, info, distfile, url))

    if install_data:
        _, _, _, info, distfile, url = install_data[0]
        if distfile:
            download_distfile(distfile, url, info['hash'])

    for idx, package, version, info, distfile, url in install_data:

        current_queue = DOWNLOAD_QUEUE[:]
        if current_queue:
            if idx:
                log("Waiting for %s to download" % current_queue[0], PROGRESS)
            while DOWNLOAD_QUEUE:
                sleep(0.5)

        if DOWNLOAD_ERROR:
            exit("ERROR: %s" % DOWNLOAD_ERROR[0].msg)

        if idx < install_items:
            _, _, _, infoN, distfileN, urlN = install_data[idx+1]
            if distfileN:
                download_distfile(distfileN, urlN, infoN['hash'], fork=True)

        log("Installing %s %s" % (package, version))

        chdir(BUILD_WORKING_DIRECTORY)
        if distfile and distfile.endswith('.tar.bz2'):
            if isdir(package):
                log("Removing previously unpacked %s distfile" % package,
                    PROGRESS)
                rmdir(package)
            tar = tarfile.open(distfile, 'r:bz2')
            log("Unpacking %s" % distfile, PROGRESS)
            tar.extractall()
            tar.close()
            chdir(package)
        elif info.get('type') == 'submodule':
            chdir(join(ROOT, info['path']))
            if info.get('clean'):
                do('git', 'clean', '-fdx')

        if info['before']:
            info['before']()

        env = environ.copy()
        if 'MAKE' in env:
            del env['MAKE']
        if 'MAKELEVEL' in env:
            del env['MAKELEVEL']
        if info['env']:
            env.update(info['env'])

        commands = info['commands']
        if isinstance(commands, basestring):
            commands = [commands]
        elif callable(commands):
            try:
                commands = commands(package, info)
            except Exception:
                log("ERROR: Error calling build command for %s %s" %
                    (package, version))
                traceback.print_exc()
                sys.exit(1)

        if not isinstance(commands, (tuple,list)):
            exit("ERROR: Invalid build commands for %s %s: %r" %
                 (package, version, commands))

        try:
            for command in commands:
                if hasattr(command, '__call__'):
                    command()
                else:
                    log("Running: %s" % ' '.join(command), PROGRESS)
                    cmd_env = {'CPPFLAGS': CPPFLAGS, 'LDFLAGS': LDFLAGS}
                    cmd_env.update(env)
                    kwargs = dict(env=cmd_env)
                    do(*command, **kwargs)
        except Exception:
            error("ERROR: Building %s %s failed" % (package, version))
            traceback.print_exc()
            sys.exit(1)
        except SystemExit:
            cleanup_partial_install(current_filelisting)
            exit("ERROR: Building %s %s failed" % (package, version))

        if info['after']:
            info['after']()

        log("Successfully Installed %s %s" % (package, version), SUCCESS)

        new_filelisting = get_listing()
        receipt_data = new_filelisting.difference(current_filelisting)
        current_filelisting = new_filelisting

        receipt = open(join(RECEIPTS, '%s-%s' % (package, version)), 'wb')
        receipt.write('\n'.join(sorted(receipt_data)))
        receipt.close()

        chdir(BUILD_WORKING_DIRECTORY)
        if distfile.endswith('.tar.bz2'):
            rmdir(join(package))

    chdir(CURRENT_DIRECTORY)

# ------------------------------------------------------------------------------
# Virgin Build Handler
# ------------------------------------------------------------------------------

def build_base_and_reload():
    load_role('base')
    install_packages()
    unlock(BUILD_LOCK)
    execve(join(ENVIRON, 'amp'), sys.argv, get_ampify_env(environ))

# ------------------------------------------------------------------------------
# Utility Functions
# ------------------------------------------------------------------------------

# This function normalises instance names -- just in case someone accidentally
# passed in a path name.
def normalise_instance_name(instance_name):
    instance_name = split(realpath(instance_name))[-1]
    instance_root = join(AMPIFY_ROOT_PARENT, instance_name)
    return instance_name, instance_root

# ------------------------------------------------------------------------------
# Main Runner
# ------------------------------------------------------------------------------

def main(argv=None, show_help=False):

    argv = argv or sys.argv[1:]

    # Set the script name to ``amp`` so that OptionParser error messages don't
    # display a potentially confusing ``ampengine`` to end users.
    sys.argv[0] = 'amp'

    usage = ("""Usage: amp <command> [options]
    \nCommands:
    \n%s
    version  show the version number and exit
    \nSee `amp help <command>` for more info on a specific command.""" %
    '\n'.join("    %-8s %s" % (cmd, COMMANDS[cmd].help) for cmd in sorted(COMMANDS))
    )

    autocomplete(
        OptionParser(add_help_option=False),
        ListCompleter(AUTOCOMPLETE_COMMANDS.keys()),
        subcommands=AUTOCOMPLETE_COMMANDS
        )

    if not argv:
        show_help = True
    else:
        command = argv[0]
        argv = argv[1:]
        if command in ['-h', '--help']:
            show_help = True
        elif command == 'help':
            if argv:
                command = argv[0]
                argv = ['--help']
            else:
                show_help = True
        if command in ['-v', '--version', 'version']:
            print('amp %s' % __release__)
            sys.exit()

    if show_help:
        print(usage)
        sys.exit(1)

    if command in COMMANDS:
        return COMMANDS[command](argv)

    # We support git-command like behaviour. That is, if there's an external
    # binary named ``amp-foo`` available on the ``$PATH``, then running ``amp
    # foo`` will automatically delegate to it.
    try:
        output, retcode = run_command(
            ['amp-%s' % command] + argv, retcode=True, redirect_stdout=False,
            redirect_stderr=False
            )
    except CommandNotFound:
        exit("ERROR: Unknown command %r" % command)

    if retcode:
        sys.exit(retcode)

# ------------------------------------------------------------------------------
# Build Command
# ------------------------------------------------------------------------------

def build(argv=None, completer=None):

    op = OptionParser(usage="Usage: amp build [options]", add_help_option=False)

    op.add_option('--role', dest='role', default='default',
                  help="specify a non-default role to build")

    options, args = parse_options(op, argv, completer)

    load_role(options.role)
    install_packages()

    _, retcode = run_command(
        [sys.executable, join(AMPIFY_ROOT, 'environ', 'assetgen')],
        retcode=True, redirect_stdout=False, redirect_stderr=False
        )

    if retcode:
        sys.exit(retcode)

# ------------------------------------------------------------------------------
# Check Command
# ------------------------------------------------------------------------------

def check(argv=None, completer=None):

    op = OptionParser(usage="Usage: amp check", add_help_option=False)
    options, args = parse_options(op, argv, completer)

    log("Checking the current revision id for your code.", PROGRESS)
    revision_id = do(
        'git', 'show', '--pretty=oneline', '--summary', redirect_stdout=True
        ).split()[0]

    log("Checking the latest commits on GitHub.", PROGRESS)
    commit_info = urlopen(
        'https://github.com/api/v2/json/commits/list/tav/ampify/master'
        ).read()

    latest_revision_id = decode_json(commit_info)['commits'][0]['id']

    if revision_id != latest_revision_id:
        exit("A new version is available. Please run `git pull`.")

    log("Your checkout is up-to-date.", SUCCESS)

# ------------------------------------------------------------------------------
# Deploy Command
# ------------------------------------------------------------------------------

def deploy(argv=None, completer=None):

    op = OptionParser(
        usage="Usage: amp deploy <instance-name> [options]",
        add_help_option=False
        )

    op.add_option('--test', dest='test', action='store_true', default=False,
                  help="run tests before completing the switch")

    options, args = parse_options(op, argv, completer, True)

# ------------------------------------------------------------------------------
# Hub Command
# ------------------------------------------------------------------------------

def hub(argv=None, completer=None):

    op = OptionParser(
        usage="Usage: amp hub [register|update] [options]",
        add_help_option=False
        )

    options, args = parse_options(op, argv, completer, True)

# ------------------------------------------------------------------------------
# Init Command
# ------------------------------------------------------------------------------

def init(argv=None, completer=None):

    op = OptionParser(
        usage="Usage: amp init <instance-name> [options]",
        add_help_option=False
        )

    op.add_option('--clobber', dest='clobber', action='store_true',
                  help="clobber any existing files/directories if they exist")

    op.add_option('--from', dest='git_repo', default='',
                  help="initialise by cloning the given git repository")

    options, args = parse_options(op, argv, completer)

    git_repo = options.git_repo
    if git_repo:
        if args:
            instance_name = args[0]
        else:
            instance_name = git_repo.split('/')[-1].rsplit('.', 1)[0]
            if not instance_name:
                exit(ERRMSG_GIT_NAME_DETECTION)
    else:
        if args:
            instance_name = args[0]
        else:
            op.print_help()
            sys.exit(1)

    instance_name, instance_root = normalise_instance_name(instance_name)
    clobber = options.clobber

    if exists(instance_root):
        if not clobber:
            exit(
                "ERROR: A directory already exists at %s\n          "
                "Use the --clobber parameter to overwrite the directory"
                % instance_root
                )
        chdir(instance_root)
        diff = do('git', 'diff', '--cached', '--name-only', redirect_stdout=1)
        if diff.strip():
            error(
                "ERROR: You have a dirty working tree at %s\n          "
                "Please either commit your changes or move your files.\n"
                % instance_root
                )
            error("  These are the problematic files:")
            for filename in diff.strip().splitlines():
                log("    %s" % filename, ERROR)
            print
        first_run = 0
    else:
        create = query("Create a instance at %s" % instance_root).lower()
        if not create.startswith('y'):
            sys.exit(2)
        makedirs(instance_root)
        print
        print("Created %s" % instance_root)
        print
        chdir(instance_root)
        do('git', 'init')
        print
        readme = open('README.md', 'wb')
        readme.close()
        do('git', 'add', 'README.md')
        do('git', 'commit', '-m', "Initialised the instance [amp].")
        first_run = 1

    diff = do('git', 'diff', '--cached', '--name-only', redirect_stdout=1)
    if diff.strip():
        do('git', 'commit', '-m', "Updated instance [amp].")

    print(DEBUG)

# ------------------------------------------------------------------------------
# Install Command
# ------------------------------------------------------------------------------

def install(argv=None, completer=None):

    op = OptionParser(
        usage="Usage: amp install <package-1> <package-2> ...",
        add_help_option=False
        )

    init_build_recipes()
    if completer:
        return op, ListCompleter(RECIPES)

    options, args = parse_options(op, argv, completer, True)
    for package in args:
        install_package(package)

    install_packages()

# ------------------------------------------------------------------------------
# Uninstall Command
# ------------------------------------------------------------------------------

def uninstall(argv=None, completer=None):

    op = OptionParser(
        usage="Usage: amp uninstall <package>", add_help_option=False
        )

    init_build_recipes()
    if completer:
        return op, ListCompleter(RECIPES)

    options, args = parse_options(op, argv, completer, True)
    uninstall_package(args[0])

# ------------------------------------------------------------------------------
# Run Command
# ------------------------------------------------------------------------------

def run(argv=None, completer=None):
    
    op = OptionParser(
        usage="Usage: amp run <instance-name> [options] [stop|quit|restart]",
        add_help_option=False
        )

    op.add_option('-d', '--debug', dest='debug', action='store_true',
                  help="enable debug mode")

    op.add_option("--file", dest="filename",
                  help="input file to read data from")

    if completer:
        return op, DirCompleter(AMPIFY_ROOT_PARENT)

    options, args = parse_options(op, argv, completer, True)

    if options.debug:
        global DEBUG
        DEBUG = True

    instance_name, instance_root = normalise_instance_name(args[0])

# ------------------------------------------------------------------------------
# Test Command
# ------------------------------------------------------------------------------

PYTHON_TEST_MODULES = [
    'pyutil.rst'
    ]

def test(argv=None, completer=None, run_all=False):

    op = OptionParser(usage="Usage: amp test [options]", add_help_option=False)

    op.add_option('-a', '--all', dest='all', action='store_true',
                  help="run the comprehensive test suite")

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

    testers = ['python', 'go', 'js']
    if completer:
        return op, ListCompleter(testers)

    options, args = parse_options(op, argv, completer)
    if not args:
        args = testers

    args = set(args)
    if options.all:
        run_all = True

    if 'python' in args:
        py_tests(PYTHON_TEST_MODULES, verbose=options.verbose)

    if 'go' in args:
        go_tests()

    if 'js' in args:
        js_tests(verbose=options.verbose)

def js_tests(verbose=None):
    js_root = join(AMPIFY_ROOT, 'src', 'jsutil')
    chdir(js_root)
    if verbose:
        command = ['vows', '--spec']
    else:
        command = ['vows']
    _, retval = run_command(
        command, retcode=True, redirect_stderr=False, redirect_stdout=False
        )
    if retval:
        sys.exit(retval)

def go_tests():
    go_root = join(AMPIFY_ROOT, 'src', 'amp')
    chdir(go_root)
    run_command([MAKE, 'nuke'])
    _, retval = run_command(
        [MAKE, 'install', 'test'], retcode=True, redirect_stderr=False,
        redirect_stdout=False
        )
    if retval:
        sys.exit(retval)

def py_tests(modules, verbose):
    failed = 0
    for module in modules:
        module = __import__(module, fromlist=[''])
        fail, _ = testmod(module, optionflags=ELLIPSIS, verbose=verbose)
        failed += fail
    if failed:
        sys.exit(1)

# ------------------------------------------------------------------------------
# Help Strings
# ------------------------------------------------------------------------------

# These, along with other strings, should perhaps be internationalised at a
# later date.
build.help = "download and build the ampify dependencies"
check.help = "check if your checkout is up-to-date"
deploy.help = "deploy an instance to remote hosts"
hub.help = "interact with amphub"
init.help = "initialise a new amp instance"
install.help = "install specific amp build packages"
run.help = "run the components for an amp instance"
test.help = "run the ampify test suite"
uninstall.help = "uninstall specific amp build packages"

# ------------------------------------------------------------------------------
# Command Mapping
# ------------------------------------------------------------------------------

COMMANDS = {
    'build': build,
    'check': check,
    'deploy': deploy,
    'hub': hub,
    'init': init,
    'install': install,
    'run': run,
    'test': test,
    'uninstall': uninstall
    }

# ------------------------------------------------------------------------------
# Command Autocompletion
# ------------------------------------------------------------------------------

AUTOCOMPLETE_COMMANDS = COMMANDS.copy()

AUTOCOMPLETE_COMMANDS['help'] = lambda completer: (
    OptionParser(add_help_option=False),
    ListCompleter(COMMANDS.keys())
    )

AUTOCOMPLETE_COMMANDS['version'] = lambda completer: (
    OptionParser(add_help_option=False),
    DirCompleter(AMPIFY_ROOT_PARENT)
    )

for command in AUTOCOMPLETE_COMMANDS.values():
    command.autocomplete = make_autocompleter(command)

# ------------------------------------------------------------------------------
# Script Runner
# ------------------------------------------------------------------------------

if __name__ == '__main__':
    main()
