import { randomUUID } from 'node:crypto';
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { PrismaClient } from '@prisma/client';
import { resetDb } from './test-utils/reset-db';
import { makeFakePorts, makeFakeInference } from '@insignia/iios-testkit';
import { MessageService, type MessagePrincipal } from './messaging/message.service';
import { ActorResolver } from './identity/actor.resolver';
import { RuleEngine } from './routing/rule-engine';
import { RouteSimulator } from './routing/route-simulator';
import { RouteService } from './routing/route.service';
import { AiJobService } from './ai/ai.service';
import { AiBudgetGuard } from './ai/ai-budget.guard';
import { CalendarService } from './calendar/calendar.service';
import { OutboundService } from './adapters/outbound.service';
import { IdempotencyService } from './idempotency/idempotency.service';
import { CapabilityBroker } from './capability/capability.broker';
import { CapabilityProviderRegistry } from './capability/capability.registry';
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);

// Two tenants on the same app, different orgs → different scopes.
const A: MessagePrincipal = { userId: 'a1', orgId: 'org_A', appId: 'portal-demo', displayName: 'A' };
const B: MessagePrincipal = { userId: 'b1', orgId: 'org_B', appId: 'portal-demo', displayName: 'B' };
const START = '2026-07-02T10:00:00.000Z';

const ai = () => new AiJobService(asService, makeFakePorts(), makeFakeInference(), new AiBudgetGuard(asService), actors);
const outbound = () => new OutboundService(asService, new CapabilityBroker(makeFakePorts(), new CapabilityProviderRegistry()), new IdempotencyService(asService));
const routeSvc = () => new RouteService(asService, new RouteSimulator(asService, new RuleEngine()), outbound(), actors);
const cal = () => new CalendarService(asService, makeFakePorts(), actors, ai(), outbound());

async function interactionFor(p: MessagePrincipal): Promise<string> {
  const m = new MessageService(asService, makeFakePorts(), actors);
  const { threadId } = await m.openThread(null, p);
  const msg = await m.send(threadId, p, { content: 'party at 9 PM' }, `k-${randomUUID()}`);
  return msg.id;
}

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

describe('Tenant isolation (P9 / KG-10 — cross-tenant blast radius)', () => {
  it('tenant B cannot read or mutate tenant A resources, and sees none of them', async () => {
    // ── Seed tenant A: a route decision, an AI artifact, a meeting ──
    const scopeA = await actors.resolveScope(A);
    expect(scopeA.cellId).toBeTruthy(); // new scopes carry a cell assignment (P9 cell hook)
    const interactionId = await interactionFor(A);
    await prisma.iiosRouteBinding.create({
      data: { scopeId: scopeA.id, originChannelType: 'WHATSAPP', originRef: 'adult-group', destinationChannelType: 'PORTAL', destinationRef: 'adults', requiresReview: true, enabled: true },
    });
    const { decisions } = await routeSvc().simulate(interactionId, { channelType: 'WHATSAPP', ref: 'adult-group' });
    const decisionId = decisions[0]!.id;

    const { artifact } = await ai().runJob({ interactionId, jobType: 'CLASSIFY', principal: A });
    const artifactId = artifact!.id;

    const meeting = await cal().scheduleDirect(A, { meetingType: 'INTERNAL', title: 'A only', startAt: START });

    // ── Same-tenant control: A can read its own ──
    expect((await ai().getArtifact(artifactId, A)).id).toBe(artifactId);
    expect((await cal().getMeeting(meeting.id, A)).id).toBe(meeting.id);

    // ── Cross-tenant DENIED (B owns nothing in A's scope) ──
    await expect(routeSvc().approve(decisionId, B)).rejects.toThrow(/tenant scope/);
    await expect(ai().getArtifact(artifactId, B)).rejects.toThrow(/tenant scope/);
    await expect(ai().accept(artifactId, B)).rejects.toThrow(/tenant scope/);
    await expect(cal().getMeeting(meeting.id, B)).rejects.toThrow(/tenant scope/);
    await expect(cal().summarize(meeting.id, B)).rejects.toThrow(/tenant scope/);

    // ── The denied calls neither mutated A's data nor created any scope for B ──
    expect((await prisma.iiosRouteDecision.findUniqueOrThrow({ where: { id: decisionId } })).decisionState).toBe('REVIEW');
    expect((await prisma.iiosAiArtifact.findUniqueOrThrow({ where: { id: artifactId } })).status).toBe('PROPOSED');
    expect(await prisma.iiosScope.findFirst({ where: { orgId: 'org_B' } })).toBeNull();

    // ── B's own lists show NONE of A's data (B is a legit but empty tenant) ──
    expect(await routeSvc().listDecisions(B)).toHaveLength(0);
    expect(await ai().listArtifacts(B)).toHaveLength(0);
    expect(await cal().listMeetings(B)).toHaveLength(0);
  });

  it('each tenant only sees its own lists', async () => {
    const iA = await interactionFor(A);
    const iB = await interactionFor(B);
    await ai().runJob({ interactionId: iA, jobType: 'CLASSIFY', principal: A });
    await ai().runJob({ interactionId: iB, jobType: 'CLASSIFY', principal: B });
    await cal().scheduleDirect(A, { meetingType: 'INTERNAL', title: 'A', startAt: START });
    await cal().scheduleDirect(B, { meetingType: 'INTERNAL', title: 'B', startAt: START });

    expect(await ai().listArtifacts(A)).toHaveLength(1);
    expect(await ai().listArtifacts(B)).toHaveLength(1);
    expect((await cal().listMeetings(A)).every((m) => m.title === 'A')).toBe(true);
    expect((await cal().listMeetings(B)).every((m) => m.title === 'B')).toBe(true);
  });
});
