#!/usr/bin/env python3
#
# ===-----------------------------------------------------------------------===#
#
# Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
#
# ===-----------------------------------------------------------------------===#

import argparse
import glob
import io
import os
import re
import sys
from typing import List


def replaceInFileRegex(fileName: str, sFrom: str, sTo: str) -> None:
    if sFrom == sTo:
        return

    # The documentation files are encoded using UTF-8, however on Windows the
    # default encoding might be different (e.g. CP-1252). To make sure UTF-8 is
    # always used, use `io.open(filename, mode, encoding='utf8')` for reading and
    # writing files here and elsewhere.
    txt = None
    with io.open(fileName, "r", encoding="utf8") as f:
        txt = f.read()

    txt = re.sub(sFrom, sTo, txt)
    print("Replacing '%s' -> '%s' in '%s'..." % (sFrom, sTo, fileName))
    with io.open(fileName, "w", encoding="utf8") as f:
        f.write(txt)


def replaceInFile(fileName: str, sFrom: str, sTo: str) -> None:
    if sFrom == sTo:
        return
    txt = None
    with io.open(fileName, "r", encoding="utf8") as f:
        txt = f.read()

    if sFrom not in txt:
        return

    txt = txt.replace(sFrom, sTo)
    print("Replacing '%s' -> '%s' in '%s'..." % (sFrom, sTo, fileName))
    with io.open(fileName, "w", encoding="utf8") as f:
        f.write(txt)


def fileRename(fileName: str, sFrom: str, sTo: str) -> str:
    if sFrom not in fileName or sFrom == sTo:
        return fileName
    newFileName = fileName.replace(sFrom, sTo)
    print("Renaming '%s' -> '%s'..." % (fileName, newFileName))
    os.rename(fileName, newFileName)
    return newFileName


def deleteMatchingLines(fileName: str, pattern: str) -> bool:
    lines = None
    with io.open(fileName, "r", encoding="utf8") as f:
        lines = f.readlines()

    not_matching_lines = [l for l in lines if not re.search(pattern, l)]
    if len(not_matching_lines) == len(lines):
        return False

    print("Removing lines matching '%s' in '%s'..." % (pattern, fileName))
    print("  " + "  ".join([l for l in lines if re.search(pattern, l)]))
    with io.open(fileName, "w", encoding="utf8") as f:
        f.writelines(not_matching_lines)

    return True


def getListOfFiles(clang_tidy_path: str) -> List[str]:
    files = glob.glob(os.path.join(clang_tidy_path, "**"), recursive=True)
    files += [
        os.path.normpath(os.path.join(clang_tidy_path, "../docs/ReleaseNotes.rst"))
    ]
    files += glob.glob(
        os.path.join(clang_tidy_path, "..", "test", "clang-tidy", "checkers", "**"),
        recursive=True,
    )
    files += glob.glob(
        os.path.join(clang_tidy_path, "..", "docs", "clang-tidy", "checks", "*.rst")
    )
    files += glob.glob(
        os.path.join(
            clang_tidy_path, "..", "docs", "clang-tidy", "checks", "*", "*.rst"
        ),
        recursive=True,
    )
    return [filename for filename in files if os.path.isfile(filename)]


# Adapts the module's CMakelist file. Returns 'True' if it could add a new
# entry and 'False' if the entry already existed.
def adapt_cmake(module_path: str, check_name_camel: str) -> bool:
    filename = os.path.join(module_path, "CMakeLists.txt")
    with io.open(filename, "r", encoding="utf8") as f:
        lines = f.readlines()

    cpp_file = check_name_camel + ".cpp"

    # Figure out whether this check already exists.
    for line in lines:
        if line.strip() == cpp_file:
            return False

    print("Updating %s..." % filename)
    with io.open(filename, "w", encoding="utf8") as f:
        cpp_found = False
        file_added = False
        for line in lines:
            cpp_line = line.strip().endswith(".cpp")
            if (not file_added) and (cpp_line or cpp_found):
                cpp_found = True
                if (line.strip() > cpp_file) or (not cpp_line):
                    f.write("  " + cpp_file + "\n")
                    file_added = True
            f.write(line)

    return True


# Modifies the module to include the new check.
def adapt_module(
    module_path: str, module: str, check_name: str, check_name_camel: str
) -> None:
    modulecpp = next(
        iter(
            filter(
                lambda p: p.lower() == module.lower() + "tidymodule.cpp",
                os.listdir(module_path),
            )
        )
    )
    filename = os.path.join(module_path, modulecpp)
    with io.open(filename, "r", encoding="utf8") as f:
        lines = f.readlines()

    print("Updating %s..." % filename)
    with io.open(filename, "w", encoding="utf8") as f:
        header_added = False
        header_found = False
        check_added = False
        check_decl = (
            "    CheckFactories.registerCheck<"
            + check_name_camel
            + '>(\n        "'
            + check_name
            + '");\n'
        )

        for line in lines:
            if not header_added:
                match = re.search('#include "(.*)"', line)
                if match:
                    header_found = True
                    if match.group(1) > check_name_camel:
                        header_added = True
                        f.write('#include "' + check_name_camel + '.h"\n')
                elif header_found:
                    header_added = True
                    f.write('#include "' + check_name_camel + '.h"\n')

            if not check_added:
                if line.strip() == "}":
                    check_added = True
                    f.write(check_decl)
                else:
                    match = re.search("registerCheck<(.*)>", line)
                    if match and match.group(1) > check_name_camel:
                        check_added = True
                        f.write(check_decl)
            f.write(line)


# Adds a release notes entry.
def add_release_notes(
    clang_tidy_path: str, old_check_name: str, new_check_name: str
) -> None:
    filename = os.path.normpath(
        os.path.join(clang_tidy_path, "../docs/ReleaseNotes.rst")
    )
    with io.open(filename, "r", encoding="utf8") as f:
        lines = f.readlines()

    lineMatcher = re.compile("Renamed checks")
    nextSectionMatcher = re.compile("Improvements to include-fixer")
    checkMatcher = re.compile("- The '(.*)")

    print("Updating %s..." % filename)
    with io.open(filename, "w", encoding="utf8") as f:
        note_added = False
        header_found = False
        add_note_here = False

        for line in lines:
            if not note_added:
                match = lineMatcher.match(line)
                match_next = nextSectionMatcher.match(line)
                match_check = checkMatcher.match(line)
                if match_check:
                    last_check = match_check.group(1)
                    if last_check > old_check_name:
                        add_note_here = True

                if match_next:
                    add_note_here = True

                if match:
                    header_found = True
                    f.write(line)
                    continue

                if line.startswith("^^^^"):
                    f.write(line)
                    continue

                if header_found and add_note_here:
                    if not line.startswith("^^^^"):
                        f.write(
                            """- The '%s' check was renamed to :doc:`%s
  <clang-tidy/checks/%s/%s>`

                    """
                            % (
                                old_check_name,
                                new_check_name,
                                new_check_name.split("-", 1)[0],
                                "-".join(new_check_name.split("-")[1:]),
                            )
                        )
                        note_added = True

            f.write(line)


def main() -> None:
    parser = argparse.ArgumentParser(description="Rename clang-tidy check.")
    parser.add_argument("old_check_name", type=str, help="Old check name.")
    parser.add_argument("new_check_name", type=str, help="New check name.")
    parser.add_argument(
        "--check_class_name",
        type=str,
        help="Old name of the class implementing the check.",
    )
    args = parser.parse_args()

    old_module = args.old_check_name.split("-")[0]
    new_module = args.new_check_name.split("-")[0]
    old_name = "-".join(args.old_check_name.split("-")[1:])
    new_name = "-".join(args.new_check_name.split("-")[1:])

    if args.check_class_name:
        check_name_camel = args.check_class_name
    else:
        check_name_camel = (
            "".join(map(lambda elem: elem.capitalize(), old_name.split("-"))) + "Check"
        )

    new_check_name_camel = (
        "".join(map(lambda elem: elem.capitalize(), new_name.split("-"))) + "Check"
    )

    clang_tidy_path = os.path.dirname(__file__)

    header_guard_variants = [
        (args.old_check_name.replace("-", "_")).upper() + "_CHECK",
        (old_module + "_" + check_name_camel).upper(),
        (old_module + "_" + new_check_name_camel).upper(),
        args.old_check_name.replace("-", "_").upper(),
    ]
    header_guard_new = (new_module + "_" + new_check_name_camel).upper()

    old_module_path = os.path.join(clang_tidy_path, old_module)
    new_module_path = os.path.join(clang_tidy_path, new_module)

    if old_module != new_module:
        # Remove the check from the old module.
        cmake_lists = os.path.join(old_module_path, "CMakeLists.txt")
        check_found = deleteMatchingLines(cmake_lists, "\\b" + check_name_camel)
        if not check_found:
            print(
                "Check name '%s' not found in %s. Exiting."
                % (check_name_camel, cmake_lists)
            )
            sys.exit(1)

        modulecpp = next(
            iter(
                filter(
                    lambda p: p.lower() == old_module.lower() + "tidymodule.cpp",
                    os.listdir(old_module_path),
                )
            )
        )
        deleteMatchingLines(
            os.path.join(old_module_path, modulecpp),
            "\\b" + check_name_camel + "|\\b" + args.old_check_name,
        )

    for filename in getListOfFiles(clang_tidy_path):
        originalName = filename
        filename = fileRename(
            filename, old_module + "/" + old_name, new_module + "/" + new_name
        )
        filename = fileRename(filename, args.old_check_name, args.new_check_name)
        filename = fileRename(filename, check_name_camel, new_check_name_camel)
        for header_guard in header_guard_variants:
            replaceInFile(filename, header_guard, header_guard_new)

        if new_module + "/" + new_name + ".rst" in filename:
            replaceInFile(
                filename,
                args.old_check_name + "\n" + "=" * len(args.old_check_name) + "\n",
                args.new_check_name + "\n" + "=" * len(args.new_check_name) + "\n",
            )

        replaceInFile(filename, args.old_check_name, args.new_check_name)
        replaceInFile(
            filename,
            old_module + "::" + check_name_camel,
            new_module + "::" + new_check_name_camel,
        )
        replaceInFile(
            filename,
            old_module + "/" + check_name_camel,
            new_module + "/" + new_check_name_camel,
        )
        replaceInFile(
            filename, old_module + "/" + old_name, new_module + "/" + new_name
        )
        replaceInFile(filename, check_name_camel, new_check_name_camel)

    if old_module != new_module or new_module == "llvm":
        if new_module == "llvm":
            new_namespace = new_module + "_check"
        else:
            new_namespace = new_module
        check_implementation_files = glob.glob(
            os.path.join(old_module_path, new_check_name_camel + "*")
        )
        for filename in check_implementation_files:
            # Move check implementation to the directory of the new module.
            filename = fileRename(filename, old_module_path, new_module_path)
            replaceInFileRegex(
                filename,
                "namespace clang::tidy::" + old_module + "[^ \n]*",
                "namespace clang::tidy::" + new_namespace,
            )

    if old_module != new_module:

        # Add check to the new module.
        adapt_cmake(new_module_path, new_check_name_camel)
        adapt_module(
            new_module_path, new_module, args.new_check_name, new_check_name_camel
        )

    os.system(os.path.join(clang_tidy_path, "add_new_check.py") + " --update-docs")
    add_release_notes(clang_tidy_path, args.old_check_name, args.new_check_name)


if __name__ == "__main__":
    main()
