import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
import { enableMapSet } from 'immer';
import { roomToUnreadAtom, unreadInfoToUnread, unreadEqual } from './roomToUnread';
import { roomToParentsAtom } from './roomToParents';
import { RoomToParents, Unread, UnreadInfo } from '../../../types/matrix/room';

// `roomToUnreadAtom` maps roomId -> Unread { total, highlight, from }. The
// reducer handles RESET (rebuild from a list of UnreadInfo), PUT (set one room's
// unread + roll the delta up to all ancestor parents, accumulating their `from`
// sets) and DELETE (remove a room's unread + roll the removal back up to
// parents, pruning a parent whose `from` set becomes empty). Parent aggregation
// reads `roomToParentsAtom`, which we seed directly in the store.
//
// The reducers `produce` over immer-managed Map/Set, so we enable that the same
// way the app does at startup (src/index.tsx). The React
// `useBindRoomToUnreadAtom` hook wiring (timeline/receipt/membership listeners,
// getUnreadInfos) is not covered here.
enableMapSet();

const get = (store: ReturnType<typeof createStore>) => store.get(roomToUnreadAtom);

const info = (roomId: string, total: number, highlight: number): UnreadInfo => ({
  roomId,
  total,
  highlight,
});

const seedParents = (store: ReturnType<typeof createStore>, map: RoomToParents) =>
  store.set(roomToParentsAtom, { type: 'INITIALIZE', roomToParents: map });

// ---------------------------------------------------------------------------
// unreadInfoToUnread
// ---------------------------------------------------------------------------

test('unreadInfoToUnread copies counts and nulls `from`', () => {
  assert.deepEqual(unreadInfoToUnread(info('!r:s', 5, 2)), {
    total: 5,
    highlight: 2,
    from: null,
  });
});

// ---------------------------------------------------------------------------
// unreadEqual
// ---------------------------------------------------------------------------

const u = (total: number, highlight: number, from: Set<string> | null): Unread => ({
  total,
  highlight,
  from,
});

test('unreadEqual: equal counts with both `from` null are equal', () => {
  assert.equal(unreadEqual(u(1, 0, null), u(1, 0, null)), true);
});

test('unreadEqual: differing total is not equal', () => {
  assert.equal(unreadEqual(u(1, 0, null), u(2, 0, null)), false);
});

test('unreadEqual: differing highlight is not equal', () => {
  assert.equal(unreadEqual(u(1, 0, null), u(1, 1, null)), false);
});

test('unreadEqual: one `from` null and the other a set is not equal', () => {
  assert.equal(unreadEqual(u(1, 0, null), u(1, 0, new Set(['!a:s']))), false);
  assert.equal(unreadEqual(u(1, 0, new Set(['!a:s'])), u(1, 0, null)), false);
});

test('unreadEqual: `from` sets of different size are not equal', () => {
  assert.equal(unreadEqual(u(1, 0, new Set(['!a:s'])), u(1, 0, new Set(['!a:s', '!b:s']))), false);
});

test('unreadEqual: same-size `from` sets with same members are equal', () => {
  assert.equal(
    unreadEqual(u(2, 1, new Set(['!a:s', '!b:s'])), u(2, 1, new Set(['!b:s', '!a:s']))),
    true,
  );
});

test('unreadEqual: same-size `from` sets with different members are not equal', () => {
  assert.equal(unreadEqual(u(1, 0, new Set(['!a:s'])), u(1, 0, new Set(['!b:s']))), false);
});

// ---------------------------------------------------------------------------
// roomToUnreadAtom: PUT (no parents)
// ---------------------------------------------------------------------------

test('starts empty', () => {
  const store = createStore();
  assert.equal(get(store).size, 0);
});

test('PUT sets a room unread with null `from`', () => {
  const store = createStore();
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
  assert.deepEqual(get(store).get('!r:s'), { total: 3, highlight: 1, from: null });
});

test('PUT overwrites a room unread with the latest counts', () => {
  const store = createStore();
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 7, 2) });
  assert.deepEqual(get(store).get('!r:s'), { total: 7, highlight: 2, from: null });
});

test('PUT with unchanged counts is skipped (same map reference)', () => {
  const store = createStore();
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
  const before = get(store);
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
  const after = get(store);
  // The "skip update if equal" guard returns without producing a new map.
  assert.equal(before, after);
});

test('PUT of { total: 0, highlight: 0 } removes the room (collapses to DELETE)', () => {
  const store = createStore();
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
  // A phantom zero-count PUT (e.g. UnreadNotifications after the server zeroes
  // counts) must clear the entry, not leave a stuck dot.
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
  assert.equal(get(store).has('!r:s'), false);
});

test('PUT of { 0, 0 } on an absent room is a no-op (same map reference)', () => {
  const store = createStore();
  const before = get(store);
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
  assert.equal(before, get(store));
  assert.equal(get(store).has('!r:s'), false);
});

// ---------------------------------------------------------------------------
// roomToUnreadAtom: PUT with parent aggregation
// ---------------------------------------------------------------------------

test('PUT rolls counts up to a single parent and records `from`', () => {
  const store = createStore();
  seedParents(store, new Map([['!c:s', new Set(['!space:s'])]]));

  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c:s', 4, 2) });

  assert.deepEqual(get(store).get('!c:s'), { total: 4, highlight: 2, from: null });
  const parent = get(store).get('!space:s');
  assert.equal(parent?.total, 4);
  assert.equal(parent?.highlight, 2);
  assert.deepEqual(Array.from(parent?.from ?? []), ['!c:s']);
});

test('PUT aggregates two children into the same parent', () => {
  const store = createStore();
  seedParents(
    store,
    new Map([
      ['!c1:s', new Set(['!space:s'])],
      ['!c2:s', new Set(['!space:s'])],
    ]),
  );

  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c1:s', 4, 2) });
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c2:s', 1, 1) });

  const parent = get(store).get('!space:s');
  assert.equal(parent?.total, 5);
  assert.equal(parent?.highlight, 3);
  assert.deepEqual(Array.from(parent?.from ?? []).sort(), ['!c1:s', '!c2:s']);
});

test('PUT applies only the delta to the parent on re-PUT of the same child', () => {
  const store = createStore();
  seedParents(store, new Map([['!c:s', new Set(['!space:s'])]]));

  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c:s', 4, 2) });
  // Increase the child's counts; the parent should reflect the delta, not 4+6.
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c:s', 6, 3) });

  const parent = get(store).get('!space:s');
  assert.equal(parent?.total, 6);
  assert.equal(parent?.highlight, 3);
  assert.deepEqual(Array.from(parent?.from ?? []), ['!c:s']);
});

test('PUT rolls up through a grandparent chain', () => {
  const store = createStore();
  seedParents(
    store,
    new Map([
      ['!c:s', new Set(['!mid:s'])],
      ['!mid:s', new Set(['!top:s'])],
    ]),
  );

  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c:s', 2, 1) });

  // Both !mid and !top are ancestors of !c.
  for (const ancestor of ['!mid:s', '!top:s']) {
    const a = get(store).get(ancestor);
    assert.equal(a?.total, 2, ancestor);
    assert.equal(a?.highlight, 1, ancestor);
    assert.deepEqual(Array.from(a?.from ?? []), ['!c:s'], ancestor);
  }
});

// ---------------------------------------------------------------------------
// roomToUnreadAtom: RESET
// ---------------------------------------------------------------------------

test('RESET rebuilds the map from scratch', () => {
  const store = createStore();
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!old:s', 9, 9) });

  store.set(roomToUnreadAtom, {
    type: 'RESET',
    unreadInfos: [info('!a:s', 2, 0), info('!b:s', 3, 1)],
  });

  assert.equal(get(store).has('!old:s'), false);
  assert.deepEqual(get(store).get('!a:s'), { total: 2, highlight: 0, from: null });
  assert.deepEqual(get(store).get('!b:s'), { total: 3, highlight: 1, from: null });
});

test('RESET aggregates parents from the seeded roomToParents', () => {
  const store = createStore();
  seedParents(
    store,
    new Map([
      ['!a:s', new Set(['!space:s'])],
      ['!b:s', new Set(['!space:s'])],
    ]),
  );

  store.set(roomToUnreadAtom, {
    type: 'RESET',
    unreadInfos: [info('!a:s', 2, 1), info('!b:s', 3, 0)],
  });

  const parent = get(store).get('!space:s');
  assert.equal(parent?.total, 5);
  assert.equal(parent?.highlight, 1);
  assert.deepEqual(Array.from(parent?.from ?? []).sort(), ['!a:s', '!b:s']);
});

// ---------------------------------------------------------------------------
// roomToUnreadAtom: DELETE
// ---------------------------------------------------------------------------

test('DELETE removes a leaf room with no parents', () => {
  const store = createStore();
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
  store.set(roomToUnreadAtom, { type: 'DELETE', roomId: '!r:s' });
  assert.equal(get(store).has('!r:s'), false);
});

test('DELETE of an absent room is a no-op (same map reference)', () => {
  const store = createStore();
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
  const before = get(store);
  store.set(roomToUnreadAtom, { type: 'DELETE', roomId: '!absent:s' });
  assert.equal(get(store), before);
});

test('DELETE subtracts the child counts from a parent that keeps other children', () => {
  const store = createStore();
  seedParents(
    store,
    new Map([
      ['!c1:s', new Set(['!space:s'])],
      ['!c2:s', new Set(['!space:s'])],
    ]),
  );
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c1:s', 4, 2) });
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c2:s', 1, 1) });

  store.set(roomToUnreadAtom, { type: 'DELETE', roomId: '!c1:s' });

  assert.equal(get(store).has('!c1:s'), false);
  const parent = get(store).get('!space:s');
  assert.equal(parent?.total, 1);
  assert.equal(parent?.highlight, 1);
  assert.deepEqual(Array.from(parent?.from ?? []), ['!c2:s']);
});

test('DELETE prunes a parent whose `from` set becomes empty', () => {
  const store = createStore();
  seedParents(store, new Map([['!c:s', new Set(['!space:s'])]]));
  store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!c:s', 4, 2) });

  store.set(roomToUnreadAtom, { type: 'DELETE', roomId: '!c:s' });

  assert.equal(get(store).has('!c:s'), false);
  // The parent had only !c contributing, so it is removed entirely.
  assert.equal(get(store).has('!space:s'), false);
  assert.equal(get(store).size, 0);
});
