<script setup lang="ts">
// /scanner — the page a door volunteer's phone lands on after the host
// shares a magic link (or the host points their own phone at the QR on
// their desktop event-detail page).
//
// Flow per scan:
//
//   1. Camera detects a QR → handleScannedPayload()
//   2. Pause the camera + dedupe set
//   3. POST /events/{id}/check-in/preview → guest name + expected party size
//   4. Show confirmation panel with "Just them / +1 / +2 / Other" buttons
//   5. Volunteer taps a count → POST /check-in with arrival_count
//   6. Success → toast + summary update; cancel → resume scanning
//
// The preview step is what lets the door volunteer correctly record
// "the bride brought her cousin who wasn't invited" without making them
// type or wonder whether they're recording two people instead of one.
//
// Offline / flaky-network mode (task #42):
//
//   When the record POST fails with a network error (no connectivity,
//   5xx), the {qr_payload, arrival_count, queued_at} tuple goes into an
//   IndexedDB store. A small badge surfaces "N queued"; the queue
//   drains automatically on 'online', and an explicit "Sync now" button
//   gives the volunteer a manual lever if they're behind a captive
//   portal. Server-side 4xx (already-checked-in, bad QR, scoped-to-
//   different-event) deliberately don't re-queue — those are user-
//   facing problems, not transport problems.

definePageMeta({ layout: false })

useHead({
  title: 'Door scanner · GuestGuard',
  meta: [
    { name: 'viewport', content: 'width=device-width, initial-scale=1, viewport-fit=cover' },
    { name: 'theme-color', content: '#0a0a0a' },
    { name: 'apple-mobile-web-app-capable', content: 'yes' },
    { name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
    { name: 'apple-mobile-web-app-title', content: 'GuestGuard Scanner' },
    { name: 'mobile-web-app-capable', content: 'yes' },
  ],
  link: [
    { rel: 'manifest', href: '/manifest.json' },
  ],
})

interface Summary {
  arrived_headcount: number
  expected_headcount: number
  guests_checked_in: number
}

interface CheckInRecord {
  id: string
  guest_id: string
  checked_in_at: string
  arrival_count: number
  walk_in: boolean
}

interface ListResponse {
  check_ins: CheckInRecord[]
  summary: Summary
}

interface PreviewResponse {
  guest: { id: string; name: string; plus_ones: number; email?: string | null }
  expected_party_size: number
  already_checked_in: boolean
  existing_check_in?: CheckInRecord | null
}

const route = useRoute()
const router = useRouter()
const config = useRuntimeConfig()
const apiBase = config.public.apiBase as string

const token = ref<string>('')
const eventId = ref<string>('')
const tokenInvalid = ref(false)
const fatalError = ref<string | null>(null)

onMounted(() => {
  token.value = String(route.query.token || '')
  eventId.value = String(route.query.event || '')
  if (!token.value || !eventId.value) {
    tokenInvalid.value = true
    return
  }
  void loadList()
  void initQueue().then(() => {
    void drainQueue()
    refreshQueueCount()
  })
  if ('serviceWorker' in navigator) {
    navigator.serviceWorker.register('/scanner-sw.js').catch(() => {})
  }
  window.addEventListener('online', onOnline)
  window.addEventListener('offline', onOffline)
})

onBeforeUnmount(() => {
  if (typeof window === 'undefined') return
  window.removeEventListener('online', onOnline)
  window.removeEventListener('offline', onOffline)
})

async function authedFetch<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
  return await $fetch<T>(path, {
    baseURL: apiBase,
    method: (init.method ?? 'GET') as any,
    body: init.body as any,
    headers: {
      'Content-Type': 'application/json',
      Authorization: `Bearer ${token.value}`,
    },
  })
}

// --- summary + recent arrivals ---

const summary = ref<Summary>({ arrived_headcount: 0, expected_headcount: 0, guests_checked_in: 0 })
const recent = ref<CheckInRecord[]>([])
const offline = ref<boolean>(typeof navigator !== 'undefined' ? !navigator.onLine : false)

async function loadList() {
  try {
    const data = await authedFetch<ListResponse>(`/events/${eventId.value}/check-ins`)
    summary.value = data.summary
    recent.value = (data.check_ins || []).slice(0, 5)
  } catch (e: any) {
    const status = e?.response?.status ?? e?.statusCode
    if (status === 401) {
      tokenInvalid.value = true
      return
    }
    // Network failure is fine — we'll work from the queued local
    // summary until the radio comes back.
    if (!isNetworkError(e)) {
      fatalError.value = e?.data?.error || 'Could not load arrivals.'
    }
  }
}

// --- IndexedDB queue ---
//
// Tiny one-store DB. We don't pull in idb-keyval to keep the bundle
// lean; the raw IndexedDB surface is enough for an append-only queue.

const QUEUE_DB = 'gg-scanner'
const QUEUE_STORE = 'pending-checkins'
const queueCount = ref(0)
let dbRef: IDBDatabase | null = null

function initQueue(): Promise<void> {
  return new Promise((resolve) => {
    if (typeof indexedDB === 'undefined') {
      resolve()
      return
    }
    const req = indexedDB.open(QUEUE_DB, 1)
    req.onupgradeneeded = () => {
      const db = req.result
      if (!db.objectStoreNames.contains(QUEUE_STORE)) {
        db.createObjectStore(QUEUE_STORE, { keyPath: 'id', autoIncrement: true })
      }
    }
    req.onsuccess = () => {
      dbRef = req.result
      resolve()
    }
    req.onerror = () => resolve()
  })
}

type QueueRow = {
  id?: number
  event_id: string
  qr_payload: string
  arrival_count: number
  notes?: string
  queued_at: number
}

function queueAdd(row: Omit<QueueRow, 'id'>): Promise<void> {
  return new Promise((resolve) => {
    if (!dbRef) { resolve(); return }
    const tx = dbRef.transaction(QUEUE_STORE, 'readwrite')
    tx.objectStore(QUEUE_STORE).add(row)
    tx.oncomplete = () => { refreshQueueCount(); resolve() }
    tx.onerror = () => resolve()
  })
}

function queueAll(): Promise<QueueRow[]> {
  return new Promise((resolve) => {
    if (!dbRef) { resolve([]); return }
    const tx = dbRef.transaction(QUEUE_STORE, 'readonly')
    const req = tx.objectStore(QUEUE_STORE).getAll()
    req.onsuccess = () => resolve((req.result as QueueRow[]) || [])
    req.onerror = () => resolve([])
  })
}

function queueDelete(id: number): Promise<void> {
  return new Promise((resolve) => {
    if (!dbRef) { resolve(); return }
    const tx = dbRef.transaction(QUEUE_STORE, 'readwrite')
    tx.objectStore(QUEUE_STORE).delete(id)
    tx.oncomplete = () => { refreshQueueCount(); resolve() }
    tx.onerror = () => resolve()
  })
}

function refreshQueueCount() {
  void queueAll().then((rows) => { queueCount.value = rows.length })
}

let draining = false
async function drainQueue() {
  if (draining) return
  if (typeof navigator !== 'undefined' && !navigator.onLine) return
  draining = true
  try {
    const rows = await queueAll()
    for (const row of rows) {
      if (row.event_id !== eventId.value) continue // foreign event id — leave it alone
      try {
        const res = await authedFetch<{ guest?: { name?: string }; summary: Summary; check_in: CheckInRecord }>(
          `/events/${eventId.value}/check-in`,
          { method: 'POST', body: { qr_payload: row.qr_payload, arrival_count: row.arrival_count, notes: row.notes } },
        )
        summary.value = res.summary
        recent.value = [res.check_in, ...recent.value].slice(0, 5)
        if (row.id !== undefined) await queueDelete(row.id)
      } catch (e: any) {
        const status = e?.response?.status ?? e?.statusCode
        if (status === 409 || status === 410 || status === 400 || status === 404) {
          // Server says this scan will never succeed. Drop the row so
          // the queue doesn't get stuck behind a poison message; surface
          // it once to the volunteer.
          if (row.id !== undefined) await queueDelete(row.id)
          showToast({ kind: 'warn', text: `Queued scan dropped (${e?.data?.error || 'rejected'})` }, 3500)
          continue
        }
        if (isNetworkError(e)) {
          // Still offline — stop and try again on next online event.
          break
        }
        // 5xx etc. — stop, retry later, but log fatally.
        fatalError.value = e?.data?.error || 'Queued scan retry failed.'
        break
      }
    }
  } finally {
    draining = false
    refreshQueueCount()
  }
}

function onOnline() {
  offline.value = false
  showToast({ kind: 'success', text: 'Back online. Syncing scans…' }, 2000)
  void drainQueue()
}
function onOffline() {
  offline.value = true
  showToast({ kind: 'warn', text: 'Offline. Scans will sync when you reconnect.' }, 3000)
}

function isNetworkError(e: any): boolean {
  if (!e) return false
  if (e.name === 'FetchError' && !e.response) return true
  if (e.message && /network|fetch|failed to fetch|load failed/i.test(e.message)) return true
  if (typeof navigator !== 'undefined' && !navigator.onLine) return true
  return false
}

// --- camera ---

type Toast = { kind: 'success' | 'warn' | 'error'; text: string }
const toast = ref<Toast | null>(null)
let toastTimer: ReturnType<typeof setTimeout> | null = null
function showToast(t: Toast, ms = 2500) {
  toast.value = t
  if (toastTimer) clearTimeout(toastTimer)
  toastTimer = setTimeout(() => { toast.value = null }, ms)
}

const cameraOn = ref(false)
const startError = ref<string | null>(null)
const videoRef = ref<HTMLVideoElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
let mediaStream: MediaStream | null = null
let scanLoopHandle: number | null = null
const lastScanAt = ref(0)
const recentlyScanned = ref<Set<string>>(new Set())

// True while the confirmation panel is showing — scanning pauses so
// the camera doesn't keep firing previews on the same QR while the
// volunteer is choosing a party size.
const scanningPaused = computed(() => confirm.value !== null)

async function ensureJsQR() {
  if (typeof window === 'undefined') return null
  const w = window as any
  if (w.jsQR) return w.jsQR
  await new Promise<void>((resolve, reject) => {
    const s = document.createElement('script')
    s.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js'
    s.onload = () => resolve()
    s.onerror = () => reject(new Error('Could not load scanner script'))
    document.head.appendChild(s)
  })
  return w.jsQR
}

async function startCamera() {
  startError.value = null
  try {
    const jsQR = await ensureJsQR()
    if (!jsQR) {
      startError.value = 'This browser can\'t scan QR codes. Try Chrome or Safari.'
      return
    }
    mediaStream = await navigator.mediaDevices.getUserMedia({
      video: { facingMode: 'environment' },
      audio: false,
    })
    cameraOn.value = true
    await nextTick()
    if (videoRef.value) {
      videoRef.value.srcObject = mediaStream
      await videoRef.value.play()
      scanLoop(jsQR)
    }
  } catch (e: any) {
    startError.value = e?.message || 'Could not start the camera.'
  }
}

function stopCamera() {
  if (scanLoopHandle !== null) {
    cancelAnimationFrame(scanLoopHandle)
    scanLoopHandle = null
  }
  if (mediaStream) {
    mediaStream.getTracks().forEach((t) => t.stop())
    mediaStream = null
  }
  cameraOn.value = false
  recentlyScanned.value.clear()
}

function scanLoop(jsQR: any) {
  const video = videoRef.value
  const canvas = canvasRef.value
  if (!video || !canvas) return
  const ctx = canvas.getContext('2d', { willReadFrequently: true })
  if (!ctx) return
  const tick = () => {
    if (!cameraOn.value || !videoRef.value || !canvasRef.value) return
    if (!scanningPaused.value && videoRef.value.readyState === videoRef.value.HAVE_ENOUGH_DATA) {
      canvas.width = videoRef.value.videoWidth
      canvas.height = videoRef.value.videoHeight
      ctx.drawImage(videoRef.value, 0, 0, canvas.width, canvas.height)
      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
      const code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' })
      if (code && code.data && shouldProcessScan(code.data)) {
        void handleScannedPayload(code.data)
      }
    }
    scanLoopHandle = requestAnimationFrame(tick)
  }
  tick()
}

function shouldProcessScan(payload: string): boolean {
  const now = Date.now()
  if (now - lastScanAt.value < 1500) return false
  if (recentlyScanned.value.has(payload)) return false
  lastScanAt.value = now
  recentlyScanned.value.add(payload)
  setTimeout(() => recentlyScanned.value.delete(payload), 10_000)
  return true
}

// --- confirm panel state ---
//
// confirm.value === null  → scanner is live, waiting for a QR
// confirm.value !== null  → showing the guest preview + party-size buttons

type ConfirmState = {
  payload: string
  guestName: string
  expected: number     // expected party size from preview
  plusOnes: number     // allowance from the guest record
  alreadyIn: boolean
  customCount: number  // for "Other (N)" input
  submitting: boolean
}

const confirm = ref<ConfirmState | null>(null)

async function handleScannedPayload(payload: string) {
  // Preview lookup. Failures fall back: if we can't reach the API we
  // still let the volunteer pick a count, defaulting to 1 — the actual
  // record POST will queue offline-style for sync later.
  try {
    const res = await authedFetch<PreviewResponse>(
      `/events/${eventId.value}/check-in/preview`,
      { method: 'POST', body: { qr_payload: payload } },
    )
    confirm.value = {
      payload,
      guestName: res.guest?.name || 'Guest',
      expected: res.expected_party_size,
      plusOnes: res.guest?.plus_ones ?? 0,
      alreadyIn: res.already_checked_in,
      customCount: res.expected_party_size,
      submitting: false,
    }
    if (navigator.vibrate) navigator.vibrate(60)
  } catch (e: any) {
    const status = e?.response?.status ?? e?.statusCode
    if (status === 401 || status === 403) {
      tokenInvalid.value = true
      stopCamera()
      return
    }
    if (status === 410) {
      showToast({ kind: 'error', text: 'This guest code has expired.' })
      return
    }
    if (status === 400 || status === 404) {
      showToast({ kind: 'error', text: 'That doesn\'t look like one of your guests.' })
      return
    }
    if (isNetworkError(e)) {
      // No preview available offline. Open the confirm panel with a
      // sensible default so the volunteer can still record. We don't
      // know the name or plus-one cap; fall back to a generic prompt.
      confirm.value = {
        payload,
        guestName: 'Guest (offline)',
        expected: 1,
        plusOnes: 5, // permissive cap on the +N buttons
        alreadyIn: false,
        customCount: 1,
        submitting: false,
      }
      return
    }
    showToast({ kind: 'error', text: e?.data?.error || 'Could not look up that scan.' })
  }
}

function cancelConfirm() {
  confirm.value = null
}

async function commitCheckIn(arrivalCount: number) {
  const c = confirm.value
  if (!c || c.submitting) return
  c.submitting = true
  const body = { qr_payload: c.payload, arrival_count: arrivalCount }
  try {
    const res = await authedFetch<{ guest?: { name?: string }; summary: Summary; check_in: CheckInRecord }>(
      `/events/${eventId.value}/check-in`,
      { method: 'POST', body },
    )
    summary.value = res.summary
    recent.value = [res.check_in, ...recent.value].slice(0, 5)
    showToast({ kind: 'success', text: `${c.guestName} · party of ${arrivalCount} ✓` })
    if (navigator.vibrate) navigator.vibrate(80)
    confirm.value = null
  } catch (e: any) {
    const status = e?.response?.status ?? e?.statusCode
    if (status === 409) {
      showToast({ kind: 'warn', text: 'Already checked in.' })
      confirm.value = null
      return
    }
    if (status === 410 || status === 400 || status === 404) {
      showToast({ kind: 'error', text: e?.data?.error || 'Could not check in.' })
      confirm.value = null
      return
    }
    if (status === 401 || status === 403) {
      tokenInvalid.value = true
      stopCamera()
      return
    }
    if (isNetworkError(e)) {
      await queueAdd({
        event_id: eventId.value,
        qr_payload: c.payload,
        arrival_count: arrivalCount,
        queued_at: Date.now(),
      })
      showToast({ kind: 'warn', text: `Queued · ${c.guestName} (will sync when online)` }, 3500)
      confirm.value = null
      return
    }
    showToast({ kind: 'error', text: e?.data?.error || 'Could not check in.' })
  } finally {
    if (confirm.value) confirm.value.submitting = false
  }
}

onUnmounted(stopCamera)

// --- walk-in ---

const walkInOpen = ref(false)
const walkInForm = reactive({ name: '', arrival_count: 1 })
const walkInSubmitting = ref(false)

async function submitWalkIn() {
  if (!walkInForm.name.trim()) return
  walkInSubmitting.value = true
  try {
    const res = await authedFetch<{ guest: { name: string }; summary: Summary; check_in: CheckInRecord }>(
      `/events/${eventId.value}/walk-ins`,
      {
        method: 'POST',
        body: { name: walkInForm.name.trim(), arrival_count: walkInForm.arrival_count },
      },
    )
    summary.value = res.summary
    recent.value = [res.check_in, ...recent.value].slice(0, 5)
    showToast({ kind: 'success', text: `${res.guest.name} added.` })
    walkInForm.name = ''
    walkInForm.arrival_count = 1
    walkInOpen.value = false
  } catch (e: any) {
    showToast({ kind: 'error', text: e?.data?.error || 'Could not add walk-in.' })
  } finally {
    walkInSubmitting.value = false
  }
}

const arrivalPct = computed(() => {
  const exp = summary.value.expected_headcount
  const arr = summary.value.arrived_headcount
  if (exp === 0) return 0
  return Math.min(100, Math.round((arr / exp) * 100))
})

// Party-size buttons. We always show 1, 1+plusOnes/2, and "Other"; the
// middle button is the recommended default (the guest's full party
// size from the preview).
const partyButtons = computed(() => {
  const c = confirm.value
  if (!c) return [] as { label: string; count: number; recommended: boolean }[]
  const out: { label: string; count: number; recommended: boolean }[] = []
  out.push({ label: 'Just them', count: 1, recommended: c.expected === 1 })
  if (c.expected > 1) {
    out.push({ label: `Party of ${c.expected}`, count: c.expected, recommended: true })
  }
  // Show intermediate plus-one counts if there's a gap (e.g. expected=3
  // → button for 2 as well).
  for (let n = 2; n < c.expected; n++) {
    out.splice(out.length - 1, 0, { label: `Party of ${n}`, count: n, recommended: false })
  }
  return out
})

function backToDashboard() {
  router.push('/dashboard')
}
</script>

<template>
  <div class="min-h-screen bg-zinc-950 text-zinc-100">
    <!-- Invalid / expired token landing. -->
    <div v-if="tokenInvalid" class="flex min-h-screen flex-col items-center justify-center px-6 text-center">
      <div class="mb-4 text-4xl">🔒</div>
      <h1 class="text-xl font-semibold">This scanner link can't be used</h1>
      <p class="mt-2 max-w-sm text-sm text-zinc-400">
        It may have expired, been revoked, or wasn't for this event.
        Ask the host to send you a fresh link from their dashboard.
      </p>
      <button class="mt-6 rounded-md bg-brand-500 px-4 py-2 text-sm font-medium text-zinc-950 hover:bg-brand-400" @click="backToDashboard">
        Back to GuestGuard
      </button>
    </div>

    <!-- Active scanner. -->
    <div v-else class="mx-auto flex min-h-screen max-w-md flex-col">
      <header class="flex items-center justify-between px-4 py-3 text-sm">
        <p class="font-medium">GuestGuard · Door scanner</p>
        <div class="flex items-center gap-3 text-xs">
          <span
            v-if="offline"
            class="rounded-full bg-amber-900/40 px-2 py-0.5 text-amber-200"
          >Offline</span>
          <button
            v-if="queueCount > 0"
            class="rounded-full bg-zinc-800 px-2 py-0.5 text-zinc-200 hover:bg-zinc-700"
            @click="drainQueue"
          >Sync {{ queueCount }}</button>
          <button v-if="cameraOn" class="text-zinc-400 hover:text-zinc-200" @click="stopCamera">Pause</button>
        </div>
      </header>

      <!-- Live counter — what the door volunteer glances at between scans. -->
      <div class="mx-4 mt-2 rounded-lg border border-brand-700/40 bg-brand-500/[0.06] p-4">
        <div class="flex items-baseline justify-between">
          <div>
            <p class="text-[10px] font-medium uppercase tracking-widest text-brand-400">Arrived</p>
            <p class="mt-1 text-3xl font-semibold tabular-nums text-brand-200">
              {{ summary.arrived_headcount }}
              <span class="text-sm font-normal text-zinc-400">of {{ summary.expected_headcount }}</span>
            </p>
          </div>
          <div class="text-right">
            <p class="text-2xl font-semibold text-brand-200 tabular-nums">{{ arrivalPct }}%</p>
          </div>
        </div>
        <div class="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-zinc-900">
          <div class="h-full rounded-full bg-brand-500 transition-all" :style="{ width: arrivalPct + '%' }"></div>
        </div>
      </div>

      <!-- Camera viewport — fills the rest of the screen. -->
      <div class="relative mt-3 flex-1 overflow-hidden bg-black">
        <video v-show="cameraOn" ref="videoRef" playsinline class="h-full w-full object-cover"></video>
        <canvas ref="canvasRef" class="hidden"></canvas>

        <div v-if="!cameraOn" class="flex h-full flex-col items-center justify-center px-6 text-center">
          <div class="mb-3 text-5xl">📷</div>
          <p class="text-sm text-zinc-300">Tap "Start scanning" to use the camera.</p>
          <button
            class="mt-4 rounded-md bg-brand-500 px-4 py-2 text-sm font-semibold text-zinc-950 hover:bg-brand-400"
            @click="startCamera"
          >Start scanning</button>
          <p v-if="startError" class="mt-3 text-xs text-red-400">{{ startError }}</p>
        </div>

        <!-- Aiming reticule, while scanning. -->
        <div v-if="cameraOn && !scanningPaused" class="pointer-events-none absolute inset-0 flex items-center justify-center">
          <div class="h-56 w-56 rounded-lg border-2 border-brand-500/80 shadow-[0_0_0_9999px_rgba(0,0,0,0.55)]"></div>
        </div>

        <!-- Confirm party size after a scan. Overlays the camera so the
             camera keeps running (and the next reticule reappears the
             moment we close the panel). -->
        <div
          v-if="confirm"
          class="absolute inset-0 flex items-end justify-center bg-black/85 px-4 pb-6 pt-10"
          @click.self="cancelConfirm"
        >
          <div class="w-full max-w-md rounded-2xl border border-zinc-800 bg-zinc-900 p-5 shadow-2xl">
            <p class="text-[10px] font-medium uppercase tracking-widest text-brand-400">Guest matched</p>
            <h2 class="mt-1 text-xl font-semibold">{{ confirm.guestName }}</h2>
            <p
              v-if="confirm.alreadyIn"
              class="mt-2 rounded-md border border-amber-700/50 bg-amber-900/30 px-3 py-2 text-xs text-amber-100"
            >Already checked in earlier. Tapping a count below will re-record the latest party size.</p>
            <p v-else class="mt-2 text-sm text-zinc-400">How many in their party walked in?</p>

            <div class="mt-4 grid grid-cols-1 gap-2">
              <button
                v-for="b in partyButtons"
                :key="b.count"
                :disabled="confirm.submitting"
                class="rounded-lg border px-4 py-3 text-left text-sm font-medium transition disabled:opacity-50"
                :class="b.recommended
                  ? 'border-brand-500 bg-brand-500/10 text-brand-100 hover:bg-brand-500/20'
                  : 'border-zinc-700 bg-zinc-950 text-zinc-100 hover:border-zinc-500'"
                @click="commitCheckIn(b.count)"
              >
                <span class="float-right tabular-nums text-zinc-400">{{ b.count }}</span>
                {{ b.label }}
                <span v-if="b.recommended" class="ml-1 text-[10px] uppercase tracking-wider text-brand-400">recommended</span>
              </button>

              <!-- Other (custom). -->
              <div class="mt-1 flex items-center gap-2">
                <input
                  v-model.number="confirm.customCount"
                  type="number"
                  min="1"
                  max="20"
                  class="input w-20"
                  :disabled="confirm.submitting"
                />
                <button
                  class="flex-1 rounded-lg border border-zinc-700 px-3 py-2 text-sm text-zinc-100 hover:border-zinc-500 disabled:opacity-50"
                  :disabled="confirm.submitting || !confirm.customCount || confirm.customCount < 1"
                  @click="commitCheckIn(confirm.customCount)"
                >Other — record {{ confirm.customCount || 0 }}</button>
              </div>
            </div>

            <button
              class="mt-4 w-full text-center text-xs text-zinc-400 hover:text-zinc-200"
              :disabled="confirm.submitting"
              @click="cancelConfirm"
            >Cancel — wrong guest</button>
          </div>
        </div>
      </div>

      <!-- Recent + walk-in. -->
      <div class="mx-4 mt-3 mb-4 space-y-3">
        <button
          class="w-full rounded-md border border-zinc-700 px-4 py-2 text-sm hover:border-zinc-500 hover:bg-zinc-900"
          @click="walkInOpen = true"
        >+ Add walk-in</button>

        <div v-if="recent.length">
          <p class="mb-2 text-[10px] font-medium uppercase tracking-wider text-zinc-500">Just arrived</p>
          <ul class="space-y-1.5">
            <li
              v-for="c in recent"
              :key="c.id"
              class="flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm"
            >
              <span>
                Guest
                <span v-if="c.walk_in" class="ml-1 rounded-full bg-amber-900/30 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300">walk-in</span>
              </span>
              <span class="text-xs text-zinc-500">party of {{ c.arrival_count }}</span>
            </li>
          </ul>
        </div>
      </div>
    </div>

    <!-- Walk-in sheet. -->
    <Teleport to="body">
      <div
        v-if="walkInOpen"
        class="fixed inset-0 z-40 flex items-end justify-center bg-black/60 backdrop-blur-sm"
        @click.self="walkInOpen = false"
      >
        <div
          role="dialog"
          aria-modal="true"
          class="w-full max-w-md rounded-t-2xl border-t border-zinc-800 bg-zinc-900 p-5 shadow-2xl"
        >
          <h3 class="mb-1 text-base font-semibold">Walk-in</h3>
          <p class="mb-4 text-xs text-zinc-500">Adds them to the guest list and marks them in.</p>
          <form class="space-y-3" @submit.prevent="submitWalkIn">
            <div>
              <label class="label">Name</label>
              <input v-model="walkInForm.name" class="input" required autofocus />
            </div>
            <div>
              <label class="label">Party size</label>
              <input v-model.number="walkInForm.arrival_count" type="number" min="1" max="20" 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="walkInSubmitting" @click="walkInOpen = false">Cancel</button>
              <button type="submit" class="btn-primary text-sm"
                :disabled="walkInSubmitting || !walkInForm.name.trim()">
                {{ walkInSubmitting ? 'Adding…' : 'Add walk-in' }}
              </button>
            </div>
          </form>
        </div>
      </div>
    </Teleport>

    <!-- Toast. -->
    <Teleport to="body">
      <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"
      >
        <div
          v-if="toast"
          class="pointer-events-none fixed left-1/2 bottom-24 z-[60] -translate-x-1/2 rounded-full px-5 py-2 text-sm shadow-lg"
          :class="toast.kind === 'success' ? 'bg-brand-500 text-zinc-950'
              : toast.kind === 'warn' ? 'bg-amber-500 text-zinc-950'
              : 'bg-red-500 text-white'"
        >{{ toast.text }}</div>
      </Transition>
    </Teleport>

    <p v-if="fatalError" class="fixed bottom-2 left-2 right-2 rounded-md border border-red-700 bg-red-950/80 px-3 py-2 text-center text-xs text-red-200">{{ fatalError }}</p>
  </div>
</template>
