/*
Copyright 2025 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/

import {
  afterEach,
  beforeEach,
  describe,
  expect,
  it,
  type MockedObject,
  vi,
} from "vitest";
import {
  type CallMembership,
  type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, filter, lastValueFrom } from "rxjs";
import fetchMock from "fetch-mock";

import {
  mockConfig,
  flushPromises,
  ownMemberMock,
  testScope,
} from "../../../utils/test";
import {
  createLocalTransport$,
  JwtEndpointVersion,
  type LocalTransportWithSFUConfig,
} from "./LocalTransport";
import { constant } from "../../Behavior";
import { Epoch, ObservableScope } from "../../ObservableScope";
import {
  MatrixRTCTransportMissingError,
  FailToGetOpenIdToken,
} from "../../../utils/errors";
import * as openIDSFU from "../../../livekit/openIDSFU";
import { customLivekitUrl } from "../../../settings/settings";
import { testJWTToken } from "../../../utils/test-fixtures";

describe("LocalTransport", () => {
  const openIdResponse: openIDSFU.SFUConfig = {
    url: "https://lk.example.org",
    jwt: testJWTToken,
    livekitAlias: "Akph4alDMhen",
    livekitIdentity: "@lk_user:ABCDEF",
  };

  beforeEach(() => vi.clearAllMocks());

  it("throws if config is missing", async () => {
    const { advertised$, active$ } = createLocalTransport$({
      scope: testScope(),
      roomId: "!room:example.org",
      memberships$: constant(new Epoch<CallMembership[]>([])),
      client: {
        // eslint-disable-next-line @typescript-eslint/naming-convention
        _unstable_getRTCTransports: async () => Promise.resolve([]),
        getDomain: () => "example.org",
        baseUrl: "example.org",
        // These won't be called in this error path but satisfy the type
        getOpenIdToken: vi.fn(),
        getDeviceId: vi.fn(),
      },
      ownMembershipIdentity: ownMemberMock,
      forceJwtEndpoint: JwtEndpointVersion.Legacy,
      delayId$: constant("delay_id_mock"),
    });
    await flushPromises();

    expect(() => advertised$.value).toThrow(
      new MatrixRTCTransportMissingError("example.org"),
    );
    expect(() => active$.value).toThrow(
      new MatrixRTCTransportMissingError("example.org"),
    );
  });

  it("throws FailToGetOpenIdToken when OpenID fetch fails", async () => {
    // Provide a valid config so makeTransportInternal resolves a transport
    const scope = new ObservableScope();
    mockConfig({
      livekit: { livekit_service_url: "https://lk.example.org" },
    });
    const resolver = Promise.withResolvers<void>();
    vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockImplementation(
      async () => {
        await resolver.promise;
        throw new FailToGetOpenIdToken(new Error("no openid"));
      },
    );
    const observations: unknown[] = [];
    const errors: Error[] = [];
    const { advertised$, active$ } = createLocalTransport$({
      scope,
      roomId: "!example_room_id",
      memberships$: constant(new Epoch<CallMembership[]>([])),
      client: {
        baseUrl: "https://example.org",
        getDomain: () => "example.org",
        // eslint-disable-next-line @typescript-eslint/naming-convention
        _unstable_getRTCTransports: async () => Promise.resolve([]),
        getOpenIdToken: vi.fn(),
        getDeviceId: vi.fn(),
      },
      ownMembershipIdentity: ownMemberMock,
      forceJwtEndpoint: JwtEndpointVersion.Legacy,
      delayId$: constant("delay_id_mock"),
    });
    active$.subscribe(
      (o) => observations.push(o),
      (e) => errors.push(e),
    );
    resolver.resolve();
    await flushPromises();

    const expectedError = new FailToGetOpenIdToken(new Error("no openid"));
    expect(observations).toStrictEqual([null]);
    expect(errors).toStrictEqual([expectedError]);
    expect(() => advertised$.value).toThrow(expectedError);
    expect(() => active$.value).toThrow(expectedError);
  });

  it("emits preferred transport after OpenID resolves", async () => {
    // Use config so transport discovery succeeds, but delay OpenID JWT fetch
    mockConfig({
      livekit: { livekit_service_url: "https://lk.example.org" },
    });

    const openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();

    vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
      openIdResolver.promise,
    );

    const { advertised$, active$ } = createLocalTransport$({
      scope: testScope(),
      roomId: "!room:example.org",
      memberships$: constant(new Epoch<CallMembership[]>([])),
      client: {
        // eslint-disable-next-line @typescript-eslint/naming-convention
        _unstable_getRTCTransports: async () => Promise.resolve([]),
        getDomain: () => "example.org",
        getOpenIdToken: vi.fn(),
        getDeviceId: vi.fn(),
        baseUrl: "https://example.org",
      },
      ownMembershipIdentity: ownMemberMock,
      forceJwtEndpoint: JwtEndpointVersion.Legacy,
      delayId$: constant("delay_id_mock"),
    });

    openIdResolver.resolve?.({
      url: "https://lk.example.org",
      jwt: "jwt",
      livekitAlias: "Akph4alDMhen",
      livekitIdentity: ownMemberMock.userId + ":" + ownMemberMock.deviceId,
    });
    expect(advertised$.value).toBe(null);
    expect(active$.value).toBe(null);
    await flushPromises();
    // final
    const expectedTransport = {
      livekit_service_url: "https://lk.example.org",
      type: "livekit",
    };
    expect(advertised$.value).toStrictEqual(expectedTransport);
    expect(active$.value).toStrictEqual({
      transport: expectedTransport,
      sfuConfig: {
        jwt: "jwt",
        livekitAlias: "Akph4alDMhen",
        livekitIdentity: "@alice:example.org:DEVICE",
        url: "https://lk.example.org",
      },
    });
  });

  type LocalTransportProps = Parameters<typeof createLocalTransport$>[0];

  describe("transport configuration mechanisms", () => {
    let localTransportOpts: LocalTransportProps & {
      client: MockedObject<LocalTransportProps["client"]>;
    };
    let openIdResolver: PromiseWithResolvers<openIDSFU.SFUConfig>;
    beforeEach(() => {
      mockConfig({});
      customLivekitUrl.setValue(customLivekitUrl.defaultValue);
      localTransportOpts = {
        ownMembershipIdentity: ownMemberMock,
        scope: testScope(),
        roomId: "!example_room_id",
        forceJwtEndpoint: JwtEndpointVersion.Legacy,
        delayId$: constant(null),
        memberships$: constant(new Epoch<CallMembership[]>([])),
        client: {
          baseUrl: "https://example.org",
          getDomain: vi.fn().mockReturnValue("example.org"),
          // eslint-disable-next-line @typescript-eslint/naming-convention
          _unstable_getRTCTransports: vi.fn().mockResolvedValue([]),
          getOpenIdToken: vi.fn(),
          getDeviceId: vi.fn(),
        },
      };
      openIdResolver = Promise.withResolvers<openIDSFU.SFUConfig>();
      vi.spyOn(openIDSFU, "getSFUConfigWithOpenID").mockReturnValue(
        openIdResolver.promise,
      );
    });

    afterEach(() => {
      fetchMock.reset();
    });

    it("supports getting transport via application config", async () => {
      mockConfig({
        livekit: { livekit_service_url: "https://lk.example.org" },
      });
      const { advertised$, active$ } =
        createLocalTransport$(localTransportOpts);
      openIdResolver.resolve?.(openIdResponse);
      expect(advertised$.value).toBe(null);
      expect(active$.value).toBe(null);
      await flushPromises();
      const expectedTransport = {
        livekit_service_url: "https://lk.example.org",
        type: "livekit",
      };
      expect(advertised$.value).toStrictEqual(expectedTransport);
      expect(active$.value).toStrictEqual({
        transport: expectedTransport,
        sfuConfig: {
          jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
          livekitAlias: "Akph4alDMhen",
          livekitIdentity: "@lk_user:ABCDEF",
          url: "https://lk.example.org",
        },
      });
    });

    it("supports getting transport via user settings", async () => {
      customLivekitUrl.setValue("https://lk.example.org");
      const { advertised$, active$ } =
        createLocalTransport$(localTransportOpts);
      openIdResolver.resolve?.(openIdResponse);
      expect(advertised$.value).toBe(null);
      await flushPromises();
      expect(active$.value).toStrictEqual({
        transport: {
          livekit_service_url: "https://lk.example.org",
          type: "livekit",
        },
        sfuConfig: {
          jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
          livekitAlias: "Akph4alDMhen",
          livekitIdentity: "@lk_user:ABCDEF",
          url: "https://lk.example.org",
        },
      });
    });

    it("supports getting transport via backend", async () => {
      localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
        { type: "livekit", livekit_service_url: "https://lk.example.org" },
      ]);
      const { advertised$, active$ } =
        createLocalTransport$(localTransportOpts);
      openIdResolver.resolve?.(openIdResponse);
      expect(advertised$.value).toBe(null);
      expect(active$.value).toBe(null);
      await flushPromises();
      const expectedTransport = {
        livekit_service_url: "https://lk.example.org",
        type: "livekit",
      };
      expect(advertised$.value).toStrictEqual(expectedTransport);
      expect(active$.value).toStrictEqual({
        transport: expectedTransport,
        sfuConfig: {
          jwt: "e30=.eyJzdWIiOiJAbWU6ZXhhbXBsZS5vcmc6QUJDREVGIiwidmlkZW8iOnsicm9vbSI6IiFleGFtcGxlX3Jvb21faWQifX0=.e30=",
          livekitAlias: "Akph4alDMhen",
          livekitIdentity: "@lk_user:ABCDEF",
          url: "https://lk.example.org",
        },
      });
    });

    it("fails fast if the openID request fails for backend config", async () => {
      localTransportOpts.client._unstable_getRTCTransports.mockResolvedValue([
        { type: "livekit", livekit_service_url: "https://lk.example.org" },
      ]);
      openIdResolver.reject(
        new FailToGetOpenIdToken(new Error("Test driven error")),
      );
      await expect(async () =>
        lastValueFrom(createLocalTransport$(localTransportOpts).active$),
      ).rejects.toThrow(expect.any(FailToGetOpenIdToken));
    });

    it("throws if no options are available", async () => {
      const { advertised$, active$ } = createLocalTransport$({
        scope: testScope(),
        ownMembershipIdentity: ownMemberMock,
        roomId: "!example_room_id",
        forceJwtEndpoint: JwtEndpointVersion.Legacy,
        delayId$: constant(null),
        memberships$: constant(new Epoch<CallMembership[]>([])),
        client: {
          getDomain: () => "example.org",
          baseUrl: "https://example.org",
          // eslint-disable-next-line @typescript-eslint/naming-convention
          _unstable_getRTCTransports: async () => Promise.resolve([]),
          // These won't be called in this error path but satisfy the type
          getOpenIdToken: vi.fn(),
          getDeviceId: vi.fn(),
        },
      });
      await flushPromises();

      expect(() => advertised$.value).toThrow(
        new MatrixRTCTransportMissingError("example.org"),
      );
      expect(() => active$.value).toThrow(
        new MatrixRTCTransportMissingError("example.org"),
      );
    });
  });

  it("should not update advertised/active transport on delayID changes, but delay Id delegation should be called", async () => {
    // For simplicity, we'll just use the config livekit
    customLivekitUrl.setValue("https://lk.example.org");

    const authCallSpy = vi
      .spyOn(openIDSFU, "getSFUConfigWithOpenID")
      .mockResolvedValue(openIdResponse);

    const delayId$ = new BehaviorSubject<string | null>(null);

    const { advertised$, active$ } = createLocalTransport$({
      scope: testScope(),
      ownMembershipIdentity: ownMemberMock,
      roomId: "!example_room_id",
      // We want multi-sdu
      forceJwtEndpoint: JwtEndpointVersion.Legacy,
      delayId$: delayId$,
      memberships$: constant(new Epoch<CallMembership[]>([])),
      client: {
        getDomain: () => "example.org",
        baseUrl: "https://example.org",
        // eslint-disable-next-line @typescript-eslint/naming-convention
        _unstable_getRTCTransports: async () => Promise.resolve([]),
        // These won't be called in this error path but satisfy the type
        getOpenIdToken: vi.fn(),
        getDeviceId: vi.fn(),
      },
    });

    const advertisedValues: LivekitTransportConfig[] = [];
    const activeValues: LocalTransportWithSFUConfig[] = [];
    advertised$
      .pipe(filter((v) => v !== null))
      .subscribe((t) => advertisedValues.push(t));
    active$
      .pipe(filter((v) => v !== null))
      .subscribe((t) => activeValues.push(t));

    await flushPromises();

    // we have now an active and an advertised
    expect(advertisedValues.length).toEqual(1);
    expect(activeValues.length).toEqual(1);
    expect(advertisedValues[0]!.livekit_service_url).toEqual(
      "https://lk.example.org",
    );
    expect(activeValues[0]!.transport.livekit_service_url).toEqual(
      "https://lk.example.org",
    );

    expect(authCallSpy).toHaveBeenCalledTimes(2);
    // Now emits 3 new delays id
    delayId$.next("delay_id_1");
    await flushPromises();
    delayId$.next("delay_id_2");
    await flushPromises();
    delayId$.next("delay_id_3");
    await flushPromises();

    // No new emissions should've happened, it is the same transport.
    expect(advertisedValues.length).toEqual(1);
    expect(activeValues.length).toEqual(1);

    // Still we should have updated the delayID to auth
    expect(authCallSpy).toHaveBeenCalledTimes(
      4 * 2 /* 2 calls for each delayId ?? why */,
    );

    expect(authCallSpy).toHaveBeenLastCalledWith(
      expect.anything(),
      expect.anything(),
      expect.anything(),
      expect.anything(),
      expect.objectContaining({
        delayId: "delay_id_3",
      }),
      expect.anything(),
    );
  });
});
