# --------------------------------------------------------------------

from pathlib import Path
import re
import os
import subprocess
import sys
import glob
import copy
import warnings
from distutils import log
from distutils import sysconfig
from distutils.util import execute
from distutils.util import split_quoted
from distutils.errors import DistutilsError
from distutils.text_file import TextFile


try:
    from cStringIO import StringIO
except ImportError:
    from io import StringIO

try:
    import setuptools
except ImportError:
    setuptools = None

if setuptools:
    from setuptools import setup as _setup
    from setuptools import Extension as _Extension
    from setuptools import Command
else:
    from distutils.core import setup as _setup
    from distutils.core import Extension as _Extension
    from distutils.core import Command


def import_command(cmd):
    try:
        from importlib import import_module
    except ImportError:

        def import_module(n):
            return __import__(n, fromlist=[None])

    try:
        if not setuptools:
            raise ImportError
        mod = import_module('setuptools.command.' + cmd)
        return getattr(mod, cmd)
    except ImportError:
        mod = import_module('distutils.command.' + cmd)
        return getattr(mod, cmd)


_config = import_command('config')
_build = import_command('build')
_build_ext = import_command('build_ext')
_install = import_command('install')

try:
    from setuptools import modified
except ImportError:
    try:
        from setuptools import dep_util as modified
    except ImportError:
        from distutils import dep_util as modified

try:
    from packaging.version import Version
except ImportError:
    try:
        from setuptools.extern.packaging.version import Version
    except ImportError:
        from distutils.version import StrictVersion as Version

# --------------------------------------------------------------------

# Cython

CYTHON = '3.0.0'


def cython_req():
    return CYTHON


def cython_chk(VERSION, verbose=True):
    #
    def warn(message):
        if not verbose:
            return
        ruler, ws, nl = '*' * 80, ' ', '\n'
        pyexe = sys.executable
        advise = '$ %s -m pip install --upgrade cython' % pyexe

        def printer(*s):
            sys.stderr.write(' '.join(s) + '\n')

        printer(ruler, nl)
        printer(ws, message, nl)
        printer(ws, ws, advise, nl)
        printer(ruler)

    #
    try:
        import Cython
    except ImportError:
        warn('You need Cython to generate C source files.')
        return False
    #
    CYTHON_VERSION = Cython.__version__
    m = re.match(r'(\d+\.\d+(?:\.\d+)?).*', CYTHON_VERSION)
    if not m:
        warn(f'Cannot parse Cython version string {CYTHON_VERSION!r}')
        return False
    REQUIRED = Version(VERSION)
    PROVIDED = Version(m.groups()[0])
    if PROVIDED < REQUIRED:
        warn(f'You need Cython >= {VERSION} (you have version {CYTHON_VERSION})')
        return False
    #
    if verbose:
        log.info('using Cython %s' % CYTHON_VERSION)
    return True


def cython_run(
    source,
    target=None,
    depends=(),
    includes=(),
    workdir=None,
    force=False,
    VERSION='0.0',
):
    if target is None:
        target = os.path.splitext(source)[0] + '.c'
    cwd = os.getcwd()
    try:
        if workdir:
            os.chdir(workdir)
        alldeps = [source]
        for dep in depends:
            alldeps += glob.glob(dep)
        if not (force or modified.newer_group(alldeps, target)):
            log.debug("skipping '%s' -> '%s' (up-to-date)", source, target)
            return
    finally:
        os.chdir(cwd)
    require = 'Cython >= %s' % VERSION
    if setuptools and not cython_chk(VERSION, verbose=False):
        if sys.modules.get('Cython'):
            removed = getattr(sys.modules['Cython'], '__version__', '')
            log.info('removing Cython %s from sys.modules' % removed)
            pkgname = re.compile(r'cython(\.|$)', re.IGNORECASE)
            for modname in list(sys.modules.keys()):
                if pkgname.match(modname):
                    del sys.modules[modname]
        try:
            install_setup_requires = setuptools._install_setup_requires
            with warnings.catch_warnings():
                if hasattr(setuptools, 'SetuptoolsDeprecationWarning'):
                    category = setuptools.SetuptoolsDeprecationWarning
                    warnings.simplefilter('ignore', category)
                log.info("fetching build requirement '%s'" % require)
                install_setup_requires({'setup_requires': [require]})
        except Exception:
            log.info("failed to fetch build requirement '%s'" % require)
    if not cython_chk(VERSION):
        raise DistutilsError("unsatisfied build requirement '%s'" % require)
    #
    log.info("cythonizing '%s' -> '%s'", source, target)
    from cythonize import cythonize

    args = []
    if workdir:
        args += ['--working', workdir]
    args += [source]
    if target:
        args += ['--output-file', target]
    err = cythonize(args)
    if err:
        raise DistutilsError(f"Cython failure: '{source}' -> '{target}'")


# --------------------------------------------------------------------


def fix_config_vars(names, values):
    values = list(values)
    if 'CONDA_BUILD' in os.environ:
        return values
    if sys.platform == 'darwin':
        if 'ARCHFLAGS' in os.environ:
            ARCHFLAGS = os.environ['ARCHFLAGS']
            for i, flag in enumerate(list(values)):
                flag, count = re.subn(r'-arch\s+\w+', ' ', str(flag))
                if count and ARCHFLAGS:
                    flag = flag + ' ' + ARCHFLAGS
                values[i] = flag
        if 'SDKROOT' in os.environ:
            SDKROOT = os.environ['SDKROOT']
            for i, flag in enumerate(list(values)):
                flag, count = re.subn(r'-isysroot [^ \t]*', ' ', str(flag))
                if count and SDKROOT:
                    flag = flag + ' ' + '-isysroot ' + SDKROOT
                values[i] = flag
    return values


def get_config_vars(*names):
    # Core Python configuration
    values = sysconfig.get_config_vars(*names)
    # Do any distutils flags fixup right now
    return fix_config_vars(names, values)


# --------------------------------------------------------------------


class PetscConfig:
    def __init__(self, petsc_dir, petsc_arch, dest_dir=None):
        if dest_dir is None:
            dest_dir = os.environ.get('DESTDIR')
        self.configdict = {}
        if not petsc_dir:
            raise DistutilsError('PETSc not found')
        if not os.path.isdir(petsc_dir):
            raise DistutilsError('invalid PETSC_DIR: %s' % petsc_dir)
        self.version = self._get_petsc_version(petsc_dir)
        self.configdict = self._get_petsc_config(petsc_dir, petsc_arch)
        self.PETSC_DIR = self['PETSC_DIR']
        self.PETSC_ARCH = self['PETSC_ARCH']
        self.DESTDIR = dest_dir
        language_map = {'CONLY': 'c', 'CXXONLY': 'c++'}
        self.language = language_map[self['PETSC_LANGUAGE']]

    def __getitem__(self, item):
        return self.configdict[item]

    def get(self, item, default=None):
        return self.configdict.get(item, default)

    def configure(self, extension, compiler=None):
        self.configure_extension(extension)
        if compiler is not None:
            self.configure_compiler(compiler)

    def _get_petsc_version(self, petsc_dir):
        import re

        version_re = {
            'major': re.compile(r'#define\s+PETSC_VERSION_MAJOR\s+(\d+)'),
            'minor': re.compile(r'#define\s+PETSC_VERSION_MINOR\s+(\d+)'),
            'micro': re.compile(r'#define\s+PETSC_VERSION_SUBMINOR\s+(\d+)'),
            'release': re.compile(r'#define\s+PETSC_VERSION_RELEASE\s+(-*\d+)'),
        }
        petscversion_h = os.path.join(petsc_dir, 'include', 'petscversion.h')
        with open(petscversion_h, 'rt') as f:
            data = f.read()
        major = int(version_re['major'].search(data).groups()[0])
        minor = int(version_re['minor'].search(data).groups()[0])
        micro = int(version_re['micro'].search(data).groups()[0])
        release = int(version_re['release'].search(data).groups()[0])
        return (major, minor, micro), (release == 1)

    def _get_petsc_config(self, petsc_dir, petsc_arch):
        from os.path import join, isdir, exists

        PETSC_DIR = petsc_dir
        PETSC_ARCH = petsc_arch
        #
        confdir = join('lib', 'petsc', 'conf')
        if not (PETSC_ARCH and isdir(join(PETSC_DIR, PETSC_ARCH))):
            petscvars = join(PETSC_DIR, confdir, 'petscvariables')
            PETSC_ARCH = makefile(open(petscvars, 'rt')).get('PETSC_ARCH')
        if not (PETSC_ARCH and isdir(join(PETSC_DIR, PETSC_ARCH))):
            PETSC_ARCH = ''
        #
        variables = join(PETSC_DIR, confdir, 'variables')
        if not exists(variables):
            variables = join(PETSC_DIR, PETSC_ARCH, confdir, 'variables')
        petscvariables = join(PETSC_DIR, PETSC_ARCH, confdir, 'petscvariables')
        #
        with open(variables) as f:
            contents = f.read()
        with open(petscvariables) as f:
            contents += f.read()
        #
        confstr = 'PETSC_DIR  = %s\n' % PETSC_DIR
        confstr += 'PETSC_ARCH = %s\n' % PETSC_ARCH
        confstr += contents
        return makefile(StringIO(confstr))

    def _configure_ext(self, ext, dct, append=False):
        extdict = ext.__dict__
        for key, values in dct.items():
            if key in extdict:
                for value in values:
                    if value not in extdict[key]:
                        if not append:
                            extdict[key].insert(0, value)
                        else:
                            extdict[key].append(value)

    def configure_extension(self, extension):
        # includes and libraries
        # paths in PETSc config files point to final installation location, but
        # we might be building against PETSc in staging location (DESTDIR) when
        # DESTDIR is set, so append DESTDIR (if nonempty) to those paths
        petsc_inc = flaglist(prepend_to_flags(self.DESTDIR, self['PETSC_CC_INCLUDES']))
        lib_flags = prepend_to_flags(
            self.DESTDIR,
            '-L{} {}'.format(self['PETSC_LIB_DIR'], self['PETSC_LIB_BASIC']),
        )
        petsc_lib = flaglist(lib_flags)
        # runtime_library_dirs is not supported on Windows
        if sys.platform != 'win32':
            # if DESTDIR is set, then we're building against PETSc in a staging
            # directory, but rpath needs to point to final install directory.
            rpath = [strip_prefix(self.DESTDIR, self['PETSC_LIB_DIR'])]
            if sys.modules.get('petsc') is not None:
                if sys.platform == 'darwin':
                    rpath = ['@loader_path/../../petsc/lib']
                else:
                    rpath = ['$ORIGIN/../../petsc/lib']
            petsc_lib['runtime_library_dirs'].extend(rpath)
        # Link in extra libraries on static builds
        if self['BUILDSHAREDLIB'] != 'yes':
            petsc_ext_lib = split_quoted(self['PETSC_EXTERNAL_LIB_BASIC'])
            petsc_lib['extra_link_args'].extend(petsc_ext_lib)
        self._configure_ext(extension, petsc_inc, append=True)
        self._configure_ext(extension, petsc_lib)

    def configure_compiler(self, compiler):
        if compiler.compiler_type != 'unix':
            return
        getenv = os.environ.get
        # distutils C/C++ compiler
        (cc, cflags, ccshared, cxx) = get_config_vars('CC', 'CFLAGS', 'CCSHARED', 'CXX')
        ccshared = getenv('CCSHARED', ccshared or '')
        cflags = getenv('CFLAGS', cflags or '')
        cflags = cflags.replace('-Wstrict-prototypes', '')
        # distutils linker
        (ldflags, ldshared, so_ext) = get_config_vars('LDFLAGS', 'LDSHARED', 'SO')
        ld = cc
        ldshared = getenv('LDSHARED', ldshared)
        ldflags = getenv('LDFLAGS', cflags + ' ' + (ldflags or ''))
        ldcmd = split_quoted(ld) + split_quoted(ldflags)
        ldshared = [
            flg
            for flg in split_quoted(ldshared)
            if flg not in ldcmd and (flg.find('/lib/spack/env') < 0) and (flg.find('/libexec/spack/') < 0)
        ]
        ldshared = str.join(' ', ldshared)

        #
        def get_flags(cmd):
            if not cmd:
                return ''
            cmd = split_quoted(cmd)
            if os.path.basename(cmd[0]) == 'xcrun':
                del cmd[0]
                while True:
                    if cmd[0] == '-sdk':
                        del cmd[0:2]
                        continue
                    if cmd[0] == '-log':
                        del cmd[0]
                        continue
                    break
            return ' '.join(cmd[1:])

        # PETSc C compiler
        PCC = self['PCC']
        PCC_FLAGS = get_flags(cc) + ' ' + self['PCC_FLAGS']
        PCC_FLAGS = PCC_FLAGS.replace('-fvisibility=hidden', '')
        PCC_FLAGS = PCC_FLAGS.replace('-Wpedantic', '-Wno-pedantic')
        PCC_FLAGS = PCC_FLAGS.replace('-Wextra-semi-stmt', '-Wno-extra-semi-stmt')
        PCC = getenv('PCC', PCC) + ' ' + getenv('PCCFLAGS', PCC_FLAGS)
        PCC_SHARED = str.join(' ', (PCC, ccshared, cflags))
        # PETSc C++ compiler
        PCXX = PCC if self.language == 'c++' else self.get('CXX', cxx)
        # PETSc linker
        PLD = self['PCC_LINKER']
        PLD_FLAGS = get_flags(ld) + ' ' + self['PCC_LINKER_FLAGS']
        PLD_FLAGS = PLD_FLAGS.replace('-fvisibility=hidden', '')
        PLD = getenv('PLD', PLD) + ' ' + getenv('PLDFLAGS', PLD_FLAGS)
        PLD_SHARED = str.join(' ', (PLD, ldshared, ldflags))
        #
        compiler.set_executables(
            compiler=PCC,
            compiler_cxx=PCXX,
            linker_exe=PLD,
            compiler_so=PCC_SHARED,
            linker_so=PLD_SHARED,
        )
        compiler.shared_lib_extension = so_ext

    def log_info(self):
        PETSC_DIR = self['PETSC_DIR']
        PETSC_ARCH = self['PETSC_ARCH']
        version = '.'.join([str(i) for i in self.version[0]])
        release = ('development', 'release')[self.version[1]]
        version_info = version + ' ' + release
        integer_size = '%s-bit' % self['PETSC_INDEX_SIZE']
        scalar_type = self['PETSC_SCALAR']
        precision = self['PETSC_PRECISION']
        language = self['PETSC_LANGUAGE']
        compiler = self['PCC']
        linker = self['PCC_LINKER']
        log.info('PETSC_DIR:    %s' % PETSC_DIR)
        log.info('PETSC_ARCH:   %s' % PETSC_ARCH)
        log.info('version:      %s' % version_info)
        log.info('integer-size: %s' % integer_size)
        log.info('scalar-type:  %s' % scalar_type)
        log.info('precision:    %s' % precision)
        log.info('language:     %s' % language)
        log.info('compiler:     %s' % compiler)
        log.info('linker:       %s' % linker)


# --------------------------------------------------------------------


class Extension(_Extension):
    pass


# --------------------------------------------------------------------

cmd_petsc_opts = [
    ('petsc-dir=', None, 'define PETSC_DIR, overriding environmental variables'),
    ('petsc-arch=', None, 'define PETSC_ARCH, overriding environmental variables'),
]


class config(_config):
    Configure = PetscConfig

    user_options = _config.user_options + cmd_petsc_opts

    def initialize_options(self):
        _config.initialize_options(self)
        self.petsc_dir = None
        self.petsc_arch = None

    def get_config_arch(self, arch):
        return config.Configure(self.petsc_dir, arch)

    def run(self):
        _config.run(self)
        self.petsc_dir = config.get_petsc_dir(self.petsc_dir)
        if self.petsc_dir is None:
            return
        petsc_arch = config.get_petsc_arch(self.petsc_dir, self.petsc_arch)
        log.info('-' * 70)
        log.info('PETSC_DIR:   %s' % self.petsc_dir)
        arch_list = petsc_arch
        if not arch_list:
            arch_list = [None]
        for arch in arch_list:
            conf = self.get_config_arch(arch)
            archname = conf.PETSC_ARCH or conf['PETSC_ARCH']
            scalar_type = conf['PETSC_SCALAR']
            precision = conf['PETSC_PRECISION']
            language = conf['PETSC_LANGUAGE']
            compiler = conf['PCC']
            linker = conf['PCC_LINKER']
            log.info('-' * 70)
            log.info('PETSC_ARCH:  %s' % archname)
            log.info(' * scalar-type: %s' % scalar_type)
            log.info(' * precision:   %s' % precision)
            log.info(' * language:    %s' % language)
            log.info(' * compiler:    %s' % compiler)
            log.info(' * linker:      %s' % linker)
        log.info('-' * 70)

    # @staticmethod
    def get_petsc_dir(petsc_dir):
        if not petsc_dir:
            return None
        petsc_dir = os.path.expandvars(petsc_dir)
        if not petsc_dir or '$PETSC_DIR' in petsc_dir:
            try:
                import petsc

                petsc_dir = petsc.get_petsc_dir()
            except ImportError:
                log.warn('PETSC_DIR not specified')
                return None
        petsc_dir = os.path.expanduser(petsc_dir)
        petsc_dir = os.path.abspath(petsc_dir)
        return config.chk_petsc_dir(petsc_dir)

    get_petsc_dir = staticmethod(get_petsc_dir)

    # @staticmethod
    def chk_petsc_dir(petsc_dir):
        if not os.path.isdir(petsc_dir):
            log.error('invalid PETSC_DIR: %s (ignored)' % petsc_dir)
            return None
        return petsc_dir

    chk_petsc_dir = staticmethod(chk_petsc_dir)

    # @staticmethod
    def get_petsc_arch(petsc_dir, petsc_arch):
        if not petsc_dir:
            return None
        petsc_arch = os.path.expandvars(petsc_arch)
        if not petsc_arch or '$PETSC_ARCH' in petsc_arch:
            petsc_arch = ''
            petsc_conf = os.path.join(petsc_dir, 'lib', 'petsc', 'conf')
            if os.path.isdir(petsc_conf):
                petscvariables = os.path.join(petsc_conf, 'petscvariables')
                if os.path.exists(petscvariables):
                    conf = makefile(open(petscvariables, 'rt'))
                    petsc_arch = conf.get('PETSC_ARCH', '')
        petsc_arch = petsc_arch.split(os.pathsep)
        petsc_arch = unique(petsc_arch)
        petsc_arch = [arch for arch in petsc_arch if arch]
        return config.chk_petsc_arch(petsc_dir, petsc_arch)

    get_petsc_arch = staticmethod(get_petsc_arch)

    # @staticmethod
    def chk_petsc_arch(petsc_dir, petsc_arch):
        valid_archs = []
        for arch in petsc_arch:
            arch_path = os.path.join(petsc_dir, arch)
            if os.path.isdir(arch_path):
                valid_archs.append(arch)
            else:
                log.warn('invalid PETSC_ARCH: %s (ignored)' % arch)
        return valid_archs

    chk_petsc_arch = staticmethod(chk_petsc_arch)


class build(_build):
    user_options = _build.user_options
    user_options += [
        (
            'inplace',
            'i',
            'ignore build-lib and put compiled extensions into the source '
            'directory alongside your pure Python modules',
        )
    ]
    user_options += cmd_petsc_opts

    boolean_options = _build.boolean_options
    boolean_options += ['inplace']

    def initialize_options(self):
        _build.initialize_options(self)
        self.inplace = None
        self.petsc_dir = None
        self.petsc_arch = None

    def finalize_options(self):
        _build.finalize_options(self)
        if self.inplace is None:
            self.inplace = False
        self.set_undefined_options(
            'config', ('petsc_dir', 'petsc_dir'), ('petsc_arch', 'petsc_arch')
        )
        self.petsc_dir = config.get_petsc_dir(self.petsc_dir)
        self.petsc_arch = config.get_petsc_arch(self.petsc_dir, self.petsc_arch)

    sub_commands = [('build_src', lambda *args: True)] + _build.sub_commands


class build_src(Command):
    description = 'build C sources from Cython files'

    user_options = [
        ('force', 'f', 'forcibly build everything (ignore file timestamps)'),
    ]

    boolean_options = ['force']

    def initialize_options(self):
        self.force = False

    def finalize_options(self):
        self.set_undefined_options(
            'build',
            ('force', 'force'),
        )

    def run(self):
        sources = getattr(self, 'sources', [])
        for source in sources:
            cython_run(force=self.force, VERSION=cython_req(), **source)


class build_ext(_build_ext):
    user_options = _build_ext.user_options + cmd_petsc_opts

    def initialize_options(self):
        _build_ext.initialize_options(self)
        self.inplace = None
        self.petsc_dir = None
        self.petsc_arch = None
        self._outputs = []

    def finalize_options(self):
        _build_ext.finalize_options(self)
        self.set_undefined_options('build', ('inplace', 'inplace'))
        self.set_undefined_options(
            'build', ('petsc_dir', 'petsc_dir'), ('petsc_arch', 'petsc_arch')
        )

    def _copy_ext(self, ext):
        extclass = ext.__class__
        fullname = self.get_ext_fullname(ext.name)
        modpath = str.split(fullname, '.')
        pkgpath = os.path.join('', *modpath[0:-1])
        name = modpath[-1]
        sources = list(ext.sources)
        newext = extclass(name, sources)
        newext.__dict__.update(copy.deepcopy(ext.__dict__))
        newext.name = name
        return pkgpath, newext

    def _build_ext_arch(self, ext, pkgpath, arch):
        build_temp = self.build_temp
        build_lib = self.build_lib
        try:
            self.build_temp = os.path.join(build_temp, arch)
            self.build_lib = os.path.join(build_lib, pkgpath, arch)
            _build_ext.build_extension(self, ext)
        finally:
            self.build_temp = build_temp
            self.build_lib = build_lib

    def get_config_arch(self, arch):
        return config.Configure(self.petsc_dir, arch)

    def build_extension(self, ext):
        if not isinstance(ext, Extension):
            return _build_ext.build_extension(self, ext)
        petsc_arch = self.petsc_arch
        if not petsc_arch:
            petsc_arch = [None]
        for arch in petsc_arch:
            config = self.get_config_arch(arch)
            ARCH = arch or config['PETSC_ARCH']
            if ARCH not in self.PETSC_ARCH_LIST:
                self.PETSC_ARCH_LIST.append(ARCH)
            self.DESTDIR = config.DESTDIR
            ext.language = config.language
            config.log_info()
            pkgpath, newext = self._copy_ext(ext)
            config.configure(newext, self.compiler)
            self._build_ext_arch(newext, pkgpath, ARCH)
        return None

    def run(self):
        self.build_sources()
        _build_ext.run(self)
        self.build_stubs()

    def build_sources(self):
        if 'build_src' in self.distribution.cmdclass:
            self.run_command('build_src')

    def build_stubs(self):
        pkgname = self.distribution.get_name()
        modname = self.extensions[0].name.split(".")[-1]
        srcdir = Path(__file__).parent.parent / 'src' / pkgname
        blddir = Path(self.build_lib) / pkgname

        alldeps = glob.glob(str(blddir / 'lib' / '*' / f'{modname}.*'))
        target =  srcdir / f'{modname}.pyi'
        if not (self.force or modified.newer_group(alldeps, target)):
            log.debug(f"skipping '{modname}.*.so' -> '{target}' (up-to-date)")
            return

        env = os.environ.copy()
        python_path = env.get('PYTHONPATH', "")
        if python_path != "":
            python_path += ":"
        python_path += self.build_lib
        env['PYTHONPATH'] = python_path
        env.pop('PETSC_ARCH', None)

        stubgen = Path(__file__).parent / 'stubgen.py'
        rc = subprocess.call([sys.executable, stubgen], env=env) # noqa S603
        if rc != 0:
            log.warn("Stubs could not be generated.")
            return

        self.copy_file(
            srcdir / f'{modname}.pyi',
            blddir / f'{modname}.pyi',
            level=self.verbose,
        )

    def build_extensions(self, *args, **kargs):
        self.PETSC_ARCH_LIST = []
        _build_ext.build_extensions(self, *args, **kargs)
        if not self.PETSC_ARCH_LIST:
            return
        self.build_configuration(self.PETSC_ARCH_LIST)

    def build_configuration(self, arch_list):
        #
        template, variables = self.get_config_data(arch_list)
        config_data = template % variables
        #
        build_lib = self.build_lib
        dist_name = self.distribution.get_name()
        config_file = os.path.join(
            build_lib, dist_name, 'lib', dist_name.replace('4py', '') + '.cfg'
        )

        #
        def write_file(filename, data):
            with open(filename, 'w') as fh:
                fh.write(config_data)

        execute(
            write_file,
            (config_file, config_data),
            msg='writing %s' % config_file,
            verbose=self.verbose,
            dry_run=self.dry_run,
        )

    def get_config_data(self, arch_list):
        DESTDIR = self.DESTDIR
        template = (
            '\n'.join(
                [
                    'PETSC_DIR  = %(PETSC_DIR)s',
                    'PETSC_ARCH = %(PETSC_ARCH)s',
                ]
            )
            + '\n'
        )
        variables = {
            'PETSC_DIR': strip_prefix(DESTDIR, self.petsc_dir),
            'PETSC_ARCH': os.path.pathsep.join(arch_list),
        }
        return template, variables

    def copy_extensions_to_source(self):
        build_py = self.get_finalized_command('build_py')
        for ext in self.extensions:
            inp_file, reg_file = self._get_inplace_equivalent(build_py, ext)

            arch_list = ['']
            if isinstance(ext, Extension) and self.petsc_arch:
                arch_list = self.petsc_arch[:]

            file_pairs = []
            inp_head, inp_tail = os.path.split(inp_file)
            reg_head, reg_tail = os.path.split(reg_file)
            for arch in arch_list:
                inp_file = os.path.join(inp_head, arch, inp_tail)
                reg_file = os.path.join(reg_head, arch, reg_tail)
                file_pairs.append((inp_file, reg_file))

            for inp_file, reg_file in file_pairs:
                if os.path.exists(reg_file) or not ext.optional:
                    dest_dir, _ = os.path.split(inp_file)
                    self.mkpath(dest_dir)
                    self.copy_file(reg_file, inp_file, level=self.verbose)

    def get_outputs(self):
        self.check_extensions_list(self.extensions)
        outputs = []
        for ext in self.extensions:
            fullname = self.get_ext_fullname(ext.name)
            filename = self.get_ext_filename(fullname)
            if isinstance(ext, Extension) and self.petsc_arch:
                head, tail = os.path.split(filename)
                for arch in self.petsc_arch:
                    outfile = os.path.join(self.build_lib, head, arch, tail)
                    outputs.append(outfile)
            else:
                outfile = os.path.join(self.build_lib, filename)
                outputs.append(outfile)

        pkgname = self.distribution.get_name()
        modname = self.extensions[0].name.split(".")[-1]
        outputs.append(os.path.join(self.build_lib, pkgname, f"{modname}.pyi"))
        return list(set(outputs))

    def get_source_files(self):
        orig = log.set_threshold(log.WARN)
        try:
            return super().get_source_files()
        finally:
            log.set_threshold(orig)


class install(_install):
    def initialize_options(self):
        with warnings.catch_warnings():
            if setuptools:
                if hasattr(setuptools, 'SetuptoolsDeprecationWarning'):
                    category = setuptools.SetuptoolsDeprecationWarning
                    warnings.simplefilter('ignore', category)
            _install.initialize_options(self)
        self.old_and_unmanageable = True


cmdclass_list = [
    config,
    build,
    build_src,
    build_ext,
    install,
]

# --------------------------------------------------------------------


def setup(**attrs):
    cmdclass = attrs.setdefault('cmdclass', {})
    for cmd in cmdclass_list:
        cmdclass.setdefault(cmd.__name__, cmd)
    build_src.sources = attrs.pop('cython_sources', None)
    use_setup_requires = False  # handle Cython requirement ourselves
    if setuptools and build_src.sources and use_setup_requires:
        version = cython_req()
        if not cython_chk(version, verbose=False):
            reqs = attrs.setdefault('setup_requires', [])
            reqs += ['Cython>=' + version]
    return _setup(**attrs)


# --------------------------------------------------------------------

if setuptools:
    try:
        from setuptools.command import egg_info as mod_egg_info

        _FileList = mod_egg_info.FileList

        class FileList(_FileList):
            def process_template_line(self, line):
                level = log.set_threshold(log.ERROR)
                try:
                    _FileList.process_template_line(self, line)
                finally:
                    log.set_threshold(level)

        mod_egg_info.FileList = FileList
    except (ImportError, AttributeError):
        pass

# --------------------------------------------------------------------


def append(seq, item):
    if item not in seq:
        seq.append(item)


def append_dict(conf, dct):
    for key, values in dct.items():
        if key in conf:
            for value in values:
                if value not in conf[key]:
                    conf[key].append(value)


def unique(seq):
    res = []
    for item in seq:
        if item not in res:
            res.append(item)
    return res


def flaglist(flags):
    conf = {
        'define_macros': [],
        'undef_macros': [],
        'include_dirs': [],
        'libraries': [],
        'library_dirs': [],
        'runtime_library_dirs': [],
        'extra_compile_args': [],
        'extra_link_args': [],
    }

    if isinstance(flags, str):
        flags = flags.split()

    switch = '-Wl,'
    newflags = []
    linkopts = []
    for f in flags:
        if f.startswith(switch):
            if len(f) > 4:
                append(linkopts, f[4:])
        else:
            append(newflags, f)
    if linkopts:
        newflags.append(switch + ','.join(linkopts))
    flags = newflags

    append_next_word = None

    for word in flags:
        if append_next_word is not None:
            append(append_next_word, word)
            append_next_word = None
            continue

        switch, value = word[0:2], word[2:]

        if switch == '-I':
            append(conf['include_dirs'], value)
        elif switch == '-D':
            try:
                idx = value.index('=')
                macro = (value[:idx], value[idx + 1 :])
            except ValueError:
                macro = (value, None)
            append(conf['define_macros'], macro)
        elif switch == '-U':
            append(conf['undef_macros'], value)
        elif switch == '-l':
            append(conf['libraries'], value)
        elif switch == '-L':
            append(conf['library_dirs'], value)
        elif switch == '-R':
            append(conf['runtime_library_dirs'], value)
        elif word.startswith('-Wl'):
            linkopts = word.split(',')
            append_dict(conf, flaglist(linkopts[1:]))
        elif word == '-rpath':
            append_next_word = conf['runtime_library_dirs']
        elif word == '-Xlinker':
            append_next_word = conf['extra_link_args']
        else:
            # log.warn("unrecognized flag '%s'" % word)
            pass
    return conf


def prepend_to_flags(path, flags):
    """Prepend a path to compiler flags with absolute paths"""
    if not path:
        return flags

    def append_path(m):
        switch = m.group(1)
        open_quote = m.group(4)
        old_path = m.group(5)
        close_quote = m.group(6)
        if os.path.isabs(old_path):
            moded_path = os.path.normpath(path + os.path.sep + old_path)
            return switch + open_quote + moded_path + close_quote
        return m.group(0)

    return re.sub(r'((^|\s+)(-I|-L))(\s*["\']?)(\S+)(["\']?)', append_path, flags)


def strip_prefix(prefix, string):
    if not prefix:
        return string
    return re.sub(r'^' + prefix, '', string)


# --------------------------------------------------------------------

# Regexes needed for parsing Makefile-like syntaxes
_variable_rx = re.compile(r'([a-zA-Z][a-zA-Z0-9_]+)\s*=\s*(.*)')
_findvar1_rx = re.compile(r'\$\(([A-Za-z][A-Za-z0-9_]*)\)')
_findvar2_rx = re.compile(r'\${([A-Za-z][A-Za-z0-9_]*)}')


def makefile(fileobj, dct=None):
    """Parse a Makefile-style file.

    A dictionary containing name/value pairs is returned.  If an
    optional dictionary is passed in as the second argument, it is
    used instead of a new dictionary.
    """
    fp = TextFile(file=fileobj, strip_comments=1, skip_blanks=1, join_lines=1)

    if dct is None:
        dct = {}
    done = {}
    notdone = {}

    while 1:
        line = fp.readline()
        if line is None:  # eof
            break
        m = _variable_rx.match(line)
        if m:
            n, v = m.group(1, 2)
            v = str.strip(v)
            if '$' in v:
                notdone[n] = v
            else:
                try:
                    v = int(v)
                except ValueError:
                    pass
                done[n] = v
                try:
                    del notdone[n]
                except KeyError:
                    pass
    fp.close()

    # do variable interpolation here
    while notdone:
        for name in list(notdone.keys()):
            value = notdone[name]
            m = _findvar1_rx.search(value) or _findvar2_rx.search(value)
            if m:
                n = m.group(1)
                found = True
                if n in done:
                    item = str(done[n])
                elif n in notdone:
                    # get it on a subsequent round
                    found = False
                else:
                    done[n] = item = ''
                if found:
                    after = value[m.end() :]
                    value = value[: m.start()] + item + after
                    if '$' in after:
                        notdone[name] = value
                    else:
                        try:
                            value = int(value)
                        except ValueError:
                            done[name] = str.strip(value)
                        else:
                            done[name] = value
                        del notdone[name]
            else:
                # bogus variable reference;
                # just drop it since we can't deal
                del notdone[name]
    # save the results in the global dictionary
    dct.update(done)
    return dct


# --------------------------------------------------------------------
