import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
import { makeFakePorts } from '@insignia/iios-testkit';
import { resetDb } from '../test-utils/reset-db';
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
import { ActorResolver } from '../identity/actor.resolver';
import { OutboxBus } from '../outbox/outbox.bus';
import { DlqService } from '../outbox/dlq.service';
import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { PresenceService } from './presence.service';
import { NotificationProjector } from './notification.projector';
import type { PrismaService } from '../prisma/prisma.service';

const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/iios?schema=public';
const prisma = new PrismaClient({ datasources: { db: { url } } });
const asService = prisma as unknown as PrismaService;
const actors = new ActorResolver(asService);
const ms = () => new MessageService(asService, makeFakePorts(), actors);
const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };

async function actorIdFor(userId: string): Promise<string> {
  const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: userId } });
  return (await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } })).id;
}
async function seedSub(userId: string): Promise<string> {
  const actorId = await actorIdFor(userId);
  const scope = await prisma.iiosScope.findFirstOrThrow();
  await prisma.iiosNotificationSubscription.create({ data: { scopeId: scope.id, actorId, kind: 'webpush', endpoint: `https://push/${userId}`, p256dh: 'k', auth: 'a' } });
  return actorId;
}
async function sentEvents(): Promise<CloudEvent[]> {
  const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.messageSent }, orderBy: { createdAt: 'asc' } });
  return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
}
/** A synthetic interaction.normalized event (the shape ingest/mail emits) for an existing interaction. */
function normalizedEvent(interactionId: string, threadId: string, kind: string): CloudEvent {
  return {
    specversion: '1.0',
    id: `evt_norm_${interactionId}`,
    type: IIOS_EVENTS.interactionNormalized,
    source: 'iios/ingest/test',
    time: '2026-01-01T00:00:00.000Z',
    insignia: { idempotencyKey: `norm:${interactionId}` },
    data: { interactionId, threadId, kind },
  } as CloudEvent;
}
function makeProjector(presence = new PresenceService(), deliverResult: 'sent' | 'gone' = 'sent') {
  const deliver = vi.fn().mockResolvedValue(deliverResult);
  const proj = new NotificationProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService), presence, { deliver } as never);
  return { proj, deliver, presence };
}

beforeAll(async () => { await prisma.$connect(); });
afterAll(async () => { await prisma.$disconnect(); });
beforeEach(async () => { await resetDb(prisma); });

describe('NotificationProjector', () => {
  it('DM: notifies the recipient (absent), never the sender', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
    await m.addParticipant(threadId, alice, 'bob');
    await seedSub('bob');
    await m.send(threadId, alice, { content: 'hi bob' }, 'k1');
    const { proj, deliver } = makeProjector();
    await proj.onEvent((await sentEvents())[0]!);
    expect(deliver).toHaveBeenCalledOnce();
    expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
  });

  it('group without a mention: does NOT notify', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
    await m.addParticipant(threadId, alice, 'bob');
    await seedSub('bob');
    await m.send(threadId, alice, { content: 'hello all' }, 'k1');
    const { proj, deliver } = makeProjector();
    await proj.onEvent((await sentEvents())[0]!);
    expect(deliver).not.toHaveBeenCalled();
  });

  it('group WITH a mention: notifies the mentioned member', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
    await m.addParticipant(threadId, alice, 'bob');
    await seedSub('bob');
    await m.send(threadId, alice, { content: 'hey @bob' }, 'k1', undefined, undefined, ['bob']);
    const { proj, deliver } = makeProjector();
    await proj.onEvent((await sentEvents())[0]!);
    expect(deliver).toHaveBeenCalledOnce();
  });

  it('group reply-to-you: notifies the parent author even without a mention', async () => {
    const m = ms();
    const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };
    const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
    await m.addParticipant(threadId, alice, 'bob');
    await seedSub('bob');
    const parent = await m.send(threadId, bob, { content: 'question?' }, 'k1'); // bob authors the parent
    await m.send(threadId, alice, { content: 'answer' }, 'k2', undefined, parent.id); // alice replies to bob (no mention)
    const { proj, deliver } = makeProjector();
    const evs = await sentEvents();
    await proj.onEvent(evs[evs.length - 1]!); // project the reply
    expect(deliver).toHaveBeenCalledOnce();
    expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
  });

  it('presence gate: a recipient viewing the thread is NOT notified', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
    await m.addParticipant(threadId, alice, 'bob');
    await seedSub('bob');
    await m.send(threadId, alice, { content: 'hi' }, 'k1');
    const presence = new PresenceService();
    presence.setFocus('sockB', 'bob', threadId);
    const { proj, deliver } = makeProjector(presence);
    await proj.onEvent((await sentEvents())[0]!);
    expect(deliver).not.toHaveBeenCalled();
  });

  it('mute gate: a muted recipient is NOT notified', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
    await m.addParticipant(threadId, alice, 'bob');
    const bobActorId = await seedSub('bob');
    await prisma.iiosThreadParticipant.update({ where: { threadId_actorId: { threadId, actorId: bobActorId } }, data: { muted: true } });
    await m.send(threadId, alice, { content: 'hi' }, 'k1');
    const { proj, deliver } = makeProjector();
    await proj.onEvent((await sentEvents())[0]!);
    expect(deliver).not.toHaveBeenCalled();
  });

  it('mail (interaction.normalized, kind EMAIL): notifies the recipient even in a group thread', async () => {
    // A group thread would NOT notify bob on message.sent (proven above); the EMAIL branch always does.
    const m = ms();
    const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
    await m.addParticipant(threadId, alice, 'bob');
    await seedSub('bob');
    const sent = await m.send(threadId, alice, { content: 'your invoice is attached' }, 'k1');
    const { proj, deliver } = makeProjector();
    await proj.onEvent(normalizedEvent(sent.id, threadId, 'EMAIL'));
    expect(deliver).toHaveBeenCalledOnce();
    expect(deliver.mock.calls[0][0].endpoint).toContain('push/bob');
  });

  it('interaction.normalized that is NOT mail (kind MESSAGE): does not notify', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
    await m.addParticipant(threadId, alice, 'bob');
    await seedSub('bob');
    const sent = await m.send(threadId, alice, { content: 'hello all' }, 'k1');
    const { proj, deliver } = makeProjector();
    await proj.onEvent(normalizedEvent(sent.id, threadId, 'MESSAGE'));
    expect(deliver).not.toHaveBeenCalled();
  });

  it('prunes a subscription that returns "gone"', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice, { membership: 'dm' });
    await m.addParticipant(threadId, alice, 'bob');
    await seedSub('bob');
    await m.send(threadId, alice, { content: 'hi' }, 'k1');
    const { proj } = makeProjector(new PresenceService(), 'gone');
    await proj.onEvent((await sentEvents())[0]!);
    expect(await prisma.iiosNotificationSubscription.count()).toBe(0);
  });
});
