# Copyright 2025 DeepMind Technologies Limited
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Tests to ensure that all Mujoco functions and structs are correctly handled.

This file contains tests that verify:
- All functions defined in Mujoco's introspect module are either bound in the
  generated bindings.cc file or explicitly excluded in constants.py.
- All structs defined in Mujoco's introspect module are either bound in the
  generated bindings.cc file or explicitly skipped in SKIPPED_STRUCTS in
  constants.py.

These tests help maintain the integrity of the generated WASM bindings by
ensuring that no functions or structs are accidentally missed or incorrectly
handled during the code generation process.
"""

from pathlib import Path
import re

from absl.testing import absltest
from introspect import functions as introspect_functions
from introspect import structs as introspect_structs

from wasm.codegen.generators import common
from wasm.codegen.generators import constants
from wasm.codegen.generators import functions


def _get_resource_content(file_path: str) -> str:
  """Reads resource file content using resources.GetResource."""
  try:
    with open(file_path, 'r') as f:
      return f.read()
  except FileNotFoundError:
    print(f'Warning: Resource {file_path} not found.')
    return ''
  except IOError as e:
    print(f'Error reading resource {file_path}: {e}')
    return ''


def _get_bound_functions_from_cc() -> set[str]:
  """Reads bindings.cc and extracts the names of bound functions."""
  content = _get_resource_content(
      Path(__file__).parent / 'generated/bindings.cc'
  )
  if not content:
    return set()

  bound_functions = set()
  # Find all strings within function("...") calls.
  matches = re.findall(r'function\("([^"]+)"', content)
  bound_functions.update(matches)
  return bound_functions


def _get_bound_structs_from_cc() -> set[str]:
  """Reads bindings.cc and extracts the names of bound structs."""
  content = _get_resource_content(
      Path(__file__).parent / 'generated/bindings.cc'
  )
  if not content:
    return set()

  bound_structs = set()
  # Find all strings within class_<...>("...") calls.
  matches = re.findall(r'class_<[^>]+>\("([^"]+)"\)', content)
  bound_structs.update(matches)
  return bound_structs


class BindingCoverageTest(absltest.TestCase):

  def test_function_coverage(self):
    """Asserts that each function is either excluded or bound."""
    all_functions = set(introspect_functions.FUNCTIONS.keys())
    excluded_functions = {
        name
        for name in all_functions
        if functions.is_excluded_function_name(name)
    }
    bound_functions = _get_bound_functions_from_cc()

    missing_functions = []
    for func_name in all_functions:
      if (
          func_name not in excluded_functions
          and func_name not in bound_functions
      ):
        missing_functions.append(func_name)

    if missing_functions:
      error_message = (
          f"""The following functions from functions.py are neither excluded in
          constants.py nor bound in bindings.cc:

          {", ".join(sorted(missing_functions))}

          Please either add them to a exclusion list in
          constants.py or create a binding in bindings.cc."""
      )
      self.fail(error_message)

  def test_struct_coverage(self):
    """Asserts that each struct is either not bound or bound in structs.cc."""
    bound_structs = _get_bound_structs_from_cc()
    all_structs = {
        common.capitalize(struct_name)
        for struct_name in introspect_structs.STRUCTS.keys()
    }
    skipped_structs = {
        common.capitalize(struct_name)
        for struct_name in constants.SKIPPED_STRUCTS
    }
    missing_structs = []
    for struct_name in all_structs:
      if (
          struct_name not in skipped_structs
          and struct_name not in bound_structs
      ):
        missing_structs.append(struct_name)
    error_messages = []

    if missing_structs:
      error_messages.append(
          f"""The following structs are defined in structs.py but are neither
            bound in bindings.cc nor listed in SKIPPED_STRUCTS:

            {", ".join(sorted(missing_structs))}

            Please either add them to SKIPPED_STRUCTS or create its binding
            in bindings.cc."""
      )

    if error_messages:
      self.fail('\n\n'.join(error_messages))


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