#!/usr/bin/env python
# Copyright 2019 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Merge results from code-coverage/pgo swarming runs.

This script merges code-coverage/pgo profiles from multiple shards. It also
merges the test results of the shards.

It is functionally similar to merge_steps.py but it accepts the parameters
passed by swarming api.
"""

import argparse
import json
import logging
import os
import shutil
import subprocess
import sys

import merge_lib as profile_merger


def _MergeAPIArgumentParser(*args, **kwargs):
  """Parameters passed to this merge script, as per:
  https://chromium.googlesource.com/chromium/tools/build/+/main/scripts/slave/recipe_modules/swarming/resources/merge_api.py
  """
  parser = argparse.ArgumentParser(*args, **kwargs)
  parser.add_argument('--build-properties',
                      help=argparse.SUPPRESS,
                      default='{}')
  parser.add_argument('--summary-json', help=argparse.SUPPRESS)
  parser.add_argument('--task-output-dir', help=argparse.SUPPRESS)
  parser.add_argument('-o',
                      '--output-json',
                      required=True,
                      help=argparse.SUPPRESS)
  parser.add_argument('jsons_to_merge', nargs='*', help=argparse.SUPPRESS)

  # Custom arguments for this merge script.
  parser.add_argument('--additional-merge-script',
                      help='additional merge script to run')
  parser.add_argument(
      '--additional-merge-script-args',
      help='JSON serialized string of args for the additional merge script')
  parser.add_argument('--profdata-dir',
                      required=True,
                      help='where to store the merged data')
  parser.add_argument('--llvm-profdata',
                      required=True,
                      help='path to llvm-profdata executable')
  parser.add_argument('--test-target-name', help='test target name')
  parser.add_argument('--java-coverage-dir',
                      help='directory for Java coverage data')
  parser.add_argument('--jacococli-path', help='path to jacococli.jar.')
  parser.add_argument(
      '--merged-jacoco-filename',
      help='filename used to uniquely name the merged exec file.')
  parser.add_argument('--javascript-coverage-dir',
                      help='directory for JavaScript coverage data')
  parser.add_argument('--chromium-src-dir',
                      help='directory for chromium/src checkout')
  parser.add_argument('--build-dir',
                      help='directory for the build directory in chromium/src')
  parser.add_argument(
      '--per-cl-coverage',
      action='store_true',
      help='set to indicate that this is a per-CL coverage build')
  parser.add_argument('--sparse',
                      action='store_true',
                      dest='sparse',
                      help='run llvm-profdata with the sparse flag.')
  # (crbug.com/1091310) - IR PGO is incompatible with the initial conversion
  # of .profraw -> .profdata that's run to detect validation errors.
  # Introducing a bypass flag that'll merge all .profraw directly to .profdata
  parser.add_argument(
      '--skip-validation',
      action='store_true',
      help='skip validation for good raw profile data. this will pass all '
      'raw profiles found to llvm-profdata to be merged. only applicable '
      'when input extension is .profraw.')
  return parser


def main():
  desc = 'Merge profraw files in <--task-output-dir> into a single profdata.'
  parser = _MergeAPIArgumentParser(description=desc)
  params = parser.parse_args()

  if params.java_coverage_dir:
    if not params.jacococli_path:
      parser.error('--jacococli-path required when merging Java coverage')
    if not params.merged_jacoco_filename:
      parser.error(
          '--merged-jacoco-filename required when merging Java coverage')

    output_path = os.path.join(params.java_coverage_dir,
                               '%s.exec' % params.merged_jacoco_filename)
    logging.info('Merging JaCoCo .exec files to %s', output_path)
    profile_merger.merge_java_exec_files(params.task_output_dir, output_path,
                                         params.jacococli_path)

  failed = False

  if params.javascript_coverage_dir and params.chromium_src_dir \
      and params.build_dir:
    current_dir = os.path.dirname(__file__)
    merge_js_results_script = os.path.join(current_dir, 'merge_js_results.py')
    args = [
        sys.executable,
        merge_js_results_script,
        '--task-output-dir',
        params.task_output_dir,
        '--javascript-coverage-dir',
        params.javascript_coverage_dir,
        '--chromium-src-dir',
        params.chromium_src_dir,
        '--build-dir',
        params.build_dir,
    ]

    rc = subprocess.call(args)
    if rc != 0:
      failed = True
      logging.warning('%s exited with %s', merge_js_results_script, rc)

  # The orderfile bot outputs only one orderfile that needs to be copied over
  # to the profdata dir, no additional merging necessary.
  if 'orderfile' in params.profdata_dir:
    logging.info('Detected orderfile bot in %s', params.profdata_dir)
    orderfiles = []
    for dir_path, _sub_dirs, file_names in os.walk(params.task_output_dir):
      logging.info('Found orderfile dir: %s', dir_path)
      for fn in file_names:
        if 'unpatched' in fn:
          logging.info('Ignoring %s as it is unpatched.', fn)
        else:
          logging.info('Found orderfile: %s', fn)
          orderfiles.append(os.path.join(dir_path, fn))
    assert len(orderfiles) == 1, f'More than one orderfile found: {orderfiles}'
    source_path = orderfiles[0]
    # It is important to copy the file to the root profile dir with a
    # predictable name for the bot to find it reliably.
    dest_path = os.path.join(os.path.dirname(params.profdata_dir),
                             'orderfile.out')
    logging.info('Copying orderfile from %s to %s', source_path, dest_path)
    shutil.copyfile(source_path, dest_path)

  # Name the output profdata file name as {test_target}.profdata or
  # default.profdata.
  output_prodata_filename = (params.test_target_name or 'default') + '.profdata'

  # NOTE: The profile data merge script must make sure that the profraw files
  # are deleted from the task output directory after merging, otherwise, other
  # test results merge script such as layout tests will treat them as json test
  # results files and result in errors.
  invalid_profiles, counter_overflows = profile_merger.merge_profiles(
      params.task_output_dir,
      os.path.join(params.profdata_dir, output_prodata_filename),
      '.profraw',
      params.llvm_profdata,
      sparse=params.sparse,
      skip_validation=params.skip_validation)

  # At the moment counter overflows overlap with invalid profiles, but this is
  # not guaranteed to remain the case indefinitely. To avoid future conflicts
  # treat these separately.
  if counter_overflows:
    with open(os.path.join(params.profdata_dir, 'profiles_with_overflows.json'),
              'w') as f:
      json.dump(counter_overflows, f)

  if invalid_profiles:
    with open(os.path.join(params.profdata_dir, 'invalid_profiles.json'),
              'w') as f:
      json.dump(invalid_profiles, f)

  # If given, always run the additional merge script, even if we only have one
  # output json. Merge scripts sometimes upload artifacts to cloud storage, or
  # do other processing which can be needed even if there's only one output.
  if params.additional_merge_script:
    new_args = [
        '--build-properties',
        params.build_properties,
        '--summary-json',
        params.summary_json,
        '--task-output-dir',
        params.task_output_dir,
        '--output-json',
        params.output_json,
    ]

    if params.additional_merge_script_args:
      new_args += json.loads(params.additional_merge_script_args)

    new_args += params.jsons_to_merge

    args = [sys.executable, params.additional_merge_script] + new_args
    rc = subprocess.call(args)
    if rc != 0:
      failed = True
      logging.warning('Additional merge script %s exited with %s',
                      params.additional_merge_script, rc)
  elif len(params.jsons_to_merge) == 1:
    logging.info('Only one output needs to be merged; directly copying it.')
    with open(params.jsons_to_merge[0]) as f_read:
      with open(params.output_json, 'w') as f_write:
        f_write.write(f_read.read())
  else:
    logging.warning(
        'This script was told to merge test results, but no additional merge '
        'script was given.')

  # TODO(crbug.com/40868908): Return non-zero if invalid_profiles is not None
  return 1 if failed else 0


if __name__ == '__main__':
  logging.basicConfig(format='[%(asctime)s %(levelname)s] %(message)s',
                      level=logging.INFO)
  sys.exit(main())
