import type { CommandContext } from "@tailor-platform/erp-kit/core";
import { createKyselyMock, type KyselyMock } from "@tailor-platform/sdk/vitest";
import { describe, expect, it } from "vitest";
import type { Namespace } from "../generated/kysely-tailordb";
import {
  InvalidPayloadError,
  MissingEntityReferenceError,
  MissingRequiredFieldError,
} from "../lib/errors.generated";
import { run, type LogNotificationEventInput } from "./logNotificationEvent";

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

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

const baseInput: LogNotificationEventInput = {
  eventType: "PO_ISSUED",
  sourceType: "PURCHASE_ORDER",
  sourceId: "po-1",
  actorUserId: "user-actor",
  payload: {
    title: "New purchase order",
    recipients: [{ userId: "user-1", reason: "SUBSCRIBED" }],
  },
};

interface Insert {
  sql: string;
  parameters: readonly unknown[];
}

// Resolve the idempotency select against `existing`, echo the inserted row back
// for `.returningAll()`, and record every insert's parameters for assertions.
function stage(mock: Mock, existing?: Record<string, unknown>): Insert[] {
  const inserts: Insert[] = [];
  mock.setQueryResolver((query) => {
    const sql = query.sql;
    if (sql.startsWith("select")) {
      return existing ? [existing] : [];
    }
    if (sql.startsWith("insert")) {
      inserts.push({ sql, parameters: query.parameters });
      return [{ id: "event-1", status: "PENDING" }];
    }
    return [];
  });
  return inserts;
}

describe("logNotificationEvent", () => {
  it("creates a PENDING NotificationEvent for a new idempotency key and returns duplicate: false", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock);

    const result = await mock.withTx((trx) => run(trx, baseInput, ctx));

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.duplicate).toBe(false);
      expect(result.value.notificationEvent).toMatchObject({ status: "PENDING" });
    }
  });

  it("returns duplicate: true with the existing row when the same idempotency key is logged twice", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock, { id: "event-existing", status: "PENDING" });

    const result = await mock.withTx((trx) => run(trx, baseInput, ctx));

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.duplicate).toBe(true);
      expect(result.value.notificationEvent).toMatchObject({ id: "event-existing" });
    }
  });

  it("accepts a non-blank eventType even when it is not yet in the Event Catalog (validation is deferred to dispatch)", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock);

    const result = await mock.withTx((trx) =>
      run(trx, { ...baseInput, eventType: "SOME_UNKNOWN_EVENT" }, ctx),
    );

    expect(result.ok).toBe(true);
  });

  it("rejects a blank eventType", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock);

    const result = await mock.withTx((trx) => run(trx, { ...baseInput, eventType: "" }, ctx));

    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.error).toBeInstanceOf(MissingRequiredFieldError);
  });

  it("rejects a missing source reference (sourceType or sourceId)", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock);

    const result = await mock.withTx((trx) => run(trx, { ...baseInput, sourceId: "" }, ctx));

    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.error).toBeInstanceOf(MissingEntityReferenceError);
  });

  it("rejects an unparseable payload", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    stage(mock);

    const result = await mock.withTx((trx) =>
      run(trx, { ...baseInput, payload: "not json {" }, ctx),
    );

    expect(result.ok).toBe(false);
    if (!result.ok) expect(result.error).toBeInstanceOf(InvalidPayloadError);
  });

  it("preserves the caller-supplied payload content (canonical key order) without reading source tables", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    const inserts = stage(mock);
    const rawPayload = '{"title":"Verbatim","custom":[1,2,3]}';

    const result = await mock.withTx((trx) => run(trx, { ...baseInput, payload: rawPayload }, ctx));

    expect(result.ok).toBe(true);
    // The stored payload carries the caller's content verbatim, serialized in
    // the canonical (key-sorted) form that backs the payloadHash.
    expect(inserts).toHaveLength(1);
    expect(inserts[0].parameters).toContain('{"custom":[1,2,3],"title":"Verbatim"}');
  });

  it("falls back to duplicate: true when a concurrent emit wins the unique-index race between the probe and the insert", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    let selectCount = 0;
    mock.setQueryResolver((query) => {
      if (query.sql.startsWith("select")) {
        selectCount += 1;
        // Probe sees nothing; the post-insert re-select finds the row the
        // concurrent transaction committed.
        return selectCount === 1 ? [] : [{ id: "event-raced", status: "PENDING" }];
      }
      throw new Error('duplicate key value violates unique constraint "uq_notification_event"');
    });

    const result = await mock.withTx((trx) => run(trx, baseInput, ctx));

    expect(result.ok).toBe(true);
    if (result.ok) {
      expect(result.value.duplicate).toBe(true);
      expect(result.value.notificationEvent).toMatchObject({ id: "event-raced" });
    }
    expect(selectCount).toBe(2);
  });

  it("rethrows an insert failure when the re-select finds no row for the tuple (not a duplicate race)", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    mock.setQueryResolver((query) => {
      if (query.sql.startsWith("select")) return [];
      throw new Error("transient storage error");
    });

    await expect(mock.withTx((trx) => run(trx, baseInput, ctx))).rejects.toThrow(
      "transient storage error",
    );
  });

  it("a differing payload for the same (eventType, sourceType, sourceId) produces a distinct row (different payloadHash)", async () => {
    const mock = createKyselyMock<Namespace[keyof Namespace]>();
    const inserts = stage(mock);

    await mock.withTx((trx) => run(trx, { ...baseInput, payload: '{"v":1}' }, ctx));
    await mock.withTx((trx) => run(trx, { ...baseInput, payload: '{"v":2}' }, ctx));

    expect(inserts).toHaveLength(2);
    // payloadHash is one of the insert parameters; the two differ.
    const hashes = inserts.map((ins) =>
      ins.parameters.find((p) => typeof p === "string" && /^[0-9a-f]{64}$/.test(p)),
    );
    expect(hashes[0]).toBeDefined();
    expect(hashes[1]).toBeDefined();
    expect(hashes[0]).not.toBe(hashes[1]);
  });
});
