# coding=utf-8
# Copyright 2014 Pants project contributors (see CONTRIBUTORS.md).
# Licensed under the Apache License, Version 2.0 (see LICENSE).

from __future__ import (absolute_import, division, generators, nested_scopes, print_function,
                        unicode_literals, with_statement)

import itertools
import logging
import os
import pkgutil
import plistlib
from abc import abstractproperty
from collections import namedtuple
from contextlib import contextmanager

from six import string_types

from pants.base.revision import Revision
from pants.java.util import execute_java, execute_java_async
from pants.subsystem.subsystem import Subsystem
from pants.util.contextutil import temporary_dir
from pants.util.memo import memoized_method, memoized_property
from pants.util.meta import AbstractClass
from pants.util.osutil import OS_ALIASES, normalize_os_name
from pants.util.process_handler import subprocess


logger = logging.getLogger(__name__)


def _parse_java_version(name, version):
  # Java version strings have been well defined since release 1.3.1 as defined here:
  #  http://www.oracle.com/technetwork/java/javase/versioning-naming-139433.html
  # These version strings comply with semver except that the traditional pre-release semver
  # slot (the 4th) can be delimited by an _ in the case of update releases of the jdk.
  # We accommodate that difference here using lenient parsing.
  # We also accommodate specification versions, which just have major and minor
  # components; eg: `1.8`.  These are useful when specifying constraints a distribution must
  # satisfy; eg: to pick any 1.8 java distribution: '1.8' <= version <= '1.8.99'
  if isinstance(version, string_types):
    version = Revision.lenient(version)
  if version and not isinstance(version, Revision):
    raise ValueError('{} must be a string or a Revision object, given: {}'.format(name, version))
  return version


class Distribution(object):
  """Represents a java distribution - either a JRE or a JDK installed on the local system.

  In particular provides access to the distribution's binaries; ie: java while ensuring basic
  constraints are met.  For example a minimum version can be specified if you know need to compile
  source code or run bytecode that exercise features only available in that version forward.

  :API: public

  TODO(John Sirois): This class has a broken API, its not reasonably useful with no methods exposed.
  Expose reasonable methods: https://github.com/pantsbuild/pants/issues/3263
  """

  class Error(Exception):
    """Indicates an invalid java distribution."""

  @staticmethod
  def _is_executable(path):
    return os.path.isfile(path) and os.access(path, os.X_OK)

  def __init__(self, home_path=None, bin_path=None, minimum_version=None, maximum_version=None,
               jdk=False):
    """Creates a distribution wrapping the given `home_path` or `bin_path`.

    Only one of `home_path` or `bin_path` should be supplied.

    :param string home_path: the path to the java distribution's home dir
    :param string bin_path: the path to the java distribution's bin dir
    :param minimum_version: a modified semantic version string or else a Revision object
    :param maximum_version: a modified semantic version string or else a Revision object
    :param bool jdk: ``True`` to require the distribution be a JDK vs a JRE
    """
    if home_path and not os.path.isdir(home_path):
      raise ValueError('The specified java home path is invalid: {}'.format(home_path))
    if bin_path and not os.path.isdir(bin_path):
      raise ValueError('The specified binary path is invalid: {}'.format(bin_path))
    if not bool(home_path) ^ bool(bin_path):
      raise ValueError('Exactly one of home path or bin path should be supplied, given: '
                       'home_path={} bin_path={}'.format(home_path, bin_path))

    self._home = home_path
    self._bin_path = bin_path or (os.path.join(home_path, 'bin') if home_path else '/usr/bin')

    self._minimum_version = _parse_java_version("minimum_version", minimum_version)
    self._maximum_version = _parse_java_version("maximum_version", maximum_version)
    self._jdk = jdk
    self._is_jdk = False
    self._system_properties = None
    self._validated_binaries = {}

  @property
  def jdk(self):
    self.validate()
    return self._is_jdk

  @property
  def system_properties(self):
    """Returns a dict containing the system properties of this java distribution."""
    return dict(self._get_system_properties(self.java))

  @property
  def version(self):
    """Returns the distribution version.

    Raises Distribution.Error if this distribution is not valid according to the configured
    constraints.
    """
    return self._get_version(self.java)

  def find_libs(self, names):
    """Looks for jars in the distribution lib folder(s).

    If the distribution is a JDK, both the `lib` and `jre/lib` dirs will be scanned.
    The endorsed and extension dirs are not checked.

    :param list names: jar file names
    :return: list of paths to requested libraries
    :raises: `Distribution.Error` if any of the jars could not be found.
    """
    def collect_existing_libs():
      def lib_paths():
        yield os.path.join(self.home, 'lib')
        if self.jdk:
          yield os.path.join(self.home, 'jre', 'lib')

      for name in names:
        for path in lib_paths():
          lib_path = os.path.join(path, name)
          if os.path.exists(lib_path):
            yield lib_path
            break
        else:
          raise Distribution.Error('Failed to locate {} library'.format(name))

    return list(collect_existing_libs())

  @property
  def home(self):
    """Returns the distribution JAVA_HOME."""
    if not self._home:
      home = self._get_system_properties(self.java)['java.home']
      # The `jre/bin/java` executable in a JDK distribution will report `java.home` as the jre dir,
      # so we check for this and re-locate to the containing jdk dir when present.
      if os.path.basename(home) == 'jre':
        jdk_dir = os.path.dirname(home)
        if self._is_executable(os.path.join(jdk_dir, 'bin', 'javac')):
          home = jdk_dir
      self._home = home
    return self._home

  @property
  def real_home(self):
    """Real path to the distribution java.home (resolving links)."""
    return os.path.realpath(self.home)

  @property
  def java(self):
    """Returns the path to this distribution's java command.

    If this distribution has no valid java command raises Distribution.Error.
    """
    return self.binary('java')

  def binary(self, name):
    """Returns the path to the command of the given name for this distribution.

    For example: ::

        >>> d = Distribution()
        >>> jar = d.binary('jar')
        >>> jar
        '/usr/bin/jar'
        >>>

    If this distribution has no valid command of the given name raises Distribution.Error.
    If this distribution is a JDK checks both `bin` and `jre/bin` for the binary.
    """
    if not isinstance(name, string_types):
      raise ValueError('name must be a binary name, given {} of type {}'.format(name, type(name)))
    self.validate()
    return self._validated_executable(name)

  def validate(self):
    """Validates this distribution against its configured constraints.

    Raises Distribution.Error if this distribution is not valid according to the configured
    constraints.
    """
    if self._validated_binaries:
      return

    with self._valid_executable('java') as java:
      if self._minimum_version:
        version = self._get_version(java)
        if version < self._minimum_version:
          raise self.Error('The java distribution at {} is too old; expecting at least {} and'
                           ' got {}'.format(java, self._minimum_version, version))
      if self._maximum_version:
        version = self._get_version(java)
        if version > self._maximum_version:
          raise self.Error('The java distribution at {} is too new; expecting no older than'
                           ' {} and got {}'.format(java, self._maximum_version, version))

    # We might be a JDK discovered by the embedded jre `java` executable.
    # If so reset the bin path to the true JDK home dir for full access to all binaries.
    self._bin_path = os.path.join(self.home, 'bin')

    try:
      self._validated_executable('javac')  # Calling purely for the check and cache side effects
      self._is_jdk = True
    except self.Error as e:
      if self._jdk:
        logger.debug('Failed to validate javac executable. Please check you have a JDK '
                      'installed. Original error: {}'.format(e))
        raise

  def execute_java(self, *args, **kwargs):
    return execute_java(*args, distribution=self, **kwargs)

  def execute_java_async(self, *args, **kwargs):
    return execute_java_async(*args, distribution=self, **kwargs)

  @memoized_method
  def _get_version(self, java):
    return _parse_java_version('java.version', self._get_system_properties(java)['java.version'])

  def _get_system_properties(self, java):
    if not self._system_properties:
      with temporary_dir() as classpath:
        with open(os.path.join(classpath, 'SystemProperties.class'), 'w+') as fp:
          fp.write(pkgutil.get_data(__name__, 'SystemProperties.class'))
        cmd = [java, '-cp', classpath, 'SystemProperties']
        process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
        stdout, stderr = process.communicate()
        if process.returncode != 0:
          raise self.Error('Failed to determine java system properties for {} with {} - exit code'
                           ' {}: {}'.format(java, ' '.join(cmd), process.returncode, stderr))

      props = {}
      for line in stdout.split(os.linesep):
        key, _, val = line.partition('=')
        props[key] = val
      self._system_properties = props

    return self._system_properties

  def _validate_executable(self, name):
    def bin_paths():
      yield self._bin_path
      if self._is_jdk:
        yield os.path.join(self.home, 'jre', 'bin')

    for bin_path in bin_paths():
      exe = os.path.join(bin_path, name)
      if self._is_executable(exe):
        return exe
    raise self.Error('Failed to locate the {} executable, {} does not appear to be a'
                     ' valid {} distribution'.format(name, self, 'JDK' if self._jdk else 'JRE'))

  def _validated_executable(self, name):
    exe = self._validated_binaries.get(name)
    if not exe:
      exe = self._validate_executable(name)
      self._validated_binaries[name] = exe
    return exe

  @contextmanager
  def _valid_executable(self, name):
    exe = self._validate_executable(name)
    yield exe
    self._validated_binaries[name] = exe

  def __repr__(self):
    return ('Distribution({!r}, minimum_version={!r}, maximum_version={!r} jdk={!r})'.format(
            self._bin_path, self._minimum_version, self._maximum_version, self._jdk))


class _DistributionEnvironment(AbstractClass):
  class Location(namedtuple('Location', ['home_path', 'bin_path'])):
    """Represents the location of a java distribution."""

    @classmethod
    def from_home(cls, home):
      """Creates a location given the JAVA_HOME directory.

      :param string home: The path of the JAVA_HOME directory.
      :returns: The java distribution location.
      """
      return cls(home_path=home, bin_path=None)

    @classmethod
    def from_bin(cls, bin_path):
      """Creates a location given the `java` executable parent directory.

      :param string bin_path: The parent path of the `java` executable.
      :returns: The java distribution location.
      """
      return cls(home_path=None, bin_path=bin_path)

  @abstractproperty
  def jvm_locations(self):
    """Return the jvm locations discovered in this environment.

    :returns: An iterator over all discovered jvm locations.
    :rtype: iterator of :class:`DistributionEnvironment.Location`
    """


class _EnvVarEnvironment(_DistributionEnvironment):
  @property
  def jvm_locations(self):
    def env_home(home_env_var):
      home = os.environ.get(home_env_var)
      return self.Location.from_home(home) if home else None

    jdk_home = env_home('JDK_HOME')
    if jdk_home:
      yield jdk_home

    java_home = env_home('JAVA_HOME')
    if java_home:
      yield java_home

    search_path = os.environ.get('PATH')
    if search_path:
      for bin_path in search_path.strip().split(os.pathsep):
        yield self.Location.from_bin(bin_path)


class _OSXEnvironment(_DistributionEnvironment):
  _OSX_JAVA_HOME_EXE = '/usr/libexec/java_home'

  @classmethod
  def standard(cls):
    return cls(cls._OSX_JAVA_HOME_EXE)

  def __init__(self, osx_java_home_exe):
    self._osx_java_home_exe = osx_java_home_exe

  @property
  def jvm_locations(self):
    # OSX will have a java_home tool that can be used to locate a unix-compatible java home dir.
    #
    # See:
    #   https://developer.apple.com/library/mac/documentation/Darwin/Reference/ManPages/man1/java_home.1.html
    #
    # The `--xml` output looks like so:
    # <?xml version="1.0" encoding="UTF-8"?>
    # <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
    #                        "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
    # <plist version="1.0">
    #   <array>
    #     <dict>
    #       ...
    #       <key>JVMHomePath</key>
    #       <string>/Library/Java/JavaVirtualMachines/jdk1.7.0_45.jdk/Contents/Home</string>
    #       ...
    #     </dict>
    #     ...
    #   </array>
    # </plist>
    if os.path.exists(self._osx_java_home_exe):
      try:
        plist = subprocess.check_output([self._osx_java_home_exe, '--failfast', '--xml'])
        for distribution in plistlib.readPlistFromString(plist):
          home = distribution['JVMHomePath']
          yield self.Location.from_home(home)
      except subprocess.CalledProcessError:
        pass


class _LinuxEnvironment(_DistributionEnvironment):
  # The `/usr/lib/jvm` dir is a common target of packages built for redhat and debian as well as
  # other more exotic distributions.  SUSE uses lib64
  _STANDARD_JAVA_DIST_DIRS = ('/usr/lib/jvm', '/usr/lib64/jvm')

  @classmethod
  def standard(cls):
    return cls(*cls._STANDARD_JAVA_DIST_DIRS)

  def __init__(self, *java_dist_dirs):
    if len(java_dist_dirs) == 0:
      raise ValueError('Expected at least 1 java dist dir.')
    self._java_dist_dirs = java_dist_dirs

  @property
  def jvm_locations(self):
    for java_dist_dir in self._java_dist_dirs:
      if os.path.isdir(java_dist_dir):
        for path in os.listdir(java_dist_dir):
          home = os.path.join(java_dist_dir, path)
          if os.path.isdir(home):
            yield self.Location.from_home(home)


class _ExplicitEnvironment(_DistributionEnvironment):
  def __init__(self, *homes):
    self._homes = homes

  @property
  def jvm_locations(self):
    for home in self._homes:
      yield self.Location.from_home(home)


class _UnknownEnvironment(_DistributionEnvironment):
  def __init__(self, *possible_environments):
    super(_DistributionEnvironment, self).__init__()
    if len(possible_environments) < 2:
      raise ValueError('At least two possible environments must be supplied.')
    self._possible_environments = possible_environments

  @property
  def jvm_locations(self):
    return itertools.chain(*(pe.jvm_locations for pe in self._possible_environments))


class _Locator(object):
  class Error(Distribution.Error):
    """Error locating a java distribution."""

  def __init__(self, distribution_environment, minimum_version=None, maximum_version=None):
    self._cache = {}
    self._distribution_environment = distribution_environment
    self._minimum_version = minimum_version
    self._maximum_version = maximum_version

  def _scan_constraint_match(self, minimum_version, maximum_version, jdk):
    """Finds a cached version matching the specified constraints

    :param Revision minimum_version: minimum jvm version to look for (eg, 1.7).
    :param Revision maximum_version: maximum jvm version to look for (eg, 1.7.9999).
    :param bool jdk: whether the found java distribution is required to have a jdk.
    :return: the Distribution, or None if no matching distribution is in the cache.
    :rtype: :class:`pants.java.distribution.Distribution`
    """

    for dist in self._cache.values():
      if minimum_version and dist.version < minimum_version:
        continue
      if maximum_version and dist.version > maximum_version:
        continue
      if jdk and not dist.jdk:
        continue
      return dist

  def locate(self, minimum_version=None, maximum_version=None, jdk=False):
    """Finds a java distribution that meets the given constraints and returns it.

    First looks for a cached version that was previously located, otherwise calls locate().
    :param minimum_version: minimum jvm version to look for (eg, 1.7).
                            The stricter of this and `--jvm-distributions-minimum-version` is used.
    :param maximum_version: maximum jvm version to look for (eg, 1.7.9999).
                            The stricter of this and `--jvm-distributions-maximum-version` is used.
    :param bool jdk: whether the found java distribution is required to have a jdk.
    :return: the Distribution.
    :rtype: :class:`Distribution`
    :raises: :class:`Distribution.Error` if no suitable java distribution could be found.
    """

    def _get_stricter_version(a, b, name, stricter):
      version_a = _parse_java_version(name, a)
      version_b = _parse_java_version(name, b)
      if version_a is None:
        return version_b
      if version_b is None:
        return version_a
      return stricter(version_a, version_b)

    # Take the tighter constraint of method args and subsystem options.
    minimum_version = _get_stricter_version(minimum_version,
                                            self._minimum_version,
                                            "minimum_version",
                                            max)
    maximum_version = _get_stricter_version(maximum_version,
                                            self._maximum_version,
                                            "maximum_version",
                                            min)

    key = (minimum_version, maximum_version, jdk)
    dist = self._cache.get(key)
    if not dist:
      dist = self._scan_constraint_match(minimum_version, maximum_version, jdk)
      if not dist:
        dist = self._locate(minimum_version=minimum_version,
                            maximum_version=maximum_version,
                            jdk=jdk)
      self._cache[key] = dist
    return dist

  def _locate(self, minimum_version=None, maximum_version=None, jdk=False):
    """Finds a java distribution that meets any given constraints and returns it.

    :param minimum_version: minimum jvm version to look for (eg, 1.7).
    :param maximum_version: maximum jvm version to look for (eg, 1.7.9999).
    :param bool jdk: whether the found java distribution is required to have a jdk.
    :return: the located Distribution.
    :rtype: :class:`Distribution`
    :raises: :class:`Distribution.Error` if no suitable java distribution could be found.
    """
    for location in itertools.chain(self._distribution_environment.jvm_locations):
      try:
        dist = Distribution(home_path=location.home_path,
                            bin_path=location.bin_path,
                            minimum_version=minimum_version,
                            maximum_version=maximum_version,
                            jdk=jdk)
        dist.validate()
        logger.debug('Located {} for constraints: minimum_version {}, maximum_version {}, jdk {}'
                     .format(dist, minimum_version, maximum_version, jdk))
        return dist
      except (ValueError, Distribution.Error) as e:
        logger.debug('{} is not a valid distribution because: {}'
                     .format(location.home_path, str(e)))
        pass

    if (minimum_version is not None
        and maximum_version is not None
        and maximum_version < minimum_version):
      error_format = ('Pants configuration/options led to impossible constraints for {} '
                      'distribution: minimum_version {}, maximum_version {}')
    else:
      error_format = ('Failed to locate a {} distribution with minimum_version {}, '
                      'maximum_version {}')
    raise self.Error(error_format.format('JDK' if jdk else 'JRE', minimum_version, maximum_version))


class DistributionLocator(Subsystem):
  """Subsystem that knows how to look up a java Distribution.

  Distributions are searched for in the following order by default:

  1. Paths listed for this operating system in the `--jvm-distributions-paths` map.
  2. JDK_HOME/JAVA_HOME
  3. PATH
  4. Likely locations on the file system such as `/usr/lib/jvm` on Linux machines.

  :API: public
  """

  class Error(Distribution.Error):
    """Error locating a java distribution.

    :API: public
    """

  @classmethod
  def cached(cls, minimum_version=None, maximum_version=None, jdk=False):
    """Finds a java distribution that meets the given constraints and returns it.

    :API: public

    First looks for a cached version that was previously located, otherwise calls locate().
    :param minimum_version: minimum jvm version to look for (eg, 1.7).
                            The stricter of this and `--jvm-distributions-minimum-version` is used.
    :param maximum_version: maximum jvm version to look for (eg, 1.7.9999).
                            The stricter of this and `--jvm-distributions-maximum-version` is used.
    :param bool jdk: whether the found java distribution is required to have a jdk.
    :return: the Distribution.
    :rtype: :class:`Distribution`
    :raises: :class:`Distribution.Error` if no suitable java distribution could be found.
    """
    try:
      return cls.global_instance()._locator().locate(
          minimum_version=minimum_version,
          maximum_version=maximum_version,
          jdk=jdk)
    except _Locator.Error as e:
      raise cls.Error('Problem locating a java distribution: {}'.format(e))

  options_scope = 'jvm-distributions'

  @classmethod
  def register_options(cls, register):
    super(DistributionLocator, cls).register_options(register)
    human_readable_os_aliases = ', '.join('{}: [{}]'.format(str(key), ', '.join(sorted(val)))
                                          for key, val in OS_ALIASES.items())
    register('--paths', advanced=True, type=dict,
             help='Map of os names to lists of paths to jdks. These paths will be searched before '
                  'everything else (before the JDK_HOME, JAVA_HOME, PATH environment variables) '
                  'when locating a jvm to use. The same OS can be specified via several different '
                  'aliases, according to this map: {}'.format(human_readable_os_aliases))
    register('--minimum-version', advanced=True, help='Minimum version of the JVM pants will use')
    register('--maximum-version', advanced=True, help='Maximum version of the JVM pants will use')

  def all_jdk_paths(self):
    """Get all explicitly configured JDK paths.

    :return: mapping of os name -> list of jdk_paths
    :rtype: dict of string -> list of string
    """
    return self._normalized_jdk_paths

  @memoized_method
  def _locator(self):
    return self._create_locator()

  @memoized_property
  def _normalized_jdk_paths(self):
    normalized = {}
    jdk_paths = self.get_options().paths or {}
    for name, paths in sorted(jdk_paths.items()):
      rename = normalize_os_name(name)
      if rename in normalized:
        logger.warning('Multiple OS names alias to "{}"; combining results.'.format(rename))
        normalized[rename].extend(paths)
      else:
        normalized[rename] = paths
    return normalized

  def _get_explicit_jdk_paths(self):
    if not self._normalized_jdk_paths:
      return ()
    os_name = normalize_os_name(os.uname()[0].lower())
    if os_name not in self._normalized_jdk_paths:
      logger.warning('--jvm-distributions-paths was specified, but has no entry for "{}".'
                     .format(os_name))
    return self._normalized_jdk_paths.get(os_name, ())

  def _create_locator(self):
    homes = self._get_explicit_jdk_paths()
    environment = _UnknownEnvironment(
        _ExplicitEnvironment(*homes),
        _UnknownEnvironment(
            _EnvVarEnvironment(),
            _LinuxEnvironment.standard(),
            _OSXEnvironment.standard()
        )
    )
    return _Locator(environment,
                    self.get_options().minimum_version,
                    self.get_options().maximum_version)
