'use client'

import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { ChevronLeft, HeartPulse, Loader2, CheckCircle2, AlertTriangle, AlertOctagon, CloudOff, ChevronRight, Mail, Mic, Square, Stethoscope, Wrench, BellOff, Send, MessageCircleQuestion } from 'lucide-react'
import { MetricChart } from '@/components/metric-chart'

interface DigestRow {
  id: string
  kind: string
  title: string
  summary: string
  body: string
  createdAt: string
  read: boolean
  notable: boolean
  replies: DigestReplyRow[]
}

// How many digests the card shows before collapsing. Three a day means this is
// roughly "today", which is the span anyone actually re-reads.
const DIGESTS_SHOWN = 3

interface DigestReplyRow {
  id: string
  prompt: string
  status: string
  reply: string | null
  error: string | null
  createdAt: string
}

type Status = 'OK' | 'WARN' | 'CRIT'
type Severity = Status | 'STALE'

interface Check {
  name: string
  label: string
  status: Status
  value: number | null
  unit: string | null
  detail: string
  since: string
  ageSec: number
  meta?: unknown
  // True when the check is healthy AND found something worth seeing.
  hit?: boolean
  acked?: { at: string; note: string | null } | null
}

// A container as the agent reports it.
interface ContainerRow { name: string; state: string; status: string; healthy: boolean }
interface DbRow { name: string; reachable: boolean }
// A camera as the SecuritySpy agent reports it. `retired` is a folder left behind
// by a camera that no longer exists — listed, but not counted against anything.
interface CameraRow { name: string; state: 'recording' | 'stale' | 'missing' | 'retired'; detail: string }

interface Host {
  host: string
  label: string
  status: Severity
  stale: boolean
  reportingAgeSec: number | null
  problems: number
  checks: Check[]
}

const TONE: Record<Severity, { icon: typeof CheckCircle2; text: string; dot: string }> = {
  OK: { icon: CheckCircle2, text: 'text-emerald-600', dot: 'bg-emerald-500' },
  WARN: { icon: AlertTriangle, text: 'text-amber-600', dot: 'bg-amber-500' },
  CRIT: { icon: AlertOctagon, text: 'text-destructive', dot: 'bg-destructive' },
  STALE: { icon: CloudOff, text: 'text-destructive', dot: 'bg-destructive' },
}

function ago(sec: number): string {
  if (sec < 90) return `${sec}s ago`
  const m = Math.round(sec / 60)
  if (m < 90) return `${m} min ago`
  const h = Math.round(m / 60)
  if (h < 48) return `${h}h ago`
  return `${Math.round(h / 24)}d ago`
}

// "how long has it been this way" — the number that tells you whether a warning
// is new or has been quietly true for a week.
function sinceLabel(iso: string): string {
  return ago(Math.round((Date.now() - new Date(iso).getTime()) / 1000)).replace(' ago', '')
}

function bytes(n: number): string {
  const u = ['B', 'K', 'M', 'G', 'T']
  let i = 0
  while (n >= 1024 && i < u.length - 1) { n /= 1024; i++ }
  return `${n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)}${u[i]}`
}

const asRec = (m: unknown): Record<string, unknown> =>
  (m && typeof m === 'object' && !Array.isArray(m) ? m as Record<string, unknown> : {})

// Does this check carry a payload worth rendering as key/values?
function hasMeta(c: Check): boolean {
  const m = asRec(c.meta)
  if (Array.isArray(m.containers) && m.containers.length) return true
  if (Array.isArray(m.databases) && m.databases.length) return true
  if (Array.isArray(m.recent) && m.recent.length) return true
  return Object.keys(m).some(k => !['up'].includes(k) && m[k] != null && m[k] !== '' &&
    !(Array.isArray(m[k]) && (m[k] as unknown[]).length === 0))
}

// Does this check carry anything worth opening? Rows that don't stay unclickable
// rather than expanding into an empty box.
//
// A number now counts on its own: since history started being recorded, a row
// like Disk with a thin payload still has a chart behind it, and that chart is
// usually the more interesting half of the drawer.
function drillable(c: Check): boolean {
  // The quail-loop row is always openable: the decisions waiting underneath it
  // are the reason to open it, and they are not part of the check's own meta.
  return c.name === 'timer.quail-loop' || typeof c.value === 'number' || hasMeta(c)
}

// The expanded body. Containers and Postgres get purpose-built lists because those are
// the rows you actually interrogate; everything else falls back to its key/value pairs
// (formatted for bytes and timestamps) rather than being dropped.
interface Investigation {
  id: string
  host: string
  checkName: string
  state: 'QUEUED' | 'RUNNING' | 'DONE' | 'FAILED'
  note: string | null
  spoken: boolean
  parentId: string | null
  findings: string | null
  actions: { action: string; result: string }[] | null
  error: string | null
  createdAt: string
  finishedAt: string | null
  stale: boolean
}

/**
 * "Ask Claude to look" — the button that exists so Eric does not have to carry
 * an amber dot to a terminal and describe it himself. The dot IS the report.
 *
 * Only offered on a row that is actually unwell. A button under every green
 * check invites pressing it out of curiosity, and a model turned loose on a
 * healthy system will always find SOMETHING to say about it.
 */
interface QuailDecision {
  id: string
  threadId: string
  buyerName: string | null
  subject: string | null
  threadUrl: string | null
  question: string
  context: string | null
  source: string
  state: 'OPEN' | 'ANSWERED' | 'SENT' | 'DISMISSED'
  answer: string | null
  spoken: boolean
  sentSummary: string | null
  sentAt: string | null
  createdAt: string
}

/**
 * The calls the quail loop will not make on its own.
 *
 * Eric gives the GIST — "yeah I've got those, they're friendly, tell them to
 * text me about pickup" — and the loop writes the actual reply in his voice
 * with his number in it. He is not drafting here; he is deciding. That is why
 * the placeholder is a sentence of intent and not a message, and why nothing on
 * this card shows him a draft to approve: approving drafts is typing with extra
 * steps, and typing is the part he is trying to hand over.
 */
function QuailPanel({ decisions, reload }: { decisions: QuailDecision[]; reload: () => void }) {
  const open = decisions.filter(d => d.state === 'OPEN')
  const recent = decisions.filter(d => d.state !== 'OPEN').slice(0, 4)
  if (!open.length && !recent.length) return null

  return (
    <div className="mb-2 space-y-2">
      {open.map(d => <QuailCard key={d.id} d={d} reload={reload} />)}
      {recent.map(d => (
        <div key={d.id} className="rounded-lg border border-border p-2.5 text-xs text-muted-foreground">
          <p className="font-medium text-foreground">
            {d.buyerName || (d.source === 'support' ? 'Someone who emailed support@' : 'Craigslist buyer')}
          </p>
          {d.answer && <p className="mt-0.5 italic">You said: “{d.answer}”</p>}
          {d.state === 'ANSWERED' && (
            <p className="mt-0.5 flex items-center gap-1.5">
              <Loader2 className="h-3 w-3 animate-spin shrink-0" />
              Waiting for the loop to send it — next tick is within 20 minutes.
            </p>
          )}
          {d.state === 'SENT' && d.sentSummary && <p className="mt-0.5">Sent: {d.sentSummary}</p>}
          {d.state === 'DISMISSED' && <p className="mt-0.5">Closed without replying.</p>}
        </div>
      ))}
    </div>
  )
}

function QuailCard({ d, reload }: { d: QuailDecision; reload: () => void }) {
  const [text, setText] = useState('')
  const [busy, setBusy] = useState(false)
  const [recording, setRecording] = useState(false)
  const recRef = useRef<MediaRecorder | null>(null)
  const chunksRef = useRef<Blob[]>([])

  const send = useCallback(async (payload: { answer?: string; audio?: Blob; dismiss?: boolean }) => {
    setBusy(true)
    try {
      if (payload.audio) {
        const fd = new FormData()
        fd.append('audio', payload.audio, 'answer.webm')
        if (payload.answer) fd.append('answer', payload.answer)
        await fetch(`/api/quail/decisions/${d.id}`, { method: 'POST', body: fd })
      } else {
        await fetch(`/api/quail/decisions/${d.id}`, {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(payload),
        })
      }
      setText('')
      reload()
    } finally { setBusy(false) }
  }, [d.id, reload])

  const speak = useCallback(async () => {
    if (recording) { recRef.current?.stop(); return }
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
      const rec = new MediaRecorder(stream)
      chunksRef.current = []
      rec.ondataavailable = e => { if (e.data.size > 0) chunksRef.current.push(e.data) }
      rec.onstop = () => {
        stream.getTracks().forEach(t => t.stop())
        setRecording(false)
        const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' })
        // A five-byte blob is not a short answer, it is an answer that did not
        // record — and sending one would have the loop reply to a buyer on the
        // strength of nothing.
        if (blob.size < 2048) return
        send({ audio: blob, answer: text.trim() || undefined })
      }
      recRef.current = rec
      rec.start()
      setRecording(true)
    } catch { setRecording(false) }
  }, [recording, send, text])

  return (
    <div className="rounded-lg border border-amber-500/40 bg-amber-500/5 p-2.5">
      <div className="flex items-start gap-1.5">
        <MessageCircleQuestion className="h-3.5 w-3.5 mt-0.5 shrink-0 text-amber-600 dark:text-amber-500" />
        <div className="min-w-0 flex-1">
          <p className="text-xs font-medium">
            {d.buyerName || (d.source === 'support' ? 'Someone who emailed support@' : 'Craigslist buyer')}
          </p>
          {d.subject && <p className="text-[11px] text-muted-foreground truncate">{d.subject}</p>}
          {d.source === 'support' && (
            <p className="text-[10px] text-muted-foreground">via support@thousandquails.com</p>
          )}
        </div>
      </div>

      <p className="mt-1.5 text-xs whitespace-pre-wrap leading-relaxed">{d.question}</p>
      {d.context && <p className="mt-1 text-[11px] text-muted-foreground whitespace-pre-wrap">{d.context}</p>}

      <div className="mt-2 flex items-center gap-1.5">
        <input
          value={text}
          onChange={e => setText(e.target.value)}
          onKeyDown={e => { if (e.key === 'Enter' && text.trim()) send({ answer: text.trim() }) }}
          placeholder={recording ? 'Listening…' : 'Tell them…'}
          disabled={busy || recording}
          className="flex-1 min-w-0 rounded-lg border border-input bg-background px-2.5 h-8 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
        />
        <button
          onClick={speak}
          disabled={busy}
          title={recording ? 'Stop and send' : 'Say it instead'}
          className={`shrink-0 inline-flex items-center justify-center rounded-lg border border-border h-8 w-8 disabled:opacity-40 ${recording ? 'bg-destructive/10 text-destructive' : 'hover:bg-muted'}`}
        >
          {recording ? <Square className="h-3.5 w-3.5" /> : <Mic className="h-3.5 w-3.5" />}
        </button>
        <button
          onClick={() => send({ answer: text.trim() })}
          disabled={busy || recording || !text.trim()}
          className="shrink-0 inline-flex items-center justify-center rounded-lg bg-primary text-primary-foreground h-8 w-8 disabled:opacity-40 hover:bg-primary/90"
        >
          {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
        </button>
      </div>

      <div className="mt-1.5 flex items-center gap-3">
        <button onClick={() => send({ dismiss: true })} disabled={busy} className="text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-40">
          Needs nothing
        </button>
        {d.threadUrl && (
          <a href={d.threadUrl} target="_blank" rel="noreferrer" className="text-[11px] text-muted-foreground hover:text-foreground">
            Open the thread
          </a>
        )}
      </div>
    </div>
  )
}

function InvestigatePanel({ check, inv, busy, onAsk }: {
  check: Check
  inv: Investigation | null
  busy: boolean
  onAsk: (follow?: { note?: string; audio?: Blob; parentId?: string }) => void
}) {
  const [note, setNote] = useState('')
  const [recording, setRecording] = useState(false)
  const recRef = useRef<MediaRecorder | null>(null)
  const chunksRef = useRef<Blob[]>([])

  const unwell = check.status !== 'OK'
  const live = inv && (inv.state === 'QUEUED' || (inv.state === 'RUNNING' && !inv.stale))
  const answered = inv && inv.state === 'DONE' && inv.findings

  const speak = useCallback(async () => {
    if (recording) { recRef.current?.stop(); return }
    try {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
      const rec = new MediaRecorder(stream)
      chunksRef.current = []
      rec.ondataavailable = e => { if (e.data.size > 0) chunksRef.current.push(e.data) }
      rec.onstop = () => {
        stream.getTracks().forEach(t => t.stop())
        setRecording(false)
        const blob = new Blob(chunksRef.current, { type: rec.mimeType || 'audio/webm' })
        // Same floor the recorder uses: a five-byte blob is not a short take,
        // it is a take that did not happen, and sending one asks the agent a
        // question nobody can answer.
        if (blob.size < 2048) return
        onAsk({ audio: blob, parentId: inv?.id, note: note.trim() || undefined })
        setNote('')
      }
      recRef.current = rec
      rec.start()
      setRecording(true)
    } catch { setRecording(false) }
  }, [recording, onAsk, inv, note])

  if (!unwell && !inv) return null

  return (
    <div className="mt-2 rounded-lg border border-border p-2.5">
      {check.acked && (
        <p className="mb-2 flex items-start gap-1.5 text-xs text-muted-foreground">
          <BellOff className="h-3.5 w-3.5 mt-0.5 shrink-0" />
          <span>
            Acknowledged{check.acked.note ? `: ${check.acked.note}` : ''}
            {' — '}still {check.status.toLowerCase()}, just not asking for you.
          </span>
        </p>
      )}

      {live ? (
        <p className="flex items-center gap-1.5 text-xs text-muted-foreground">
          <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
          {inv!.state === 'QUEUED' ? 'Queued — the mini picks this up within two minutes' : 'Looking into it…'}
        </p>
      ) : (
        unwell && !answered && (
          <button
            onClick={() => onAsk()}
            disabled={busy}
            className="inline-flex items-center gap-1.5 text-xs font-medium rounded-lg border border-border px-2.5 h-8 disabled:opacity-40 hover:bg-muted"
          >
            {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Stethoscope className="h-3.5 w-3.5" />}
            {inv ? 'Look again' : 'Ask Claude to look'}
          </button>
        )
      )}

      {inv && inv.state === 'RUNNING' && inv.stale && (
        <p className="mt-1.5 text-xs text-amber-600 dark:text-amber-500">
          Started {new Date(inv.createdAt).toLocaleTimeString()} and never reported back — the mini
          probably went away mid-run. Ask again.
        </p>
      )}

      {inv?.note && (
        <p className="mb-1.5 text-xs italic text-muted-foreground">You said: “{inv.note}”</p>
      )}

      {answered && (
        <div className="space-y-1.5">
          <p className="text-xs whitespace-pre-wrap leading-relaxed">{inv!.findings}</p>
          {inv!.actions && inv!.actions.length > 0 && (
            <ul className="space-y-1 border-t border-border pt-1.5">
              {inv!.actions.map((a, i) => (
                <li key={i} className="flex items-start gap-1.5 text-xs">
                  <Wrench className="h-3 w-3 mt-0.5 shrink-0 text-muted-foreground" />
                  <span><span className="font-medium">{a.action}</span> — {a.result}</span>
                </li>
              ))}
            </ul>
          )}
          <p className="text-[10px] text-muted-foreground">
            {inv!.finishedAt ? new Date(inv!.finishedAt).toLocaleString() : ''}
          </p>
        </div>
      )}

      {inv && inv.state === 'FAILED' && (
        <p className="mt-1.5 text-xs text-destructive">Could not finish: {inv.error || 'unknown'}</p>
      )}

      {/* Answer the answer. The common one is "that is not a real issue, clear
          the yellow" — a judgement only Eric can make, because the agent can
          tell him a 503 was a blip but not whether he cares. */}
      {answered && !live && (
        <div className="mt-2 border-t border-border pt-2">
          <div className="flex items-center gap-1.5">
            <input
              value={note}
              onChange={e => setNote(e.target.value)}
              onKeyDown={e => {
                if (e.key === 'Enter' && note.trim()) { onAsk({ note: note.trim(), parentId: inv!.id }); setNote('') }
              }}
              placeholder={recording ? 'Listening…' : 'Not a real issue — clear it'}
              disabled={busy || recording}
              className="flex-1 min-w-0 rounded-lg border border-input bg-background px-2.5 h-8 text-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
            />
            <button
              onClick={speak}
              disabled={busy}
              title={recording ? 'Stop and send' : 'Say it instead'}
              className={`shrink-0 inline-flex items-center justify-center rounded-lg border border-border h-8 w-8 disabled:opacity-40 ${recording ? 'bg-destructive/10 text-destructive' : 'hover:bg-muted'}`}
            >
              {recording ? <Square className="h-3.5 w-3.5" /> : <Mic className="h-3.5 w-3.5" />}
            </button>
            <button
              onClick={() => { onAsk({ note: note.trim(), parentId: inv!.id }); setNote('') }}
              disabled={busy || recording || !note.trim()}
              className="shrink-0 inline-flex items-center justify-center rounded-lg bg-primary text-primary-foreground h-8 w-8 disabled:opacity-40 hover:bg-primary/90"
            >
              {busy ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
            </button>
          </div>
        </div>
      )}
    </div>
  )
}

function CheckDetail({ check }: { check: Check }) {
  const m = asRec(check.meta)
  const containers = (Array.isArray(m.containers) ? m.containers : []) as ContainerRow[]
  const databases = (Array.isArray(m.databases) ? m.databases : []) as DbRow[]
  const cameras = (Array.isArray(m.cameras) ? m.cameras : []) as CameraRow[]

  // "35 of 35 recording" is the right row, and the wrong answer to the only
  // question you ever have at that moment, which is WHICH one stopped.
  if (cameras.length) {
    const dot = {
      recording: 'bg-emerald-500', stale: 'bg-destructive',
      missing: 'bg-destructive', retired: 'bg-muted-foreground/40',
    }
    return (
      <ul className="space-y-1">
        {cameras.map(c => (
          <li key={c.name} className="flex items-center gap-2 text-xs">
            <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${dot[c.state] || 'bg-muted-foreground/40'}`} />
            <span className={`truncate ${c.state === 'recording' || c.state === 'retired' ? '' : 'text-destructive'}`}>
              {c.name}
            </span>
            <span className="ml-auto shrink-0 text-muted-foreground truncate max-w-[55%]" title={c.detail}>
              {c.detail}
            </span>
          </li>
        ))}
      </ul>
    )
  }

  if (containers.length) {
    return (
      <ul className="space-y-1">
        {containers.map(c => {
          const bad = c.state !== 'running'
          const dot = bad ? 'bg-destructive' : !c.healthy ? 'bg-amber-500' : 'bg-emerald-500'
          return (
            <li key={c.name} className="flex items-center gap-2 text-xs">
              <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${dot}`} />
              <span className={`font-mono truncate ${bad ? 'text-destructive' : ''}`}>{c.name}</span>
              <span className="ml-auto shrink-0 text-muted-foreground truncate max-w-[45%]" title={c.status}>
                {c.status}
              </span>
            </li>
          )
        })}
      </ul>
    )
  }

  if (databases.length) {
    return (
      <ul className="space-y-1">
        {databases.map(d => (
          <li key={d.name} className="flex items-center gap-2 text-xs">
            <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${d.reachable ? 'bg-emerald-500' : 'bg-destructive'}`} />
            <span className={`font-mono truncate ${d.reachable ? '' : 'text-destructive'}`}>{d.name}</span>
            <span className="ml-auto shrink-0 text-muted-foreground">
              {d.reachable ? 'reachable' : 'NOT reachable'}
            </span>
          </li>
        ))}
      </ul>
    )
  }

  const entries = Object.entries(m).filter(([, v]) =>
    v != null && v !== '' && !(Array.isArray(v) && v.length === 0)
    && !(typeof v === 'object' && !Array.isArray(v) && Object.keys(v as object).length === 0))
  if (!entries.length) return <p className="text-xs text-muted-foreground">No further detail reported.</p>

  return (
    <dl className="space-y-1.5">
      {entries.map(([k, v]) => {
        // A nested object is the interesting case, not a defect: backup.apps reports
        // {ages: {learn: 1.5, chores: 5, ...}}, i.e. the per-app breakdown behind
        // "10 of 10 fresh". Rendering it with String() gave "[object Object]".
        if (v && typeof v === 'object' && !Array.isArray(v)) {
          const sub = Object.entries(v as Record<string, unknown>)
          return (
            <div key={k}>
              <dt className="text-[11px] uppercase tracking-wide text-muted-foreground">{humanKey(k)}</dt>
              <dd className="mt-0.5 space-y-0.5">
                {sub.map(([sk, sv]) => {
                  // This sub-renderer was written for short numeric values
                  // ({learn: 1.5, chores: 5}). Given a sentence — quail-loop
                  // reports "<buyer>: <what happened to their thread>" — the
                  // shrink-0 value took the whole row and collapsed the KEY to
                  // nothing, so you saw three descriptions and no names. Prose
                  // gets its own line and wraps; numbers keep the tidy
                  // right-aligned column.
                  const prose = typeof sv === 'string' && sv.length > 32
                  if (prose) return (
                    <div key={sk} className="text-xs">
                      <span className="font-mono font-medium">{sk}</span>
                      <p className="mt-0.5 whitespace-pre-wrap break-words text-muted-foreground">{sv}</p>
                    </div>
                  )
                  return (
                  <div key={sk} className="flex items-center gap-2 text-xs">
                    <span className="font-mono min-w-0 flex-1 truncate">{sk}</span>
                    <span className={`ml-auto min-w-0 shrink font-mono ${sv == null ? 'text-destructive' : ''}`}>
                      {fmtValue(k, sv)}
                    </span>
                  </div>
                  )
                })}
              </dd>
            </div>
          )
        }
        return (
          <div key={k} className="flex items-start gap-2 text-xs">
            <dt className="text-muted-foreground shrink-0">{humanKey(k)}</dt>
            <dd className="ml-auto text-right font-mono break-all">{fmtValue(k, v)}</dd>
          </div>
        )
      })}
    </dl>
  )
}

function humanKey(k: string): string {
  return k.replace(/([A-Z])/g, ' $1').replace(/^./, ch => ch.toUpperCase())
}

// Format a leaf value using its key as the unit hint. `parentKey` carries the hint down
// into a nested object, so backup.apps' {ages: {learn: 1.5}} renders as hours.
function fmtValue(key: string, v: unknown): string {
  if (v == null) return 'missing'
  if (typeof v === 'boolean') return v ? 'yes' : 'no'
  if (Array.isArray(v)) return v.join(', ')
  if (typeof v === 'number') {
    if (/bytes$/i.test(key)) return bytes(v)
    if (/agesec$/i.test(key)) return ago(v).replace(' ago', '')
    if (/^ages$/i.test(key)) return v < 1 ? `${Math.round(v * 60)}m` : `${v}h`
    return v.toLocaleString()
  }
  const str = String(v)
  if (/iso$/i.test(key)) {
    const d = new Date(str)
    if (!Number.isNaN(d.getTime())) return d.toLocaleString()
  }
  return str
}

export function HealthPage() {
  const [data, setData] = useState<{ hosts: Host[]; problems: number; configured: boolean; digests?: DigestRow[] } | null>(null)
  const [loaded, setLoaded] = useState(false)
  const [open, setOpen] = useState<string | null>(null)   // "<host>:<check>" currently expanded
  const [openDigest, setOpenDigest] = useState<string | null>(null)
  const [showOlder, setShowOlder] = useState(false)
  const [draft, setDraft] = useState('')
  const [sending, setSending] = useState(false)
  const [recording, setRecording] = useState(false)
  const recRef = useRef<MediaRecorder | null>(null)
  const [invs, setInvs] = useState<Investigation[]>([])
  const [asking, setAsking] = useState<string | null>(null)
  const [quail, setQuail] = useState<QuailDecision[]>([])

  const loadQuail = useCallback(async () => {
    const res = await fetch('/api/quail/decisions', { cache: 'no-store' })
    if (res.ok) setQuail((await res.json()).decisions || [])
  }, [])

  const load = useCallback(async () => {
    const res = await fetch('/api/monitor', { cache: 'no-store' })
    if (res.ok) setData(await res.json())
    setLoaded(true)
  }, [])

  const loadInvs = useCallback(async () => {
    const res = await fetch('/api/health/investigate', { cache: 'no-store' })
    if (res.ok) setInvs((await res.json()).investigations || [])
  }, [])

  useEffect(() => {
    load()
    const t = setInterval(load, 60000)
    return () => clearInterval(t)
  }, [load])

  // Investigations move on their own clock — a diagnosis finishes minutes after
  // the press, with nothing to poll for in between — so they refresh faster
  // than the host cards, and faster still while one is actually in flight.
  const anyRunning = invs.some(i => i.state === 'QUEUED' || (i.state === 'RUNNING' && !i.stale))
  useEffect(() => {
    loadInvs()
    const t = setInterval(loadInvs, anyRunning ? 8000 : 60000)
    return () => clearInterval(t)
  }, [loadInvs, anyRunning])

  useEffect(() => {
    loadQuail()
    const t = setInterval(loadQuail, 60000)
    return () => clearInterval(t)
  }, [loadQuail])

  const investigate = useCallback(async (
    host: string, c: Check,
    follow?: { note?: string; audio?: Blob; parentId?: string },
  ) => {
    setAsking(`${host}:${c.name}`)
    try {
      const base = { host, checkName: c.name, checkStatus: c.status, checkDetail: c.detail }
      if (follow?.audio) {
        const fd = new FormData()
        for (const [k, v] of Object.entries(base)) fd.append(k, String(v ?? ''))
        if (follow.parentId) fd.append('parentId', follow.parentId)
        if (follow.note) fd.append('note', follow.note)
        fd.append('audio', follow.audio, 'feedback.webm')
        await fetch('/api/health/investigate', { method: 'POST', body: fd })
      } else {
        await fetch('/api/health/investigate', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ ...base, ...(follow || {}) }),
        })
      }
      await loadInvs()
    } finally { setAsking(null) }
  }, [loadInvs])

  const hosts = data?.hosts || []

  return (
    <div className="min-h-screen max-w-md mx-auto flex flex-col">
      <header className="safe-top px-4 pb-3 flex items-center gap-2">
        <Link href="/" className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground">
          <ChevronLeft className="h-4 w-4" /> Dashboard
        </Link>
        <span className="ml-auto font-semibold inline-flex items-center gap-1.5">
          <HeartPulse className="h-4 w-4 text-primary" /> Health
        </span>
      </header>

      <main className="flex-1 px-4 pb-10 space-y-4">
        {!loaded && <div className="py-10 text-center"><Loader2 className="h-5 w-5 animate-spin mx-auto text-muted-foreground" /></div>}

        {loaded && !data?.configured && (
          <p className="text-sm text-muted-foreground text-center py-10">
            No host has reported yet. The VPS agent posts every 5 minutes.
          </p>
        )}

        {/* Digests. Above the hosts because it is the thing with something new to
            say; the host cards are a steady state you check, not news. */}
        {(data?.digests || []).length > 0 && (
          <section className="rounded-xl border border-border bg-card overflow-hidden">
            <div className="flex items-center gap-2 px-4 py-3 border-b border-border">
              <Mail className="h-4 w-4 shrink-0 text-primary" />
              <span className="text-sm font-semibold truncate">Daily digest</span>
              {(data?.digests || []).some(d => !d.read && d.notable) && (
                /* Deliberately the accent colour, not amber. Amber on this page
                   means a check is failing; an unread summary is information,
                   and borrowing the warning colour for it would make amber mean
                   two different things on the same screen. */
                <span className="ml-auto text-[11px] rounded-full px-2 py-0.5 bg-emerald-500 text-white shrink-0">
                  New
                </span>
              )}
            </div>

            <ul className="divide-y divide-border">
              {/* Three a day fills this card in two days. Show the recent few and
                  anything still unread — an unread one is the whole point of the
                  card and must never be the thing that got collapsed — and put
                  the rest behind a toggle. The API still sends 7 and the database
                  keeps 30, so "older" is a click, not a refetch. */}
              {(data?.digests || [])
                .filter((d, i) => showOlder || i < DIGESTS_SHOWN || (!d.read && d.notable))
                .map(d => {
                const isOpen = openDigest === d.id
                return (
                  <li key={d.id}>
                    <button
                      className="w-full text-left flex items-start gap-2.5 px-4 py-2.5 hover:bg-accent/40"
                      aria-expanded={isOpen}
                      onClick={async () => {
                        const next = isOpen ? null : d.id
                        setOpenDigest(next)
                        // Opening IS reading. Marking it optimistically keeps the
                        // badge from lingering for a poll cycle; the server call
                        // is idempotent, so a failure costs a stale badge until
                        // the next load, never a wrong readAt.
                        if (next && !d.read) {
                          setData(prev => prev ? {
                            ...prev,
                            digests: (prev.digests || []).map(x => x.id === d.id ? { ...x, read: true } : x),
                          } : prev)
                          try {
                            await fetch('/api/digest/read', {
                              method: 'POST',
                              headers: { 'content-type': 'application/json' },
                              body: JSON.stringify({ id: d.id }),
                            })
                          } catch { /* the next poll will correct it */ }
                        }
                      }}
                    >
                      {/* Only an unread digest that found something gets the
                          live dot. A quiet one is listed, legible and dim. */}
                      <span className={`mt-1.5 h-1.5 w-1.5 rounded-full shrink-0 ${!d.read && d.notable ? 'bg-emerald-500' : 'bg-muted-foreground/30'}`} />
                      <span className="min-w-0 flex-1">
                        <span className={`block text-sm truncate ${!d.read && d.notable ? 'font-medium' : ''}`}>{d.title}</span>
                        <span className="block text-xs text-muted-foreground line-clamp-2">{d.summary}</span>
                      </span>
                      <ChevronRight className={`h-4 w-4 shrink-0 text-muted-foreground transition-transform ${isOpen ? 'rotate-90' : ''}`} />
                    </button>

                    {isOpen && (
                      <div className="px-4 pb-3">
                        {/* whitespace-pre-wrap because the timer writes plain text
                            for an email — line breaks are the only structure it has. */}
                        <div className="rounded-lg bg-muted/40 p-2.5 max-h-96 overflow-y-auto">
                          <p className="text-xs whitespace-pre-wrap break-words">{d.body}</p>
                        </div>
                        <p className="mt-1 text-[10px] text-muted-foreground">
                          {new Date(d.createdAt).toLocaleString()}
                        </p>

                        {/* The thread. Questions asked of this digest, and what
                            came back. */}
                        {(d.replies || []).map(r => (
                          <div key={r.id} className="mt-2 space-y-1">
                            <p className="text-xs rounded-lg bg-primary/10 px-2.5 py-1.5 inline-flex items-center gap-1.5">
                              {!r.prompt && <Mic className="h-3 w-3 shrink-0 opacity-60" />}
                              {r.prompt || 'spoken — transcribing on the mini'}
                            </p>
                            {r.status === 'PENDING' && (
                              <p className="text-[11px] text-muted-foreground px-2.5 inline-flex items-center gap-1.5">
                                <Loader2 className="h-3 w-3 animate-spin" />
                                {/* Named so the wait reads as a queue, not a hang:
                                    the model runs on the mini, not in this request. */}
                                queued for the mini — answers on its next pass
                              </p>
                            )}
                            {r.status === 'DONE' && r.reply && (
                              <p className="text-xs whitespace-pre-wrap break-words rounded-lg bg-muted/60 px-2.5 py-1.5">{r.reply}</p>
                            )}
                            {r.status === 'FAILED' && (
                              <p className="text-xs text-destructive px-2.5">{r.error || 'that one failed'}</p>
                            )}
                          </div>
                        ))}

                        <form
                          className="mt-2 flex gap-1.5"
                          onSubmit={async (e) => {
                            e.preventDefault()
                            const prompt = draft.trim()
                            if (!prompt || sending) return
                            setSending(true)
                            try {
                              await fetch('/api/digest/reply', {
                                method: 'POST',
                                headers: { 'content-type': 'application/json' },
                                body: JSON.stringify({ digestId: d.id, prompt }),
                              })
                              setDraft('')
                              await load()
                            } catch { /* the next poll will show it if it landed */ }
                            setSending(false)
                          }}
                        >
                          <input
                            value={openDigest === d.id ? draft : ''}
                            onChange={e => setDraft(e.target.value)}
                            placeholder="Make reminders from this, count the bills…"
                            className="flex-1 min-w-0 rounded-lg border border-border bg-background px-2.5 py-1.5 text-xs"
                          />
                          {/* Speak it instead. The recording is uploaded as-is
                              and transcribed on the mini, where whisper already
                              lives for captures — the browser never tries to
                              turn it into text. */}
                          <button
                            type="button"
                            aria-label={recording ? 'Stop and send' : 'Ask by voice'}
                            disabled={sending}
                            className={`rounded-lg px-2.5 py-1.5 text-xs disabled:opacity-40 ${recording ? 'bg-destructive text-white' : 'bg-muted text-muted-foreground'}`}
                            onClick={async () => {
                              if (recording) { recRef.current?.stop(); return }
                              try {
                                const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
                                const rec = new MediaRecorder(stream)
                                const chunks: BlobPart[] = []
                                rec.ondataavailable = e => { if (e.data.size) chunks.push(e.data) }
                                rec.onstop = async () => {
                                  stream.getTracks().forEach(t => t.stop())
                                  setRecording(false)
                                  const blob = new Blob(chunks, { type: rec.mimeType || 'audio/webm' })
                                  if (!blob.size) return
                                  setSending(true)
                                  try {
                                    const fd = new FormData()
                                    fd.append('digestId', d.id)
                                    fd.append('audio', blob, 'ask.webm')
                                    await fetch('/api/digest/reply', { method: 'POST', body: fd })
                                    await load()
                                  } catch { /* the next poll will show it if it landed */ }
                                  setSending(false)
                                }
                                recRef.current = rec
                                rec.start()
                                setRecording(true)
                              } catch { /* no mic permission — the text box still works */ }
                            }}
                          >
                            {recording ? <Square className="h-3.5 w-3.5" /> : <Mic className="h-3.5 w-3.5" />}
                          </button>
                          <button
                            type="submit"
                            disabled={sending || !draft.trim()}
                            className="rounded-lg bg-primary px-2.5 py-1.5 text-xs text-primary-foreground disabled:opacity-40"
                          >
                            {sending ? '…' : 'Ask'}
                          </button>
                        </form>
                      </div>
                    )}
                  </li>
                )
              })}
            </ul>

            {(data?.digests || []).length > DIGESTS_SHOWN && (
              <button
                type="button"
                onClick={() => setShowOlder(v => !v)}
                className="w-full px-4 py-2 text-[11px] text-muted-foreground hover:bg-accent/40 border-t border-border"
              >
                {showOlder
                  ? 'Show fewer'
                  : `Show ${(data?.digests || []).length - DIGESTS_SHOWN} older`}
              </button>
            )}
          </section>
        )}

        {hosts.map(h => {
          const tone = TONE[h.status]
          const Icon = tone.icon
          return (
            <section key={h.host} className="rounded-xl border border-border bg-card overflow-hidden">
              <div className="flex items-center gap-2 px-4 py-3 border-b border-border">
                <Icon className={`h-4 w-4 shrink-0 ${tone.text}`} />
                <span className="text-sm font-semibold truncate">{h.label}</span>
                {h.problems > 0 && (
                  <span className={`ml-auto text-xs rounded-full px-2 py-0.5 text-white shrink-0 ${tone.dot}`}>
                    {h.problems}
                  </span>
                )}
              </div>

              {h.stale && (
                <div className="px-4 py-2.5 text-sm bg-destructive/10 text-destructive border-b border-border">
                  Not reporting — last heard {h.reportingAgeSec != null ? ago(h.reportingAgeSec) : 'never'}.
                  The host or its monitor agent may be down.
                </div>
              )}

              <ul className="divide-y divide-border">
                {h.checks.map(c => {
                  const t = TONE[c.status]
                  // A hit is a finding: a discount located, a promo live. It
                  // gets its own faint green ground so it reads as "look at
                  // this" while the DOT still carries status, so a row can
                  // honestly say both "found you something" and "and something
                  // is wrong" at once.
                  //
                  // Only CRIT suppresses it (STALE is a host state, not a
                  // check one). The first rule here was
                  // "OK only", which hid the single real example on the board:
                  // feed-watch found a live 10% promo and went WARN in the same
                  // run because one product of three was rate-limited. That
                  // finding was not stale, it was minutes old. A check that is
                  // merely degraded can still have found something true; one
                  // that is failing or silent cannot be trusted to have.
                  const isHit = c.hit === true && c.status !== 'CRIT'
                  // Buyers waiting on a decision only Eric can make. Amber and
                  // a number, because unlike the digest's green badge this one
                  // is not "there is something to read" — it is somebody on the
                  // other end of a Craigslist thread who has not been answered.
                  const waiting = c.name === 'timer.quail-loop'
                    ? quail.filter(q => q.state === 'OPEN').length : 0
                  const key = `${h.host}:${c.name}`
                  const canOpen = drillable(c)
                  const isOpen = open === key
                  const body = (
                    <>
                      <span className={`h-1.5 w-1.5 rounded-full shrink-0 ${isHit ? 'bg-emerald-500 ring-2 ring-emerald-500/30' : t.dot}`} />
                      <span className="text-sm min-w-0 flex-1 truncate flex items-center gap-1" title={c.label}>
                        {c.label}
                        {canOpen && (
                          <ChevronRight
                            className={`h-3 w-3 shrink-0 text-muted-foreground transition-transform ${isOpen ? 'rotate-90' : ''}`}
                          />
                        )}
                      </span>
                      {/* Not shrink-0: this column holds the detail, and a long
                          one used to take the whole row, collapsing the label's
                          flex-1 to zero so the check rendered with NO TITLE at
                          all rather than a truncated one. min-w-0 lets the
                          detail truncate, max-w keeps the label a fair share,
                          and the value itself never wraps. */}
                      {waiting > 0 && (
                        <span
                          className="shrink-0 inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-amber-500 text-white text-[11px] font-semibold tabular-nums"
                          title={`${waiting} buyer${waiting === 1 ? '' : 's'} waiting on your answer`}
                        >
                          {waiting}
                        </span>
                      )}
                      <span className="text-right min-w-0 max-w-[60%]">
                        <span className={`block whitespace-nowrap text-sm tabular-nums ${c.status === 'OK' ? '' : t.text}`}>
                          {c.value != null ? `${Number.isInteger(c.value) ? c.value : c.value.toFixed(1)}${c.unit || ''}` : '—'}
                        </span>
                        <span className="block truncate text-xs text-muted-foreground" title={c.detail}>{c.detail}</span>
                      </span>
                    </>
                  )
                  return (
                    <li key={c.name} className={isHit ? 'bg-emerald-500/10' : undefined}>
                      {canOpen ? (
                        <button
                          type="button"
                          aria-expanded={isOpen}
                          onClick={() => setOpen(o => (o === key ? null : key))}
                          className={`w-full flex items-center gap-2.5 px-4 py-2.5 text-left transition-colors ${isHit ? 'hover:bg-emerald-500/20' : 'hover:bg-muted/40'}`}
                        >
                          {body}
                        </button>
                      ) : (
                        <div className="flex items-center gap-2.5 px-4 py-2.5">{body}</div>
                      )}
                      {isOpen && (
                        <div className="px-4 pb-3 -mt-0.5">
                          {hasMeta(c) && (
                            <div className="rounded-lg bg-muted/40 p-2.5 max-h-72 overflow-y-auto">
                              <CheckDetail check={c} />
                            </div>
                          )}
                          {/* Outside the box above on purpose: that one scrolls at
                              max-h-72, and a chart inside a scroll area gets its
                              x-axis clipped into a second tiny scrollbar. */}
                          {typeof c.value === 'number' && (
                            <MetricChart host={h.host} name={c.name} />
                          )}
                          {c.name === 'timer.quail-loop' && (
                            <QuailPanel decisions={quail} reload={loadQuail} />
                          )}
                          <InvestigatePanel
                            check={c}
                            inv={invs.find(i => i.host === h.host && i.checkName === c.name) || null}
                            busy={asking === `${h.host}:${c.name}`}
                            onAsk={(follow) => investigate(h.host, c, follow)}
                          />
                          <p className="mt-1 text-[10px] text-muted-foreground">
                            As of {ago(c.ageSec)} · unchanged for {sinceLabel(c.since)}
                          </p>
                        </div>
                      )}
                    </li>
                  )
                })}
              </ul>

              <div className="px-4 py-2 text-xs text-muted-foreground border-t border-border flex items-center gap-2">
                <span>Reported {h.reportingAgeSec != null ? ago(h.reportingAgeSec) : '—'}</span>
                {h.problems > 0 && (
                  <span className="ml-auto">
                    Oldest issue: {sinceLabel(
                      h.checks.filter(c => c.status !== 'OK').map(c => c.since).sort()[0] || new Date().toISOString(),
                    )}
                  </span>
                )}
              </div>
            </section>
          )
        })}

        {loaded && data?.configured && data.problems === 0 && (
          <p className="text-sm text-muted-foreground text-center py-2">
            All checks green. Alerts email you directly — you don&apos;t have to watch this page.
          </p>
        )}
      </main>
    </div>
  )
}
