# 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/.

from abc import ABCMeta, abstractmethod, abstractproperty
from argparse import ArgumentParser, SUPPRESS
from urlparse import urlparse
import os
import tempfile

from droid import DroidADB, DroidSUT
from mozprofile import DEFAULT_PORTS
import mozinfo
import mozlog
import moznetwork


here = os.path.abspath(os.path.dirname(__file__))

try:
    from mozbuild.base import (
        MozbuildObject,
        MachCommandConditions as conditions,
    )
    build_obj = MozbuildObject.from_environment(cwd=here)
except ImportError:
    build_obj = None
    conditions = None


VMWARE_RECORDING_HELPER_BASENAME = "vmwarerecordinghelper"


class ArgumentContainer():
    __metaclass__ = ABCMeta

    @abstractproperty
    def args(self):
        pass

    @abstractproperty
    def defaults(self):
        pass

    @abstractmethod
    def validate(self, parser, args, context):
        pass

    def get_full_path(self, path, cwd):
        """Get an absolute path relative to cwd."""
        return os.path.normpath(os.path.join(cwd, os.path.expanduser(path)))


class MochitestArguments(ArgumentContainer):
    """General mochitest arguments."""

    LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "FATAL")
    LEVEL_STRING = ", ".join(LOG_LEVELS)

    args = [
        [["test_paths"],
         {"nargs": "*",
          "metavar": "TEST",
          "default": [],
          "help": "Test to run. Can be a single test file or a directory of tests "
                  "(to run recursively). If omitted, the entire suite is run.",
          }],
        [["--keep-open"],
         {"action": "store_false",
          "dest": "closeWhenDone",
          "default": True,
          "help": "Always keep the browser open after tests complete.",
          }],
        [["--appname"],
         {"dest": "app",
          "default": None,
          "help": "Override the default binary used to run tests with the path provided, e.g "
                  "/usr/bin/firefox. If you have run ./mach package beforehand, you can "
                  "specify 'dist' to run tests against the distribution bundle's binary.",
          "suppress": build_obj is not None,
          }],
        [["--utility-path"],
         {"dest": "utilityPath",
          "default": build_obj.bindir if build_obj is not None else None,
          "help": "absolute path to directory containing utility programs "
                  "(xpcshell, ssltunnel, certutil)",
          "suppress": True,
          }],
        [["--certificate-path"],
         {"dest": "certPath",
          "default": None,
          "help": "absolute path to directory containing certificate store to use testing profile",
          "suppress": True,
          }],
        [["--no-autorun"],
         {"action": "store_false",
          "dest": "autorun",
          "default": True,
          "help": "Do not start running tests automatically.",
          }],
        [["--timeout"],
         {"type": int,
          "default": None,
          "help": "The per-test timeout in seconds (default: 60 seconds).",
          }],
        [["--max-timeouts"],
         {"type": int,
          "dest": "maxTimeouts",
          "default": None,
          "help": "The maximum number of timeouts permitted before halting testing.",
          }],
        [["--total-chunks"],
         {"type": int,
          "dest": "totalChunks",
          "help": "Total number of chunks to split tests into.",
          "default": None,
          }],
        [["--this-chunk"],
         {"type": int,
          "dest": "thisChunk",
          "help": "If running tests by chunks, the chunk number to run.",
          "default": None,
          }],
        [["--chunk-by-runtime"],
         {"action": "store_true",
          "dest": "chunkByRuntime",
          "help": "Group tests such that each chunk has roughly the same runtime.",
          "default": False,
          }],
        [["--chunk-by-dir"],
         {"type": int,
          "dest": "chunkByDir",
          "help": "Group tests together in the same chunk that are in the same top "
                  "chunkByDir directories.",
          "default": 0,
          }],
        [["--run-by-dir"],
         {"action": "store_true",
          "dest": "runByDir",
          "help": "Run each directory in a single browser instance with a fresh profile.",
          "default": False,
          }],
        [["--shuffle"],
         {"action": "store_true",
          "help": "Shuffle execution order of tests.",
          "default": False,
          }],
        [["--console-level"],
         {"dest": "consoleLevel",
          "choices": LOG_LEVELS,
          "default": "INFO",
          "help": "One of %s to determine the level of console logging." % LEVEL_STRING,
          "suppress": True,
          }],
        [["--chrome"],
         {"action": "store_true",
          "default": False,
          "help": "Run chrome mochitests.",
          "suppress": True,
          }],
        [["--ipcplugins"],
         {"action": "store_true",
          "dest": "ipcplugins",
          "help": "Run ipcplugins mochitests.",
          "default": False,
          "suppress": True,
          }],
        [["--bisect-chunk"],
         {"dest": "bisectChunk",
          "default": None,
          "help": "Specify the failing test name to find the previous tests that may be "
                  "causing the failure.",
          }],
        [["--start-at"],
         {"dest": "startAt",
          "default": "",
          "help": "Start running the test sequence at this test.",
          }],
        [["--end-at"],
         {"dest": "endAt",
          "default": "",
          "help": "Stop running the test sequence at this test.",
          }],
        [["--browser-chrome"],
         {"action": "store_true",
          "dest": "browserChrome",
          "default": False,
          "help": "run browser chrome Mochitests",
          "suppress": True,
          }],
        [["--subsuite"],
         {"default": None,
          "help": "Subsuite of tests to run. Unlike tags, subsuites also remove tests from "
                  "the default set. Only one can be specified at once.",
          }],
        [["--jetpack-package"],
         {"action": "store_true",
          "dest": "jetpackPackage",
          "help": "Run jetpack package tests.",
          "default": False,
          "suppress": True,
          }],
        [["--jetpack-addon"],
         {"action": "store_true",
          "dest": "jetpackAddon",
          "help": "Run jetpack addon tests.",
          "default": False,
          "suppress": True,
          }],
        [["--webapprt-content"],
         {"action": "store_true",
          "dest": "webapprtContent",
          "help": "Run WebappRT content tests.",
          "default": False,
          "suppress": True,
          }],
        [["--webapprt-chrome"],
         {"action": "store_true",
          "dest": "webapprtChrome",
          "help": "Run WebappRT chrome tests.",
          "default": False,
          "suppress": True,
          }],
        [["--a11y"],
         {"action": "store_true",
          "help": "Run accessibility Mochitests.",
          "default": False,
          "suppress": True,
          }],
        [["--setenv"],
         {"action": "append",
          "dest": "environment",
          "metavar": "NAME=VALUE",
          "default": [],
          "help": "Sets the given variable in the application's environment.",
          }],
        [["--exclude-extension"],
         {"action": "append",
          "dest": "extensionsToExclude",
          "default": [],
          "help": "Excludes the given extension from being installed in the test profile.",
          "suppress": True,
          }],
        [["--browser-arg"],
         {"action": "append",
          "dest": "browserArgs",
          "default": [],
          "help": "Provides an argument to the test application (e.g Firefox).",
          "suppress": True,
          }],
        [["--leak-threshold"],
         {"type": int,
          "dest": "defaultLeakThreshold",
          "default": 0,
          "help": "Fail if the number of bytes leaked in default processes through "
                  "refcounted objects (or bytes in classes with MOZ_COUNT_CTOR and "
                  "MOZ_COUNT_DTOR) is greater than the given number.",
          "suppress": True,
          }],
        [["--fatal-assertions"],
         {"action": "store_true",
          "dest": "fatalAssertions",
          "default": False,
          "help": "Abort testing whenever an assertion is hit (requires a debug build to "
                  "be effective).",
          "suppress": True,
          }],
        [["--extra-profile-file"],
         {"action": "append",
          "dest": "extraProfileFiles",
          "default": [],
          "help": "Copy specified files/dirs to testing profile. Can be specified more "
                  "than once.",
          "suppress": True,
          }],
        [["--install-extension"],
         {"action": "append",
          "dest": "extensionsToInstall",
          "default": [],
          "help": "Install the specified extension in the testing profile. Can be a path "
                  "to a .xpi file.",
          }],
        [["--profile-path"],
         {"dest": "profilePath",
          "default": None,
          "help": "Directory where the profile will be stored. This directory will be "
                  "deleted after the tests are finished.",
          "suppress": True,
          }],
        [["--testing-modules-dir"],
         {"dest": "testingModulesDir",
          "default": None,
          "help": "Directory where testing-only JS modules are located.",
          "suppress": True,
          }],
        [["--use-vmware-recording"],
         {"action": "store_true",
          "dest": "vmwareRecording",
          "default": False,
          "help": "Enables recording while the application is running inside a VMware "
                  "Workstation 7.0 or later VM.",
          "suppress": True,
          }],
        [["--repeat"],
         {"type": int,
          "default": 0,
          "help": "Repeat the tests the given number of times.",
          }],
        [["--run-until-failure"],
         {"action": "store_true",
          "dest": "runUntilFailure",
          "default": False,
          "help": "Run tests repeatedly but stop the first time a test fails. Default cap "
                  "is 30 runs, which can be overridden with the --repeat parameter.",
          }],
        [["--manifest"],
         {"dest": "manifestFile",
          "default": None,
          "help": "Path to a manifestparser (.ini formatted) manifest of tests to run.",
          "suppress": True,
          }],
        [["--testrun-manifest-file"],
         {"dest": "testRunManifestFile",
          "default": 'tests.json',
          "help": "Overrides the default filename of the tests.json manifest file that is "
                  "generated by the harness and used by SimpleTest. Only useful when running "
                  "multiple test runs simulatenously on the same machine.",
          "suppress": True,
          }],
        [["--failure-file"],
         {"dest": "failureFile",
          "default": None,
          "help": "Filename of the output file where we can store a .json list of failures "
                  "to be run in the future with --run-only-tests.",
          "suppress": True,
          }],
        [["--run-slower"],
         {"action": "store_true",
          "dest": "runSlower",
          "default": False,
          "help": "Delay execution between tests.",
          }],
        [["--metro-immersive"],
         {"action": "store_true",
          "dest": "immersiveMode",
          "default": False,
          "help": "Launches tests in an immersive browser.",
          "suppress": True,
          }],
        [["--httpd-path"],
         {"dest": "httpdPath",
          "default": None,
          "help": "Path to the httpd.js file.",
          "suppress": True,
          }],
        [["--setpref"],
         {"action": "append",
          "metavar": "PREF=VALUE",
          "default": [],
          "dest": "extraPrefs",
          "help": "Defines an extra user preference.",
          }],
        [["--jsdebugger"],
         {"action": "store_true",
          "default": False,
          "help": "Start the browser JS debugger before running the test. Implies --no-autorun.",
          }],
        [["--debug-on-failure"],
         {"action": "store_true",
          "default": False,
          "dest": "debugOnFailure",
          "help": "Breaks execution and enters the JS debugger on a test failure. Should "
                  "be used together with --jsdebugger."
          }],
        [["--e10s"],
         {"action": "store_true",
          "default": False,
          "help": "Run tests with electrolysis preferences and test filtering enabled.",
          }],
        [["--strict-content-sandbox"],
         {"action": "store_true",
          "default": False,
          "dest": "strictContentSandbox",
          "help": "Run tests with a more strict content sandbox (Windows only).",
          "suppress": not mozinfo.isWin,
          }],
        [["--nested_oop"],
         {"action": "store_true",
          "default": False,
          "help": "Run tests with nested_oop preferences and test filtering enabled.",
          }],
        [["--dmd"],
         {"action": "store_true",
          "default": False,
          "help": "Run tests with DMD active.",
          }],
        [["--dmd-path"],
         {"default": None,
          "dest": "dmdPath",
          "help": "Specifies the path to the directory containing the shared library for DMD.",
          "suppress": True,
          }],
        [["--dump-output-directory"],
         {"default": None,
          "dest": "dumpOutputDirectory",
          "help": "Specifies the directory in which to place dumped memory reports.",
          }],
        [["--dump-about-memory-after-test"],
         {"action": "store_true",
          "default": False,
          "dest": "dumpAboutMemoryAfterTest",
          "help": "Dump an about:memory log after each test in the directory specified "
                  "by --dump-output-directory.",
          }],
        [["--dump-dmd-after-test"],
         {"action": "store_true",
          "default": False,
          "dest": "dumpDMDAfterTest",
          "help": "Dump a DMD log after each test in the directory specified "
                  "by --dump-output-directory.",
          }],
        [["--slowscript"],
         {"action": "store_true",
          "default": False,
          "help": "Do not set the JS_DISABLE_SLOW_SCRIPT_SIGNALS env variable; "
                  "when not set, recoverable but misleading SIGSEGV instances "
                  "may occur in Ion/Odin JIT code.",
          }],
        [["--screenshot-on-fail"],
         {"action": "store_true",
          "default": False,
          "dest": "screenshotOnFail",
          "help": "Take screenshots on all test failures. Set $MOZ_UPLOAD_DIR to a directory "
                  "for storing the screenshots."
          }],
        [["--quiet"],
         {"action": "store_true",
          "dest": "quiet",
          "default": False,
          "help": "Do not print test log lines unless a failure occurs.",
          }],
        [["--pidfile"],
         {"dest": "pidFile",
          "default": "",
          "help": "Name of the pidfile to generate.",
          "suppress": True,
          }],
        [["--use-test-media-devices"],
         {"action": "store_true",
          "default": False,
          "dest": "useTestMediaDevices",
          "help": "Use test media device drivers for media testing.",
          }],
        [["--gmp-path"],
         {"default": None,
          "help": "Path to fake GMP plugin. Will be deduced from the binary if not passed.",
          "suppress": True,
          }],
        [["--xre-path"],
         {"dest": "xrePath",
          "default": None,    # individual scripts will set a sane default
          "help": "Absolute path to directory containing XRE (probably xulrunner).",
          "suppress": True,
          }],
        [["--symbols-path"],
         {"dest": "symbolsPath",
          "default": None,
          "help": "Absolute path to directory containing breakpad symbols, or the URL of a "
                  "zip file containing symbols",
          "suppress": True,
          }],
        [["--debugger"],
         {"default": None,
          "help": "Debugger binary to run tests in. Program name or path.",
          }],
        [["--debugger-args"],
         {"dest": "debuggerArgs",
          "default": None,
          "help": "Arguments to pass to the debugger.",
          }],
        [["--debugger-interactive"],
         {"action": "store_true",
          "dest": "debuggerInteractive",
          "default": None,
          "help": "Prevents the test harness from redirecting stdout and stderr for "
                  "interactive debuggers.",
          "suppress": True,
          }],
        [["--tag"],
         {"action": "append",
          "dest": "test_tags",
          "default": None,
          "help": "Filter out tests that don't have the given tag. Can be used multiple "
                  "times in which case the test must contain at least one of the given tags.",
          }],
        [["--enable-cpow-warnings"],
         {"action": "store_true",
          "dest": "enableCPOWWarnings",
          "help": "Enable logging of unsafe CPOW usage, which is disabled by default for tests",
          "suppress": True,
          }],
    ]

    defaults = {
        # Bug 1065098 - The geckomediaplugin process fails to produce a leak
        # log for some reason.
        'ignoreMissingLeaks': ["geckomediaplugin"],

        # Set server information on the args object
        'webServer': '127.0.0.1',
        'httpPort': DEFAULT_PORTS['http'],
        'sslPort': DEFAULT_PORTS['https'],
        'webSocketPort': '9988',
        # The default websocket port is incorrect in mozprofile; it is
        # set to the SSL proxy setting. See:
        # see https://bugzilla.mozilla.org/show_bug.cgi?id=916517
        # args.webSocketPort = DEFAULT_PORTS['ws']
    }

    def validate(self, parser, options, context):
        """Validate generic options."""

        # for test manifest parsing.
        mozinfo.update({"strictContentSandbox": options.strictContentSandbox})
        # for test manifest parsing.
        mozinfo.update({"nested_oop": options.nested_oop})

        # b2g and android don't use 'app' the same way, so skip validation
        if parser.app not in ('b2g', 'android'):
            if options.app is None:
                if build_obj:
                    options.app = build_obj.get_binary_path()
                else:
                    parser.error(
                        "could not find the application path, --appname must be specified")
            elif options.app == "dist" and build_obj:
                options.app = build_obj.get_binary_path(where='staged-package')

            options.app = self.get_full_path(options.app, parser.oldcwd)
            if not os.path.exists(options.app):
                parser.error("Error: Path {} doesn't exist. Are you executing "
                             "$objdir/_tests/testing/mochitest/runtests.py?".format(
                                 options.app))

        if options.gmp_path is None and options.app and build_obj:
            # Need to fix the location of gmp_fake which might not be shipped in the binary
            gmp_modules = (
                ('gmp-fake', '1.0'),
                ('gmp-clearkey', '0.1'),
                ('gmp-fakeopenh264', '1.0')
            )
            options.gmp_path = os.pathsep.join(
                os.path.join(build_obj.bindir, *p) for p in gmp_modules)

        if options.ipcplugins:
            options.test_paths.append('dom/plugins/test/mochitest')

        if options.totalChunks is not None and options.thisChunk is None:
            parser.error(
                "thisChunk must be specified when totalChunks is specified")

        if options.totalChunks:
            if not 1 <= options.thisChunk <= options.totalChunks:
                parser.error("thisChunk must be between 1 and totalChunks")

        if options.chunkByDir and options.chunkByRuntime:
            parser.error(
                "can only use one of --chunk-by-dir or --chunk-by-runtime")

        if options.xrePath is None:
            # default xrePath to the app path if not provided
            # but only if an app path was explicitly provided
            if options.app != parser.get_default('app'):
                options.xrePath = os.path.dirname(options.app)
                if mozinfo.isMac:
                    options.xrePath = os.path.join(
                        os.path.dirname(
                            options.xrePath),
                        "Resources")
            elif build_obj is not None:
                # otherwise default to dist/bin
                options.xrePath = build_obj.bindir
            else:
                parser.error(
                    "could not find xre directory, --xre-path must be specified")

        # allow relative paths
        if options.xrePath:
            options.xrePath = self.get_full_path(options.xrePath, parser.oldcwd)

        if options.profilePath:
            options.profilePath = self.get_full_path(options.profilePath, parser.oldcwd)

        if options.dmdPath:
            options.dmdPath = self.get_full_path(options.dmdPath, parser.oldcwd)

        if options.dmd and not options.dmdPath:
            if build_obj:
                options.dmdPath = build_obj.bin_dir
            else:
                parser.error(
                    "could not find dmd libraries, specify them with --dmd-path")

        if options.utilityPath:
            options.utilityPath = self.get_full_path(options.utilityPath, parser.oldcwd)

        if options.certPath:
            options.certPath = self.get_full_path(options.certPath, parser.oldcwd)
        elif build_obj:
            options.certPath = os.path.join(build_obj.topsrcdir, 'build', 'pgo', 'certs')

        if options.symbolsPath and len(urlparse(options.symbolsPath).scheme) < 2:
            options.symbolsPath = self.get_full_path(options.symbolsPath, parser.oldcwd)
        elif not options.symbolsPath and build_obj:
            options.symbolsPath = os.path.join(build_obj.distdir, 'crashreporter-symbols')

        if options.vmwareRecording:
            if not mozinfo.isWin:
                parser.error(
                    "use-vmware-recording is only supported on Windows.")
            options.vmwareHelperPath = os.path.join(
                options.utilityPath, VMWARE_RECORDING_HELPER_BASENAME + ".dll")
            if not os.path.exists(options.vmwareHelperPath):
                parser.error("%s not found, cannot automate VMware recording." %
                             options.vmwareHelperPath)

        if options.webapprtContent and options.webapprtChrome:
            parser.error(
                "Only one of --webapprt-content and --webapprt-chrome may be given.")

        if options.jsdebugger:
            options.extraPrefs += [
                "devtools.debugger.remote-enabled=true",
                "devtools.chrome.enabled=true",
                "devtools.debugger.prompt-connection=false"
            ]
            options.autorun = False

        if options.debugOnFailure and not options.jsdebugger:
            parser.error(
                "--debug-on-failure requires --jsdebugger.")

        if options.debuggerArgs and not options.debugger:
            parser.error(
                "--debugger-args requires --debugger.")

        if options.testingModulesDir is None:
            if build_obj:
                options.testingModulesDir = os.path.join(
                    build_obj.topobjdir, '_tests', 'modules')
            else:
                # Try to guess the testing modules directory.
                # This somewhat grotesque hack allows the buildbot machines to find the
                # modules directory without having to configure the buildbot hosts. This
                # code should never be executed in local runs because the build system
                # should always set the flag that populates this variable. If buildbot ever
                # passes this argument, this code can be deleted.
                possible = os.path.join(here, os.path.pardir, 'modules')

                if os.path.isdir(possible):
                    options.testingModulesDir = possible

        if build_obj:
            plugins_dir = os.path.join(build_obj.distdir, 'plugins')
            if plugins_dir not in options.extraProfileFiles:
                options.extraProfileFiles.append(plugins_dir)

        # Even if buildbot is updated, we still want this, as the path we pass in
        # to the app must be absolute and have proper slashes.
        if options.testingModulesDir is not None:
            options.testingModulesDir = os.path.normpath(
                options.testingModulesDir)

            if not os.path.isabs(options.testingModulesDir):
                options.testingModulesDir = os.path.abspath(
                    options.testingModulesDir)

            if not os.path.isdir(options.testingModulesDir):
                parser.error('--testing-modules-dir not a directory: %s' %
                             options.testingModulesDir)

            options.testingModulesDir = options.testingModulesDir.replace(
                '\\',
                '/')
            if options.testingModulesDir[-1] != '/':
                options.testingModulesDir += '/'

        if options.immersiveMode:
            if not mozinfo.isWin:
                parser.error("immersive is only supported on Windows 8 and up.")
            options.immersiveHelperPath = os.path.join(
                options.utilityPath, "metrotestharness.exe")
            if not os.path.exists(options.immersiveHelperPath):
                parser.error("%s not found, cannot launch immersive tests." %
                             options.immersiveHelperPath)

        if options.runUntilFailure:
            if not options.repeat:
                options.repeat = 29

        if options.dumpOutputDirectory is None:
            options.dumpOutputDirectory = tempfile.gettempdir()

        if options.dumpAboutMemoryAfterTest or options.dumpDMDAfterTest:
            if not os.path.isdir(options.dumpOutputDirectory):
                parser.error('--dump-output-directory not a directory: %s' %
                             options.dumpOutputDirectory)

        if options.useTestMediaDevices:
            if not mozinfo.isLinux:
                parser.error(
                    '--use-test-media-devices is only supported on Linux currently')
            for f in ['/usr/bin/gst-launch-0.10', '/usr/bin/pactl']:
                if not os.path.isfile(f):
                    parser.error(
                        'Missing binary %s required for '
                        '--use-test-media-devices' % f)

        if options.nested_oop:
            if not options.e10s:
                options.e10s = True
        mozinfo.update({"e10s": options.e10s})  # for test manifest parsing.

        options.leakThresholds = {
            "default": options.defaultLeakThreshold,
            "tab": 10000,  # See dependencies of bug 1051230.
            # GMP rarely gets a log, but when it does, it leaks a little.
            "geckomediaplugin": 20000,
        }

        # Bug 1091917 - We exit early in tab processes on Windows, so we don't
        # get leak logs yet.
        if mozinfo.isWin:
            options.ignoreMissingLeaks.append("tab")

        # XXX We can't normalize test_paths in the non build_obj case here,
        # because testRoot depends on the flavor, which is determined by the
        # mach command and therefore not finalized yet. Conversely, test paths
        # need to be normalized here for the mach case.
        if options.test_paths and build_obj:
            # Normalize test paths so they are relative to test root
            options.test_paths = [build_obj._wrap_path_argument(p).relpath()
                for p in options.test_paths]

        return options


class B2GArguments(ArgumentContainer):
    """B2G specific arguments."""

    args = [
        [["--b2gpath"],
         {"dest": "b2gPath",
          "default": None,
          "help": "Path to B2G repo or QEMU directory.",
          "suppress": True,
          }],
        [["--desktop"],
         {"action": "store_true",
          "default": False,
          "help": "Run the tests on a B2G desktop build.",
          "suppress": True,
          }],
        [["--marionette"],
         {"default": None,
          "help": "host:port to use when connecting to Marionette",
          }],
        [["--emulator"],
         {"default": None,
          "help": "Architecture of emulator to use, x86 or arm",
          "suppress": True,
          }],
        [["--wifi"],
         {"default": False,
          "help": "Devine wifi configuration for on device mochitest",
          "suppress": True,
          }],
        [["--sdcard"],
         {"default": "10MB",
          "help": "Define size of sdcard: 1MB, 50MB...etc",
          }],
        [["--no-window"],
         {"action": "store_true",
          "dest": "noWindow",
          "default": False,
          "help": "Pass --no-window to the emulator",
          }],
        [["--adbpath"],
         {"dest": "adbPath",
          "default": "adb",
          "help": "Path to adb binary.",
          "suppress": True,
          }],
        [["--deviceIP"],
         {"dest": "deviceIP",
          "default": None,
          "help": "IP address of remote device to test.",
          "suppress": True,
          }],
        [["--devicePort"],
         {"default": 20701,
          "help": "port of remote device to test",
          "suppress": True,
          }],
        [["--remote-logfile"],
         {"dest": "remoteLogFile",
          "default": None,
          "help": "Name of log file on the device relative to the device root. "
                  "PLEASE ONLY USE A FILENAME.",
          "suppress": True,
          }],
        [["--remote-webserver"],
         {"dest": "remoteWebServer",
          "default": None,
          "help": "IP address where the remote web server is hosted.",
          "suppress": True,
          }],
        [["--http-port"],
         {"dest": "httpPort",
          "default": DEFAULT_PORTS['http'],
          "help": "Port used for http on the remote web server.",
          "suppress": True,
          }],
        [["--ssl-port"],
         {"dest": "sslPort",
          "default": DEFAULT_PORTS['https'],
          "help": "Port used for https on the remote web server.",
          "suppress": True,
          }],
        [["--gecko-path"],
         {"dest": "geckoPath",
          "default": None,
          "help": "The path to a gecko distribution that should be installed on the emulator "
                  "prior to test.",
          "suppress": True,
          }],
        [["--profile"],
         {"dest": "profile",
          "default": None,
          "help": "For desktop testing, the path to the gaia profile to use.",
          }],
        [["--logdir"],
         {"dest": "logdir",
          "default": None,
          "help": "Directory to store log files.",
          }],
        [['--busybox'],
         {"dest": 'busybox',
          "default": None,
          "help": "Path to busybox binary to install on device.",
          }],
        [['--profile-data-dir'],
         {"dest": 'profile_data_dir',
          "default": os.path.join(here, 'profile_data'),
          "help": "Path to a directory containing preference and other data to be installed "
                  "into the profile.",
          "suppress": True,
          }],
    ]

    defaults = {
        'logFile': 'mochitest.log',
        'extensionsToExclude': ['specialpowers'],
        # See dependencies of bug 1038943.
        'defaultLeakThreshold': 5536,
    }

    def validate(self, parser, options, context):
        """Validate b2g options."""

        if options.desktop and not options.app:
            if not (build_obj and conditions.is_b2g_desktop(build_obj)):
                parser.error(
                    "--desktop specified, but no b2g desktop build detected! Either "
                    "build for b2g desktop, or point --appname to a b2g desktop binary.")
        elif build_obj and conditions.is_b2g_desktop(build_obj):
            options.desktop = True
            if not options.app:
                options.app = build_obj.get_binary_path()
                if not options.app.endswith('-bin'):
                    options.app = '%s-bin' % options.app
                if not os.path.isfile(options.app):
                    options.app = options.app[:-len('-bin')]

        if options.remoteWebServer is None:
            if os.name != "nt":
                options.remoteWebServer = moznetwork.get_ip()
            else:
                parser.error(
                    "You must specify a --remote-webserver=<ip address>")
        options.webServer = options.remoteWebServer

        if not options.b2gPath and hasattr(context, 'b2g_home'):
            options.b2gPath = context.b2g_home

        if hasattr(context, 'device_name') and not options.emulator:
            if context.device_name.startswith('emulator'):
                options.emulator = 'x86' if 'x86' in context.device_name else 'arm'

        if options.geckoPath and not options.emulator:
            parser.error(
                "You must specify --emulator if you specify --gecko-path")

        if options.logdir and not options.emulator:
            parser.error("You must specify --emulator if you specify --logdir")
        elif not options.logdir and options.emulator and build_obj:
            options.logdir = os.path.join(
                build_obj.topobjdir, '_tests', 'testing', 'mochitest')

        if hasattr(context, 'xre_path'):
            options.xrePath = context.xre_path

        if not os.path.isdir(options.xrePath):
            parser.error("--xre-path '%s' is not a directory" % options.xrePath)

        xpcshell = os.path.join(options.xrePath, 'xpcshell')
        if not os.access(xpcshell, os.F_OK):
            parser.error('xpcshell not found at %s' % xpcshell)

        if self.elf_arm(xpcshell):
            parser.error('--xre-path points to an ARM version of xpcshell; it '
                         'should instead point to a version that can run on '
                         'your desktop')

        if not options.httpdPath and build_obj:
            options.httpdPath = os.path.join(
                build_obj.topobjdir, '_tests', 'testing', 'mochitest')

        # Bug 1071866 - B2G Mochitests do not always produce a leak log.
        options.ignoreMissingLeaks.append("default")
        # Bug 1070068 - Leak logging does not work for tab processes on B2G.
        options.ignoreMissingLeaks.append("tab")

        if options.pidFile != "":
            f = open(options.pidFile, 'w')
            f.write("%s" % os.getpid())
            f.close()

        return options

    def elf_arm(self, filename):
        data = open(filename, 'rb').read(20)
        return data[:4] == "\x7fELF" and ord(data[18]) == 40  # EM_ARM


class AndroidArguments(ArgumentContainer):
    """Android specific arguments."""

    args = [
        [["--remote-app-path"],
         {"dest": "remoteAppPath",
          "help": "Path to remote executable relative to device root using \
                   only forward slashes. Either this or app must be specified \
                   but not both.",
          "default": None,
          }],
        [["--deviceIP"],
         {"dest": "deviceIP",
          "help": "ip address of remote device to test",
          "default": None,
          }],
        [["--deviceSerial"],
         {"dest": "deviceSerial",
          "help": "ip address of remote device to test",
          "default": None,
          }],
        [["--dm_trans"],
         {"choices": ["adb", "sut"],
          "default": "adb",
          "help": "The transport to use for communication with the device [default: adb].",
          "suppress": True,
          }],
        [["--devicePort"],
         {"dest": "devicePort",
          "type": int,
          "default": 20701,
          "help": "port of remote device to test",
          }],
        [["--remote-product-name"],
         {"dest": "remoteProductName",
          "default": "fennec",
          "help": "The executable's name of remote product to test - either \
                   fennec or firefox, defaults to fennec",
          "suppress": True,
          }],
        [["--remote-logfile"],
         {"dest": "remoteLogFile",
          "default": None,
          "help": "Name of log file on the device relative to the device \
                   root. PLEASE ONLY USE A FILENAME.",
          }],
        [["--remote-webserver"],
         {"dest": "remoteWebServer",
          "default": None,
          "help": "ip address where the remote web server is hosted at",
          }],
        [["--http-port"],
         {"dest": "httpPort",
          "default": DEFAULT_PORTS['http'],
          "help": "http port of the remote web server",
          "suppress": True,
          }],
        [["--ssl-port"],
         {"dest": "sslPort",
          "default": DEFAULT_PORTS['https'],
          "help": "ssl port of the remote web server",
          "suppress": True,
          }],
        [["--robocop-ini"],
         {"dest": "robocopIni",
          "default": "",
          "help": "name of the .ini file containing the list of tests to run",
          }],
        [["--robocop-apk"],
         {"dest": "robocopApk",
          "default": "",
          "help": "name of the Robocop APK to use for ADB test running",
          }],
        [["--remoteTestRoot"],
         {"dest": "remoteTestRoot",
          "default": None,
          "help": "remote directory to use as test root \
                   (eg. /mnt/sdcard/tests or /data/local/tests)",
          "suppress": True,
          }],
    ]

    defaults = {
        'dm': None,
        'logFile': 'mochitest.log',
        'utilityPath': None,
    }

    def validate(self, parser, options, context):
        """Validate android options."""

        if build_obj:
            options.log_mach = '-'

        if options.dm_trans == "adb":
            if options.deviceIP:
                options.dm = DroidADB(
                    options.deviceIP,
                    options.devicePort,
                    deviceRoot=options.remoteTestRoot)
            elif options.deviceSerial:
                options.dm = DroidADB(
                    None,
                    None,
                    deviceSerial=options.deviceSerial,
                    deviceRoot=options.remoteTestRoot)
            else:
                options.dm = DroidADB(deviceRoot=options.remoteTestRoot)
        elif options.dm_trans == 'sut':
            if options.deviceIP is None:
                parser.error(
                    "If --dm_trans = sut, you must provide a device IP")

            options.dm = DroidSUT(
                options.deviceIP,
                options.devicePort,
                deviceRoot=options.remoteTestRoot)

        if not options.remoteTestRoot:
            options.remoteTestRoot = options.dm.deviceRoot

        if options.remoteWebServer is None:
            if os.name != "nt":
                options.remoteWebServer = moznetwork.get_ip()
            else:
                parser.error(
                    "you must specify a --remote-webserver=<ip address>")

        options.webServer = options.remoteWebServer

        if options.remoteLogFile is None:
            options.remoteLogFile = options.remoteTestRoot + \
                '/logs/mochitest.log'

        if options.remoteLogFile.count('/') < 1:
            options.remoteLogFile = options.remoteTestRoot + \
                '/' + options.remoteLogFile

        if options.remoteAppPath and options.app:
            parser.error(
                "You cannot specify both the remoteAppPath and the app setting")
        elif options.remoteAppPath:
            options.app = options.remoteTestRoot + "/" + options.remoteAppPath
        elif options.app is None:
            if build_obj:
                options.app = build_obj.substs['ANDROID_PACKAGE_NAME']
            else:
                # Neither remoteAppPath nor app are set -- error
                parser.error("You must specify either appPath or app")

        if build_obj and 'MOZ_HOST_BIN' in os.environ:
            options.xrePath = os.environ['MOZ_HOST_BIN']

        # Only reset the xrePath if it wasn't provided
        if options.xrePath is None:
            options.xrePath = options.utilityPath

        if options.pidFile != "":
            f = open(options.pidFile, 'w')
            f.write("%s" % os.getpid())
            f.close()

        # Robocop specific options
        if options.robocopIni != "":
            if not os.path.exists(options.robocopIni):
                parser.error(
                    "Unable to find specified robocop .ini manifest '%s'" %
                    options.robocopIni)
            options.robocopIni = os.path.abspath(options.robocopIni)

            if not options.robocopApk and build_obj:
                options.robocopApk = os.path.join(build_obj.topobjdir, 'build', 'mobile',
                                                  'robocop', 'robocop-debug.apk')

        if options.robocopApk != "":
            if not os.path.exists(options.robocopApk):
                parser.error(
                    "Unable to find robocop APK '%s'" %
                    options.robocopApk)
            options.robocopApk = os.path.abspath(options.robocopApk)

        # allow us to keep original application around for cleanup while
        # running robocop via 'am'
        options.remoteappname = options.app
        return options


container_map = {
    'generic': [MochitestArguments],
    'b2g': [MochitestArguments, B2GArguments],
    'android': [MochitestArguments, AndroidArguments],
}


class MochitestArgumentParser(ArgumentParser):
    """
    Usage instructions for Mochitest.

    All arguments are optional.
    If --chrome is specified, chrome tests will be run instead of web content tests.
    If --browser-chrome is specified, browser-chrome tests will be run instead of web content tests.
    See <http://mochikit.com/doc/html/MochiKit/Logging.html> for details on the logging levels.
    """

    _containers = None
    context = {}

    def __init__(self, app=None, **kwargs):
        ArgumentParser.__init__(self, usage=self.__doc__, conflict_handler='resolve', **kwargs)

        self.oldcwd = os.getcwd()
        self.app = app
        if not self.app and build_obj:
            if conditions.is_android(build_obj):
                self.app = 'android'
            elif conditions.is_b2g(build_obj) or conditions.is_b2g_desktop(build_obj):
                self.app = 'b2g'
        if not self.app:
            # platform can't be determined and app wasn't specified explicitly,
            # so just use generic arguments and hope for the best
            self.app = 'generic'

        if self.app not in container_map:
            self.error("Unrecognized app '{}'! Must be one of: {}".format(
                self.app, ', '.join(container_map.keys())))

        defaults = {}
        for container in self.containers:
            defaults.update(container.defaults)
            group = self.add_argument_group(container.__class__.__name__, container.__doc__)

            for cli, kwargs in container.args:
                # Allocate new lists so references to original don't get mutated.
                # allowing multiple uses within a single process.
                if "default" in kwargs and isinstance(kwargs['default'], list):
                    kwargs["default"] = []

                if 'suppress' in kwargs:
                    if kwargs['suppress']:
                        kwargs['help'] = SUPPRESS
                    del kwargs['suppress']

                group.add_argument(*cli, **kwargs)

        self.set_defaults(**defaults)
        mozlog.commandline.add_logging_group(self)

    @property
    def containers(self):
        if self._containers:
            return self._containers

        containers = container_map[self.app]
        self._containers = [c() for c in containers]
        return self._containers

    def validate(self, args):
        for container in self.containers:
            args = container.validate(self, args, self.context)
        return args

    def parse_args(self, *args, **kwargs):
        return self.validate(ArgumentParser.parse_args(self, *args, **kwargs))

    def parse_known_args(self, *args, **kwargs):
        args, remainder = ArgumentParser.parse_known_args(self, *args, **kwargs)
        return (self.validate(args), remainder)
