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

import dataclasses
import os
import pathlib
import re
import tempfile
from typing import Dict, Iterable, List, Set

import setup_modules  # pylint: disable=unused-import

import chromium_src.tools.metrics.common.path_util as path_util
import chromium_src.tools.metrics.python_support.dependency_solver as dependency_solver

_THIS_DIR = path_util.METRICS_TOOLS_PATH / 'python_support'

# Paths relative to src/ that we want to run the test from
_TEST_DIRECTORIES_TO_SCAN = [
  'tools/metrics',
  'tools/variations',
  'tools/json_comment_eater',
  'tools/json_to_struct',
]

# Lists a directories that contain test files relevant for Chrome Metrics
# tooling as path relative to src/.
TEST_DIRECTORIES_RELATIVE_TO_SRC = [
  path_util.CHROMIUM_SRC_PATH / d for d in _TEST_DIRECTORIES_TO_SCAN
]

_EXPECTED_TEST_FILES_SUFFIXES = ['test.py', 'tests.py']


def _get_temp_path(prefix=''):
  fd, path = tempfile.mkstemp(prefix=prefix)
  os.close(fd)
  return path


@dataclasses.dataclass
class TestableScript:
  """Describe a script that can be safely run for testing"""

  # The name to identify the script by
  identifiable_name: str

  # pathlib.Path relative to src/
  file_path: pathlib.Path

  # Full cmd to run with args included
  cmd: List[str]

  @classmethod
  def CreatePythonScript(
    cls, file_path: pathlib.Path, flags: List[str] = []
  ) -> 'TestableScript':
    """Creates TestableScript object for python script

    Args:
      file_path - a path to python script relative to src/
      flags - a list of flags to pass to the script

    Returns:
      TestableScript object set up to run using vpython3
    """
    return TestableScript(
      identifiable_name=str(file_path),
      file_path=file_path,
      cmd=['vpython3', str(file_path), *flags],
    )


# As one of the check we just run some of our existing scripts that don't have
# side effects and check if they finish successfully this is done as a last
# line of defense against changing in dependencies causing failures of scripts.
_TESTABLE_SCRIPTS: List[TestableScript] = [
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/actions/extract_actions.py'),
    flags=['--presubmit'],
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/actions/pretty_print.py'),
    flags=['--presubmit'],
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/actions/print_action_names.py')
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/histograms/merge_xml.py'),
    flags=['--output', _get_temp_path('merge_xml_test')],
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/histograms/pretty_print.py'),
    flags=[
      '--presubmit',
      'tools/metrics/histograms/metadata/uma/histograms.xml',
    ],
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path(
      'tools/metrics/histograms/print_expanded_histograms.py'
    )
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/histograms/print_histogram_names.py')
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/histograms/validate_format.py')
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path(
      'tools/metrics/histograms/validate_histograms_index.py'
    )
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/histograms/validate_token.py'),
    flags=['tools/metrics/histograms/metadata/uma/histograms.xml'],
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/private_metrics/pretty_print.py'),
    flags=['--presubmit', 'tools/metrics/private_metrics/dwa.xml'],
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/private_metrics/validate_format.py'),
    flags=['tools/metrics/private_metrics/dwa.xml'],
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/ukm/pretty_print.py'),
    flags=['--presubmit'],
  ),
  TestableScript.CreatePythonScript(
    file_path=pathlib.Path('tools/metrics/ukm/validate_format.py')
  ),
  # TODO(crbug.com/488367077): Fix this script.
  # TestableScript.CreatePythonScript(
  #   file_path='tools/metrics/histograms/histogram_ownership.py'
  # ),
  # TODO(crbug.com/488362727): Fix the unmapped histograms.
  # TestableScript.CreatePythonScript(file_path=pathlib.Path(
  #      'tools/metrics/histograms/find_unmapped_histograms.py')),
]


def _find_all_tests_in(directory_path: pathlib.Path) -> Iterable[pathlib.Path]:
  for suffix in _EXPECTED_TEST_FILES_SUFFIXES:
    yield from directory_path.rglob(f'*{suffix}')


def find_all_tests() -> Iterable[pathlib.Path]:
  """Finds all python tests in all directories listed in DIRECTORIES_TO_SCAN"""
  all_test_files: List[pathlib.Path] = []
  for dir in TEST_DIRECTORIES_RELATIVE_TO_SRC:
    all_test_files.extend(_find_all_tests_in(dir))
  return all_test_files


def validate_gn_sources(target_group: str) -> set[str]:
  """Validates that all test files are listed in target_group of BUILD.gn

  Returns: A set of paths missing in BUILD.gn, relative to src/.
  """
  dir_path = _THIS_DIR.parent
  gn_path = dir_path.joinpath('BUILD.gn')

  if not dir_path.is_dir():
    raise ValueError(f'Error: Directory {dir_path} not found.')
  if not gn_path.is_file():
    raise ValueError(f'Error: BUILD file {gn_path} not found.')

  with open(gn_path, 'r') as f:
    content = f.read()

  group_pattern = r"group\(['\"](.+?)['\"]\) \{(.*?)\n\}"
  matched_groups = re.findall(group_pattern, content, re.DOTALL)

  if not matched_groups:
    raise ValueError(
      f"Error: Could not find group '{target_group}' in {gn_path}"
    )
  relevant_groups = [g for g in matched_groups if g[0] == target_group]

  if len(relevant_groups) != 1:
    raise ValueError(
      f"Error: Could not find group '{target_group}' in {gn_path}"
    )

  group_content = relevant_groups[0][1]

  data_pattern = r'data = \[(.*?)\]'
  match_data = re.search(data_pattern, group_content, re.DOTALL)

  if not match_data:
    raise ValueError(
      f"Error: Could not find data in group '{target_group}' in {gn_path}"
    )

  data_list = match_data.group(1)

  listed_files = set(re.findall(r'["\'](.*?)["\']', data_list))
  listed_files = {pathlib.Path(f.replace('//', '')) for f in listed_files}

  actual_files = {
    f.relative_to(path_util.CHROMIUM_SRC_PATH) for f in find_all_tests()
  }

  missing_in_gn = actual_files - listed_files

  return {str(f) for f in missing_in_gn}


def _is_script_affected_by(
  testable_script: TestableScript,
  modified_files: Set[pathlib.Path],
  deps_graph: Dict[pathlib.Path, List[pathlib.Path]],
) -> bool:
  modified_files = set(
    [
      p.resolve().relative_to(path_util.CHROMIUM_SRC_PATH)
      for p in modified_files
    ]
  )
  if testable_script.file_path in modified_files:
    return True
  all_dependencies = dependency_solver.get_all_dependencies(
    deps_graph, testable_script.file_path
  )
  return any(file in all_dependencies for file in modified_files)


def get_affected_testable_scripts(
  modified_files: Set[pathlib.Path],
  deps_graph: Dict[pathlib.Path, List[pathlib.Path]],
) -> List[TestableScript]:
  """Returns testable scripts that are affected by changes in modified_files.

  Args:
    modified_files: list of modified files as absolute paths that can
      affects _TESTABLE_SCRIPTS.
  """
  return [
    testable_script
    for testable_script in _TESTABLE_SCRIPTS
    if _is_script_affected_by(testable_script, set(modified_files), deps_graph)
  ]


def get_affected_tests(
  modified_files: Set[pathlib.Path],
  deps_graph: Dict[pathlib.Path, List[pathlib.Path]],
) -> Iterable[TestableScript]:
  """Finds all python tests that could be affected by the changes."""
  all_tests = [
    TestableScript.CreatePythonScript(
      t.relative_to(path_util.CHROMIUM_SRC_PATH)
    )
    for t in find_all_tests()
    # We cannot detect dependencies for tools outside of tools/metrics
    # at the moment.
    if t.resolve().is_relative_to(path_util.METRICS_TOOLS_PATH)
  ]

  affected_tests = [
    test_script
    for test_script in all_tests
    if _is_script_affected_by(test_script, set(modified_files), deps_graph)
  ]
  return affected_tests
