# --------------------------------------------------------------------------
# Source file provided under Apache License, Version 2.0, January 2004,
# http://www.apache.org/licenses/
# (c) Copyright IBM Corp. 2015, 2022
# --------------------------------------------------------------------------
'''Provides utility functions about the runtime environment.

You can display information about your runtime environment using::

    $ python
    >>> from docplex.mp.environment import Environment
    >>> Environment().print_information()

or by invoking the `docplex.mp.environment` package on your shell command line::

    $ python -m docplex.mp.environment
    * system is: Linux 64bit
    * Python version 3.6.1, located at: /usr/bin/python
    * docplex is present, version is (2, 9, 0)
    * CPLEX library is present, version is 12.9.0.0, located at: /usr/local/CPLEX_Studio129/cplex/python/3.6/x86-64_linux
'''
try:
    import importlib.util as importlib_util
except ImportError:  # pragma: no cover
    importlib_util = None  # Python 2
import platform
import os
import sys
import warnings

from docplex.mp.error_handler import docplex_fatal

min_cplex_major = 12
min_cplex_minor = 8


def env_is_64_bit():
    return sys.maxsize > 2 ** 32


class UnsupportedPlatformError(Exception):
    pass


# maps paths to modules for already loaded cplexes
# for path == None, key is "__NONE__"
_loaded_cplexes = {}


# noinspection PyPep8
class Environment(object):
    """ This class detects and contains information regarding other modules of interest, such as
        whether CPLEX, `numpy`, and `matplotlib` are installed.
    """
    _default_env = None  # The default env singleton

    """ This class detects and contains information regarding other modules of interest, such as
        whether CPLEX, `numpy`, and `matplotlib` are installed.
    """
    def __init__(self, start_auto_configure=True, logger=None):
        """
        __init__(self)
        """
        self._found_cplex = False
        self._cplex_version = ''
        self._cplex_location = None

        self._found_numpy = None
        self._numpy_version = None
        self._numpy_hook = None

        self._found_pandas = None
        self._pandas_version = ''
        self._found_matplotlib = None
        self._matplotlib_version = None

        self._python_version = platform.python_version()
        self._system = platform.system()
        self._machine = platform.machine()
        self._bitness = platform.architecture()[0]
        self._is64bit = sys.maxsize > 2 ** 32

        if start_auto_configure:
            self.auto_configure(logger=logger)

    # class variable
    # Must be true if python versin is > 3.6
    env_is_python36 = (sys.version_info.major >= 3) and (sys.version_info.minor >= 6)

    def _get_numpy_hook(self):
        return self._numpy_hook

    def _set_numpy_hook(self, hook):
        self._numpy_hook = hook
        if hook is not None:
            if self.has_numpy:  # now that we have set a hook, do check for numpy
                hook()          # if numpy is present, do call the hook

    numpy_hook = property(_get_numpy_hook, _set_numpy_hook)

    def equals(self, other):
        if type(other) != Environment:
            return False
        if self.has_cplex != other.has_cplex:
            return False
        if self.cplex_version != other.cplex_version:
            return False

        if self.has_numpy != other.has_numpy:
            return False
        if self.has_matplotlib != other.has_matplotlib:
            return False
        if self.has_pandas != other.has_pandas:
            return False

        return True

    @property
    def has_cplex(self):
        """True if the CPLEX libraries are available.

        The cplex libraries search order is:

            - import the module `import cplex` if the import is sucessful
            - import the module in location $CPLEX_STUDIO_DIR1210/cplex/python/<python.version>/<platform>
            - import the module in location $CPLEX_STUDIO_DIR129/cplex/python/<python.version>/<platform>
            - import the module in location $CPLEX_STUDIO_DIR128/cplex/python/<python.version>/<platform>
        """
        return self._found_cplex

    def hash_cplex_with_version_min(self, min_version):
        return self.has_cplex and self._cplex_version >= min_version

    def check_cplex_version(self):
        cpx_version = self.cplex_version_as_tuple

        if self.has_cplex and cpx_version < (min_cplex_major, min_cplex_minor):
            s_min_version = "{0}.{1}".format(min_cplex_major, min_cplex_minor)
            docplex_fatal("DOcplex supports Cplex from {0} up, unsupported version {1} was found"
                          .format(s_min_version, self.cplex_version))

    @property
    def cplex_lp_obj_kw(self):
        if self.has_cplex and self.cplex_version_as_tuple >= (12, 10):
            obj_kw = "obj1"
        else:
            obj_kw = "obj"
        return obj_kw

    @staticmethod
    def cplex_platform():
        sys_platform = platform.system()
        machine = platform.machine()
        if sys_platform == 'Windows':
            return 'x64_win64'
        elif sys_platform == 'Darwin':
            if machine == 'x86_64':
                return 'x86-64_osx'
            else:
                return 'arm64_osx'
        elif sys_platform == 'Linux':
            if machine == 'x86_64':
                return 'x86-64_linux'
            else:
                # different flavors of linux (ppc64le_linux, s390x_linux)
                return machine + "_linux"
        return None

    @staticmethod
    def cplex_distribname():
        distribname = Environment.cplex_platform()
        if distribname == 'x64_win64':
            distribname = 'x64_windows'
        return distribname

    @property
    def cplex_version(self):
        return self._cplex_version

    @property
    def cplex_version_as_tuple(self):
        cpxv = self._cplex_version
        return self.parse_cplex_version(cpxv, fallback=None)

    @property
    def has_matplotlib(self):
        """True if the `matplotlib` libraries are available.
        """
        if self._found_matplotlib is None:
            self.check_matplotlib()
        return self._found_matplotlib

    @property
    def has_pandas(self):
        """True if the `pandas` libraries are available.
        """
        self.check_pandas()
        return self._found_pandas

    @property
    def pandas_version(self):
        self.check_pandas()
        return self._pandas_version

    @property
    def cplex_location(self):
        """The system path where CPLEX is located, if present. Otherwise, returns None.
        """
        return self._cplex_location

    @property
    def has_numpy(self):
        """True if the `numpy` libraries are available.
        """
        self.check_numpy()
        return self._found_numpy

    def is_64bit(self):
        """True if running on a 64-bit platform.
        """
        return self._is64bit

    @property
    def python_version(self):
        """ Returns the Python version as a string"""
        return platform.python_version()

    def auto_configure(self, logger=None):
        self.check_cplex(logger=logger)
        # check for pandas (docplex_wml studio)
        self.check_pandas()

    def check_all(self):
        self.check_cplex()
        self.check_pandas()
        self.check_numpy()
        self.check_matplotlib()

    def get_cplex_module(self, default_location=None, logger=None):
        '''Returns the cplex module.

        If `default_location` is None, this method will try to import the cplex module in the following order:

            - by importing the module `import cplex` if the import is sucessful
            - by importing the module in location $CPLEX_STUDIO_DIR20101/cplex/python
            - by importing the module in location $CPLEX_STUDIO_DIR201/cplex/python
            - by importing the module in location $CPLEX_STUDIO_DIR1210/cplex/python
            - by importing the module in location $CPLEX_STUDIO_DIR129/cplex/python
            - by importing the module in location $CPLEX_STUDIO_DIR128/cplex/python

        If `default_location` is a valid path and contains a valid python package,
        `cplex` is imported from the specified location.

        If `default_location` is a valid path and
        `<default_location>/cplex/python/<python_version>/<platform>` exists, `cplex`
        is imported from that location

        If `cplex` could not be found, this method returns `None`
        '''
        cplex = None

        def load_cplex(location, version=""):
            # we cache loaded modules as we want to make sure that modules are loaded
            # only once (same behaviour than 'import')
            # example location: C:\Program Files\IBM\ILOG\CPLEX_Studio1210\cplex\python\3.7\x64_win64\
            cplex = _loaded_cplexes.get(location, None)
            if cplex is None:
                absolute_name = "cplex%s" % version
                module_location = os.path.join(location, "cplex", "__init__.py")
                if not os.path.isfile(module_location):
                    return None
                if importlib_util:
                    spec = importlib_util.spec_from_file_location(absolute_name,
                                                                  module_location)
                    cplex = importlib_util.module_from_spec(spec)
                    # TODO: Error out if one was already loaded
                    previous = sys.modules.get(absolute_name, None)
                    if previous is not None:
                        lo = location if location else "default sys.path"
                        raise RuntimeError("Cannot load cplex from %s, a previous version has already been loaded from %s" % (lo, previous.__file__))
                    sys.modules[absolute_name] = cplex
                    spec.loader.exec_module(cplex)
                else:
                    import imp
                    cplex = imp.load_source(absolute_name.split('.')[-1], module_location)
                _loaded_cplexes[location] = cplex
            return cplex

        def load_cplex_from_cos_root(cos_root, version=""):
            platform = Environment.cplex_platform()
            if platform is None:
                raise UnsupportedPlatformError("Platform not supported, please install cplex python module")
            python_version = '%s.%s' % (sys.version_info[0],
                                        sys.version_info[1])
            full_path = os.path.join(cos_root, 'cplex', 'python', python_version, platform)
            return load_cplex(full_path, version=version)

        if default_location is None:
            try:
                import cplex   # @UnresolvedImport
                if logger is not None:
                    logger.info("Found cplex with 'import cplex'")
            except (ImportError, ModuleNotFoundError):
                # in py3.7, ModuleNotFoundError is raised
                user_cos_location = os.environ.get('DOCPLEX_COS_LOCATION', None)
                if user_cos_location is not None:
                    cplex = load_cplex_from_cos_root(user_cos_location)
                    if cplex is None:
                        # user provided a cos location that was not right, raise warning
                        warnings.warn("Could not load CPLEX from Location provided by DOCPLEX_COS_LOCATION=%s. Using default locations." % user_cos_location)
                if cplex is None:
                    try_environs = ['CPLEX_STUDIO_DIR2211',
                                    'CPLEX_STUDIO_DIR221',
                                    'CPLEX_STUDIO_DIR20101',
                                    'CPLEX_STUDIO_DIR201',
                                    'CPLEX_STUDIO_DIR1210',
                                    'CPLEX_STUDIO_DIR129',
                                    'CPLEX_STUDIO_DIR128']
                    for t in try_environs:
                        loc = os.environ.get(t, None)
                        # version = t[len('CPLEX_STUDIO_DIR'):] if loc else ""
                        # currently, there are some import in CPLEX, like:
                        #   File "C:\Program Files\IBM\ILOG\CPLEX_Studio1210\cplex\python\3.7\x64_win64\cplex\_internal\_pycplex_platform.py", line 24, in <module>
                        #     from cplex._internal.py37_cplex12100 import *
                        # that prevent us from loading multiples instances of cplex
                        # so for now, let's just ignore this version
                        cplex = load_cplex_from_cos_root(loc) if loc else None
                        if logger is not None:
                            logger.info("Looking into location %s, found = %s" % (loc, (cplex is not None)))
                        if cplex is not None:
                            return cplex
        else:
            if os.path.isfile(os.path.join(default_location, "__init__.py")):
                cplex = load_cplex(default_location)
            else:
                cplex = load_cplex_from_cos_root(default_location)
        return cplex

    def check_cplex(self, logger=None):
        # detecting CPLEX using default search location
        cplex = self.get_cplex_module(logger=logger)
        self._found_cplex = (cplex is not None)
        if self.has_cplex:
            cplex_module_file = cplex.__file__
            if cplex_module_file:
                self._cplex_location = os.path.dirname(os.path.dirname(cplex_module_file))
            try:
                self._cplex_version = cplex.__version__
            except AttributeError:
                # older version: use an instance
                cpx = cplex.Cplex()
                # format: MM.mm.rr.ff e.g.e 12.6.2.0
                self._cplex_version = cpx.get_version()
                # terminate the dummy instance...
                del cpx

    def check_numpy(self):
        if self._found_numpy is None:
            try:
                import numpy.version as npv
                self._found_numpy = True
                self._numpy_version = npv.version

                self_numpy_hook = self._numpy_hook
                if self_numpy_hook is not None:
                    # lazy call the hook once at first check time.
                    self_numpy_hook()

            except ImportError:  # pragma: no cover
                self._found_numpy = False
                self._numpy_version = None

        return self._found_numpy

    def check_matplotlib(self):
        try:
            from matplotlib import __version__ as matplotlib_version
            self._found_matplotlib = True
            self._matplotlib_version = matplotlib_version
        except ImportError:  # pragma: no cover
            self._found_matplotlib = False

    def check_pandas(self):
        if self._found_pandas is None:
            try:
                import pandas
                self._found_pandas = True
                self._pandas_version = pandas.__version__
            except ImportError:  # pragma: no cover
                self._found_pandas = False

    @staticmethod
    def _display_feature(is_present, feature_name, feature_version, location=None):
        safe_feature_version = feature_version or "?"
        if is_present is None:
            pass  # we dont know yet
        elif is_present:
            if location:
                print("* {0} is present, version is {1}, located at: {2}".format(feature_name, safe_feature_version,
                                                                                 location))
            else:
                print("* {0} is present, version is {1}".format(feature_name, safe_feature_version))
        else:
            print("* {0} is not available".format(feature_name))

    @property
    def max_nb_digits(self):
        # source: https://en.wikipedia.org/wiki/IEEE_floating_point
        return 17 if self.is_64bit() else 9

    @property
    def bitness(self):
        return 64 if self.is_64bit() else 32

    def print_information(self):
        print("* system is: {0} {1}".format(self._system, self._bitness))
        from sys import version_info
        from docplex.mp import __version_info__

        python_version = '%s.%s.%s' % (version_info[0], version_info[1], version_info[2])
        print("* Python version %s, located at: %s" % (python_version, sys.executable))
        self._display_feature(True, "docplex", "%d.%d.%d" % __version_info__)
        self._display_feature(self.has_cplex, "CPLEX library", self._cplex_version, self._cplex_location)
        self._display_feature(self._found_pandas, "pandas", self._pandas_version)
        self._display_feature(self._found_numpy, "numpy", self._numpy_version)
        self._display_feature(self._found_matplotlib, "matplotlib", self._matplotlib_version)

    @staticmethod
    def closed_env():
        closed = Environment(start_auto_configure=False)
        # force matplotlib absent
        closed._found_matplotlib = False
        closed._found_numpy = False
        closed._found_pandas = False
        return closed

    @staticmethod
    def make_new_configured_env():
        # returns a fresh new environment
        return Environment(start_auto_configure=True)

    @staticmethod
    def get_default_env():
        if not Environment._default_env:
            Environment._default_env = Environment.make_new_configured_env()
        return Environment._default_env

    @staticmethod
    def parse_cplex_version(version_text, fallback=None):
        # parse such strings as: 20.1.0.0 | 2020-11-10 | 9bedb6d68
        if not version_text:
            return fallback
        barpos = version_text.find('|')
        if barpos > 0:
            assert barpos >= 2
            before_bar = barpos-1
            baseline_version = version_text[:before_bar]
        else:
            baseline_version = version_text
        return tuple(float(x) for x in baseline_version.split('.'))

    # for pickling: recreate a fresh environment at the other end of pickle.
    def __reduce__(self):
        return Environment.make_new_configured_env, ()


def get_closed_environment():
    # This instance assumes nothing is found, CPLEX, numpy, etc, to be used for tests
    return Environment.closed_env()


if __name__ == '__main__':
    Environment().print_information()
