import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from '../test-utils/reset-db';
import { makeFakePorts } from '@insignia/iios-testkit';
import { IIOS_EVENTS, type CloudEvent } from '@insignia/iios-contracts';
import { MessageService, type MessagePrincipal } from '../messaging/message.service';
import { SupportService } from './support.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { AssignmentService } from './assignment.service';
import { ActorResolver } from '../identity/actor.resolver';
import { OutboxBus } from '../outbox/outbox.bus';
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 support = () => new SupportService(asService, makeFakePorts(), actors, new IdempotencyService(asService));
const assignment = () => new AssignmentService(asService, new OutboxBus(), actors);
const msg = () => new MessageService(asService, makeFakePorts(), actors);

const cust: MessagePrincipal = { userId: 'cust', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Customer' };
const agentA: MessagePrincipal = { userId: 'agentA', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Agent A' };
const agentB: MessagePrincipal = { userId: 'agentB', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Agent B' };

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;
}

/** Create a queue and add the given agents as AVAILABLE members. */
async function seedQueue(agents: MessagePrincipal[]): Promise<string> {
  const asg = assignment();
  const queue = await asg.createQueue(cust, 'Support'); // any principal in the scope
  for (const a of agents) {
    await asg.addMember(queue.id, a);
    await asg.setAvailability(a, 'AVAILABLE');
  }
  return queue.id;
}

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

describe('AssignmentService (P4)', () => {
  it('assigns an escalated ticket to an available agent; agent joins the thread; base thread unchanged', async () => {
    await seedQueue([agentA]);
    const { threadId } = await msg().openThread(null, cust);
    const ticket = await support().escalate(threadId, cust);

    const asg = assignment();
    expect(await asg.assignTicket(ticket.id)).toBe(true);

    const updated = await prisma.iiosTicket.findUniqueOrThrow({ where: { id: ticket.id } });
    expect(updated.state).toBe('OPEN');
    expect(updated.assignedActorId).toBe(await actorIdFor('agentA'));

    // agent is now a participant on the (still-generic) thread
    const part = await prisma.iiosThreadParticipant.findUnique({
      where: { threadId_actorId: { threadId, actorId: await actorIdFor('agentA') } },
    });
    expect(part).not.toBeNull();
    const thread = await prisma.iiosThread.findUniqueOrThrow({ where: { id: threadId } });
    expect(thread.status).toBe('OPEN'); // base thread never mutated by support
  });

  it('picks the least-loaded agent within capacity', async () => {
    const queueId = await seedQueue([agentA, agentB]);
    // Pre-load agentA with one active ticket so agentB is least-loaded.
    await prisma.iiosSupportTeamMember.update({
      where: { queueId_actorId: { queueId, actorId: await actorIdFor('agentA') } },
      data: { activeCount: 1 },
    });
    const t = await support().createTicket(cust, { subject: 's' });
    await assignment().assignTicket(t.id);
    const updated = await prisma.iiosTicket.findUniqueOrThrow({ where: { id: t.id } });
    expect(updated.assignedActorId).toBe(await actorIdFor('agentB'));
  });

  it('leaves the ticket NEW when no agent is available', async () => {
    await seedQueue([agentA]);
    await assignment().setAvailability(agentA, 'OFFLINE');
    const t = await support().createTicket(cust, { subject: 's' });
    expect(await assignment().assignTicket(t.id)).toBe(false);
    expect((await prisma.iiosTicket.findUniqueOrThrow({ where: { id: t.id } })).state).toBe('NEW');
  });

  it('is idempotent: replaying ticket.created assigns once (no double activeCount)', async () => {
    const queueId = await seedQueue([agentA]);
    const t = await support().createTicket(cust, { subject: 's' });
    const [event] = (await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.ticketCreated } })).map(
      (r) => r.cloudEvent as unknown as CloudEvent,
    );
    const asg = assignment();
    await asg.onTicketCreated(event);
    await asg.onTicketCreated(event); // replay
    const member = await prisma.iiosSupportTeamMember.findFirstOrThrow({
      where: { queueId, actorId: await actorIdFor('agentA') },
    });
    expect(member.activeCount).toBe(1);
  });
});
