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

from __future__ import annotations

from dataclasses import dataclass, field
import sys, os, subprocess, shutil
import pathlib
import shlex
import typing as T

from .. import envconfig
from .. import mlog
from ..compilers import compilers
from ..compilers.detect import defaults as compiler_names

if T.TYPE_CHECKING:
    import argparse

    from ..compilers.compilers import Language

# Note: when adding arguments, please also add them to the completion
# scripts in $MESONSRC/data/shell-completions/
def add_arguments(parser: 'argparse.ArgumentParser') -> None:
    action = parser.add_mutually_exclusive_group(required=True)
    action.add_argument('--cross', default=False, action='store_true',
                        help='Generate a cross compilation file.')
    action.add_argument('--native', default=False, action='store_true',
                        help='Generate a native compilation file.')
    action.add_argument('--android', default=False, action='store_true',
                        help='Generate cross files for Android toolchains.')

    parser.add_argument('--debarch', default=None,
                        help='The dpkg architecture to generate.')
    parser.add_argument('--gccsuffix', default="",
                        help='A particular gcc version suffix if necessary.')
    parser.add_argument('-o', required=True, dest='outfile',
                        help='The output file or directory (for Android).')
    parser.add_argument('--use-for-build', default=False, action='store_true',
                        help='Use _FOR_BUILD envvars.')
    parser.add_argument('--system', default=None,
                        help='Define system for cross compilation.')
    parser.add_argument('--subsystem', default=None,
                        help='Define subsystem for cross compilation.')
    parser.add_argument('--kernel', default=None,
                        help='Define kernel for cross compilation.')
    parser.add_argument('--cpu', default=None,
                        help='Define cpu for cross compilation.')
    parser.add_argument('--cpu-family', default=None,
                        help='Define cpu family for cross compilation.')
    parser.add_argument('--endian', default='little', choices=['big', 'little'],
                        help='Define endianness for cross compilation.')

@dataclass
class MachineInfo:
    compilers: T.Dict[str, T.List[str]] = field(default_factory=dict)
    binaries: T.Dict[str, T.List[str]] = field(default_factory=dict)
    properties: T.Dict[str, T.Union[str, T.List[str]]] = field(default_factory=dict)
    compile_args: T.Dict[str, T.List[str]] = field(default_factory=dict)
    link_args: T.Dict[str, T.List[str]] = field(default_factory=dict)
    cmake: T.Dict[str, T.Union[str, T.List[str]]] = field(default_factory=dict)

    system: T.Optional[str] = None
    subsystem: T.Optional[str] = None
    kernel: T.Optional[str] = None
    cpu: T.Optional[str] = None
    cpu_family: T.Optional[str] = None
    endian: T.Optional[str] = None

#parser = argparse.ArgumentParser(description='''Generate cross compilation definition file for the Meson build system.
#
#If you do not specify the --arch argument, Meson assumes that running
#plain 'dpkg-architecture' will return correct information for the
#host system.
#
#This script must be run in an environment where CPPFLAGS et al are set to the
#same values used in the actual compilation.
#'''
#)

def locate_path(program: str) -> T.List[str]:
    if os.path.isabs(program):
        return [program]
    for d in os.get_exec_path():
        f = os.path.join(d, program)
        if os.access(f, os.X_OK):
            return [f]
    raise ValueError("%s not found on $PATH" % program)

def write_args_line(ofile: T.TextIO, name: str, args: T.Union[str, T.List[str]]) -> None:
    if len(args) == 0:
        return
    if isinstance(args, str):
        ostr = name + "= '" + args + "'\n"
    else:
        ostr = name + ' = ['
        ostr += ', '.join("'" + i + "'" for i in args)
        ostr += ']\n'
    ofile.write(ostr)

def get_args_from_envvars(infos: MachineInfo) -> None:
    cppflags = shlex.split(os.environ.get('CPPFLAGS', ''))
    cflags = shlex.split(os.environ.get('CFLAGS', ''))
    cxxflags = shlex.split(os.environ.get('CXXFLAGS', ''))
    objcflags = shlex.split(os.environ.get('OBJCFLAGS', ''))
    objcxxflags = shlex.split(os.environ.get('OBJCXXFLAGS', ''))
    ldflags = shlex.split(os.environ.get('LDFLAGS', ''))

    c_args = cppflags + cflags
    cpp_args = cppflags + cxxflags
    c_link_args = cflags + ldflags
    cpp_link_args = cxxflags + ldflags

    objc_args = cppflags + objcflags
    objcpp_args = cppflags + objcxxflags
    objc_link_args = objcflags + ldflags
    objcpp_link_args = objcxxflags + ldflags

    if c_args:
        infos.compile_args['c'] = c_args
    if c_link_args:
        infos.link_args['c'] = c_link_args
    if cpp_args:
        infos.compile_args['cpp'] = cpp_args
    if cpp_link_args:
        infos.link_args['cpp'] = cpp_link_args
    if objc_args:
        infos.compile_args['objc'] = objc_args
    if objc_link_args:
        infos.link_args['objc'] = objc_link_args
    if objcpp_args:
        infos.compile_args['objcpp'] = objcpp_args
    if objcpp_link_args:
        infos.link_args['objcpp'] = objcpp_link_args

# map from DEB_HOST_GNU_CPU to Meson machine.cpu_family()
deb_cpu_family_map = {
    'mips64el': 'mips64',
    'i686': 'x86',
    'powerpc64le': 'ppc64',
}

# map from DEB_HOST_ARCH to Meson machine.cpu()
deb_arch_cpu_map = {
    'armhf': 'arm7hlf',
}

# map from DEB_HOST_GNU_CPU to Meson machine.cpu()
deb_cpu_map = {
    'mips64el': 'mips64',
    'powerpc64le': 'ppc64',
}

# map from DEB_HOST_ARCH_OS to Meson machine.system()
deb_os_map = {
    'hurd': 'gnu',
}

# map from DEB_HOST_ARCH_OS to Meson machine.kernel()
deb_kernel_map = {
    'kfreebsd': 'freebsd',
    'hurd': 'gnu',
}

def replace_special_cases(special_cases: T.Mapping[str, str], name: str) -> str:
    '''
    If name is a key in special_cases, replace it with the value, or otherwise
    pass it through unchanged.
    '''
    return special_cases.get(name, name)

def deb_detect_cmake(infos: MachineInfo, data: T.Dict[str, str]) -> None:
    system_name_map = {'linux': 'Linux', 'kfreebsd': 'kFreeBSD', 'hurd': 'GNU'}
    system_processor_map = {'arm': 'armv7l', 'mips64el': 'mips64', 'powerpc64le': 'ppc64le'}

    infos.cmake["CMAKE_C_COMPILER"] = infos.compilers['c']
    try:
        infos.cmake["CMAKE_CXX_COMPILER"] = infos.compilers['cpp']
    except KeyError:
        pass
    infos.cmake["CMAKE_SYSTEM_NAME"] = system_name_map[data['DEB_HOST_ARCH_OS']]
    infos.cmake["CMAKE_SYSTEM_PROCESSOR"] = replace_special_cases(system_processor_map, data['DEB_HOST_GNU_CPU'])

def deb_compiler_lookup(infos: MachineInfo, compilerstems: T.List[T.Tuple[str, str]], host_arch: str, gccsuffix: str) -> None:
    for langname, stem in compilerstems:
        compilername = f'{host_arch}-{stem}{gccsuffix}'
        try:
            p = locate_path(compilername)
            infos.compilers[langname] = p
        except ValueError:
            pass

def detect_cross_debianlike(options: T.Any) -> MachineInfo:
    if options.debarch == 'auto':
        cmd = ['dpkg-architecture']
    else:
        cmd = ['dpkg-architecture', '-a' + options.debarch]
    output = subprocess.check_output(cmd, universal_newlines=True,
                                     stderr=subprocess.DEVNULL)
    return dpkg_architecture_to_machine_info(output, options)

def dpkg_architecture_to_machine_info(output: str, options: T.Any) -> MachineInfo:
    data = {}
    for line in output.split('\n'):
        line = line.strip()
        if line == '':
            continue
        k, v = line.split('=', 1)
        data[k] = v
    host_arch = data['DEB_HOST_GNU_TYPE']
    host_os = replace_special_cases(deb_os_map, data['DEB_HOST_ARCH_OS'])
    host_subsystem = host_os
    host_kernel = replace_special_cases(deb_kernel_map, data['DEB_HOST_ARCH_OS'])
    host_cpu_family = replace_special_cases(deb_cpu_family_map, data['DEB_HOST_GNU_CPU'])
    host_cpu = deb_arch_cpu_map.get(data['DEB_HOST_ARCH'],
                                    replace_special_cases(deb_cpu_map, data['DEB_HOST_GNU_CPU']))
    host_endian = data['DEB_HOST_ARCH_ENDIAN']

    compilerstems = [('c', 'gcc'),
                     ('cpp', 'g++'),
                     ('objc', 'gobjc'),
                     ('objcpp', 'gobjc++')]
    infos = MachineInfo()
    deb_compiler_lookup(infos, compilerstems, host_arch, options.gccsuffix)
    if len(infos.compilers) == 0:
        print('Warning: no compilers were detected.')
    infos.binaries['ar'] = locate_path("%s-ar" % host_arch)
    infos.binaries['strip'] = locate_path("%s-strip" % host_arch)
    infos.binaries['objcopy'] = locate_path("%s-objcopy" % host_arch)
    infos.binaries['ld'] = locate_path("%s-ld" % host_arch)
    try:
        infos.binaries['cmake'] = locate_path("cmake")
        deb_detect_cmake(infos, data)
    except ValueError:
        pass
    for tool in [
        'g-ir-annotation-tool',
        'g-ir-compiler',
        'g-ir-doc-tool',
        'g-ir-generate',
        'g-ir-inspect',
        'g-ir-scanner',
        'pkg-config',
        'vapigen',
    ]:
        try:
            infos.binaries[tool] = locate_path("%s-%s" % (host_arch, tool))
        except ValueError:
            pass    # optional
    for tool, exe in [
        ('exe_wrapper', 'cross-exe-wrapper'),
    ]:
        try:
            infos.binaries[tool] = locate_path("%s-%s" % (host_arch, exe))
        except ValueError:
            pass
    for tool, exe in [
        ('vala', 'valac'),
    ]:
        try:
            infos.compilers[tool] = locate_path("%s-%s" % (host_arch, exe))
        except ValueError:
            pass
    try:
        infos.binaries['cups-config'] = locate_path("cups-config")
    except ValueError:
        pass
    infos.system = host_os
    infos.subsystem = host_subsystem
    infos.kernel = host_kernel
    infos.cpu_family = host_cpu_family
    infos.cpu = host_cpu
    infos.endian = host_endian

    get_args_from_envvars(infos)
    return infos

def write_machine_file(infos: MachineInfo, ofilename: str, write_system_info: bool) -> None:
    tmpfilename = ofilename + '~'
    with open(tmpfilename, 'w', encoding='utf-8') as ofile:
        ofile.write('[binaries]\n')
        ofile.write('# Compilers\n')
        for langname in sorted(infos.compilers.keys()):
            compiler = infos.compilers[langname]
            write_args_line(ofile, langname, compiler)
        ofile.write('\n')

        ofile.write('# Other binaries\n')
        for exename in sorted(infos.binaries.keys()):
            exe = infos.binaries[exename]
            write_args_line(ofile, exename, exe)
        ofile.write('\n')

        ofile.write('[built-in options]\n')
        all_langs = list(set(infos.compile_args.keys()).union(set(infos.link_args.keys())))
        all_langs.sort()
        for lang in all_langs:
            if lang in infos.compile_args:
                write_args_line(ofile, lang + '_args', infos.compile_args[lang])
            if lang in infos.link_args:
                write_args_line(ofile, lang + '_link_args', infos.link_args[lang])
        ofile.write('\n')

        ofile.write('[properties]\n')
        for k, v in infos.properties.items():
            write_args_line(ofile, k, v)
        ofile.write('\n')

        if infos.cmake:
            ofile.write('[cmake]\n\n')
            for k, v in infos.cmake.items():
                write_args_line(ofile, k, v)
            ofile.write('\n')

        if write_system_info:
            ofile.write('[host_machine]\n')
            ofile.write(f"cpu = '{infos.cpu}'\n")
            ofile.write(f"cpu_family = '{infos.cpu_family}'\n")
            ofile.write(f"endian = '{infos.endian}'\n")
            ofile.write(f"system = '{infos.system}'\n")
            if infos.subsystem:
                ofile.write(f"subsystem = '{infos.subsystem}'\n")
            if infos.kernel:
                ofile.write(f"kernel = '{infos.kernel}'\n")

    os.replace(tmpfilename, ofilename)

def detect_language_args_from_envvars(langname: str, envvar_suffix: str = '') -> T.Tuple[T.List[str], T.List[str]]:
    compile_args = []
    if langname in compilers.CFLAGS_MAPPING:
        compile_args = shlex.split(os.environ.get(
            compilers.CFLAGS_MAPPING[T.cast('Language', langname)] + envvar_suffix, ''))
    if langname in compilers.LANGUAGES_USING_CPPFLAGS:
        cppflags = tuple(shlex.split(os.environ.get('CPPFLAGS' + envvar_suffix, '')))
        lang_compile_args = list(cppflags) + compile_args
    else:
        lang_compile_args = compile_args
    lang_link_args = []
    if langname in compilers.LANGUAGES_USING_LDFLAGS:
        lang_link_args += shlex.split(os.environ.get('LDFLAGS' + envvar_suffix, ''))
    lang_link_args += compile_args
    return (lang_compile_args, lang_link_args)

def detect_compilers_from_envvars(envvar_suffix: str = '') -> MachineInfo:
    infos = MachineInfo()
    for langname, envvarnames in envconfig.ENV_VAR_COMPILER_MAP.items():
        for envvarname in envvarnames:
            compilerstr = os.environ.get(envvarname + envvar_suffix)
            if not compilerstr:
                continue
            if os.path.exists(compilerstr):
                compiler = [compilerstr]
            else:
                compiler = shlex.split(compilerstr)
            infos.compilers[langname] = compiler
            lang_compile_args, lang_link_args = detect_language_args_from_envvars(langname, envvar_suffix)
            if lang_compile_args:
                infos.compile_args[langname] = lang_compile_args
            if lang_link_args:
                infos.link_args[langname] = lang_link_args
            break
    return infos

def detect_binaries_from_envvars(infos: MachineInfo, envvar_suffix: str = '') -> None:
    for binname, envvar_bases in envconfig.ENV_VAR_TOOL_MAP.items():
        for envvar_base in envvar_bases:
            envvar = envvar_base + envvar_suffix
            binstr = os.environ.get(envvar)
            if binstr:
                infos.binaries[binname] = shlex.split(binstr)
                break

def detect_properties_from_envvars(infos: MachineInfo, envvar_suffix: str = '') -> None:
    var = os.environ.get('PKG_CONFIG_LIBDIR' + envvar_suffix)
    if var is not None:
        infos.properties['pkg_config_libdir'] = var
    var = os.environ.get('PKG_CONFIG_SYSROOT_DIR' + envvar_suffix)
    if var is not None:
        infos.properties['sys_root'] = var

def detect_cross_system(infos: MachineInfo, options: T.Any) -> None:
    for optname in ('system', 'subsystem', 'kernel', 'cpu', 'cpu_family', 'endian'):
        v = getattr(options, optname)
        if not v:
            mlog.error(f'Cross property "{optname}" missing, set it with --{optname.replace("_", "-")}.')
            sys.exit(1)
        setattr(infos, optname, v)

def detect_cross_env(options: T.Any) -> MachineInfo:
    if options.debarch:
        print('Detecting cross environment via dpkg-architecture.')
        infos = detect_cross_debianlike(options)
    else:
        print('Detecting cross environment via environment variables.')
        infos = detect_compilers_from_envvars()
        detect_cross_system(infos, options)
    detect_binaries_from_envvars(infos)
    detect_properties_from_envvars(infos)
    return infos

def add_compiler_if_missing(infos: MachineInfo, langname: str, exe_names: T.List[str]) -> None:
    if langname in infos.compilers:
        return
    for exe_name in exe_names:
        lookup = shutil.which(exe_name)
        if not lookup:
            continue
        compflags, linkflags = detect_language_args_from_envvars(langname)
        infos.compilers[langname] = [lookup]
        if compflags:
            infos.compile_args[langname] = compflags
        if linkflags:
            infos.link_args[langname] = linkflags
        return

def detect_missing_native_compilers(infos: MachineInfo) -> None:
    # T.Any per-platform special detection should go here.
    for langname, exes in compiler_names.items():
        if langname not in envconfig.ENV_VAR_COMPILER_MAP:
            continue
        add_compiler_if_missing(infos, langname, exes)

def detect_missing_native_binaries(infos: MachineInfo) -> None:
    # T.Any per-platform special detection should go here.
    for toolname in sorted(envconfig.ENV_VAR_TOOL_MAP.keys()):
        if toolname in infos.binaries:
            continue
        exe = shutil.which(toolname)
        if exe:
            infos.binaries[toolname] = [exe]

def detect_native_env(options: T.Any) -> MachineInfo:
    if options.use_for_build:
        mlog.log('Using _FOR_BUILD envvars for detection (native file for use during cross compilation)')
        esuffix = '_FOR_BUILD'
    else:
        esuffix = ''
    infos = detect_compilers_from_envvars(esuffix)
    detect_missing_native_compilers(infos)
    detect_binaries_from_envvars(infos, esuffix)
    detect_missing_native_binaries(infos)
    detect_properties_from_envvars(infos, esuffix)
    return infos

ANDROID_CPU_TO_MESON_CPU_FAMILY: dict[str, str] = {
    'aarch64': 'aarch64',
    'armv7a': 'arm',
    'i686': 'x86',
    'x86_64': 'x86_64',
    'riscv64': 'riscv64',
}

class AndroidDetector:
    def __init__(self, options: T.Any):
        import platform
        self.platform = platform.system().lower()
        self.options = options

        if self.platform == 'windows':
            self.build_machine_id = 'windows-X86_64'
            self.command_suffix = '.cmd'
            self.exe_suffix = '.exe'
        elif self.platform == 'darwin':
            self.build_machine_id = 'darwin-x86_64' # Yes, even on aarch64 for some reason
            self.command_suffix = ''
            self.exe_suffix = ''
        elif self.platform == 'linux':
            self.build_machine_id = 'linux-x86_64'
            self.command_suffix = ''
            self.exe_suffix = ''
        else:
            sys.exit('Android lookup only supported on Linux, Windows and macOS. Patches welcome.')
        self.outdir = pathlib.Path(options.outfile)

    def detect_android_sdk_root(self) -> None:
        home = pathlib.Path.home()
        if self.platform == 'windows':
            sdk_root = home / 'AppData/Local/Android/Sdk'
        elif self.platform == 'darwin':
            sdk_root = home / 'Library/Android/Sdk'
        elif self.platform == 'linux':
            sdk_root = home / 'Android/Sdk'
        else:
            sys.exit('Unsupported platform.')
        if not sdk_root.is_dir():
            sys.exit(f'Could not locate Android SDK root in {sdk_root}.')
        ndk_root = sdk_root / 'ndk'
        if not ndk_root.is_dir():
            sys.exit(f'Could not locate Android ndk in {ndk_root}')
        self.ndk_root = ndk_root

    def detect_toolchains(self) -> None:
        self.detect_android_sdk_root()
        if not self.outdir.is_dir():
            self.outdir.mkdir()
        for ndk in self.ndk_root.glob('*'):
            if not ndk.is_dir():
                continue
            self.process_ndk(ndk)

    def process_ndk(self, ndk: pathlib.Path) -> None:
        ndk_version = ndk.parts[-1]
        toolchain_root = ndk / f'toolchains/llvm/prebuilt/{self.build_machine_id}'
        bindir = toolchain_root / 'bin'
        if not bindir.is_dir():
            sys.exit(f'Could not detect toolchain in {toolchain_root}.')
        ar_path = bindir / f'llvm-ar{self.exe_suffix}'
        if not ar_path.is_file():
            sys.exit(f'Could not detect llvm-ar in {toolchain_root}.')
        ar_str = str(ar_path).replace('\\', '/')
        strip_path = bindir / f'llvm-strip{self.exe_suffix}'
        if not strip_path.is_file():
            sys.exit(f'Could not detect llvm-strip n {toolchain_root}.')
        strip_str = str(strip_path).replace('\\', '/')
        for compiler in bindir.glob('*-clang++'):
            parts = compiler.parts[-1].split('-')
            assert len(parts) == 4
            cpu = parts[0]
            assert parts[1] == 'linux'
            android_version = parts[2]
            cpp_compiler_str = str(compiler).replace('\\', '/')
            c_compiler_str = cpp_compiler_str[:-2]
            cpp_compiler_str += self.command_suffix
            c_compiler_str += self.command_suffix
            crossfile_name = f'android-{ndk_version}-{android_version}-{cpu}-cross.txt'
            with open(pathlib.Path(self.options.outfile) / crossfile_name, 'w', encoding='utf-8') as ofile:
                ofile.write('[binaries]\n')
                ofile.write(f"c = '{c_compiler_str}'\n")
                ofile.write(f"cpp = '{cpp_compiler_str}'\n")
                ofile.write(f"ar = '{ar_str}'\n")
                ofile.write(f"strip = '{strip_str}'\n")

                ofile.write('\n[host_machine]\n')
                ofile.write("system = 'android'\n")
                ofile.write(f"cpu_family = '{ANDROID_CPU_TO_MESON_CPU_FAMILY[cpu]}'\n")
                ofile.write(f"cpu = '{cpu}'\n")
                ofile.write("endian = 'little'\n")


def run(options: argparse.Namespace) -> int:
    mlog.notice('This functionality is experimental and subject to change.')
    if options.cross:
        if options.use_for_build:
            sys.exit('--use-for-build only makes sense for --native, not --cross')
        infos = detect_cross_env(options)
        write_system_info = True
        write_machine_file(infos, options.outfile, write_system_info)
    elif options.native:
        infos = detect_native_env(options)
        write_system_info = False
        write_machine_file(infos, options.outfile, write_system_info)
    elif options.android:
        ad = AndroidDetector(options)
        ad.detect_toolchains()
    else:
        raise ValueError("Encountered unreachable code-path")

    return 0
