// ── /api/docs.json, with a module installed ────────────────────────────────
//
// The request-time half of docs/website/MODULE_API.md §6.1's settled decision.
// `swagger-output.json` is core's own routes and cannot be anything else: it is
// generated on a developer's machine and committed, so it has to come out the
// same regardless of what they had checked out, and a module arrives on the
// volume long after the image was built. The module's routes therefore reach the
// document only here, from the fragment it ships (§2.8, §6.1a).
//
// This boots the REAL app against a throwaway module directory, because the two
// things worth locking are properties of the served document rather than of the
// merge helper: that a started module's paths are IN it, and that core wins.
//
// The modules directory is written and MODULES_DIR set BEFORE app.js is required
// — the scan is synchronous and happens during that require.

process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

const fs = require('fs')
const os = require('os')
const path = require('path')

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

const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-module-docs-'))
const moduleDir = path.join(tmpRoot, 'atlas')
fs.mkdirSync(path.join(moduleDir, 'client', 'dist'), { recursive: true })
fs.writeFileSync(path.join(moduleDir, 'client', 'dist', 'entry.js'), 'export const hello = 1\n')
fs.writeFileSync(
  path.join(moduleDir, 'module.json'),
  JSON.stringify({
    id: 'atlas',
    name: 'Atlas',
    version: '1.0.0',
    coreApi: '^1.0.0',
    client: { entry: 'client/dist/entry.js' },
  }),
)
fs.writeFileSync(
  path.join(moduleDir, 'swagger-fragment.json'),
  JSON.stringify({
    paths: {
      '/api/v1/public/atlas/creatures': { get: { tags: ['Public · Atlas'], summary: 'List creatures' } },
      // The collision case, and the one that matters: a module trying to
      // redefine a path core already declares. Core wins and the module's
      // version is dropped (§6.1a) — a module cannot rewrite core's docs.
      '/api/v1/public/settings': { get: { summary: 'MODULE OVERRIDE' } },
    },
    tags: [{ name: 'Public · Atlas', description: 'from the module' }],
    components: {
      schemas: {
        AtlasCreature: { type: 'object' },
        // Same shape of collision, one section down.
        Error: { type: 'string', description: 'MODULE OVERRIDE' },
      },
    },
  }),
)
process.env.MODULES_DIR = tmpRoot

/* eslint-disable global-require */
const app = require('../src/app')
const loader = require('../src/modules/loader')
const db = require('../src/utils/db')
const coreSpec = require('../swagger/swagger-output.json')
const { docsSpec, reset } = require('../swagger/docsSpec')
/* eslint-enable global-require */

let server
let base

before(async () => {
  server = await new Promise((resolve) => {
    const s = app.listen(0, '127.0.0.1', () => resolve(s))
  })
  base = `http://127.0.0.1:${server.address().port}`
})

after(async () => {
  server.closeAllConnections()
  await new Promise((resolve) => server.close(resolve))
  await db.close()
  fs.rmSync(tmpRoot, { recursive: true, force: true })
})

const fetchSpec = async () => {
  const res = await fetch(`${base}/api/docs.json`)
  assert.equal(res.status, 200)
  return res.json()
}

test('a module that is not started contributes nothing', async () => {
  // It is `registered` here: loaded cleanly, onBoot not yet dispatched. Its
  // routes answer 503 in that state, so documenting them would send a client
  // somewhere it cannot go — the same reason the client chunk's <script> tag is
  // withheld until `started`.
  reset()
  const spec = await fetchSpec()
  assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
})

test('a started module\'s paths, tags and schemas are in the served document', async () => {
  loader.setState('atlas', 'started')
  const spec = await fetchSpec()

  assert.equal(spec.paths['/api/v1/public/atlas/creatures'].get.summary, 'List creatures')
  assert.ok(spec.tags.some((t) => t.name === 'Public · Atlas'))
  assert.deepEqual(spec.components.schemas.AtlasCreature, { type: 'object' })
})

test('core wins every collision, in every section', async () => {
  loader.setState('atlas', 'started')
  const spec = await fetchSpec()

  assert.notEqual(spec.paths['/api/v1/public/settings'].get.summary, 'MODULE OVERRIDE')
  assert.notEqual(spec.components.schemas.Error.description, 'MODULE OVERRIDE')
  assert.deepEqual(spec.components.schemas.Error, coreSpec.components.schemas.Error)
})

test('the committed spec is never mutated by the merge', async () => {
  // `swagger-output.json` is a require()d JSON module, so one in-place merge
  // would be permanent for the life of the process AND cumulative across
  // rebuilds — a module's paths surviving its own uninstall.
  loader.setState('atlas', 'started')
  await fetchSpec()
  assert.equal(coreSpec.paths['/api/v1/public/atlas/creatures'], undefined)
  assert.equal(coreSpec.components.schemas.AtlasCreature, undefined)
})

test('a state change rebuilds the document rather than serving the cached one', async () => {
  loader.setState('atlas', 'started')
  assert.ok((await fetchSpec()).paths['/api/v1/public/atlas/creatures'])

  loader.setState('atlas', 'disabled')
  assert.equal((await fetchSpec()).paths['/api/v1/public/atlas/creatures'], undefined)

  loader.setState('atlas', 'started')
  assert.ok((await fetchSpec()).paths['/api/v1/public/atlas/creatures'])
})

test('an unreadable fragment costs that module its paths and nothing else', async () => {
  // §4.4's bargain, applied here: one module's failure is never the site's. A
  // docs page that 500s is strictly worse than one missing a module's routes.
  loader.setState('atlas', 'started')
  const file = path.join(moduleDir, 'swagger-fragment.json')
  const good = fs.readFileSync(file, 'utf8')
  fs.writeFileSync(file, 'not json {')
  try {
    reset()
    const spec = await fetchSpec()
    assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
    assert.ok(spec.paths['/api/v1/public/settings'], 'core\'s own paths must survive')
  } finally {
    fs.writeFileSync(file, good)
    reset()
  }
})

test('a module with no fragment at all is simply absent', async () => {
  // Registering routes without documenting them is checked in the MODULE's CI,
  // where the routes are known. Core cannot tell a module with no routes from
  // one that forgot, so it does not guess.
  loader.setState('atlas', 'started')
  const file = path.join(moduleDir, 'swagger-fragment.json')
  const good = fs.readFileSync(file, 'utf8')
  fs.rmSync(file)
  try {
    reset()
    const spec = await fetchSpec()
    assert.equal(spec.paths['/api/v1/public/atlas/creatures'], undefined)
    assert.ok(Object.keys(spec.paths).length > 100)
  } finally {
    fs.writeFileSync(file, good)
    reset()
  }
})

test('before the loader has scanned, core\'s own spec is the answer', () => {
  // §7.6 makes every other accessor THROW when asked before modules.load(), so a
  // mis-ordered boot cannot be mistaken for an empty install. A request handler
  // is the exception: 500ing the docs page over it would be the wrong trade, and
  // core's routes are the honest answer to "what is documented" at that point.
  reset()
  const stub = { isLoaded: () => false }
  const original = Object.getOwnPropertyDescriptor(loader, 'isLoaded')
  Object.defineProperty(loader, 'isLoaded', { value: stub.isLoaded, configurable: true })
  try {
    assert.equal(docsSpec(coreSpec), coreSpec)
  } finally {
    Object.defineProperty(loader, 'isLoaded', original)
    reset()
  }
})

test('the interactive UI is rebuilt per request, not bound to boot\'s document', async () => {
  // Bound once at require time, /api/docs would show core's routes for the life
  // of the process while /api/docs.json showed the merged set.
  loader.setState('atlas', 'started')
  reset()
  const withModule = await fetch(`${base}/api/docs/`)
  assert.equal(withModule.status, 200)
  assert.match(await withModule.text(), /swagger/i)
})
