import { NextResponse } from 'next/server'
import { z } from 'zod'
import { db } from '@/lib/db'
import { checkAgentAuth } from '@/lib/agent-auth'
import { logSystemActivity } from '@/lib/activity'
import { hostLabel } from '@/lib/monitor'

export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'

// POST /api/monitor/ingest — a host agent pushes its full check snapshot.
// Auth: agent bearer token (same as /api/sync and the trades routes).
//
// The snapshot is authoritative for that host: every check is upserted, and
// checks the agent no longer sends are dropped, so retiring a check needs no
// migration. `since` only moves when the status actually CHANGES, which is what
// makes "WARN for 3 days" legible in the UI instead of "WARN for 5 minutes"
// forever. Status transitions are mirrored into the Activity feed.
const CheckSchema = z.object({
  name: z.string().min(1).max(64),
  label: z.string().min(1).max(64),
  status: z.enum(['OK', 'WARN', 'CRIT']),
  value: z.number().finite().nullable().optional(),
  unit: z.string().max(8).nullable().optional(),
  detail: z.string().max(300),
  meta: z.unknown().optional(),
  hit: z.boolean().default(false),
})

// How much history /health can draw. 30 days covers "is this disk filling up or
// is it just Tuesday" while keeping the table around a third of a million rows.
const RETENTION_DAYS = 30

const BodySchema = z.object({
  host: z.string().min(1).max(64),
  checks: z.array(CheckSchema).max(64),
})


/**
 * Does a previous acknowledgement still cover what this check is now saying?
 *
 * Only while the detail is unchanged AND it is still unwell. Going green clears
 * it too, so the next genuine problem is not born pre-acknowledged.
 */
function ackStillApplies(
  prior: { ackedAt: Date | null; ackSignature: string | null } | undefined,
  c: { status: string; detail: string },
): boolean {
  if (!prior?.ackedAt) return false
  if (c.status === 'OK') return false
  return prior.ackSignature === c.detail
}

export async function POST(req: Request) {
  if (!checkAgentAuth(req)) {
    return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
  }

  let raw: unknown
  try {
    raw = await req.json()
  } catch {
    return NextResponse.json({ error: 'bad json' }, { status: 400 })
  }

  const parsed = BodySchema.safeParse(raw)
  if (!parsed.success) {
    return NextResponse.json({ error: 'bad payload', issues: parsed.error.issues.slice(0, 5) }, { status: 400 })
  }
  const { host, checks } = parsed.data

  const prior = await db.hostCheck.findMany({
    where: { host },
    select: { name: true, status: true, ackedAt: true, ackSignature: true },
  })
  const priorStatus = new Map(prior.map(p => [p.name, p.status]))
  const priorAck = new Map(prior.map(p => [p.name, p]))

  const now = new Date()
  const transitions: { label: string; from: string; to: string; detail: string }[] = []

  for (const [i, c] of checks.entries()) {
    const was = priorStatus.get(c.name)
    const changed = was !== c.status
    // Informational state tiles (name `sensor.*`, e.g. a window reading
    // Open/Closed) colour themselves but are not alerts: an open daytime window
    // flipping OK<->WARN must not land in the Activity feed, or the genuine
    // events (a door left open too long, a host down) drown in door-wiggle. A
    // transition INTO or OUT OF CRIT is different — that's a sensor going
    // unreadable, a real fault — so it still logs.
    const infoFlip = c.name.startsWith('sensor.') && c.status !== 'CRIT' && was !== 'CRIT'
    if (was && changed && !infoFlip) transitions.push({ label: c.label, from: was, to: c.status, detail: c.detail })

    const data = {
      label: c.label,
      status: c.status,
      value: c.value ?? null,
      unit: c.unit ?? null,
      detail: c.detail,
      meta: (c.meta ?? null) as never,
      hit: c.hit ?? false,
      order: i,
      // hold `since` steady across reports that don't change the status
      ...(changed ? { since: now } : {}),
      // An acknowledgement survives repeated reports of the SAME thing and
      // dies the moment the check starts saying something else. Eric
      // acknowledged a sentence he read, not the row in perpetuity — if the
      // detail changes, that is new information and he has not seen it.
      ...(ackStillApplies(priorAck.get(c.name), c) ? {} : { ackedAt: null, ackNote: null, ackSignature: null }),
    }
    await db.hostCheck.upsert({
      where: { host_name: { host, name: c.name } },
      create: { host, name: c.name, since: now, ...data },
      update: data,
    })
  }

  // drop checks this host no longer reports
  const seen = checks.map(c => c.name)
  await db.hostCheck.deleteMany({ where: { host, name: { notIn: seen } } })

  // Append the numeric readings, so /health can draw a line instead of a number.
  // One createMany rather than a row at a time: this runs every 5 minutes per
  // host and the upserts above are already N queries.
  //
  // Checks without a number are skipped rather than stored as 0 — "Songbook AI
  // search" reports null because a latency is not always meaningful, and a null
  // charted as zero is a lie that looks like an outage.
  const samples = checks
    .filter(c => typeof c.value === 'number' && Number.isFinite(c.value))
    .map(c => ({ host, name: c.name, value: c.value as number, status: c.status, at: now }))
  if (samples.length) {
    await db.hostCheckSample.createMany({ data: samples })
  }

  // Prune on roughly one report in twelve — hourly per host, rather than every
  // five minutes — because the delete is pure overhead on a 3.6G box and the
  // table is bounded by retention, not by how promptly we trim it.
  if (Math.random() < 1 / 12) {
    const cutoff = new Date(Date.now() - RETENTION_DAYS * 86400_000)
    await db.hostCheckSample.deleteMany({ where: { at: { lt: cutoff } } })
  }

  for (const t of transitions) {
    const verb = t.to === 'OK' ? 'recovered' : t.to.toLowerCase()
    logSystemActivity(`host.${t.to.toLowerCase()}`, `${hostLabel(host)} — ${t.label} ${verb}: ${t.detail}`)
  }

  return NextResponse.json({ ok: true, stored: checks.length, transitions: transitions.length })
}
