import { NextResponse } from 'next/server'
import { db } from '@/lib/db'
import { checkAgentAuth, checkCallbackSecret } from '@/lib/agent-auth'
import { appleOwnerId } from '@/lib/owner'
import { sendTelegram } from '@/lib/telegram'
import { pushToUser } from '@/lib/push'
import { absFromStored } from '@/lib/media'
import { readFile } from 'fs/promises'
import { scheduleOrganize, triggerAgent } from '@/lib/agent-trigger'
import { logActivity } from '@/lib/activity'

export const runtime = 'nodejs'

function toDate(v: unknown): Date | null {
  if (typeof v !== 'string' || !v.trim()) return null
  const d = new Date(v)
  return isNaN(d.getTime()) ? null : d
}

// Best-effort human reason from a failed external-API response (these endpoints
// return { error } JSON), falling back to the status code.
async function fwdErr(res: Response): Promise<string> {
  try {
    const j = await res.json()
    if (j && typeof j.error === 'string' && j.error) return j.error
  } catch { /* non-JSON body */ }
  return `HTTP ${res.status}`
}
interface FwdFailure { kind: 'chore' | 'sale' | 'expense' | 'points' | 'timer' | 'prayer' | 'praise'; label: string; reason: string }

// The chores app caps a chore at 8 photos; sending more just wastes a round trip.
const MAX_CHORE_IMAGES = 8

// Send a capture's photos to the chore it produced, so "take a picture of the
// mess and say 'add a chore to clean this'" arrives as one thing.
//
// A second request rather than part of the create: the chores app takes photos
// as multipart on its own route, and the chore has to exist before anything can
// hang off it. Best-effort by design — the chore is already real, and losing a
// reference photo must not read as the chore having failed.
async function attachChorePhotos(
  choreId: number,
  paths: string[],
  base: string,
  secret: string,
): Promise<string | null> {
  const form = new FormData()
  let attached = 0
  for (const rel of paths.slice(0, MAX_CHORE_IMAGES)) {
    try {
      const buf = await readFile(absFromStored(rel))
      // Steward stores processed images as WebP; the chores app re-encodes with
      // sharp, so the type only has to be honest enough to pass its filter.
      form.append('images', new Blob([new Uint8Array(buf)], { type: 'image/webp' }), rel.split('/').pop() || 'photo.webp')
      attached++
    } catch (e) {
      console.error(`[agent/result] could not read capture image ${rel}:`, e)
    }
  }
  if (attached === 0) return 'no readable photos'
  // The photo route is a child of the create route, so the one configured URL
  // locates both — no second env var to set and drift.
  const res = await fetch(`${base.replace(/\/+$/, '')}/${choreId}/images`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${secret}` },
    body: form,
  })
  return res.ok ? null : await fwdErr(res)
}

// Everything that leaves Steward is per-account and fail-closed.
//
// The five payload keys below (chores/points/timers → the chores app,
// sales/expenses → FlockBase) POST to OTHER services. Every other key the agent
// can send — reminders, events, thoughts, lists, focus — stays inside Steward
// and needs no capability, so a household member gets the whole voice-capture
// experience for their own notes, lists, reminders and calendar.
//
// Enforced HERE, on the server, before any outbound fetch — deliberately not in
// the routing prompt. The router is an LLM reading whatever was spoken into it,
// so "don't create sales" is a suggestion, not a boundary; a persuasive voice
// memo must not be able to talk its way past this.
//
// A dropped item is surfaced through fwdFailures rather than silently swallowed,
// so the capture detail explains itself instead of the item just vanishing.
function gateCapability<T>(
  allowed: boolean,
  raw: unknown,
  kind: FwdFailure['kind'],
  label: (x: T) => string,
  failures: FwdFailure[],
): T[] {
  const list: T[] = Array.isArray(raw) ? (raw as T[]) : []
  if (allowed || list.length === 0) return allowed ? list : []
  for (const x of list) {
    failures.push({ kind, label: label(x), reason: 'not enabled for this account' })
  }
  return []
}

interface ReminderIn {
  externalId?: string
  listId?: string
  listName?: string
  title?: string
  notes?: string
  dueDate?: string
  priority?: number
}
interface EventIn {
  externalId?: string
  calendarName?: string
  title?: string
  notes?: string
  location?: string
  startsAt?: string
  endsAt?: string
  allDay?: boolean
  recurrence?: {
    frequency?: string
    interval?: number
    daysOfWeek?: string[]
    until?: string
    count?: number
  }
}

// Render the agent's structured recurrence into a short human-readable label
// (e.g. "Weekly on Thursday") for display. Apple Calendar holds the
// authoritative repeat rule; this is just for the app's at-a-glance view.
// Returns null for one-time events.
function recurrenceLabel(rec?: EventIn['recurrence']): string | null {
  const freq = rec?.frequency?.toLowerCase()
  if (!freq) return null
  const interval = typeof rec?.interval === 'number' && rec.interval > 1 ? rec.interval : 1
  const dayNames: Record<string, string> = {
    su: 'Sunday', mo: 'Monday', tu: 'Tuesday', we: 'Wednesday', th: 'Thursday', fr: 'Friday', sa: 'Saturday',
  }
  const unit: Record<string, string> = { daily: 'day', weekly: 'week', monthly: 'month', yearly: 'year' }
  let base =
    interval > 1
      ? `Every ${interval} ${(unit[freq] || freq)}s`
      : freq.charAt(0).toUpperCase() + freq.slice(1)
  if (freq === 'weekly' && Array.isArray(rec?.daysOfWeek) && rec.daysOfWeek.length) {
    const days = rec.daysOfWeek.map(d => dayNames[d.slice(0, 2).toLowerCase()] || d).join(', ')
    base += ` on ${days}`
  }
  if (rec?.until) base += ` until ${rec.until.slice(0, 10)}`
  else if (typeof rec?.count === 'number' && rec.count > 0) base += `, ${rec.count} times`
  return base
}
interface ThoughtIn {
  body?: string
  tags?: string[]
}
interface FocusIn {
  title?: string
}
interface ChoreIn {
  title?: string
  members?: string[]
  pointValue?: number
  importance?: number
  penaltyPoints?: number
  banner?: boolean
  tags?: string[]
  // A chore can nag out loud at a time of day. Unlike a timer — which speaks
  // once and leaves nothing behind — this belongs to the chore and comes back
  // every day the chore is due, until someone does it.
  announceAt?: string      // "HH:MM" family-local
  announceUntil?: string   // "HH:MM"; stop nagging after this
  announceSpeech?: string  // exact words; omitted => the app generates them
  // The day the chore is for. Without it a one-time chore counts as due every
  // day from the moment it exists, so "create a chore for tomorrow, announce
  // at 9:30" announced itself immediately when dictated late in the evening —
  // 9:30 had already passed and the chore thought today was its day.
  dueDate?: string         // "YYYY-MM-DD"
}
interface SaleItemIn {
  category?: string
  quantity?: number
  unitPrice?: number
  lineTotal?: number
}
interface SaleIn {
  customerName?: string
  phone?: string
  email?: string
  items?: SaleItemIn[]
  tradeValue?: number
  tradeNote?: string
  fulfillmentDate?: string
  fulfillmentTime?: string
  fulfillmentMethod?: string
  notes?: string
}
interface ExpenseItemIn {
  category?: string
  quantity?: number
  unitCost?: number
}
interface ExpenseIn {
  totalCost?: number
  source?: string
  date?: string
  items?: ExpenseItemIn[]
  notes?: string
}
interface PointsIn {
  members?: string[]
  points?: number
  reason?: string
}
// "Delay announcements for 25 minutes" — one house, one mute, so one object.
// 0 lifts an active mute rather than being a no-op.
interface PrayerIn {
  content?: string
  // Names as the supplications app registers them ("Samuel", not "Sam Tran" —
  // the prayer app and the chores app keep separate rosters for the same
  // household). Omitted means a prayer for nobody in particular, which is the
  // common case: most of them are about a situation, not a person.
  members?: string[]
  verseReference?: string
}

interface PraiseIn {
  content?: string
  members?: string[]
}

interface MuteIn {
  minutes?: number
}

interface TimerIn {
  speech?: string
  title?: string
  // Exactly one of these. `at` is a local wall clock ("2026-09-29T18:30") and is
  // what a named time becomes; `inMinutes` is the relative form for "in ten
  // minutes". Absolute is preferred wherever the speaker named a clock time:
  // a delay computed here bakes in whatever "now" was at ROUTING time, so a
  // capture that waits in the queue fires that much late.
  at?: string
  inMinutes?: number
  repeat?: string
  // An interval the speaker actually named ("repeat every 20 seconds"), in
  // seconds. Takes precedence over `repeat`, which is the three-word form.
  everySeconds?: number
  // "outside" (both outdoor speakers) or "chicken" (the chicken-area speaker)
  // for a DELAYED outdoor announcement; absent for the family screen. The
  // agent sends these so the chores app holds one cancellable list of
  // everything still to be said; the M1's house-watch plays the outdoor ones.
  speaker?: 'outside' | 'chicken'
}

// How insistent a spoken timer should be. The AGENT PICKS A WORD, not a repeat
// sequence: "30,30,30,30,60,60,0" is exactly the kind of string a model gets
// subtly wrong, while the meaning ("keep after us until I stop it") is the part
// it can be trusted with. So the words are the contract and the seconds live
// here — the same division of labour as `banner:false` → `isPinned:false`.
//
// The chores app's grammar: comma-separated gaps in seconds AFTER the first
// utterance, each either 0 (stop) or >= 15; the last value repeats forever
// unless a 0 ends it. An empty string says it exactly once.
// Floor for a spoken interval. The chores app refuses anything under 15s, and
// the family screen only polls every 20s — asking it to speak twice between
// checks is not a cadence, it is a promise it cannot keep. So 20 is both the
// honest minimum and the one the screen can actually hold to.
const MIN_TIMER_INTERVAL_S = 20
const MAX_TIMER_INTERVAL_S = 3600

// A named interval repeats until someone taps "Got it", bounded so a forgotten
// one stops on its own: at 20s that is 45 announcements, which is plenty of
// chances to be heard and well short of the hour untilDismissed runs for.
const NAMED_INTERVAL_MINUTES = 15

const TIMER_REPEATS: Record<string, { repeat: string; forMinutes?: number }> = {
  // Said once, at the moment, and never again.
  once: { repeat: '' },
  // Seven tries over five minutes instead of four over two.
  insistent: { repeat: '30,30,30,30,60,60,0' },
  // No trailing 0, so it keeps going every minute — and the window has to grow
  // with it, or "until I turn it off" would quietly give up after ten minutes.
  untilDismissed: { repeat: '60', forMinutes: 60 },
  // "Urgent": as fast as the screen can honestly manage, until someone comes.
  // Same shape as a spoken 20-second interval, because that is exactly what it
  // is — a word for the cadence people actually want when it matters.
  urgent: { repeat: String(MIN_TIMER_INTERVAL_S), forMinutes: NAMED_INTERVAL_MINUTES },
}

// POST /api/agent/result — the Mac agent reports back after routing.
// Body: { captureId, transcript?, routing?, status?, error?,
//         reminders?: ReminderIn[], events?: EventIn[], thoughts?: ThoughtIn[], summary? }
export async function POST(req: Request) {
  if (!checkAgentAuth(req)) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
  if (!checkCallbackSecret(req)) return NextResponse.json({ error: 'bad callback secret' }, { status: 403 })

  const body = await req.json().catch(() => null)
  if (!body?.captureId) return NextResponse.json({ error: 'captureId required' }, { status: 400 })
  const captureId: string = body.captureId

  const capture = await db.capture.findUnique({
    where: { id: captureId },
    select: {
      id: true,
      ownerId: true,
      source: true,
      owner: { select: { capabilities: true, telegramChatId: true } },
      images: { select: { path: true }, orderBy: { createdAt: 'asc' } },
    },
  })
  if (!capture) return NextResponse.json({ error: 'capture not found' }, { status: 404 })
  // Everything this route creates belongs to whoever recorded the capture —
  // one lookup rather than per-record ownership logic.
  const ownerId = capture.ownerId
  const caps = new Set(capture.owner.capabilities)

  const status = body.status === 'FAILED' ? 'FAILED' : 'DONE'

  await db.capture.update({
    where: { id: captureId },
    data: {
      transcript: typeof body.transcript === 'string' ? body.transcript : undefined,
      routing: body.routing ?? undefined,
      error: status === 'FAILED' ? String(body.error || 'agent reported failure').slice(0, 1000) : null,
      status,
      processedAt: new Date(),
    },
  })

  if (status === 'DONE') {
    // Refine capture: the agent synthesized new info INTO an existing thought.
    // Apply the rewrite (bumps updatedAt → clients re-sync) and skip routing
    // side effects — a refine capture creates nothing else.
    const refined = body.refinedThought as { id?: string; body?: string; tags?: string[] } | undefined
    if (refined?.id && typeof refined.body === 'string' && refined.body.trim()) {
      await db.thought.update({
        where: { id: refined.id },
        data: {
          body: refined.body.trim(),
          tags: Array.isArray(refined.tags) ? refined.tags.filter((x: unknown) => typeof x === 'string') : undefined,
        },
      }).catch(() => {}) // thought may have been deleted since capture
      logActivity(ownerId, 'thought.refined', `Synthesized into thought: ${typeof body.summary === 'string' && body.summary ? body.summary : refined.body.slice(0, 80)}`)
      return NextResponse.json({ ok: true })
    }

    // Replace any items previously created from this capture (re-process safe
    // on the app side; Apple-side reconciliation is handled by the agent).
    await db.$transaction([
      db.reminder.deleteMany({ where: { sourceId: captureId } }),
      db.calendarEvent.deleteMany({ where: { sourceId: captureId } }),
      db.thought.deleteMany({ where: { sourceId: captureId } }),
      // Focus pins live on these reminders/checklists and cascade away with them.
      db.checklistItem.deleteMany({ where: { sourceId: captureId } }), // items voice-added to existing lists
      db.checklist.deleteMany({ where: { sourceId: captureId } }), // lists spoken into existence (cascades items)
    ])

    const reminders: ReminderIn[] = Array.isArray(body.reminders) ? body.reminders : []
    for (const r of reminders) {
      if (!r.title) continue
      let listId: string | undefined
      if (r.listId && r.listName) {
        const list = await db.reminderList.upsert({
          where: { externalId: r.listId },
          update: { name: r.listName },
          create: { ownerId: await appleOwnerId(), externalId: r.listId, name: r.listName },
        })
        listId = list.id
      }
      await db.reminder.create({
        data: {
          ownerId,
          externalId: r.externalId || null,
          listId,
          title: r.title,
          notes: r.notes || null,
          dueDate: toDate(r.dueDate),
          priority: typeof r.priority === 'number' ? r.priority : null,
          sourceId: captureId,
          lastSyncedAt: new Date(),
        },
      })
    }

    const events: EventIn[] = Array.isArray(body.events) ? body.events : []
    for (const e of events) {
      const start = toDate(e.startsAt)
      if (!e.title || !start) continue
      await db.calendarEvent.create({
        data: {
          ownerId,
          externalId: e.externalId || null,
          calendar: e.calendarName || null,
          title: e.title,
          notes: e.notes || null,
          location: e.location || null,
          startsAt: start,
          endsAt: toDate(e.endsAt),
          allDay: !!e.allDay,
          recurrence: recurrenceLabel(e.recurrence),
          sourceId: captureId,
          lastSyncedAt: new Date(),
        },
      })
    }

    const thoughts: ThoughtIn[] = Array.isArray(body.thoughts) ? body.thoughts : []
    for (const t of thoughts) {
      if (!t.body) continue
      await db.thought.create({
        data: {
          ownerId,
          body: t.body,
          tags: Array.isArray(t.tags) ? t.tags.filter(x => typeof x === 'string') : [],
          sourceId: captureId,
        },
      })
    }

    // Special lists spoken into existence ("make me a camping list with…").
    const checklists: { name?: string; items?: string[] }[] = Array.isArray(body.checklists) ? body.checklists : []
    for (const c of checklists) {
      if (!c?.name) continue
      const titles = (Array.isArray(c.items) ? c.items : []).filter((x): x is string => typeof x === 'string' && !!x.trim())
      await db.checklist.create({
        data: {
          ownerId,
          name: c.name,
          sourceId: captureId,
          items: { create: titles.map(t => ({ title: t.trim(), sourceId: captureId })) },
        },
      })
    }

    // Items voice-added to an existing special list ("add bug spray to the
    // camping list"). Unknown list names get created rather than dropped.
    const checklistAdd: { list?: string; items?: string[] }[] = Array.isArray(body.checklistAdd) ? body.checklistAdd : []
    let addedItems = 0
    for (const a of checklistAdd) {
      if (!a?.list) continue
      const titles = (Array.isArray(a.items) ? a.items : []).filter((x): x is string => typeof x === 'string' && !!x.trim())
      if (titles.length === 0) continue
      const list =
        (await db.checklist.findFirst({ where: { dismissed: false, name: { equals: a.list, mode: 'insensitive' } } })) ??
        (await db.checklist.create({ data: { ownerId, name: a.list, sourceId: captureId } }))
      await db.checklistItem.createMany({ data: titles.map(t => ({ listId: list.id, title: t.trim(), sourceId: captureId })) })
      addedItems += titles.length
    }

    // Captures routed to Focus ("add to focus list"): Focus owns nothing, so
    // each becomes a real reminder (synced to Apple) that's pinned to the board.
    const focus: FocusIn[] = Array.isArray(body.focus) ? body.focus : []
    for (const f of focus) {
      if (!f.title) continue
      const r = await db.reminder.create({
        data: { ownerId, title: f.title, sourceId: captureId, syncState: 'PENDING_PUSH', pendingAction: 'create' },
        select: { id: true },
      })
      await db.focusPin.create({ data: { ownerId, reminderId: r.id } })
    }
    if (focus.length > 0) triggerAgent('push').catch(() => {}) // push the new focus reminders to Apple

    // Chores routed to the kids' chores app (a separate service on the same VPS).
    // We forward each to its bearer-token external API — the same endpoint
    // quailmanager uses. That endpoint applies the shared defaults: one-time,
    // pinned banner, shared/up-for-grabs across all kids, 1 point unless given —
    // so we send only the title plus any explicit overrides. Fire-and-forget:
    // a chores-app hiccup must not fail the capture (its other items persisted).
    // Forward failures across chores/sales/points, surfaced in the capture detail
    // so a dropped item doesn't hide behind the agent's optimistic summary.
    const fwdFailures: FwdFailure[] = []

    const capturePhotos = capture.images.map(i => i.path)

    const chores: ChoreIn[] = gateCapability<ChoreIn>(caps.has('chores'), body.chores, 'chore', ch => (ch.title || '').trim() || 'chore', fwdFailures)
    const choresCreatedItems: ChoreIn[] = []
    if (chores.length > 0) {
      const url = process.env.CHORES_API_URL
      const secret = process.env.CHORES_API_SECRET
      if (!url || !secret) {
        console.error('[agent/result] chores requested but CHORES_API_URL/CHORES_API_SECRET not set')
        for (const ch of chores) if (ch.title?.trim()) fwdFailures.push({ kind: 'chore', label: ch.title.trim(), reason: 'chores app not configured' })
      } else {
        for (const ch of chores) {
          if (!ch.title?.trim()) continue
          try {
            const res = await fetch(url, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` },
              body: JSON.stringify({
                title: ch.title.trim(),
                // announceOn is implied by naming a time — the app rejects one
                // without the other, so never send a half-configured chore.
                ...(typeof ch.announceAt === 'string' && /^\d{2}:\d{2}$/.test(ch.announceAt)
                  ? {
                      announceOn: true,
                      announceAt: ch.announceAt,
                      ...(typeof ch.announceUntil === 'string' && /^\d{2}:\d{2}$/.test(ch.announceUntil)
                        ? { announceUntil: ch.announceUntil }
                        : {}),
                      ...(ch.announceSpeech?.trim() ? { announceSpeech: ch.announceSpeech.trim() } : {}),
                    }
                  : {}),
                // The day it is for. hideUntilDue rides along only when the
                // chore also speaks: a silent chore with a deadline can sit on
                // the board early and be done ahead of time, which is the
                // app's own stance on due dates, but one that calls out must
                // not call out before its day.
                ...(typeof ch.dueDate === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(ch.dueDate)
                  ? {
                      dueDate: ch.dueDate,
                      ...(typeof ch.announceAt === 'string' && /^\d{2}:\d{2}$/.test(ch.announceAt)
                        ? { hideUntilDue: true }
                        : {}),
                    }
                  : {}),
                // Names resolved to kids in the chores app; omitted => all kids.
                ...(Array.isArray(ch.members) && ch.members.length ? { memberNames: ch.members } : {}),
                ...(typeof ch.pointValue === 'number' ? { pointValue: ch.pointValue } : {}),
                ...(typeof ch.importance === 'number' ? { importance: ch.importance } : {}),
                // Penalty only fires on mandatory chores, so set both together.
                ...(typeof ch.penaltyPoints === 'number' && ch.penaltyPoints > 0
                  ? { penaltyPoints: ch.penaltyPoints, isMandatory: true }
                  : {}),
                // Chores default to a pinned dashboard banner; only send the
                // override when the user explicitly asked for no banner.
                ...(ch.banner === false ? { isPinned: false } : {}),
                // Category tag(s) the agent matched to the chores app's tags.
                ...(Array.isArray(ch.tags) && ch.tags.length ? { tagNames: ch.tags } : {}),
              }),
            })
            if (res.ok) {
              choresCreatedItems.push({
                title: ch.title.trim(),
                members: ch.members,
                pointValue: ch.pointValue,
                penaltyPoints: ch.penaltyPoints,
                ...(ch.announceAt ? { announceAt: ch.announceAt } : {}),
              })
              // Photos ride along to every chore this capture produced: one
              // picture of the thing usually explains all of them.
              if (capturePhotos.length > 0) {
                const created = await res.json().catch(() => null)
                const choreId = Number(created?.id)
                if (Number.isInteger(choreId)) {
                  const problem = await attachChorePhotos(choreId, capturePhotos, url, secret).catch(() => 'request failed')
                  if (problem) fwdFailures.push({ kind: 'chore', label: `photo for “${ch.title.trim()}”`, reason: problem })
                }
              }
            } else fwdFailures.push({ kind: 'chore', label: ch.title.trim(), reason: await fwdErr(res) })
          } catch (e) {
            fwdFailures.push({ kind: 'chore', label: ch.title.trim(), reason: 'request failed' })
            console.error(`[agent/result] chores API request failed for "${ch.title}":`, e)
          }
        }
      }
    }
    const choresCreated = choresCreatedItems.length

    // Sales routed to the FlockBase/quailmanager farm-sales app (separate service
    // on the same VPS). We forward each to its bearer-token external API, which
    // resolves preset category prices and owns the tenant. Same fire-and-forget
    // contract as chores: a FlockBase hiccup must not fail the capture.
    const sales: SaleIn[] = gateCapability<SaleIn>(caps.has('sales'), body.sales, 'sale', sale => (sale.customerName || '').trim() || 'sale (no name)', fwdFailures)
    const salesCreatedItems: SaleIn[] = []
    if (sales.length > 0) {
      const url = process.env.QUAIL_SALES_API_URL
      const secret = process.env.QUAIL_SALES_API_SECRET
      if (!url || !secret) {
        console.error('[agent/result] sales requested but QUAIL_SALES_API_URL/QUAIL_SALES_API_SECRET not set')
        for (const sale of sales) fwdFailures.push({ kind: 'sale', label: (sale.customerName || 'sale').trim() || 'sale', reason: 'FlockBase not configured' })
      } else {
        for (const sale of sales) {
          const items = (Array.isArray(sale.items) ? sale.items : [])
            .filter(it => it?.category && typeof it.quantity === 'number' && it.quantity > 0)
            .map(it => ({
              category: it.category!.trim(),
              quantity: it.quantity!,
              // Omit unitPrice unless the user gave an explicit override → FlockBase fills the preset.
              ...(typeof it.unitPrice === 'number' ? { unitPrice: it.unitPrice } : {}),
              // A stated whole-line total ("$50 for 120 balut") rides as lineTotal so
              // FlockBase bills exactly that: a per-unit price derived from it would
              // round to cents and drift ($50/120 → $0.42 → $50.40).
              ...(typeof it.lineTotal === 'number' ? { lineTotal: it.lineTotal } : {}),
            }))
          if (items.length === 0) {
            // A sale with no usable line item (no quantity given) can't be created
            // in FlockBase. Surface it so the optimistic summary doesn't hide it.
            fwdFailures.push({ kind: 'sale', label: (sale.customerName || '').trim() || 'sale (no name)', reason: 'no quantity/item given — add a count and re-process' })
            continue
          }
          try {
            const res = await fetch(url, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` },
              body: JSON.stringify({
                customerName: (sale.customerName || '').trim(),
                ...(sale.phone ? { phone: sale.phone } : {}),
                ...(sale.email ? { email: sale.email } : {}),
                items,
                // Trade/barter value fills FlockBase's Trade field so a "fully traded"
                // sale settles instead of sitting with an open balance.
                ...(typeof sale.tradeValue === 'number' && sale.tradeValue > 0
                  ? { tradeValue: sale.tradeValue, ...(sale.tradeNote ? { tradeNote: sale.tradeNote } : {}) }
                  : {}),
                ...(sale.fulfillmentDate ? { fulfillmentDate: sale.fulfillmentDate } : {}),
                ...(sale.fulfillmentTime ? { fulfillmentTime: sale.fulfillmentTime } : {}),
                ...(sale.fulfillmentMethod ? { fulfillmentMethod: sale.fulfillmentMethod } : {}),
                ...(sale.notes ? { notes: sale.notes } : {}),
              }),
            })
            if (res.ok) {
              salesCreatedItems.push({ ...sale, items })
            } else {
              fwdFailures.push({ kind: 'sale', label: (sale.customerName || '').trim() || 'sale (no name)', reason: await fwdErr(res) })
            }
          } catch (e) {
            fwdFailures.push({ kind: 'sale', label: (sale.customerName || '').trim() || 'sale (no name)', reason: 'request failed' })
            console.error(`[agent/result] sales API request failed for "${sale.customerName}":`, e)
          }
        }
      }
    }
    const salesCreated = salesCreatedItems.length

    // Expenses routed to the FlockBase/quailmanager farm app (separate service on
    // the same VPS). Forwarded to its bearer-token external API (same secret as
    // sales), which maps categories and owns the tenant. Same fire-and-forget
    // contract: a FlockBase hiccup must not fail the capture.
    const expenses: ExpenseIn[] = gateCapability<ExpenseIn>(caps.has('expenses'), body.expenses, 'expense', exp => (exp.source || '').trim() || 'expense', fwdFailures)
    const expensesCreatedItems: ExpenseIn[] = []
    if (expenses.length > 0) {
      const url = process.env.QUAIL_EXPENSE_API_URL
      const secret = process.env.QUAIL_EXPENSE_API_SECRET || process.env.QUAIL_SALES_API_SECRET
      if (!url || !secret) {
        console.error('[agent/result] expenses requested but QUAIL_EXPENSE_API_URL/secret not set')
        for (const exp of expenses) fwdFailures.push({ kind: 'expense', label: (exp.source || 'expense').trim() || 'expense', reason: 'FlockBase not configured' })
      } else {
        for (const exp of expenses) {
          const items = (Array.isArray(exp.items) ? exp.items : [])
            .filter(it => it?.category)
            .map(it => ({
              category: it.category!.trim(),
              ...(typeof it.quantity === 'number' && it.quantity > 0 ? { quantity: it.quantity } : {}),
              ...(typeof it.unitCost === 'number' ? { unitCost: it.unitCost } : {}),
            }))
          if (items.length === 0) continue
          const label = (exp.source || '').trim() || (typeof exp.totalCost === 'number' ? `$${exp.totalCost} expense` : 'expense')
          try {
            const res = await fetch(url, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` },
              body: JSON.stringify({
                ...(typeof exp.totalCost === 'number' ? { totalCost: exp.totalCost } : {}),
                ...(exp.source ? { source: exp.source } : {}),
                ...(exp.date ? { date: exp.date } : {}),
                items,
                ...(exp.notes ? { notes: exp.notes } : {}),
              }),
            })
            if (res.ok) expensesCreatedItems.push({ ...exp, items })
            else fwdFailures.push({ kind: 'expense', label, reason: await fwdErr(res) })
          } catch (e) {
            fwdFailures.push({ kind: 'expense', label, reason: 'request failed' })
            console.error(`[agent/result] expenses API request failed for "${label}":`, e)
          }
        }
      }
    }
    const expensesCreated = expensesCreatedItems.length

    // Points awarded/deducted to a kid in the chores app (e.g. "add 1 point to
    // Abel for helping clean the garage"). Forwarded to the chores points API
    // (same bearer secret as chores). Fire-and-forget like chores/sales.
    const points: PointsIn[] = gateCapability<PointsIn>(caps.has('points'), body.points, 'points', pt => `${pt.points ?? 0} pt → ${(pt.members || []).join(', ') || 'someone'}`, fwdFailures)
    const pointsCreatedItems: PointsIn[] = []
    if (points.length > 0) {
      const url = process.env.CHORES_POINTS_API_URL
      const secret = process.env.CHORES_API_SECRET
      if (!url || !secret) {
        console.error('[agent/result] points requested but CHORES_POINTS_API_URL/CHORES_API_SECRET not set')
        for (const p of points) {
          const members = (Array.isArray(p.members) ? p.members : []).filter((m): m is string => typeof m === 'string' && !!m.trim())
          if (members.length) fwdFailures.push({ kind: 'points', label: `${p.points} pt → ${members.join(', ')}`, reason: 'chores app not configured' })
        }
      } else {
        for (const p of points) {
          const members = (Array.isArray(p.members) ? p.members : []).filter((m): m is string => typeof m === 'string' && !!m.trim())
          if (members.length === 0 || typeof p.points !== 'number' || p.points === 0) continue
          try {
            const res = await fetch(url, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` },
              body: JSON.stringify({
                memberNames: members,
                points: p.points,
                ...(p.reason ? { reason: p.reason } : {}),
              }),
            })
            if (res.ok) pointsCreatedItems.push({ members, points: p.points, reason: p.reason })
            else fwdFailures.push({ kind: 'points', label: `${p.points} pt → ${members.join(', ')}`, reason: await fwdErr(res) })
          } catch (e) {
            fwdFailures.push({ kind: 'points', label: `${p.points} pt → ${members.join(', ')}`, reason: 'request failed' })
            console.error(`[agent/result] points API request failed for ${members.join(', ')}:`, e)
          }
        }
      }
    }
    const pointsCreated = pointsCreatedItems.length

    // Spoken timers on the kids' family screen (e.g. "set a timer for five
    // minutes to turn off the pool water"). The chores app owns the audible
    // half of this: an always-on screen polls it and speaks with the browser's
    // speech synthesis, repeating a few times in case the room missed it.
    //
    // `inMinutes` goes over as-is rather than being resolved to a clock time
    // here. Three timezones touch this path — the Mac agent is Pacific, this
    // VPS runs Mountain, the family's screen is Pacific — and only the chores
    // app knows the family's own clock, so it does the arithmetic.
    const timers: TimerIn[] = gateCapability<TimerIn>(caps.has('timers'), body.timers, 'timer', t => (t.title || t.speech || 'timer').trim() || 'timer', fwdFailures)
    const timersCreatedItems: TimerIn[] = []
    if (timers.length > 0) {
      const url = process.env.CHORES_TIMERS_API_URL
      const secret = process.env.CHORES_API_SECRET
      const label = (t: TimerIn) => {
        const name = t.title?.trim() || t.speech?.trim() || 'timer'
        if (typeof t.at === 'string' && t.at.trim()) return `${name} at ${t.at.trim().replace('T', ' ')}`
        return `${name}${typeof t.inMinutes === 'number' ? ` in ${t.inMinutes}m` : ''}`
      }
      if (!url || !secret) {
        console.error('[agent/result] timers requested but CHORES_TIMERS_API_URL/CHORES_API_SECRET not set')
        for (const t of timers) if (t.speech?.trim()) fwdFailures.push({ kind: 'timer', label: label(t), reason: 'chores app not configured' })
      } else {
        for (const t of timers) {
          const speech = typeof t.speech === 'string' ? t.speech.trim() : ''
          if (!speech) continue
          const at = typeof t.at === 'string' && t.at.trim() ? t.at.trim() : null
          const inMinutes = typeof t.inMinutes === 'number' && Number.isFinite(t.inMinutes)
            ? Math.max(0, Math.round(t.inMinutes))
            : 0
          // An unrecognised word falls through to the chores app's own default
          // rather than being guessed at: a timer that says its piece four
          // times is never the wrong answer, but a silent one might be.
          // An explicitly spoken interval wins over the three-word form: the
          // speaker was more specific than the vocabulary.
          const spoken = typeof t.everySeconds === 'number' && Number.isFinite(t.everySeconds)
            ? Math.min(MAX_TIMER_INTERVAL_S, Math.max(MIN_TIMER_INTERVAL_S, Math.round(t.everySeconds)))
            : null
          const cadence = spoken !== null
            ? { repeat: String(spoken), forMinutes: NAMED_INTERVAL_MINUTES }
            : typeof t.repeat === 'string' ? TIMER_REPEATS[t.repeat] : undefined
          const speaker = t.speaker === 'outside' || t.speaker === 'chicken' ? t.speaker : undefined
          try {
            const res = await fetch(url, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` },
              body: JSON.stringify({
                speech,
                ...(t.title?.trim() ? { title: t.title.trim() } : {}),
                // The chores app resolves `at` against the family timezone and
                // ignores inMinutes when it is present.
                ...(at ? { at } : { inMinutes }),
                ...(cadence ? { repeat: cadence.repeat } : {}),
                ...(cadence?.forMinutes ? { forMinutes: cadence.forMinutes } : {}),
                ...(speaker ? { speaker } : {}),
                source: 'steward',
              }),
            })
            if (res.ok) timersCreatedItems.push({
              speech,
              title: t.title?.trim(),
              ...(at ? { at } : { inMinutes }),
              ...(spoken !== null ? { everySeconds: spoken } : cadence ? { repeat: t.repeat } : {}),
              ...(speaker ? { speaker } : {}),
            })
            else fwdFailures.push({ kind: 'timer', label: label(t), reason: await fwdErr(res) })
          } catch (e) {
            fwdFailures.push({ kind: 'timer', label: label(t), reason: 'request failed' })
            console.error(`[agent/result] timers API request failed for "${speech}":`, e)
          }
        }
      }
    }
    const timersCreated = timersCreatedItems.length

    // ── mute: "delay announcements for 25 minutes" ──────────────────────────
    //
    // One object, not an array: there is a single house to quieten, and two
    // "delay for N" in one breath is a contradiction rather than two requests.
    // Gated with timers — it steers the same family screen.
    //
    // 0 minutes is meaningful: it clears a mute ("never mind, go ahead"), which
    // is why the gate below tests for the key rather than for a truthy number.
    const muteIn = body.mute && typeof body.mute === 'object' ? (body.mute as MuteIn) : null
    const muteMinutes = muteIn && typeof muteIn.minutes === 'number' && Number.isFinite(muteIn.minutes)
      ? Math.min(24 * 60, Math.max(0, Math.round(muteIn.minutes)))
      : null
    // Prayers routed to the supplications app (prayer.etpics.com), the family's
    // prayer tracker. Same fire-and-forget contract as the rest.
    //
    // They land in the QUEUE, with no session attached — which is the whole
    // point. The next prayer session to start sweeps every queued topic into
    // itself, so "add a prayer for Samuel's test" spoken on a Tuesday simply
    // shows up when the family next sits down together. Nobody has to open the
    // app, and nothing has to guess which session it belonged to.
    const prayers: PrayerIn[] = gateCapability<PrayerIn>(caps.has('prayers'), body.prayers, 'prayer', pr => (pr.content || '').trim() || 'prayer', fwdFailures)
    const prayersCreatedItems: PrayerIn[] = []
    if (prayers.length > 0) {
      const url = process.env.SUPPLICATIONS_PRAYERS_API_URL
      const secret = process.env.SUPPLICATIONS_API_SECRET
      if (!url || !secret) {
        console.error('[agent/result] prayers requested but SUPPLICATIONS_PRAYERS_API_URL/SUPPLICATIONS_API_SECRET not set')
        for (const pr of prayers) if (pr.content?.trim()) fwdFailures.push({ kind: 'prayer', label: pr.content.trim(), reason: 'prayer app not configured' })
      } else {
        for (const pr of prayers) {
          if (!pr.content?.trim()) continue
          try {
            const res = await fetch(url, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` },
              body: JSON.stringify({
                content: pr.content.trim(),
                // Resolved to members on the prayer app's side, which owns the
                // roster. A name it cannot place is dropped there rather than
                // rejected: a prayer attached to nobody still gets prayed.
                ...(Array.isArray(pr.members) && pr.members.length ? { memberNames: pr.members } : {}),
                ...(pr.verseReference?.trim() ? { verseReference: pr.verseReference.trim() } : {}),
              }),
            })
            if (res.ok) {
              const created = await res.json().catch(() => null)
              // Report back the members the app ACTUALLY matched, not the ones
              // the router guessed — otherwise the capture detail would claim a
              // prayer was attached to someone the prayer app never found.
              const matched = Array.isArray(created?.topic?.members)
                ? created.topic.members.map((m: { name?: string }) => m?.name).filter((n: unknown): n is string => typeof n === 'string' && !!n)
                : []
              prayersCreatedItems.push({
                content: pr.content.trim(),
                ...(matched.length ? { members: matched } : {}),
                ...(pr.verseReference?.trim() ? { verseReference: pr.verseReference.trim() } : {}),
              })
            } else fwdFailures.push({ kind: 'prayer', label: pr.content.trim(), reason: await fwdErr(res) })
          } catch (e) {
            fwdFailures.push({ kind: 'prayer', label: pr.content.trim(), reason: 'request failed' })
            console.error(`[agent/result] prayers API request failed for "${pr.content}":`, e)
          }
        }
      }
    }
    const prayersCreated = prayersCreatedItems.length

    // Praise reports, the other half of a prayer session. Same app, same queue,
    // same next-session sweep — and gated on the SAME `prayers` capability
    // rather than one of its own: the capability is "may write to the prayer
    // app", and an account allowed to add a request has no reason to be barred
    // from adding a thanksgiving.
    const praises: PraiseIn[] = gateCapability<PraiseIn>(caps.has('prayers'), body.praises, 'praise', pr => (pr.content || '').trim() || 'praise', fwdFailures)
    const praisesCreatedItems: PraiseIn[] = []
    if (praises.length > 0) {
      const url = process.env.SUPPLICATIONS_PRAISE_API_URL
      const secret = process.env.SUPPLICATIONS_API_SECRET
      if (!url || !secret) {
        console.error('[agent/result] praise requested but SUPPLICATIONS_PRAISE_API_URL/SUPPLICATIONS_API_SECRET not set')
        for (const pr of praises) if (pr.content?.trim()) fwdFailures.push({ kind: 'praise', label: pr.content.trim(), reason: 'prayer app not configured' })
      } else {
        for (const pr of praises) {
          if (!pr.content?.trim()) continue
          try {
            const res = await fetch(url, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` },
              body: JSON.stringify({
                content: pr.content.trim(),
                ...(Array.isArray(pr.members) && pr.members.length ? { memberNames: pr.members } : {}),
              }),
            })
            if (res.ok) {
              const created = await res.json().catch(() => null)
              const matched = Array.isArray(created?.praise?.members)
                ? created.praise.members.map((m: { name?: string }) => m?.name).filter((n: unknown): n is string => typeof n === 'string' && !!n)
                : []
              praisesCreatedItems.push({
                content: pr.content.trim(),
                ...(matched.length ? { members: matched } : {}),
              })
            } else fwdFailures.push({ kind: 'praise', label: pr.content.trim(), reason: await fwdErr(res) })
          } catch (e) {
            fwdFailures.push({ kind: 'praise', label: pr.content.trim(), reason: 'request failed' })
            console.error(`[agent/result] praise API request failed for "${pr.content}":`, e)
          }
        }
      }
    }
    const praisesCreated = praisesCreatedItems.length

    let muteApplied: { minutes: number } | null = null
    if (muteMinutes !== null) {
      const label = muteMinutes === 0 ? 'resume announcements' : `delay announcements ${muteMinutes}m`
      if (!caps.has('timers')) {
        fwdFailures.push({ kind: 'timer', label, reason: 'not enabled for this account' })
      } else {
        const url = process.env.CHORES_MUTE_API_URL
        const secret = process.env.CHORES_API_SECRET
        if (!url || !secret) {
          console.error('[agent/result] mute requested but CHORES_MUTE_API_URL/CHORES_API_SECRET not set')
          fwdFailures.push({ kind: 'timer', label, reason: 'chores app not configured' })
        } else {
          try {
            const res = await fetch(url, {
              method: 'POST',
              headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${secret}` },
              // includeTimers is left to the chores app's default (true for a
              // spoken delay): someone asking out loud for quiet means every
              // voice on that screen, timers included.
              body: JSON.stringify({ minutes: muteMinutes }),
            })
            if (res.ok) muteApplied = { minutes: muteMinutes }
            else fwdFailures.push({ kind: 'timer', label, reason: await fwdErr(res) })
          } catch (e) {
            fwdFailures.push({ kind: 'timer', label, reason: 'request failed' })
            console.error('[agent/result] mute API request failed:', e)
          }
        }
      }
    }

    // One consolidated routing write: replace the agent's intended chores/sales/
    // points with what ACTUALLY landed, and record any forward failures so the
    // capture detail can flag them instead of the summary implying success.
    // fwdFailures matters on its own: a capability-blocked account has all five
    // arrays emptied by the gate, so keying the write off their length alone
    // would drop the very explanation the capture detail needs to show.
    if (fwdFailures.length > 0 || chores.length > 0 || sales.length > 0 || expenses.length > 0 || points.length > 0 || timers.length > 0 || prayers.length > 0 || praises.length > 0 || muteMinutes !== null) {
      const baseRouting = body.routing && typeof body.routing === 'object' ? (body.routing as Record<string, unknown>) : {}
      const nextRouting = JSON.parse(JSON.stringify({
        ...baseRouting,
        chores: choresCreatedItems,
        sales: salesCreatedItems,
        expenses: expensesCreatedItems,
        points: pointsCreatedItems,
        timers: timersCreatedItems,
        prayers: prayersCreatedItems,
        praises: praisesCreatedItems,
        ...(muteApplied ? { mute: muteApplied } : {}),
        ...(fwdFailures.length ? { failures: fwdFailures } : {}),
      }))
      await db.capture.update({
        where: { id: captureId },
        data: { routing: nextRouting },
      }).catch(() => {})
    }

    // New capture-created items (thoughts + lists) should get grouped without
    // waiting for a manual Organize.
    if (thoughts.length > 0 || checklists.length > 0 || addedItems > 0) scheduleOrganize()

    // Voice-driven completion of EXISTING reminders (matched by externalId).
    const completed: { externalId?: string }[] = Array.isArray(body.completed) ? body.completed : []
    for (const c of completed) {
      if (!c.externalId) continue
      await db.reminder.updateMany({
        where: { externalId: c.externalId },
        data: { completed: true, lastSyncedAt: new Date() },
      })
    }

    // Voice-driven update (reschedule/edit) of EXISTING reminders.
    const updated: ReminderIn[] = Array.isArray(body.updated) ? body.updated : []
    for (const u of updated) {
      if (!u.externalId) continue
      let listId: string | undefined
      if (u.listId && u.listName) {
        const list = await db.reminderList.upsert({
          where: { externalId: u.listId }, update: { name: u.listName },
          create: { ownerId: await appleOwnerId(), externalId: u.listId, name: u.listName },
        })
        listId = list.id
      }
      const data: Record<string, unknown> = { lastSyncedAt: new Date() }
      if (u.title) data.title = u.title
      if (u.notes !== undefined) data.notes = u.notes
      if (typeof u.priority === 'number') data.priority = u.priority
      if (u.dueDate !== undefined) data.dueDate = toDate(u.dueDate)
      if (listId) data.listId = listId
      await db.reminder.updateMany({ where: { externalId: u.externalId }, data })
    }

    // Voice-driven deletion of EXISTING reminders.
    const deleted: { externalId?: string }[] = Array.isArray(body.deleted) ? body.deleted : []
    for (const dItem of deleted) {
      if (!dItem.externalId) continue
      await db.reminder.deleteMany({ where: { externalId: dItem.externalId } })
    }

    const counts = [
      reminders.length && `${reminders.length} reminder${reminders.length === 1 ? '' : 's'}`,
      events.length && `${events.length} event${events.length === 1 ? '' : 's'}`,
      thoughts.length && `${thoughts.length} thought${thoughts.length === 1 ? '' : 's'}`,
      focus.length && `${focus.length} focus`,
      choresCreated && `${choresCreated} chore${choresCreated === 1 ? '' : 's'}`,
      salesCreated && `${salesCreated} sale${salesCreated === 1 ? '' : 's'}`,
      expensesCreated && `${expensesCreated} expense${expensesCreated === 1 ? '' : 's'}`,
      pointsCreated && `${pointsCreated} point adjustment${pointsCreated === 1 ? '' : 's'}`,
      timersCreated && `${timersCreated} timer${timersCreated === 1 ? '' : 's'}`,
      prayersCreated && `${prayersCreated} prayer${prayersCreated === 1 ? '' : 's'}`,
      praisesCreated && `${praisesCreated} praise${praisesCreated === 1 ? '' : 's'}`,
      fwdFailures.length && `${fwdFailures.length} forward failure${fwdFailures.length === 1 ? '' : 's'}`,
      checklists.length && `${checklists.length} list${checklists.length === 1 ? '' : 's'}`,
      addedItems && `${addedItems} list item${addedItems === 1 ? '' : 's'}`,
      completed.length && `${completed.length} completed`,
      updated.length && `${updated.length} updated`,
      deleted.length && `${deleted.length} deleted`,
    ].filter(Boolean).join(', ')
    logActivity(ownerId, 'capture.processed', `Capture processed${counts ? ` (${counts})` : ''}: ${typeof body.summary === 'string' && body.summary ? body.summary : '(no summary)'}`)

    // Tell Jarvis what happened. This is the loop the app could never close:
    // you spoke, walked away, and the only way to learn the outcome was to
    // open the capture. Counts come from what ACTUALLY landed, not the
    // router's summary — the summary says what it meant to do.
    //
    // Fire-and-forget on purpose: the capture is already saved and every
    // record already created, so a Telegram outage must not fail this request
    // or make the agent retry work it has finished.
    const spokenSummary = typeof body.summary === 'string' && body.summary ? body.summary : null
    // A question, not something to file. The router emits an answer and nothing
    // else, so there are no counts to report — the reply IS the answer.
    const answer = typeof body.answer === 'string' && body.answer.trim() ? body.answer.trim() : null
    const headline = answer ?? (counts ? `✅ ${counts}` : '✅ Nothing to create')

    // Report back to WHERE IT WAS ASKED, not everywhere. Asking in Telegram
    // and being answered in the app is a split-brain; answering in both is
    // noise you learn to ignore, which is worse than either.
    const replyTelegram = async (): Promise<boolean> => {
      const chat = capture.owner.telegramChatId
      if (!chat) return false
      const lines = [headline]
      // The summary describes filing work; when the capture was a question
      // there was none, and repeating "logged a question" under the answer
      // just makes it look like something went wrong.
      if (!answer && spokenSummary) lines.push(spokenSummary)
      if (fwdFailures.length) {
        lines.push('')
        for (const f of fwdFailures) lines.push(`⚠️ ${f.label} — ${f.reason}`)
      }
      // No link on a plain answer — it is the whole reply, and a capture page
      // showing nothing created is a worse place to land than the message.
      if (!answer) {
        lines.push('')
        lines.push(`https://steward.etpics.com/capture/${captureId}`)
      }
      return sendTelegram(lines.join('\n'), chat)
    }

    const replyPush = async (): Promise<boolean> => {
      const sent = await pushToUser(ownerId, {
        // An answer can run long; a notification title cannot. Lead with a
        // marker and let the body carry it.
        title: answer ? 'Jarvis' : headline,
        body: answer ?? ([spokenSummary, fwdFailures.length ? `⚠️ ${fwdFailures.length} couldn't be done` : null]
          .filter(Boolean).join(' · ') || 'Capture processed'),
        url: `/capture/${captureId}`,
        // Tagged by capture id, so re-processing replaces the earlier
        // notification rather than leaving two to dismiss.
        tag: `capture-${captureId}`,
      })
      return sent > 0
    }

    // Fall back to the other channel only when the first delivered nothing —
    // no Telegram chat mapped, or no device subscribed. Silence is the one
    // outcome worse than a notification in the wrong place.
    const [first, second] = capture.source === 'TELEGRAM'
      ? [replyTelegram, replyPush]
      : [replyPush, replyTelegram]
    void (async () => {
      try {
        if (!(await first())) await second()
      } catch (e) {
        console.error(`[capture ${captureId}] reply failed:`, e)
      }
    })()
  }

  return NextResponse.json({ ok: true })
}
