// ── Push-notification fan-out (content-free tickles) ───────────────────────
//
// The transport-agnostic publisher that turns a stream id into opt-in push
// notifications. It knows nothing about where the stream came from: the admin
// create/publish-post path calls publish('news.post', …), and utils/shardPush.js
// resolves a shard event to a stream and an owner and calls the same function.
//
// That split is MODULE_SYSTEM.md §1.8's second entanglement, inverted. This file
// used to own `fromShardEvent()`, which required the shardLinks model and the
// shard event mapper — core infrastructure reaching into game content. Now the
// content side calls in, and a module reaches this through `ctx.push.publish`.
//
// What actually leaves the server is a CONTENT-FREE tickle — `{ stream, ref }`,
// no sensitive data — POSTed to each subscribed device's UnifiedPush/ntfy
// endpoint. The app wakes and PULLS the real content over the authenticated,
// ownership-checked API. So ntfy is treated as an untrusted relay: a leaked topic
// reveals nothing, which is what lets it run with no per-user accounts
// (docs/android/PLAN.md §11).
//
// SECURITY: a device `endpoint` is a client-supplied URL the server makes
// server-side POSTs to — a classic SSRF vector. isAllowedEndpoint() gates every
// registration AND every publish: HTTPS only, never a private/loopback host, and
// (when configured) the origin must be in the shard's ntfy allow-set.

const pushDevicesModel = require('../model/pushDevices/pushDevices.model')
const log = require('./logger')('push-dispatch')

const TIMEOUT_MS = 5000

// Hosts that must never be POSTed to, even if the allow-set is empty (dev). This
// is a coarse literal check (no DNS resolution) — the real protection in prod is
// the configured allow-set below, which pins the single ntfy origin.
const PRIVATE_HOST =
  /^(localhost|127\.|0\.0\.0\.0|10\.|192\.168\.|169\.254\.|::1|fc00:|fd00:|172\.(1[6-9]|2\d|3[01])\.)/i

function toOrigin(u) {
  try {
    return new URL(u).origin
  } catch {
    return null
  }
}

// Allowed publish origins, from NTFY_ALLOWED_ORIGINS (comma-separated) or, failing
// that, NTFY_BASE_URL's origin. Empty when neither is set (dev fallback).
function allowedOrigins() {
  const raw = process.env.NTFY_ALLOWED_ORIGINS || process.env.NTFY_BASE_URL || ''
  return raw
    .split(',')
    .map((s) => toOrigin(s.trim()))
    .filter(Boolean)
}

// Is this endpoint safe to POST to? HTTPS + non-private host + (if an allow-set is
// configured) an allowed origin. With no allow-set (dev), any public HTTPS host is
// permitted; the private-host check still blocks the obvious SSRF targets.
function isAllowedEndpoint(endpoint) {
  let url
  try {
    url = new URL(String(endpoint))
  } catch {
    return false
  }
  if (url.protocol !== 'https:') return false
  if (PRIVATE_HOST.test(url.hostname)) return false
  const allow = allowedOrigins()
  if (allow.length === 0) return true
  return allow.includes(url.origin)
}

async function postTickle(endpoint, bodyStr, deps) {
  const doFetch = deps.fetchImpl || fetch
  if (!isAllowedEndpoint(endpoint)) {
    log.warn('skipping push to disallowed endpoint', { endpoint })
    return
  }
  const controller = new AbortController()
  const timeout = setTimeout(() => controller.abort(), TIMEOUT_MS)
  try {
    const headers = { 'Content-Type': 'application/json' }
    const token = process.env.NTFY_PUBLISH_TOKEN
    if (token) headers.Authorization = `Bearer ${token}`
    await doFetch(endpoint, { method: 'POST', headers, body: bodyStr, signal: controller.signal })
  } catch (err) {
    log.warn('push tickle failed', { message: err.message })
  } finally {
    clearTimeout(timeout)
  }
}

// Publish one content-free tickle. Public (ownerUserId absent) → every device
// whose user subscribes to the stream. Personal (ownerUserId set) → only that
// user's devices, and only if subscribed. Never throws.
async function publish(streamId, { ref, ownerUserId } = {}, deps = {}) {
  const devices = deps.pushDevices || pushDevicesModel
  let rows
  try {
    rows =
      ownerUserId != null
        ? await devices.endpointsForUserStream(ownerUserId, streamId)
        : await devices.endpointsForStream(streamId)
  } catch (err) {
    log.warn('push endpoint lookup failed', { streamId, message: err.message })
    return
  }
  if (!rows || rows.length === 0) return
  const bodyStr = JSON.stringify({ stream: streamId, ref: ref ?? null })
  await Promise.all(rows.map((r) => postTickle(r.endpoint, bodyStr, deps)))
}

module.exports = { publish, isAllowedEndpoint }
