// Contract tests for the CDC dispatch executor: re-read the fired row
// `forUpdate`, guard on `status = PENDING` (at-least-once CDC safety), run the
// shared plan helper inside the transaction, deliver the planned outbox rows
// outside it, surface NO_DELIVERY via logger.warn, and re-throw plan failures
// after logger.error.

import { logger } from "@tailor-platform/function-logger";
import { createKyselyMock, type KyselyMock } from "@tailor-platform/sdk/vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Namespace } from "../generated/kysely-tailordb";
import type { DispatchAdapters } from "../lib/dispatchAdapters";
import { dispatchNotificationEvents } from "./dispatchNotificationEvents";

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

const pendingEvent = {
  id: "event-1",
  eventType: "PO_ISSUED",
  sourceType: "PURCHASE_ORDER",
  sourceId: "po-1",
  actorUserId: "user-actor",
  payload: JSON.stringify({ recipients: [{ userId: "user-1", reason: "ASSIGNED" }] }),
  payloadHash: "hash-1",
  status: "PENDING",
};

const inAppChannel = {
  id: "channel-1",
  channelId: "IN_APP",
  kind: "PERSONAL",
  enabled: true,
};

const binding = {
  id: "binding-1",
  eventType: "PO_ISSUED",
  categoryId: "category-1",
  transactional: true,
  defaultChannels: ["IN_APP"],
};

const inAppTemplate = {
  id: "template-1",
  eventType: "PO_ISSUED",
  channelId: "channel-1",
  locale: "en-US",
  subject: "PO issued",
  body: "A purchase order has been issued.",
  htmlBody: null,
  variableSchema: '{"variables":[]}',
};

// Stages the queries the executor + plan/deliver engine run: the forUpdate
// re-read of the fired NotificationEvent, the catalog lookups, the Notification
// insert (returningAll echoes the inserted row), and the delivery worker's
// read-back of the QUEUED row by id (the mock does not persist inserts, so the
// id-keyed select is answered with a synthetic QUEUED row).
function stageQueries(
  mock: Mock,
  opts: { event?: Record<string, unknown>; withBinding?: boolean } = {},
) {
  const withBinding = opts.withBinding ?? true;
  mock.setQueryResolver((query) => {
    const sql = query.sql;
    if (sql.startsWith("select")) {
      if (sql.includes('from "NotificationEvent"')) return opts.event ? [opts.event] : [];
      if (sql.includes('from "EventCategoryBinding"')) return withBinding ? [binding] : [];
      if (sql.includes('from "NotificationChannel"')) return [inAppChannel];
      if (sql.includes('from "NotificationTemplate"')) return [inAppTemplate];
      if (sql.includes('from "Notification"')) {
        const cols = Array.from(sql.matchAll(/"([^"]+)" = \$\d+/g)).map((m) => m[1]);
        // The plan-stage dedup probe keys on recipientUserId: a miss (no row).
        if (cols.includes("recipientUserId")) return [];
        // The delivery worker's read-back keys on id alone: a QUEUED row.
        return [
          {
            id: query.parameters[0],
            recipientUserId: "user-1",
            channelId: "channel-1",
            subject: "PO issued",
            body: "A purchase order has been issued.",
            htmlBody: null,
            deliveryStatus: "QUEUED",
          },
        ];
      }
      // NotificationCategory / NotificationSubscription: empty.
      return [];
    }
    if (sql.startsWith("insert") && sql.includes('into "Notification"')) {
      return [query.insertValues()];
    }
    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: "msg-1" })) },
    resolveRecipient: vi.fn((_db: unknown, userId: string) =>
      Promise.resolve({
        userId,
        locale: "en-US",
        addresses: { IN_APP: userId, EMAIL: `${userId}@example.com` },
      }),
    ),
    ...overrides,
  };
}

function makeBody(mock: Mock, adapters: DispatchAdapters) {
  const executor = dispatchNotificationEvents({
    // The trigger only reads `type.name` at config time.
    notificationEventType: { name: "NotificationEvent" } as never,
    getDB: () => mock.db,
    adapters,
  });
  return executor.operation.body;
}

function eventUpdates(mock: Mock) {
  return mock.updates.filter((q) => q.sql.includes('update "NotificationEvent"'));
}

describe("dispatchNotificationEvents (CDC executor)", () => {
  beforeEach(() => {
    vi.spyOn(logger, "warn").mockImplementation(() => {});
    vi.spyOn(logger, "error").mockImplementation(() => {});
  });

  afterEach(() => {
    vi.restoreAllMocks();
  });

  it("dispatches a PENDING row and stamps the event DISPATCHED", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, { event: pendingEvent });
    const adapters = makeAdapters();

    await makeBody(mock, adapters)({ newRecord: { id: "event-1" } });

    // The forUpdate re-read takes the PENDING-guard lock.
    const reread = mock.selects.find((q) => q.sql.includes('from "NotificationEvent"'));
    expect(reread?.sql).toContain("for update");
    expect(reread?.parameters).toEqual(["event-1"]);

    expect(adapters.inApp.send).toHaveBeenCalledTimes(1);
    const stamps = eventUpdates(mock);
    expect(stamps).toHaveLength(1);
    expect(stamps[0].updateValues().status).toBe("DISPATCHED");
    expect(stamps[0].parameters).toContain("event-1");
    expect(logger.warn).not.toHaveBeenCalled();
  });

  it("is a no-op when the forUpdate re-read finds the row no longer PENDING (at-least-once re-fire)", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, { event: { ...pendingEvent, status: "DISPATCHED" } });
    const adapters = makeAdapters();

    await makeBody(mock, adapters)({ newRecord: { id: "event-1" } });

    expect(adapters.resolveRecipient).not.toHaveBeenCalled();
    expect(adapters.inApp.send).not.toHaveBeenCalled();
    expect(mock.updates).toHaveLength(0);
    expect(logger.warn).not.toHaveBeenCalled();
  });

  it("is a no-op when the row has vanished", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, { event: undefined });
    const adapters = makeAdapters();

    await makeBody(mock, adapters)({ newRecord: { id: "event-1" } });

    expect(adapters.inApp.send).not.toHaveBeenCalled();
    expect(mock.updates).toHaveLength(0);
  });

  it("warns with the eventId and reason when the event is stamped NO_DELIVERY", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    // No EventCategoryBinding: the dispatcher rejects the eventType.
    stageQueries(mock, { event: pendingEvent, withBinding: false });
    const adapters = makeAdapters();

    await makeBody(mock, adapters)({ newRecord: { id: "event-1" } });

    const stamps = eventUpdates(mock);
    expect(stamps).toHaveLength(1);
    expect(stamps[0].updateValues().status).toBe("NO_DELIVERY");
    expect(logger.warn).toHaveBeenCalledTimes(1);
    const message = vi.mocked(logger.warn).mock.calls[0][0];
    expect(message).toContain("event-1");
    expect(message).toContain("NOTIFICATION_CATEGORY_NOT_FOUND");
  });

  it("logs and re-throws when dispatch fails, leaving the event unstamped", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, { event: pendingEvent });
    const adapters = makeAdapters({
      resolveRecipient: vi.fn(() => Promise.reject(new Error("recipient lookup down"))),
    });

    await expect(makeBody(mock, adapters)({ newRecord: { id: "event-1" } })).rejects.toThrow(
      "recipient lookup down",
    );

    expect(logger.error).toHaveBeenCalledTimes(1);
    expect(vi.mocked(logger.error).mock.calls[0][0]).toContain("event-1");
    // The throw aborted the transaction before the terminal-status stamp.
    expect(eventUpdates(mock)).toHaveLength(0);
  });
});
