#!/usr/bin/env python3
"""Simple C++ Linter."""

import argparse
import bisect
import io
import logging
import re
import sys

_RE_LINT = re.compile("//.*NOLINT")
_RE_COMMENT_STRIP = re.compile("//.*")
_RE_GENERIC_FCV_COMMENT = re.compile(r"\(Generic FCV reference\):")
GENERIC_FCV = [
    r"::kLatest",
    r"::kLastContinuous",
    r"::kLastLTS",
    r"::kUpgradingFromLastLTSToLatest",
    r"::kUpgradingFromLastContinuousToLatest",
    r"::kDowngradingFromLatestToLastLTS",
    r"::kDowngradingFromLatestToLastContinuous",
    r"\.isUpgradingOrDowngrading",
    r"->isUpgradingOrDowngrading",
    r"::kDowngradingFromLatestToLastContinuous",
    r"::kUpgradingFromLastLTSToLastContinuous",
]
_RE_GENERIC_FCV_REF = re.compile(r"(" + "|".join(GENERIC_FCV) + r")\b")
_RE_FEATURE_FLAG_IGNORE_FCV_CHECK_REF = re.compile(r"isEnabledAndIgnoreFCVUnsafe\(\)")
_RE_FEATURE_FLAG_IGNORE_FCV_CHECK_COMMENT = re.compile(r"\(Ignore FCV check\)")
_RE_HEADER = re.compile(r"\.(h|hpp)$")


class Linter:
    """Simple C++ Linter."""

    _license_header = """\
/**
 *    Copyright (C) {year}-present MongoDB, Inc.
 *
 *    This program is free software: you can redistribute it and/or modify
 *    it under the terms of the Server Side Public License, version 1,
 *    as published by MongoDB, Inc.
 *
 *    This program is distributed in the hope that it will be useful,
 *    but WITHOUT ANY WARRANTY; without even the implied warranty of
 *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *    Server Side Public License for more details.
 *
 *    You should have received a copy of the Server Side Public License
 *    along with this program. If not, see
 *    <http://www.mongodb.com/licensing/server-side-public-license>.
 *
 *    As a special exception, the copyright holders give permission to link the
 *    code of portions of this program with the OpenSSL library under certain
 *    conditions as described in each individual source file and distribute
 *    linked combinations including the program with the OpenSSL library. You
 *    must comply with the Server Side Public License in all respects for
 *    all of the code used other than as permitted herein. If you modify file(s)
 *    with this exception, you may extend this exception to your version of the
 *    file(s), but you are not obligated to do so. If you do not wish to do so,
 *    delete this exception statement from your version. If you delete this
 *    exception statement from all source files in the program, then also delete
 *    it in the license file.
 */""".splitlines()

    def __init__(self, file_name, raw_lines):
        """Create new linter."""
        self.file_name = file_name
        self.raw_lines = raw_lines
        self.clean_lines = []
        self.nolint_suppression = []
        self.generic_fcv_comments = []
        self.feature_flag_ignore_fcv_check_comments = []
        self._error_count = 0

    def lint(self):
        """Run linter, returning error count."""
        # steps:
        #  - Check for header
        #  - Check for NOLINT and Strip multi line comments
        #  - Run file-level checks
        #  - Run per-line checks

        start_line = self._check_for_server_side_public_license()

        self._check_newlines()
        self._check_and_strip_comments()

        # Line-level checks
        for linenum in range(start_line, len(self.clean_lines)):
            if not self.clean_lines[linenum]:
                continue

            # Relax the rule of commenting generic FCV references for files directly related to FCV
            # implementations.
            if "feature_compatibility_version" not in self.file_name:
                self._check_for_generic_fcv(linenum)

            # Don't check feature_flag.h/cpp where the function is defined and test files.
            if "feature_flag" not in self.file_name and "test" not in self.file_name:
                self._check_for_feature_flag_ignore_fcv(linenum)

        return self._error_count

    def _check_newlines(self):
        """Check that each source file ends with a newline character."""
        if self.raw_lines and self.raw_lines[-1][-1:] != "\n":
            self._error(
                len(self.raw_lines),
                "mongo/final_newline",
                "Files must end with a newline character.",
            )

    def _check_and_strip_comments(self):
        in_multi_line_comment = False

        for linenum, clean_line in enumerate(self.raw_lines):
            # Users can write NOLINT different ways
            # // NOLINT
            # // Some explanation NOLINT
            # so we need a regular expression
            if _RE_LINT.search(clean_line):
                self.nolint_suppression.append(linenum)

            if _RE_GENERIC_FCV_COMMENT.search(clean_line):
                self.generic_fcv_comments.append(linenum)

            if _RE_FEATURE_FLAG_IGNORE_FCV_CHECK_COMMENT.search(clean_line):
                self.feature_flag_ignore_fcv_check_comments.append(linenum)

            if not in_multi_line_comment:
                if "/*" in clean_line and "*/" not in clean_line:
                    in_multi_line_comment = True
                    clean_line = ""

                # Trim comments - approximately
                # Note, this does not understand if // is in a string
                # i.e. it will think URLs are also comments but this should be good enough to find
                # violators of the coding convention
                if "//" in clean_line:
                    clean_line = _RE_COMMENT_STRIP.sub("", clean_line)
            else:
                if "*/" in clean_line:
                    in_multi_line_comment = False

                clean_line = ""

            self.clean_lines.append(clean_line)

    def _license_error(self, linenum, msg, category="legal/license"):
        style_url = "https://github.com/mongodb/mongo/wiki/Server-Code-Style"
        self._error(linenum, category, "{} See {}".format(msg, style_url))
        return (False, linenum)

    def _check_for_server_side_public_license(self):
        """Return the number of the line at which the check ended."""
        src_iter = (x.rstrip() for x in self.raw_lines)
        linenum = 0
        for linenum, lic_line in enumerate(self._license_header):
            src_line = next(src_iter, None)
            if src_line is None:
                self._license_error(linenum, "Missing or incomplete license header.")
                return linenum
            lic_re = re.escape(lic_line).replace(r"\{year\}", r"\d{4}")
            if not re.fullmatch(lic_re, src_line):
                self._license_error(
                    linenum,
                    "Incorrect license header.\n" '  Expected: "{}"\n' '  Received: "{}"\n'.format(
                        lic_line, src_line
                    ),
                )
                return linenum

        # Warn if SSPL appears in Enterprise code, which has a different license.
        expect_sspl_license = "enterprise" not in self.file_name
        if not expect_sspl_license:
            self._license_error(
                linenum,
                "Incorrect license header found. Expected Enterprise license.",
                category="legal/enterprise_license",
            )
            return linenum
        return linenum

    def _check_for_generic_fcv(self, linenum):
        line = self.clean_lines[linenum]
        if _RE_GENERIC_FCV_REF.search(line):
            # Find the first generic FCV comment preceding the current line.
            i = bisect.bisect_right(self.generic_fcv_comments, linenum)
            if not i or self.generic_fcv_comments[i - 1] < (linenum - 10):
                self._error(
                    linenum,
                    "mongodb/fcv",
                    'Please add a comment containing "(Generic FCV reference):" within 10 lines '
                    + "before the generic FCV reference.",
                )

    def _check_for_feature_flag_ignore_fcv(self, linenum):
        line = self.clean_lines[linenum]
        if _RE_FEATURE_FLAG_IGNORE_FCV_CHECK_REF.search(line):
            # Find the first ignore FCV check comment preceding the current line.
            i = bisect.bisect_right(self.feature_flag_ignore_fcv_check_comments, linenum)
            if not i or self.feature_flag_ignore_fcv_check_comments[i - 1] < (linenum - 10):
                self._error(
                    linenum,
                    "mongodb/fcv",
                    'Please add a comment containing "(Ignore FCV check)":" within 10 lines '
                    + "before the isEnabledAndIgnoreFCVUnsafe() function call explaining why "
                    + "the FCV check is ignored.",
                )

    def _error(self, linenum, category, message):
        if linenum in self.nolint_suppression:
            return

        norm_file_name = self.file_name.replace("\\", "/")

        # Custom clang-tidy check tests purposefully produce errors for
        # tests to find. They should be ignored.
        if "mongo_tidy_checks/tests/" in norm_file_name:
            return

        if category == "legal/license":
            # Enterprise module does not have the SSPL license
            if "enterprise" in self.file_name:
                return

            # The following files are in the src/mongo/ directory but technically belong
            # in src/third_party/ because their copyright does not belong to MongoDB.
            files_to_ignore = set(
                [
                    "src/mongo/scripting/mozjs/PosixNSPR.cpp",
                    "src/mongo/shell/linenoise.cpp",
                    "src/mongo/shell/linenoise.h",
                    "src/mongo/shell/mk_wcwidth.cpp",
                    "src/mongo/shell/mk_wcwidth.h",
                    "src/mongo/util/net/ssl_stream.cpp",
                    "src/mongo/util/scopeguard.h",
                ]
            )

            for file_to_ignore in files_to_ignore:
                if file_to_ignore in norm_file_name:
                    return

        # We count internally from 0 but users count from 1 for line numbers
        print("Error: %s:%d - %s - %s" % (self.file_name, linenum + 1, category, message))
        self._error_count += 1


def lint_file(file_name):
    """Lint file and print errors to console."""
    with io.open(file_name, encoding="utf-8") as file_stream:
        raw_lines = file_stream.readlines()

    linter = Linter(file_name, raw_lines)
    return linter.lint()


def main():
    # type: () -> int
    """Execute Main Entry point."""
    parser = argparse.ArgumentParser(description="MongoDB Simple C++ Linter.")

    parser.add_argument("file", type=str, help="C++ input file")

    parser.add_argument("-v", "--verbose", action="count", help="Enable verbose tracing")

    args = parser.parse_args()

    if args.verbose:
        logging.basicConfig(level=logging.DEBUG)

    try:
        error_count = lint_file(args.file)
        if error_count != 0:
            print('File "{}" failed with {} errors.'.format(args.file, error_count))
            return 1
        return 0
    except Exception as ex:  # pylint: disable=broad-except
        print('Exception while checking file "{}": {}'.format(args.file, ex))
        return 2


if __name__ == "__main__":
    sys.exit(main())
