#!/usr/bin/env python3
import argparse
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path


DOC_PATH = Path("docs/lua-api/lua_api.json")
WEAK_PARAM_PATTERNS = (
    re.compile(r":\s*any\b"),
    re.compile(r"\barg\d+\b"),
)
GIT_REF_PATTERN = re.compile(r"^(?:[0-9a-fA-F]{7,40}|[A-Za-z0-9][A-Za-z0-9._/\\-]*)$")
INVALID_REF_CHARS = set(" ~^:?*[]\\\x00\n\r")


def _has_invalid_ref_segment(value):
    for part in value.split("/"):
        if part in {".", "..", ""} or part.startswith(".") or part.endswith(".") or part.endswith(".lock"):
            return True
    return False

@dataclass(frozen=True)
class Binding:
    path: Path
    line: int
    kind: str
    name: str
    handler: str


def run_git(args):
    completed = subprocess.run(["git", *args], check=True, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    return completed.stdout


def validate_git_ref(value):
    if not isinstance(value, str):
        raise ValueError(f"invalid git ref: {value!r}")

    value = value.strip()
    if (
        not value
        or value.startswith("-")
        or value.startswith("/")
        or value.endswith("/")
        or ".." in value
        or "//" in value
        or any(ch in INVALID_REF_CHARS for ch in value)
        or not GIT_REF_PATTERN.fullmatch(value)
        or _has_invalid_ref_segment(value)
    ):
        raise ValueError(f"invalid git ref: {value!r}")
    return value


def resolve_base_ref(explicit_base):
    candidates = []
    if explicit_base:
        try:
            candidates.append(validate_git_ref(explicit_base))
        except ValueError as error:
            raise ValueError(f"invalid --base value: {error}") from None

    github_base_ref = os.environ.get("GITHUB_BASE_REF")
    if github_base_ref:
        try:
            base_ref = validate_git_ref(github_base_ref)
            candidates.extend([f"origin/{base_ref}", base_ref])
        except ValueError:
            pass
    candidates.extend(["origin/main", "main"])

    for candidate in candidates:
        try:
            return run_git(["merge-base", "HEAD", candidate]).strip()
        except subprocess.CalledProcessError:
            continue

    raise RuntimeError("could not resolve a base ref for Lua API binding documentation check")


def parse_added_bindings(base_ref):
    bindings = []
    for path, line_number, source in iter_added_cpp_lines(base_ref):
        binding = parse_registration_line(path, line_number, source)
        if binding:
            bindings.append(binding)
    return bindings


def iter_added_cpp_lines(base_ref):
    diff = run_git(["diff", "--unified=0", "--diff-filter=AM", f"{base_ref}...HEAD", "--", "src/lua"])
    current_path = None
    new_line = None

    for line in diff.splitlines():
        if line.startswith("+++ b/"):
            current_path = Path(line[6:])
            continue
        if line.startswith("@@"):
            new_line = parse_hunk_start(line)
            continue
        if not is_cpp_source_path(current_path):
            continue
        if is_added_source_line(line):
            yield current_path, new_line or 0, line[1:]
            new_line = next_line_number(new_line)
            continue
        if is_context_source_line(line):
            new_line = next_line_number(new_line)


def parse_hunk_start(line):
    match = re.search(r"\+(\d+)", line)
    return int(match.group(1)) if match else None


def is_cpp_source_path(path):
    return path is not None and path.suffix == ".cpp"


def is_added_source_line(line):
    return line.startswith("+") and not line.startswith("+++")


def is_context_source_line(line):
    return not line.startswith("-")


def next_line_number(line_number):
    return None if line_number is None else line_number + 1


def split_call_arguments(line, call_name):
    open_index = find_call_open_index(line, call_name)
    if open_index < 0:
        return None

    args = []
    current = []
    depth = 0
    in_string = False
    escape = False

    for char in line[open_index + 1 :]:
        if in_string:
            in_string, escape = consume_string_character(current, char, escape)
            continue

        if char == '"':
            in_string = True
            current.append(char)
            continue

        if char in "([{":
            depth += 1
            current.append(char)
            continue

        if is_call_close(char, depth):
            append_current_argument(args, current)
            return args

        if char in ")]}":
            depth -= 1
            current.append(char)
            continue

        if char == "," and depth == 0:
            append_current_argument(args, current)
            continue

        current.append(char)

    return None


def find_call_open_index(line, call_name):
    call_index = line.find(call_name)
    if call_index < 0:
        return -1
    return line.find("(", call_index + len(call_name))


def consume_string_character(current, char, escape):
    current.append(char)
    if escape:
        return True, False
    if char == "\\":
        return True, True
    if char == '"':
        return False, False
    return True, False


def append_current_argument(args, current):
    args.append("".join(current).strip())
    current.clear()


def is_call_close(char, depth):
    return char in ")]}" and depth == 0


def string_literal_value(value):
    value = value.strip()
    if len(value) < 2 or value[0] != '"' or value[-1] != '"':
        return None
    return value[1:-1]


def handler_name(value):
    return value.strip().rstrip(";")


def parse_registration_line(path, line_number, line):
    for parser in (parse_method_registration, parse_global_registration, parse_class_registration):
        binding = parser(path, line_number, line)
        if binding:
            return binding
    return None


def parse_method_registration(path, line_number, line):
    args = split_call_arguments(line, "Lua::registerMethod")
    if args and len(args) >= 4:
        class_name = string_literal_value(args[1])
        method_name = string_literal_value(args[2])
        if class_name and method_name:
            return Binding(path, line_number, "method", f"{class_name}:{method_name}", handler_name(args[3]))
    return None


def parse_global_registration(path, line_number, line):
    args = split_call_arguments(line, "Lua::registerGlobalMethod")
    if args and len(args) >= 2:
        name = string_literal_value(args[0])
        if name:
            return Binding(path, line_number, "global", name, handler_name(args[1]))
    return None


def parse_class_registration(path, line_number, line):
    for call_name in ("Lua::registerSharedClass", "Lua::registerClass"):
        args = split_call_arguments(line, call_name)
        if args and len(args) >= 4:
            class_name = string_literal_value(args[1])
            handler = handler_name(args[3])
            if class_name and handler != "nullptr":
                return Binding(path, line_number, "class", class_name, handler)

    return None


def load_docs():
    with DOC_PATH.open("r", encoding="utf-8") as file:
        return json.load(file)


def find_doc_entry(data, binding):
    if binding.kind == "method":
        class_name, method_name = binding.name.split(":", 1)
        for method in data.get("classes", {}).get(class_name, []):
            if method.get("name") == method_name:
                return method
        return None
    if binding.kind == "global":
        for function in data.get("globals", []):
            if function.get("name") == binding.name:
                return function
        return None
    if binding.kind == "class":
        return {
            "params": [],
            "return": "",
            "overloads": data.get("classOverloads", {}).get(binding.name, []),
        }
    return None


def weak_reasons(entry):
    reasons = []
    for parameter in entry.get("params", []):
        if parameter.startswith("..."):
            reasons.append(f"parameter uses vararg placeholder: {parameter}")
        for pattern in WEAK_PARAM_PATTERNS:
            if pattern.search(parameter):
                reasons.append(f"weak parameter: {parameter}")
                break

    return_type = entry.get("return", "")
    if return_type in {"any", "table"}:
        reasons.append(f"weak return type: {return_type}")
    if "boolean|" in return_type or "|boolean" in return_type:
        reasons.append(f"broad boolean union return: {return_type}")
    if entry.get("overloads") == [] and entry.get("return", "") == "" and entry.get("params") == []:
        reasons.append("callable class has no documented overload")
    return reasons


def has_lua_docblock(binding):
    if not binding.path.exists():
        return False

    text = binding.path.read_text(encoding="utf-8", errors="replace")
    registration_index = find_registration_index(text, binding)
    handler_index = find_handler_index(text, binding.handler)
    return has_docblock_before(text, registration_index) or has_docblock_before(text, handler_index)


def find_registration_index(text, binding):
    escaped_name = re.escape(binding.name.split(":", 1)[-1] if binding.kind == "method" else binding.name)
    escaped_handler = re.escape(binding.handler)
    pattern = re.compile(rf'Lua::register[A-Za-z]*\s*\([^;]*"{escaped_name}"[^;]*{escaped_handler}', re.DOTALL)
    match = pattern.search(text)
    return match.start() if match else -1


def find_handler_index(text, handler):
    if not handler:
        return -1
    pattern = re.compile(rf"\b(?:int|void|bool)\s+{re.escape(handler)}\s*\(")
    match = pattern.search(text)
    return match.start() if match else -1


def has_docblock_before(text, index):
    if index < 0:
        return False
    block_end = text.rfind("*/", 0, index)
    if block_end < 0:
        return False
    block_start = text.rfind("/***", 0, block_end)
    if block_start < 0:
        return False
    between = text[block_end + 2 : index]
    if between.strip():
        return False
    block = text[block_start:block_end]
    return any(tag in block for tag in ("@function", "@param", "@return", "@class", "@overload"))


def main():
    parser = argparse.ArgumentParser(description="Require explicit Lua API docblocks for new weak Lua bindings.")
    parser.add_argument("--base", help="base ref or commit to compare against")
    args = parser.parse_args()

    try:
        base_ref = resolve_base_ref(args.base)
        bindings = parse_added_bindings(base_ref)
        docs = load_docs()
    except (OSError, RuntimeError, subprocess.CalledProcessError, ValueError) as error:
        print(f"::error::failed to run Lua API binding documentation check: {error}")
        return 1

    failed = False
    for binding in bindings:
        entry = find_doc_entry(docs, binding)
        if entry is None:
            print(f"::error file={binding.path},line={binding.line}::Lua API binding {binding.name} is missing from docs/lua-api/lua_api.json")
            failed = True
            continue

        reasons = weak_reasons(entry)
        if reasons and not has_lua_docblock(binding):
            print(f"::error file={binding.path},line={binding.line}::Lua API binding {binding.name} has a weak generated signature and needs an explicit /*** */ docblock")
            for reason in reasons:
                print(f"  - {reason}")
            failed = True

    if failed:
        return 1

    print(f"Lua API binding documentation check passed: {len(bindings)} new binding registration(s) inspected")
    return 0


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