import { randomBytes, randomUUID } from "node:crypto";
import { HttpException } from "@nestjs/common";
import { sql } from "drizzle-orm";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getDb, getSql, schema, withTenant, withUser } from "@slackwsh/data";
import * as messaging from "./index";

/**
 * Phase 1 exit: every HTTP handler and every socket event attempted with a
 * foreign workspace_id must fail closed. Controllers and the gateway delegate
 * to these functions, so this suite is the executable half; the catalogs in
 * apps/api and apps/gateway assert no route/event was added without a gate.
 */
const hasDatabase = Boolean(process.env.DATABASE_URL);

const redis = {
  publish: async () => 1,
  set: async () => "OK",
} as never;

function uniqueSuffix() {
  return randomBytes(4).toString("hex");
}

async function expectDenied(fn: () => Promise<unknown>) {
  try {
    const result = await fn();
    if (Array.isArray(result) && result.length === 0) return;
    if (typeof result === "number" && result === 0) {
      throw new Error("numeric zero is not a denial — membership should have thrown");
    }
  } catch (err) {
    if (err instanceof HttpException) {
      expect([403, 404]).toContain(err.getStatus());
      return;
    }
    throw err;
  }
  throw new Error("expected Forbidden/NotFound (or an empty RLS-filtered read) for a foreign tenant id");
}

afterAll(async () => {
  if (hasDatabase) await getSql().end({ timeout: 1 });
});

describe.skipIf(!hasDatabase)("cross-tenant isolation — messaging (HTTP + socket delegates)", () => {
  let orgA: number, orgB: number, wsA: number, wsB: number, userA: number, userB: number, userC: number;
  let chA: number, chB: number, msgB: number, rootA: number, rootSeq: number;

  beforeAll(async () => {
    const db = getDb();
    const suffix = uniqueSuffix();
    [orgA, orgB] = await Promise.all([
      db.insert(schema.organizations).values({ name: "iso-a" }).returning({ id: schema.organizations.id }).then((r) => r[0]!.id),
      db.insert(schema.organizations).values({ name: "iso-b" }).returning({ id: schema.organizations.id }).then((r) => r[0]!.id),
    ]);
    userA = await db
      .insert(schema.users)
      .values({ email: `iso-a-${suffix}@test.local`, username: `isoa${suffix}`, passwordHash: "x", name: "IsoA" })
      .returning({ id: schema.users.id })
      .then((r) => r[0]!.id);
    userB = await db
      .insert(schema.users)
      .values({ email: `iso-b-${suffix}@test.local`, username: `isob${suffix}`, passwordHash: "x", name: "IsoB" })
      .returning({ id: schema.users.id })
      .then((r) => r[0]!.id);
    userC = await db
      .insert(schema.users)
      .values({ email: `iso-c-${suffix}@test.local`, username: `isoc${suffix}`, passwordHash: "x", name: "IsoC" })
      .returning({ id: schema.users.id })
      .then((r) => r[0]!.id);

    async function ownWorkspace(userId: number, orgId: number, slug: string) {
      return withUser(userId, async (tx) => {
        const [workspace] = await tx.insert(schema.workspaces).values({ orgId, slug, name: slug }).returning();
        if (!workspace) throw new Error("workspace insert failed");
        await tx.execute(sql`select set_config('app.current_workspace_id', ${String(workspace.id)}, true)`);
        await tx.insert(schema.workspaceMembers).values({ workspaceId: workspace.id, userId, role: "owner" });
        return workspace.id;
      });
    }

    wsA = await ownWorkspace(userA, orgA, `iso-a-${suffix}`);
    wsB = await ownWorkspace(userB, orgB, `iso-b-${suffix}`);
    chA = (await messaging.createChannel(wsA, userA, { name: "iso-a", type: "public" })).id;
    chB = (await messaging.createChannel(wsB, userB, { name: "iso-b", type: "public" })).id;

    await withTenant({ workspaceId: wsA, userId: userA }, async (tx) => {
      await tx.insert(schema.workspaceMembers).values({ workspaceId: wsA, userId: userC, role: "member" });
    });
    await messaging.addChannelMembers(wsA, userA, chA, [userC]);

    const sentB = await messaging.sendMessage(redis, wsB, chB, userB, {
      clientMsgId: randomUUID(),
      text: "tenant B message",
    });
    msgB = sentB.id;
    const sentA = await messaging.sendMessage(redis, wsA, chA, userA, {
      clientMsgId: randomUUID(),
      text: "tenant A root",
    });
    rootA = sentA.id;
    rootSeq = sentA.seq;
  }, 30_000);

  it("socket membership helpers reject a foreign workspace/channel", async () => {
    expect(await messaging.checkWorkspaceMembership(wsB, userA)).toBe(false);
    expect(await messaging.checkChannelMembership(wsB, chB, userA)).toBe(false);
    expect(await messaging.checkChannelMembership(wsA, chB, userA)).toBe(false);
    expect(await messaging.checkWorkspaceMembership(wsA, userA)).toBe(true);
    expect(await messaging.checkChannelMembership(wsA, chA, userA)).toBe(true);
    expect(await messaging.isCallParticipant(wsB, 1, userA)).toBe(false);
  });

  it.each([
    ["createChannel", () => messaging.createChannel(wsB, userA, { name: "leak", type: "public" })],
    ["listMyChannels", () => messaging.listMyChannels(wsB, userA)],
    ["joinChannel", () => messaging.joinChannel(wsB, userA, chB)],
    ["leaveChannel", () => messaging.leaveChannel(wsB, userA, chB)],
    ["addChannelMembers", () => messaging.addChannelMembers(wsB, userA, chB, [userA])],
    ["listChannelMembers", () => messaging.listChannelMembers(wsB, userA, chB)],
    ["removeChannelMember", () => messaging.removeChannelMember(wsB, userA, chB, userB)],
    ["archiveChannel", () => messaging.archiveChannel(wsB, userA, chB, true)],
    ["updateChannel", () => messaging.updateChannel(redis, wsB, userA, chB, { name: "hacked" })],
    ["updateChannelPrefs", () => messaging.updateChannelPrefs(wsB, userA, chB, { isMuted: true })],
    ["getChannelAbout", () => messaging.getChannelAbout(wsB, userA, chB)],
    ["getOrCreateDirectMessage", () => messaging.getOrCreateDirectMessage(wsB, userA, userB)],
    ["createGroupDirectMessage", () => messaging.createGroupDirectMessage(wsB, userA, [userB, userC])],
    ["sendMessage", () => messaging.sendMessage(redis, wsB, chB, userA, { clientMsgId: randomUUID(), text: "nope" })],
    ["sendMessage mismatched channel", () => messaging.sendMessage(redis, wsA, chB, userA, { clientMsgId: randomUUID(), text: "nope" })],
    ["getScrollback", () => messaging.getScrollback(wsB, chB, userA, { limit: 50 })],
    ["getMessage", () => messaging.getMessage(wsB, chB, userA, msgB)],
    ["getThread", () => messaging.getThread(wsB, chB, userA, msgB)],
    ["editMessage", () => messaging.editMessage(redis, wsB, chB, userA, msgB, { text: "x" })],
    ["deleteMessage", () => messaging.deleteMessage(redis, wsB, chB, userA, msgB)],
    ["setReaction", () => messaging.setReaction(redis, wsB, chB, userA, msgB, "👍", "add")],
    ["setPin", () => messaging.setPin(redis, wsB, chB, userA, msgB, "add")],
    ["markChannelRead", () => messaging.markChannelRead(redis, wsB, chB, userA, 1)],
    ["markMessageUnread", () => messaging.markMessageUnread(redis, wsB, chB, userA, msgB)],
    ["getThreadSubscriptionStatus", () => messaging.getThreadSubscriptionStatus(wsB, chB, userA, msgB)],
    ["setThreadFollowing", () => messaging.setThreadFollowing(redis, wsB, chB, userA, msgB, true)],
    ["markThreadRead", () => messaging.markThreadRead(redis, wsB, chB, userA, msgB, 1)],
    ["listFollowedThreads", () => messaging.listFollowedThreads(wsB, userA)],
    ["forwardMessage", () => messaging.forwardMessage(redis, wsB, chB, userA, msgB, { destinations: [{ channelId: chA, clientMsgId: randomUUID() }] })],
    ["createTask", () => messaging.createTask(redis, wsB, userA, { title: "leak" })],
    ["listTasks", () => messaging.listTasks(wsB, userA)],
    ["getTask", () => messaging.getTask(wsB, userA, 1)],
    ["unreadAssignedTaskCount", () => messaging.unreadAssignedTaskCount(wsB, userA)],
    ["markAssignedTasksSeen", () => messaging.markAssignedTasksSeen(wsB, userA)],
    [
      "createEvent",
      () =>
        messaging.createEvent(redis, wsB, userA, {
          title: "leak",
          startsAt: new Date().toISOString(),
          endsAt: new Date(Date.now() + 3600_000).toISOString(),
        }),
    ],
    [
      "listEvents",
      () =>
        messaging.listEvents(wsB, userA, {
          from: new Date().toISOString(),
          to: new Date(Date.now() + 86400_000).toISOString(),
        }),
    ],
    ["getEvent", () => messaging.getEvent(wsB, userA, 1)],
    ["startCall", () => messaging.startCall(redis, wsB, userA, { inviteeUserIds: [userB] })],
    ["listCalls", () => messaging.listCalls(redis, wsB, userA)],
    ["getCall", () => messaging.getCall(wsB, userA, 1)],
    ["joinCall", () => messaging.joinCall(redis, wsB, userA, 1)],
    ["declineCall", () => messaging.declineCall(redis, wsB, userA, 1)],
    ["leaveCall", () => messaging.leaveCall(redis, wsB, userA, 1)],
    ["endCall", () => messaging.endCall(redis, wsB, userA, 1)],
    ["callContacts", () => messaging.callContacts(wsB, userA)],
    ["unseenMissedCallCount", () => messaging.unseenMissedCallCount(wsB, userA)],
    ["markCallsSeen", () => messaging.markCallsSeen(wsB, userA)],
    ["searchMessages", () => messaging.searchMessages(wsB, userA, "secret")],
    ["listMentionActivity", () => messaging.listMentionActivity(wsB, userA)],
    ["listUnreadMessages", () => messaging.listUnreadMessages(wsB, userA)],
    ["getNotificationPreferences", () => messaging.getNotificationPreferences(wsB, userA)],
    ["listSavedItems", () => messaging.listSavedItems(wsB, userA)],
    ["saveItem", () => messaging.saveItem(redis, wsB, userA, msgB)],
    ["listDrafts", () => messaging.listDrafts(wsB, userA)],
    ["upsertDraft", () => messaging.upsertDraft(redis, wsB, userA, { channelId: chB, text: "x" })],
    ["startOrJoinConnect", () => messaging.startOrJoinConnect(redis, wsB, userA, chB)],
  ] as Array<[string, () => Promise<unknown>]>)("%s", async (_name, fn) => {
    await expectDenied(fn);
  });

  it("I5 sidebar unread counts broadcast thread replies and ignores in-thread-only replies", async () => {
    await messaging.markChannelRead(redis, wsA, chA, userC, rootSeq);
    await messaging.sendMessage(redis, wsA, chA, userA, {
      clientMsgId: randomUUID(),
      text: "thread-only",
      parentId: rootA,
      isBroadcast: false,
    });
    const afterHidden = await messaging.listMyChannels(wsA, userC);
    const hiddenRow = afterHidden.find((row) => row.channel.id === chA);
    expect(hiddenRow?.member.unreadCount ?? 0).toBe(0);

    await messaging.sendMessage(redis, wsA, chA, userA, {
      clientMsgId: randomUUID(),
      text: "also in channel",
      parentId: rootA,
      isBroadcast: true,
    });
    const afterBroadcast = await messaging.listMyChannels(wsA, userC);
    const broadcastRow = afterBroadcast.find((row) => row.channel.id === chA);
    expect(broadcastRow?.member.unreadCount).toBe(1);
  });
});
