#!/usr/bin/env python3
"""
Author: kymckay
Crawl function headers to produce appropriate documentation of public functions.

Supported header sections:
 - Author(s) (with description below)
 - Arguments
 - Return Value
 - Example(s)
 - Public (by default function will only be documented if set to "Yes")

EXAMPLES
    document_functions common --debug
        Crawl only functions in addons/common and only reports debug messages.
"""

import os
import sys
import re
import argparse


class FunctionFile:
    def __init__(self, directory="."):
        self.directory = directory

        # False unless specified in processing
        self.debug = False
        self.lint_private = False

        # Empty until imported from file
        self.path = ""
        self.header = ""

        # Defaults until header is processed
        self.public = False
        self.authors = []
        self.description = ""
        self.arguments = []
        self.return_value = []
        self.example = ""

        # Filepath should only be logged once
        self.logged = False

        # Count parse results
        self.errors = 0

    def import_header(self, file_path):
        self.path = file_path

        with open(file_path, "r", encoding="utf-8") as file:
            code = file.read()

        header_match = re.search(r"\s*/\*.+?\*/", code, re.S)
        if header_match:
            self.header = header_match.group(0)
        else:
            self.feedback("Missing header", 3)

    def has_header(self):
        return bool(self.header)

    def process_header(self, debug=False, lint_private=False):
        # Detailed debugging occurs here so value is set
        self.debug = debug
        self.lint_private = lint_private

        for lineNumber, line in enumerate(self.header.splitlines()):
            if (not (line.startswith(" * ") or line in ["", " *", "/*", "*/", " */"])):
                self.feedback(f"header formating on line {lineNumber+1}: ({line})", 1)

        # Preemptively cut away the comment characters (and leading/trailing whitespace)
        self.header_text = "\n".join([x[3:].strip() for x in self.header.splitlines()])

        # Split the header into expected sections
        self.sections = re.split(r"^(Author|Argument|Return Value|Example|Public)s?:\s?", self.header_text, 0, re.M)

        # If public section is missing we can't continue
        public_raw = self.get_section("Public")
        if not public_raw:
            self.feedback("Public value undefined", 3)
            return self.errors

        # Determine whether the header is public
        self.public = self.process_public(public_raw)

        # Don't bother to process the rest if private
        # Unless in debug mode
        if not self.public and not self.lint_private:
            return self.errors

        # Retrieve the raw sections text for processing
        author_raw = self.get_section("Author")
        arguments_raw = self.get_section("Argument")
        return_value_raw = self.get_section("Return Value")
        example_raw = self.get_section("Example")

        # Author and description are stored in first section
        if author_raw:
            self.authors = self.process_author(author_raw)
            self.description = self.process_description(author_raw)

        if arguments_raw:
            self.arguments = self.process_arguments(arguments_raw)

        # Process return
        if return_value_raw:
            self.return_value = self.process_return_value(return_value_raw)

        # Process example
        if example_raw:
            self.example = self.process_example(example_raw)

        return self.errors

    def get_section(self, section_name):
        try:
            section_text = self.sections[self.sections.index(section_name) + 1]
            return section_text
        except ValueError:
            self.feedback("Missing \"{}\" header section".format(section_name), 2)
            return ""

    def process_public(self, raw):
        # Raw just includes an EOL character
        public_text = raw[:-1]

        if not re.match(r"(Yes|No)", public_text, re.I):
            self.feedback("Invalid public value \"{}\"".format(public_text), 2)

        return public_text.capitalize() == "Yes"

    def is_public(self):
        return self.public

    def process_author(self, raw):
        # Authors are listed on the first line
        authors_text = raw.splitlines()[0]

        # Seperate authors are divided by commas
        return authors_text.split(", ")

    def process_description(self, raw):
        # Just use all the lines after the authors line
        description_text = "".join(raw.splitlines(1)[1:])

        return description_text

    def process_arguments(self, raw):
        lines = raw.splitlines()

        if lines[0] == "None":
            return []

        if lines.count("") == len(lines):
            self.feedback("No arguments provided (use \"None\" where appropriate)", 2)
            return []

        if lines[-1] == "":
            lines.pop()
        else:
            self.feedback("No blank line after arguments list", 1)

        arguments = []
        expectedMainIndex = 0
        expectedSubIndex = 0
        for argument in lines:
            valid = re.match(r"^(- ){0,1}(\d+):\s(.+?)\<([\s\w,\|]+?)\>( )?(\s\(default: (.+)\))?$", argument)

            if valid:
                arg_isSubIndex = valid.group(1) is not None
                arg_index = valid.group(2)
                arg_name = valid.group(3)
                arg_types = valid.group(4)
                arg_default = valid.group(7)
                arg_notes = []

                if arg_isSubIndex:
                    expectedIndex = expectedSubIndex
                    expectedSubIndex = expectedSubIndex + 1
                else:
                    expectedIndex = expectedMainIndex
                    expectedMainIndex = expectedMainIndex + 1
                    expectedSubIndex = 0

                if int(arg_index) != expectedIndex:
                    print(f"line|{argument}|")
                    self.feedback(f"Argument index {arg_index} does not match listed order {expectedIndex}", 1)

                if arg_default is None:
                    arg_default = ""

                if ("SCALAR" in arg_types or "NUMVER" in arg_types):
                    self.feedback("Bad Arg Type \"{}\"".format(arg_types), 1)

                arguments.append([arg_index, arg_name, arg_types, arg_default, arg_notes])
            else:
                # Notes about the above argument won't start with an index
                # Only applies if there exists an above argument
                if re.match(r"^(\d+):", argument) or not arguments:
                    self.feedback("Malformed argument \"{}\"".format(argument), 2)
                    arguments.append(["?", "Malformed", "?", "?", []])
                else:
                    arguments[-1][-1].append(argument)

        return arguments

    def process_return_value(self, raw):
        return_value = raw.strip()

        if return_value == "None":
            return []

        valid = re.match(r"^(.+?)\<([\s\w]+?)\>", return_value)

        if valid:
            return_name = valid.group(1)
            return_types = valid.group(2)
        else:
            self.feedback("Malformed return value \"{}\"".format(return_value), 2)
            return ["Malformed", ""]

        return [return_name, return_types]

    def process_example(self, raw):
        return_value = raw.strip()
        if return_value == "None":
            return return_value

        path_match = re.match(r".*addons.(.*).functions.(.*).sqf", self.path)
        expected_func = f"ace_{path_match.group(1)}_{path_match.group(2)}"
        if (not expected_func.lower() in return_value.lower()) and ((not return_value.startswith("Handled by")) and (not return_value.startswith("Called By"))):
            self.feedback(f"Malformed example {return_value} should contain func {expected_func}", 2)

        return return_value


    def document(self, component):
        str_list = []

        # Title
        str_list.append("\n## ace_{}_fnc_{}\n".format(component, os.path.basename(self.path)[4:-4]))
        # Description
        str_list.append("__Description__\n\n" + self.description)
        # Arguments
        if self.arguments:
            str_list.append("__Parameters__\n\nIndex | Description | Datatype(s) | Default Value\n--- | --- | --- | ---\n")
            for argument in self.arguments:
                str_list.append("{} | {} | {} | {}\n".format(*argument))
            str_list.append("\n")
        else:
            str_list.append("__Parameters__\n\nNone\n\n")
        # Return Value
        if self.return_value:
            str_list.append("__Return Value__\n\nDescription | Datatype(s)\n--- | ---\n{} | {}\n\n".format(*self.return_value))
        else:
            str_list.append("__Return Value__\n\nNone\n\n")
        # Example
        str_list.append("__Example__\n\n```sqf\n{}\n```\n\n".format(self.example))
        # Authors
        str_list.append("\n__Authors__\n\n")
        for author in self.authors:
            str_list.append("- {}\n".format(author))
        # Horizontal rule
        str_list.append("\n---\n")

        return ''.join(str_list)

    def log_file(self, error=False):
        # When in debug mode we only want to see the files with errors
        if not self.debug or error:
            if not self.logged:
                rel_path = os.path.relpath(self.path, self.directory)

                self.write("Processing... {}".format(rel_path), 1)
                self.logged = True

    def feedback(self, message, level=0):
        priority_str = ["Info", "Warning", "Error", "Aborted"][level]

        self.log_file(level > 0)
        self.write("{0}: {1}".format(priority_str, message))

        if priority_str in ["Error", "Aborted"]:
            self.errors += 1

    def write(self, message, indent=2):
        to_print = ["  "] * indent
        to_print.append(message)
        print("".join(to_print))


def get_component_name(addons_dir, component):
    errors = 0

    script_component = os.path.join(addons_dir, component, 'script_component.hpp')
    with open(script_component, "r", encoding="utf-8") as file:
        code = file.read()

    name_match = re.search(r"#define COMPONENT_BEAUTIFIED (.*)", code)
    if name_match:
        name = name_match.group(1)
    else:
        name = component.title()
        print("    Warning: Missing COMPONENT_BEAUTIFIED")
        errors += 1

    return name, errors


def document_functions(addons_dir, components):
    errors = 0

    wiki_dir = os.path.abspath(os.path.join(addons_dir, '../docs/wiki/functions/'))
    os.makedirs(wiki_dir, exist_ok=True)
    print("Wiki: {}".format(wiki_dir))

    for component in components:
        print("  Documenting... {}.md".format(component))
        component_name, error = get_component_name(addons_dir, component)
        errors += error

        output = os.path.join(wiki_dir, component) + ".md"
        with open(output, "w", encoding="utf-8") as file:
            file.writelines([
                "---\n",
                "layout: wiki\n",
                "title: {} Functions\n".format(component_name),
                "description: List of functions in {} component.\n".format(component_name),
                "group: functions\n",
                "parent: wiki\n",
                "---\n",
            ])

            for function in components[component]:
                file.write(function.document(component))

    return errors


def crawl_dir(addons_dir, directory, debug=False, lint_private=False):
    components = {}
    errors = 0

    for root, dirs, files in os.walk(directory):
        for file in files:
            if file.endswith(".sqf") and file.startswith("fnc_"):
                file_path = os.path.join(root, file)

                # Attempt to import the header from file
                function = FunctionFile(directory)
                function.import_header(file_path)

                # Undergo data extraction and detailed debug
                if function.has_header():
                    errors += function.process_header(debug, lint_private)

                    if function.is_public() and not debug:
                        # Add functions to component key (initalise key if necessary)
                        component = os.path.basename(os.path.dirname(root))
                        components.setdefault(component, []).append(function)

                        function.feedback("Publicly documented")
                else:
                    errors += 1

    if not debug:
        print()
        errors += document_functions(addons_dir, components)

    if errors != 0:
        print("\n  Unclean!\n    {} errors".format(errors))
    else:
        print("\n  Clean!")

    return errors


def main():
    print("""
    #########################
    # Documenting Functions #
    #########################
    """)

    parser = argparse.ArgumentParser()
    parser.add_argument('directory', nargs="?", type=str, default=".", help='only crawl specified module addon folder')
    parser.add_argument('--debug', action="store_true", help='only check for header debug messages')
    parser.add_argument('--lint-private', action="store_true", help='lint private function headers as well')
    args = parser.parse_args()

    # Allow calling from anywhere and work our way to addons from this file
    addons_dir = os.path.abspath(os.path.join(__file__, '../../../addons/'))
    prospective_dir = os.path.abspath(os.path.join(addons_dir, args.directory))

    if os.path.isdir(prospective_dir):
        print("Directory: {}".format(prospective_dir))
        errors = crawl_dir(addons_dir, prospective_dir, args.debug, args.lint_private)
        return 0 if errors == 0 else 1
    else:
        print("Invalid directory: {}".format(prospective_dir))
        return 1


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