// ── Push-notification stream catalog + event → stream mapping ───────────────
//
// The single source of truth for which streams a user can subscribe to, and how
// a shard event maps onto them. Two families:
//   • public / opt-in    — no linked game account required; delivered to every
//                           subscriber. Drawn ONLY from the SSE public allowlist
//                           (utils/shardBroadcast PUBLIC_KINDS) — a sensitive kind
//                           can never produce a public push.
//   • personal / owner-keyed — require a linked game account; delivered ONLY to
//                           the owning user's devices (resolved from the event's
//                           game account via shardLinks), never fanned out publicly.
//
// The payload the relay ever carries is a CONTENT-FREE tickle ({ stream, ref });
// `ref` is an opaque hint (serial / city / timestamp) the app uses to pull the
// real, ownership-checked content over the authenticated API. So even a leaked
// ntfy topic reveals nothing (docs/android/PLAN.md §11).

const { PUBLIC_KINDS } = require('../utils/shardBroadcast')

// The subscribable catalog. `news.post` is produced by the website's own posts
// path (not the shard feed) — see utils/pushDispatch — so it has no mapShardEvent
// case; every other stream is shard-derived below.
const STREAMS = [
  {
    id: 'news.post',
    label: 'News posts',
    description: 'New news / Five-on-Friday / newsletter posts.',
    personal: false,
    requiresLinkedAccount: false,
  },
  {
    id: 'server.status',
    label: 'Server up / down',
    description: 'The shard comes online or goes offline.',
    personal: false,
    requiresLinkedAccount: false,
  },
  {
    id: 'idoc.warning',
    label: 'IDOC warnings',
    description: 'A house falls into its final (IDOC) decay stage.',
    personal: false,
    requiresLinkedAccount: false,
  },
  {
    id: 'champ.start',
    label: 'Champion spawn starts',
    description: 'A champion spawn becomes active.',
    personal: false,
    requiresLinkedAccount: false,
  },
  {
    id: 'governor.election',
    label: 'Governor elections',
    description: 'A town elects a new governor.',
    personal: false,
    requiresLinkedAccount: false,
  },
  {
    id: 'vendor.sale',
    label: 'Your vendor sold an item',
    description: 'One of your player vendors made a sale.',
    personal: true,
    requiresLinkedAccount: true,
  },
  {
    id: 'house.idoc',
    label: 'Your house entered IDOC',
    description: 'One of your houses fell into its final decay stage.',
    personal: true,
    requiresLinkedAccount: true,
  },
  {
    id: 'account.login',
    label: 'A login to your account',
    description: 'An authentication attempt against your game account.',
    personal: true,
    requiresLinkedAccount: true,
  },
]

const STREAM_IDS = new Set(STREAMS.map((s) => s.id))
const isValidStream = (id) => STREAM_IDS.has(id)
const PERSONAL_STREAMS = new Set(STREAMS.filter((s) => s.personal).map((s) => s.id))

// Per-process transition state so full-state upserts (champ.update / city.update
// are upserts, not discrete "started"/"elected" events — see docs/link
// PROTOCOL_2 §383) only fire once, on an actual transition. Injectable so tests
// pass a fresh tracker; a module-level default backs the live dispatcher.
function createTracker() {
  return { champActive: new Map(), cityGovernor: new Map() }
}
const defaultTracker = createTracker()

// Per-kind mappers, each pushing 0+ targets onto `out` (and updating `tracker`
// for the upsert-transition kinds). Split out of mapShardEvent so that function
// stays a trivial dispatch + the public-safety filter.
const serverStatusUp = (event, tracker, out) =>
  out.push({ streamId: 'server.status', ref: `up:${event.bootId || ''}` })
const serverStatusDown = (event, tracker, out) => out.push({ streamId: 'server.status', ref: 'down' })

const EVENT_MAPPERS = {
  'server.hello': serverStatusUp,
  'server.shutdown': serverStatusDown,
  'server.crashed': serverStatusDown,
  'house.decay': (event, tracker, out) => {
    if (String(event.to).toUpperCase() !== 'IDOC') return
    const ref = String(event.serial ?? '')
    out.push({ streamId: 'idoc.warning', ref }) // public — location only
    if (event.ownerAcct) {
      out.push({ streamId: 'house.idoc', ref, ownerAccount: event.ownerAcct }) // personal
    }
  },
  'champ.update': (event, tracker, out) => {
    const { serial } = event
    if (serial == null) return
    const wasActive = tracker.champActive.get(serial) === true
    const isActive = event.active === true
    tracker.champActive.set(serial, isActive)
    if (isActive && !wasActive) out.push({ streamId: 'champ.start', ref: String(serial) })
  },
  'champ.remove': (event, tracker) => {
    if (event.serial != null) tracker.champActive.delete(event.serial)
  },
  'city.update': (event, tracker, out) => {
    const { city } = event
    if (!city) return
    const gov = event.governor && event.governor.serial != null ? String(event.governor.serial) : null
    const prev = tracker.cityGovernor.get(city)
    tracker.cityGovernor.set(city, gov)
    // Only a real transition to a new governor, and never on first sight
    // (prev === undefined) so a reconnect snapshot isn't read as an election.
    if (prev !== undefined && gov && gov !== prev) {
      out.push({ streamId: 'governor.election', ref: String(city) })
    }
  },
  'vendor.sale': (event, tracker, out) => {
    if (event.ownerAcct) {
      out.push({ streamId: 'vendor.sale', ref: String(event.t ?? ''), ownerAccount: event.ownerAcct })
    }
  },
  'account.login.attempt': (event, tracker, out) => {
    if (event.acct) {
      out.push({ streamId: 'account.login', ref: String(event.t ?? ''), ownerAccount: event.acct })
    }
  },
}

// Map one shard event → an array of targets ({ streamId, ref, ownerAccount? }).
// May yield 0, 1, or 2 targets (an owner house.decay produces both the public
// idoc.warning and the personal house.idoc). Pure given `tracker`.
function mapShardEvent(event, tracker = defaultTracker) {
  if (!event || typeof event.kind !== 'string') return []
  const kind = event.kind
  const out = []

  const mapper = EVENT_MAPPERS[kind]
  if (mapper) mapper(event, tracker, out)

  // Defense in depth: a PUBLIC (non-personal) target may only ride a public-safe
  // kind. Personal targets are owner-keyed and delivered solely to the owner, so
  // they are exempt from the public allowlist (that is the whole point of the
  // owner-keyed split). This guarantees a sensitive kind can never leak publicly
  // even if a future mapping case is added carelessly.
  return out.filter((t) => (PERSONAL_STREAMS.has(t.streamId) ? true : PUBLIC_KINDS.has(kind)))
}

module.exports = { STREAMS, isValidStream, mapShardEvent, createTracker, PERSONAL_STREAMS }
