#!/usr/bin/env python3
# Nucleic — Phase 0 AT-SPI probe (docs/LINUX_VM_SEMANTIC_AGENT.md §Phase 0).
#
# Ground truth *before* building the Linux semantic agent: walk the AT-SPI accessibility tree of a
# target app set inside a GNOME/Wayland guest and measure the two things that decide the plan's shape:
#
#   1. **Semantic-action coverage** — what fraction of interactive controls are reachable by a
#      *semantic* action (the `ax_*` surface: Action.doAction → ax_press, EditableText / Value →
#      ax_set_value, grabFocus → ax_focus) with NO coordinates. High coverage means geometry (the hard,
#      Wayland-broken part) barely matters; low coverage means Phase 2/3 geometry work is load-bearing.
#
#   2. **Geometry validity** — whether `Component.get_extents(SCREEN)` returns real on-screen
#      coordinates or the known-broken Wayland output (zeros, or identical to WINDOW-relative extents
#      because the client can't know its surface origin). This is *the* gap Newton exists to close.
#
# It also times a full-tree walk (AT-SPI D-Bus is chatty on large trees) and emits, per node, a record
# that mirrors the macOS agent's `ax_dump` schema (guest/NucleicVMAgent Ops+AX.swift: ref / role /
# title / value / states / actions / frame) so Phase 1's Linux `ax_dump` can be checked for parity.
#
# This is a THROWAWAY measurement tool that doubles as the per-GNOME-bump regression harness (§7): its
# coverage/geometry table must not regress across a pinned-Mutter upgrade.
#
# Runs INSIDE the Linux guest (needs a live AT-SPI bus + running apps); it cannot run on the macOS host
# or in the build container. Requires: python3-gi + gir1.2-atspi-2.0 (Debian/Ubuntu), i.e.
#   apt-get install -y python3-gi gir1.2-atspi-2.0
# and an AT-SPI bus reachable on the session (AT_SPI_BUS / a running at-spi2 registry). For Chromium /
# Electron targets, launch them with accessibility forced on (e.g. --force-renderer-accessibility) or
# the tree is lazily empty until an AT attaches.
#
# Usage:
#   scripts/a11y-probe.py --list                      # list the apps AT-SPI can see
#   scripts/a11y-probe.py                              # probe every app, print the summary table
#   scripts/a11y-probe.py --app gedit --app foot       # probe only apps whose name matches
#   scripts/a11y-probe.py --json out.json --tree       # machine-readable dump incl. the node tree
#
# Status: pending real-guest validation (no AT-SPI in the build container). The AT-SPI binding method
# names are queried defensively so minor GI-version differences degrade gracefully rather than crash.

import argparse
import json
import sys
import time

try:
    import gi

    gi.require_version("Atspi", "2.0")
    from gi.repository import Atspi
except Exception as exc:  # pragma: no cover - environment guard
    sys.stderr.write(
        "a11y-probe: could not import the AT-SPI GObject-Introspection bindings (%s).\n"
        "Install them in the guest: apt-get install -y python3-gi gir1.2-atspi-2.0\n" % exc
    )
    sys.exit(2)


# ---- interface access (defensive against GI accessor-name drift) -----------------------------------

# The C API is atspi_accessible_get_<iface>_iface(); GI usually maps that to acc.get_<iface>_iface().
# Some builds also expose the older acc.get_<iface>(). Try the modern name first, fall back, and treat
# any failure as "interface absent" — a probe must never crash on one odd node.
def _iface(acc, *names):
    for name in names:
        fn = getattr(acc, name, None)
        if fn is None:
            continue
        try:
            iface = fn()
        except Exception:
            continue
        if iface is not None:
            return iface
    return None


def action_iface(acc):
    return _iface(acc, "get_action_iface", "get_action")


def component_iface(acc):
    return _iface(acc, "get_component_iface", "get_component")


def value_iface(acc):
    return _iface(acc, "get_value_iface", "get_value")


def editable_text_iface(acc):
    return _iface(acc, "get_editable_text_iface", "get_editable_text")


def text_iface(acc):
    return _iface(acc, "get_text_iface")


# ---- state handling --------------------------------------------------------------------------------

# The subset of AT-SPI states that matter for "is this an actionable/interactive control" and for the
# node record. Curated (rather than dumping the whole StateSet) to keep records small and stable.
STATE_NAMES = [
    "FOCUSABLE",
    "FOCUSED",
    "EDITABLE",
    "ENABLED",
    "SENSITIVE",
    "SELECTABLE",
    "SELECTED",
    "CHECKABLE",
    "CHECKED",
    "EXPANDABLE",
    "EXPANDED",
    "SHOWING",
    "VISIBLE",
]


def states_of(acc):
    """Return the subset of STATE_NAMES this accessible has set."""
    try:
        ss = acc.get_state_set()
    except Exception:
        return []
    out = []
    for name in STATE_NAMES:
        st = getattr(Atspi.StateType, name, None)
        if st is None:
            continue
        try:
            if ss.contains(st):
                out.append(name)
        except Exception:
            pass
    return out


# Roles (by AT-SPI role name, lowercased) we count as "interactive" — the denominator for coverage.
# A node also counts as interactive if it is FOCUSABLE or has an action, so this list only needs to
# capture controls that might expose neither (rare) plus give a stable role-based view.
INTERACTIVE_ROLES = {
    "push button",
    "toggle button",
    "check box",
    "radio button",
    "menu item",
    "check menu item",
    "radio menu item",
    "menu",
    "combo box",
    "entry",
    "password text",
    "spin button",
    "slider",
    "scroll bar",
    "link",
    "list item",
    "tree item",
    "table cell",
    "page tab",
    "tab",
    "document web",
    "text",  # only interactive when EDITABLE — refined below
}


# ---- per-node record -------------------------------------------------------------------------------

MAX_VALUE_CHARS = 250  # match the macOS agent's value truncation


def rect_dict(rect):
    """Atspi.Rect → the macOS agent's {x,y,w,h} frame shape, or None."""
    if rect is None:
        return None
    try:
        return {"x": int(rect.x), "y": int(rect.y), "w": int(rect.width), "h": int(rect.height)}
    except Exception:
        return None


def value_text(acc):
    """A short human/model-readable value string (editable/label text, or a Value number)."""
    ti = text_iface(acc)
    if ti is not None:
        try:
            n = ti.get_character_count()
            if n > 0:
                s = ti.get_text(0, min(n, MAX_VALUE_CHARS))
                if s:
                    return s + ("…" if n > MAX_VALUE_CHARS else "")
        except Exception:
            pass
    vi = value_iface(acc)
    if vi is not None:
        try:
            return str(vi.get_current_value())
        except Exception:
            pass
    return None


def action_names(acc):
    ai = action_iface(acc)
    if ai is None:
        return []
    try:
        n = ai.get_n_actions()
    except Exception:
        return []
    out = []
    for i in range(n):
        try:
            out.append(ai.get_action_name(i) or "action%d" % i)
        except Exception:
            out.append("action%d" % i)
    return out


def node_record(acc, ref, want_geometry=True):
    """Build a node record mirroring the macOS ax_dump schema, plus probe-only geometry fields
    (window vs screen extents) used for the geometry-validity measurement."""
    rec = {"ref": ref}
    try:
        rec["role"] = acc.get_role_name()
    except Exception:
        rec["role"] = "?"
    try:
        name = acc.get_name()
        if name:
            rec["title"] = name
    except Exception:
        pass

    val = value_text(acc)
    if val:
        rec["value"] = val

    st = states_of(acc)
    rec["states"] = st
    if "ENABLED" not in st and "SENSITIVE" not in st:
        rec["enabled"] = False
    if "FOCUSED" in st:
        rec["focused"] = True

    acts = action_names(acc)
    if acts:
        rec["actions"] = acts

    # Interface presence flags — drives the coverage classification below.
    rec["_ifaces"] = {
        "action": bool(acts),
        "editable_text": editable_text_iface(acc) is not None,
        "value": value_iface(acc) is not None,
        "text": text_iface(acc) is not None,
        "component": component_iface(acc) is not None,
    }

    if want_geometry:
        ci = component_iface(acc)
        if ci is not None:
            win = scr = None
            try:
                win = rect_dict(ci.get_extents(Atspi.CoordType.WINDOW))
            except Exception:
                pass
            try:
                scr = rect_dict(ci.get_extents(Atspi.CoordType.SCREEN))
            except Exception:
                pass
            rec["frame_window"] = win
            rec["frame_screen"] = scr
            # The macOS schema's `frame` is global (screen) coords; expose the same key so a Phase 1
            # parity check can diff shapes directly.
            if scr is not None:
                rec["frame"] = scr
    return rec


# ---- classification for the coverage/geometry metrics ----------------------------------------------


def is_interactive(rec):
    """Interactive = the denominator for semantic-action coverage."""
    role = rec.get("role", "")
    st = rec.get("states", [])
    if role == "text":
        # Static labels are role "text" too; only editable text counts as interactive.
        return "EDITABLE" in st
    if role in INTERACTIVE_ROLES:
        return True
    if "FOCUSABLE" in st:
        return True
    return bool(rec.get("actions"))


def is_semantically_actionable(rec):
    """Reachable by an ax_* op with no coordinates: press (action) / set_value (editable|value) /
    focus (focusable)."""
    ifc = rec.get("_ifaces", {})
    st = rec.get("states", [])
    if ifc.get("action"):
        return True
    if ifc.get("editable_text") or "EDITABLE" in st:
        return True
    if ifc.get("value"):
        return True
    if "FOCUSABLE" in st:
        return True
    return False


def geometry_flags(rec):
    """(has_component, screen_zero, screen_eq_window) for one node — the geometry-validity inputs."""
    ifc = rec.get("_ifaces", {})
    if not ifc.get("component"):
        return (False, False, False)
    win = rec.get("frame_window")
    scr = rec.get("frame_screen")
    if scr is None:
        return (True, True, False)  # no screen extents at all == broken for our purposes
    screen_zero = (scr.get("w", 0) <= 0) or (scr.get("h", 0) <= 0)
    screen_eq_window = win is not None and win == scr
    return (True, screen_zero, screen_eq_window)


# ---- tree walk -------------------------------------------------------------------------------------


class Walk:
    def __init__(self, max_nodes, max_children, want_geometry, want_tree):
        self.max_nodes = max_nodes
        self.max_children = max_children
        self.want_geometry = want_geometry
        self.want_tree = want_tree
        self.count = 0
        self.records = []
        self.truncated = False
        self._counter = 0

    def _ref(self):
        self._counter += 1
        return "e%d" % self._counter

    def walk(self, acc, depth, max_depth):
        if self.count >= self.max_nodes:
            self.truncated = True
            return None
        self.count += 1
        rec = node_record(acc, self._ref(), want_geometry=self.want_geometry)
        self.records.append(rec)

        children_nodes = []
        if depth < max_depth and self.count < self.max_nodes:
            try:
                n = acc.get_child_count()
            except Exception:
                n = 0
            shown = 0
            for i in range(n):
                if self.count >= self.max_nodes:
                    self.truncated = True
                    break
                if shown >= self.max_children:
                    rec["childrenOmitted"] = n - shown
                    break
                try:
                    child = acc.get_child_at_index(i)
                except Exception:
                    continue
                if child is None:
                    continue
                sub = self.walk(child, depth + 1, max_depth)
                if sub is not None:
                    children_nodes.append(sub)
                shown += 1
        if self.want_tree:
            if children_nodes:
                rec["children"] = children_nodes
            return _tree_view(rec)
        return None


def _tree_view(rec):
    """Strip probe-only helper keys for the macOS-parity tree view."""
    out = {k: v for k, v in rec.items() if not k.startswith("_") and k not in ("frame_window", "frame_screen", "states")}
    if rec.get("states"):
        out["states"] = rec["states"]
    return out


# ---- app enumeration -------------------------------------------------------------------------------


def app_pid(acc):
    for name in ("get_process_id", "get_pid"):
        fn = getattr(acc, name, None)
        if fn is None:
            continue
        try:
            return int(fn())
        except Exception:
            pass
    return None


def list_apps(desktop):
    apps = []
    try:
        n = desktop.get_child_count()
    except Exception:
        n = 0
    for i in range(n):
        try:
            app = desktop.get_child_at_index(i)
        except Exception:
            continue
        if app is None:
            continue
        try:
            name = app.get_name() or "(unnamed)"
        except Exception:
            name = "(unnamed)"
        try:
            children = app.get_child_count()
        except Exception:
            children = 0
        apps.append({"index": i, "name": name, "pid": app_pid(app), "toplevels": children, "acc": app})
    return apps


# ---- per-app measurement ---------------------------------------------------------------------------


def measure_app(app, args):
    walker = Walk(
        max_nodes=args.max_nodes,
        max_children=args.max_children,
        want_geometry=not args.no_geometry,
        want_tree=args.tree,
    )
    t0 = time.monotonic()
    tree = walker.walk(app, 0, args.max_depth)
    elapsed = time.monotonic() - t0

    recs = walker.records
    interactive = [r for r in recs if is_interactive(r)]
    actionable = [r for r in interactive if is_semantically_actionable(r)]

    comp_total = screen_zero = screen_eq_window = 0
    for r in recs:
        has_comp, s_zero, s_eq = geometry_flags(r)
        if has_comp:
            comp_total += 1
            if s_zero:
                screen_zero += 1
            if s_eq:
                screen_eq_window += 1

    def frac(num, den):
        return (num / den) if den else 0.0

    summary = {
        "nodes": len(recs),
        "truncated": walker.truncated,
        "walk_seconds": round(elapsed, 3),
        "interactive": len(interactive),
        "semantically_actionable": len(actionable),
        "coverage": round(frac(len(actionable), len(interactive)), 3),
        "component_nodes": comp_total,
        "screen_zero_frac": round(frac(screen_zero, comp_total), 3),
        "screen_eq_window_frac": round(frac(screen_eq_window, comp_total), 3),
        # Heuristic verdict: SCREEN geometry is "broken" if most component nodes report zero-size
        # screen extents or screen extents identical to window-relative extents (no surface origin
        # applied — the classic Wayland symptom).
        "geometry_broken": (
            frac(screen_zero, comp_total) > 0.5 or frac(screen_eq_window, comp_total) > 0.5
        )
        if comp_total
        else None,
    }
    result = {"summary": summary}
    if args.tree:
        result["tree"] = tree
    if args.nodes_json:
        result["nodes"] = [
            {k: v for k, v in r.items() if k != "_ifaces"} for r in recs
        ]
    return result


# ---- output ----------------------------------------------------------------------------------------


def print_table(results):
    hdr = (
        "%-24s %6s %5s %8s %10s %9s %10s %9s"
        % ("app", "nodes", "int.", "cover", "walk(s)", "geom-nod", "scr=0", "scr=win")
    )
    print(hdr)
    print("-" * len(hdr))
    for name, res in results:
        s = res["summary"]
        print(
            "%-24s %6d %5d %7.0f%% %10.3f %9d %8.0f%% %8.0f%%"
            % (
                name[:24],
                s["nodes"],
                s["interactive"],
                s["coverage"] * 100,
                s["walk_seconds"],
                s["component_nodes"],
                s["screen_zero_frac"] * 100,
                s["screen_eq_window_frac"] * 100,
            )
        )
    print()
    for name, res in results:
        s = res["summary"]
        verdict = s["geometry_broken"]
        vtxt = "unknown (no component nodes)" if verdict is None else ("BROKEN" if verdict else "usable")
        note = " [truncated]" if s["truncated"] else ""
        print(
            "  %-22s coverage %.0f%% of %d interactive controls; SCREEN geometry: %s%s"
            % (name[:22], s["coverage"] * 100, s["interactive"], vtxt, note)
        )


def main():
    ap = argparse.ArgumentParser(description="Nucleic Phase 0 AT-SPI probe")
    ap.add_argument("--list", action="store_true", help="list the apps AT-SPI can see and exit")
    ap.add_argument(
        "--app",
        action="append",
        default=[],
        metavar="SUBSTR",
        help="probe only apps whose name contains SUBSTR (case-insensitive; repeatable)",
    )
    ap.add_argument("--max-nodes", type=int, default=4000, help="per-app node budget (default 4000)")
    ap.add_argument("--max-depth", type=int, default=25, help="max tree depth (default 25)")
    ap.add_argument(
        "--max-children", type=int, default=64, help="per-node child cap (default 64)"
    )
    ap.add_argument("--no-geometry", action="store_true", help="skip extents (faster tree-only walk)")
    ap.add_argument("--tree", action="store_true", help="include the macOS-parity node tree in JSON")
    ap.add_argument("--nodes-json", action="store_true", help="include the flat per-node records in JSON")
    ap.add_argument("--json", metavar="PATH", help="write the full machine-readable result to PATH")
    args = ap.parse_args()

    if Atspi.init() != 0:
        sys.stderr.write(
            "a11y-probe: Atspi.init() failed — no AT-SPI bus. Is the desktop session up and the "
            "at-spi2 registry running? (Wayland: the session bus must expose org.a11y.Bus.)\n"
        )
        return 3

    try:
        if Atspi.get_desktop_count() < 1:
            sys.stderr.write("a11y-probe: no AT-SPI desktop found.\n")
            return 3
        desktop = Atspi.get_desktop(0)
        apps = list_apps(desktop)

        if args.list:
            print("%-4s %-30s %-8s %s" % ("idx", "name", "pid", "toplevels"))
            for a in apps:
                print("%-4d %-30s %-8s %d" % (a["index"], a["name"][:30], a["pid"], a["toplevels"]))
            return 0

        if args.app:
            wanted = [s.lower() for s in args.app]
            apps = [a for a in apps if any(w in a["name"].lower() for w in wanted)]
            if not apps:
                sys.stderr.write("a11y-probe: no apps matched %r\n" % args.app)
                return 4

        results = []
        for a in apps:
            res = measure_app(a["acc"], args)
            res["summary"]["name"] = a["name"]
            res["summary"]["pid"] = a["pid"]
            results.append((a["name"], res))

        print_table(results)

        if args.json:
            payload = {
                "tool": "a11y-probe",
                "max_nodes": args.max_nodes,
                "apps": [
                    {"name": name, **{k: v for k, v in res.items()}} for name, res in results
                ],
            }
            with open(args.json, "w") as fh:
                json.dump(payload, fh, indent=2)
            print("\nwrote %s" % args.json)
        return 0
    finally:
        # Best-effort; some GI builds lack a matching exit.
        try:
            Atspi.exit()
        except Exception:
            pass


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