#!/usr/bin/env python3

from __future__ import annotations

import argparse
import re
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Iterable


@dataclass
class ApiNode:
    methods: set[str] = field(default_factory=set)
    children: dict[str, "ApiNode"] = field(default_factory=dict)


@dataclass
class ObjectClass:
    name: str
    methods: set[str] = field(default_factory=set)
    fields: dict[str, str] = field(default_factory=dict)


def read_text(path: Path) -> str:
    return path.read_text(encoding="utf-8")


def find_matching_brace(text: str, open_brace_idx: int) -> int:
    depth = 0
    in_string = False
    string_char = ""
    escaped = False

    for i in range(open_brace_idx, len(text)):
        c = text[i]

        if in_string:
            if escaped:
                escaped = False
                continue

            if c == "\\":
                escaped = True
                continue

            if c == string_char:
                in_string = False
            continue

        if c in ('"', "'"):
            in_string = True
            string_char = c
            continue

        if c == "{":
            depth += 1
        elif c == "}":
            depth -= 1
            if depth == 0:
                return i

    raise ValueError("Unbalanced braces while parsing C++ source")


def extract_function_bodies(source: str, header_pattern: re.Pattern[str]) -> list[tuple[re.Match[str], str]]:
    out: list[tuple[re.Match[str], str]] = []
    for match in header_pattern.finditer(source):
        open_idx = source.find("{", match.end() - 1)
        if open_idx < 0:
            continue

        close_idx = find_matching_brace(source, open_idx)
        out.append((match, source[open_idx + 1 : close_idx]))

    return out


def merge_node(dst: ApiNode, src: ApiNode) -> None:
    dst.methods |= src.methods
    for key, child in src.children.items():
        if key not in dst.children:
            dst.children[key] = child
        else:
            merge_node(dst.children[key], child)


def parse_binding_tree(root: Path) -> tuple[ApiNode, set[str]]:
    lua_dir = root / "src/config/lua"

    root_node = ApiNode()
    callable_namespaces: set[str] = set()

    register_header = re.compile(
        r"void\s+(?:(?:Config::Lua::Bindings::)?Internal::)?register\w+Bindings\s*\([^)]*\)\s*\{", re.MULTILINE
    )
    set_fn = re.compile(r'(?:Internal::)?set(?:Mgr)?Fn\(\s*L\s*,(?:\s*mgr\s*,)?\s*"([^"]+)"\s*,')
    set_field = re.compile(r'lua_setfield\(L,\s*-2,\s*"([^"]+)"\s*\);')

    for cpp in sorted(lua_dir.rglob("*.cpp")):
        source = read_text(cpp)
        for _, body in extract_function_bodies(source, register_header):
            local_root = ApiNode()
            stack: list[ApiNode] = [local_root]

            if re.search(
                r'lua_setfield\(L,\s*-2,\s*"__call"\s*\);.*?lua_setfield\(L,\s*-2,\s*"([^"]+)"\s*\);',
                body,
                flags=re.DOTALL,
            ):
                for ns in re.findall(
                    r'lua_setfield\(L,\s*-2,\s*"__call"\s*\);.*?lua_setfield\(L,\s*-2,\s*"([^"]+)"\s*\);',
                    body,
                    flags=re.DOTALL,
                ):
                    callable_namespaces.add(ns)

            for raw_line in body.splitlines():
                line = raw_line.strip()
                if not line:
                    continue

                if "lua_newtable(L)" in line:
                    stack.append(ApiNode())
                    continue

                m = set_fn.search(line)
                if m:
                    stack[-1].methods.add(m.group(1))
                    continue

                if "lua_setmetatable(L" in line:
                    if len(stack) > 1:
                        stack.pop()
                    continue

                m = set_field.search(line)
                if m:
                    field_name = m.group(1)
                    if field_name == "__call":
                        continue

                    if len(stack) > 1:
                        node = stack.pop()
                        if field_name in stack[-1].children:
                            merge_node(stack[-1].children[field_name], node)
                        else:
                            stack[-1].children[field_name] = node

            merge_node(root_node, local_root)

    return root_node, callable_namespaces


def parse_object_classes(root: Path) -> dict[str, ObjectClass]:
    objects_dir = root / "src/config/lua/objects"
    mt_regex = re.compile(r'static constexpr const char\*\s+MT\s*=\s*"([^"]+)";')
    index_header = re.compile(r"static int\s+\w*Index\s*\(lua_State\* L\)\s*\{", re.MULTILINE)
    cond_regex = re.compile(r"(?:if|else\s+if)\s*\(([^)]*\bkey\b[^)]*)\)")
    push_class_regex = re.compile(r"Objects::CLua([A-Za-z0-9_]+)::push")

    out: dict[str, ObjectClass] = {}

    for cpp in sorted(objects_dir.glob("*.cpp")):
        source = read_text(cpp)
        mt_match = mt_regex.search(source)
        if not mt_match:
            continue

        mt_name = mt_match.group(1)
        if not mt_name.startswith("HL."):
            continue

        class_name = mt_name
        obj = ObjectClass(name=class_name)

        bodies = extract_function_bodies(source, index_header)
        if not bodies:
            out[class_name] = obj
            continue

        body = bodies[0][1]
        cond_matches = list(cond_regex.finditer(body))

        for i, cond in enumerate(cond_matches):
            start = cond.start()
            end = cond_matches[i + 1].start() if i + 1 < len(cond_matches) else len(body)
            segment = body[start:end]

            keys = re.findall(r'"([^"]+)"', cond.group(1))
            if not keys:
                continue

            is_method = "lua_pushcfunction" in segment

            if is_method:
                for key in keys:
                    obj.methods.add(key)
                continue

            inferred_types: set[str] = set()
            if "lua_pushboolean" in segment:
                inferred_types.add("boolean")
            if "lua_pushstring" in segment or "lua_pushfstring" in segment:
                inferred_types.add("string")
            if "lua_pushinteger" in segment:
                inferred_types.add("integer")
            if "lua_pushnumber" in segment:
                inferred_types.add("number")
            if "lua_newtable" in segment:
                inferred_types.add("table")
            if "lua_pushnil" in segment:
                inferred_types.add("nil")

            for pushed in push_class_regex.findall(segment):
                inferred_types.add(f"HL.{pushed}")

            if not inferred_types:
                type_str = "any"
            else:
                ordered = sorted(inferred_types, key=lambda t: (t == "nil", t))
                type_str = "|".join(ordered)

            for key in keys:
                if key in obj.fields:
                    existing = set(obj.fields[key].split("|"))
                    merged = existing | set(type_str.split("|"))
                    obj.fields[key] = "|".join(sorted(merged, key=lambda t: (t == "nil", t)))
                else:
                    obj.fields[key] = type_str

        out[class_name] = obj

    return out


def lua_type_from_config_ctor(ctor: str) -> str:
    mapping = {
        "CLuaConfigBool": "boolean",
        "CLuaConfigInt": "integer|boolean",
        "CLuaConfigFloat": "number|boolean",
        "CLuaConfigString": "string",
        "CLuaConfigColor": "string",
        "CLuaConfigVec2": "HL.Vec2Like",
        "CLuaConfigCssGap": "integer|HL.CssGap",
        "CLuaConfigFontWeight": "integer|string",
        "CLuaConfigGradient": "string|HL.Gradient",
    }
    return mapping.get(ctor, "any")


def parse_config_values(root: Path) -> dict[str, str]:
    cfg = root / "src/config/values/ConfigValues.cpp"
    source = read_text(cfg)
    pattern = re.compile(r'MS<([A-Za-z0-9_]+)>\("([^"]+)"')

    type_map = {
        "Bool": "boolean",
        "Int": "integer|boolean",
        "Float": "number|boolean",
        "String": "string",
        "Color": "string",
        "Vec2": "HL.Vec2Like",
        "CssGap": "integer|HL.CssGap",
        "FontWeight": "integer|string",
        "Gradient": "string|HL.Gradient",
    }

    out: dict[str, str] = {}
    for vtype, key in pattern.findall(source):
        out[key.replace(":", ".").replace("-", "_")] = type_map.get(vtype, "any")

    return out


def pascal_case(s: str) -> str:
    if s == 'opengl':
        return 'OpenGL'
    return ''.join(p.capitalize() for p in s.split('_'))

# Config { animations = Config.Animations }
# Config.Animations { enabled = bool }

type ClassMember = str
type IsTreeType = bool
type MemberType = tuple[str, IsTreeType]
type ConfigSubtable = dict[ClassMember, MemberType]

type ConfigTree = dict[str, ConfigSubtable]

def config_values_to_config_tree(config_values: dict[str, str]) -> ConfigTree:
    ret: ConfigTree = {};

    for route, tp in config_values.items():
        route_parts = route.split('.');
        curr_type = "";
        # We iterate over the config route, e.g 
        # ['input', 'touchpad', 'tap_and_drag']
        #
        # and create the corresponding entries with pascal names
        # Input { touchpad: Input.Touchpad }
        # Input.Touchpad { tap_and_drag: boolean }

        for i, route_part in enumerate(route_parts):
            is_tree_type = i + 1 < len(route_parts)
            if is_tree_type:
                next_type = curr_type + '.' + pascal_case(route_parts[i]);
            else:
                next_type = tp;

            entry: ConfigSubtable = ret.setdefault(curr_type, {});
            entry[route_part] = (next_type, is_tree_type);
            curr_type = next_type
    return ret;

def emit_config_tree(config_tree: ConfigTree, class_prefix: str, optional: bool) -> list[str]:
    lines: list[str] = [];
    for tp, subtable in config_tree.items():
        class_ = [(member, (class_prefix if tp[1] else "") + tp[0], optional) for member, tp in subtable.items()]
        lines.append("");
        lines.extend(emit_class_block(class_prefix + tp, class_, None, False));
    return lines

def extract_initializer_body(source: str, array_name: str) -> str:
    marker = f"{array_name}[]"
    idx = source.find(marker)
    if idx < 0:
        return ""

    eq_idx = source.find("=", idx)
    if eq_idx < 0:
        return ""

    open_idx = source.find("{", eq_idx)
    if open_idx < 0:
        return ""

    close_idx = find_matching_brace(source, open_idx)
    return source[open_idx + 1 : close_idx]


def parse_descriptor_fields(root: Path) -> dict[str, dict[str, str]]:
    source = read_text(root / "src/config/lua/bindings/LuaBindingsConfigRules.cpp")
    arrays = {
        "MONITOR_FIELDS": "HL.MonitorSpec",
        "DEVICE_FIELDS": "HL.DeviceSpec",
        "WORKSPACE_RULE_FIELDS": "HL.WorkspaceRuleSpec",
        "WINDOW_RULE_EFFECT_DESCS": "HL.WindowRuleSpec",
        "LAYER_RULE_EFFECT_DESCS": "HL.LayerRuleSpec",
    }

    entry_regex = re.compile(
        r'\{\s*"([^"]+)"\s*,\s*\[\]\(\)\s*->\s*ILuaConfigValue\*\s*\{\s*return\s+new\s+([A-Za-z0-9_]+)\((.*?)\);\s*\}',
        re.DOTALL,
    )

    out: dict[str, dict[str, str]] = {class_name: {} for class_name in arrays.values()}

    for array_name, class_name in arrays.items():
        body = extract_initializer_body(source, array_name)
        if not body:
            continue

        for name, ctor, _ in entry_regex.findall(body):
            out[class_name][name] = lua_type_from_config_ctor(ctor)

    # required / conventional fields not included / special in descriptor arrays
    out["HL.MonitorSpec"]["output"] = "string"
    out["HL.MonitorSpec"]["scale"] = "string|number"
    out["HL.DeviceSpec"]["name"] = "string"
    out["HL.WorkspaceRuleSpec"]["workspace"] = "string"
    out["HL.WorkspaceRuleSpec"]["enabled"] = "boolean"
    out["HL.WorkspaceRuleSpec"]["layout_opts"] = "table<string, string|number|boolean>"
    out["HL.WindowRuleSpec"]["name"] = "string"
    out["HL.WindowRuleSpec"]["enabled"] = "boolean"
    out["HL.WindowRuleSpec"]["match"] = "table<string, string|number|boolean>"
    out["HL.LayerRuleSpec"]["name"] = "string"
    out["HL.LayerRuleSpec"]["enabled"] = "boolean"
    out["HL.LayerRuleSpec"]["match"] = "table<string, string|boolean>"

    return out


def parse_known_events(root: Path) -> list[str]:
    source = read_text(root / "src/config/lua/LuaEventHandler.cpp")
    block_match = re.search(
        r"std::unordered_set<std::string> EVENTS = \{(.*?)\};",
        source,
        flags=re.DOTALL,
    )
    if not block_match:
        return []

    events = sorted(set(re.findall(r'"([^"]+)"', block_match.group(1))))
    return events


def helper_to_lua_type(helper: str) -> str:
    mapping = {
        "Str": "string",
        "Num": "number",
        "Bool": "boolean",
        "Monitor": "HL.MonitorSelector",
        "Workspace": "HL.WorkspaceSelector",
        "Window": "HL.WindowSelector",
        "MonitorSelector": "string",
        "WorkspaceSelector": "string",
        "WindowSelector": "string",
    }
    return mapping.get(helper, "any")


def query_struct_to_type(struct_name: str) -> str:
    name = struct_name
    if name.startswith("S") and len(name) > 1:
        name = name[1:]
    if name.endswith("Query"):
        name = f"{name}Filter"
    return f"HL.{name}"


def parse_query_filter_types(root: Path) -> tuple[dict[str, dict[str, str]], dict[str, str]]:
    source = read_text(root / "src/config/lua/bindings/LuaBindingsQuery.cpp")

    parse_header = re.compile(
        r"static void\s+(\w+)\s*\(\s*lua_State\* L,\s*int idx,\s*const char\* fnName,\s*(\w+)&\s*\w+\s*\)\s*\{"
    )

    query_types: dict[str, dict[str, str]] = {}
    parse_fn_to_type: dict[str, str] = {}

    for m, body in extract_function_bodies(source, parse_header):
        parse_fn = m.group(1)
        struct_name = m.group(2)
        type_name = query_struct_to_type(struct_name)
        parse_fn_to_type[parse_fn] = type_name
        query_types.setdefault(type_name, {})

        direct_assign = re.finditer(
            r'query\.([A-Za-z_][A-Za-z0-9_]*)\s*=\s*Internal::tableOpt([A-Za-z_]+)\(L,\s*idx,\s*"([^"]+)"',
            body,
        )
        for dm in direct_assign:
            field_name = dm.group(3)
            helper = dm.group(2)
            query_types[type_name][field_name] = helper_to_lua_type(helper)

        helper_calls = re.finditer(r'Internal::tableOpt([A-Za-z_]+)\(L,\s*idx,\s*"([^"]+)"', body)
        for hm in helper_calls:
            helper = hm.group(1)
            field_name = hm.group(2)
            query_types[type_name].setdefault(field_name, helper_to_lua_type(helper))

    api_overrides: dict[str, str] = {}
    for parse_fn, type_name in parse_fn_to_type.items():
        for api in re.findall(rf'{parse_fn}\(L,\s*1,\s*"([^"]+)"\s*,', source):
            if api == "hl.get_windows":
                api_overrides[api] = f"fun(filters?: {type_name}): HL.Window[]"
            elif api == "hl.get_layers":
                api_overrides[api] = f"fun(filters?: {type_name}): HL.LayerSurface[]"
            else:
                api_overrides[api] = f"fun(filters?: {type_name}): any"

    return query_types, api_overrides


def namespace_class_name(path: list[str]) -> str:
    if not path:
        return "HL.API"
    parts = [p[:1].upper() + p[1:] for p in path]
    return f"HL.{''.join(parts)}Namespace"


def format_union_alias(name: str, values: Iterable[str]) -> list[str]:
    values = list(values)
    if not values:
        return []

    lines = [f"---@alias {name}"]
    for value in values:
        lines.append(f'---| "{value}"')
    return lines


def emit_class_block(class_name: str, fields: list[tuple[str, str, bool]], operator_call: str | None = None, emit_local_var: bool = True) -> list[str]:
    lines = [f"---@class {class_name}"]
    if operator_call:
        lines.append(f"---@operator call:{operator_call}")

    for field_name, type_name, optional in fields:
        if field_name.startswith("[") and field_name.endswith("]"):
            # preformatted index field, e.g. [string]
            lines.append(f"---@field {field_name} {type_name}")
            continue

        if re.match(r"^[A-Za-z_][A-Za-z0-9_]*$", field_name):
            suffix = "?" if optional else ""
            lines.append(f"---@field {field_name}{suffix} {type_name}")
            continue

        quoted = field_name.replace("'", "\\'")
        type_with_optional = f"{type_name}|nil" if optional else type_name
        lines.append(f"---@field ['{quoted}'] {type_with_optional}")

    if (emit_local_var):
        local_name = f"__{class_name.replace('.', '_')}"
        lines.append(f"local {local_name} = {{}}")
    return lines


def generate_stub(root: Path) -> str:
    api_tree, callable_namespaces = parse_binding_tree(root)
    object_classes = parse_object_classes(root)
    config_values = parse_config_values(root)
    config_tree = config_values_to_config_tree(config_values);
    descriptor_classes = parse_descriptor_fields(root)
    events = parse_known_events(root)
    query_types, query_overrides = parse_query_filter_types(root)

    api_signatures: dict[str, str] = {
        "hl.on": "fun(event: HL.EventName, cb: fun(...)): HL.EventSubscription",
        "hl.bind": "fun(keys: string, dispatcher: HL.Dispatcher|function, opts?: HL.BindOptions): HL.Keybind",
        "hl.dispatch": "fun(dispatcher: HL.Dispatcher|function): any",
        "hl.define_submap": "fun(name: string, reset_or_fn: string|function, fn?: function): nil",
        "hl.timer": "fun(callback: function, opts: HL.TimerOptions): HL.Timer",
        "hl.config": "fun(config: HL.ConfigOpt): nil",
        "hl.get_config": "fun(key: HL.ConfigKey|string): any, string?",
        "hl.device": "fun(spec: HL.DeviceSpec): nil",
        "hl.monitor": "fun(spec: HL.MonitorSpec): nil",
        "hl.window_rule": "fun(spec: HL.WindowRuleSpec): HL.WindowRule",
        "hl.layer_rule": "fun(spec: HL.LayerRuleSpec): HL.LayerRule",
        "hl.workspace_rule": "fun(spec: HL.WorkspaceRuleSpec): HL.WorkspaceRule",
        "hl.permission": "fun(spec: HL.PermissionSpec): nil",
        "hl.gesture": "fun(spec: HL.GestureSpec): nil",
        "hl.get_windows": "fun(filters?: HL.WindowQueryFilter): HL.Window[]",
        "hl.get_window": "fun(selector: HL.WindowSelector): HL.Window|nil",
        "hl.get_active_window": "fun(): HL.Window|nil",
        "hl.get_urgent_window": "fun(): HL.Window|nil",
        "hl.get_workspaces": "fun(): HL.Workspace[]",
        "hl.get_workspace": "fun(selector: HL.WorkspaceSelector): HL.Workspace|nil",
        "hl.get_active_workspace": "fun(monitor?: HL.MonitorSelector): HL.Workspace|nil",
        "hl.get_active_special_workspace": "fun(monitor?: HL.MonitorSelector): HL.Workspace|nil",
        "hl.get_monitors": "fun(options?: HL.GetMonitorsOptions): HL.Monitor[]",
        "hl.get_monitor": "fun(selector: HL.MonitorSelector): HL.Monitor|nil",
        "hl.get_active_monitor": "fun(): HL.Monitor|nil",
        "hl.get_monitor_at": "fun(x: number|HL.Vec2, y?: number): HL.Monitor|nil",
        "hl.get_monitor_at_cursor": "fun(): HL.Monitor|nil",
        "hl.get_layers": "fun(filters?: HL.LayerQueryFilter): HL.LayerSurface[]",
        "hl.get_workspace_windows": "fun(workspace: HL.WorkspaceSelector): HL.Window[]",
        "hl.get_cursor_pos": "fun(): HL.Vec2|nil",
        "hl.get_last_window": "fun(): HL.Window|nil",
        "hl.get_last_workspace": "fun(monitor?: HL.MonitorSelector): HL.Workspace|nil",
        "hl.get_current_submap": "fun(): string",
        "hl.notification.create": "fun(opts?: HL.NotificationOptions): HL.Notification",
        "hl.notification.get": "fun(): HL.Notification[]",
        "hl.layout.register": "fun(name: string, provider: HL.LayoutProvider): nil",
        "hl.exec_cmd": "fun(cmd: string, rules?: table<string, string|number|boolean>): nil",
        "hl.get_loaded_plugins": "fun(): HL.Plugin[]",
        "hl.is_key_down": "fun(key: number|string): boolean",
        "hl.version": "fun(): string",
        "hl.clear_crashed_lockscreen": "fun(): nil",
        "hl.exec_scheduled_prop_refresh_immediately": "fun(): nil",
        "hl.unbind": "fun(key: string): nil"
    }
    api_signatures.update(query_overrides)

    object_method_signatures: dict[tuple[str, str], str] = {
        ("HL.Group", "add"): "fun(self: HL.Group, window: HL.Window, index?: integer)",
        ("HL.Group", "remove"): "fun(self: HL.Group, window_or_index: HL.Window|integer)",
    }

    lines: list[str] = []
    lines.append("-- This file is autogenerated. Do not edit by hand.")
    lines.append("-- Generator: scripts/generateLuaStubs.py")
    lines.append("---@meta")
    lines.append("")

    lines.extend(format_union_alias("HL.EventName", events))
    lines.append("")
    lines.extend(format_union_alias("HL.ConfigKey", sorted(config_values.keys())))
    lines.append("")
    lines.append("---@alias HL.MonitorSelector string|integer|HL.Monitor")
    lines.append("---@alias HL.WorkspaceSelector string|integer|HL.Workspace")
    lines.append("---@alias HL.WindowSelector string|integer|HL.Window")
    lines.append("---@alias HL.Vec2Like HL.Vec2|{x:number, y:number}|{number, number}|string")
    lines.append("---@alias HL.CssGap integer|{top?:integer, right?:integer, bottom?:integer, left?:integer}")
    lines.append("---@alias HL.Gradient string|{colors:string[], angle?:number}")
    lines.append("")
    lines.append("---@class HL.Dispatcher")
    lines.append("local __HL_Dispatcher = {}")
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.Vec2",
            [
                ("x", "number", False),
                ("y", "number", False),
            ],
        )
    )
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.Box",
            [
                ("x", "number", False),
                ("y", "number", False),
                ("w", "number", False),
                ("h", "number", False),
            ],
        )
    )
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.LayoutTarget",
            [
                ("index", "integer", False),
                ("window", "HL.Window|nil", False),
                ("box", "HL.Box", False),
                ("place", "fun(self: HL.LayoutTarget, box: HL.Box): nil", False),
                ("set_box", "fun(self: HL.LayoutTarget, box: HL.Box): nil", False),
            ],
        )
    )
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.LayoutContext",
            [
                ("area", "HL.Box", False),
                ("targets", "HL.LayoutTarget[]", False),
                ("grid_cell", "fun(self: HL.LayoutContext, i: integer, cols: integer, rows?: integer): HL.Box", False),
                ("column", "fun(self: HL.LayoutContext, i: integer, n: integer): HL.Box", False),
                ("row", "fun(self: HL.LayoutContext, i: integer, n: integer): HL.Box", False),
                ("split", "fun(self: HL.LayoutContext, box: HL.Box, side: 'left'|'right'|'top'|'bottom'|'up'|'down', ratio: number): HL.Box", False),
            ],
        )
    )
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.LayoutProvider",
            [
                ("recalculate", "fun(ctx: HL.LayoutContext): nil", False),
                ("layout_msg", "fun(ctx: HL.LayoutContext, msg: string): boolean|string|nil", True),
            ],
        )
    )
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.BindOptions",
            [
                ("repeating", "boolean", True),
                ("locked", "boolean", True),
                ("release", "boolean", True),
                ("non_consuming", "boolean", True),
                ("transparent", "boolean", True),
                ("ignore_mods", "boolean", True),
                ("dont_inhibit", "boolean", True),
                ("long_press", "boolean", True),
                ("submap_universal", "boolean", True),
                ("click", "boolean", True),
                ("drag", "boolean", True),
                ("description", "string", True),
                ("desc", "string", True),
                ("device", "{inclusive?: boolean, list?: string[]}", True),
                ("allow_input_capture", "boolean", True),
            ],
        )
    )
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.TimerOptions",
            [
                ("timeout", "integer", False),
                ("type", '"repeat"|"oneshot"', False),
            ],
        )
    )
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.GetMonitorsOptions",
            [
                ("all", "boolean", True),
            ],
        )
    )
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.GestureSpec",
            [
                ("fingers", "integer", False),
                ("direction", "string", False),
                ("action", "string|function", False),
                ("mods", "string", True),
                ("scale", "number", True),
                ("mode", "string", True),
                ("zoom_level", "number", True),
                ("workspace_name", "string", True),
                ("disable_inhibit", "boolean", True),
            ],
        )
    )
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.PermissionSpec",
            [
                ("binary", "string", False),
                ("type", "string", False),
                ("mode", "string", False),
            ],
        )
    )
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.NotificationOptions",
            [
                ("text", "string", False),
                ("timeout", "number", False),
                ("color", "string", True),
                ("icon", "integer|string", True),
                ("font_size", "number", True),
            ],
        )
    )
    lines.append("")

    lines.extend(
        emit_class_block(
            "HL.Plugin",
            [
                ("name", "string", True),
                ("author", "string", True),
                ("version", "string", True),
                ("description", "string", True),
            ],
        )
    )
    lines.append("")

    for class_name in sorted(query_types.keys()):
        fields = [(name, typ, True) for name, typ in sorted(query_types[class_name].items())]
        lines.extend(emit_class_block(class_name, fields))
        lines.append("")

    for class_name in sorted(descriptor_classes.keys()):
        required_fields = {
            ("HL.MonitorSpec", "output"),
            ("HL.DeviceSpec", "name"),
            ("HL.WorkspaceRuleSpec", "workspace"),
        }
        fields: list[tuple[str, str, bool]] = []
        for key, typ in sorted(descriptor_classes[class_name].items()):
            optional = (class_name, key) not in required_fields
            fields.append((key, typ, optional))
        lines.extend(emit_class_block(class_name, fields))
        lines.append("")

    for class_name in sorted(object_classes.keys()):
        obj = object_classes[class_name]
        fields: list[tuple[str, str, bool]] = []
        for key in sorted(obj.methods):
            method_type = object_method_signatures.get((class_name, key), f"fun(self: {class_name}, ...): any")
            fields.append((key, method_type, False))
        for key, typ in sorted(obj.fields.items()):
            if key in obj.methods:
                continue
            fields.append((key, typ, False))

        lines.extend(emit_class_block(class_name, fields))
        lines.append("")

    def emit_namespace(node: ApiNode, path: list[str]) -> None:
        class_name = namespace_class_name(path)
        fields: list[tuple[str, str, bool]] = []

        full_prefix = ".".join(["hl", *path])

        for method in sorted(node.methods):
            full_name = f"{full_prefix}.{method}"
            default_method_type = "fun(...): HL.Dispatcher" if path and path[0] == "dsp" else "fun(...): any"
            method_type = api_signatures.get(full_name, default_method_type)
            fields.append((method, method_type, False))

        for child_name in sorted(node.children.keys()):
            fields.append((child_name, namespace_class_name(path + [child_name]), False))

        if path == ["plugin"]:
            fields.append(("[string]", "any", False))

        operator_call = None
        if path and path[-1] in callable_namespaces:
            operator_call = "fun(...): any"

        lines.extend(emit_class_block(class_name, fields, operator_call=operator_call))
        lines.append("")

        for child_name in sorted(node.children.keys()):
            emit_namespace(node.children[child_name], path + [child_name])

    emit_namespace(api_tree, [])

    lines.append("---@type HL.API")
    lines.append("hl = {}")
    lines.append("")

    # include a tiny map of config key value types for users who query values dynamically
    lines.append("---@class HL.ConfigValueTypes")
    for key, typ in sorted(config_values.items()):
        lines.append(f"---@field ['{key}'] {typ}")
    lines.append("local __HL_ConfigValueTypes = {}")
    lines.append("")

    lines.extend(emit_config_tree(config_tree, "HL.ConfigOpt", True));
    return "\n".join(lines)


def write_if_changed(path: Path, content: str) -> bool:
    if path.exists():
        existing = read_text(path)
        if existing == content:
            return False

    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(content, encoding="utf-8")
    return True


def main() -> int:
    parser = argparse.ArgumentParser(description="Generate LuaLS stubs for Hyprland Lua config API")
    parser.add_argument(
        "--root",
        type=Path,
        default=Path(__file__).resolve().parents[1],
        help="Repository root",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=None,
        help="Output .lua stub path (defaults to ./meta/hl.meta.lua)",
    )
    parser.add_argument(
        "--check",
        action="store_true",
        help="Check mode: fail if output differs from generated content",
    )
    args = parser.parse_args()

    root = args.root.resolve()
    output = args.output.resolve() if args.output else root / "meta/hl.meta.lua"

    content = generate_stub(root)

    if args.check:
        if not output.exists():
            print(f"[lua-stubs] missing generated file: {output}", file=sys.stderr)
            return 1

        existing = read_text(output)
        if existing != content:
            print(f"[lua-stubs] generated stubs are out of date: {output}", file=sys.stderr)
            return 1

        print(f"[lua-stubs] up to date: {output}")
        return 0

    changed = write_if_changed(output, content)
    state = "updated" if changed else "unchanged"
    print(f"[lua-stubs] {state}: {output}")
    return 0


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