#!/usr/bin/env vpython3

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

"""Aggregates Jacoco coverage files to produce output."""

import argparse
import fnmatch
import json
import os
import sys

import devil_chromium
from devil.utils import cmd_helper
from pylib.constants import host_paths

# Source paths should be passed to Jacoco in a way that the relative file paths
# reflect the class package name.
_PARTIAL_PACKAGE_NAMES = ['com/google', 'org/chromium']

# The sources_json_file is generated by jacoco_instr.py with source directories
# and input path to non-instrumented jars.
# e.g.
# 'source_dirs': [
#   "chrome/android/java/src/org/chromium/chrome/browser/toolbar/bottom",
#   "chrome/android/java/src/org/chromium/chrome/browser/ui/system",
# ...]
# 'input_path':
#   '$CHROMIUM_OUTPUT_DIR/\
#    obj/chrome/android/features/tab_ui/java__process_prebuilt-filtered.jar'

_SOURCES_JSON_FILES_SUFFIX = '__jacoco_sources.json'


def _CreateClassfileArgs(class_files, report_type, include_substr=None):
    """Returns a filtered list of files with classfile option.

    Args:
      class_files: A list of class files.
      report_type: A string indicating if device or host files are desired.
      include_substr: A substring that must be present to include the file.

    Returns:
      A list of files that don't use the suffix.
    """
    # These should match the jar class files generated in internal_rules.gni
    search_jar_suffix = '%s.filter.jar' % report_type
    result_class_files = []
    for f in class_files:
        include_file = False
        if f.endswith(search_jar_suffix):
            include_file = True

        # If include_substr is specified, remove files that don't have the
        # required substring.
        if include_file and include_substr and include_substr not in f:
            include_file = False
        if include_file:
            result_class_files += ['--classfiles', f]

    return result_class_files


def _GenerateReportOutputArgs(args, class_files, report_type):
    cmd = _CreateClassfileArgs(
        class_files, report_type, args.include_substr_filter
    )
    if args.format == 'html':
        report_dir = os.path.join(args.output_dir, report_type)
        if not os.path.exists(report_dir):
            os.makedirs(report_dir)
        cmd += ['--html', report_dir]
    elif args.format == 'xml':
        cmd += ['--xml', args.output_file]
    elif args.format == 'csv':
        cmd += ['--csv', args.output_file]

    return cmd


def _GetFilesWithSuffix(root_dir, suffix):
    """Gets all files with a given suffix.

    Args:
      root_dir: Directory in which to search for files.
      suffix: Suffix to look for.

    Returns:
      A list of absolute paths to files that match.
    """
    files = []
    for root, _, filenames in os.walk(root_dir):
        basenames = fnmatch.filter(filenames, '*' + suffix)
        files.extend([os.path.join(root, basename) for basename in basenames])

    return files


def _GetExecFiles(root_dir, exclude_substr=None):
    """Gets all .exec files

    Args:
      root_dir: Root directory in which to search for files.
      exclude_substr: Substring which should be absent in filename. If None, all
        files are selected.

    Returns:
      A list of absolute paths to .exec files

    """
    all_exec_files = _GetFilesWithSuffix(root_dir, ".exec")
    valid_exec_files = []
    for exec_file in all_exec_files:
        if not exclude_substr or exclude_substr not in exec_file:
            valid_exec_files.append(exec_file)
    return valid_exec_files


def _ParseArguments(parser):
    """Parses the command line arguments.

    Args:
      parser: ArgumentParser object.

    Returns:
      The parsed arguments.
    """
    parser.add_argument(
        '--format',
        required=True,
        choices=['html', 'xml', 'csv'],
        help='Output report format. Choose one from html, xml and csv.',
    )
    parser.add_argument(
        '--device-or-host',
        choices=['device', 'host'],
        help='Selection on whether to use the device classpath files or the '
        'host classpath files. Host would typically be used for junit tests '
        ' and device for tests that run on the device. Only used for xml and csv'
        ' reports.',
    )
    parser.add_argument(
        '--include-substr-filter',
        help='Substring that must be included in classjars.',
        type=str,
        default='',
    )
    parser.add_argument('--output-dir', help='html report output directory.')
    parser.add_argument(
        '--output-file', help='xml file to write device coverage results.'
    )
    parser.add_argument(
        '--coverage-dir',
        required=True,
        help='Root of the directory in which to search for '
        'coverage data (.exec) files.',
    )
    parser.add_argument(
        '--exec-filename-excludes',
        required=False,
        help='Excludes .exec files which contain a particular '
        'substring in their name',
    )
    parser.add_argument(
        '--sources-json-dir',
        help='Root of the directory in which to search for '
        '*__jacoco_sources.json files.',
    )
    parser.add_argument(
        '--class-files',
        nargs='+',
        help='Location of Java non-instrumented class files. '
        'Use non-instrumented jars instead of instrumented jars. '
        'e.g. use chrome_java__process_prebuilt_(host/device)_filter.jar instead'
        'of chrome_java__process_prebuilt-instrumented.jar',
    )
    parser.add_argument(
        '--sources',
        nargs='+',
        help='Location of the source files. '
        'Specified source folders must be the direct parent of the folders '
        'that define the Java packages.'
        'e.g. <src_dir>/chrome/android/java/src/',
    )
    parser.add_argument(
        '--cleanup',
        action='store_true',
        help='If set, removes coverage files generated at runtime.',
    )
    args = parser.parse_args()

    if args.format == 'html' and not args.output_dir:
        parser.error('--output-dir needed for report.')
    if args.format in ('csv', 'xml'):
        if not args.output_file:
            parser.error('--output-file needed for xml/csv reports.')
        if not args.device_or_host and args.sources_json_dir:
            parser.error(
                '--device-or-host selection needed with --sources-json-dir'
            )
    if not (args.sources_json_dir or args.class_files):
        parser.error(
            'At least either --sources-json-dir or --class-files needed.'
        )
    return args


def main():
    parser = argparse.ArgumentParser()
    args = _ParseArguments(parser)

    devil_chromium.Initialize()

    coverage_files = _GetExecFiles(
        args.coverage_dir, args.exec_filename_excludes
    )
    if not coverage_files:
        parser.error('No coverage file found under %s' % args.coverage_dir)
    print('Found coverage files: %s' % str(coverage_files))

    class_files = []
    source_dirs = []
    if args.sources_json_dir:
        sources_json_files = _GetFilesWithSuffix(
            args.sources_json_dir, _SOURCES_JSON_FILES_SUFFIX
        )
        for f in sources_json_files:
            with open(f, 'r') as json_file:
                data = json.load(json_file)
                class_files.extend(data['input_path'])
                source_dirs.extend(data['source_dirs'])

    # Fix source directories as direct parent of Java packages.
    fixed_source_dirs = set()
    for path in source_dirs:
        for partial in _PARTIAL_PACKAGE_NAMES:
            if partial in path:
                fixed_dir = os.path.join(
                    host_paths.DIR_SOURCE_ROOT, path[: path.index(partial)]
                )
                fixed_source_dirs.add(fixed_dir)
                break

    if args.class_files:
        class_files += args.class_files
    if args.sources:
        fixed_source_dirs.update(args.sources)

    cmd = [
        'java',
        '-jar',
        os.path.join(
            host_paths.DIR_SOURCE_ROOT,
            'third_party',
            'jacoco',
            'cipd',
            'lib',
            'jacococli.jar',
        ),
        'report',
    ] + coverage_files

    for source in fixed_source_dirs:
        cmd += ['--sourcefiles', source]

    if args.format == 'html':
        # Both reports are generated for html as the cq bot generates an html
        # report and we wouldn't know which one a developer needed.
        device_cmd = cmd + _GenerateReportOutputArgs(
            args, class_files, 'device'
        )
        host_cmd = cmd + _GenerateReportOutputArgs(args, class_files, 'host')

        device_exit_code = cmd_helper.RunCmd(device_cmd)
        host_exit_code = cmd_helper.RunCmd(host_cmd)
        exit_code = device_exit_code or host_exit_code
    else:
        cmd = cmd + _GenerateReportOutputArgs(
            args, class_files, args.device_or_host
        )
        exit_code = cmd_helper.RunCmd(cmd)

    if args.cleanup:
        for f in coverage_files:
            os.remove(f)

    # Command tends to exit with status 0 when it actually failed.
    if not exit_code:
        if args.format == 'html':
            if not os.path.isdir(args.output_dir) or not os.listdir(
                args.output_dir
            ):
                print('No report generated at %s' % args.output_dir)
                exit_code = 1
        elif not os.path.isfile(args.output_file):
            print(
                'No device coverage report generated at %s' % args.output_file
            )
            exit_code = 1

    return exit_code


if __name__ == '__main__':
    sys.exit(main())
