import { beforeEach, describe, expect, it, vi } from "vitest";
import { deviceHandlers } from "./devices.js";
import type { GatewayRequestHandlerOptions } from "./types.js";

const {
  getPairedDeviceMock,
  removePairedDeviceMock,
  revokeDeviceTokenMock,
  rotateDeviceTokenMock,
} = vi.hoisted(() => ({
  getPairedDeviceMock: vi.fn(),
  removePairedDeviceMock: vi.fn(),
  revokeDeviceTokenMock: vi.fn(),
  rotateDeviceTokenMock: vi.fn(),
}));

vi.mock("../../infra/device-pairing.js", async () => {
  const actual = await vi.importActual<typeof import("../../infra/device-pairing.js")>(
    "../../infra/device-pairing.js",
  );
  return {
    ...actual,
    getPairedDevice: getPairedDeviceMock,
    removePairedDevice: removePairedDeviceMock,
    revokeDeviceToken: revokeDeviceTokenMock,
    rotateDeviceToken: rotateDeviceTokenMock,
  };
});

function createClient(scopes: string[], deviceId?: string) {
  return {
    connect: {
      scopes,
      ...(deviceId ? { device: { id: deviceId } } : {}),
    },
  } as never;
}

function createOptions(
  method: string,
  params: Record<string, unknown>,
  overrides?: Partial<GatewayRequestHandlerOptions>,
): GatewayRequestHandlerOptions {
  return {
    req: { type: "req", id: "req-1", method, params },
    params,
    client: null,
    isWebchatConnect: () => false,
    respond: vi.fn(),
    context: {
      disconnectClientsForDevice: vi.fn(),
      logGateway: {
        debug: vi.fn(),
        error: vi.fn(),
        info: vi.fn(),
        warn: vi.fn(),
      },
    },
    ...overrides,
  } as unknown as GatewayRequestHandlerOptions;
}

function mockPairedOperatorDevice(): void {
  getPairedDeviceMock.mockResolvedValue({
    deviceId: "device-1",
    role: "operator",
    roles: ["operator"],
    scopes: ["operator.pairing"],
    tokens: {
      operator: {
        token: "old-token",
        role: "operator",
        scopes: ["operator.pairing"],
        createdAtMs: 123,
      },
    },
  });
}

function mockRotateOperatorTokenSuccess(): void {
  rotateDeviceTokenMock.mockResolvedValue({
    ok: true,
    entry: {
      token: "new-token",
      role: "operator",
      scopes: ["operator.pairing"],
      createdAtMs: 456,
      rotatedAtMs: 789,
    },
  });
}

describe("deviceHandlers", () => {
  beforeEach(() => {
    vi.clearAllMocks();
  });

  it("disconnects active clients after removing a paired device", async () => {
    removePairedDeviceMock.mockResolvedValue({ deviceId: "device-1", removedAtMs: 123 });
    const opts = createOptions("device.pair.remove", { deviceId: " device-1 " });

    await deviceHandlers["device.pair.remove"](opts);
    await Promise.resolve();

    expect(removePairedDeviceMock).toHaveBeenCalledWith(" device-1 ");
    expect(opts.context.disconnectClientsForDevice).toHaveBeenCalledWith("device-1");
    expect(opts.respond).toHaveBeenCalledWith(
      true,
      { deviceId: "device-1", removedAtMs: 123 },
      undefined,
    );
  });

  it("does not disconnect clients when device removal fails", async () => {
    removePairedDeviceMock.mockResolvedValue(null);
    const opts = createOptions("device.pair.remove", { deviceId: "device-1" });

    await deviceHandlers["device.pair.remove"](opts);

    expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
    expect(opts.respond).toHaveBeenCalledWith(
      false,
      undefined,
      expect.objectContaining({ message: "unknown deviceId" }),
    );
  });

  it("rejects removing another device from a non-admin device session", async () => {
    const opts = createOptions(
      "device.pair.remove",
      { deviceId: "device-2" },
      { client: createClient(["operator.pairing"], "device-1") },
    );

    await deviceHandlers["device.pair.remove"](opts);

    expect(removePairedDeviceMock).not.toHaveBeenCalled();
    expect(opts.respond).toHaveBeenCalledWith(
      false,
      undefined,
      expect.objectContaining({ message: "device pairing removal denied" }),
    );
  });

  it("treats normalized device ids as self-owned for paired device removal", async () => {
    removePairedDeviceMock.mockResolvedValue({ deviceId: "device-1", removedAtMs: 123 });
    const opts = createOptions(
      "device.pair.remove",
      { deviceId: " device-1 " },
      { client: createClient(["operator.pairing"], "device-1") },
    );

    await deviceHandlers["device.pair.remove"](opts);

    expect(removePairedDeviceMock).toHaveBeenCalledWith(" device-1 ");
    expect(opts.respond).toHaveBeenCalledWith(
      true,
      { deviceId: "device-1", removedAtMs: 123 },
      undefined,
    );
  });

  it("disconnects active clients after revoking a device token", async () => {
    revokeDeviceTokenMock.mockResolvedValue({ role: "operator", revokedAtMs: 456 });
    const opts = createOptions("device.token.revoke", {
      deviceId: " device-1 ",
      role: " operator ",
    });

    await deviceHandlers["device.token.revoke"](opts);
    await Promise.resolve();

    expect(revokeDeviceTokenMock).toHaveBeenCalledWith({
      deviceId: " device-1 ",
      role: " operator ",
    });
    expect(opts.context.disconnectClientsForDevice).toHaveBeenCalledWith("device-1", {
      role: "operator",
    });
    expect(opts.respond).toHaveBeenCalledWith(
      true,
      { deviceId: "device-1", role: "operator", revokedAtMs: 456 },
      undefined,
    );
  });

  it("allows admin-scoped callers to revoke another device's token", async () => {
    revokeDeviceTokenMock.mockResolvedValue({ role: "operator", revokedAtMs: 456 });
    const opts = createOptions(
      "device.token.revoke",
      { deviceId: "device-2", role: "operator" },
      { client: createClient(["operator.admin"], "device-1") },
    );

    await deviceHandlers["device.token.revoke"](opts);

    expect(revokeDeviceTokenMock).toHaveBeenCalledWith({
      deviceId: "device-2",
      role: "operator",
    });
    expect(opts.respond).toHaveBeenCalledWith(
      true,
      { deviceId: "device-2", role: "operator", revokedAtMs: 456 },
      undefined,
    );
  });

  it("treats normalized device ids as self-owned for token revocation", async () => {
    revokeDeviceTokenMock.mockResolvedValue({ role: "operator", revokedAtMs: 456 });
    const opts = createOptions(
      "device.token.revoke",
      { deviceId: " device-1 ", role: "operator" },
      { client: createClient(["operator.pairing"], "device-1") },
    );

    await deviceHandlers["device.token.revoke"](opts);

    expect(revokeDeviceTokenMock).toHaveBeenCalledWith({
      deviceId: " device-1 ",
      role: "operator",
    });
    expect(opts.respond).toHaveBeenCalledWith(
      true,
      { deviceId: "device-1", role: "operator", revokedAtMs: 456 },
      undefined,
    );
  });

  it("disconnects active clients after rotating a device token", async () => {
    mockPairedOperatorDevice();
    mockRotateOperatorTokenSuccess();
    const opts = createOptions(
      "device.token.rotate",
      {
        deviceId: " device-1 ",
        role: " operator ",
        scopes: ["operator.pairing"],
      },
      {
        client: {
          connect: {
            scopes: ["operator.pairing"],
          },
        } as never,
      },
    );

    await deviceHandlers["device.token.rotate"](opts);
    await Promise.resolve();

    expect(rotateDeviceTokenMock).toHaveBeenCalledWith({
      deviceId: " device-1 ",
      role: " operator ",
      scopes: ["operator.pairing"],
    });
    expect(opts.context.disconnectClientsForDevice).toHaveBeenCalledWith("device-1", {
      role: "operator",
    });
    expect(opts.respond).toHaveBeenCalledWith(
      true,
      {
        deviceId: " device-1 ",
        role: "operator",
        token: "new-token",
        scopes: ["operator.pairing"],
        rotatedAtMs: 789,
      },
      undefined,
    );
  });

  it("treats normalized device ids as self-owned for token rotation", async () => {
    mockPairedOperatorDevice();
    mockRotateOperatorTokenSuccess();
    const opts = createOptions(
      "device.token.rotate",
      {
        deviceId: " device-1 ",
        role: "operator",
        scopes: ["operator.pairing"],
      },
      { client: createClient(["operator.pairing"], "device-1") },
    );

    await deviceHandlers["device.token.rotate"](opts);

    expect(rotateDeviceTokenMock).toHaveBeenCalledWith({
      deviceId: " device-1 ",
      role: "operator",
      scopes: ["operator.pairing"],
    });
    expect(opts.respond).toHaveBeenCalledWith(
      true,
      {
        deviceId: " device-1 ",
        role: "operator",
        token: "new-token",
        scopes: ["operator.pairing"],
        rotatedAtMs: 789,
      },
      undefined,
    );
  });

  it("rejects rotating a token for a role that was never approved", async () => {
    mockPairedOperatorDevice();
    const opts = createOptions(
      "device.token.rotate",
      {
        deviceId: "device-1",
        role: "node",
      },
      {
        client: {
          connect: {
            scopes: ["operator.pairing"],
          },
        } as never,
      },
    );

    await deviceHandlers["device.token.rotate"](opts);

    expect(rotateDeviceTokenMock).not.toHaveBeenCalled();
    expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
    expect(opts.respond).toHaveBeenCalledWith(
      false,
      undefined,
      expect.objectContaining({ message: "device token rotation denied" }),
    );
  });

  it("does not disconnect clients when token revocation fails", async () => {
    revokeDeviceTokenMock.mockResolvedValue(null);
    const opts = createOptions("device.token.revoke", {
      deviceId: "device-1",
      role: "operator",
    });

    await deviceHandlers["device.token.revoke"](opts);

    expect(opts.context.disconnectClientsForDevice).not.toHaveBeenCalled();
    expect(opts.respond).toHaveBeenCalledWith(
      false,
      undefined,
      expect.objectContaining({ message: "unknown deviceId/role" }),
    );
  });
});
