import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

import {
  registry,
  registerRoutes,
  registerNav,
  registerFeatureProvider,
  routesFor,
  navFor,
  featureProviderFor,
  featureProviders,
  registeredIds,
  _reset,
} from '../src/modules/registry.js'
import { MODULE_API_VERSION } from '../src/modules/version.js'

// The client-side module registry (docs/website/MODULE_API.md §3.3). Tested in
// isolation from React, like the nav-override merge next door, because the
// property worth proving has nothing to do with rendering: a module gets exactly
// the URL namespace core gave it, however it spells the paths it registers.
//
// window.__rg itself (modules/shared.js) is not tested here — it imports .jsx and
// there is no DOM in this runner. What it publishes is React, the router and
// core components: a wiring test would assert that an import statement imported
// something. Phase 1's spike proved the half that can actually fail, which is a
// real chunk resolving its externals against the global in a browser under an
// enforced CSP.

beforeEach(() => _reset())

test('a module route is namespaced under the module id', () => {
  registerRoutes('uo', { public: [{ path: 'atlas', element: 'ATLAS' }] })
  assert.deepEqual(
    routesFor('public').map((r) => r.path),
    ['uo/atlas'],
  )
})

test('a module cannot spell its way out of its namespace', () => {
  // Whatever the module writes, the segment it lands under is core's to choose:
  // leading slashes, several of them, a trailing one, or nothing at all.
  registerRoutes('uo', {
    public: [
      { path: '/atlas' },
      { path: '//atlas/creatures' },
      { path: 'atlas/' },
      { path: '' },
    ],
  })
  assert.deepEqual(
    routesFor('public').map((r) => r.path),
    ['uo/atlas', 'uo/atlas/creatures', 'uo/atlas', 'uo'],
  )
})

test('a path is namespaced, not sanitised — traversal stays a literal segment', () => {
  // `..` is not stripped, and does not need to be: React Router matches path
  // patterns literally, so `/uo/../admin` is a route nothing navigates to rather
  // than a route that resolves somewhere else. Asserted so that a future
  // "cleanup" that starts resolving these knows it changed a behaviour.
  registerRoutes('uo', { public: [{ path: '../admin' }] })
  assert.deepEqual(routesFor('public')[0].path, 'uo/../admin')
})

test('routes keep their gate and carry the owning module id', () => {
  registerRoutes('uo', {
    admin: [{ path: 'shard-ops', element: 'OPS', gate: { roles: ['admin', 'moderator'] } }],
  })
  const [route] = routesFor('admin')
  assert.deepEqual(route.gate, { roles: ['admin', 'moderator'] })
  assert.equal(route.moduleId, 'uo')
  assert.equal(route.element, 'OPS')
})

test('the three areas are kept apart', () => {
  registerRoutes('uo', {
    public: [{ path: 'atlas' }],
    admin: [{ path: 'link' }],
    player: [{ path: 'chars' }],
  })
  assert.equal(routesFor('public').length, 1)
  assert.equal(routesFor('admin').length, 1)
  assert.equal(routesFor('player').length, 1)
  // An area nobody registered is an empty list, never undefined: App.jsx maps
  // over all three unconditionally.
  _reset()
  for (const area of ['public', 'admin', 'player']) assert.deepEqual(routesFor(area), [])
})

test('an unknown area throws rather than being dropped', () => {
  // Loudly, because the alternative is a module whose pages simply never appear
  // and no indication anywhere of why.
  assert.throws(() => registerRoutes('uo', { publik: [{ path: 'atlas' }] }), /unknown area/)
  assert.throws(() => registerNav('uo', { area: 'sidebar', items: [] }), /unknown area/)
  assert.equal(registeredIds().length, 0)
})

test('nav items sort by order, and equal orders keep load order', () => {
  registerNav('aa', { area: 'admin', items: [{ label: 'Second', to: '/a', order: 30 }] })
  registerNav('zz', { area: 'admin', items: [{ label: 'Third', to: '/z', order: 30 }] })
  registerNav('mm', { area: 'admin', items: [{ label: 'First', to: '/m', order: 10 }] })
  assert.deepEqual(
    navFor('admin').map((i) => i.label),
    ['First', 'Second', 'Third'],
  )
})

test('a nav item with no order sorts after the ones that asked for a place', () => {
  registerNav('uo', {
    area: 'public',
    items: [{ label: 'Unordered', to: '/u' }, { label: 'Early', to: '/e', order: 5 }],
  })
  assert.deepEqual(
    navFor('public').map((i) => i.label),
    ['Early', 'Unordered'],
  )
})

test('a feature provider is stored under its namespace, with its owner', () => {
  const hook = () => ({ atlas: true })
  registerFeatureProvider('uo', 'shard', hook)
  assert.deepEqual(featureProviderFor('shard'), { id: 'uo', hook })
  assert.equal(featureProviderFor('nothing'), undefined)
})

test('providers can be enumerated in registration order, with their owner', () => {
  // Core's feature context has to CALL each of these, as a hook, in a fixed
  // order — so it needs the list, and it needs the owner id to resolve a nav
  // row whose `moduleId` says who it belongs to (modules/features.jsx).
  const uo = () => null
  const rust = () => null
  registerFeatureProvider('uo', 'shard', uo)
  registerFeatureProvider('rust', 'server', rust)
  assert.deepEqual(featureProviders(), [
    { id: 'uo', namespace: 'shard', hook: uo },
    { id: 'rust', namespace: 'server', hook: rust },
  ])
})

test('enumerating providers is NOT part of the module-facing surface', () => {
  // A module asks for a namespace it knows the name of; enumerating what
  // everyone else registered is core's business, so `featureProviders` is a
  // module export and not a member of window.__rg.registry.
  assert.equal(registry.featureProviders, undefined)
  assert.equal(typeof featureProviders, 'function')
})

test('every registration marks the module registered', () => {
  registerRoutes('a', { public: [{ path: 'x' }] })
  registerNav('b', { area: 'public', items: [] })
  registerFeatureProvider('c', 'ns', () => {})
  assert.deepEqual(registeredIds().sort(), ['a', 'b', 'c'])
})

test('the registry object handed to modules exposes the whole surface', () => {
  // window.__rg.registry is the ONLY way a module reaches any of this, so a
  // member missing from the object is a member that does not exist.
  assert.deepEqual(Object.keys(registry).sort(), [
    'featureProviderFor',
    'navFor',
    'registerExtension',
    'registerFeatureProvider',
    'registerNav',
    'registerRoutes',
    'registeredIds',
    'routesFor',
  ])
})

test('the client and server halves declare the same MODULE_API_VERSION', () => {
  // The value is duplicated because it has to be on window.__rg before the first
  // module chunk evaluates, which is earlier than a fetch could answer. This is
  // the test that pays for the copy: a bump that edits one file fails here
  // instead of shipping a core whose two halves disagree about the contract they
  // implement.
  const here = path.dirname(fileURLToPath(import.meta.url))
  const server = fs.readFileSync(
    path.join(here, '..', '..', 'server', 'src', 'modules', 'version.js'),
    'utf8',
  )
  const match = server.match(/MODULE_API_VERSION\s*=\s*'([^']+)'/)
  assert.ok(match, 'server/src/modules/version.js no longer declares MODULE_API_VERSION as a literal')
  assert.equal(MODULE_API_VERSION, match[1])
})
