// Copyright (C) 2023-2025  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 screenshot_png = @embedFile("screenshot.png");

const id = @import("../id.zig");
const CDP = @import("../CDP.zig");

const log = @import("../../log.zig");
const js = @import("../../browser/js/js.zig");
const URL = @import("../../browser/URL.zig");
const Page = @import("../../browser/Page.zig");
const timestampF = @import("../../datetime.zig").timestamp;
const Notification = @import("../../Notification.zig");

const Allocator = std.mem.Allocator;

pub fn processMessage(cmd: *CDP.Command) !void {
    const action = std.meta.stringToEnum(enum {
        enable,
        getFrameTree,
        setLifecycleEventsEnabled,
        addScriptToEvaluateOnNewDocument,
        removeScriptToEvaluateOnNewDocument,
        createIsolatedWorld,
        navigate,
        reload,
        stopLoading,
        close,
        captureScreenshot,
        getLayoutMetrics,
    }, cmd.input.action) orelse return error.UnknownMethod;

    switch (action) {
        .enable => return cmd.sendResult(null, .{}),
        .getFrameTree => return getFrameTree(cmd),
        .setLifecycleEventsEnabled => return setLifecycleEventsEnabled(cmd),
        .addScriptToEvaluateOnNewDocument => return addScriptToEvaluateOnNewDocument(cmd),
        .removeScriptToEvaluateOnNewDocument => return removeScriptToEvaluateOnNewDocument(cmd),
        .createIsolatedWorld => return createIsolatedWorld(cmd),
        .navigate => return navigate(cmd),
        .reload => return doReload(cmd),
        .stopLoading => return cmd.sendResult(null, .{}),
        .close => return close(cmd),
        .captureScreenshot => return captureScreenshot(cmd),
        .getLayoutMetrics => return getLayoutMetrics(cmd),
    }
}

const Frame = struct {
    id: []const u8,
    loaderId: []const u8,
    url: []const u8,
    domainAndRegistry: []const u8 = "",
    securityOrigin: []const u8,
    mimeType: []const u8 = "text/html",
    adFrameStatus: struct {
        adFrameType: []const u8 = "none",
    } = .{},
    secureContextType: []const u8,
    crossOriginIsolatedContextType: []const u8 = "NotIsolated",
    gatedAPIFeatures: [][]const u8 = &[0][]const u8{},
};

fn getFrameTree(cmd: *CDP.Command) !void {
    // Stagehand parses the response and error if we don't return a
    // correct one for this call when browser context or target id are missing.
    const startup = .{
        .frameTree = .{
            .frame = .{
                .id = "TID-STARTUP",
                .loaderId = "LID-STARTUP",
                .securityOrigin = @import("../CDP.zig").URL_BASE,
                .url = "about:blank",
                .secureContextType = "Secure",
            },
        },
    };
    const bc = cmd.browser_context orelse return cmd.sendResult(startup, .{});
    const target_id = bc.target_id orelse return cmd.sendResult(startup, .{});

    return cmd.sendResult(.{
        .frameTree = .{
            .frame = Frame{
                .id = &target_id,
                .securityOrigin = bc.security_origin,
                .loaderId = "LID-0000000001",
                .url = bc.getURL() orelse "about:blank",
                .secureContextType = bc.secure_context_type,
            },
        },
    }, .{});
}

fn setLifecycleEventsEnabled(cmd: *CDP.Command) !void {
    const params = (try cmd.params(struct {
        enabled: bool,
    })) orelse return error.InvalidParams;

    const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;

    if (params.enabled == false) {
        bc.lifecycleEventsDisable();
        return cmd.sendResult(null, .{});
    }

    // Enable lifecycle events.
    try bc.lifecycleEventsEnable();

    // When we enable lifecycle events, we must dispatch events for all
    // attached targets.
    const page = bc.session.currentPage() orelse return error.PageNotLoaded;

    if (page._load_state == .complete) {
        const frame_id = &id.toFrameId(page._frame_id);
        const loader_id = &id.toLoaderId(page._req_id);

        const now = timestampF(.monotonic);
        try sendPageLifecycle(bc, "DOMContentLoaded", now, frame_id, loader_id);
        try sendPageLifecycle(bc, "load", now, frame_id, loader_id);

        const http_client = page._session.browser.http_client;
        const http_active = http_client.active;
        const total_network_activity = http_active + http_client.intercepted;
        if (page._notified_network_almost_idle.check(total_network_activity <= 2)) {
            try sendPageLifecycle(bc, "networkAlmostIdle", now, frame_id, loader_id);
        }
        if (page._notified_network_idle.check(total_network_activity == 0)) {
            try sendPageLifecycle(bc, "networkIdle", now, frame_id, loader_id);
        }
    }

    return cmd.sendResult(null, .{});
}

fn addScriptToEvaluateOnNewDocument(cmd: *CDP.Command) !void {
    const params = (try cmd.params(struct {
        source: []const u8,
        worldName: ?[]const u8 = null,
        includeCommandLineAPI: bool = false,
        runImmediately: bool = false,
    })) orelse return error.InvalidParams;

    const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;

    if (params.runImmediately) {
        log.warn(.not_implemented, "addScriptOnNewDocument", .{ .param = "runImmediately" });
    }

    const script_id = bc.next_script_id;
    bc.next_script_id += 1;

    const source_dupe = try bc.arena.dupe(u8, params.source);
    try bc.scripts_on_new_document.append(bc.arena, .{
        .identifier = script_id,
        .source = source_dupe,
    });

    var id_buf: [16]u8 = undefined;
    const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{script_id}) catch "1";
    return cmd.sendResult(.{
        .identifier = id_str,
    }, .{});
}

fn removeScriptToEvaluateOnNewDocument(cmd: *CDP.Command) !void {
    const params = (try cmd.params(struct {
        identifier: []const u8,
    })) orelse return error.InvalidParams;

    const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;

    const target_id = std.fmt.parseInt(u32, params.identifier, 10) catch
        return cmd.sendResult(null, .{});

    for (bc.scripts_on_new_document.items, 0..) |script, i| {
        if (script.identifier == target_id) {
            _ = bc.scripts_on_new_document.orderedRemove(i);
            break;
        }
    }
    return cmd.sendResult(null, .{});
}

fn close(cmd: *CDP.Command) !void {
    const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;

    const target_id = bc.target_id orelse return error.TargetNotLoaded;

    // can't be null if we have a target_id
    lp.assert(bc.session.page != null, "CDP.page.close null page", .{});

    try cmd.sendResult(.{}, .{});

    // Following code is similar to target.closeTarget
    //
    // could be null, created but never attached
    if (bc.session_id) |session_id| {
        // Inspector.detached event
        try cmd.sendEvent("Inspector.detached", .{
            .reason = "Render process gone.",
        }, .{ .session_id = session_id });

        // detachedFromTarget event
        try cmd.sendEvent("Target.detachedFromTarget", .{
            .targetId = target_id,
            .sessionId = session_id,
            .reason = "Render process gone.",
        }, .{});

        bc.session_id = null;
    }

    bc.session.removePage();
    for (bc.isolated_worlds.items) |world| {
        world.deinit();
    }
    bc.isolated_worlds.clearRetainingCapacity();
    bc.target_id = null;
}

fn createIsolatedWorld(cmd: *CDP.Command) !void {
    const params = (try cmd.params(struct {
        frameId: []const u8,
        worldName: []const u8,
        grantUniveralAccess: bool = false,
    })) orelse return error.InvalidParams;
    if (!params.grantUniveralAccess) {
        log.warn(.not_implemented, "Page.createIsolatedWorld", .{ .param = "grantUniveralAccess" });
        // When grantUniveralAccess == false and the client attempts to resolve
        // or otherwise access a DOM or other JS Object from another context that should fail.
    }
    const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;

    const world = try bc.createIsolatedWorld(params.worldName, params.grantUniveralAccess);
    const page = bc.session.currentPage() orelse return error.PageNotLoaded;

    const js_context = try world.createContext(page);
    return cmd.sendResult(.{ .executionContextId = js_context.id }, .{});
}

fn navigate(cmd: *CDP.Command) !void {
    const params = (try cmd.params(struct {
        url: [:0]const u8,
        // referrer: ?[]const u8 = null,
        // transitionType: ?[]const u8 = null, // TODO: enum
        // frameId: ?[]const u8 = null,
        // referrerPolicy: ?[]const u8 = null, // TODO: enum
    })) orelse return error.InvalidParams;

    const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;

    // didn't create?
    // const target_id = bc.target_id orelse return error.TargetIdNotLoaded;

    // didn't attach?
    if (bc.session_id == null) {
        return error.SessionIdNotLoaded;
    }

    const session = bc.session;
    var page = session.currentPage() orelse return error.PageNotLoaded;

    if (page._load_state != .waiting) {
        page = try session.replacePage();
    }

    const encoded_url = try URL.ensureEncoded(page.call_arena, params.url);
    try page.navigate(encoded_url, .{
        .reason = .address_bar,
        .cdp_id = cmd.input.id,
        .kind = .{ .push = null },
    });
}

fn doReload(cmd: *CDP.Command) !void {
    const params = try cmd.params(struct {
        ignoreCache: ?bool = null,
        scriptToEvaluateOnLoad: ?[]const u8 = null,
    });

    const bc = cmd.browser_context orelse return error.BrowserContextNotLoaded;

    if (bc.session_id == null) {
        return error.SessionIdNotLoaded;
    }

    const session = bc.session;
    var page = session.currentPage() orelse return error.PageNotLoaded;

    // Dupe URL before replacePage() frees the old page's arena.
    const reload_url = try cmd.arena.dupeZ(u8, page.url);

    if (page._load_state != .waiting) {
        page = try session.replacePage();
    }

    try page.navigate(reload_url, .{
        .reason = .address_bar,
        .cdp_id = cmd.input.id,
        .kind = .reload,
        .force = if (params) |p| p.ignoreCache orelse false else false,
    });
}

pub fn pageNavigate(bc: *CDP.BrowserContext, event: *const Notification.PageNavigate) !void {
    // detachTarget could be called, in which case, we still have a page doing
    // things, but no session.
    const session_id = bc.session_id orelse return;
    bc.reset();

    const frame_id = &id.toFrameId(event.frame_id);
    const loader_id = &id.toLoaderId(event.req_id);

    var cdp = bc.cdp;
    const reason_: ?[]const u8 = switch (event.opts.reason) {
        .anchor => "anchorClick",
        .script, .history, .navigation => "scriptInitiated",
        .form => switch (event.opts.method) {
            .GET => "formSubmissionGet",
            .POST => "formSubmissionPost",
            else => unreachable,
        },
        .address_bar => null,
        .initialFrameNavigation => "initialFrameNavigation",
    };
    if (reason_) |reason| {
        if (event.opts.reason != .initialFrameNavigation) {
            try cdp.sendEvent("Page.frameScheduledNavigation", .{
                .frameId = frame_id,
                .delay = 0,
                .reason = reason,
                .url = event.url,
            }, .{ .session_id = session_id });
        }
        try cdp.sendEvent("Page.frameRequestedNavigation", .{
            .frameId = frame_id,
            .reason = reason,
            .url = event.url,
            .disposition = "currentTab",
        }, .{ .session_id = session_id });
    }

    // frameStartedNavigating event
    try cdp.sendEvent("Page.frameStartedNavigating", .{
        .frameId = frame_id,
        .url = event.url,
        .loaderId = loader_id,
        .navigationType = "differentDocument",
    }, .{ .session_id = session_id });

    // frameStartedLoading event
    try cdp.sendEvent("Page.frameStartedLoading", .{
        .frameId = frame_id,
    }, .{ .session_id = session_id });
}

pub fn pageRemove(bc: *CDP.BrowserContext) !void {
    // Clear all remote object mappings to prevent stale objectIds from being used
    // after the context is destroy
    bc.inspector_session.inspector.resetContextGroup();

    // The main page is going to be removed, we need to remove contexts from other worlds first.
    for (bc.isolated_worlds.items) |isolated_world| {
        try isolated_world.removeContext();
    }
}

pub fn pageCreated(bc: *CDP.BrowserContext, page: *Page) !void {
    _ = bc.cdp.page_arena.reset(.{ .retain_with_limit = 1024 * 512 });

    for (bc.isolated_worlds.items) |isolated_world| {
        _ = try isolated_world.createContext(page);
    }
    // Only retain captured responses until a navigation event. In CDP term,
    // this is called a "renderer" and the cache-duration can be controlled via
    // the Network.configureDurableMessages message (which we don't support)
    bc.captured_responses = .empty;
}

pub fn pageFrameCreated(bc: *CDP.BrowserContext, event: *const Notification.PageFrameCreated) !void {
    const session_id = bc.session_id orelse return;

    const cdp = bc.cdp;
    const frame_id = &id.toFrameId(event.frame_id);

    try cdp.sendEvent("Page.frameAttached", .{ .params = .{
        .frameId = frame_id,
        .parentFrameId = &id.toFrameId(event.parent_id),
    } }, .{ .session_id = session_id });

    if (bc.page_life_cycle_events) {
        try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
            .name = "init",
            .frameId = frame_id,
            .loaderId = &id.toLoaderId(event.frame_id),
            .timestamp = event.timestamp,
        }, .{ .session_id = session_id });
    }
}

pub fn pageNavigated(arena: Allocator, bc: *CDP.BrowserContext, event: *const Notification.PageNavigated) !void {
    // detachTarget could be called, in which case, we still have a page doing
    // things, but no session.
    const session_id = bc.session_id orelse return;

    const frame_id = &id.toFrameId(event.frame_id);
    const loader_id = &id.toLoaderId(event.req_id);

    var cdp = bc.cdp;

    // Drivers are sensitive to the order of events. Some more than others.
    // The result for the Page.navigate seems like it _must_ come after
    // the frameStartedLoading, but before any lifecycleEvent. So we
    // unfortunately have to put the input_id ito the NavigateOpts which gets
    // passed back into the notification.
    if (event.opts.cdp_id) |input_id| {
        try cdp.sendJSON(.{
            .id = input_id,
            .result = .{
                .frameId = frame_id,
                .loaderId = loader_id,
            },
            .sessionId = session_id,
        });
    }

    if (bc.page_life_cycle_events) {
        try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
            .name = "init",
            .frameId = frame_id,
            .loaderId = loader_id,
            .timestamp = event.timestamp,
        }, .{ .session_id = session_id });
    }

    const reason_: ?[]const u8 = switch (event.opts.reason) {
        .anchor => "anchorClick",
        .script, .history, .navigation => "scriptInitiated",
        .form => switch (event.opts.method) {
            .GET => "formSubmissionGet",
            .POST => "formSubmissionPost",
            else => unreachable,
        },
        .address_bar => null,
        .initialFrameNavigation => "initialFrameNavigation",
    };

    if (reason_ != null) {
        try cdp.sendEvent("Page.frameClearedScheduledNavigation", .{
            .frameId = frame_id,
        }, .{ .session_id = session_id });
    }

    const page = bc.session.currentPage() orelse return error.PageNotLoaded;

    // When we actually recreated the context we should have the inspector send
    // this event, see: resetContextGroup. Sending this event will tell the
    // client that the context ids they had are invalid and the context should
    // be dropped. The client will expect us to send new contextCreated events,
    // such that the client has new id's for the active contexts.
    // Only send executionContextsCleared for main frame navigations. For child
    // frames (iframes), clearing all contexts would destroy the main frame's
    // context, causing Puppeteer's page.evaluate()/page.content() to hang
    // forever.
    if (event.frame_id == page._frame_id) {
        try cdp.sendEvent("Runtime.executionContextsCleared", null, .{ .session_id = session_id });
    }

    // frameNavigated event
    try cdp.sendEvent("Page.frameNavigated", .{
        .type = "Navigation",
        .frame = Frame{
            .id = frame_id,
            .url = event.url,
            .loaderId = loader_id,
            .securityOrigin = bc.security_origin,
            .secureContextType = bc.secure_context_type,
        },
    }, .{ .session_id = session_id });

    {
        const aux_data = try std.fmt.allocPrint(arena, "{{\"isDefault\":true,\"type\":\"default\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });

        var ls: js.Local.Scope = undefined;
        page.js.localScope(&ls);
        defer ls.deinit();

        bc.inspector_session.inspector.contextCreated(
            &ls.local,
            "",
            page.origin orelse "",
            aux_data,
            true,
        );
    }
    for (bc.isolated_worlds.items) |isolated_world| {
        const aux_json = try std.fmt.allocPrint(arena, "{{\"isDefault\":false,\"type\":\"isolated\",\"frameId\":\"{s}\",\"loaderId\":\"{s}\"}}", .{ frame_id, loader_id });

        // Calling contextCreated will assign a new Id to the context and send the contextCreated event

        var ls: js.Local.Scope = undefined;
        (isolated_world.context orelse continue).localScope(&ls);
        defer ls.deinit();

        bc.inspector_session.inspector.contextCreated(
            &ls.local,
            isolated_world.name,
            "://",
            aux_json,
            false,
        );
    }

    // Evaluate scripts registered via Page.addScriptToEvaluateOnNewDocument.
    // Must run after the execution context is created but before the client
    // receives frameNavigated/loadEventFired so polyfills are available for
    // subsequent CDP commands.
    if (bc.scripts_on_new_document.items.len > 0) {
        var ls: js.Local.Scope = undefined;
        page.js.localScope(&ls);
        defer ls.deinit();

        for (bc.scripts_on_new_document.items) |script| {
            var try_catch: lp.js.TryCatch = undefined;
            try_catch.init(&ls.local);
            defer try_catch.deinit();

            ls.local.eval(script.source, null) catch |err| {
                const caught = try_catch.caughtOrError(arena, err);
                log.warn(.cdp, "script on new doc", .{ .caught = caught });
            };
        }
    }

    // frameNavigated event
    try cdp.sendEvent("Page.frameNavigated", .{
        .type = "Navigation",
        .frame = Frame{
            .id = frame_id,
            .url = event.url,
            .loaderId = loader_id,
            .securityOrigin = bc.security_origin,
            .secureContextType = bc.secure_context_type,
        },
    }, .{ .session_id = session_id });

    // The DOM.documentUpdated event must be send after the frameNavigated one.
    // chromedp client expects to receive the events is this order.
    // see https://github.com/chromedp/chromedp/issues/1558
    try cdp.sendEvent("DOM.documentUpdated", null, .{ .session_id = session_id });
}

pub fn pageDOMContentLoaded(bc: anytype, event: *const Notification.PageDOMContentLoaded) !void {
    const session_id = bc.session_id orelse return;
    const timestamp = event.timestamp;
    var cdp = bc.cdp;

    try cdp.sendEvent(
        "Page.domContentEventFired",
        .{ .timestamp = timestamp },
        .{ .session_id = session_id },
    );

    if (bc.page_life_cycle_events) {
        const frame_id = &id.toFrameId(event.frame_id);
        const loader_id = &id.toLoaderId(event.req_id);
        try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
            .timestamp = timestamp,
            .name = "DOMContentLoaded",
            .frameId = frame_id,
            .loaderId = loader_id,
        }, .{ .session_id = session_id });
    }
}

pub fn pageLoaded(bc: anytype, event: *const Notification.PageLoaded) !void {
    const session_id = bc.session_id orelse return;
    const timestamp = event.timestamp;
    var cdp = bc.cdp;

    const frame_id = &id.toFrameId(event.frame_id);

    try cdp.sendEvent(
        "Page.loadEventFired",
        .{ .timestamp = timestamp },
        .{ .session_id = session_id },
    );

    if (bc.page_life_cycle_events) {
        const loader_id = &id.toLoaderId(event.req_id);
        try cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
            .timestamp = timestamp,
            .name = "load",
            .frameId = frame_id,
            .loaderId = loader_id,
        }, .{ .session_id = session_id });
    }

    return cdp.sendEvent("Page.frameStoppedLoading", .{
        .frameId = frame_id,
    }, .{ .session_id = session_id });
}

pub fn pageNetworkIdle(bc: *CDP.BrowserContext, event: *const Notification.PageNetworkIdle) !void {
    return sendPageLifecycle(bc, "networkIdle", event.timestamp, &id.toFrameId(event.frame_id), &id.toLoaderId(event.req_id));
}

pub fn pageNetworkAlmostIdle(bc: *CDP.BrowserContext, event: *const Notification.PageNetworkAlmostIdle) !void {
    return sendPageLifecycle(bc, "networkAlmostIdle", event.timestamp, &id.toFrameId(event.frame_id), &id.toLoaderId(event.req_id));
}

fn sendPageLifecycle(bc: *CDP.BrowserContext, name: []const u8, timestamp: u64, frame_id: []const u8, loader_id: []const u8) !void {
    // detachTarget could be called, in which case, we still have a page doing
    // things, but no session.
    const session_id = bc.session_id orelse return;

    return bc.cdp.sendEvent("Page.lifecycleEvent", LifecycleEvent{
        .name = name,
        .frameId = frame_id,
        .loaderId = loader_id,
        .timestamp = timestamp,
    }, .{ .session_id = session_id });
}

const LifecycleEvent = struct {
    frameId: []const u8,
    loaderId: ?[]const u8,
    name: []const u8,
    timestamp: u64,
};

const Viewport = struct {
    x: f64,
    y: f64,
    width: f64,
    height: f64,
    scale: f64,
};

fn base64Encode(comptime input: []const u8) [std.base64.standard.Encoder.calcSize(input.len)]u8 {
    const encoder = std.base64.standard.Encoder;
    var buf: [encoder.calcSize(input.len)]u8 = undefined;
    _ = encoder.encode(&buf, input);
    return buf;
}

fn captureScreenshot(cmd: *CDP.Command) !void {
    const Params = struct {
        format: ?[]const u8 = "png",
        quality: ?u8 = null,
        clip: ?Viewport = null,
        fromSurface: ?bool = false,
        captureBeyondViewport: ?bool = false,
        optimizeForSpeed: ?bool = false,
    };
    const params = try cmd.params(Params) orelse Params{};

    const format = params.format orelse "png";

    if (!std.mem.eql(u8, format, "png")) {
        log.warn(.not_implemented, "Page.captureScreenshot params", .{ .format = format });
        return cmd.sendError(-32000, "unsupported screenshot format.", .{});
    }
    if (params.quality != null) {
        log.warn(.not_implemented, "Page.captureScreenshot params", .{ .quality = params.quality });
    }
    if (params.clip != null) {
        log.warn(.not_implemented, "Page.captureScreenshot params", .{ .clip = params.clip });
    }
    if (params.fromSurface orelse false or params.captureBeyondViewport orelse false or params.optimizeForSpeed orelse false) {
        log.warn(.not_implemented, "Page.captureScreenshot params", .{
            .fromSurface = params.fromSurface,
            .captureBeyondViewport = params.captureBeyondViewport,
            .optimizeForSpeed = params.optimizeForSpeed,
        });
    }

    return cmd.sendResult(.{
        .data = base64Encode(screenshot_png),
    }, .{});
}

fn getLayoutMetrics(cmd: *CDP.Command) !void {
    const width = 1920;
    const height = 1080;

    return cmd.sendResult(.{
        .layoutViewport = .{
            .pageX = 0,
            .pageY = 0,
            .clientWidth = width,
            .clientHeight = height,
        },
        .visualViewport = .{
            .offsetX = 0,
            .offsetY = 0,
            .pageX = 0,
            .pageY = 0,
            .clientWidth = width,
            .clientHeight = height,
            .scale = 1,
            .zoom = 1,
        },
        .contentSize = .{
            .x = 0,
            .y = 0,
            .width = width,
            .height = height,
        },
        .cssLayoutViewport = .{
            .pageX = 0,
            .pageY = 0,
            .clientWidth = width,
            .clientHeight = height,
        },
        .cssVisualViewport = .{
            .offsetX = 0,
            .offsetY = 0,
            .pageX = 0,
            .pageY = 0,
            .clientWidth = width,
            .clientHeight = height,
            .scale = 1,
            .zoom = 1,
        },
        .cssContentSize = .{
            .x = 0,
            .y = 0,
            .width = width,
            .height = height,
        },
    }, .{});
}

const testing = @import("../testing.zig");
test "cdp.page: getFrameTree" {
    var ctx = try testing.context();
    defer ctx.deinit();

    {
        // no browser context - should return TID-STARTUP
        try ctx.processMessage(.{ .id = 1, .method = "Page.getFrameTree", .sessionId = "STARTUP" });
        try ctx.expectSentResult(.{
            .frameTree = .{
                .frame = .{
                    .id = "TID-STARTUP",
                    .loaderId = "LID-STARTUP",
                    .url = "about:blank",
                    .secureContextType = "Secure",
                },
            },
        }, .{ .id = 1, .session_id = "STARTUP" });
    }

    const bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });
    {
        try ctx.processMessage(.{ .id = 11, .method = "Page.getFrameTree" });
        try ctx.expectSentResult(.{
            .frameTree = .{
                .frame = .{
                    .id = "FID-000000000X",
                    .loaderId = "LID-0000000001",
                    .url = "http://127.0.0.1:9582/src/browser/tests/hi.html",
                    .domainAndRegistry = "",
                    .securityOrigin = bc.security_origin,
                    .mimeType = "text/html",
                    .adFrameStatus = .{
                        .adFrameType = "none",
                    },
                    .secureContextType = bc.secure_context_type,
                    .crossOriginIsolatedContextType = "NotIsolated",
                    .gatedAPIFeatures = [_][]const u8{},
                },
            },
        }, .{ .id = 11 });
    }

    {
        // STARTUP sesion is handled when a broweser context and a target id exists.
        try ctx.processMessage(.{ .id = 12, .method = "Page.getFrameTree", .session_id = "STARTUP" });
        try ctx.expectSentResult(.{
            .frameTree = .{
                .frame = .{
                    .id = "FID-000000000X",
                    .loaderId = "LID-0000000001",
                    .url = "http://127.0.0.1:9582/src/browser/tests/hi.html",
                    .domainAndRegistry = "",
                    .securityOrigin = bc.security_origin,
                    .mimeType = "text/html",
                    .adFrameStatus = .{
                        .adFrameType = "none",
                    },
                    .secureContextType = bc.secure_context_type,
                    .crossOriginIsolatedContextType = "NotIsolated",
                    .gatedAPIFeatures = [_][]const u8{},
                },
            },
        }, .{ .id = 12 });
    }
}

test "cdp.page: captureScreenshot" {
    const LogFilter = @import("../../testing.zig").LogFilter;
    const filter: LogFilter = .init(&.{.not_implemented});
    defer filter.deinit();

    var ctx = try testing.context();
    defer ctx.deinit();
    {
        try ctx.processMessage(.{ .id = 10, .method = "Page.captureScreenshot", .params = .{ .format = "jpg" } });
        try ctx.expectSentError(-32000, "unsupported screenshot format.", .{ .id = 10 });
    }

    {
        try ctx.processMessage(.{ .id = 11, .method = "Page.captureScreenshot" });
        try ctx.expectSentResult(.{
            .data = base64Encode(screenshot_png),
        }, .{ .id = 11 });
    }
}

test "cdp.page: getLayoutMetrics" {
    var ctx = try testing.context();
    defer ctx.deinit();

    _ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });

    const width = 1920;
    const height = 1080;

    try ctx.processMessage(.{ .id = 12, .method = "Page.getLayoutMetrics" });
    try ctx.expectSentResult(.{
        .layoutViewport = .{
            .pageX = 0,
            .pageY = 0,
            .clientWidth = width,
            .clientHeight = height,
        },
        .visualViewport = .{
            .offsetX = 0,
            .offsetY = 0,
            .pageX = 0,
            .pageY = 0,
            .clientWidth = width,
            .clientHeight = height,
            .scale = 1,
            .zoom = 1,
        },
        .contentSize = .{
            .x = 0,
            .y = 0,
            .width = width,
            .height = height,
        },
        .cssLayoutViewport = .{
            .pageX = 0,
            .pageY = 0,
            .clientWidth = width,
            .clientHeight = height,
        },
        .cssVisualViewport = .{
            .offsetX = 0,
            .offsetY = 0,
            .pageX = 0,
            .pageY = 0,
            .clientWidth = width,
            .clientHeight = height,
            .scale = 1,
            .zoom = 1,
        },
        .cssContentSize = .{
            .x = 0,
            .y = 0,
            .width = width,
            .height = height,
        },
    }, .{ .id = 12 });
}

test "cdp.page: reload" {
    var ctx = try testing.context();
    defer ctx.deinit();

    {
        // reload without browser context — should error
        try ctx.processMessage(.{ .id = 30, .method = "Page.reload" });
        try ctx.expectSentError(-31998, "BrowserContextNotLoaded", .{ .id = 30 });
    }

    _ = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });

    {
        // reload with no params — should not error (navigation is async,
        // so no result is sent synchronously; we just verify no error)
        try ctx.processMessage(.{ .id = 31, .method = "Page.reload" });
    }

    {
        // reload with ignoreCache param
        try ctx.processMessage(.{ .id = 32, .method = "Page.reload", .params = .{ .ignoreCache = true } });
    }
}

test "cdp.page: addScriptToEvaluateOnNewDocument" {
    var ctx = try testing.context();
    defer ctx.deinit();

    var bc = try ctx.loadBrowserContext(.{ .id = "BID-9", .url = "hi.html", .target_id = "FID-000000000X".* });

    {
        // Register a script — should return unique identifier "1"
        try ctx.processMessage(.{ .id = 20, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{ .source = "window.__test = 1" } });
        try ctx.expectSentResult(.{
            .identifier = "1",
        }, .{ .id = 20 });
    }

    {
        // Register another script — should return identifier "2"
        try ctx.processMessage(.{ .id = 21, .method = "Page.addScriptToEvaluateOnNewDocument", .params = .{ .source = "window.__test2 = 2" } });
        try ctx.expectSentResult(.{
            .identifier = "2",
        }, .{ .id = 21 });
    }

    {
        // Remove the first script — should succeed
        try ctx.processMessage(.{ .id = 22, .method = "Page.removeScriptToEvaluateOnNewDocument", .params = .{ .identifier = "1" } });
        try ctx.expectSentResult(null, .{ .id = 22 });
    }

    {
        // Remove a non-existent identifier — should succeed silently
        try ctx.processMessage(.{ .id = 23, .method = "Page.removeScriptToEvaluateOnNewDocument", .params = .{ .identifier = "999" } });
        try ctx.expectSentResult(null, .{ .id = 23 });
    }

    {
        try ctx.processMessage(.{ .id = 34, .method = "Page.reload" });
        // wait for this event, which is sent after we've run the registered scripts
        try ctx.expectSentEvent("Page.frameNavigated", .{
            .frame = .{ .loaderId = "LID-0000000002" },
        }, .{});

        const page = bc.session.currentPage() orelse unreachable;

        var ls: js.Local.Scope = undefined;
        page.js.localScope(&ls);
        defer ls.deinit();

        const test_val = try ls.local.exec("window.__test2", null);
        try testing.expectEqual(2, try test_val.toI32());
    }
}
