import { ConflictException, NotFoundException } from "@nestjs/common";
import { Prisma } from "@jorgecuadros/database";
import { NumidService } from "./numid.service";

/**
 * What matters about the allocator is the two things it must never do: hand the
 * same id to two customers, and hand out a recycled id while Access can still
 * take it back. Both are tested here; the emptiness SQL itself is exercised
 * against real data by scripts/numid-audit.mjs.
 */

interface Options {
  existingRef?: { legacyId: string } | null;
  archived?: boolean;
  missing?: boolean;
  recycle?: boolean;
  empty?: { numid: string; refId: string; customerId: string }[];
  max?: number | null;
  /** Make the first N create() calls fail the unique key, as a race would. */
  createConflicts?: number;
  /** Make updateMany report "nothing matched", as a lost recycle race would. */
  recycleMisses?: number;
}

function build(opts: Options = {}) {
  const created: { legacyId: string }[] = [];
  let conflictsLeft = opts.createConflicts ?? 0;
  let missesLeft = opts.recycleMisses ?? 0;

  const prisma = {
    customer: {
      findUnique: jest.fn().mockResolvedValue(
        opts.missing ? null : { id: "cust-new", archivedAt: opts.archived ? new Date() : null },
      ),
    },
    customerLegacyRef: {
      findFirst: jest.fn().mockResolvedValue(opts.existingRef ?? null),
      updateMany: jest.fn().mockImplementation(() => {
        if (missesLeft > 0) {
          missesLeft -= 1;
          return Promise.resolve({ count: 0 });
        }
        return Promise.resolve({ count: 1 });
      }),
      create: jest.fn().mockImplementation(({ data }: { data: { legacyId: string } }) => {
        if (conflictsLeft > 0) {
          conflictsLeft -= 1;
          return Promise.reject(
            new Prisma.PrismaClientKnownRequestError("dup", {
              code: "P2002",
              clientVersion: "5",
            }),
          );
        }
        created.push(data);
        return Promise.resolve(data);
      }),
    },
    // Two different raw queries share one mock: the MAX lookup returns a single
    // {max} row, everything else is the empty-candidate list.
    $queryRaw: jest.fn().mockImplementation((sql: { strings?: string[]; sql?: string }) => {
      const text = String((sql as unknown as { sql?: string }).sql ?? "");
      if (text.includes("MAX(")) return Promise.resolve([{ max: opts.max ?? null }]);
      return Promise.resolve(opts.empty ?? []);
    }),
  };

  const settings = {
    numidRecycleEmpty: jest
      .fn()
      .mockResolvedValue({ value: opts.recycle ?? false, source: "default" }),
  };

  return {
    service: new NumidService(prisma as never, settings as never),
    prisma,
    created,
  };
}

describe("NUMid allocation", () => {
  it("returns the id a customer already holds instead of minting a second one", async () => {
    // A double-clicked button must not fork the customer's portal identity.
    const { service, prisma } = build({ existingRef: { legacyId: "501" } });

    await expect(service.allocate("cust-new")).resolves.toEqual({
      numid: "501",
      origin: "existing",
    });
    expect(prisma.customerLegacyRef.create).not.toHaveBeenCalled();
  });

  it("allocates one past the highest id in the pool", async () => {
    const { service, created } = build({ max: 1171 });

    await expect(service.allocate("cust-new")).resolves.toEqual({
      numid: "1172",
      origin: "new",
    });
    expect(created[0]).toMatchObject({
      sourceSystem: "utilities",
      sourceTable: "DATGRAL",
      legacyId: "1172",
    });
  });

  it("starts at 1 when the pool is empty", async () => {
    const { service } = build({ max: null });

    await expect(service.allocate("cust-new")).resolves.toMatchObject({ numid: "1" });
  });

  it("does NOT recycle while the setting is off, even with candidates free", async () => {
    // The default has to be the safe one: every reusable id still exists in
    // Access, and a --sync run reassigns it back to its Access owner.
    const { service, prisma } = build({
      max: 1171,
      empty: [{ numid: "1089", refId: "ref-1089", customerId: "cust-old" }],
    });

    await expect(service.allocate("cust-new")).resolves.toMatchObject({
      numid: "1172",
      origin: "new",
    });
    expect(prisma.customerLegacyRef.updateMany).not.toHaveBeenCalled();
  });

  it("takes the lowest empty id once recycling is switched on", async () => {
    const { service, prisma } = build({
      recycle: true,
      max: 1171,
      empty: [
        { numid: "1089", refId: "ref-1089", customerId: "cust-old" },
        { numid: "1094", refId: "ref-1094", customerId: "cust-other" },
      ],
    });

    await expect(service.allocate("cust-new")).resolves.toEqual({
      numid: "1089",
      origin: "recycled",
      previousCustomerId: "cust-old",
    });
    // Guarded on the owner read a moment ago, so a ref that moved underneath us
    // matches nothing rather than being stolen.
    expect(prisma.customerLegacyRef.updateMany).toHaveBeenCalledWith({
      where: { id: "ref-1089", customerId: "cust-old" },
      data: { customerId: "cust-new" },
    });
  });

  it("skips a candidate that someone else took first", async () => {
    const { service } = build({
      recycle: true,
      max: 1171,
      recycleMisses: 1,
      empty: [
        { numid: "1089", refId: "ref-1089", customerId: "cust-old" },
        { numid: "1094", refId: "ref-1094", customerId: "cust-other" },
      ],
    });

    await expect(service.allocate("cust-new")).resolves.toMatchObject({
      numid: "1094",
      origin: "recycled",
    });
  });

  it("falls back to a new id when recycling is on but nothing is free", async () => {
    const { service } = build({ recycle: true, max: 1171, empty: [] });

    await expect(service.allocate("cust-new")).resolves.toMatchObject({
      numid: "1172",
      origin: "new",
    });
  });

  it("retries when two writers pick the same id", async () => {
    // The unique key on (sourceSystem, sourceTable, legacyId) is what decides
    // the winner; the loser must retry, never overwrite.
    const { service, prisma } = build({ max: 1171, createConflicts: 1 });

    await expect(service.allocate("cust-new")).resolves.toMatchObject({
      numid: "1172",
      origin: "new",
    });
    expect(prisma.customerLegacyRef.create).toHaveBeenCalledTimes(2);
  });

  it("gives up loudly rather than looping forever", async () => {
    const { service } = build({ max: 1171, createConflicts: 99 });

    await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
  });

  it("refuses an archived customer", async () => {
    const { service } = build({ archived: true });

    await expect(service.allocate("cust-new")).rejects.toBeInstanceOf(ConflictException);
  });

  it("refuses a customer that does not exist", async () => {
    const { service } = build({ missing: true });

    await expect(service.allocate("nope")).rejects.toBeInstanceOf(NotFoundException);
  });
});
