/*
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, vi } from "vitest";
import {
  type MatrixEvent,
  type RoomMember,
  type RoomState,
  RoomStateEvent,
} from "matrix-js-sdk";
import EventEmitter from "events";
import { it } from "vitest";

import { ObservableScope } from "../../ObservableScope.ts";
import type { Room as MatrixRoom } from "matrix-js-sdk/lib/models/room";
import {
  mockRtcMembership,
  mockMatrixRoomMember,
  withTestScheduler,
} from "../../../utils/test.ts";
import {
  createMatrixMemberMetadata$,
  createRoomMembers$,
} from "./MatrixMemberMetadata.ts";
let testScope: ObservableScope;
let mockMatrixRoom: MatrixRoom;

describe("MatrixMemberMetadata", () => {
  /*
   * To be populated in the test setup.
   * Maps userId to a partial/mock RoomMember object.
   */
  let fakeMembersMap: Map<string, Partial<RoomMember>>;

  beforeEach(() => {
    testScope = new ObservableScope();
    fakeMembersMap = new Map<string, Partial<RoomMember>>();

    const roomEmitter = new EventEmitter();
    mockMatrixRoom = {
      on: roomEmitter.on.bind(roomEmitter),
      off: roomEmitter.off.bind(roomEmitter),
      emit: roomEmitter.emit.bind(roomEmitter),
      // addListener: roomEmitter.addListener.bind(roomEmitter),
      // removeListener: roomEmitter.removeListener.bind(roomEmitter),
      getMember: vi.fn().mockImplementation((userId: string) => {
        const member = fakeMembersMap.get(userId);
        if (member) {
          return member as RoomMember;
        }
        return null;
      }),
      getMembers: vi.fn().mockImplementation(() => {
        const members = Array.from(fakeMembersMap.values());
        return members;
      }),
      getMembersWithMembership: vi.fn().mockImplementation(() => {
        const members = Array.from(fakeMembersMap.values());
        return members;
      }),
    } as unknown as MatrixRoom;
  });

  function fakeMemberWith(data: Partial<RoomMember>): void {
    const userId = data.userId || "@alice:example.com";
    const member: Partial<RoomMember> = {
      userId: userId,
      rawDisplayName: data.rawDisplayName ?? userId,
      getMxcAvatarUrl:
        data.getMxcAvatarUrl ||
        vi.fn().mockImplementation(() => {
          return `mxc://example.com/${userId}`;
        }),
      ...data,
    } as unknown as RoomMember;
    fakeMembersMap.set(userId, member);
  }

  afterEach(() => {
    fakeMembersMap.clear();
  });

  describe("displayname", () => {
    function updateDisplayName(
      userId: `@${string}:${string}`,
      newDisplayName: string,
    ): void {
      const member = fakeMembersMap.get(userId);
      if (member) {
        member.rawDisplayName = newDisplayName;
        // Emit the event to notify listeners
        mockMatrixRoom.emit(
          RoomStateEvent.Members,
          {} as unknown as MatrixEvent,
          {} as unknown as RoomState,
          member as RoomMember,
        );
      } else {
        throw new Error(`No member found with userId: ${userId}`);
      }
    }

    it("should show our own user if present in rtc session and room", () => {
      withTestScheduler(({ scope, behavior, expectObservable }) => {
        fakeMemberWith({
          userId: "@local:example.com",
          rawDisplayName: "it's a me",
        });
        const memberships$ = behavior("a", {
          a: [mockRtcMembership("@local:example.com", "DEVICE1")],
        });
        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );
        const dn$ = metadataStore.createDisplayNameBehavior$(
          scope,
          "@local:example.com",
        );

        expectObservable(dn$).toBe("a", {
          a: "it's a me",
        });
        expectObservable(metadataStore.displaynameMap$).toBe("a", {
          a: new Map<string, string>([["@local:example.com", "it's a me"]]),
        });
      });
    });

    function setUpBasicRoom(): void {
      fakeMemberWith({
        userId: "@local:example.com",
        rawDisplayName: "it's a me",
      });
      fakeMemberWith({ userId: "@alice:example.com", rawDisplayName: "Alice" });
      fakeMemberWith({ userId: "@bob:example.com", rawDisplayName: "Bob" });
      fakeMemberWith({ userId: "@carl:example.com", rawDisplayName: "Carl" });
      fakeMemberWith({ userId: "@evil:example.com", rawDisplayName: "Carl" });
      fakeMemberWith({ userId: "@bob:foo.bar", rawDisplayName: "Bob" });
      fakeMemberWith({ userId: "@no-name:foo.bar" });
    }

    it("should get displayName for users", () => {
      setUpBasicRoom();

      withTestScheduler(({ scope, behavior, expectObservable }) => {
        const memberships$ = behavior("a", {
          a: [
            mockRtcMembership("@alice:example.com", "DEVICE1"),
            mockRtcMembership("@bob:example.com", "DEVICE1"),
          ],
        });
        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );
        const aliceDispName$ = metadataStore.createDisplayNameBehavior$(
          scope,
          "@alice:example.com",
        );

        expectObservable(aliceDispName$).toBe("a", {
          a: "Alice",
        });

        expectObservable(metadataStore.displaynameMap$).toBe("a", {
          a: new Map<string, string>([
            ["@alice:example.com", "Alice"],
            ["@bob:example.com", "Bob"],
          ]),
        });
      });
    });

    it("should use userId if no display name", () => {
      withTestScheduler(({ behavior, expectObservable }) => {
        setUpBasicRoom();

        const memberships$ = behavior("a", {
          a: [mockRtcMembership("@no-name:foo.bar", "D000")],
        });
        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );

        expectObservable(metadataStore.displaynameMap$).toBe("a", {
          a: new Map<string, string>([
            ["@no-name:foo.bar", "@no-name:foo.bar"],
          ]),
        });
      });
    });

    it("should disambiguate users with same display name", () => {
      withTestScheduler(({ behavior, expectObservable }) => {
        setUpBasicRoom();

        const memberships$ = behavior("a", {
          a: [
            mockRtcMembership("@bob:example.com", "DEVICE1"),
            mockRtcMembership("@bob:example.com", "DEVICE2"),
            mockRtcMembership("@bob:foo.bar", "BOB000"),
            mockRtcMembership("@carl:example.com", "C000"),
            mockRtcMembership("@evil:example.com", "E000"),
          ],
        });

        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );

        expectObservable(metadataStore.displaynameMap$).toBe("a", {
          a: new Map<string, string>([
            // ["@local:example.com", "it's a me"],
            ["@bob:example.com", "Bob (@bob:example.com)"],
            ["@bob:example.com", "Bob (@bob:example.com)"],
            ["@bob:foo.bar", "Bob (@bob:foo.bar)"],
            ["@carl:example.com", "Carl (@carl:example.com)"],
            ["@evil:example.com", "Carl (@evil:example.com)"],
          ]),
        });
      });
    });

    it("should start to disambiguate reactivly when needed", () => {
      withTestScheduler(({ behavior, expectObservable }) => {
        setUpBasicRoom();

        const memberships$ = behavior("ab", {
          a: [mockRtcMembership("@bob:example.com", "DEVICE1")],
          b: [
            mockRtcMembership("@bob:example.com", "DEVICE1"),
            mockRtcMembership("@bob:foo.bar", "BOB000"),
          ],
        });

        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );

        expectObservable(metadataStore.displaynameMap$).toBe("ab", {
          a: new Map<string, string>([["@bob:example.com", "Bob"]]),
          b: new Map<string, string>([
            ["@bob:example.com", "Bob (@bob:example.com)"],
            ["@bob:foo.bar", "Bob (@bob:foo.bar)"],
          ]),
        });
      });
    });

    it("should keep disambiguated name when other leave", () => {
      withTestScheduler(({ behavior, expectObservable }) => {
        setUpBasicRoom();

        const memberships$ = behavior("ab", {
          a: [
            mockRtcMembership("@bob:example.com", "DEVICE1"),
            mockRtcMembership("@bob:foo.bar", "BOB000"),
          ],
          b: [mockRtcMembership("@bob:example.com", "DEVICE1")],
        });

        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );

        expectObservable(metadataStore.displaynameMap$).toBe("ab", {
          a: new Map<string, string>([
            ["@bob:example.com", "Bob (@bob:example.com)"],
            ["@bob:foo.bar", "Bob (@bob:foo.bar)"],
          ]),
          b: new Map<string, string>([
            ["@bob:example.com", "Bob (@bob:example.com)"],
          ]),
        });
      });
    });

    it("should disambiguate on name change", () => {
      withTestScheduler(({ behavior, schedule, expectObservable }) => {
        setUpBasicRoom();

        const memberships$ = behavior("a", {
          a: [
            mockRtcMembership("@bob:example.com", "B000"),
            mockRtcMembership("@carl:example.com", "C000"),
          ],
        });
        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );

        schedule("-a", {
          a: () => {
            updateDisplayName("@carl:example.com", "Bob");
          },
        });

        expectObservable(metadataStore.displaynameMap$).toBe("ab", {
          a: new Map<string, string>([
            ["@bob:example.com", "Bob"],
            ["@carl:example.com", "Carl"],
          ]),
          b: new Map<string, string>([
            ["@bob:example.com", "Bob (@bob:example.com)"],
            ["@carl:example.com", "Bob (@carl:example.com)"],
          ]),
        });
      });
    });

    it("should track individual member id with createDisplayNameBehavior", () => {
      withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
        setUpBasicRoom();
        const BOB = "@bob:example.com";
        const CARL = "@carl:example.com";
        // for this test we build a mock environment that does all possible changes:
        // - memberships join/leave
        // - room join/leave
        // - disambiguate
        const memberships$ = behavior("ab-d", {
          a: [mockRtcMembership(CARL, "C000")],
          b: [
            mockRtcMembership(CARL, "C000"),
            // bob joins
            mockRtcMembership(BOB, "B000"),
          ],
          // c carl gets renamed to BOB
          d: [
            // carl leaves
            mockRtcMembership(BOB, "B000"),
          ],
        });
        schedule("--a-", {
          a: () => {
            // carl renames
            updateDisplayName(CARL, "Bob");
          },
        });

        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );

        const bob$ = metadataStore.createDisplayNameBehavior$(scope, BOB);
        const carl$ = metadataStore.createDisplayNameBehavior$(scope, CARL);

        expectObservable(bob$).toBe("abc-", {
          a: undefined,
          b: "Bob",
          c: "Bob (@bob:example.com)",
          // bob stays disambiguate even though carl left
          // d: "Bob (@bob:example.com)",
        });

        expectObservable(carl$).toBe("a-cd", {
          a: "Carl",
          // b: "Carl",
          // carl gets renamed and disambiguate
          c: "Bob (@carl:example.com)",
          d: undefined,
        });
      });
    });

    it("should disambiguate users with invisible characters", () => {
      withTestScheduler(({ scope, behavior, expectObservable }) => {
        const bobRtcMember = mockRtcMembership("@bob:example.org", "BBBB");
        const bobZeroWidthSpaceRtcMember = mockRtcMembership(
          "@bob2:example.org",
          "BBBB",
        );
        const bob = mockMatrixRoomMember(bobRtcMember, {
          rawDisplayName: "Bob",
        });
        const bobZeroWidthSpace = mockMatrixRoomMember(
          bobZeroWidthSpaceRtcMember,
          {
            rawDisplayName: "Bo\u200bb",
          },
        );
        fakeMemberWith(bob);
        fakeMemberWith(bobZeroWidthSpace);
        fakeMemberWith({ userId: "@carol:example.org" });
        const memberships$ = behavior("ab", {
          a: [mockRtcMembership("@carol:example.org", "1111"), bobRtcMember],
          b: [
            mockRtcMembership("@carol:example.org", "1111"),
            bobRtcMember,
            bobZeroWidthSpaceRtcMember,
          ],
        });

        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );

        const bob$ = metadataStore.createDisplayNameBehavior$(
          scope,
          "@bob:example.org",
        );
        const bob2$ = metadataStore.createDisplayNameBehavior$(
          scope,
          "@bob2:example.org",
        );
        const carol$ = metadataStore.createDisplayNameBehavior$(
          scope,
          "@carol:example.org",
        );
        expectObservable(bob$).toBe("ab", {
          a: "Bob",
          b: "Bob (@bob:example.org)",
        });
        expectObservable(bob2$).toBe("ab", {
          a: undefined,
          b: "Bo\u200bb (@bob2:example.org)",
        });
        expectObservable(carol$).toBe("a-", {
          a: "@carol:example.org",
        });

        expectObservable(metadataStore.displaynameMap$).toBe("ab", {
          // Carol has no displayname - So userId is used.
          a: new Map([
            ["@carol:example.org", "@carol:example.org"],
            ["@bob:example.org", "Bob"],
          ]),
          // Other Bob joins, and should handle zero width hacks.
          b: new Map([
            ["@carol:example.org", "@carol:example.org"],
            [bobRtcMember.userId, `Bob (@bob:example.org)`],
            [
              bobZeroWidthSpace.userId,
              `${bobZeroWidthSpace.rawDisplayName} (${bobZeroWidthSpace.userId})`,
            ],
          ]),
        });
      });
    });

    it("should strip RTL characters from displayname", () => {
      withTestScheduler(({ behavior, expectObservable }) => {
        const daveRtcMember = mockRtcMembership("@dave:example.org", "DDDD");
        const daveRTLRtcMember = mockRtcMembership(
          "@dave2:example.org",
          "DDDD",
        );
        const dave = mockMatrixRoomMember(daveRtcMember, {
          rawDisplayName: "Dave",
        });
        const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
          rawDisplayName: "\u202eevaD",
        });

        fakeMemberWith({ userId: "@carol:example.org" });
        fakeMemberWith(daveRTL);
        fakeMemberWith(dave);
        const memberships$ = behavior("ab", {
          a: [mockRtcMembership("@carol:example.org", "DDDD")],
          b: [
            mockRtcMembership("@carol:example.org", "DDDD"),
            daveRtcMember,
            daveRTLRtcMember,
          ],
        });

        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );

        expectObservable(metadataStore.displaynameMap$).toBe("ab", {
          // Carol has no displayname - So userId is used.
          a: new Map([["@carol:example.org", "@carol:example.org"]]),
          // Both Dave's join. Since after stripping
          b: new Map([
            ["@carol:example.org", "@carol:example.org"],
            // Not disambiguated
            ["@dave:example.org", "Dave"],
            // This one is, since it's using RTL.
            ["@dave2:example.org", "evaD (@dave2:example.org)"],
          ]),
        });
      });
    });
  });

  describe("avatarUrl", () => {
    function updateAvatarUrl(
      userId: `@${string}:${string}`,
      avatarUrl: string,
    ): void {
      const member = fakeMembersMap.get(userId);
      if (member) {
        member.getMxcAvatarUrl = vi.fn().mockReturnValue(avatarUrl);
        // Emit the event to notify listeners
        mockMatrixRoom.emit(
          RoomStateEvent.Members,
          {} as unknown as MatrixEvent,
          {} as unknown as RoomState,
          member as RoomMember,
        );
      } else {
        throw new Error(`No member found with userId: ${userId}`);
      }
    }

    it("should use avatar url from room members", () => {
      withTestScheduler(({ scope, behavior, expectObservable }) => {
        fakeMemberWith({
          userId: "@local:example.com",
        });
        fakeMemberWith({
          userId: "@alice:example.com",
          getMxcAvatarUrl: vi.fn().mockReturnValue("mxc://custom.url/avatar"),
        });
        const memberships$ = behavior("a", {
          a: [
            mockRtcMembership("@local:example.com", "DEVICE1"),
            mockRtcMembership("@alice:example.com", "DEVICE1"),
          ],
        });
        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );
        const local$ = metadataStore.createAvatarUrlBehavior$(
          scope,
          "@local:example.com",
        );

        const alice$ = metadataStore.createAvatarUrlBehavior$(
          scope,
          "@alice:example.com",
        );

        expectObservable(local$).toBe("a", {
          a: "mxc://example.com/@local:example.com",
        });
        expectObservable(alice$).toBe("a", {
          a: "mxc://custom.url/avatar",
        });
        expectObservable(metadataStore.avatarMap$).toBe("a", {
          a: new Map<string, string>([
            ["@local:example.com", "mxc://example.com/@local:example.com"],
            ["@alice:example.com", "mxc://custom.url/avatar"],
          ]),
        });
      });
    });

    it("should update on avatar change and user join/leave", () => {
      withTestScheduler(({ scope, behavior, schedule, expectObservable }) => {
        fakeMemberWith({ userId: "@carl:example.com" });
        fakeMemberWith({ userId: "@bob:example.com" });
        const memberships$ = behavior("ab-d", {
          a: [mockRtcMembership("@bob:example.com", "B000")],
          b: [
            mockRtcMembership("@bob:example.com", "B000"),
            mockRtcMembership("@carl:example.com", "C000"),
          ],
          d: [mockRtcMembership("@carl:example.com", "C000")],
        });

        const metadataStore = createMatrixMemberMetadata$(
          testScope,
          memberships$,
          createRoomMembers$(testScope, mockMatrixRoom),
        );

        schedule("--c-", {
          c: () => {
            updateAvatarUrl(
              "@carl:example.com",
              "mxc://updated.me/updatedAvatar",
            );
          },
        });

        const bob$ = metadataStore.createAvatarUrlBehavior$(
          scope,
          "@bob:example.com",
        );
        const carl$ = metadataStore.createAvatarUrlBehavior$(
          scope,
          "@carl:example.com",
        );
        expectObservable(bob$).toBe("a---", {
          a: "mxc://example.com/@bob:example.com",
        });
        expectObservable(carl$).toBe("a-c-", {
          a: "mxc://example.com/@carl:example.com",

          c: "mxc://updated.me/updatedAvatar",
        });
        expectObservable(metadataStore.avatarMap$).toBe("a-c-", {
          a: new Map<string, string>([
            ["@bob:example.com", "mxc://example.com/@bob:example.com"],
            ["@carl:example.com", "mxc://example.com/@carl:example.com"],
          ]),
          // expect an update once we update the avatar URL
          c: new Map<string, string>([
            ["@bob:example.com", "mxc://example.com/@bob:example.com"],
            ["@carl:example.com", "mxc://updated.me/updatedAvatar"],
          ]),
        });
      });
    });
  });
});
