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

"""Tests for jni_generator.py.

This test suite contains various tests for the JNI generator.
It exercises the low-level parser all the way up to the
code generator and ensures the output matches a golden
file.
"""

import collections
import copy
import difflib
import glob
import logging
import os
import pathlib
import shlex
import subprocess
import sys
import tempfile
import unittest
import zipfile

_SCRIPT_DIR = os.path.normpath(os.path.dirname(__file__))
_GOLDENS_DIR = os.path.join(_SCRIPT_DIR, 'golden')
_INCLUDES = ('base/android/jni_generator/jni_generator_helper.h')
_JAVA_SRC_DIR = os.path.join(_SCRIPT_DIR, 'java', 'src', 'org', 'chromium',
                             'example', 'jni_generator')

# Set this environment variable in order to regenerate the golden text
# files.
_REBASELINE = os.environ.get('REBASELINE', '0') != '0'

_accessed_goldens = set()


class CommonOptions:
  def __init__(self):
    self.enable_jni_multiplexing = False
    self.package_prefix = None
    self.use_proxy_hash = False

  def to_args(self):
    ret = []
    if self.package_prefix:
      ret += ['--package_prefix', self.package_prefix]
    return ret


class JniGeneratorOptions(CommonOptions):
  def __init__(self, **kwargs):
    super().__init__()
    self.includes = _INCLUDES
    self.input_file = None
    self.jar_file = None
    self.module_name = ''
    self.output_dir = None
    self.output_name = 'output.h'
    self.__dict__.update(kwargs)

  def to_args(self):
    ret = super().to_args()
    ret += ['--output_dir', self.output_dir]
    ret += ['--input_file', self.input_file]
    ret += ['--output_name', self.output_name]
    if self.jar_file:
      ret += ['--jar_file', self.jar_file]
    if self.enable_jni_multiplexing:
      ret.append('--enable_jni_multiplexing')
    if self.includes:
      ret += ['--includes', self.includes, '--ptr_type', 'long']
    if self.use_proxy_hash:
      ret.append('--use_proxy_hash')
    return ret


class JniRegistrationGeneratorOptions(CommonOptions):
  """The mock options object which is passed to the jni_generator.py script."""

  def __init__(self, **kwargs):
    super().__init__()
    self.add_stubs_for_missing_native = False
    self.enable_proxy_mocks = False
    self.header_path = None
    self.include_test_only = False
    self.manual_jni_registration = False
    self.module_name = ''
    self.remove_uncalled_methods = False
    self.require_mocks = False
    self.__dict__.update(kwargs)

  def to_args(self):
    ret = super().to_args()
    if self.add_stubs_for_missing_native:
      ret.append('--add-stubs-for-missing-native')
    if self.enable_jni_multiplexing:
      ret.append('--enable-jni-multiplexing')
    if self.enable_proxy_mocks:
      ret.append('--enable-proxy-mocks')
    if self.header_path:
      ret += ['--header-path', self.header_path]
    if self.include_test_only:
      ret.append('--include-test-only')
    if self.manual_jni_registration:
      ret.append('--manual-jni-registration')
    if self.module_name:
      ret += ['--module-name', self.module_name]
    if self.package_prefix:
      ret += ['--package_prefix', self.package_prefix]
    if self.remove_uncalled_methods:
      ret.append('--remove-uncalled-methods')
    if self.require_mocks:
      ret.append('--require-mocks')
    if self.use_proxy_hash:
      ret.append('--use-proxy-hash')
    return ret


def _MakePrefixes(options):
  package_prefix = ''
  if options.package_prefix:
    package_prefix = options.package_prefix.replace('.', '/') + '/'
  module_prefix = ''
  if options.module_name:
    module_prefix = f'{options.module_name}_'
  return package_prefix, module_prefix


class BaseTest(unittest.TestCase):

  def _CheckSrcjarGoldens(self, srcjar_path, name_to_goldens):
    with zipfile.ZipFile(srcjar_path, 'r') as srcjar:
      self.assertEqual(set(srcjar.namelist()), set(name_to_goldens))
      for name in srcjar.namelist():
        self.assertTrue(
            name in name_to_goldens,
            f'Found {name} output, but not present in name_to_goldens map.')
        contents = srcjar.read(name).decode('utf-8')
        self.AssertGoldenTextEquals(contents, name_to_goldens[name])

  def _TestEndToEndGeneration(self, input_file, *, srcjar=False, **kwargs):
    golden_name = self._testMethodName
    options = JniGeneratorOptions(**kwargs)
    basename = os.path.splitext(input_file)[0]
    header_golden = f'{golden_name}-{basename}_jni.h.golden'
    if srcjar:
      dir_prefix, file_prefix = _MakePrefixes(options)
      name_to_goldens = {
          f'{dir_prefix}org/chromium/base/natives/{file_prefix}GEN_JNI.java':
          f'{golden_name}-Placeholder-GEN_JNI.java.golden',
          f'{dir_prefix}org/chromium/example/jni_generator/{basename}Jni.java':
          f'{golden_name}-{basename}Jni.java.golden',
      }

    with tempfile.TemporaryDirectory() as tdir:
      relative_input_file = os.path.join(_JAVA_SRC_DIR, input_file)
      if input_file.endswith('.class'):
        jar_path = os.path.join(tdir, 'input.jar')
        with zipfile.ZipFile(jar_path, 'w') as z:
          z.write(relative_input_file, input_file)
        options.jar_file = jar_path
        options.input_file = input_file
      else:
        options.input_file = relative_input_file

      options.output_dir = tdir
      cmd = [os.path.join(_SCRIPT_DIR, 'jni_generator.py')]
      if srcjar:
        srcjar_path = os.path.join(tdir, 'srcjar.jar')
        cmd += ['--srcjar-path', srcjar_path]
      cmd += options.to_args()

      logging.info('Running: %s', shlex.join(cmd))
      subprocess.check_call(cmd)

      output_path = os.path.join(tdir, options.output_name)
      with open(output_path, 'r') as f:
        contents = f.read()
      self.AssertGoldenTextEquals(contents, header_golden)

      if srcjar:
        self._CheckSrcjarGoldens(srcjar_path, name_to_goldens)

  def _TestEndToEndRegistration(self,
                                input_files,
                                src_files_for_asserts_and_stubs=None,
                                **kwargs):
    golden_name = self._testMethodName
    options = JniRegistrationGeneratorOptions(**kwargs)
    dir_prefix, file_prefix = _MakePrefixes(options)
    name_to_goldens = {
        f'{dir_prefix}org/chromium/base/natives/{file_prefix}GEN_JNI.java':
        f'{golden_name}-Final-GEN_JNI.java.golden',
    }
    if options.use_proxy_hash:
      name_to_goldens[f'{dir_prefix}J/{file_prefix}N.java'] = (
          f'{golden_name}-Final-N.java.golden')
    header_golden = None
    if options.use_proxy_hash or options.manual_jni_registration:
      header_golden = f'{golden_name}-Registration.h.golden'

    with tempfile.TemporaryDirectory() as tdir:
      native_sources = [os.path.join(_JAVA_SRC_DIR, f) for f in input_files]

      if src_files_for_asserts_and_stubs:
        java_sources = [
            os.path.join(_JAVA_SRC_DIR, f)
            for f in src_files_for_asserts_and_stubs
        ]
      else:
        java_sources = native_sources

      cmd = [os.path.join(_SCRIPT_DIR, 'jni_registration_generator.py')]

      java_sources_file = pathlib.Path(tdir) / 'java_sources.txt'
      java_sources_file.write_text('\n'.join(java_sources))
      cmd += ['--java-sources-files', str(java_sources_file)]
      if native_sources:
        native_sources_file = pathlib.Path(tdir) / 'native_sources.txt'
        native_sources_file.write_text('\n'.join(native_sources))
        cmd += ['--native-sources-file', str(native_sources_file)]

      srcjar_path = os.path.join(tdir, 'srcjar.jar')
      cmd += ['--srcjar-path', srcjar_path]
      if header_golden:
        header_path = os.path.join(tdir, 'header.h')
        cmd += ['--header-path', header_path]

      cmd += options.to_args()
      logging.info('Running: %s', shlex.join(cmd))
      subprocess.check_call(cmd)

      self._CheckSrcjarGoldens(srcjar_path, name_to_goldens)

      if header_golden:
        with open(header_path, 'r') as f:
          # Temp directory will cause some diffs each time we run if we don't
          # normalize.
          contents = f.read().replace(
              tdir.replace('/', '_').upper(), 'TEMP_DIR')
          self.AssertGoldenTextEquals(contents, header_golden)

  def _ReadGoldenFile(self, path):
    _accessed_goldens.add(path)
    if not os.path.exists(path):
      return None
    with open(path, 'r') as f:
      return f.read()

  def AssertTextEquals(self, golden_text, generated_text):
    if not self.CompareText(golden_text, generated_text):
      self.fail('Golden text mismatch.')

  def CompareText(self, golden_text, generated_text):

    def FilterText(text):
      return [
          l.strip() for l in text.split('\n')
          if not l.startswith('// Copyright')
      ]

    stripped_golden = FilterText(golden_text)
    stripped_generated = FilterText(generated_text)
    if stripped_golden == stripped_generated:
      return True
    print(self.id())
    for line in difflib.context_diff(stripped_golden, stripped_generated):
      print(line)
    print('\n\nGenerated')
    print('=' * 80)
    print(generated_text)
    print('=' * 80)
    print('Run with:')
    print('REBASELINE=1', sys.argv[0])
    print('to regenerate the data files.')

  def AssertGoldenTextEquals(self, generated_text, golden_file):
    """Compares generated text with the corresponding golden_file

    It will instead compare the generated text with
    script_dir/golden/golden_file."""
    golden_path = os.path.join(_GOLDENS_DIR, golden_file)
    golden_text = self._ReadGoldenFile(golden_path)
    if _REBASELINE:
      if golden_text != generated_text:
        print('Updated', golden_path)
        with open(golden_path, 'w') as f:
          f.write(generated_text)
      return
    # golden_text is None if no file is found. Better to fail than in
    # AssertTextEquals so we can give a clearer message.
    if golden_text is None:
      self.fail('Golden file does not exist: ' + golden_path)
    self.AssertTextEquals(golden_text, generated_text)


@unittest.skipIf(os.name == 'nt', 'Not intended to work on Windows')
class Tests(BaseTest):
  def testNonProxy(self):
    self._TestEndToEndGeneration('SampleNonProxy.java')

  def testBirectionalNonProxy(self):
    self._TestEndToEndGeneration('SampleBidirectionalNonProxy.java')

  def testBidirectionalClass(self):
    self._TestEndToEndGeneration('SampleForTests.java', srcjar=True)
    self._TestEndToEndRegistration(['SampleForTests.java'])

  def testFromClassFile(self):
    self._TestEndToEndGeneration('SampleNonProxy.class')

  def testUniqueAnnotations(self):
    self._TestEndToEndGeneration('SampleUniqueAnnotations.java', srcjar=True)

  def testEndToEndProxyHashed(self):
    self._TestEndToEndRegistration(['SampleForAnnotationProcessor.java'],
                                   use_proxy_hash=True)

  def testEndToEndManualRegistration(self):
    self._TestEndToEndRegistration(['SampleForAnnotationProcessor.java'],
                                   manual_jni_registration=True)

  def testEndToEndProxyJniWithModules(self):
    self._TestEndToEndGeneration('SampleModule.java',
                                 srcjar=True,
                                 use_proxy_hash=True,
                                 module_name='module')
    self._TestEndToEndRegistration(
        ['SampleForAnnotationProcessor.java', 'SampleModule.java'],
        use_proxy_hash=True,
        module_name='module')

  def testStubRegistration(self):
    input_java_files = ['SampleForAnnotationProcessor.java']
    stubs_java_files = input_java_files + [
        'TinySample.java', 'SampleProxyEdgeCases.java'
    ]
    extra_input_java_files = ['TinySample2.java']
    self._TestEndToEndRegistration(
        input_java_files + extra_input_java_files,
        src_files_for_asserts_and_stubs=stubs_java_files,
        add_stubs_for_missing_native=True,
        remove_uncalled_methods=True)

  def testFullStubs(self):
    self._TestEndToEndRegistration(
        [],
        src_files_for_asserts_and_stubs=['TinySample.java'],
        add_stubs_for_missing_native=True)

  def testForTestingKept(self):
    input_java_file = 'SampleProxyEdgeCases.java'
    self._TestEndToEndGeneration(input_java_file, srcjar=True)
    self._TestEndToEndRegistration([input_java_file],
                                   use_proxy_hash=True,
                                   include_test_only=True)

  def testForTestingRemoved(self):
    self._TestEndToEndRegistration(['SampleProxyEdgeCases.java'],
                                   use_proxy_hash=True,
                                   include_test_only=True)

  def testProxyMocks(self):
    self._TestEndToEndRegistration(['TinySample.java'], enable_proxy_mocks=True)

  def testRequireProxyMocks(self):
    self._TestEndToEndRegistration(['TinySample.java'],
                                   enable_proxy_mocks=True,
                                   require_mocks=True)

  def testPackagePrefixGenerator(self):
    self._TestEndToEndGeneration('SampleForTests.java',
                                 srcjar=True,
                                 package_prefix='this.is.a.package.prefix')

  def testPackagePrefixWithManualRegistration(self):
    self._TestEndToEndRegistration(['SampleForAnnotationProcessor.java'],
                                   package_prefix='this.is.a.package.prefix',
                                   manual_jni_registration=True)

  def testPackagePrefixWithProxyHash(self):
    self._TestEndToEndRegistration(['SampleForAnnotationProcessor.java'],
                                   package_prefix='this.is.a.package.prefix',
                                   use_proxy_hash=True)

  def testPackagePrefixWithManualRegistrationWithProxyHash(self):
    self._TestEndToEndRegistration(['SampleForAnnotationProcessor.java'],
                                   package_prefix='this.is.a.package.prefix',
                                   use_proxy_hash=True,
                                   manual_jni_registration=True)

  def testMultiplexing(self):
    self._TestEndToEndRegistration(['SampleForAnnotationProcessor.java'],
                                   enable_jni_multiplexing=True,
                                   use_proxy_hash=True)


def main():
  try:
    unittest.main()
  finally:
    if _REBASELINE and not any(not x.startswith('-') for x in sys.argv[1:]):
      for path in glob.glob(os.path.join(_GOLDENS_DIR, '*.golden')):
        if path not in _accessed_goldens:
          print('Removing obsolete golden:', path)
          os.unlink(path)


if __name__ == '__main__':
  main()
