# 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.

# A library allowing to solve dependencies within the tools/metrics
# setup. It only detects internal dependencies imported through
#
# ```
# import chromium_src.tools.metrics...
# ```
#
# or similar import.
#
# It's used to make sure that all affected files are tested when
# running presubmits.

import os
import pathlib
import re
from typing import Any, Dict, List, Optional, Set

_IMPORT_PATTERNS = [
  (re.compile(r'^import chromium_src\.tools\.metrics\.?(.*) as .*$'), False),
  (re.compile(r'^import chromium_src\.tools\.metrics\.?([^ ]*)$'), False),
  (re.compile(r'^from chromium_src\.tools\.metrics\.?(.*) import (.*)$'), True),
]


def _get_py_files_recursive(directory: pathlib.Path) -> List[pathlib.Path]:
  """Recursively finds all .py files in a given directory."""
  return list(directory.rglob('*.py'))


def _resolve_fs_path(path: pathlib.Path) -> List[pathlib.Path]:
  """Resolves a path to either a single .py file or all files in a directory.

  It checks if the path is a file (by appending .py) - if so, it returns a list
  containing the Path to that file.

  If it's not a py file, it tries to resolve it as a directory - returning paths
  of all files within that directory recursively.

  If neither exists, throws FileNotFoundError.
  """
  file_candidate = pathlib.Path(str(path) + '.py')
  if file_candidate.is_file():
    return [file_candidate]

  if path.is_dir():
    return _get_py_files_recursive(path)

  raise FileNotFoundError(
    f"Could not resolve import. Neither '{file_candidate}' exists, "
    f"nor is '{path}' a directory."
  )


def _process_import_match(
  root_path: pathlib.Path, path_group: str, symbol: Optional[str] = None
) -> List[pathlib.Path]:
  """Constructs the final Path from the parsed import statement.

  If a symbol is present, it tries to append it first as a file/sub-module.
  If that fails, it falls back to checking the path without the symbol.
  """
  full_path = root_path / path_group.replace('.', os.sep)

  if not symbol:
    return _resolve_fs_path(full_path)

  path_with_symbol = full_path / symbol
  try:
    return _resolve_fs_path(path_with_symbol)
  except FileNotFoundError:
    return _resolve_fs_path(full_path)


def _parse_line_dependencies(
  root_path: pathlib.Path, line: str, pattern: re.Pattern
) -> List[pathlib.Path]:
  """Returns dependencies for the line (if any) using pattern."""
  match = pattern.match(line)
  if not match:
    return []

  path_suffix = match.group(1)

  return [
    p.relative_to(root_path)
    for p in _process_import_match(root_path, path_suffix, None)
  ]


def _parse_line_dependencies_with_symbol(
  root_path: pathlib.Path, line: str, pattern: re.Pattern
) -> List[pathlib.Path]:
  """Returns dependencies for the line (if any) using pattern."""
  match = pattern.match(line)
  if not match:
    return []
  path_suffix = match.group(1)
  symbols_str = match.group(2).strip()
  symbols = [s.strip() for s in symbols_str.split(',')]

  imports: List[pathlib.Path] = []
  for symbol in symbols:
    imports.extend(
      p.relative_to(root_path)
      for p in _process_import_match(root_path, path_suffix, symbol)
    )
  return imports


def _dependencies_of(
  root_path: pathlib.Path, relative_path: pathlib.Path
) -> List[pathlib.Path]:
  """Returns a list of dependencies (as Paths relative to root_path) for a file.

  The file is identified by relative_path within root_path.
  """
  full_path = root_path / relative_path
  if not full_path.exists():
    raise FileNotFoundError(f'Input file not found: {full_path}')

  with open(full_path, 'r', encoding='utf-8') as f:
    lines = f.readlines()
  dependencies = []

  for line in lines:
    line = line.strip()
    for pattern, has_symbol in _IMPORT_PATTERNS:
      if has_symbol:
        dependencies.extend(
          _parse_line_dependencies_with_symbol(root_path, line, pattern)
        )
      else:
        dependencies.extend(_parse_line_dependencies(root_path, line, pattern))

  return dependencies


def scan_directory_dependencies(
  root_path: pathlib.Path, report_relative_to: Optional[pathlib.Path] = None
) -> Dict[pathlib.Path, List[pathlib.Path]]:
  """Scans the directory for .py files and builds a dependency map.

  Returns:
    Dict[pathlib.Path, List[pathlib.Path]]: Keys are relative file Paths,
    values are lists of dependencies as relative file Paths.
  """
  root_path = pathlib.Path(root_path)
  reporting_root = (
    pathlib.Path(report_relative_to) if report_relative_to else root_path
  )
  rel_files = [
    p.relative_to(root_path) for p in _get_py_files_recursive(root_path)
  ]
  return {
    (root_path / path).relative_to(reporting_root): [
      (root_path / dep_path).relative_to(reporting_root)
      for dep_path in _dependencies_of(root_path, path)
    ]
    for path in rel_files
  }


def print_dependency_graph(
  dependency_graph: Dict[pathlib.Path, List[pathlib.Path]],
) -> None:
  """Prints a visual representation of direct dependencies for each file."""
  header_script = 'Script'
  print(f'{header_script:<50} | Dependencies')
  print('-' * 80)

  empty_prefix = ''
  for script_path in dependency_graph:
    deps = dependency_graph[script_path]

    if not deps:
      print(f'{script_path:<50} | (None)')
    else:
      print(f'{script_path:<50} | -> {deps[0]}')
      for dep in deps[1:]:
        print(f'{empty_prefix:<50} | -> {dep}')
    print('-' * 80)


def get_all_dependencies(
  dependency_graph: Dict[pathlib.Path, List[pathlib.Path]],
  target_script: pathlib.Path,
) -> Set[pathlib.Path]:
  """Returns a set of ALL dependencies (direct and indirect) for a script.

  Detects and handles circular dependencies safely.
  """
  if not dependency_graph:
    return set()

  if target_script not in dependency_graph:
    raise ValueError(
      f"Script '{target_script}' not found in the scanned graph."
    )

  all_dependencies: Set[pathlib.Path] = set()
  queue = [target_script]
  visited = set()

  while queue:
    current_script = queue.pop(0)

    if current_script in visited:
      continue
    visited.add(current_script)
    direct_deps = dependency_graph.get(current_script, [])

    for dep in direct_deps:
      all_dependencies.add(dep)
      queue.append(dep)

  return all_dependencies
