# Copyright 2017 The Chromium Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Presubmit script for ios.

See http://dev.chromium.org/developers/how-tos/depottools/presubmit-scripts
for more details about the presubmit API built into depot_tools.
"""

import os
import xml.etree.ElementTree as ElementTree

NULLABILITY_PATTERN = r'(nonnull|nullable|_Nullable|_Nonnull)'
TODO_PATTERN = r'TO[D]O\(([^\)]*)\)'
BUG_PATTERN = r'^(crbug\.com|b)/\d+$'
DEPRECATED_BUG_PATTERN = r'^b/\d+$'
INCLUDE_PATTERN = r'^#include'
PIPE_IN_COMMENT_PATTERN = r'//.*[^|]\|(?!\|)'
IOS_PACKAGE_PATTERN = r'^ios'
BOXED_BOOL_PATTERN = r'@\((YES|NO)\)'
USER_DEFAULTS_PATTERN = r'\[NSUserDefaults standardUserDefaults]'

# Color management constants
COLOR_SHARED_DIR = 'ios/chrome/common/ui/colors/'
COLOR_FILE_PATTERN = '.colorset/Contents.json'


def FormatMessageWithFiles(message, errors):
    """Helper to format warning/error messages with affected files."""
    if not errors:
        return message
    return '\n'.join([message + '\n\nAffected file(s):'] + errors) + '\n'

def IsSubListOf(needle, hay):
    """Returns whether there is a slice of |hay| equal to |needle|."""
    for i, line in enumerate(hay):
        if line == needle[0]:
            if needle == hay[i:i + len(needle)]:
                return True
    return False


def _CheckNullabilityAnnotations(input_api, output_api):
    """ Checks whether there are nullability annotations in ios code.

    They are accepted in ios/web_view/public since it tries to mimic
    the platform library but not anywhere else.
    """
    nullability_regex = input_api.re.compile(NULLABILITY_PATTERN)

    errors = []
    for f in input_api.AffectedFiles():
        if f.LocalPath().startswith('ios/web_view/public/'):
            # ios/web_view/public tries to mimic an existing API that
            # might have nullability in it and that is acceptable.
            continue
        for line_num, line in f.ChangedContents():
            if nullability_regex.search(line):
                errors.append('%s:%s' % (f.LocalPath(), line_num))
    if not errors:
        return []

    plural_suffix = '' if len(errors) == 1 else 's'
    warning_message = ('Found Nullability annotation%(plural)s. '
                     'Prefer DCHECKs in ios code to check for nullness:' % {
                         'plural': plural_suffix
                     })

    return [output_api.PresubmitPromptWarning(warning_message, items=errors)]


def _CheckBugInToDo(input_api, output_api):
    """ Checks whether TODOs in ios code are identified by a bug number."""
    errors = []
    warnings = []
    for f in input_api.AffectedFiles():
        for line_num, line in f.ChangedContents():
            if _HasToDoWithNoBug(input_api, line):
                errors.append('%s:%s' % (f.LocalPath(), line_num))
            if _HasToDoWithDeprecatedBug(input_api, line):
                warnings.append('%s:%s' % (f.LocalPath(), line_num))
    if not errors and not warnings:
        return []

    output = []
    if errors:
        singular_article = 'a ' if len(errors) == 1 else ''
        plural_suffix = '' if len(errors) == 1 else 's'
        error_message = '\n'.join([
            'Found TO'
            'DO%(plural)s without %(a)sbug number%(plural)s (expected format '
            'is \"TO'
            'DO(crbug.com/######)\"):' % {
                'plural': plural_suffix,
                'a' : singular_article
            }
        ] + errors) + '\n'
        output.append(output_api.PresubmitError(error_message))

    if warnings:
        singular_article = 'a ' if len(warnings) == 1 else ''
        plural_suffix = '' if len(warnings) == 1 else 's'
        warning_message = '\n'.join([
            'Found TO'
            'DO%(plural)s with %(a)sdeprecated bug link%(plural)s (found '
            '"b/#####\", expected format is \"crbug.com/######"):' % {
                'plural': plural_suffix,
                'a' : singular_article
            }
        ] + warnings) + '\n'
        output.append(output_api.PresubmitPromptWarning(warning_message))

    return output


def _CheckHasNoIncludeDirectives(input_api, output_api):
    """ Checks that #include preprocessor directives are not present."""
    errors = []
    for f in input_api.AffectedFiles():
        if not _IsInIosPackage(input_api, f.LocalPath()):
            continue
        _, ext = os.path.splitext(f.LocalPath())
        if ext != '.mm':
            continue
        for line_num, line in f.ChangedContents():
            if _HasIncludeDirective(input_api, line):
                errors.append('%s:%s' % (f.LocalPath(), line_num))
    if not errors:
        return []

    singular_plural = 'it' if len(errors) == 1 else 'them'
    plural_suffix = '' if len(errors) == 1 else 's'
    error_message = '\n'.join([
        'Found usage of `#include` preprocessor directive%(plural)s! Please, '
        'replace %(singular_plural)s with `#import` preprocessor '
        'directive%(plural)s instead. '
        'Consider replacing all existing `#include` with `#import` (if any) in '
        'this file for the code clean up. See '
        'https://chromium.googlesource.com/chromium/src.git/+/refs/heads/main'
        '/styleguide/objective-c/objective-c.md'
        '#import-and-include-in-the-directory for more details. '
        '\n\nAffected file%(plural)s:' % {
            'plural': plural_suffix,
            'singular_plural': singular_plural
        }
    ] + errors) + '\n'

    return [output_api.PresubmitError(error_message)]


def _CheckHasNoPipeInComment(input_api, output_api):
    """ Checks that comments don't contain pipes."""
    pipe_regex = input_api.re.compile(PIPE_IN_COMMENT_PATTERN)

    errors = []
    for f in input_api.AffectedFiles():
        if not _IsInIosPackage(input_api, f.LocalPath()):
            continue
        for line_num, line in f.ChangedContents():
            if pipe_regex.search(line):
                errors.append('%s:%s' % (f.LocalPath(), line_num))
    if not errors:
        return []
    warning_message = '\n'.join([
        'Please use backticks "`" instead of pipes "|" if you need to quote'
        ' variable names and symbols in comments.\n'
        'Found potential uses of pipes in:'
    ] + errors) + '\n'

    return [output_api.PresubmitPromptWarning(warning_message)]

def _CheckCanImproveTestUsingExpectNSEQ(input_api, output_api):
    """ Checks that test files use EXPECT_NSEQ when possible."""
    errors = []
    # Substrings that should not be used together with EXPECT_TRUE or
    # EXPECT_FALSE in tests.
    wrong_patterns = ["isEqualToString:", "isEqualToData:", "isEqualToArray:"]
    for f in input_api.AffectedFiles():
        if not '_unittest.' in f.LocalPath():
            continue
        for line_num, line in f.ChangedContents():
            if line.startswith(("EXPECT_TRUE", "EXPECT_FALSE")):
                # Condition is in one line.
                if any(x in line for x in wrong_patterns):
                    errors.append('%s:%s' % (f.LocalPath(), line_num))
                # Condition is split on multiple lines.
                elif not line.endswith(";"):
                    # Check this is not the last line.
                    if line_num < len(f.NewContents()):
                        next_line = f.NewContents()[line_num]
                        if any(x in next_line for x in wrong_patterns):
                            errors.append('%s:%s' % (f.LocalPath(), line_num))

    if not errors:
        return []

    plural_suffix = '' if len(errors) == 1 else 's'
    warning_message = '\n'.join([
         'Found possible improvement in unittest. Prefer using'
         ' EXPECT_NSEQ() or EXPECT_NSNE() when possible.'
         '\n\nAffected file%(plural)s:' % {
            'plural': plural_suffix,
          }
    ] + errors) + '\n'

    return [output_api.PresubmitPromptWarning(warning_message)]

def _IsInIosPackage(input_api, path):
    """ Returns True if path is within ios package"""
    ios_package_regex = input_api.re.compile(IOS_PACKAGE_PATTERN)

    return ios_package_regex.search(path)


def _HasIncludeDirective(input_api, line):
    """ Returns True if #include is found in the line"""
    include_regex = input_api.re.compile(INCLUDE_PATTERN)

    return include_regex.search(line)


def _HasToDoWithNoBug(input_api, line):
    """ Returns True if TODO is not identified by a bug number."""
    todo_regex = input_api.re.compile(TODO_PATTERN)
    bug_regex = input_api.re.compile(BUG_PATTERN)

    todo_match = todo_regex.search(line)
    if not todo_match:
        return False

    return not bug_regex.match(todo_match.group(1))

def _HasToDoWithDeprecatedBug(input_api, line):
    """ Returns True if TODO is identified by a deprecated bug number format."""
    todo_regex = input_api.re.compile(TODO_PATTERN)
    deprecated_bug_regex = input_api.re.compile(DEPRECATED_BUG_PATTERN)

    todo_match = todo_regex.search(line)
    if not todo_match:
        return False
    return deprecated_bug_regex.match(todo_match.group(1))

def _CheckHasNoBoxedBOOL(input_api, output_api):
    """ Checks that there are no @(YES) or @(NO)."""
    boxed_BOOL_regex = input_api.re.compile(BOXED_BOOL_PATTERN)

    errors = []
    for f in input_api.AffectedFiles():
        for line_num, line in f.ChangedContents():
            if boxed_BOOL_regex.search(line):
                errors.append('%s:%s' % (f.LocalPath(), line_num))
    if not errors:
        return []

    plural_suffix = '' if len(errors) == 1 else 's'
    warning_message = ('Found boxed BOOL%(plural)s. '
                     'Prefer @YES or @NO in ios code:' % {
                         'plural': plural_suffix
                     })

    return [output_api.PresubmitPromptWarning(warning_message, items=errors)]

def _CheckNoTearDownEGTest(input_api, output_api):
    """ Checks that `- (void)tearDown {` is not present in an egtest.mm"""
    errors = []
    for f in input_api.AffectedFiles():
        if not '_egtest.' in f.LocalPath():
            continue
        for line_num, line in f.ChangedContents():
            if line.startswith("- (void)tearDown {"):
                errors.append('%s:%s' % (f.LocalPath(), line_num))

    if not errors:
        return []
    warning_message = '\n'.join([
        'To support hermetic EarlGrey test cases, tearDown has been renamed '
        'to tearDownHelper, and will soon be removed. If tearDown is really '
        'necessary for this test, please use addTeardownBlock'
    ] + errors) + '\n'

    return [output_api.PresubmitError(warning_message)]


def _IsAlphabeticallySortedXML(file):
    """Check that the `file` is alphabetically sorted"""
    parser = ElementTree.XMLParser(target=ElementTree.TreeBuilder(
        insert_comments=True))
    with open(file, 'r', encoding='utf8') as xml_file:
        tree = ElementTree.parse(xml_file, parser)
    root = tree.getroot()

    original_tree_string = ElementTree.tostring(root, encoding='utf8')

    messages_element = tree.findall('.//messages')[0]
    messages = messages_element.findall('message')
    messages.sort(key=lambda message: message.attrib["name"])
    for message in messages:
        messages_element.remove(message)
    for message in messages:
        messages_element.append(message)
    ordered_tree_string = ElementTree.tostring(root, encoding='utf8')
    return ordered_tree_string == original_tree_string


def _CheckOrderedStringFile(input_api, output_api):
    """ Checks that the string files are alphabetically ordered"""
    errors = []
    for f in input_api.AffectedFiles():
        if not f.LocalPath().endswith("_strings.grd"):
            continue
        if not _IsAlphabeticallySortedXML(f.AbsoluteLocalPath()):
            errors.append('  python3 ios/tools/order_string_file.py ' +
                          f.LocalPath())

    if not errors:
        return []
    warning_message = '\n'.join(
        ['Files not alphabetically sorted, try running:'] + errors) + '\n'

    return [output_api.PresubmitPromptWarning(warning_message)]


def _CheckNotUsingNSUserDefaults(input_api, output_api):
    """ Checks the added code to limit new usage of NSUserDefaults """
    user_defaults_regex = input_api.re.compile(USER_DEFAULTS_PATTERN)

    errors = []
    for f in input_api.AffectedFiles():
        if (not f.LocalPath().endswith('.mm')):
            continue
        for line_num, line in f.ChangedContents():
            if user_defaults_regex.search(line):
                errors.append('%s:%s' % (f.LocalPath(), line_num))

    if not errors:
        return []
    warning_message = '\n'.join([
        'A new use of NSUserDefaults was added. If this is a newly added key '
        'consider storing it to PrefService instead.'
    ] + errors) + '\n'

    return [output_api.PresubmitPromptWarning(warning_message)]


def _CheckNewColorIntroduction(input_api, output_api):
    """Checks for new or modified colorset files.

    Ensures colors are properly added to the shared directory.
    """
    results = []

    affected_files = [
        f for f in input_api.AffectedFiles()
        if f.LocalPath().endswith(COLOR_FILE_PATTERN)
    ]

    warnings = {
        'shared_added': [],
        'shared_modified': [],
        'other_modified': []
    }
    errors = []

    for affected_file in affected_files:
        action = affected_file.Action()
        local_path = affected_file.LocalPath()
        file_path_error = '%s' % (affected_file.LocalPath())

        if COLOR_SHARED_DIR in local_path:
            if action == 'A':
                warnings['shared_added'].append(file_path_error)
            elif action == 'M':
                warnings['shared_modified'].append(file_path_error)
        else:
            if action == 'A':
                errors.append(file_path_error)
            elif action == 'M':
                warnings['other_modified'].append(file_path_error)

    output = []

    if errors:
        error_message = ('New color(s) must be added to the %s directory.' %
                         COLOR_SHARED_DIR)
        output.append(
            output_api.PresubmitError(
                FormatMessageWithFiles(error_message, errors)))

    warning_message = ('Please ensure the color does not already exist in the '
                       'shared %s directory.' % COLOR_SHARED_DIR)

    if warnings['shared_added']:
        shared_added_message = ('New color(s) added in %s. %s' %
                                (COLOR_SHARED_DIR, warning_message))
        output.append(
            output_api.PresubmitPromptWarning(
                FormatMessageWithFiles(shared_added_message,
                                       warnings['shared_added'])))

    if warnings['shared_modified']:
        shared_modified_message = ('Color(s) modified in %s. %s' %
                                   (COLOR_SHARED_DIR, warning_message))
        output.append(
            output_api.PresubmitPromptWarning(
                FormatMessageWithFiles(shared_modified_message,
                                       warnings['shared_modified'])))

    if warnings['other_modified']:
        modified_message = ('Color(s) modified. %s' % warning_message)
        output.append(
            output_api.PresubmitPromptWarning(
                FormatMessageWithFiles(modified_message,
                                       warnings['other_modified'])))

    return output

def _CheckStyleESLint(input_api, output_api):
  results = []

  try:
    import sys
    old_sys_path = sys.path[:]
    cwd = input_api.PresubmitLocalPath()
    sys.path += [input_api.os_path.join(cwd, '..', 'tools')]
    from web_dev_style import presubmit_support
    results += presubmit_support.CheckStyleESLint(input_api, output_api)
  finally:
    sys.path = old_sys_path

  return results

def CheckChange(input_api, output_api):
    results = []
    results.extend(_CheckBugInToDo(input_api, output_api))
    results.extend(_CheckNullabilityAnnotations(input_api, output_api))
    results.extend(_CheckHasNoIncludeDirectives(input_api, output_api))
    results.extend(_CheckHasNoPipeInComment(input_api, output_api))
    results.extend(_CheckHasNoBoxedBOOL(input_api, output_api))
    results.extend(_CheckNoTearDownEGTest(input_api, output_api))
    results.extend(_CheckCanImproveTestUsingExpectNSEQ(input_api, output_api))
    results.extend(_CheckOrderedStringFile(input_api, output_api))
    results.extend(_CheckNotUsingNSUserDefaults(input_api, output_api))
    results.extend(_CheckNewColorIntroduction(input_api, output_api))
    results.extend(_CheckStyleESLint(input_api, output_api))
    return results

def CheckChangeOnUpload(input_api, output_api):
  return CheckChange(input_api, output_api)

def CheckChangeOnCommit(input_api, output_api):
  return CheckChange(input_api, output_api)