/**
 * listChangeDispatcher — access-scoped, self-suppressed, coalesced list-change
 * push notifications (NOTIF-02, D-01/D-02/D-03).
 *
 * Plugs into the same publish points as the SSE fan-out (publishListEvent).
 * Called after each meaningful list/item mutation; excluded for reorder (position)
 * changes (D-01).
 *
 * Threat mitigations:
 *   T-05-14: audience = list owner ∪ list_shares only — never all users.
 *   T-05-15: D-02 generic copy — no item text in notification body.
 *   T-05-16: D-03 excludeUserId = actorId — actor never receives their own push.
 *
 * Design notes:
 *   - notifyListChange is fire-and-forget (never awaited at call site).
 *   - dispatchPush is called per subscription; one failed send never aborts the loop.
 *   - coalesceListPush handles burst collapsing; this module owns audience + copy.
 */

import { eq, inArray } from 'drizzle-orm';
import { db } from '../db/client.js';
import { users, lists, listShares, pushSubscriptions } from '../db/schema.js';
import { coalesceListPush } from './pushCoalescer.js';
import { dispatchPush } from './pushDispatcher.js';

/**
 * Notify all accessible, non-actor subscribers that a list changed.
 *
 * Coalesces bursts per (list, actor) — calls within the window are batched
 * into a single push carrying the change count (D-01).
 *
 * @param listId   - The list that changed.
 * @param actorId  - The user who made the change. Their own subscriptions are
 *                   never dispatched (D-03 self-suppression).
 * @param windowMs - Coalesce window in ms (default 45 s). Override in tests
 *                   for fast fake-timer or real-timer test execution.
 */
export function notifyListChange(listId: number, actorId: number, windowMs?: number): void {
  coalesceListPush(
    listId,
    actorId,
    async (coalListId, coalActorId, count) => {
      try {
        await sendListChangePush(coalListId, coalActorId, count);
      } catch (err: unknown) {
        console.error(
          `[listChangeDispatcher] unhandled error for list ${coalListId}:`,
          err instanceof Error ? err.message : String(err),
        );
      }
    },
    windowMs,
  );
}

/**
 * Inner dispatch: resolves actor name + list name, builds the audience,
 * and sends a push to every accessible non-actor subscriber.
 */
async function sendListChangePush(listId: number, actorId: number, count: number): Promise<void> {
  // Resolve actor display name and list name in parallel
  const [actorRow, listRow] = await Promise.all([
    db.select({ displayName: users.displayName }).from(users).where(eq(users.id, actorId)).limit(1),
    db.select({ name: lists.name }).from(lists).where(eq(lists.id, listId)).limit(1),
  ]);

  if (!listRow[0]) {
    // List deleted between mutation and coalesce fire — no-op
    return;
  }

  const actorName: string = actorRow[0]?.displayName ?? 'Someone';
  const listName: string = listRow[0].name;

  // Build audience: list owner ∪ list_shares members, MINUS the actor (D-03)
  const [ownerRows, shareRows] = await Promise.all([
    db.select({ ownerId: lists.ownerId }).from(lists).where(eq(lists.id, listId)).limit(1),
    db.select({ userId: listShares.userId }).from(listShares).where(eq(listShares.listId, listId)),
  ]);

  if (!ownerRows[0]) {
    // List gone — no-op
    return;
  }

  const ownerId = ownerRows[0].ownerId;
  const shareUserIds = shareRows.map((r) => r.userId);

  // Union of owner + sharees; deduplicate; exclude actor (D-03)
  const audienceIds = [...new Set([ownerId, ...shareUserIds])].filter((uid) => uid !== actorId);

  if (audienceIds.length === 0) {
    return;
  }

  // Load push subscriptions for all audience members
  const subs = await db
    .select()
    .from(pushSubscriptions)
    .where(inArray(pushSubscriptions.userId, audienceIds));

  if (subs.length === 0) {
    return;
  }

  // D-02 generic copy: "{Actor} made {N} changes to {ListName}"
  // No item text — keeps the lock screen clean.
  const body = `${actorName} made ${count} ${count === 1 ? 'change' : 'changes'} to ${listName}`;
  const notification = {
    title: listName,
    body,
    tag: `list-change:${listId}`,
    navigate: `/lists/${listId}`,
  };

  // Fan out to each subscription; one failure must not abort the rest (T-05-04)
  for (const sub of subs) {
    await dispatchPush(sub, notification).catch((err: unknown) => {
      console.error(
        `[listChangeDispatcher] dispatchPush failed for sub ${sub.id}:`,
        err instanceof Error ? err.message : String(err),
      );
    });
  }
}
