import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { ConflictException } from '@nestjs/common';
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, type CloudEvent } from '@insignia/iios-contracts';
import { signedFixture, emailFixture, emailInboundFixture, emailReplyFixture } from '@insignia/iios-adapter-sdk';
import { AdapterRegistry } from './adapter.registry';
import { InboundService } from './inbound.service';
import { OutboundService } from './outbound.service';
import { IdempotencyService } from '../idempotency/idempotency.service';
import { CapabilityBroker } from '../capability/capability.broker';
import { CapabilityProviderRegistry } from '../capability/capability.registry';
import { RawEventProjector } from './raw-event.projector';
import { IngestService } from '../interactions/ingest.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 SECRET = 'dev-adapter-secret'; // AdapterRegistry default

const registry = () => new AdapterRegistry();
const inbound = () => new InboundService(asService, registry());
const projector = () =>
  new RawEventProjector(asService, new OutboxBus(), new DlqService(asService), registry(), new IngestService(asService, makeFakePorts(), new ActorResolver(asService)), new ProjectionCursorService(asService));

async function rawReceivedEvents(): Promise<CloudEvent[]> {
  const rows = await prisma.iiosOutboxEvent.findMany({ where: { eventType: IIOS_EVENTS.rawReceived }, 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 resetDb(prisma); });

describe('Adapter inbound (P5)', () => {
  it('signed webhook → raw stored, then projector normalizes → interaction with the text + traceId', async () => {
    const { body, headers } = signedFixture(emailFixture, SECRET);
    const ack = await inbound().receive('WEBHOOK', body, headers);
    expect(ack.status).toBe('RECEIVED');

    const raw = await prisma.iiosInboundRawEvent.findUniqueOrThrow({ where: { id: ack.rawEventId } });
    expect(raw.signatureStatus).toBe('VERIFIED');

    await projector().onRawReceived((await rawReceivedEvents())[0]!);

    const after = await prisma.iiosInboundRawEvent.findUniqueOrThrow({ where: { id: ack.rawEventId } });
    expect(after.status).toBe('NORMALIZED');
    expect(after.interactionId).toBeTruthy();

    const interaction = await prisma.iiosInteraction.findUniqueOrThrow({
      where: { id: after.interactionId! },
      include: { parts: true },
    });
    expect(interaction.parts[0]?.bodyText).toBe(emailFixture.text);
    expect(interaction.traceId).toBe(raw.traceId); // trace flows raw → interaction
  });

  it('bad signature → REJECTED, nothing ingested (fail-closed)', async () => {
    const { body } = signedFixture(emailFixture, SECRET);
    await expect(inbound().receive('WEBHOOK', body, { 'x-iios-signature': 'sha256=bad' })).rejects.toThrow();
    expect(await prisma.iiosInboundRawEvent.count({ where: { status: 'REJECTED' } })).toBe(1);
    expect(await prisma.iiosInteraction.count()).toBe(0);
  });

  it('replays: same provider eventId twice → one raw event, one interaction', async () => {
    const { body, headers } = signedFixture(emailFixture, SECRET);
    const svc = inbound();
    await svc.receive('WEBHOOK', body, headers);
    const second = await svc.receive('WEBHOOK', body, headers);
    expect(second.deduped).toBe(true);

    await projector().onRawReceived((await rawReceivedEvents())[0]!);
    expect(await prisma.iiosInboundRawEvent.count()).toBe(1);
    expect(await prisma.iiosInteraction.count()).toBe(1);
  });
});

describe('Email adapter — inbound + threading', () => {
  const receiveEmail = async (payload: object) => {
    const { body, headers } = signedFixture(payload, SECRET);
    return inbound().receive('EMAIL', body, headers);
  };
  const normalizeAll = async () => {
    for (const ev of await rawReceivedEvents()) await projector().onRawReceived(ev);
  };

  it('a signed inbound email → a normalized interaction with the body on the email thread', async () => {
    const ack = await receiveEmail(emailInboundFixture);
    expect(ack.status).toBe('RECEIVED');
    await normalizeAll();

    const raw = await prisma.iiosInboundRawEvent.findUniqueOrThrow({ where: { id: ack.rawEventId } });
    expect(raw.status).toBe('NORMALIZED');
    const interaction = await prisma.iiosInteraction.findUniqueOrThrow({ where: { id: raw.interactionId! }, include: { parts: true, thread: true } });
    expect(interaction.parts[0]?.bodyText).toBe(emailInboundFixture.text);
    expect(interaction.thread?.externalThreadRef).toBe('email:<m1@example.com>');
  });

  it('a reply email (In-Reply-To) lands on the SAME email thread as the original', async () => {
    await receiveEmail(emailInboundFixture);
    await receiveEmail(emailReplyFixture);
    await normalizeAll();

    const interactions = await prisma.iiosInteraction.findMany({ orderBy: { occurredAt: 'asc' } });
    expect(interactions).toHaveLength(2);
    expect(interactions[0]!.threadId).toBe(interactions[1]!.threadId); // reply joined the thread
    expect(await prisma.iiosThread.count()).toBe(1); // one email conversation
  });

  it('redelivered email (same Message-ID) → one raw event, one interaction (dedup)', async () => {
    const svc = inbound();
    const { body, headers } = signedFixture(emailInboundFixture, SECRET);
    await svc.receive('EMAIL', body, headers);
    const second = await svc.receive('EMAIL', body, headers);
    expect(second.deduped).toBe(true);
    await normalizeAll();
    expect(await prisma.iiosInboundRawEvent.count()).toBe(1);
    expect(await prisma.iiosInteraction.count()).toBe(1);
  });

  it('bad signature on EMAIL → REJECTED, nothing ingested (fail-closed)', async () => {
    const { body } = signedFixture(emailInboundFixture, SECRET);
    await expect(inbound().receive('EMAIL', body, { 'x-iios-signature': 'sha256=bad' })).rejects.toThrow();
    expect(await prisma.iiosInboundRawEvent.count({ where: { status: 'REJECTED' } })).toBe(1);
    expect(await prisma.iiosInteraction.count()).toBe(0);
  });
});

describe('Adapter outbound (P5)', () => {
  const outbound = (limit?: number): OutboundService => {
    const s = new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
    if (limit) s.limit = limit;
    return s;
  };

  it('send → command SENT + one delivery attempt (sandbox, no network)', async () => {
    const cmd = await outbound().send('WEBHOOK', 'user@x', { text: 'hi' }, 'ob-1');
    expect(cmd.status).toBe('SENT');
    expect(cmd.providerRef).toContain('sim-');
    expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id } })).toBe(1);
  });

  it('rate limit: exceeding the window → RATE_LIMITED', async () => {
    const svc = outbound(2);
    await svc.send('WEBHOOK', 't', {}, 'a');
    await svc.send('WEBHOOK', 't', {}, 'b');
    const third = await svc.send('WEBHOOK', 't', {}, 'c');
    expect(third.status).toBe('RATE_LIMITED');
  });

  it('idempotent: same key twice → one command', async () => {
    const svc = outbound();
    const a = await svc.send('WEBHOOK', 't2', {}, 'k');
    const b = await svc.send('WEBHOOK', 't2', {}, 'k');
    expect(b.id).toBe(a.id);
    expect(await prisma.iiosOutboundCommand.count()).toBe(1);
  });

  it('broker obligation DENY_OUTBOUND → command BLOCKED, no send (P9)', async () => {
    const ports = makeFakePorts();
    ports.opa.setDecision({ allow: true, obligations: [{ kind: 'DENY_OUTBOUND', reason: 'recipient opted out' }] });
    const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
    const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'blk-1');
    expect(cmd.status).toBe('BLOCKED');
    expect(cmd.providerRef).toBe('blocked');
  });

  it('CMP allow → command records the consent receipt (P9 slice 2)', async () => {
    const ports = makeFakePorts();
    ports.cmp.setStatus('ALLOW');
    const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
    const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'hi' }, 'consent-1', 'scope1', 'support');
    expect(cmd.status).toBe('SENT');
    expect(cmd.consentReceiptRef).toBe('fake-receipt');
  });

  it('CMP DENY for the purpose → command BLOCKED, no send (P9 slice 2)', async () => {
    const ports = makeFakePorts();
    ports.cmp.denyPurpose('marketing');
    const svc = new OutboundService(asService, new CapabilityBroker(ports, new CapabilityProviderRegistry()), new IdempotencyService(asService));
    const cmd = await svc.send('WEBHOOK', 'user@x', { text: 'promo' }, 'consent-2', 'scope1', 'marketing');
    expect(cmd.status).toBe('BLOCKED');
    expect(await prisma.iiosDeliveryAttempt.count({ where: { commandId: cmd.id, status: 'SENT' } })).toBe(0);
  });

  it('per-tenant quota: a noisy tenant is capped while another still sends (P9 slice 3)', async () => {
    const svc = outbound();
    svc.tenantLimit = 1;
    const a1 = await svc.send('WEBHOOK', 't1', {}, 'ta-1', 'scope-A');
    const a2 = await svc.send('WEBHOOK', 't2', {}, 'ta-2', 'scope-A'); // A over its cap
    const b1 = await svc.send('WEBHOOK', 't3', {}, 'tb-1', 'scope-B'); // B unaffected
    expect(a1.status).toBe('SENT');
    expect(a2.status).toBe('RATE_LIMITED');
    expect(b1.status).toBe('SENT');
  });

  it('idempotency ledger: same key + a different target → conflict, no second command (P9 slice 10)', async () => {
    const svc = outbound();
    const first = await svc.send('WEBHOOK', 'user@a', { text: 'hi' }, 'dup-key');
    expect(first.status).toBe('SENT');
    await expect(svc.send('WEBHOOK', 'user@b', { text: 'hi' }, 'dup-key')).rejects.toBeInstanceOf(ConflictException);
    expect(await prisma.iiosOutboundCommand.count({ where: { idempotencyKey: 'dup-key' } })).toBe(1);
  });

  it('opt-in: a keyless send creates a command but writes no idempotency-ledger row (P9 slice 10)', async () => {
    const cmd = await outbound().send('WEBHOOK', 'user@x', { text: 'hi' });
    expect(cmd.status).toBe('SENT');
    expect(await prisma.iiosIdempotencyCommand.count()).toBe(0);
  });
});
