import { beforeEach, describe, expect, it } from 'vitest';
import { createNotification, excerptForNotification, extractMentionedUserIds, isNotificationKind, listForUser as listNotificationsForUser, markAllRead as markAllNotificationsRead, markRead as markNotificationRead, unreadCount as notificationsUnreadCount, toNotificationDTO, } from '../../realtime/notifications.js';
/**
 * In-memory fake of the Prisma `documentNotification` delegate. The
 * production schema lives in `cms-schema.prisma`; this fake mirrors the
 * narrow interface declared in `notifications.ts`.
 */
function createFakeDB() {
    const rows = [];
    let nextId = 1;
    let now = new Date('2026-01-01T00:00:00.000Z').getTime();
    const advance = () => {
        now += 1000;
        return new Date(now);
    };
    const db = {
        documentNotification: {
            async create({ data }) {
                const row = {
                    id: `n-${nextId++}`,
                    userId: data.userId,
                    kind: data.kind,
                    documentId: data.documentId ?? null,
                    commentId: data.commentId ?? null,
                    payload: data.payload,
                    readAt: data.readAt ?? null,
                    createdAt: advance(),
                };
                rows.push(row);
                return row;
            },
            async findUnique({ where: { id } }) {
                return rows.find((r) => r.id === id) ?? null;
            },
            async findMany({ where, orderBy, take }) {
                let candidates = rows.filter((r) => r.userId === where.userId);
                if (where.readAt === null)
                    candidates = candidates.filter((r) => r.readAt === null);
                candidates = candidates.slice();
                candidates.sort((a, b) => orderBy?.createdAt === 'asc'
                    ? a.createdAt.getTime() - b.createdAt.getTime()
                    : b.createdAt.getTime() - a.createdAt.getTime());
                return typeof take === 'number' ? candidates.slice(0, take) : candidates;
            },
            async update({ where, data }) {
                const row = rows.find((r) => r.id === where.id);
                if (!row)
                    throw new Error('row not found');
                if (data.readAt !== undefined)
                    row.readAt = data.readAt;
                return row;
            },
            async updateMany({ where, data }) {
                let count = 0;
                for (const row of rows) {
                    if (row.userId !== where.userId)
                        continue;
                    if (where.readAt === null && row.readAt !== null)
                        continue;
                    row.readAt = data.readAt;
                    count += 1;
                }
                return { count };
            },
            async count({ where }) {
                return rows.filter((r) => r.userId === where.userId && r.readAt === null).length;
            },
        },
    };
    return { db, rows };
}
let fake = createFakeDB();
beforeEach(() => {
    fake = createFakeDB();
});
describe('isNotificationKind', () => {
    it('accepts the three known kinds', () => {
        expect(isNotificationKind('comment_reply')).toBe(true);
        expect(isNotificationKind('comment_resolved')).toBe(true);
        expect(isNotificationKind('comment_mention')).toBe(true);
    });
    it('rejects anything else', () => {
        expect(isNotificationKind('unknown')).toBe(false);
        expect(isNotificationKind('')).toBe(false);
        expect(isNotificationKind(null)).toBe(false);
    });
});
describe('excerptForNotification', () => {
    it('trims whitespace and collapses runs', () => {
        expect(excerptForNotification('  hello   world\n\n!  ')).toBe('hello world !');
    });
    it('clips to the max length with an ellipsis', () => {
        const long = 'x'.repeat(200);
        const out = excerptForNotification(long, 50);
        expect(out.length).toBe(50);
        expect(out.endsWith('…')).toBe(true);
    });
    it('leaves short strings alone', () => {
        expect(excerptForNotification('short')).toBe('short');
    });
});
describe('extractMentionedUserIds', () => {
    it('returns ids for @<id> tokens at boundaries', () => {
        expect(extractMentionedUserIds('cc @user_abc and @user_def!')).toEqual(['user_abc', 'user_def']);
    });
    it('deduplicates repeated mentions', () => {
        expect(extractMentionedUserIds('@user_a @user_a @user_a')).toEqual(['user_a']);
    });
    it('ignores too-short tokens', () => {
        expect(extractMentionedUserIds('@a @ab @abc')).toEqual(['abc']);
    });
    it('matches an @ at the very start of the body', () => {
        expect(extractMentionedUserIds('@user_xyz hello')).toEqual(['user_xyz']);
    });
    it('matches an @ after a newline boundary', () => {
        expect(extractMentionedUserIds('line one\n@user_z hi')).toEqual(['user_z']);
    });
    it('does NOT extract a mention from an email-shaped token (PR #159 fix)', () => {
        // `foo@bar` is an email address mid-word, not a mention. The
        // picker doesn't open for it client-side, so the server must
        // also refuse to extract — otherwise a user would type their
        // own email and accidentally notify someone whose id matches.
        expect(extractMentionedUserIds('contact foo@user_abc for details')).toEqual([]);
    });
    it('returns [] for empty / non-string input', () => {
        expect(extractMentionedUserIds('')).toEqual([]);
        expect(extractMentionedUserIds(null)).toEqual([]);
    });
});
describe('toNotificationDTO', () => {
    it('re-tags the payload with the kind discriminator', () => {
        const row = {
            id: 'n-1',
            userId: 'u-1',
            kind: 'comment_reply',
            documentId: 'doc-1',
            commentId: 'c-1',
            payload: { actorId: 'u-2', actorName: 'Alice', excerpt: 'hi' },
            readAt: null,
            createdAt: new Date('2026-01-01T00:00:00.000Z'),
        };
        const dto = toNotificationDTO(row);
        expect(dto.payload).toEqual({
            kind: 'comment_reply',
            actorId: 'u-2',
            actorName: 'Alice',
            excerpt: 'hi',
        });
        expect(dto.readAt).toBeNull();
        expect(dto.createdAt).toBe('2026-01-01T00:00:00.000Z');
    });
});
describe('createNotification', () => {
    it('persists a comment_reply notification', async () => {
        const out = await createNotification(fake.db, {
            userId: 'u-1',
            commentId: 'c-1',
            documentId: 'doc-1',
            payload: {
                kind: 'comment_reply',
                actorId: 'u-2',
                actorName: 'Alice',
                excerpt: 'hi',
            },
        });
        expect(out.ok).toBe(true);
        if (!out.ok)
            return;
        expect(out.value.kind).toBe('comment_reply');
        expect(fake.rows).toHaveLength(1);
        expect(fake.rows[0].kind).toBe('comment_reply');
        // Kind tag is stripped from the stored payload (lives in the
        // dedicated column).
        expect(fake.rows[0].payload.kind).toBeUndefined();
    });
    it('rejects an unknown kind', async () => {
        const out = await createNotification(fake.db, {
            userId: 'u-1',
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            payload: { kind: 'nonsense', actorId: 'u-2', actorName: 'Alice', excerpt: 'x' },
        });
        expect(out.ok).toBe(false);
        if (out.ok)
            return;
        expect(out.error.code).toBe('validation');
    });
    it('rejects a comment_reply without an excerpt', async () => {
        const out = await createNotification(fake.db, {
            userId: 'u-1',
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            payload: { kind: 'comment_reply', actorId: 'u-2', actorName: 'Alice' },
        });
        expect(out.ok).toBe(false);
    });
    it('rejects a comment_resolved without a rootExcerpt', async () => {
        const out = await createNotification(fake.db, {
            userId: 'u-1',
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            payload: { kind: 'comment_resolved', actorId: 'u-2', actorName: 'Alice' },
        });
        expect(out.ok).toBe(false);
    });
    it('rejects an empty userId', async () => {
        const out = await createNotification(fake.db, {
            userId: '',
            payload: {
                kind: 'comment_reply',
                actorId: 'u-2',
                actorName: 'Alice',
                excerpt: 'hi',
            },
        });
        expect(out.ok).toBe(false);
    });
});
describe('listForUser / unreadCount', () => {
    beforeEach(async () => {
        await createNotification(fake.db, {
            userId: 'u-1',
            payload: { kind: 'comment_reply', actorId: 'u-2', actorName: 'A', excerpt: 'a' },
        });
        await createNotification(fake.db, {
            userId: 'u-1',
            payload: { kind: 'comment_reply', actorId: 'u-2', actorName: 'A', excerpt: 'b' },
        });
        await createNotification(fake.db, {
            userId: 'u-2',
            payload: { kind: 'comment_reply', actorId: 'u-1', actorName: 'B', excerpt: 'c' },
        });
    });
    it('returns only the target user notifications, newest first, unread only', async () => {
        const out = await listNotificationsForUser(fake.db, { userId: 'u-1' });
        expect(out.ok).toBe(true);
        if (!out.ok)
            return;
        expect(out.value.map((n) => n.payload.kind === 'comment_resolved' ? n.payload.rootExcerpt : n.payload.excerpt)).toEqual(['b', 'a']);
    });
    it('honours pageSize', async () => {
        const out = await listNotificationsForUser(fake.db, { userId: 'u-1', pageSize: 1 });
        expect(out.ok).toBe(true);
        if (!out.ok)
            return;
        expect(out.value).toHaveLength(1);
    });
    it('clamps pageSize at the upper bound', async () => {
        const out = await listNotificationsForUser(fake.db, {
            userId: 'u-1',
            pageSize: 999999,
        });
        expect(out.ok).toBe(true);
    });
    it('unreadCount matches the count of unread rows', async () => {
        const out = await notificationsUnreadCount(fake.db, 'u-1');
        expect(out.ok).toBe(true);
        if (!out.ok)
            return;
        expect(out.value).toBe(2);
    });
    it('rejects missing userId', async () => {
        expect((await listNotificationsForUser(fake.db, { userId: '' })).ok).toBe(false);
        expect((await notificationsUnreadCount(fake.db, '')).ok).toBe(false);
    });
});
describe('markRead', () => {
    it('marks an unread row as read', async () => {
        const created = await createNotification(fake.db, {
            userId: 'u-1',
            payload: { kind: 'comment_reply', actorId: 'u-2', actorName: 'A', excerpt: 'x' },
        });
        if (!created.ok)
            throw new Error('setup failed');
        const out = await markNotificationRead(fake.db, created.value.id, { userId: 'u-1' });
        expect(out.ok).toBe(true);
        if (!out.ok)
            return;
        expect(out.value.readAt).not.toBeNull();
    });
    it('is idempotent — marking an already-read row returns the same DTO', async () => {
        const created = await createNotification(fake.db, {
            userId: 'u-1',
            payload: { kind: 'comment_reply', actorId: 'u-2', actorName: 'A', excerpt: 'x' },
        });
        if (!created.ok)
            throw new Error('setup failed');
        await markNotificationRead(fake.db, created.value.id, { userId: 'u-1' });
        const second = await markNotificationRead(fake.db, created.value.id, { userId: 'u-1' });
        expect(second.ok).toBe(true);
    });
    it("forbids marking another user's notification", async () => {
        const created = await createNotification(fake.db, {
            userId: 'u-1',
            payload: { kind: 'comment_reply', actorId: 'u-2', actorName: 'A', excerpt: 'x' },
        });
        if (!created.ok)
            throw new Error('setup failed');
        const out = await markNotificationRead(fake.db, created.value.id, { userId: 'u-2' });
        expect(out.ok).toBe(false);
        if (out.ok)
            return;
        expect(out.error.code).toBe('forbidden');
    });
    it('returns not_found for an unknown id', async () => {
        const out = await markNotificationRead(fake.db, 'unknown', { userId: 'u-1' });
        expect(out.ok).toBe(false);
        if (out.ok)
            return;
        expect(out.error.code).toBe('not_found');
    });
});
describe('markAllRead', () => {
    it('clears the unread queue', async () => {
        for (let i = 0; i < 5; i++) {
            await createNotification(fake.db, {
                userId: 'u-1',
                payload: { kind: 'comment_reply', actorId: 'u-2', actorName: 'A', excerpt: `${i}` },
            });
        }
        const out = await markAllNotificationsRead(fake.db, 'u-1');
        expect(out.ok).toBe(true);
        if (!out.ok)
            return;
        expect(out.value.count).toBe(5);
        const remaining = await notificationsUnreadCount(fake.db, 'u-1');
        expect(remaining.ok && remaining.value).toBe(0);
    });
    it('rejects missing userId', async () => {
        expect((await markAllNotificationsRead(fake.db, '')).ok).toBe(false);
    });
});
//# sourceMappingURL=notifications.test.js.map