#!/usr/bin/env python3

# SPDX-FileCopyrightText: 2023 Mm2PL <mm2pl+gh@kotmisia.pl>
# SPDX-FileContributor: Nerixyz <nerixdev@outlook.de>
#
# SPDX-License-Identifier: MIT

"""
This script generates docs/plugin-meta.lua. It accepts no arguments

It assumes comments look like:
/**
 * Thing
 *
 * @lua@param thing boolean
 * @lua@returns boolean
 * @exposed name
 */
- Do not have any useful info on '/**' and '*/' lines.
- Class members are not allowed to have non-@command lines and commands different from @lua@field

Only entire comment blocks are used. One comment block can describe at most one
entity (function/class/enum). Blocks without commands are ignored.

Valid commands are:
1. @exposeenum [dotted.name.in_lua.last_part]
    Define a table with keys of the enum. Values behind those keys aren't
    written on purpose.
2. @exposed [c2.name]
    Generates a function definition line from the last `@lua@param`s.
3. @lua[@command]
    Writes [@command] to the file as a comment, usually this is @class, @param, @return, ...
    @lua@class and @lua@field have special treatment when it comes to generation of spacing new lines

Non-command lines of comments are written with a space after '---'

To insert larger fragments of documentation, comments like
/* @lua-fragment
---@class foo
...
*/
will be inserted as-is. These only work in the top-level scope. They don't work
for class bodies.
"""

from io import TextIOWrapper
from pathlib import Path
import re
from typing import Optional

BOILERPLATE = """
---@meta _

-- This file is automatically generated from src/controllers/plugins/LuaAPI.hpp by the scripts/make_luals_meta.py script
-- This file is intended to be used with LuaLS (https://luals.github.io/).
-- Add the folder this file is in to "Lua.workspace.library".

c2 = {}
"""

repo_root = Path(__file__).parent.parent
lua_api_file = repo_root / "src" / "controllers" / "plugins" / "LuaAPI.hpp"
lua_meta = repo_root / "docs" / "lua-meta" / "globals.lua"

print("Writing to", lua_meta.relative_to(repo_root))


def strip_line(line: str):
    return re.sub(r"^/\*\*|^\*|\*/$", "", line).strip()


def strip_comments(line: str):
    return re.sub(r"//.*|/\*.*?\*/", "", line).strip()


def is_comment_start(line: str):
    return line.startswith("/**")


def is_fragment_start(line: str):
    return line.startswith("/* @lua-fragment")


def is_enum_class(line: str):
    return line.startswith("enum class")


def is_class(line: str):
    return line.startswith(("class", "struct"))


class Reader:
    lines: list[str]
    line_idx: int

    def __init__(self, lines: list[str]) -> None:
        self.lines = lines
        self.line_idx = 0

    def line_no(self) -> int:
        """Returns the current line number (starting from 1)"""
        return self.line_idx + 1

    def has_next(self) -> bool:
        """Returns true if there are lines left to read"""
        return self.line_idx < len(self.lines)

    def peek_line(self) -> Optional[str]:
        """Reads the line the cursor is at"""
        if self.has_next():
            return self.lines[self.line_idx].strip()
        return None

    def next_line(self) -> Optional[str]:
        """Consumes and returns one line"""
        if self.has_next():
            self.line_idx += 1
            return self.lines[self.line_idx - 1].strip()
        return None

    def next_item(self) -> list[str] | str | None:
        """Finds the next documentation comment or fragment and advances the cursor"""
        # find the start
        while (line := self.next_line()) is not None:
            if is_comment_start(line):
                return self._read_doc_comment(line)
            elif is_fragment_start(line):
                return self._read_fragment()

        return None

    def _read_doc_comment(self, starting_line: str) -> Optional[list[str]]:
        """Reads a documentation comment (/** ... */) and advances the cursor"""
        lines = []
        line = starting_line
        stripped = strip_line(line)
        if stripped:
            lines.append(stripped)

        if line.endswith("*/"):
            return lines if lines else None

        while (line := self.next_line()) is not None:
            if line.startswith("*/"):
                break

            stripped = strip_line(line)
            if not stripped:
                continue

            if stripped.startswith("@"):
                lines.append(stripped)
                continue

            if not lines:
                lines.append(stripped)
            else:
                lines[-1] += "\n--- " + stripped

        return lines if lines else None

    def _read_fragment(self) -> Optional[str]:
        """Reads an inline fragment comment (/* @lua-fragment ... */) and advances the cursor"""

        body = ""
        while (line := self.next_line()) is not None:
            if line.endswith("*/"):
                break
            body += "\n" + line

        return body

    def read_class_body(self) -> list[list[str]]:
        """The reader must be at the first line of the class/struct body. All comments inside the class are returned."""
        items = []
        nesting = -1  # for the opening brace
        while (line := self.peek_line()) is not None:
            if line.startswith("};") and nesting == 0:
                self.next_line()
                break
            if not is_comment_start(line):
                nesting += line.count("{") - line.count("}")
                self.next_line()
                continue
            doc = self.next_item()
            assert not isinstance(doc, str), "Fragment inside class body found"
            if not doc:
                break
            items.append(doc)
        return items

    def read_enum_variants(self) -> list[str]:
        """The reader must be before an enum class definition (possibly with some comments before). It returns all variants."""
        items = []
        is_comment = False
        waiting_for_end = False
        while (line := self.peek_line()) is not None and not line.startswith("};"):
            self.next_line()
            if is_comment:
                if line.endswith("*/"):
                    is_comment = False
                continue
            if line.startswith("/*"):
                is_comment = True
                continue
            if line.startswith("//"):
                continue
            if line.endswith("};"):  # oneline declaration
                opener = line.find("{") + 1
                closer = line.find("}")
                items = [
                    line.split("=", 1)[0].strip()
                    for line in line[opener:closer].split(",")
                ]
                break
            if line.startswith("enum "):
                continue

            if waiting_for_end:
                if line.endswith(","):
                    waiting_for_end = False
                continue
            m = re.match(r"^([\w_]+)", line)
            if not m:
                continue

            items.append(m.group(1))
            waiting_for_end = not strip_comments(line).endswith(",")

        return items


def finish_class(out, name):
    out.write(f"{name} = {{}}\n")


def printmsg(path: Path, line: int, message: str):
    print(f"{path.relative_to(repo_root)}:{line} {message}")


def panic(path: Path, line: int, message: str):
    printmsg(path, line, message)
    exit(1)


def write_func(path: Path, line: int, comments: list[str], out: TextIOWrapper):
    if not comments[0].startswith("@"):
        out.write(f"--- {comments[0]}\n---\n")
        comments = comments[1:]
    params: list[str] = []
    for comment in comments[:-1]:
        if not comment.startswith("@lua"):
            panic(path, line, f"Invalid function specification - got '{comment}'")
        if comment.startswith("@lua@param"):
            params.append(comment.split(" ", 2)[1])

        out.write(f"---{comment.removeprefix('@lua')}\n")

    if not comments[-1].startswith("@exposed "):
        panic(path, line, f"Invalid function exposure - got '{comments[-1]}'")
    name = comments[-1].split(" ", 1)[1]
    printmsg(path, line, f"function {name}")
    lua_params = ", ".join(p.removesuffix("?") for p in params)
    out.write(f"function {name}({lua_params}) end\n\n")


def write_enum(
    name: str,
    description: str | None,
    variants: list[str],
    is_flags: bool,
    out: TextIOWrapper,
):
    if description:
        out.write(f"--- {description}\n")
    out.write(f"---@enum {name}\n")
    out.write(f"{name} = {{\n")

    def value(variant):
        return "0," if is_flags else f"{{}}, ---@type {name}.{variant}"

    out.write("\n".join([f"    {variant} = {value(variant)}" for variant in variants]))
    out.write("\n}\n\n")


def read_file(path: Path, out: TextIOWrapper):
    print("Reading", path.relative_to(repo_root))
    with path.open("r") as f:
        lines = f.read().splitlines()

    reader = Reader(lines)
    while reader.has_next():
        doc_comment = reader.next_item()
        if not doc_comment:
            break
        elif isinstance(doc_comment, str):
            out.write(doc_comment)
            out.write("\n")
            continue

        header_comment = None
        if not doc_comment[0].startswith("@"):
            if len(doc_comment) == 1:
                continue
            header_comment = doc_comment[0]
            header = doc_comment[1:]
        else:
            header = doc_comment

        # enum
        if header[0].startswith("@exposeenum "):
            if len(header) > 1:
                panic(
                    path,
                    reader.line_no(),
                    f"Invalid enum exposure - one command expected, got {len(header)}",
                )
            args = header[0].split(" ")[1:]
            if len(args) < 1 or len(args) > 2:
                panic(
                    path,
                    reader.line_no(),
                    f"Invalid @exposeenum - expected 2 arguments, got {len(args)}",
                )
            name = args[0]
            is_flags = len(args) >= 2 and args[1] == "[flags]"
            printmsg(path, reader.line_no(), f"enum {name}")
            write_enum(
                name,
                header_comment,
                reader.read_enum_variants(),
                is_flags,
                out,
            )
            continue

        # class
        elif header[0].startswith("@lua@class "):
            name = header[0].split(" ", 1)[1]
            classname = name.split(":")[0].strip()
            printmsg(path, reader.line_no(), f"class {classname}")

            if header_comment:
                out.write(f"--- {header_comment}\n")
            out.write(f"---@class {name}\n")
            # inline class
            if len(header) > 1:
                for field in header[1:]:
                    if not field.startswith("@lua@field "):
                        panic(
                            path,
                            reader.line_no(),
                            f"Invalid inline class exposure - all lines must be fields, got '{field}'",
                        )
                    out.write(f"---{field.removeprefix('@lua')}\n")
                out.write("\n")
                continue

            # class definition
            # save functions for later (print fields first)
            funcs = []
            for comment in reader.read_class_body():
                if comment[-1].startswith("@exposed "):
                    funcs.append(comment)
                    continue
                if len(comment) > 1 or not comment[0].startswith("@lua"):
                    continue
                out.write(f"---{comment[0].removeprefix('@lua')}\n")

            if funcs:
                # only define global if there are functions on the class
                out.write(f"{classname} = {{}}\n\n")
            else:
                out.write("\n")

            for func in funcs:
                write_func(path, reader.line_no(), func, out)
            continue
        # global function
        elif header[-1].startswith("@exposed "):
            write_func(path, reader.line_no(), doc_comment, out)
            continue
        else:
            for comment in header:
                inline_command(path, reader.line_no(), comment, out)


def inline_command(path: Path, line: int, comment: str, out: TextIOWrapper):
    if comment.startswith("@includefile "):
        filename = comment.split(" ", 1)[1]
        out.write(f"-- Begin src/{filename}\n\n")
        read_file(repo_root / "src" / filename, out)
        out.write(f"-- End src/{filename}\n\n")
    elif comment.startswith("@lua@class"):
        panic(
            path,
            line,
            "Unexpected @lua@class command. @lua@class must be placed at the start of the comment block!",
        )
    elif comment.startswith("@lua@"):
        out.write(f"---{comment.replace('@lua', '', 1)}\n")


if __name__ == "__main__":
    with lua_meta.open("w") as output:
        output.write(BOILERPLATE[1:])  # skip the newline after triple quote
        read_file(lua_api_file, output)
