// ── Announcement dispatcher worker ──────────────────────────────────────────
//
// A lightweight, in-process table poller (no Redis/BullMQ in the stack). Every
// ANNOUNCE_POLL_MS it sweeps announce_jobs for legs that are due — freshly
// enqueued or past their backoff — and dispatches each one through the leg that
// registered itself for that id (modules/registries.js). Core registers
// `discord`; module-uo registers `towncrier`; another game's module registers its
// own, and nothing in this file changes.
//
// A leg's client never throws (they return { ok, status, error }) and its
// classify() turns that into done / retry / terminal, which the model converts to
// backoff + rollup. One leg failing never touches another. Same setInterval +
// unref + stop() shape as middleware/botScore's sweeper, wired into server.js
// start/shutdown.

const announceJobs = require('../model/announceJobs/announceJobs.model')
const announceJobsDb = require('../model/announceJobs/announceJobs.db')
const posts = require('../model/posts/posts.model')
const registries = require('../modules/registries')
const log = require('./logger')('announce-worker')

const POLL_MS = Number(process.env.ANNOUNCE_POLL_MS) || 15_000

// Process a single due leg of a job: fetch the post, dispatch, classify, record.
async function processLeg(job, leg) {
  const registered = registries.announceLeg(leg)
  if (!registered) {
    // A row for a leg nobody registers any more (its module was removed). Leave
    // it alone: failing it would make the job roll up terminal on the strength of
    // a leg that no longer exists, and reinstalling the module should resume it.
    return
  }

  const post = await posts.getById(job.post_id)
  if (!post) {
    // Post was deleted between enqueue and dispatch (the CASCADE usually reaps
    // the job first, but guard anyway). Nothing to announce — fail the leg.
    await announceJobs.recordOutcome(job, leg, { outcome: 'terminal', error: 'post no longer exists' })
    return
  }

  let classification
  try {
    classification = registered.classify(await registered.dispatch(post))
  } catch (err) {
    // Clients shouldn't throw, but if one does, treat it as a transient failure
    // rather than crashing the tick.
    log.error('dispatch threw', { jobId: job.id, leg, message: err.message })
    classification = { outcome: 'retry', error: err.message }
  }

  await announceJobs.recordOutcome(job, leg, classification)
}

// One sweep: find due jobs and process each due leg. A job may have several legs
// due at once (a fresh enqueue). `job` is a snapshot from the SELECT;
// recordOutcome re-reads for the rollup, so processing the legs sequentially off
// the same snapshot is fine (each leg only writes its own row).
async function tick(now = new Date()) {
  let jobs
  try {
    jobs = await announceJobsDb.findDue(now)
  } catch (err) {
    log.error('failed to load due jobs', { message: err.message })
    return
  }
  if (!jobs || jobs.length === 0) return

  for (const job of jobs) {
    for (const row of job.legs || []) {
      if (isLegDue(row, now)) await processLeg(job, row.leg)
    }
  }
}

function isLegDue(row, now) {
  if (!row || row.status !== 'pending') return false
  return row.next_attempt_at == null || new Date(row.next_attempt_at) <= now
}

let timer = null

function start() {
  if (timer) return timer
  timer = setInterval(() => {
    tick().catch((err) => log.error('announce tick failed', { message: err.message }))
  }, POLL_MS)
  if (timer.unref) timer.unref() // don't keep the event loop alive (tests, shutdown)
  log.info('announcement dispatcher started', { pollMs: POLL_MS, legs: registries.announceLegIds() })
  return timer
}

function stop() {
  if (timer) {
    clearInterval(timer)
    timer = null
  }
}

module.exports = { start, stop, tick, processLeg, isLegDue }
