import { OtpService, isDomesticPhone, normalizeOtpTarget } from "./otp.service";

describe("normalizeOtpTarget", () => {
  it("canonicalises Ethiopian forms to one E.164 key", () => {
    const forms = [
      "+251986680099",
      "251986680099",
      "0986680099",
      "+251 98 668 0099",
    ];
    const keys = forms.map((phone) => normalizeOtpTarget({ phone }).phone);
    expect(new Set(keys)).toEqual(new Set(["+251986680099"]));
  });

  it("canonicalises email case and surrounding whitespace to one key", () => {
    const forms = ["a@b.com", "A@B.com", "  a@B.COM  ", "A@b.COM"];
    const keys = forms.map((email) => normalizeOtpTarget({ email }).email);
    expect(new Set(keys)).toEqual(new Set(["a@b.com"]));
  });

  it("keeps an already-normalised email stable (idempotent)", () => {
    const once = normalizeOtpTarget({ email: " User@Example.COM " }).email!;
    expect(normalizeOtpTarget({ email: once }).email).toBe(once);
  });

  it("keeps an already-normalised number stable (idempotent)", () => {
    const once = normalizeOtpTarget({ phone: "0986680099" }).phone!;
    expect(normalizeOtpTarget({ phone: once }).phone).toBe(once);
  });
});

describe("isDomesticPhone", () => {
  it.each(["+251986680099", "0986680099", "251986680099"])(
    "accepts Ethiopian mobile form %s",
    (phone) => expect(isDomesticPhone(phone)).toBe(true),
  );

  it.each(["+14155550123", "+447911123456", "0712345678", "+2519866", "12345"])(
    "rejects non-domestic or malformed %s",
    (phone) => expect(isDomesticPhone(phone)).toBe(false),
  );
});

interface FakeRow {
  id: string;
  phone?: string;
  email?: string;
  otp: string;
  updatedAt: Date;
}

/**
 * In-memory stand-in for OtpRepository, mirroring the two properties the service
 * depends on: rows are matched by OR across every channel named, and a send
 * replaces all overlapping rows with one row carrying every channel.
 */
function makeService(
  transports: {
    sms?: () => Promise<{ queued: boolean }>;
    email?: () => Promise<{ queued: boolean }>;
  } = {},
) {
  let rows: FakeRow[] = [];
  let nextId = 1;

  const matches = (row: FakeRow, t: { phone?: string; email?: string }) =>
    (!!t.email && row.email === t.email) ||
    (!!t.phone && row.phone === t.phone);

  const repo = {
    findByTarget: jest.fn(
      async (t: { phone?: string; email?: string }) =>
        rows.filter((row) => matches(row, t))[0] ?? null,
    ),
    replaceOtp: jest.fn(
      async (t: { phone?: string; email?: string }, otp: string) => {
        const overlapping = rows.filter((row) => matches(row, t));
        rows = rows.filter((row) => !overlapping.includes(row));
        const record: FakeRow = {
          id: String(nextId++),
          ...t,
          otp,
          updatedAt: new Date(),
        };
        rows.push(record);
        return { record, rotated: overlapping.length > 0 };
      },
    ),
    deleteOtp: jest.fn(async (row: FakeRow) => {
      rows = rows.filter((r) => r !== row);
    }),
  };

  // Both clients return `{ queued }` — the service reads it to tell a published
  // code apart from one the transport silently dropped.
  const sms = {
    sendSms: jest.fn(transports.sms ?? (async () => ({ queued: true }))),
  };
  const email = {
    sendEmail: jest.fn(transports.email ?? (async () => ({ queued: true }))),
  };
  const service = new OtpService(repo as never, sms as never, email as never);
  return { service, sms, email, rows: () => rows };
}

describe("OtpService — send/verify agree across phone formats", () => {
  it("verifies a code sent to +251… when verify is called with 09…", async () => {
    const { service, rows } = makeService();
    await service.sendOtp({ phone: "+251986680099" });

    await expect(
      service.verifyOtpForAction({ phone: "0986680099" }, rows()[0]!.otp),
    ).resolves.toEqual({ success: true });
  });

  it("verifies a code sent to User@X.com when verify is called with user@x.com", async () => {
    const { service, rows } = makeService();
    await service.sendOtp({ email: "  User@Example.COM " });

    await expect(
      service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
    ).resolves.toEqual({ success: true });
  });
});

describe("OtpService — dual-channel send", () => {
  const both = { phone: "0986680099", email: "User@Example.COM" };

  it("sends ONE code to both transports", async () => {
    const { service, sms, email, rows } = makeService();
    await service.sendOtp(both);

    const otp = rows()[0]!.otp;
    expect(sms.sendSms).toHaveBeenCalledTimes(1);
    expect(email.sendEmail).toHaveBeenCalledTimes(1);
    // Same secret on both messages — the user types whichever arrives first.
    expect(sms.sendSms).toHaveBeenCalledWith(
      expect.objectContaining({
        to: "+251986680099",
        message: expect.stringContaining(otp),
      }),
    );
    expect(email.sendEmail).toHaveBeenCalledWith(
      expect.objectContaining({
        to: "user@example.com",
        text: expect.stringContaining(otp),
      }),
    );
    // One row, both channels canonicalised.
    expect(rows()).toHaveLength(1);
    expect(rows()[0]).toMatchObject({
      phone: "+251986680099",
      email: "user@example.com",
    });
  });

  it.each([
    ["phone alone", { phone: "0986680099" }],
    ["email alone", { email: "user@example.com" }],
    ["both", both],
  ])(
    "verifies a dual-channel code when quoted back by %s",
    async (_label, target) => {
      const { service, rows } = makeService();
      await service.sendOtp(both);

      await expect(
        service.verifyOtpForAction(target, rows()[0]!.otp),
      ).resolves.toEqual({ success: true });
    },
  );

  it("consuming the code via one channel kills the other", async () => {
    const { service, rows } = makeService();
    await service.sendOtp(both);
    const otp = rows()[0]!.otp;

    await service.verifyOtpForAction({ email: "user@example.com" }, otp);

    // Single-use is per-code, not per-channel: the phone half must be dead too.
    await expect(
      service.verifyOtpForAction({ phone: "0986680099" }, otp),
    ).rejects.toThrow(/No verification code was requested/);
  });

  it("replaces an overlapping single-channel row instead of colliding with it", async () => {
    const { service, rows } = makeService();
    // A pending signup code on the phone only, then a dual-channel send.
    await service.sendOtp({ phone: "0986680099" });
    await service.sendOtp(both);

    expect(rows()).toHaveLength(1);
    expect(rows()[0]).toMatchObject({ email: "user@example.com" });
  });

  it("degrades to one channel when the account has only one contact", async () => {
    const { service, sms, email } = makeService();
    await service.sendOtp({ phone: "0986680099" });

    expect(sms.sendSms).toHaveBeenCalledTimes(1);
    expect(email.sendEmail).not.toHaveBeenCalled();
  });

  it("skips SMS for a foreign number when email is available", async () => {
    const { service, sms, email, rows } = makeService();
    await service.sendOtp({ phone: "+14155550123", email: "user@example.com" });

    // The gateway is domestic-only — email is the delivery route, but the
    // foreign phone stays on the row so verify still matches either channel.
    expect(sms.sendSms).not.toHaveBeenCalled();
    expect(email.sendEmail).toHaveBeenCalledTimes(1);
    await expect(
      service.verifyOtpForAction({ phone: "+14155550123" }, rows()[0]!.otp),
    ).resolves.toEqual({ success: true });
  });

  it("still attempts SMS for a foreign number when it is the only channel", async () => {
    const { service, sms } = makeService();
    await service.sendOtp({ phone: "+14155550123" });

    expect(sms.sendSms).toHaveBeenCalledTimes(1);
  });

  it("still succeeds when one transport throws", async () => {
    const { service, rows } = makeService({
      sms: async () => {
        throw new Error("broker down");
      },
    });

    await expect(service.sendOtp(both)).resolves.toMatchObject({
      success: true,
      delivered: true,
    });
    // The code is live and verifiable on the channel that worked.
    await expect(
      service.verifyOtpForAction({ email: "user@example.com" }, rows()[0]!.otp),
    ).resolves.toEqual({ success: true });
  });

  it("fails the request when every transport throws", async () => {
    const { service } = makeService({
      sms: async () => {
        throw new Error("broker down");
      },
      email: async () => {
        throw new Error("broker down");
      },
    });

    await expect(service.sendOtp(both)).rejects.toThrow("Failed to send OTP");
  });

  it("shares one brute-force budget across both channels", async () => {
    const { service, rows } = makeService();
    await service.sendOtp(both);
    const otp = rows()[0]!.otp;

    // Alternating channels must not hand the attacker two independent budgets:
    // 5 wrong guesses in total burn the code regardless of how they are split.
    for (const target of [
      { phone: "0986680099" },
      { email: "user@example.com" },
      { phone: "0986680099" },
      { email: "user@example.com" },
    ]) {
      await expect(
        service.verifyOtpForAction(target, "000000"),
      ).rejects.toThrow("Invalid verification code");
    }
    await expect(
      service.verifyOtpForAction({ email: "user@example.com" }, "000000"),
    ).rejects.toThrow(/Too many incorrect attempts/);

    // Burned: even the correct code no longer works.
    await expect(service.verifyOtpForAction(both, otp)).rejects.toThrow(
      /No verification code was requested/,
    );
  });
});
