import java.awt.*;
import java.awt.event.*;
import java.io.*;
import java.lang.instrument.Instrumentation;
import java.lang.reflect.*;
import java.net.*;
import java.util.*;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.swing.*;
import javax.swing.text.JTextComponent;

/**
 * SwingBridgeAgentV8 — Swing bridge + JavaFX (JFXPanel/Scene graph) bridge,
 * in one class, loaded under a fresh class name to avoid the "already
 * started" static-guard collision documented in REPORT.md for repeated
 * attach/detach cycles against the same long-lived JVM (see Blocker #3 in
 * the original report).
 *
 * Protocol is the original line-based text protocol on 127.0.0.1:9797,
 * PLUS four new FX* commands that reach into embedded JavaFX scene graphs
 * living inside javafx.embed.swing.JFXPanel components found anywhere in
 * the Swing component tree (Window.getWindows() walk, same discovery
 * mechanism as the original Swing TREE):
 *
 *   TREE                          -> (unchanged) full Swing component tree JSON
 *   CLICK <selector>              -> (unchanged) Swing AbstractButton.doClick()
 *   SETTEXT <selector> <value>    -> (unchanged) Swing JTextComponent.setText()
 *   GETTEXT <selector>            -> (unchanged) Swing component text
 *   PING                          -> PONG
 *
 *   FXTREE                        -> walks every JFXPanel's Scene graph (JSON)
 *   FXCLICK <selector>            -> ButtonBase.fire() / Tab select / TableView row select
 *   FXSETTEXT <selector> <value>  -> TextInputControl.setText()
 *   FXGETTEXT <selector>          -> current text of matched FX node
 *
 * CRITICAL THREADING DIFFERENCE vs the Swing half of this file: JavaFX has
 * its OWN application thread (the "JavaFX Application Thread"), completely
 * separate from the Swing EDT. All javafx.scene.* reads/writes here are
 * marshalled onto it via javafx.application.Platform.runLater(), NOT
 * SwingUtilities.invokeAndWait. A CountDownLatch is used to make the
 * TCP-handler-thread call synchronous, mirroring (in spirit, not
 * mechanism) how the Swing half uses SwingUtilities.invokeAndWait.
 *
 * All javafx.* access is done via reflection so this file can still be
 * *compiled* against a JDK that may or may not have JavaFX on its default
 * bootclasspath (older JDK8 non-JavaFX-fx builds, JDK 11+ split-out FX,
 * etc.) as long as jfxrt.jar (or the FX module) is reachable via -cp at
 * compile+run time inside the target JVM's own classpath, which it always
 * is here because MCSJ itself already loads and uses JavaFX (JFXPanel is
 * already resident in the target JVM's runtime classes by the time we
 * inject, since MCSJ's own UI created it long before we attach).
 */
public class SwingBridgeAgentV11 {

    private static final int PORT = 9797;
    private static final AtomicBoolean STARTED = new AtomicBoolean(false);

    public static void agentmain(String agentArgs, Instrumentation inst) { start(agentArgs); }
    public static void premain(String agentArgs, Instrumentation inst) { start(agentArgs); }

    private static void start(String agentArgs) {
        if (!STARTED.compareAndSet(false, true)) return;
        int port = PORT;
        if (agentArgs != null && agentArgs.trim().length() > 0) {
            try { port = Integer.parseInt(agentArgs.trim()); } catch (NumberFormatException ignore) {}
        }
        final int finalPort = port;
        Thread t = new Thread(new Runnable() {
            public void run() { runServer(finalPort); }
        }, "SwingBridgeAgentV11-Listener");
        t.setDaemon(true);
        t.start();
        System.err.println("[SwingBridgeAgentV8] listening on 127.0.0.1:" + finalPort);
    }

    private static void runServer(int port) {
        ServerSocket server = null;
        try {
            server = new ServerSocket();
            server.setReuseAddress(true);
            server.bind(new InetSocketAddress(InetAddress.getByName("127.0.0.1"), port));
            while (true) {
                Socket client = server.accept();
                handleClientSafely(client);
            }
        } catch (Throwable t) {
            System.err.println("[SwingBridgeAgentV8] server error: " + t);
            t.printStackTrace();
        } finally {
            if (server != null) { try { server.close(); } catch (IOException ignore) {} }
        }
    }

    private static void handleClientSafely(final Socket client) {
        Thread worker = new Thread(new Runnable() {
            public void run() {
                try { handleClient(client); }
                catch (Throwable t) { System.err.println("[SwingBridgeAgentV8] client error: " + t); }
                finally { try { client.close(); } catch (IOException ignore) {} }
            }
        }, "SwingBridgeAgentV8-Client");
        worker.setDaemon(true);
        worker.start();
    }

    private static void handleClient(Socket client) throws IOException {
        client.setSoTimeout(30000);
        BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream(), "UTF-8"));
        OutputStream rawOut = client.getOutputStream();
        PrintWriter out = new PrintWriter(new OutputStreamWriter(rawOut, "UTF-8"), false);

        String line = in.readLine();
        if (line == null) return;
        String response;
        try {
            response = dispatch(line);
        } catch (Throwable t) {
            response = errorJson(t.getClass().getSimpleName() + ": " + String.valueOf(t.getMessage()));
        }
        out.print(response);
        if (!response.endsWith("\n")) out.print("\n");
        out.flush();
    }

    // ---------------------------------------------------------------
    // Command dispatch
    // ---------------------------------------------------------------

    private static String dispatch(String line) throws Exception {
        line = line.trim();
        if (line.length() == 0) return "ERROR empty command";

        String cmd;
        String rest;
        int sp = line.indexOf(' ');
        if (sp < 0) { cmd = line; rest = ""; } else { cmd = line.substring(0, sp); rest = line.substring(sp + 1).trim(); }
        cmd = cmd.toUpperCase(Locale.ROOT);

        if (cmd.equals("TREE")) {
            return runOnEdtForString(new java.util.concurrent.Callable<String>() {
                public String call() { return dumpTree(); }
            });
        } else if (cmd.equals("CLICK") || cmd.equals("SETTEXT")) {
            return errorJson("legacy Swing mutation command disabled in hardened agent");
        } else if (cmd.equals("GETTEXT")) {
            final String selector = rest;
            return runOnEdtForString(new java.util.concurrent.Callable<String>() {
                public String call() { return doGetText(selector); }
            });
        } else if (cmd.equals("PING")) {
            return "PONG";
        } else if (cmd.equals("FXWINDOWSTATE")) {
            String[] a = rest.split("\\s+");
            if (a.length != 1) return errorJson("usage: FXWINDOWSTATE <exactWindowTitle>");
            final String windowTitle = decodeToken(a[0]);
            return runOnFxThreadForString(new java.util.concurrent.Callable<String>() {
                public String call() throws Exception { return fxWindowState(windowTitle); }
            });
        } else if (cmd.equals("FXWINDOWBUTTON")) {
            String[] a = rest.split("\\s+");
            if (a.length != 3) return errorJson("usage: FXWINDOWBUTTON <exactWindowTitle> <buttonText> <expectedDisabled>");
            final String windowTitle = decodeToken(a[0]);
            final String buttonText = decodeToken(a[1]);
            final boolean expectedDisabled = Boolean.parseBoolean(a[2]);
            return runOnFxThreadForString(new java.util.concurrent.Callable<String>() {
                public String call() throws Exception { return fxWindowButton(windowTitle, buttonText, expectedDisabled); }
            });
        } else if (cmd.equals("FXSELECTTAB")) {
            String[] a = rest.split("\\s+");
            if (a.length != 3) return errorJson("usage: FXSELECTTAB <panelClass> <tabText> <expectedSelected>");
            final String panelClass = decodeToken(a[0]);
            final String tabText = decodeToken(a[1]);
            final boolean expectedSelected = Boolean.parseBoolean(a[2]);
            final List<Component> panels = snapshotJfxPanels();
            return runOnFxThreadForString(new java.util.concurrent.Callable<String>() {
                public String call() throws Exception { return fxSelectTab(panels, panelClass, tabText, expectedSelected); }
            });
        } else if (cmd.equals("FXTABLECELL")) {
            String[] a = rest.split("\\s+");
            if (a.length != 8) return errorJson("usage: FXTABLECELL <panelClass> <panelOccurrence> <headerText> <tableOccurrence> <row> <column> <action> <expectedCellText>");
            final String panelClass = decodeToken(a[0]);
            final int panelOccurrence = parseNonNegativeInt(a[1], "panelOccurrence");
            final String headerText = decodeToken(a[2]);
            final int tableOccurrence = parseNonNegativeInt(a[3], "tableOccurrence");
            final int row = parseNonNegativeInt(a[4], "row");
            final int column = parseNonNegativeInt(a[5], "column");
            final String action = a[6].toLowerCase(Locale.ROOT);
            final String expectedCellText = decodeToken(a[7]);
            final List<Component> panels = snapshotJfxPanels();
            return runOnFxThreadForString(new java.util.concurrent.Callable<String>() {
                public String call() throws Exception { return fxTableCell(panels, panelClass, panelOccurrence, headerText, tableOccurrence, row, column, action, expectedCellText); }
            });
        } else if (cmd.equals("FXEDITTEXT")) {
            String[] a = rest.split("\\s+");
            if (a.length != 8) return errorJson("usage: FXEDITTEXT <panelClass> <panelOccurrence> <nodeClass> <id> <text> <nodeOccurrence> <expectedOldText> <newText>");
            final String panelClass = decodeToken(a[0]);
            final int panelOccurrence = parseNonNegativeInt(a[1], "panelOccurrence");
            final String nodeClass = decodeToken(a[2]);
            final String id = decodeToken(a[3]);
            final String text = decodeToken(a[4]);
            final int nodeOccurrence = parseNonNegativeInt(a[5], "nodeOccurrence");
            final String expectedOldText = decodeToken(a[6]);
            final String newText = decodeToken(a[7]);
            final List<Component> panels = snapshotJfxPanels();
            return runOnFxThreadForString(new java.util.concurrent.Callable<String>() {
                public String call() throws Exception { return fxEditText(panels, panelClass, panelOccurrence, nodeClass, id, text, nodeOccurrence, expectedOldText, newText); }
            });
        } else if (cmd.equals("FXFIREBUTTON")) {
            String[] a = rest.split("\\s+");
            if (a.length != 3) return errorJson("usage: FXFIREBUTTON <panelClass> <buttonText> <expectedDisabled>");
            final String panelClass = decodeToken(a[0]);
            final String buttonText = decodeToken(a[1]);
            final boolean expectedDisabled = Boolean.parseBoolean(a[2]);
            final List<Component> panels = snapshotJfxPanels();
            return runOnFxThreadForString(new java.util.concurrent.Callable<String>() {
                public String call() throws Exception { return fxFireButton(panels, panelClass, buttonText, expectedDisabled); }
            });
        } else if (cmd.equals("FXSELECTRADIO")) {
            String[] a = rest.split("\\s+");
            if (a.length != 4) return errorJson("usage: FXSELECTRADIO <panelClass> <radioText> <expectedSelected> <newSelected>");
            final String panelClass = decodeToken(a[0]);
            final String radioText = decodeToken(a[1]);
            final boolean expectedSelected = Boolean.parseBoolean(a[2]);
            final boolean newSelected = Boolean.parseBoolean(a[3]);
            final List<Component> panels = snapshotJfxPanels();
            return runOnFxThreadForString(new java.util.concurrent.Callable<String>() {
                public String call() throws Exception { return fxSelectRadio(panels, panelClass, radioText, expectedSelected, newSelected); }
            });
        } else if (cmd.equals("FXFIND")) {
            String[] a = rest.split("\\s+");
            if (a.length != 6) return errorJson("usage: FXFIND <panelClass> <panelOccurrence> <nodeClass> <id> <text> <limit>");
            final String panelClass = decodeToken(a[0]);
            final int panelOccurrence = parseNonNegativeInt(a[1], "panelOccurrence");
            final String nodeClass = decodeToken(a[2]);
            final String id = decodeToken(a[3]);
            final String text = decodeToken(a[4]);
            final int limit = Math.min(200, Math.max(1, parseNonNegativeInt(a[5], "limit")));
            final List<Component> panels = snapshotJfxPanels();
            return runOnFxThreadForString(new java.util.concurrent.Callable<String>() {
                public String call() throws Exception { return fxFind(panels, panelClass, panelOccurrence, nodeClass, id, text, limit); }
            });
        } else if (cmd.equals("FXSTATE")) {
            final List<Component> panels = snapshotJfxPanels();
            return runOnFxThreadForString(new java.util.concurrent.Callable<String>() {
                public String call() throws Exception { return fxState(panels); }
            });
        } else if (cmd.equals("FXTREE")) {
            return errorJson("legacy FXTREE disabled in hardened agent; use FXSTATE/FXFIND");
        } else if (cmd.equals("FXCLICK") || cmd.equals("FXSETTEXT")) {
            return errorJson("legacy JavaFX mutation command disabled; use guarded FXEDITTEXT/FXTABLECELL");
        } else if (cmd.equals("FXGETTEXT")) {
            return errorJson("legacy global FXGETTEXT disabled in hardened agent; use scoped FXFIND");
        } else {
            return errorJson("unknown command: " + cmd);
        }
    }

    private static String runOnEdtForString(final java.util.concurrent.Callable<String> task) throws Exception {
        if (SwingUtilities.isEventDispatchThread()) {
            try { return task.call(); } catch (Exception e) { throw e; } catch (Throwable t) { throw new RuntimeException(t); }
        }
        final String[] result = new String[1];
        final Throwable[] error = new Throwable[1];
        SwingUtilities.invokeAndWait(new Runnable() {
            public void run() {
                try { result[0] = task.call(); } catch (Throwable t) { error[0] = t; }
            }
        });
        if (error[0] != null) {
            if (error[0] instanceof Exception) throw (Exception) error[0];
            throw new RuntimeException(error[0]);
        }
        return result[0];
    }

    // ---------------------------------------------------------------
    // TREE (Swing) — identical logic to SwingBridgeAgent.java
    // ---------------------------------------------------------------

    private static String dumpTree() {
        StringBuilder sb = new StringBuilder();
        Window[] windows = Window.getWindows();
        sb.append("{\"windowCount\":").append(windows.length).append(",\"windows\":[\n");
        for (int i = 0; i < windows.length; i++) {
            appendComponentJson(sb, windows[i], 1);
            if (i < windows.length - 1) sb.append(",");
            sb.append("\n");
        }
        sb.append("]}");
        return sb.toString();
    }

    private static void appendComponentJson(StringBuilder sb, Component c, int indent) {
        indentSb(sb, indent);
        sb.append("{");
        sb.append("\"class\":").append(jsonStr(c.getClass().getName())).append(",");
        sb.append("\"name\":").append(jsonStr(safeName(c))).append(",");
        sb.append("\"text\":").append(jsonStr(safeText(c))).append(",");
        Rectangle b = c.getBounds();
        sb.append("\"bounds\":{\"x\":").append(b.x).append(",\"y\":").append(b.y)
          .append(",\"w\":").append(b.width).append(",\"h\":").append(b.height).append("},");
        sb.append("\"visible\":").append(c.isVisible()).append(",");
        sb.append("\"enabled\":").append(c.isEnabled()).append(",");
        sb.append("\"showing\":").append(c.isShowing());

        if (c instanceof Window) {
            String title = getTitleIfAny(c);
            if (title != null) sb.append(",\"title\":").append(jsonStr(title));
        }

        boolean isJfxPanel = isJFXPanel(c);
        if (isJfxPanel) {
            sb.append(",\"isJFXPanel\":true");
        }

        Component[] children = getChildren(c);
        if (children != null && children.length > 0) {
            sb.append(",\"children\":[\n");
            for (int i = 0; i < children.length; i++) {
                appendComponentJson(sb, children[i], indent + 1);
                if (i < children.length - 1) sb.append(",");
                sb.append("\n");
            }
            indentSb(sb, indent);
            sb.append("]");
        }
        sb.append("}");
    }

    private static boolean isJFXPanel(Component c) {
        try {
            return Class.forName("javafx.embed.swing.JFXPanel").isInstance(c);
        } catch (Throwable t) {
            return false;
        }
    }

    private static Component[] getChildren(Component c) {
        if (c instanceof Container) return ((Container) c).getComponents();
        return null;
    }

    private static String getTitleIfAny(Component c) {
        try {
            if (c instanceof Frame) return ((Frame) c).getTitle();
            if (c instanceof Dialog) return ((Dialog) c).getTitle();
            Method m = c.getClass().getMethod("getTitle");
            Object v = m.invoke(c);
            return v == null ? null : v.toString();
        } catch (Throwable t) { return null; }
    }

    private static String safeName(Component c) {
        try { String n = c.getName(); return n == null ? "" : n; }
        catch (Throwable t) { return ""; }
    }

    private static String safeText(Component c) {
        try {
            if (c instanceof JLabel) return nullToEmpty(((JLabel) c).getText());
            if (c instanceof AbstractButton) return nullToEmpty(((AbstractButton) c).getText());
            if (c instanceof JTextComponent) return nullToEmpty(((JTextComponent) c).getText());
            if (c instanceof JComboBox) {
                Object sel = ((JComboBox) c).getSelectedItem();
                return sel == null ? "" : sel.toString();
            }
            if (c instanceof Frame) return nullToEmpty(((Frame) c).getTitle());
            if (c instanceof Dialog) return nullToEmpty(((Dialog) c).getTitle());
        } catch (Throwable ignore) {}
        try {
            Method m = c.getClass().getMethod("getText");
            Object v = m.invoke(c);
            if (v != null) return v.toString();
        } catch (Throwable ignore) {}
        return "";
    }

    private static String nullToEmpty(String s) { return s == null ? "" : s; }

    private static void indentSb(StringBuilder sb, int n) { for (int i = 0; i < n; i++) sb.append("  "); }

    private static String decodeToken(String token) {
        if ("*".equals(token)) return "*";
        if (".".equals(token)) return "";
        try {
            int mod = token.length() % 4;
            if (mod != 0) token += "====".substring(mod);
            return new String(Base64.getUrlDecoder().decode(token), java.nio.charset.StandardCharsets.UTF_8);
        } catch (Throwable t) {
            throw new IllegalArgumentException("invalid base64 token");
        }
    }

    private static int parseNonNegativeInt(String value, String name) {
        try {
            int n = Integer.parseInt(value);
            if (n < 0) throw new NumberFormatException();
            return n;
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(name + " must be a non-negative integer");
        }
    }

    private static String errorJson(String message) {
        return "{\"status\":\"ERROR\",\"message\":" + jsonStr(message) + "}";
    }

    private static String jsonStr(String s) {
        if (s == null) return "null";
        StringBuilder sb = new StringBuilder("\"");
        for (int i = 0; i < s.length(); i++) {
            char ch = s.charAt(i);
            switch (ch) {
                case '"': sb.append("\\\""); break;
                case '\\': sb.append("\\\\"); break;
                case '\n': sb.append("\\n"); break;
                case '\r': sb.append("\\r"); break;
                case '\t': sb.append("\\t"); break;
                default:
                    if (ch < 0x20) sb.append(String.format("\\u%04x", (int) ch));
                    else sb.append(ch);
            }
        }
        sb.append("\"");
        return sb.toString();
    }

    // ---------------------------------------------------------------
    // Selector-based find (Swing)
    // ---------------------------------------------------------------

    private static List<Component> allComponents() {
        List<Component> out = new ArrayList<Component>();
        Window[] windows = Window.getWindows();
        for (Window w : windows) collect(w, out);
        return out;
    }

    private static void collect(Component c, List<Component> out) {
        out.add(c);
        Component[] kids = getChildren(c);
        if (kids != null) for (Component k : kids) collect(k, out);
    }

    private static Component findComponent(String selector) {
        if (selector == null) return null;
        selector = selector.trim();
        if (selector.length() == 0) return null;
        List<Component> all = allComponents();
        for (Component c : all) if (selector.equals(safeName(c)) || selector.equals(safeText(c))) return c;
        for (Component c : all) if (selector.equalsIgnoreCase(safeName(c)) || selector.equalsIgnoreCase(safeText(c))) return c;
        return null;
    }

    private static String doClick(String selector) {
        Component c = findComponent(selector);
        if (c == null) return "NOTFOUND selector=" + selector;
        if (c instanceof AbstractButton) {
            ((AbstractButton) c).doClick();
            return "OK class=" + c.getClass().getName();
        }
        return "NOTCLICKABLE class=" + c.getClass().getName();
    }

    private static String doSetText(String selector, String value) {
        Component c = findComponent(selector);
        if (c == null) return "NOTFOUND selector=" + selector;
        if (c instanceof JTextComponent) {
            ((JTextComponent) c).setText(value);
            return "OK class=" + c.getClass().getName();
        }
        return "NOTTEXTCOMPONENT class=" + c.getClass().getName();
    }

    private static String doGetText(String selector) {
        Component c = findComponent(selector);
        if (c == null) return "NOTFOUND selector=" + selector;
        return "OK class=" + c.getClass().getName() + " text=" + safeText(c);
    }

    // =================================================================
    // JAVAFX BRIDGE
    //
    // All JavaFX access below is done via reflection to avoid a hard
    // compile-time dependency on any one JavaFX packaging scheme, and
    // everything that touches the scene graph is marshalled onto the
    // JavaFX Application Thread via Platform.runLater() + CountDownLatch.
    // =================================================================

    private static final long FX_TIMEOUT_SECONDS = 20;

    /** Runs task on the JavaFX Application Thread. A queued timeout is cancelled before reporting failure. */
    private static String runOnFxThreadForString(final java.util.concurrent.Callable<String> task) throws Exception {
        Class<?> platformClass = Class.forName("javafx.application.Platform");
        Method isFxThread = platformClass.getMethod("isFxApplicationThread");
        if (Boolean.TRUE.equals(isFxThread.invoke(null))) return task.call();

        final java.util.concurrent.FutureTask<String> future =
            new java.util.concurrent.FutureTask<String>(task);
        Method runLater = platformClass.getMethod("runLater", Runnable.class);
        runLater.invoke(null, (Runnable) future);
        try {
            return future.get(FX_TIMEOUT_SECONDS, TimeUnit.SECONDS);
        } catch (java.util.concurrent.TimeoutException timeout) {
            if (future.cancel(false)) {
                throw new RuntimeException("FX operation timed out and was cancelled before execution");
            }
            // The callback has already started. Do not report a failure while a mutation may still run.
            return future.get();
        } catch (java.util.concurrent.ExecutionException execution) {
            Throwable cause = execution.getCause();
            if (cause instanceof Exception) throw (Exception) cause;
            throw new RuntimeException(cause);
        }
    }

    /**
     * Platform.runLater() silently drops the runnable (or throws
     * IllegalStateException on some versions) if the FX toolkit was never
     * initialized in this JVM. Since MCSJ itself already uses JFXPanel
     * (which internally calls Platform.startup / implicit-exit management
     * the first time one is constructed), by the time we attach, the FX
     * toolkit is already up as long as at least one JFXPanel has been
     * constructed somewhere in the Swing tree. We proactively check for at
     * least one JFXPanel instance and give a clear error otherwise, rather
     * than hanging silently on a runLater() that never fires.
     */
    private static void ensureFxToolkitInitPossible() throws Exception {
        boolean found = false;
        for (Component c : allComponents()) {
            if (isJFXPanel(c)) { found = true; break; }
        }
        if (!found) {
            throw new IllegalStateException(
                "No javafx.embed.swing.JFXPanel found anywhere in the current Swing tree " +
                "(Window.getWindows() walk) -- the JavaFX toolkit may not be initialized in " +
                "this JVM yet, or no FX content is currently showing. FXTREE/FXCLICK/etc " +
                "require at least one live JFXPanel to exist first.");
        }
    }

    /** Collect every live JFXPanel anywhere in the Swing tree. */
    private static List<Component> findAllJFXPanels() {
        List<Component> out = new ArrayList<Component>();
        for (Component c : allComponents()) {
            if (isJFXPanel(c)) out.add(c);
        }
        return out;
    }

    /** Snapshot live JFXPanel references on the Swing EDT. */
    private static List<Component> snapshotJfxPanels() throws Exception {
        final List<Component>[] holder = new List[1];
        final Throwable[] error = new Throwable[1];
        Runnable task = new Runnable() {
            public void run() {
                try { holder[0] = findAllJFXPanels(); }
                catch (Throwable t) { error[0] = t; }
            }
        };
        if (SwingUtilities.isEventDispatchThread()) task.run();
        else SwingUtilities.invokeAndWait(task);
        if (error[0] != null) throw new RuntimeException(error[0]);
        return holder[0] == null ? new ArrayList<Component>() : holder[0];
    }

    private static final class FxPanelRoot {
        final Component panel;
        final Object root;
        FxPanelRoot(Component panel, Object root) { this.panel = panel; this.root = root; }
    }

    private static List<FxPanelRoot> matchingPanelRoots(List<Component> panels, String panelClass) throws Exception {
        List<FxPanelRoot> out = new ArrayList<FxPanelRoot>();
        for (Component panel : panels) {
            Object scene = panel.getClass().getMethod("getScene").invoke(panel);
            Object root = scene == null ? null : scene.getClass().getMethod("getRoot").invoke(scene);
            if (root == null) continue;
            if (classMatches(panel, panelClass) || classMatches(root, panelClass)) {
                out.add(new FxPanelRoot(panel, root));
                continue;
            }
            List<Object> nodes = new ArrayList<Object>();
            collectFxDedup(root, nodes, new IdentityHashMap<Object, Boolean>());
            for (Object node : nodes) {
                if (node != root && classMatches(node, panelClass)) out.add(new FxPanelRoot(panel, node));
            }
        }
        return out;
    }

    private static boolean classMatches(Object value, String wanted) {
        if ("*".equals(wanted)) return true;
        String full = value.getClass().getName();
        String simple = value.getClass().getSimpleName();
        if (wanted.equals(full) || wanted.equals(simple) || full.startsWith(wanted + "$")) return true;
        try {
            Class<?> wantedClass = Class.forName(wanted, false, value.getClass().getClassLoader());
            return wantedClass.isAssignableFrom(value.getClass());
        } catch (Throwable ignore) {
            return false;
        }
    }

    private static FxPanelRoot requireUniqueMutationPanel(List<Component> panels, String panelClass, int occurrence) throws Exception {
        if ("*".equals(panelClass) || panelClass.indexOf('.') < 0) {
            throw new IllegalArgumentException("mutation panelClass must be a fully-qualified exact class, not wildcard/simple name");
        }
        if (occurrence != 0) throw new IllegalArgumentException("mutation panelOccurrence must be 0");
        List<FxPanelRoot> matches = matchingPanelRoots(panels, panelClass);
        if (matches.size() != 1) {
            throw new IllegalArgumentException("mutation panel anchor must resolve uniquely; matches=" + matches.size());
        }
        return matches.get(0);
    }

    private static FxPanelRoot requirePanelRoot(List<Component> panels, String panelClass, int occurrence) throws Exception {
        List<FxPanelRoot> matches = matchingPanelRoots(panels, panelClass);
        if (occurrence >= matches.size()) return null;
        return matches.get(occurrence);
    }

    private static boolean exactOrWildcard(String actual, String wanted) {
        return "*".equals(wanted) || wanted.equals(actual == null ? "" : actual);
    }

    private static List<Object> scopedNodeMatches(List<Component> panels, String panelClass, int panelOccurrence,
                                                   String nodeClass, String id, String text) throws Exception {
        FxPanelRoot scoped = requirePanelRoot(panels, panelClass, panelOccurrence);
        List<Object> matches = new ArrayList<Object>();
        if (scoped == null) return matches;
        List<Object> nodes = new ArrayList<Object>();
        collectFxDedup(scoped.root, nodes, new IdentityHashMap<Object, Boolean>());
        for (Object node : nodes) {
            if (classMatches(node, nodeClass)
                && exactOrWildcard(fxGetId(node), id)
                && exactOrWildcard(fxGetText(node), text)) matches.add(node);
        }
        return matches;
    }

    private static String fxFireTableCellDoubleClick(Object table, Object column, int row) {
        try {
            try { table.getClass().getMethod("applyCss").invoke(table); } catch (Throwable ignore) {}
            try { table.getClass().getMethod("layout").invoke(table); } catch (Throwable ignore) {}
            List<Object> nodes = new ArrayList<Object>();
            collectFxDedup(table, nodes, new IdentityHashMap<Object, Boolean>());
            Object cell = null;
            for (Object node : nodes) {
                if (!isInstanceOfFx(node, "javafx.scene.control.TableCell")) continue;
                int index = ((Number) node.getClass().getMethod("getIndex").invoke(node)).intValue();
                Object nodeColumn = node.getClass().getMethod("getTableColumn").invoke(node);
                Object nodeTable = node.getClass().getMethod("getTableView").invoke(node);
                if (index == row && nodeColumn == column && nodeTable == table) { cell = node; break; }
            }
            if (cell == null) return "resolved table cell is not realized after scroll/layout";
            Rectangle2DLike bounds = fxGetBounds(cell, "getBoundsInLocal");
            double x = bounds == null ? 0 : bounds.minX + bounds.width / 2.0;
            double y = bounds == null ? 0 : bounds.minY + bounds.height / 2.0;
            double[] screen = null;
            try {
                Method localToScreen = cell.getClass().getMethod("localToScreen", double.class, double.class);
                Object point = localToScreen.invoke(cell, x, y);
                if (point != null) {
                    screen = new double[]{
                        ((Number) point.getClass().getMethod("getX").invoke(point)).doubleValue(),
                        ((Number) point.getClass().getMethod("getY").invoke(point)).doubleValue()
                    };
                }
            } catch (Throwable ignore) {}
            double sx = screen == null ? x : screen[0];
            double sy = screen == null ? y : screen[1];
            Class<?> mouseEventClass = Class.forName("javafx.scene.input.MouseEvent");
            Constructor<?> constructor = null;
            for (Constructor<?> candidate : mouseEventClass.getConstructors()) {
                Class<?>[] p = candidate.getParameterTypes();
                if (p.length == 18 && "javafx.event.EventType".equals(p[0].getName())
                    && "javafx.scene.input.MouseButton".equals(p[5].getName())) {
                    constructor = candidate;
                    break;
                }
            }
            if (constructor == null) return "JavaFX 18-argument MouseEvent constructor not found";
            Object mouseClicked = mouseEventClass.getField("MOUSE_CLICKED").get(null);
            Class<?> mouseButtonClass = Class.forName("javafx.scene.input.MouseButton");
            Object primary = mouseButtonClass.getField("PRIMARY").get(null);
            Object event = constructor.newInstance(
                mouseClicked, x, y, sx, sy, primary, 2,
                false, false, false, false,
                false, false, false,
                true, false, true, null);
            Method fireEvent = Class.forName("javafx.event.Event").getMethod(
                "fireEvent", Class.forName("javafx.event.EventTarget"), Class.forName("javafx.event.Event"));
            fireEvent.invoke(null, cell, event);
            return null;
        } catch (Throwable t) {
            Throwable cause = t instanceof InvocationTargetException && ((InvocationTargetException) t).getCause() != null
                ? ((InvocationTargetException) t).getCause() : t;
            return "table-cell doubleclick failed: " + cause;
        }
    }

    private static String fxTableCell(List<Component> panels, String panelClass, int panelOccurrence,
                                      String headerText, int tableOccurrence, int row, int column,
                                      String action, String expectedCellText) throws Exception {
        if ("*".equals(expectedCellText)) return errorJson("wildcard expectedCellText is forbidden for mutation");
        if ("*".equals(headerText)) return errorJson("wildcard table header is forbidden for mutation");
        FxPanelRoot scoped;
        try { scoped = requireUniqueMutationPanel(panels, panelClass, panelOccurrence); }
        catch (IllegalArgumentException e) { return errorJson(e.getMessage()); }
        List<Object> nodes = new ArrayList<Object>();
        collectFxDedup(scoped.root, nodes, new IdentityHashMap<Object, Boolean>());
        List<Object> tables = new ArrayList<Object>();
        for (Object node : nodes) {
            if (!isInstanceOfFx(node, "javafx.scene.control.TableView")) continue;
            Object colsObj = node.getClass().getMethod("getColumns").invoke(node);
            if (!(colsObj instanceof List)) continue;
            for (Object col : (List<?>) colsObj) {
                if (headerText.equals(fxGetText(col))) { tables.add(node); break; }
            }
        }
        if (tableOccurrence != 0) return errorJson("mutation tableOccurrence must be 0");
        if (tables.size() != 1) return errorJson("mutation table must resolve uniquely; matches=" + tables.size());
        Object table = tables.get(0);
        Object itemsObj = table.getClass().getMethod("getItems").invoke(table);
        Object colsObj = table.getClass().getMethod("getColumns").invoke(table);
        List<?> items = itemsObj instanceof List ? (List<?>) itemsObj : Collections.emptyList();
        List<?> cols = colsObj instanceof List ? (List<?>) colsObj : Collections.emptyList();
        if (row >= items.size() || column >= cols.size()) return errorJson("row or column out of range");
        Object col = cols.get(column);
        String actual = fxTableCellText(col, row);
        if (!expectedCellText.equals(actual)) {
            return "{\"status\":\"STALE\",\"actual\":" + jsonStr(actual)
                + ",\"expected\":" + jsonStr(expectedCellText) + "}";
        }
        if (!("select".equals(action) || "edit".equals(action) || "doubleclick".equals(action))) {
            return errorJson("action must be select, edit, or doubleclick");
        }
        table.getClass().getMethod("requestFocus").invoke(table);
        Method scrollTo = table.getClass().getMethod("scrollTo", int.class);
        scrollTo.invoke(table, row);
        Method scrollToColumn = table.getClass().getMethod("scrollToColumn", Class.forName("javafx.scene.control.TableColumn"));
        scrollToColumn.invoke(table, col);
        Object selectionModel = table.getClass().getMethod("getSelectionModel").invoke(table);
        Method clearAndSelect = selectionModel.getClass().getMethod("clearAndSelect", int.class, Class.forName("javafx.scene.control.TableColumn"));
        clearAndSelect.setAccessible(true);
        clearAndSelect.invoke(selectionModel, row, col);
        Object focusModel = table.getClass().getMethod("getFocusModel").invoke(table);
        Method focus = focusModel.getClass().getMethod("focus", int.class, Class.forName("javafx.scene.control.TableColumn"));
        focus.setAccessible(true);
        focus.invoke(focusModel, row, col);
        if ("edit".equals(action)) {
            Method edit = table.getClass().getMethod("edit", int.class, Class.forName("javafx.scene.control.TableColumn"));
            edit.invoke(table, row, col);
            Object editingCell = table.getClass().getMethod("getEditingCell").invoke(table);
            if (editingCell == null) return errorJson("TableView did not enter editing state");
            int editingRow = ((Number) editingCell.getClass().getMethod("getRow").invoke(editingCell)).intValue();
            Object editingColumn = editingCell.getClass().getMethod("getTableColumn").invoke(editingCell);
            if (editingRow != row || editingColumn != col) return errorJson("TableView editing-cell readback mismatch");
        } else if ("doubleclick".equals(action)) {
            String failure = fxFireTableCellDoubleClick(table, col, row);
            if (failure != null) return errorJson(failure);
        }
        int selectedRow = ((Number) selectionModel.getClass().getMethod("getSelectedIndex").invoke(selectionModel)).intValue();
        int selectedColumn = column;
        try {
            Object focusedCell = focusModel.getClass().getMethod("getFocusedCell").invoke(focusModel);
            Object focusedColumn = focusedCell.getClass().getMethod("getTableColumn").invoke(focusedCell);
            selectedColumn = cols.indexOf(focusedColumn);
        } catch (Throwable ignore) {}
        String verification = "doubleclick".equals(action) ? "event-dispatched-to-resolved-cell"
            : ("edit".equals(action) ? "editing-cell-readback" : "selection-focus-readback");
        return "{\"status\":\"OK\",\"action\":" + jsonStr(action)
            + ",\"verification\":" + jsonStr(verification)
            + ",\"actual\":" + jsonStr(actual)
            + ",\"selectedRow\":" + selectedRow
            + ",\"selectedColumn\":" + selectedColumn + "}";
    }

    private static String fxEditText(List<Component> panels, String panelClass, int panelOccurrence,
                                     String nodeClass, String id, String text, int nodeOccurrence,
                                     String expectedOldText, String newText) throws Exception {
        if ("*".equals(expectedOldText)) return errorJson("wildcard expectedOldText is forbidden for mutation");
        if ("*".equals(nodeClass) || nodeClass.indexOf('.') < 0) return errorJson("mutation nodeClass must be fully qualified and non-wildcard");
        if ("*".equals(id)) return errorJson("wildcard id is forbidden for text mutation; use exact id including empty");
        FxPanelRoot scoped;
        try { scoped = requireUniqueMutationPanel(panels, panelClass, panelOccurrence); }
        catch (IllegalArgumentException e) { return errorJson(e.getMessage()); }
        List<Object> nodes = new ArrayList<Object>();
        collectFxDedup(scoped.root, nodes, new IdentityHashMap<Object, Boolean>());
        List<Object> matches = new ArrayList<Object>();
        for (Object candidate : nodes) {
            if (classMatches(candidate, nodeClass)
                && exactOrWildcard(fxGetId(candidate), id)
                && exactOrWildcard(fxGetText(candidate), text)) matches.add(candidate);
        }
        if (nodeOccurrence != 0) return errorJson("mutation nodeOccurrence must be 0");
        if (matches.size() != 1) return errorJson("mutation node must resolve uniquely; matches=" + matches.size());
        Object node = matches.get(0);
        if (!isInstanceOfFx(node, "javafx.scene.control.TextInputControl")) return errorJson("target is not TextInputControl");
        if (!fxGetBool(node, "isVisible")) return errorJson("target is not visible");
        if (fxGetBool(node, "isDisabled")) return errorJson("target is disabled");
        if (!fxGetBool(node, "isEditable")) return errorJson("target is not editable");
        String before = fxGetText(node);
        boolean bound = fxTextPropertyBound(node);
        if (!expectedOldText.equals(before)) {
            return "{\"status\":\"STALE\",\"bound\":" + bound + ",\"before\":" + jsonStr(before)
                + ",\"expected\":" + jsonStr(expectedOldText) + "}";
        }
        String failure = null;
        try {
            Method requestFocus = node.getClass().getMethod("requestFocus");
            requestFocus.invoke(node);
            Method getLength = node.getClass().getMethod("getLength");
            int length = ((Number) getLength.invoke(node)).intValue();
            Method replaceText = node.getClass().getMethod("replaceText", int.class, int.class, String.class);
            replaceText.invoke(node, 0, length, newText);
        } catch (Throwable t) {
            Throwable cause = t instanceof InvocationTargetException && ((InvocationTargetException) t).getCause() != null
                ? ((InvocationTargetException) t).getCause() : t;
            failure = cause.toString();
        }
        String actual = fxGetText(node);
        String status = actual.equals(newText) ? "OK" : (actual.equals(before) ? "REJECTED" : "NORMALIZED");
        StringBuilder sb = new StringBuilder();
        sb.append("{\"status\":").append(jsonStr(status))
          .append(",\"bound\":").append(bound)
          .append(",\"before\":").append(jsonStr(before))
          .append(",\"requested\":").append(jsonStr(newText))
          .append(",\"actual\":").append(jsonStr(actual));
        if (failure != null) sb.append(",\"error\":").append(jsonStr(failure));
        sb.append("}");
        return sb.toString();
    }

    private static String fxWindowTitle(Object window) {
        try {
            Method m = window.getClass().getMethod("getTitle");
            Object v = m.invoke(window);
            return v == null ? "" : v.toString();
        } catch (Throwable ignore) { return ""; }
    }

    private static Object requireUniqueFxWindow(String exactTitle) throws Exception {
        if (exactTitle.length() == 0 || "*".equals(exactTitle)) throw new IllegalArgumentException("exact non-empty window title required");
        Object windowsObj = Class.forName("javafx.stage.Window").getMethod("impl_getWindows").invoke(null);
        Iterator<?> windows = windowsObj instanceof Iterator ? (Iterator<?>) windowsObj : Collections.emptyList().iterator();
        List<Object> matches = new ArrayList<Object>();
        while (windows.hasNext()) {
            Object window = windows.next();
            boolean showing = ((Boolean) window.getClass().getMethod("isShowing").invoke(window)).booleanValue();
            if (showing && exactTitle.equals(fxWindowTitle(window))) matches.add(window);
        }
        if (matches.size() != 1) throw new IllegalArgumentException("JavaFX window title must resolve uniquely; matches=" + matches.size());
        return matches.get(0);
    }

    private static Object fxWindowRoot(Object window) throws Exception {
        Object scene = window.getClass().getMethod("getScene").invoke(window);
        return scene == null ? null : scene.getClass().getMethod("getRoot").invoke(scene);
    }

    private static String fxWindowState(String exactTitle) throws Exception {
        Object window;
        try { window = requireUniqueFxWindow(exactTitle); }
        catch (IllegalArgumentException e) { return errorJson(e.getMessage()); }
        Object root = fxWindowRoot(window);
        if (root == null) return errorJson("JavaFX window has no scene root");
        List<Object> nodes = new ArrayList<Object>();
        collectFxDedup(root, nodes, new IdentityHashMap<Object, Boolean>());
        StringBuilder sb = new StringBuilder();
        sb.append("{\"status\":\"OK\",\"title\":").append(jsonStr(exactTitle)).append(",\"items\":[");
        int count = 0;
        for (Object node : nodes) {
            String text = fxGetText(node);
            if (text.length() == 0 && !isInstanceOfFx(node, "javafx.scene.control.TextInputControl")
                && !isInstanceOfFx(node, "javafx.scene.control.ButtonBase")) continue;
            if (count > 0) sb.append(",");
            sb.append("{\"class\":").append(jsonStr(node.getClass().getName()))
              .append(",\"text\":").append(jsonStr(text))
              .append(",\"visible\":").append(fxGetBool(node, "isVisible"))
              .append(",\"disabled\":").append(fxGetBool(node, "isDisabled")).append("}");
            count++;
            if (count >= 200) break;
        }
        sb.append("],\"count\":").append(count).append("}");
        return sb.toString();
    }

    private static String fxWindowButton(String exactTitle, String buttonText, boolean expectedDisabled) throws Exception {
        Object window;
        try { window = requireUniqueFxWindow(exactTitle); }
        catch (IllegalArgumentException e) { return errorJson(e.getMessage()); }
        Object root = fxWindowRoot(window);
        if (root == null) return errorJson("JavaFX window has no scene root");
        List<Object> nodes = new ArrayList<Object>();
        collectFxDedup(root, nodes, new IdentityHashMap<Object, Boolean>());
        List<Object> matches = new ArrayList<Object>();
        for (Object node : nodes) {
            if (isInstanceOfFx(node, "javafx.scene.control.ButtonBase") && buttonText.equals(fxGetText(node))) matches.add(node);
        }
        if (matches.size() != 1) return errorJson("window button must resolve uniquely; matches=" + matches.size());
        Object button = matches.get(0);
        boolean disabled = fxGetBool(button, "isDisabled");
        if (disabled != expectedDisabled) return "{\"status\":\"STALE\",\"disabled\":" + disabled + "}";
        if (disabled) return errorJson("refusing to fire disabled window button");
        button.getClass().getMethod("fire").invoke(button);
        return "{\"status\":\"OK\",\"title\":" + jsonStr(exactTitle) + ",\"text\":" + jsonStr(buttonText) + ",\"fired\":true}";
    }

    private static String fxSelectTab(List<Component> panels, String panelClass, String tabText,
                                      boolean expectedSelected) throws Exception {
        FxPanelRoot scoped = requireUniqueMutationPanel(panels, panelClass, 0);
        List<Object> nodes = new ArrayList<Object>();
        collectFxDedup(scoped.root, nodes, new IdentityHashMap<Object, Boolean>());
        List<Object[]> matches = new ArrayList<Object[]>();
        for (Object node : nodes) {
            if (!isInstanceOfFx(node, "javafx.scene.control.TabPane")) continue;
            Object tabsObj = node.getClass().getMethod("getTabs").invoke(node);
            if (!(tabsObj instanceof List)) continue;
            for (Object tab : (List<?>) tabsObj) if (tabText.equals(fxGetText(tab))) matches.add(new Object[]{node, tab});
        }
        if (matches.size() != 1) return errorJson("tab must resolve uniquely; matches=" + matches.size());
        Object tabPane = matches.get(0)[0], tab = matches.get(0)[1];
        Object model = tabPane.getClass().getMethod("getSelectionModel").invoke(tabPane);
        Object selected = model.getClass().getMethod("getSelectedItem").invoke(model);
        boolean before = selected == tab;
        if (before != expectedSelected) return "{\"status\":\"STALE\",\"before\":" + before + "}";
        if (!before) {
            Method select = model.getClass().getMethod("select", Class.forName("javafx.scene.control.Tab"));
            select.invoke(model, tab);
        }
        Object actualItem = model.getClass().getMethod("getSelectedItem").invoke(model);
        boolean actual = actualItem == tab;
        return "{\"status\":\"" + (actual ? "OK" : "REJECTED") + "\",\"text\":" + jsonStr(tabText)
            + ",\"before\":" + before + ",\"actual\":" + actual + "}";
    }

    private static String fxFireButton(List<Component> panels, String panelClass, String buttonText,
                                       boolean expectedDisabled) throws Exception {
        FxPanelRoot scoped = requireUniqueMutationPanel(panels, panelClass, 0);
        List<Object> nodes = new ArrayList<Object>();
        collectFxDedup(scoped.root, nodes, new IdentityHashMap<Object, Boolean>());
        List<Object> matches = new ArrayList<Object>();
        for (Object node : nodes) {
            if (isInstanceOfFx(node, "javafx.scene.control.ButtonBase") && buttonText.equals(fxGetText(node))) matches.add(node);
        }
        if (matches.size() != 1) return errorJson("button must resolve uniquely; matches=" + matches.size());
        Object button = matches.get(0);
        boolean disabled = ((Boolean) button.getClass().getMethod("isDisabled").invoke(button)).booleanValue();
        if (disabled != expectedDisabled) return "{\"status\":\"STALE\",\"disabled\":" + disabled + "}";
        if (disabled) return errorJson("refusing to fire disabled button");
        button.getClass().getMethod("fire").invoke(button);
        return "{\"status\":\"OK\",\"text\":" + jsonStr(buttonText) + ",\"disabled\":false,\"fired\":true}";
    }

    private static String fxSelectRadio(List<Component> panels, String panelClass, String radioText,
                                        boolean expectedSelected, boolean newSelected) throws Exception {
        FxPanelRoot scoped = requireUniqueMutationPanel(panels, panelClass, 0);
        List<Object> nodes = new ArrayList<Object>();
        collectFxDedup(scoped.root, nodes, new IdentityHashMap<Object, Boolean>());
        List<Object> matches = new ArrayList<Object>();
        for (Object node : nodes) {
            if (isInstanceOfFx(node, "javafx.scene.control.RadioButton") && radioText.equals(fxGetText(node))) matches.add(node);
        }
        if (matches.size() != 1) return errorJson("radio must resolve uniquely; matches=" + matches.size());
        Object radio = matches.get(0);
        boolean before = ((Boolean) radio.getClass().getMethod("isSelected").invoke(radio)).booleanValue();
        if (before != expectedSelected) return "{\"status\":\"STALE\",\"before\":" + before + "}";
        if (!newSelected) return errorJson("radio fire mutation only supports requested=true");
        radio.getClass().getMethod("fire").invoke(radio);
        radio.getClass().getMethod("requestFocus").invoke(radio);
        boolean actual = ((Boolean) radio.getClass().getMethod("isSelected").invoke(radio)).booleanValue();
        String status = actual == newSelected ? "OK" : "REJECTED";
        return "{\"status\":\"" + status + "\",\"text\":" + jsonStr(radioText)
            + ",\"before\":" + before + ",\"requested\":" + newSelected + ",\"actual\":" + actual + "}";
    }

    private static String fxFind(List<Component> panels, String panelClass, int panelOccurrence,
                                 String nodeClass, String id, String text, int limit) throws Exception {
        FxPanelRoot scoped = requirePanelRoot(panels, panelClass, panelOccurrence);
        if (scoped == null) return errorJson("panel not found");
        List<Object> nodes = new ArrayList<Object>();
        collectFxDedup(scoped.root, nodes, new IdentityHashMap<Object, Boolean>());
        StringBuilder sb = new StringBuilder();
        sb.append("{\"status\":\"OK\",\"matches\":[");
        int count = 0;
        for (Object node : nodes) {
            if (!classMatches(node, nodeClass)) continue;
            if (!exactOrWildcard(fxGetId(node), id) || !exactOrWildcard(fxGetText(node), text)) continue;
            if (count > 0) sb.append(",");
            StringBuilder control = new StringBuilder();
            appendFxControlState(control, node);
            sb.append("{\"occurrence\":").append(count).append(",").append(control.substring(1));
            count++;
            if (count >= limit) break;
        }
        sb.append("],\"count\":").append(count).append("}");
        return sb.toString();
    }

    // ---------------------------------------------------------------
    // FXSTATE (compact semantic state; panels already snapshotted on EDT)
    // ---------------------------------------------------------------

    private static String fxState(List<Component> panels) throws Exception {
        StringBuilder sb = new StringBuilder();
        sb.append("{\"status\":\"OK\",\"panelCount\":").append(panels.size()).append(",\"panels\":[");
        for (int i = 0; i < panels.size(); i++) {
            Component panel = panels.get(i);
            Object scene = panel.getClass().getMethod("getScene").invoke(panel);
            Object root = scene == null ? null : scene.getClass().getMethod("getRoot").invoke(scene);
            List<Object> nodes = new ArrayList<Object>();
            collectFxDedup(root, nodes, new IdentityHashMap<Object, Boolean>());
            sb.append("{\"panelClass\":").append(jsonStr(panel.getClass().getName()))
              .append(",\"rootClass\":").append(jsonStr(root == null ? "" : root.getClass().getName()))
              .append(",\"controls\":[");
            int controls = 0;
            for (Object node : nodes) {
                if (!isStateControl(node)) continue;
                if (controls > 0) sb.append(",");
                appendFxControlState(sb, node);
                controls++;
                if (controls >= 100) break;
            }
            sb.append("],\"tables\":[");
            int tables = 0;
            for (Object node : nodes) {
                if (!isInstanceOfFx(node, "javafx.scene.control.TableView")) continue;
                if (tables > 0) sb.append(",");
                appendFxTableState(sb, node);
                tables++;
            }
            sb.append("]}");
            if (i < panels.size() - 1) sb.append(",");
        }
        sb.append("]}");
        return sb.toString();
    }

    private static boolean isStateControl(Object node) {
        return isInstanceOfFx(node, "javafx.scene.control.ButtonBase")
            || isInstanceOfFx(node, "javafx.scene.control.TextInputControl")
            || isInstanceOfFx(node, "javafx.scene.control.ComboBoxBase")
            || isInstanceOfFx(node, "javafx.scene.control.TabPane");
    }

    private static void appendFxControlState(StringBuilder sb, Object node) {
        sb.append("{\"class\":").append(jsonStr(node.getClass().getName()))
          .append(",\"id\":").append(jsonStr(fxGetId(node)))
          .append(",\"text\":").append(jsonStr(clipText(fxGetText(node), 256)))
          .append(",\"visible\":").append(fxGetBool(node, "isVisible"))
          .append(",\"disabled\":").append(fxGetBool(node, "isDisabled"))
          .append(",\"focused\":").append(fxGetBool(node, "isFocused"));
        if (isInstanceOfFx(node, "javafx.scene.control.TextInputControl")) {
            sb.append(",\"editable\":").append(fxGetBool(node, "isEditable"))
              .append(",\"bound\":").append(fxTextPropertyBound(node));
        }
        if (isInstanceOfFx(node, "javafx.scene.control.TabPane")) {
            try {
                Object sm = node.getClass().getMethod("getSelectionModel").invoke(node);
                Object selected = sm.getClass().getMethod("getSelectedItem").invoke(sm);
                sb.append(",\"selectedTab\":").append(jsonStr(fxGetText(selected)));
            } catch (Throwable ignore) {}
        }
        sb.append("}");
    }

    private static void appendFxTableState(StringBuilder sb, Object table) throws Exception {
        Object itemsObj = table.getClass().getMethod("getItems").invoke(table);
        Object colsObj = table.getClass().getMethod("getColumns").invoke(table);
        List<?> items = itemsObj instanceof List ? (List<?>) itemsObj : Collections.emptyList();
        List<?> cols = colsObj instanceof List ? (List<?>) colsObj : Collections.emptyList();
        sb.append("{\"class\":").append(jsonStr(table.getClass().getName()))
          .append(",\"id\":").append(jsonStr(fxGetId(table)))
          .append(",\"rowCount\":").append(items.size())
          .append(",\"columns\":[");
        for (int c = 0; c < cols.size(); c++) {
            if (c > 0) sb.append(",");
            sb.append(jsonStr(fxGetText(cols.get(c))));
        }
        sb.append("],\"rows\":[");
        int rowLimit = Math.min(items.size(), 40);
        for (int r = 0; r < rowLimit; r++) {
            if (r > 0) sb.append(",");
            sb.append("[");
            for (int c = 0; c < cols.size(); c++) {
                if (c > 0) sb.append(",");
                sb.append(jsonStr(clipText(fxTableCellText(cols.get(c), r), 256)));
            }
            sb.append("]");
        }
        sb.append("]}");
    }

    private static String fxTableCellText(Object column, int row) throws Exception {
        try {
            Method m = column.getClass().getMethod("getCellObservableValue", int.class);
            Object observable = m.invoke(column, row);
            if (observable == null) return "";
            Object value = observable.getClass().getMethod("getValue").invoke(observable);
            return value == null ? "" : value.toString();
        } catch (InvocationTargetException invocation) {
            Throwable cause = invocation.getCause();
            if (cause instanceof Exception) throw (Exception) cause;
            throw new RuntimeException(cause);
        }
    }

    private static boolean fxTextPropertyBound(Object node) {
        try {
            Object property = node.getClass().getMethod("textProperty").invoke(node);
            Object value = property.getClass().getMethod("isBound").invoke(property);
            return value instanceof Boolean && (Boolean) value;
        } catch (Throwable t) { return false; }
    }

    private static String clipText(String value, int max) {
        if (value == null) return "";
        return value.length() <= max ? value : value.substring(0, max);
    }

    private static void collectFxDedup(Object node, List<Object> out, IdentityHashMap<Object, Boolean> seen) throws Exception {
        if (node == null || seen.containsKey(node)) return;
        seen.put(node, Boolean.TRUE);
        out.add(node);
        if (isInstanceOfFx(node, "javafx.scene.Parent")) {
            Object childList = node.getClass().getMethod("getChildrenUnmodifiable").invoke(node);
            if (childList instanceof List) {
                for (Object kid : (List<?>) childList) collectFxDedup(kid, out, seen);
            }
        }
        if (isInstanceOfFx(node, "javafx.scene.control.TabPane")) {
            Object tabsObj = node.getClass().getMethod("getTabs").invoke(node);
            if (tabsObj instanceof List) {
                for (Object tab : (List<?>) tabsObj) {
                    if (!seen.containsKey(tab)) { seen.put(tab, Boolean.TRUE); out.add(tab); }
                    try {
                        Object content = tab.getClass().getMethod("getContent").invoke(tab);
                        collectFxDedup(content, out, seen);
                    } catch (Throwable ignore) {}
                }
            }
        }
    }

    // ---------------------------------------------------------------
    // FXTREE
    // ---------------------------------------------------------------

    /** MUST be called already on the JavaFX Application Thread. */
    private static String fxDumpTree() throws Exception {
        List<Component> panels = findAllJFXPanels();
        StringBuilder sb = new StringBuilder();
        sb.append("{\"jfxPanelCount\":").append(panels.size()).append(",\"panels\":[\n");
        for (int i = 0; i < panels.size(); i++) {
            Component panel = panels.get(i);
            indentSb(sb, 1);
            sb.append("{\"panelClass\":").append(jsonStr(panel.getClass().getName())).append(",");
            sb.append("\"panelName\":").append(jsonStr(safeName(panel))).append(",");
            Rectangle pb = panel.getBounds();
            sb.append("\"panelBounds\":{\"x\":").append(pb.x).append(",\"y\":").append(pb.y)
              .append(",\"w\":").append(pb.width).append(",\"h\":").append(pb.height).append("},");

            Method getScene = panel.getClass().getMethod("getScene");
            Object scene = getScene.invoke(panel);
            if (scene == null) {
                sb.append("\"scene\":null}");
            } else {
                sb.append("\"scene\":");
                appendFxSceneJson(sb, scene, 2);
                sb.append("}");
            }
            if (i < panels.size() - 1) sb.append(",");
            sb.append("\n");
        }
        sb.append("]}");
        return sb.toString();
    }

    private static void appendFxSceneJson(StringBuilder sb, Object scene, int indent) throws Exception {
        Method getRoot = scene.getClass().getMethod("getRoot");
        Object root = getRoot.invoke(scene);
        sb.append("{\"sceneClass\":").append(jsonStr(scene.getClass().getName())).append(",\"root\":");
        appendFxNodeJson(sb, root, indent + 1);
        sb.append("}");
    }

    private static void appendFxNodeJson(StringBuilder sb, Object node, int indent) throws Exception {
        if (node == null) { sb.append("null"); return; }
        sb.append("{");
        sb.append("\"class\":").append(jsonStr(node.getClass().getName())).append(",");
        sb.append("\"id\":").append(jsonStr(fxGetId(node))).append(",");
        sb.append("\"text\":").append(jsonStr(fxGetText(node))).append(",");

        Rectangle2DLike boundsInParent = fxGetBounds(node, "getBoundsInParent");
        Rectangle2DLike boundsInLocal = fxGetBounds(node, "getBoundsInLocal");
        appendFxBoundsJson(sb, "boundsInParent", boundsInParent);
        sb.append(",");
        appendFxBoundsJson(sb, "boundsInLocal", boundsInLocal);
        sb.append(",");

        double[] screenXY = fxLocalToScreen(node, boundsInLocal);
        if (screenXY != null) {
            sb.append("\"screenX\":").append(fmtNum(screenXY[0])).append(",");
            sb.append("\"screenY\":").append(fmtNum(screenXY[1])).append(",");
        }

        sb.append("\"visible\":").append(fxGetBool(node, "isVisible")).append(",");
        sb.append("\"disabled\":").append(fxGetBool(node, "isDisabled")).append(",");
        sb.append("\"disable\":").append(fxGetBool(node, "isDisable")).append(",");
        sb.append("\"focused\":").append(fxGetBool(node, "isFocused"));

        // TabPane -> expose tabs and currently selected tab
        if (isInstanceOfFx(node, "javafx.scene.control.TabPane")) {
            try {
                Method getTabs = node.getClass().getMethod("getTabs");
                Object tabsList = getTabs.invoke(node);
                Method getSelectionModel = node.getClass().getMethod("getSelectionModel");
                Object selModel = getSelectionModel.invoke(node);
                Object selectedItem = selModel == null ? null : selModel.getClass().getMethod("getSelectedItem").invoke(selModel);
                sb.append(",\"selectedTabText\":").append(jsonStr(selectedItem == null ? "" : fxGetText(selectedItem)));
                sb.append(",\"tabs\":[");
                if (tabsList instanceof List) {
                    List<?> tabs = (List<?>) tabsList;
                    for (int i = 0; i < tabs.size(); i++) {
                        Object tab = tabs.get(i);
                        sb.append("{\"text\":").append(jsonStr(fxGetText(tab)))
                          .append(",\"id\":").append(jsonStr(fxGetId(tab)))
                          .append(",\"selected\":").append(tab.equals(selectedItem))
                          .append("}");
                        if (i < tabs.size() - 1) sb.append(",");
                    }
                }
                sb.append("]");
            } catch (Throwable ignore) {}
        }

        // TableView -> expose column headers and row count (not full data dump to keep this generic/light)
        if (isInstanceOfFx(node, "javafx.scene.control.TableView")) {
            try {
                Method getColumns = node.getClass().getMethod("getColumns");
                Object colsList = getColumns.invoke(node);
                Method getItems = node.getClass().getMethod("getItems");
                Object itemsList = getItems.invoke(node);
                sb.append(",\"rowCount\":").append(itemsList instanceof List ? ((List<?>) itemsList).size() : 0);
                sb.append(",\"columns\":[");
                if (colsList instanceof List) {
                    List<?> cols = (List<?>) colsList;
                    for (int i = 0; i < cols.size(); i++) {
                        sb.append(jsonStr(fxGetText(cols.get(i))));
                        if (i < cols.size() - 1) sb.append(",");
                    }
                }
                sb.append("]");
            } catch (Throwable ignore) {}
        }

        // Recurse into children if this node is a Parent
        if (isInstanceOfFx(node, "javafx.scene.Parent")) {
            try {
                Method getChildrenUnmod = node.getClass().getMethod("getChildrenUnmodifiable");
                Object childList = getChildrenUnmod.invoke(node);
                if (childList instanceof List) {
                    List<?> kids = (List<?>) childList;
                    if (!kids.isEmpty()) {
                        sb.append(",\"children\":[\n");
                        for (int i = 0; i < kids.size(); i++) {
                            indentSb(sb, indent + 1);
                            appendFxNodeJson(sb, kids.get(i), indent + 1);
                            if (i < kids.size() - 1) sb.append(",");
                            sb.append("\n");
                        }
                        indentSb(sb, indent);
                        sb.append("]");
                    }
                }
            } catch (Throwable ignore) {}
        }

        sb.append("}");
    }

    private static void appendFxBoundsJson(StringBuilder sb, String key, Rectangle2DLike r) {
        sb.append("\"").append(key).append("\":");
        if (r == null) { sb.append("null"); return; }
        sb.append("{\"x\":").append(fmtNum(r.minX)).append(",\"y\":").append(fmtNum(r.minY))
          .append(",\"w\":").append(fmtNum(r.width)).append(",\"h\":").append(fmtNum(r.height)).append("}");
    }

    private static String fmtNum(double d) {
        if (d == Math.floor(d) && !Double.isInfinite(d)) return String.valueOf((long) d);
        return String.valueOf(d);
    }

    private static class Rectangle2DLike {
        double minX, minY, width, height;
    }

    private static Rectangle2DLike fxGetBounds(Object node, String methodName) {
        try {
            Method m = node.getClass().getMethod(methodName);
            Object bounds = m.invoke(node);
            if (bounds == null) return null;
            Rectangle2DLike r = new Rectangle2DLike();
            r.minX = ((Number) bounds.getClass().getMethod("getMinX").invoke(bounds)).doubleValue();
            r.minY = ((Number) bounds.getClass().getMethod("getMinY").invoke(bounds)).doubleValue();
            r.width = ((Number) bounds.getClass().getMethod("getWidth").invoke(bounds)).doubleValue();
            r.height = ((Number) bounds.getClass().getMethod("getHeight").invoke(bounds)).doubleValue();
            return r;
        } catch (Throwable t) { return null; }
    }

    /** node.localToScreen(minX, minY) via reflection -> [screenX, screenY], or null. */
    private static double[] fxLocalToScreen(Object node, Rectangle2DLike localBounds) {
        if (localBounds == null) return null;
        try {
            Method m = node.getClass().getMethod("localToScreen", double.class, double.class);
            Object point2D = m.invoke(node, localBounds.minX, localBounds.minY);
            if (point2D == null) return null;
            double x = ((Number) point2D.getClass().getMethod("getX").invoke(point2D)).doubleValue();
            double y = ((Number) point2D.getClass().getMethod("getY").invoke(point2D)).doubleValue();
            return new double[]{x, y};
        } catch (Throwable t) { return null; }
    }

    private static boolean fxGetBool(Object node, String methodName) {
        try {
            Method m = node.getClass().getMethod(methodName);
            Object v = m.invoke(node);
            return v instanceof Boolean && (Boolean) v;
        } catch (Throwable t) { return false; }
    }

    private static String fxGetId(Object node) {
        try {
            Method m = node.getClass().getMethod("getId");
            Object v = m.invoke(node);
            return v == null ? "" : v.toString();
        } catch (Throwable t) { return ""; }
    }

    private static boolean isInstanceOfFx(Object node, String className) {
        try { return Class.forName(className).isInstance(node); }
        catch (Throwable t) { return false; }
    }

    /**
     * Generic, MCSJ-agnostic visible-text extraction for JavaFX nodes.
     * Covers the common javafx.scene.control widget family via instanceof
     * (fast path) and falls back to reflection on getText()/getTitle() for
     * anything else (custom cell factories, skins, third-party controls).
     */
    private static String fxGetText(Object node) {
        if (node == null) return "";
        try {
            if (isInstanceOfFx(node, "javafx.scene.control.Labeled")) { // Label, Button, ToggleButton, CheckBox, RadioButton, Tab-content-ish
                Method m = node.getClass().getMethod("getText");
                Object v = m.invoke(node);
                if (v != null) return v.toString();
            }
        } catch (Throwable ignore) {}
        try {
            if (isInstanceOfFx(node, "javafx.scene.control.TextInputControl")) { // TextField, TextArea
                Method m = node.getClass().getMethod("getText");
                Object v = m.invoke(node);
                if (v != null) return v.toString();
            }
        } catch (Throwable ignore) {}
        try {
            if (isInstanceOfFx(node, "javafx.scene.control.ComboBoxBase")) { // ComboBox, DatePicker
                Method m = node.getClass().getMethod("getValue");
                Object v = m.invoke(node);
                if (v != null) return v.toString();
            }
        } catch (Throwable ignore) {}
        try {
            if (isInstanceOfFx(node, "javafx.scene.control.Tab")) {
                Method m = node.getClass().getMethod("getText");
                Object v = m.invoke(node);
                if (v != null) return v.toString();
            }
        } catch (Throwable ignore) {}
        try {
            if (isInstanceOfFx(node, "javafx.scene.control.TableColumnBase")) { // TableColumn, TreeTableColumn
                Method m = node.getClass().getMethod("getText");
                Object v = m.invoke(node);
                if (v != null) return v.toString();
            }
        } catch (Throwable ignore) {}
        try {
            if (isInstanceOfFx(node, "javafx.scene.text.Text")) {
                Method m = node.getClass().getMethod("getText");
                Object v = m.invoke(node);
                if (v != null) return v.toString();
            }
        } catch (Throwable ignore) {}
        // Generic reflection fallback for anything not covered above.
        try {
            Method m = node.getClass().getMethod("getText");
            Object v = m.invoke(node);
            if (v != null) return v.toString();
        } catch (Throwable ignore) {}
        return "";
    }

    // ---------------------------------------------------------------
    // FX selector-based find
    // ---------------------------------------------------------------

    /** Pre-order walk of every JFXPanel's Scene graph (all panels, all windows). */
    private static List<Object> allFxNodes() throws Exception {
        List<Object> out = new ArrayList<Object>();
        for (Component panel : findAllJFXPanels()) {
            Method getScene = panel.getClass().getMethod("getScene");
            Object scene = getScene.invoke(panel);
            if (scene == null) continue;
            Method getRoot = scene.getClass().getMethod("getRoot");
            Object root = getRoot.invoke(scene);
            collectFx(root, out);
        }
        return out;
    }

    private static void collectFx(Object node, List<Object> out) throws Exception {
        if (node == null) return;
        out.add(node);
        if (isInstanceOfFx(node, "javafx.scene.Parent")) {
            Method getChildrenUnmod = node.getClass().getMethod("getChildrenUnmodifiable");
            Object childList = getChildrenUnmod.invoke(node);
            if (childList instanceof List) {
                for (Object kid : (List<?>) childList) collectFx(kid, out);
            }
        }
        // TabPane holds Tab objects that are NOT part of getChildrenUnmodifiable()
        // (Tab is not a Node); walk them separately so selectors can find Tab text/id.
        if (isInstanceOfFx(node, "javafx.scene.control.TabPane")) {
            try {
                Method getTabs = node.getClass().getMethod("getTabs");
                Object tabsList = getTabs.invoke(node);
                if (tabsList instanceof List) {
                    for (Object tab : (List<?>) tabsList) {
                        out.add(tab);
                        // A Tab's content Node may itself contain further controls
                        // (e.g. a GridPane of fields inside a tab) not otherwise
                        // reached because Tab isn't a Node in getChildrenUnmodifiable().
                        try {
                            Method getContent = tab.getClass().getMethod("getContent");
                            Object content = getContent.invoke(tab);
                            if (content != null) collectFx(content, out);
                        } catch (Throwable ignore) {}
                    }
                }
            } catch (Throwable ignore) {}
        }
    }

    /**
     * find first FX node whose fx:id or visible text matches selector.
     *
     * Matching is done in 6 passes, each a full scan, in priority order:
     *   1. exact match, "interactive" control classes (Tab, ButtonBase,
     *      TextInputControl, ComboBoxBase, TableView/TableColumn) --
     *      excludes plain Labeled/Label on purpose, see below
     *   2. exact match, any other "notable" public-API class (Labeled/Label)
     *   3. exact match, any node at all (including internal skin classes)
     *   4-6. same three passes again, case-insensitive
     *
     * The staged priority exists because JavaFX skins duplicate a Tab's
     * text onto an internal, but PUBLIC-API-typed, javafx.scene.control.Label
     * living inside the TabPane's header skin (e.g. selecting "TabTwo"
     * would otherwise land on that decorative header Label instead of the
     * actual Tab object, since both are "notable" classes and the Label is
     * reached first in traversal order). Ranking actionable control types
     * above Labeled fixes this without needing MCSJ-specific hacks.
     */
    private static Object findFxNode(String selector) throws Exception {
        if (selector == null) return null;
        selector = selector.trim();
        if (selector.length() == 0) return null;
        List<Object> all = allFxNodes();

        for (Object n : all) {
            if (isInteractiveFxClass(n) && (selector.equals(fxGetId(n)) || selector.equals(fxGetText(n)))) return n;
        }
        for (Object n : all) {
            if (isNotableFxClass(n) && (selector.equals(fxGetId(n)) || selector.equals(fxGetText(n)))) return n;
        }
        for (Object n : all) {
            if (selector.equals(fxGetId(n)) || selector.equals(fxGetText(n))) return n;
        }
        for (Object n : all) {
            if (isInteractiveFxClass(n) && (selector.equalsIgnoreCase(fxGetId(n)) || selector.equalsIgnoreCase(fxGetText(n)))) return n;
        }
        for (Object n : all) {
            if (isNotableFxClass(n) && (selector.equalsIgnoreCase(fxGetId(n)) || selector.equalsIgnoreCase(fxGetText(n)))) return n;
        }
        for (Object n : all) {
            if (selector.equalsIgnoreCase(fxGetId(n)) || selector.equalsIgnoreCase(fxGetText(n))) return n;
        }
        return null;
    }

    /** Highest-priority actionable control types: things FXCLICK/FXSETTEXT can actually do something with. */
    private static boolean isInteractiveFxClass(Object node) {
        if (node == null) return false;
        String cn = node.getClass().getName();
        if (cn.startsWith("com.sun.javafx.")) return false;
        return isInstanceOfFx(node, "javafx.scene.control.Tab")
            || isInstanceOfFx(node, "javafx.scene.control.ButtonBase")
            || isInstanceOfFx(node, "javafx.scene.control.TextInputControl")
            || isInstanceOfFx(node, "javafx.scene.control.ComboBoxBase")
            || isInstanceOfFx(node, "javafx.scene.control.TableView")
            || isInstanceOfFx(node, "javafx.scene.control.TableColumnBase");
    }

    /**
     * True for real public-API javafx.scene.control.* / javafx.scene.Parent
     * node types a caller is likely to actually mean (Tab, ButtonBase,
     * TextInputControl, ComboBoxBase, TableView/TableColumn, Labeled),
     * false for internal skin/implementation classes
     * (com.sun.javafx.scene.control.skin.*, *Skin$*, etc.) that merely
     * happen to carry the same visible text as decoration.
     */
    private static boolean isNotableFxClass(Object node) {
        if (node == null) return false;
        String cn = node.getClass().getName();
        if (cn.startsWith("com.sun.javafx.")) return false;
        if (cn.contains("Skin$") || cn.endsWith("Skin")) return false;
        return isInteractiveFxClass(node) || isInstanceOfFx(node, "javafx.scene.control.Labeled");
    }

    // ---------------------------------------------------------------
    // FXCLICK / FXSETTEXT / FXGETTEXT — MUST be called already on the
    // JavaFX Application Thread (invoked from inside a Platform.runLater).
    // ---------------------------------------------------------------

    private static String fxDoClick(String selector) throws Exception {
        Object node = findFxNode(selector);
        if (node == null) return "NOTFOUND selector=" + selector;

        // Case 1: ButtonBase (Button, ToggleButton, CheckBox, RadioButton, Hyperlink...)
        // -- fire() invokes the action handler directly, no synthetic mouse event needed.
        if (isInstanceOfFx(node, "javafx.scene.control.ButtonBase")) {
            Method fire = node.getClass().getMethod("fire");
            fire.setAccessible(true);
            fire.invoke(node);
            return "OK class=" + node.getClass().getName() + " method=fire()";
        }

        // Case 2: Tab -- select it via its owning TabPane's selection model.
        if (isInstanceOfFx(node, "javafx.scene.control.Tab")) {
            Method getTabPane = node.getClass().getMethod("getTabPane");
            Object tabPane = getTabPane.invoke(node);
            if (tabPane == null) {
                return "ERROR tab has no owning TabPane (not attached?) class=" + node.getClass().getName();
            }
            Method getSelectionModel = tabPane.getClass().getMethod("getSelectionModel");
            Object selModel = getSelectionModel.invoke(tabPane);
            Method select = selModel.getClass().getMethod("select", Class.forName("javafx.scene.control.Tab"));
            // The concrete selection-model class (e.g. TabPane$TabPaneSelectionModel) is
            // package-private even though its methods are public, so a plain invoke()
            // throws IllegalAccessException. setAccessible(true) bypasses that language-level
            // check; safe here since we're already running as an injected in-process agent
            // with full reflective access to this JVM.
            select.setAccessible(true);
            select.invoke(selModel, node);
            return "OK class=" + node.getClass().getName() + " method=TabPane.getSelectionModel().select(tab)";
        }

        // Case 3: TableView -- select a row by index encoded as the selector
        // in the form "TableView:<index>" is NOT how selectors work here
        // (selector matches id/text) -- instead if the node itself IS a
        // TableView, select its first row as a conservative default, and
        // note this limitation clearly rather than guessing further.
        if (isInstanceOfFx(node, "javafx.scene.control.TableView")) {
            Method getSelectionModel = node.getClass().getMethod("getSelectionModel");
            Object selModel = getSelectionModel.invoke(node);
            Method selectFirst = selModel.getClass().getMethod("selectFirst");
            selectFirst.setAccessible(true);
            selectFirst.invoke(selModel);
            return "OK class=" + node.getClass().getName() +
                " method=TableView.getSelectionModel().selectFirst() " +
                "(NOTE: FXCLICK on a TableView selects row 0 only; row-index / cell-level " +
                "selection is not implemented in this generic bridge -- would need a " +
                "TableView-specific selector syntax, out of scope here)";
        }

        // Case 4: anything else that exposes a no-arg fire()-like or
        // requestFocus()+synthetic MouseEvent fallback. Try a generic
        // "fire" method via reflection first (covers MenuItem, which is
        // not a Node but is common inside FX menu bars).
        try {
            Method fire = node.getClass().getMethod("fire");
            fire.invoke(node);
            return "OK class=" + node.getClass().getName() + " method=fire() (generic reflection path)";
        } catch (NoSuchMethodException nsme) {
            // fall through to synthetic MouseEvent as last resort
        }

        // Last resort: synthetic MouseEvent with click count 2 (double-click),
        // matching the task brief's suggestion for TableView cell editing.
        // Attempted only if the node is a javafx.scene.Node (has fireEvent).
        if (isInstanceOfFx(node, "javafx.scene.Node")) {
            try {
                Object screenPoint = null; // best-effort center point
                double cx = 0, cy = 0;
                try {
                    Method getBoundsInLocal = node.getClass().getMethod("getBoundsInLocal");
                    Object b = getBoundsInLocal.invoke(node);
                    double w = ((Number) b.getClass().getMethod("getWidth").invoke(b)).doubleValue();
                    double h = ((Number) b.getClass().getMethod("getHeight").invoke(b)).doubleValue();
                    cx = w / 2; cy = h / 2;
                } catch (Throwable ignore) {}

                Class<?> meClass = Class.forName("javafx.scene.input.MouseEvent");
                Class<?> metClass = Class.forName("javafx.scene.input.MouseButton");
                Object primary = metClass.getField("PRIMARY").get(null);

                Constructor<?> ctor = null;
                for (Constructor<?> c2 : meClass.getConstructors()) {
                    if (c2.getParameterTypes().length == 15) { ctor = c2; break; } // classic 15-arg MouseEvent ctor
                }
                if (ctor == null) {
                    return "ERROR no synthetic-MouseEvent constructor found (15-arg overload) for class=" + node.getClass().getName();
                }
                Class<?> eventTypeClass = Class.forName("javafx.event.EventType");
                Object mouseClicked = meClass.getField("MOUSE_CLICKED").get(null);
                Object evt = ctor.newInstance(
                    mouseClicked, cx, cy, cx, cy, primary, 2 /*clickCount*/,
                    false, false, false, false, true /*primaryButtonDown*/, false, false,
                    false, false, false, null);
                Method fireEvent = Class.forName("javafx.event.Event").getMethod("fireEvent",
                    Class.forName("javafx.event.EventTarget"), Class.forName("javafx.event.Event"));
                fireEvent.invoke(null, node, evt);
                return "OK class=" + node.getClass().getName() +
                    " method=Event.fireEvent(node, synthetic MOUSE_CLICKED clickCount=2) (fallback path, less reliable than fire())";
            } catch (Throwable t) {
                return "ERROR synthetic MouseEvent fallback failed for class=" + node.getClass().getName() + ": " + t;
            }
        }

        return "NOTCLICKABLE class=" + node.getClass().getName();
    }

    private static String fxDoSetText(String selector, String value) throws Exception {
        Object node = findFxNode(selector);
        if (node == null) return "NOTFOUND selector=" + selector;
        if (isInstanceOfFx(node, "javafx.scene.control.TextInputControl")) {
            Method setText = node.getClass().getMethod("setText", String.class);
            setText.setAccessible(true);
            setText.invoke(node, value);
            return "OK class=" + node.getClass().getName();
        }
        return "NOTTEXTCOMPONENT class=" + node.getClass().getName();
    }

    private static String fxDoGetText(String selector) throws Exception {
        Object node = findFxNode(selector);
        if (node == null) return "NOTFOUND selector=" + selector;
        return "OK class=" + node.getClass().getName() + " text=" + fxGetText(node);
    }
}

