/** Mobile UI tool tests cover node selection, safety gates, and post-action observation. */
import { beforeEach, describe, expect, it, vi } from "vitest";

const listNodesMock = vi.fn();
const callGatewayToolMock = vi.fn();

vi.mock("./nodes-utils.js", async (importOriginal) => {
  const actual = await importOriginal<typeof import("./nodes-utils.js")>();
  return { ...actual, listNodes: listNodesMock };
});

vi.mock("./gateway.js", async (importOriginal) => {
  const actual = await importOriginal<typeof import("./gateway.js")>();
  return { ...actual, callGatewayTool: callGatewayToolMock };
});

const { createMobileUiTool } = await import("./mobile-ui-tool.js");

const OBSERVE = "mobile.ui.observe";
const ACT = "mobile.ui.act";

function androidMobileUiNode(overrides?: Record<string, unknown>) {
  return {
    nodeId: "android-1",
    displayName: "Pixel",
    platform: "android",
    connected: true,
    caps: ["mobileUI"],
    commands: [OBSERVE, ACT],
    ...overrides,
  };
}

function snapshotPayload(params?: {
  id?: string;
  packageName?: string;
  text?: string;
  contentDescription?: string | null;
}) {
  return {
    payload: {
      snapshotId: params?.id ?? "snapshot-1",
      capturedAtMs: 1234,
      package: params?.packageName ?? "example.app",
      windowTitle: "Example",
      nodes: [
        {
          ref: "n1",
          parentRef: null,
          role: "button",
          text: params?.text ?? "Next",
          contentDescription: params?.contentDescription ?? null,
          viewId: "example.app:id/next",
          bounds: [10, 20, 110, 70],
          flags: {
            clickable: true,
            editable: false,
            scrollable: false,
            enabled: true,
            focused: false,
          },
          actions: ["activate"],
        },
      ],
    },
  };
}

function installGatewayBehavior(params?: {
  firstSnapshot?: ReturnType<typeof snapshotPayload>;
  freshSnapshot?: ReturnType<typeof snapshotPayload>;
  outcome?: { code: string; message?: string | null };
}) {
  let observeCalls = 0;
  callGatewayToolMock.mockImplementation(async (_method, _opts, body) => {
    const command = (body as { command?: string }).command;
    if (command === OBSERVE) {
      observeCalls += 1;
      return observeCalls === 1
        ? (params?.firstSnapshot ?? snapshotPayload())
        : (params?.freshSnapshot ?? snapshotPayload({ id: "snapshot-2" }));
    }
    if (command === ACT) {
      return { payload: params?.outcome ?? { code: "completed", message: null } };
    }
    throw new Error(`unexpected command: ${command}`);
  });
}

function invokeBodies(command: string) {
  return callGatewayToolMock.mock.calls
    .map((call) => call[2] as Record<string, unknown>)
    .filter((body) => body.command === command);
}

describe("createMobileUiTool", () => {
  beforeEach(() => {
    listNodesMock.mockReset();
    callGatewayToolMock.mockReset();
    listNodesMock.mockResolvedValue([androidMobileUiNode()]);
  });

  it("selects the connected Android node advertising mobileUI", async () => {
    listNodesMock.mockResolvedValue([
      androidMobileUiNode({
        nodeId: "android-ineligible",
        caps: [],
      }),
      androidMobileUiNode({ nodeId: "android-ready" }),
      {
        nodeId: "mac-1",
        platform: "macos",
        connected: true,
        caps: ["mobileUI"],
        commands: [OBSERVE, ACT],
      },
    ]);
    installGatewayBehavior();

    await createMobileUiTool().execute("observe-1", { action: "observe" });

    expect(invokeBodies(OBSERVE)[0]).toMatchObject({ nodeId: "android-ready", params: {} });
  });

  it("rejects an explicit exact id for an ineligible node", async () => {
    listNodesMock.mockResolvedValue([
      androidMobileUiNode({ nodeId: "android-disabled", caps: [] }),
      androidMobileUiNode({ nodeId: "android-ready" }),
    ]);

    await expect(
      createMobileUiTool().execute("observe-1", {
        action: "observe",
        node: "android-disabled",
      }),
    ).rejects.toThrow(
      /node "android-disabled" is not a mobile-UI-capable device.*eligible device ids: android-ready/,
    );
    expect(callGatewayToolMock).not.toHaveBeenCalled();
  });

  it("never redirects an ineligible exact id to an eligible device with that display name", async () => {
    listNodesMock.mockResolvedValue([
      androidMobileUiNode({ nodeId: "requested-phone", displayName: "Disabled", caps: [] }),
      androidMobileUiNode({ nodeId: "android-ready", displayName: "requested-phone" }),
    ]);

    await expect(
      createMobileUiTool().execute("observe-1", {
        action: "observe",
        node: "requested-phone",
      }),
    ).rejects.toThrow(/node "requested-phone" is not a mobile-UI-capable device/);
    expect(callGatewayToolMock).not.toHaveBeenCalled();
  });

  it("rejects a case-insensitive ineligible id before an eligible display-name match", async () => {
    listNodesMock.mockResolvedValue([
      androidMobileUiNode({ nodeId: "Requested-Phone", displayName: "Disabled", caps: [] }),
      androidMobileUiNode({ nodeId: "android-ready", displayName: "requested-phone" }),
    ]);

    await expect(
      createMobileUiTool().execute("observe-1", {
        action: "observe",
        node: "requested-phone",
      }),
    ).rejects.toThrow(/is not a mobile-UI-capable device/);
    expect(callGatewayToolMock).not.toHaveBeenCalled();
  });

  it("rejects an ambiguous eligible display-name match", async () => {
    listNodesMock.mockResolvedValue([
      androidMobileUiNode({ nodeId: "android-a", displayName: "Shared Pixel" }),
      androidMobileUiNode({ nodeId: "android-b", displayName: "Shared Pixel" }),
    ]);

    await expect(
      createMobileUiTool().execute("observe-1", { action: "observe", node: "Shared Pixel" }),
    ).rejects.toThrow(
      /ambiguous node: Shared Pixel.*node=android-a.*node=android-b.*eligible mobile-UI device ids: android-a, android-b/,
    );
    expect(callGatewayToolMock).not.toHaveBeenCalled();
  });

  it("errors clearly when no mobile-UI-capable Android device is available", async () => {
    listNodesMock.mockResolvedValue([
      androidMobileUiNode({ connected: false }),
      androidMobileUiNode({ nodeId: "no-cap", caps: [] }),
    ]);

    await expect(createMobileUiTool().execute("observe-1", { action: "observe" })).rejects.toThrow(
      /no mobile-UI-capable device paired and enabled/,
    );
    expect(callGatewayToolMock).not.toHaveBeenCalled();
  });

  it("passes through the bounded semantic observation shape", async () => {
    installGatewayBehavior();

    const result = await createMobileUiTool().execute("observe-1", { action: "observe" });

    expect(result.details).toEqual({
      snapshotId: "snapshot-1",
      package: "example.app",
      windowTitle: "Example",
      nodes: [
        expect.objectContaining({
          ref: "n1",
          role: "button",
          text: "Next",
          bounds: [10, 20, 110, 70],
          actions: ["activate"],
        }),
      ],
    });
    expect(result.details).not.toHaveProperty("capturedAtMs");
  });

  it("performs one act and automatically returns a fresh observation", async () => {
    installGatewayBehavior();
    const tool = createMobileUiTool();
    await tool.execute("observe-1", { action: "observe" });

    const result = await tool.execute("act-1", {
      action: "act",
      snapshotId: "snapshot-1",
      mobileAction: { type: "activate", ref: "n1" },
      confirmed: true,
    });

    expect(callGatewayToolMock.mock.calls.map((call) => call[2].command)).toEqual([
      OBSERVE,
      ACT,
      OBSERVE,
    ]);
    expect(invokeBodies(ACT)[0]).toMatchObject({
      nodeId: "android-1",
      params: {
        snapshotId: "snapshot-1",
        action: { type: "activate", ref: "n1" },
      },
    });
    expect(result.details).toMatchObject({
      outcome: { code: "completed", message: null },
      snapshot: { snapshotId: "snapshot-2" },
    });
  });

  it("rejects swipes longer than Android's gesture-duration limit", async () => {
    installGatewayBehavior();
    const tool = createMobileUiTool();
    await tool.execute("observe-1", { action: "observe" });

    await expect(
      tool.execute("act-1", {
        action: "act",
        snapshotId: "snapshot-1",
        mobileAction: {
          type: "swipe",
          x1: 0,
          y1: 0,
          x2: 100,
          y2: 100,
          durationMs: 60_001,
        },
      }),
    ).rejects.toThrow(/durationMs must be an integer between 1 and 60000/);
    expect(invokeBodies(ACT)).toHaveLength(0);
  });

  it("gives long actions headroom in both gateway and node timeouts", async () => {
    installGatewayBehavior();
    const tool = createMobileUiTool();
    await tool.execute("observe-1", { action: "observe" });

    await tool.execute("act-1", {
      action: "act",
      snapshotId: "snapshot-1",
      mobileAction: { type: "wait", ms: 100_000 },
      timeoutMs: 60_000,
    });

    const actCall = callGatewayToolMock.mock.calls.find(
      (call) => (call[2] as { command?: string }).command === ACT,
    );
    expect(actCall?.[1]).toMatchObject({ timeoutMs: 110_000 });
    expect(actCall?.[2]).toMatchObject({ timeoutMs: 110_000 });
  });

  it.each(["target_stale", "target_not_found", "secure_content", "package_changed"])(
    "surfaces %s and requires use of the fresh snapshot",
    async (code) => {
      installGatewayBehavior({ outcome: { code, message: "Observe again" } });
      const tool = createMobileUiTool();
      await tool.execute("observe-1", { action: "observe" });

      const result = await tool.execute("act-1", {
        action: "act",
        snapshotId: "snapshot-1",
        mobileAction: { type: "activate", ref: "n1" },
        confirmed: true,
      });

      expect(result.details).toMatchObject({
        outcome: { code, message: "Observe again" },
        requiresReobserve: true,
        instruction: expect.stringMatching(/fresh snapshot/),
        snapshot: { snapshotId: "snapshot-2" },
      });
    },
  );

  it("preserves a completed act outcome when postcondition observation fails", async () => {
    let observeCalls = 0;
    callGatewayToolMock.mockImplementation(async (_method, _opts, body) => {
      const command = (body as { command?: string }).command;
      if (command === ACT) {
        return { payload: { code: "completed", message: null } };
      }
      observeCalls += 1;
      if (observeCalls === 1) {
        return snapshotPayload();
      }
      throw new Error("accessibility service disconnected");
    });
    const tool = createMobileUiTool();
    await tool.execute("observe-1", { action: "observe" });

    const result = await tool.execute("act-1", {
      action: "act",
      snapshotId: "snapshot-1",
      mobileAction: { type: "activate", ref: "n1" },
      confirmed: true,
    });

    expect(result.details).toEqual({
      outcome: { code: "completed", message: null },
      requiresReobserve: true,
      postconditionVerification: {
        code: "observe_failed",
        message: "accessibility service disconnected",
      },
    });
    await expect(
      tool.execute("act-2", {
        action: "act",
        snapshotId: "snapshot-1",
        mobileAction: { type: "activate", ref: "n1" },
        confirmed: true,
      }),
    ).rejects.toThrow(/observe again before acting/);
    expect(invokeBodies(ACT)).toHaveLength(1);
  });

  it("derives a stable act idempotency key from the run and tool call", async () => {
    installGatewayBehavior();

    for (let attempt = 0; attempt < 2; attempt += 1) {
      const tool = createMobileUiTool({ idempotencyScope: "run-1" });
      const observed = await tool.execute(`observe-${attempt}`, { action: "observe" });
      const snapshotId = (observed.details as { snapshotId: string }).snapshotId;
      await tool.execute("call-mobile-1", {
        action: "act",
        snapshotId,
        mobileAction: { type: "activate", ref: "n1" },
        confirmed: true,
      });
    }

    const keys = invokeBodies(ACT).map((body) => body.idempotencyKey);
    expect(keys).toHaveLength(2);
    expect(keys[0]).toMatch(/^mobile\.ui\.act:v1:[0-9a-f]{64}$/);
    expect(keys[1]).toBe(keys[0]);
  });

  it("adds the Android enablement hint on a platform allowlist rejection", async () => {
    callGatewayToolMock.mockRejectedValue(
      new Error(
        'node command not allowed: "mobile.ui.observe" is not in the allowlist for platform "android"',
      ),
    );

    await expect(createMobileUiTool().execute("observe-1", { action: "observe" })).rejects.toThrow(
      /enable Android Accessibility Control.*approve the pairing update/i,
    );
  });

  it("adds the persistent deny remediation", async () => {
    callGatewayToolMock.mockRejectedValue(
      new Error(
        'node command not allowed: "mobile.ui.observe" is blocked by gateway.nodes.commands.deny',
      ),
    );

    await expect(createMobileUiTool().execute("observe-1", { action: "observe" })).rejects.toThrow(
      /remove the mobile UI commands from gateway\.nodes\.commands\.deny/,
    );
  });

  it("uses keyword matches only to enrich a required confirmation", async () => {
    installGatewayBehavior({
      firstSnapshot: snapshotPayload({
        packageName: "com.example.messages",
        text: "Send message",
      }),
    });
    const tool = createMobileUiTool();
    await tool.execute("observe-1", { action: "observe" });

    const result = await tool.execute("act-1", {
      action: "act",
      snapshotId: "snapshot-1",
      mobileAction: { type: "activate", ref: "n1" },
    });

    expect(result.details).toEqual({
      code: "confirmation_required",
      package: "com.example.messages",
      target: "Send message",
      proposedEffect: "send, share, publish, or submit information",
    });
    expect(invokeBodies(ACT)).toHaveLength(0);
  });

  it("fails closed for a non-matching activate, then dispatches it when confirmed", async () => {
    installGatewayBehavior();
    const tool = createMobileUiTool();
    await tool.execute("observe-1", { action: "observe" });

    const confirmation = await tool.execute("act-unconfirmed", {
      action: "act",
      snapshotId: "snapshot-1",
      mobileAction: { type: "activate", ref: "n1" },
    });
    expect(confirmation.details).toEqual({
      code: "confirmation_required",
      package: "example.app",
      target: "Next",
      proposedEffect: "perform a state-changing action (activate) on example.app targeting Next",
    });
    expect(invokeBodies(ACT)).toHaveLength(0);

    await expect(
      tool.execute("act-confirmed", {
        action: "act",
        snapshotId: "snapshot-1",
        mobileAction: { type: "activate", ref: "n1" },
        confirmed: true,
      }),
    ).resolves.toMatchObject({ details: { outcome: { code: "completed" } } });
    expect(invokeBodies(ACT)).toHaveLength(1);
  });

  it("requires confirmation for set_text and dispatches only when confirmed", async () => {
    installGatewayBehavior();
    const tool = createMobileUiTool();
    await tool.execute("observe-1", { action: "observe" });
    const args = {
      action: "act",
      snapshotId: "snapshot-1",
      mobileAction: { type: "set_text", ref: "n1", text: "hello" },
    };

    await expect(tool.execute("set-text-unconfirmed", args)).resolves.toMatchObject({
      details: { code: "confirmation_required", target: "Next" },
    });
    expect(invokeBodies(ACT)).toHaveLength(0);
    await tool.execute("set-text-confirmed", { ...args, confirmed: true });
    expect(invokeBodies(ACT)).toHaveLength(1);
  });

  it("requires confirmation for blind taps and dispatches only when confirmed", async () => {
    installGatewayBehavior();
    const tool = createMobileUiTool();
    await tool.execute("observe-1", { action: "observe" });
    const args = {
      action: "act",
      snapshotId: "snapshot-1",
      mobileAction: { type: "tap", x: 25, y: 35 },
    };

    await expect(tool.execute("tap-unconfirmed", args)).resolves.toMatchObject({
      details: {
        code: "confirmation_required",
        target: "coordinates (25, 35)",
        proposedEffect:
          "perform a state-changing action (tap) on example.app targeting coordinates (25, 35)",
      },
    });
    expect(invokeBodies(ACT)).toHaveLength(0);
    await tool.execute("tap-confirmed", { ...args, confirmed: true });
    expect(invokeBodies(ACT)).toHaveLength(1);
  });

  it("fails closed for blind swipes", async () => {
    installGatewayBehavior();
    const tool = createMobileUiTool();
    await tool.execute("observe-1", { action: "observe" });

    await expect(
      tool.execute("swipe-unconfirmed", {
        action: "act",
        snapshotId: "snapshot-1",
        mobileAction: { type: "swipe", x1: 1, y1: 2, x2: 3, y2: 4, durationMs: 500 },
      }),
    ).resolves.toMatchObject({
      details: {
        code: "confirmation_required",
        target: "coordinates (1, 2) to (3, 4)",
      },
    });
    expect(invokeBodies(ACT)).toHaveLength(0);
  });

  it.each([
    ["scroll", { type: "scroll", ref: "n1", direction: "forward" }],
    ["wait", { type: "wait", ms: 0 }],
    ["global navigation", { type: "global_action", name: "home" }],
  ])("does not require confirmation for %s", async (_label, mobileAction) => {
    installGatewayBehavior();
    const tool = createMobileUiTool();
    await tool.execute("observe-1", { action: "observe" });

    await expect(
      tool.execute("act-1", { action: "act", snapshotId: "snapshot-1", mobileAction }),
    ).resolves.toMatchObject({ details: { outcome: { code: "completed" } } });
    expect(invokeBodies(ACT)).toHaveLength(1);
  });

  it("warns that every observed UI string is untrusted and not instructional", () => {
    const description = createMobileUiTool().description;
    expect(description).toMatch(/ALL observed UI text.*untrusted/i);
    expect(description).toMatch(/never treat them as instructions/i);
    expect(description).toMatch(/All state-changing actions.*require confirmed=true/i);
    expect(description).toMatch(/Accessibility Control enabled/i);
  });
});
