import { EventEmitter } from "node:events";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";

/**
 * Ticket #57 — verify that:
 *
 *   1. Publishing on RBAC_INVALIDATE_CHANNEL from node A causes node B to
 *      drop its local `_roleDefaultsCache`, so its next `loadRoleDefaults()`
 *      call re-reads from the DB (acceptance criterion:
 *      "2nd node sees update within 1 s" — we verify the mechanism, not the
 *      Redis latency).
 *
 *   2. `invalidateRoleDefaultsCache()` on the current node publishes on the
 *      same channel so peer instances receive the event.
 *
 * Strategy: stub `ioredis` with an EventEmitter-based fake before loading
 * trpc.ts. The fake captures `publish()` calls and lets the test emit
 * synthetic "message" events.
 */

// Fake Redis with two separate instances so the test mirrors the multi-node
// shape: one as subscriber, one as publisher. Both share the same module-
// level event router keyed by channel.
const channelSubscribers = new Map<string, Set<FakeRedis>>();
const publishCalls: Array<{ channel: string; message: string }> = [];

class FakeRedis extends EventEmitter {
  constructor(_url: string, _opts: unknown) {
    super();
  }
  // eslint-disable-next-line @typescript-eslint/require-await
  async subscribe(channel: string): Promise<number> {
    let set = channelSubscribers.get(channel);
    if (!set) {
      set = new Set();
      channelSubscribers.set(channel, set);
    }
    set.add(this);
    return set.size;
  }
  // eslint-disable-next-line @typescript-eslint/require-await
  async publish(channel: string, message: string): Promise<number> {
    publishCalls.push({ channel, message });
    const subs = channelSubscribers.get(channel);
    if (!subs) return 0;
    // Fan out synchronously so the subscriber handler runs before the test
    // assertion reads the cache — matches real ioredis "message" semantics
    // from the subscriber's point of view.
    for (const sub of subs) sub.emit("message", channel, message);
    return subs.size;
  }
}

vi.mock("ioredis", () => ({ Redis: FakeRedis, default: FakeRedis }));
vi.mock("../lib/logger.js", () => ({
  logger: { warn: vi.fn(), error: vi.fn(), info: vi.fn(), debug: vi.fn() },
}));

// Prisma client mock — loadRoleDefaults pulls from systemRoleConfig.findMany.
const findManyCalls: number[] = [];
vi.mock("@capakraken/db", async () => {
  const actual = await vi.importActual<Record<string, unknown>>("@capakraken/db");
  return {
    ...actual,
    prisma: {
      systemRoleConfig: {
        findMany: vi.fn().mockImplementation(async () => {
          findManyCalls.push(Date.now());
          return [{ role: "ADMIN", defaultPermissions: ["MANAGE_USERS"] }];
        }),
      },
    },
  };
});

// REDIS_URL is needed so trpc.ts decides to instantiate the fake Redis.
// `trpc.ts` now reads it lazily on first RBAC call, so setting it in
// beforeAll is enough; we always restore in afterAll to avoid leaking into
// other test files in the same worker.
const originalRedisUrl = process.env["REDIS_URL"];

describe("RBAC cache Redis pub/sub (#57)", () => {
  beforeAll(() => {
    process.env["REDIS_URL"] = "redis://fake:6379";
  });
  afterAll(() => {
    if (originalRedisUrl === undefined) delete process.env["REDIS_URL"];
    else process.env["REDIS_URL"] = originalRedisUrl;
  });
  beforeEach(() => {
    findManyCalls.length = 0;
  });

  it("peer-instance invalidation: receiving a message clears the local cache", async () => {
    const { loadRoleDefaults } = await import("../trpc.js");

    // Warm the cache.
    await loadRoleDefaults();
    const hitsAfterWarm = findManyCalls.length;
    expect(hitsAfterWarm).toBe(1);

    // Second call within TTL should be cached — no additional findMany.
    await loadRoleDefaults();
    expect(findManyCalls.length).toBe(hitsAfterWarm);

    // Simulate a peer instance publishing an invalidation: grab any
    // subscriber on the channel and fire the event as if Redis delivered it.
    const subs = channelSubscribers.get("capakraken:rbac-invalidate");
    expect(subs).toBeDefined();
    expect(subs!.size).toBeGreaterThanOrEqual(1);
    for (const sub of subs!) sub.emit("message", "capakraken:rbac-invalidate", "1");

    // Next load must hit the DB again.
    await loadRoleDefaults();
    expect(findManyCalls.length).toBe(hitsAfterWarm + 1);
  });

  it("local invalidation publishes on the RBAC channel", async () => {
    const { invalidateRoleDefaultsCache } = await import("../trpc.js");
    const countBefore = publishCalls.length;

    invalidateRoleDefaultsCache();

    // Give the microtask queue one tick (publish returns a promise).
    await Promise.resolve();

    const newPublishes = publishCalls.slice(countBefore);
    expect(newPublishes.length).toBe(1);
    expect(newPublishes[0]!.channel).toBe("capakraken:rbac-invalidate");
  });
});
