# -*- coding: utf-8 -*-
# Copyright 2010-2016, Google Inc.
# All rights reserved.
#
# Redistribution and use in source and binary forms, with or without
# modification, are permitted provided that the following conditions are
# met:
#
#     * Redistributions of source code must retain the above copyright
# notice, this list of conditions and the following disclaimer.
#     * Redistributions in binary form must reproduce the above
# copyright notice, this list of conditions and the following disclaimer
# in the documentation and/or other materials provided with the
# distribution.
#     * Neither the name of Google Inc. nor the names of its
# contributors may be used to endorse or promote products derived from
# this software without specific prior written permission.
#
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

"""A script to run unittests on android.

This file searches all tests under --test_bin_dir (the default is the current
directory), and run them on android (emulator or actual device).
You need to run an android emulator or to connect a dvice, before running this
script.
"""

__author__ = "hidehiko"

import errno
import logging
import multiprocessing
import optparse
import os
import subprocess
import time
from xml.etree import cElementTree as ElementTree
from build_tools import android_util
from build_tools.test_tools import gtest_report


def FindTestBinaries(test_dir):
  """Returns the file names of tests."""
  logging.info('Gathering binaries in %s.', os.path.abspath(test_dir))
  result = []

  for f in os.listdir(test_dir):
    path = os.path.join(test_dir, f)
    # Test binaries are "executable file" and "its name ends with _test."
    if (os.access(path, os.R_OK | os.X_OK) and
        os.path.isfile(path) and
        f.endswith('_test')):
      result.append(f)
  return result


def ParseArgs():
  """Parses command line options and returns them."""
  parser = optparse.OptionParser()
  parser.add_option('--run_java_test', dest='run_java_test', default=False,
                    action='store_true',
                    help='Runs JUnit tests. [JAVA] options are used.')
  android_devices_default = os.environ.get('ANDROID_DEVICES', '')
  parser.add_option('--android_devices', dest='android_devices',
                    default=android_devices_default,
                    help='[JAVA][NATIVE] Comma separated serial numbers '
                    'on which devices the tests run. '
                    'If not specified all the available devices are used.')
  parser.add_option('--run_native_test', dest='run_native_test', default=False,
                    action='store_true',
                    help='Runs native tests. [NATIVE] options are used.')
  parser.add_option('--configuration', dest='configuration',
                    default='Debug',
                    help='[JAVA] Build configuration. Used to build correct '
                    'testee binary.')
  parser.add_option('--app_package_name', dest='app_package_name',
                    default='org.mozc.android.inputmethod.japanese',
                    help='[JAVA] Testee project\'s package name. '
                    'This is used to find test reporting XML file.')
  parser.add_option('--remote_dir', dest='remote_dir',
                    default='/sdcard/mozctest',
                    help='[NATIVE] Working directory on '
                    'a SD card on a Android device')
  parser.add_option('--remote_device', dest='remote_device',
                    default='/dev/block/vold/179:0',
                    help='[NATIVE] Path to the device to be (re)mounted.')
  parser.add_option('--remote_mount_point', dest='remote_mount_point',
                    default='/sdcard',
                    help='[NATIVE] Mount point for the --remote_device.')
  parser.add_option('--test_bin_dir', dest='test_bin_dir', default='.',
                    help='[NATIVE] The directory which contains test binaries')
  parser.add_option('--test_case', dest='testcase', default='',
                    help='[NATIVE] Limits testcases to run.')
  parser.add_option('--native_abi', dest='abi', default='armeabi',
                    help='[JAVA][NATIVE] ABI of built test executables.')
  parser.add_option('--mozc_dataset_file',
                    dest='mozc_dataset_file', default=None,
                    help='[NATIVE] Path to mozc.data file.')
  parser.add_option('--mozc_connection_text_data_file',
                    dest='mozc_connection_text_data_file', default=None,
                    help='[NATIVE] Path to connection_single_column.txt file.')
  parser.add_option('--mozc_test_dataset_file',
                    dest='mozc_test_dataset_file', default=None,
                    help='[NATIVE] Path to mock_mozc.data file.')
  parser.add_option('--mozc_test_connection_text_data_file',
                    dest='mozc_test_connection_text_data_file', default=None,
                    help='[NATIVE] Path to connection_single_column.txt file.')
  parser.add_option('--mozc_data_dir',
                    dest='mozc_data_dir', default=None,
                    help='[NATIVE] Path to data directory.')
  parser.add_option('--output_report_dir', dest='output_report_dir',
                    default=None,
                    help='[JAVA][NATIVE] Path to output gtest '
                    'reporting XML files')
  parser.add_option('--android_home', dest='android_home',
                    default=None,
                    help='[JAVA][NATIVE] Path to the the SDK.')
  return parser.parse_args()[0]


def AppendPrefixToSuiteName(in_file_name, out_file_name, prefix):
  """Appends prefix to <testsuite> element's name attribute."""

  def AppendPrefix(elem):
    name = elem.attrib['name']
    elem.attrib['name'] = prefix + name
  tree = ElementTree.parse(in_file_name)
  root = tree.getroot()
  # Root element isn't returned by any traversing methods.
  if root.tag == 'testsuite':
    AppendPrefix(root)
  for elem in root.findall('testsuite'):
    AppendPrefix(elem)
  with open(out_file_name, 'w') as f:
    # Note that ElementTree of 2.6 doesn't write XML declaration.
    f.write('<?xml version="1.0" encoding="utf-8"?>\n')
    f.write(ElementTree.tostring(root, 'utf-8'))


class AndroidDevice(android_util.AndroidDevice):
  def __init__(self, serial, android_home):
    android_util.AndroidDevice.__init__(self, serial, android_home)

  def WaitForMount(self):
    """Wait until SD card is mounted."""
    retry = 10
    sleep = 30
    for _ in xrange(retry):
      if self._RunCommand('mount').find('/sdcard') != -1:
        self.GetLogger().info('SD card has been mounted.')
        return
      self.GetLogger().info(
          'SD card has not been mounted. Wait and retry. '
          'Don\'t worry. This is typically expected behavior.')
      time.sleep(sleep)
    raise IOError('No mounted SD card found. Something went wrong or the '
                  'emulator is too slow.')

  def CopyFile(self, host_path, remote_path, operation):
    """Copy a file between host and remote.

    Args:
      host_path: path in host side.
      remote_path: path in remote side.
      operation: 'push' or 'pull'.
        If 'push', 'adb push host_path remote_path' is executed.
        If 'pull', 'adb pull remote_path host_path' is executed.
    """
    if operation == 'push':
      command_args = [os.path.join(self._android_home, 'platform-tools', 'adb'),
                      '-s', self.serial, 'push', host_path, remote_path]
    elif operation == 'pull':
      command_args = [os.path.join(self._android_home, 'platform-tools', 'adb'),
                      '-s', self.serial, 'pull', remote_path, host_path]
    else:
      raise ValueError('operation parameter must be push or pull but '
                       '%s is given.' % operation)
    self.GetLogger().info('Copying at %s: %s', self.serial, command_args)
    process = subprocess.Popen(command_args)
    if process.wait() == 0:
      return
    raise IOError(
        'Failed to copy a file: %s to %s' % (host_path, remote_path))

  def _VerifyResultXMLFile(self, xml_path, test_name):
    test_suites = gtest_report.GetFromXMLFile(xml_path)
    if not test_suites:
      error_message = ('[FAIL] Result XML file for %s is invalid; %s' %
                       (test_name, xml_path))
    else:
      if test_suites.fail_num:
        self.GetLogger().warning('[FAIL] %d of test failures for %s are found',
                                 test_suites.fail_num, test_name)
        error_message = test_suites.GetErrorSummary()
      else:
        self.GetLogger().info('[ OK ] All tests for %s succeeded.', test_name)
        error_message = None
    return error_message

  def _CopyAndVerifyResult(
      self, test_name, remote_result_path, host_result_path):
    try:
      self.CopyFile(host_path=host_result_path,
                    remote_path=remote_result_path,
                    operation='pull')
      self.GetLogger().info('[ OK ] %s result file is successfully pulled.',
                            test_name)
      error_message = self._VerifyResultXMLFile(host_result_path, test_name)
    except IOError:
      self.GetLogger().warning('[FAIL] %s result file is not pulled.',
                               test_name)
      error_message = ('[FAIL] Result file does not exist. '
                       'The process might crash: %s' % test_name)
    if error_message:
      self.GetLogger().warning(error_message)
    return error_message

  def RunOneTest(self, test_bin_dir, test_name, remote_dir,
                 output_report_dir):
    """Execute each test."""
    try:
      prefix = '%s::' % (self._GetAvdName())
      host_path = os.path.join(test_bin_dir, test_name)
      remote_path = os.path.join(remote_dir, test_name)
      remote_report_file_name = '%s.xml' % test_name
      output_report_file_name = '%s%s.xml' % (prefix, test_name)
      if output_report_dir:
        output_report_path = os.path.join(output_report_dir,
                                          output_report_file_name)
      else:
        output_report_path = os.tmpnam()

      # Create default test report file.
      CreateDefaultReportFile(output_report_path, test_name, prefix)

      # Copy the binary file, run the test, and clean up the executable.
      self.CopyFile(host_path=host_path,
                    remote_path=remote_path,
                    operation='push')
      remote_report_path = os.path.abspath(
          os.path.join(remote_dir, remote_report_file_name))
      # We should append colored_log=false to suppress escape sequences on
      # continuous build.
      command = ['cd', remote_dir, ';',
                 './' + test_name, '--test_srcdir=.', '--logtostderr',
                 '--gunit_output=xml:%s' % remote_report_path,
                 '--colored_log=false']
      self._RunCommand(*command)
      temporal_report_path = os.tmpnam()
      error_message = self._CopyAndVerifyResult(
          test_name, remote_report_path, temporal_report_path)
      # Successfully the result file is pulled.
      if not error_message:
        # Append prefix to testsuite name.
        # Otherwise duplicate testsuites name will be generated finally.
        AppendPrefixToSuiteName(temporal_report_path, output_report_path,
                                prefix)
      return error_message
    finally:
      if remote_path:
        self._RunCommand('rm', remote_path)
      if remote_report_path:
        self._RunCommand('rm', remote_report_path)

  def SetUpTest(self, device, mount_point, remote_dir,
                mozc_dataset, connection_text_data,
                test_dataset, test_connection_text_data,
                mozc_data_dir):
    """Set up the android to run tests."""
    self.WaitForMount()
    # Now, the binary size of unittests are getting bigger and actually,
    # some of them exceed the /data/data/... quota.
    # So we'll use sdcard, instead. Unfortunately, sdcard has noexec
    # attribute
    # by default, so we remove it by remounting.
    self._RunCommand('mount', '-o', 'remount', device, mount_point)
    # Some tests depend on dictionary data. In the product, the
    # data is set at jni loading time, but it is necessary to somehow
    # set the data in native tests. So, copy the dictionary data to the
    # emulator.
    self.CopyFile(host_path=mozc_dataset,
                  remote_path=os.path.join(remote_dir,
                                           'embedded_data', 'mozc_data'),
                  operation='push')
    self.CopyFile(host_path=connection_text_data,
                  remote_path=os.path.join(remote_dir,
                                           'data_manager', 'android',
                                           'connection_single_column.txt'),
                  operation='push')
    self.CopyFile(host_path=test_dataset,
                  remote_path=os.path.join(remote_dir,
                                           'data_manager', 'testing',
                                           'mock_mozc.data'),
                  operation='push')
    self.CopyFile(host_path=test_connection_text_data,
                  remote_path=os.path.join(remote_dir,
                                           'data_manager', 'testing',
                                           'connection_single_column.txt'),
                  operation='push')
    # mozc_data_dir contains both generated .h files and test data.
    # We want only test data and they are in mozc_data_dir/data.
    # TODO(matsuzakit): Split generated .h files and test data
    #                   into separate directories.
    self.CopyFile(host_path=os.path.join(mozc_data_dir, 'data'),
                  remote_path=os.path.join(remote_dir, 'data'),
                  operation='push')

  def TearDownTest(self, remote_dir):
    self._RunCommand('rm', '-r', remote_dir)

  def RunNativeTests(self, options, abi, binaries):
    self.GetLogger().info('[NATIVE] Testing device=%s', self.serial)
    if not self.IsAcceptableAbi(abi):
      self.GetLogger().info('ABI %s is not compatible with built executables.',
                            abi)
      return []
    if int(self.GetProperty('ro.build.version.sdk')) <= 15:
      self.GetLogger().info('The device does not support PIE so skip testing.')
      return []
    try:
      error_messages = []
      self.SetUpTest(options.remote_device, options.remote_mount_point,
                     options.remote_dir,
                     options.mozc_dataset_file,
                     options.mozc_connection_text_data_file,
                     options.mozc_test_dataset_file,
                     options.mozc_test_connection_text_data_file,
                     options.mozc_data_dir)
      if options.testcase:
        test_list = options.testcase.split(',')
      else:
        test_list = None
      self.GetLogger().info('[NATIVE] Testing device=%s, abi=%s',
                            self.serial, abi)
      for test in binaries:
        if test_list and test not in test_list:
          continue
        self.GetLogger().info('[NATIVE] Testing device=%s, abi=%s, test=%s',
                              self.serial, abi, test)
        error_message = self.RunOneTest(options.test_bin_dir, test,
                                        options.remote_dir,
                                        options.output_report_dir)
        if error_message:
          error_messages.append(error_message)
      self.TearDownTest(options.remote_dir)
      return error_messages
    except Exception as e:  # pylint: disable=broad-except
      # Catches all the exceptions and returns string instead.
      # If an exception is thrown, multiprocessing module
      # complains because exceptions might not be able to be pickled.
      return [str(e)]

  def RunJavaTests(self, output_report_dir, configuration,
                   app_package_name, abi):
    try:
      if not self.IsAcceptableAbi(abi):
        self.GetLogger().info(
            'ABI %s is not compatible with built executables.', abi)
        return []
      self.GetLogger().info('[JAVA] Testing device=%s', self.serial)
      prefix = '%s::' % self._GetAvdName()
      report_file_name_remote = 'gtest-report.xml'
      report_file_name_host = prefix + 'java_layer.xml'
      output_report_path = os.path.join(output_report_dir,
                                        report_file_name_host)
      CreateDefaultReportFile(output_report_path, 'JUnit all tests', prefix)
      # Run 'run-tests' target on specified device.
      # This target does build (testee and tester), install (both) and run.
      args = ['ant', 'run-tests',
              '-Dadb.device.arg=-s %s' % self.serial,
              '-Dgyp.build_type=%s' % configuration]
      process = subprocess.Popen(args, cwd='tests')
      process.wait()
      if process.wait() != 0:
        return '[FAIL] [JAVA] run_test target fails'

      remote_report_path = os.path.join('data', 'data',
                                        app_package_name,
                                        report_file_name_remote)
      try:
        temporal_report_path = os.tmpnam()
        self.CopyFile(host_path=temporal_report_path,
                      remote_path=remote_report_path,
                      operation='pull')
        self.GetLogger().info('[ OK ] Java result file is successfully pulled.')
        # Append prefix to testsuite name.
        # Otherwise duplicate testsuites name will be generated finally.
        AppendPrefixToSuiteName(temporal_report_path, output_report_path,
                                prefix)
        # XML file verification is omitted because
        # - Java's report XML doesn't fit verifier's expectation.
      except IOError:
        self.GetLogger().warning('[FAIL] Java result file is not pulled.')
        return '[FAIL] Result file does not exist. The process might crash.'

      # TODO(matsuzakit): Verify reporting XML file here.
      # gtest_report.GetFromXMLFile() is not applicable becuase of
      # format difference.
      return None
    except Exception as e:  # pylint: disable=broad-except
      # Catches all the exceptions and returns string instead.
      # If an exception is thrown, multiprocessing module
      # complains because exceptions might not be able to be pickled.
      return str(e)

  @staticmethod
  def GetDevices(android_home):
    return [AndroidDevice(super_device.serial, android_home)
            for super_device in
            android_util.AndroidDevice.GetDevices(android_home)]


def CreateDefaultReportFile(output_report_path, test_name, prefix):
  # Create default test report file.
  # It will be used as the result of the test if the test crashes.
  with open(output_report_path, 'w') as f:
    f.write("""<?xml version="1.0" encoding="UTF-8"?>
<testsuite name="%s%s" tests="1" errors="1">
  <testcase name="No reporting XML">
    <error message="No reporting XML has been generated. Process crash?" />
  </testcase>
</testsuite>""" % (prefix, test_name))


def RunTest(device, options, binaries):
  error_messages = []
  if options.run_native_test:
    native_error_messages = device.RunNativeTests(options, options.abi,
                                                  binaries)
    if native_error_messages:
      error_messages.extend(native_error_messages)
  if options.run_java_test:
    error_message = device.RunJavaTests(options.output_report_dir,
                                        options.configuration,
                                        options.app_package_name,
                                        options.abi)
    if error_message:
      error_messages.append(error_message)
  return '\n'.join(error_messages)


def main():
  # Enable logging.info.
  logging.getLogger().setLevel(logging.INFO)

  options = ParseArgs()

  if not options.android_home:
    logging.error('--android_home option must be specified.')
    os.exit(1)

  if options.run_native_test:
    binaries = FindTestBinaries(options.test_bin_dir)
    logging.info(binaries)
  else:
    binaries = None

  # Prepare reporting directory.
  if options.output_report_dir:
    options.output_report_dir = os.path.abspath(options.output_report_dir)
    try:
      os.makedirs(options.output_report_dir)
      logging.info('Made directory; %s', options.output_report_dir)
    except OSError as e:
      if e.errno == errno.EEXIST:
        logging.info('%s has existed already.', options.output_report_dir)
      else:
        raise

  android_home = options.android_home

  devices = AndroidDevice.GetDevices(android_home)
  logging.info('All the activated devices are %s',
               [device.serial for device in devices])
  if options.android_devices:
    using_devices = options.android_devices.split(',')
    devices = [device for device in devices if device.serial in using_devices]
    logging.info('Filtered to %s',
                 [device.serial for device in devices])

  if not devices:
    logging.info('No devices are specified. Do nothing.')
    return

  # Maximum # of devices is 10 so running them in the same time is possible.
  pool = multiprocessing.Pool(len(devices))
  results = [pool.apply_async(RunTest, [device, options, binaries])
             for device in devices]
  pool.close()
  # result.get() blocks until the test terminates.
  error_messages = [result.get() for result in results if result.get()]
  if error_messages:
    print '[FAIL] Native tests result : Test failures are found;'
    for message in error_messages:
      print message
  else:
    print '[ OK ] Native tests result : Tests scceeded.'


if __name__ == '__main__':
  main()
