/**
 * Integration tests — listChangeDispatcher (Plan 05-05).
 *
 * Asserts that notifyListChange:
 *   - Calls dispatchPush exactly ONCE per subscriber who is NOT the actor,
 *     after a burst of calls coalesces within the window.
 *   - Self-suppression: the actor never receives a push (D-03).
 *   - Access scoping: a third user with no owner/share access is never dispatched.
 *   - Empty audience (no other accessible members or no subscriptions): no dispatch,
 *     no crash.
 *
 * Uses a real dev MariaDB (same pattern as lists.test.ts) for DB seeding.
 * Mocks dispatchPush via vi.doMock to assert recipients without network.
 * Uses a tiny windowMs (10 ms) so the coalescer fires quickly with real timers.
 *
 * Run:
 *   set -a; . .env 2>/dev/null; set +a
 *   export DB_HOST=127.0.0.1
 *   pnpm --filter @familysync/api exec vitest run tests/lib/listChangeDispatcher.test.ts
 */

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { randomUUID } from 'node:crypto';
import { db } from '../../src/db/client.js';
import { users, lists, listShares, pushSubscriptions } from '../../src/db/schema.js';

// ---------------------------------------------------------------------------
// Seed helpers
// ---------------------------------------------------------------------------

async function seedUser(label: string, displayName?: string): Promise<number> {
  const [result] = await db
    .insert(users)
    .values({
      oidcIss: 'https://auth.test',
      oidcSub: `sub-${label}-${randomUUID()}`,
      displayName: displayName ?? `User ${label}`,
      color: '#4A90D9',
    })
    .$returningId();
  return result.id;
}

async function seedList(ownerId: number, name: string, isShared = true): Promise<number> {
  const [result] = await db.insert(lists).values({ ownerId, name, isShared }).$returningId();
  return result.id;
}

async function seedShare(listId: number, userId: number): Promise<void> {
  await db.insert(listShares).values({ listId, userId });
}

async function seedSubscription(userId: number, suffix = ''): Promise<number> {
  const [result] = await db
    .insert(pushSubscriptions)
    .values({
      userId,
      endpoint: `https://push.example.com/${userId}${suffix}`,
      p256dh: 'fake-p256dh-key',
      auth: 'fake-auth',
    })
    .$returningId();
  return result.id;
}

/** Short wait for real-timer coalescer window + async DB queries to settle. */
function sleep(ms: number): Promise<void> {
  return new Promise<void>((resolve) => setTimeout(resolve, ms));
}

/**
 * Poll a predicate until it passes or the timeout is exceeded.
 * Replaces @testing-library/waitFor — keeps the test deps minimal.
 */
async function pollUntil(predicate: () => void, timeoutMs = 3000, intervalMs = 30): Promise<void> {
  const deadline = Date.now() + timeoutMs;
  let lastErr: unknown;
  while (Date.now() < deadline) {
    try {
      predicate();
      return; // predicate passed
    } catch (err) {
      lastErr = err;
    }
    await sleep(intervalMs);
  }
  throw lastErr;
}

// ---------------------------------------------------------------------------
// Tests
// Use a tiny windowMs (10 ms) so the coalescer fires quickly in real time.
// Use vi.doMock + vi.resetModules before each test so the mock is fresh.
// ---------------------------------------------------------------------------

const WINDOW_MS = 10;

describe('notifyListChange — access-scoped, self-suppressed, coalesced (NOTIF-02)', () => {
  beforeEach(() => {
    vi.resetModules();
    // Re-register the dispatchPush mock after each resetModules so fresh
    // dynamic imports of listChangeDispatcher.js get the mocked pushDispatcher.
    vi.doMock('../../src/lib/pushDispatcher.js', () => ({
      dispatchPush: vi.fn().mockResolvedValue(undefined),
    }));
  });

  async function getDispatchPushMock() {
    const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
    return vi.mocked(dispatchPush);
  }

  it('burst of N calls coalesces into exactly one dispatchPush to the non-actor subscriber', async () => {
    const actorId = await seedUser('actor-burst', 'Alice');
    const otherId = await seedUser('other-burst', 'Bob');
    const listId = await seedList(actorId, 'Groceries');
    await seedShare(listId, otherId);

    // Subscriptions: one for actor, one for other
    await seedSubscription(actorId);
    const otherSubId = await seedSubscription(otherId);

    const mockDispatch = await getDispatchPushMock();
    const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');

    // Three rapid calls within the coalesce window
    notifyListChange(listId, actorId, WINDOW_MS);
    notifyListChange(listId, actorId, WINDOW_MS);
    notifyListChange(listId, actorId, WINDOW_MS);

    // Wait for the coalescer window to expire + DB queries to settle
    await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1));

    // The called subscription must be the other user's subscription
    const [calledSub, notification] = mockDispatch.mock.calls[0];
    expect(calledSub.id).toBe(otherSubId);
    expect(calledSub.userId).toBe(otherId);

    // Notification body must contain actor name and change count
    expect(notification.body).toMatch(/Alice/);
    expect(notification.body).toMatch(/3/);
    // Notification must have a title (D-02 — no item text, just generic copy)
    expect(notification.title).toBeDefined();
    expect(typeof notification.title).toBe('string');
  });

  it('actor is never dispatched to their own subscription (D-03 self-suppression)', async () => {
    const actorId = await seedUser('actor-self-suppress', 'Charlie');
    const listId = await seedList(actorId, 'Private List');
    // Actor subscribes, but there are no other accessible members → audience is empty after D-03
    await seedSubscription(actorId);

    const mockDispatch = await getDispatchPushMock();
    const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');

    notifyListChange(listId, actorId, WINDOW_MS);

    // Wait past the window; actor's subscription must never be dispatched
    await sleep(WINDOW_MS + 200);

    expect(mockDispatch).not.toHaveBeenCalled();
  });

  it('unrelated user with no access is never dispatched (T-05-14 access scoping)', async () => {
    const actorId = await seedUser('actor-scope', 'Dave');
    const memberId = await seedUser('member-scope', 'Eve');
    const unrelatedId = await seedUser('unrelated-scope', 'Frank');

    const listId = await seedList(actorId, 'Scoped List');
    await seedShare(listId, memberId);

    await seedSubscription(actorId);
    await seedSubscription(memberId);
    // Unrelated user also has a subscription — must never be dispatched
    await seedSubscription(unrelatedId);

    const mockDispatch = await getDispatchPushMock();
    const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');

    notifyListChange(listId, actorId, WINDOW_MS);

    // Wait for coalescer + DB queries to settle
    await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1));

    // Only member (Eve) should be dispatched; unrelated (Frank) must NOT receive push
    const [calledSub] = mockDispatch.mock.calls[0];
    expect(calledSub.userId).toBe(memberId);
    expect(calledSub.userId).not.toBe(unrelatedId);
  });

  it('empty audience (no other members) → no dispatch, no crash', async () => {
    const actorId = await seedUser('actor-no-other', 'Grace');
    const listId = await seedList(actorId, 'Solo List');
    // No shares; actor-only list
    await seedSubscription(actorId);

    const mockDispatch = await getDispatchPushMock();
    const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');

    notifyListChange(listId, actorId, WINDOW_MS);
    await sleep(WINDOW_MS + 200);

    expect(mockDispatch).not.toHaveBeenCalled();
  });

  it('empty audience (non-actor member has no subscription) → no dispatch, no crash', async () => {
    const actorId = await seedUser('actor-no-sub', 'Heidi');
    const otherId = await seedUser('other-no-sub', 'Ivan');

    const listId = await seedList(actorId, 'No Sub List');
    await seedShare(listId, otherId);

    // Actor has a subscription, other does NOT
    await seedSubscription(actorId);
    // Deliberately no subscription for otherId

    const mockDispatch = await getDispatchPushMock();
    const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');

    notifyListChange(listId, actorId, WINDOW_MS);
    await sleep(WINDOW_MS + 200);

    // Other has no subscription → no push dispatched
    expect(mockDispatch).not.toHaveBeenCalled();
  });
});
