#!/usr/bin/env python3
# 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.
"""TestSuiteMapper: Maps a file to its target test suite."""

import argparse
import ast
from contextlib import contextmanager
import json
import os
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile

SRC_ROOT = pathlib.Path(__file__).resolve().parents[2]
ISOLATE_MAP_PATH = (
  pathlib.Path('infra')
  / 'config'
  / 'generated'
  / 'testing'
  / 'gn_isolate_map.pyl'
)
UPDATE_RUST_PATH = pathlib.Path('tools') / 'rust' / 'update_rust.py'
RUST_TOOLCHAIN_PATH = pathlib.Path('third_party') / 'rust-toolchain'


def run_git(src_root, args):
  """Runs a git command and returns the CompletedProcess object.

  Args:
    src_root: Path to the git repository root.
    args: List of command line arguments for git.

  Returns:
    A subprocess.CompletedProcess object.
  """
  cmd = ['git'] + args
  return subprocess.run(
    cmd,
    capture_output=True,
    text=True,
    cwd=src_root,
    check=False,
    encoding='utf-8',
  )


def parse_isolate_map(content_str, source_name):
  """Parses isolate map content string.

  Args:
    content_str: The string content of the isolate map file (AST literal).
    source_name: Name or path of the source file (used for error logging).

  Returns:
    A dictionary mapping GN labels (strings) to test suite names (strings),
    or None if parsing fails.
  """
  try:
    data = ast.literal_eval(content_str)
    # Map label -> suite name, excluding additional_compile_target
    label_to_suite = {}
    if not isinstance(data, dict):
      print(
        f'Error parsing {source_name}: Expected a dictionary', file=sys.stderr
      )
      return None
    for suite, details in data.items():
      if (
        isinstance(details, dict)
        and details.get('type') == 'additional_compile_target'
      ):
        continue
      if isinstance(details, dict):
        label = details.get('label')
        if label:
          label_to_suite[label] = suite
    return label_to_suite
  except Exception as e:
    print(
      f'Error parsing gn_isolate_map.pyl from {source_name}: {e}',
      file=sys.stderr,
    )
    return None


def load_isolate_map(src_root):
  """Loads and parses the gn_isolate_map.pyl file from the workspace.

  Args:
    src_root: Path to the source root.

  Returns:
    A dictionary mapping GN labels to test suite names, or None if it fails.
  """
  src_root = pathlib.Path(src_root)
  pyl_path = src_root / ISOLATE_MAP_PATH
  if not pyl_path.exists():
    print(
      f'Error: Isolate map not found at {pyl_path}. '
      f'Please run main.star or sync.',
      file=sys.stderr,
    )
    return None

  try:
    with open(pyl_path, 'r', encoding='utf-8') as f:
      return parse_isolate_map(f.read(), str(pyl_path))
  except Exception as e:
    print(f'Error reading {pyl_path}: {e}', file=sys.stderr)
    return None


def run_gn_refs(src_root, build_dir, target_file):
  """Executes `gn refs` to retrieve the dependent targets closure.

  Args:
    src_root: Path to the source root.
    build_dir: Path to the build directory (e.g., out/Default).
    target_file: pathlib.Path to the target file to scan.

  Returns:
    A tuple (refs, error_message):
      refs: A list of strings (GN targets) if successful, or None.
      error_message: A string containing error output if failed, or None.
  """
  # Normalize target file to gn syntax
  gn_target = target_file.as_posix()
  if not gn_target.startswith('//'):
    gn_target = '//' + gn_target.lstrip('/')

  # Execute gn refs command
  cmd = ['gn', 'refs', build_dir, '--all', gn_target]
  try:
    res = subprocess.run(
      cmd,
      capture_output=True,
      text=True,
      cwd=src_root,
      check=False,
      encoding='utf-8',
    )
    if res.returncode != 0:
      return None, res.stderr.strip()
    return res.stdout.splitlines(), None
  except Exception as e:
    return None, str(e)


def common_dir_prefix_len(path1, path2):
  """Calculates the number of common leading directory components.

  Args:
    path1: First path string.
    path2: Second path string.

  Returns:
    Integer representing the number of matching path components from the
    root.
  """
  p1 = path1.strip('/').split('/')
  p2 = path2.strip('/').split('/')
  common = 0
  for c1, c2 in zip(p1, p2):
    if c1 == c2:
      common += 1
    else:
      break
  return common


def extract_test_suites(gn_refs_output, file_path, isolate_map):
  """Scans and filters transitive GN targets for executable test suites.

  Heuristic: Prefers test suites that are in a closer directory to the
  target file.

  Args:
    gn_refs_output: List of strings representing GN targets.
    file_path: pathlib.Path of the target file.
    isolate_map: Dictionary mapping GN labels to test suite names.

  Returns:
    A list of strings representing the names of the test suites that cover
    the file, sorted by proximity.
  """
  resolved_suites = {}

  # Normalize file_path to relative path without leading // for comparison
  file_path_str = file_path.as_posix()
  file_dir = str(pathlib.Path(file_path_str.lstrip('/')).parent)

  for target in gn_refs_output:
    target = target.strip()
    if not target:
      continue

    parts = target.split(':')
    target_dir = parts[0].lstrip('/') if len(parts) > 1 else ''

    # Look up in isolate map
    if target in isolate_map:
      suite_name = isolate_map[target]
      # Score based on how many directory components are shared.
      # The 'score' is the depth of the common directory prefix between the
      # file and the target.
      score = common_dir_prefix_len(file_dir, target_dir)
      resolved_suites[suite_name] = max(
        resolved_suites.get(suite_name, -1), score
      )

  if not resolved_suites:
    return []

  max_score = max(resolved_suites.values())

  # Filter: if we have any matches with score > 0, discard matches with
  # score == 0 (e.g., targets in the root directory).
  if max_score > 0:
    filtered_suites = [
      name for name, score in resolved_suites.items() if score > 0
    ]
  else:
    filtered_suites = list(resolved_suites.keys())

  return sorted(filtered_suites)


def get_build_gn_paths(gn_refs_output):
  """Extracts unique BUILD.gn file paths from GN targets.

  Args:
    gn_refs_output: List of strings representing GN targets.

  Returns:
    A sorted list of strings representing relative paths to BUILD.gn files.
  """
  build_gns = set()
  for target in gn_refs_output:
    target = target.strip()
    if not target.startswith('//'):
      continue
    path_part = target[2:]
    parts = path_part.split(':')
    dir_path = parts[0]
    if dir_path:
      build_gns.add(dir_path + '/BUILD.gn')
    else:
      build_gns.add('BUILD.gn')
  return sorted(list(build_gns))


def check_graph_stability(src_root, revision, target_file, gn_refs_output):
  """Checks if BUILD.gn files or the target file have changed since revision.

  Args:
    src_root: Path to the source root.
    revision: The git revision to compare against.
    target_file: pathlib.Path to the target file.
    gn_refs_output: List of GN targets that depend on the file.

  Returns:
    A tuple (stable, changed_files):
      stable: Boolean indicating if the graph is stable.
      changed_files: List of strings (paths to changed files) if unstable.
  """
  build_gns = get_build_gn_paths(gn_refs_output)
  rel_target_file = target_file.lstrip('/')
  paths_to_check = [rel_target_file] + build_gns
  cmd = ['diff', '--name-only', f'{revision}..HEAD', '--'] + paths_to_check
  res = run_git(src_root, cmd)
  if res.returncode != 0:
    print(
      f'Warning: Stability check failed to run: {res.stderr.strip()}',
      file=sys.stderr,
    )
    return False, []
  changed_files = res.stdout.splitlines()
  return len(changed_files) == 0, changed_files


def get_ignored_paths(src_root):
  """Finds ignored file and directory paths in the main checkout.

  Args:
    src_root: Path to the source root.

  Returns:
    A list of strings representing relative paths to ignored directories/files.
  """
  res = run_git(src_root, ['status', '--ignored', '--porcelain'])
  ignored_paths = []
  if res.returncode == 0:
    for line in res.stdout.splitlines():
      if line.startswith('!! '):
        path = line[3:]
        if path.endswith('/'):
          path = path[:-1]
        ignored_paths.append(path)
  return ignored_paths


def find_nested_repos_and_symlinks(src_root):
  """Finds nested git repos and symlinks in the checkout up to depth 5.

  Args:
    src_root: Path to the source root.

  Returns:
    A list of strings representing relative paths to nested repos/symlinks.
  """
  src_root = pathlib.Path(src_root)
  targets = []
  max_depth = 5
  for root, dirs, files in os.walk(src_root):
    root_path = pathlib.Path(root)
    rel_path = root_path.relative_to(src_root)
    parts = rel_path.parts

    # Skip ignored directories and out/
    if parts:
      if parts[0] in ('out', '.git'):
        dirs.clear()
        continue
      if len(parts) >= max_depth:
        dirs.clear()
        continue

    # Check for git repo
    if '.git' in files or '.git' in dirs:
      if parts:
        targets.append(rel_path.as_posix())
        dirs.clear()
        continue
      else:
        if '.git' in dirs:
          dirs.remove('.git')

    # Check for symlinks
    for d in list(dirs):
      p = root_path / d
      if p.is_symlink():
        targets.append(p.relative_to(src_root).as_posix())
        dirs.remove(d)
    for f in files:
      p = root_path / f
      if p.is_symlink():
        targets.append(p.relative_to(src_root).as_posix())

  return targets


def _stub_missing_target(wt_path, target_ref):
  """Stubs a missing GN target in the worktree.

  Args:
    wt_path: pathlib.Path to the git worktree root.
    target_ref: GN target reference string (e.g., //foo/bar:baz).

  Returns:
    Boolean indicating if stubbing was successful.
  """
  if not target_ref.startswith('//'):
    return False

  parts = target_ref[2:].split(':')
  if len(parts) != 2:
    return False

  dir_path, target_name = parts
  dest_dir = wt_path / dir_path
  build_gn_path = dest_dir / 'BUILD.gn'

  stub_content = (
    f'\n# Stub added by test_suite_mapper.py\ngroup("{target_name}") {{}}\n'
  )

  try:
    dest_dir.mkdir(parents=True, exist_ok=True)
    with open(build_gn_path, 'a', encoding='utf-8', newline='') as f:
      f.write(stub_content)
    print(f'Stubbed {target_ref} in {build_gn_path}')
    return True
  except Exception as e:
    print(f'Failed to stub {target_ref}: {e}', file=sys.stderr)
    return False


def _spoof_rust_version(
  wt_path, src_rust_toolchain_dir, dest_rust_toolchain_dir
):
  """Spoofs the Rust VERSION file in the worktree to match update_rust.py.

  Args:
    wt_path: pathlib.Path to the worktree root.
    src_rust_toolchain_dir: pathlib.Path to the source rust toolchain dir.
    dest_rust_toolchain_dir: pathlib.Path to the destination rust toolchain
      dir in worktree.

  Returns:
    Boolean indicating if spoofing was successful.
  """
  update_rust_path = wt_path / UPDATE_RUST_PATH
  if not update_rust_path.exists():
    return False

  # Parse expected version from update_rust.py
  content = update_rust_path.read_text(encoding='utf-8')
  rev_match = re.search(r"RUST_REVISION\s*=\s*'([0-9a-f]+)'", content)
  sub_match = re.search(r'RUST_SUB_REVISION\s*=\s*([0-9]+)', content)
  if not (rev_match and sub_match):
    return False

  expected_rev = rev_match.group(1)
  expected_sub = sub_match.group(1)

  # Read live VERSION
  src_version_path = src_rust_toolchain_dir / 'VERSION'
  if not src_version_path.exists():
    return False

  version_content = src_version_path.read_text(encoding='utf-8').strip()

  # Example VERSION format:
  # rustc 1.79.0-dev 49b139rt3 (12487ec81-1-1256 clbr987s chromium)
  pattern = r'rustc (\S+) (\S+) \(([0-9a-f]+)-([0-9]+)-(\S+) chromium\)'
  match = re.match(pattern, version_content)
  if not match:
    return False

  rust_version = match.group(1)
  clang_part = match.group(5)
  new_version_content = (
    f'rustc {rust_version} {expected_rev} '
    f'({expected_rev}-{expected_sub}-{clang_part} chromium)\n'
  )

  dest_version_path = dest_rust_toolchain_dir / 'VERSION'
  try:
    dest_rust_toolchain_dir.mkdir(parents=True, exist_ok=True)
    dest_version_path.write_text(new_version_content, encoding='utf-8')
    return True
  except Exception as e:
    print(f'Failed to write spoofed Rust VERSION: {e}', file=sys.stderr)
    return False


def _setup_worktree_symlinks(src_root, wt_path, build_dir):
  """Creates symlinks in the worktree for ignored paths and nested repos.

  Args:
    src_root: Path to the source root.
    wt_path: pathlib.Path to the worktree root.
    build_dir: Path to the build directory.
  """
  ignored_paths = get_ignored_paths(src_root)
  extra_targets = find_nested_repos_and_symlinks(src_root)
  all_paths = set(ignored_paths + extra_targets)

  exclude_prefixes = ['out']
  build_dir_parts = pathlib.Path(build_dir).parts
  if build_dir_parts:
    exclude_prefixes.append(build_dir_parts[0])

  sorted_paths = sorted(all_paths, key=lambda p: len(pathlib.Path(p).parts))

  for rel_path in sorted_paths:
    if rel_path in ('', '.'):
      continue

    parts = pathlib.Path(rel_path).parts
    if parts and parts[0] in exclude_prefixes:
      continue

    target = src_root / rel_path
    link = wt_path / rel_path

    if rel_path == RUST_TOOLCHAIN_PATH.as_posix():
      if (wt_path / UPDATE_RUST_PATH).exists():
        link.mkdir(parents=True, exist_ok=True)
        for child in target.iterdir():
          if child.name == 'VERSION':
            continue
          child_link = link / child.name
          if child_link.exists() or child_link.is_symlink():
            if child_link.is_dir() and not child_link.is_symlink():
              shutil.rmtree(child_link)
            else:
              child_link.unlink()
          child_link.symlink_to(child, target_is_directory=child.is_dir())

        if _spoof_rust_version(wt_path, target, link):
          continue
        else:
          shutil.rmtree(link)
          # Fallthrough to standard symlink creation

    # Skip if any parent directory is already a symlink
    parent_is_symlink = False
    for parent in link.parents:
      if parent == wt_path:
        break
      if parent.is_symlink():
        parent_is_symlink = True
        break
    if parent_is_symlink:
      continue

    link.parent.mkdir(parents=True, exist_ok=True)
    if link.exists() or link.is_symlink():
      if link.is_dir() and not link.is_symlink():
        shutil.rmtree(link)
      else:
        link.unlink()
    if target.exists():
      link.symlink_to(target, target_is_directory=target.is_dir())


def _run_gn_gen_with_stubbing(wt_path, build_dir):
  """Runs gn gen in the worktree, attempting to stub missing targets.

  Args:
    wt_path: pathlib.Path to the worktree root.
    build_dir: Path to the build directory.

  Returns:
    Boolean indicating if gn gen succeeded (possibly after stubbing).
  """
  stubbed_targets = set()
  max_retries = 10
  for attempt in range(max_retries):
    res = subprocess.run(
      ['gn', 'gen', build_dir],
      capture_output=True,
      text=True,
      cwd=wt_path,
      check=False,
      encoding='utf-8',
    )
    if res.returncode == 0:
      return True

    # Check for unresolved dependencies
    if 'ERROR Unresolved dependencies.' in res.stdout:
      missing_targets = []
      # Regex to find missing targets, e.g., "//foo:bar" or
      # "//foo:bar(//toolchain)"
      pattern = r'needs\s+([\w./:-]+)(?:\([\w./:-]+\))?'
      for line in res.stdout.splitlines():
        match = re.search(pattern, line)
        if match:
          missing_targets.append(match.group(1))

      if missing_targets:
        stubbed_any = False
        for t in set(missing_targets):
          if t in stubbed_targets:
            print(
              f'Error: Target {t} was already stubbed but '
              f'still unresolved. Cannot recover.',
              file=sys.stderr,
            )
            return False
          if _stub_missing_target(wt_path, t):
            stubbed_targets.add(t)
            stubbed_any = True
        if stubbed_any:
          print(f'Retrying gn gen after stubbing... (Attempt {attempt + 1})')
          continue

    print(
      f'Error running gn gen in worktree (Attempt {attempt + 1}): '
      f'{res.stdout.strip()}',
      file=sys.stderr,
    )
    if res.stderr:
      print(res.stderr.strip(), file=sys.stderr)
    break
  else:
    print(
      'Error: Failed to resolve GN graph after maximum retries.',
      file=sys.stderr,
    )
  return False


@contextmanager
def git_worktree(src_root, revision, prefix='wt_cov_map_', parent_dir=None):
  """Context manager for creating and cleaning up a git worktree.

  Args:
    src_root: Path to the source root.
    revision: Git revision to checkout in the worktree.
    prefix: Prefix for the temporary directory name.
    parent_dir: Parent directory for the temporary directory (defaults to
      system temp).

  Yields:
    pathlib.Path to the created worktree.
  """
  with tempfile.TemporaryDirectory(prefix=prefix, dir=parent_dir) as wt_dir:
    wt_path = pathlib.Path(wt_dir)

    res = run_git(
      src_root,
      ['worktree', 'add', '--detach', '--force', str(wt_path), revision],
    )
    if res.returncode != 0:
      raise RuntimeError(f'Error creating git worktree: {res.stderr.strip()}')

    try:
      yield wt_path
    finally:
      run_git(src_root, ['worktree', 'remove', '--force', str(wt_path)])


def run_heavyweight_resolution(src_root, revision, build_dir, target_file):
  """Runs GN graph resolution at a revision by checking out a worktree.

  Args:
    src_root: Path to the source root.
    revision: Git revision to perform the mapping at.
    build_dir: Path to the build directory.
    target_file: pathlib.Path to the target file.

  Returns:
    A list of strings (test suites) if successful, or None.
  """
  try:
    with git_worktree(
      src_root, revision, parent_dir=src_root.parent
    ) as wt_path:
      # 2. Create symlinks for non-tracked/nested repo paths
      _setup_worktree_symlinks(src_root, wt_path, build_dir)

      # 3. Copy args.gn
      wt_build_dir = wt_path / build_dir
      wt_build_dir.mkdir(parents=True, exist_ok=True)
      shutil.copy2(src_root / build_dir / 'args.gn', wt_build_dir / 'args.gn')

      # 4. Run gn gen (with auto-recovery for missing targets)
      if not _run_gn_gen_with_stubbing(wt_path, build_dir):
        return None

      # 5. Run gn refs
      refs, err = run_gn_refs(wt_path, build_dir, target_file)
      if err:
        print(f'Error running gn refs in worktree: {err}', file=sys.stderr)
        return None
      if refs is None:
        return None

      # 6. Load isolate map at revision (from worktree)
      pyl_path = wt_path / ISOLATE_MAP_PATH
      if not pyl_path.exists():
        print(f'Error: Isolate map not found in worktree.', file=sys.stderr)
        return None

      try:
        with open(pyl_path, 'r', encoding='utf-8') as f:
          isolate_map = parse_isolate_map(f.read(), str(pyl_path))
      except Exception as e:
        print(f'Error reading isolate map from worktree: {e}', file=sys.stderr)
        return None

      if isolate_map is None:
        return None

      # 7. Extract test suites
      return extract_test_suites(refs, target_file, isolate_map)
  except Exception as e:
    print(e, file=sys.stderr)
    return None


def verify_build_dir(src_root, build_dir):
  """Verifies that build_dir is configured to compile for Linux.

  Args:
    src_root: Path to the source root.
    build_dir: Path to the build directory.

  Returns:
    A tuple (success, error_message):
      success: Boolean indicating if configuration is valid.
      error_message: String containing error detail if invalid, or None.
  """
  src_root = pathlib.Path(src_root)
  args_gn_path = src_root / build_dir / 'args.gn'
  if not args_gn_path.exists():
    return (
      False,
      f'args.gn not found in {build_dir}. Please run `gn gen`.',
    )

  target_os = None
  try:
    with open(args_gn_path, 'r', encoding='utf-8') as f:
      for line in f:
        line = line.split('#')[0].strip()
        if not line:
          continue
        parts = line.split('=', 1)
        if len(parts) == 2:
          key = parts[0].strip()
          val = parts[1].strip().strip('"\'')
          if key == 'target_os':
            target_os = val
            break
  except Exception as e:
    return False, f'Error reading {args_gn_path}: {e}'

  if target_os and target_os != 'linux':
    return (
      False,
      f'Unsupported target_os: {target_os}. '
      f"Only 'linux' is supported initially.",
    )

  if not target_os and not sys.platform.startswith('linux'):
    return (
      False,
      f'Host platform {sys.platform} is not Linux, '
      f"and target_os is not set to 'linux' in args.gn.",
    )

  return True, None


def _load_isolate_map_for_args(
  src_root, isolate_map_file, revision, force_heavyweight
):
  """Loads the isolate map based on arguments.

  Args:
    src_root: Path to the source root.
    isolate_map_file: Path to a local isolate map file, or None.
    revision: Git revision, or None.
    force_heavyweight: Boolean indicating if heavyweight resolution is
      forced.

  Returns:
    A dictionary mapping GN labels to test suite names, or None if it should
    be skipped (for heavyweight) or failed.
  """
  if isolate_map_file:
    if not os.path.exists(isolate_map_file):
      print(
        f'Error: Isolate map file not found: {isolate_map_file}',
        file=sys.stderr,
      )
      sys.exit(1)
    try:
      with open(isolate_map_file, 'r', encoding='utf-8') as f:
        return parse_isolate_map(f.read(), os.fspath(isolate_map_file))
    except Exception as e:
      print(f'Error reading isolate map file: {e}', file=sys.stderr)
      sys.exit(1)
  elif revision:
    if not force_heavyweight:
      print(
        'Error: For lightweight mapping at a revision, you must provide '
        'the historical isolate map using --isolate-map-file.',
        file=sys.stderr,
      )
      sys.exit(1)
    return None
  else:
    return load_isolate_map(src_root)


def _check_and_warn_stability(src_root, revision, file_path, refs):
  """Checks graph stability and prints warnings if unstable.

  Args:
    src_root: Path to the source root.
    revision: Git revision.
    file_path: pathlib.Path of the target file.
    refs: List of GN targets that depend on the file.

  Returns:
    Boolean indicating if the graph is stable.
  """
  stable, changed = check_graph_stability(src_root, revision, file_path, refs)
  if not stable:
    print(
      f'Warning: The GN graph or the file may have changed '
      f'since revision {revision}.',
      file=sys.stderr,
    )
    changed_files_display = ', '.join(changed[:5])
    if len(changed) > 5:
      changed_files_display += f" and {len(changed) - 5} more"
    print(
      f'The following relevant files were modified: {changed_files_display}',
      file=sys.stderr,
    )
    print(
      'The mapping results might be inaccurate.',
      file=sys.stderr,
    )
  return stable


def _should_run_heavyweight(
  src_root,
  revision,
  file_path,
  refs,
  force_heavyweight,
  stable,
  lightweight_suites,
):
  """Determines if heavyweight resolution should be run.

  Args:
    src_root: Path to the source root.
    revision: Git revision, or None.
    file_path: pathlib.Path of the target file.
    refs: List of GN targets that depend on the file.
    force_heavyweight: Boolean indicating if heavyweight resolution is
      forced.
    stable: Boolean indicating if the graph is stable.
    lightweight_suites: List of test suites from lightweight mapping.

  Returns:
    Boolean indicating if heavyweight resolution should be run.
  """
  if force_heavyweight:
    return True

  if revision and not stable:
    head_isolate_map = load_isolate_map(src_root)
    if head_isolate_map and lightweight_suites is not None:
      current_suites = extract_test_suites(refs, file_path, head_isolate_map)
      if lightweight_suites != current_suites:
        print(
          'Difference detected between lightweight revision mapping and '
          'HEAD mapping, and the build graph is unstable. Triggering '
          'heavyweight resolution.',
          file=sys.stderr,
        )
        return True
  return False


def _execute_heavyweight_resolution(
  src_root, revision, build_dir, file_path, lightweight_suites
):
  """Executes heavyweight resolution and handles fallback/logging.

  Args:
    src_root: Path to the source root.
    revision: Git revision.
    build_dir: Path to the build directory.
    file_path: pathlib.Path of the target file.
    lightweight_suites: List of test suites from lightweight mapping.

  Returns:
    A list of strings (test suites).
  """
  if not revision:
    print(
      'Error: Heavyweight resolution requires a --revision.', file=sys.stderr
    )
    sys.exit(1)
  print(
    f'Running heavyweight GN graph resolution at revision {revision}...',
    file=sys.stderr,
  )
  print(
    'This requires checking out a worktree and running gn gen '
    '(takes ~1 minute).',
    file=sys.stderr,
  )
  heavyweight_suites = run_heavyweight_resolution(
    src_root, revision, build_dir, file_path
  )
  if heavyweight_suites is not None:
    if (
      lightweight_suites is not None
      and heavyweight_suites != lightweight_suites
    ):
      print(
        f'Heavyweight resolution completed. Results corrected: '
        f'{lightweight_suites} -> {heavyweight_suites}',
        file=sys.stderr,
      )
    else:
      print(
        f'Heavyweight resolution completed. '
        f'Confirmed results: {heavyweight_suites}',
        file=sys.stderr,
      )
    return heavyweight_suites
  else:
    print(
      'Error: Heavyweight resolution failed. '
      'Falling back to lightweight results.',
      file=sys.stderr,
    )
    return lightweight_suites if lightweight_suites is not None else []


def main():
  epilog = """Examples:
  # Map a source file in HEAD to test suites using default build directory (out/Default):
  vpython3 tools/code_coverage/test_suite_mapper.py chrome/browser/ui/color/color_mixer.cc

  # Map a source file using a custom build directory:
  vpython3 tools/code_coverage/test_suite_mapper.py --build-dir out/Coverage chrome/browser/ui/color/color_mixer.cc

  # Map a source file at a historical git revision with an isolate map file:
  vpython3 tools/code_coverage/test_suite_mapper.py -r 7916168 --isolate-map-file scratch/gn_isolate_map.pyl chrome/browser/ui/color/color_mixer.cc

  # Force heavyweight GN graph resolution at a historical revision:
  vpython3 tools/code_coverage/test_suite_mapper.py -r 7916168 --force-heavyweight chrome/browser/ui/color/color_mixer.cc

Output Format:
  Returns a JSON array of covering test suite names (e.g., ["unit_tests", "browser_tests"]).
"""
  parser = argparse.ArgumentParser(
    description=(
      'Maps a Chromium source file path to its covering target test suites.'
    ),
    epilog=epilog,
    formatter_class=argparse.RawDescriptionHelpFormatter,
  )
  parser.add_argument(
    'file_path',
    type=pathlib.Path,
    help='Relative path to the target file (e.g., chrome/browser/.../file.cc)',
  )
  parser.add_argument(
    '--build-dir',
    type=pathlib.Path,
    default=pathlib.Path('out/Default'),
    help=(
      'Build directory (defaults to out/Default). Must contain args.gn '
      'and be configured for target_os="linux".'
    ),
  )
  parser.add_argument(
    '--revision',
    '-r',
    help='Git revision (hash, tag, branch) to perform the mapping at.',
  )
  parser.add_argument(
    '--force-heavyweight',
    action='store_true',
    help=(
      'Force rebuilding the GN build graph at the revision '
      '(slower but 100%% accurate).'
    ),
  )
  parser.add_argument(
    '--isolate-map-file',
    type=pathlib.Path,
    help='Path to a local gn_isolate_map.pyl file to use for mapping.',
  )
  args = parser.parse_args()

  src_root = SRC_ROOT
  build_dir = args.build_dir
  revision = args.revision

  if revision:
    res = run_git(src_root, ['rev-parse', '--verify', revision])
    if res.returncode != 0:
      print(f'Error: Invalid revision: {revision}', file=sys.stderr)
      sys.exit(1)
    revision = res.stdout.strip()

  # Verify build directory exists
  if not (src_root / build_dir).exists():
    print(
      f'Error: Build directory {build_dir} does not exist or is not '
      f'configured.',
      file=sys.stderr,
    )
    print(
      f'Please run `gn gen {build_dir}` or supply a valid --build-dir.',
      file=sys.stderr,
    )
    sys.exit(1)

  success, err = verify_build_dir(src_root, build_dir)
  if not success:
    print(f'Error: {err}', file=sys.stderr)
    sys.exit(1)

  isolate_map = _load_isolate_map_for_args(
    src_root, args.isolate_map_file, revision, args.force_heavyweight
  )

  if isolate_map is None and not args.force_heavyweight:
    sys.exit(1)

  # Run references scan (uses current workspace GN graph)
  refs, err = run_gn_refs(src_root, build_dir, args.file_path)
  if err:
    print(f'Error running GN refs query: {err}', file=sys.stderr)
    sys.exit(1)

  if not refs:
    print(json.dumps([]))
    sys.exit(0)

  stable = True
  if revision:
    stable = _check_and_warn_stability(src_root, revision, args.file_path, refs)

  lightweight_suites = None
  if isolate_map:
    lightweight_suites = extract_test_suites(refs, args.file_path, isolate_map)

  run_heavyweight = _should_run_heavyweight(
    src_root,
    revision,
    args.file_path,
    refs,
    args.force_heavyweight,
    stable,
    lightweight_suites,
  )

  if run_heavyweight:
    test_suites = _execute_heavyweight_resolution(
      src_root, revision, build_dir, args.file_path, lightweight_suites
    )
  else:
    test_suites = lightweight_suites if lightweight_suites is not None else []

  print(json.dumps(test_suites, indent=2))


if __name__ == '__main__':
  main()
