<script setup lang="ts">
definePageMeta({ middleware: ['auth'] })

interface Guest {
  id: string
  event_id: string
  name: string
  email?: string | null
  phone?: string | null
  plus_ones: number
  created_at: string
  rsvp_response?: 'attending' | 'declined' | 'maybe' | null
  rsvp_plus_ones?: number | null
  rsvp_risk_score?: number | null
  rsvp_submitted_at?: string | null
  has_token?: boolean
}

interface GuestStats {
  total: number
  attending: number
  declined: number
  maybe: number
  pending: number
}

interface EventDetail {
  id: string
  host_id: string
  name: string
  slug: string
  event_date: string
  venue: string
  max_capacity: number
  status: string
  created_at: string
  updated_at: string
  // Tier 2 Block C — caller's role on this event. The dashboard branches
  // UI affordances off this rather than the legacy host_id check.
  your_role?: 'owner' | 'editor' | 'viewer'
}

interface IssuedToken {
  token: string
  token_id?: string
  invitation_queued: boolean
  invitation_link?: string
}

interface WSMessage {
  type: string
  event_id: string
  payload: any
  timestamp: string
}

const route = useRoute()
const eventId = route.params.id as string

const event = ref<EventDetail | null>(null)
const guests = ref<Guest[]>([])
const stats = ref<GuestStats>({ total: 0, attending: 0, declined: 0, maybe: 0, pending: 0 })
const filter = ref<'all' | 'attending' | 'declined' | 'maybe' | 'pending'>('all')
const loading = ref(true)

async function refresh() {
  const [evt, list] = await Promise.all([
    useApi<EventDetail>(`/events/${eventId}`),
    useApi<{ guests: Guest[]; stats: GuestStats }>(`/events/${eventId}/guests`),
  ])
  event.value = evt
  guests.value = list.guests || []
  if (list.stats) stats.value = list.stats
}

const filteredGuests = computed(() => {
  if (filter.value === 'all') return guests.value
  if (filter.value === 'pending') return guests.value.filter((g) => !g.rsvp_response)
  return guests.value.filter((g) => g.rsvp_response === filter.value)
})

// Grouping for the "All" view. UX-wise we order by what the host needs to
// act on next: invites still to send, then in-flight, then ambivalent
// replies, then confirmed, then declined. Empty groups don't render.
// Other filters render flat (the filter chip is itself the label).
interface GuestGroup {
  key: string
  label: string
  guests: Guest[]
}
const groupedGuests = computed<GuestGroup[]>(() => {
  if (filter.value !== 'all') {
    return [{ key: 'flat', label: '', guests: filteredGuests.value }]
  }
  const needsInvite: Guest[] = []
  const invited: Guest[] = []
  const maybe: Guest[] = []
  const attending: Guest[] = []
  const declined: Guest[] = []
  for (const g of guests.value) {
    if (g.rsvp_response === 'attending') attending.push(g)
    else if (g.rsvp_response === 'maybe') maybe.push(g)
    else if (g.rsvp_response === 'declined') declined.push(g)
    else if (g.has_token) invited.push(g)
    else needsInvite.push(g)
  }
  return [
    { key: 'needs', label: 'Needs invitation', guests: needsInvite },
    { key: 'invited', label: 'Invited · awaiting reply', guests: invited },
    { key: 'maybe', label: 'Maybe', guests: maybe },
    { key: 'attending', label: 'Attending', guests: attending },
    { key: 'declined', label: 'Declined', guests: declined },
  ].filter((grp) => grp.guests.length > 0)
})

interface ActivityItem {
  type: 'rsvp' | 'access_check'
  ts: string
  guest_id: string
  guest_name: string
  // RSVP
  response?: string
  plus_ones?: number
  // Access check
  score?: number
  band?: string
  blocked?: boolean
}

onMounted(async () => {
  try {
    await refresh()
    // Pull history from the activity endpoint. The WebSocket hub only
    // broadcasts future events, so without this catch-up the monitor
    // is blank until a guest does something while the dashboard is open.
    backfillFeed()
  } finally {
    loading.value = false
  }
})

async function backfillFeed() {
  if (feed.value.length > 0) return // don't clobber WS items that arrived first
  try {
    const res = await useApi<{ activity: ActivityItem[] }>(
      `/events/${eventId}/activity?limit=50`,
    )
    feed.value = (res.activity || []).map(activityToFeedItem)
  } catch (e) {
    console.error('activity backfill failed', e)
  }
}

function activityToFeedItem(a: ActivityItem): FeedItem {
  if (a.type === 'rsvp') {
    return {
      type: 'rsvp.confirmed',
      ts: a.ts,
      text: `${a.guest_name} → ${a.response} (+${a.plus_ones || 0})`,
    }
  }
  // access_check — friendly text per band, matching the live-WS handler.
  const band = a.band || 'low'
  const who = a.guest_name || 'A guest'
  const friendlyText: Record<string, string> = {
    low: `${who} opened their invitation — looks normal.`,
    medium: `${who}'s access looks a bit unusual — worth a glance.`,
    high: `${who}'s access looks suspicious — review when you can.`,
    block: `${who}'s access was blocked.`,
  }
  return {
    type: 'fraud.scored',
    ts: a.ts,
    text: friendlyText[band] || `${who}'s invitation was checked.`,
    band,
  }
}

// Add-guest form — opens via toolbar button. Reset on open so previous
// values don't bleed across sessions.
const newGuest = reactive({ name: '', email: '', phone: '', plus_ones: 0 })
const addingGuest = ref(false)
const addingGuestOpen = ref(false)
const importingOpen = ref(false)

function resetNewGuest() {
  newGuest.name = ''
  newGuest.email = ''
  newGuest.phone = ''
  newGuest.plus_ones = 0
}
function openAddGuest() {
  resetNewGuest()
  addingGuestOpen.value = true
}
function handleImported() {
  importingOpen.value = false
  refresh()
  showToast({ kind: 'success', text: 'Guests imported' })
}

// --- WhatsApp send wizard (Option A+) ---
//
// No official API — uses wa.me click-to-chat. Per-guest the host taps
// "Send", we mint a fresh token (so any earlier email link for the same
// guest stops working, by design), build a pre-filled wa.me URL, and
// open it in a new tab. The host hits Send inside WhatsApp.
//
// We persist *which guests have been wizard-sent* (booleans only, no
// tokens) so closing/reopening the modal preserves the host's progress
// — important when the list is 30+ guests.
const whatsappOpen = ref(false)
const waSending = ref<string | null>(null)
const waStorageKey = `gg.wa-sent.${eventId}`
const waSent = ref<Set<string>>(loadWaSent())

function loadWaSent(): Set<string> {
  if (!import.meta.client) return new Set()
  try {
    const raw = window.localStorage.getItem(waStorageKey)
    return raw ? new Set(JSON.parse(raw)) : new Set()
  } catch {
    return new Set()
  }
}
function persistWaSent() {
  if (!import.meta.client) return
  try {
    window.localStorage.setItem(waStorageKey, JSON.stringify(Array.from(waSent.value)))
  } catch { /* quota / private mode */ }
}
watch(waSent, persistWaSent, { deep: true })

const waEligible = computed(() => guests.value.filter((g) => !!g.phone && g.phone.trim() !== ''))
const waNoPhoneCount = computed(() => guests.value.length - waEligible.value.length)
const waSentCount = computed(() => {
  // Intersect with current eligible set — drops stale ids from deleted guests.
  let n = 0
  for (const g of waEligible.value) if (waSent.value.has(g.id)) n++
  return n
})

function whatsappUrl(phone: string, text: string): string {
  // wa.me wants digits only — no +, spaces, dashes, or parens.
  const digits = phone.replace(/\D/g, '')
  return `https://wa.me/${digits}?text=${encodeURIComponent(text)}`
}

// E.164-ish: a usable wa.me number must include the country code. The two
// strongest signals are (a) explicit leading "+", or (b) ≥10 digits with
// no leading 0 after symbols are stripped. UK 07700... and similar local
// formats will look "almost right" but break in WhatsApp, so we'd rather
// flag them now than have the host see the cryptic "couldn't be opened"
// error inside the app.
function isLikelyE164(phone: string | null | undefined): boolean {
  if (!phone) return false
  const trimmed = phone.trim()
  if (trimmed.startsWith('+')) {
    return /^\+\d{8,15}$/.test(trimmed.replace(/[\s\-()]/g, ''))
  }
  const digits = trimmed.replace(/\D/g, '')
  // No leading +. Only safe if it doesn't start with 0 and is long enough
  // to plausibly include a country code (≥10 digits).
  return digits.length >= 10 && !digits.startsWith('0')
}
function whatsappMessage(name: string, link: string): string {
  const ev = event.value?.name || 'an event'
  return `Hi ${name}, you're invited to ${ev}! Please RSVP here: ${link}`
}

async function sendOneViaWhatsapp(g: Guest) {
  if (!g.phone) return
  waSending.value = g.id
  try {
    // Rotate (no email) gives us a fresh raw token + canonical link.
    const res = await useApi<IssuedToken>(
      `/events/${eventId}/guests/${g.id}/tokens/rotate`,
      { method: 'POST', body: { send_email: false } },
    )
    const link = res.invitation_link || rsvpUrl(res.token)
    const url = whatsappUrl(g.phone, whatsappMessage(g.name, link))
    if (import.meta.client) {
      const win = window.open(url, '_blank', 'noopener')
      if (!win) {
        showToast({ kind: 'error', text: 'Browser blocked the WhatsApp window — allow pop-ups for this site.' })
        return
      }
    }
    // Optimistic mark — the host might still abandon inside WhatsApp,
    // but the marker reflects "I started this one", which is the value
    // a host scanning the list needs to track their own progress.
    waSent.value = new Set([...waSent.value, g.id])
  } catch (e: any) {
    showToast({ kind: 'error', text: useErrMessage(e, 'Could not start WhatsApp send') })
  } finally {
    waSending.value = null
  }
}

function resetWaSent() {
  waSent.value = new Set()
}

// Opens the existing edit-guest modal on top of the wizard. After save,
// refresh() rehydrates `guests` and the wizard's eligibility computeds
// recompute, so a fixed phone becomes sendable without further action.
function fixPhoneFromWizard(g: Guest) {
  openEdit(g)
}

async function addGuest() {
  addingGuest.value = true
  try {
    await useApi(`/events/${eventId}/guests`, {
      method: 'POST',
      body: {
        name: newGuest.name,
        email: newGuest.email || null,
        phone: newGuest.phone || null,
        plus_ones: Number(newGuest.plus_ones) || 0,
      },
    })
    const justAdded = newGuest.name.trim()
    resetNewGuest()
    addingGuestOpen.value = false
    await refresh()
    showToast({ kind: 'success', text: `Added ${justAdded}` })
  } catch (e: any) {
    showToast({ kind: 'error', text: useErrMessage(e, 'Could not add guest') })
  } finally {
    addingGuest.value = false
  }
}

// In-memory only — raw invitation tokens are never persisted client-side.
// After a session refresh the host re-acquires links via the Regenerate
// modal, which mints a fresh token (invalidating the prior one).
const issued = ref<Record<string, IssuedToken>>({})
const issuing = ref<string | null>(null)
const copiedFor = ref<string | null>(null)
let copyResetTimer: ReturnType<typeof setTimeout> | null = null

// Bulk invite — selection model
interface BulkInviteToken {
  guest_id: string
  token: string
  invitation_queued: boolean
  invitation_link: string
}
interface BulkInviteResult {
  issued: number
  queued: number
  skipped_existing: number
  skipped_no_email: number
  tokens?: BulkInviteToken[]
  errors?: { guest_id: string; reason: string }[]
}
const bulkConfirmOpen = ref(false)
const bulkSending = ref(false)
const selectedIds = ref<Set<string>>(new Set())

// --- Row actions: edit / delete (Needs invitation), regenerate (Invited) ---

const editing = ref<Guest | null>(null)
const editForm = reactive({ name: '', email: '', phone: '', plus_ones: 0 })
const savingEdit = ref(false)

function openEdit(g: Guest) {
  editing.value = g
  editForm.name = g.name
  editForm.email = g.email || ''
  editForm.phone = g.phone || ''
  editForm.plus_ones = g.plus_ones || 0
}

async function saveEdit() {
  if (!editing.value) return
  savingEdit.value = true
  try {
    await useApi(`/events/${eventId}/guests/${editing.value.id}`, {
      method: 'PATCH',
      body: {
        name: editForm.name.trim(),
        // Empty string clears the field server-side.
        email: editForm.email.trim(),
        phone: editForm.phone.trim(),
        plus_ones: editForm.plus_ones,
      },
    })
    showToast({ kind: 'success', text: `Updated ${editForm.name.trim()}` })
    editing.value = null
    await refresh()
  } catch (e: any) {
    showToast({ kind: 'error', text: useErrMessage(e, 'Could not save changes') })
  } finally {
    savingEdit.value = false
  }
}

const deleting = ref<Guest | null>(null)
const deletingInFlight = ref(false)
async function confirmDelete() {
  if (!deleting.value) return
  deletingInFlight.value = true
  const name = deleting.value.name
  try {
    await useApi(`/events/${eventId}/guests/${deleting.value.id}`, { method: 'DELETE' })
    deleting.value = null
    await refresh()
    showToast({ kind: 'success', text: `Removed ${name}` })
  } catch (e: any) {
    showToast({ kind: 'error', text: useErrMessage(e, 'Could not delete guest') })
  } finally {
    deletingInFlight.value = false
  }
}

// RSVP edit history (Tier 2 Block A). Lazy-loaded — we only fetch revisions
// when the host clicks "History" on a guest, so a 200-row event list stays
// cheap. The current RSVP is in historyData.rsvp; revisions are newest-first.
interface RSVPRevision {
  id: string
  rsvp_id: string
  prev_response: 'attending' | 'declined' | 'maybe'
  prev_plus_ones: number
  prev_dietary?: string | null
  changed_at: string
}
interface RSVPHistoryResponse {
  rsvp: {
    response: 'attending' | 'declined' | 'maybe'
    plus_ones: number
    dietary_notes?: string | null
    submitted_at: string
    edit_count: number
  } | null
  revisions: RSVPRevision[]
}
const historyFor = ref<Guest | null>(null)
const historyData = ref<RSVPHistoryResponse | null>(null)
const historyLoading = ref(false)
const historyError = ref<string | null>(null)
async function openHistory(g: Guest) {
  historyFor.value = g
  historyData.value = null
  historyError.value = null
  historyLoading.value = true
  try {
    historyData.value = await useApi<RSVPHistoryResponse>(
      `/events/${eventId}/guests/${g.id}/rsvp/history`,
    )
  } catch (e: any) {
    historyError.value = useErrMessage(e, 'Could not load history')
  } finally {
    historyLoading.value = false
  }
}
function closeHistory() {
  historyFor.value = null
  historyData.value = null
  historyError.value = null
}

// Regenerate-link flow. The modal stays open after click so the host can
// see the new link copy succeed; closing returns to the list.
const regenerating = ref<Guest | null>(null)
const regenInFlight = ref<'resend' | 'copy' | null>(null)
async function regenerateAndResend() {
  if (!regenerating.value) return
  regenInFlight.value = 'resend'
  try {
    await useApi(`/events/${eventId}/guests/${regenerating.value.id}/tokens/rotate`, {
      method: 'POST',
      body: { send_email: true },
    })
    showToast({ kind: 'success', text: `New invitation sent to ${regenerating.value.email}` })
    regenerating.value = null
    await refresh()
  } catch (e: any) {
    showToast({ kind: 'error', text: useErrMessage(e, 'Could not resend invitation') })
  } finally {
    regenInFlight.value = null
  }
}
async function regenerateAndCopy() {
  if (!regenerating.value) return
  regenInFlight.value = 'copy'
  try {
    const res = await useApi<IssuedToken>(
      `/events/${eventId}/guests/${regenerating.value.id}/tokens/rotate`,
      { method: 'POST', body: { send_email: false } },
    )
    const url = res.invitation_link || rsvpUrl(res.token)
    if (import.meta.client) await navigator.clipboard.writeText(url)
    showToast({ kind: 'success', text: 'New link copied to clipboard' })
    regenerating.value = null
    await refresh()
  } catch (e: any) {
    showToast({ kind: 'error', text: useErrMessage(e, 'Could not generate link') })
  } finally {
    regenInFlight.value = null
  }
}

// Esc closes any open modal — standard expectation for dialog UI.
function onKeydown(e: KeyboardEvent) {
  if (e.key !== 'Escape') return
  if (editing.value || deleting.value || regenerating.value || historyFor.value || addingGuestOpen.value || importingOpen.value || whatsappOpen.value) {
    editing.value = null
    deleting.value = null
    regenerating.value = null
    closeHistory()
    addingGuestOpen.value = false
    importingOpen.value = false
    whatsappOpen.value = false
  }
}
if (import.meta.client) {
  onMounted(() => window.addEventListener('keydown', onKeydown))
  onUnmounted(() => window.removeEventListener('keydown', onKeydown))
}

// Toast state — auto-dismisses after a few seconds.
interface Toast { kind: 'success' | 'error'; text: string }
const toast = ref<Toast | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
function showToast(t: Toast, ms = 5000) {
  toast.value = t
  if (toastTimer) clearTimeout(toastTimer)
  toastTimer = setTimeout(() => { toast.value = null }, ms)
}

// A guest is eligible for invitation if they don't already have a token.
function isEligible(g: Guest): boolean {
  return !g.has_token
}
const eligibleGuests = computed(() => guests.value.filter(isEligible))
const eligibleIds = computed(() => new Set(eligibleGuests.value.map((g) => g.id)))
const selectedCount = computed(() => {
  // Drop stale ids (guest may have just been deleted) by intersecting
  // with the current eligible set.
  let n = 0
  selectedIds.value.forEach((id) => { if (eligibleIds.value.has(id)) n++ })
  return n
})
const selectedWithEmail = computed(() => {
  let n = 0
  for (const g of eligibleGuests.value) {
    if (selectedIds.value.has(g.id) && g.email) n++
  }
  return n
})
const selectedNoEmail = computed(() => selectedCount.value - selectedWithEmail.value)

const selectAllState = computed<'none' | 'some' | 'all'>(() => {
  if (eligibleGuests.value.length === 0) return 'none'
  if (selectedCount.value === 0) return 'none'
  if (selectedCount.value === eligibleGuests.value.length) return 'all'
  return 'some'
})

function isSelected(id: string): boolean {
  return selectedIds.value.has(id)
}

function toggleGuest(id: string) {
  const next = new Set(selectedIds.value)
  if (next.has(id)) next.delete(id); else next.add(id)
  selectedIds.value = next
}

function toggleSelectAll() {
  if (selectAllState.value === 'all') {
    selectedIds.value = new Set()
  } else {
    selectedIds.value = new Set(eligibleGuests.value.map((g) => g.id))
  }
}

function clearSelection() {
  selectedIds.value = new Set()
}

async function runBulkSend() {
  bulkSending.value = true
  try {
    const ids = Array.from(selectedIds.value).filter((id) => eligibleIds.value.has(id))
    const res = await useApi<BulkInviteResult>(
      `/events/${eventId}/guests/invitations/bulk`,
      { method: 'POST', body: { guest_ids: ids } },
    )
    // Hydrate per-guest tokens so the row-level copy-link icon works
    // immediately, without waiting for a refresh+re-issue dance.
    for (const t of res.tokens || []) {
      issued.value[t.guest_id] = {
        token: t.token,
        token_id: '',
        invitation_queued: t.invitation_queued,
        invitation_link: t.invitation_link,
      }
    }
    bulkConfirmOpen.value = false
    clearSelection()
    await refresh()

    const parts: string[] = []
    if (res.queued > 0) parts.push(`Sent ${res.queued} invitation${res.queued === 1 ? '' : 's'}`)
    if (res.skipped_no_email > 0) parts.push(`${res.skipped_no_email} link${res.skipped_no_email === 1 ? '' : 's'} ready to copy`)
    if (res.skipped_existing > 0) parts.push(`${res.skipped_existing} already invited`)
    if (res.errors?.length) parts.push(`${res.errors.length} error${res.errors.length === 1 ? '' : 's'}`)
    showToast({
      kind: res.errors?.length ? 'error' : 'success',
      text: parts.length ? parts.join(' · ') : 'Nothing to send.',
    })
  } catch (e: any) {
    showToast({ kind: 'error', text: useErrMessage(e, 'Bulk send failed') })
  } finally {
    bulkSending.value = false
  }
}

async function issueToken(guestId: string) {
  issuing.value = guestId
  try {
    const res = await useApi<IssuedToken>(`/events/${eventId}/guests/${guestId}/tokens`, {
      method: 'POST',
    })
    issued.value[guestId] = res
  } catch (e) {
    console.error(e)
  } finally {
    issuing.value = null
  }
}

function rsvpUrl(token: string): string {
  if (import.meta.server) return ''
  return `${window.location.origin}/rsvp/${token}`
}

async function copyLink(guestId: string, token: string) {
  if (!import.meta.client) return
  try {
    await navigator.clipboard.writeText(rsvpUrl(token))
    copiedFor.value = guestId
    if (copyResetTimer) clearTimeout(copyResetTimer)
    copyResetTimer = setTimeout(() => {
      if (copiedFor.value === guestId) copiedFor.value = null
    }, 1500)
  } catch (e) {
    console.error('clipboard copy failed', e)
  }
}

// Live monitor — RSVP + fraud feed via WS
interface FeedItem {
  type: string
  ts: string
  text: string
  band?: string
}
const feed = ref<FeedItem[]>([])
const wsConnected = ref(false)
const guestById = computed(() => Object.fromEntries(guests.value.map((g) => [g.id, g])))

function pushFeed(item: FeedItem) {
  feed.value = [item, ...feed.value].slice(0, 50)
}

let stopWS: (() => void) | null = null
onMounted(() => {
  stopWS = useEventWS(eventId, (msg: WSMessage) => {
    wsConnected.value = true
    if (msg.type === 'rsvp.confirmed') {
      const g = guestById.value[msg.payload.guest_id]
      pushFeed({
        type: msg.type,
        ts: msg.timestamp,
        text: `${g?.name || 'Guest'} → ${msg.payload.response} (+${msg.payload.plus_ones || 0})`,
      })
      // Refresh stats and per-guest status so the counts reflect the new RSVP.
      refresh().catch(() => {})
    } else if (msg.type === 'fraud.scored') {
      const g = guestById.value[msg.payload.guest_id]
      const who = g?.name || 'A guest'
      const band = (msg.payload.risk as string) || 'low'
      // Plain-English text per band — no raw scores, no jargon.
      const friendlyText: Record<string, string> = {
        low: `${who} opened their invitation — looks normal.`,
        medium: `${who}'s access looks a bit unusual — worth a glance.`,
        high: `${who}'s access looks suspicious — review when you can.`,
        block: `${who}'s access was blocked.`,
      }
      pushFeed({
        type: msg.type,
        ts: msg.timestamp,
        text: friendlyText[band] || `${who}'s invitation was checked.`,
        band,
      })
    }
  })
})

onUnmounted(() => stopWS?.())

function fmtTime(iso: string): string {
  try { return new Date(iso).toLocaleTimeString() } catch { return iso }
}
function fmtDate(iso: string): string {
  try { return new Date(iso).toLocaleString() } catch { return iso }
}

// Friendly pill label for an access-check item (replaces "fraud · {band}").
function checkLabel(band?: string): string {
  switch (band) {
    case 'low':    return 'Verified'
    case 'medium': return 'Review'
    case 'high':   return 'Suspicious'
    case 'block':  return 'Blocked'
    default:       return 'Checked'
  }
}
</script>

<template>
  <section v-if="loading" class="text-sm text-zinc-500">Loading…</section>
  <section v-else-if="!event" class="card">Event not found.</section>
  <section v-else class="space-y-8">
    <div>
      <NuxtLink to="/dashboard" class="mb-2 inline-block text-sm text-zinc-400 hover:text-zinc-200">
        ← Back to dashboard
      </NuxtLink>
      <div class="flex items-center justify-between">
        <h1 class="text-2xl font-semibold">{{ event.name }}</h1>
        <span class="badge bg-zinc-800 text-zinc-300">{{ event.status }}</span>
      </div>
      <p class="text-sm text-zinc-400">{{ event.venue }} · {{ fmtDate(event.event_date) }}</p>
    </div>

    <div class="grid gap-8 lg:grid-cols-[2fr_1fr]">
      <!-- Primary content: the guest list. Add / Import live as modals
           triggered from the toolbar — keeps secondary tools out of the
           way until the host needs them. -->
      <div class="space-y-6">
        <div class="card">
          <div class="mb-4 flex flex-wrap items-center justify-between gap-3">
            <h2 class="text-lg font-semibold">Guests</h2>
            <div class="flex items-center gap-2">
              <button
                type="button"
                class="btn-primary inline-flex items-center gap-1.5 !px-3 !py-1.5 text-sm"
                @click="openAddGuest"
              >
                <svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                  <path d="M10 5a1 1 0 011 1v3h3a1 1 0 110 2h-3v3a1 1 0 11-2 0v-3H6a1 1 0 110-2h3V6a1 1 0 011-1z" />
                </svg>
                Add guest
              </button>
              <button
                type="button"
                class="inline-flex items-center gap-1.5 rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-200 transition hover:border-zinc-500 hover:bg-zinc-800"
                @click="importingOpen = true"
              >
                <svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                  <path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
                </svg>
                Import CSV
              </button>
              <button
                type="button"
                class="inline-flex items-center gap-1.5 rounded-md border border-zinc-700 px-3 py-1.5 text-sm text-zinc-200 transition hover:border-zinc-500 hover:bg-zinc-800"
                title="Send personal invitations via WhatsApp"
                @click="whatsappOpen = true"
              >
                <!-- Chat-bubble glyph — readable without trademark friction;
                     the label spells the destination. -->
                <svg class="h-4 w-4 text-emerald-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                  <path fill-rule="evenodd" d="M18 5.05A9 9 0 002 10.5a8.96 8.96 0 001.46 4.92L2 19l3.74-1.45A8.97 8.97 0 0010 19.05a9 9 0 008-13.5zM10 17.55a7.05 7.05 0 01-3.6-.99l-.26-.15-2.22.86.84-2.16-.17-.27a7.05 7.05 0 119.16 2.71 7.04 7.04 0 01-3.75 0z" clip-rule="evenodd" />
                </svg>
                WhatsApp
              </button>
              <button class="text-xs text-zinc-400 hover:text-zinc-200" @click="refresh">Refresh</button>
            </div>
          </div>

          <!-- Compact segmented filter -->
          <div v-if="guests.length > 0" class="mb-3 inline-flex flex-wrap gap-1 rounded-lg border border-zinc-800 bg-zinc-950 p-1 text-xs">
            <button
              v-for="bucket in (['all','attending','declined','maybe','pending'] as const)"
              :key="bucket"
              class="rounded-md px-2.5 py-1 transition"
              :class="filter === bucket
                ? 'bg-zinc-800 text-zinc-100'
                : 'text-zinc-400 hover:text-zinc-200'"
              @click="filter = bucket"
            >
              <span class="capitalize">{{ bucket }}</span>
              <span class="ml-1.5 tabular-nums text-zinc-500">{{ bucket === 'all' ? stats.total : stats[bucket] }}</span>
            </button>
          </div>

          <!-- Selection toolbar — visually elevated above the list. The
               left padding is intentionally identical to the row padding
               (px-3) so the master checkbox shares a column with every
               row checkbox, Gmail-style. -->
          <div
            v-if="eligibleGuests.length > 0"
            class="mb-5 flex items-center justify-between gap-3 rounded-lg border py-3 pl-3 pr-4 shadow-sm transition"
            :class="selectedCount > 0
              ? 'border-brand-600/70 bg-brand-500/10 shadow-brand-500/10'
              : 'border-zinc-700 bg-zinc-800/70'"
          >
            <label class="flex cursor-pointer items-center gap-3 text-sm text-zinc-100">
              <span class="flex h-5 w-5 shrink-0 items-center justify-center">
                <input
                  type="checkbox"
                  class="h-4 w-4 cursor-pointer accent-brand-500"
                  :checked="selectAllState === 'all'"
                  :indeterminate.prop="selectAllState === 'some'"
                  :aria-label="selectAllState === 'all' ? 'Deselect all' : 'Select all eligible guests'"
                  @change="toggleSelectAll"
                />
              </span>
              <!-- Count badge — circular pill with current selection / eligible count -->
              <span
                class="inline-flex h-6 min-w-[2rem] items-center justify-center rounded-full px-2 text-xs font-semibold tabular-nums"
                :class="selectedCount > 0
                  ? 'bg-brand-500 text-zinc-950'
                  : 'bg-zinc-700 text-zinc-200'"
              >
                {{ selectedCount > 0 ? selectedCount : eligibleGuests.length }}
              </span>
              <span class="text-zinc-200">
                {{
                  selectedCount === 0
                    ? `eligible to invite`
                    : selectedCount === eligibleGuests.length
                      ? `selected · all eligible guests`
                      : `selected of ${eligibleGuests.length}`
                }}
              </span>
            </label>
            <button
              class="btn-primary disabled:cursor-not-allowed disabled:opacity-30"
              :disabled="selectedCount === 0 || bulkSending"
              @click="bulkConfirmOpen = true"
            >
              {{ bulkSending ? 'Sending…' : 'Send invitations' }}
              <span v-if="selectedCount > 0" aria-hidden="true">→</span>
            </button>
          </div>

          <!-- Confirmation banner (only on demand; no longer permanently visible) -->
          <div v-if="bulkConfirmOpen" class="mb-3 rounded-lg border border-zinc-700 bg-zinc-900 p-3 text-sm">
            <p class="mb-1">
              Email <span class="font-semibold text-zinc-100">{{ selectedWithEmail }}</span>
              of the <span class="font-semibold text-zinc-100">{{ selectedCount }}</span> selected
              {{ selectedCount === 1 ? 'guest' : 'guests' }}?
              <span v-if="selectedNoEmail > 0" class="text-zinc-500">
                The other {{ selectedNoEmail }} will get links you can copy.
              </span>
            </p>
            <div class="mt-2 flex items-center gap-2">
              <button class="btn-primary !px-3 !py-1.5 text-xs" :disabled="bulkSending" @click="runBulkSend">
                {{ bulkSending ? 'Sending…' : 'Confirm send' }}
              </button>
              <button class="text-xs text-zinc-400 hover:text-zinc-200" :disabled="bulkSending" @click="bulkConfirmOpen = false">Cancel</button>
            </div>
          </div>

          <!-- Empty states -->
          <div v-if="guests.length === 0" class="rounded-lg border border-dashed border-zinc-800 bg-zinc-900/30 px-4 py-8 text-center">
            <p class="text-sm text-zinc-300">No guests yet.</p>
            <p class="mt-1 text-xs text-zinc-500">Add one above, or drop in a spreadsheet using the import card.</p>
          </div>
          <div v-else-if="filteredGuests.length === 0" class="rounded-lg border border-dashed border-zinc-800 bg-zinc-900/30 px-4 py-6 text-center text-sm text-zinc-500">
            No guests match this filter.
          </div>

          <!-- Grouped list. Each group renders its own <ul> so the
               horizontal dividers stop at the group boundary. The header
               sticks to the viewport top while its rows are in view —
               standard pattern for long lists (Linear, Apple Mail). -->
          <div v-else class="space-y-4">
            <section v-for="grp in groupedGuests" :key="grp.key">
              <h3
                v-if="grp.label"
                class="sticky top-0 z-10 -mx-5 mb-1 flex items-center gap-2 bg-zinc-900/95 px-5 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-zinc-400 backdrop-blur"
              >
                {{ grp.label }}
                <span class="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[10px] font-medium tabular-nums text-zinc-300">
                  {{ grp.guests.length }}
                </span>
              </h3>
              <ul class="divide-y divide-zinc-800/70">
                <li
                  v-for="g in grp.guests"
                  :key="g.id"
                  class="group flex items-center gap-3 py-2.5 pl-3 pr-4 transition"
                  :class="isEligible(g) && isSelected(g.id) ? 'bg-brand-500/[0.04]' : ''"
                  :tabindex="isEligible(g) ? 0 : -1"
                  :role="isEligible(g) ? 'button' : undefined"
                  :aria-pressed="isEligible(g) ? isSelected(g.id) : undefined"
                  @click="isEligible(g) && toggleGuest(g.id)"
                  @keydown.space.prevent="isEligible(g) && toggleGuest(g.id)"
                  @keydown.enter.prevent="isEligible(g) && toggleGuest(g.id)"
                >
                  <!-- Lead column — 20px slot. Holds either the checkbox
                       (eligible rows) or a state-specific status icon. -->
                  <span class="flex h-5 w-5 shrink-0 items-center justify-center">
                    <input
                      v-if="isEligible(g)"
                      type="checkbox"
                      class="h-4 w-4 cursor-pointer accent-brand-500"
                      :checked="isSelected(g.id)"
                      :aria-label="`Select ${g.name}`"
                      @click.stop
                      @change="toggleGuest(g.id)"
                    />
                    <!-- Invited, awaiting reply → paper-plane (sent + flying) -->
                    <svg
                      v-else-if="!g.rsvp_response"
                      class="h-5 w-5 text-brand-400/80" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
                    >
                      <title>{{ g.name }} has been invited</title>
                      <path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
                    </svg>
                    <!-- Attending → check-circle solid (brand green) -->
                    <svg
                      v-else-if="g.rsvp_response === 'attending'"
                      class="h-5 w-5 text-brand-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
                    >
                      <title>{{ g.name }} is attending</title>
                      <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
                    </svg>
                    <!-- Maybe → question-mark-circle solid (amber) -->
                    <svg
                      v-else-if="g.rsvp_response === 'maybe'"
                      class="h-5 w-5 text-amber-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
                    >
                      <title>{{ g.name }} replied maybe</title>
                      <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2h-1V9z" clip-rule="evenodd" />
                    </svg>
                    <!-- Declined → x-circle solid (neutral) -->
                    <svg
                      v-else-if="g.rsvp_response === 'declined'"
                      class="h-5 w-5 text-zinc-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
                    >
                      <title>{{ g.name }} declined</title>
                      <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
                    </svg>
                  </span>

                  <!-- Name + email — primary identity. -->
                  <div class="min-w-0 flex-1">
                    <div class="truncate text-sm font-medium text-zinc-100">{{ g.name }}</div>
                    <div class="truncate text-xs text-zinc-500">
                      {{ g.email || (g.phone ? g.phone : 'no contact details') }}
                    </div>
                  </div>

                  <!-- Status slot: fixed-width, right-aligned. Without
                       this the variable-length pills (Pending vs
                       Attending +2) land at different x-positions row to
                       row. The flagged badge sits BEFORE the status so
                       the main status badge's right edge always anchors
                       to the slot's right edge. -->
                  <div class="flex w-28 shrink-0 items-center justify-end gap-1.5">
                    <span
                      v-if="g.rsvp_risk_score != null && g.rsvp_risk_score >= 60"
                      class="badge-high"
                      :title="`Risk score ${g.rsvp_risk_score}`"
                    >flagged</span>

                    <span v-if="g.rsvp_response === 'attending'" class="badge-low">Attending<span v-if="(g.rsvp_plus_ones ?? 0) > 0" class="ml-1 text-brand-200/80">+{{ g.rsvp_plus_ones }}</span></span>
                    <span v-else-if="g.rsvp_response === 'declined'" class="badge bg-zinc-800 text-zinc-400">Declined</span>
                    <span v-else-if="g.rsvp_response === 'maybe'" class="badge-medium">Maybe</span>
                    <span
                      v-else-if="g.has_token || issued[g.id]"
                      class="badge bg-brand-900/30 text-brand-300"
                      :title="issued[g.id]?.invitation_queued
                        ? 'Invitation email queued for delivery'
                        : 'Invited — copy the link to share manually'"
                    >Invited</span>
                    <span v-else class="badge bg-zinc-800/40 text-zinc-500">Pending</span>
                  </div>

                  <!-- Row actions: fixed-width slot keeps the badge
                       column (above) aligned across rows regardless of
                       which icons render. Right-aligned content so the
                       outer edge of every row's action area lines up. -->
                  <div class="flex w-32 shrink-0 items-center justify-end gap-0.5 opacity-0 transition group-hover:opacity-100 group-focus-within:opacity-100">
                    <template v-if="isEligible(g)">
                      <button
                        type="button"
                        class="rounded-md p-1.5 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
                        title="Edit guest"
                        :aria-label="`Edit ${g.name}`"
                        @click.stop="openEdit(g)"
                      >
                        <svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                          <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
                        </svg>
                      </button>
                      <button
                        type="button"
                        class="rounded-md p-1.5 text-zinc-400 transition hover:bg-red-500/10 hover:text-red-300"
                        title="Delete guest"
                        :aria-label="`Delete ${g.name}`"
                        @click.stop="deleting = g"
                      >
                        <svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                          <path fill-rule="evenodd" d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z" clip-rule="evenodd" />
                        </svg>
                      </button>
                    </template>
                    <template v-else-if="!g.rsvp_response">
                      <!-- Copy link (only when invitation wasn't emailed AND
                           we still hold the raw token from this session —
                           the host can SMS the guest manually). -->
                      <button
                        v-if="issued[g.id] && !issued[g.id].invitation_queued"
                        type="button"
                        class="rounded-md p-1.5 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
                        :title="copiedFor === g.id ? 'Copied' : 'Copy invitation link'"
                        :aria-label="copiedFor === g.id ? 'Copied' : 'Copy invitation link'"
                        @click.stop="copyLink(g.id, issued[g.id].token)"
                      >
                        <svg v-if="copiedFor !== g.id" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                          <path d="M7 3a2 2 0 00-2 2v8a2 2 0 002 2h6a2 2 0 002-2V7.414A2 2 0 0014.414 6L12 3.586A2 2 0 0010.586 3H7z" />
                          <path d="M5 7H4a2 2 0 00-2 2v8a2 2 0 002 2h6a2 2 0 002-2v-1H5V7z" />
                        </svg>
                        <svg v-else class="h-4 w-4 text-brand-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                          <path fill-rule="evenodd" d="M16.704 5.296a1 1 0 010 1.408l-8 8a1 1 0 01-1.408 0l-4-4a1 1 0 011.408-1.408L8 12.592l7.296-7.296a1 1 0 011.408 0z" clip-rule="evenodd" />
                        </svg>
                      </button>
                      <!-- Regenerate link — labelled pill rather than
                           an icon-only button. The action is app-specific
                           and uncommon, so industry-standard UX is to
                           spell it out for clarity over compactness. -->
                      <button
                        type="button"
                        class="inline-flex items-center gap-1 rounded-md border border-zinc-700 px-2 py-1 text-xs font-medium text-zinc-300 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
                        title="Invalidate the old invitation and create a fresh one"
                        :aria-label="`Generate a new invitation link for ${g.name}`"
                        @click.stop="regenerating = g"
                      >
                        <svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                          <path d="M10.894 2.553a1 1 0 00-1.788 0l-7 14a1 1 0 001.169 1.409l5-1.429A1 1 0 009 15.571V11a1 1 0 112 0v4.571a1 1 0 00.725.962l5 1.428a1 1 0 001.17-1.408l-7-14z" />
                        </svg>
                        New link
                      </button>
                    </template>
                    <template v-else-if="g.rsvp_response">
                      <!-- Responded guests: show edit history. Useful when
                           "attending" silently flips to "declined" and the
                           host wants to know when. -->
                      <button
                        type="button"
                        class="inline-flex items-center gap-1 rounded-md border border-zinc-700 px-2 py-1 text-xs font-medium text-zinc-300 transition hover:border-brand-600 hover:bg-brand-500/10 hover:text-brand-200"
                        :title="`View the edit trail for ${g.name}`"
                        :aria-label="`View RSVP history for ${g.name}`"
                        @click.stop="openHistory(g)"
                      >
                        <svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                          <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM10 4a1 1 0 011 1v5l3 1a1 1 0 11-.5 1.93l-3.5-1.16A1 1 0 019 10.83V5a1 1 0 011-1z" clip-rule="evenodd" />
                        </svg>
                        History
                      </button>
                    </template>
                  </div>
                </li>
              </ul>
            </section>
          </div>
        </div>
      </div>

      <!-- Live monitor -->
      <aside class="card flex max-h-[80vh] flex-col">
        <div class="mb-3 flex items-center justify-between">
          <h2 class="text-lg font-semibold">Live monitor</h2>
          <span
            class="badge"
            :class="wsConnected ? 'bg-brand-900/40 text-brand-300' : 'bg-zinc-800 text-zinc-400'"
          >
            <span class="mr-1 inline-block h-1.5 w-1.5 rounded-full" :class="wsConnected ? 'bg-brand-400' : 'bg-zinc-500'"></span>
            {{ wsConnected ? 'live' : 'connecting' }}
          </span>
        </div>
        <p class="mb-3 text-xs text-zinc-500">
          Guest responses and security alerts, the moment they happen.
        </p>
        <div class="flex-1 overflow-y-auto">
          <div v-if="feed.length === 0" class="rounded-lg border border-zinc-800/50 bg-zinc-900/30 p-5 text-center">
            <p class="mb-1 text-sm font-medium text-zinc-400">All quiet for now</p>
            <p class="text-xs leading-relaxed text-zinc-600">
              Once guests start responding, their RSVPs and any<br />
              security alerts will appear here in real time.
            </p>
          </div>
          <ul v-else class="space-y-2">
            <li
              v-for="(item, i) in feed"
              :key="`${item.type}-${item.ts}-${i}`"
              class="rounded border border-zinc-800 bg-zinc-950 p-3 text-sm"
            >
              <div class="mb-1 flex items-center justify-between text-xs">
                <span class="font-mono text-zinc-500">{{ fmtTime(item.ts) }}</span>
                <span
                  v-if="item.type === 'fraud.scored'"
                  :class="`badge-${item.band || 'low'}`"
                >{{ checkLabel(item.band) }}</span>
                <span v-else class="badge-low">RSVP</span>
              </div>
              <p class="text-zinc-200">{{ item.text }}</p>
            </li>
          </ul>
        </div>
      </aside>
    </div>

    <!-- Analytics (Tier 2 Block E). Read-only summary; viewer+ can see it. -->
    <div v-if="event" class="mt-6">
      <AnalyticsCard :event-id="eventId" />
    </div>

    <!-- Team (Tier 2 Block C). Visible to anyone with viewer+ access; action
         buttons gated to owners. -->
    <div v-if="event" class="mt-6">
      <TeamCard :event-id="eventId" :your-role="event.your_role" />
    </div>

    <!-- ===== Modals ===== -->
    <!-- All modals share the same pattern: backdrop click + Esc close,
         role=dialog + aria-modal, primary action on the right.
         Auto-focus on first input (Add) or safest button (Delete). -->

    <!-- Add guest -->
    <Teleport to="body">
      <div
        v-if="addingGuestOpen"
        class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
        @click.self="addingGuestOpen = false"
      >
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="add-guest-title"
          class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
        >
          <h3 id="add-guest-title" class="mb-1 text-base font-semibold">Add a guest</h3>
          <p class="mb-4 text-xs text-zinc-500">
            Adding a guest doesn't send an invitation yet — you'll do that from the list.
          </p>
          <form class="space-y-3" @submit.prevent="addGuest">
            <div>
              <label class="label">Name</label>
              <input
                ref="addGuestNameRef"
                v-model="newGuest.name"
                class="input"
                placeholder="Mira Patel"
                required
                autofocus
              />
            </div>
            <div>
              <label class="label">Email</label>
              <input v-model="newGuest.email" type="email" class="input" placeholder="optional" />
            </div>
            <div>
              <label class="label">Phone</label>
              <PhoneInput v-model="newGuest.phone" />
            </div>
            <div>
              <label class="label mb-1">Plus-ones allowed</label>
              <div class="flex items-center overflow-hidden rounded-lg border border-zinc-700 bg-zinc-950">
                <button
                  type="button"
                  class="flex h-10 w-11 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100 disabled:opacity-30"
                  :disabled="newGuest.plus_ones <= 0"
                  @click="newGuest.plus_ones = Math.max(0, newGuest.plus_ones - 1)"
                >−</button>
                <span class="flex-1 text-center font-semibold tabular-nums text-zinc-100">{{ newGuest.plus_ones }}</span>
                <button
                  type="button"
                  class="flex h-10 w-11 shrink-0 items-center justify-center text-lg text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
                  @click="newGuest.plus_ones++"
                >+</button>
              </div>
            </div>
            <div class="flex items-center justify-end gap-2 pt-2">
              <button type="button" class="text-sm text-zinc-400 hover:text-zinc-200" :disabled="addingGuest" @click="addingGuestOpen = false">Cancel</button>
              <button type="submit" class="btn-primary" :disabled="addingGuest || !newGuest.name.trim()">
                {{ addingGuest ? 'Adding…' : 'Add guest' }}
              </button>
            </div>
          </form>
        </div>
      </div>
    </Teleport>

    <!-- Import CSV -->
    <Teleport to="body">
      <div
        v-if="importingOpen"
        class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
        @click.self="importingOpen = false"
      >
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="import-csv-title"
          class="flex max-h-[85vh] w-full max-w-2xl flex-col rounded-lg border border-zinc-800 bg-zinc-900 shadow-2xl"
        >
          <div class="flex items-center justify-between border-b border-zinc-800 px-5 py-3">
            <h3 id="import-csv-title" class="text-base font-semibold">Import guests from a spreadsheet</h3>
            <button
              type="button"
              class="rounded-md p-1 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
              aria-label="Close"
              @click="importingOpen = false"
            >
              <svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
              </svg>
            </button>
          </div>
          <div class="overflow-y-auto px-5 py-4">
            <CsvImportCard :event-id="eventId" @imported="handleImported" />
          </div>
        </div>
      </div>
    </Teleport>

    <!-- WhatsApp send wizard -->
    <Teleport to="body">
      <div
        v-if="whatsappOpen"
        class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
        @click.self="whatsappOpen = false"
      >
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="wa-title"
          class="flex max-h-[85vh] w-full max-w-xl flex-col rounded-lg border border-zinc-800 bg-zinc-900 shadow-2xl"
        >
          <div class="flex items-center justify-between border-b border-zinc-800 px-5 py-3">
            <div>
              <h3 id="wa-title" class="text-base font-semibold">Send invitations via WhatsApp</h3>
              <p class="mt-0.5 text-xs text-zinc-500">
                Each tap opens WhatsApp with a personal link pre-filled — you hit Send inside the app.
                Numbers must include the country code (e.g. <code class="font-mono text-zinc-400">+233…</code>).
              </p>
            </div>
            <button
              type="button"
              class="rounded-md p-1 text-zinc-400 transition hover:bg-zinc-800 hover:text-zinc-100"
              aria-label="Close"
              @click="whatsappOpen = false"
            >
              <svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
              </svg>
            </button>
          </div>

          <!-- Progress bar -->
          <div v-if="waEligible.length > 0" class="border-b border-zinc-800 px-5 py-3">
            <div class="mb-1.5 flex items-center justify-between text-xs">
              <span class="text-zinc-300">
                <span class="font-semibold tabular-nums text-zinc-100">{{ waSentCount }}</span>
                of
                <span class="font-semibold tabular-nums text-zinc-100">{{ waEligible.length }}</span>
                sent
              </span>
              <button
                v-if="waSentCount > 0"
                type="button"
                class="text-zinc-500 hover:text-zinc-300"
                @click="resetWaSent"
              >Reset progress</button>
            </div>
            <div class="h-1.5 w-full overflow-hidden rounded-full bg-zinc-800">
              <div
                class="h-full bg-emerald-500 transition-all"
                :style="{ width: `${waEligible.length === 0 ? 0 : (waSentCount / waEligible.length) * 100}%` }"
              ></div>
            </div>
          </div>

          <!-- List -->
          <div class="overflow-y-auto px-5 py-3">
            <div v-if="waEligible.length === 0" class="py-8 text-center text-sm text-zinc-400">
              No guests have a phone number on file.
              <p class="mt-1 text-xs text-zinc-500">Edit a guest to add their number, then come back here.</p>
            </div>
            <ul v-else class="divide-y divide-zinc-800/70">
              <li v-for="g in waEligible" :key="g.id" class="flex items-center justify-between gap-3 py-2.5">
                <div class="min-w-0">
                  <div class="truncate text-sm font-medium text-zinc-100">{{ g.name }}</div>
                  <div class="flex items-center gap-1.5 truncate text-xs">
                    <!-- Inline warning when the number isn't routable by wa.me -->
                    <svg
                      v-if="!isLikelyE164(g.phone)"
                      class="h-3.5 w-3.5 shrink-0 text-amber-400"
                      viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
                    >
                      <title>Missing country code — WhatsApp can't open this number</title>
                      <path fill-rule="evenodd" d="M8.485 3.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 3.495zM10 8a1 1 0 01.993.883L11 9v3a1 1 0 01-1.993.117L9 12V9a1 1 0 011-1zm0 6a1 1 0 110 2 1 1 0 010-2z" clip-rule="evenodd" />
                    </svg>
                    <span class="truncate" :class="isLikelyE164(g.phone) ? 'text-zinc-500' : 'text-amber-300'">
                      {{ g.phone }}
                    </span>
                  </div>
                </div>

                <!-- Bad phone → Fix button. Good phone → Send / Resend. -->
                <button
                  v-if="!isLikelyE164(g.phone)"
                  type="button"
                  class="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-amber-700/60 bg-amber-500/10 px-3 py-1.5 text-xs font-medium text-amber-200 transition hover:bg-amber-500/20"
                  @click="fixPhoneFromWizard(g)"
                >
                  <svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                    <path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
                  </svg>
                  Fix number
                </button>
                <button
                  v-else
                  type="button"
                  class="inline-flex shrink-0 items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition disabled:opacity-50"
                  :class="waSent.has(g.id)
                    ? 'border border-zinc-700 text-zinc-300 hover:border-emerald-600 hover:bg-emerald-500/10'
                    : 'bg-emerald-500 text-zinc-950 hover:bg-emerald-400'"
                  :disabled="waSending === g.id"
                  @click="sendOneViaWhatsapp(g)"
                >
                  <template v-if="waSending === g.id">Opening…</template>
                  <template v-else-if="waSent.has(g.id)">
                    <svg class="h-3.5 w-3.5 text-emerald-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                      <path fill-rule="evenodd" d="M16.704 5.296a1 1 0 010 1.408l-8 8a1 1 0 01-1.408 0l-4-4a1 1 0 011.408-1.408L8 12.592l7.296-7.296a1 1 0 011.408 0z" clip-rule="evenodd" />
                    </svg>
                    Sent · resend
                  </template>
                  <template v-else>Send</template>
                </button>
              </li>
            </ul>
          </div>

          <!-- Footer hint -->
          <div
            v-if="waNoPhoneCount > 0"
            class="border-t border-zinc-800 bg-zinc-900/50 px-5 py-2.5 text-xs text-zinc-500"
          >
            <span aria-hidden="true">ⓘ</span>
            {{ waNoPhoneCount }} {{ waNoPhoneCount === 1 ? 'guest has' : 'guests have' }} no phone number — invite them via email or import their numbers.
          </div>
        </div>
      </div>
    </Teleport>

    <!-- Edit guest -->
    <Teleport to="body">
      <div
        v-if="editing"
        class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
        @click.self="editing = null"
      >
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="edit-guest-title"
          class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
        >
          <h3 id="edit-guest-title" class="mb-1 text-base font-semibold">Edit guest</h3>
          <p class="mb-4 text-xs text-zinc-500">Adjust contact details before sending the invitation.</p>
          <form class="space-y-3" @submit.prevent="saveEdit">
            <div>
              <label class="label">Name</label>
              <input v-model="editForm.name" class="input" required maxlength="255" />
            </div>
            <div>
              <label class="label">Email</label>
              <input v-model="editForm.email" type="email" class="input" placeholder="optional" />
            </div>
            <div>
              <label class="label">Phone</label>
              <PhoneInput v-model="editForm.phone" />
            </div>
            <div>
              <label class="label">Plus-ones</label>
              <input v-model.number="editForm.plus_ones" type="number" min="0" class="input" />
            </div>
            <div class="flex items-center justify-end gap-2 pt-2">
              <button type="button" class="text-sm text-zinc-400 hover:text-zinc-200" :disabled="savingEdit" @click="editing = null">Cancel</button>
              <button type="submit" class="btn-primary" :disabled="savingEdit || !editForm.name.trim()">
                {{ savingEdit ? 'Saving…' : 'Save changes' }}
              </button>
            </div>
          </form>
        </div>
      </div>
    </Teleport>

    <!-- Delete guest -->
    <Teleport to="body">
      <div
        v-if="deleting"
        class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
        @click.self="deleting = null"
      >
        <div
          role="alertdialog"
          aria-modal="true"
          aria-labelledby="delete-guest-title"
          aria-describedby="delete-guest-desc"
          class="w-full max-w-sm rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
        >
          <h3 id="delete-guest-title" class="mb-1 text-base font-semibold">Remove guest?</h3>
          <p id="delete-guest-desc" class="mb-4 text-sm text-zinc-400">
            <span class="text-zinc-200">{{ deleting.name }}</span> will be removed from this event.
            This can't be undone.
          </p>
          <div class="flex items-center justify-end gap-2">
            <button type="button" class="text-sm text-zinc-400 hover:text-zinc-200" :disabled="deletingInFlight" @click="deleting = null">Cancel</button>
            <button
              type="button"
              class="rounded-md bg-red-500/90 px-3 py-1.5 text-sm font-medium text-white shadow-sm transition hover:bg-red-500 disabled:opacity-40"
              :disabled="deletingInFlight"
              @click="confirmDelete"
            >
              {{ deletingInFlight ? 'Removing…' : 'Remove guest' }}
            </button>
          </div>
        </div>
      </div>
    </Teleport>

    <!-- Regenerate invitation link -->
    <Teleport to="body">
      <div
        v-if="regenerating"
        class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
        @click.self="regenerating = null"
      >
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="regen-title"
          class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
        >
          <h3 id="regen-title" class="mb-1 text-base font-semibold">Generate a new invitation link?</h3>
          <p class="mb-4 text-sm text-zinc-400">
            The current link for
            <span class="text-zinc-200">{{ regenerating.name }}</span>
            will stop working immediately. Choose how to deliver the new one.
          </p>

          <div class="space-y-2">
            <button
              type="button"
              class="flex w-full items-center justify-between rounded-md border border-zinc-700 bg-zinc-950 px-3 py-3 text-left transition hover:border-brand-600 hover:bg-zinc-900 disabled:opacity-50"
              :disabled="!regenerating.email || regenInFlight !== null"
              @click="regenerateAndResend"
            >
              <span>
                <span class="block text-sm font-medium text-zinc-100">Resend via email</span>
                <span class="block text-xs text-zinc-500">
                  {{ regenerating.email
                    ? `Sends a fresh branded invitation to ${regenerating.email}.`
                    : 'No email on file — add one in Edit to enable this option.' }}
                </span>
              </span>
              <span class="text-xs text-zinc-500">{{ regenInFlight === 'resend' ? 'Sending…' : '→' }}</span>
            </button>

            <button
              type="button"
              class="flex w-full items-center justify-between rounded-md border border-zinc-700 bg-zinc-950 px-3 py-3 text-left transition hover:border-brand-600 hover:bg-zinc-900 disabled:opacity-50"
              :disabled="regenInFlight !== null"
              @click="regenerateAndCopy"
            >
              <span>
                <span class="block text-sm font-medium text-zinc-100">Copy new link</span>
                <span class="block text-xs text-zinc-500">
                  Copies the new URL to your clipboard so you can share it manually.
                </span>
              </span>
              <span class="text-xs text-zinc-500">{{ regenInFlight === 'copy' ? 'Copying…' : '→' }}</span>
            </button>
          </div>

          <div class="mt-4 flex items-center justify-end">
            <button type="button" class="text-sm text-zinc-400 hover:text-zinc-200" :disabled="regenInFlight !== null" @click="regenerating = null">Cancel</button>
          </div>
        </div>
      </div>
    </Teleport>

    <!-- RSVP edit history (Tier 2 Block A) -->
    <Teleport to="body">
      <div
        v-if="historyFor"
        class="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
        @click.self="closeHistory"
      >
        <div
          role="dialog"
          aria-modal="true"
          aria-labelledby="history-title"
          class="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
        >
          <h3 id="history-title" class="mb-1 text-base font-semibold">
            RSVP history — {{ historyFor.name }}
          </h3>
          <p class="mb-4 text-xs text-zinc-500">
            Each edit replaces the previous response. The trail below is read-only.
          </p>

          <div v-if="historyLoading" class="py-6 text-center text-sm text-zinc-500">Loading…</div>
          <div v-else-if="historyError" class="rounded-md border border-red-900/60 bg-red-950/30 p-3 text-sm text-red-300">
            {{ historyError }}
          </div>
          <div v-else-if="historyData && historyData.rsvp" class="space-y-3">
            <div class="rounded-md border border-brand-900/60 bg-brand-950/20 p-3">
              <div class="mb-1 flex items-center justify-between text-xs text-brand-300">
                <span class="font-medium uppercase tracking-wider">Current</span>
                <span class="text-zinc-500">{{ fmtTime(historyData.rsvp.submitted_at) }}</span>
              </div>
              <p class="text-sm text-zinc-200">
                <strong class="capitalize">{{ historyData.rsvp.response }}</strong>
                <span v-if="historyData.rsvp.plus_ones > 0"> · +{{ historyData.rsvp.plus_ones }} plus-ones</span>
                <span v-if="historyData.rsvp.dietary_notes"> · {{ historyData.rsvp.dietary_notes }}</span>
              </p>
              <p class="mt-1 text-xs text-zinc-500">
                {{ historyData.rsvp.edit_count }} edit{{ historyData.rsvp.edit_count === 1 ? '' : 's' }}
              </p>
            </div>
            <div v-if="historyData.revisions.length === 0" class="py-3 text-center text-xs text-zinc-500">
              No edits yet.
            </div>
            <ul v-else class="space-y-2">
              <li
                v-for="rev in historyData.revisions"
                :key="rev.id"
                class="rounded-md border border-zinc-800 bg-zinc-950 p-3"
              >
                <div class="mb-1 flex items-center justify-between text-xs text-zinc-500">
                  <span>was</span>
                  <span>{{ fmtTime(rev.changed_at) }}</span>
                </div>
                <p class="text-sm text-zinc-300">
                  <strong class="capitalize">{{ rev.prev_response }}</strong>
                  <span v-if="rev.prev_plus_ones > 0"> · +{{ rev.prev_plus_ones }} plus-ones</span>
                  <span v-if="rev.prev_dietary"> · {{ rev.prev_dietary }}</span>
                </p>
              </li>
            </ul>
          </div>
          <div v-else class="py-3 text-center text-sm text-zinc-500">
            No RSVP has been submitted yet.
          </div>

          <div class="mt-5 flex items-center justify-end">
            <button type="button" class="text-sm text-zinc-400 hover:text-zinc-200" @click="closeHistory">Close</button>
          </div>
        </div>
      </div>
    </Teleport>

    <!-- Toast: bulk-send result. Fades after ~5s; click to dismiss. -->
    <Transition
      enter-active-class="transition duration-200 ease-out"
      enter-from-class="translate-y-2 opacity-0"
      enter-to-class="translate-y-0 opacity-100"
      leave-active-class="transition duration-200 ease-in"
      leave-from-class="translate-y-0 opacity-100"
      leave-to-class="translate-y-2 opacity-0"
    >
      <button
        v-if="toast"
        type="button"
        class="fixed bottom-6 right-6 z-50 max-w-sm rounded-lg border px-4 py-3 text-left text-sm shadow-lg backdrop-blur"
        :class="toast.kind === 'success'
          ? 'border-brand-700/60 bg-brand-950/90 text-brand-100'
          : 'border-red-800/60 bg-red-950/90 text-red-100'"
        :aria-label="`Dismiss notification: ${toast.text}`"
        @click="toast = null"
      >
        <span aria-hidden="true" class="mr-2">{{ toast.kind === 'success' ? '✓' : '!' }}</span>
        {{ toast.text }}
      </button>
    </Transition>
  </section>
</template>
