import type { CommandContext } from "@tailor-platform/erp-kit/core";
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,
  baseEventCategoryBinding,
  baseInAppChannel,
  baseQueuedNotification,
  baseTemplate,
} from "../testing/fixtures";
import { planPersistedEvent, type PersistedEventRow } from "./planEvent";

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

const ctx: CommandContext = {
  actorId: "notification-dispatch-runner",
  permissions: ["notification:ingest:logNotificationEvent"],
};

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

interface SelectStub {
  table: string;
  match?: Record<string, unknown>;
  value: unknown;
}

function stageQueries(mock: Mock, stubs: SelectStub[], insertValue?: unknown) {
  mock.setQueryResolver((query) => {
    const sql = query.sql;
    if (sql.startsWith("select")) {
      const table = /from "([^"]+)"/.exec(sql)?.[1] ?? null;
      const cols = Array.from(sql.matchAll(/"([^"]+)" = \$\d+/g)).map((m) => m[1]);
      const where: Record<string, unknown> = {};
      cols.forEach((col, i) => {
        where[col] = query.parameters[i];
      });
      const stub = stubs.find((s) => {
        if (s.table !== table) return false;
        if (!s.match) return true;
        return Object.entries(s.match).every(([k, v]) => where[k] === v);
      });
      return stub?.value === undefined ? [] : [stub.value as Record<string, unknown>];
    }
    if (sql.startsWith("insert")) {
      const table = /into "([^"]+)"/.exec(sql)?.[1] ?? null;
      if (table === "Notification" && insertValue !== undefined) {
        return [insertValue as Record<string, unknown>];
      }
      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` },
      }),
    ),
    now: () => NOW,
    ...overrides,
  };
}

const inAppTemplate = { ...baseTemplate, channelId: "channel-1" };
const emailTemplate = { ...baseTemplate, id: "template-2", channelId: "channel-2" };

function baseEvent(overrides: Partial<PersistedEventRow> = {}): PersistedEventRow {
  return {
    id: "event-1",
    eventType: "PO_ISSUED",
    sourceType: "PURCHASE_ORDER",
    sourceId: "po-1",
    actorUserId: "user-actor",
    payload: JSON.stringify({
      poNumber: "PO-001",
      recipients: [{ userId: "user-1", reason: "ASSIGNED" }],
    }),
    payloadHash: "hash-1",
    status: "PENDING",
    ...overrides,
  };
}

const deliverableStubs: SelectStub[] = [
  { table: "EventCategoryBinding", value: baseEventCategoryBinding },
  { table: "NotificationChannel", match: { channelId: "IN_APP" }, value: baseInAppChannel },
  { table: "NotificationChannel", match: { channelId: "EMAIL" }, value: baseEmailChannel },
  { table: "Notification", value: undefined },
  { table: "NotificationTemplate", match: { channelId: "channel-1" }, value: inAppTemplate },
  { table: "NotificationTemplate", match: { channelId: "channel-2" }, value: emailTemplate },
];

describe("planPersistedEvent (CDC plan e2e)", () => {
  it("plans a PENDING event from its payload.recipients, stamps it DISPATCHED, and returns the QUEUED outbox ids", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, deliverableStubs, { ...baseQueuedNotification });
    const adapters = makeAdapters();

    const outcome = await mock.withTx((trx) =>
      planPersistedEvent(trx, baseEvent(), adapters, ctx, NOW),
    );

    expect(outcome.ok).toBe(true);
    expect(outcome.status).toBe("DISPATCHED");
    // The single payload recipient (not the actor) was fanned out.
    expect(adapters.resolveRecipient).toHaveBeenCalledWith(expect.anything(), "user-1");
    // Planning never calls the channel adapters — delivery is a separate stage.
    expect(adapters.inApp.send).not.toHaveBeenCalled();
    expect(outcome.notificationIds.length).toBeGreaterThan(0);
  });

  it("stamps the event DISPATCHED via an in-transaction update", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, deliverableStubs, { ...baseQueuedNotification });

    await mock.withTx((trx) => planPersistedEvent(trx, baseEvent(), makeAdapters(), ctx, NOW));

    const eventUpdate = mock.updates
      .map((q) => ({ sql: q.sql, values: q.updateValues() as { status?: string } }))
      .find((u) => u.sql.includes('"NotificationEvent"'));
    expect(eventUpdate?.values.status).toBe("DISPATCHED");
  });

  it("derives the notification idempotencyKey from (eventType, sourceType, sourceId, payloadHash) so duplicate event rows converge on the same Notifications", async () => {
    const planAndCollectKeys = async (eventId: string) => {
      const mock = createKyselyMock<Namespace[keyof Namespace]>();
      stageQueries(mock, deliverableStubs, { ...baseQueuedNotification });
      await mock.withTx((trx) =>
        planPersistedEvent(trx, baseEvent({ id: eventId }), makeAdapters(), ctx, NOW),
      );
      return mock.inserts
        .map((q) => q.insertValues() as { idempotencyKey?: string })
        .filter((row) => row.idempotencyKey !== undefined)
        .map((row) => row.idempotencyKey);
    };

    const first = await planAndCollectKeys("event-1");
    const second = await planAndCollectKeys("event-2");

    expect(first.length).toBeGreaterThan(0);
    expect(new Set(first)).toEqual(new Set(["PO_ISSUED:PURCHASE_ORDER:po-1:hash-1"]));
    expect(second).toEqual(first);
  });

  it("stamps NO_DELIVERY when planning fails on an unmapped eventType (CATEGORY_NOT_FOUND)", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, [{ table: "EventCategoryBinding", value: undefined }]);

    const outcome = await mock.withTx((trx) =>
      planPersistedEvent(trx, baseEvent({ eventType: "UNMAPPED_EVENT" }), makeAdapters(), ctx, NOW),
    );

    expect(outcome.ok).toBe(false);
    expect(outcome.status).toBe("NO_DELIVERY");
    expect(outcome.reason).toBe("NOTIFICATION_CATEGORY_NOT_FOUND");
    expect(outcome.notificationIds).toHaveLength(0);
  });

  it("suppresses the actor from the recipient set and stamps NO_DELIVERY when only the actor was hinted", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, deliverableStubs);

    const event = baseEvent({
      payload: JSON.stringify({
        poNumber: "PO-001",
        recipients: [{ userId: "user-actor", reason: "AUTHOR" }],
      }),
    });
    const outcome = await mock.withTx((trx) =>
      planPersistedEvent(trx, event, makeAdapters(), ctx, NOW),
    );

    expect(outcome.ok).toBe(true);
    expect(outcome.status).toBe("NO_DELIVERY");
    expect(outcome.notificationIds).toHaveLength(0);
  });

  it("includes the actor when notifySelf is set", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stageQueries(mock, deliverableStubs, { ...baseQueuedNotification });
    const adapters = makeAdapters();

    const event = baseEvent({
      payload: JSON.stringify({
        poNumber: "PO-001",
        notifySelf: true,
        recipients: [{ userId: "user-actor", reason: "AUTHOR" }],
      }),
    });
    const outcome = await mock.withTx((trx) => planPersistedEvent(trx, event, adapters, ctx, NOW));

    expect(outcome.status).toBe("DISPATCHED");
    expect(adapters.resolveRecipient).toHaveBeenCalledWith(expect.anything(), "user-actor");
  });
});
