import { randomBytes } from "node:crypto";
import { describe, expect, vi } from "vitest";
import type { PrismaClient } from "@trigger.dev/database";

const prismaHolder = vi.hoisted(() => ({
  client: null as PrismaClient | null,
}));

vi.mock("~/services/rbac.server", () => ({
  rbac: {
    setUserRole: async () => ({ ok: true as const }),
  },
}));

vi.mock("~/db.server", () => ({
  get prisma() {
    if (!prismaHolder.client) {
      throw new Error("test prisma not set");
    }
    return prismaHolder.client;
  },
  get $replica() {
    if (!prismaHolder.client) {
      throw new Error("test prisma not set");
    }
    return prismaHolder.client;
  },
}));

import { postgresTest } from "@internal/testcontainers";

vi.setConfig({ testTimeout: 60_000 });

function randomHex(len = 12): string {
  return randomBytes(Math.ceil(len / 2))
    .toString("hex")
    .slice(0, len);
}

async function seedInviteFixture(
  prisma: PrismaClient,
  opts: { activeProjectCount: number; deletedProjectCount?: number }
) {
  const suffix = randomHex(8);
  const inviter = await prisma.user.create({
    data: {
      email: `inviter-${suffix}@test.local`,
      authenticationMethod: "MAGIC_LINK",
    },
  });
  const invitee = await prisma.user.create({
    data: {
      email: `invitee-${suffix}@test.local`,
      authenticationMethod: "MAGIC_LINK",
    },
  });

  const organization = await prisma.organization.create({
    data: {
      title: `invite-org-${suffix}`,
      slug: `invite-org-${suffix}`,
      isActivated: true,
      members: { create: { userId: inviter.id, role: "ADMIN" } },
    },
  });

  const activeProjects = [];
  for (let i = 0; i < opts.activeProjectCount; i++) {
    activeProjects.push(
      await prisma.project.create({
        data: {
          name: `active-project-${i}-${suffix}`,
          slug: `active-proj-${i}-${suffix}`,
          externalRef: `proj_active_${i}_${suffix}`,
          organizationId: organization.id,
          engine: "V2",
        },
      })
    );
  }

  const deletedProjectCount = opts.deletedProjectCount ?? 0;
  for (let i = 0; i < deletedProjectCount; i++) {
    await prisma.project.create({
      data: {
        name: `deleted-project-${i}-${suffix}`,
        slug: `deleted-proj-${i}-${suffix}`,
        externalRef: `proj_deleted_${i}_${suffix}`,
        organizationId: organization.id,
        engine: "V2",
        deletedAt: new Date(),
      },
    });
  }

  const invite = await prisma.orgMemberInvite.create({
    data: {
      email: invitee.email,
      organizationId: organization.id,
      inviterId: inviter.id,
      role: "MEMBER",
    },
  });

  return { inviter, invitee, organization, activeProjects, invite };
}

function devEnvKeys(apiKey: string, pkApiKey: string) {
  return { apiKey, pkApiKey, shortcode: randomHex(4) };
}

describe("acceptInvite", () => {
  postgresTest(
    "creates member and dev environments for active projects only (many projects)",
    { timeout: 60_000 },
    async ({ prisma }) => {
      prismaHolder.client = prisma;
      const { acceptInvite } = await import("../app/models/member.server");

      const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, {
        activeProjectCount: 25,
        deletedProjectCount: 3,
      });

      const beforeEnvCount = await prisma.runtimeEnvironment.count();

      const { organization: joinedOrg } = await acceptInvite({
        inviteId: invite.id,
        organizationId: organization.id,
        user: { id: invitee.id, email: invitee.email },
      });

      expect(joinedOrg.id).toBe(organization.id);

      const member = await prisma.orgMember.findFirst({
        where: { userId: invitee.id, organizationId: organization.id },
      });
      expect(member).not.toBeNull();

      const devEnvs = await prisma.runtimeEnvironment.findMany({
        where: {
          organizationId: organization.id,
          orgMemberId: member!.id,
          type: "DEVELOPMENT",
        },
      });
      expect(devEnvs).toHaveLength(activeProjects.length);

      const envProjectIds = new Set(devEnvs.map((e) => e.projectId));
      for (const project of activeProjects) {
        expect(envProjectIds.has(project.id)).toBe(true);
      }

      const newEnvCount = await prisma.runtimeEnvironment.count();
      expect(newEnvCount - beforeEnvCount).toBe(activeProjects.length);
    }
  );

  postgresTest(
    "rejects wrong email without creating member or environments",
    { timeout: 60_000 },
    async ({ prisma }) => {
      prismaHolder.client = prisma;
      const { acceptInvite, INVITE_NOT_FOUND } = await import("../app/models/member.server");

      const { invitee, organization, invite } = await seedInviteFixture(prisma, {
        activeProjectCount: 2,
      });

      const beforeMemberCount = await prisma.orgMember.count({
        where: { organizationId: organization.id, userId: invitee.id },
      });
      const beforeEnvCount = await prisma.runtimeEnvironment.count();

      await expect(
        acceptInvite({
          inviteId: invite.id,
          user: { id: invitee.id, email: "wrong@example.com" },
        })
      ).rejects.toThrow(INVITE_NOT_FOUND);

      const afterMemberCount = await prisma.orgMember.count({
        where: { organizationId: organization.id, userId: invitee.id },
      });
      expect(afterMemberCount).toBe(beforeMemberCount);

      const afterEnvCount = await prisma.runtimeEnvironment.count();
      expect(afterEnvCount).toBe(beforeEnvCount);
    }
  );

  postgresTest(
    "rejects invite for deleted organization without creating member or environments",
    { timeout: 60_000 },
    async ({ prisma }) => {
      prismaHolder.client = prisma;
      const { acceptInvite, INVITE_NOT_FOUND } = await import("../app/models/member.server");

      const { invitee, organization, invite } = await seedInviteFixture(prisma, {
        activeProjectCount: 2,
      });

      await prisma.organization.update({
        where: { id: organization.id },
        data: { deletedAt: new Date() },
      });

      const beforeMemberCount = await prisma.orgMember.count({
        where: { organizationId: organization.id, userId: invitee.id },
      });
      const beforeEnvCount = await prisma.runtimeEnvironment.count();

      await expect(
        acceptInvite({
          inviteId: invite.id,
          user: { id: invitee.id, email: invitee.email },
        })
      ).rejects.toThrow(INVITE_NOT_FOUND);

      const afterMemberCount = await prisma.orgMember.count({
        where: { organizationId: organization.id, userId: invitee.id },
      });
      expect(afterMemberCount).toBe(beforeMemberCount);

      const afterEnvCount = await prisma.runtimeEnvironment.count();
      expect(afterEnvCount).toBe(beforeEnvCount);
    }
  );

  postgresTest(
    "rejects already consumed invite with normalized error",
    { timeout: 60_000 },
    async ({ prisma }) => {
      prismaHolder.client = prisma;
      const { acceptInvite, INVITE_NOT_FOUND } = await import("../app/models/member.server");

      const { invitee, organization, invite } = await seedInviteFixture(prisma, {
        activeProjectCount: 1,
      });

      await prisma.orgMemberInvite.delete({ where: { id: invite.id } });

      await expect(
        acceptInvite({
          inviteId: invite.id,
          user: { id: invitee.id, email: invitee.email },
        })
      ).rejects.toThrow(INVITE_NOT_FOUND);

      const member = await prisma.orgMember.findFirst({
        where: { userId: invitee.id, organizationId: organization.id },
      });
      expect(member).toBeNull();
    }
  );
});

describe("provisionMemberDevelopmentEnvironments", () => {
  postgresTest(
    "skips projects that already have development environments",
    { timeout: 60_000 },
    async ({ prisma }) => {
      prismaHolder.client = prisma;
      const { provisionMemberDevelopmentEnvironments } =
        await import("../app/models/member.server");

      const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, {
        activeProjectCount: 3,
      });

      await prisma.orgMemberInvite.delete({ where: { id: invite.id } });

      const member = await prisma.orgMember.create({
        data: {
          organizationId: organization.id,
          userId: invitee.id,
          role: "MEMBER",
        },
      });

      const keys = devEnvKeys(`tr_dev_${randomHex(24)}`, `pk_dev_${randomHex(24)}`);
      await prisma.runtimeEnvironment.create({
        data: {
          slug: "dev",
          type: "DEVELOPMENT",
          ...keys,
          projectId: activeProjects[1].id,
          organizationId: organization.id,
          orgMemberId: member.id,
        },
      });

      await provisionMemberDevelopmentEnvironments({
        inviteId: invite.id,
        user: { id: invitee.id, email: invitee.email },
        member,
        organization,
        projects: activeProjects,
        maximumConcurrencyLimit: 5,
      });

      const devEnvs = await prisma.runtimeEnvironment.findMany({
        where: {
          organizationId: organization.id,
          orgMemberId: member.id,
          type: "DEVELOPMENT",
        },
      });

      expect(devEnvs).toHaveLength(activeProjects.length);
    }
  );

  postgresTest(
    "throws partial-success error when env creation fails mid-loop",
    { timeout: 60_000 },
    async ({ prisma }) => {
      prismaHolder.client = prisma;
      const { provisionMemberDevelopmentEnvironments, ENV_SETUP_INCOMPLETE } =
        await import("../app/models/member.server");

      const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, {
        activeProjectCount: 2,
      });

      await prisma.orgMemberInvite.delete({ where: { id: invite.id } });

      const member = await prisma.orgMember.create({
        data: {
          organizationId: organization.id,
          userId: invitee.id,
          role: "MEMBER",
        },
      });

      await expect(
        provisionMemberDevelopmentEnvironments({
          inviteId: invite.id,
          user: { id: invitee.id, email: invitee.email },
          member,
          organization,
          projects: [...activeProjects, { id: "missing-project-id" }],
          maximumConcurrencyLimit: 5,
        })
      ).rejects.toThrow(ENV_SETUP_INCOMPLETE);

      const devEnvs = await prisma.runtimeEnvironment.findMany({
        where: {
          organizationId: organization.id,
          orgMemberId: member.id,
          type: "DEVELOPMENT",
        },
      });

      const envProjectIds = devEnvs.map((env) => env.projectId);
      expect(envProjectIds).toContain(activeProjects[0].id);
      expect(envProjectIds).toContain(activeProjects[1].id);
    }
  );
});

describe("acceptInvite recovery", () => {
  postgresTest(
    "retries successfully when membership exists and the invite is still pending",
    { timeout: 60_000 },
    async ({ prisma }) => {
      prismaHolder.client = prisma;
      const { acceptInvite } = await import("../app/models/member.server");

      const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, {
        activeProjectCount: 3,
      });

      const member = await prisma.orgMember.create({
        data: {
          organizationId: organization.id,
          userId: invitee.id,
          role: "MEMBER",
        },
      });

      const keys = devEnvKeys(`tr_dev_${randomHex(24)}`, `pk_dev_${randomHex(24)}`);
      await prisma.runtimeEnvironment.create({
        data: {
          slug: "dev",
          type: "DEVELOPMENT",
          ...keys,
          projectId: activeProjects[0].id,
          organizationId: organization.id,
          orgMemberId: member.id,
        },
      });

      const { organization: joinedOrg } = await acceptInvite({
        inviteId: invite.id,
        organizationId: organization.id,
        user: { id: invitee.id, email: invitee.email },
      });

      expect(joinedOrg.id).toBe(organization.id);

      const devEnvs = await prisma.runtimeEnvironment.findMany({
        where: {
          organizationId: organization.id,
          orgMemberId: member.id,
          type: "DEVELOPMENT",
        },
      });
      expect(devEnvs).toHaveLength(activeProjects.length);

      const remainingInvite = await prisma.orgMemberInvite.findFirst({
        where: { id: invite.id },
      });
      expect(remainingInvite).toBeNull();
    }
  );

  postgresTest(
    "recovers when the invite was already consumed but development environments are incomplete",
    { timeout: 60_000 },
    async ({ prisma }) => {
      prismaHolder.client = prisma;
      const { acceptInvite } = await import("../app/models/member.server");

      const { invitee, organization, activeProjects, invite } = await seedInviteFixture(prisma, {
        activeProjectCount: 3,
      });

      await prisma.orgMemberInvite.delete({ where: { id: invite.id } });

      const member = await prisma.orgMember.create({
        data: {
          organizationId: organization.id,
          userId: invitee.id,
          role: "MEMBER",
        },
      });

      const keys = devEnvKeys(`tr_dev_${randomHex(24)}`, `pk_dev_${randomHex(24)}`);
      await prisma.runtimeEnvironment.create({
        data: {
          slug: "dev",
          type: "DEVELOPMENT",
          ...keys,
          projectId: activeProjects[0].id,
          organizationId: organization.id,
          orgMemberId: member.id,
        },
      });

      const { organization: joinedOrg } = await acceptInvite({
        inviteId: invite.id,
        organizationId: organization.id,
        user: { id: invitee.id, email: invitee.email },
      });

      expect(joinedOrg.id).toBe(organization.id);

      const devEnvs = await prisma.runtimeEnvironment.findMany({
        where: {
          organizationId: organization.id,
          orgMemberId: member.id,
          type: "DEVELOPMENT",
        },
      });
      expect(devEnvs).toHaveLength(activeProjects.length);
    }
  );

  postgresTest(
    "does not recover unrelated memberships when invite is missing and organizationId is omitted",
    { timeout: 60_000 },
    async ({ prisma }) => {
      prismaHolder.client = prisma;
      const { acceptInvite, INVITE_NOT_FOUND } = await import("../app/models/member.server");

      const fixtureA = await seedInviteFixture(prisma, { activeProjectCount: 2 });
      const fixtureB = await seedInviteFixture(prisma, { activeProjectCount: 2 });

      await prisma.orgMemberInvite.delete({ where: { id: fixtureA.invite.id } });

      const member = await prisma.orgMember.create({
        data: {
          organizationId: fixtureA.organization.id,
          userId: fixtureA.invitee.id,
          role: "MEMBER",
        },
      });

      const keys = devEnvKeys(`tr_dev_${randomHex(24)}`, `pk_dev_${randomHex(24)}`);
      await prisma.runtimeEnvironment.create({
        data: {
          slug: "dev",
          type: "DEVELOPMENT",
          ...keys,
          projectId: fixtureA.activeProjects[0].id,
          organizationId: fixtureA.organization.id,
          orgMemberId: member.id,
        },
      });

      await expect(
        acceptInvite({
          inviteId: fixtureA.invite.id,
          user: { id: fixtureA.invitee.id, email: fixtureA.invitee.email },
        })
      ).rejects.toThrow(INVITE_NOT_FOUND);

      const devEnvs = await prisma.runtimeEnvironment.findMany({
        where: {
          organizationId: fixtureA.organization.id,
          orgMemberId: member.id,
          type: "DEVELOPMENT",
        },
      });
      expect(devEnvs).toHaveLength(1);
      expect(fixtureB.invite.id).not.toBe(fixtureA.invite.id);
    }
  );

  postgresTest(
    "does not recover memberships for a different organizationId than the stale invite",
    { timeout: 60_000 },
    async ({ prisma }) => {
      prismaHolder.client = prisma;
      const { acceptInvite, INVITE_NOT_FOUND } = await import("../app/models/member.server");

      const fixtureA = await seedInviteFixture(prisma, { activeProjectCount: 2 });
      const fixtureB = await seedInviteFixture(prisma, { activeProjectCount: 2 });

      await prisma.orgMemberInvite.delete({ where: { id: fixtureA.invite.id } });

      const member = await prisma.orgMember.create({
        data: {
          organizationId: fixtureA.organization.id,
          userId: fixtureA.invitee.id,
          role: "MEMBER",
        },
      });

      const keys = devEnvKeys(`tr_dev_${randomHex(24)}`, `pk_dev_${randomHex(24)}`);
      await prisma.runtimeEnvironment.create({
        data: {
          slug: "dev",
          type: "DEVELOPMENT",
          ...keys,
          projectId: fixtureA.activeProjects[0].id,
          organizationId: fixtureA.organization.id,
          orgMemberId: member.id,
        },
      });

      await expect(
        acceptInvite({
          inviteId: fixtureA.invite.id,
          organizationId: fixtureB.organization.id,
          user: { id: fixtureA.invitee.id, email: fixtureA.invitee.email },
        })
      ).rejects.toThrow(INVITE_NOT_FOUND);

      const devEnvs = await prisma.runtimeEnvironment.findMany({
        where: {
          organizationId: fixtureA.organization.id,
          orgMemberId: member.id,
          type: "DEVELOPMENT",
        },
      });
      expect(devEnvs).toHaveLength(1);
    }
  );
});
