import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { router, adminProcedure, protectedProcedure, publicProcedure } from '../trpc'
import { logAudit } from '../utils/audit'
import { buildManifest, buildRecapPayload } from '../services/lunch-recap'
import { selectUnpickedAttendees, selectUnpickedExternals } from '../services/lunch-reminders'
import { sendExternalDishInvite } from '../services/lunch-external-invite'
import { sendLunchRecapEmail, sendLunchReminderEmail } from '@/lib/email'
import { verifyExternalLunchToken } from '@/lib/external-lunch-token'
import { csvCell } from '@/lib/csv'

// ─── Shared zod schemas ──────────────────────────────────────────────────────

const dietaryTags = z.array(
  z.enum(['VEGETARIAN', 'VEGAN', 'GLUTEN_FREE', 'PESCATARIAN']),
)

const allergens = z.array(
  z.enum([
    'GLUTEN', 'CRUSTACEANS', 'EGGS', 'FISH', 'PEANUTS', 'SOYBEANS', 'MILK',
    'TREE_NUTS', 'CELERY', 'MUSTARD', 'SESAME', 'SULPHITES', 'LUPIN', 'MOLLUSCS',
  ]),
)

// ─── Router ──────────────────────────────────────────────────────────────────

export const lunchRouter = router({
  /**
   * Get-or-create the LunchEvent for a program. Lazy creation mirrors
   * the hotel pattern: callers don't have to know whether the row
   * already exists.
   */
  getEvent: adminProcedure
    .input(z.object({ programId: z.string() }))
    .query(async ({ ctx, input }) => {
      const existing = await ctx.prisma.lunchEvent.findUnique({
        where: { programId: input.programId },
      })
      if (existing) return existing
      return ctx.prisma.lunchEvent.create({ data: { programId: input.programId } })
    }),

  // ─── Dish CRUD ────────────────────────────────────────────────────────────

  listDishes: protectedProcedure
    .input(z.object({ lunchEventId: z.string() }))
    .query(({ ctx, input }) =>
      ctx.prisma.dish.findMany({
        where: { lunchEventId: input.lunchEventId },
        orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
      }),
    ),

  createDish: adminProcedure
    .input(
      z.object({
        lunchEventId: z.string(),
        name: z.string().min(1).max(200),
        dietaryTags,
        sortOrder: z.number().int().optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const dish = await ctx.prisma.dish.create({
        data: {
          lunchEventId: input.lunchEventId,
          name: input.name,
          dietaryTags: input.dietaryTags,
          sortOrder: input.sortOrder ?? 0,
        },
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'LUNCH_DISH_CREATED',
        entityType: 'Dish',
        entityId: dish.id,
        detailsJson: { name: dish.name, dietaryTags: dish.dietaryTags },
      })
      return dish
    }),

  updateDish: adminProcedure
    .input(
      z.object({
        dishId: z.string(),
        name: z.string().min(1).max(200).optional(),
        dietaryTags: dietaryTags.optional(),
        sortOrder: z.number().int().optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const { dishId, ...patch } = input
      const dish = await ctx.prisma.dish.update({ where: { id: dishId }, data: patch })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'LUNCH_DISH_UPDATED',
        entityType: 'Dish',
        entityId: dish.id,
        detailsJson: patch as Record<string, unknown>,
      })
      return dish
    }),

  deleteDish: adminProcedure
    .input(z.object({ dishId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const dish = await ctx.prisma.dish.delete({ where: { id: input.dishId } })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'LUNCH_DISH_DELETED',
        entityType: 'Dish',
        entityId: dish.id,
        detailsJson: { name: dish.name },
      })
      return { ok: true as const }
    }),

  reorderDishes: adminProcedure
    .input(
      z.object({
        ordered: z.array(
          z.object({ dishId: z.string(), sortOrder: z.number().int() }),
        ),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      await ctx.prisma.$transaction(
        input.ordered.map(({ dishId, sortOrder }) =>
          ctx.prisma.dish.update({ where: { id: dishId }, data: { sortOrder } }),
        ),
      )
      return { ok: true as const }
    }),

  // ─── External attendees CRUD ─────────────────────────────────────────────

  listExternals: adminProcedure
    .input(z.object({ lunchEventId: z.string() }))
    .query(({ ctx, input }) =>
      ctx.prisma.externalAttendee.findMany({
        where: { lunchEventId: input.lunchEventId },
        orderBy: { createdAt: 'asc' },
        include: { project: { select: { id: true, title: true } } },
      }),
    ),

  createExternal: adminProcedure
    .input(
      z.object({
        lunchEventId: z.string(),
        name: z.string().min(1).max(200),
        email: z.string().email().optional(),
        projectId: z.string().nullable().optional(),
        roleNote: z.string().max(500).optional(),
        dishId: z.string().nullable().optional(),
        allergens: allergens.optional(),
        allergenOther: z.string().max(500).nullable().optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const ext = await ctx.prisma.externalAttendee.create({
        data: {
          lunchEventId: input.lunchEventId,
          name: input.name,
          email: input.email,
          projectId: input.projectId ?? null,
          roleNote: input.roleNote,
          dishId: input.dishId ?? null,
          allergens: input.allergens ?? [],
          allergenOther: input.allergenOther,
        },
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'LUNCH_EXTERNAL_CREATED',
        entityType: 'ExternalAttendee',
        entityId: ext.id,
        detailsJson: { name: ext.name, projectId: ext.projectId },
      })
      // Auto-send the dish-selection invite when an email is on file. Never throws
      // — a failed send leaves inviteSentAt null so the admin can resend.
      if (ext.email) {
        try {
          const event = await ctx.prisma.lunchEvent.findUnique({
            where: { id: input.lunchEventId },
            select: { eventAt: true, venue: true, notes: true, changeCutoffHours: true },
          })
          if (event) await sendExternalDishInvite(ctx.prisma, ext, event)
        } catch (e) {
          console.error('[lunch.createExternal] dish invite send failed', e)
        }
      }
      return ext
    }),

  updateExternal: adminProcedure
    .input(
      z.object({
        externalId: z.string(),
        name: z.string().min(1).max(200).optional(),
        email: z.string().email().nullable().optional(),
        projectId: z.string().nullable().optional(),
        roleNote: z.string().max(500).nullable().optional(),
        dishId: z.string().nullable().optional(),
        allergens: allergens.optional(),
        allergenOther: z.string().max(500).nullable().optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const { externalId, ...patch } = input
      const ext = await ctx.prisma.externalAttendee.update({
        where: { id: externalId },
        data: patch,
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'LUNCH_EXTERNAL_UPDATED',
        entityType: 'ExternalAttendee',
        entityId: ext.id,
        detailsJson: patch as Record<string, unknown>,
      })
      return ext
    }),

  deleteExternal: adminProcedure
    .input(z.object({ externalId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const ext = await ctx.prisma.externalAttendee.delete({
        where: { id: input.externalId },
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'LUNCH_EXTERNAL_DELETED',
        entityType: 'ExternalAttendee',
        entityId: ext.id,
        detailsJson: { name: ext.name },
      })
      return { ok: true as const }
    }),

  /**
   * Send (or resend) the dish-selection invite to one external attendee.
   * Stamps `inviteSentAt` and audit-logs. Fails if the attendee has no email.
   */
  sendExternalInvite: adminProcedure
    .input(z.object({ externalId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const external = await ctx.prisma.externalAttendee.findUnique({
        where: { id: input.externalId },
        include: {
          lunchEvent: {
            select: { eventAt: true, venue: true, notes: true, changeCutoffHours: true },
          },
        },
      })
      if (!external) throw new TRPCError({ code: 'NOT_FOUND' })
      if (!external.email) {
        throw new TRPCError({
          code: 'PRECONDITION_FAILED',
          message: 'This attendee has no email address. Add one first.',
        })
      }
      try {
        await sendExternalDishInvite(ctx.prisma, external, external.lunchEvent)
      } catch (e) {
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: `Failed to send invite: ${e instanceof Error ? e.message : String(e)}`,
          cause: e,
        })
      }
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'LUNCH_EXTERNAL_INVITE_SENT',
        entityType: 'ExternalAttendee',
        entityId: external.id,
        detailsJson: { email: external.email },
      })
      return { ok: true as const }
    }),

  // ─── Public tokenized external dish picker ───────────────────────────────

  /**
   * Read an external attendee + their event + dishes by a signed, no-login token.
   * Token verification throws on bad signature / expiry; the page maps those to
   * friendly states.
   */
  getExternalByToken: publicProcedure
    .input(z.object({ token: z.string() }))
    .query(async ({ ctx, input }) => {
      const payload = verifyExternalLunchToken(input.token) // throws on bad sig / expired
      const external = await ctx.prisma.externalAttendee.findUnique({
        where: { id: payload.externalId },
        include: {
          lunchEvent: {
            select: {
              id: true,
              eventAt: true,
              endAt: true,
              venue: true,
              notes: true,
              changeCutoffHours: true,
            },
          },
        },
      })
      if (!external) throw new TRPCError({ code: 'NOT_FOUND' })
      const dishes = await ctx.prisma.dish.findMany({
        where: { lunchEventId: external.lunchEventId },
        orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
      })
      const eventAt = external.lunchEvent.eventAt
      const changeDeadline = eventAt
        ? new Date(eventAt.getTime() - external.lunchEvent.changeCutoffHours * 3_600_000)
        : null
      return {
        external: {
          id: external.id,
          name: external.name,
          dishId: external.dishId,
          allergens: external.allergens,
          allergenOther: external.allergenOther,
        },
        event: {
          eventAt,
          endAt: external.lunchEvent.endAt,
          venue: external.lunchEvent.venue,
          notes: external.lunchEvent.notes,
        },
        dishes,
        changeDeadline,
      }
    }),

  /**
   * Save an external attendee's dish pick via their signed token. Enforces the
   * lunch change cutoff (eventAt − changeCutoffHours); past it, the attendee must
   * contact an admin.
   */
  setExternalPick: publicProcedure
    .input(
      z.object({
        token: z.string(),
        dishId: z.string().nullable(),
        allergens,
        allergenOther: z.string().max(500).nullable(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const payload = verifyExternalLunchToken(input.token)
      const external = await ctx.prisma.externalAttendee.findUnique({
        where: { id: payload.externalId },
        include: {
          lunchEvent: { select: { eventAt: true, changeCutoffHours: true } },
        },
      })
      if (!external) throw new TRPCError({ code: 'NOT_FOUND' })

      const eventAt = external.lunchEvent.eventAt
      if (eventAt) {
        const deadline = new Date(
          eventAt.getTime() - external.lunchEvent.changeCutoffHours * 3_600_000,
        )
        if (new Date() > deadline) {
          throw new TRPCError({
            code: 'PRECONDITION_FAILED',
            message: 'Past the lunch change deadline. Please contact an admin.',
          })
        }
      }

      // Dish must belong to this attendee's event (defends against cross-event ids).
      if (input.dishId) {
        const dish = await ctx.prisma.dish.findFirst({
          where: { id: input.dishId, lunchEventId: external.lunchEventId },
          select: { id: true },
        })
        if (!dish) throw new TRPCError({ code: 'BAD_REQUEST', message: 'Unknown dish' })
      }

      const updated = await ctx.prisma.externalAttendee.update({
        where: { id: external.id },
        data: {
          dishId: input.dishId,
          allergens: input.allergens,
          allergenOther: input.allergenOther,
        },
      })
      return { ok: true as const, dishId: updated.dishId }
    }),

  // ─── Single-row pick read (used by per-row picker UI) ────────────────────

  /**
   * Read the current MemberLunchPick for an AttendingMember plus the dishes
   * for the parent event. Permission: any user with a TeamMember row on the
   * project, OR the AttendingMember.userId itself, OR admin.
   */
  getMemberPick: protectedProcedure
    .input(z.object({ attendingMemberId: z.string() }))
    .query(async ({ ctx, input }) => {
      const am = await ctx.prisma.attendingMember.findUnique({
        where: { id: input.attendingMemberId },
        include: {
          confirmation: {
            select: {
              project: {
                select: {
                  id: true,
                  programId: true,
                  teamMembers: { select: { userId: true } },
                },
              },
            },
          },
          lunchPick: true,
        },
      })
      if (!am) throw new TRPCError({ code: 'NOT_FOUND' })
      const userId = ctx.user.id
      const role = ctx.user.role
      const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
      const isOnTeam = am.confirmation.project.teamMembers.some(
        (tm) => tm.userId === userId,
      )
      if (!isAdmin && !isOnTeam && am.userId !== userId) {
        throw new TRPCError({ code: 'FORBIDDEN' })
      }
      return { pick: am.lunchPick }
    }),

  // ─── Manifest + CSV export ───────────────────────────────────────────────

  getManifest: adminProcedure
    .input(z.object({ programId: z.string() }))
    .query(({ ctx, input }) => buildManifest(ctx.prisma, input.programId)),

  exportManifestCsv: adminProcedure
    .input(z.object({ programId: z.string() }))
    .query(async ({ ctx, input }) => {
      const m = await buildManifest(ctx.prisma, input.programId)
      const lines = [
        'Type,Team,Name,Email,Dish,Allergens,Allergen notes',
        ...m.members.map((row) =>
          [
            'Member',
            csvCell(row.project?.name),
            csvCell(row.name),
            csvCell(row.email),
            csvCell(row.dish?.name),
            csvCell(row.allergens.join(';')),
            csvCell(row.allergenOther),
          ].join(','),
        ),
        ...m.externals.map((row) =>
          [
            'External',
            csvCell(row.project?.name),
            csvCell(row.name),
            csvCell(row.email),
            csvCell(row.dish?.name),
            csvCell(row.allergens.join(';')),
            csvCell(row.allergenOther),
          ].join(','),
        ),
      ]
      return lines.join('\n')
    }),

  // ─── Recap preview + send ────────────────────────────────────────────────

  getRecapPreview: adminProcedure
    .input(z.object({ programId: z.string() }))
    .query(({ ctx, input }) => buildRecapPayload(ctx.prisma, input.programId)),

  sendRecap: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        forceUpdate: z.boolean().optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const event = await ctx.prisma.lunchEvent.findUnique({
        where: { programId: input.programId },
      })
      if (!event) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lunch event not found' })
      if (event.recapSentAt && !input.forceUpdate) {
        throw new TRPCError({
          code: 'PRECONDITION_FAILED',
          message: 'Recap already sent. Pass forceUpdate=true to resend.',
        })
      }
      const payload = await buildRecapPayload(ctx.prisma, input.programId)
      const adminUsers = await ctx.prisma.user.findMany({
        where: {
          role: { in: ['SUPER_ADMIN', 'PROGRAM_ADMIN'] },
          email: { not: '' },
        },
        select: { email: true },
      })
      const recipients = [
        ...adminUsers.map((u) => u.email).filter(Boolean),
        ...event.extraRecipients,
      ]
      try {
        await sendLunchRecapEmail(recipients, payload)
      } catch (e) {
        console.error('[lunch.sendRecap] email send failed', e)
        throw new TRPCError({
          code: 'INTERNAL_SERVER_ERROR',
          message: `Recap email failed to send: ${e instanceof Error ? e.message : String(e)}`,
          cause: e,
        })
      }
      const updated = await ctx.prisma.lunchEvent.update({
        where: { programId: input.programId },
        data: { recapSentAt: new Date() },
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'LUNCH_RECAP_SENT',
        entityType: 'LunchEvent',
        entityId: event.id,
        detailsJson: {
          recipientCount: recipients.length,
          forceUpdate: !!input.forceUpdate,
          source: 'manual',
        },
      })
      return updated
    }),

  // ─── Member reads ────────────────────────────────────────────────────────

  /**
   * Public-ish event view for the applicant dashboard banner.
   * Returns null when the lunch event is disabled (banner hidden).
   */
  getEventForMember: protectedProcedure
    .input(z.object({ programId: z.string() }))
    .query(async ({ ctx, input }) => {
      const event = await ctx.prisma.lunchEvent.findUnique({
        where: { programId: input.programId },
        select: {
          id: true,
          enabled: true,
          eventAt: true,
          endAt: true,
          venue: true,
          notes: true,
          changeCutoffHours: true,
        },
      })
      if (!event || !event.enabled) return null
      const changeDeadline = event.eventAt
        ? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000)
        : null
      return { ...event, changeDeadline }
    }),

  /**
   * Read-only list of project-attached externals for a project. Visible to
   * any team member of the project (so they know who's joining their lunch).
   */
  getProjectExternals: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      const userId = ctx.user.id
      const role = ctx.user.role
      const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
      if (!isAdmin) {
        const tm = await ctx.prisma.teamMember.findFirst({
          where: { projectId: input.projectId, userId },
        })
        if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
      }
      return ctx.prisma.externalAttendee.findMany({
        where: { projectId: input.projectId },
        include: { dish: true },
        orderBy: { createdAt: 'asc' },
      })
    }),

  /**
   * All picks for the caller's team. Within-team transparency: every team
   * member sees their teammates' picks (lunch picks aren't sensitive).
   * Cross-team and admins go through the manifest endpoint instead, which
   * has more detail.
   */
  getTeamPicks: protectedProcedure
    .input(z.object({ projectId: z.string() }))
    .query(async ({ ctx, input }) => {
      const userId = ctx.user.id
      const role = ctx.user.role
      const isAdmin = role === 'SUPER_ADMIN' || role === 'PROGRAM_ADMIN'
      if (!isAdmin) {
        const tm = await ctx.prisma.teamMember.findFirst({
          where: { projectId: input.projectId, userId },
        })
        if (!tm) throw new TRPCError({ code: 'FORBIDDEN' })
      }
      const ams = await ctx.prisma.attendingMember.findMany({
        where: { confirmation: { projectId: input.projectId } },
        include: {
          user: { select: { id: true, name: true, email: true } },
          lunchPick: { include: { dish: true } },
        },
      })
      return ams.map((am) => ({
        attendingMemberId: am.id,
        userId: am.user.id,
        memberName: am.user.name ?? am.user.email,
        dish: am.lunchPick?.dish ?? null,
        allergens: am.lunchPick?.allergens ?? [],
        allergenOther: am.lunchPick?.allergenOther ?? null,
        hasPicked: !!am.lunchPick?.pickedAt,
      }))
    }),

  // ─── Mixed-permission picker ─────────────────────────────────────────────

  /**
   * Upsert a MemberLunchPick. Permission:
   *  - admin (SUPER_ADMIN / PROGRAM_ADMIN): always allowed, no deadline cap
   *  - team lead of the parent project: allowed before deadline
   *  - the AttendingMember.userId themselves: allowed before deadline
   *  - everyone else: FORBIDDEN
   * Audit-logged with the actor role (SELF / TEAM_LEAD / ADMIN).
   */
  upsertPick: protectedProcedure
    .input(
      z.object({
        attendingMemberId: z.string(),
        dishId: z.string().nullable(),
        allergens,
        allergenOther: z.string().max(500).nullable(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const am = await ctx.prisma.attendingMember.findUnique({
        where: { id: input.attendingMemberId },
        include: {
          confirmation: {
            select: {
              project: {
                select: {
                  id: true,
                  programId: true,
                  teamMembers: { select: { userId: true, role: true } },
                },
              },
            },
          },
          lunchPick: true,
        },
      })
      if (!am) {
        throw new TRPCError({
          code: 'NOT_FOUND',
          message: 'Attending member not found',
        })
      }

      const userId = ctx.user.id
      const userRole = ctx.user.role
      const isAdmin =
        userRole === 'SUPER_ADMIN' || userRole === 'PROGRAM_ADMIN'
      const isSelf = am.userId === userId
      const isLead = am.confirmation.project.teamMembers.some(
        (tm) => tm.userId === userId && tm.role === 'LEAD',
      )
      if (!isAdmin && !isSelf && !isLead) {
        throw new TRPCError({
          code: 'FORBIDDEN',
          message: 'Not allowed to edit this pick',
        })
      }

      // Cutoff check (admins skip)
      if (!isAdmin) {
        const event = await ctx.prisma.lunchEvent.findUnique({
          where: { programId: am.confirmation.project.programId },
          select: { eventAt: true, changeCutoffHours: true },
        })
        if (event?.eventAt) {
          const deadline = new Date(
            event.eventAt.getTime() - event.changeCutoffHours * 3_600_000,
          )
          if (new Date() > deadline) {
            throw new TRPCError({
              code: 'PRECONDITION_FAILED',
              message: 'Past lunch change deadline. Contact an admin.',
            })
          }
        }
      }

      const actorRole: 'SELF' | 'TEAM_LEAD' | 'ADMIN' = isAdmin
        ? 'ADMIN'
        : isLead && !isSelf
          ? 'TEAM_LEAD'
          : 'SELF'

      const pick = await ctx.prisma.memberLunchPick.upsert({
        where: { attendingMemberId: input.attendingMemberId },
        create: {
          attendingMemberId: input.attendingMemberId,
          dishId: input.dishId,
          allergens: input.allergens,
          allergenOther: input.allergenOther,
          pickedAt: input.dishId ? new Date() : null,
        },
        update: {
          dishId: input.dishId,
          allergens: input.allergens,
          allergenOther: input.allergenOther,
          pickedAt: input.dishId ? new Date() : null,
        },
      })

      await logAudit({
        prisma: ctx.prisma,
        userId,
        action: 'LUNCH_PICK_UPDATED',
        entityType: 'MemberLunchPick',
        entityId: pick.id,
        detailsJson: {
          actorRole,
          dishId: input.dishId,
          allergenCount: input.allergens.length,
        },
      })
      return pick
    }),

  /**
   * Manually send lunch pick reminders to all unpicked attendees for a given
   * LunchEvent.  Unlike the cron, this does NOT gate on reminderSentAt and does
   * NOT stamp it — manual sends are repeatable.  Returns { sent } count.
   */
  sendReminders: adminProcedure
    .input(z.object({ lunchEventId: z.string() }))
    .mutation(async ({ ctx, input }) => {
      const event = await ctx.prisma.lunchEvent.findUnique({
        where: { id: input.lunchEventId },
      })
      if (!event) throw new TRPCError({ code: 'NOT_FOUND', message: 'Lunch event not found' })

      const ams = await selectUnpickedAttendees(ctx.prisma, event)

      const deadline = event.eventAt
        ? new Date(event.eventAt.getTime() - event.changeCutoffHours * 3_600_000)
        : new Date(Date.now() + 48 * 3_600_000)

      let sent = 0
      for (const am of ams) {
        if (!am.user.email) continue
        try {
          await sendLunchReminderEmail({
            to: am.user.email,
            memberName: am.user.name ?? am.user.email,
            eventAt: event.eventAt ?? new Date(),
            venue: event.venue,
            changeDeadline: deadline,
            pickUrl: `${process.env.NEXTAUTH_URL ?? ''}/applicant`,
          })
          sent++
        } catch (e) {
          console.error('[lunch.sendReminders] send failed for', am.user.email, e)
        }
      }

      // External attendees: chase the ones with an email and no dish yet, via
      // their own tokenized pick page.
      const externals = await selectUnpickedExternals(ctx.prisma, { id: event.id })
      for (const ext of externals) {
        if (!ext.email) continue
        try {
          await sendExternalDishInvite(ctx.prisma, ext, event)
          sent++
        } catch (e) {
          console.error('[lunch.sendReminders] external send failed for', ext.email, e)
        }
      }
      return { sent }
    }),

  /** Patch any subset of LunchEvent config fields. Audit-logged. */
  updateEvent: adminProcedure
    .input(
      z.object({
        programId: z.string(),
        enabled: z.boolean().optional(),
        eventAt: z.date().nullable().optional(),
        endAt: z.date().nullable().optional(),
        venue: z.string().nullable().optional(),
        notes: z.string().nullable().optional(),
        changeCutoffHours: z.number().int().min(0).max(720).optional(),
        reminderHoursBeforeDeadline: z
          .number()
          .int()
          .min(0)
          .max(720)
          .nullable()
          .optional(),
        cronEnabled: z.boolean().optional(),
        extraRecipients: z.array(z.string().email()).optional(),
      }),
    )
    .mutation(async ({ ctx, input }) => {
      const { programId, ...patch } = input
      // Lazy-create before patching so updateEvent doubles as "create + update"
      await ctx.prisma.lunchEvent.upsert({
        where: { programId },
        create: { programId },
        update: {},
      })
      const updated = await ctx.prisma.lunchEvent.update({
        where: { programId },
        data: patch,
      })
      await logAudit({
        prisma: ctx.prisma,
        userId: ctx.user.id,
        action: 'LUNCH_EVENT_UPDATED',
        entityType: 'LunchEvent',
        entityId: updated.id,
        detailsJson: patch as Record<string, unknown>,
      })
      return updated
    }),
})
