#!/usr/bin/env python3

#  Simple DirectMedia Layer
#  Copyright (C) 1997-2025 Sam Lantinga <slouken@libsdl.org>
#
#  This software is provided 'as-is', without any express or implied
#  warranty.  In no event will the authors be held liable for any damages
#  arising from the use of this software.
#
#  Permission is granted to anyone to use this software for any purpose,
#  including commercial applications, and to alter it and redistribute it
#  freely, subject to the following restrictions:
#
#  1. The origin of this software must not be misrepresented; you must not
#     claim that you wrote the original software. If you use this software
#     in a product, an acknowledgment in the product documentation would be
#     appreciated but is not required.
#  2. Altered source versions must be plainly marked as such, and must not be
#     misrepresented as being the original software.
#  3. This notice may not be removed or altered from any source distribution.

# WHAT IS THIS?
#  When you add a public API to a SDL library, please run this script, make sure the
#  output looks sane (git diff, it adds to existing files), and commit it.
#  It keeps the export lists in sync with the API.
#

import argparse
import dataclasses
import json
import logging
import os
from pathlib import Path
import pprint
import re

RE_EXTERN_C = re.compile(r'.*extern[ "]*C[ "].*')
RE_COMMENT_REMOVE_CONTENT = re.compile(r'\/\*.*\*/')
RE_PARSING_FUNCTION = re.compile(r'(.*SDL_DECLSPEC[^\(\)]*) ([a-zA-Z0-9_]+) *\((.*)\) *;.*')

#eg:
# void (SDLCALL *callback)(void*, int)
# \1(\2)\3
RE_PARSING_CALLBACK = re.compile(r'([^\(\)]*)\(([^\(\)]+)\)(.*)')


logger = logging.getLogger(__name__)


@dataclasses.dataclass(frozen=True)
class SdlProjectSymbolProperties:
    include_dir: Path
    version_export_path: Path
    macos_exports_path: Path
    re_symbol: str

    @classmethod
    def from_root(cls, project_root: Path) -> "SdlProjectSymbolProperties":
        wikiheaders_options = {}
        with (project_root / ".wikiheaders-options").open("r", newline="\n") as f:
            for line in f.readlines():
                key, value = line.split("=", 1)
                key, value = key.strip(), value.strip()
                wikiheaders_options[key] = value
        return cls(
            include_dir=project_root / wikiheaders_options["incsubdir"],
            version_export_path=project_root / "src" / (wikiheaders_options["projectfullname"] + ".sym"),
            macos_exports_path=project_root / "src" / (wikiheaders_options["projectfullname"] + ".exports"),
            re_symbol= wikiheaders_options["apiprefixregex"],
        )

@dataclasses.dataclass(frozen=True)
class SdlProcedure:
    retval: str
    name: str
    parameter: list[str]
    parameter_name: list[str]
    header: str
    comment: str

    @property
    def variadic(self) -> bool:
        return "..." in self.parameter


def parse_header(header_path: Path) -> list[SdlProcedure]:
    logger.debug("Parse header: %s", header_path)

    header_procedures = []

    parsing_function = False
    current_func = ""
    parsing_comment = False
    current_comment = ""
    ignore_wiki_documentation = False

    with header_path.open() as f:
        for line in f:

            # Skip lines if we're in a wiki documentation block.
            if ignore_wiki_documentation:
                if line.startswith("#endif"):
                    ignore_wiki_documentation = False
                continue

            # Discard wiki documentations blocks.
            if line.startswith("#ifdef SDL_WIKI_DOCUMENTATION_SECTION"):
                ignore_wiki_documentation = True
                continue

            # Discard pre-processor directives ^#.*
            if line.startswith("#"):
                continue

            # Discard "extern C" line
            match = RE_EXTERN_C.match(line)
            if match:
                continue

            # Remove one line comment // ...
            # eg: extern SDL_DECLSPEC SDL_hid_device * SDLCALL SDL_hid_open_path(const char *path, int bExclusive /* = false */)
            line = RE_COMMENT_REMOVE_CONTENT.sub('', line)

            # Get the comment block /* ... */ across several lines
            match_start = "/*" in line
            match_end = "*/" in line
            if match_start and match_end:
                continue
            if match_start:
                parsing_comment = True
                current_comment = line
                continue
            if match_end:
                parsing_comment = False
                current_comment += line
                continue
            if parsing_comment:
                current_comment += line
                continue

            # Get the function prototype across several lines
            if parsing_function:
                # Append to the current function
                current_func += " "
                current_func += line.strip()
            else:
                # if is contains "extern", start grabbing
                if "extern" not in line:
                    continue
                # Start grabbing the new function
                current_func = line.strip()
                parsing_function = True

            # If it contains ';', then the function is complete
            if ";" not in current_func:
                continue

            # Got function/comment, reset vars
            parsing_function = False
            func = current_func
            comment = current_comment
            current_func = ""
            current_comment = ""

            # Discard if it doesn't contain 'SDLCALL'
            if "SDLCALL" not in func:
                logger.debug("  Discard, doesn't have SDLCALL: %r", func)
                continue

            # Discard if it contains 'SDLMAIN_DECLSPEC' (these are not SDL symbols).
            if "SDLMAIN_DECLSPEC" in func:
                logger.debug("  Discard, has SDLMAIN_DECLSPEC: %r", func)
                continue

            logger.debug("Raw data: %r", func)

            # Replace unusual stuff...
            func = func.replace(" SDL_PRINTF_VARARG_FUNC(1)", "")
            func = func.replace(" SDL_PRINTF_VARARG_FUNC(2)", "")
            func = func.replace(" SDL_PRINTF_VARARG_FUNC(3)", "")
            func = func.replace(" SDL_PRINTF_VARARG_FUNC(4)", "")
            func = func.replace(" SDL_PRINTF_VARARG_FUNCV(1)", "")
            func = func.replace(" SDL_PRINTF_VARARG_FUNCV(2)", "")
            func = func.replace(" SDL_PRINTF_VARARG_FUNCV(3)", "")
            func = func.replace(" SDL_PRINTF_VARARG_FUNCV(4)", "")
            func = func.replace(" SDL_WPRINTF_VARARG_FUNC(3)", "")
            func = func.replace(" SDL_WPRINTF_VARARG_FUNCV(3)", "")
            func = func.replace(" SDL_SCANF_VARARG_FUNC(2)", "")
            func = func.replace(" SDL_SCANF_VARARG_FUNCV(2)", "")
            func = func.replace(" SDL_ANALYZER_NORETURN", "")
            func = func.replace(" SDL_MALLOC", "")
            func = func.replace(" SDL_ALLOC_SIZE2(1, 2)", "")
            func = func.replace(" SDL_ALLOC_SIZE(2)", "")
            func = re.sub(r" SDL_ACQUIRE\(.*\)", "", func)
            func = re.sub(r" SDL_ACQUIRE_SHARED\(.*\)", "", func)
            func = re.sub(r" SDL_TRY_ACQUIRE\(.*\)", "", func)
            func = re.sub(r" SDL_TRY_ACQUIRE_SHARED\(.*\)", "", func)
            func = re.sub(r" SDL_RELEASE\(.*\)", "", func)
            func = re.sub(r" SDL_RELEASE_SHARED\(.*\)", "", func)
            func = re.sub(r" SDL_RELEASE_GENERIC\(.*\)", "", func)
            func = re.sub(r"([ (),])(SDL_IN_BYTECAP\([^)]*\))", r"\1", func)
            func = re.sub(r"([ (),])(SDL_OUT_BYTECAP\([^)]*\))", r"\1", func)
            func = re.sub(r"([ (),])(SDL_INOUT_Z_CAP\([^)]*\))", r"\1", func)
            func = re.sub(r"([ (),])(SDL_OUT_Z_CAP\([^)]*\))", r"\1", func)

            # Should be a valid function here
            match = RE_PARSING_FUNCTION.match(func)
            if not match:
                logger.error("Cannot parse: %s", func)
                raise ValueError(func)

            func_ret = match.group(1)
            func_name = match.group(2)
            func_params = match.group(3)

            #
            # Parse return value
            #
            func_ret = func_ret.replace('extern', ' ')
            func_ret = func_ret.replace('SDLCALL', ' ')
            func_ret = func_ret.replace('SDL_DECLSPEC', ' ')
            func_ret, _ = re.subn('([ ]{2,})', ' ', func_ret)
            # Remove trailing spaces in front of '*'
            func_ret = func_ret.replace(' *', '*')
            func_ret = func_ret.strip()

            #
            # Parse parameters
            #
            func_params = func_params.strip()
            if func_params == "":
                func_params = "void"

            # Identify each function parameters with type and name
            # (eventually there are callbacks of several parameters)
            tmp = func_params.split(',')
            tmp2 = []
            param = ""
            for t in tmp:
                if param == "":
                    param = t
                else:
                    param = param + "," + t
                # Identify a callback or parameter when there is same count of '(' and ')'
                if param.count('(') == param.count(')'):
                    tmp2.append(param.strip())
                    param = ""

            # Process each parameters, separation name and type
            func_param_type = []
            func_param_name = []
            for t in tmp2:
                if t == "void":
                    func_param_type.append(t)
                    func_param_name.append("")
                    continue

                if t == "...":
                    func_param_type.append(t)
                    func_param_name.append("")
                    continue

                param_name = ""

                # parameter is a callback
                if '(' in t:
                    match = RE_PARSING_CALLBACK.match(t)
                    if not match:
                        logger.error("cannot parse callback: %s", t)
                        raise ValueError(t)
                    a = match.group(1).strip()
                    b = match.group(2).strip()
                    c = match.group(3).strip()

                    try:
                        (param_type, param_name) = b.rsplit('*', 1)
                    except:
                        param_type = t
                        param_name = "param_name_not_specified"

                    # bug rsplit ??
                    if param_name == "":
                        param_name = "param_name_not_specified"

                    # reconstruct a callback name for future parsing
                    func_param_type.append(a + " (" + param_type.strip() + " *REWRITE_NAME)" + c)
                    func_param_name.append(param_name.strip())

                    continue

                # array like "char *buf[]"
                has_array = False
                if t.endswith("[]"):
                    t = t.replace("[]", "")
                    has_array = True

                # pointer
                if '*' in t:
                    try:
                        (param_type, param_name) = t.rsplit('*', 1)
                    except:
                        param_type = t
                        param_name = "param_name_not_specified"

                    # bug rsplit ??
                    if param_name == "":
                        param_name = "param_name_not_specified"

                    val = param_type.strip() + "*REWRITE_NAME"

                    # Remove trailing spaces in front of '*'
                    tmp = ""
                    while val != tmp:
                        tmp = val
                        val = val.replace('  ', ' ')
                    val = val.replace(' *', '*')
                    # first occurrence
                    val = val.replace('*', ' *', 1)
                    val = val.strip()

                else: # non pointer
                    # cut-off last word on
                    try:
                        (param_type, param_name) = t.rsplit(' ', 1)
                    except:
                        param_type = t
                        param_name = "param_name_not_specified"

                    val = param_type.strip() + " REWRITE_NAME"

                # set back array
                if has_array:
                    val += "[]"

                func_param_type.append(val)
                func_param_name.append(param_name.strip())

            new_proc = SdlProcedure(
                retval=func_ret,                # Return value type
                name=func_name,                 # Function name
                comment=comment,                # Function comment
                header=header_path.name,        # Header file
                parameter=func_param_type,      # List of parameters (type + anonymized param name 'REWRITE_NAME')
                parameter_name=func_param_name, # Real parameter name, or 'param_name_not_specified'
            )

            header_procedures.append(new_proc)

            if logger.getEffectiveLevel() <= logging.DEBUG:
                logger.debug("%s", pprint.pformat(new_proc))

    return header_procedures


# Dump API into a json file
def full_API_json(path: Path, procedures: list[SdlProcedure]):
    with path.open('w', newline='') as f:
        json.dump([dataclasses.asdict(proc) for proc in procedures], f, indent=4, sort_keys=True)
        logger.info("dump API to '%s'", path)


class CallOnce:
    def __init__(self, cb):
        self._cb = cb
        self._called = False
    def __call__(self, *args, **kwargs):
        if self._called:
            return
        self._called = True
        self._cb(*args, **kwargs)


# Check public function comments are correct
def print_check_comment_header():
    logger.warning("")
    logger.warning("Please fix following warning(s):")
    logger.warning("--------------------------------")


def check_documentations(procedures: list[SdlProcedure]) -> None:

    check_comment_header = CallOnce(print_check_comment_header)

    warning_header_printed = False

    # Check \param
    for proc in procedures:
        expected = len(proc.parameter)
        if expected == 1:
            if proc.parameter[0] == 'void':
                expected = 0
        count = proc.comment.count("\\param")
        if count != expected:
            # skip SDL_stdinc.h
            if proc.header != 'SDL_stdinc.h':
                # Warning mismatch \param and function prototype
                check_comment_header()
                logger.warning("  In file %s: function %s() has %d '\\param' but expected %d", proc.header, proc.name, count, expected)

        # Warning check \param uses the correct parameter name
        # skip SDL_stdinc.h
        if proc.header != 'SDL_stdinc.h':
            for n in proc.parameter_name:
                if n != "" and "\\param " + n not in proc.comment and "\\param[out] " + n not in proc.comment:
                    check_comment_header()
                    logger.warning("  In file %s: function %s() missing '\\param %s'", proc.header, proc.name, n)

    # Check \returns
    for proc in procedures:
        expected = 1
        if proc.retval == 'void':
            expected = 0

        count = proc.comment.count("\\returns")
        if count != expected:
            # skip SDL_stdinc.h
            if proc.header != 'SDL_stdinc.h':
                # Warning mismatch \param and function prototype
                check_comment_header()
                logger.warning("  In file %s: function %s() has %d '\\returns' but expected %d" % (proc.header, proc.name, count, expected))

    # Check \since
    for proc in procedures:
        expected = 1
        count = proc.comment.count("\\since")
        if count != expected:
            # skip SDL_stdinc.h
            if proc.header != 'SDL_stdinc.h':
                # Warning mismatch \param and function prototype
                check_comment_header()
                logger.warning("  In file %s: function %s() has %d '\\since' but expected %d" % (proc.header, proc.name, count, expected))


# Parse 'sdl_dynapi_procs_h' file to find existing functions
def find_existing_proc_names(project_properties: SdlProjectSymbolProperties) -> set[str]:
    versioned_symbols = set()
    re_version_export_symbol = re.compile(r'\s*(' + project_properties.re_symbol + r"[a-zA-Z0-9_]+);\s*")
    with project_properties.version_export_path.open() as f:
        for line in f:
            match = re_version_export_symbol.match(line)
            if not match:
                continue
            existing_func = match.group(1)
            versioned_symbols.add(existing_func)
    logger.debug("symbols from version script: %r", versioned_symbols)

    macos_symbols = set()
    re_macos_export_symbol = re.compile(r'\s*_(' + project_properties.re_symbol + r"[a-zA-Z0-9_]+)\s*")
    with project_properties.macos_exports_path.open() as f:
        for line in f:
            match = re_macos_export_symbol.match(line)
            if not match:
                continue
            existing_func = match.group(1)
            macos_symbols.add(existing_func)
    logger.debug("symbols from macos exports file: %r", macos_symbols)

    non_matching_symbols = (versioned_symbols - macos_symbols).union(macos_symbols - versioned_symbols)
    if non_matching_symbols:
        logger.error("Following symbols do not match: %r", non_matching_symbols)
        raise RuntimeError("Non-matching symbols", non_matching_symbols)
    return versioned_symbols

# Get list of SDL headers
def get_header_list(project_properties: SdlProjectSymbolProperties) -> list[Path]:
    ret = []

    for f in project_properties.include_dir.iterdir():
        # Only *.h files
        if f.is_file() and f.suffix == ".h":
            ret.append(f)
        else:
            logger.debug("Skip %s", f)

    # Order headers for reproducible behavior
    ret.sort()

    return ret

# Write the new API in files: _procs.h _overrides.h and .sym
def add_dyn_api(proc: SdlProcedure, project_properties:SdlProjectSymbolProperties) -> None:
    decl_args: list[str] = []
    call_args = []
    for i, argtype in enumerate(proc.parameter):
        # Special case, void has no parameter name
        if argtype == "void":
            assert len(decl_args) == 0
            assert len(proc.parameter) == 1
            decl_args.append("void")
            continue

        # Var name: a, b, c, ...
        varname = chr(ord('a') + i)

        decl_args.append(argtype.replace("REWRITE_NAME", varname))
        if argtype != "...":
            call_args.append(varname)

    macro_args = (
        proc.retval,
        proc.name,
        "({})".format(",".join(decl_args)),
        "({})".format(",".join(call_args)),
        "" if proc.retval == "void" else "return",
    )

    # File: SDL_{image,mixer,net,ttf}.sym
    #
    # Add before "extra symbols go here" line
    with project_properties.version_export_path.open(newline="\n") as f:
        new_input = []
        for line in f:
            if "extra symbols go here" in line:
                new_input.append(f"    {proc.name};\n")
            new_input.append(line)

    with project_properties.version_export_path.open('w', newline='') as f:
        for line in new_input:
            f.write(line)

    # File: SDL_{image,mixer,net,ttf}.exports
    #
    # Add before "extra symbols go here" line
    with project_properties.macos_exports_path.open(newline="\n") as f:
        new_input = []
        for line in f:
            if "extra symbols go here" in line:
                new_input.append(f"_{proc.name}\n")
            new_input.append(line)

    with project_properties.macos_exports_path.open("w", newline="\n") as f:
        for line in new_input:
            f.write(line)



def main():
    parser = argparse.ArgumentParser()
    parser.set_defaults(loglevel=logging.INFO)
    parser.add_argument('--dump', nargs='?', default=None, const="sdl.json", metavar="JSON", help='output all API into a .json file')
    parser.add_argument('--debug', action='store_const', const=logging.DEBUG, dest="loglevel", help='add debug traces')
    args = parser.parse_args()

    logging.basicConfig(level=args.loglevel, format='[%(levelname)s] %(message)s')

    root = Path(__file__).resolve().parents[1]

    project_properties = SdlProjectSymbolProperties.from_root(root)
    logger.debug("project_properties=%r", project_properties)

    # Get list of SDL headers
    sdl_list_includes = get_header_list(project_properties)
    procedures = []
    for filename in sdl_list_includes:
        header_procedures = parse_header(filename)
        procedures.extend(header_procedures)

    # Parse 'SDL_{image,mixer,ttf}.sym' file to find existing functions
    existing_proc_names = find_existing_proc_names(project_properties)
    for procedure in procedures:
        if procedure.name not in existing_proc_names:
            logger.info("NEW %s", procedure.name)
            add_dyn_api(procedure, project_properties)

    if args.dump:
        # Dump API into a json file
        full_API_json(path=Path(args.dump), procedures=procedures)

    # Check comment formatting
    check_documentations(procedures)


if __name__ == "__main__":
    raise SystemExit(main())
