// ── A module's client chunk, served by the real app ────────────────────────
//
// Phase 2, PR 7 of docs/website/MODULE_SYSTEM.md §2.7; the contract is
// MODULE_API.md §3.1. moduleLoader.test.js proves the loader resolves and
// validates the chunk; this file proves what the app does with the answer, and
// it boots the REAL app.js to do it — because the three properties worth locking
// are properties of the mount, not of the loader:
//
//   1. the module's own dist directory is published, and nothing above it;
//   2. the chunk is served behind the module's state guard, so a failed or
//      disabled module's client half is as absent as its API;
//   3. a miss is a 404 and never the SPA shell, which a browser would reject on
//      its MIME type after the request appeared to succeed.
//
// The modules directory is written and MODULES_DIR is set BEFORE app.js is
// required, because the scan is synchronous and happens during that require.
// Node's test runner gives each file its own process, so this cannot disturb
// another test's view of the loader.

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 CHUNK = 'export const hello = 1\n'

const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rg-module-chunk-'))
const dist = path.join(tmpRoot, 'uo', 'client', 'dist')
fs.mkdirSync(dist, { recursive: true })
fs.writeFileSync(path.join(dist, 'entry.js'), CHUNK)
fs.writeFileSync(path.join(dist, 'sidecar.js'), 'export const also = 2\n')
// The two files a static mount rooted one level too high would publish.
fs.writeFileSync(path.join(tmpRoot, 'uo', 'secrets.js'), 'const TOKEN = "leak"\n')
fs.writeFileSync(
  path.join(tmpRoot, 'uo', 'module.json'),
  JSON.stringify({
    id: 'uo',
    name: 'Ultima Online',
    version: '1.0.0',
    coreApi: '^1.0.0',
    client: { entry: 'client/dist/entry.js' },
  }),
)
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 htmlShell = require('../src/utils/htmlShell')
const settings = require('../src/model/settings/settings.model')
/* 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}`
  // The state the module would be in after a clean boot. lifecycle.js does this
  // against the database; here it is set directly, since what is under test is
  // what the mount does with a state, not how the state was reached.
  loader.setState('uo', 'started')
})

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

test('the chunk is served at the URL the shell injects', async () => {
  const [entryUrl] = loader.clientEntryUrls()
  assert.equal(entryUrl, '/modules/uo/entry.js')

  const res = await fetch(base + entryUrl)
  assert.equal(res.status, 200)
  assert.equal(await res.text(), CHUNK)
  // A module chunk is JavaScript to the browser or it is nothing: a `<script
  // type="module">` whose response is not a JS MIME type is refused outright.
  assert.match(res.headers.get('content-type'), /javascript/)
})

test('a sibling file in the same dist directory is served too', async () => {
  // Not incidental: Rollup can split a chunk, and the entry then imports its
  // siblings by relative URL. Publishing only the named entry would break every
  // module that is more than one file.
  const res = await fetch(`${base}/modules/uo/sidecar.js`)
  assert.equal(res.status, 200)
})

test('nothing above the dist directory is reachable', async () => {
  // The failure this rule exists to prevent: server source, module.json and the
  // schema fragment published to the internet by one over-broad static mount.
  for (const p of ['/modules/uo/module.json', '/modules/uo/secrets.js', '/modules/uo/../module.json']) {
    const res = await fetch(base + p)
    assert.notEqual(res.status, 200, `${p} must not be served`)
    assert.ok(!(await res.text()).includes('leak'))
  }
})

test('the chunk revalidates rather than being cached to a stale copy', async () => {
  // Vite's library build emits an unhashed entry.js, so an upgraded module would
  // otherwise keep serving yesterday's chunk out of the browser's disk cache.
  const res = await fetch(`${base}/modules/uo/entry.js`)
  assert.equal(res.headers.get('cache-control'), 'no-cache')
  assert.equal(res.headers.get('x-content-type-options'), 'nosniff')
})

test('a missing file is a 404, not the SPA shell', async () => {
  const res = await fetch(`${base}/modules/uo/nope.js`)
  assert.equal(res.status, 404)
  assert.ok(!(await res.text()).includes('<div id="root">'))
})

test('an unknown module id is not served at all', async () => {
  const res = await fetch(`${base}/modules/nope/entry.js`)
  assert.notEqual(res.status, 200)
})

test('a failed module’s chunk is 503 and a disabled one’s is 404', async () => {
  // The same answers the module's API routes give, and for the same reason: the
  // browser must not be running the client half of something the server half has
  // stopped serving.
  loader.setState('uo', 'startup_failed', { stage: 'boot', reason: 'onBoot threw' })
  let res = await fetch(`${base}/modules/uo/entry.js`)
  assert.equal(res.status, 503)

  loader.setState('uo', 'disabled')
  res = await fetch(`${base}/modules/uo/entry.js`)
  assert.equal(res.status, 404)

  // And the guard reads the LIVE state — the mount happened once, at boot, long
  // before any of these transitions.
  loader.setState('uo', 'started')
  res = await fetch(`${base}/modules/uo/entry.js`)
  assert.equal(res.status, 200)
})

test('the module is published to clients while it is started, and only then', async () => {
  // Ties the two surfaces together: /public/modules and the injected script tag
  // answer the same question — what is serving — and they must never disagree.
  const seen = async () => {
    const res = await fetch(`${base}/api/v1/public/modules`)
    return (await res.json()).modules.map((m) => m.id)
  }
  assert.deepEqual(await seen(), ['uo'])
  assert.deepEqual(loader.clientEntryUrls(), ['/modules/uo/entry.js'])

  loader.setState('uo', 'disabled')
  assert.deepEqual(await seen(), [])
  assert.deepEqual(loader.clientEntryUrls(), [])
  loader.setState('uo', 'started')
})

test('the shell hands the browser the tag for the chunk the app serves', async () => {
  // The one seam htmlShell.test.js cannot cover, because it renders against a
  // literal list: that the shell asks the LOADER, and gets back a URL this same
  // app answers 200 on. The two are wired through a lazy require inside a
  // try/catch, which is exactly the shape that can silently return [] forever.
  //
  // The settings read is stubbed rather than left to fail: the pool points at a
  // dead port, and its ten-second connect timeout would be paid here for a
  // fallback the test does not care about.
  settings.getShellBrand = async () => ({ logo: '', favicon: '', theme: null })
  htmlShell.init('<!doctype html><html><head><title>t</title></head><body><div id="root"></div></body></html>')
  const html = await htmlShell.get()

  assert.match(html, /<script type="module" src="\/modules\/uo\/entry\.js"><\/script>/)
  const res = await fetch(`${base}/modules/uo/entry.js`)
  assert.equal(res.status, 200)
})
