# -*- coding: utf_8 -*-
"""Dynamic Analyzer Helpers."""
import logging
import os
import shutil
import subprocess
import tempfile
import threading
import time
from pathlib import Path
from base64 import b64encode
from hashlib import md5

from django.conf import settings

from OpenSSL import crypto

from frida import __version__ as frida_version

from mobsf.DynamicAnalyzer.tools.webproxy import (
    create_ca,
    get_ca_file,
    get_http_tools_url,
    start_proxy,
    stop_httptools,
)
from mobsf.DynamicAnalyzer.views.common.frida.server_update import (
    FridaServerUpdater,
)
from mobsf.MobSF.utils import (
    get_adb,
    get_device,
    get_proxy_ip,
    is_file_exists,
    python_list,
)
from mobsf.StaticAnalyzer.models import StaticAnalyzerAndroid

logger = logging.getLogger(__name__)
ANDROID_API_SUPPORTED = 30


class Environment:

    def __init__(self, identifier=None):
        if identifier:
            self.identifier = identifier
        else:
            self.identifier = get_device()
        self.tools_dir = settings.TOOLS_DIR
        self.frida_str = f'MobSF-Frida-{frida_version}'.encode('utf-8')
        self.xposed_str = b'MobSF-Xposed'

    def wait(self, sec):
        """Wait in Seconds."""
        if sec > 0:
            logger.info('Waiting for %s seconds...', str(sec))
            time.sleep(sec)

    def check_connect_error(self, output):
        """Check if connect failed."""
        if b'unable to connect' in output or b'failed to connect' in output:
            logger.error('%s', output.decode('utf-8').replace('\n', ''))
            return False
        return True

    def run_subprocess_verify_output(self, cmd, wait=2):
        """Run subprocess and verify execution."""
        out = subprocess.check_output(cmd)  # lgtm [py/command-line-injection]
        self.wait(wait)                        # adb shell is allowed
        return self.check_connect_error(out)

    def connect(self):
        """ADB Connect."""
        if not self.identifier:
            return False
        logger.info('Connecting to Android %s', self.identifier)
        self.run_subprocess_verify_output([get_adb(),
                                           'connect',
                                           self.identifier])

    def connect_n_mount(self):
        """Test ADB Connection."""
        if not self.identifier:
            return False
        self.adb_command(['kill-server'])
        self.adb_command(['start-server'], False, True)
        logger.info('ADB Restarted')
        self.wait(2)
        logger.info('Connecting to Android %s', self.identifier)
        if not self.run_subprocess_verify_output([get_adb(),
                                                 'connect',
                                                  self.identifier]):
            return False
        logger.info('Restarting ADB Daemon as root')
        if not self.run_subprocess_verify_output([get_adb(),
                                                  '-s',
                                                  self.identifier,
                                                  'root']):
            return False
        logger.info('Reconnecting to Android Device')
        # connect again with root adb
        if not self.run_subprocess_verify_output([get_adb(),
                                                  'connect',
                                                  self.identifier]):
            return False
        # identify environment
        runtime = self.get_environment()
        logger.info('Remounting')
        # Allow non supported environments also
        self.adb_command(['remount'])
        logger.info('Performing System check')
        if not self.system_check(runtime):
            return False
        return True

    def is_package_installed(self, package, extra):
        """Check if package is installed."""
        success = '\nSuccess' in extra
        out = self.adb_command(['pm', 'list', 'packages'], True)
        pkg = f'{package}'.encode('utf-8')
        pkg_fmts = [pkg + b'\n', pkg + b'\r\n', pkg + b'\r\r\n']
        if any(pkg in out for pkg in pkg_fmts):
            # Windows uses \r\n and \r\r\n
            return True
        if success:
            # Fallback check
            return True
        return False

    def install_apk(self, apk_path, package, reinstall):
        """Install APK and Verify Installation."""
        if self.is_package_installed(package, '') and reinstall != '0':
            logger.info('Removing existing installation')
            # Remove existing installation'
            self.adb_command(['uninstall', package], False, True)
        # Disable install verification
        self.adb_command([
            'settings',
            'put',
            'global',
            'verifier_verify_adb_installs',
            '0',
        ], True)
        logger.info('Installing APK - %s', package)
        # Install APK
        out = self.adb_command([
            'install',
            '-r',
            '-t',
            '-d',
            apk_path], False, True)
        if not out:
            return False, 'adb install failed'
        out = out.decode('utf-8', 'ignore')
        # Verify Installation
        return self.is_package_installed(package, out), out

    def adb_command(self, cmd_list, shell=False, silent=False):
        """ADB Command wrapper."""
        args = [get_adb(),
                '-s',
                self.identifier]
        if shell:
            args += ['shell']
        args += cmd_list
        try:
            result = subprocess.check_output(
                args,  # lgtm [py/command-line-injection]
                stderr=subprocess.STDOUT)
            return result
        except Exception:
            if not silent:
                logger.exception('Error Running ADB Command')
            return None

    def dz_cleanup(self, bin_hash):
        """Clean up before Dynamic Analysis."""
        # Delete ScreenStream Cache
        screen_file = os.path.join(settings.SCREEN_DIR, 'screen.png')
        if os.path.exists(screen_file):
            os.remove(screen_file)
        # Delete Contents of Screenshot Dir
        screen_dir = os.path.join(
            settings.UPLD_DIR, bin_hash + '/screenshots-apk/')
        if os.path.isdir(screen_dir):
            shutil.rmtree(screen_dir)
        else:
            os.makedirs(screen_dir)

    def configure_proxy(self, project, request):
        """HTTPS Proxy."""
        self.install_mobsf_ca('install')
        proxy_port = settings.PROXY_PORT
        logger.info('Starting HTTPS Proxy on %s', proxy_port)
        stop_httptools(get_http_tools_url(request))
        start_proxy(proxy_port, project)

    def install_mobsf_ca(self, action):
        """Install or Remove MobSF Root CA."""
        mobsf_ca = get_ca_file()
        ca_file = None
        if is_file_exists(mobsf_ca):
            ca_construct = '{}.0'
            pem = open(mobsf_ca, 'rb')
            ca_obj = crypto.load_certificate(crypto.FILETYPE_PEM, pem.read())
            md = md5(ca_obj.get_subject().der()).digest()
            ret = (md[0] | (md[1] << 8) | (md[2] << 16) | md[3] << 24)
            ca_file_hash = hex(ret).lstrip('0x')
            ca_file = os.path.join('/system/etc/security/cacerts/',
                                   ca_construct.format(ca_file_hash))
            pem.close()
        else:
            logger.warning('mitmproxy root CA is not generated yet.')
            return
        if action == 'install':
            logger.info('Installing MobSF RootCA')
            self.adb_command(['push',
                              mobsf_ca,
                              ca_file])
            self.adb_command(['chmod',
                              '644',
                              ca_file], True)
        elif action == 'remove':
            logger.info('Removing MobSF RootCA')
            self.adb_command(['rm',
                              ca_file], True)
        # with a high timeout afterwards

    def set_global_proxy(self, version):
        """Set Global Proxy on device."""
        # Android 4.4+ supported
        proxy_ip = None
        proxy_port = settings.PROXY_PORT
        if version < 5:
            proxy_ip = get_proxy_ip(self.identifier)
        else:
            proxy_ip = settings.PROXY_IP
        if proxy_ip:
            if version < 4.4:
                logger.warning('Please set Android VM proxy as %s:%s',
                               proxy_ip, proxy_port)
                return
            logger.info('Setting Global Proxy for Android VM')
            self.adb_command(
                ['settings',
                 'put',
                 'global',
                 'http_proxy',
                 '{}:{}'.format(proxy_ip, proxy_port)], True)

    def unset_global_proxy(self):
        """Unset Global Proxy on device."""
        logger.info('Removing Global Proxy for Android VM')
        self.adb_command(
            ['settings',
             'delete',
             'global',
             'http_proxy'], True)
        self.adb_command(
            ['settings',
             'delete',
             'global',
             'global_http_proxy_host'], True)
        self.adb_command(
            ['settings',
             'delete',
             'global',
             'global_http_proxy_port'], True)
        self.adb_command(
            ['settings',
             'put',
             'global',
             'http_proxy',
             ':0'], True)

    def enable_adb_reverse_tcp(self, version):
        """Enable ADB Reverse TCP for Proxy."""
        # Androd 5+ supported
        if not version >= 5:
            return
        proxy_port = settings.PROXY_PORT
        logger.info('Enabling ADB Reverse TCP on %s', proxy_port)
        tcp = 'tcp:{}'.format(proxy_port)
        try:
            proc = subprocess.Popen([get_adb(),
                                     '-s', self.identifier,
                                     'reverse', tcp, tcp],
                                    stdout=subprocess.PIPE,
                                    stderr=subprocess.PIPE)
            _, stderr = proc.communicate()
            if b'error: closed' in stderr:
                logger.warning('ADB Reverse TCP works only on'
                               ' Android 5.0 and above. Please '
                               'configure a reachable IP Address'
                               ' in Android proxy settings.')
            elif stderr:
                logger.error(stderr.decode('utf-8').replace('\n', ''))
        except Exception:
            logger.exception('Enabling ADB Reverse TCP')

    def start_clipmon(self):
        """Start Clipboard Monitoring."""
        logger.info('Starting Clipboard Monitor')
        args = ['am', 'startservice',
                'opensecurity.clipdump/.ClipDumper']
        self.adb_command(args, True)

    def screen_shot(self, outfile):
        """Take Screenshot."""
        self.adb_command(['screencap',
                          '-p',
                          '/data/local/screen.png'], True, True)
        self.adb_command(['pull',
                          '/data/local/screen.png',
                          outfile], False, True)

    def screen_stream(self):
        """Screen Stream."""
        self.adb_command(['screencap',
                          '-p',
                          '/data/local/stream.png'],
                         True, True)
        out = self.adb_command(['cat', '/data/local/stream.png'], True, True)
        if out:
            return b64encode(out).decode('utf-8')
        return ''

    def android_component(self, bin_hash, comp):
        """Get APK Components."""
        try:
            anddb = StaticAnalyzerAndroid.objects.get(MD5=bin_hash)
            resp = []
            if comp == 'activities':
                resp = python_list(anddb.ACTIVITIES)
            elif comp == 'receivers':
                resp = python_list(anddb.RECEIVERS)
            elif comp == 'providers':
                resp = python_list(anddb.PROVIDERS)
            elif comp == 'services':
                resp = python_list(anddb.SERVICES)
            elif comp == 'libraries':
                resp = python_list(anddb.LIBRARIES)
            elif comp == 'exported_activities':
                resp = python_list(anddb.EXPORTED_ACTIVITIES)
            return '\n'.join(resp)
        except Exception:
            return 'Static Analysis not done.'

    def get_environment(self):
        """Identify the environment."""
        out = self.adb_command(['getprop',
                                'ro.boot.serialno'], True, False)
        out += self.adb_command(['getprop',
                                 'ro.serialno'], True, False)
        out += self.adb_command(['getprop',
                                 'ro.build.user'], True, False)
        out += self.adb_command(['getprop',
                                 'ro.manufacturer.geny-def'],
                                True, False)
        out += self.adb_command(['getprop',
                                 'ro.product.manufacturer.geny-def'],
                                True, False)
        ver = self.adb_command(['getprop',
                                'ro.genymotion.version'],
                               True, False).decode('utf-8', 'ignore')
        if b'EMULATOR' in out:
            logger.info('Found Android Studio Emulator')
            return 'emulator'
        elif (b'genymotion' in out.lower()
                or any(char.isdigit() for char in ver)):
            logger.info('Found Genymotion Android VM')
            return 'genymotion'
        elif b'corellium' in out:
            logger.info('Found Corellium ARM Android VM')
            return 'corellium'
        else:
            logger.warning(
                'Unable to identify Dynamic Analysis environment. '
                'Official support is available only for Android '
                'Emulator, Corellium, and Genymotion')
            return ''

    def get_android_version(self):
        """Get Android version."""
        out = self.adb_command(['getprop',
                                'ro.build.version.release'],
                               True, False)
        and_version = out.decode('utf-8').rstrip()
        if and_version.count('.') > 1:
            and_version = and_version.rsplit('.', 1)[0]
        if and_version.count('.') > 1:
            and_version = and_version.split('.', 1)[0]
        return float(and_version)

    def get_android_arch(self):
        """Get Android Architecture."""
        out = self.adb_command([
            'getprop',
            'ro.product.cpu.abi'], True)
        return out.decode('utf-8').rstrip()

    def get_android_sdk(self):
        """Get Android API version."""
        out = self.adb_command([
            'getprop',
            'ro.build.version.sdk'], True)
        return out.decode('utf-8').strip()

    def get_device_packages(self):
        """Get all packages from device."""
        device_packages = {}
        out = self.adb_command([
            'pm',
            'list',
            'packages',
            '-f',
            '-3'], True, True)
        if not out:
            return device_packages
        for pkg_str in out.decode('utf-8').rstrip().split():
            path_pkg = pkg_str.split('package:', 1)[1].strip()
            parts = path_pkg.split('.apk=', 1)
            apk = f'{parts[0]}.apk'
            pkg = parts[1]
            if pkg == 'opensecurity.clipdump':
                # Do not include MobSF agent
                continue
            out1 = self.adb_command([
                'md5sum',
                '-b',
                apk], True)
            md5 = out1.decode('utf-8').strip()
            if '.apk' in md5:
                # -b not respected in Android 5.0
                md5 = md5.split()[0]
            device_packages[md5] = (pkg, apk)
        return device_packages

    def download_apk_packages(self, pkg_path, out_file):
        """Download APK package(s)."""
        with tempfile.TemporaryDirectory() as temp_dir:
            # Download APK package(s)
            # Can be single or multiple packages
            out = self.adb_command([
                'pull',
                pkg_path.as_posix(),
                temp_dir,
            ])
            fmt = out.decode('utf-8').strip()
            logger.info('ADB Pull Output: %s', fmt)
            # Filter for APK files in the directory
            apk_files = []
            for f in Path(temp_dir).glob('*.apk'):
                if f.is_file():
                    apk_files.append(f)
            # Check if there is exactly one APK file
            if len(apk_files) == 1:
                shutil.move(apk_files[0], out_file)
                return 'apk'
            else:
                # If there are multiple APK files, zip them
                shutil.make_archive(out_file, 'zip', root_dir=temp_dir, base_dir='.')
                # Rename the zip file to APK
                apks_file = out_file.with_suffix('.apk')
                os.rename(out_file.as_posix() + '.zip', apks_file)
                return 'apks'

    def get_apk_packages(self, package):
        """Get all APK packages from device."""
        out = self.adb_command([
            'pm',
            'path',
            package], True)
        return out.decode('utf-8').strip()

    def get_apk_parent_directory(self, package):
        """Get parent directory of APK packages."""
        package_out = self.get_apk_packages(package)
        package_out = package_out.split()
        if ('package:' in package_out[0]
                and package_out[0].endswith('.apk')):
            path = package_out[0].split('package:', 1)[1].strip()
            return Path(path).parent
        return False

    def get_apk(self, checksum, package):
        """Download APK from device."""
        try:
            # Do not download if already exists
            out_dir = Path(settings.UPLD_DIR) / checksum
            out_dir.mkdir(parents=True, exist_ok=True)
            out_file = out_dir / f'{checksum}.apk'
            if out_file.exists():
                return 'apk'
            # Get APK package parent directory
            pkg_path = self.get_apk_parent_directory(package)
            if pkg_path:
                # Download APK package(s)
                logger.info('Downloading APK')
                return self.download_apk_packages(pkg_path, out_file)
        except Exception:
            logger.exception('Failed to download APK')
        return False

    def system_check(self, runtime):
        """Check if /system is writable."""
        try:
            try:
                api = self.get_android_sdk()
                if api:
                    logger.info('Android API Level '
                                'identified as %s', api)
                    if int(api) > ANDROID_API_SUPPORTED:
                        logger.error('This API Level is not supported'
                                     ' for Dynamic Analysis.')
                        return False
            except Exception:
                pass
            err_msg = ('VM\'s /system is not writable. '
                       'This VM cannot be used for '
                       'Dynamic Analysis.')
            proc = subprocess.Popen([get_adb(),
                                     '-s', self.identifier,
                                     'shell',
                                     'touch',
                                     '/system/test'],
                                    stdout=subprocess.PIPE,
                                    stderr=subprocess.PIPE)
            _, stderr = proc.communicate()
            if b'Read-only' in stderr:
                logger.error(err_msg)
                if runtime == 'emulator':
                    logger.error('Please start the AVD as per '
                                 'MobSF documentation!')
                return False
        except Exception:
            logger.exception('System check failed')
            logger.error(err_msg)
            return False
        return True

    def launch_n_capture(self, package, activity, outfile):
        """Launch and Capture Activity."""
        self.adb_command(['am',
                          'start',
                          '-n',
                          package + '/' + activity], True)
        sleep = getattr(settings, 'ACTIVITY_TESTER_SLEEP', 3)
        self.wait(sleep)
        self.screen_shot(outfile)
        logger.info('Activity screenshot captured')

    def run_app(self, package):
        """Launch an app with package name."""
        self.adb_command(['monkey',
                          '-p',
                          package,
                          '-c',
                          'android.intent.category.LAUNCHER',
                          '1'], True)

    def is_mobsfyied(self, android_version):
        """Check is Device is MobSFyed."""
        logger.info('Environment MobSFyed Check')
        if android_version < 5:
            agent_file = '.mobsf-x'
            agent_str = self.xposed_str
        else:
            agent_file = '.mobsf-f'
            agent_str = self.frida_str
        try:
            out = subprocess.check_output(
                [get_adb(),
                 '-s', self.identifier,
                 'shell',
                 'cat',
                 '/system/' + agent_file])
            if agent_str not in out:
                return False
        except Exception:
            return False
        return True

    def mobsfy_init(self):
        """Init MobSFy."""
        version = self.get_android_version()
        logger.info('Android Version identified as %s', version)
        try:
            if version < 5:
                self.xposed_setup(version)
                self.mobsf_agents_setup('xposed')
            else:
                self.frida_setup()
                self.mobsf_agents_setup('frida')
            logger.info('MobSFying Completed!')
            return version
        except Exception:
            logger.exception('Failed to MobSFy Android Instance')
            return False

    def mobsf_agents_setup(self, agent):
        """Setup MobSF agents."""
        create_ca()
        # Install MITM RootCA
        self.install_mobsf_ca('install')
        if agent == 'frida':
            agent_file = '.mobsf-f'
            agent_str = self.frida_str
        else:
            agent_file = '.mobsf-x'
            agent_str = self.xposed_str
        f = tempfile.NamedTemporaryFile(delete=False)
        f.write(agent_str)
        f.close()
        self.adb_command(['push', f.name, '/system/' + agent_file])
        os.unlink(f.name)

    def xposed_setup(self, android_version):
        """Setup Xposed."""
        xposed_dir = 'onDevice/xposed/'
        xposed_modules = xposed_dir + 'modules/'
        # Install MobSF Agents for Xposed
        clip_dump_apk = os.path.join(self.tools_dir,
                                     xposed_dir,
                                     'ClipDump.apk')
        logger.info('Installing MobSF Clipboard Dumper')
        self.adb_command(['install', '-r', clip_dump_apk])
        if android_version < 5:
            logger.info('Installing Xposed for Kitkat and below')
            xposed_apk = os.path.join(self.tools_dir,
                                      xposed_dir,
                                      'Xposed.apk')
            hooks = os.path.join(self.tools_dir,
                                 xposed_modules,
                                 'hooks.json')
            droidmon = os.path.join(self.tools_dir,
                                    xposed_modules,
                                    'Droidmon.apk')
            logger.info('Installing Droidmon API Analyzer')
            self.adb_command(['install', '-r', droidmon])
            logger.info('Copying Droidmon hooks config')
            self.adb_command(['push', hooks, '/data/local/tmp/'])
        else:
            logger.info('Installing Xposed for Lollipop and above')
            xposed_apk = os.path.join(self.tools_dir,
                                      xposed_dir,
                                      'XposedInstaller_3.1.5.apk')
        self.adb_command(['install', '-r', xposed_apk])
        # Xposed Modules and Support Files
        justrustme = os.path.join(self.tools_dir,
                                  xposed_modules,
                                  'JustTrustMe.apk')
        rootcloak = os.path.join(self.tools_dir,
                                 xposed_modules,
                                 'com.devadvance.rootcloak2_v18_c43b61.apk')
        proxyon = os.path.join(self.tools_dir,
                               xposed_modules,
                               'mobi.acpm.proxyon_v1_419b04.apk')
        sslunpin = os.path.join(self.tools_dir,
                                xposed_modules,
                                'mobi.acpm.sslunpinning_v2_37f44f.apk')
        bluepill = os.path.join(self.tools_dir,
                                xposed_modules,
                                'AndroidBluePill.apk')
        logger.info('Installing JustTrustMe')
        self.adb_command(['install', '-r', justrustme])
        logger.info('Installing SSLUnpinning')
        self.adb_command(['install', '-r', sslunpin])
        logger.info('Installing ProxyOn')
        self.adb_command(['install', '-r', proxyon])
        logger.info('Installing RootCloak')
        self.adb_command(['install', '-r', rootcloak])
        logger.info('Installing Android BluePill')
        self.adb_command(['install', '-r', bluepill])
        logger.info('Launching Xposed Framework.')
        xposed_installer = ('de.robv.android.xposed.installer/'
                            'de.robv.android.xposed.installer.'
                            'WelcomeActivity')
        self.adb_command(['am', 'start', '-n',
                          xposed_installer], True)

    def frida_setup(self):
        """Setup Frida."""
        frida_arch = None
        arch = self.get_android_arch()
        logger.info('Android OS architecture identified as %s', arch)
        if arch in ['armeabi-v7a', 'armeabi']:
            frida_arch = 'arm'
        elif arch == 'arm64-v8a':
            frida_arch = 'arm64'
        elif arch == 'x86':
            frida_arch = 'x86'
        elif arch == 'x86_64':
            frida_arch = 'x86_64'
        else:
            logger.error('Make sure a Genymotion Android VM'
                         ' or Android Studio Emulator'
                         ' instance is running')
            return
        frida_bin = f'frida-server-{frida_version}-android-{frida_arch}'
        stat = FridaServerUpdater(
            'android', frida_version).update_frida_server(frida_arch)
        if not stat:
            msg = ('Cannot download frida-server binary. You will need'
                   f' {frida_bin} in {settings.DWD_DIR} for '
                   'Dynamic Analysis to work')
            logger.error(msg)
            return
        frida_path = os.path.join(settings.DWD_DIR, frida_bin)
        logger.info('Copying frida server v%s for %s', frida_version, frida_arch)
        self.adb_command(['push', frida_path, '/system/fd_server'])
        self.adb_command(['chmod', '755', '/system/fd_server'], True)

    def run_frida_server(self):
        """Start Frida Server."""
        check = self.adb_command(['ps'], True)
        if b'fd_server' in check:
            logger.info('Frida Server v%s is already running', frida_version)
            return

        def start_frida():
            fnull = open(os.devnull, 'w')
            argz = [get_adb(),
                    '-s',
                    self.identifier,
                    'shell',
                    '/system/fd_server']
            subprocess.call(argz, stdout=fnull, stderr=subprocess.STDOUT)
        trd = threading.Thread(target=start_frida)
        trd.daemon = True
        trd.start()
        logger.info('Starting Frida Server v%s', frida_version)
        logger.info('Waiting for 2 seconds...')
        time.sleep(2)
