# SPDX-License-Identifier: Apache-2.0
# Copyright 2013-2021 The Meson development team

from __future__ import annotations

from pathlib import Path

from .base import ExternalDependency, DependencyException, sort_libpaths, DependencyTypeName
from ..mesonlib import (EnvironmentVariables, OrderedSet, PerMachine, Popen_safe, Popen_safe_logged, MachineChoice,
                        join_args, MesonException)
from ..options import OptionKey
from ..programs import find_external_program, ExternalProgram
from .. import mlog
from pathlib import PurePath
from functools import lru_cache
import re
import os
import shlex
import typing as T

if T.TYPE_CHECKING:
    from typing_extensions import Literal
    from .._typing import ImmutableListProtocol

    from ..environment import Environment
    from ..utils.core import EnvironOrDict
    from ..interpreter.type_checking import PkgConfigDefineType

class PkgConfigInterface:
    '''Base class wrapping a pkg-config implementation'''

    class_impl: PerMachine[T.Union[Literal[False], T.Optional[PkgConfigInterface]]] = PerMachine(False, False)
    class_cli_impl: PerMachine[T.Union[Literal[False], T.Optional[PkgConfigCLI]]] = PerMachine(False, False)
    pkg_bin_per_machine: PerMachine[T.Optional[ExternalProgram]] = PerMachine(None, None)

    @staticmethod
    def set_program_override(pkg_bin: ExternalProgram, for_machine: MachineChoice) -> None:
        if PkgConfigInterface.class_impl[for_machine]:
            raise MesonException(f'Tried to override pkg-config for machine {for_machine} but it was already initialized.\n'
                                 'pkg-config must be overridden before it\'s used.')
        PkgConfigInterface.pkg_bin_per_machine[for_machine] = pkg_bin

    @staticmethod
    def instance(env: Environment, for_machine: MachineChoice, silent: bool) -> T.Optional[PkgConfigInterface]:
        '''Return a pkg-config implementation singleton'''
        for_machine = for_machine if env.is_cross_build() else MachineChoice.HOST
        impl = PkgConfigInterface.class_impl[for_machine]
        if impl is False:
            impl = PkgConfigCLI(env, for_machine, silent, PkgConfigInterface.pkg_bin_per_machine[for_machine])
            if not impl.found():
                impl = None
            if not impl and not silent:
                mlog.log('Found pkg-config:', mlog.red('NO'))
            PkgConfigInterface.class_impl[for_machine] = impl
        return impl

    @staticmethod
    def _cli(env: Environment, for_machine: MachineChoice, silent: bool = False) -> T.Optional[PkgConfigCLI]:
        '''Return the CLI pkg-config implementation singleton
        Even when we use another implementation internally, external tools might
        still need the CLI implementation.
        '''
        for_machine = for_machine if env.is_cross_build() else MachineChoice.HOST
        impl: T.Union[Literal[False], T.Optional[PkgConfigInterface]] # Help confused mypy
        impl = PkgConfigInterface.instance(env, for_machine, silent)
        if impl and not isinstance(impl, PkgConfigCLI):
            impl = PkgConfigInterface.class_cli_impl[for_machine]
            if impl is False:
                impl = PkgConfigCLI(env, for_machine, silent, PkgConfigInterface.pkg_bin_per_machine[for_machine])
                if not impl.found():
                    impl = None
                PkgConfigInterface.class_cli_impl[for_machine] = impl
        return T.cast('T.Optional[PkgConfigCLI]', impl) # Trust me, mypy

    @staticmethod
    def get_env(env: Environment, for_machine: MachineChoice, uninstalled: bool = False) -> EnvironmentVariables:
        cli = PkgConfigInterface._cli(env, for_machine)
        return cli._get_env(uninstalled) if cli else EnvironmentVariables()

    @staticmethod
    def setup_env(environ: EnvironOrDict, env: Environment, for_machine: MachineChoice,
                  uninstalled: bool = False) -> EnvironOrDict:
        cli = PkgConfigInterface._cli(env, for_machine)
        return cli._setup_env(environ, uninstalled) if cli else environ

    def __init__(self, env: Environment, for_machine: MachineChoice) -> None:
        self.env = env
        self.for_machine = for_machine

    def found(self) -> bool:
        '''Return whether pkg-config is supported'''
        raise NotImplementedError

    def version(self, name: str) -> T.Optional[str]:
        '''Return module version or None if not found'''
        raise NotImplementedError

    def cflags(self, name: str, allow_system: bool = False,
               define_variable: PkgConfigDefineType = None) -> ImmutableListProtocol[str]:
        '''Return module cflags
           @allow_system: If False, remove default system include paths
        '''
        raise NotImplementedError

    def libs(self, name: str, static: bool = False, allow_system: bool = False,
             define_variable: PkgConfigDefineType = None) -> ImmutableListProtocol[str]:
        '''Return module libs
           @static: If True, also include private libraries
           @allow_system: If False, remove default system libraries search paths
        '''
        raise NotImplementedError

    def variable(self, name: str, variable_name: str,
                 define_variable: PkgConfigDefineType) -> T.Optional[str]:
        '''Return module variable or None if variable is not defined'''
        raise NotImplementedError

    def list_all(self) -> ImmutableListProtocol[str]:
        '''Return all available pkg-config modules'''
        raise NotImplementedError

class PkgConfigCLI(PkgConfigInterface):
    '''pkg-config CLI implementation'''

    def __init__(self, env: Environment, for_machine: MachineChoice, silent: bool,
                 pkgbin: T.Optional[ExternalProgram] = None) -> None:
        super().__init__(env, for_machine)
        self._detect_pkgbin(pkgbin)
        if self.pkgbin and not silent:
            mlog.log('Found pkg-config:', mlog.green('YES'), mlog.bold(f'({self.pkgbin.get_path()})'), mlog.blue(self.pkgbin_version))

    def found(self) -> bool:
        return bool(self.pkgbin)

    @lru_cache(maxsize=None)
    def version(self, name: str) -> T.Optional[str]:
        mlog.debug(f'Determining dependency {name!r} with pkg-config executable {self.pkgbin.get_path()!r}')
        ret, version, _ = self._call_pkgbin(['--modversion', name])
        return version if ret == 0 else None

    @staticmethod
    def _define_variable_args(define_variable: PkgConfigDefineType) -> T.List[str]:
        ret = []
        if define_variable:
            for pair in define_variable:
                ret.append('--define-variable=' + '='.join(pair))
        return ret

    @lru_cache(maxsize=None)
    def cflags(self, name: str, allow_system: bool = False,
               define_variable: PkgConfigDefineType = None) -> ImmutableListProtocol[str]:
        env = None
        if allow_system:
            env = os.environ.copy()
            env['PKG_CONFIG_ALLOW_SYSTEM_CFLAGS'] = '1'
        args: T.List[str] = []
        args += self._define_variable_args(define_variable)
        args += ['--cflags', name]
        ret, out, err = self._call_pkgbin(args, env=env)
        if ret != 0:
            raise DependencyException(f'Could not generate cflags for {name}:\n{err}\n')
        return self._split_args(out)

    @lru_cache(maxsize=None)
    def libs(self, name: str, static: bool = False, allow_system: bool = False,
             define_variable: PkgConfigDefineType = None) -> ImmutableListProtocol[str]:
        env = None
        if allow_system:
            env = os.environ.copy()
            env['PKG_CONFIG_ALLOW_SYSTEM_LIBS'] = '1'
        args: T.List[str] = []
        args += self._define_variable_args(define_variable)
        if static:
            args.append('--static')
        args += ['--libs', name]
        ret, out, err = self._call_pkgbin(args, env=env)
        if ret != 0:
            raise DependencyException(f'Could not generate libs for {name}:\n{err}\n')
        return self._split_args(out)

    @lru_cache(maxsize=None)
    def variable(self, name: str, variable_name: str,
                 define_variable: PkgConfigDefineType) -> T.Optional[str]:
        args: T.List[str] = []
        args += self._define_variable_args(define_variable)
        args += ['--variable=' + variable_name, name]
        ret, out, err = self._call_pkgbin(args)
        if ret != 0:
            raise DependencyException(f'Could not get variable for {name}:\n{err}\n')
        variable = out.strip()
        # pkg-config doesn't distinguish between empty and nonexistent variables
        # use the variable list to check for variable existence
        if not variable:
            ret, out, _ = self._call_pkgbin(['--print-variables', name])
            if not re.search(rf'^{variable_name}$', out, re.MULTILINE):
                return None
        mlog.debug(f'Got pkg-config variable {variable_name} : {variable}')
        return variable

    @lru_cache(maxsize=None)
    def list_all(self) -> ImmutableListProtocol[str]:
        ret, out, err = self._call_pkgbin(['--list-all'])
        if ret != 0:
            raise DependencyException(f'could not list modules:\n{err}\n')
        return [i.split(' ', 1)[0] for i in out.splitlines()]

    @staticmethod
    def _split_args(cmd: str) -> T.List[str]:
        # pkg-config paths follow Unix conventions, even on Windows; split the
        # output using shlex.split rather than mesonlib.split_args
        return shlex.split(cmd)

    def _detect_pkgbin(self, pkgbin: T.Optional[ExternalProgram] = None) -> None:
        def validate(potential_pkgbin: ExternalProgram) -> bool:
            version_if_ok = self._check_pkgconfig(potential_pkgbin)
            if version_if_ok:
                self.pkgbin = potential_pkgbin
                self.pkgbin_version = version_if_ok
                return True
            return False

        if pkgbin and validate(pkgbin):
            return

        for potential_pkgbin in find_external_program(self.env, self.for_machine, "pkg-config", "Pkg-config",
                                                      self.env.default_pkgconfig, allow_default_for_cross=False):
            if validate(potential_pkgbin):
                return
        self.pkgbin = None

    def _check_pkgconfig(self, pkgbin: ExternalProgram) -> T.Optional[str]:
        if not pkgbin.found():
            mlog.log(f'Did not find pkg-config by name {pkgbin.name!r}')
            return None
        command_as_string = ' '.join(pkgbin.get_command())
        try:
            helptext = Popen_safe(pkgbin.get_command() + ['--help'])[1]
            if 'Pure-Perl' in helptext:
                mlog.log(f'Found pkg-config {command_as_string!r} but it is Strawberry Perl and thus broken. Ignoring...')
                return None
            p, out = Popen_safe(pkgbin.get_command() + ['--version'])[0:2]
            if p.returncode != 0:
                mlog.warning(f'Found pkg-config {command_as_string!r} but it failed when ran')
                return None
        except FileNotFoundError:
            mlog.warning(f'We thought we found pkg-config {command_as_string!r} but now it\'s not there. How odd!')
            return None
        except PermissionError:
            msg = f'Found pkg-config {command_as_string!r} but didn\'t have permissions to run it.'
            if not self.env.machines.build.is_windows():
                msg += '\n\nOn Unix-like systems this is often caused by scripts that are not executable.'
            mlog.warning(msg)
            return None
        return out.strip()

    def _get_env(self, uninstalled: bool = False) -> EnvironmentVariables:
        env = EnvironmentVariables()
        key = OptionKey('pkg_config_path', machine=self.for_machine)
        pathlist = self.env.coredata.optstore.get_value_for(key)
        assert isinstance(pathlist, list)
        extra_paths: T.List[str] = pathlist[:]
        if uninstalled:
            bpath = self.env.get_build_dir()
            if bpath is not None:
                # uninstalled can only be used if a build dir exists.
                uninstalled_path = Path(bpath, 'meson-uninstalled').as_posix()
                if uninstalled_path not in extra_paths:
                    extra_paths.insert(0, uninstalled_path)
        env.set('PKG_CONFIG_PATH', extra_paths)
        sysroot = self.env.properties[self.for_machine].get_sys_root()
        if sysroot:
            env.set('PKG_CONFIG_SYSROOT_DIR', [sysroot])
        pkg_config_libdir_prop = self.env.properties[self.for_machine].get_pkg_config_libdir()
        if pkg_config_libdir_prop:
            env.set('PKG_CONFIG_LIBDIR', pkg_config_libdir_prop)
        env.set('PKG_CONFIG', [join_args(self.pkgbin.get_command())])
        return env

    def _setup_env(self, env: EnvironOrDict, uninstalled: bool = False) -> T.Dict[str, str]:
        envvars = self._get_env(uninstalled)
        env = envvars.get_env(env)
        # Dump all PKG_CONFIG environment variables
        for key, value in env.items():
            if key.startswith('PKG_'):
                mlog.debug(f'env[{key}]: {value}')
        return env

    def _call_pkgbin(self, args: T.List[str], env: T.Optional[EnvironOrDict] = None) -> T.Tuple[int, str, str]:
        assert isinstance(self.pkgbin, ExternalProgram)
        env = env or os.environ
        env = self._setup_env(env)
        cmd = self.pkgbin.get_command() + args
        p, out, err = Popen_safe_logged(cmd, env=env)
        return p.returncode, out.strip(), err.strip()


class PkgConfigDependency(ExternalDependency):

    def __init__(self, name: str, environment: Environment, kwargs: T.Dict[str, T.Any],
                 language: T.Optional[str] = None) -> None:
        super().__init__(DependencyTypeName('pkgconfig'), environment, kwargs, language=language)
        self.name = name
        self.is_libtool = False
        pkgconfig = PkgConfigInterface.instance(self.env, self.for_machine, self.silent)
        if not pkgconfig:
            msg = f'Pkg-config for machine {self.for_machine} not found. Giving up.'
            if self.required:
                raise DependencyException(msg)
            mlog.debug(msg)
            return
        self.pkgconfig = pkgconfig

        version = self.pkgconfig.version(name)
        if version is None:
            return

        self.version = version
        self.is_found = True

        try:
            # Fetch cargs to be used while using this dependency
            self._set_cargs()
            # Fetch the libraries and library paths needed for using this
            self._set_libs()
        except DependencyException as e:
            mlog.debug(f"Pkg-config error with '{name}': {e}")
            if self.required:
                raise
            else:
                self.compile_args = []
                self.link_args = []
                self.is_found = False
                self.reason = e

    def __repr__(self) -> str:
        s = '<{0} {1}: {2} {3}>'
        return s.format(self.__class__.__name__, self.name, self.is_found,
                        self.version_reqs)

    def _convert_mingw_paths(self, args: ImmutableListProtocol[str]) -> T.List[str]:
        '''
        Both MSVC and native Python on Windows cannot handle MinGW-esque /c/foo
        paths so convert them to C:/foo. We cannot resolve other paths starting
        with / like /home/foo so leave them as-is so that the user gets an
        error/warning from the compiler/linker.
        '''
        if not self.env.machines.build.is_windows():
            return args.copy()
        converted = []
        for arg in args:
            pargs: T.Tuple[str, ...] = tuple()
            # Library search path
            if arg.startswith('-L/'):
                pargs = PurePath(arg[2:]).parts
                tmpl = '-L{}:/{}'
            elif arg.startswith('-I/'):
                pargs = PurePath(arg[2:]).parts
                tmpl = '-I{}:/{}'
            # Full path to library or .la file
            elif arg.startswith('/'):
                pargs = PurePath(arg).parts
                tmpl = '{}:/{}'
            elif arg.startswith(('-L', '-I')) or (len(arg) > 2 and arg[1] == ':'):
                # clean out improper '\\ ' as comes from some Windows pkg-config files
                arg = arg.replace('\\ ', ' ')
            if len(pargs) > 1 and len(pargs[1]) == 1:
                arg = tmpl.format(pargs[1], '/'.join(pargs[2:]))
            converted.append(arg)
        return converted

    def _set_cargs(self) -> None:
        allow_system = False
        if self.language == 'fortran':
            # gfortran doesn't appear to look in system paths for INCLUDE files,
            # so don't allow pkg-config to suppress -I flags for system paths
            allow_system = True
        cflags = self.pkgconfig.cflags(self.name, allow_system)
        self.compile_args = self._convert_mingw_paths(cflags)

    def _search_libs(self, libs_in: ImmutableListProtocol[str], raw_libs_in: ImmutableListProtocol[str]) -> T.Tuple[T.List[str], T.List[str]]:
        '''
        @libs_in: PKG_CONFIG_ALLOW_SYSTEM_LIBS=1 pkg-config --libs
        @raw_libs_in: pkg-config --libs

        We always look for the file ourselves instead of depending on the
        compiler to find it with -lfoo or foo.lib (if possible) because:
        1. We want to be able to select static or shared
        2. We need the full path of the library to calculate RPATH values
        3. De-dup of libraries is easier when we have absolute paths

        Libraries that are provided by the toolchain or are not found by
        find_library() will be added with -L -l pairs.
        '''
        # Library paths should be safe to de-dup
        #
        # First, figure out what library paths to use. Originally, we were
        # doing this as part of the loop, but due to differences in the order
        # of -L values between pkg-config and pkgconf, we need to do that as
        # a separate step. See:
        # https://github.com/mesonbuild/meson/issues/3951
        # https://github.com/mesonbuild/meson/issues/4023
        #
        # Separate system and prefix paths, and ensure that prefix paths are
        # always searched first.
        prefix_libpaths: OrderedSet[str] = OrderedSet()
        # We also store this raw_link_args on the object later
        raw_link_args = self._convert_mingw_paths(raw_libs_in)
        for arg in raw_link_args:
            if arg.startswith('-L') and not arg.startswith(('-L-l', '-L-L')):
                path = arg[2:]
                if not os.path.isabs(path):
                    # Resolve the path as a compiler in the build directory would
                    path = os.path.join(self.env.get_build_dir(), path)
                prefix_libpaths.add(path)
        # Library paths are not always ordered in a meaningful way
        #
        # Instead of relying on pkg-config or pkgconf to provide -L flags in a
        # specific order, we reorder library paths ourselves, according to th
        # order specified in PKG_CONFIG_PATH. See:
        # https://github.com/mesonbuild/meson/issues/4271
        #
        # Only prefix_libpaths are reordered here because there should not be
        # too many system_libpaths to cause library version issues.
        pkg_config_path: T.List[str] = self.env.coredata.optstore.get_value(OptionKey('pkg_config_path', machine=self.for_machine)) # type: ignore[assignment]
        pkg_config_path = self._convert_mingw_paths(pkg_config_path)
        prefix_libpaths = OrderedSet(sort_libpaths(list(prefix_libpaths), pkg_config_path))
        system_libpaths: OrderedSet[str] = OrderedSet()
        full_args = self._convert_mingw_paths(libs_in)
        for arg in full_args:
            if arg.startswith(('-L-l', '-L-L')):
                # These are D language arguments, not library paths
                continue
            if arg.startswith('-L') and arg[2:] not in prefix_libpaths:
                system_libpaths.add(arg[2:])
        # Use this re-ordered path list for library resolution
        libpaths = list(prefix_libpaths) + list(system_libpaths)
        # Track -lfoo libraries to avoid duplicate work
        libs_found: OrderedSet[str] = OrderedSet()
        # Track not-found libraries to know whether to add library paths
        libs_notfound = []
        # Generate link arguments for this library
        link_args = []
        for lib in full_args:
            if lib.startswith(('-L-l', '-L-L')):
                # These are D language arguments, add them as-is
                pass
            elif lib.startswith('-L'):
                # We already handled library paths above
                continue
            elif lib.startswith('-l:'):
                # see: https://stackoverflow.com/questions/48532868/gcc-library-option-with-a-colon-llibevent-a
                # also : See the documentation of -lnamespec | --library=namespec in the linker manual
                #                     https://sourceware.org/binutils/docs-2.18/ld/Options.html

                # Don't resolve the same -l:libfoo.a argument again
                if lib in libs_found:
                    continue
                libfilename = lib[3:]
                foundname = None
                for libdir in libpaths:
                    target = os.path.join(libdir, libfilename)
                    if os.path.exists(target):
                        foundname = target
                        break
                if foundname is None:
                    if lib in libs_notfound:
                        continue
                    else:
                        mlog.warning('Library {!r} not found for dependency {!r}, may '
                                     'not be successfully linked'.format(libfilename, self.name))
                    libs_notfound.append(lib)
                else:
                    lib = foundname
            elif lib.startswith('-l'):
                # Don't resolve the same -lfoo argument again
                if lib in libs_found:
                    continue
                if self.clib_compiler:
                    args = self.clib_compiler.find_library(lib[2:], self.env,
                                                           libpaths, self.libtype,
                                                           lib_prefix_warning=False)
                # If the project only uses a non-clib language such as D, Rust,
                # C#, Python, etc, all we can do is limp along by adding the
                # arguments as-is and then adding the libpaths at the end.
                else:
                    args = None
                if args is not None:
                    libs_found.add(lib)
                    # Replace -l arg with full path to library if available
                    # else, library is either to be ignored, or is provided by
                    # the compiler, can't be resolved, and should be used as-is
                    if args:
                        if not args[0].startswith('-l'):
                            lib = args[0]
                    else:
                        continue
                else:
                    # Library wasn't found, maybe we're looking in the wrong
                    # places or the library will be provided with LDFLAGS or
                    # LIBRARY_PATH from the environment (on macOS), and many
                    # other edge cases that we can't account for.
                    #
                    # Add all -L paths and use it as -lfoo
                    if lib in libs_notfound:
                        continue
                    if self.static:
                        mlog.warning('Static library {!r} not found for dependency {!r}, may '
                                     'not be statically linked'.format(lib[2:], self.name))
                    libs_notfound.append(lib)
            elif lib.endswith(".la"):
                shared_libname = self.extract_libtool_shlib(lib)
                shared_lib = os.path.join(os.path.dirname(lib), shared_libname)
                if not os.path.exists(shared_lib):
                    shared_lib = os.path.join(os.path.dirname(lib), ".libs", shared_libname)

                if not os.path.exists(shared_lib):
                    raise DependencyException(f'Got a libtools specific "{lib}" dependencies'
                                              'but we could not compute the actual shared'
                                              'library path')
                self.is_libtool = True
                lib = shared_lib
                if lib in link_args:
                    continue
            link_args.append(lib)
        # Add all -Lbar args if we have -lfoo args in link_args
        if libs_notfound:
            # Order of -L flags doesn't matter with ld, but it might with other
            # linkers such as MSVC, so prepend them.
            link_args = ['-L' + lp for lp in prefix_libpaths] + link_args
        return link_args, raw_link_args

    def _set_libs(self) -> None:
        # Force pkg-config to output -L fields even if they are system
        # paths so we can do manual searching with cc.find_library() later.
        libs = self.pkgconfig.libs(self.name, self.static, allow_system=True)
        # Also get the 'raw' output without -Lfoo system paths for adding -L
        # args with -lfoo when a library can't be found, and also in
        # gnome.generate_gir + gnome.gtkdoc which need -L -l arguments.
        raw_libs = self.pkgconfig.libs(self.name, self.static, allow_system=False)
        self.link_args, self.raw_link_args = self._search_libs(libs, raw_libs)

    def extract_field(self, la_file: str, fieldname: str) -> T.Optional[str]:
        with open(la_file, encoding='utf-8') as f:
            for line in f:
                arr = line.strip().split('=')
                if arr[0] == fieldname:
                    return arr[1][1:-1]
        return None

    def extract_dlname_field(self, la_file: str) -> T.Optional[str]:
        return self.extract_field(la_file, 'dlname')

    def extract_libdir_field(self, la_file: str) -> T.Optional[str]:
        return self.extract_field(la_file, 'libdir')

    def extract_libtool_shlib(self, la_file: str) -> T.Optional[str]:
        '''
        Returns the path to the shared library
        corresponding to this .la file
        '''
        dlname = self.extract_dlname_field(la_file)
        if dlname is None:
            return None

        # Darwin uses absolute paths where possible; since the libtool files never
        # contain absolute paths, use the libdir field
        if self.env.machines[self.for_machine].is_darwin():
            dlbasename = os.path.basename(dlname)
            libdir = self.extract_libdir_field(la_file)
            if libdir is None:
                return dlbasename
            return os.path.join(libdir, dlbasename)
        # From the comments in extract_libtool(), older libtools had
        # a path rather than the raw dlname
        return os.path.basename(dlname)

    @staticmethod
    def log_tried() -> str:
        return 'pkgconfig'

    def get_variable(self, *, cmake: T.Optional[str] = None, pkgconfig: T.Optional[str] = None,
                     configtool: T.Optional[str] = None, internal: T.Optional[str] = None,
                     system: T.Optional[str] = None, default_value: T.Optional[str] = None,
                     pkgconfig_define: PkgConfigDefineType = None) -> str:
        if pkgconfig:
            try:
                variable = self.pkgconfig.variable(self.name, pkgconfig, pkgconfig_define)
                if variable is not None:
                    return variable
            except DependencyException:
                pass
        if default_value is not None:
            return default_value
        raise DependencyException(f'Could not get pkg-config variable and no default provided for {self!r}')
