// ── The announcement pipeline, once legs became registrations ──────────────
//
// Phase 2 PR 4 (docs/website/MODULE_SYSTEM.md §1.8): `announce_jobs`' two
// hardcoded leg column groups became `announce_job_legs` rows, and which legs
// exist is what modules/registries.js answers. These tests are about that
// property specifically — that nothing in the worker or the model knows the word
// "towncrier", and a leg nobody registered is handled rather than assumed away.
//
// Point the DB at a closed port BEFORE requiring anything; every DB call the
// model makes is stubbed.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

const { test, beforeEach, afterEach, after } = require('node:test')
const assert = require('node:assert/strict')

const registries = require('../src/modules/registries')
const announceJobs = require('../src/model/announceJobs/announceJobs.model')
const announceDb = require('../src/model/announceJobs/announceJobs.db')
const worker = require('../src/utils/announceWorker')
const posts = require('../src/model/posts/posts.model')
const db = require('../src/utils/db')

after(() => db.close())

const originals = {
  create: announceDb.create,
  findById: announceDb.findById,
  findByPostId: announceDb.findByPostId,
  findDue: announceDb.findDue,
  updateLeg: announceDb.updateLeg,
  ensureLegs: announceDb.ensureLegs,
  setStatus: announceDb.setStatus,
  linkAnnounceJob: posts.linkAnnounceJob,
  markAnnounced: posts.markAnnounced,
  getById: posts.getById,
}
afterEach(() => Object.assign(announceDb, {
  create: originals.create,
  findById: originals.findById,
  findByPostId: originals.findByPostId,
  findDue: originals.findDue,
  updateLeg: originals.updateLeg,
  ensureLegs: originals.ensureLegs,
  setStatus: originals.setStatus,
}) && Object.assign(posts, {
  linkAnnounceJob: originals.linkAnnounceJob,
  markAnnounced: originals.markAnnounced,
  getById: originals.getById,
}))

// Two legs that record what they were asked to do, standing in for core's
// discord and a module's own.
function fakeLegs() {
  const calls = []
  return {
    calls,
    a: { leg: 'discord', label: 'Discord #news', dispatch: async (p) => { calls.push(['discord', p.id]); return { ok: true } }, classify: (r) => (r.ok ? { outcome: 'done' } : { outcome: 'retry', error: 'x' }) },
    b: { leg: 'rust.motd', label: 'Server MOTD', dispatch: async (p) => { calls.push(['rust.motd', p.id]); return { ok: false, status: 503 } }, classify: (r) => (r.ok ? { outcome: 'done' } : { outcome: 'retry', error: 'down' }) },
  }
}

beforeEach(() => registries._reset())

function register(...legs) {
  const api = registries.stage('core')
  for (const l of legs) api.registerAnnounceLeg(l)
  registries.apply(api.staged)
}

// ── Enqueue ────────────────────────────────────────────────────────────────

test('enqueue creates one leg row per REGISTERED leg, whatever they are', () => {
  const legs = fakeLegs()
  register(legs.a, legs.b)

  let created = null
  announceDb.create = async (postId, ids) => { created = { postId, ids }; return 42 }
  posts.linkAnnounceJob = async () => {}

  return announceJobs.enqueue(9).then((jobId) => {
    assert.equal(jobId, 42)
    assert.deepEqual(created, { postId: 9, ids: ['discord', 'rust.motd'] })
  })
})

test('with no legs registered, a job is created with none — and rolls up done', async () => {
  let created = null
  announceDb.create = async (postId, ids) => { created = { postId, ids }; return 1 }
  posts.linkAnnounceJob = async () => {}
  await announceJobs.enqueue(9)
  assert.deepEqual(created.ids, [])

  announceDb.findById = async () => ({ id: 1, post_id: 9, status: 'pending', legs: [] })
  const statuses = []
  announceDb.setStatus = async (_id, s) => statuses.push(s)
  posts.markAnnounced = async () => {}
  const job = await announceJobs.refreshStatus(1)
  assert.equal(job.status, 'done')
  assert.deepEqual(statuses, ['done'])
})

// ── The worker dispatches through the registration ─────────────────────────

test('the worker dispatches each due leg through whatever registered it', async () => {
  const legs = fakeLegs()
  register(legs.a, legs.b)
  posts.getById = async () => ({ id: 5, title: 't', excerpt: 'e', body: null })

  const updates = []
  announceDb.findDue = async () => [{
    id: 1,
    post_id: 5,
    status: 'pending',
    legs: [
      { leg: 'discord', status: 'pending', attempts: 0, next_attempt_at: null },
      { leg: 'rust.motd', status: 'pending', attempts: 0, next_attempt_at: null },
    ],
  }]
  announceDb.updateLeg = async (jobId, leg, fields) => updates.push([leg, fields.status])
  announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'pending', legs: [] })
  announceDb.setStatus = async () => {}
  posts.markAnnounced = async () => {}

  await worker.tick(new Date())

  assert.deepEqual(legs.calls, [['discord', 5], ['rust.motd', 5]])
  // discord delivered; rust.motd got a 503, so it is rescheduled, not failed.
  assert.deepEqual(updates, [['discord', 'done'], ['rust.motd', 'pending']])
})

test('a leg row nobody registers any more is left alone, not failed', async () => {
  // Its module was uninstalled. Failing it would roll the job up terminal on the
  // strength of a leg that no longer exists, and reinstalling should resume it.
  register(fakeLegs().a)
  let touched = false
  announceDb.updateLeg = async () => { touched = true }
  posts.getById = async () => { throw new Error('must not even look up the post') }

  await worker.processLeg({ id: 1, post_id: 5, legs: [{ leg: 'gone.leg', status: 'pending', attempts: 0 }] }, 'gone.leg')
  assert.equal(touched, false)
})

test('isLegDue reads the row, not a leg-prefixed column', () => {
  const past = new Date(Date.now() - 1000)
  const future = new Date(Date.now() + 60_000)
  assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: null }, new Date()), true)
  assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: past }, new Date()), true)
  assert.equal(worker.isLegDue({ status: 'pending', next_attempt_at: future }, new Date()), false)
  assert.equal(worker.isLegDue({ status: 'done', next_attempt_at: null }, new Date()), false)
  assert.equal(worker.isLegDue({ status: 'failed', next_attempt_at: null }, new Date()), false)
})

// ── Retry, and the label the panel renders ─────────────────────────────────

test('resetLeg refuses a leg nobody registered', async () => {
  register(fakeLegs().a)
  await assert.rejects(() => announceJobs.resetLeg(5, 'rust.motd'), /unknown announce leg/)
})

test('resetLeg creates the row when a module was installed after the job', async () => {
  // Otherwise the retry button could never deliver a newly-installed module's
  // leg on an already-announced post: the worker only sees rows that exist.
  const legs = fakeLegs()
  register(legs.a, legs.b)
  const ensured = []
  announceDb.findByPostId = async () => ({ id: 1, post_id: 5, status: 'partial', legs: [{ leg: 'discord', status: 'done' }] })
  announceDb.ensureLegs = async (jobId, ids) => ensured.push([jobId, ids])
  announceDb.updateLeg = async () => {}
  announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'partial', legs: [{ leg: 'discord', status: 'done' }, { leg: 'rust.motd', status: 'pending' }] })
  announceDb.setStatus = async () => {}

  const job = await announceJobs.resetLeg(5, 'rust.motd')
  assert.deepEqual(ensured, [[1, ['rust.motd']]])
  assert.deepEqual(job.legs.map((l) => l.label), ['Discord #news', 'Server MOTD'])
})

test('a leg’s label comes from its registration, and an orphan keeps its id', async () => {
  register(fakeLegs().a)
  announceDb.findByPostId = async () => ({
    id: 1,
    post_id: 5,
    status: 'partial',
    legs: [{ leg: 'discord', status: 'done' }, { leg: 'gone.leg', status: 'pending' }],
  })
  const job = await announceJobs.getByPostId(5)
  assert.deepEqual(job.legs.map((l) => [l.leg, l.label]), [
    ['discord', 'Discord #news'],
    ['gone.leg', 'gone.leg'],
  ])
})

// ── recordOutcome reads attempts off the row ───────────────────────────────

test('recordOutcome takes the attempt count from the leg row', async () => {
  register(fakeLegs().a)
  const updates = []
  announceDb.updateLeg = async (jobId, leg, fields) => updates.push(fields)
  announceDb.findById = async () => ({ id: 1, post_id: 5, status: 'pending', legs: [{ leg: 'discord', status: 'pending' }] })
  announceDb.setStatus = async () => {}

  const job = { id: 1, post_id: 5, legs: [{ leg: 'discord', status: 'pending', attempts: 2 }] }
  await announceJobs.recordOutcome(job, 'discord', { outcome: 'retry', error: 'nope' })
  assert.equal(updates[0].attempts, 3)
  assert.ok(updates[0].nextAttemptAt instanceof Date)
})
