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

# Copyright (C) 2012, 2013, 2014 Johan Andersson
# Copyright (C) 2013-2014, 2016 Sebastian Wiesner

# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 3, or (at your option)
# any later version.

# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.

# You should have received a copy of the GNU General Public License
# along with GNU Emacs; see the file COPYING.  If not, write to the
# Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor,
# Boston, MA 02110-1301, USA.

"""Cask start script
=================

This script is the command line frontend to Cask.  Basically it just delegates
all commands to the Emacs Lisp implementation, with the exception of `exec`,
due to the lack of a corresponding function in Emacs.
"""


from __future__ import print_function, unicode_literals


import sys
import os
import locale
import re
import subprocess
import errno


# The Cask executable and directories
CASK = os.path.realpath(os.path.abspath(sys.argv[0]))
CASK_BIN_DIRECTORY = os.path.dirname(CASK)
CASK_DIRECTORY = os.path.dirname(CASK_BIN_DIRECTORY)

# Get the byte-string process environment
ENVB = getattr(os, 'environb', os.environ)

# Regular expression to extract the version number from emacs --version
VERSION_RE = re.compile(r'^GNU Emacs (?P<version>\d+(?:\.\d+)*)$', re.MULTILINE)

# The minimum support Emacs version
MIN_EMACS_VERSION = (24,)

# Various Emacs locations on OS X.  We use these to try and find a better Emacs
# when the default Emacs is not supported.
OSX_EMACSEN = [
    # Emacs.app at various places
    os.path.expanduser('~/Applications/Emacs.app/Contents/MacOS/Emacs'),
    '/Applications/Emacs.app/Contents/MacOS/Emacs',
    # Homebrew'ed Emacs
    '/usr/local/bin/emacs',
]

class UnsupportedEmacsVersionError(Exception):
    """An exception indicating an unsupported Emacs version.

    The ``actual_version`` attribute provides the actual version of the
    affected Emacs as tuple of integers.  The ``expected_version`` attribute
    provides the expected version in the same type.

    """
    def __init__(self, actual_version, expected_version):
        Exception.__init__(self)
        self.actual_version = actual_version
        self.expected_version = expected_version


class MissingEmacsError(Exception):
    """An exception indicating that Emacs is missing.

    The ``emacs`` attribute has the name of the executable that was used to
    call Emacs.

    """

    def __init__(self, emacs):
        Exception.__init__(self)
        self.emacs = emacs


class EmacsCommandError(Exception):
    """An exception indicating a failed Emacs command.

    The ``command`` attribute has the failed command as list, as given to
    ``subprocess``, and the ``real_error`` attribute stores the underlying
    exception.

    """

    def __init__(self, command, real_error):
        Exception.__init__(self)
        self.command = command
        self.real_error = real_error


class ExecCommandError(Exception):
    """An exception indicating that ``cask exec`` failed.

    The ``command`` attribute has the command to be executed.  The
    ``real_error`` attribute holds the original exception.

    """

    def __init__(self, command, real_error):
        Exception.__init__(self)
        self.command = command
        self.real_error = real_error


def is_executable_file(path):
    """Determine whether ``path`` is an executable file.

    ``path`` is a string containing the path to test.

    Return ``True``, if ``path`` is executable, or ``False`` otherwise.

    """
    return os.path.isfile(path) and os.access(path, os.X_OK)


def parse_version(version_string):
    """Parse a version ``version_string``.

    ``version_string`` is a string containing a simple version number, where
    all parts are nummeric, and separated by a dot.

    Return a tuple with the parsed version.  The length of the tuple is equal
    to the number of parts in ``version_string``.  Raise ``ValueError`` if no
    version could be parsed from ``version_string``.

    """
    return tuple(int(part) for part in version_string.split('.'))


def format_version(version):
    """Format a ``version`` into a human-readable string.

    ``version`` is a tuple of integral components.

    Return a string representing ``version``."""
    return '.'.join(str(part) for part in version)


def get_cask_path(kind):
    """Get the Cask package environment path of the given ``kind``.

    ``kind`` is a string, denoting the kind of the path to get.  Cask supports
    two different kinds, namely ``'path'`` for the executable path of the
    package environment, and ``'load-path'`` for the Emacs Lisp load path of
    the package environment.

    Return the path as string, which contains all directories of the path
    separated by path separators.

    """
    process = subprocess.Popen([sys.executable, CASK, kind], stdout=subprocess.PIPE)
    stdout, _ = process.communicate()
    return stdout.rstrip()


def get_emacs_version(emacs):
    """Determine the version of the Emacs executable denoted by ``emacs``.

    ``emacs`` is a string containing the path to an Emacs executable.

    Return the version of ``emacs`` as tuple of integers.  Raise
    ``ValueError``, if the version of ``emacs`` could not be determined,
    e.g. because ``emacs`` was not a valid Emacs executable.  Raise
    ``OSError``, if ``emacs`` did not refer to an existing executable file.
    """
    cmd = [emacs, '--version']
    try:
        process = subprocess.Popen(cmd,
                                   stdout=subprocess.PIPE,
                                   stderr=subprocess.PIPE,
                                   universal_newlines=True)
        stdout, _ = process.communicate()
    except OSError as error:
        if error.errno == errno.ENOENT:
            raise MissingEmacsError(emacs)
        else:
            raise EmacsCommandError(cmd, error)
    else:
        version = VERSION_RE.search(stdout)
        if not version:
            raise ValueError(
                'Could not determine the version of Emacs at {0}'.format(emacs))
        else:
            return parse_version(version.group('version'))


def is_supported_emacs(emacs):
    """Determine whether Cask supports the given ``emacs``.

    ``emacs`` is a string containing the path to an Emacs executable.

    Return ``True``, if Cask supports the version of ``emacs``.  Raise
    ``ValueError``, if the version of ``emacs`` could not be determined,
    e.g. because ``emacs`` was not a valid Emacs executable.  Raise
    ``OSError``, if ``emacs`` did not refer to an existing executable file.

    """
    return get_emacs_version(emacs) >= MIN_EMACS_VERSION


def ensure_supported_emacs(emacs):
    """Ensure that ``emacs`` is an existing and supported Emacs version.

    ``emacs`` is a string containing the path to an Emacs executable.

    Raise ``UnsupportedEmacsVersionError`` if the Emacs executable is not a
    supported Emacs version.  Raise ``ValueError``, if the version of ``emacs``
    could not be determined, e.g. because ``emacs`` was not a valid Emacs
    executable.  Raise ``OSError``, if ``emacs`` did not refer to an existing
    executable file.

    """
    if not is_supported_emacs(emacs):
        raise UnsupportedEmacsVersionError(get_emacs_version(emacs),
                                           MIN_EMACS_VERSION)


def find_best_emacs():
    """Find the best Emacs executable for the current platform.

    On OS X, try hard to avoid the system's default Emacs, which Cask does not
    support, and look for third-party Emacs installations at various places.
    On any other system, just use the default Emacs.

    Return the name or path for the best Emacs command for the current platform
    as string.

    """
    if sys.platform == 'darwin' and not is_supported_emacs('emacs'):
        suitable_candidates = (e for e in OSX_EMACSEN if
                               is_executable_file(e) and is_supported_emacs(e))
        return next(suitable_candidates, 'emacs')
    else:
        return 'emacs'

def inside_emacs_24():
    """Whether Cask is running inside Emacs-24.

    Emacs 24 is rather inconsistent in its use of the ``$EMACS`` and
    ``$INSIDE_EMACS`` environment variables, so we try here to test if Cask is
    being run inside Emacs (either in a shell or with ``M-x compile``).

    Return ``True`` when running in Emacs 24, ``False`` otherwise.

    """
    ## are we inside Emacs at all
    return (b'INSIDE_EMACS' in ENVB and
            ## M-x compile in Emacs-24 sets INSIDE_EMACS to exactly 't'
            (ENVB.get(b'INSIDE_EMACS') == b't' or
             ## while M-x shell sets it to a string, starting with the
             ## version number
             ENVB.get(b'INSIDE_EMACS').startswith(b'24')))

def get_emacs_from_env():
    """Get the Emacs executable as specified by the environment.

    Use the command given by the environment variable ``$CASK_EMACS`` if set.
    Failing that use ``$EMACS``, unless the value of this variable does not
    refer to a real Emacs executable (i.e. Cask is run from inside Emacs).

    """
    return (ENVB.get(b'CASK_EMACS') or
            (ENVB.get(b'EMACS') if not inside_emacs_24() else None))

def get_cask_emacs():
    """Get the Emacs executable to use for Cask.

    If an Emacs is defined by the environment (see ``get_emacs_from_env``) use
    that, or automatically find a good Emacs for the current platform (see
    ``find_best_emacs``).

    Return the name or path for the Emacs command Cask shall use as string.
    Raise ``UnsupportedEmacsVersionError``, if the Emacs command does not meet
    the minimum version requirements of Cask.

    """
    emacs = get_emacs_from_env() or find_best_emacs()
    ensure_supported_emacs(emacs)
    return emacs


def exec_command(command):
    """Execute a ``command`` with the proper Cask environment.

    ``command`` is a list of strings, containing the command to execute and its
    arguments.

    Set ``$PATH`` and ``$EMACSLOADPATH`` to include the Cask package
    environment, and execute ``command``.

    This function replaces the current process.  It does **not** return.

    """
    # Copy the environment and update the paths
    ENVB[b'EMACSLOADPATH'] = get_cask_path('load-path')
    ENVB[b'PATH'] = get_cask_path('path')

    ## special handling for where we have a CASK_EMACS, we can use that and
    ## pass it as the EMACS variable to the command
    if (b'CASK_EMACS' in ENVB):
        ENVB[b'EMACS'] = ENVB[b'CASK_EMACS']

    try:
        os.execvp(command[0], command)
    except OSError as error:
        raise ExecCommandError(command, error)


def exec_emacs(command):
    """Execute Emacs with the proper Cask environment.

    ``command`` is a list of strings, containing the arguments to pass to the
    emacs process. The Emacs executable is choosen according to the normal
    rules (see ``get_cask_emacs``). Set ``$PATH`` and ``$EMACSLOADPATH`` to
    include the Cask package environment.

    This function replaces the current process.  It does **not** return.

    """
    exec_command([get_cask_emacs()] + command)


def exec_cask(arguments):
    """Execute the Cask CLI with the given ``arguments``.

    ``arguments`` is a list of strings, containing the arguments for Cask.

    Find the Emacs to use for Cask, and run the Cask CLI with the given
    ``arguments``.

    This function replaces the current process.  It does **not** return.

    """
    emacs = get_cask_emacs()
    cli = os.path.join(CASK_DIRECTORY, 'cask-cli.el')
    command = [emacs, '-Q', '--script', cli, '--'] + arguments
    os.execvp(command[0], command)


def exit_error(error):
    """Report an ``error`` and exit.

    ``error`` is a string, or an object representing an error, which provides a
    human-readable error description when stringified.

    Print a human-readable error message to standard error, and exit with
    return code 1.  See ``sys.exit``.

    """
    executable = os.path.basename(sys.argv[0])
    command = (' ' + sys.argv[1]) if len(sys.argv) > 1 else ''
    print('{0}{1}: error: {2}'.format(executable, command, error),
          file=sys.stderr)
    sys.exit(1)


def main():
    """Entry point.

    Partially parse arguments, and either execute commands within the Cask
    package environment, or delegate to the Cask CLI.

    """
    try:
        # Special handling for emacs on travis with evm and buggy pyenv, see
        # cask issue #399.
        if ENVB.get(b'TRAVIS', b'') == b'true':
            paths = ENVB[b'PATH'].split(b':')
            if len(paths) > 0 and paths[0] == b'/usr/bin':
                ENVB[b'PATH'] = ':'.join(paths[1:])


        if sys.version_info[:2] < (2, 6):
            exit_error(
                'Python 2.6 required, yours is {0.major}.{0.minor}'.format(
                    sys.version_info))
        ## TODO: replace with a command line parser!
        if len(sys.argv) > 1 and sys.argv[1] == 'exec':
            if len(sys.argv) == 2:
                exec_cask(['help'])
            else:
                exec_command(sys.argv[2:])
        elif len(sys.argv) > 1 and sys.argv[1] == 'emacs':
            exec_emacs(sys.argv[2:])
        else:
            exec_cask(sys.argv[1:])
    except OSError as error:
        exit_error(error)
    except UnsupportedEmacsVersionError as error:
        exit_error('Emacs {0} required, yours is Emacs {1}.'.format(
            format_version(error.expected_version),
            format_version(error.actual_version)))
    except MissingEmacsError as error:
        exit_error(
            'Emacs does not exist at {0}.  Did you install Emacs?'.format(
                error.emacs))
    except EmacsCommandError as error:
        exit_error('Emacs command "{0}" failed: {1}'.format(
            error.command, error.real_error))
    except ExecCommandError as error:
        exit_error('Failed to execute {0}: {1}\n'
                   'Did you run cask install?'.format(
                       ' '.join(error.command), error.real_error))


if __name__ == '__main__':
    main()
