#!/usr/bin/env python3
# Copyright 2020 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Command-line tool for printing class-level dependencies."""

import argparse
from dataclasses import dataclass
from typing import List, Set, Tuple

import chrome_names
import class_dependency
import graph
import package_dependency
import print_dependencies_helper
import serialization


# Return values of categorize_dependency().
IGNORE = 'ignore'
CLEAR = 'clear'
PRINT = 'print'


@dataclass
class PrintMode:
    """Options of how and which dependencies to output."""

    inbound: bool
    outbound: bool
    ignore_modularized: bool
    ignore_audited_here: bool
    ignore_same_package: bool
    fully_qualified: bool


class TargetDependencies:
    """Build target dependencies that the set of classes depends on."""

    def __init__(self):
        # Build targets usable by modularized code that need to be depended on.
        self.cleared: Set[str] = set()

        # Build targets usable by modularized code that might need to be
        # depended on. This happens rarely, when a class dependency is in
        # multiple build targets (Android resource .R classes, or due to
        # bytecode rewriting).
        self.dubious: Set[str] = set()

    def update_with_class_node(self, class_node: class_dependency.JavaClass):
        if len(class_node.build_targets) == 1:
            self.cleared.update(class_node.build_targets)
        else:
            self.dubious.update(class_node.build_targets)

    def merge(self, other: 'TargetDependencies'):
        self.cleared.update(other.cleared)
        self.dubious.update(other.dubious)

    def print(self):
        if self.cleared:
            print()
            print('Cleared dependencies:')
            for dep in sorted(self.cleared):
                print(dep)
        if self.dubious:
            print()
            print(
                'Dubious dependencies due to classes with multiple build '
                'targets. Only some of these are required:'
            )
            for dep in sorted(self.dubious):
                print(dep)


INBOUND = 'inbound'
OUTBOUND = 'outbound'
ALLOWED_PREFIXES = {
    '//base/',
    '//base:',
    '//chrome/browser/',
    '//components/',
    '//content/',
    '//ui/',
    '//url:',
}
IGNORED_CLASSES = {'org.chromium.base.natives.GEN_JNI'}


def get_class_name_to_display(
    fully_qualified_name: str, print_mode: PrintMode
) -> str:
    if print_mode.fully_qualified:
        return fully_qualified_name
    else:
        return chrome_names.shorten_class(fully_qualified_name)


def get_build_target_name_to_display(
    build_target: str, print_mode: PrintMode
) -> str:
    if print_mode.fully_qualified:
        return build_target
    else:
        return chrome_names.shorten_build_target(build_target)


def is_allowed_target_dependency(build_target: str) -> bool:
    return any(build_target.startswith(p) for p in ALLOWED_PREFIXES)


def is_ignored_class_dependency(class_name: str) -> bool:
    return class_name in IGNORED_CLASSES


def categorize_dependency(
    from_class: class_dependency.JavaClass,
    to_class: class_dependency.JavaClass,
    ignore_modularized: bool,
    print_mode: PrintMode,
    audited_classes: Set[str],
) -> str:
    """Decides if a class dependency should be printed, cleared, or ignored."""
    if is_ignored_class_dependency(to_class.name):
        return IGNORE
    if ignore_modularized and all(
        is_allowed_target_dependency(target)
        for target in to_class.build_targets
    ):
        return CLEAR
    if (
        print_mode.ignore_same_package
        and to_class.package == from_class.package
    ):
        return IGNORE
    if print_mode.ignore_audited_here and to_class.name in audited_classes:
        return IGNORE
    return PRINT


def print_class_dependencies(
    to_classes: List[class_dependency.JavaClass],
    print_mode: PrintMode,
    from_class: class_dependency.JavaClass,
    direction: str,
    audited_classes: Set[str],
) -> TargetDependencies:
    """Prints the class dependencies to or from a class, grouped by target.

    If direction is OUTBOUND and print_mode.ignore_modularized is True, omits
    modularized outbound dependencies and returns the build targets that need
    to be added for those dependencies. In other cases, returns an empty
    TargetDependencies.

    If print_mode.ignore_same_package is True, omits outbound dependencies in
    the same package.
    """
    ignore_modularized = direction == OUTBOUND and print_mode.ignore_modularized
    bullet_point = '<-' if direction == INBOUND else '->'

    print_backlog: List[Tuple[int, str]] = []

    # TODO(crbug.com/40147556): This is not quite correct because
    # sets considered equal can be converted to different strings. Fix this by
    # making JavaClass.build_targets return a List instead of a Set.
    suspect_dependencies = 0

    target_dependencies = TargetDependencies()
    to_classes = sorted(to_classes, key=lambda c: str(c.build_targets))
    last_build_target = None

    for to_class in to_classes:
        # Check if dependency should be ignored due to --ignore-modularized,
        # --ignore-same-package, or due to being an ignored class.
        # Check if dependency should be listed as a cleared dep.
        ignore_allow = categorize_dependency(
            from_class,
            to_class,
            ignore_modularized,
            print_mode,
            audited_classes,
        )
        if ignore_allow == CLEAR:
            target_dependencies.update_with_class_node(to_class)
            continue
        elif ignore_allow == IGNORE:
            continue

        # Print the dependency
        suspect_dependencies += 1
        build_target = str(to_class.build_targets)
        if last_build_target != build_target:
            build_target_names = [
                get_build_target_name_to_display(target, print_mode)
                for target in to_class.build_targets
            ]
            build_target_names_string = ", ".join(build_target_names)
            print_backlog.append((4, f'[{build_target_names_string}]'))
            last_build_target = build_target
        display_name = get_class_name_to_display(to_class.name, print_mode)
        print_backlog.append((8, f'{bullet_point} {display_name}'))

    # Print header
    class_name = get_class_name_to_display(from_class.name, print_mode)
    if ignore_modularized:
        cleared = len(to_classes) - suspect_dependencies
        print(
            f'{class_name} has {suspect_dependencies} outbound dependencies '
            f'that may need to be broken (omitted {cleared} cleared '
            f'dependencies):'
        )
    else:
        if direction == INBOUND:
            print(f'{class_name} has {len(to_classes)} inbound dependencies:')
        else:
            print(f'{class_name} has {len(to_classes)} outbound dependencies:')

    # Print build targets and dependencies
    for indent, message in print_backlog:
        indents = ' ' * indent
        print(f'{indents}{message}')

    return target_dependencies


def print_class_dependencies_for_key(
    class_graph: class_dependency.JavaClassDependencyGraph,
    key: str,
    print_mode: PrintMode,
    audited_classes: Set[str],
) -> TargetDependencies:
    """Prints dependencies for a valid key into the class graph."""
    target_dependencies = TargetDependencies()
    node: class_dependency.JavaClass = class_graph.get_node_by_key(key)

    if print_mode.inbound:
        print_class_dependencies(
            graph.sorted_nodes_by_name(node.inbound),
            print_mode,
            node,
            INBOUND,
            audited_classes,
        )

    if print_mode.outbound:
        target_dependencies = print_class_dependencies(
            graph.sorted_nodes_by_name(node.outbound),
            print_mode,
            node,
            OUTBOUND,
            audited_classes,
        )
    return target_dependencies


def main():
    """Prints class-level dependencies for one or more input classes."""
    arg_parser = argparse.ArgumentParser(
        description='Given a JSON dependency graph, output '
        'the class-level dependencies for a given list of classes.'
    )
    required_arg_group = arg_parser.add_argument_group('required arguments')
    required_arg_group.add_argument(
        '-f',
        '--file',
        required=True,
        help='Path to the JSON file containing the dependency graph. '
        'See the README on how to generate this file.',
    )
    required_arg_group_either = arg_parser.add_argument_group(
        'required arguments (at least one)'
    )
    required_arg_group_either.add_argument(
        '-c',
        '--classes',
        dest='class_names',
        help='Case-sensitive name of the classes to print dependencies for. '
        'Matches either the simple class name without package or the fully '
        'qualified class name. For example, `AppHooks` matches '
        '`org.chromium.browser.AppHooks`. Specify multiple classes with a '
        'comma-separated list, for example '
        '`ChromeActivity,ChromeTabbedActivity`',
    )
    required_arg_group_either.add_argument(
        '-p',
        '--packages',
        dest='package_names',
        help='Case-sensitive name of the packages to print dependencies for, '
        'such as `org.chromium.browser`. Specify multiple packages with a '
        'comma-separated list.`',
    )
    direction_arg_group = arg_parser.add_mutually_exclusive_group()
    direction_arg_group.add_argument(
        '--inbound',
        dest='inbound_only',
        action='store_true',
        help='Print inbound dependencies only.',
    )
    direction_arg_group.add_argument(
        '--outbound',
        dest='outbound_only',
        action='store_true',
        help='Print outbound dependencies only.',
    )
    arg_parser.add_argument(
        '--fully-qualified',
        action='store_true',
        help='Use fully qualified class names instead of shortened ones.',
    )
    arg_parser.add_argument(
        '--ignore-modularized',
        action='store_true',
        help='Do not print outbound dependencies on '
        'allowed (modules, components, base, etc.) '
        'dependencies.',
    )
    arg_parser.add_argument(
        '--ignore-audited-here',
        action='store_true',
        help='Do not print outbound dependencies on '
        'other classes being audited in this run.',
    )
    arg_parser.add_argument(
        '--ignore-same-package',
        action='store_true',
        help='Do not print outbound dependencies on '
        'classes in the same package.',
    )
    arguments = arg_parser.parse_args()

    if not arguments.class_names and not arguments.package_names:
        raise ValueError(
            'Either -c/--classes or -p/--packages need to be specified.'
        )

    print_mode = PrintMode(
        inbound=not arguments.outbound_only,
        outbound=not arguments.inbound_only,
        ignore_modularized=arguments.ignore_modularized,
        ignore_audited_here=arguments.ignore_audited_here,
        ignore_same_package=arguments.ignore_same_package,
        fully_qualified=arguments.fully_qualified,
    )

    class_graph, package_graph, _ = (
        serialization.load_class_and_package_graphs_from_file(arguments.file)
    )

    valid_class_names = []
    if arguments.class_names:
        valid_class_names.extend(
            print_dependencies_helper.get_valid_classes_from_class_input(
                class_graph, arguments.class_names
            )
        )
    if arguments.package_names:
        valid_class_names.extend(
            print_dependencies_helper.get_valid_classes_from_package_input(
                package_graph, arguments.package_names
            )
        )

    target_dependencies = TargetDependencies()
    for i, fully_qualified_class_name in enumerate(valid_class_names):
        if i > 0:
            print()

        new_target_deps = print_class_dependencies_for_key(
            class_graph,
            fully_qualified_class_name,
            print_mode,
            set(valid_class_names),
        )
        target_dependencies.merge(new_target_deps)

    target_dependencies.print()


if __name__ == '__main__':
    main()
