#!/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 re
import subprocess
import errno
import platform


# 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)


class EnvbShim():
    """Python3 does not support environb on Windows.  This shim should
    work-around that limitation.

    """

    def __init__(self, environ):
        self.environ = environ

    def _fix_key(self, key):
        if type(key) is bytes:
            key = str(key, 'utf8')
        elif type(key) is not str:
            key = str(key)
        return key

    def __getitem__(self, key):
        key = self._fix_key(key)
        return self.environ[key]

    def __setitem__(self, key, value):
        key = self._fix_key(key)
        self.environ[key] = self._fix_key(value)

    def get(self, key, default=None):
        key = self._fix_key(key)
        return self.environ.get(key, default)

    def __contains__(self, key):
        key = self._fix_key(key)
        return key in self.environ


def getEnvb():
    alt = EnvbShim(os.environ) \
        if sys.version_info[0] > 2 and platform.system() == 'Windows' \
        else os.environ
    return getattr(os, 'environb', alt)


# Get the byte-string process environment
ENVB = getEnvb()

# 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.

    """
    FAIL = "\033[31m"
    ENDC = "\033[0m"
    notice = """\
!!!
!!!                         DEPRECATION NOTICE
!!!
!!!    This `cask` executable will be required Python 3.6 from 2021/08/01.
!!!    Your Python is {0.major}.{0.minor}.  Please install latest Python.
!!!
"""
    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))

        if sys.version_info[:2] < (3, 6):
            print(FAIL + notice.format(sys.version_info) + ENDC, file=sys.stderr)

        # 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()
