// Contract tests for the insurance re-drain cron's two duties: (1) scan PENDING
// events up to `limit`, re-read each `forUpdate` in its own transaction and
// re-plan via the shared helper; (2) drain stranded QUEUED Notification outbox
// rows by delivering them. Per-row failures are contained (aggregate + throw at
// the end) and NO_DELIVERY is surfaced via logger.warn.

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 { redrainNotificationEvents } from "./redrainNotificationEvents";

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

function makeEvent(id: string, recipientUserId = "user-1", status = "PENDING") {
  return {
    id,
    eventType: "PO_ISSUED",
    sourceType: "PURCHASE_ORDER",
    sourceId: `po-${id}`,
    actorUserId: "user-actor",
    payload: JSON.stringify({ recipients: [{ userId: recipientUserId, reason: "ASSIGNED" }] }),
    payloadHash: `hash-${id}`,
    status,
  };
}

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 event scan (id-only) and per-row forUpdate re-read, the catalog
// lookups + Notification insert the plan engine runs, the stranded-QUEUED scan,
// and the delivery worker's id-keyed read-back (synthetic QUEUED row, since the
// mock does not persist inserts).
function stageQueries(
  mock: Mock,
  opts: {
    stuck?: Array<{ id: string }>;
    eventsById?: Record<string, Record<string, unknown> | undefined>;
    queued?: Array<{ id: string }>;
    withBinding?: boolean;
  } = {},
) {
  const withBinding = opts.withBinding ?? true;
  mock.setQueryResolver((query) => {
    const sql = query.sql;
    if (sql.startsWith("select")) {
      if (sql.includes('from "NotificationEvent"')) {
        if (sql.includes("for update")) {
          const id = query.parameters[0] as string;
          const event = opts.eventsById?.[id];
          return event ? [event] : [];
        }
        return opts.stuck ?? [];
      }
      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]);
        if (cols.includes("recipientUserId")) return []; // plan dedup miss
        if (cols.includes("deliveryStatus")) return opts.queued ?? []; // stranded scan
        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",
          },
        ];
      }
      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, limit?: number) {
  const executor = redrainNotificationEvents({
    cron: "*/15 * * * *",
    getDB: () => mock.db,
    adapters,
    ...(limit !== undefined ? { limit } : {}),
  });
  return executor.operation.body;
}

function scanQuery(mock: Mock) {
  return mock.selects.find(
    (q) => q.sql.includes('from "NotificationEvent"') && !q.sql.includes("for update"),
  );
}

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

describe("redrainNotificationEvents (insurance cron)", () => {
  beforeEach(() => {
    vi.spyOn(logger, "warn").mockImplementation(() => {});
    vi.spyOn(logger, "error").mockImplementation(() => {});
  });

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

  it("scans only PENDING events, oldest first, with the configured limit bound into the query", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, { stuck: [] });

    await makeBody(mock, makeAdapters(), 5)();

    const scan = scanQuery(mock);
    expect(scan).toBeDefined();
    expect(scan?.sql).toContain('"status" =');
    expect(scan?.sql).toContain('order by "createdAt" asc');
    expect(scan?.sql).toContain("limit");
    expect(scan?.parameters).toEqual(["PENDING", 5]);
  });

  it("defaults the scan limit to 100", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, { stuck: [] });

    await makeBody(mock, makeAdapters())();

    expect(scanQuery(mock)?.parameters).toEqual(["PENDING", 100]);
  });

  it("re-plans a still-PENDING event, stamps it DISPATCHED, and delivers the stranded QUEUED outbox row", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, {
      stuck: [{ id: "event-1" }],
      eventsById: { "event-1": makeEvent("event-1") },
      queued: [{ id: "queued-1" }],
    });
    const adapters = makeAdapters();

    await makeBody(mock, adapters)();

    // Delivery (not planning) is what posts to the adapter.
    expect(adapters.inApp.send).toHaveBeenCalledTimes(1);
    const stamps = eventStamps(mock);
    expect(stamps).toHaveLength(1);
    expect(stamps[0].updateValues().status).toBe("DISPATCHED");
    expect(stamps[0].parameters).toContain("event-1");
  });

  it("skips a row that lost PENDING between the scan and the forUpdate lock", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, {
      stuck: [{ id: "event-1" }],
      eventsById: { "event-1": makeEvent("event-1", "user-1", "DISPATCHED") },
    });
    const adapters = makeAdapters();

    await makeBody(mock, adapters)();

    expect(eventStamps(mock)).toHaveLength(0);
    expect(logger.error).not.toHaveBeenCalled();
  });

  it("contains a per-row plan failure: the other rows still plan, then the aggregated failure throws", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, {
      stuck: [{ id: "event-1" }, { id: "event-2" }],
      eventsById: {
        "event-1": makeEvent("event-1", "user-bad"),
        "event-2": makeEvent("event-2", "user-1"),
      },
    });
    const adapters = makeAdapters({
      resolveRecipient: vi.fn((_db: unknown, userId: string) => {
        if (userId === "user-bad") return Promise.reject(new Error("recipient lookup down"));
        return Promise.resolve({
          userId,
          locale: "en-US",
          addresses: { IN_APP: userId, EMAIL: `${userId}@example.com` },
        });
      }),
    });

    await expect(makeBody(mock, adapters)()).rejects.toThrow("1 re-drains failed");

    // event-1 failed inside its own transaction...
    expect(logger.error).toHaveBeenCalledTimes(1);
    expect(vi.mocked(logger.error).mock.calls[0][0]).toContain("event-1");
    // ...but event-2 was still planned and stamped.
    const stamps = eventStamps(mock);
    expect(stamps).toHaveLength(1);
    expect(stamps[0].updateValues().status).toBe("DISPATCHED");
    expect(stamps[0].parameters).toContain("event-2");
    expect(vi.mocked(logger.warn).mock.calls.some((c) => c[0].includes("1 failures"))).toBe(true);
  });

  it("warns with the event id and reason when a re-planned event lands NO_DELIVERY", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, {
      stuck: [{ id: "event-1" }],
      eventsById: { "event-1": makeEvent("event-1") },
      withBinding: false,
    });

    // NO_DELIVERY is not a failure: the cycle completes without throwing.
    await makeBody(mock, makeAdapters())();

    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");
    const stamps = eventStamps(mock);
    expect(stamps).toHaveLength(1);
    expect(stamps[0].updateValues().status).toBe("NO_DELIVERY");
  });

  it("sweeps only stranded outbox rows older than the grace window (createdAt cutoff bound into both scans)", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, { stuck: [], queued: [{ id: "queued-1" }] });
    const destination = { send: vi.fn(() => Promise.resolve({ ok: true as const })) };
    const adapters = makeAdapters({ destination });

    const executor = redrainNotificationEvents({
      cron: "*/15 * * * *",
      getDB: () => mock.db,
      adapters,
      staleAfterMs: 120_000,
    });
    await executor.operation.body();

    const strandedNotificationScan = mock.selects.find(
      (q) =>
        q.sql.includes('from "Notification"') &&
        q.sql.includes('"deliveryStatus" =') &&
        q.sql.includes('"createdAt" <'),
    );
    expect(strandedNotificationScan).toBeDefined();

    const strandedLogScan = mock.selects.find(
      (q) =>
        q.sql.includes('from "DestinationDeliveryLog"') &&
        q.sql.includes('"status" =') &&
        q.sql.includes('"createdAt" <'),
    );
    expect(strandedLogScan).toBeDefined();
    // The cutoff is a Date strictly in the past relative to the run clock.
    const cutoff = strandedLogScan?.parameters.find((p) => p instanceof Date) as Date | undefined;
    expect(cutoff).toBeInstanceOf(Date);
    expect(cutoff!.getTime()).toBeLessThan(Date.now());
  });

  it("delivers stranded QUEUED outbox rows even when no events are PENDING", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, { stuck: [], queued: [{ id: "queued-1" }] });
    const adapters = makeAdapters();

    await makeBody(mock, adapters)();

    expect(adapters.inApp.send).toHaveBeenCalledTimes(1);
    expect(eventStamps(mock)).toHaveLength(0);
  });
});
