#!/usr/bin/env vpython3
# 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 io
import json
import os
import pathlib
import subprocess
import sys
import textwrap
import unittest
from unittest import mock
from pyfakefs import fake_filesystem_unittest

import ci_only_validator


class TestCIOnlyValidator(fake_filesystem_unittest.TestCase):
  def setUp(self):
    # Resolve the path on the real filesystem first before pyfakefs is active.
    self.real_src_root = (
      pathlib.Path(ci_only_validator.__file__).resolve().parents[2]
    )
    self.setUpPyfakefs()
    self.fs.create_dir(self.real_src_root)

    # Set up global mocking for subprocess.run.
    self.subprocess_run_patcher = mock.patch('subprocess.run')
    self.mock_run = self.subprocess_run_patcher.start()
    self.addCleanup(self.subprocess_run_patcher.stop)

  def test_discover_trybots_for_test_suite(self):
    self.mock_run.return_value = mock.MagicMock(
      returncode=0,
      stdout=textwrap.dedent("""\

            infra/config/generated/builders/try/android-code-coverage/targets/chromium.coverage.json
            infra/config/generated/builders/try/android-x86-code-coverage/targets/chromium.coverage.json
            """),
    )

    src_root = pathlib.Path('/mock/src')
    entries = ci_only_validator.discover_trybots_for_test_suite(
      src_root, 'chrome_junit_tests'
    )
    self.assertEqual(
      entries,
      [
        ci_only_validator.TrybotEntry(
          'android-code-coverage', 'chromium.coverage'
        ),
        ci_only_validator.TrybotEntry(
          'android-x86-code-coverage', 'chromium.coverage'
        ),
      ],
    )

  def test_discover_trybots_for_test_suite_error(self):
    self.mock_run.return_value = mock.MagicMock(returncode=1)
    src_root = pathlib.Path('/mock/src')
    entries = ci_only_validator.discover_trybots_for_test_suite(
      src_root, 'chrome_junit_tests'
    )
    self.assertEqual(entries, [])

  def test_discover_trybots_for_test_suite_with_revision(self):
    self.mock_run.return_value = mock.MagicMock(
      returncode=0,
      stdout=textwrap.dedent("""\
            myrev:infra/config/generated/builders/try/android-code-coverage/targets/chromium.coverage.json
            """),
    )

    src_root = pathlib.Path('/mock/src')
    entries = ci_only_validator.discover_trybots_for_test_suite(
      src_root, 'chrome_junit_tests', 'myrev'
    )
    self.assertEqual(
      entries,
      [
        ci_only_validator.TrybotEntry(
          'android-code-coverage', 'chromium.coverage'
        )
      ],
    )

  def test_read_local_success(self):
    src_root = pathlib.Path('/mock/src')
    dummy_file = src_root / 'dummy_path'
    self.fs.create_file(dummy_file, contents='file_content')

    content = ci_only_validator.read_local(src_root, 'dummy_path')
    self.assertEqual(content, 'file_content')

  def test_read_local_not_found(self):
    src_root = pathlib.Path('/mock/src')
    content = ci_only_validator.read_local(src_root, 'non_existent_file')
    self.assertIsNone(content)

  def test_read_local_os_error(self):
    src_root = pathlib.Path('/mock/src')
    dummy_file = src_root / 'dummy_path'
    # Create an unreadable file to raise OSError/PermissionError natively.
    self.fs.create_file(dummy_file, contents='')
    os.chmod(dummy_file, 0o000)

    # Redirect stderr to suppress prints in test output
    with mock.patch('sys.stderr', new=io.StringIO()):
      content = ci_only_validator.read_local(src_root, 'dummy_path')
      self.assertIsNone(content)

  def test_read_git_success(self):
    self.mock_run.return_value = mock.MagicMock(
      returncode=0, stdout='git_file_content'
    )
    src_root = pathlib.Path('/mock/src')

    content = ci_only_validator.read_git(src_root, 'myrev', 'dummy_path')
    self.assertEqual(content, 'git_file_content')

  def test_read_git_error(self):
    self.mock_run.return_value = mock.MagicMock(returncode=1)
    src_root = pathlib.Path('/mock/src')

    content = ci_only_validator.read_git(src_root, 'myrev', 'dummy_path')
    self.assertIsNone(content)

  def test_check_coverage_enabled_true(self):
    mock_read = mock.MagicMock(
      return_value=json.dumps({'gn_args': {'use_clang_coverage': True}})
    )
    enabled = ci_only_validator.check_coverage_enabled(
      mock_read, 'android-code-coverage'
    )
    self.assertTrue(enabled)

  def test_check_coverage_enabled_false(self):
    mock_read = mock.MagicMock(
      return_value=json.dumps({'gn_args': {'use_clang_coverage': False}})
    )
    enabled = ci_only_validator.check_coverage_enabled(
      mock_read, 'android-code-coverage'
    )
    self.assertFalse(enabled)

  def test_check_coverage_enabled_none_content(self):
    mock_read = mock.MagicMock(return_value=None)
    enabled = ci_only_validator.check_coverage_enabled(
      mock_read, 'android-code-coverage'
    )
    self.assertFalse(enabled)

  def test_check_coverage_enabled_invalid_json(self):
    mock_read = mock.MagicMock(return_value='{invalid_json')
    enabled = ci_only_validator.check_coverage_enabled(
      mock_read, 'android-code-coverage'
    )
    self.assertFalse(enabled)

  def test_check_trybot_coverage_config_ci_only_true(self):
    targets_json = {
      'android-code-coverage': {
        'isolated_scripts': [{'name': 'chrome_junit_tests', 'ci_only': True}]
      }
    }
    mock_read = mock.MagicMock(return_value=json.dumps(targets_json))

    results = ci_only_validator.check_trybot_coverage_config(
      mock_read,
      'android-code-coverage',
      'chromium.coverage',
      'chrome_junit_tests',
    )
    self.assertEqual(
      results,
      [ci_only_validator.BuilderConfig('android-code-coverage', True)],
    )

  def test_check_trybot_coverage_config_ci_only_false(self):
    targets_json = {
      'android-code-coverage': {
        'isolated_scripts': [{'name': 'chrome_junit_tests', 'ci_only': False}]
      }
    }
    mock_read = mock.MagicMock(return_value=json.dumps(targets_json))

    results = ci_only_validator.check_trybot_coverage_config(
      mock_read,
      'android-code-coverage',
      'chromium.coverage',
      'chrome_junit_tests',
    )
    self.assertEqual(
      results,
      [ci_only_validator.BuilderConfig('android-code-coverage', False)],
    )

  def test_check_trybot_coverage_config_ci_only_omitted(self):
    # Tests that when 'ci_only' is omitted entirely, it defaults to False.
    targets_json = {
      'android-code-coverage': {
        'isolated_scripts': [{'name': 'chrome_junit_tests'}]
      }
    }
    mock_read = mock.MagicMock(return_value=json.dumps(targets_json))

    results = ci_only_validator.check_trybot_coverage_config(
      mock_read,
      'android-code-coverage',
      'chromium.coverage',
      'chrome_junit_tests',
    )
    self.assertEqual(
      results,
      [ci_only_validator.BuilderConfig('android-code-coverage', False)],
    )

  def test_check_trybot_coverage_config_none_content(self):
    mock_read = mock.MagicMock(return_value=None)
    results = ci_only_validator.check_trybot_coverage_config(
      mock_read,
      'android-code-coverage',
      'chromium.coverage',
      'chrome_junit_tests',
    )
    self.assertEqual(results, [])

  def test_check_trybot_coverage_config_invalid_json(self):
    mock_read = mock.MagicMock(return_value='{invalid_json')
    # Redirect stderr to suppress prints in test output
    with mock.patch('sys.stderr', new=io.StringIO()):
      results = ci_only_validator.check_trybot_coverage_config(
        mock_read,
        'android-code-coverage',
        'chromium.coverage',
        'chrome_junit_tests',
      )
      self.assertEqual(results, [])

  @mock.patch('ci_only_validator.discover_trybots_for_test_suite')
  def test_main_success_disk(self, mock_discover):
    mock_discover.return_value = [
      ci_only_validator.TrybotEntry(
        'android-code-coverage', 'chromium.coverage'
      )
    ]

    targets_json = {
      'android-code-coverage': {
        'isolated_scripts': [{'name': 'chrome_junit_tests', 'ci_only': True}]
      }
    }
    # Create the files on the fake filesystem relative to real_src_root
    targets_file = (
      self.real_src_root
      / 'infra/config/generated/builders/try/android-code-coverage/targets/'
      'chromium.coverage.json'
    )
    self.fs.create_file(targets_file, contents=json.dumps(targets_json))

    gn_args_file = (
      self.real_src_root
      / 'infra/config/generated/builders/try/android-code-coverage/'
      'gn-args.json'
    )
    self.fs.create_file(
      gn_args_file,
      contents=json.dumps({'gn_args': {'use_clang_coverage': True}}),
    )

    test_args = ['ci_only_validator.py', 'chrome_junit_tests']
    with (
      mock.patch('sys.argv', test_args),
      mock.patch('sys.stdout', new=io.StringIO()) as mock_stdout,
    ):
      ci_only_validator.main()
      output = mock_stdout.getvalue()
      self.assertIn("Checking test suite 'chrome_junit_tests'", output)
      self.assertIn('android-code-coverage', output)
      self.assertIn('ci_only = True', output)
      self.assertIn('Coverage: Enabled', output)

  @mock.patch('ci_only_validator.discover_trybots_for_test_suite')
  def test_main_success_git(self, mock_discover):
    mock_discover.return_value = [
      ci_only_validator.TrybotEntry(
        'android-code-coverage', 'chromium.coverage'
      )
    ]

    targets_json = {
      'android-code-coverage': {
        'isolated_scripts': [{'name': 'chrome_junit_tests', 'ci_only': False}]
      }
    }

    # Setup mock subprocess runs for git show
    def mock_run_side_effect(args, **kwargs):
      # git rev-parse --verify myrev
      if 'rev-parse' in args:
        return mock.MagicMock(returncode=0)

      # git show myrev:path/to/targets/chromium.coverage.json
      if 'show' in args:
        rel_path = args[2].split(':', 1)[1]
        if 'targets' in rel_path:
          return mock.MagicMock(returncode=0, stdout=json.dumps(targets_json))
        if 'gn-args.json' in rel_path:
          return mock.MagicMock(
            returncode=0,
            stdout=json.dumps({'gn_args': {'use_jacoco_coverage': True}}),
          )
      return mock.MagicMock(returncode=1)

    self.mock_run.side_effect = mock_run_side_effect

    test_args = [
      'ci_only_validator.py',
      'chrome_junit_tests',
      '--revision',
      'myrev',
    ]
    with (
      mock.patch('sys.argv', test_args),
      mock.patch('sys.stdout', new=io.StringIO()) as mock_stdout,
    ):
      ci_only_validator.main()
      output = mock_stdout.getvalue()
      self.assertIn(
        "Checking test suite 'chrome_junit_tests' across 1 configured"
        ' trybots at revision myrev...',
        output,
      )
      self.assertIn('android-code-coverage', output)
      self.assertIn('ci_only = False', output)
      self.assertIn('Coverage: Enabled', output)

  def test_main_src_root_not_exists(self):
    test_args = ['ci_only_validator.py', 'chrome_junit_tests']
    with (
      mock.patch('pathlib.Path.exists', return_value=False),
      mock.patch('sys.argv', test_args),
      mock.patch('sys.stderr', new=io.StringIO()) as mock_stderr,
    ):
      with self.assertRaises(SystemExit) as cm:
        ci_only_validator.main()
      self.assertEqual(cm.exception.code, 1)
      self.assertIn('Error: Src path', mock_stderr.getvalue())

  def test_main_invalid_revision(self):
    self.mock_run.return_value = mock.MagicMock(returncode=1)
    test_args = [
      'ci_only_validator.py',
      'chrome_junit_tests',
      '--revision',
      'invalidrev',
    ]
    with (
      mock.patch('sys.argv', test_args),
      mock.patch('sys.stderr', new=io.StringIO()) as mock_stderr,
    ):
      with self.assertRaises(SystemExit) as cm:
        ci_only_validator.main()
      self.assertEqual(cm.exception.code, 1)
      self.assertIn('Error: Invalid revision', mock_stderr.getvalue())

  @mock.patch('ci_only_validator.discover_trybots_for_test_suite')
  def test_main_no_trybots(self, mock_discover):
    mock_discover.return_value = []
    test_args = ['ci_only_validator.py', 'chrome_junit_tests']
    with (
      mock.patch('sys.argv', test_args),
      mock.patch('sys.stderr', new=io.StringIO()) as mock_stderr,
    ):
      with self.assertRaises(SystemExit) as cm:
        ci_only_validator.main()
      self.assertEqual(cm.exception.code, 0)
      self.assertIn("Warning: Test suite", mock_stderr.getvalue())


class TestCIOnlyValidatorExecutable(unittest.TestCase):
  def test_main_executable_run(self):
    import runpy

    script_path = pathlib.Path(ci_only_validator.__file__).resolve()
    test_args = ['ci_only_validator.py', '--help']
    with (
      mock.patch('sys.argv', test_args),
      mock.patch('sys.stdout', new=io.StringIO()) as mock_stdout,
    ):
      try:
        runpy.run_path(str(script_path), run_name='__main__')
      except SystemExit as e:
        self.assertEqual(e.code, 0)
      self.assertIn(
        'Check if a test suite is barred by ci_only', mock_stdout.getvalue()
      )


if __name__ == '__main__':
  unittest.main()
