#!/usr/bin/env python3

import argparse
import dataclasses
import os
import pathlib
import re

ROOT = pathlib.Path(__file__).resolve().parents[1]
SDL_ANDROID_C = ROOT / "src/core/android/SDL_android.c"
METHOD_SOURCE_PATHS = (
    SDL_ANDROID_C,
    ROOT / "src/hidapi/android/hid.cpp",
)
JAVA_ROOT = ROOT / "android-project/app/src/main/java"


BASIC_TYPE_SPEC_LUT = {
    "char": "C",
    "byte": "B",
    "short": "S",
    "int": "I",
    "long": "J",
    "float": "F",
    "double": "D",
    "void": "V",
    "boolean": "Z",
    "Object": "Ljava/lang/Object;",
    "String": "Ljava/lang/String;",
}


@dataclasses.dataclass(frozen=True)
class JniType:
    typ: str
    array: int


def java_type_to_jni_spec_internal(type_str: str) -> tuple[int, str]:
    for basic_type_str, basic_type_spec in BASIC_TYPE_SPEC_LUT.items():
        if type_str.startswith(basic_type_str):
            return len(basic_type_str), basic_type_spec
    raise ValueError(f"Don't know how to convert {repr(type_str)} to its equivalent jni spec")


def java_type_to_jni_spec(type_str: str) -> str:
    end, type_spec = java_type_to_jni_spec_internal(type_str)
    suffix_str = type_str[end:]
    assert(all(c in "[] \t" for c in suffix_str))
    suffix_str = "".join(filter(lambda v: v in "[]", suffix_str))
    assert len(suffix_str) % 2 == 0
    array_spec = "[" * (len(suffix_str) // 2)
    return array_spec + type_spec


def java_method_to_jni_spec(ret: str, args: list[str]) -> str:
    return "(" + "".join(java_type_to_jni_spec(a) for a in args) +")" + java_type_to_jni_spec(ret)


@dataclasses.dataclass(frozen=True)
class JniMethodBinding:
    name: str
    spec: str


def collect_jni_bindings_from_c() -> dict[str, set[JniMethodBinding]]:
    bindings = {}

    sdl_android_text = SDL_ANDROID_C.read_text()
    for m in re.finditer(r"""register_methods\((?:[A-Za-z0-9]+),\s*"(?P<class>[a-zA-Z0-9_/]+)",\s*(?P<table>[a-zA-Z0-9_]+),\s*SDL_arraysize\((?P=table)\)\)""", sdl_android_text):
        kls = m["class"]
        table = m["table"]
        methods = set()
        in_struct = False
        for method_source_path in METHOD_SOURCE_PATHS:
            method_source = method_source_path.read_text()
            for line in method_source.splitlines(keepends=False):
                if re.match(f"(static )?JNINativeMethod {table}" + r"\[([0-9]+)?\] = \{", line):
                    in_struct = True
                    continue
                if in_struct:
                    if re.match(r"\};", line):
                        in_struct = False
                        break
                    n = re.match(r"""\s*\{\s*"(?P<method>[a-zA-Z0-9_]+)"\s*,\s*"(?P<spec>[()A-Za-z0-9_/;[]+)"\s*,\s*(\(void\*\))?(HID|SDL)[_A-Z]*_JAVA_[_A-Z]*INTERFACE[_A-Z]*\((?P=method)\)\s*\},?""", line)
                    assert n, f"'{line}' does not match regex"
                    methods.add(JniMethodBinding(name=n["method"], spec=n["spec"]))
                    continue
                if methods:
                    break
            if methods:
                break
        assert methods, f"Could not find methods for {kls} (table={table})"

        assert not in_struct

        assert kls not in bindings, f"{kls} must be unique in C sources"
        bindings[kls] = methods
    return bindings

def collect_jni_bindings_from_java() -> dict[str, set[JniMethodBinding]]:
    bindings = {}

    for root, _, files in os.walk(JAVA_ROOT):
        for file in files:
            file_path = pathlib.Path(root) / file
            java_text = file_path.read_text()
            methods = set()
            for m in re.finditer(r"(?:(?:public|private)\s+)?(?:static\s+)?native\s+(?P<ret>[A-Za-z0-9_]+)\s+(?P<method>[a-zA-Z0-9_]+)\s*\(\s*(?P<args>[^)]*)\);", java_text):
                name = m["method"]
                ret = m["ret"]
                args = []
                args_str = m["args"].strip()
                if args_str:
                    for a_s in args_str.split(","):
                        atype_str, _ = a_s.strip().rsplit(" ")
                        args.append(atype_str.strip())

                spec = java_method_to_jni_spec(ret=ret, args=args)
                methods.add(JniMethodBinding(name=name, spec=spec))
            if methods:
                relative_java_path = file_path.relative_to(JAVA_ROOT)
                relative_java_path_without_suffix = relative_java_path.with_suffix("")
                kls = "/".join(relative_java_path_without_suffix.parts)
                assert kls not in bindings, f"{kls} must be unique in JAVA sources"
                bindings[kls] = methods
    return bindings


def print_error(*args):
    print("ERROR:", *args)


def main():
    parser = argparse.ArgumentParser(allow_abbrev=False, description="Verify Android JNI bindings")
    args = parser.parse_args()

    bindings_from_c = collect_jni_bindings_from_c()
    bindings_from_java = collect_jni_bindings_from_java()

    all_ok = bindings_from_c == bindings_from_java
    if all_ok:
        print("OK")
    else:
        print("NOT OK")
        kls_c = set(bindings_from_c.keys())
        kls_java = set(bindings_from_java.keys())
        if kls_c != kls_java:
            only_c = kls_c - kls_java
            for c in only_c:
                print_error(f"Missing class in JAVA sources: {c}")
            only_java = kls_java - kls_c
            for c in only_java:
                print_error(f"Missing class in C sources: {c}")

        klasses = kls_c.union(kls_java)
        for kls in klasses:
            m_c = bindings_from_c.get(kls)
            m_j = bindings_from_java.get(kls)
            if m_c and m_j and m_c != m_j:
                m_only_c = m_c - m_j
                for c in m_only_c:
                    print_error(f"{kls}: Binding only in C source: {c.name} {c.spec}")
                m_only_j = m_j - m_c
                for c in m_only_j:
                    print_error(f"{kls}: Binding only in JAVA source: {c.name} {c.spec}")

    return 0 if all_ok else 1


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