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

from __future__ import annotations

from .base import ExternalDependency, DependencyException, DependencyTypeName
from .pkgconfig import PkgConfigDependency
from ..mesonlib import (Popen_safe, join_args, version_compare, version_compare_many)
from ..options import OptionKey
from ..programs import ExternalProgram
from .. import mlog
from enum import Enum
import re
import os
import json
import typing as T

if T.TYPE_CHECKING:
    from typing_extensions import TypedDict

    from ..environment import Environment
    from .base import DependencyObjectKWs

    # Definition of what `dub describe` returns (only the fields used by Meson)
    class DubDescription(TypedDict):
        platform: T.List[str]
        architecture: T.List[str]
        buildType: str
        packages: T.List[DubPackDesc]
        targets: T.List[DubTargetDesc]

    class DubPackDesc(TypedDict):
        name: str
        version: str
        active: bool
        configuration: str
        path: str
        targetType: str
        targetFileName: str

    class DubTargetDesc(TypedDict):
        rootPackage: str
        linkDependencies: T.List[str]
        buildSettings: DubBuildSettings
        cacheArtifactPath: str

    class DubBuildSettings(TypedDict):
        importPaths: T.List[str]
        stringImportPaths: T.List[str]
        versions: T.List[str]
        mainSourceFile: str
        sourceFiles: T.List[str]
        dflags: T.List[str]
        libs: T.List[str]
        lflags: T.List[str]

    class FindTargetEntry(TypedDict):
        search: str
        artifactPath: str

class DubDescriptionSource(Enum):
    Local = 'local'
    External = 'external'

class DubDependency(ExternalDependency):
    # dub program and version
    class_dubbin: T.Optional[T.Tuple[ExternalProgram, str]] = None
    class_dubbin_searched = False
    class_cache_dir = ''

    # Map Meson Compiler ID's to Dub Compiler ID's
    _ID_MAP: T.Mapping[str, str] = {
        'dmd': 'dmd',
        'gcc': 'gdc',
        'llvm': 'ldc',
    }

    def __init__(self, name: str, environment: 'Environment', kwargs: DependencyObjectKWs):
        super().__init__(DependencyTypeName('dub'), environment, kwargs, language='d')
        self.name = name
        from ..compilers.d import DCompiler, d_feature_args

        _temp_comp = super().get_compiler()
        assert isinstance(_temp_comp, DCompiler)
        self.compiler = _temp_comp

        if kwargs.get('required') is not None:
            self.required = kwargs['required']

        if DubDependency.class_dubbin is None and not DubDependency.class_dubbin_searched:
            DubDependency.class_dubbin = self._check_dub()
            DubDependency.class_dubbin_searched = True
        if DubDependency.class_dubbin is None:
            if self.required:
                raise DependencyException('DUB not found.')
            return

        (self.dubbin, dubver) = DubDependency.class_dubbin  # pylint: disable=unpacking-non-sequence

        assert isinstance(self.dubbin, ExternalProgram)

        # Check Dub's compatibility with Meson
        self._search_in_cache = version_compare(dubver, '<=1.31.1')
        self._use_cache_describe = version_compare(dubver, '>=1.35.0')
        self._dub_has_build_deep = version_compare(dubver, '>=1.35.0')

        if not self._search_in_cache and not self._use_cache_describe:
            if self.required:
                raise DependencyException(
                    f'DUB version {dubver} is not compatible with Meson'
                    " (can't locate artifacts in DUB's cache). Upgrade to Dub >= 1.35.")
            else:
                mlog.warning(f'DUB dependency {name} not found because Dub {dubver} '
                             "is not compatible with Meson. (Can't locate artifacts in DUB's cache)."
                             ' Upgrade to Dub >= 1.35')
            return

        mlog.debug('Determining dependency {!r} with DUB executable '
                   '{!r}'.format(name, self.dubbin.get_path()))

        # we need to know the target architecture
        dub_arch = self.compiler.arch

        # we need to know the build type as well
        dub_buildtype = str(environment.coredata.optstore.get_value_for(OptionKey('buildtype')))
        # MESON types: choices=['plain', 'debug', 'debugoptimized', 'release', 'minsize', 'custom'])),
        # DUB types: debug (default), plain, release, release-debug, release-nobounds, unittest, profile, profile-gc,
        # docs, ddox, cov, unittest-cov, syntax and custom
        if dub_buildtype == 'debugoptimized':
            dub_buildtype = 'release-debug'
        elif dub_buildtype == 'minsize':
            dub_buildtype = 'release'

        result = self._get_dub_description(dub_arch, dub_buildtype)
        if result is None:
            return
        description, build_cmd, description_source = result
        dub_comp_id = self._ID_MAP[self.compiler.get_id()]

        self.compile_args = []
        self.link_args = self.raw_link_args = []

        show_buildtype_warning = False

        # collect all targets
        targets = {t['rootPackage']: t for t in description['targets']}

        def find_package_target(pkg: DubPackDesc) -> bool:
            nonlocal show_buildtype_warning
            # try to find a static library in a DUB folder corresponding to
            # version, configuration, compiler, arch and build-type
            # if can find, add to link_args.
            # link_args order is meaningful, so this function MUST be called in the right order
            pack_id = f'{pkg["name"]}@{pkg["version"]}'
            tgt_desc = targets[pkg['name']]
            (tgt_file, compatibilities) = self._find_target_in_cache(description, pkg, tgt_desc, dub_comp_id)
            if tgt_file is None:
                if not compatibilities:
                    mlog.error(mlog.bold(pack_id), 'not found')
                elif 'compiler' not in compatibilities:
                    mlog.error(mlog.bold(pack_id), 'found but not compiled with ', mlog.bold(dub_comp_id))
                elif dub_comp_id != 'gdc' and 'compiler_version' not in compatibilities:
                    mlog.error(mlog.bold(pack_id), 'found but not compiled with',
                               mlog.bold(f'{dub_comp_id}-{self.compiler.version}'))
                elif 'arch' not in compatibilities:
                    mlog.error(mlog.bold(pack_id), 'found but not compiled for', mlog.bold(dub_arch))
                elif 'platform' not in compatibilities:
                    mlog.error(mlog.bold(pack_id), 'found but not compiled for',
                               mlog.bold('.'.join(description['platform'])))
                elif 'configuration' not in compatibilities:
                    mlog.error(mlog.bold(pack_id), 'found but not compiled for the',
                               mlog.bold(pkg['configuration']), 'configuration')
                else:
                    mlog.error(mlog.bold(pack_id), 'not found')

                mlog.log('You may try the following command to install the necessary DUB libraries:')
                mlog.log(mlog.bold(build_cmd))

                return False

            if 'build_type' not in compatibilities:
                mlog.warning(mlog.bold(pack_id), 'found but not compiled as', mlog.bold(dub_buildtype))
                show_buildtype_warning = True

            self.link_args.append(tgt_file)
            return True

        # Main algorithm:
        # 1. Ensure that the target is a compatible library type (not dynamic)
        # 2. Find a compatible built library for the main dependency
        # 3. Do the same for each sub-dependency.
        #    link_args MUST be in the same order than the "linkDependencies" of the main target
        # 4. Add other build settings (imports, versions etc.)

        # 1
        packages: T.Dict[str, DubPackDesc] = {}
        found_it = False
        for pkg in description['packages']:
            packages[pkg['name']] = pkg

            if not pkg['active']:
                continue

            # check that the main dependency is indeed a library
            if pkg['name'] == name:
                if pkg['targetType'] not in ['library', 'sourceLibrary', 'staticLibrary']:
                    mlog.error(mlog.bold(name), "found but it isn't a static library, it is:",
                               pkg['targetType'])
                    return

                if self.version_reqs is not None:
                    ver = pkg['version']
                    if not version_compare_many(ver, self.version_reqs)[0]:
                        mlog.error(mlog.bold(f'{name}@{ver}'),
                                   'does not satisfy all version requirements of:',
                                   ' '.join(self.version_reqs))
                        return

                found_it = True
                self.version = pkg['version']
                self.pkg = pkg

        if not found_it:
            mlog.error(f'Could not find {name} in DUB description.')
            if description_source is DubDescriptionSource.Local:
                mlog.log('Make sure that the dependency is registered for your dub project by running:')
                mlog.log(mlog.bold(f'dub add {name}'))
            elif description_source is DubDescriptionSource.External:
                # `dub describe pkg` did not contain the pkg
                raise RuntimeError(f'`dub describe` succeeded but it does not contains {name}')
            return

        if name not in targets:
            if self.pkg['targetType'] == 'sourceLibrary':
                # source libraries have no associated targets,
                # but some build settings like import folders must be found from the package object.
                # Current algo only get these from "buildSettings" in the target object.
                # Let's save this for a future PR.
                # (See openssl DUB package for example of sourceLibrary)
                mlog.error('DUB targets of type', mlog.bold('sourceLibrary'), 'are not supported.')
            else:
                mlog.error('Could not find target description for', mlog.bold(self.name))
            return

        # Current impl only supports static libraries
        self.static = True

        # 2
        if not find_package_target(self.pkg):
            return

        # 3
        for link_dep in targets[name]['linkDependencies']:
            pkg = packages[link_dep]
            if not find_package_target(pkg):
                return

        if show_buildtype_warning:
            mlog.log('If it is not suitable, try the following command and reconfigure Meson with', mlog.bold('--clearcache'))
            mlog.log(mlog.bold(build_cmd))

        # 4
        bs = targets[name]['buildSettings']

        for flag in bs['dflags']:
            self.compile_args.append(flag)

        for path in bs['importPaths']:
            self.compile_args.append('-I' + path)

        for path in bs['stringImportPaths']:
            if 'import_dir' not in d_feature_args[self.compiler.id]:
                break
            flag = d_feature_args[self.compiler.id]['import_dir']
            self.compile_args.append(f'{flag}={path}')

        for ver in bs['versions']:
            if 'version' not in d_feature_args[self.compiler.id]:
                break
            flag = d_feature_args[self.compiler.id]['version']
            self.compile_args.append(f'{flag}={ver}')

        if bs['mainSourceFile']:
            self.compile_args.append(bs['mainSourceFile'])

        # pass static libraries
        # linkerFiles are added during step 3
        # for file in bs['linkerFiles']:
        #     self.link_args.append(file)

        for file in bs['sourceFiles']:
            # sourceFiles may contain static libraries
            if file.endswith('.lib') or file.endswith('.a'):
                self.link_args.append(file)

        for flag in bs['lflags']:
            self.link_args.append(flag)

        is_windows = self.env.machines.host.is_windows()
        if is_windows:
            winlibs = ['kernel32', 'user32', 'gdi32', 'winspool', 'shell32', 'ole32',
                       'oleaut32', 'uuid', 'comdlg32', 'advapi32', 'ws2_32']

        for lib in bs['libs']:
            if os.name != 'nt':
                # trying to add system libraries by pkg-config
                pkgdep = PkgConfigDependency(lib, environment, {'required': True, 'silent': True})
                if pkgdep.is_found:
                    for arg in pkgdep.get_compile_args():
                        self.compile_args.append(arg)
                    for arg in pkgdep.get_link_args():
                        self.link_args.append(arg)
                    for arg in pkgdep.get_link_args(raw=True):
                        self.raw_link_args.append(arg)
                    continue

            if is_windows and lib in winlibs:
                self.link_args.append(lib + '.lib')
                continue

            # fallback
            self.link_args.append('-l'+lib)

        self.is_found = True

    # Get the dub description needed to resolve the dependency and a
    # build command that can be used to build the dependency in case it is
    # not present.
    def _get_dub_description(self, dub_arch: str, dub_buildtype: str) -> T.Optional[T.Tuple[DubDescription, str, DubDescriptionSource]]:
        def get_build_command() -> T.List[str]:
            if self._dub_has_build_deep:
                cmd = ['dub', 'build', '--deep']
            else:
                cmd = ['dub', 'run', '--yes', 'dub-build-deep', '--']

            return cmd + [
                '--arch=' + dub_arch,
                '--compiler=' + self.compiler.get_exelist()[-1],
                '--build=' + dub_buildtype,
            ]

        # Ask dub for the package
        describe_cmd = [
            'describe', '--arch=' + dub_arch,
            '--build=' + dub_buildtype, '--compiler=' + self.compiler.get_exelist()[-1]
        ]
        helper_build = join_args(get_build_command())
        source = DubDescriptionSource.Local
        ret, res, err = self._call_dubbin(describe_cmd)
        if ret == 0:
            return (json.loads(res), helper_build, source)

        pack_spec = self.name
        if self.version_reqs is not None:
            if len(self.version_reqs) > 1:
                mlog.error('Multiple version requirements are not supported for raw dub dependencies.')
                mlog.error("Please specify only an exact version like '1.2.3'")
                raise DependencyException('Multiple version requirements are not solvable for raw dub depencies')
            elif len(self.version_reqs) == 1:
                pack_spec += '@' + self.version_reqs[0]

        describe_cmd = [
            'describe', pack_spec, '--arch=' + dub_arch,
            '--build=' + dub_buildtype, '--compiler=' + self.compiler.get_exelist()[-1]
        ]
        helper_build = join_args(get_build_command() + [pack_spec])
        source = DubDescriptionSource.External
        ret, res, err = self._call_dubbin(describe_cmd)
        if ret == 0:
            return (json.loads(res), helper_build, source)

        mlog.debug('DUB describe failed: ' + err)
        if 'locally' in err:
            mlog.error(mlog.bold(pack_spec), 'is not present locally. You may try the following command:')
            mlog.log(mlog.bold(helper_build))
        return None

    # This function finds the target of the provided JSON package, built for the right
    # compiler, architecture, configuration...
    # It returns (target|None, {compatibilities})
    # If None is returned for target, compatibilities will list what other targets were found without full compatibility
    def _find_target_in_cache(self, desc: DubDescription, pkg_desc: DubPackDesc,
                              tgt_desc: DubTargetDesc, dub_comp_id: str
                              ) -> T.Tuple[T.Optional[str], T.Set[str]]:
        mlog.debug('Searching in DUB cache for compatible', pkg_desc['targetFileName'])

        # recent DUB versions include a direct path to a compatible cached artifact
        if self._use_cache_describe:
            tgt_file = tgt_desc['cacheArtifactPath']
            if os.path.exists(tgt_file):
                return (tgt_file, {'configuration', 'platform', 'arch', 'compiler', 'compiler_version', 'build_type'})
            else:
                return (None, set())

        assert self._search_in_cache

        # try to find a string like library-debug-linux.posix-x86_64-ldc_2081-EF934983A3319F8F8FF2F0E107A363BA

        # fields are:
        #  - configuration
        #  - build type
        #  - platform
        #  - architecture
        #  - compiler id (dmd, ldc, gdc)
        #  - compiler version or frontend id or frontend version?

        comp_versions = self._get_comp_versions_to_find(dub_comp_id)

        # build_type is not in check_list because different build types might be compatible.
        # We do show a WARNING that the build type is not the same.
        # It might be critical in release builds, and acceptable otherwise
        check_list = {'configuration', 'platform', 'arch', 'compiler', 'compiler_version'}
        compatibilities: T.Set[str] = set()

        for entry in self._cache_entries(pkg_desc):
            target = entry['artifactPath']
            if not os.path.exists(target):
                # unless Dub and Meson are racing, the target file should be present
                # when the directory is present
                mlog.debug("WARNING: Could not find a Dub target: " + target)
                continue

            # we build a new set for each entry, because if this target is returned
            # we want to return only the compatibilities associated to this target
            # otherwise we could miss the WARNING about build_type
            comps: T.Set[str] = set()

            search = entry['search']

            mlog.debug('searching compatibility in ' + search)
            mlog.debug('compiler_versions', comp_versions)

            if pkg_desc['configuration'] in search:
                comps.add('configuration')

            if desc['buildType'] in search:
                comps.add('build_type')

            if all(platform in search for platform in desc['platform']):
                comps.add('platform')

            if all(arch in search for arch in desc['architecture']):
                comps.add('arch')

            if dub_comp_id in search:
                comps.add('compiler')

            if not comp_versions or any(cv in search for cv in comp_versions):
                comps.add('compiler_version')

            if check_list.issubset(comps):
                mlog.debug('Found', target)
                return (target, comps)
            else:
                compatibilities = set.union(compatibilities, comps)

        return (None, compatibilities)

    def _cache_entries(self, pkg_desc: DubPackDesc) -> T.List[FindTargetEntry]:
        # the "old" cache is the `.dub` directory in every package of ~/.dub/packages
        dub_build_path = os.path.join(pkg_desc['path'], '.dub', 'build')

        if not os.path.exists(dub_build_path):
            mlog.warning('No such cache folder:', dub_build_path)
            return []

        mlog.debug('Checking in DUB cache folder', dub_build_path)

        return [
            {
                'search': dir_entry,
                'artifactPath': os.path.join(dub_build_path, dir_entry, pkg_desc['targetFileName'])
            }
            for dir_entry in os.listdir(dub_build_path)
        ]

    def _get_comp_versions_to_find(self, dub_comp_id: str) -> T.List[str]:
        # Get D frontend version implemented in the compiler, or the compiler version itself
        # gdc doesn't support this

        if dub_comp_id == 'gdc':
            return []

        comp_versions = [self.compiler.version]

        ret, res = self._call_compbin(['--version'])[0:2]
        if ret != 0:
            mlog.error('Failed to run', mlog.bold(' '.join(self.dubbin.get_command() + ['--version'])))
            return []
        d_ver_reg = re.search('v[0-9].[0-9][0-9][0-9].[0-9]', res)  # Ex.: v2.081.2

        if d_ver_reg is not None:
            frontend_version = d_ver_reg.group()
            frontend_id = frontend_version.rsplit('.', 1)[0].replace(
                'v', '').replace('.', '')  # Fix structure. Ex.: 2081
            comp_versions.extend([frontend_version, frontend_id])

        return comp_versions

    def _call_dubbin(self, args: T.List[str], env: T.Optional[T.Dict[str, str]] = None) -> T.Tuple[int, str, str]:
        assert isinstance(self.dubbin, ExternalProgram)
        p, out, err = Popen_safe(self.dubbin.get_command() + args, env=env, cwd=self.env.get_source_dir())
        return p.returncode, out.strip(), err.strip()

    def _call_compbin(self, args: T.List[str], env: T.Optional[T.Dict[str, str]] = None) -> T.Tuple[int, str, str]:
        p, out, err = Popen_safe(self.compiler.get_exelist() + args, env=env)
        return p.returncode, out.strip(), err.strip()

    def _check_dub(self) -> T.Optional[T.Tuple[ExternalProgram, str]]:

        def find() -> T.Optional[T.Tuple[ExternalProgram, str]]:
            dubbin = ExternalProgram('dub', silent=True)

            if not dubbin.found():
                return None

            try:
                p, out = Popen_safe(dubbin.get_command() + ['--version'])[0:2]
                if p.returncode != 0:
                    mlog.warning('Found dub {!r} but couldn\'t run it'
                                 ''.format(' '.join(dubbin.get_command())))
                    return None

            except (FileNotFoundError, PermissionError):
                return None

            vermatch = re.search(r'DUB version (\d+\.\d+\.\d+.*), ', out.strip())
            if vermatch:
                dubver = vermatch.group(1)
            else:
                mlog.warning(f"Found dub {' '.join(dubbin.get_command())} but couldn't parse version in {out.strip()}")
                return None

            return (dubbin, dubver)

        found = find()

        if found is None:
            mlog.log('Found DUB:', mlog.red('NO'))
        else:
            (dubbin, dubver) = found
            mlog.log('Found DUB:', mlog.bold(dubbin.get_path()),
                     '(version %s)' % dubver)

        return found
