#!/usr/bin/env python3
import argparse
import dataclasses
import itertools
import json
import re
import string
import subprocess
import typing

MARKDOWN_CONVERTER = "lowdown -m 'shiftheadinglevelby=3'"
CPP_FUNC_IDENT_ALLOWLIST = string.ascii_letters + string.digits + "_"
PROGRAM_NAME: str = ""

HTML_PREFIX = """<html>
    <head>
        <meta charset="utf-8">
        <title>Dokumentacja funkcji języka Musique</title>
        <style>
            html {
                color: #1a1a1a;
                background-color: #fdfdfd;
            }

            body {
                font-family: sans-serif;
                margin: 10px auto;
                overflow-wrap: break-word;
                text-rendering: optimizeLegibility;
                font-kerning: normal;
                max-width: 50em;
            }

            nav a, nav a:link, nav a:visited {
                color: #07a;
                text-decoration: none;
            }

            h2 > a, h2>a:visited, h2>a:link {
                color: gray;
                text-decoration: none;
                padding-right: 5px;
            }
            h2 > a:hover { color: lightgrey; }

            footer {
                width: 100%;
                border-top: 1px solid black;
            }
        </style>
    </head>
    <body>
    <h1>Dokumentacja funkcji języka Musique</h1>
"""

HTML_SUFFIX = """
    <footer>
        Wszystkie treści podlegają licencji <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a>
    </footer>
    </body>
</html>
"""

FIND_DOCUMENTATION_FOR_BUILTIN = """
std::optional<std::string_view> find_documentation_for_builtin(std::string_view builtin_name)
{
	for (auto [name, doc] : names_to_documentation) {
		if (builtin_name == name)
			return doc;
	}
	return std::nullopt;
}
"""

def warning(*args, prefix: str | None = None):
    if prefix is None:
        prefix = PROGRAM_NAME
    message = ": ".join(itertools.chain([prefix, "warning"], args))
    print(message)


def error(*args, prefix=None):
    if prefix is None:
        prefix = PROGRAM_NAME
    message = ": ".join(itertools.chain([prefix, "error"], args))
    print(message)
    exit(1)


@dataclasses.dataclass
class Builtin:
    implementation: str
    definition_location: tuple[str, int]  # Filename and line number
    names: list[str]
    documentation: str


def builtins_from_file(source_path: str) -> typing.Generator[Builtin, None, None]:
    with open(source_path) as f:
        source = f.readlines()

    builtins: dict[str, Builtin] = {}
    definition = re.compile(
        r"""force_define.*\("([^"]+)"\s*,\s*(builtin_[a-zA-Z0-9_]+)\)"""
    )

    current_documentation = []

    for lineno, line in enumerate(source):
        line = line.strip()

        # Check if line contains force_define with static string and builtin_*
        # thats beeing defined. It's a one of many names that given builtin
        # has in Musique
        if result := definition.search(line):
            musique_name = result.group(1)
            builtin_name = result.group(2)

            if builtin_name in builtins:
                builtins[builtin_name].names.append(musique_name)
            else:
                error(
                    f"tried adding Musique name '{musique_name}' to builtin '{builtin_name}' that has not been defined yet",
                    prefix=f"{source_path}:{lineno}",
                )
            continue

        # Check if line contains special documentation comment.
        # We assume that only documentation comments are in given line (modulo whitespace)
        if line.startswith("//:"):
            line = line.removeprefix("//:").strip()
            current_documentation.append(line)
            continue

        # Check if line contains builtin_* identifier.
        # If contains then this must be first definition of this function
        # and therefore all documentation comments before it describe it
        if (index := line.find("builtin_")) >= 0 and line[
            index - 1
        ] not in CPP_FUNC_IDENT_ALLOWLIST:
            identifier = line[index:]
            for i, char in enumerate(identifier):
                if char not in CPP_FUNC_IDENT_ALLOWLIST:
                    identifier = identifier[:i]
                    break

            if identifier not in builtins:
                builtin = Builtin(
                    implementation=identifier,
                    # TODO Allow redefinition of source path with some prefix
                    # to allow website links
                    definition_location=(source_path, lineno),
                    names=[],
                    documentation="\n".join(current_documentation),
                )
                builtins[identifier] = builtin
                current_documentation = []
            continue

    for builtin in builtins.values():
        builtin.names.sort()
        yield builtin


def filter_builtins(
    builtins: typing.Iterable[Builtin],
) -> typing.Generator[Builtin, None, None]:
    for builtin in builtins:
        if not builtin.documentation:
            warning(f"builtin '{builtin.implementation}' doesn't have documentation")
            continue

        # Testt if builtin is unused
        if not builtin.names:
            continue

        yield builtin


def each_musique_name_occurs_once(
    builtins: typing.Iterable[Builtin],
) -> typing.Generator[Builtin, None, None]:
    names: dict[str, str] = {}
    for builtin in builtins:
        for name in builtin.names:
            if name in names:
                error(
                    f"'{name}' has been registered as both '{builtin.implementation}' and '{names[name]}'"
                )
            names[name] = builtin.implementation
        yield builtin


def generate_html_document(builtins: typing.Iterable[Builtin], output_path: str):
    with open(output_path, "w") as out:
        out.write(HTML_PREFIX)

        out.write("<nav>")

        names = sorted(
            [
                (name, builtin.implementation)
                for builtin in builtins
                for name in builtin.names
            ]
        )

        out.write(", ".join(f"""<a href="#{id}">{name}</a>""" for name, id in names))

        out.write("</nav>")

        out.write("<main>")

        for builtin in builtins:
            out.write("<p>")
            out.write(
                f"""<h2 id="{builtin.implementation}"><a href="#{builtin.implementation}">&sect;</a>{', '.join(builtin.names)}</h2>"""
            )
            out.write(
                subprocess.check_output(
                    MARKDOWN_CONVERTER,
                    input=builtin.documentation,
                    encoding="utf-8",
                    shell=True,
                )
            )
            out.write("</p>")

        out.write("</main>")

        out.write(HTML_SUFFIX)


def generate_cpp_documentation(builtins: typing.Iterable[Builtin], output_path: str):
    # TODO Support markdown rendering and colors using `pretty` sublibrary

    def documentation_str_var(builtin: Builtin):
        return f"{builtin.implementation}_doc"

    with open(output_path, "w") as out:
        print("#include <array>", file=out)
        print("#include <musique/interpreter/builtin_function_documentation.hh>\n", file=out)

        # 1. Generate strings with documentation
        for builtin in builtins:
            # FIXME json.dumps will probably produce valid C++ strings for most cases,
            # but can we ensure that will output valid strings for all cases?
            print(
                "static constexpr std::string_view %s = %s;"
                % (documentation_str_var(builtin), json.dumps(builtin.documentation)),
                file=out,
            )
        print("", file=out)

        # 2. Generate array mapping from name to documentation variable
        names_to_documentation = list(sorted((name, documentation_str_var(builtin)) for builtin in builtins for name in builtin.names))
        print("static constexpr std::array<std::pair<std::string_view, std::string_view>, %d> names_to_documentation = {" % (len(names_to_documentation), ), file=out)
        for name, doc in names_to_documentation:
              print("  std::pair { std::string_view(%s), %s }," % (json.dumps(name), doc), file=out)
        print("};", file=out);

        # 3. Generate function that given builtin name results in documentation string
        print(FIND_DOCUMENTATION_FOR_BUILTIN, file=out)



def main(source_path: str, output_path: str, format: typing.Literal["html", "cpp"]):
    "Generates documentaiton from file source_path and saves in output_path"

    builtins = builtins_from_file(source_path)
    builtins = filter_builtins(builtins)
    builtins = each_musique_name_occurs_once(builtins)
    builtins = sorted(list(builtins), key=lambda builtin: builtin.names[0])

    if format == "md":
        generate_html_document(builtins, output_path)
    else:
        generate_cpp_documentation(builtins, output_path)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(
        description="Builtin functions documentation generator from C++ Musique implementation"
    )
    parser.add_argument(
        "source",
        type=str,
        nargs=1,
        help="C++ source file from which documentation will be generated",
    )
    parser.add_argument(
        "-o",
        "--output",
        type=str,
        nargs=1,
        required=True,
        help="path for standalone HTML file containing generated documentation",
    )
    parser.add_argument(
        "-f",
        "--format",
        type=str,
        default="md",
        help="output format. One of {html, cpp} are allowed, where HTML yields standalone docs, and C++ mode yields integrated docs",
    )

    PROGRAM_NAME = parser.prog
    args = parser.parse_args()
    assert len(args.source) == 1
    assert len(args.output) == 1
    assert args.format in (
        "html",
        "cpp",
    ), "Only C++ and HTML output formats are supported"

    main(source_path=args.source[0], output_path=args.output[0], format=args.format)