# 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 os
import sys
import tempfile
import unittest
from unittest import mock

import PRESUBMIT


class MockInputApi:
  def __init__(self):
    self.os_path = os.path
    self.change = mock.Mock()

  @staticmethod
  def PresubmitLocalPath():
    return os.path.dirname(__file__)


class MockOutputApi:
  @staticmethod
  def PresubmitError(message, items=None):
    # Using a tuple to make it easy to compare.
    return ('Error', message, items)

  @staticmethod
  def PresubmitResult(message, items=None):
    # Using a tuple to make it easy to compare.
    return ('Result', message, items)


class UndeclaredFeaturesTest(unittest.TestCase):
  def setUp(self):
    self.temp_dir = tempfile.TemporaryDirectory()
    self.repo_root = self.temp_dir.name

    self.mock_input_api = MockInputApi()
    self.mock_input_api.change.RepositoryRoot.return_value = self.repo_root
    self.mock_output_api = MockOutputApi()

  def tearDown(self):
    self.temp_dir.cleanup()
    # Ensure find_features is reloaded for the next test or test suite
    # by removing it from the module cache. This prevents test state from
    # leaking.
    if 'find_features' in sys.modules:
      del sys.modules['find_features']

  def _create_file_in_repo(self, path, content):
    full_path = os.path.join(self.repo_root, path)
    os.makedirs(os.path.dirname(full_path), exist_ok=True)
    with open(full_path, 'w', encoding='utf-8') as f:
      f.write(content)

  def testAllFeaturesOnChangedLinesAreDeclared(self):
    # Declare a variety of features using both 2- and 3-argument macros.
    self._create_file_in_repo(
      'components/feature_a.cc',
      'BASE_FEATURE(kFeatureA, base::FEATURE_ENABLED_BY_DEFAULT);',
    )
    self._create_file_in_repo(
      'chrome/feature_b.cc',
      'BASE_FEATURE(kFeatureB, "FeatureB", base::FEATURE_ENABLED_BY_DEFAULT);',
    )
    self._create_file_in_repo(
      'components/feature_c.cc',
      'BASE_FEATURE(kFeatureC, "FeatureC",\n'
      '             base::FEATURE_ENABLED_BY_DEFAULT);',
    )

    json_data = {
      'Study1': [
        {
          'experiments': [
            {
              'name': 'group1',
              'enable_features': ['FeatureA', 'FeatureB'],
            }
          ]
        }
      ],
      'Study2_Unaffected': [
        {
          'experiments': [
            {
              'name': 'group2',
              'enable_features': ['UndeclaredFeature'],
            }
          ]
        }
      ],
    }
    # The check should only trigger on features in changed lines.
    # FeatureA and FeatureB are declared, so this should pass.
    # UndeclaredFeature is not on a changed line, so it's ignored.
    changed_lines = [(1, '"enable_features": ["FeatureA", "FeatureB"]')]
    result = PRESUBMIT.CheckUndeclaredFeatures(
      self.mock_input_api, self.mock_output_api, json_data, changed_lines
    )
    self.assertEqual(result, [])

  def testUndeclaredFeatureOnChangedLine(self):
    self._create_file_in_repo(
      'components/feature_a.cc',
      'BASE_FEATURE(kFeatureA, base::FEATURE_ENABLED_BY_DEFAULT);',
    )
    json_data = {
      'Study1': [
        {
          'experiments': [
            {
              'name': 'group1',
              'enable_features': ['FeatureA', 'UndeclaredFeature'],
            }
          ]
        }
      ]
    }
    changed_lines = [(1, '"enable_features": ["UndeclaredFeature"]')]
    result = PRESUBMIT.CheckUndeclaredFeatures(
      self.mock_input_api, self.mock_output_api, json_data, changed_lines
    )
    self.assertEqual(len(result), 1)
    self.assertEqual(result[0][0], 'Result')
    self.assertIn('UndeclaredFeature', str(result[0]))
    self.assertIn('Study1', str(result[0]))

  def testFeatureWithMacroParametersOnDifferentLines(self):
    self._create_file_in_repo(
      'components/feature_d.cc',
      'BASE_FEATURE(kFeatureD,\n'
      '             "FeatureD",\n'
      '             base::FEATURE_ENABLED_BY_DEFAULT);',
    )
    json_data = {
      'Study1': [
        {'experiments': [{'name': 'group1', 'enable_features': ['FeatureD']}]}
      ]
    }
    changed_lines = [(1, '"enable_features": ["FeatureD"]')]
    result = PRESUBMIT.CheckUndeclaredFeatures(
      self.mock_input_api, self.mock_output_api, json_data, changed_lines
    )
    self.assertEqual(result, [])

  def testTwoParameterMacro(self):
    self._create_file_in_repo(
      'components/feature_g.cc',
      'BASE_FEATURE(kFeatureG, base::FEATURE_ENABLED_BY_DEFAULT);',
    )
    json_data = {
      'Study1': [
        {'experiments': [{'name': 'group1', 'enable_features': ['FeatureG']}]}
      ]
    }
    changed_lines = [(1, '"enable_features": ["FeatureG"]')]
    result = PRESUBMIT.CheckUndeclaredFeatures(
      self.mock_input_api, self.mock_output_api, json_data, changed_lines
    )
    self.assertEqual(result, [])

  def testTwoParameterMacroWithParametersOnDifferentLines(self):
    self._create_file_in_repo(
      'components/feature_h.cc',
      'BASE_FEATURE(kFeatureH,\n'
      '             base::FEATURE_ENABLED_BY_DEFAULT);',
    )
    json_data = {
      'Study1': [
        {'experiments': [{'name': 'group1', 'enable_features': ['FeatureH']}]}
      ]
    }
    changed_lines = [(1, '"enable_features": ["FeatureH"]')]
    result = PRESUBMIT.CheckUndeclaredFeatures(
      self.mock_input_api, self.mock_output_api, json_data, changed_lines
    )
    self.assertEqual(result, [])

  def testFeatureWithPreprocessorDirectives(self):
    self._create_file_in_repo(
      'components/feature_f.cc',
      'BASE_FEATURE(kFeatureF,\n'
      '             "FeatureF",\n'
      '#if BUILDFLAG(IS_APPLE) || BUILDFLAG(IS_WIN)\n'
      '             base::FEATURE_ENABLED_BY_DEFAULT\n'
      '#else\n'
      '             base::FEATURE_DISABLED_BY_DEFAULT\n'
      '#endif\n'
      ');',
    )
    json_data = {
      'Study1': [
        {'experiments': [{'name': 'group1', 'enable_features': ['FeatureF']}]}
      ]
    }
    changed_lines = [(1, '"enable_features": ["FeatureF"]')]
    result = PRESUBMIT.CheckUndeclaredFeatures(
      self.mock_input_api, self.mock_output_api, json_data, changed_lines
    )
    self.assertEqual(result, [])

  def testTwoParameterMacroWithPreprocessorDirectives(self):
    self._create_file_in_repo(
      'components/feature_k.cc',
      'BASE_FEATURE(kFeatureK,\n'
      '#if BUILDFLAG(IS_WIN)\n'
      '    base::FEATURE_ENABLED_BY_DEFAULT\n'
      '#else\n'
      '    base::FEATURE_DISABLED_BY_DEFAULT\n'
      '#endif\n'
      ');',
    )
    json_data = {
      'Study1': [
        {'experiments': [{'name': 'group1', 'enable_features': ['FeatureK']}]}
      ]
    }
    changed_lines = [(1, '"enable_features": ["FeatureK"]')]
    result = PRESUBMIT.CheckUndeclaredFeatures(
      self.mock_input_api, self.mock_output_api, json_data, changed_lines
    )
    self.assertEqual(result, [])

  def testRustFeatures(self):
    self._create_file_in_repo(
      'components/feature_rust.rs',
      'base_feature!(FeatureRust, FeatureState::Disabled);',
    )
    json_data = {
      'Study1': [
        {
          'experiments': [
            {'name': 'group1', 'enable_features': ['FeatureRust']}
          ]
        }
      ]
    }
    changed_lines = [(1, '"enable_features": ["FeatureRust"]')]
    result = PRESUBMIT.CheckUndeclaredFeatures(
      self.mock_input_api, self.mock_output_api, json_data, changed_lines
    )
    self.assertEqual(result, [])

  def testRustFeaturesMultiple(self):
    self._create_file_in_repo(
      'components/feature_rust.rs',
      'base_feature!(FeatureRust1, FeatureState::Enabled);\n'
      'base_feature!(FeatureRust2, FeatureState::Disabled);',
    )
    json_data = {
      'Study1': [
        {
          'experiments': [
            {
              'name': 'group1',
              'enable_features': ['FeatureRust1', 'FeatureRust2'],
            }
          ]
        }
      ]
    }
    changed_lines = [(1, '"enable_features": ["FeatureRust1", "FeatureRust2"]')]
    result = PRESUBMIT.CheckUndeclaredFeatures(
      self.mock_input_api, self.mock_output_api, json_data, changed_lines
    )
    self.assertEqual(result, [])

  def testUndeclaredRustFeature(self):
    self._create_file_in_repo(
      'components/feature_rust.rs',
      'base_feature!(FeatureRust, FeatureState::Enabled);',
    )
    json_data = {
      'Study1': [
        {
          'experiments': [
            {
              'name': 'group1',
              'enable_features': ['FeatureRust', 'UndeclaredRustFeature'],
            }
          ]
        }
      ]
    }
    changed_lines = [(1, '"enable_features": ["UndeclaredRustFeature"]')]
    result = PRESUBMIT.CheckUndeclaredFeatures(
      self.mock_input_api, self.mock_output_api, json_data, changed_lines
    )
    self.assertEqual(len(result), 1)
    self.assertIn('UndeclaredRustFeature', str(result[0]))

  def testNoFeaturesDeclaredInRepo(self):
    # No files created, so no features will be found, which is an error
    # condition.
    result = PRESUBMIT.CheckUndeclaredFeatures(
      self.mock_input_api, self.mock_output_api, {}, []
    )
    self.assertEqual(len(result), 1)
    self.assertEqual(result[0][0], 'Error')
    self.assertIn('unable to find any declared flags', result[0][1])


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