"""
macljqCtrl —— ljqCtrl 的 macOS 等价实现 (Quartz CGEvent + screencapture)。
与 ljqCtrl.py API 镜像对齐, 跨平台代码可写 `import macljqCtrl as ljqCtrl`。

CRITICAL: 严禁 import pyautogui。
依赖: pyobjc-framework-Quartz, pyobjc-framework-Cocoa (其余 numpy/opencv/Pillow 同 Windows 版)。
坐标约定 (与 Windows 版完全一致):
  - 对外 API 接收【物理像素坐标】(= screencapture/ui_detect 截图内坐标)
  - dpi_scale = 逻辑点 / 物理像素 (Retina=0.5, 普通屏=1.0)
  - 逻辑坐标 = 物理坐标 * dpi_scale  (CGEvent 内部用逻辑点)

权限前置 (缺失则键鼠/截图静默失败):
  - 辅助功能(Accessibility): 系统设置>隐私与安全性>辅助功能, 授权 GA 宿主进程
  - 屏幕录制(Screen Recording): 同上>屏幕录制
  用 macljqCtrl.check_permissions() 自检。

API 快速参考:
  - dpi_scale: float
  - Click(x, y=None, check=True): 物理坐标; check=True 比较前后像素变化, 返回变化信息
  - SetCursorPos((x,y)): 物理坐标移动鼠标
  - Press(cmd, staytime=0): 快捷键, 如 'cmd+v' 'cmd+c' 'enter' 'cmd+shift+4'
  - TypeText(s): 直接键入文本(Unicode, 无需剪贴板)
  - MouseClick / MouseDClick / MouseDown / MouseUp / RightClick
  - GrabWindow(win) -> PIL Image: 指定窗口截图(物理像素)。win=窗口号(int)或标题/应用名子串(str)
  - GrabScreen(bbox=None) -> PIL Image: 全屏或区域截图, bbox=(l,u,r,b)物理像素
  - ScreenCapAt(x, y, r=100) -> PIL Image: 物理坐标(x,y)±r 区域截图
  - FindBlock(fn, wrect=None, threshold=0.8) -> ((cx,cy), is_found): 模板匹配, 物理坐标
  - ListWindows(name=None) -> [dict]: 枚举窗口(替代 win32gui), 返回号/标题/应用/物理bbox
  - ActivateApp(name): 激活应用到前台(替代 win32gui.SetForegroundWindow)
"""
import os, sys, time, subprocess, tempfile, math
import numpy as np

try:
    from PIL import Image, ImageGrab, ImageEnhance, ImageFilter, ImageDraw
    import cv2
    _HAS_CV2 = True
except Exception:
    _HAS_CV2 = False

import Quartz
from AppKit import NSScreen, NSWorkspace, NSPasteboard, NSStringPboardType, NSRunningApplication

verbose_click = True  # Click 像素变化打印开关

# ---------- 屏幕几何 & dpi_scale ----------
_main = Quartz.CGMainDisplayID()
_bounds = Quartz.CGDisplayBounds(_main)
cwidth = int(_bounds.size.width)    # 逻辑点宽 (CGEvent 坐标系)
cheight = int(_bounds.size.height)
_mode = Quartz.CGDisplayCopyDisplayMode(_main)
swidth = int(Quartz.CGDisplayModeGetPixelWidth(_mode))   # 物理像素宽
sheight = int(Quartz.CGDisplayModeGetPixelHeight(_mode))
dpi_scale = cwidth / swidth if swidth else 1.0           # 逻辑/物理, Retina=0.5


def check_permissions(verbose=True):
    """返回 (accessibility_ok, screen_recording_ok)。缺失时打印授权指引。"""
    sc = bool(Quartz.CGPreflightScreenCaptureAccess()) if hasattr(Quartz, 'CGPreflightScreenCaptureAccess') else None
    ax = None
    try:
        import HIServices
        ax = bool(HIServices.AXIsProcessTrusted())
    except Exception:
        try:
            from ApplicationServices import AXIsProcessTrusted
            ax = bool(AXIsProcessTrusted())
        except Exception:
            ax = None
    if verbose:
        print(f'[PERM] Accessibility(键鼠): {ax}   ScreenRecording(截图): {sc}')
        if ax is False:
            print('  → 系统设置>隐私与安全性>辅助功能, 勾选 GA 宿主进程后重启 GA')
        if sc is False:
            print('  → 系统设置>隐私与安全性>屏幕录制, 勾选 GA 宿主进程后重启 GA')
    return ax, sc


# ---------- 鼠标 ----------
def _post(ev):
    Quartz.CGEventPost(Quartz.kCGHIDEventTap, ev)

def _cursor_logical():
    e = Quartz.CGEventCreate(None)
    loc = Quartz.CGEventGetLocation(e)
    return loc.x, loc.y

def _phys_to_logical(x, y):
    return x * dpi_scale, y * dpi_scale

def SetCursorPos(z):
    """z=(x,y) 物理坐标。移动鼠标(不点击)。"""
    lx, ly = _phys_to_logical(int(z[0]), int(z[1]))
    ev = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventMouseMoved,
                                        Quartz.CGPointMake(lx, ly), Quartz.kCGMouseButtonLeft)
    _post(ev)
    time.sleep(0.05)

def _mouse_event(etype, button=Quartz.kCGMouseButtonLeft):
    lx, ly = _cursor_logical()
    ev = Quartz.CGEventCreateMouseEvent(None, etype, Quartz.CGPointMake(lx, ly), button)
    _post(ev)

def MouseDown():
    _mouse_event(Quartz.kCGEventLeftMouseDown)
def MouseUp():
    _mouse_event(Quartz.kCGEventLeftMouseUp)

def MouseClick(staytime=0.05):
    MouseDown(); time.sleep(staytime)
    MouseUp(); time.sleep(0.05)

def MouseDClick(staytime=0.05):
    # 真双击: 同坐标连发2次, 用 click state=2
    lx, ly = _cursor_logical()
    p = Quartz.CGPointMake(lx, ly)
    for state in (1, 2):
        down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseDown, p, Quartz.kCGMouseButtonLeft)
        Quartz.CGEventSetIntegerValueField(down, Quartz.kCGMouseEventClickState, state)
        _post(down)
        up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventLeftMouseUp, p, Quartz.kCGMouseButtonLeft)
        Quartz.CGEventSetIntegerValueField(up, Quartz.kCGMouseEventClickState, state)
        _post(up)
    time.sleep(0.05)

def RightClick(staytime=0.05):
    lx, ly = _cursor_logical()
    p = Quartz.CGPointMake(lx, ly)
    down = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseDown, p, Quartz.kCGMouseButtonRight)
    _post(down); time.sleep(staytime)
    up = Quartz.CGEventCreateMouseEvent(None, Quartz.kCGEventRightMouseUp, p, Quartz.kCGMouseButtonRight)
    _post(up); time.sleep(0.05)

# ---------- 键盘 ----------
# macOS 虚拟键码 (kVK_*)
_KEYMAP = {
    'a':0,'s':1,'d':2,'f':3,'h':4,'g':5,'z':6,'x':7,'c':8,'v':9,'b':11,'q':12,
    'w':13,'e':14,'r':15,'y':16,'t':17,'1':18,'2':19,'3':20,'4':21,'6':22,'5':23,
    '=':24,'9':25,'7':26,'-':27,'8':28,'0':29,']':30,'o':31,'u':32,'[':33,'i':34,
    'p':35,'l':37,'j':38,"'":39,'k':40,';':41,'\\':42,',':43,'/':44,'n':45,'m':46,
    '.':47,'`':50,
    'enter':36,'return':36,'tab':48,'space':49,' ':49,'delete':51,'backspace':51,
    'esc':53,'escape':53,'forwarddelete':117,
    'left':123,'right':124,'down':125,'up':126,
    'home':115,'end':119,'pageup':116,'pagedown':121,
    'f1':122,'f2':120,'f3':99,'f4':118,'f5':96,'f6':97,'f7':98,'f8':100,'f9':101,
    'f10':109,'f11':103,'f12':111,
}
_MODS = {
    'cmd':Quartz.kCGEventFlagMaskCommand, 'command':Quartz.kCGEventFlagMaskCommand,
    'ctrl':Quartz.kCGEventFlagMaskControl, 'control':Quartz.kCGEventFlagMaskControl,
    'alt':Quartz.kCGEventFlagMaskAlternate, 'option':Quartz.kCGEventFlagMaskAlternate,
    'opt':Quartz.kCGEventFlagMaskAlternate,
    'shift':Quartz.kCGEventFlagMaskShift,
    'fn':Quartz.kCGEventFlagMaskSecondaryFn,
}

def _key_tap(keycode, flags=0):
    down = Quartz.CGEventCreateKeyboardEvent(None, keycode, True)
    if flags: Quartz.CGEventSetFlags(down, flags)
    _post(down)
    up = Quartz.CGEventCreateKeyboardEvent(None, keycode, False)
    if flags: Quartz.CGEventSetFlags(up, flags)
    _post(up)

def Press(cmd, staytime=0):
    """快捷键。如 'cmd+v' 'cmd+shift+4' 'enter' 'cmd+c'。Win版的 ctrl 在mac多对应 cmd, 调用方自行决定。"""
    parts = [p.strip().lower() for p in str(cmd).split('+') if p.strip()]
    flags = 0; key = None
    for p in parts:
        if p in _MODS: flags |= _MODS[p]
        else: key = p
    if key is None:
        return
    kc = _KEYMAP.get(key)
    if kc is None:
        # 单字符走 TypeText
        TypeText(key)
    else:
        _key_tap(kc, flags)
    if staytime: time.sleep(staytime)
    time.sleep(0.03)

def TypeText(s):
    """直接键入 Unicode 文本 (无需剪贴板)。"""
    for ch in str(s):
        ev = Quartz.CGEventCreateKeyboardEvent(None, 0, True)
        Quartz.CGEventKeyboardSetUnicodeString(ev, len(ch), ch)
        _post(ev)
        ev2 = Quartz.CGEventCreateKeyboardEvent(None, 0, False)
        Quartz.CGEventKeyboardSetUnicodeString(ev2, len(ch), ch)
        _post(ev2)
        time.sleep(0.005)

# 剪贴板 (替代 pyperclip)
def set_clipboard(text):
    pb = NSPasteboard.generalPasteboard()
    pb.clearContents()
    pb.setString_forType_(text, NSStringPboardType)

def get_clipboard():
    pb = NSPasteboard.generalPasteboard()
    return pb.stringForType_(NSStringPboardType)

def Paste(text):
    """把 text 放剪贴板并 cmd+v 粘贴 (等价 Win版 pyperclip+ctrl+v)。"""
    set_clipboard(text); time.sleep(0.05); Press('cmd+v')


# ---------- 截图 (screencapture, 输出物理像素) ----------
def GrabScreen(bbox=None):
    """全屏或区域截图 -> PIL Image。bbox=(l,u,r,b) 物理像素坐标。
    传 bbox 后图内坐标相对裁剪原点; 转屏幕绝对坐标用 CropToScreen, 勿手搓 screencapture -R。
    """
    fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd)
    try:
        if bbox:
            l, u, r, b = bbox
            # screencapture -R 用逻辑点; 转换 物理->逻辑
            lx, ly = l*dpi_scale, u*dpi_scale
            lw, lh = (r-l)*dpi_scale, (b-u)*dpi_scale
            cmd = ['/usr/sbin/screencapture','-x','-t','png',
                   f'-R{lx:.0f},{ly:.0f},{lw:.0f},{lh:.0f}', fn]
        else:
            cmd = ['/usr/sbin/screencapture','-x','-t','png', fn]
        subprocess.run(cmd, check=True, capture_output=True)
        return Image.open(fn).copy()
    finally:
        try: os.remove(fn)
        except Exception: pass

def ScreenCapAt(x, y, r=100):
    """物理坐标(x,y)为中心±r 截图 -> PIL Image。"""
    return GrabScreen((x-r, y-r, x+r, y+r))

def CropToScreen(bbox, x, y=None):
    """裁剪图内坐标 -> 屏幕绝对物理坐标。bbox=GrabScreen 用的 (l,u,r,b) 物理像素。
    (x,y)=在 GrabScreen(bbox) 返回图内找到的点。返回可直接喂给 Click 的 (X,Y)。
    macOS 版的 ClientToScreen: 纯加裁剪原点偏移, 不做缩放(裁剪图与 bbox 同物理像素)。"""
    if y is None and isinstance(x, (tuple, list)):
        x, y = x[0], x[1]
    return int(bbox[0] + x), int(bbox[1] + y)

def GrabWindow(win):
    """窗口截图 -> PIL Image。win=窗口号(int) 或 标题/应用名子串(str)。物理像素。"""
    if isinstance(win, str):
        ws = ListWindows(win)
        if not ws: raise RuntimeError(f'window not found: {win}')
        win = ws[0]['id']
    fd, fn = tempfile.mkstemp(suffix='.png'); os.close(fd)
    try:
        subprocess.run(['/usr/sbin/screencapture','-x','-o','-l',str(win),'-t','png',fn],
                       check=True, capture_output=True)
        return Image.open(fn).copy()
    finally:
        try: os.remove(fn)
        except Exception: pass

# ---------- 窗口枚举 / 激活 (替代 win32gui) ----------
def ListWindows(name=None):
    """枚举屏上窗口 -> [{'id','title','app','bbox'(物理像素 l,u,r,b),'pid'}]。
    name: 标题或应用名子串过滤(不区分大小写)。"""
    opts = Quartz.kCGWindowListOptionOnScreenOnly | Quartz.kCGWindowListExcludeDesktopElements
    infos = Quartz.CGWindowListCopyWindowInfo(opts, Quartz.kCGNullWindowID)
    out = []
    inv = 1.0 / dpi_scale  # 逻辑->物理
    for w in infos:
        b = w.get('kCGWindowBounds') or {}
        layer = w.get('kCGWindowLayer', 0)
        if layer != 0:  # 只要普通应用窗口层
            continue
        title = w.get('kCGWindowName') or ''
        app = w.get('kCGWindowOwnerName') or ''
        l = b.get('X', 0)*inv; u = b.get('Y', 0)*inv
        r = l + b.get('Width', 0)*inv; bo = u + b.get('Height', 0)*inv
        rec = {'id': int(w.get('kCGWindowNumber', 0)), 'title': title, 'app': app,
               'pid': int(w.get('kCGWindowOwnerPID', 0)),
               'bbox': (int(l), int(u), int(r), int(bo))}
        if name:
            n = name.lower()
            if n not in title.lower() and n not in app.lower():
                continue
        out.append(rec)
    return out

def ActivateApp(target):
    """激活应用到前台 (替代 SetForegroundWindow)。

    macOS 的前台是 *应用* 粒度而非窗口句柄粒度, 故无法 1:1 镜像 Win 版
    Activate(hwnd)。target 支持两种定位键, 优先用精确的:
      - int  : 进程 pid (来自 ListWindows 的 'pid' 字段) —— 精确, 不受语言/本地化影响, **推荐**
      - str  : 应用名 / bundle id。按精确度分级匹配, 避免误中同厂商应用:
               ① bundle id 精确等值 (如 'com.tencent.meeting') —— 最可靠
               ② localizedName 精确等值 (如 '腾讯会议')
               ③ localizedName 子串 (最后兜底, 可能模糊)
               注意: 不对 bundle id 做子串匹配, 因同厂商应用共享前缀
               (微信 com.tencent.xinWeChat 与腾讯会议 com.tencent.meeting 都含 'tencent')。
    返回是否成功。"""
    ws = NSWorkspace.sharedWorkspace()
    # 1) pid 精确激活 (首选)
    if isinstance(target, int):
        app = NSRunningApplication.runningApplicationWithProcessIdentifier_(target)
        if app is not None:
            app.activateWithOptions_(1 << 1)  # NSApplicationActivateAllWindows
            time.sleep(0.3)
            return True
        return False
    # 2) 字符串: 按精确度分级匹配 (避免同厂商前缀误伤)
    key = str(target)
    keyl = key.lower()
    apps = list(ws.runningApplications())
    def _fire(app):
        app.activateWithOptions_(1 << 1)
        time.sleep(0.3)
        return True
    # ① bundle id 精确等值
    for app in apps:
        if (app.bundleIdentifier() or '').lower() == keyl:
            return _fire(app)
    # ② localizedName 精确等值
    for app in apps:
        if (app.localizedName() or '').lower() == keyl:
            return _fire(app)
    # ③ localizedName 子串 (兜底)
    for app in apps:
        if keyl in (app.localizedName() or '').lower():
            return _fire(app)
    # 3) 兜底用 open -a
    try:
        subprocess.run(['open', '-a', str(target)], check=True, capture_output=True)
        time.sleep(0.5); return True
    except Exception:
        return False


# ---------- 模板匹配 FindBlock ----------
def GetWRect(sr):
    """快捷区域名 -> 物理像素 [l,u,r,b]。如 'left2'=左半屏, 'top3'=上1/3。"""
    num = int(sr[-1])
    l, u, r, b = 0, 0, swidth, sheight
    if 'left' in sr: r = swidth // num
    if 'right' in sr: l = swidth * (num - 1) // num
    if 'top' in sr: b = sheight // num
    if 'bottom' in sr: u = sheight * (num - 1) // num
    return [l, u, r, b]

def FindBlock(fn, wrect=None, verbose=0, threshold=0.8):
    """在屏幕(或wrect区域)内找模板图 fn。返回 ((cx,cy), is_found), 物理坐标。
    fn: 模板图路径(str)或 PIL Image。
    wrect: None=全屏 | [l,u,r,b]物理像素 | 'left2'等快捷名 | PIL Image(直接当搜索底图)。"""
    if not _HAS_CV2:
        raise RuntimeError('FindBlock 需要 opencv-python 和 Pillow')
    if wrect is not None and isinstance(wrect, Image.Image):
        scr, wrect = wrect, None
    else:
        if isinstance(wrect, str): wrect = GetWRect(wrect)
        scr = GrabScreen(wrect)  # 物理像素
    blc = Image.open(fn) if isinstance(fn, str) else fn
    T = cv2.cvtColor(np.array(blc), cv2.COLOR_RGB2BGR)
    B = cv2.cvtColor(np.array(scr), cv2.COLOR_RGB2BGR)
    tsh, tsw = T.shape[:2]
    res = cv2.matchTemplate(B, T, cv2.TM_CCOEFF_NORMED)
    _, max_val, _, max_loc = cv2.minMaxLoc(res)
    oj, oi = max_loc
    if wrect is None: wrect = [0, 0, scr.size[0], scr.size[1]]
    obj = (oj + wrect[0] + tsw // 2, oi + wrect[1] + tsh // 2)
    if verbose:
        print(f'FindBlock {fn}: score={max_val:.4f} at phys={obj}')
    return obj, max_val > threshold

def imshow(mt, sec=0):
    cv2.imshow('cc', mt); cv2.waitKey(sec)



# ---------- Click (带像素变化检测) ----------
def Click(x, y=None, check=True, r=60):
    """物理坐标点击。check=True 时比较点击前后周边像素变化, 返回 (变化百分比, 截图)。
    若变化≈0 → 可能点歪了 (同 Win 版语义)。"""
    if y is None and isinstance(x, (tuple, list)):
        x, y = x[0], x[1]
    x, y = int(x), int(y)
    before = None
    if check:
        try: before = np.array(ScreenCapAt(x, y, r))
        except Exception: before = None
    SetCursorPos((x, y)); time.sleep(0.05)
    MouseClick()
    if check:
        time.sleep(0.25)
        try:
            after = np.array(ScreenCapAt(x, y, r))
        except Exception:
            return None
        if before is not None and before.shape == after.shape:
            diff = np.mean(np.any(before != after, axis=-1)) * 100
            if verbose_click:
                print(f'Click({x},{y}) pixel change: {diff:.1f}%')
            if diff < 0.5:
                print(f'[WARN] Click({x},{y}) 像素变化≈0%, 可能点歪了, 请诊断坐标! '
                       '常见错因: 用了裁剪图内坐标却没加裁剪原点(用 CropToScreen), '
                       '或对已是物理像素的坐标又做了 *dpi_scale 换算。')
            return diff, Image.fromarray(after)
    return None


# ---------- AX 辅助功能控件枚举 (UIA 层的 macOS 等价) ----------
try:
    from ApplicationServices import (
        AXUIElementCreateApplication, AXUIElementCopyAttributeValue,
        AXValueGetValue, AXUIElementPerformAction, AXIsProcessTrusted,
        kAXChildrenAttribute, kAXRoleAttribute, kAXDescriptionAttribute,
        kAXPositionAttribute, kAXSizeAttribute, kAXWindowsAttribute,
        kAXTitleAttribute, kAXValueCGPointType, kAXValueCGSizeType,
        kAXPressAction, kAXEnabledAttribute,
    )
    _HAS_AX = True
except ImportError:
    _HAS_AX = False


def _resolve_pid(target):
    """target(int pid | str bundle_id/应用名) → pid(int)。

    str 优先按 bundle id 精确匹配, 再按 localizedName 精确/子串兜底,
    与 ActivateApp 的匹配纪律一致, 避免同厂商前缀误伤。"""
    if isinstance(target, int):
        return int(target)
    key = str(target); keyl = key.lower()
    ws = NSWorkspace.sharedWorkspace()
    apps = list(ws.runningApplications())
    for a in apps:                                    # ① bundle id 精确
        if (a.bundleIdentifier() or '') == key:
            return int(a.processIdentifier())
    for a in apps:                                    # ② localizedName 精确
        if (a.localizedName() or '').lower() == keyl:
            return int(a.processIdentifier())
    for a in apps:                                    # ③ localizedName 子串
        if keyl in (a.localizedName() or '').lower():
            return int(a.processIdentifier())
    raise ValueError(f'找不到 target={target!r} 对应的运行中应用')


def _ax_attr(el, key):
    """读取单个 AX 属性,失败返回 None。"""
    if not _HAS_AX:
        return None
    err, val = AXUIElementCopyAttributeValue(el, key, None)
    return val if err == 0 else None


def AXElements(target, max_depth=10, include_zero_size=False):
    """枚举应用控件树。

    Parameters
    ----------
    target : int | str
        pid(int) 或 bundle_id(str, 如 'com.tencent.meeting')。
    max_depth : int
        递归深度上限。
    include_zero_size : bool
        是否包含 w<=0 或 h<=0 的零尺寸节点。

    Returns
    -------
    list[dict] : 每项含 role/desc/title/id/value/x/y/w/h(物理像素)/depth/el。
        value 为控件当前值(文本/输入框内容等),非文本值为 None。
        el 是原始 AXUIElement 引用,目标窗口关闭/重建后失效,AXPress 前宜就近重新枚举。
    """
    if not _HAS_AX:
        raise RuntimeError(
            'AX 不可用。请安装: pip install pyobjc-framework-ApplicationServices')
    if not AXIsProcessTrusted():
        raise PermissionError('需要授予辅助功能权限(系统设置 > 隐私与安全 > 辅助功能)')
    pid = _resolve_pid(target)

    app_el = AXUIElementCreateApplication(pid)
    wins = _ax_attr(app_el, kAXWindowsAttribute) or []
    scale = dpi_scale  # 逻辑/物理, Retina=0.5

    results = []

    def _walk(el, depth):
        if depth > max_depth:
            return
        role = _ax_attr(el, kAXRoleAttribute)
        desc = _ax_attr(el, kAXDescriptionAttribute)
        title = _ax_attr(el, kAXTitleAttribute)
        ident = _ax_attr(el, 'AXIdentifier')
        value = _ax_attr(el, 'AXValue')
        enabled = _ax_attr(el, kAXEnabledAttribute)
        # AXValue 多为 str/num(文本/输入框);若是 AXValueRef(坐标等)忽略
        if value is not None and not isinstance(value, (str, int, float, bool)):
            value = None
        pos_val = _ax_attr(el, kAXPositionAttribute)
        sz_val = _ax_attr(el, kAXSizeAttribute)
        # 解包坐标(逻辑点)
        px = py_ = pw = ph = 0.0
        if pos_val is not None:
            ok, pt = AXValueGetValue(pos_val, kAXValueCGPointType, None)
            if ok:
                px, py_ = pt.x, pt.y
        if sz_val is not None:
            ok, sz = AXValueGetValue(sz_val, kAXValueCGSizeType, None)
            if ok:
                pw, ph = sz.width, sz.height
        # 转物理像素
        phys_x = px / scale if scale else px
        phys_y = py_ / scale if scale else py_
        phys_w = pw / scale if scale else pw
        phys_h = ph / scale if scale else ph

        if not include_zero_size and (phys_w <= 0 or phys_h <= 0):
            pass  # 跳过零尺寸,但仍递归子节点
        else:
            results.append(dict(
                depth=depth, role=role, desc=desc, title=title, id=ident,
                value=value, enabled=bool(enabled) if enabled is not None else None,
                x=round(phys_x), y=round(phys_y),
                w=round(phys_w), h=round(phys_h), el=el))
        for child in (_ax_attr(el, kAXChildrenAttribute) or []):
            _walk(child, depth + 1)

    for win in wins:
        _walk(win, 0)
    return results


def AXPress(element) -> bool:
    """对 AX element 执行 Press 动作(免坐标点击)。"""
    if not _HAS_AX:
        return False
    err = AXUIElementPerformAction(element, kAXPressAction)
    return err == 0


def AXClick(node, check=True) -> bool:
    """点击控件: AXPress 优先(免坐标), 失败回退到中心点物理坐标 Click。
    node: AXFind/AXElements 返回的 dict(含 el 与 x/y/w/h), 或裸 AXUIElement。
    返回是否点击成功(回退路径据像素变化判定, check=False 时无法判定按 True)。
    呼应 computer_use SOP: AXPress 优先, 回退 Click(phys_cx, phys_cy)。"""
    if not isinstance(node, dict):
        return AXPress(node)
    if node.get('enabled') is False:
        print(f"[WARN] AXClick: 控件 disabled (role={node.get('role')}, "
              f"title={node.get('title')!r}), 点击可能无效")
    if AXPress(node.get('el')):
        return True
    # 回退: 中心点物理坐标
    cx = node['x'] + node['w'] // 2
    cy = node['y'] + node['h'] // 2
    res = Click(cx, cy, check=check)
    if not check or res is None:
        return check is False  # 无法判定时: 关检查按成功, 截图失败按失败
    diff, _ = res
    return diff >= 0.5


def AXFind(target, role=None, desc=None, title=None, identifier=None,
           enabled_only=False, max_depth=10):
    """枚举并过滤控件。所有过滤条件为子串匹配(大小写不敏感)。
    enabled_only=True 时只返回 enabled 的控件(SOP: 点前查 disabled)。

    Returns
    -------
    list[dict] : 匹配项,同 AXElements 返回格式。
    """
    def _hit(field, needle):
        return needle is None or (field and needle.lower() in field.lower())
    return [n for n in AXElements(target, max_depth=max_depth)
            if _hit(n['role'], role) and _hit(n['desc'], desc)
            and _hit(n['title'], title) and _hit(n['id'], identifier)
            and not (enabled_only and n.get('enabled') is False)]


# ---------- API 镜像别名 (drop-in 替换 ljqCtrl 用) ----------
click = Click
press = Press
activate = ActivateApp        # 注意: Win 版 Activate(hwnd) 收窗口句柄, Mac 版收应用名子串
GrabWindowBg = GrabWindow     # Mac screencapture 本身支持后台窗口
VK_CODE = _KEYMAP             # 名称兼容


if __name__ == '__main__':
    print('--- macljqCtrl self-check ---')
    check_permissions()
    print('cursor(logical):', _cursor_logical())
    print('windows(top5):')
    for w in ListWindows()[:5]:
        print('  ', w['id'], '|', w['app'], '|', w['title'][:30], '|', w['bbox'])
