import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { DlqService } from '../outbox/dlq.service';
import { resetDb } from '../test-utils/reset-db';
import { ProjectionCursorService } from '../projection/projection-cursor.service';
import { makeFakePorts } from '@insignia/iios-testkit';
import { IIOS_EVENTS, PolicyDeniedError, type CloudEvent } from '@insignia/iios-contracts';
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
import { ActorResolver } from '../identity/actor.resolver';
import { OutboxBus } from '../outbox/outbox.bus';
import { InboxProjector } from './inbox.projector';
import { InboxService } from './inbox.service';
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 projector = () => new InboxProjector(asService, new OutboxBus(), new DlqService(asService), new ProjectionCursorService(asService));

const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' };
const bob: MessagePrincipal = { userId: 'bob', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Bob' };

async function clean(): Promise<void> {
  await resetDb(prisma);
}

async function actorIdFor(userId: string): Promise<string> {
  const handle = await prisma.iiosSourceHandle.findFirstOrThrow({ where: { externalId: userId } });
  const actor = await prisma.iiosActorRef.findFirstOrThrow({ where: { sourceHandleId: handle.id } });
  return actor.id;
}
async function itemsFor(userId: string) {
  return prisma.iiosInboxItem.findMany({ where: { ownerActorId: await actorIdFor(userId) } });
}
async function eventsOf(type: string) {
  const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: type }, orderBy: { createdAt: 'asc' } });
  return rows.map((r) => r.cloudEvent as unknown as CloudEvent);
}

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

describe('InboxProjector (P3 work surface)', () => {
  it('message.sent → NEEDS_REPLY for the recipient (not the sender), with the message traceId', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice);
    await m.openThread(threadId, bob);
    const sent = await m.send(threadId, alice, { content: 'hi bob' }, 'k1');

    const [event] = await eventsOf(IIOS_EVENTS.messageSent);
    await projector().onMessageSent(event);

    const bobItems = await itemsFor('bob');
    expect(bobItems).toHaveLength(1);
    expect(bobItems[0]?.kind).toBe('NEEDS_REPLY');
    expect(bobItems[0]?.state).toBe('OPEN');
    expect(bobItems[0]?.traceId).toBe(sent.traceId);
    expect(await itemsFor('alice')).toHaveLength(0);
  });

  it('collapses per thread: two messages → still 1 open item', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice);
    await m.openThread(threadId, bob);
    await m.send(threadId, alice, { content: '1' }, 'k1');
    await m.send(threadId, alice, { content: '2' }, 'k2');

    const proj = projector();
    for (const e of await eventsOf(IIOS_EVENTS.messageSent)) await proj.onMessageSent(e);

    expect(await itemsFor('bob')).toHaveLength(1);
  });

  it('is idempotent: replaying the same event id creates no duplicate', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice);
    await m.openThread(threadId, bob);
    await m.send(threadId, alice, { content: 'hi' }, 'k1');

    const [event] = await eventsOf(IIOS_EVENTS.messageSent);
    const proj = projector();
    await proj.onMessageSent(event);
    await proj.onMessageSent(event); // replay

    expect(await itemsFor('bob')).toHaveLength(1);
  });

  it('message.sent with mentions → a MENTION item for the mentioned participant only', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
    await m.addParticipant(threadId, alice, 'bob');
    await m.addParticipant(threadId, alice, 'carol');
    await m.send(threadId, alice, { content: 'hey @bob look' }, 'k1', undefined, undefined, ['bob']);

    await projector().onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);

    const bobMentions = (await itemsFor('bob')).filter((i) => i.kind === 'MENTION');
    expect(bobMentions).toHaveLength(1);
    expect(bobMentions[0]?.state).toBe('OPEN');
    expect(bobMentions[0]?.sourceInteractionId).toBeTruthy();
    expect((await itemsFor('carol')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // not mentioned
    expect((await itemsFor('alice')).filter((i) => i.kind === 'MENTION')).toHaveLength(0); // never the sender
  });

  it('reading the thread resolves the reader’s MENTION item to DONE', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
    await m.addParticipant(threadId, alice, 'bob');
    const sent = await m.send(threadId, alice, { content: '@bob ping' }, 'k1', undefined, undefined, ['bob']);

    const proj = projector();
    await proj.onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
    expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('OPEN');

    await m.markRead(threadId, bob, sent.id);
    await proj.onMessageRead((await eventsOf(IIOS_EVENTS.messageRead))[0]!);
    expect((await itemsFor('bob')).filter((i) => i.kind === 'MENTION')[0]?.state).toBe('DONE');
  });

  it('message.read → the recipient item goes DONE', async () => {
    const m = ms();
    const { threadId } = await m.openThread(null, alice);
    await m.openThread(threadId, bob);
    const sent = await m.send(threadId, alice, { content: 'hi' }, 'k1');

    const proj = projector();
    await proj.onMessageSent((await eventsOf(IIOS_EVENTS.messageSent))[0]!);
    expect((await itemsFor('bob'))[0]?.state).toBe('OPEN');

    await m.markRead(threadId, bob, sent.id);
    await proj.onMessageRead((await eventsOf(IIOS_EVENTS.messageRead))[0]!);

    expect((await itemsFor('bob'))[0]?.state).toBe('DONE');
    const history = await prisma.iiosInboxItemStateHistory.findMany({ where: { toState: 'DONE' } });
    expect(history.length).toBeGreaterThanOrEqual(1);
  });
});

describe('InboxService (state machine + list)', () => {
  const inbox = (ports = makeFakePorts()) => new InboxService(asService, ports, actors);

  async function makeItemForBob(): Promise<string> {
    const { threadId } = await ms().openThread(null, bob);
    const scope = await prisma.iiosScope.findFirstOrThrow();
    const item = await prisma.iiosInboxItem.create({
      data: { scopeId: scope.id, ownerActorId: await actorIdFor('bob'), kind: 'NEEDS_REPLY', title: 't', threadId },
    });
    return item.id;
  }

  it('snooze / done / reopen / stale transitions land correctly and write history', async () => {
    const svc = inbox();
    const id = await makeItemForBob();
    await svc.transition(id, bob, 'SNOOZED');
    await svc.transition(id, bob, 'DONE');
    await svc.transition(id, bob, 'OPEN'); // reopen
    await svc.transition(id, bob, 'STALE');
    expect((await prisma.iiosInboxItem.findUniqueOrThrow({ where: { id } })).state).toBe('STALE');
    expect((await prisma.iiosInboxItemStateHistory.findMany({ where: { inboxItemId: id } })).length).toBe(4);
  });

  it('rejects an illegal transition', async () => {
    const svc = inbox();
    const id = await makeItemForBob();
    await svc.transition(id, bob, 'ARCHIVED');
    await expect(svc.transition(id, bob, 'OPEN')).rejects.toThrow(); // ARCHIVED has no out-transitions
  });

  it('list returns the owner OPEN items and is fail-closed', async () => {
    await makeItemForBob();
    expect((await inbox().list(bob, { state: 'OPEN' })).length).toBe(1);
    const denied = makeFakePorts();
    denied.opa.deny();
    await expect(inbox(denied).list(bob, {})).rejects.toBeInstanceOf(PolicyDeniedError);
  });
});
