// ── The three de-entanglement registries ───────────────────────────────────
//
// Phase 2 PR 4 of docs/website/MODULE_SYSTEM.md §2.7. The properties worth a test
// are the ones nobody exercises by hand: what happens when two registrants want
// the same name, and what is left behind when one of them fails halfway.
//
// Point the DB at a closed port BEFORE requiring anything — registerCore() pulls
// in the announce legs, which pull in models that build a pool at require time.
// No query is ever run.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

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

const registries = require('../src/modules/registries')
const db = require('../src/utils/db')

// Declares `admin.users.detail` the way production does — at require time, in the
// router that owns the resource.
require('../src/router/v1/admin')

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

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

const stream = (id, over = {}) => ({ id, label: id, ...over })
const leg = (id, over = {}) => ({ leg: id, label: id, dispatch: async () => ({ ok: true }), classify: () => ({ outcome: 'done' }), ...over })

/** Register a batch as `owner` and return the error message, or null on success. */
function tryApply(owner, build) {
  const api = registries.stage(owner)
  try {
    build(api)
    registries.apply(api.staged)
    return null
  } catch (err) {
    return err.message
  }
}

// ── Core goes through the same door ────────────────────────────────────────

test('registerCore registers exactly what core owns, and nothing else', () => {
  registries.registerCore()

  // One stream, one leg, no filled slot. Before Phase 3 this was eight streams,
  // two legs and a core-filled `admin.users.detail` — core was holding shard
  // CONTENT so the seam would be exercised on every boot before a module first
  // used it. module-uo registers all of it now, through the same door.
  assert.deepEqual(registries.allStreams().map((s) => s.id), ['news.post'])
  assert.deepEqual(registries.announceLegIds(), ['discord'])
  assert.equal(registries.slotFilledBy('admin.users.detail'), null)
  assert.equal(registries.isCoreRegistered(), true)
})

test('registerCore is idempotent — a second call registers nothing twice', () => {
  registries.registerCore()
  const before = registries.allStreams().length
  registries.registerCore()
  assert.equal(registries.allStreams().length, before)
})

test('the wire shape of a stream survives registration', () => {
  // Asserted against a REGISTERED stream rather than a core one, because the
  // shape is what a module hands over and core republishes. `vendor.sale` used
  // to be the subject here and is module-uo's now; a synthetic registration
  // tests the same contract without core needing a personal stream of its own.
  assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([{
    id: 'uo.vendorsale',
    label: 'Vendor sales',
    description: 'One of your vendors sold something.',
    personal: true,
    requiresLinkedAccount: true,
  }])), null)

  const personal = registries.allStreams().find((s) => s.id === 'uo.vendorsale')
  // Two booleans, not the contract's single `scope`: this object is the body of
  // GET /auth/me/notifications/streams and a shipped Android client reads both.
  assert.equal(personal.personal, true)
  assert.equal(personal.requiresLinkedAccount, true)
  assert.ok(personal.description.length > 0)
  assert.ok(registries.personalStreams().has('uo.vendorsale'))
  assert.equal(registries.isValidStream('uo.vendorsale'), true)
  assert.equal(registries.isValidStream('nope.nope'), false)
})

// ── Namespacing ────────────────────────────────────────────────────────────

test('a module’s stream ids must carry its module id', () => {
  assert.equal(tryApply('rust', (api) => api.registerNotificationStreams([stream('rust.raid')])), null)
  assert.match(
    tryApply('rust', (api) => api.registerNotificationStreams([stream('raid.started')])),
    /not namespaced "rust\."/,
  )
})

test('the seven pre-module-system stream ids are grandfathered to uo alone', () => {
  // Renaming them would be a data migration (notification_subs rows) and a break
  // for a shipped Android client — the same reasoning as the loader's legacy
  // table prefixes.
  assert.equal(tryApply('uo', (api) => api.registerNotificationStreams([stream('vendor.sale')])), null)
  registries._reset()
  assert.match(
    tryApply('rust', (api) => api.registerNotificationStreams([stream('vendor.sale')])),
    /not namespaced "rust\."/,
  )
})

test('an announce leg must be namespaced too, with towncrier grandfathered to uo', () => {
  assert.equal(tryApply('uo', (api) => api.registerAnnounceLeg(leg('towncrier'))), null)
  registries._reset()
  assert.equal(tryApply('rust', (api) => api.registerAnnounceLeg(leg('rust.motd'))), null)
  registries._reset()
  assert.match(
    tryApply('rust', (api) => api.registerAnnounceLeg(leg('towncrier'))),
    /not namespaced "rust\."/,
  )
})

// ── Collisions name the holder ─────────────────────────────────────────────

test('a stream core already registered is refused, naming core', () => {
  registries.registerCore()
  assert.match(
    tryApply('uo', (api) => api.registerNotificationStreams([stream('news.post')])),
    /already registered by "core"/,
  )
})

test('two modules cannot register the same stream or leg', () => {
  assert.equal(tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])), null)
  // A second module can only reach it via its own namespace, so collide on a
  // grandfathered id, which is the realistic case.
  assert.match(
    tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.thing')])),
    /already registered by "aaa"/,
  )
  assert.equal(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), null)
  assert.match(tryApply('bbb', (api) => api.registerAnnounceLeg(leg('bbb.x'))), /already registered by "bbb"/)
})

test('a batch cannot claim the same name twice', () => {
  assert.match(
    tryApply('aaa', (api) => api.registerNotificationStreams([stream('aaa.x'), stream('aaa.x')])),
    /registered twice/,
  )
})

// ── Validate-then-commit ───────────────────────────────────────────────────

test('a batch whose LAST claim collides commits none of the earlier ones', () => {
  // The property the whole staging design exists for. A half-registered catalog
  // is worse than a missing one: a subscribable stream nothing will publish to.
  registries.registerCore()
  const before = registries.allStreams().length

  const err = tryApply('uo', (api) => {
    api.registerNotificationStreams([stream('uo.first'), stream('uo.second')])
    api.registerAnnounceLeg(leg('uo.leg'))
    api.registerNotificationStreams([stream('news.post')]) // collides with core
  })

  assert.match(err, /already registered by "core"/)
  assert.equal(registries.allStreams().length, before, 'uo.first / uo.second must not be registered')
  assert.equal(registries.isValidStream('uo.first'), false)
  assert.equal(registries.announceLeg('uo.leg'), null)
})

test('staging alone changes nothing — only apply() commits', () => {
  const api = registries.stage('uo')
  api.registerNotificationStreams([stream('uo.staged')])
  assert.equal(registries.isValidStream('uo.staged'), false)
  registries.apply(api.staged)
  assert.equal(registries.isValidStream('uo.staged'), true)
})

// ── Shape checks fire at the call ──────────────────────────────────────────

test('a malformed claim throws where the registrant made it, not at apply()', () => {
  const api = registries.stage('uo')
  assert.throws(() => api.registerNotificationStreams([stream('nodots')]), /bad stream id/)
  assert.throws(() => api.registerNotificationStreams([{ id: 'uo.x' }]), /has no label/)
  assert.throws(() => api.registerNotificationStreams('not an array'), /expected an array/)
  assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { dispatch: null })), /has no dispatch/)
  assert.throws(() => api.registerAnnounceLeg(leg('uo.x', { classify: null })), /has no classify/)
  assert.throws(() => api.registerAnnounceLeg({ leg: 'NOPE' }), /bad leg id/)
})

// ── Extension slots ────────────────────────────────────────────────────────

test('only a declared slot can be filled, and only once', () => {
  const router = () => {}
  const api = registries.stage('uo')
  assert.throws(() => api.registerExtension('admin.invented', router), /unknown extension slot/)
  assert.throws(() => api.registerExtension('admin.users.detail', 'not a router'), /is not a router/)

  // Core no longer fills it — module-uo does. Two registrants racing for the
  // same slot is still the case worth testing, so the first fill is a module's.
  assert.equal(tryApply('uo', (a) => a.registerExtension('admin.users.detail', router)), null)
  assert.match(
    tryApply('rust', (a) => a.registerExtension('admin.users.detail', router)),
    /already filled by "uo"/,
  )
})

test('a slot cannot be declared twice', () => {
  assert.throws(() => registries.declareSlot('admin.users.detail'), /already declared/)
})

// ── The slot's spec, which static analysis cannot see ──────────────────────

test('the filled slot’s router is findable in the live app, at the resource path', () => {
  // Guards swagger/slotSpecs.js: it recovers each slot's mount prefix from the
  // live stack rather than a hardcoded table. If this stops working, the six
  // slot routes vanish from swagger-output.json with `Success` printed — the
  // exact silent failure the spike hit (MODULE_API.md §7.4).
  /* eslint-disable global-require */
  const app = require('../src/app')
  const { findMountPrefix } = require('../swagger/slotSpecs')
  /* eslint-enable global-require */

  // The slot is DECLARED by core and filled by whichever module is installed —
  // none, in core's own test run. What must keep working regardless is the
  // recovery of its mount prefix from the live stack, because that is what
  // slotSpecs.js needs and what fails silently when it breaks.
  const router = registries.declaredSlotRouter('admin.users.detail')
  assert.ok(router, 'core declares the slot at require time, filled or not')
  assert.equal(findMountPrefix(app._router.stack, router), '/api/v1/admin/users/:id')
})
