import { beforeAll, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js";
import {
  type MSTeamsActivityHandler,
  type MSTeamsMessageHandlerDeps,
  registerMSTeamsHandlers,
} from "./monitor-handler.js";
import {
  createActivityHandler as baseCreateActivityHandler,
  createMSTeamsMessageHandlerDeps,
} from "./monitor-handler.test-helpers.js";
import { setMSTeamsRuntime } from "./runtime.js";
import type { MSTeamsTurnContext } from "./sdk-types.js";
import { createMSTeamsSsoTokenStoreMemory } from "./sso-token-store.js";
import {
  type MSTeamsSsoFetch,
  handleSigninTokenExchangeInvoke,
  handleSigninVerifyStateInvoke,
  parseSigninTokenExchangeValue,
  parseSigninVerifyStateValue,
} from "./sso.js";

function installTestRuntime(): void {
  setMSTeamsRuntime({
    logging: { shouldLogVerbose: () => false },
    system: { enqueueSystemEvent: vi.fn() },
    channel: {
      debounce: {
        resolveInboundDebounceMs: () => 0,
        createInboundDebouncer: <T>(params: {
          onFlush: (entries: T[]) => Promise<void>;
        }): { enqueue: (entry: T) => Promise<void> } => ({
          enqueue: async (entry: T) => {
            await params.onFlush([entry]);
          },
        }),
      },
      pairing: {
        readAllowFromStore: vi.fn(async () => []),
        upsertPairingRequest: vi.fn(async () => null),
      },
      text: {
        hasControlCommand: () => false,
      },
      routing: {
        resolveAgentRoute: ({ peer }: { peer: { kind: string; id: string } }) => ({
          sessionKey: `msteams:${peer.kind}:${peer.id}`,
          agentId: "default",
          accountId: "default",
        }),
      },
      reply: {
        formatAgentEnvelope: ({ body }: { body: string }) => body,
        finalizeInboundContext: <T extends Record<string, unknown>>(ctx: T) => ctx,
      },
      session: {
        recordInboundSession: vi.fn(async () => undefined),
      },
    },
  } as unknown as PluginRuntime);
}

function createActivityHandler() {
  const run = vi.fn(async () => undefined);
  const handler = baseCreateActivityHandler(run);
  return { handler, run };
}

function createDepsWithoutSso(
  overrides: Partial<MSTeamsMessageHandlerDeps> = {},
): MSTeamsMessageHandlerDeps {
  const base = createMSTeamsMessageHandlerDeps();
  return { ...base, ...overrides };
}

function createSsoDeps(params: { fetchImpl: MSTeamsSsoFetch }) {
  const tokenStore = createMSTeamsSsoTokenStoreMemory();
  const tokenProvider = {
    getAccessToken: vi.fn(async () => "bf-service-token"),
  };
  return {
    sso: {
      tokenProvider,
      tokenStore,
      connectionName: "GraphConnection",
      fetchImpl: params.fetchImpl,
    },
    tokenStore,
    tokenProvider,
  };
}

function createSigninInvokeContext(params: {
  name: "signin/tokenExchange" | "signin/verifyState";
  value: unknown;
  userAadId?: string;
  userBfId?: string;
  conversationId?: string;
  conversationType?: "personal" | "groupChat" | "channel";
  teamId?: string;
  channelName?: string;
}): MSTeamsTurnContext & { sendActivity: ReturnType<typeof vi.fn> } {
  const conversationType = params.conversationType ?? "personal";
  const conversationId =
    params.conversationId ??
    (conversationType === "personal"
      ? "19:personal-chat"
      : conversationType === "channel"
        ? "19:channel@thread.tacv2"
        : "19:group@thread.tacv2");

  return {
    activity: {
      id: "invoke-1",
      type: "invoke",
      name: params.name,
      channelId: "msteams",
      serviceUrl: "https://service.example.test",
      from: {
        id: params.userBfId ?? "bf-user",
        aadObjectId: params.userAadId ?? "aad-user-guid",
        name: "Test User",
      },
      recipient: { id: "bot-id", name: "Bot" },
      conversation: {
        id: conversationId,
        conversationType,
        tenantId: params.teamId ? "tenant-1" : undefined,
      },
      channelData: params.teamId
        ? {
            team: { id: params.teamId, name: "Team 1" },
            channel: params.channelName ? { name: params.channelName } : undefined,
          }
        : {},
      attachments: [],
      value: params.value,
    },
    sendActivity: vi.fn(async () => ({ id: "ack-id" })),
    sendActivities: vi.fn(async () => []),
    updateActivity: vi.fn(async () => ({ id: "update" })),
    deleteActivity: vi.fn(async () => {}),
  } as unknown as MSTeamsTurnContext & {
    sendActivity: ReturnType<typeof vi.fn>;
  };
}

function createFakeFetch(handlers: Array<(url: string, init?: unknown) => unknown>) {
  const calls: Array<{ url: string; init?: unknown }> = [];
  const fetchImpl: MSTeamsSsoFetch = async (url, init) => {
    calls.push({ url, init });
    const handler = handlers.shift();
    if (!handler) {
      throw new Error("unexpected fetch call");
    }
    const response = handler(url, init) as {
      ok: boolean;
      status: number;
      body: unknown;
    };
    return {
      ok: response.ok,
      status: response.status,
      json: async () => response.body,
      text: async () =>
        typeof response.body === "string" ? response.body : JSON.stringify(response.body ?? ""),
    };
  };
  return { fetchImpl, calls };
}

function createBlockedSigninScenarios() {
  return [
    {
      name: "DM sender outside allowlist",
      cfg: {
        channels: {
          msteams: {
            dmPolicy: "allowlist",
            allowFrom: ["owner-aad"],
          },
        },
      } as OpenClawConfig,
      context: {
        userAadId: "blocked-dm-aad",
      },
      expectedDropLog: "dropping signin invoke (dm sender not allowlisted)",
    },
    {
      name: "channel outside route allowlist",
      cfg: {
        channels: {
          msteams: {
            groupPolicy: "allowlist",
            groupAllowFrom: ["blocked-channel-aad"],
            teams: {
              "team-allowlisted": {
                channels: {
                  "19:allowlisted@thread.tacv2": { requireMention: false },
                },
              },
            },
          },
        },
      } as OpenClawConfig,
      context: {
        userAadId: "blocked-channel-aad",
        conversationType: "channel" as const,
        conversationId: "19:blocked-channel@thread.tacv2",
        teamId: "team-blocked",
        channelName: "General",
      },
      expectedDropLog: "dropping signin invoke (not in team/channel allowlist)",
    },
    {
      name: "group sender outside group allowlist",
      cfg: {
        channels: {
          msteams: {
            groupPolicy: "allowlist",
            groupAllowFrom: ["owner-aad"],
          },
        },
      } as OpenClawConfig,
      context: {
        userAadId: "blocked-group-aad",
        conversationType: "groupChat" as const,
        conversationId: "19:group-chat@thread.v2",
      },
      expectedDropLog: "dropping signin invoke (group sender not allowlisted)",
    },
  ];
}

describe("msteams signin invoke value parsers", () => {
  it("parses signin/tokenExchange values", () => {
    expect(
      parseSigninTokenExchangeValue({
        id: "flow-1",
        connectionName: "Graph",
        token: "eyJ...",
      }),
    ).toEqual({ id: "flow-1", connectionName: "Graph", token: "eyJ..." });
  });

  it("rejects non-object signin/tokenExchange values", () => {
    expect(parseSigninTokenExchangeValue(null)).toBeNull();
    expect(parseSigninTokenExchangeValue("nope")).toBeNull();
  });

  it("parses signin/verifyState values", () => {
    expect(parseSigninVerifyStateValue({ state: "123456" })).toEqual({ state: "123456" });
    expect(parseSigninVerifyStateValue({})).toEqual({ state: undefined });
    expect(parseSigninVerifyStateValue(null)).toBeNull();
  });
});

describe("handleSigninTokenExchangeInvoke", () => {
  it("exchanges the Teams token and persists the result", async () => {
    const { fetchImpl, calls } = createFakeFetch([
      () => ({
        ok: true,
        status: 200,
        body: {
          channelId: "msteams",
          connectionName: "GraphConnection",
          token: "delegated-graph-token",
          expiration: "2030-01-01T00:00:00Z",
        },
      }),
    ]);
    const { sso, tokenStore } = createSsoDeps({ fetchImpl });

    const result = await handleSigninTokenExchangeInvoke({
      value: { id: "flow-1", connectionName: "GraphConnection", token: "exchangeable-token" },
      user: { userId: "aad-user-guid", channelId: "msteams" },
      deps: sso,
    });

    expect(result).toEqual({
      ok: true,
      token: "delegated-graph-token",
      expiresAt: "2030-01-01T00:00:00Z",
    });
    expect(calls).toHaveLength(1);
    expect(calls[0]?.url).toContain("/api/usertoken/exchange");
    expect(calls[0]?.url).toContain("userId=aad-user-guid");
    expect(calls[0]?.url).toContain("connectionName=GraphConnection");
    expect(calls[0]?.url).toContain("channelId=msteams");

    const init = calls[0]?.init as {
      method?: string;
      headers?: Record<string, string>;
      body?: string;
    };
    expect(init?.method).toBe("POST");
    expect(init?.headers?.Authorization).toBe("Bearer bf-service-token");
    expect(JSON.parse(init?.body ?? "{}")).toEqual({ token: "exchangeable-token" });

    const stored = await tokenStore.get({
      connectionName: "GraphConnection",
      userId: "aad-user-guid",
    });
    expect(stored?.token).toBe("delegated-graph-token");
    expect(stored?.expiresAt).toBe("2030-01-01T00:00:00Z");
  });

  it("returns a service error when the User Token service rejects the exchange", async () => {
    const { fetchImpl } = createFakeFetch([
      () => ({ ok: false, status: 502, body: "bad gateway" }),
    ]);
    const { sso, tokenStore } = createSsoDeps({ fetchImpl });

    const result = await handleSigninTokenExchangeInvoke({
      value: { id: "flow-1", connectionName: "GraphConnection", token: "exchangeable-token" },
      user: { userId: "aad-user-guid", channelId: "msteams" },
      deps: sso,
    });

    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.code).toBe("service_error");
      expect(result.status).toBe(502);
      expect(result.message).toContain("bad gateway");
    }
    const stored = await tokenStore.get({
      connectionName: "GraphConnection",
      userId: "aad-user-guid",
    });
    expect(stored).toBeNull();
  });

  it("refuses to exchange without a user id", async () => {
    const { fetchImpl, calls } = createFakeFetch([]);
    const { sso } = createSsoDeps({ fetchImpl });

    const result = await handleSigninTokenExchangeInvoke({
      value: { id: "flow-1", connectionName: "GraphConnection", token: "exchangeable-token" },
      user: { userId: "", channelId: "msteams" },
      deps: sso,
    });
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.code).toBe("missing_user");
    }
    expect(calls).toHaveLength(0);
  });
});

describe("handleSigninVerifyStateInvoke", () => {
  it("fetches the user token for the magic code and persists it", async () => {
    const { fetchImpl, calls } = createFakeFetch([
      () => ({
        ok: true,
        status: 200,
        body: {
          channelId: "msteams",
          connectionName: "GraphConnection",
          token: "delegated-token-2",
          expiration: "2031-02-03T04:05:06Z",
        },
      }),
    ]);
    const { sso, tokenStore } = createSsoDeps({ fetchImpl });

    const result = await handleSigninVerifyStateInvoke({
      value: { state: "654321" },
      user: { userId: "aad-user-guid", channelId: "msteams" },
      deps: sso,
    });

    expect(result.ok).toBe(true);
    expect(calls[0]?.url).toContain("/api/usertoken/GetToken");
    expect(calls[0]?.url).toContain("code=654321");
    const init = calls[0]?.init as { method?: string };
    expect(init?.method).toBe("GET");

    const stored = await tokenStore.get({
      connectionName: "GraphConnection",
      userId: "aad-user-guid",
    });
    expect(stored?.token).toBe("delegated-token-2");
  });

  it("rejects invocations without a state code", async () => {
    const { fetchImpl, calls } = createFakeFetch([]);
    const { sso } = createSsoDeps({ fetchImpl });
    const result = await handleSigninVerifyStateInvoke({
      value: { state: "   " },
      user: { userId: "aad-user-guid", channelId: "msteams" },
      deps: sso,
    });
    expect(result.ok).toBe(false);
    if (!result.ok) {
      expect(result.code).toBe("missing_state");
    }
    expect(calls).toHaveLength(0);
  });
});

describe("msteams signin invoke handler registration", () => {
  beforeAll(() => {
    installTestRuntime();
  });

  const blockedSigninScenarios = createBlockedSigninScenarios();
  const invokeVariants = [
    {
      name: "signin/tokenExchange" as const,
      value: { id: "x", connectionName: "GraphConnection", token: "exchangeable" },
    },
    {
      name: "signin/verifyState" as const,
      value: { state: "112233" },
    },
  ];

  it("acks signin invokes even when sso is not configured", async () => {
    const deps = createDepsWithoutSso();
    const { handler, run } = createActivityHandler();
    const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
      run: NonNullable<MSTeamsActivityHandler["run"]>;
    };

    const ctx = createSigninInvokeContext({
      name: "signin/tokenExchange",
      value: { id: "x", connectionName: "Graph", token: "exchangeable" },
    });

    await registered.run(ctx);

    expect(ctx.sendActivity).toHaveBeenCalledWith(
      expect.objectContaining({
        type: "invokeResponse",
        value: expect.objectContaining({ status: 200 }),
      }),
    );
    expect(run).not.toHaveBeenCalled();
    expect(deps.log.debug).toHaveBeenCalledWith(
      "signin invoke received but msteams.sso is not configured",
      expect.objectContaining({ name: "signin/tokenExchange" }),
    );
  });

  for (const invoke of invokeVariants) {
    for (const scenario of blockedSigninScenarios) {
      it(`does not process ${invoke.name} for ${scenario.name}`, async () => {
        const { fetchImpl, calls } = createFakeFetch([
          () => ({
            ok: true,
            status: 200,
            body: {
              channelId: "msteams",
              connectionName: "GraphConnection",
              token: "delegated-graph-token",
              expiration: "2030-01-01T00:00:00Z",
            },
          }),
        ]);
        const { sso, tokenStore } = createSsoDeps({ fetchImpl });
        const deps = createDepsWithoutSso({ cfg: scenario.cfg, sso });
        const { handler } = createActivityHandler();
        const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
          run: NonNullable<MSTeamsActivityHandler["run"]>;
        };

        const ctx = createSigninInvokeContext({
          name: invoke.name,
          value: invoke.value,
          ...scenario.context,
        });

        await registered.run(ctx);

        expect(ctx.sendActivity).toHaveBeenCalledWith(
          expect.objectContaining({
            type: "invokeResponse",
            value: expect.objectContaining({ status: 200 }),
          }),
        );
        expect(calls).toHaveLength(0);
        const stored = await tokenStore.get({
          connectionName: "GraphConnection",
          userId: scenario.context.userAadId ?? "aad-user-guid",
        });
        expect(stored).toBeNull();
        expect(deps.log.debug).toHaveBeenCalledWith(
          scenario.expectedDropLog,
          expect.objectContaining({ name: invoke.name }),
        );
      });
    }
  }

  it("invokes the token exchange handler when sso is configured", async () => {
    const { fetchImpl } = createFakeFetch([
      () => ({
        ok: true,
        status: 200,
        body: {
          channelId: "msteams",
          connectionName: "GraphConnection",
          token: "delegated-graph-token",
          expiration: "2030-01-01T00:00:00Z",
        },
      }),
    ]);
    const { sso, tokenStore } = createSsoDeps({ fetchImpl });
    const deps = createDepsWithoutSso({ sso });
    const { handler } = createActivityHandler();
    const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
      run: NonNullable<MSTeamsActivityHandler["run"]>;
    };

    const ctx = createSigninInvokeContext({
      name: "signin/tokenExchange",
      value: { id: "x", connectionName: "GraphConnection", token: "exchangeable" },
    });

    await registered.run(ctx);

    expect(ctx.sendActivity).toHaveBeenCalledWith(
      expect.objectContaining({
        type: "invokeResponse",
        value: expect.objectContaining({ status: 200 }),
      }),
    );
    expect(deps.log.info).toHaveBeenCalledWith(
      "msteams sso token exchanged",
      expect.objectContaining({ userId: "aad-user-guid", hasExpiry: true }),
    );
    const stored = await tokenStore.get({
      connectionName: "GraphConnection",
      userId: "aad-user-guid",
    });
    expect(stored?.token).toBe("delegated-graph-token");
  });

  it("logs an error when the token exchange fails", async () => {
    const { fetchImpl } = createFakeFetch([
      () => ({ ok: false, status: 400, body: "bad request" }),
    ]);
    const { sso } = createSsoDeps({ fetchImpl });
    const deps = createDepsWithoutSso({ sso });
    const { handler } = createActivityHandler();
    const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
      run: NonNullable<MSTeamsActivityHandler["run"]>;
    };

    const ctx = createSigninInvokeContext({
      name: "signin/tokenExchange",
      value: { id: "x", connectionName: "GraphConnection", token: "exchangeable" },
    });

    await registered.run(ctx);

    expect(ctx.sendActivity).toHaveBeenCalledWith(
      expect.objectContaining({ type: "invokeResponse" }),
    );
    expect(deps.log.error).toHaveBeenCalledWith(
      "msteams sso token exchange failed",
      expect.objectContaining({ code: "unexpected_response", status: 400 }),
    );
  });

  it("handles signin/verifyState via the magic-code flow", async () => {
    const { fetchImpl } = createFakeFetch([
      () => ({
        ok: true,
        status: 200,
        body: {
          channelId: "msteams",
          connectionName: "GraphConnection",
          token: "delegated-token-3",
        },
      }),
    ]);
    const { sso, tokenStore } = createSsoDeps({ fetchImpl });
    const deps = createDepsWithoutSso({ sso });
    const { handler } = createActivityHandler();
    const registered = registerMSTeamsHandlers(handler, deps) as MSTeamsActivityHandler & {
      run: NonNullable<MSTeamsActivityHandler["run"]>;
    };

    const ctx = createSigninInvokeContext({
      name: "signin/verifyState",
      value: { state: "112233" },
    });

    await registered.run(ctx);

    expect(deps.log.info).toHaveBeenCalledWith(
      "msteams sso verifyState succeeded",
      expect.objectContaining({ userId: "aad-user-guid" }),
    );
    const stored = await tokenStore.get({
      connectionName: "GraphConnection",
      userId: "aad-user-guid",
    });
    expect(stored?.token).toBe("delegated-token-3");
  });
});
