// ── Shard live-feed SSE broadcaster ────────────────────────────────────────
//
// The browser can't talk to the sidecar's WebSocket directly (the token must
// never reach it, and the WS may be on another host). Instead the server ingests
// the WS feed and re-broadcasts events to browsers over Server-Sent Events
// (plain HTTP — works through any reverse proxy).
//
// Since Protocol 3.0 the split is no longer "one public channel with a static
// allowlist plus one admin channel". Each subscriber carries the audience rung
// it resolved to at subscribe time, and every frame is
//
//   1. mapped kind → feature (an UNMAPPED kind reaches nobody below admin —
//      fail closed; see utils/shardVisibility.js rule 2),
//   2. gated on that feature being enabled, streamed, and within the viewer's
//      rung, and
//   3. passed through field projection, so `acct` / `webId` and any field an
//      admin has re-gated are stripped per viewer.
//
// **This is the security boundary.** It used to be the PUBLIC_KINDS set in this
// file; it is now the kind map plus the visibility config. PUBLIC_KINDS still
// exists and is still exported, but it is now DERIVED from the kind map (see
// shardVisibility.js) so the two can no longer drift.
//
// shardIngest calls broadcast(event) for each ingested event; the public/admin
// SSE route handlers call subscribe(req, res, channel).

const visibility = require('./shardVisibility')
const log = require('./logger')('shard-broadcast')

// Re-exported for back-compat: shardEvents `/feed` filtering and
// config/notificationStreams.js both ask "is this kind public-safe?".
const { PUBLIC_KINDS } = visibility

// Open streams. Each entry is { res, level }. The admin bucket is kept separate
// because it is unconditional and must not depend on a config read.
const clients = { public: new Set(), admin: new Set() }

const KEEPALIVE_MS = 25000

// Register an SSE stream on a channel. Sets the SSE headers, sends an initial
// comment, keeps the connection warm with periodic pings, and cleans up on close.
//
// The viewer's rung is resolved ONCE, here, and frozen for the life of the
// connection — a long-lived stream must not silently gain privilege because the
// caller's session changed underneath it. (Config changes, by contrast, DO take
// effect live: the config is read per broadcast, cached ~5s.)
async function subscribe(req, res, channel) {
  const bucket = clients[channel]
  if (!bucket) {
    res.status(400).end()
    return
  }

  let level = 'admin'
  if (channel === 'public') {
    try {
      level = await visibility.viewerLevel(req)
    } catch (err) {
      // Fail closed: an unresolvable viewer is anonymous, not privileged.
      log.warn('viewerLevel failed on subscribe; treating as anonymous', { message: err.message })
      level = 'anonymous'
    }
  }

  res.writeHead(200, {
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache, no-transform',
    Connection: 'keep-alive',
    'X-Accel-Buffering': 'no', // disable proxy buffering so events flush immediately
  })
  res.write('retry: 5000\n\n') // tell EventSource to reconnect after 5s if dropped
  res.write(': connected\n\n')

  const client = { res, level, ping: null }
  bucket.add(client)

  client.ping = setInterval(() => {
    try {
      res.write(': ping\n\n')
    } catch {
      /* write after close — cleanup below handles it */
    }
  }, KEEPALIVE_MS)

  const cleanup = () => drop(bucket, client)
  req.on('close', cleanup)
  res.on('error', cleanup)
}

// The ONLY way a client leaves a bucket. Clearing the keepalive here (rather
// than only in the close handler) matters: a client dropped because its write
// threw never fires `req.close`, so its interval would otherwise keep firing on
// a dead socket for the life of the process.
function drop(bucket, client) {
  clearInterval(client.ping)
  bucket.delete(client)
}

function writeTo(bucket, client, payload) {
  try {
    client.res.write(payload)
  } catch (err) {
    log.warn('sse write failed; dropping client', { message: err.message })
    drop(bucket, client)
  }
}

// Fan an ingested event out. The admin channel gets it verbatim, always. Public
// subscribers are filtered and projected per their own rung — so two viewers on
// the same channel can legitimately receive different versions of one frame, or
// one of them nothing at all.
async function broadcast(event) {
  if (!event || !event.kind) return

  if (clients.admin.size) {
    const frame = `data: ${JSON.stringify(event)}\n\n`
    for (const client of [...clients.admin]) writeTo(clients.admin, client, frame)
  }

  if (!clients.public.size) return

  let config
  try {
    config = await visibility.getConfig()
  } catch (err) {
    // Fail closed: without a config we cannot prove a frame is safe to send.
    log.error('visibility config unavailable; withholding public frame', err)
    return
  }

  // Most frames land on one rung set, so cache the serialised payload per level
  // instead of re-projecting and re-stringifying for every subscriber.
  const byLevel = new Map()
  for (const client of [...clients.public]) {
    let frame = byLevel.get(client.level)
    if (frame === undefined) {
      frame = visibility.kindVisibleTo(event.kind, client.level, config)
        ? `data: ${JSON.stringify(visibility.projectFeature(visibility.KIND_FEATURE.get(event.kind), event, client.level, config))}\n\n`
        : null
      byLevel.set(client.level, frame)
    }
    if (frame) writeTo(clients.public, client, frame)
  }
}

// Close every open stream (graceful shutdown). Clears each keepalive timer too —
// without that the intervals keep the event loop alive after the streams are
// gone, and the process won't exit.
function closeAll() {
  for (const bucket of Object.values(clients)) {
    for (const client of [...bucket]) {
      drop(bucket, client)
      try {
        client.res.end()
      } catch {
        /* ignore */
      }
    }
  }
}

function stats() {
  return { publicClients: clients.public.size, adminClients: clients.admin.size }
}

module.exports = { subscribe, broadcast, closeAll, stats, PUBLIC_KINDS }
