#!/usr/bin/env python3
# 
# Cross Platform and Multi Architecture Advanced Binary Emulation Framework
#

"""
This module is intended for general purpose functions that can be used
thoughout the qiling framework
"""

from functools import partial
from pathlib import Path
import importlib, inspect, os

from configparser import ConfigParser
from types import ModuleType
from typing import TYPE_CHECKING, Any, Callable, Mapping, Optional, Tuple, TypeVar, Union

from unicorn import UC_ERR_READ_UNMAPPED, UC_ERR_FETCH_UNMAPPED

from qiling.exception import *
from qiling.const import QL_ARCH, QL_ENDIAN, QL_OS, QL_DEBUGGER
from qiling.const import debugger_map, arch_map, os_map, arch_os_map

if TYPE_CHECKING:
    from qiling import Qiling
    from qiling.arch.arch import QlArch
    from qiling.debugger.debugger import QlDebugger
    from qiling.loader.loader import QlLoader
    from qiling.os.os import QlOs

T = TypeVar('T')
QlClassInit = Callable[['Qiling'], T]


def __name_to_enum(name: str, mapping: Mapping[str, T], aliases: Mapping[str, str] = {}) -> Optional[T]:
    key = name.casefold()

    return mapping.get(aliases.get(key) or key)

def os_convert(os: str) -> Optional[QL_OS]:
    alias_map = {
        'darwin' : 'macos'
    }

    return __name_to_enum(os, os_map, alias_map)

def arch_convert(arch: str) -> Optional[QL_ARCH]:
    alias_map = {
        'x86_64'  : 'x8664',
        'riscv32' : 'riscv'
    }

    return __name_to_enum(arch, arch_map, alias_map)

def debugger_convert(debugger: str) -> Optional[QL_DEBUGGER]:
    return __name_to_enum(debugger, debugger_map)

def arch_os_convert(arch: QL_ARCH) -> Optional[QL_OS]:
    return arch_os_map.get(arch)

def ql_get_module(module_name: str) -> ModuleType:
    try:
        module = importlib.import_module(module_name, 'qiling')
    except (ModuleNotFoundError, KeyError):
        raise QlErrorModuleNotFound(f'Unable to import module {module_name}')

    return module

def ql_get_module_function(module_name: str, member_name: str):
    module = ql_get_module(module_name)

    try:
        member = getattr(module, member_name)
    except AttributeError:
        raise QlErrorModuleFunctionNotFound(f'Unable to import {member_name} from {module_name}')

    return member

def __emu_env_from_pathname(path: str) -> Tuple[Optional[QL_ARCH], Optional[QL_OS], Optional[QL_ENDIAN]]:
    if os.path.isdir(path) and path.endswith('.kext'):
        return QL_ARCH.X8664, QL_OS.MACOS, QL_ENDIAN.EL

    if os.path.isfile(path):
        _, ext = os.path.splitext(path)

        if ext in ('.DOS_COM', '.DOS_MBR', '.DOS_EXE'):
            return QL_ARCH.A8086, QL_OS.DOS, QL_ENDIAN.EL

    return None, None, None

def __emu_env_from_elf(path: str) -> Tuple[Optional[QL_ARCH], Optional[QL_OS], Optional[QL_ENDIAN]]:
    # instead of using full-blown elffile parsing, we perform a simple parsing to avoid
    # external dependencies for target systems that do not need them.
    #
    # see: https://refspecs.linuxfoundation.org/elf/gabi4+/ch4.eheader.html

    # ei_class
    ELFCLASS32 = 1    # 32-bit
    ELFCLASS64 = 2    # 64-bit

    #ei_data
    ELFDATA2LSB = 1   # little-endian
    ELFDATA2MSB = 2   # big-endian

    # ei_osabi
    ELFOSABI_SYSV       = 0
    ELFOSABI_LINUX      = 3
    ELFOSABI_FREEBSD    = 9
    ELFOSABI_ARM_AEABI  = 64
    ELFOSABI_ARM        = 97
    ELFOSABI_STANDALONE = 255

    # e_machine
    EM_386     = 3
    EM_MIPS    = 8
    EM_ARM     = 40
    EM_X86_64  = 62
    EM_AARCH64 = 183
    EM_RISCV   = 243
    EM_PPC     = 20

    endianess = {
        ELFDATA2LSB : (QL_ENDIAN.EL, 'little'),
        ELFDATA2MSB : (QL_ENDIAN.EB, 'big')
    }

    machines32 = {
        EM_386   : QL_ARCH.X86,
        EM_MIPS  : QL_ARCH.MIPS,
        EM_ARM   : QL_ARCH.ARM,
        EM_RISCV : QL_ARCH.RISCV,
        EM_PPC   : QL_ARCH.PPC
    }

    machines64 = {
        EM_X86_64  : QL_ARCH.X8664,
        EM_AARCH64 : QL_ARCH.ARM64,
        EM_RISCV   : QL_ARCH.RISCV64
    }

    classes = {
        ELFCLASS32 : machines32,
        ELFCLASS64 : machines64
    }

    abis = {
        ELFOSABI_SYSV       : QL_OS.LINUX,
        ELFOSABI_LINUX      : QL_OS.LINUX,
        ELFOSABI_FREEBSD    : QL_OS.FREEBSD,
        ELFOSABI_ARM_AEABI  : QL_OS.LINUX,
        ELFOSABI_ARM        : QL_OS.LINUX,
        ELFOSABI_STANDALONE : QL_OS.BLOB
    }

    archtype = None
    ostype = None
    archendian = None

    with open(path, 'rb') as binfile:
        e_ident = binfile.read(16)
        e_type = binfile.read(2)
        e_machine = binfile.read(2)

        # qnx may be detected by the interpreter name: 'ldqnx.so'.
        # instead of properly parsing the file to locate the pt_interp
        # segment, we detect qnx fuzzily by looking for that string in
        # the first portion of the file.
        blob = binfile.read(0x200 - 20)

    if e_ident[:4] == b'\x7fELF':
        ei_class = e_ident[4]   # arch bits
        ei_data  = e_ident[5]   # arch endianess
        ei_osabi = e_ident[7]

        if ei_class in classes:
            machines = classes[ei_class]

            if ei_data in endianess:
                archendian, endian = endianess[ei_data]

                machine = int.from_bytes(e_machine, endian)

                if machine in machines:
                    archtype = machines[machine]

                if ei_osabi in abis:
                    ostype = abis[ei_osabi]

                    if blob and b'ldqnx.so' in blob:
                        ostype = QL_OS.QNX

    return archtype, ostype, archendian

def __emu_env_from_macho(path: str) -> Tuple[Optional[QL_ARCH], Optional[QL_OS], Optional[QL_ENDIAN]]:
    macho_macos_sig64 = b'\xcf\xfa\xed\xfe'
    macho_macos_sig32 = b'\xce\xfa\xed\xfe'
    macho_macos_fat = b'\xca\xfe\xba\xbe'  # should be header for FAT

    arch = None
    ostype = None
    endian = None

    with open(path, 'rb') as f:
        ident = f.read(32)

    if ident[:4] in (macho_macos_sig32, macho_macos_sig64, macho_macos_fat):
        ostype = QL_OS.MACOS

        # if ident[7] == 0: # 32 bit
        #    arch = QL_ARCH.X86

        if ident[4] == 0x07 and ident[7] == 0x01:  # X86 64 bit
            endian = QL_ENDIAN.EL
            arch = QL_ARCH.X8664

        elif ident[4] == 0x0c and ident[7] == 0x01:  # ARM64
            endian = QL_ENDIAN.EL
            arch = QL_ARCH.ARM64

    return arch, ostype, endian

def __emu_env_from_pe(path: str) -> Tuple[Optional[QL_ARCH], Optional[QL_OS], Optional[QL_ENDIAN]]:
    import pefile

    try:
        pe = pefile.PE(path, fast_load=True)
    except:
        return None, None, None

    arch = None
    ostype = None
    archendian = None

    machine_map = {
        pefile.MACHINE_TYPE['IMAGE_FILE_MACHINE_I386']  : QL_ARCH.X86,
        pefile.MACHINE_TYPE['IMAGE_FILE_MACHINE_AMD64'] : QL_ARCH.X8664,
        pefile.MACHINE_TYPE['IMAGE_FILE_MACHINE_ARM']   : QL_ARCH.ARM,
        pefile.MACHINE_TYPE['IMAGE_FILE_MACHINE_THUMB'] : QL_ARCH.ARM,
        pefile.MACHINE_TYPE['IMAGE_FILE_MACHINE_ARM64'] : QL_ARCH.ARM64
    }

    # get arch
    arch = machine_map.get(pe.FILE_HEADER.Machine)

    if arch:
        subsystem_uefi = (
            pefile.SUBSYSTEM_TYPE['IMAGE_SUBSYSTEM_EFI_APPLICATION'],
            pefile.SUBSYSTEM_TYPE['IMAGE_SUBSYSTEM_EFI_BOOT_SERVICE_DRIVER'],
            pefile.SUBSYSTEM_TYPE['IMAGE_SUBSYSTEM_EFI_RUNTIME_DRIVER'],
            pefile.SUBSYSTEM_TYPE['IMAGE_SUBSYSTEM_EFI_ROM']
        )

        if pe.OPTIONAL_HEADER.Subsystem in subsystem_uefi:
            ostype = QL_OS.UEFI
        else:
            ostype = QL_OS.WINDOWS

        archendian = QL_ENDIAN.EL

    return arch, ostype, archendian

def ql_guess_emu_env(path: str) -> Tuple[Optional[QL_ARCH], Optional[QL_OS], Optional[QL_ENDIAN]]:
    guessing_methods = (
        __emu_env_from_pathname,
        __emu_env_from_elf,
        __emu_env_from_macho,
        __emu_env_from_pe
    )

    for gm in guessing_methods:
        arch, ostype, endian = gm(path)

        if None not in (arch, ostype, endian):
            break
    else:
        arch, ostype, endian = (None, ) * 3

    return arch, ostype, endian

def select_loader(ostype: QL_OS, libcache: bool) -> QlClassInit['QlLoader']:
    if ostype == QL_OS.WINDOWS:
        kwargs = {'libcache' : libcache}

    else:
        kwargs = {}

    module = {
        QL_OS.LINUX   : r'elf',
        QL_OS.FREEBSD : r'elf',
        QL_OS.QNX     : r'elf',
        QL_OS.MACOS   : r'macho',
        QL_OS.WINDOWS : r'pe',
        QL_OS.UEFI    : r'pe_uefi',
        QL_OS.DOS     : r'dos',
        QL_OS.EVM     : r'evm',
        QL_OS.MCU     : r'mcu',
        QL_OS.BLOB    : r'blob'
    }[ostype]

    qlloader_path = f'.loader.{module}'
    qlloader_class = f'QlLoader{module.upper()}'

    obj = ql_get_module_function(qlloader_path, qlloader_class)

    return partial(obj, **kwargs)

def select_component(component_type: str, component_name: str, **kwargs) -> QlClassInit[Any]:
    component_path = f'.{component_type}.{component_name}'
    component_class = f'Ql{component_name.capitalize()}Manager'

    obj = ql_get_module_function(component_path, component_class)

    return partial(obj, **kwargs)

def select_debugger(options: Union[str, bool]) -> Optional[QlClassInit['QlDebugger']]:
    if options is True:
        options = 'gdb'

    if type(options) is str:
        objname, *args = options.split(':')
        dbgtype = debugger_convert(objname)

        if dbgtype == QL_DEBUGGER.GDB:
            kwargs = dict(zip(('ip', 'port'), args))

        elif dbgtype == QL_DEBUGGER.QDB:
            kwargs = {}

            def __int_nothrow(v: str, /) -> Optional[int]:
                try:
                    return int(v, 0)
                except ValueError:
                    return None

            # qdb init args are independent and may include any combination of: rr enable, init hook and script
            for a in args:
                if a == 'rr':
                    kwargs['rr'] = True

                elif __int_nothrow(a) is not None:
                    kwargs['init_hook'] = a

                else:
                    kwargs['script'] = a

        else:
            raise QlErrorOutput('Debugger not supported')

        obj = ql_get_module_function(f'.debugger.{objname}.{objname}', f'Ql{str.capitalize(objname)}')

        return partial(obj, **kwargs)

    return None

def select_arch(archtype: QL_ARCH, endian: QL_ENDIAN, thumb: bool) -> QlClassInit['QlArch']:
    # set endianess and thumb mode for arm-based archs
    if archtype == QL_ARCH.ARM:
        kwargs = {'endian' : endian, 'thumb' : thumb}

    # set endianess for mips arch
    elif archtype == QL_ARCH.MIPS:
        kwargs = {'endian' : endian}

    else:
        kwargs = {}

    module = {
        QL_ARCH.A8086    : r'x86',
        QL_ARCH.X86      : r'x86',
        QL_ARCH.X8664    : r'x86',
        QL_ARCH.ARM      : r'arm',
        QL_ARCH.ARM64    : r'arm64',
        QL_ARCH.MIPS     : r'mips',
        QL_ARCH.EVM      : r'evm.evm',
        QL_ARCH.CORTEX_M : r'cortex_m',
        QL_ARCH.RISCV    : r'riscv',
        QL_ARCH.RISCV64  : r'riscv64',
        QL_ARCH.PPC      : r'ppc'
    }[archtype]

    qlarch_path = f'.arch.{module}'
    qlarch_class = f'QlArch{archtype.name.upper()}'

    obj = ql_get_module_function(qlarch_path, qlarch_class)

    return partial(obj, **kwargs)

def select_os(ostype: QL_OS) -> QlClassInit['QlOs']:
    qlos_name = ostype.name
    qlos_path = f'.os.{qlos_name.lower()}.{qlos_name.lower()}'
    qlos_class = f'QlOs{qlos_name.capitalize()}'

    obj = ql_get_module_function(qlos_path, qlos_class)

    return partial(obj)

def profile_setup(ostype: QL_OS, user_config: Optional[Union[str, dict]]):
    # mcu uses a yaml-based config
    if ostype == QL_OS.MCU:
        import yaml

        if user_config:
            with open(user_config) as f:
                config = yaml.load(f, Loader=yaml.Loader)
        else:
            config = {}

    else:
        # patch 'getint' to convert integers of all bases
        int_converter = partial(int, base=0)
        config = ConfigParser(converters={'int': int_converter})

        qiling_home = Path(inspect.getfile(inspect.currentframe())).parent
        os_profile = qiling_home / 'profiles' / f'{ostype.name.lower()}.ql'

        # read default profile first
        config.read(os_profile)

        # user-specified profile adds or overrides existing setting
        if isinstance(user_config, dict):
            config.read_dict(user_config)

        elif user_config:
            config.read(user_config)
        
    return config

# verify if emulator returns properly
def verify_ret(ql: 'Qiling', err):
    # init_sp location is not consistent; this is here to work around that
    if not hasattr(ql.os, 'init_sp'):
        ql.os.init_sp = ql.loader.init_sp

    ql.log.debug("Got exception %u: init SP = %x, current SP = %x, PC = %x" %(err.errno, ql.os.init_sp, ql.arch.regs.arch_sp, ql.arch.regs.arch_pc))

    if hasattr(ql.os, 'RUN'):
        ql.os.RUN = False

    # timeout is acceptable in this case
    if err.errno in (UC_ERR_READ_UNMAPPED, UC_ERR_FETCH_UNMAPPED):
        if ql.os.type == QL_OS.MACOS:
            if ql.loader.kext_name:
                # FIXME: Should I push saved RIP before every method callings of IOKit object?
                if ql.os.init_sp == ql.arch.regs.arch_sp - 8:
                    pass
                else:
                    raise

        if ql.arch.type == QL_ARCH.X8664: # Win64
            if ql.os.init_sp == ql.arch.regs.arch_sp or ql.os.init_sp + 8 == ql.arch.regs.arch_sp or ql.os.init_sp + 0x10 == ql.arch.regs.arch_sp:  # FIXME
                # 0x11626	 c3	  	ret
                # print("OK, stack balanced!")
                pass
            else:
                raise
        else:   # Win32
            if ql.os.init_sp + 12 == ql.arch.regs.arch_sp:   # 12 = 8 + 4
                # 0x114dd	 c2 08 00	  	ret 	8
                pass
            else:
                raise
    else:
        raise

__all__ = [
    'os_convert',
    'arch_convert',
    'debugger_convert',
    'arch_os_convert',
    'ql_get_module',
    'ql_get_module_function',
    'ql_guess_emu_env',
    'select_os',
    'select_arch',
    'select_loader',
    'select_debugger',
    'select_component',
    'profile_setup',
    'verify_ret'
]
