__author__ = "7987847, Werner"
__email__ = "s5260822@stud.uni-frankfurt.de"

import os
import re
import sys

DEBUG_PRINTS = False

def debug_print(*args, **kwargs) -> None:
    """
    Print debug messages if the DEBUG_PRINTS flag is set to True.

    :param args: The positional arguments to print.
    :param kwargs: The keyword arguments to print.
    """
    if DEBUG_PRINTS:
        print(*args, **kwargs)

def get_java_files(root_dir:str) -> list:
    """
    Get all Java files in the root_dir and its subdirectories.

    :param root_dir: The root directory to search for Java files.
    :return: A list of relative file paths to the Java files.
    """
    java_files = []
    for dirpath, dirnames, filenames in os.walk(root_dir):
        for filename in filenames:
            if filename.endswith('.java'):
                # Create relative file path from the root_dir
                relative_path = os.path.relpath(os.path.join(dirpath, filename), root_dir)
                java_files.append(relative_path)
    print(f"Found {len(java_files)} Java files in {root_dir} and its subdirectories.")
    return java_files


def get_package_slug(java_file:str) -> str:
    """
    Get the base package slug from a Java file.

    :param java_file: The relative file path to the Java file.
    :return: The base package slug.
    """
    # open the file and read the first line
    with open(java_file, "r") as file:
        line = file.readline()
        # extract the package name from the first line
        if line.startswith("package "):
            return line.split(" ")[1].replace(";", "").strip()
    return ""


def is_line_field_declaration(line:str) -> bool:
    """
    Check if a line is a field declaration.

    :param line: The line to check.
    :return: True if the line is a field declaration, False otherwise.
    """
    if "public" not in line and "private" not in line and "protected" not in line:
        return
    field_pattern = re.compile(r'^\s*(public|private|protected|\s)*\s*(static|final|\s)*\s*(\w[\w\d,<>\s]*)\s+(\w[\w\d]*)\s*(=\s*[^;]*)?;')
    match = field_pattern.match(line.strip())
    return match is not None


def get_field_visibility(line):
    """
    Checks if the line is a field declaration and returns its visibility (+, -, or #).
    """
    field_pattern = re.compile(r'^\s*(public|private|protected|\s)*\s*(static|final|\s)*\s*(\w[\w\d,<>\s]*)\s+(\w[\w\d]*)\s*(=\s*[^;]*)?;')
    match = field_pattern.match(line.strip())
    if match:
        # Check for the access modifier in the line and return the corresponding symbol
        split_line = line.split(" ")

        debug_print("FOUND FIELD WITH ACCESS MODIFIER:", split_line[0])

        if "public" in split_line:
            return "+"
        elif "private" in split_line:
            return "-"
        elif "protected" in split_line:
            return "#"
        else:
            # If there's no access modifier, assume package-private (default) -> return '-'
            return "-"
    return None  # Return None if it's not a field declaration


def is_line_method_declaration(line:str, class_type:str) -> bool:
    """
    Check if a line is a method declaration.

    :param line: The line to check.
    :return: True if the line is a method declaration, False otherwise.
    """
    # Sometimes these slip in as method declarations
    if "else if" in line:
        return False
    method_pattern = re.compile(r'^\s*(public|private|protected|\s)*\s*(static|final|\s)*\s*(\w[\w\d]*)\s+(\w[\w\d]*)\s*\(.*\)\s*(?:throws\s+\w[\w\d]*)?\s*{')

    if class_type == "interface":
        method_pattern = re.compile(r'^\s*(public|private|protected|\s)*(\w[\w\d,<>\s]*)\s+\w\w*\((\w[\w\d,<>\s]*)\s*\w*\)\s*(?:throws\s+\w[\w\d]*)?\s*;$')

    match = method_pattern.match(line.strip())
    return match is not None


def get_method_visibility(line:str) -> str:
    """
    Checks if the line is a method declaration and returns its visibility (+, -, or #).
    """
    access_modifier = line.strip().split(" ")[0]

    debug_print("FOUND METHOD WITH ACCESS MODIFIER:", access_modifier)

    if "public" in access_modifier:
        return "+"
    elif "private" in access_modifier:
        return "-"
    elif "protected" in access_modifier:
        return "#"
    else:
        # If there's no access modifier, assume package-private (default) -> return '-'
        return "-"


class JavaClass:
    def __init__(self,
                 class_name,
                 class_package,
                 class_type,
                 puml_content,
                 uml_relations,
                 class_fields,
                 class_methods):
        self.class_name = class_name
        self.class_package = class_package
        self.class_type = class_type
        self.puml_content = puml_content
        self.uml_relations = uml_relations
        self.class_fields = class_fields
        self.class_methods = class_methods

    def set_puml_content(self, puml_content):
        self.puml_content = puml_content

    def get_class_name(self):
        return self.class_name

    def get_class_package(self):
        return self.class_package

    def get_class_type(self):
        return self.class_type

    def get_puml_content(self):
        return self.puml_content

    def get_uml_relations(self):
        return self.uml_relations

    def get_class_fields(self):
        return self.class_fields

    def get_class_methods(self):
        return self.class_methods


def gen_puml_code_from_class(java_class:JavaClass, no_pkgs:bool=False) -> str:
    """
    Generate PlantUML code from a JavaClass object.

    :param java_class: The JavaClass object.
    :param no_pkgs: Do not generate package visualisation in the PlantUML diagram.
    :return: The PlantUML code as a string.
    """
    puml_code = ""
    class_name = java_class.get_class_name()
    class_package = java_class.get_class_package()
    class_type = java_class.get_class_type()
    class_fields = java_class.get_class_fields()
    class_methods = java_class.get_class_methods()
    uml_relations = java_class.get_uml_relations()

    if not no_pkgs:
        puml_code += f"package {class_package + " {"}\n"

    puml_code += f"\t{class_type} \"{class_name + "\" as " + class_package + "." + class_name + " {"}\n"

    for field, visibility in class_fields.items():
        puml_code += f"\t  {visibility} {field}\n"

    for method, visibility in class_methods.items(): # TODO: no visibility if None
        if visibility:
            puml_code += f"\t  {visibility} {method}()\n"
        else:
            puml_code += f"\t  {method}()\n"

    puml_code += "\t}\n"

    if not no_pkgs:
        puml_code += "}\n"

    for related_class, relation in uml_relations.items():
        if not related_class == "*":
            cardinalities = ("1", "1")
            puml_code += f"{class_package + "." + class_name} {"\"" + cardinalities[1] + "\""} {relation}-- {"\"" + cardinalities[0] + "\""} {related_class}\n"


    puml_code += "\n"

    java_class.set_puml_content(puml_code)

    return java_class


def get_field_name_from_line(line:str) -> str:
    """
    Get the field name from a field declaration line.

    :param line: The field declaration line.
    :return: The field name.
    """
    return line.strip().split("=")[0].strip().split(" ")[-1].replace(";", "")


def get_method_name_from_line(line:str) -> str:
    """
    Get the method name from a method declaration line.

    :param line: The method declaration line.
    :return: The method name.
    """
    return line.strip().split("(")[0].split(" ")[-1]


def class_to_puml(filename:str, base_package_slug:str) -> JavaClass:
    imported_classes = {}
    puml_content = []
    uml_relations = {}
    class_fields = {}
    class_methods = {}
    class_name = filename.split("/")[-1].replace(".java", "")
    class_package = get_package_slug(filename)

    class_type = "class"

    reached_class = False
    class_getter = False
    class_setter = False

    next_line_getter = False
    next_line_setter = False

    # open file
    with open(filename, "r") as javafile:
        for javaline in javafile:
            javaline = javaline.strip()

            debug_print("LINE:", javaline)

            if next_line_getter:
                if is_line_field_declaration(javaline):
                    field_name = get_field_name_from_line(javaline)
                    class_methods[f"get{field_name.capitalize()}"] = get_field_visibility(javaline)
                    next_line_getter = False
                elif is_line_method_declaration(javaline):
                    method_name = javaline.split(" ")[-1].replace("()", "")
                    class_methods[method_name] = get_method_visibility(javaline)
                next_line_getter = False
            if next_line_setter:
                if is_line_field_declaration(javaline):
                    field_name = get_field_name_from_line(javaline)
                    class_methods[f"set{field_name.capitalize()}"] = get_field_visibility(javaline)
                    next_line_setter = False
                elif is_line_method_declaration(javaline):
                    method_name = get_method_name_from_line(javaline)
                    class_methods[method_name] = get_method_visibility(javaline)
                next_line_setter = False
            elif javaline.startswith("import"):
                if "*" not in javaline and base_package_slug in javaline:
                    importline_package_slug = javaline.strip().split(" ")[1].replace(";", "")
                    imported_classes[importline_package_slug] = importline_package_slug

            if javaline.startswith("@"):
                if "@Getter" in javaline and "@Setter" in javaline:
                    if not reached_class:
                        class_getter = True
                        class_setter = True
                    else:
                        next_line_getter = True
                        next_line_setter = True
                elif "@Getter" in javaline:
                    if not reached_class:
                        class_getter = True
                    else:
                        next_line_getter = True
                elif "@Setter" in javaline:
                    if not reached_class:
                        class_setter = True
                    else:
                        next_line_setter = True
            elif " enum " in javaline:
                class_type = "enum"
            elif " interface " in javaline:
                class_type = "interface"
                reached_class = True
            elif " class " in javaline:
                reached_class = True
            elif is_line_field_declaration(javaline):
                visibility = get_field_visibility(javaline)
                field_name = get_field_name_from_line(javaline)
                class_fields[field_name] = visibility

                if class_getter:
                    class_methods[f"get{field_name.capitalize()}"] = visibility
                if class_setter:
                    class_methods[f"set{field_name.capitalize()}"] = visibility

            elif is_line_method_declaration(javaline, class_type=class_type):
                visibility = get_method_visibility(javaline)
                method_name = get_method_name_from_line(javaline)
                if method_name != class_name:
                    class_methods[method_name] = visibility

    for class_package_slug in imported_classes:
        if not class_package_slug in uml_relations:
            uml_relations[class_package_slug] = "<"

    java_class = JavaClass(
        class_name,
        class_package,
        class_type,
        puml_content,
        uml_relations,
        class_fields,
        class_methods)

    return gen_puml_code_from_class(java_class)


def generate_puml(root_dir:str=".", no_pkgs:bool=False) -> None:
    """
    Generate a PlantUML diagram from the Java files in the root_dir.

    :param root_dir: The root directory to search for Java files.
    :param no_pkgs: Do not generate package visualisation in the PlantUML diagram
    """
    java_files = get_java_files(root_dir)
    if len(java_files) == 0:
        print("Error: No Java Files Found in this or any subsequent directory!")

    base_package_slug = ""

    if any("Main.java" in s for s in java_files):
        base_package_slug = get_package_slug(
            next((s for s in java_files if "Main.java" in s), None))
    else:
        base_package_slug = get_package_slug(java_files[0])

    puml_code = ""

    for class_file in java_files:
        puml_code += class_to_puml(class_file, base_package_slug).get_puml_content()

    # write the PlantUML code to a file
    with open(f"{root_dir}/generated_class_diagram.puml", "w") as puml_file:
        puml_file.write(f"@startuml \n title {base_package_slug}\n")
        puml_file.write(puml_code)
        puml_file.write("@enduml\n")


    print(f"PlantUML written to {root_dir}/generated_class_diagram.puml")



def get_arguments(argv: list) -> tuple:
    """
    Get the root directory and optional flags from the command line arguments.

    :param argv: The command line arguments.
    :return: A tuple containing the root directory and the no_pkgs flag.
    """
    root_dir = "."
    if "--dir" in argv:
        root_dir = argv[argv.index("--dir") + 1]
    no_pkgs = "--no-pkgs" in argv

    global DEBUG_PRINTS
    DEBUG_PRINTS = "--debug" in argv
    return root_dir, no_pkgs


def print_help() -> None:
    """
    Print a help message.
    """
    print("Usage: python puml_generator.py")
    print("Generates a PlantUML diagram from the Java files in the root_dir.")
    print("Optional flags:")
    print("--dir <root_dir> \t\t Specify the root directory for the Java files.")
    print("--help \t\t\t\t Print this help message.")
    print("--no-pkgs \t\t\t Do not generate package visualisation in the PlantUML diagram.")

if __name__ == "__main__":
    if "-h" in sys.argv or "--help" in sys.argv:
        print_help()
    else:
        root_dir, no_pkgs = get_arguments(sys.argv)
        generate_puml(root_dir, no_pkgs)
