// Copyright (C) 2023-2026  Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

const std = @import("std");
const lp = @import("lightpanda");

const browser_tools = lp.tools;
const BrowserTool = browser_tools.Tool;
const CDPNode = @import("../cdp/Node.zig");
const Schema = @import("Schema.zig");

const v8 = lp.js.v8;

const Runtime = @This();

allocator: std.mem.Allocator,
app: *lp.App,
session: *lp.Session,
registry: *CDPNode.Registry,
env: lp.js.Env,
context: v8.Global,
has_context: bool,
call_arena: std.heap.ArenaAllocator,
primitive_data: [recorded_tool_count]PrimitiveData,
console_data: [std.enums.values(ConsoleMethod).len]ConsoleData,
/// Notified before each `console.*` line is written. The REPL uses it to
/// clear the live spinner so script output starts on a clean line instead
/// of colliding with the indicator; the line still goes to stdout/stderr.
console_observer: ?ConsoleObserver = null,
/// In-flight async `goto`s; emptied before each `runSource` returns.
pending_gotos: std.ArrayList(PendingGoto),
/// Restarted per `runSource`; backs `PendingGoto.deadline_ms`.
run_timer: std.time.Timer,

/// The runtime installs exactly the recorded browser tools as script
/// primitives — the same set the recorder writes — so every recorded call
/// replays. `buildArgs` adapts each tool's JS calling convention to the JSON
/// `browser_tools.call` expects.
const recorded_tool_count = blk: {
    var n: usize = 0;
    for (std.enums.values(BrowserTool)) |t| {
        if (t.isRecorded()) n += 1;
    }
    break :blk n;
};

const PrimitiveData = struct {
    runtime: *Runtime,
    tool: BrowserTool,
};

/// An async `goto` not yet settled. The resolver and receiver (`this`) are
/// Globals so they survive the microtask drains between driver iterations.
const PendingGoto = struct {
    frame_id: u32,
    resolver: v8.Global,
    receiver: v8.Global,
    /// `run_timer` reading (ms) past which the navigation is abandoned.
    deadline_ms: u64,

    fn reset(self: *PendingGoto) void {
        v8.v8__Global__Reset(&self.resolver);
        v8.v8__Global__Reset(&self.receiver);
    }
};

const ConsoleMethod = enum {
    debug,
    @"error",
    info,
    log,
    warn,

    fn writesStderr(self: ConsoleMethod) bool {
        return switch (self) {
            .@"error", .warn => true,
            .debug, .info, .log => false,
        };
    }
};

const ConsoleData = struct {
    runtime: *Runtime,
    method: ConsoleMethod,
};

pub const ConsoleObserver = struct {
    context: *anyopaque,
    notify: *const fn (context: *anyopaque) void,
};

pub const InitError = error{
    OutOfMemory,
    RuntimeInitFailed,
    TooManyContexts,
};

pub const RunError = error{
    OutOfMemory,
};

pub fn init(
    allocator: std.mem.Allocator,
    app: *lp.App,
    session: *lp.Session,
    registry: *CDPNode.Registry,
) InitError!*Runtime {
    const self = try allocator.create(Runtime);
    errdefer allocator.destroy(self);

    self.* = .{
        .allocator = allocator,
        .app = app,
        .session = session,
        .registry = registry,
        .env = undefined,
        .context = undefined,
        .has_context = false,
        .call_arena = .init(allocator),
        .primitive_data = undefined,
        .console_data = undefined,
        .pending_gotos = .empty,
        .run_timer = undefined,
    };
    errdefer self.call_arena.deinit();

    // Separate isolate from the page. The full `Env` is used only as an isolate
    // + terminate/microtask carrier; the agent context is bare (no WebAPIs).
    self.env = lp.js.Env.init(app, .{}) catch return error.RuntimeInitFailed;
    errdefer self.env.deinit();
    self.env.protectHeapLimit();

    try self.createContext();
    errdefer self.resetContext();

    return self;
}

pub fn deinit(self: *Runtime) void {
    // Free leftover Globals while the isolate is still alive (a run terminated
    // mid-flight can leave some).
    for (self.pending_gotos.items) |*pending| pending.reset();
    self.pending_gotos.deinit(self.allocator);
    self.resetContext();
    self.env.deinit();
    self.call_arena.deinit();
    const allocator = self.allocator;
    allocator.destroy(self);
}

pub fn terminate(self: *Runtime) void {
    self.env.terminate();
}

pub fn cancelTerminate(self: *Runtime) void {
    self.env.cancelTerminate();
}

fn createContext(self: *Runtime) InitError!void {
    var hs: lp.js.HandleScope = undefined;
    hs.init(self.env.isolate);
    defer hs.deinit();

    const context = v8.v8__Context__New(self.env.isolate.handle, null, null) orelse
        return error.RuntimeInitFailed;
    v8.v8__Global__New(self.env.isolate.handle, context, &self.context);
    self.has_context = true;

    v8.v8__Context__Enter(context);
    defer v8.v8__Context__Exit(context);

    // The promise-reject callback every `Env` installs reads a browser `Context`
    // from embedder slot 1; this bare context has none. Pin the slot to null so
    // the callback no-ops instead of aligncasting garbage — the script body runs
    // inside an async wrapper, so unhandled rejections happen routinely.
    v8.v8__Context__SetAlignedPointerInEmbedderData(context, 1, null);

    const global = v8.v8__Context__Global(context) orelse return error.RuntimeInitFailed;

    // `Page` is the only global verb; `new Page()` attaches a method for each
    // recorded tool, reusing these `primitive_data` entries — so fill them here.
    var i: usize = 0;
    for (std.enums.values(BrowserTool)) |t| {
        if (!t.isRecorded()) continue;
        self.primitive_data[i] = .{ .runtime = self, .tool = t };
        i += 1;
    }
    try self.installFunction(context, global, "Page", pageConstructor, self);
    try self.installConsole(context, global);
}

fn resetContext(self: *Runtime) void {
    if (!self.has_context) return;
    v8.v8__Global__Reset(&self.context);
    self.env.isolate.notifyContextDisposed();
    self.has_context = false;
}

const Callback = *const fn (?*const v8.FunctionCallbackInfo) callconv(.c) void;

/// Install `callback` (carrying `data` as its `External`) under `name` on `object`.
fn installFunction(
    self: *Runtime,
    context: *const v8.Context,
    object: *const v8.Object,
    name: []const u8,
    callback: Callback,
    data: *anyopaque,
) InitError!void {
    const external = self.env.isolate.createExternal(data);
    const func = v8.v8__Function__New__DEFAULT2(context, callback, external) orelse
        return error.RuntimeInitFailed;
    try self.setObjectProperty(context, object, name, @ptrCast(func));
}

fn installConsole(
    self: *Runtime,
    context: *const v8.Context,
    global: *const v8.Object,
) InitError!void {
    const console = v8.v8__Object__New(self.env.isolate.handle) orelse
        return error.RuntimeInitFailed;

    for (std.enums.values(ConsoleMethod), 0..) |method, i| {
        self.console_data[i] = .{ .runtime = self, .method = method };
        try self.installFunction(context, console, @tagName(method), consoleCallback, &self.console_data[i]);
    }

    try setObjectProperty(self, context, global, "console", @ptrCast(console));
}

fn setObjectProperty(
    self: *Runtime,
    context: *const v8.Context,
    object: *const v8.Object,
    name: []const u8,
    value: *const v8.Value,
) InitError!void {
    var out: v8.MaybeBool = undefined;
    v8.v8__Object__Set(
        object,
        context,
        @ptrCast(self.env.isolate.initStringHandle(name)),
        value,
        &out,
    );
    if (!out.has_value or !out.value) return error.RuntimeInitFailed;
}

/// Run script source in the agent context. Returns null on success; on a JS
/// compile/runtime exception returns a formatted error allocated in this
/// runtime's call arena and valid until deinit or the next run.
pub fn runSource(self: *Runtime, source: []const u8, name: []const u8) RunError!?[]const u8 {
    _ = self.call_arena.reset(.retain_capacity);
    self.run_timer = std.time.Timer.start() catch return try self.dupeError("internal: timer unavailable");

    var hs: lp.js.HandleScope = undefined;
    hs.init(self.env.isolate);
    defer hs.deinit();

    const context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.context, self.env.isolate.handle) orelse
        return try self.dupeError("agent script context is not available"));
    v8.v8__Context__Enter(context);
    defer v8.v8__Context__Exit(context);

    var try_catch: v8.TryCatch = undefined;
    v8.v8__TryCatch__CONSTRUCT(&try_catch, self.env.isolate.handle);
    defer v8.v8__TryCatch__DESTRUCT(&try_catch);

    const script_name = self.env.isolate.initStringHandle(name);
    // Wrap in an async IIFE so the source can use top-level `await` (e.g.
    // `await page.goto(...)`). The wrapper evaluates to a Promise; a top-level
    // `return <expr>` becomes that Promise's value, which we echo. (A bare
    // trailing expression no longer auto-prints — `await` and a script
    // completion value are mutually exclusive in JS.)
    const wrapped = std.fmt.allocPrint(self.call_arena.allocator(), "(async () => {{\n{s}\n}})()", .{source}) catch
        return try self.dupeError("out of memory");
    const script_source = self.env.isolate.initStringHandle(wrapped);

    var origin: v8.ScriptOrigin = undefined;
    v8.v8__ScriptOrigin__CONSTRUCT(&origin, script_name);

    var compiler_source: v8.ScriptCompilerSource = undefined;
    v8.v8__ScriptCompiler__Source__CONSTRUCT2(script_source, &origin, null, &compiler_source);
    defer v8.v8__ScriptCompiler__Source__DESTRUCT(&compiler_source);

    const script = v8.v8__ScriptCompiler__Compile(
        context,
        &compiler_source,
        v8.kNoCompileOptions,
        v8.kNoCacheNoReason,
    ) orelse return try self.formatCaught(context, &try_catch, "compile failed");

    const completion = v8.v8__Script__Run(script, context) orelse
        return try self.formatCaught(context, &try_catch, "script failed");

    // `goto` only *starts* a navigation, so the root Promise is usually still
    // pending; drive the in-flight ones to completion.
    const root: *const v8.Promise = @ptrCast(completion);
    self.driveAsync(context, &try_catch, root);
    if (v8.v8__TryCatch__HasCaught(&try_catch)) {
        return try self.formatCaught(context, &try_catch, "script failed");
    }

    // A still-pending root means the script awaited something we can't settle
    // (no async navigation is in flight) — stay silent.
    const state = promiseState(root);
    if (state != v8.kFulfilled and state != v8.kRejected) return null;
    const completion_value = v8.v8__Promise__Result(root) orelse return null;
    if (state == v8.kRejected) return try self.formatRejection(context, completion_value);
    self.printCompletion(context, completion_value);
    return null;
}

/// `v8__Promise__State` returns the `c_uint` `PromiseState`, but the `k*`
/// constants are `c_int`; normalize so comparisons and switches type-check.
fn promiseState(promise: *const v8.Promise) c_int {
    return @intCast(v8.v8__Promise__State(promise));
}

/// Format a script's rejection reason into a run-arena message. Prefers a thrown
/// Error's `.message` so the caller's own "Error:" label isn't doubled
/// (`Value.toString` on an Error yields "Error: <message>").
fn formatRejection(self: *Runtime, context: *const v8.Context, reason: *const v8.Value) RunError![]const u8 {
    const value = self.errorMessage(context, reason) orelse reason;
    const text = self.valueToString(self.call_arena.allocator(), context, value) catch
        return try self.dupeError("script failed");
    return try self.dupeError(text);
}

/// The `.message` of a thrown Error (a string), or null when `reason` is not an
/// Error-like object — in which case the caller stringifies the value itself.
fn errorMessage(self: *Runtime, context: *const v8.Context, reason: *const v8.Value) ?*const v8.Value {
    if (!v8.v8__Value__IsObject(reason)) return null;
    const key: *const v8.Value = @ptrCast(self.env.isolate.initStringHandle("message"));
    const message = v8.v8__Object__Get(@ptrCast(reason), context, key) orelse return null;
    return if (v8.v8__Value__IsString(message)) message else null;
}

/// Echo a script's output — the value it `return`s from the async wrapper, so a
/// script ending in `return page.extract(...)` prints without `console.log`.
/// `undefined` — no `return`, or a bare trailing expression — stays silent.
fn printCompletion(self: *Runtime, context: *const v8.Context, value: *const v8.Value) void {
    if (v8.v8__Value__IsUndefined(value)) return;

    var arena_state: std.heap.ArenaAllocator = .init(self.allocator);
    defer arena_state.deinit();
    const text = self.displayString(arena_state.allocator(), context, value) catch return;
    self.writeConsoleLine(.log, text);
}

/// Unwrap a callback's info handle and its `External` payload (the `*T` passed
/// to `installFunction`). Returns null — so the callback no-ops — if either is
/// missing.
fn callbackData(comptime T: type, info_handle: ?*const v8.FunctionCallbackInfo) ?struct { *const v8.FunctionCallbackInfo, *T } {
    const info = info_handle orelse return null;
    const raw_data = v8.v8__FunctionCallbackInfo__Data(info) orelse return null;
    const data: *T = @ptrCast(@alignCast(v8.v8__External__Value(@ptrCast(raw_data)) orelse return null));
    return .{ info, data };
}

fn primitiveCallback(info_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
    const info, const data = callbackData(PrimitiveData, info_handle) orelse return;
    data.runtime.invoke(data.tool, info);
}

fn consoleCallback(info_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
    const info, const data = callbackData(ConsoleData, info_handle) orelse return;
    data.runtime.invokeConsole(data.method, info);
}

fn pageConstructor(info_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
    const info, const self = callbackData(Runtime, info_handle) orelse return;
    self.construct(info);
}

fn closeCallback(info_handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
    const info, const self = callbackData(Runtime, info_handle) orelse return;
    self.invokeClose(info);
}

/// `new Page()`: attach a method for every recorded tool (including `goto`) plus
/// `close`. `__lpFrameId` is left unset until the first `goto` navigates the page.
fn construct(self: *Runtime, info: *const v8.FunctionCallbackInfo) void {
    if (!v8.v8__FunctionCallbackInfo__IsConstructCall(info)) {
        return self.throwTypeError("Page must be called with new");
    }
    const context = v8.v8__Isolate__GetCurrentContext(self.env.isolate.handle) orelse
        return self.throwError("internal: missing callback context");
    const this = v8.v8__FunctionCallbackInfo__This(info) orelse
        return self.throwError("internal: missing receiver");

    for (&self.primitive_data) |*data| {
        self.installFunction(context, this, @tagName(data.tool), primitiveCallback, data) catch
            return self.throwError("failed to construct page");
    }
    self.installFunction(context, this, "close", closeCallback, self) catch
        return self.throwError("failed to construct page");
}

/// Property carrying a page handle's claim on a live frame. Numeric while the
/// page is navigated; absent/undefined once never-navigated or closed.
const frame_id_key = "__lpFrameId";

/// `page.close()`: stale the wrapper so later method calls error. A single
/// synchronous page has no popups to free; the active page is reclaimed on the
/// next `goto` or at script end.
fn invokeClose(self: *Runtime, info: *const v8.FunctionCallbackInfo) void {
    const context = v8.v8__Isolate__GetCurrentContext(self.env.isolate.handle) orelse return;
    const this = v8.v8__FunctionCallbackInfo__This(info) orelse return;
    self.setObjectProperty(context, this, frame_id_key, self.env.isolate.initUndefined()) catch {};
}

fn invoke(self: *Runtime, tool: BrowserTool, info: *const v8.FunctionCallbackInfo) void {
    // Owned, not shared: marshalling runs JS (`toJSON`) that can re-enter a
    // primitive; a shared arena would let the nested call reset ours mid-flight.
    var arena_state: std.heap.ArenaAllocator = .init(self.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const context = v8.v8__Isolate__GetCurrentContext(self.env.isolate.handle) orelse {
        self.throwError("internal: missing callback context");
        return;
    };

    const args = self.buildArgs(arena, context, tool, info) catch |err| switch (err) {
        error.OutOfMemory => return self.throwError("out of memory"),
        error.JsException => return,
        error.InvalidArguments => return self.throwTypeError("invalid arguments"),
    };

    if (tool == .goto) return self.invokeGoto(arena, context, info, args);

    // Route the call at the receiver's own frame: sibling pages may coexist, so
    // `pages[0]` is not necessarily this handle's page.
    const frame_id = self.receiverFrameId(context, info) orelse
        return self.throwError("page is not navigated or has been closed; call page.goto(url) first");
    if (self.session.findFrameByFrameId(frame_id) == null)
        return self.throwError("page handle is no longer valid; the page was closed");

    self.session.setToolFrameOverride(frame_id);
    defer self.session.setToolFrameOverride(null);

    const result = self.callTool(arena, tool, args) catch |err| switch (err) {
        error.OutOfMemory => return self.throwError("out of memory"),
    };

    switch (result) {
        .ok => |text| switch (tool) {
            .extract => {
                const normalized = self.normalizeExtractReturnJson(arena, text) catch |err| switch (err) {
                    error.OutOfMemory => return self.throwError("out of memory"),
                };
                self.setReturnJson(context, info, normalized);
            },
            else => self.setReturnString(info, text),
        },
        .fail => |message| self.throwError(message),
    }
}

/// Start the receiver Page's navigation and return a *pending* Promise of the
/// page object; `driveAsync` settles it once the frame loads. This is what lets
/// `Promise.all([a.goto(x), b.goto(y)])` fetch both in parallel.
fn invokeGoto(
    self: *Runtime,
    arena: std.mem.Allocator,
    context: *const v8.Context,
    info: *const v8.FunctionCallbackInfo,
    args: ?std.json.Value,
) void {
    const resolver = v8.v8__Promise__Resolver__New(context) orelse
        return self.throwError("internal: resolver alloc failed");
    const promise = v8.v8__Promise__Resolver__GetPromise(resolver) orelse
        return self.throwError("internal: promise alloc failed");
    self.setReturnValue(info, @ptrCast(promise));

    const this = v8.v8__FunctionCallbackInfo__This(info) orelse
        return self.rejectResolver(context, resolver, "navigation failed");

    // Non-null on a re-goto: startGoto replaces only that page, not its siblings.
    const receiver_frame_id = self.receiverFrameId(context, info);
    // startGoto is browser-side work; run it under the browser's isolate.
    // Settle the resolver only after the block: a `return` inside it runs
    // script-isolate work before the deferred exit.
    const maybe_started: ?browser_tools.StartedGoto = blk: {
        self.session.browser.env.isolate.enter();
        defer self.session.browser.env.isolate.exit();
        break :blk browser_tools.startGoto(arena, self.session, self.registry, args, receiver_frame_id) catch null;
    };
    const started = maybe_started orelse
        return self.rejectResolver(context, resolver, "navigation failed");

    var pending: PendingGoto = .{
        .frame_id = started.frame_id,
        .resolver = undefined,
        .receiver = undefined,
        .deadline_ms = self.run_timer.read() / std.time.ns_per_ms + started.timeout_ms,
    };
    v8.v8__Global__New(self.env.isolate.handle, resolver, &pending.resolver);
    v8.v8__Global__New(self.env.isolate.handle, this, &pending.receiver);
    self.pending_gotos.append(self.allocator, pending) catch {
        pending.reset();
        return self.rejectResolver(context, resolver, "out of memory");
    };
}

/// Drive every in-flight `goto` to completion: each pass ticks the browser over
/// the pending frames, settles those that finished, then drains microtasks so
/// awaiting continuations run (and may start more gotos). Ends when the root
/// Promise settles, no gotos remain, or the script is cancelled.
fn driveAsync(self: *Runtime, context: *const v8.Context, try_catch: *const v8.TryCatch, root: *const v8.Promise) void {
    var runner = self.session.runner(.{});

    var arena_state: std.heap.ArenaAllocator = .init(self.allocator);
    defer arena_state.deinit();

    // Settle anything resolved synchronously, so a goto-free run still drains.
    self.env.performIsolateMicrotasks();

    while (true) {
        const state = promiseState(root);
        if (state == v8.kFulfilled or state == v8.kRejected) break;
        if (v8.v8__TryCatch__HasCaught(try_catch)) break;
        if (self.pending_gotos.items.len == 0) break;
        if (self.session.isCancelled()) {
            self.failAllPending(context, "navigation cancelled");
            break;
        }

        _ = arena_state.reset(.retain_capacity);
        const conditions = arena_state.allocator().alloc(lp.Session.Runner.WaitCondition, self.pending_gotos.items.len) catch {
            self.failAllPending(context, "out of memory");
            break;
        };
        for (self.pending_gotos.items, conditions) |pending, *condition| {
            condition.* = .{ .frame_id = pending.frame_id, .until = .load };
        }

        // Browser-side tick: run under the browser's isolate, exit before the
        // agent-side settling below (the Runtime owns a separate isolate).
        var tick_failed = false;
        {
            self.session.browser.env.isolate.enter();
            defer self.session.browser.env.isolate.exit();
            _ = runner.tick(50, conditions) catch {
                tick_failed = true;
            };
        }
        if (tick_failed) {
            self.failAllPending(context, "navigation failed");
            break;
        }

        self.settleCompleted(context, conditions);
        self.env.performIsolateMicrotasks();
    }

    // Reject anything still in flight (fire-and-forget goto, or a broken-out loop).
    self.failAllPending(context, "navigation abandoned");
}

/// Settle each pending goto that finished or timed out, compacting survivors back
/// in place. `conditions[i]` lines up with `pending_gotos.items[i]` (same pass).
fn settleCompleted(self: *Runtime, context: *const v8.Context, conditions: []const lp.Session.Runner.WaitCondition) void {
    const now_ms = self.run_timer.read() / std.time.ns_per_ms;
    var write: usize = 0;
    for (conditions, 0..) |condition, i| {
        const pending = &self.pending_gotos.items[i];
        const outcome: ?Outcome = switch (condition.status) {
            .complete => .loaded,
            .err => .failed,
            .pending => if (now_ms >= pending.deadline_ms) .timed_out else null,
        };
        if (outcome) |o| {
            self.settlePending(context, pending, o);
        } else {
            self.pending_gotos.items[write] = pending.*;
            write += 1;
        }
    }
    self.pending_gotos.shrinkRetainingCapacity(write);
}

const Outcome = enum { loaded, failed, timed_out };

/// Resolve or reject one pending goto's Promise and free its Globals.
fn settlePending(self: *Runtime, context: *const v8.Context, pending: *PendingGoto, outcome: Outcome) void {
    const resolver: *const v8.PromiseResolver = @ptrCast(v8.v8__Global__Get(&pending.resolver, self.env.isolate.handle));
    switch (outcome) {
        .loaded => done: {
            const frame = self.session.findFrameByFrameId(pending.frame_id);
            if (frame == null or frame.?._last_navigate_error != null) {
                self.rejectResolver(context, resolver, "navigation failed");
                break :done;
            }
            const this: *const v8.Object = @ptrCast(v8.v8__Global__Get(&pending.receiver, self.env.isolate.handle));
            self.bindFrameId(context, this, pending.frame_id) catch {
                self.rejectResolver(context, resolver, "internal: page bind failed");
                break :done;
            };
            self.resolveResolver(context, resolver, @ptrCast(this));
        },
        .failed => self.rejectResolver(context, resolver, "navigation failed"),
        .timed_out => self.rejectResolver(context, resolver, "navigation timed out"),
    }
    pending.reset();
}

/// Reject every still-pending goto and clear the list, freeing all Globals.
fn failAllPending(self: *Runtime, context: *const v8.Context, message: []const u8) void {
    for (self.pending_gotos.items) |*pending| {
        const resolver: *const v8.PromiseResolver = @ptrCast(v8.v8__Global__Get(&pending.resolver, self.env.isolate.handle));
        self.rejectResolver(context, resolver, message);
        pending.reset();
    }
    self.pending_gotos.clearRetainingCapacity();
}

fn resolveResolver(_: *Runtime, context: *const v8.Context, resolver: *const v8.PromiseResolver, value: *const v8.Value) void {
    var out: v8.MaybeBool = undefined;
    v8.v8__Promise__Resolver__Resolve(resolver, context, value, &out);
}

fn rejectResolver(self: *Runtime, context: *const v8.Context, resolver: *const v8.PromiseResolver, message: []const u8) void {
    var out: v8.MaybeBool = undefined;
    v8.v8__Promise__Resolver__Reject(resolver, context, self.env.isolate.createError(message), &out);
}

/// Bind `__lpFrameId` on a page object to `frame_id` — the handle's claim on the
/// live frame, checked by `invoke` on every later method call.
fn bindFrameId(self: *Runtime, context: *const v8.Context, this: *const v8.Object, frame_id: u32) InitError!void {
    const value = v8.v8__Integer__NewFromUnsigned(self.env.isolate.handle, frame_id) orelse
        return error.RuntimeInitFailed;
    return self.setObjectProperty(context, this, frame_id_key, @ptrCast(value));
}

/// Frame id a method's receiver was bound to (`this.__lpFrameId`), or null when
/// the page was never navigated or has been closed.
fn receiverFrameId(self: *Runtime, context: *const v8.Context, info: *const v8.FunctionCallbackInfo) ?u32 {
    const this = v8.v8__FunctionCallbackInfo__This(info) orelse return null;
    const key: *const v8.Value = @ptrCast(self.env.isolate.initStringHandle(frame_id_key));
    const prop = v8.v8__Object__Get(this, context, key) orelse return null;
    if (!v8.v8__Value__IsNumber(prop)) return null;
    var out: v8.MaybeU32 = undefined;
    v8.v8__Value__Uint32Value(prop, context, &out);
    return if (out.has_value) out.value else null;
}

fn invokeConsole(self: *Runtime, method: ConsoleMethod, info: *const v8.FunctionCallbackInfo) void {
    // Owned arena (see `invoke`): an argument's `toString` can re-enter a
    // primitive mid-loop and must not reset the buffer we're accumulating.
    var arena_state: std.heap.ArenaAllocator = .init(self.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    const context = v8.v8__Isolate__GetCurrentContext(self.env.isolate.handle) orelse return;
    const argc: usize = @intCast(v8.v8__FunctionCallbackInfo__Length(info));

    var aw: std.Io.Writer.Allocating = .init(arena);
    for (0..argc) |i| {
        if (i > 0) aw.writer.writeByte(' ') catch return;
        const value = v8.v8__FunctionCallbackInfo__INDEX(info, @intCast(i)) orelse continue;
        const text = self.valueToString(arena, context, value) catch "<unprintable>";
        aw.writer.writeAll(text) catch return;
    }

    self.writeConsoleLine(method, aw.written());
}

fn writeConsoleLine(self: *Runtime, method: ConsoleMethod, line: []const u8) void {
    if (self.console_observer) |obs| obs.notify(obs.context);
    var buf: [4096]u8 = undefined;
    var file = if (method.writesStderr()) std.fs.File.stderr() else std.fs.File.stdout();
    var writer = file.writer(&buf);
    writer.interface.print("{s}\n", .{line}) catch return;
    writer.interface.flush() catch return;
}

const PrimitiveResult = union(enum) {
    ok: []const u8,
    fail: []const u8,
};

fn callTool(
    self: *Runtime,
    arena: std.mem.Allocator,
    tool: BrowserTool,
    args: ?std.json.Value,
) error{OutOfMemory}!PrimitiveResult {
    self.session.browser.env.isolate.enter();
    defer self.session.browser.env.isolate.exit();

    const result = browser_tools.call(arena, self.session, self.registry, @tagName(tool), args) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        error.FrameNotLoaded => return .{ .fail = "no page loaded - run page.goto(url) first" },
        else => return .{ .fail = std.fmt.allocPrint(arena, "{s} failed: {s}", .{ @tagName(tool), @errorName(err) }) catch return error.OutOfMemory },
    };

    if (result.is_error) return .{ .fail = result.text };
    return .{ .ok = result.text };
}

const BuildArgsError = error{
    OutOfMemory,
    JsException,
    InvalidArguments,
};

fn buildArgs(
    self: *Runtime,
    arena: std.mem.Allocator,
    context: *const v8.Context,
    tool: BrowserTool,
    info: *const v8.FunctionCallbackInfo,
) BuildArgsError!?std.json.Value {
    const argc: usize = @intCast(v8.v8__FunctionCallbackInfo__Length(info));
    return switch (tool) {
        .extract => try self.extractArgs(arena, context, info, argc),
        else => try self.marshalArgs(arena, context, info, argc, Schema.positionalFor(tool)),
    };
}

/// Marshals `tool(positionals…, options?)` into the args object `browser_tools.call`
/// expects: leading primitives bind to `positional` by index (a `null` omits its
/// field), a trailing object merges as options (conflict on a repeated field), and a
/// lone object passes through. Positionals stay opaque; the tool's parser types them.
fn marshalArgs(
    self: *Runtime,
    arena: std.mem.Allocator,
    context: *const v8.Context,
    info: *const v8.FunctionCallbackInfo,
    argc: usize,
    positional: []const []const u8,
) BuildArgsError!std.json.Value {
    const values = try arena.alloc(std.json.Value, argc);
    for (values, 0..) |*v, i| v.* = try self.argJson(arena, context, info, @intCast(i));

    if (argc == 1 and values[0] == .object) return values[0];

    // A trailing object is the options bag; everything before it is positional.
    var positional_count = argc;
    var options: ?std.json.ObjectMap = null;
    if (argc > 0 and values[argc - 1] == .object) {
        options = values[argc - 1].object;
        positional_count = argc - 1;
    }
    if (positional_count > positional.len) return error.InvalidArguments;

    var obj: std.json.ObjectMap = .init(arena);
    for (values[0..positional_count], positional[0..positional_count]) |v, field| {
        switch (v) {
            .null => {}, // omit the field — e.g. page/focused-level selector
            .object, .array => return error.InvalidArguments,
            else => try obj.put(field, v),
        }
    }
    if (options) |opts| {
        var it = opts.iterator();
        while (it.next()) |entry| {
            if (obj.contains(entry.key_ptr.*)) return error.InvalidArguments;
            try obj.put(entry.key_ptr.*, entry.value_ptr.*);
        }
    }
    return .{ .object = obj };
}

fn extractArgs(
    self: *Runtime,
    arena: std.mem.Allocator,
    context: *const v8.Context,
    info: *const v8.FunctionCallbackInfo,
    argc: usize,
) BuildArgsError!std.json.Value {
    if (argc != 1) return error.InvalidArguments;
    const value = try self.argJson(arena, context, info, 0);
    const schema = switch (value) {
        .string, .array => try extractSchemaString(arena, value),
        .object => |obj| if (obj.get("schema")) |inner| blk: {
            if (obj.count() != 1) return error.InvalidArguments;
            break :blk try extractSchemaString(arena, inner);
        } else try extractSchemaString(arena, value),
        else => return error.InvalidArguments,
    };
    return try objectWith(arena, "schema", .{ .string = schema });
}

fn extractSchemaString(arena: std.mem.Allocator, value: std.json.Value) error{OutOfMemory}![]const u8 {
    return switch (value) {
        .string => |str| normalizeExtractSchemaString(arena, str),
        .array => |arr| normalizeExtractSchemaString(
            arena,
            try std.json.Stringify.valueAlloc(arena, std.json.Value{ .array = arr }, .{}),
        ),
        else => try std.json.Stringify.valueAlloc(arena, value, .{}),
    };
}

fn normalizeExtractSchemaString(arena: std.mem.Allocator, schema: []const u8) error{OutOfMemory}![]const u8 {
    const trimmed = std.mem.trim(u8, schema, &std.ascii.whitespace);
    if (trimmed.len == 0 or trimmed[0] != '[') return schema;
    return try std.fmt.allocPrint(arena, "{{\"__root\":{s}}}", .{schema});
}

fn argJson(
    self: *Runtime,
    arena: std.mem.Allocator,
    context: *const v8.Context,
    info: *const v8.FunctionCallbackInfo,
    index: u32,
) BuildArgsError!std.json.Value {
    const value = v8.v8__FunctionCallbackInfo__INDEX(info, @intCast(index)) orelse return error.InvalidArguments;
    return self.valueToJson(arena, context, value);
}

fn valueToJson(
    self: *Runtime,
    arena: std.mem.Allocator,
    context: *const v8.Context,
    value: *const v8.Value,
) BuildArgsError!std.json.Value {
    const json_string = v8.v8__JSON__Stringify(context, value, null) orelse return error.JsException;
    const json = try self.stringToOwned(arena, json_string);
    if (std.mem.eql(u8, json, "undefined")) return error.InvalidArguments;
    return std.json.parseFromSliceLeaky(std.json.Value, arena, json, .{}) catch error.InvalidArguments;
}

fn objectWith(arena: std.mem.Allocator, key: []const u8, value: std.json.Value) error{OutOfMemory}!std.json.Value {
    var obj: std.json.ObjectMap = .init(arena);
    try obj.put(key, value);
    return .{ .object = obj };
}

/// Unwraps only the `__root` sentinel that `normalizeExtractSchemaString` injects
/// for array schemas; a real single-field object schema keeps its shape.
fn normalizeExtractReturnJson(_: *Runtime, arena: std.mem.Allocator, value: []const u8) error{OutOfMemory}![]const u8 {
    if (value.len == 0) return value;

    const parsed = std.json.parseFromSliceLeaky(std.json.Value, arena, value, .{}) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return value,
    };
    if (parsed != .object or parsed.object.count() != 1) return value;

    var it = parsed.object.iterator();
    const entry = it.next() orelse return value;
    if (!std.mem.eql(u8, entry.key_ptr.*, "__root")) return value;
    return try std.json.Stringify.valueAlloc(arena, entry.value_ptr.*, .{});
}

fn setReturnString(self: *Runtime, info: *const v8.FunctionCallbackInfo, value: []const u8) void {
    self.setReturnValue(info, @ptrCast(self.env.isolate.initStringHandle(value)));
}

fn setReturnJson(self: *Runtime, context: *const v8.Context, info: *const v8.FunctionCallbackInfo, value: []const u8) void {
    if (value.len == 0) {
        self.setReturnValue(info, self.env.isolate.initUndefined());
        return;
    }
    const json = self.env.isolate.initStringHandle(value);
    const parsed = v8.v8__JSON__Parse(context, json) orelse {
        self.throwError("extract returned invalid JSON");
        return;
    };
    self.setReturnValue(info, parsed);
}

fn setReturnValue(_: *Runtime, info: *const v8.FunctionCallbackInfo, value: *const v8.Value) void {
    var rv: v8.ReturnValue = undefined;
    v8.v8__FunctionCallbackInfo__GetReturnValue(info, &rv);
    v8.v8__ReturnValue__Set(rv, value);
}

fn throwError(self: *Runtime, message: []const u8) void {
    _ = v8.v8__Isolate__ThrowException(self.env.isolate.handle, self.env.isolate.createError(message));
}

fn throwTypeError(self: *Runtime, message: []const u8) void {
    _ = v8.v8__Isolate__ThrowException(self.env.isolate.handle, self.env.isolate.createTypeError(message));
}

fn formatCaught(
    self: *Runtime,
    context: *const v8.Context,
    try_catch: *const v8.TryCatch,
    fallback: []const u8,
) RunError![]const u8 {
    const arena = self.call_arena.allocator();
    if (v8.v8__TryCatch__StackTrace(try_catch, context)) |stack_value| {
        const stack = self.valueToString(arena, context, stack_value) catch "";
        if (stack.len > 0) return stack;
    }

    const exception = if (v8.v8__TryCatch__Exception(try_catch)) |exception_value|
        self.valueToString(arena, context, exception_value) catch fallback
    else
        fallback;

    const line: ?u32 = blk: {
        const msg = v8.v8__TryCatch__Message(try_catch) orelse break :blk null;
        const n = v8.v8__Message__GetLineNumber(msg, context);
        break :blk if (n < 0) null else @as(u32, @intCast(n));
    };
    if (line) |n| {
        return std.fmt.allocPrint(arena, "line {d}: {s}", .{ n, exception }) catch return error.OutOfMemory;
    }
    return try self.dupeError(exception);
}

fn valueToString(
    self: *Runtime,
    arena: std.mem.Allocator,
    context: *const v8.Context,
    value: *const v8.Value,
) error{ OutOfMemory, JsException }![]const u8 {
    const string = v8.v8__Value__ToString(value, context) orelse return error.JsException;
    return self.stringToOwned(arena, string);
}

/// Display form for the script's completion value: objects and arrays as JSON
/// (plain coercion gives a useless `[object Object]`), every other value via that
/// coercion. Falls back to coercion when JSON.stringify yields no string — a
/// thrown circular reference, or a value (e.g. a function) that stringifies to
/// `undefined`; the nested TryCatch keeps such a throw from leaking into the
/// caller's scope.
fn displayString(
    self: *Runtime,
    arena: std.mem.Allocator,
    context: *const v8.Context,
    value: *const v8.Value,
) error{ OutOfMemory, JsException }![]const u8 {
    if (v8.v8__Value__IsObject(value)) {
        var try_catch: v8.TryCatch = undefined;
        v8.v8__TryCatch__CONSTRUCT(&try_catch, self.env.isolate.handle);
        defer v8.v8__TryCatch__DESTRUCT(&try_catch);
        if (v8.v8__JSON__Stringify(context, value, null)) |json| {
            return self.stringToOwned(arena, json);
        }
    }
    return self.valueToString(arena, context, value);
}

fn stringToOwned(
    self: *Runtime,
    arena: std.mem.Allocator,
    string: *const v8.String,
) error{OutOfMemory}![]const u8 {
    const len: usize = @intCast(v8.v8__String__Utf8Length(string, self.env.isolate.handle));
    const buf = try arena.alloc(u8, len);
    const written = v8.v8__String__WriteUtf8(
        string,
        self.env.isolate.handle,
        buf.ptr,
        buf.len,
        v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8,
    );
    return buf[0..written];
}

fn dupeError(self: *Runtime, message: []const u8) RunError![]const u8 {
    return self.call_arena.allocator().dupe(u8, message) catch error.OutOfMemory;
}

const testing = @import("../testing.zig");

fn runTestScript(runtime: *Runtime, source: []const u8) !void {
    if (try runtime.runSource(source, "agent-runtime-test.js")) |message| {
        std.debug.print("agent script failed:\n{s}\n", .{message});
        return error.AgentScriptFailed;
    }
}

fn terminateRuntimeSoon(runtime: *Runtime) void {
    std.Thread.sleep(10 * std.time.ns_per_ms);
    runtime.terminate();
}

test "agent script runtime: goto and evaluate dispatch through browser tools" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    try runTestScript(runtime,
        \\const page = new Page();
        \\const same = await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
        \\if (same !== page) throw new Error("page.goto should resolve the same page object");
        \\const text = page.evaluate("document.getElementById('btn').textContent");
        \\if (text !== "Click Me") throw new Error("evaluate ran in the wrong context: " + text);
    );

    const frame = testing.test_session.currentFrame().?;
    try testing.expect(std.mem.indexOf(u8, frame.url, "/src/browser/tests/mcp_actions.html") != null);
}

test "agent script runtime: Page must be called with new" {
    defer testing.reset();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    const message = (try runtime.runSource("Page();", "agent-runtime-page-no-new.js")).?;
    try testing.expect(std.mem.indexOf(u8, message, "must be called with new") != null);
}

test "agent script runtime: a method on an un-navigated page errors" {
    defer testing.reset();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    const message = (try runtime.runSource(
        \\const page = new Page();
        \\page.extract({ btn: "#btn" });
    , "agent-runtime-not-navigated.js")).?;
    try testing.expect(std.mem.indexOf(u8, message, "not navigated") != null);
}

test "agent script runtime: page.close stales the handle" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    // After close() the wrapper is stale; a later method call must error rather
    // than silently hit whatever page happens to be current.
    const message = (try runtime.runSource(
        \\const page = new Page();
        \\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
        \\page.close();
        \\page.extract({ btn: "#btn" });
    , "agent-runtime-close.js")).?;
    try testing.expect(std.mem.indexOf(u8, message, "closed") != null);
}

test "agent script runtime: parallel gotos coexist and route per page" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    // Sibling pages stay live; each handle's tools must read its own page.
    try runTestScript(runtime,
        \\const a = new Page();
        \\const b = new Page();
        \\await Promise.all([
        \\  a.goto("http://localhost:9582/src/browser/tests/mcp_actions.html"),
        \\  b.goto("http://localhost:9582/src/browser/tests/runner/runner1.html"),
        \\]);
        \\const da = a.extract({ btn: "#btn" });
        \\const db = b.extract({ s0: "#sel0" });
        \\if (da.btn !== "Click Me") throw new Error("page a read wrong page: " + JSON.stringify(da));
        \\if (db.s0 !== "selector-0-content") throw new Error("page b read wrong page: " + JSON.stringify(db));
    );
}

test "agent script runtime: goto resolves $LP_* placeholders" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    _ = setenv(@constCast("LP_RUNTIME_GOTO_BASE"), @constCast("http://localhost:9582"), 1);
    defer _ = unsetenv(@constCast("LP_RUNTIME_GOTO_BASE"));

    try runTestScript(runtime,
        \\const page = new Page();
        \\await page.goto("$LP_RUNTIME_GOTO_BASE/src/browser/tests/mcp_actions.html");
        \\const { btn } = page.extract({ btn: "#btn" });
        \\if (btn !== "Click Me") throw new Error("wrong page: " + btn);
    );
}

extern fn setenv(name: [*:0]u8, value: [*:0]u8, override: c_int) c_int;
extern fn unsetenv(name: [*:0]u8) c_int;

test "agent script runtime: goto with invalid arguments rejects instead of crashing" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    // A goto that fails to start (vs. mid-flight) settles on the startGoto
    // error path.
    try runTestScript(runtime,
        \\const page = new Page();
        \\let caught = "";
        \\try { await page.goto(null); } catch (e) { caught = String(e); }
        \\if (!caught.toLowerCase().includes("navigation")) throw new Error("expected a navigation rejection, got: " + caught);
    );
}

test "agent script runtime: a tool-triggered navigation keeps the handle routable" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    // click commits a replacement Page mid-call; the frame override and the
    // handle's frame id must both survive the swap.
    try runTestScript(runtime,
        \\const page = new Page();
        \\await page.goto("http://localhost:9582/src/browser/tests/mcp_nav.html");
        \\page.click("#navlink");
        \\const href = page.evaluate("location.href");
        \\if (href !== "about:blank") throw new Error("expected about:blank, got: " + href);
    );
}

test "agent script runtime: re-goto on the same page object replaces its page" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    try runTestScript(runtime,
        \\const page = new Page();
        \\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
        \\await page.goto("http://localhost:9582/src/browser/tests/runner/runner1.html");
        \\const data = page.extract({ s1: "#sel1" });
        \\if (data.s1 !== "selector-1-content") throw new Error("re-goto did not replace the page: " + JSON.stringify(data));
    );
}

test "agent script runtime: a failed navigation rejects the goto promise" {
    const filter: testing.LogFilter = .init(&.{.frame});
    defer filter.deinit();

    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    // An unreachable URL must reject (not hang); the short timeout bounds it.
    try runTestScript(runtime,
        \\let caught = "";
        \\try {
        \\  await new Page().goto("http://localhost:1/", { timeout: 2000 });
        \\} catch (e) {
        \\  caught = String(e);
        \\}
        \\if (!caught.toLowerCase().includes("navigation")) throw new Error("expected a navigation rejection, got: " + caught);
    );
}

test "agent script runtime: extract returns a JavaScript object" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    try runTestScript(runtime,
        \\const page = new Page();
        \\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
        \\const data = page.extract({
        \\  button: "#btn",
        \\  options: [{
        \\    selector: "#sel option",
        \\    limit: 2,
        \\    fields: {
        \\      text: "",
        \\      value: { attr: "value" }
        \\    }
        \\  }]
        \\});
        \\if (typeof data !== "object" || data === null || Array.isArray(data)) throw new Error("extract did not return an object");
        \\if (data.button !== "Click Me") throw new Error("unexpected button text: " + data.button);
        \\if (data.options.length !== 2) throw new Error("unexpected option count: " + data.options.length);
        \\if (data.options[1].value !== "opt2") throw new Error("unexpected option value: " + data.options[1].value);
        \\const options = page.extract({
        \\  options: [{
        \\    selector: "#sel option",
        \\    limit: 2,
        \\    fields: {
        \\      text: "",
        \\      value: { attr: "value" }
        \\    }
        \\  }]
        \\});
        \\if (typeof options !== "object" || options === null || Array.isArray(options)) throw new Error("single object field should stay an object");
        \\if (options.options[0].text !== "Option 1") throw new Error("unexpected option text: " + options.options[0].text);
        \\const direct = page.extract([{ selector: "#sel option", limit: 1 }]);
        \\if (!Array.isArray(direct)) throw new Error("array schema should return an array");
        \\if (direct[0] !== "Option 1") throw new Error("unexpected direct array extract: " + direct[0]);
        \\const saveField = page.extract({ save: "#btn" });
        \\if (saveField.save !== "Click Me") throw new Error("top-level save field should be schema data");
        \\let rejectedSaveOption = false;
        \\try {
        \\  page.extract({ schema: { button: "#btn" }, save: "snap" });
        \\} catch (err) {
        \\  rejectedSaveOption = true;
        \\}
        \\if (!rejectedSaveOption) throw new Error("extract save option should be rejected");
    );
}

test "agent script runtime: extract tolerates list selectors that match nothing" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    try runTestScript(runtime,
        \\const page = new Page();
        \\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
        \\const empty = page.extract({ comments: [{ selector: ".no-such-element", fields: { text: "" } }] });
        \\if (!Array.isArray(empty.comments) || empty.comments.length !== 0) throw new Error("empty list selector should yield an empty array, not throw");
        \\const bare = page.extract([".no-such-element"]);
        \\if (!Array.isArray(bare) || bare.length !== 0) throw new Error("bare empty array schema should yield an empty array");
        \\const mixed = page.extract({ button: "#btn", comments: [".no-such-element"] });
        \\if (mixed.button !== "Click Me" || mixed.comments.length !== 0) throw new Error("a matched scalar beside an empty list should still resolve");
        \\let threwOnAllNull = false;
        \\try {
        \\  page.extract({ missing: "#does-not-exist" });
        \\} catch (err) {
        \\  threwOnAllNull = true;
        \\}
        \\if (!threwOnAllNull) throw new Error("an all-null scalar schema should still throw");
    );
}

test "agent script runtime: strict-mode scripts can call primitives" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    try runTestScript(runtime,
        \\"use strict";
        \\const page = new Page();
        \\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
        \\const text = page.evaluate("document.getElementById('btn').textContent");
        \\if (text !== "Click Me") throw new Error("strict-mode evaluate failed: " + text);
    );
}

test "agent script runtime: promise microtasks run to completion" {
    defer testing.reset();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    try runTestScript(runtime,
        \\globalThis.microtaskRan = false;
        \\Promise.resolve().then(() => { globalThis.microtaskRan = true; });
        \\if (globalThis.microtaskRan) throw new Error("microtask ran before the checkpoint");
    );

    try runTestScript(runtime,
        \\if (!globalThis.microtaskRan) throw new Error("microtask did not run after the script");
    );
}

test "agent script runtime: primitives re-entered from argument callbacks stay isolated" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    try runTestScript(runtime,
        \\const page = new Page();
        \\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
        \\// toJSON re-enters evaluate mid-marshal; the outer extract must still see "#btn".
        \\const data = page.extract({ button: { toJSON() { return page.evaluate("'#btn'"); } } });
        \\if (data.button !== "Click Me") throw new Error("re-entrant extract corrupted: " + JSON.stringify(data));
        \\// toString re-enters a primitive mid-loop; the console buffer must survive.
        \\let probed = 0;
        \\console.log("value", { toString() { probed += 1; return page.evaluate("'ok'"); } }, "tail");
        \\if (probed !== 1) throw new Error("console toString re-entry not exercised");
    );
}

test "agent script runtime: terminate interrupts local JavaScript" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    const thread = try std.Thread.spawn(.{}, terminateRuntimeSoon, .{runtime});
    defer runtime.cancelTerminate();
    defer thread.join();

    const message = try runtime.runSource("while (true) {}", "agent-runtime-terminate-test.js");
    try testing.expect(message != null);
}

test "agent script runtime: agent variables persist and page globals are isolated" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    try runTestScript(runtime,
        \\globalThis.counter = 1;
        \\if (typeof window !== "undefined") throw new Error("window leaked into agent runtime");
        \\if (typeof document !== "undefined") throw new Error("document leaked into agent runtime");
        \\await new Page().goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
        \\globalThis.counter += 1;
        \\if (globalThis.counter !== 2) throw new Error("agent global state did not persist");
    );

    try runTestScript(runtime,
        \\globalThis.counter += 1;
        \\if (globalThis.counter !== 3) throw new Error("agent global state was reset between scripts");
    );
}

test "agent script runtime: page evaluate cannot see agent primitives or bindings" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    try runTestScript(runtime,
        \\const agentOnly = "secret";
        \\const page = new Page();
        \\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
        \\if (page.evaluate("typeof Page") !== "undefined") throw new Error("agent primitive leaked to page evaluate");
        \\if (page.evaluate("typeof agentOnly") !== "undefined") throw new Error("agent binding leaked to page evaluate");
        \\if (page.evaluate("typeof document") !== "object") throw new Error("page evaluate did not run in the page context");
    );
}

test "agent script runtime: console is available in agent context" {
    defer testing.reset();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    try runTestScript(runtime,
        \\if (typeof console !== "object") throw new Error("missing console");
        \\if (typeof console.log !== "function") throw new Error("missing console.log");
        \\console.log("agent console ready");
    );
}

test "agent script runtime: tool errors throw and stop execution" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    const message = (try runtime.runSource(
        \\globalThis.marker = "before";
        \\const page = new Page();
        \\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
        \\page.click({ selector: "#does-not-exist" });
        \\globalThis.marker = "after";
    , "agent-runtime-failure.js")).?;

    try testing.expect(std.mem.indexOf(u8, message, "click") != null or
        std.mem.indexOf(u8, message, "NodeNotFound") != null or
        std.mem.indexOf(u8, message, "#does-not-exist") != null);

    try runTestScript(runtime,
        \\if (globalThis.marker !== "before") throw new Error("script continued after tool failure");
    );
}

test "agent script runtime: builtin argument marshalling (positional + options)" {
    defer testing.reset();
    defer testing.test_session.closeAllPages();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    try runTestScript(runtime,
        \\// The reported bug: Playwright-style goto(url, options) must merge, not throw.
        \\const page = new Page();
        \\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html", { timeout: 5000 });
        \\// waitForState: single required param, positional like waitForSelector.
        \\if (!page.waitForState("load").includes("reached")) throw new Error("waitForState positional failed");
        \\// Object form still works; re-navigation rebinds the same page object.
        \\await page.goto({ url: "http://localhost:9582/src/browser/tests/mcp_actions.html", timeout: 5000 });
        \\// Single selector positional.
        \\page.click("#btn");
        \\if (page.evaluate("String(window.clicked)") !== "true") throw new Error("click positional failed");
        \\// Two positionals: selector, value.
        \\page.fill("#inp", "hello");
        \\if (page.evaluate("window.inputVal") !== "hello") throw new Error("fill two-positional failed");
        \\page.selectOption("#sel", "opt2");
        \\if (page.evaluate("window.selChanged") !== "opt2") throw new Error("selectOption two-positional failed");
        \\// Bool positional, and the default-true shorthand when omitted.
        \\if (!page.setChecked("#chk").includes("to checked")) throw new Error("setChecked default-true failed");
        \\if (page.evaluate("String(document.getElementById('chk').checked)") !== "true") throw new Error("setChecked(true) did not check the box");
        \\if (!page.setChecked("#chk", false).includes("to unchecked")) throw new Error("setChecked bool positional failed");
        \\if (page.evaluate("String(document.getElementById('chk').checked)") !== "false") throw new Error("setChecked(false) did not uncheck the box");
        \\// Repeat call to the same state is an idempotent no-op, not a toggle.
        \\page.setChecked("#chk", false);
        \\if (page.evaluate("String(document.getElementById('chk').checked)") !== "false") throw new Error("setChecked no-op toggled the box");
        \\// Selector-first press, and a null selector for a page/focused key press.
        \\page.press("#keyTarget", "Enter");
        \\if (page.evaluate("window.keyPressed") !== "Enter") throw new Error("selector-first press failed");
        \\page.press(null, "a");
    );

    // A field set by both a positional and the options object is a conflict.
    {
        const message = (try runtime.runSource(
            \\await new Page().goto("http://localhost:9582/src/browser/tests/mcp_actions.html", { url: "http://other" });
        , "agent-runtime-conflict.js")).?;
        try testing.expect(std.mem.indexOf(u8, message, "invalid arguments") != null);
    }

    // More positionals than the tool has fields throws.
    {
        const message = (try runtime.runSource(
            \\const page = new Page();
            \\await page.goto("http://localhost:9582/src/browser/tests/mcp_actions.html");
            \\page.click("#btn", "#extra");
        , "agent-runtime-arity.js")).?;
        try testing.expect(std.mem.indexOf(u8, message, "invalid arguments") != null);
    }
}

test "agent script runtime: top-level await runs in an async wrapper" {
    defer testing.reset();

    var registry = CDPNode.Registry.init(testing.allocator);
    defer registry.deinit();

    const runtime = try Runtime.init(testing.allocator, testing.test_app, testing.test_session, &registry);
    defer runtime.deinit();

    // `await` on a non-goto promise resolves without touching the browser, and
    // a top-level `return` surfaces as the (otherwise un-echoed) result.
    try runTestScript(runtime,
        \\const x = await Promise.resolve(40);
        \\if (x + 2 !== 42) throw new Error("top-level await did not resolve: " + x);
        \\return x + 2;
    );
}
