#!/usr/bin/env python3
# Copyright 2021 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 finding differences in dependency graphs."""

import argparse

import itertools
from typing import List, Set, Tuple, Callable

import chrome_names
import count_cycles
import graph
import serialization


def _print_diff_num_nodes(graph1: graph.Graph, graph2: graph.Graph, label: str):
    before: int = graph1.num_nodes
    after: int = graph2.num_nodes
    _print_diff_metric(before, after, label)


def _print_diff_num_edges(graph1: graph.Graph, graph2: graph.Graph, label: str):
    before: int = graph1.num_edges
    after: int = graph2.num_edges
    _print_diff_metric(before, after, label)


def _print_diff_metric(before: int, after: int, label: str):
    diff: int = after - before
    print(f'{label}: {diff:+} ({before} -> {after})')


def _print_diff_node_list(graph1: graph.Graph, graph2: graph.Graph, label: str):
    before_nodes: Set[str] = set(node.name for node in graph1.nodes)
    after_nodes: Set[str] = set(node.name for node in graph2.nodes)
    _print_set_diff(before_nodes, after_nodes, label)


def _print_diff_node_list_filtered(
    graph1: graph.Graph,
    graph2: graph.Graph,
    label: str,
    filter_fn: Callable[[graph.Node], bool],
):

    before_nodes: Set[str] = set(
        node.name for node in graph1.nodes if filter_fn(node)
    )
    after_nodes: Set[str] = set(
        node.name for node in graph2.nodes if filter_fn(node)
    )
    _print_diff_metric(len(before_nodes), len(after_nodes), label)
    _print_set_diff(before_nodes, after_nodes, label)


def _print_diff_edge_list(graph1: graph.Graph, graph2: graph.Graph, label: str):
    before_edges: Set[str] = set(_edge_str(edge) for edge in graph1.edges)
    after_edges: Set[str] = set(_edge_str(edge) for edge in graph2.edges)
    _print_set_diff(before_edges, after_edges, label)


def _edge_str(edge: Tuple[graph.Node, graph.Node]) -> str:
    return f'{edge[0]} -> {edge[1]}'


def _print_diff_cycle_list(
    cycles1: Set[count_cycles.Cycle],
    cycles2: Set[count_cycles.Cycle],
    label: str,
):
    before_cycles: Set[str] = set(_cycle_str(cycle) for cycle in cycles1)
    after_cycles: Set[str] = set(_cycle_str(cycle) for cycle in cycles2)
    _print_set_diff(before_cycles, after_cycles, label)


def _cycle_str(cycle: count_cycles.Cycle) -> str:
    return ' > '.join(chrome_names.shorten_class(node.name) for node in cycle)


def _print_set_diff(before_set: Set[str], after_set: Set[str], label: str):
    all_added: List[str] = sorted(after_set - before_set)
    all_removed: List[str] = sorted(before_set - after_set)
    if not all_added and not all_removed:
        print(f'{label} - no changes')
        return

    print(f'{label} added (+) and removed (-):')
    for i, added in enumerate(all_added, start=1):
        print(f'+ [{i:4}] {added}')
    for i, removed in enumerate(all_removed, start=1):
        print(f'- [{i:4}] {removed}')


def _cycle_set(
    graph: graph.Graph, max_cycle_size: int
) -> Set[count_cycles.Cycle]:
    all_cycles_by_size = count_cycles.find_cycles(graph, max_cycle_size)
    return set(itertools.chain(*all_cycles_by_size))


def main():
    arg_parser = argparse.ArgumentParser(
        description='Given two JSON dependency graphs, output the differences '
        'between them. By default, outputs the differences in the sets of '
        'class and package nodes.'
    )
    required_arg_group = arg_parser.add_argument_group('required arguments')
    required_arg_group.add_argument(
        '-b',
        '--before',
        required=True,
        help='Path to the JSON file containing the "before" dependency graph. '
        'See the README on how to generate this file.',
    )
    required_arg_group.add_argument(
        '-a',
        '--after',
        required=True,
        help='Path to the JSON file containing the "after" dependency graph.',
    )
    arg_parser.add_argument(
        '-e',
        '--edges',
        action='store_true',
        help='Also diff the set of graph edges.',
    )
    arg_parser.add_argument(
        '--package-cycles',
        type=int,
        help='Also diff the set of package cycles up to the specified size.',
    )
    arg_parser.add_argument(
        '--build-target',
        type=str,
        help='Also diff the set of class nodes in the given build target, e.g. '
        '"//chrome/android:chrome_java"',
    )
    arguments = arg_parser.parse_args()

    class_graph_before, package_graph_before, _ = (
        serialization.load_class_and_package_graphs_from_file(arguments.before)
    )
    class_graph_after, package_graph_after, _ = (
        serialization.load_class_and_package_graphs_from_file(arguments.after)
    )
    _print_diff_num_nodes(
        class_graph_before, class_graph_after, 'Total Java class count'
    )
    _print_diff_num_nodes(
        package_graph_before, package_graph_after, 'Total Java package count'
    )

    print()
    _print_diff_node_list(class_graph_before, class_graph_after, 'Java classes')
    print()
    _print_diff_node_list(
        package_graph_before, package_graph_after, 'Java packages'
    )

    if arguments.edges:
        print()
        _print_diff_num_edges(
            class_graph_before, class_graph_after, 'Total Java class edge count'
        )
        _print_diff_num_edges(
            package_graph_before,
            package_graph_after,
            'Total Java package edge count',
        )

        print()
        _print_diff_edge_list(
            class_graph_before, class_graph_after, 'Java class edges'
        )
        print()
        _print_diff_edge_list(
            package_graph_before, package_graph_after, 'Java package edges'
        )

    if arguments.package_cycles:
        cycles_before = _cycle_set(
            package_graph_before, arguments.package_cycles
        )
        cycles_after = _cycle_set(package_graph_after, arguments.package_cycles)
        print()
        _print_diff_metric(
            len(cycles_before),
            len(cycles_after),
            'Total Java package cycle count (up to size '
            f'{arguments.package_cycles})',
        )

        print()
        _print_diff_cycle_list(
            cycles_before,
            cycles_after,
            f'Java package cycles (up to size {arguments.package_cycles})',
        )

    if arguments.build_target:

        def is_in_build_target(node):
            return arguments.build_target in node.build_targets

        print()
        _print_diff_node_list_filtered(
            class_graph_before,
            class_graph_after,
            f'Java classes in {arguments.build_target}',
            is_in_build_target,
        )


if __name__ == '__main__':
    main()
