#!/usr/bin/env python
#
# Copyright 2016 Google LLC
#
# 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.

"""This is used to validate that the library is correct.

This checks:
 * All files in lib/ appear when compiling +@complete
 * Runs a compiler pass over the test code to check for type errors
 * Run the linter to check for style violations.
"""

import argparse
import ast
import logging
import os
import re

import build
import compiler
import shakaBuildHelpers


_CHECKS = []

def _Check(name):
  """A decorator for checks."""
  def decorator(func):
    _CHECKS.append((name, func))
    return func
  return decorator

def complete_build_files():
  """Returns a complete set of build files."""
  complete = build.Build()
  # Normally we don't need to include @core, but because we look at the build
  # object directly, we need to include it here.  When using main(), it will
  # call addCore which will ensure core is included.
  if not complete.parse_build(['+@complete', '+@core'], os.getcwd()):
    logging.error('Error parsing complete build')
    return False
  return complete.include

def get_lint_files():
  """Returns the absolute paths to all the files to run the linter over."""
  base = shakaBuildHelpers.get_source_base()
  get = shakaBuildHelpers.get_all_js_files
  main_sources = (
      get('lib') +
      # TODO: get third_party/closure-uri in compliance and then lint it.
      # get('third_party') +
      get('ui') +
      get('externs') +
      get('test') +
      get('demo') +
      get('build'))
  main_sources.remove(os.path.join(base, 'build', 'wrapper.template.js'))
  tool_sources = [
      os.path.join(base, '.eslintrc.js'),
      os.path.join(base, 'docs', 'jsdoc-plugin.js'),
      os.path.join(base, 'karma.conf.js'),
  ]
  return main_sources + tool_sources


@_Check('js_lint')
def check_js_lint(args):
  """Runs the JavaScript linter."""
  # TODO: things not enforced: property doc requirements
  logging.info('Linting JavaScript...')

  base = shakaBuildHelpers.get_source_base()
  config_path = os.path.join(base, '.eslintrc.js')

  linter = compiler.Linter(get_lint_files(), config_path)
  return linter.lint(fix=args.fix, force=args.force)


@_Check('css_lint')
def check_css_lint(args):
  """Runs the CSS linter."""
  logging.info('Linting CSS...')

  match = re.compile(r'.*\.(less|css)$')
  base = shakaBuildHelpers.get_source_base()
  def get(*path_components):
    return shakaBuildHelpers.get_all_files(
        os.path.join(base, *path_components), match)
  files = (get('ui') + get('demo'))
  config_path = os.path.join(base, '.csslintrc')

  linter = compiler.CssLinter(files, config_path)
  return linter.lint(fix=args.fix, force=args.force)


@_Check('html_lint')
def check_html_lint(args):
  """Runs the HTML linter."""
  logging.info('Linting HTML...')

  base = shakaBuildHelpers.get_source_base()
  files = ['index.html', os.path.join('demo', 'index.html'), 'support.html']
  file_paths = [os.path.join(base, x) for x in files]
  config_path = os.path.join(base, '.htmlhintrc')

  htmllinter = compiler.HtmlLinter(file_paths, config_path)
  return htmllinter.lint(force=args.force)


@_Check('complete')
def check_complete(_):
  """Checks whether the 'complete' build references every file.

  This is used by the build script to ensure that every file is included in at
  least one build type.

  Returns:
    True on success, False on failure.
  """
  logging.info('Checking that the build files are complete...')

  complete_build = complete_build_files()
  if not complete_build:
    return False

  base = shakaBuildHelpers.get_source_base()
  all_files = set()
  all_files.update(shakaBuildHelpers.get_all_js_files('lib'))
  all_files.update(shakaBuildHelpers.get_all_js_files('ui'))
  all_files.update(shakaBuildHelpers.get_all_js_files('third_party'))
  missing_files = all_files - complete_build

  if missing_files:
    logging.error('There are files missing from the complete build:')
    for missing in missing_files:
      # Convert to a path relative to source base.
      logging.error('  ' + os.path.relpath(missing, base))
    return False
  return True


@_Check('spelling')
def check_spelling(_):
  """Checks that source files don't have any common misspellings."""
  logging.info('Checking for common misspellings...')

  complete_build = complete_build_files()
  if not complete_build:
    return False

  base = shakaBuildHelpers.get_source_base()
  complete_build.update(shakaBuildHelpers.get_all_js_files('test'))
  complete_build.update(shakaBuildHelpers.get_all_js_files('demo'))
  complete_build.update(shakaBuildHelpers.get_all_js_files('externs'))
  complete_build.update(shakaBuildHelpers.get_all_files(
      os.path.join(base, 'build'), re.compile(r'.*\.(js|py)$')))

  with shakaBuildHelpers.open_file(
      os.path.join(base, 'build', 'misspellings.txt')) as f:
    misspellings = ast.literal_eval(f.read())
  has_error = False
  for path in complete_build:
    with shakaBuildHelpers.open_file(path) as f:
      for i, line in enumerate(f):
        for regex, replace_pattern in misspellings.items():
          for match in re.finditer(regex, line):
            repl = match.expand(replace_pattern).lower()
            if match.group(0).lower() == repl:
              continue  # No-op suggestion

            if not has_error:
              logging.error('The following file(s) have misspellings:')
            logging.error(
                '  %s:%d:%d: Did you mean %r?' %
                (os.path.relpath(path, base), i + 1, match.start() + 1, repl))
            has_error = True

  return not has_error


@_Check('eslint_disable')
def check_eslint_disable(_):
  """Checks that source files correctly use "eslint-disable".

  - Rules are disabled/enabled in nested blocks.
  - Rules are not disabled multiple times.
  - Rules are enabled again by the end of the file.

  Returns:
    True on success, False on failure.
  """
  logging.info('Checking correct usage of eslint-disable...')

  complete_build = complete_build_files()
  if not complete_build:
    return False

  base = shakaBuildHelpers.get_source_base()
  complete_build.update(shakaBuildHelpers.get_all_js_files('test'))
  complete_build.update(shakaBuildHelpers.get_all_js_files('demo'))

  has_error = False
  for path in complete_build:
    # The stack of rules that are disabled.
    disabled = []

    with shakaBuildHelpers.open_file(path, 'r') as f:
      rel_path = os.path.relpath(path, base)
      for i, line in enumerate(f):
        match = re.match(r'^\s*/\* eslint-(disable|enable) ([\w-]*) \*/$', line)
        if match:
          if match.group(1) == 'disable':
            # |line| disables a rule; validate it isn't already disabled.
            if match.group(2) in disabled:
              logging.error('%s:%d Rule %r already disabled',
                            rel_path, i + 1, match.group(2))
              has_error = True
            else:
              disabled.append(match.group(2))
          else:
            # |line| enabled a rule; validate it's already disabled and it's
            # enabled in the correct order.
            if not disabled or match.group(2) not in disabled:
              logging.error("%s:%d Rule %r isn't disabled",
                            rel_path, i + 1, match.group(2))
              has_error = True
            elif disabled[-1] != match.group(2):
              logging.error('%s:%d Rule %r enabled out of order',
                            rel_path, i + 1, match.group(2))
              has_error = True
              disabled = [x for x in disabled if x != match.group(2)]
            else:
              disabled = disabled[:-1]
        else:
          # |line| is not a normal eslint-disable or eslint-enable line.  Verify
          # we don't have this text elsewhere where eslint will ignore it.
          if re.search(r'eslint-(disable|enable)(?!-(next-)?line)', line):
            logging.error('%s:%d Invalid eslint-disable',
                          rel_path, i + 1)
            has_error = True

      for rule in disabled:
        logging.error('%s:%d Rule %r still disabled at end of file',
                      rel_path, i + 1, rule)
        has_error = True

  return not has_error


@_Check('test_type')
def check_tests(args):
  """Runs an extra compile pass over the test code to check for type errors.

  Returns:
    True on success, False on failure.
  """
  logging.info('Checking the tests for type errors...')

  complete_build = complete_build_files()
  if not complete_build:
    return False

  base = shakaBuildHelpers.get_source_base()
  closure_base_js = shakaBuildHelpers.get_closure_base_js_path()
  get = shakaBuildHelpers.get_all_js_files

  files = complete_build
  files.update(set(
      get('externs') +
      get('test') +
      [closure_base_js]))
  files.add(os.path.join(base, 'demo', 'common', 'asset.js'))
  files.add(os.path.join(base, 'demo', 'common', 'assets.js'))
  files.add(os.path.join(base, 'demo', 'common', 'message_ids.js'))

  localizations = compiler.GenerateLocalizations(None)
  localizations.generate(args.force)
  files.add(localizations.output)

  closure_opts = build.common_closure_opts + build.common_closure_defines
  closure_opts += build.debug_closure_opts + build.debug_closure_defines

  # Ignore missing goog.require since we assume the whole library is
  # already included.
  closure_opts += [
      '--jscomp_off=missingRequire',
      '--checks-only', '-O', 'SIMPLE',
  ]

  # Set up a build with the build name of "dummy".  With output_compiled_bundle
  # set to False, the build name is irrelevant, since we won't generate any
  # compiled output.
  closure = compiler.ClosureCompiler(files, 'dummy')
  closure.output_compiled_bundle = False
  # Instead of creating a compiled bundle, we will touch a timestamp file to
  # keep track of how recently we've run this check.
  closure.timestamp_file = os.path.join(base, 'dist', '.testcheckstamp')
  return closure.compile(closure_opts, args.force)


def main(args):
  parser = argparse.ArgumentParser(
      description=__doc__,
      formatter_class=argparse.RawDescriptionHelpFormatter)
  parser.add_argument(
      '--fix',
      help='Automatically fix style violations.',
      action='store_true')
  parser.add_argument(
      '--force',
      '-f',
      help='Force checks even if no files have changed.',
      action='store_true')
  parser.add_argument(
      '--filter',
      metavar='CHECK',
      nargs='+',
      choices=[i[0] for i in _CHECKS],
      help='Run only the given checks (choices: %(choices)s).')

  parsed_args = parser.parse_args(args)

  # Make the dist/ folder, ignore errors.
  base = shakaBuildHelpers.get_source_base()
  try:
    os.mkdir(os.path.join(base, 'dist'))
  except OSError:
    pass

  # Update node modules if needed.
  if not shakaBuildHelpers.update_node_modules():
    return 1

  for name, step in _CHECKS:
    if not parsed_args.filter or name in parsed_args.filter:
      if not step(parsed_args):
        return 1
  return 0


if __name__ == '__main__':
  shakaBuildHelpers.run_main(main)
