##
# Copyright 2019-2022 Ghent University
#
# This file is part of EasyBuild,
# originally created by the HPC team of Ghent University (http://ugent.be/hpc/en),
# with support of Ghent University (http://ugent.be/hpc),
# the Flemish Supercomputer Centre (VSC) (https://www.vscentrum.be),
# Flemish Research Foundation (FWO) (http://www.fwo.be/en)
# and the Department of Economy, Science and Innovation (EWI) (http://www.ewi-vlaanderen.be/en).
#
# https://github.com/easybuilders/easybuild
#
# EasyBuild 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 v2.
#
# EasyBuild 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 EasyBuild.  If not, see <http://www.gnu.org/licenses/>.
##
"""
EasyBuild support for OpenMPI, implemented as an easyblock

@author: Kenneth Hoste (Ghent University)
@author: Robert Mijakovic (LuxProvide)
"""
import os
import re
from distutils.version import LooseVersion

import easybuild.tools.toolchain as toolchain
from easybuild.easyblocks.generic.configuremake import ConfigureMake
from easybuild.framework.easyconfig.constants import EASYCONFIG_CONSTANTS
from easybuild.tools.build_log import EasyBuildError
from easybuild.tools.config import build_option
from easybuild.tools.modules import get_software_root
from easybuild.tools.systemtools import check_os_dependency, get_shared_lib_ext
from easybuild.tools.toolchain.mpi import get_mpi_cmd_template


class EB_OpenMPI(ConfigureMake):
    """OpenMPI easyblock."""

    def configure_step(self):
        """Custom configuration step for OpenMPI."""

        def config_opt_used(key, enable_opt=False):
            """Helper function to check whether a configure option is already specified in 'configopts'."""
            if enable_opt:
                regex = '--(disable|enable)-%s' % key
            else:
                regex = '--(with|without)-%s' % key

            return bool(re.search(regex, self.cfg['configopts']))

        config_opt_names = [
            # suppress failure modes in relation to mpirun path
            'mpirun-prefix-by-default',
            # build shared libraries
            'shared',
        ]

        for key in config_opt_names:
            if not config_opt_used(key, enable_opt=True):
                self.cfg.update('configopts', '--enable-%s' % key)

        # List of EasyBuild dependencies for which OMPI has known options
        known_dependencies = ('CUDA', 'hwloc', 'libevent', 'libfabric', 'PMIx', 'UCX')
        # Value to use for `--with-<dep>=<value>` if the dependency is not specified in the easyconfig
        # No entry is interpreted as no option added at all
        # This is to make builds reproducible even when the system libraries are changed and avoids failures
        # due to e.g. finding only PMIx but not libevent on the system
        unused_dep_value = dict()
        # Known options since version 3.0 (no earlier ones checked)
        if LooseVersion(self.version) >= LooseVersion('3.0'):
            # Default to disable the option with "no"
            unused_dep_value = {dep: 'no' for dep in known_dependencies}
            # For these the default is to use an internal copy and not using any is not supported
            for dep in ('hwloc', 'libevent', 'PMIx'):
                unused_dep_value[dep] = 'internal'

        # handle dependencies
        for dep in known_dependencies:
            opt_name = dep.lower()
            # If the option is already used, don't add it
            if config_opt_used(opt_name):
                continue

            # libfabric option renamed in OpenMPI 3.1.0 to ofi
            if dep == 'libfabric' and LooseVersion(self.version) >= LooseVersion('3.1'):
                opt_name = 'ofi'
                # Check new option name. They are synonyms since 3.1.0 for backward compatibility
                if config_opt_used(opt_name):
                    continue

            dep_root = get_software_root(dep)
            # If the dependency is loaded, specify its path, else use the "unused" value, if any
            if dep_root:
                opt_value = dep_root
            else:
                opt_value = unused_dep_value.get(dep)
            if opt_value is not None:
                self.cfg.update('configopts', '--with-%s=%s' % (opt_name, opt_value))

        if bool(get_software_root('PMIx')) != bool(get_software_root('libevent')):
            raise EasyBuildError('You must either use both PMIx and libevent as dependencies or none of them. '
                                 'This is to enforce the same libevent is used for OpenMPI as for PMIx or '
                                 'the behavior may be unpredictable.')

        # check whether VERBS support should be enabled
        if not config_opt_used('verbs'):

            # for OpenMPI v4.x, the openib BTL should be disabled when UCX is used;
            # this is required to avoid "error initializing an OpenFabrics device" warnings,
            # see also https://www.open-mpi.org/faq/?category=all#ofa-device-error
            is_ucx_enabled = ('--with-ucx' in self.cfg['configopts'] and
                              '--with-ucx=no' not in self.cfg['configopts'])
            if LooseVersion(self.version) >= LooseVersion('4.0.0') and is_ucx_enabled:
                verbs = False
            else:
                # auto-detect based on available OS packages
                os_packages = EASYCONFIG_CONSTANTS['OS_PKG_IBVERBS_DEV'][0]
                verbs = any(check_os_dependency(osdep) for osdep in os_packages)
            # for OpenMPI v5.x, the verbs support is removed, only UCX is available
            # see https://github.com/open-mpi/ompi/pull/6270
            if LooseVersion(self.version) < LooseVersion('5.0.0'):
                if verbs:
                    self.cfg.update('configopts', '--with-verbs')
                else:
                    self.cfg.update('configopts', '--without-verbs')

        super(EB_OpenMPI, self).configure_step()

    def test_step(self):
        """Test step for OpenMPI"""
        # Default to `make check` if nothing is set. Disable with "runtest = False" in the EC
        if self.cfg['runtest'] is None:
            self.cfg['runtest'] = 'check'

        super(EB_OpenMPI, self).test_step()

    def load_module(self, *args, **kwargs):
        """
        Load (temporary) module file, after resetting to initial environment.

        Also put RPATH wrappers back in place if needed, to ensure that sanity check commands work as expected.
        """
        super(EB_OpenMPI, self).load_module(*args, **kwargs)

        # ensure RPATH wrappers are in place, otherwise compiling minimal test programs will fail
        if build_option('rpath'):
            if self.toolchain.options.get('rpath', True):
                self.toolchain.prepare_rpath_wrappers(rpath_filter_dirs=self.rpath_filter_dirs,
                                                      rpath_include_dirs=self.rpath_include_dirs)

    def sanity_check_step(self):
        """Custom sanity check for OpenMPI."""

        bin_names = ['mpicc', 'mpicxx', 'mpif90', 'mpifort', 'ompi_info', 'opal_wrapper']
        if LooseVersion(self.version) >= LooseVersion('5.0.0'):
            bin_names.append('prterun')
        else:
            if '--with-orte=no' not in self.cfg['configopts'] and '--without-orte' not in self.cfg['configopts']:
                bin_names.extend(['orterun', 'mpirun'])
        bin_files = [os.path.join('bin', x) for x in bin_names]

        shlib_ext = get_shared_lib_ext()
        lib_names = ['mpi_mpifh', 'mpi', 'open-pal']
        if LooseVersion(self.version) >= LooseVersion('5.0.0'):
            lib_names.append('prrte')
        else:
            lib_names.extend(['ompitrace', 'open-rte'])
        lib_files = [os.path.join('lib', 'lib%s.%s' % (x, shlib_ext)) for x in lib_names]

        inc_names = ['mpi-ext', 'mpif-config', 'mpif', 'mpi', 'mpi_portable_platform']
        if LooseVersion(self.version) >= LooseVersion('5.0.0'):
            inc_names.append('prte')
        inc_files = [os.path.join('include', x + '.h') for x in inc_names]

        custom_paths = {
            'files': bin_files + inc_files + lib_files,
            'dirs': [],
        }

        # make sure MPI compiler wrappers pick up correct compilers
        expected = {
            'mpicc': os.getenv('CC', 'gcc'),
            'mpicxx': os.getenv('CXX', 'g++'),
            'mpifort': os.getenv('FC', 'gfortran'),
            'mpif90': os.getenv('F90', 'gfortran'),
        }
        # actual pattern for gfortran is "GNU Fortran"
        for key in ['mpifort', 'mpif90']:
            if expected[key] == 'gfortran':
                expected[key] = "GNU Fortran"
        # for PGI, correct pattern is "pgfortran" with mpif90
        if expected['mpif90'] == 'pgf90':
            expected['mpif90'] = 'pgfortran'

        custom_commands = ["%s --version | grep '%s'" % (key, expected[key]) for key in sorted(expected.keys())]

        # Add minimal test program to sanity checks
        # Run with correct MPI launcher
        mpi_cmd_tmpl, params = get_mpi_cmd_template(toolchain.OPENMPI, dict(), mpi_version=self.version)
        # Limit number of ranks to 8 to avoid it failing due to hyperthreading
        ranks = min(8, self.cfg['parallel'])
        for src, compiler in (('hello_c.c', 'mpicc'), ('hello_mpifh.f', 'mpifort'), ('hello_usempi.f90', 'mpif90')):
            src_path = os.path.join(self.cfg['start_dir'], 'examples', src)
            if os.path.exists(src_path):
                test_exe = os.path.join(self.builddir, 'mpi_test_' + os.path.splitext(src)[0])
                self.log.info("Adding minimal MPI test program to sanity checks: %s", test_exe)

                # Build test binary
                custom_commands.append("%s %s -o %s" % (compiler, src_path, test_exe))

                # Run the test if chosen
                if build_option('mpi_tests'):
                    params.update({'nr_ranks': ranks, 'cmd': test_exe})
                    # Allow oversubscription for this test (in case of hyperthreading)
                    custom_commands.append("OMPI_MCA_rmaps_base_oversubscribe=1 " + mpi_cmd_tmpl % params)
                    # Run with 1 process which may trigger other bugs
                    # See https://github.com/easybuilders/easybuild-easyconfigs/issues/12978
                    params['nr_ranks'] = 1
                    custom_commands.append(mpi_cmd_tmpl % params)

        super(EB_OpenMPI, self).sanity_check_step(custom_paths=custom_paths, custom_commands=custom_commands)