// @vitest-environment jsdom

import { afterEach, describe, expect, it, vi } from "vitest";
import {
  AssistantCloudEvents,
  clearPendingAssistantCloudEvents,
  type AssistantCloudEvent,
} from "./AssistantCloudEvents";
import { AssistantCloudAPI } from "./AssistantCloudAPI";

const createAccessToken = (subject: string) =>
  `${Buffer.from(JSON.stringify({ alg: "none" })).toString("base64url")}.${Buffer.from(JSON.stringify({ exp: 4102444800, sub: subject })).toString("base64url")}.sig`;

const event = (index: number): AssistantCloudEvent => ({
  kind: "message_sent",
  thread_id: `thread-${index}`,
});

const createEvents = (enabled: boolean | (() => boolean) = true) => {
  const makeRequest = vi.fn().mockResolvedValue({ accepted: 1 });
  const events = new AssistantCloudEvents(
    { makeRequest } as unknown as AssistantCloudAPI,
    typeof enabled === "function" ? enabled : () => enabled,
  );
  return { events, makeRequest };
};

afterEach(() => {
  vi.useRealTimers();
  vi.restoreAllMocks();
  vi.unstubAllGlobals();
  Object.defineProperty(document, "visibilityState", {
    configurable: true,
    value: "visible",
  });
});

describe("AssistantCloudEvents", () => {
  it("flushes twenty events in one keepalive request", async () => {
    const { events, makeRequest } = createEvents();

    for (let index = 0; index < 20; index++) events.track(event(index));

    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());
    expect(makeRequest).toHaveBeenCalledWith("/events", {
      method: "POST",
      body: { events: Array.from({ length: 20 }, (_, index) => event(index)) },
      keepalive: true,
    });
  });

  it("keeps its page listeners only while events are buffered", async () => {
    const add = vi.spyOn(window, "addEventListener");
    const remove = vi.spyOn(window, "removeEventListener");
    const isEnabled = vi.fn(() => true);
    const { events, makeRequest } = createEvents(isEnabled);

    for (let index = 0; index < 20; index++) events.track(event(index));
    expect(add).toHaveBeenCalledWith("pagehide", expect.any(Function));

    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());
    await vi.waitFor(() =>
      expect(remove).toHaveBeenCalledWith("pagehide", expect.any(Function)),
    );

    isEnabled.mockClear();
    window.dispatchEvent(new Event("pagehide"));
    Object.defineProperty(document, "visibilityState", {
      configurable: true,
      value: "hidden",
    });
    document.dispatchEvent(new Event("visibilitychange"));
    expect(isEnabled).not.toHaveBeenCalled();
  });

  it("dispose removes the listeners and flushes what is buffered", async () => {
    const remove = vi.spyOn(document, "removeEventListener");
    const isEnabled = vi.fn(() => true);
    const { events, makeRequest } = createEvents(isEnabled);

    events.track(event(1));
    events.dispose();

    expect(remove).toHaveBeenCalledWith(
      "visibilitychange",
      expect.any(Function),
    );
    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());

    isEnabled.mockClear();
    window.dispatchEvent(new Event("pagehide"));
    Object.defineProperty(document, "visibilityState", {
      configurable: true,
      value: "hidden",
    });
    document.dispatchEvent(new Event("visibilitychange"));
    expect(isEnabled).not.toHaveBeenCalled();
  });

  it("uses cached authentication for a pagehide flush", async () => {
    const providerToken = createAccessToken("user-a");
    const internalToken = createAccessToken("internal-user-a");
    const authToken = vi.fn(async () => providerToken);
    const fetchMock = vi
      .fn<typeof fetch>()
      .mockResolvedValueOnce({
        ok: true,
        headers: new Headers({ Authorization: `Bearer ${internalToken}` }),
      } as Response)
      .mockResolvedValueOnce({
        ok: true,
        status: 204,
        headers: new Headers(),
      } as Response);
    vi.stubGlobal("fetch", fetchMock);
    const api = new AssistantCloudAPI({
      baseUrl: "https://test.example.com",
      authToken,
    });
    const events = new AssistantCloudEvents(api, () => true);

    await api.makeRawRequest("/threads");
    events.track(event(1));
    window.dispatchEvent(new Event("pagehide"));

    await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
    expect(authToken).toHaveBeenCalledOnce();
    expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
      keepalive: true,
      headers: expect.objectContaining({
        Authorization: `Bearer ${internalToken}`,
      }),
    });
  });

  it("flushes buffered events after two seconds", async () => {
    vi.useFakeTimers();
    const { events, makeRequest } = createEvents();

    events.track(event(1));
    await vi.advanceTimersByTimeAsync(1_999);
    expect(makeRequest).not.toHaveBeenCalled();

    await vi.advanceTimersByTimeAsync(1);
    expect(makeRequest).toHaveBeenCalledOnce();
  });

  it("retries a failed event batch with bounded backoff", async () => {
    vi.useFakeTimers();
    const { events, makeRequest } = createEvents();
    makeRequest.mockRejectedValueOnce(new Error("temporary failure"));

    for (let index = 0; index < 20; index++) events.track(event(index));
    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());

    await vi.advanceTimersByTimeAsync(249);
    expect(makeRequest).toHaveBeenCalledOnce();
    await vi.advanceTimersByTimeAsync(1);

    expect(makeRequest).toHaveBeenCalledTimes(2);
    expect(makeRequest.mock.calls[1]).toEqual(makeRequest.mock.calls[0]);
  });

  it("does not retry a cleared batch", async () => {
    vi.useFakeTimers();
    const { events, makeRequest } = createEvents();
    makeRequest.mockRejectedValueOnce(new Error("temporary failure"));

    for (let index = 0; index < 20; index++) events.track(event(index));
    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());

    clearPendingAssistantCloudEvents(events);
    await vi.runAllTimersAsync();

    expect(makeRequest).toHaveBeenCalledOnce();
  });

  it("does not let an invalidated flush consume new events", async () => {
    vi.useFakeTimers();
    let resolveFirstRequest!: (value: { accepted: number }) => void;
    const { events, makeRequest } = createEvents();
    makeRequest
      .mockImplementationOnce(
        () =>
          new Promise((resolve) => {
            resolveFirstRequest = resolve;
          }),
      )
      .mockRejectedValueOnce(new Error("temporary failure"));

    for (let index = 0; index < 20; index++) events.track(event(index));
    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());

    window.dispatchEvent(new Event("pagehide"));
    clearPendingAssistantCloudEvents(events);
    events.track(event(20));
    resolveFirstRequest({ accepted: 20 });

    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledTimes(2));
    await vi.advanceTimersByTimeAsync(250);

    expect(makeRequest).toHaveBeenCalledTimes(3);
    expect(makeRequest.mock.calls[1]?.[1].body.events).toEqual([event(20)]);
    expect(makeRequest.mock.calls[2]).toEqual(makeRequest.mock.calls[1]);
  });

  it("stops retrying an event batch after three attempts", async () => {
    vi.useFakeTimers();
    const { events, makeRequest } = createEvents();
    makeRequest.mockRejectedValue(new Error("persistent failure"));

    for (let index = 0; index < 20; index++) events.track(event(index));
    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());

    await vi.advanceTimersByTimeAsync(250);
    expect(makeRequest).toHaveBeenCalledTimes(2);
    await vi.advanceTimersByTimeAsync(999);
    expect(makeRequest).toHaveBeenCalledTimes(2);
    await vi.advanceTimersByTimeAsync(1);
    expect(makeRequest).toHaveBeenCalledTimes(3);
    await vi.runAllTimersAsync();

    expect(makeRequest).toHaveBeenCalledTimes(3);
  });

  it("keeps dispose delivery best effort", async () => {
    vi.useFakeTimers();
    const { events, makeRequest } = createEvents();
    makeRequest.mockRejectedValue(new Error("offline"));

    events.track(event(1));
    events.dispose();
    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());
    await vi.runAllTimersAsync();

    expect(makeRequest).toHaveBeenCalledOnce();
  });

  it.each(["dispose", "pagehide"] as const)(
    "interrupts retry backoff on %s and drains buffered events once",
    async (trigger) => {
      vi.useFakeTimers();
      const { events, makeRequest } = createEvents();
      makeRequest.mockRejectedValue(new Error("offline"));

      for (let index = 0; index < 20; index++) events.track(event(index));
      await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());
      events.track(event(20));

      if (trigger === "dispose") {
        events.dispose();
      } else {
        window.dispatchEvent(new Event("pagehide"));
      }
      await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledTimes(2));
      await vi.runAllTimersAsync();

      expect(makeRequest).toHaveBeenCalledTimes(2);
      expect(makeRequest.mock.calls[1]?.[1].body.events).toEqual([event(20)]);
    },
  );

  it("flushes when the document becomes hidden", async () => {
    const { events, makeRequest } = createEvents();
    events.track(event(1));

    Object.defineProperty(document, "visibilityState", {
      configurable: true,
      value: "hidden",
    });
    document.dispatchEvent(new Event("visibilitychange"));

    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());
  });

  it("does not send events when telemetry is disabled", async () => {
    vi.useFakeTimers();
    const { events, makeRequest } = createEvents(false);
    events.track(event(1));

    await vi.advanceTimersByTimeAsync(2_000);
    expect(makeRequest).not.toHaveBeenCalled();
  });

  it("does not send queued batches after telemetry is disabled", async () => {
    let enabled = true;
    let resolveFirstRequest!: (value: { accepted: number }) => void;
    const { events, makeRequest } = createEvents(() => enabled);
    makeRequest.mockImplementationOnce(
      () =>
        new Promise((resolve) => {
          resolveFirstRequest = resolve;
        }),
    );

    for (let index = 0; index < 51; index++) events.track(event(index));
    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());

    enabled = false;
    resolveFirstRequest({ accepted: 50 });

    await vi.waitFor(() =>
      expect(makeRequest.mock.results[0]?.value).resolves.toEqual({
        accepted: 50,
      }),
    );
    expect(makeRequest).toHaveBeenCalledOnce();
  });

  it("keeps every request below the server batch limit", async () => {
    const { events, makeRequest } = createEvents();

    for (let index = 0; index < 51; index++) events.track(event(index));

    const sent = () =>
      makeRequest.mock.calls.reduce(
        (count, [, options]) => count + options.body.events.length,
        0,
      );
    await vi.waitFor(() => expect(sent()).toBe(51));
    for (const [, options] of makeRequest.mock.calls) {
      expect(options.body.events.length).toBeLessThanOrEqual(50);
    }
  });

  it.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])(
    "drops props containing the non-finite number %s",
    async (value) => {
      const { events, makeRequest } = createEvents();

      events.track({ kind: "message_sent", props: { value } });
      events.dispose();

      await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());
      expect(makeRequest).toHaveBeenCalledWith("/events", {
        method: "POST",
        body: { events: [{ kind: "message_sent" }] },
        keepalive: true,
      });
    },
  );

  it("keeps finite numeric props", async () => {
    const { events, makeRequest } = createEvents();

    events.track({
      kind: "message_sent",
      props: { negative: -1.5, zero: 0, positive: 2.5 },
    });
    events.dispose();

    await vi.waitFor(() => expect(makeRequest).toHaveBeenCalledOnce());
    expect(makeRequest).toHaveBeenCalledWith("/events", {
      method: "POST",
      body: {
        events: [
          {
            kind: "message_sent",
            props: { negative: -1.5, zero: 0, positive: 2.5 },
          },
        ],
      },
      keepalive: true,
    });
  });
});
