#!/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.
"""Test analysis functions for the testing framework."""

from collections import defaultdict
import logging
import os
import re
from typing import Dict, List, Set

from models import Action
from models import ActionType
from models import CoverageTest
from models import CoverageTestsByPlatform
from models import CoverageTestsByPlatformSet
from models import TestId
from models import TestIdsTestNamesByPlatformSet
from models import TestPartitionDescription
from models import TestPlatform


def filter_tests_for_partition(
    tests: List[CoverageTest], partition: TestPartitionDescription
) -> List[CoverageTest]:
    """Returns tests whose actions match any prefix assigned to `partition`."""
    return [
        test
        for test in tests
        if any(
            action.name.startswith(prefix)
            for action in test.actions
            for prefix in partition.action_name_prefixes
        )
    ]


def compare_and_print_tests_to_remove_and_add(
    existing_tests: TestIdsTestNamesByPlatformSet,
    required_tests: CoverageTestsByPlatformSet,
    test_partitions: List[TestPartitionDescription],
    default_partition: TestPartitionDescription,
    add_to_file: bool,
):
    """
    Given the existing tests on disk and the required tests, print out the
    changes that need to happen to make them match. This also takes into account
    test partitioning, so tests are asked to be written to the appropriate test
    partition file.
    Note: This does NOT support moving tests between partition files. If a test
    was found in any partition file, then it is ignored.
    """

    def write_tests(
        filename: str,
        tests: List[CoverageTest],
        partition: TestPartitionDescription,
        ordered_tests: List[CoverageTest],
    ):

        def update_existing_generated_test_block(
            existing_block: str, new_block: str
        ) -> str:
            """Replace helper_ body; preserve existing header when possible."""
            existing_body_match = re.search(
                r'(?m)^[ \t]*helper_\.', existing_block
            )
            new_body_match = re.search(r'(?m)^[ \t]*helper_\.', new_block)
            existing_body_start = (
                existing_body_match.start() if existing_body_match else -1
            )
            new_body_start = new_body_match.start() if new_body_match else -1
            existing_body_end = existing_block.rfind("\n}")
            new_body_end = new_block.rfind("\n}")
            if (
                min(
                    existing_body_start,
                    new_body_start,
                    existing_body_end,
                    new_body_end,
                )
                == -1
            ):
                return new_block
            return (
                existing_block[:existing_body_start]
                + new_block[new_body_start:new_body_end]
                + existing_block[existing_body_end:]
            )

        def shared_name_segments(test_name_a: str, test_name_b: str) -> int:
            shared_segments = 0
            for segment_a, segment_b in zip(
                test_name_a.split("_"), test_name_b.split("_")
            ):
                if segment_a != segment_b:
                    break
                shared_segments += 1
            return shared_segments

        def find_generated_matches(test_file: str) -> List[re.Match[str]]:
            """Return regex matches for generated test blocks appearing
            after the '// Generated tests:' marker, or in the whole file
            if the marker is absent."""
            generated_tests_start = test_file.find("// Generated tests:")
            if generated_tests_start == -1:
                generated_tests_start = 0
            return [
                match
                for match in re.finditer(
                    'IN_PROC_BROWSER_TEST_[PF][\\(\\w\\s,]+'
                    f'{CoverageTest.TEST_ID_PREFIX}([a-zA-Z0-9._-]+)\\)'
                    '\\s*{\\n(?:\\s*\\/\\/.*\\n)+((?:[^;^}}]+;\\n)+)}',
                    test_file,
                )
                if match.start() > generated_tests_start
            ]

        new_test_str = "\n\n".join(
            test.generate_browsertest(partition) for test in tests
        )
        if add_to_file:
            if os.path.exists(filename):
                with open(filename, "r", encoding="utf-8") as f:
                    test_file = f.read()
                generated_matches = find_generated_matches(test_file)
                existing_matches_by_name = {
                    match.group(1): match for match in generated_matches
                }
                tests_by_name = {
                    test.generate_test_name(): test for test in tests
                }

                if generated_matches:
                    # Phase 1: Update tests that already exist by name.
                    # Process in reverse file order so earlier match positions
                    # remain valid after each replacement.
                    replacement_names = [
                        test_name
                        for test_name in tests_by_name
                        if test_name in existing_matches_by_name
                    ]
                    for test_name in sorted(
                        replacement_names,
                        key=lambda name: existing_matches_by_name[name].start(),
                        reverse=True,
                    ):
                        match = existing_matches_by_name[test_name]
                        new_block = tests_by_name[
                            test_name
                        ].generate_browsertest(partition)
                        updated_block = update_existing_generated_test_block(
                            match.group(0), new_block
                        )
                        test_file = (
                            test_file[: match.start()]
                            + updated_block
                            + test_file[match.end() :]
                        )
                    if replacement_names:
                        generated_matches = find_generated_matches(test_file)
                        existing_matches_by_name = {
                            match.group(1): match for match in generated_matches
                        }

                    # Phase 2: Insert new tests near the most similar existing
                    # test by name prefix.
                    insertion_names = [
                        test.generate_test_name()
                        for test in ordered_tests
                        if test.generate_test_name() in tests_by_name
                        and test.generate_test_name() not in replacement_names
                    ]
                    for test_name in insertion_names:
                        insertion_position = (
                            generated_matches[-1].end()
                            if generated_matches
                            else len(test_file)
                        )
                        max_shared_segments = 0
                        most_similar_name = None
                        for existing_name in existing_matches_by_name:
                            shared_segments = shared_name_segments(
                                existing_name, test_name
                            )
                            if shared_segments > max_shared_segments or (
                                shared_segments == max_shared_segments
                                and shared_segments > 0
                            ):
                                max_shared_segments = shared_segments
                                most_similar_name = existing_name
                        if max_shared_segments >= 1:
                            insertion_position = existing_matches_by_name[
                                most_similar_name
                            ].end()
                        insertion_block = tests_by_name[
                            test_name
                        ].generate_browsertest(partition)
                        # Add spacing: prepend newlines when inserting after
                        # a test block; otherwise append newlines after insertion.
                        inserts_after_existing_test = (
                            generated_matches
                            and insertion_position
                            == generated_matches[-1].end()
                        ) or (
                            most_similar_name is not None
                            and insertion_position
                            == existing_matches_by_name[most_similar_name].end()
                        )
                        if inserts_after_existing_test:
                            insertion_str = f"\n\n{insertion_block}"
                        else:
                            insertion_str = f"{insertion_block}\n\n"
                        test_file = (
                            test_file[:insertion_position]
                            + insertion_str
                            + test_file[insertion_position:]
                        )
                        generated_matches = find_generated_matches(test_file)
                        existing_matches_by_name = {
                            match.group(1): match for match in generated_matches
                        }

                # Fallback to adding the tests to the end of the file instead
                # of the smarter approach.
                if not generated_matches:
                    # Find the last test in the test file
                    matches = list(
                        re.finditer(
                            r"IN_PROC_BROWSER_TEST_[PF](.|\n)*?}\n", test_file
                        )
                    )
                    if matches:
                        last_test_end_index = matches[-1].end()
                    else:
                        # If no tests found, try to insert before the last closing brace
                        # (which is usually the closing namespace).
                        last_brace_index = test_file.rfind("}")
                        if last_brace_index != -1:
                            # Find the start of the line with the last brace to be clean.
                            last_test_end_index = (
                                test_file.rindex('\n', 0, last_brace_index) + 1
                            )
                        else:
                            last_test_end_index = len(test_file)
                    test_file = (
                        test_file[:last_test_end_index]
                        + "\n"
                        + new_test_str
                        + "\n"
                        + test_file[last_test_end_index:]
                    )
                with open(filename, "w", encoding="utf-8") as f:
                    f.write(test_file)
            else:
                print(
                    f"\n\nCreate a new test file: {filename}\n"
                    "Remember to add the new test file to the BUILD file.\n"
                    "Add the following tests to the new test file:\n"
                    f"\n{new_test_str}\n"
                )
        else:
            print(
                f"\n\nAdd the following tests to {filename}:\n"
                f"\n{new_test_str}\n"
            )

    test_ids_to_keep: Dict[frozenset[TestPlatform], Set[TestId]] = defaultdict(
        set
    )
    test_names_to_keep: Dict[frozenset[TestPlatform], Set[str]] = defaultdict(
        set
    )
    for platforms, tests in required_tests.items():
        tests_to_add: List[CoverageTest] = []
        for test in tests:
            test_names_to_keep[platforms].add(test.generate_test_name())
            if platforms in existing_tests:
                existing_test_set = {
                    test_id for (test_id, _) in existing_tests[platforms]
                }
                if test.id not in existing_test_set:
                    tests_to_add.append(test)
                else:
                    test_ids_to_keep[platforms].add(test.id)
            else:
                tests_to_add.append(test)
        tests_added_to_partition: Set[TestId] = set()
        for partition in test_partitions:
            tests_to_add_partition = filter_tests_for_partition(
                tests_to_add, partition
            )
            if not tests_to_add_partition:
                continue
            # Record all tests to ensure we don't have duplicates in different
            # files, and to output remaining tests to the default partition.
            for test in tests_to_add_partition:
                if test.id in tests_added_to_partition:
                    raise ValueError(
                        "Cannot have a test written to multiple test files."
                    )
                tests_added_to_partition.add(test.id)
            filename = partition.generate_browsertest_filepath(platforms)
            ordered_partition_tests = filter_tests_for_partition(
                tests, partition
            )
            write_tests(
                filename,
                tests_to_add_partition,
                partition,
                ordered_partition_tests,
            )

        # All remaining tests go into the default partition
        default_tests: List[CoverageTest] = [
            test
            for test in tests_to_add
            if test.id not in tests_added_to_partition
        ]
        if not default_tests:
            continue
        filename = default_partition.generate_browsertest_filepath(platforms)
        ordered_default_tests = [
            test for test in tests if test.id not in tests_added_to_partition
        ]
        write_tests(
            filename, default_tests, default_partition, ordered_default_tests
        )
    # Print out all tests to remove. To keep the algorithm simple the partition
    # is not kept track of.
    for platforms, test_ids_names in existing_tests.items():
        tests_to_remove = []
        prompt_str = ""
        nice_platform_str = ", ".join([f"{platform}" for platform in platforms])
        if platforms not in test_ids_to_keep:
            prompt_str = (
                f"\n\nRemove ALL tests from the file for the "
                f"platforms [{nice_platform_str}]:\n"
            )
            tests_to_remove = [
                test_name
                for (_, test_name) in test_ids_names
                if test_name not in test_names_to_keep[platforms]
            ]
        else:
            prompt_str = (
                f"\n\nRemove these tests from the file for the "
                f"platforms [{nice_platform_str}]:\n"
            )
            tests_to_remove = [
                test_name
                for (test_id, test_name) in test_ids_names
                if test_id not in test_ids_to_keep[platforms]
                and test_name not in test_names_to_keep[platforms]
            ]

        if not tests_to_remove:
            continue
        print(f"{prompt_str}{', '.join(tests_to_remove)}")


def expand_parameterized_tests(
    coverage_tests: List[CoverageTest],
) -> List[CoverageTest]:
    """
    Takes a list of coverage tests that contain parameterized actions, and
    expands all of the tests with those actions to result in a list of tests
    without parameterized actions.
    """

    def get_all_parameterized_tests(
        test_actions: List[Action],
    ) -> List[List[Action]]:
        """
        Takes a list of actions with possible parameterized actions, and outputs
        a list of resulting tests with all parameterized actions expanded.
        """
        if not test_actions:
            return [[]]
        for i, action in enumerate(test_actions):
            if action.type is not ActionType.PARAMETERIZED:
                continue
            actions_before_parameterized = test_actions[:i]
            actions_after_parameterized = test_actions[i + 1 :]
            resulting_tests = []
            for output_action in action.output_actions:
                remaining_expanded_tests = get_all_parameterized_tests(
                    actions_after_parameterized
                )
                for remaining_test in remaining_expanded_tests:
                    test = (
                        actions_before_parameterized
                        + [output_action]
                        + remaining_test
                    )
                    resulting_tests.append(test)
            return resulting_tests
        # No parameterized actions were found, so just return the test actions.
        return [test_actions]

    result_tests = []
    for test in coverage_tests:
        queue = [test.actions]
        final_expanded_tests = []

        while queue:
            current_actions = queue.pop(0)
            expanded = get_all_parameterized_tests(current_actions)

            for expanded_test in expanded:
                if any(
                    action.type is ActionType.PARAMETERIZED
                    for action in expanded_test
                ):
                    queue.append(expanded_test)
                else:
                    final_expanded_tests.append(expanded_test)

        logging.info(
            f"Generated {len(final_expanded_tests)} test/s from {test.id}"
        )
        for resulting_test in final_expanded_tests:
            result_tests.append(CoverageTest(resulting_test, test.platforms))
    return result_tests


def filter_coverage_tests_for_platform(
    tests: List[CoverageTest], platform: TestPlatform
) -> List[CoverageTest]:
    return [test for test in tests if platform in test.platforms]


def partition_framework_tests_per_platform_combination(
    generated_tests_per_platform: CoverageTestsByPlatform,
) -> CoverageTestsByPlatformSet:
    test_id_to_platforms = defaultdict(lambda: set())
    test_id_to_test = {}
    platform_set_to_tests = defaultdict(lambda: list())
    for platform, tests in generated_tests_per_platform.items():
        for test in tests:
            test_id_to_platforms[test.id].add(platform)
            if test.id not in test_id_to_test:
                test_id_to_test[test.id] = CoverageTest(test.actions, set())
            test_id_to_test[test.id].platforms.add(platform)
    for test_id, platforms in test_id_to_platforms.items():
        platforms = frozenset(platforms)
        platform_set_to_tests[platforms].append(test_id_to_test[test_id])
    return platform_set_to_tests
