import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  EventTimeline,
  JoinRule,
  MatrixClient,
  MatrixEvent,
  NotificationCountType,
  Room,
  RoomMember,
} from 'matrix-js-sdk';
import {
  getStateEvent,
  getStateEvents,
  getMDirects,
  isDirectInvite,
  isSpace,
  isRoom,
  isUnsupportedRoom,
  isValidChild,
  getAllParents,
  getSpaceChildren,
  mapParentWithChildren,
  getOrphanParents,
  isMutedRule,
  findMutedRule,
  isNotificationEvent,
  isVerificationFlowEvent,
  unreadIsOnlyVerification,
  readReceiptCoversTail,
  roomHasUnreadThread,
  roomHaveNotification,
  getUnreadInfo,
  getRoomIconSrc,
  trimReplyFromBody,
  trimReplyFromFormattedBody,
  parseReplyBody,
  parseReplyFormattedBody,
  getMemberDisplayName,
  getMemberSearchStr,
  getMemberAvatarMxc,
  isMembershipChanged,
  getReactionContent,
  getLatestEdit,
  reactionOrEditEvent,
  getMentionContent,
  getAllVersionsRoomCreator,
} from './room';
import { RoomType, StateEvent, RoomToParents } from '../../types/matrix/room';

// --- Mock helpers ---------------------------------------------------------

const mockEvent = (overrides: Partial<Record<string, unknown>> = {}): MatrixEvent => {
  const base: Record<string, () => unknown> = {
    getType: () => undefined,
    getContent: () => ({}),
    getPrevContent: () => ({}),
    getStateKey: () => '',
    getSender: () => undefined,
    getTs: () => 0,
    getId: () => undefined,
    getRelation: () => null,
    isRedacted: () => false,
  };
  return { ...base, ...overrides } as unknown as MatrixEvent;
};

// Build a Room whose live-timeline forward state returns the given state events.
const mockRoomWithState = (
  stateEventsByType: Record<string, MatrixEvent[]>,
  rest: Record<string, unknown> = {},
): Room => {
  const state = {
    getStateEvents: (eventType: string, stateKey?: string) => {
      const events = stateEventsByType[eventType] ?? [];
      if (stateKey !== undefined) {
        return events[0]; // single-event form
      }
      return events; // array form
    },
  };
  return {
    getLiveTimeline: () => ({
      getState: () => state,
    }),
    ...rest,
  } as unknown as Room;
};

// --- getStateEvent / getStateEvents --------------------------------------

test('getStateEvent returns the matching state event or undefined', () => {
  const ev = mockEvent({ getType: () => StateEvent.RoomName });
  const room = mockRoomWithState({ [StateEvent.RoomName]: [ev] });
  assert.equal(getStateEvent(room, StateEvent.RoomName), ev);

  const empty = mockRoomWithState({});
  assert.equal(getStateEvent(empty, StateEvent.RoomName), undefined);
});

test('getStateEvent returns undefined when there is no forward state', () => {
  const room = {
    getLiveTimeline: () => ({ getState: () => undefined }),
  } as unknown as Room;
  assert.equal(getStateEvent(room, StateEvent.RoomName), undefined);
});

test('getStateEvents returns an array, empty when none', () => {
  const ev = mockEvent();
  const room = mockRoomWithState({ [StateEvent.SpaceChild]: [ev] });
  assert.deepEqual(getStateEvents(room, StateEvent.SpaceChild), [ev]);

  const empty = mockRoomWithState({});
  assert.deepEqual(getStateEvents(empty, StateEvent.SpaceChild), []);
});

// --- getMDirects ----------------------------------------------------------

test('getMDirects collects all direct room ids across users', () => {
  const ev = mockEvent({
    getContent: () => ({
      '@a:x': ['!room1', '!room2'],
      '@b:x': ['!room3'],
    }),
  });
  const result = getMDirects(ev);
  assert.deepEqual([...result].sort(), ['!room1', '!room2', '!room3']);
});

test('getMDirects ignores non-array and non-string entries', () => {
  const ev = mockEvent({
    getContent: () => ({
      '@a:x': 'not-an-array',
      '@b:x': ['!ok', 42, null],
    }),
  });
  const result = getMDirects(ev);
  assert.deepEqual([...result], ['!ok']);
});

test('getMDirects returns empty set when content is undefined', () => {
  const ev = mockEvent({ getContent: () => undefined });
  assert.equal(getMDirects(ev).size, 0);
});

// --- isDirectInvite -------------------------------------------------------

test('isDirectInvite returns false for null room or null user', () => {
  assert.equal(isDirectInvite(null, '@me:x'), false);
  assert.equal(isDirectInvite({} as unknown as Room, null), false);
});

test('isDirectInvite reads is_direct from the member event content', () => {
  const room = {
    getMember: () => ({
      events: { member: { getContent: () => ({ is_direct: true }) } },
    }),
  } as unknown as Room;
  assert.equal(isDirectInvite(room, '@me:x'), true);

  const roomNotDirect = {
    getMember: () => ({
      events: { member: { getContent: () => ({}) } },
    }),
  } as unknown as Room;
  assert.equal(isDirectInvite(roomNotDirect, '@me:x'), false);
});

test('isDirectInvite returns false when member is missing', () => {
  const room = { getMember: () => null } as unknown as Room;
  assert.equal(isDirectInvite(room, '@me:x'), false);
});

// --- isSpace / isRoom / isUnsupportedRoom --------------------------------

test('isSpace detects space rooms via create event type', () => {
  const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) });
  assert.equal(isSpace(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), true);

  const normalEv = mockEvent({ getContent: () => ({}) });
  assert.equal(isSpace(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })), false);

  assert.equal(isSpace(null), false);
  assert.equal(isSpace(mockRoomWithState({})), false); // no create event
});

test('isRoom is true for normal rooms and rooms with no create event', () => {
  const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) });
  assert.equal(isRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), false);

  const normalEv = mockEvent({ getContent: () => ({}) });
  assert.equal(isRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })), true);

  assert.equal(isRoom(mockRoomWithState({})), true); // no create event -> treated as room
  assert.equal(isRoom(null), false);
});

test('isUnsupportedRoom is true when type is set and not a space', () => {
  const callEv = mockEvent({ getContent: () => ({ type: RoomType.Call }) });
  assert.equal(isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [callEv] })), true);

  const spaceEv = mockEvent({ getContent: () => ({ type: RoomType.Space }) });
  assert.equal(isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [spaceEv] })), false);

  const normalEv = mockEvent({ getContent: () => ({}) });
  assert.equal(
    isUnsupportedRoom(mockRoomWithState({ [StateEvent.RoomCreate]: [normalEv] })),
    false,
  );

  assert.equal(isUnsupportedRoom(mockRoomWithState({})), true); // missing create event
  assert.equal(isUnsupportedRoom(null), false);
});

// --- isValidChild / getSpaceChildren -------------------------------------

test('isValidChild requires SpaceChild type with via array', () => {
  const valid = mockEvent({
    getType: () => StateEvent.SpaceChild,
    getContent: () => ({ via: ['server'] }),
  });
  assert.equal(isValidChild(valid), true);

  const noVia = mockEvent({
    getType: () => StateEvent.SpaceChild,
    getContent: () => ({}),
  });
  assert.equal(isValidChild(noVia), false);

  const wrongType = mockEvent({
    getType: () => StateEvent.RoomName,
    getContent: () => ({ via: ['server'] }),
  });
  assert.equal(isValidChild(wrongType), false);
});

test('getSpaceChildren returns state keys of valid child events', () => {
  const valid = mockEvent({
    getType: () => StateEvent.SpaceChild,
    getStateKey: () => '!child:x',
    getContent: () => ({ via: ['s'] }),
  });
  const invalid = mockEvent({
    getType: () => StateEvent.SpaceChild,
    getStateKey: () => '!bad:x',
    getContent: () => ({}),
  });
  const noKey = mockEvent({
    getType: () => StateEvent.SpaceChild,
    getStateKey: () => '',
    getContent: () => ({ via: ['s'] }),
  });
  const room = mockRoomWithState({
    [StateEvent.SpaceChild]: [valid, invalid, noKey],
  });
  assert.deepEqual(getSpaceChildren(room), ['!child:x']);
});

// --- getAllParents / mapParentWithChildren / getOrphanParents ------------

test('getAllParents walks the parent graph excluding the start room', () => {
  const map: RoomToParents = new Map([
    ['child', new Set(['mid'])],
    ['mid', new Set(['root'])],
  ]);
  assert.deepEqual([...getAllParents(map, 'child')].sort(), ['mid', 'root']);
  assert.deepEqual([...getAllParents(map, 'root')], []);
});

test('getAllParents handles cycles without infinite recursion', () => {
  const map: RoomToParents = new Map([
    ['a', new Set(['b'])],
    ['b', new Set(['a'])],
  ]);
  assert.deepEqual([...getAllParents(map, 'a')], ['b']);
});

test('mapParentWithChildren registers parent for each child', () => {
  const map: RoomToParents = new Map();
  mapParentWithChildren(map, 'space1', ['c1', 'c2']);
  assert.deepEqual([...(map.get('c1') ?? [])], ['space1']);
  assert.deepEqual([...(map.get('c2') ?? [])], ['space1']);
});

test('mapParentWithChildren skips children that would create a cycle', () => {
  const map: RoomToParents = new Map([['space1', new Set(['ancestor'])]]);
  mapParentWithChildren(map, 'space1', ['ancestor', 'newchild']);
  // ancestor is an existing parent -> cycle -> skipped
  assert.equal(map.has('ancestor'), false);
  assert.deepEqual([...(map.get('newchild') ?? [])], ['space1']);
});

test('getOrphanParents returns parents not present as keys in the map', () => {
  const map: RoomToParents = new Map([
    ['child', new Set(['mid'])],
    ['mid', new Set(['root'])],
  ]);
  // root is a parent but has no entry of its own -> orphan
  assert.deepEqual(getOrphanParents(map, 'child'), ['root']);
});

// --- isMutedRule / findMutedRule -----------------------------------------

test('isMutedRule matches empty actions or dont_notify with event_match', () => {
  assert.equal(
    isMutedRule({
      actions: [],
      conditions: [{ kind: 'event_match' }],
    } as never),
    true,
  );
  assert.equal(
    isMutedRule({
      actions: ['dont_notify'],
      conditions: [{ kind: 'event_match' }],
    } as never),
    true,
  );
  assert.equal(
    isMutedRule({
      actions: ['notify'],
      conditions: [{ kind: 'event_match' }],
    } as never),
    false,
  );
  assert.ok(
    !isMutedRule({
      actions: [],
      conditions: [{ kind: 'other' }],
    } as never),
  );
});

test('findMutedRule finds rule by id that is also muted', () => {
  const rules = [
    { rule_id: '!a', actions: [], conditions: [{ kind: 'event_match' }] },
    { rule_id: '!b', actions: ['notify'], conditions: [{ kind: 'event_match' }] },
  ] as never[];
  assert.equal(findMutedRule(rules, '!a'), rules[0]);
  assert.equal(findMutedRule(rules, '!b'), undefined);
  assert.equal(findMutedRule(rules, '!missing'), undefined);
});

// --- isNotificationEvent --------------------------------------------------

test('isNotificationEvent accepts message/sticker but rejects member, redacted, edits', () => {
  assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.message' })), true);
  assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.sticker' })), true);
  assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.member' })), false);
  assert.equal(isNotificationEvent(mockEvent({ getType: () => 'm.room.topic' })), false);

  assert.equal(
    isNotificationEvent(mockEvent({ getType: () => 'm.room.message', isRedacted: () => true })),
    false,
  );
  assert.equal(
    isNotificationEvent(
      mockEvent({
        getType: () => 'm.room.message',
        getRelation: () => ({ rel_type: 'm.replace' }),
      }),
    ),
    false,
  );

  // Device-verification requests (m.room.message + this msgtype) are crypto
  // control messages — must NOT count as unread/notify (stale-request nag fix).
  assert.equal(
    isNotificationEvent(
      mockEvent({
        getType: () => 'm.room.message',
        getContent: () => ({ msgtype: 'm.key.verification.request' }),
      }),
    ),
    false,
  );
  // Regression guard: a normal text message still notifies.
  assert.equal(
    isNotificationEvent(
      mockEvent({
        getType: () => 'm.room.message',
        getContent: () => ({ msgtype: 'm.text', body: 'hi' }),
      }),
    ),
    true,
  );
});

// --- roomHaveNotification / getUnreadInfo --------------------------------

test('roomHaveNotification true when total or highlight count > 0', () => {
  const make = (total: number, highlight: number) =>
    ({
      getUnreadNotificationCount: (type: NotificationCountType) =>
        type === NotificationCountType.Total ? total : highlight,
    }) as unknown as Room;
  assert.equal(roomHaveNotification(make(0, 0)), false);
  assert.equal(roomHaveNotification(make(3, 0)), true);
  assert.equal(roomHaveNotification(make(0, 1)), true);
});

test('getUnreadInfo uses highlight when it exceeds total', () => {
  const room = {
    roomId: '!r:x',
    getUnreadNotificationCount: (type: NotificationCountType) =>
      type === NotificationCountType.Total ? 2 : 5,
  } as unknown as Room;
  assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 5, total: 5 });

  const room2 = {
    roomId: '!r:y',
    getUnreadNotificationCount: (type: NotificationCountType) =>
      type === NotificationCountType.Total ? 7 : 1,
  } as unknown as Room;
  assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 });
});

// --- verification-flow unread suppression --------------------------------

test('isVerificationFlowEvent', () => {
  // the in-room request (m.room.message + verification msgtype)
  assert.equal(
    isVerificationFlowEvent(
      mockEvent({
        getType: () => 'm.room.message',
        getContent: () => ({ msgtype: 'm.key.verification.request' }),
      }),
    ),
    true,
  );
  // the handshake events (their own m.key.verification.* types)
  ['ready', 'start', 'accept', 'key', 'mac', 'done', 'cancel'].forEach((phase) => {
    assert.equal(
      isVerificationFlowEvent(mockEvent({ getType: () => `m.key.verification.${phase}` })),
      true,
    );
  });
  // a normal message is not verification flow
  assert.equal(
    isVerificationFlowEvent(
      mockEvent({ getType: () => 'm.room.message', getContent: () => ({ msgtype: 'm.text' }) }),
    ),
    false,
  );
  // a still-encrypted event can't be classified → false (conservative)
  assert.equal(isVerificationFlowEvent(mockEvent({ getType: () => 'm.room.encrypted' })), false);
});

const mockUnreadRoom = (
  events: MatrixEvent[],
  readUpToId: string | null,
  counts: { total: number; highlight: number } = { total: 0, highlight: 0 },
  threadCounts: Record<string, { total: number; highlight: number }> = {},
): Room =>
  ({
    roomId: '!r:x',
    getEventReadUpTo: () => readUpToId,
    getLiveTimeline: () => ({ getEvents: () => events }),
    getUnreadNotificationCount: (type: NotificationCountType) =>
      type === NotificationCountType.Total ? counts.total : counts.highlight,
    getThreads: () => Object.keys(threadCounts).map((id) => ({ id })),
    getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
      type === NotificationCountType.Total
        ? (threadCounts[threadId]?.total ?? 0)
        : (threadCounts[threadId]?.highlight ?? 0),
  }) as unknown as Room;

const mx = { getUserId: () => '@me:x' } as unknown as MatrixClient;
const verifRequest = (id: string) =>
  mockEvent({
    getId: () => id,
    getType: () => 'm.room.message',
    getContent: () => ({ msgtype: 'm.key.verification.request' }),
  });
const verifPhase = (id: string, phase: string) =>
  mockEvent({ getId: () => id, getType: () => `m.key.verification.${phase}` });
const textMsg = (id: string) =>
  mockEvent({
    getId: () => id,
    getType: () => 'm.room.message',
    getContent: () => ({ msgtype: 'm.text', body: 'hi' }),
  });
const reactionEv = (id: string) =>
  mockEvent({ getId: () => id, getType: () => 'm.reaction', getContent: () => ({}) });
const encryptedEv = (id: string) =>
  mockEvent({ getId: () => id, getType: () => 'm.room.encrypted', getContent: () => ({}) });
const pollEv = (id: string) =>
  mockEvent({ getId: () => id, getType: () => 'm.poll.start', getContent: () => ({}) });

test('unreadIsOnlyVerification: verification-only unread tail → true', () => {
  // timeline oldest→newest: [read msg] then the verification handshake at the tail
  const events = [textMsg('$read'), verifPhase('$done', 'done'), verifRequest('$req')];
  assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), true);
});

test('unreadIsOnlyVerification: a real unread message in the span → false', () => {
  // an unread text message sits between the read marker and the verification tail
  const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')];
  assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false);
});

test('unreadIsOnlyVerification: read marker off-window → false (conservative)', () => {
  // the read marker isn't in the loaded timeline
  const events = [verifPhase('$done', 'done'), verifRequest('$req')];
  assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$offwindow'), '@me:x'), false);
});

test('unreadIsOnlyVerification: still-encrypted tail → false (conservative)', () => {
  const encryptedTail = mockEvent({ getId: () => '$enc', getType: () => 'm.room.encrypted' });
  const events = [textMsg('$read'), encryptedTail];
  assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false);
});

test('unreadIsOnlyVerification: no userId → false', () => {
  const events = [textMsg('$read'), verifRequest('$req')];
  assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), null), false);
});

test('unreadIsOnlyVerification: verification-only main tail but a real unread THREAD → false', () => {
  // markAsRead clears every thread, so a verification-only main timeline must NOT
  // count as "only verification" when a thread still has a genuine unread reply.
  const events = [textMsg('$read'), verifRequest('$req')];
  const room = mockUnreadRoom(
    events,
    '$read',
    { total: 2, highlight: 0 },
    {
      $thread: { total: 1, highlight: 0 },
    },
  );
  assert.equal(unreadIsOnlyVerification(room, '@me:x'), false);
});

test('getUnreadInfo suppresses a verification-only room to {0,0} when mx is passed', () => {
  const events = [textMsg('$read'), verifRequest('$req')];
  const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
  // Without mx, the raw count is trusted (backward compatible).
  assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 });
  // With mx, the verification-only count is suppressed.
  assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 });
});

test('getUnreadInfo does NOT suppress a highlight (real mention) even if the tail is a verification', () => {
  const events = [textMsg('$read'), verifRequest('$req')];
  const room = mockUnreadRoom(events, '$read', { total: 2, highlight: 1 });
  assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 1, total: 2 });
});

test('getUnreadInfo does NOT suppress when a real message is unread alongside a verification', () => {
  const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')];
  const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
  assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 });
});

// --- readReceiptCoversTail (UTD / spurious-count suppression) --------------

test('readReceiptCoversTail: receipt on the tail (a reaction) → true', () => {
  // The Cool Kids case: a corrupt/undecryptable event sits BEFORE the read
  // receipt, and the receipt itself landed on the trailing reaction.
  const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')];
  assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), '@me:x'), true);
});

test('readReceiptCoversTail: only non-notifiable events after the receipt → true', () => {
  const events = [textMsg('$read'), reactionEv('$r1'), verifRequest('$v')];
  assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), true);
});

test('readReceiptCoversTail: a real unread message after the receipt → false', () => {
  const events = [textMsg('$read'), reactionEv('$r'), textMsg('$new')];
  assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
});

test('readReceiptCoversTail: an unread poll after the receipt → false (polls are content)', () => {
  const events = [textMsg('$read'), pollEv('$poll')];
  assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
});

test('isNotificationEvent recognizes polls (MSC3381)', () => {
  assert.equal(isNotificationEvent(pollEv('$p')), true);
});

test('readReceiptCoversTail: a still-encrypted message after the receipt → false (conservative)', () => {
  const events = [textMsg('$read'), encryptedEv('$enc')];
  assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
});

test('readReceiptCoversTail: receipt off-window → false', () => {
  const events = [reactionEv('$r'), textMsg('$new')];
  assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$gone'), '@me:x'), false);
});

test('readReceiptCoversTail: no receipt / null user → false', () => {
  const events = [textMsg('$read'), reactionEv('$tail')];
  assert.equal(readReceiptCoversTail(mockUnreadRoom(events, null), '@me:x'), false);
  assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), null), false);
});

test('readReceiptCoversTail: a genuine unread THREAD blocks suppression → false', () => {
  const events = [textMsg('$read'), reactionEv('$tail')];
  const room = mockUnreadRoom(
    events,
    '$tail',
    { total: 2, highlight: 0 },
    {
      $thread: { total: 1, highlight: 0 },
    },
  );
  assert.equal(readReceiptCoversTail(room, '@me:x'), false);
});

test('roomHasUnreadThread reflects per-thread counts', () => {
  const events = [textMsg('$read')];
  assert.equal(roomHasUnreadThread(mockUnreadRoom(events, '$read')), false);
  assert.equal(
    roomHasUnreadThread(
      mockUnreadRoom(
        events,
        '$read',
        { total: 1, highlight: 0 },
        { $t: { total: 1, highlight: 0 } },
      ),
    ),
    true,
  );
});

test('getUnreadInfo suppresses a spurious count when the read receipt covers the tail', () => {
  // Inflated Total=1 (undecryptable event) but the receipt is on the trailing
  // reaction → the room is genuinely read; suppress to {0,0}. Without mx the raw
  // count is trusted (backward compatible).
  const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')];
  const room = mockUnreadRoom(events, '$tail', { total: 1, highlight: 0 });
  assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 });
  assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 });
});

test('getUnreadInfo does NOT suppress a spurious count when a real message is unread past the receipt', () => {
  const events = [textMsg('$read'), textMsg('$new')];
  const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
  assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 });
});

const mockRoomWithThreadCounts = (
  total: number,
  highlight: number,
  threadCounts: Record<string, { total: number; highlight: number }>,
): Room =>
  ({
    roomId: '!r:x',
    getUnreadNotificationCount: (type: NotificationCountType) =>
      type === NotificationCountType.Total ? total : highlight,
    getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
      type === NotificationCountType.Total
        ? (threadCounts[threadId]?.total ?? 0)
        : (threadCounts[threadId]?.highlight ?? 0),
  }) as unknown as Room;

test('getUnreadInfo subtracts muted thread counts from room totals', () => {
  const room = mockRoomWithThreadCounts(5, 2, { $t1: { total: 3, highlight: 1 } });
  assert.deepEqual(getUnreadInfo(room, new Set(['$t1'])), {
    roomId: '!r:x',
    highlight: 1,
    total: 2,
  });
});

test('getUnreadInfo subtracts multiple muted threads', () => {
  const room = mockRoomWithThreadCounts(9, 3, {
    $t1: { total: 3, highlight: 1 },
    $t2: { total: 2, highlight: 1 },
  });
  assert.deepEqual(getUnreadInfo(room, new Set(['$t1', '$t2'])), {
    roomId: '!r:x',
    highlight: 1,
    total: 4,
  });
});

test('getUnreadInfo clamps subtracted counts at zero', () => {
  const room = mockRoomWithThreadCounts(2, 1, { $t1: { total: 5, highlight: 4 } });
  assert.deepEqual(getUnreadInfo(room, new Set(['$t1'])), {
    roomId: '!r:x',
    highlight: 0,
    total: 0,
  });
});

test('getUnreadInfo leaves counts untouched without muted threads', () => {
  const room = mockRoomWithThreadCounts(4, 1, { $t1: { total: 3, highlight: 1 } });
  // undefined muted set (backward compat)
  assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 1, total: 4 });
  // empty muted set is a no-op too
  assert.deepEqual(getUnreadInfo(room, new Set<string>()), {
    roomId: '!r:x',
    highlight: 1,
    total: 4,
  });
});

// --- getRoomIconSrc -------------------------------------------------------

test('getRoomIconSrc selects icon by room type and join rule', () => {
  const icons = {
    Warning: 'warning',
    SpaceGlobe: 'space-globe',
    SpaceLock: 'space-lock',
    Space: 'space',
    VolumeHighGlobe: 'vol-globe',
    VolumeHighLock: 'vol-lock',
    VolumeHigh: 'vol',
    HashGlobe: 'hash-globe',
    HashLock: 'hash-lock',
    Hash: 'hash',
  } as never;

  assert.equal(getRoomIconSrc(icons, 'm.server_notice'), 'warning');
  assert.equal(getRoomIconSrc(icons, RoomType.Space, JoinRule.Public), 'space-globe');
  assert.equal(getRoomIconSrc(icons, RoomType.Space, JoinRule.Invite), 'space-lock');
  assert.equal(getRoomIconSrc(icons, RoomType.Space), 'space');
  assert.equal(getRoomIconSrc(icons, RoomType.Call, JoinRule.Public), 'vol-globe');
  assert.equal(getRoomIconSrc(icons, RoomType.Call, JoinRule.Knock), 'vol-lock');
  assert.equal(getRoomIconSrc(icons, RoomType.Call), 'vol');
  assert.equal(getRoomIconSrc(icons, undefined, JoinRule.Public), 'hash-globe');
  assert.equal(getRoomIconSrc(icons, undefined, JoinRule.Invite), 'hash-lock');
  assert.equal(getRoomIconSrc(icons), 'hash');
});

// --- reply parsing helpers -----------------------------------------------

test('trimReplyFromBody strips a matrix reply fallback prefix', () => {
  const body = '> <@a:x> hello\n> more\n\nactual message';
  assert.equal(trimReplyFromBody(body), 'actual message');
  // no fallback -> unchanged
  assert.equal(trimReplyFromBody('plain text'), 'plain text');
});

test('trimReplyFromFormattedBody strips up to and including mx-reply', () => {
  const fb = '<mx-reply><blockquote>x</blockquote></mx-reply>real';
  assert.equal(trimReplyFromFormattedBody(fb), 'real');
  assert.equal(trimReplyFromFormattedBody('no reply here'), 'no reply here');
});

test('parseReplyBody builds a quoted fallback with prefixed newlines', () => {
  assert.equal(parseReplyBody('@a:x', 'line1\nline2'), '> <@a:x> line1\n> line2\n\n');
});

test('parseReplyFormattedBody builds an mx-reply block with encoded links', () => {
  const result = parseReplyFormattedBody('!r:x', '@a:x', '$e:x', 'hi');
  assert.ok(result.startsWith('<mx-reply><blockquote>'));
  assert.ok(result.endsWith('hi</blockquote></mx-reply>'));
  assert.ok(result.includes(encodeURIComponent('!r:x')));
  assert.ok(result.includes(encodeURIComponent('$e:x')));
  assert.ok(result.includes(encodeURIComponent('@a:x')));
});

// --- member helpers -------------------------------------------------------

test('getMemberDisplayName returns name unless it equals the userId', () => {
  const room = {
    getMember: (id: string) =>
      id === '@named:x' ? { rawDisplayName: 'Alice' } : { rawDisplayName: '@plain:x' },
  } as unknown as Room;
  assert.equal(getMemberDisplayName(room, '@named:x'), 'Alice');
  assert.equal(getMemberDisplayName(room, '@plain:x'), undefined);

  const noMember = { getMember: () => null } as unknown as Room;
  assert.equal(getMemberDisplayName(noMember, '@x:x'), undefined);
});

test('getMemberSearchStr produces display and id variants', () => {
  const member = {
    rawDisplayName: 'Alice',
    userId: '@alice:x',
  } as unknown as RoomMember;
  const mxIdToName = (id: string) => `name(${id})`;

  // query without @ or : -> uses mapped name for second entry
  assert.deepEqual(getMemberSearchStr(member, 'al', mxIdToName), ['Alice', 'name(@alice:x)']);
  // query with @ -> uses raw userId for second entry
  assert.deepEqual(getMemberSearchStr(member, '@al', mxIdToName), ['Alice', '@alice:x']);

  // when display name equals userId, first entry falls back to mapped name
  const unnamed = { rawDisplayName: '@bob:x', userId: '@bob:x' } as unknown as RoomMember;
  assert.deepEqual(getMemberSearchStr(unnamed, 'bob', mxIdToName), [
    'name(@bob:x)',
    'name(@bob:x)',
  ]);
});

test('getMemberAvatarMxc returns member mxc or undefined', () => {
  const room = {
    getMember: (id: string) => (id === '@a:x' ? { getMxcAvatarUrl: () => 'mxc://a' } : null),
  } as unknown as Room;
  assert.equal(getMemberAvatarMxc(room, '@a:x'), 'mxc://a');
  assert.equal(getMemberAvatarMxc(room, '@b:x'), undefined);
});

// --- isMembershipChanged --------------------------------------------------

test('isMembershipChanged compares membership and reason against prev content', () => {
  const changedMembership = mockEvent({
    getContent: () => ({ membership: 'join' }),
    getPrevContent: () => ({ membership: 'invite' }),
  });
  assert.equal(isMembershipChanged(changedMembership), true);

  const changedReason = mockEvent({
    getContent: () => ({ membership: 'leave', reason: 'spam' }),
    getPrevContent: () => ({ membership: 'leave' }),
  });
  assert.equal(isMembershipChanged(changedReason), true);

  const unchanged = mockEvent({
    getContent: () => ({ membership: 'join', reason: 'x' }),
    getPrevContent: () => ({ membership: 'join', reason: 'x' }),
  });
  assert.equal(isMembershipChanged(unchanged), false);
});

// --- getReactionContent / getMentionContent ------------------------------

test('getReactionContent builds an annotation relation', () => {
  assert.deepEqual(getReactionContent('$e:x', '👍', 'thumbsup'), {
    'm.relates_to': {
      event_id: '$e:x',
      key: '👍',
      rel_type: 'm.annotation',
    },
    shortcode: 'thumbsup',
  });
});

test('getMentionContent includes user_ids and room only when relevant', () => {
  assert.deepEqual(getMentionContent(['@a:x'], true), {
    user_ids: ['@a:x'],
    room: true,
  });
  assert.deepEqual(getMentionContent([], false), {});
  assert.deepEqual(getMentionContent(['@a:x'], false), { user_ids: ['@a:x'] });
  assert.deepEqual(getMentionContent([], true), { room: true });
});

// --- getLatestEdit / reactionOrEditEvent ---------------------------------

test('getLatestEdit returns newest edit from the same sender', () => {
  const target = mockEvent({ getSender: () => '@a:x' });
  const oldEdit = mockEvent({ getSender: () => '@a:x', getTs: () => 100 });
  const newEdit = mockEvent({ getSender: () => '@a:x', getTs: () => 200 });
  const otherSender = mockEvent({ getSender: () => '@b:x', getTs: () => 300 });

  assert.equal(getLatestEdit(target, [oldEdit, newEdit, otherSender]), newEdit);
  assert.equal(getLatestEdit(target, [otherSender]), undefined);
});

test('reactionOrEditEvent detects annotation and replace relations', () => {
  assert.equal(
    reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.annotation' }) })),
    true,
  );
  assert.equal(
    reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.replace' }) })),
    true,
  );
  assert.equal(
    reactionOrEditEvent(mockEvent({ getRelation: () => ({ rel_type: 'm.thread' }) })),
    false,
  );
  assert.equal(reactionOrEditEvent(mockEvent({ getRelation: () => null })), false);
});

// --- getAllVersionsRoomCreator -------------------------------------------

test('getAllVersionsRoomCreator collects sender and additional_creators', () => {
  const createEv = mockEvent({
    getSender: () => '@creator:x',
    getContent: () => ({ additional_creators: ['@co1:x', '@co2:x', 5] }),
  });
  const room = mockRoomWithState({ [StateEvent.RoomCreate]: [createEv] });
  const creators = getAllVersionsRoomCreator(room);
  assert.deepEqual([...creators].sort(), ['@co1:x', '@co2:x', '@creator:x']);
});

test('getAllVersionsRoomCreator returns empty set when no create event', () => {
  const room = mockRoomWithState({});
  assert.equal(getAllVersionsRoomCreator(room).size, 0);
});

// Reference EventTimeline so the import is used even though state mocks bypass it.
test('EventTimeline.FORWARDS constant is available', () => {
  assert.equal(typeof EventTimeline.FORWARDS, 'string');
});
