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

import ctypes
import io
import json
import logging
import os
import pandas as pd
import signal
import subprocess
import sys
import datetime
import time
import typing

import browsers
import scenarios
import utils


class DriverContext:
    """Class in charge of running the measurements and keeping track
    of the global state needed to do so.
    """

    def __init__(self, output_dir: str, power_sample_path: str):
        """
        Args:
          output_dir: A string path of Where the results should be stored.
        """

        self._output_dir = output_dir
        self._power_sample_path = power_sample_path

        # Make sure there is somewhere to put  results.
        os.makedirs(f"{self._output_dir}", exist_ok=True)

    def __enter__(self):

        self._caffeinate_process = subprocess.Popen(
            [
                "caffeinate",
                "-d",  # Prevent the display from sleeping.
            ]
        )

        # Launch a power_sampler whose job is only to simulate an active user.
        # This
        # is applicable to both profiling and measuring so should always be
        # done.
        power_sampler_args = [
            self._power_sample_path,
            "--no-samplers",
            "--simulate-user-active",
        ]
        self._power_sampler_process = subprocess.Popen(
            power_sampler_args, stdout=subprocess.PIPE, stdin=subprocess.PIPE
        )

        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        utils.TerminateProcess(self._caffeinate_process)
        utils.TerminateProcess(self._power_sampler_process)

    def SetMainDisplayBrightness(self, brightness_level: int):
        # This function imitates the open-source "brightness" tool at
        # https://github.com/nriley/brightness.
        # Since the benchmark doesn't care about older MacOSen, multiple
        # displays
        # or other complications that tool has to consider, setting the
        # brightness
        # level boils down to calling this function for the main display.
        CoreGraphics = ctypes.CDLL(
            "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics"
        )
        main_display = CoreGraphics.CGMainDisplayID()
        DisplayServices = ctypes.CDLL(
            "/System/Library/PrivateFrameworks/DisplayServices.framework"
            "/DisplayServices"
        )
        DisplayServices.DisplayServicesSetBrightness.argtypes = [
            ctypes.c_int,
            ctypes.c_float,
        ]
        DisplayServices.DisplayServicesSetBrightness(
            main_display, brightness_level / 100
        )

    def WaitBatteryNotFull(self):
        # Empirical evidence has shown that right after a full battery charge,
        # the
        # current capacity stays equal to the maximum capacity for several
        # minutes,
        # despite the fact that power is definitely consumed. To ensure that
        # power
        # consumption estimates from battery level are meaningful, wait until
        # the
        # battery is no longer reporting being fully charged before
        # benchmarking.

        power_sampler_args = [
            self._power_sample_path,
            "--sample-on-notification",
            "--samplers=battery",
            "--sample-count=1",
        ]

        logging.info('Waiting for battery to no longer be full')

        while True:
            power_sampler_output = subprocess.check_output(power_sampler_args)
            power_sampler_data = pd.read_csv(io.BytesIO(power_sampler_output))
            max_capacity = power_sampler_data.iloc[0][
                'battery_max_capacity(Ah)'
            ]
            current_capacity = power_sampler_data.iloc[0][
                'battery_current_capacity(Ah)'
            ]

            logging.info(
                f'Battery level is {100 * current_capacity / max_capacity:.2f}%'
            )

            if max_capacity != current_capacity:
                return

        logging.info('Battery is no longer full')

    def CheckEnv(self, throw_on_bad_env: bool):
        """Verifies that the environment is conducive to proper profiling or
        measurements.

        Args:
          throw_on_bad_env: False if executions continues no matter what and
          only warnings are printed.

        Raises:
          SystemExit: When the environment is invalid and throw_on_bad_env is
          True.
        """

        if throw_on_bad_env:
            logging_function = logging.error
        else:
            logging_function = logging.warning

        logging.warning("Trying sudo access. Possibly enter password:")
        sudo_check = subprocess.Popen(
            ["sudo", "ls"], stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT
        )
        sudo_check.wait()

        try:
            check_env = subprocess.run(
                [
                    'zsh',
                    '-c',
                    os.path.join(os.path.dirname(__file__), 'check_env.sh'),
                ],
                check=throw_on_bad_env,
                capture_output=True,
            )
            logging_function(check_env.stdout.decode('ascii'))
        except subprocess.CalledProcessError as e:
            logging_function(e.stdout.decode('ascii'))
            sys.exit(-1)

        # Make sure that no browsers are running which would affect the
        # tests.
        for browser_name in browsers.PROCESS_NAMES:
            if utils.FindProcess(browser_name):
                logging_function(
                    f"{browser_name} already running. "
                    "Make sure to close it before running again."
                )

                if throw_on_bad_env:
                    sys.exit(-1)

    def Record(self, scenario_driver: scenarios.ScenarioOSADriver):
        """Cover the running of the scenario with powermetrics and save
        the results

        Args:
          scenario_config: A dictionary describing the scenario.
        """

        self.WriteScenarioSummary(scenario_driver)

        powermetrics_output = os.path.join(
            self._output_dir, scenario_driver.name, "powermetrics.plist"
        )
        power_sampler_output = os.path.join(
            self._output_dir, scenario_driver.name, "power_sampler.json"
        )

        powermetrics_process = None
        power_sampler_process = None
        browser_process = None

        cycle_length_in_secs = scenario_driver.CycleDuration().total_seconds()
        if cycle_length_in_secs < 60:
            raise ValueError("Cycle length must be more than 60 seconds!")
        elif cycle_length_in_secs % 60 != 0:
            logging.warning(
                "Cycle has a duration not divisible by 60 secs."
                "This is suboptimal for measurment variance."
            )
        try:
            scenario_driver.Launch()
            if hasattr(scenario_driver, 'browser'):
                browser_process = scenario_driver.browser.browser_process

            # "-i 60000" to emit a sample every minute. This is the same
            # frequency as
            # power_sampler, which emits a sample on IOPMPowerSource
            # notification,
            # which happens every minute.
            powermetrics_args = [
                "sudo",
                "powermetrics",
                "-f",
                "plist",
                "--samplers",
                "tasks,cpu_power,gpu_power,thermal,disk,network",
                "--show-process-coalition",
                "--show-process-gpu",
                "--show-process-energy",
                "-i",
                "60000",
                "--output-file",
                powermetrics_output,
            ]

            powermetrics_process = subprocess.Popen(powermetrics_args)

            power_sampler_args = [
                self._power_sample_path,
                f"--sample-on-notification",
                f"--initial-sample",
                "--samplers=battery,smc,user_idle_level,main_display",
                f"--timeout={int(scenario_driver.duration.total_seconds())}",
                f"--json-output-file={power_sampler_output}",
            ]

            if browser_process is not None:
                power_sampler_args += [
                    f"--resource-coalition-pid={browser_process.pid}"
                ]
            power_sampler_process = subprocess.Popen(power_sampler_args)

            scenario_driver.Wait()

            logging.debug("Waiting for power_sampler to exit")
            power_sampler_process.wait()
            logging.debug(
                f"power_sampler returned {power_sampler_process.returncode}"
            )

        finally:
            scenario_driver.TearDown()
            if power_sampler_process:
                utils.TerminateProcess(power_sampler_process)
            if powermetrics_process:
                # Force powermetrics to flush data.
                utils.SendSignalToRootProcess(
                    powermetrics_process, signal.SIGIO
                )
                utils.TerminateRootProcess(powermetrics_process)

    def Profile(
        self,
        scenario_driver: scenarios.ScenarioWithBrowserOSADriver,
        profile_mode: str,
    ):
        """Cover the running of the scenario with DTrace and save the
        results.

        Args:
          profile_mode: A string describing the Profile mode between "wakeups"
          and "cpu_time".

        Raises:
          TimeoutExpired: When a DTrace process takes more than 30 seconds to
          terminate after the end of the scenario.
        """

        if scenario_driver.browser is None:
            raise ValueError("Scenario must have an associated browser.")
        if scenario_driver.browser.name not in ["chromium", "canary", "chrome"]:
            raise ValueError("Only Chromium can be profiled! Skipping.")

        self.WriteScenarioSummary(scenario_driver)

        dtraces_output_dir = os.path.join(
            self._output_dir, scenario_driver.name, f"dtraces_{profile_mode}"
        )
        os.makedirs(dtraces_output_dir, exist_ok=True)
        scenario_driver.Launch()
        browser_process = scenario_driver.browser.browser_process

        # Set up the environment for correct dtrace execution.
        dtrace_env = os.environ.copy()
        dtrace_env["DYLD_SHARED_REGION"] = "avoid"

        try:
            with open(
                os.path.join(
                    self._output_dir,
                    scenario_driver.name,
                    f'dtrace_{profile_mode}_log.txt',
                ),
                "w",
            ) as dtrace_log:
                scripts_dir = os.path.join(
                    os.path.dirname(__file__), "dtrace_scripts"
                )
                if profile_mode == "wakeups":
                    script = os.path.join(scripts_dir, "iwakeups.d")
                else:
                    script = os.path.join(scripts_dir, "profile.d")

                pid = browser_process.pid
                iteration = 1

                # Capture until the scenario is done running.
                while True:
                    output_filename = os.path.join(
                        dtraces_output_dir, f"{pid}_{iteration}.txt"
                    )
                    dtrace_args = [
                        'sudo',
                        'dtrace',
                        '-p',
                        f"{pid}",
                        "-o",
                        output_filename,
                        '-s',
                        script,
                        f"{pid}",
                    ]

                    dtrace_process = subprocess.Popen(
                        dtrace_args,
                        env=dtrace_env,
                        stdout=dtrace_log,
                        stderr=dtrace_log,
                    )

                    # This timeout was chosen experimentally to on an M1 MBA
                    # to avoid
                    # running for too long and losing samples (because of
                    # suspected
                    # bug in Dtrace) and running too little and thus having
                    # too much
                    # overhead.
                    time.sleep(7.199)
                    utils.TerminateRootProcess(dtrace_process)
                    iteration = iteration + 1

                    if scenario_driver.script_process.poll() is not None:
                        break

        finally:
            scenario_driver.TearDown()

        logging.debug(f"Waiting for dtrace to exit")

    def Trace(self, scenario_driver: scenarios.ScenarioOSADriver):
        self.WriteScenarioSummary(scenario_driver)

        try:
            scenario_driver.Launch()
            scenario_driver.Wait()
        finally:
            scenario_driver.TearDown()

    def WriteScenarioSummary(
        self, scenario_driver: scenarios.ScenarioWithBrowserOSADriver
    ):
        """Outputs a json file describing `scenario_driver` arguments into the
        output directory
        """
        os.makedirs(
            os.path.join(self._output_dir, scenario_driver.name), exist_ok=True
        )
        with open(
            os.path.join(
                self._output_dir, scenario_driver.name, 'metadata.json'
            ),
            'w',
        ) as summary_file:
            json.dump(scenario_driver.Summary(), summary_file, indent=2)
