import { createKyselyMock, type KyselyMock } from "@tailor-platform/sdk/vitest";
import { describe, expect, it, vi } from "vitest";
import type { Namespace } from "../generated/kysely-tailordb";
import type { DispatchAdapters } from "../lib/dispatchAdapters";
import { baseEmailChannel, baseInAppChannel } from "../testing/fixtures";
import { deliverNotification } from "./deliverNotification";

type Mock = KyselyMock<Namespace[keyof Namespace]>;

const NOW = new Date("2024-06-15T12:00:00.000Z");

const queuedRow = {
  id: "notification-1",
  recipientUserId: "user-1",
  channelId: "channel-1",
  subject: "PO PO-001 issued",
  body: "Purchase order PO-001 has been issued.",
  htmlBody: null,
  deliveryStatus: "QUEUED",
};

// Resolves the delivery worker's reads: the Notification row by id and its
// channel by id. `channel` controls which PERSONAL channel the row routes to.
function stage(
  mock: Mock,
  opts: { row?: Record<string, unknown> | null; channel?: Record<string, unknown> } = {},
) {
  const row = opts.row === undefined ? queuedRow : opts.row;
  const channel = opts.channel ?? baseInAppChannel;
  mock.setQueryResolver((query): Record<string, unknown>[] => {
    const sql = query.sql;
    if (sql.startsWith("select")) {
      if (sql.includes('from "Notification"')) return row ? [row] : [];
      if (sql.includes('from "NotificationChannel"')) return channel ? [channel] : [];
      return [];
    }
    return [];
  });
}

function makeAdapters(overrides: Partial<DispatchAdapters> = {}): DispatchAdapters {
  return {
    inApp: { send: vi.fn(() => Promise.resolve({ ok: true as const })) },
    email: { send: vi.fn(() => Promise.resolve({ ok: true as const, messageId: "sg-msg-1" })) },
    resolveRecipient: vi.fn((_db: unknown, userId: string) =>
      Promise.resolve({
        userId,
        locale: "en-US",
        addresses: { IN_APP: userId, EMAIL: `${userId}@example.com` },
      }),
    ),
    ...overrides,
  };
}

describe("deliverNotification", () => {
  it("posts an IN_APP row outside any transaction, advances QUEUED → SENT → DELIVERED, and passes the row id as the provider idempotency key", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock);
    const adapters = makeAdapters();

    const outcome = await deliverNotification(mock.db, "notification-1", adapters, NOW);

    expect(outcome.deliveryStatus).toBe("DELIVERED");
    expect(adapters.inApp.send).toHaveBeenCalledTimes(1);
    expect(adapters.inApp.send).toHaveBeenCalledWith(
      expect.objectContaining({
        notificationId: "notification-1",
        idempotencyKey: "notification-1",
      }),
    );
    const statuses = mock.updates.map((q) => q.updateValues().deliveryStatus);
    expect(statuses).toEqual(["SENT", "DELIVERED"]);
  });

  it("sends an EMAIL row, records SENT with the provider messageId, and forwards the idempotency key", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock, { row: { ...queuedRow, channelId: "channel-2" }, channel: baseEmailChannel });
    const adapters = makeAdapters();

    const outcome = await deliverNotification(mock.db, "notification-1", adapters, NOW);

    expect(outcome.deliveryStatus).toBe("SENT");
    expect(adapters.email?.send).toHaveBeenCalledWith(
      expect.objectContaining({ idempotencyKey: "notification-1" }),
    );
    const sentUpdate = mock.updates
      .map((q) => q.updateValues() as { deliveryStatus?: string; adapterMessageId?: string })
      .find((u) => u.deliveryStatus === "SENT");
    expect(sentUpdate?.adapterMessageId).toBe("sg-msg-1");
  });

  it("marks the row FAILED when the adapter reports a failure", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock);
    const adapters = makeAdapters({
      inApp: {
        send: vi.fn(() =>
          Promise.resolve({ ok: false as const, errorClass: "Transport", errorDetail: "boom" }),
        ),
      },
    });

    const outcome = await deliverNotification(mock.db, "notification-1", adapters, NOW);

    expect(outcome.deliveryStatus).toBe("FAILED");
    expect(mock.updates.map((q) => q.updateValues().deliveryStatus)).toEqual(["FAILED"]);
  });

  it("marks an EMAIL row FAILED without sending when no email adapter is wired", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock, { row: { ...queuedRow, channelId: "channel-2" }, channel: baseEmailChannel });
    const adapters = makeAdapters({ email: undefined });

    const outcome = await deliverNotification(mock.db, "notification-1", adapters, NOW);

    expect(outcome.deliveryStatus).toBe("FAILED");
  });

  it("marks the row FAILED without sending when the recipient address can no longer be resolved", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock);
    const adapters = makeAdapters({
      resolveRecipient: vi.fn((_db: unknown, userId: string) =>
        Promise.resolve({ userId, locale: "en-US", addresses: { IN_APP: null } }),
      ),
    });

    const outcome = await deliverNotification(mock.db, "notification-1", adapters, NOW);

    expect(outcome.deliveryStatus).toBe("FAILED");
    expect(adapters.inApp.send).not.toHaveBeenCalled();
  });

  it("is a no-op when the row is no longer QUEUED (a concurrent worker already advanced it)", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock, { row: { ...queuedRow, deliveryStatus: "SENT" } });
    const adapters = makeAdapters();

    const outcome = await deliverNotification(mock.db, "notification-1", adapters, NOW);

    expect(outcome.deliveryStatus).toBe("SKIPPED");
    expect(adapters.inApp.send).not.toHaveBeenCalled();
    expect(mock.updates).toHaveLength(0);
  });
});
