import { Awareness } from 'y-protocols/awareness';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import * as Y from 'yjs';
import { createCollaborationProvider, summarisePresence, } from '../../lib/collaboration-provider.js';
// The provider helper requires a browser-like context (checks for `window`).
// The cms-admin vitest config runs under `node`, so we stub a minimal window
// for the whole file.
const originalWindow = globalThis.window;
beforeAll(() => {
    ;
    globalThis.window = globalThis;
});
afterAll(() => {
    ;
    globalThis.window = originalWindow;
});
/**
 * Fake WebsocketProvider that records constructor args + exposes manual
 * emit hooks so tests can simulate connection-state transitions without
 * opening real sockets.
 */
class FakeWsProvider {
    static instances = [];
    url;
    room;
    ydoc;
    opts;
    awareness;
    listeners = new Map();
    destroyed = false;
    constructor(url, room, ydoc, opts) {
        this.url = url;
        this.room = room;
        this.ydoc = ydoc;
        this.opts = opts;
        this.awareness = new Awareness(ydoc);
        FakeWsProvider.instances.push(this);
    }
    on(event, handler) {
        let bucket = this.listeners.get(event);
        if (!bucket) {
            bucket = new Set();
            this.listeners.set(event, bucket);
        }
        bucket.add(handler);
    }
    emit(event, payload) {
        const bucket = this.listeners.get(event);
        if (!bucket)
            return;
        for (const handler of bucket)
            handler(payload);
    }
    destroy() {
        this.destroyed = true;
    }
}
const Ctor = FakeWsProvider;
const baseOptions = {
    documentId: 'doc-1',
    url: 'ws://localhost:1234/realtime',
    user: { name: 'Alice', color: '#ff00aa', userId: 'user_1' },
};
describe('createCollaborationProvider — construction', () => {
    beforeEach(() => {
        FakeWsProvider.instances.length = 0;
    });
    afterEach(() => {
        FakeWsProvider.instances.length = 0;
    });
    it('throws if the global window is undefined (true SSR)', () => {
        const origWindow = globalThis.window;
        delete globalThis.window;
        try {
            expect(() => createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor })).toThrow(/browser context/);
        }
        finally {
            ;
            globalThis.window = origWindow;
        }
    });
    it('throws when documentId is missing', () => {
        expect(() => createCollaborationProvider({ ...baseOptions, documentId: '' }, { WebsocketProviderCtor: Ctor })).toThrow(/documentId/);
    });
    it('throws when url is missing', () => {
        expect(() => createCollaborationProvider({ ...baseOptions, url: '' }, { WebsocketProviderCtor: Ctor })).toThrow(/url/);
    });
    it('instantiates the provider with the supplied url + documentId', () => {
        const ctx = createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor });
        expect(FakeWsProvider.instances.length).toBe(1);
        const instance = FakeWsProvider.instances[0];
        expect(instance.url).toBe(baseOptions.url);
        expect(instance.room).toBe(baseOptions.documentId);
        expect(instance.ydoc).toBe(ctx.ydoc);
        ctx.destroy();
    });
    it('forwards params (e.g. auth token) to the provider', () => {
        createCollaborationProvider({ ...baseOptions, params: { token: 'abc' } }, { WebsocketProviderCtor: Ctor });
        expect(FakeWsProvider.instances[0].opts.params).toEqual({ token: 'abc' });
    });
    it('defaults connect to true', () => {
        createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor });
        expect(FakeWsProvider.instances[0].opts.connect).toBe(true);
    });
    it('respects connect: false for offline / test usage', () => {
        createCollaborationProvider({ ...baseOptions, connect: false }, { WebsocketProviderCtor: Ctor });
        expect(FakeWsProvider.instances[0].opts.connect).toBe(false);
    });
});
describe('createCollaborationProvider — awareness seeding', () => {
    beforeEach(() => {
        FakeWsProvider.instances.length = 0;
    });
    it('writes the user info into local awareness state', () => {
        const ctx = createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor });
        const localState = ctx.awareness.getLocalState();
        expect(localState.user).toEqual(baseOptions.user);
        ctx.destroy();
    });
    it('includes avatarUrl in awareness when supplied', () => {
        const ctx = createCollaborationProvider({ ...baseOptions, user: { ...baseOptions.user, avatarUrl: 'https://example.test/a.png' } }, { WebsocketProviderCtor: Ctor });
        const localState = ctx.awareness.getLocalState();
        expect(localState.user?.avatarUrl).toBe('https://example.test/a.png');
        ctx.destroy();
    });
});
describe('createCollaborationProvider — onStatus / onError', () => {
    beforeEach(() => {
        FakeWsProvider.instances.length = 0;
    });
    it('forwards normalised status transitions to onStatus', () => {
        const onStatus = vi.fn();
        const ctx = createCollaborationProvider({ ...baseOptions, onStatus }, { WebsocketProviderCtor: Ctor });
        const provider = FakeWsProvider.instances[0];
        provider.emit('status', { status: 'connecting' });
        provider.emit('status', { status: 'connected' });
        provider.emit('status', { status: 'disconnected' });
        expect(onStatus.mock.calls.map((c) => c[0])).toEqual([
            'connecting',
            'connected',
            'disconnected',
        ]);
        ctx.destroy();
    });
    it('ignores unknown status values', () => {
        const onStatus = vi.fn();
        const ctx = createCollaborationProvider({ ...baseOptions, onStatus }, { WebsocketProviderCtor: Ctor });
        FakeWsProvider.instances[0].emit('status', { status: 'gibberish' });
        expect(onStatus).not.toHaveBeenCalled();
        ctx.destroy();
    });
    it('calls onError when the provider fires a connection-error event', () => {
        const onError = vi.fn();
        const ctx = createCollaborationProvider({ ...baseOptions, onError }, { WebsocketProviderCtor: Ctor });
        FakeWsProvider.instances[0].emit('connection-error', { code: 4401 });
        expect(onError).toHaveBeenCalledTimes(1);
        expect(onError.mock.calls[0][0].message).toMatch(/connection error/i);
        ctx.destroy();
    });
});
describe('createCollaborationProvider — destroy', () => {
    beforeEach(() => {
        FakeWsProvider.instances.length = 0;
    });
    it('clears local awareness, destroys the provider, and destroys the doc', () => {
        const ctx = createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor });
        const provider = FakeWsProvider.instances[0];
        const docDestroySpy = vi.spyOn(ctx.ydoc, 'destroy');
        ctx.destroy();
        expect(ctx.awareness.getLocalState()).toBeNull();
        expect(provider.destroyed).toBe(true);
        expect(docDestroySpy).toHaveBeenCalledTimes(1);
    });
    it('is idempotent — second destroy does not throw', () => {
        const ctx = createCollaborationProvider(baseOptions, { WebsocketProviderCtor: Ctor });
        ctx.destroy();
        expect(() => ctx.destroy()).not.toThrow();
    });
});
describe('createCollaborationProvider — offline persistence', () => {
    /** Stub IndexeddbPersistence that lets tests drive `whenSynced`. */
    class FakeIdb {
        static instances = [];
        name;
        ydoc;
        resolve;
        reject;
        whenSynced;
        destroyed = false;
        constructor(name, ydoc) {
            this.name = name;
            this.ydoc = ydoc;
            this.whenSynced = new Promise((res, rej) => {
                this.resolve = res;
                this.reject = rej;
            });
            FakeIdb.instances.push(this);
        }
        fulfill() {
            this.resolve();
        }
        fail(err) {
            this.reject(err);
        }
        destroy() {
            this.destroyed = true;
        }
    }
    const IdbCtor = FakeIdb;
    beforeEach(() => {
        FakeWsProvider.instances.length = 0;
        FakeIdb.instances.length = 0;
    });
    it('marks offlineStatus as unsupported when indexedDB is unavailable', () => {
        const onOfflineStatus = vi.fn();
        const ctx = createCollaborationProvider({ ...baseOptions, onOfflineStatus }, {
            WebsocketProviderCtor: Ctor,
            IndexeddbPersistenceCtor: IdbCtor,
            isIndexedDbSupported: () => false,
        });
        expect(onOfflineStatus).toHaveBeenCalledWith('unsupported');
        expect(ctx.offlinePersistence).toBeNull();
        expect(ctx.offlineStatus).toBe('unsupported');
        expect(FakeIdb.instances.length).toBe(0);
        ctx.destroy();
    });
    it('opts out entirely when offline: false is passed', () => {
        const onOfflineStatus = vi.fn();
        const ctx = createCollaborationProvider({ ...baseOptions, offline: false, onOfflineStatus }, {
            WebsocketProviderCtor: Ctor,
            IndexeddbPersistenceCtor: IdbCtor,
            isIndexedDbSupported: () => true,
        });
        expect(onOfflineStatus).toHaveBeenCalledWith('unsupported');
        expect(ctx.offlinePersistence).toBeNull();
        expect(FakeIdb.instances.length).toBe(0);
        ctx.destroy();
    });
    it('emits loading → ready when IndexedDB whenSynced resolves', async () => {
        const onOfflineStatus = vi.fn();
        const ctx = createCollaborationProvider({ ...baseOptions, onOfflineStatus }, {
            WebsocketProviderCtor: Ctor,
            IndexeddbPersistenceCtor: IdbCtor,
            isIndexedDbSupported: () => true,
        });
        expect(onOfflineStatus).toHaveBeenCalledWith('loading');
        expect(FakeIdb.instances.length).toBe(1);
        expect(FakeIdb.instances[0].name).toBe(`actuate-cms:${baseOptions.documentId}`);
        expect(ctx.offlinePersistence).toBe(FakeIdb.instances[0]);
        FakeIdb.instances[0].fulfill();
        await new Promise((r) => setTimeout(r, 0));
        expect(onOfflineStatus).toHaveBeenLastCalledWith('ready');
        expect(ctx.offlineStatus).toBe('ready');
        ctx.destroy();
        expect(FakeIdb.instances[0].destroyed).toBe(true);
    });
    it('emits error when whenSynced rejects', async () => {
        const onOfflineStatus = vi.fn();
        const onError = vi.fn();
        const ctx = createCollaborationProvider({ ...baseOptions, onOfflineStatus, onError }, {
            WebsocketProviderCtor: Ctor,
            IndexeddbPersistenceCtor: IdbCtor,
            isIndexedDbSupported: () => true,
        });
        FakeIdb.instances[0].fail(new Error('quota exceeded'));
        await new Promise((r) => setTimeout(r, 0));
        expect(onOfflineStatus).toHaveBeenLastCalledWith('error');
        expect(onError).toHaveBeenCalledTimes(1);
        expect(onError.mock.calls[0][0].message).toBe('quota exceeded');
        ctx.destroy();
    });
    it('emits error when IndexeddbPersistence constructor throws', () => {
        const onOfflineStatus = vi.fn();
        const onError = vi.fn();
        class ThrowingIdb {
            constructor() {
                throw new Error('IDB blocked');
            }
        }
        const ctx = createCollaborationProvider({ ...baseOptions, onOfflineStatus, onError }, {
            WebsocketProviderCtor: Ctor,
            IndexeddbPersistenceCtor: ThrowingIdb,
            isIndexedDbSupported: () => true,
        });
        expect(onOfflineStatus).toHaveBeenLastCalledWith('error');
        expect(onError).toHaveBeenCalledTimes(1);
        expect(onError.mock.calls[0][0].message).toBe('IDB blocked');
        expect(ctx.offlinePersistence).toBeNull();
        ctx.destroy();
    });
});
describe('summarisePresence', () => {
    function makeAwareness() {
        return new Awareness(new Y.Doc());
    }
    it('returns the local awareness clientId', () => {
        const aw = makeAwareness();
        const snap = summarisePresence(aw);
        expect(snap.selfClientId).toBe(aw.clientID);
    });
    it('includes peers that have a user object with a name', () => {
        const aw = makeAwareness();
        aw.setLocalState({ user: { name: 'Alice', color: '#fff', userId: 'u1' } });
        const snap = summarisePresence(aw);
        expect(snap.users).toHaveLength(1);
        expect(snap.users[0].user.name).toBe('Alice');
    });
    it('skips entries with no user info', () => {
        const aw = makeAwareness();
        aw.setLocalState({});
        const snap = summarisePresence(aw);
        expect(snap.users).toHaveLength(0);
    });
    it('deduplicates by userId when peers share the same logical user', () => {
        const aw = makeAwareness();
        const otherDoc = new Y.Doc();
        const otherAw = new Awareness(otherDoc);
        aw.setLocalState({ user: { name: 'Alice', color: '#fff', userId: 'u1' } });
        otherAw.setLocalState({ user: { name: 'Alice (tab 2)', color: '#000', userId: 'u1' } });
        // Manually pipe otherAw's state into aw to simulate the awareness fan-out.
        aw.getStates().set(otherAw.clientID, otherAw.getLocalState());
        const snap = summarisePresence(aw);
        // Both states share userId 'u1' → only one entry.
        expect(snap.users).toHaveLength(1);
    });
    it('keeps multiple peers without userId as distinct entries', () => {
        const aw = makeAwareness();
        aw.setLocalState({ user: { name: 'Alice', color: '#fff' } });
        aw.getStates().set(9999, { user: { name: 'Bob', color: '#000' } });
        const snap = summarisePresence(aw);
        expect(snap.users).toHaveLength(2);
        expect(snap.users.map((u) => u.user.name).sort()).toEqual(['Alice', 'Bob']);
    });
});
//# sourceMappingURL=collaboration-provider.test.js.map