#!/usr/bin/env python
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at http://mozilla.org/MPL/2.0/.

import copy
import glob
import json
import os
import posixpath
import subprocess
import sys
import time

# load modules from parent dir
sys.path.insert(1, os.path.dirname(sys.path[0]))

from mozharness.base.script import BaseScript, PreScriptAction
from mozharness.mozilla.automation import EXIT_STATUS_DICT, TBPL_RETRY
from mozharness.mozilla.mozbase import MozbaseMixin
from mozharness.mozilla.testing.android import AndroidMixin
from mozharness.mozilla.testing.testbase import TestingMixin, testing_config_options

PAGES = [
    "js-input/webkit/PerformanceTests/Speedometer/index.html",
    "js-input/webkit/PerformanceTests/Speedometer3/index.html?startAutomatically=true",
    # TODO: Add support for the pgo-extended-corpus to get JetStream3 running here.
    "blueprint/sample.html",
    "blueprint/forms.html",
    "blueprint/grid.html",
    "blueprint/elements.html",
    "js-input/3d-thingy.html",
    "js-input/crypto-otp.html",
    "js-input/collator_bench.html",
    "js-input/normalizer_bench.html",
    "js-input/sunspider/3d-cube.html",
    "js-input/sunspider/3d-morph.html",
    "js-input/sunspider/3d-raytrace.html",
    "js-input/sunspider/access-binary-trees.html",
    "js-input/sunspider/access-fannkuch.html",
    "js-input/sunspider/access-nbody.html",
    "js-input/sunspider/access-nsieve.html",
    "js-input/sunspider/bitops-3bit-bits-in-byte.html",
    "js-input/sunspider/bitops-bits-in-byte.html",
    "js-input/sunspider/bitops-bitwise-and.html",
    "js-input/sunspider/bitops-nsieve-bits.html",
    "js-input/sunspider/controlflow-recursive.html",
    "js-input/sunspider/crypto-aes.html",
    "js-input/sunspider/crypto-md5.html",
    "js-input/sunspider/crypto-sha1.html",
    "js-input/sunspider/date-format-tofte.html",
    "js-input/sunspider/date-format-xparb.html",
    "js-input/sunspider/math-cordic.html",
    "js-input/sunspider/math-partial-sums.html",
    "js-input/sunspider/math-spectral-norm.html",
    "js-input/sunspider/regexp-dna.html",
    "js-input/sunspider/string-base64.html",
    "js-input/sunspider/string-fasta.html",
    "js-input/sunspider/string-tagcloud.html",
    "js-input/sunspider/string-unpack-code.html",
    "js-input/sunspider/string-validate-input.html",
]


class AndroidProfileRun(TestingMixin, BaseScript, MozbaseMixin, AndroidMixin):
    """
    Mozharness script to generate an android PGO profile using the emulator
    """

    config_options = copy.deepcopy(testing_config_options)

    def __init__(self, require_config_file=False):
        super().__init__(
            config_options=self.config_options,
            all_actions=[
                "download",
                "create-virtualenv",
                "start-emulator",
                "verify-device",
                "install",
                "run-tests",
            ],
            require_config_file=require_config_file,
            config={
                "virtualenv_modules": [],
                "virtualenv_requirements": [],
                "require_test_zip": True,
                "mozbase_requirements": "mozbase_source_requirements.txt",
            },
        )

        # these are necessary since self.config is read only
        c = self.config
        self.installer_path = c.get("installer_path")
        self.device_serial = "emulator-5554"

    def query_abs_dirs(self):
        if self.abs_dirs:
            return self.abs_dirs
        abs_dirs = super().query_abs_dirs()
        dirs = {}

        dirs["abs_test_install_dir"] = os.path.join(abs_dirs["abs_src_dir"], "testing")
        # On macOS, use checkout dir; on Linux, use /builds/worker
        if sys.platform == "darwin":
            base_dir = abs_dirs["abs_src_dir"]
        else:
            base_dir = "/builds/worker"
        dirs["abs_artifacts_dir"] = os.path.join(base_dir, "artifacts")
        dirs["abs_blob_upload_dir"] = os.path.join(
            dirs["abs_artifacts_dir"], "blobber_upload_dir"
        )
        dirs["abs_workspace_dir"] = os.path.join(base_dir, "workspace")
        work_dir = os.environ.get("MOZ_FETCHES_DIR") or abs_dirs["abs_work_dir"]
        dirs["abs_xre_dir"] = os.path.join(work_dir, "hostutils")
        sdk_dirname = (
            "android-sdk-macosx" if sys.platform == "darwin" else "android-sdk-linux"
        )
        dirs["abs_sdk_dir"] = os.path.join(work_dir, sdk_dirname)
        dirs["abs_avds_dir"] = os.path.join(work_dir, "android-device")
        dirs["abs_bundletool_path"] = os.path.join(work_dir, "bundletool.jar")

        for key in dirs.keys():
            if key not in abs_dirs:
                abs_dirs[key] = dirs[key]
        self.abs_dirs = abs_dirs
        return self.abs_dirs

    ##########################################
    # Actions for AndroidProfileRun        #
    ##########################################

    def preflight_install(self):
        # in the base class, this checks for mozinstall, but we don't use it
        pass

    @PreScriptAction("create-virtualenv")
    def pre_create_virtualenv(self, action):
        dirs = self.query_abs_dirs()
        self.register_virtualenv_module(
            "marionette",
            os.path.join(dirs["abs_test_install_dir"], "marionette", "client"),
        )

    def download(self):
        """
        Download host utilities
        """
        dirs = self.query_abs_dirs()
        self.xre_path = dirs["abs_xre_dir"]

    def install(self):
        """
        Install APKs on the device.
        """
        assert self.installer_path is not None, (
            "Either add installer_path to the config or use --installer-path."
        )
        self.install_android_app(self.installer_path)
        self.info("Finished installing apps for %s" % self.device_serial)

    def _wait_for_process(self, adbdevice, process_name, running, timeout=10):
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            try:
                output = adbdevice.shell_output(f"pgrep {process_name}").strip()
                found = bool(output)
            except Exception:
                found = False
            if found == running:
                return True
            time.sleep(0.5)
        return False

    def _stop_and_pull_recording(self, adbdevice, recording_path, video_filename):
        try:
            self.info("Stopping screen recording...")
            try:
                adbdevice.shell_output("pkill -SIGINT screenrecord", timeout=30)
            except Exception:
                # pkill exits with 1 if no process was found; ignore and still pull
                pass
            if not self._wait_for_process(
                adbdevice, "screenrecord", running=False, timeout=15
            ):
                self.warning("Timed out waiting for screenrecord to stop")
            dirs = self.query_abs_dirs()
            blob_dir = dirs["abs_blob_upload_dir"]
            os.makedirs(blob_dir, exist_ok=True)
            video_dest = os.path.join(blob_dir, video_filename)
            adbdevice.pull(recording_path, video_dest)
            self.info(f"Screen recording saved to {video_dest}")
            adbdevice.shell_output(f"rm {recording_path}")
        except Exception as e:
            self.warning(f"Failed to stop/pull screen recording: {e}")

    def run_tests(self):
        """
        Generate the PGO profile data
        """
        from marionette_driver.marionette import Marionette
        from mozdevice import ADBDeviceFactory, ADBTimeoutError
        from mozhttpd import MozHttpd
        from mozprofile import Preferences

        app = self.query_package_name()

        IP = "10.0.2.2"
        PORT = 8888

        PATH_MAPPINGS = {
            "/js-input/webkit/PerformanceTests": "third_party/webkit/PerformanceTests",
        }

        dirs = self.query_abs_dirs()
        topsrcdir = dirs["abs_src_dir"]
        adb = self.query_exe("adb")

        path_mappings = {
            k: os.path.join(topsrcdir, v) for k, v in PATH_MAPPINGS.items()
        }
        httpd = MozHttpd(
            port=PORT,
            docroot=os.path.join(topsrcdir, "build", "pgo"),
            path_mappings=path_mappings,
        )
        httpd.start(block=False)

        profile_data_dir = os.path.join(topsrcdir, "testing", "profiles")
        with open(os.path.join(profile_data_dir, "profiles.json")) as fh:
            base_profiles = json.load(fh)["profileserver"]

        prefpaths = [
            os.path.join(profile_data_dir, profile, "user.js")
            for profile in base_profiles
        ]

        prefs = {}
        for path in prefpaths:
            prefs.update(Preferences.read_prefs(path))

        interpolation = {"server": "%s:%d" % httpd.httpd.server_address, "OOP": "false"}
        for k, v in prefs.items():
            if isinstance(v, str):
                v = v.format(**interpolation)
            prefs[k] = Preferences.cast(v)

        adbdevice = ADBDeviceFactory(adb=adb, device="emulator-5554")

        outputdir = posixpath.join(adbdevice.test_root, "pgo_profile")
        jarlog = posixpath.join(outputdir, "en-US.log")
        profdata = posixpath.join(outputdir, "default_%p_random_%m.profraw")

        env = {}
        env["XPCOM_DEBUG_BREAK"] = "warn"
        env["MOZ_IN_AUTOMATION"] = "1"
        env["MOZ_JAR_LOG_FILE"] = jarlog
        env["LLVM_PROFILE_FILE"] = profdata

        if self.query_minidump_stackwalk():
            os.environ["MINIDUMP_STACKWALK"] = self.minidump_stackwalk_path
        os.environ["MINIDUMP_SAVE_PATH"] = self.query_abs_dirs()["abs_blob_upload_dir"]
        if not self.symbols_path:
            self.symbols_path = os.environ.get("MOZ_FETCHES_DIR")

        adbdevice.mkdir(outputdir, parents=True)

        # Start screen recording of PGO training
        recording_path = "/sdcard/pgo-training.mp4"
        recording_started = False
        try:
            self.info("Starting screen recording of PGO training session...")
            sdk = int(
                adbdevice.shell_output(
                    "getprop ro.build.version.sdk", timeout=5
                ).strip()
            )
            # --time-limit > 180 is only supported on Android 14 (API 34) and above
            time_limit_arg = "--time-limit 900 " if sdk >= 34 else ""
            adbdevice.shell_output(
                f"sh -c 'screenrecord --verbose {time_limit_arg}{recording_path} > /dev/null 2>&1 &'",
                timeout=10,
            )
            if self._wait_for_process(
                adbdevice, "screenrecord", running=True, timeout=10
            ):
                recording_started = True
                self.info("Screen recording started")
            else:
                self.warning("screenrecord did not start within timeout")
        except Exception as e:
            self.warning(f"Failed to start screen recording: {e}")

        try:
            # Run Fennec a first time to initialize its profile
            driver = Marionette(
                app="fennec",
                package_name=app,
                adb_path=adb,
                bin="geckoview-androidTest.apk",
                prefs=prefs,
                connect_to_running_emulator=True,
                startup_timeout=1000,
                env=env,
                symbols_path=self.symbols_path,
            )
            driver.start_session()

            # Now generate the profile and wait for it to complete
            for page in PAGES:
                driver.navigate("http://%s:%d/%s" % (IP, PORT, page))
                timeout = 2
                if "Speedometer" in page:
                    # The Speedometer[23] test actually runs many tests internally in
                    # javascript, so it needs extra time to run through them. The
                    # emulator doesn't get very far through the whole suite, but
                    # this extra time at least lets some of them process.
                    timeout = 360
                time.sleep(timeout)

            driver.quit(in_app=True)

            if recording_started:
                self._stop_and_pull_recording(
                    adbdevice, recording_path, "pgo-training.mp4"
                )

            # Pull all the profraw files and en-US.log
            dirs = self.query_abs_dirs()
            workspace_dir = dirs["abs_workspace_dir"]
            os.makedirs(workspace_dir, exist_ok=True)
            adbdevice.pull(outputdir, workspace_dir)
        except ADBTimeoutError:
            if recording_started:
                self._stop_and_pull_recording(
                    adbdevice, recording_path, "pgo-training-partial.mp4"
                )
            self.fatal(
                "INFRA-ERROR: Failed with an ADBTimeoutError",
                EXIT_STATUS_DICT[TBPL_RETRY],
            )

        dirs = self.query_abs_dirs()
        workspace_dir = dirs["abs_workspace_dir"]
        profraw_files = glob.glob(os.path.join(workspace_dir, "*.profraw"))
        if not profraw_files:
            self.fatal(f"Could not find any profraw files in {workspace_dir}")
        elif len(profraw_files) == 1:
            self.fatal(
                "Only found 1 profraw file. Did child processes terminate early?"
            )
        merged_profdata = os.path.join(workspace_dir, "merged.profdata")
        merge_cmd = [
            os.path.join(os.environ["MOZ_FETCHES_DIR"], "clang/bin/llvm-profdata"),
            "merge",
            "-o",
            merged_profdata,
        ] + profraw_files
        rc = subprocess.call(merge_cmd)
        if rc != 0:
            self.fatal(
                "INFRA-ERROR: Failed to merge profile data. Corrupt profile?",
                EXIT_STATUS_DICT[TBPL_RETRY],
            )

        # tarfile doesn't support xz in this version of Python
        artifacts_dir = dirs["abs_artifacts_dir"]
        os.makedirs(artifacts_dir, exist_ok=True)
        tar_cmd = [
            "tar",
            "-acvf",
            os.path.join(artifacts_dir, "profdata.tar.xz"),
            "-C",
            workspace_dir,
            "merged.profdata",
            "en-US.log",
        ]
        subprocess.check_call(tar_cmd)

        httpd.stop()


if __name__ == "__main__":
    test = AndroidProfileRun()
    test.run_and_exit()
