import { test } from 'node:test'
import assert from 'node:assert/strict'

import { applyNavOverrides } from '../src/lib/navOverrides.js'

// The nav-override merge (docs/website/THEMING_AND_NAV.md §7.1) — the one piece
// of this feature with real correctness risk, so it is tested in isolation from
// React. Two properties matter above all others:
//
//   1. No override, or a useless one, renders the coded nav untouched.
//   2. The override cannot add a route, cannot touch a role/feature gate, and
//      cannot un-hide anything. It is presentation only.

const FLAT = [
  { label: 'Home', to: '/', end: true },
  { label: 'News', to: '/site/news' },
  { label: 'Wiki', to: '/wiki' },
  { label: 'Shard', to: '/site/shard', feature: 'status' },
]

const GROUPED = [
  { items: [{ to: '/admin', label: 'Dashboard', end: true, roles: ['admin', 'editor', 'moderator'] }] },
  {
    title: 'Content',
    items: [
      { to: '/admin/posts', label: 'Posts', roles: ['admin', 'editor'] },
      { to: '/admin/wiki', label: 'Wiki', roles: ['admin', 'editor'] },
    ],
  },
  {
    title: 'System',
    items: [
      { to: '/admin/settings', label: 'Settings', roles: ['admin'] },
      { to: '/admin/users', label: 'Users', roles: ['admin'] },
    ],
  },
]

const labels = (nav) => nav.map((i) => i.label)
const groupLabels = (nav) => nav.map((g) => [g.title ?? null, g.items.map((i) => i.label)])

// ── The untouched path ────────────────────────────────────────────────────

// Most instances will never set these keys. Absence must be a true no-op, and
// cheap: the same array reference back means no needless re-render either.
test('no override returns the base nav unchanged', () => {
  for (const overrides of [null, undefined, '', 0, [], 'not an object']) {
    assert.equal(applyNavOverrides(FLAT, overrides), FLAT)
  }
})

test('an override with nothing usable in it returns the base nav unchanged', () => {
  assert.equal(applyNavOverrides(FLAT, {}), FLAT)
  // Every field here is unusable: unknown route, blank label, non-numeric order,
  // hidden as a string rather than the boolean true.
  assert.equal(
    applyNavOverrides(FLAT, {
      '/does/not/exist': { label: 'Ghost', hidden: true },
      '/wiki': { label: '   ', order: 'first', hidden: 'yes' },
    }),
    FLAT,
  )
})

// ── The security boundary ─────────────────────────────────────────────────

// The single most important negative case: the override layer must never be a
// way to introduce a route into a nav.
test('an unknown `to` is ignored, never added', () => {
  const out = applyNavOverrides(FLAT, { '/admin/secret': { label: 'Secret', order: 0 } })
  assert.equal(out.length, FLAT.length)
  assert.ok(!out.some((i) => i.to === '/admin/secret'))
})

test('roles, feature, icon, end and to survive the merge verbatim', () => {
  const out = applyNavOverrides(FLAT, {
    '/site/shard': { label: 'Server Status', roles: ['player'], feature: null, to: '/evil' },
  })
  const shard = out.find((i) => i.to === '/site/shard')
  assert.equal(shard.label, 'Server Status') // the one thing an override may set
  assert.equal(shard.feature, 'status') // gate untouched
  assert.equal(shard.roles, undefined) // and not invented
  assert.ok(!out.some((i) => i.to === '/evil'))
})

test('hidden:false cannot un-hide anything — hiding is subtractive only', () => {
  // The item is still present after the merge; whether it renders is decided by
  // the caller's own role/feature filter, which this layer cannot reach.
  const out = applyNavOverrides(GROUPED, { '/admin/settings': { hidden: false } })
  assert.equal(out, GROUPED, 'a no-op override leaves the base nav alone')
})

// ── Flat navs: label, order, hidden ───────────────────────────────────────

test('label overrides only the labelled item', () => {
  const out = applyNavOverrides(FLAT, { '/site/news': { label: 'Announcements' } })
  assert.deepEqual(labels(out), ['Home', 'Announcements', 'Wiki', 'Shard'])
})

test('hidden drops the item', () => {
  const out = applyNavOverrides(FLAT, { '/wiki': { hidden: true } })
  assert.deepEqual(labels(out), ['Home', 'News', 'Shard'])
})

// An item the admin never reordered keeps its position in the coded array, so
// setting one order does not scramble the rest.
test('order moves one item and leaves the others in code order', () => {
  const out = applyNavOverrides(FLAT, { '/wiki': { order: -1 } })
  assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
})

test('two items given the same order keep their code order (stable sort)', () => {
  const out = applyNavOverrides(FLAT, { '/site/news': { order: 0 }, '/wiki': { order: 0 } })
  // News before Wiki — the tie resolves to the coded order, not to insertion
  // order in the settings JSON. Both precede Home, whose 0 is only its index.
  assert.deepEqual(labels(out), ['News', 'Wiki', 'Home', 'Shard'])
})

// An explicit order and an untouched item's index share one number line, so
// they can collide. "Put this first" has to actually mean first.
test('an explicit order beats an untouched item that merely sits at that index', () => {
  const out = applyNavOverrides(FLAT, { '/wiki': { order: 0 } })
  assert.deepEqual(labels(out), ['Wiki', 'Home', 'News', 'Shard'])
})

test('the merge does not mutate the base nav', () => {
  const before = JSON.stringify(FLAT)
  applyNavOverrides(FLAT, { '/wiki': { label: 'Library', order: 0, hidden: false } })
  assert.equal(JSON.stringify(FLAT), before)
})

test('no internal sort key leaks into the returned items', () => {
  const out = applyNavOverrides(FLAT, { '/wiki': { order: 1 } })
  for (const item of out) assert.ok(!('__order' in item), 'sort key must not be rendered')
})

// ── Grouped (admin) navs ──────────────────────────────────────────────────

test('label and order apply within a group', () => {
  const out = applyNavOverrides(GROUPED, {
    '/admin/wiki': { label: 'Knowledge Base', order: 0 },
  })
  assert.deepEqual(groupLabels(out), [
    [null, ['Dashboard']],
    ['Content', ['Knowledge Base', 'Posts']],
    ['System', ['Settings', 'Users']],
  ])
})

test('group moves an item into another existing section', () => {
  const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content' } })
  assert.deepEqual(groupLabels(out), [
    [null, ['Dashboard']],
    ['Content', ['Posts', 'Wiki', 'Users']],
    ['System', ['Settings']],
  ])
})

// A group that does not exist must not conjure a header. Groups are chosen from
// a dropdown of existing titles in the editor; this is the stale-row guard.
test('a group that is not an existing title is ignored', () => {
  const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Danger Zone' } })
  assert.deepEqual(groupLabels(out), [
    [null, ['Dashboard']],
    ['Content', ['Posts', 'Wiki']],
    ['System', ['Settings', 'Users']],
  ])
})

test('a moved item can be ordered in its new group', () => {
  const out = applyNavOverrides(GROUPED, { '/admin/users': { group: 'Content', order: -1 } })
  assert.deepEqual(groupLabels(out)[1], ['Content', ['Users', 'Posts', 'Wiki']])
})

test('hiding every item in a group leaves no orphaned header', () => {
  const out = applyNavOverrides(GROUPED, {
    '/admin/settings': { hidden: true },
    '/admin/users': { hidden: true },
  })
  assert.deepEqual(groupLabels(out), [
    [null, ['Dashboard']],
    ['Content', ['Posts', 'Wiki']],
  ])
})

test('group ordering itself is not overridable — sections stay in code order', () => {
  const out = applyNavOverrides(GROUPED, { '/admin/settings': { order: -99 } })
  assert.deepEqual(
    out.map((g) => g.title ?? null),
    [null, 'Content', 'System'],
  )
})

// ── Degenerate input ──────────────────────────────────────────────────────

test('a non-array base nav yields an empty nav rather than throwing', () => {
  assert.deepEqual(applyNavOverrides(null, { '/': { hidden: true } }), [])
  assert.deepEqual(applyNavOverrides(undefined, null), [])
})

test('an empty base nav stays empty', () => {
  assert.deepEqual(applyNavOverrides([], { '/': { label: 'Home' } }), [])
})
