import { ForbiddenException, NotFoundException } from "@nestjs/common";
import { and, eq, gte, inArray, isNotNull, isNull, lte, or } from "drizzle-orm";
import type Redis from "ioredis";
import type { AttendeeStatus, CalendarEvent, EventAttendee } from "@slackwsh/contracts";
import { schema, withTenant, withUser } from "@slackwsh/data";
import { can, expandOccurrences, MAX_OCCURRENCES, parseRecurrenceRule } from "@slackwsh/core";
import { asEntityId, requireWorkspaceMembership, type EntityIdInput } from "./channels";
import { activeWorkspaceMemberIds, fanoutToMembers } from "./member-fanout";

export interface CreateEventInput {
  title: string;
  description?: string | null;
  location?: string | null;
  startsAt: string;
  endsAt: string;
  allDay?: boolean;
  timezone?: string;
  recurrenceRule?: string | null;
  channelId?: EntityIdInput | null;
  attendeeUserIds?: EntityIdInput[];
}

export type EventWriteScope = "series" | "occurrence";

export interface UpdateEventInput {
  scope?: EventWriteScope;
  occurrenceDate?: string;
  title?: string;
  description?: string | null;
  location?: string | null;
  startsAt?: string;
  endsAt?: string;
  allDay?: boolean;
  timezone?: string;
  recurrenceRule?: string | null;
  channelId?: EntityIdInput | null;
}

export interface ListEventsFilters {
  from: string;
  to: string;
  channelId?: EntityIdInput;
  mine?: boolean;
}

type EventRow = typeof schema.events.$inferSelect;
type AttendeeRow = typeof schema.eventAttendees.$inferSelect;

async function requireEventInWorkspace(tx: any, eventId: EntityIdInput, workspaceId: number): Promise<EventRow> {
  const id = asEntityId(eventId);
  const [row] = await tx.select().from(schema.events).where(eq(schema.events.id, id)).limit(1);
  if (!row || row.workspaceId !== workspaceId) throw new NotFoundException("event not found");
  return row;
}

/** The channel an event optionally references must belong to the same
 * workspace — RLS alone only scopes by workspace_id, so caller-supplied id
 * pairs still need the explicit check every action in this codebase does. */
async function requireChannelInWorkspace(tx: any, channelId: EntityIdInput, workspaceId: number) {
  const chId = asEntityId(channelId);
  const [channel] = await tx.select().from(schema.channels).where(eq(schema.channels.id, chId)).limit(1);
  if (!channel || channel.workspaceId !== workspaceId) throw new NotFoundException("channel not found");
  return channel;
}

async function attendeesFor(tx: any, eventIds: number[]): Promise<Map<number, EventAttendee[]>> {
  const byEvent = new Map<number, EventAttendee[]>();
  if (eventIds.length === 0) return byEvent;
  // One query for every event in the response — the alternative is an N+1
  // across a whole calendar window.
  const rows: AttendeeRow[] = await tx
    .select()
    .from(schema.eventAttendees)
    .where(inArray(schema.eventAttendees.eventId, eventIds));
  for (const row of rows) {
    const list = byEvent.get(row.eventId) ?? [];
    list.push({ userId: row.userId, status: row.status, respondedAt: row.respondedAt?.toISOString() ?? null });
    byEvent.set(row.eventId, list);
  }
  return byEvent;
}

/** Wire shape for a stored row (a series or a one-off), not an expansion. */
function toWireEvent(row: EventRow, attendees: EventAttendee[]): CalendarEvent {
  return {
    id: row.id,
    workspaceId: row.workspaceId,
    title: row.title,
    description: row.description,
    location: row.location,
    startsAt: row.startsAt.toISOString(),
    endsAt: row.endsAt.toISOString(),
    allDay: row.allDay,
    timezone: row.timezone,
    recurrenceRule: row.recurrenceRule,
    channelId: row.channelId,
    createdBy: row.createdBy,
    attendees,
    seriesId: row.parentEventId ?? row.id,
    occurrenceDate: row.occurrenceDate ?? row.startsAt.toISOString().slice(0, 10),
    isOccurrence: false,
    isOverride: row.parentEventId != null,
    createdAt: row.createdAt.toISOString(),
    updatedAt: row.updatedAt.toISOString(),
  };
}

export async function createEvent(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  input: CreateEventInput,
): Promise<CalendarEvent> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  const channelId = input.channelId != null ? asEntityId(input.channelId) : null;
  const attendeeIds = [...new Set((input.attendeeUserIds ?? []).map((id) => asEntityId(id)))];

  const { event, attendees, memberIds } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    if (!can({ userId, role: membership.role }, "event:create")) {
      throw new ForbiddenException("insufficient role to create events");
    }
    if (channelId != null) await requireChannelInWorkspace(tx, channelId, wsId);
    for (const attendeeId of attendeeIds) await requireWorkspaceMembership(tx, wsId, attendeeId);

    const [inserted] = await tx
      .insert(schema.events)
      .values({
        workspaceId: wsId,
        title: input.title,
        description: input.description ?? null,
        location: input.location ?? null,
        startsAt: new Date(input.startsAt),
        endsAt: new Date(input.endsAt),
        allDay: input.allDay ?? false,
        timezone: input.timezone ?? "UTC",
        recurrenceRule: input.recurrenceRule ?? null,
        channelId,
        createdBy: userId,
      })
      .returning();
    if (!inserted) throw new Error("event insert returned no row");

    const attendees = await replaceAttendees(tx, inserted.id, attendeeIds, userId);
    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return { event: inserted as EventRow, attendees, memberIds };
  });

  const wire = toWireEvent(event, attendees);
  await fanoutToMembers(redis, memberIds, { type: "event:created", payload: { event: wire } });
  await notifyInvitees(attendeeIds, userId, event);
  return wire;
}

/**
 * Reads a window of the calendar as concrete occurrences.
 *
 * The work is in reconciling three kinds of row against one date range:
 *   - one-off events that overlap the window,
 *   - series (recurrenceRule set) that *might* touch it, expanded on the fly,
 *   - detached children of those series — either an override that replaces one
 *     occurrence, or a cancellation that removes it.
 *
 * Occurrences are never materialised in the database, so this is the only
 * place the three are merged; every client sees the same reconciliation.
 */
export async function listEvents(
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  filters: ListEventsFilters,
): Promise<CalendarEvent[]> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  const windowFrom = new Date(filters.from);
  const windowTo = new Date(filters.to);

  return withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    if (!can({ userId, role: membership.role }, "event:read")) {
      throw new ForbiddenException("insufficient role to read events");
    }

    const scope = [eq(schema.events.workspaceId, wsId)];
    if (filters.channelId != null) scope.push(eq(schema.events.channelId, asEntityId(filters.channelId)));

    // Parents only. A series row whose UNTIL/COUNT has run out still gets
    // fetched and simply expands to nothing — cheaper than encoding rule
    // semantics in SQL, and the row count here is small.
    const parents: EventRow[] = await tx
      .select()
      .from(schema.events)
      .where(
        and(
          ...scope,
          isNull(schema.events.parentEventId),
          or(
            // A recurring series can produce occurrences long after its stored
            // start, so it can't be filtered by the window's lower bound.
            isNotNull(schema.events.recurrenceRule),
            and(gte(schema.events.endsAt, windowFrom), lte(schema.events.startsAt, windowTo)),
          ),
        ),
      );

    const seriesIds = parents.filter((row) => row.recurrenceRule).map((row) => row.id);
    const children: EventRow[] =
      seriesIds.length > 0
        ? await tx
            .select()
            .from(schema.events)
            .where(and(eq(schema.events.workspaceId, wsId), inArray(schema.events.parentEventId, seriesIds)))
        : [];

    // Keyed by "seriesId|YYYY-MM-DD" — the pair that identifies one occurrence.
    const overrides = new Map<string, EventRow>();
    for (const child of children) {
      if (child.parentEventId == null || child.occurrenceDate == null) continue;
      overrides.set(`${child.parentEventId}|${child.occurrenceDate}`, child);
    }

    const attendeeMap = await attendeesFor(tx, [...parents.map((r) => r.id), ...children.map((r) => r.id)]);
    const attendeesOf = (id: number) => attendeeMap.get(id) ?? [];
    const out: CalendarEvent[] = [];

    for (const parent of parents) {
      const rule = parseRecurrenceRule(parent.recurrenceRule);

      if (!rule) {
        out.push({ ...toWireEvent(parent, attendeesOf(parent.id)), isOccurrence: false });
        continue;
      }

      const occurrences = expandOccurrences(
        {
          startsAt: parent.startsAt,
          endsAt: parent.endsAt,
          timezone: parent.timezone,
          rule,
        },
        windowFrom,
        windowTo,
        MAX_OCCURRENCES,
      );

      for (const occ of occurrences) {
        const override = overrides.get(`${parent.id}|${occ.occurrenceDate}`);
        if (override) continue; // handled below, on its own terms
        out.push({
          ...toWireEvent(parent, attendeesOf(parent.id)),
          startsAt: occ.start.toISOString(),
          endsAt: occ.end.toISOString(),
          occurrenceDate: occ.occurrenceDate,
          isOccurrence: true,
          isOverride: false,
        });
      }
    }

    // Overrides are placed by their own times (a moved occurrence may land
    // outside the parent's expansion), and cancellations simply never appear.
    for (const child of children) {
      if (child.isCancelled) continue;
      const overlaps = child.endsAt >= windowFrom && child.startsAt <= windowTo;
      if (!overlaps) continue;
      out.push({
        ...toWireEvent(child, attendeesOf(child.id)),
        isOccurrence: true,
        isOverride: true,
      });
    }

    const mine = filters.mine
      ? out.filter((e) => e.createdBy === userId || e.attendees.some((a) => a.userId === userId))
      : out;

    return mine.sort((a, b) => a.startsAt.localeCompare(b.startsAt));
  });
}

export async function getEvent(
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  eventId: EntityIdInput,
): Promise<CalendarEvent> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  return withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    if (!can({ userId, role: membership.role }, "event:read")) {
      throw new ForbiddenException("insufficient role to read events");
    }
    const row = await requireEventInWorkspace(tx, eventId, wsId);
    const attendees = await attendeesFor(tx, [row.id]);
    return toWireEvent(row, attendees.get(row.id) ?? []);
  });
}

/**
 * Edits either the whole series or a single occurrence.
 *
 * `scope: "occurrence"` writes a *detached instance*: a child row carrying
 * the merged fields for that one date, which listEvents then substitutes for
 * the expanded occurrence. The series row is untouched, so every other
 * occurrence keeps following the rule. Attendees are copied to the child on
 * first detach, otherwise a moved occurrence would silently lose its invites.
 */
export async function updateEvent(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  eventId: EntityIdInput,
  input: UpdateEventInput,
): Promise<CalendarEvent> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  const scope: EventWriteScope = input.scope ?? "series";

  const { event, attendees, memberIds } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    const existing = await requireEventInWorkspace(tx, eventId, wsId);
    if (!can({ userId, role: membership.role }, "event:update", { isOwnResource: existing.createdBy === userId })) {
      throw new ForbiddenException("insufficient permission to edit this event");
    }
    if (input.channelId !== undefined && input.channelId != null) {
      await requireChannelInWorkspace(tx, input.channelId, wsId);
    }

    const patch: Record<string, unknown> = { updatedAt: new Date() };
    if (input.title !== undefined) patch.title = input.title;
    if (input.description !== undefined) patch.description = input.description;
    if (input.location !== undefined) patch.location = input.location;
    if (input.startsAt !== undefined) patch.startsAt = new Date(input.startsAt);
    if (input.endsAt !== undefined) patch.endsAt = new Date(input.endsAt);
    if (input.allDay !== undefined) patch.allDay = input.allDay;
    if (input.timezone !== undefined) patch.timezone = input.timezone;
    if (input.channelId !== undefined) patch.channelId = input.channelId != null ? asEntityId(input.channelId) : null;

    if (scope === "series") {
      if (input.recurrenceRule !== undefined) {
        patch.recurrenceRule = input.recurrenceRule;
        // Dropping the rule turns the series back into a one-off; its
        // overrides and cancellations no longer address anything, so they
        // would otherwise linger as orphans that can never be reached.
        if (input.recurrenceRule == null) {
          await tx.delete(schema.eventAttendees).where(
            inArray(
              schema.eventAttendees.eventId,
              tx.select({ id: schema.events.id }).from(schema.events).where(eq(schema.events.parentEventId, existing.id)),
            ),
          );
          await tx.delete(schema.events).where(eq(schema.events.parentEventId, existing.id));
        }
      }
      const [updated] = await tx.update(schema.events).set(patch).where(eq(schema.events.id, existing.id)).returning();
      if (!updated) throw new Error("event update returned no row");
      const attendees = await attendeesFor(tx, [updated.id]);
      const memberIds = await activeWorkspaceMemberIds(tx, wsId);
      return { event: updated as EventRow, attendees: attendees.get(updated.id) ?? [], memberIds };
    }

    // --- occurrence scope ---
    const occurrenceDate = input.occurrenceDate!;
    // Editing an already-detached occurrence updates that child in place;
    // the series is always the parent, never the child, so overrides can't
    // chain into a tree.
    const seriesId = existing.parentEventId ?? existing.id;
    if (existing.parentEventId == null && !existing.recurrenceRule) {
      throw new ForbiddenException("this event does not repeat — use scope 'series'");
    }

    const [series] = await tx.select().from(schema.events).where(eq(schema.events.id, seriesId)).limit(1);
    if (!series) throw new NotFoundException("event series not found");

    const [child] = await tx
      .select()
      .from(schema.events)
      .where(and(eq(schema.events.parentEventId, seriesId), eq(schema.events.occurrenceDate, occurrenceDate)))
      .limit(1);

    if (child) {
      const [updated] = await tx
        .update(schema.events)
        .set({ ...patch, isCancelled: false })
        .where(eq(schema.events.id, child.id))
        .returning();
      if (!updated) throw new Error("event override update returned no row");
      const attendees = await attendeesFor(tx, [updated.id]);
      const memberIds = await activeWorkspaceMemberIds(tx, wsId);
      return { event: updated as EventRow, attendees: attendees.get(updated.id) ?? [], memberIds };
    }

    // First detach: start from the occurrence's own expanded times so an edit
    // that only changes the title doesn't drag the instance back to the
    // series' original start.
    const expanded = occurrenceTimes(series as EventRow, occurrenceDate);
    const [inserted] = await tx
      .insert(schema.events)
      .values({
        workspaceId: wsId,
        title: (patch.title as string) ?? series.title,
        description: (input.description !== undefined ? input.description : series.description) ?? null,
        location: (input.location !== undefined ? input.location : series.location) ?? null,
        startsAt: (patch.startsAt as Date) ?? expanded.start,
        endsAt: (patch.endsAt as Date) ?? expanded.end,
        allDay: (patch.allDay as boolean) ?? series.allDay,
        timezone: (patch.timezone as string) ?? series.timezone,
        recurrenceRule: null, // a detached instance never repeats on its own
        channelId: input.channelId !== undefined ? (patch.channelId as number | null) : series.channelId,
        createdBy: series.createdBy,
        parentEventId: seriesId,
        occurrenceDate,
      })
      .returning();
    if (!inserted) throw new Error("event override insert returned no row");

    const seriesAttendees = await attendeesFor(tx, [seriesId]);
    const carried = (seriesAttendees.get(seriesId) ?? []).map((a) => ({
      eventId: inserted.id,
      userId: a.userId,
      status: a.status,
      respondedAt: a.respondedAt ? new Date(a.respondedAt) : null,
    }));
    if (carried.length > 0) await tx.insert(schema.eventAttendees).values(carried);

    const attendees = await attendeesFor(tx, [inserted.id]);
    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return { event: inserted as EventRow, attendees: attendees.get(inserted.id) ?? [], memberIds };
  });

  const wire = toWireEvent(event, attendees);
  await fanoutToMembers(redis, memberIds, { type: "event:updated", payload: { event: wire } });
  return wire;
}

/** The expanded start/end of one occurrence of a series, by date. Falls back
 * to the series' own times if the date isn't actually an occurrence (a client
 * can send any date; better a sane instance than a crash). */
function occurrenceTimes(series: EventRow, occurrenceDate: string): { start: Date; end: Date } {
  const durationMs = Math.max(0, series.endsAt.getTime() - series.startsAt.getTime());
  const dayStart = new Date(`${occurrenceDate}T00:00:00.000Z`);
  const dayEnd = new Date(dayStart.getTime() + 2 * 86_400_000);
  const [match] = expandOccurrences(
    { startsAt: series.startsAt, endsAt: series.endsAt, timezone: series.timezone, rule: series.recurrenceRule },
    new Date(dayStart.getTime() - 86_400_000),
    dayEnd,
    8,
  ).filter((o) => o.occurrenceDate === occurrenceDate);
  if (match) return { start: match.start, end: match.end };
  return { start: series.startsAt, end: new Date(series.startsAt.getTime() + durationMs) };
}

/**
 * Deletes a whole series (row, its overrides, and all their attendees) or
 * cancels one occurrence.
 *
 * A cancellation is a child row with isCancelled — a tombstone, not an
 * absence. There is nothing to delete for an occurrence that was never
 * materialised, so the row is what tells listEvents to skip it.
 */
export async function deleteEvent(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  eventId: EntityIdInput,
  options: { scope?: EventWriteScope; occurrenceDate?: string } = {},
): Promise<void> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  const scope: EventWriteScope = options.scope ?? "series";

  const { deletedId, memberIds } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    const existing = await requireEventInWorkspace(tx, eventId, wsId);
    if (!can({ userId, role: membership.role }, "event:delete", { isOwnResource: existing.createdBy === userId })) {
      throw new ForbiddenException("insufficient permission to delete this event");
    }

    if (scope === "occurrence") {
      const seriesId = existing.parentEventId ?? existing.id;
      const occurrenceDate = options.occurrenceDate!;
      const [child] = await tx
        .select()
        .from(schema.events)
        .where(and(eq(schema.events.parentEventId, seriesId), eq(schema.events.occurrenceDate, occurrenceDate)))
        .limit(1);

      if (child) {
        await tx
          .update(schema.events)
          .set({ isCancelled: true, updatedAt: new Date() })
          .where(eq(schema.events.id, child.id));
      } else {
        const [seriesRow] = await tx.select().from(schema.events).where(eq(schema.events.id, seriesId)).limit(1);
        if (!seriesRow) throw new NotFoundException("event series not found");
        const series = seriesRow as EventRow;
        const times = occurrenceTimes(series, occurrenceDate);
        await tx.insert(schema.events).values({
          workspaceId: wsId,
          title: series.title,
          startsAt: times.start,
          endsAt: times.end,
          allDay: series.allDay,
          timezone: series.timezone,
          recurrenceRule: null,
          createdBy: series.createdBy,
          parentEventId: seriesId,
          occurrenceDate,
          isCancelled: true,
        });
      }
      const memberIds = await activeWorkspaceMemberIds(tx, wsId);
      return { deletedId: seriesId, memberIds };
    }

    // Series delete: children first (FK), attendees before their events.
    const children = await tx
      .select({ id: schema.events.id })
      .from(schema.events)
      .where(eq(schema.events.parentEventId, existing.id));
    const ids = [existing.id, ...children.map((c: { id: number }) => c.id)];
    await tx.delete(schema.eventAttendees).where(inArray(schema.eventAttendees.eventId, ids));
    if (children.length > 0) await tx.delete(schema.events).where(eq(schema.events.parentEventId, existing.id));
    await tx.delete(schema.events).where(eq(schema.events.id, existing.id));

    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return { deletedId: existing.id, memberIds };
  });

  // A cancelled occurrence is an update to a series that still exists, so it
  // must not tell clients the whole event is gone.
  if (scope === "occurrence") {
    const refreshed = await getEvent(wsId, userId, deletedId);
    await fanoutToMembers(redis, memberIds, { type: "event:updated", payload: { event: refreshed } });
    return;
  }
  await fanoutToMembers(redis, memberIds, {
    type: "event:deleted",
    payload: { workspaceId: wsId, eventId: deletedId },
  });
}

/** Replaces the attendee list, preserving existing RSVPs for retained users. */
async function replaceAttendees(
  tx: any,
  eventId: number,
  attendeeIds: number[],
  organizerId: number,
): Promise<EventAttendee[]> {
  const existing: AttendeeRow[] = await tx
    .select()
    .from(schema.eventAttendees)
    .where(eq(schema.eventAttendees.eventId, eventId));
  const keep = new Set(attendeeIds);
  const existingById = new Map(existing.map((row) => [row.userId, row]));

  const toRemove = existing.filter((row) => !keep.has(row.userId)).map((row) => row.userId);
  if (toRemove.length > 0) {
    await tx
      .delete(schema.eventAttendees)
      .where(and(eq(schema.eventAttendees.eventId, eventId), inArray(schema.eventAttendees.userId, toRemove)));
  }

  const toAdd = attendeeIds
    .filter((id) => !existingById.has(id))
    .map((userId) => ({
      eventId,
      userId,
      // The organiser is implicitly going — they scheduled it.
      status: (userId === organizerId ? "going" : "needs_action") as AttendeeStatus,
      respondedAt: userId === organizerId ? new Date() : null,
    }));
  if (toAdd.length > 0) await tx.insert(schema.eventAttendees).values(toAdd);

  const rows = await attendeesFor(tx, [eventId]);
  return rows.get(eventId) ?? [];
}

export async function inviteToEvent(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  eventId: EntityIdInput,
  attendeeUserIds: EntityIdInput[],
): Promise<CalendarEvent> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);
  const attendeeIds = [...new Set(attendeeUserIds.map((id) => asEntityId(id)))];

  const { event, attendees, memberIds, added } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    const existing = await requireEventInWorkspace(tx, eventId, wsId);
    if (!can({ userId, role: membership.role }, "event:invite", { isOwnResource: existing.createdBy === userId })) {
      throw new ForbiddenException("insufficient permission to invite to this event");
    }
    for (const attendeeId of attendeeIds) await requireWorkspaceMembership(tx, wsId, attendeeId);

    const before = new Set(
      (await attendeesFor(tx, [existing.id])).get(existing.id)?.map((a) => a.userId) ?? [],
    );
    const attendees = await replaceAttendees(tx, existing.id, attendeeIds, existing.createdBy);
    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return { event: existing, attendees, memberIds, added: attendeeIds.filter((id) => !before.has(id)) };
  });

  const wire = toWireEvent(event, attendees);
  await fanoutToMembers(redis, memberIds, { type: "event:updated", payload: { event: wire } });
  await notifyInvitees(added, userId, event);
  return wire;
}

/**
 * Answers an invitation. Deliberately takes no target user: you may only RSVP
 * for yourself, which the role table can't express (it has no notion of
 * "subject equals actor"), so it is enforced here by construction rather than
 * by a check that could be forgotten.
 */
export async function rsvpEvent(
  redis: Redis,
  workspaceId: EntityIdInput,
  actorUserId: EntityIdInput,
  eventId: EntityIdInput,
  status: AttendeeStatus,
): Promise<CalendarEvent> {
  const wsId = asEntityId(workspaceId);
  const userId = asEntityId(actorUserId);

  const { event, attendees, memberIds } = await withTenant({ workspaceId: wsId, userId }, async (tx) => {
    const membership = await requireWorkspaceMembership(tx, wsId, userId);
    if (!can({ userId, role: membership.role }, "event:rsvp")) {
      throw new ForbiddenException("insufficient role to respond to events");
    }
    const existing = await requireEventInWorkspace(tx, eventId, wsId);

    const [row] = await tx
      .select()
      .from(schema.eventAttendees)
      .where(and(eq(schema.eventAttendees.eventId, existing.id), eq(schema.eventAttendees.userId, userId)))
      .limit(1);
    if (!row) throw new NotFoundException("you are not invited to this event");

    await tx
      .update(schema.eventAttendees)
      .set({ status, respondedAt: new Date() })
      .where(and(eq(schema.eventAttendees.eventId, existing.id), eq(schema.eventAttendees.userId, userId)));

    const attendees = await attendeesFor(tx, [existing.id]);
    const memberIds = await activeWorkspaceMemberIds(tx, wsId);
    return { event: existing, attendees: attendees.get(existing.id) ?? [], memberIds };
  });

  const wire = toWireEvent(event, attendees);
  await fanoutToMembers(redis, memberIds, { type: "event:updated", payload: { event: wire } });
  return wire;
}

/** Skips notifying yourself, mirroring notifyAssignee in tasks.ts — same
 * direct-insert-per-user pattern, since apps/worker's notification consumer
 * is still a logging stub. */
async function notifyInvitees(inviteeIds: number[], actorUserId: number, event: EventRow) {
  for (const inviteeId of inviteeIds) {
    if (inviteeId === actorUserId) continue;
    await withUser(inviteeId, (tx) =>
      tx.insert(schema.notifications).values({
        userId: inviteeId,
        type: "event_invite",
        title: "You were invited to an event",
        body: event.title,
        payload: {
          eventId: event.id,
          workspaceId: event.workspaceId,
          startsAt: event.startsAt.toISOString(),
          invitedByUserId: actorUserId,
        },
      }),
    );
  }
}
