# Copyright 2018 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import contextlib
import logging
import os
import shutil
import subprocess
import sys
import tempfile

from devil import devil_env
from devil.android import device_signal, device_errors
from devil.android.sdk import version_codes
from pylib import constants


def _ProcessType(proc):
    _, _, suffix = proc.name.partition(':')
    if not suffix:
        return 'browser'
    if suffix.startswith('sandboxed_process'):
        return 'renderer'
    if suffix.startswith('privileged_process'):
        return 'gpu'
    return None


def _GetSpecifiedPID(device, package_name, process_specifier):
    if process_specifier is None:
        return None

    # Check for numeric PID
    try:
        pid = int(process_specifier)
        return pid
    except ValueError:
        pass

    # Check for exact process name; can be any of these formats:
    #   <package>:<process name>, i.e. 'org.chromium.chrome:sandboxed_process0'
    #   :<process name>, i.e. ':sandboxed_process0'
    #   <process name>, i.e. 'sandboxed_process0'
    full_process_name = process_specifier
    if process_specifier.startswith(':'):
        full_process_name = package_name + process_specifier
    elif ':' not in process_specifier:
        full_process_name = '%s:%s' % (package_name, process_specifier)
    matching_processes = device.ListProcesses(full_process_name)
    if len(matching_processes) == 1:
        return matching_processes[0].pid
    if len(matching_processes) > 1:
        raise RuntimeError(
            'Found %d processes with name "%s".'
            % (len(matching_processes), process_specifier)
        )

    # Check for process type (i.e. 'renderer')
    package_processes = device.ListProcesses(package_name)
    matching_processes = [
        p for p in package_processes if (_ProcessType(p) == process_specifier)
    ]
    if process_specifier == 'renderer' and len(matching_processes) > 1:
        raise RuntimeError(
            'Found %d renderer processes; please re-run with only '
            'one open tab.' % len(matching_processes)
        )
    if len(matching_processes) != 1:
        raise RuntimeError(
            'Found %d processes of type "%s".'
            % (len(matching_processes), process_specifier)
        )
    return matching_processes[0].pid


def _ThreadsForProcess(device, pid):
    # The thread list output format for 'ps' is the same regardless of version.
    # Here's the column headers, and a sample line for a thread belonging to
    # pid 12345 (note that the last few columns are not aligned with headers):
    #
    # USER        PID   TID  PPID     VSZ    RSS WCHAN            ADDR S CMD
    # u0_i101   12345 24680   567 1357902  97531 futex_wait_queue_me e85acd9c S \
    #     CrRendererMain
    if device.build_version_sdk >= version_codes.OREO:
        pid_regex = (
            r'^[[:graph:]]\{1,\}[[:blank:]]\{1,\}%d[[:blank:]]\{1,\}' % pid
        )
        ps_cmd = "ps -T -e | grep '%s'" % pid_regex
        ps_output_lines = device.RunShellCommand(
            ps_cmd, shell=True, check_return=True
        )
    else:
        ps_cmd = ['ps', '-p', str(pid), '-t']
        ps_output_lines = device.RunShellCommand(ps_cmd, check_return=True)
    result = []
    for l in ps_output_lines:
        fields = l.split()
        # fields[2] is tid, fields[-1] is thread name. Output may include an entry
        # for the process itself with tid=pid; omit that one.
        if fields[2] == str(pid):
            continue
        result.append((int(fields[2]), fields[-1]))
    return result


def _ThreadType(thread_name):
    if not thread_name:
        return 'unknown'
    if thread_name.startswith('Chrome_ChildIO') or thread_name.startswith(
        'Chrome_IO'
    ):
        return 'io'
    if thread_name.startswith('Compositor'):
        return 'compositor'
    if (
        thread_name.startswith('ChildProcessMai')
        or thread_name.startswith('CrGpuMain')
        or thread_name.startswith('CrRendererMain')
    ):
        return 'main'
    if thread_name.startswith('RenderThread'):
        return 'render'
    raise ValueError('got no matching thread_name')


def _GetSpecifiedTID(device, pid, thread_specifier):
    if thread_specifier is None:
        return None

    # Check for numeric TID
    try:
        tid = int(thread_specifier)
        return tid
    except ValueError:
        pass

    # Check for thread type
    if pid is not None:
        matching_threads = [
            t
            for t in _ThreadsForProcess(device, pid)
            if (_ThreadType(t[1]) == thread_specifier)
        ]
        if len(matching_threads) != 1:
            raise RuntimeError(
                'Found %d threads of type "%s".'
                % (len(matching_threads), thread_specifier)
            )
        return matching_threads[0][0]

    return None


def PrepareDevice(device):
    if device.build_version_sdk < version_codes.NOUGAT:
        raise RuntimeError(
            'Simpleperf profiling is only supported on Android N and later.'
        )

    # Necessary for profiling
    # https://android-review.googlesource.com/c/platform/system/sepolicy/+/234400
    device.SetProp('security.perf_harden', '0')


def InstallSimpleperf(device, package_name):
    package_arch = device.GetPackageArchitecture(package_name) or 'armeabi-v7a'
    host_simpleperf_path = devil_env.config.LocalPath(
        'simpleperf', package_arch
    )
    if not host_simpleperf_path:
        raise Exception('Could not get path to simpleperf executable on host.')
    device_simpleperf_path = '/'.join(
        ('/data/local/tmp/profilers', package_arch, 'simpleperf')
    )
    device.PushChangedFiles([(host_simpleperf_path, device_simpleperf_path)])
    return device_simpleperf_path


@contextlib.contextmanager
def RunSimpleperf(
    device,
    device_simpleperf_path,
    package_name,
    process_specifier,
    thread_specifier,
    events,
    profiler_args,
    host_out_path,
):
    pid = _GetSpecifiedPID(device, package_name, process_specifier)
    tid = _GetSpecifiedTID(device, pid, thread_specifier)
    if pid is None and tid is None:
        raise RuntimeError(
            'Could not find specified process/thread running on '
            'device. Make sure the apk is already running before '
            'attempting to profile.'
        )
    profiler_args = list(profiler_args)
    if profiler_args and profiler_args[0] == 'record':
        profiler_args.pop(0)
    profiler_args.extend(('-e', events))
    if '--call-graph' not in profiler_args and '-g' not in profiler_args:
        profiler_args.append('-g')
    if '-f' not in profiler_args:
        profiler_args.extend(('-f', '1000'))

    device_out_path = '/data/local/tmp/perf.data'
    should_remove_device_out_path = True
    if '-o' in profiler_args:
        device_out_path = profiler_args[profiler_args.index('-o') + 1]
        should_remove_device_out_path = False
    else:
        profiler_args.extend(('-o', device_out_path))

    # Remove the default output to avoid confusion if simpleperf opts not
    # to update the file.
    file_exists = True
    try:
        device.adb.Shell('readlink -e ' + device_out_path)
    except device_errors.AdbCommandFailedError:
        file_exists = False
    if file_exists:
        logging.warning(
            '%s output file already exists on device', device_out_path
        )
        if not should_remove_device_out_path:
            raise RuntimeError(
                'Specified output file \'{}\' already exists, not '
                'continuing'.format(device_out_path)
            )
    device.adb.Shell('rm -f ' + device_out_path)

    if tid:
        profiler_args.extend(('-t', str(tid)))
    else:
        profiler_args.extend(('-p', str(pid)))

    adb_shell_simpleperf_process = device.adb.StartShell(
        [device_simpleperf_path, 'record'] + profiler_args
    )

    completed = False
    try:
        yield
        completed = True

    finally:
        device.KillAll(
            'simpleperf', signum=device_signal.SIGINT, blocking=True, quiet=True
        )
        if completed:
            adb_shell_simpleperf_process.wait()
            ret = adb_shell_simpleperf_process.returncode
            if ret == 0:
                # Successfully gathered a profile
                device.PullFile(device_out_path, host_out_path)
            else:
                logging.warning(
                    'simpleperf exited unusually, expected exit 0, got %d', ret
                )
                stdout, stderr = adb_shell_simpleperf_process.communicate()
                logging.info('stdout: \'%s\', stderr: \'%s\'', stdout, stderr)
                raise RuntimeError(
                    'simpleperf exited with unexpected code {} '
                    '(run with -vv for full stdout/stderr)'.format(ret)
                )


def ConvertSimpleperfToPprof(
    simpleperf_out_path, build_directory, pprof_out_path
):
    # The simpleperf scripts require the unstripped libs to be installed in the
    # same directory structure as the libs on the device. Much of the logic here
    # is just figuring out and creating the necessary directory structure, and
    # symlinking the unstripped shared libs.

    # Get the set of libs that we can symbolize
    unstripped_lib_dir = os.path.join(build_directory, 'lib.unstripped')
    unstripped_libs = set(
        f for f in os.listdir(unstripped_lib_dir) if f.endswith('.so')
    )

    # report.py will show the directory structure above the shared libs;
    # that is the directory structure we need to recreate on the host.
    script_dir = devil_env.config.LocalPath('simpleperf_scripts')
    report_path = os.path.join(script_dir, 'report.py')
    report_cmd = [sys.executable, report_path, '-i', simpleperf_out_path]
    device_lib_path = None
    output = subprocess.check_output(report_cmd, stderr=subprocess.STDOUT)
    if isinstance(output, bytes):
        output = output.decode()
    for line in output.splitlines():
        fields = line.split()
        if len(fields) < 5:
            continue
        shlib_path = fields[4]
        shlib_dirname, shlib_basename = shlib_path.rpartition('/')[::2]
        if shlib_basename in unstripped_libs:
            device_lib_path = shlib_dirname
            break
    if not device_lib_path:
        raise RuntimeError(
            'No chrome-related symbols in profiling data in %s. '
            'Either the process was idle for the entire profiling '
            'period, or something went very wrong (and you should '
            'file a bug at crbug.com/new with component '
            'Speed>Tracing, and assign it to szager@chromium.org).'
            % simpleperf_out_path
        )

    # Recreate the directory structure locally, and symlink unstripped libs.
    processing_dir = tempfile.mkdtemp()
    try:
        processing_lib_dir = os.path.join(
            processing_dir, 'binary_cache', device_lib_path.lstrip('/')
        )
        os.makedirs(processing_lib_dir)
        for lib in unstripped_libs:
            unstripped_lib_path = os.path.join(unstripped_lib_dir, lib)
            processing_lib_path = os.path.join(processing_lib_dir, lib)
            os.symlink(unstripped_lib_path, processing_lib_path)

        # Run the script to annotate symbols and convert from simpleperf format to
        # pprof format.
        pprof_converter_script = os.path.join(
            script_dir, 'pprof_proto_generator.py'
        )
        pprof_converter_cmd = [
            sys.executable,
            pprof_converter_script,
            '-i',
            simpleperf_out_path,
            '-o',
            os.path.abspath(pprof_out_path),
            '--ndk_path',
            constants.ANDROID_NDK_ROOT,
        ]
        subprocess.check_output(
            pprof_converter_cmd, stderr=subprocess.STDOUT, cwd=processing_dir
        )
    finally:
        shutil.rmtree(processing_dir, ignore_errors=True)
