#!/usr/bin/env vpython3
# Copyright 2025 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.

import unittest
import json
import sys
import os
import tempfile
import shutil
from unittest.mock import patch, MagicMock

# We need to adjust the path to import the script directly.
# This assumes the test script is in the same directory as the main script.
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))

import decisiongraph_invoker as dgi
import requests


class TestDecisionGraphInvoker(unittest.TestCase):
  def setUp(self):
    self.maxDiff = None
    self.test_dir = tempfile.mkdtemp()
    self.config_data = {
      'build_id': '12345',
      'change': 123456,
      'patchset': 1,
      'builder': 'chromium-commit-queue',
      'api_key': 'test_api_key_123',
    }
    self.config_file_path = os.path.join(self.test_dir, 'test_config.json')
    self.api_url_with_key = f"{dgi.API_URL}?key={self.config_data['api_key']}"

  def tearDown(self):
    shutil.rmtree(self.test_dir)

  def _create_mock_config_file(self, data):
    """Helper to create a mock JSON config file."""
    with open(self.config_file_path, 'w', encoding='utf-8') as f:
      json.dump(data, f)

  @patch('builtins.print')
  def test_load_config_from_json_success(self, mock_print):
    self._create_mock_config_file(self.config_data)
    config = dgi.load_config_from_json(self.config_file_path)
    self.assertEqual(config, self.config_data)

  @patch('sys.exit')
  @patch('builtins.print')
  def test_load_config_from_json_file_not_found(self, mock_print, mock_exit):
    dgi.load_config_from_json('non_existent_file.json')
    mock_print.assert_any_call(
      'Error: Configuration file not found at non_existent_file.json'
    )
    mock_exit.assert_called_with(1)

  @patch('sys.exit')
  @patch('builtins.print')
  def test_load_config_from_json_missing_args(self, mock_print, mock_exit):
    incomplete_config = {'build_id': '123', 'change': 1}
    self._create_mock_config_file(incomplete_config)
    dgi.load_config_from_json(self.config_file_path)
    mock_print.assert_any_call(
      'Error: Missing required arguments in JSON config file.'
    )
    mock_exit.assert_called_with(1)

  @patch('sys.exit')
  @patch('builtins.print')
  def test_load_config_from_json_invalid_type(self, mock_print, mock_exit):
    invalid_config = self.config_data.copy()
    invalid_config['change'] = 'not_an_int'
    self._create_mock_config_file(invalid_config)
    dgi.load_config_from_json(self.config_file_path)
    mock_print.assert_any_call(
      "Error: Invalid data type for 'change' or 'patchset' in JSON. "
      "Expected integers."
    )
    mock_exit.assert_called_with(1)

  @patch('builtins.print')
  @patch('requests.post')
  def test_fetch_api_data_success(self, mock_post, mock_print):
    mock_response = MagicMock()
    mock_response.status_code = 200
    mock_response.json.return_value = {'status': 'success'}
    mock_response.text = '{"status": "success"}'
    mock_response.raise_for_status.return_value = None

    mock_post.return_value = mock_response

    response = dgi.fetch_api_data(self.api_url_with_key, {})
    self.assertEqual(response, {'status': 'success'})
    mock_print.assert_any_call('{"status": "success"}')
    mock_print.assert_any_call(200)

  @patch('builtins.print')
  @patch('requests.post')
  def test_fetch_api_data_failure_http_error(self, mock_post, mock_print):
    mock_response = MagicMock()
    mock_response.status_code = 500
    mock_response.text = 'Internal Server Error'
    mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError(
      '500 Server Error'
    )

    mock_post.return_value = mock_response

    response = dgi.fetch_api_data(self.api_url_with_key, {})
    self.assertIsNone(response)
    mock_print.assert_any_call('An error occurred: 500 Server Error')
    mock_print.assert_any_call('Internal Server Error')
    mock_print.assert_any_call(500)

  def test_overwrite_filter_file_success(self):
    test_suite = 'browser_tests'
    tests_to_skip = ['Test1.testA', 'Test2.testB']
    result = dgi.overwrite_filter_file(self.test_dir, test_suite, tests_to_skip)
    self.assertTrue(result)
    filter_file_path = os.path.join(self.test_dir, f'{test_suite}.filter')
    self.assertTrue(os.path.exists(filter_file_path))
    with open(filter_file_path, 'r', encoding='utf-8') as f:
      content = f.read()
      self.assertIn('-Test1.testA', content)
      self.assertIn('-Test2.testB', content)

  def test_overwrite_filter_file_appends_wildcard(self):
    """Tests that a wildcard '*' is appended to each test name."""
    test_suite = 'browser_tests'
    tests_to_skip = ['TestClassOne', 'TestClassTwo']
    result = dgi.overwrite_filter_file(self.test_dir, test_suite, tests_to_skip)

    self.assertTrue(result)
    filter_file_path = os.path.join(self.test_dir, f'{test_suite}.filter')

    with open(filter_file_path, 'r', encoding='utf-8') as f:
      content = f.read()

    expected_content = (
      '# A list of tests to be skipped, generated by test'
      ' selection.\n'
      '-TestClassOne.*\n'
      '-TestClassTwo.*\n'
    )
    self.assertEqual(content, expected_content)

  @patch('builtins.print')
  def test_overwrite_filter_file_dir_not_found(self, mock_print):
    result = dgi.overwrite_filter_file('non_existent_dir', 'suite', [])
    self.assertFalse(result)
    mock_print.assert_called_with(
      'Error: Filter file directory not found at non_existent_dir'
    )

  @patch('sys.exit')
  @patch('decisiongraph_invoker.load_config_from_json')
  @patch('decisiongraph_invoker.fetch_api_data')
  def test_main_trigger_phase_success(
    self, mock_fetch, mock_load_config, mock_exit
  ):
    mock_load_config.return_value = self.config_data
    mock_fetch.return_value = {'output': [{'result': 'success'}]}

    with patch(
      'sys.argv',
      [
        'decisiongraph_invoker.py',
        '--test-targets',
        'browser_tests',
        '--sts-config-file',
        self.config_file_path,
        '--test-selection-phase',
        'TRIGGER',
      ],
    ):
      dgi.main()
      mock_fetch.assert_called_once()
      _, kwargs = mock_fetch.call_args
      payload = kwargs['json_payload']
      self.assertTrue(
        payload['graph']['stages'][0]['execution_options']['prepare']
      )
      mock_exit.assert_called_with(0)

  @patch('sys.exit')
  @patch('decisiongraph_invoker.load_config_from_json')
  @patch('decisiongraph_invoker.fetch_api_data')
  def test_main_fetch_phase_success(
    self, mock_fetch, mock_load_config, mock_exit
  ):
    mock_load_config.return_value = self.config_data
    mock_fetch.return_value = {
      'outputs': [
        {
          'checks': [
            {
              'identifier': {'luciTest': {'testSuite': 'browser_tests'}},
              'children': [
                {
                  'identifier': {
                    'luciTest': {'testId': 'TestClass.testExample1'}
                  }
                },
                {
                  'identifier': {
                    'luciTest': {'testId': 'TestClass.testExample2'}
                  }
                },
              ],
            }
          ]
        }
      ]
    }

    with patch(
      'sys.argv',
      [
        'decisiongraph_invoker.py',
        '--test-targets',
        'browser_tests',
        '--sts-config-file',
        self.config_file_path,
        '--test-selection-phase',
        'FETCH',
        '--filter-file-dir',
        self.test_dir,
      ],
    ):
      dgi.main()

      mock_fetch.assert_called_once()
      _, kwargs = mock_fetch.call_args
      payload = kwargs['json_payload']
      self.assertFalse(
        payload['graph']['stages'][0]['execution_options']['prepare']
      )

      filter_file_path = os.path.join(self.test_dir, 'browser_tests.filter')
      self.assertTrue(os.path.exists(filter_file_path))
      with open(filter_file_path, 'r', encoding='utf-8') as f:
        content = f.read()
        self.assertIn('-TestClass.testExample1', content)
        self.assertIn('-TestClass.testExample2', content)

      mock_exit.assert_called_with(0)

  @patch('sys.exit')
  @patch('decisiongraph_invoker.load_config_from_json')
  @patch('decisiongraph_invoker.fetch_api_data')
  def test_main_fetch_phase_key_error(
    self, mock_fetch, mock_load_config, mock_exit
  ):
    mock_load_config.return_value = self.config_data
    # Malformed response missing 'testId'
    mock_fetch.return_value = {
      'outputs': [
        {
          'checks': [
            {
              'identifier': {'luciTest': {'testSuite': 'browser_tests'}},
              'children': [{'identifier': {'luciTest': {}}}],
            }
          ]
        }
      ]
    }

    with patch(
      'sys.argv',
      [
        'decisiongraph_invoker.py',
        '--test-targets',
        'browser_tests',
        '--sts-config-file',
        self.config_file_path,
        '--test-selection-phase',
        'FETCH',
        '--filter-file-dir',
        self.test_dir,
      ],
    ):
      with self.assertRaises(KeyError):
        dgi.main()

  @patch('argparse.ArgumentParser.error')
  def test_main_fetch_phase_missing_dir_arg(self, mock_parser_error):
    # Make the mock raise an exception to halt execution, which is what
    # parser.error() does internally by calling sys.exit().
    mock_parser_error.side_effect = SystemExit

    with self.assertRaises(SystemExit):
      with patch(
        'sys.argv',
        [
          'decisiongraph_invoker.py',
          '--test-targets',
          'browser_tests',
          '--sts-config-file',
          self.config_file_path,
          '--test-selection-phase',
          'FETCH',
        ],
      ):
        dgi.main()

    # Verify that parser.error was called with the correct message.
    mock_parser_error.assert_called_with(
      '--filter-file-dir is required when phase is FETCH.'
    )


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