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

import {
  applyNavOverrides,
  buildNavRows,
  buildNavOverrides,
  buildPublicNav,
  pruneNav,
  buildPublicNavOverrides,
} 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' } }), [])
})

// ── The editor's round trip (phase 7) ─────────────────────────────────────
//
// buildNavRows and buildNavOverrides are inverse, and the property that matters
// is that the editor and the site agree: the rows an admin drags come out of the
// same merge the layouts render, hidden ones included.

const rowLabels = (groups) => groups.map((g) => [g.title, g.items.map((i) => i.label)])

test('rows with no override are the coded nav, in code order', () => {
  const rows = buildNavRows(FLAT, null)
  assert.deepEqual(rowLabels(rows), [[null, ['Home', 'News', 'Wiki', 'Shard']]])
  assert.equal(rows[0].items.every((i) => i.hidden === false), true)
})

test('a flat nav becomes one untitled group, so one editor handles both shapes', () => {
  assert.equal(buildNavRows(FLAT, null).length, 1)
  assert.equal(buildNavRows(GROUPED, null).length, 3)
})

test('rows keep hidden items, in place and marked — the site drops them', () => {
  const overrides = { '/site/news': { hidden: true } }
  // The layout must not render it...
  assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Home', 'Wiki', 'Shard'])
  // ...while the editor must, or there is no way to un-hide it.
  const rows = buildNavRows(FLAT, overrides)[0].items
  assert.deepEqual(rows.map((i) => i.label), ['Home', 'News', 'Wiki', 'Shard'])
  assert.equal(rows[1].hidden, true)
  assert.equal(rows[0].hidden, false)
})

test('rows carry the coded label alongside the overridden one', () => {
  const rows = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })[0].items
  assert.equal(rows[1].label, 'Announcements')
  assert.equal(rows[1].defaultLabel, 'News')
})

test('rows show the same order the site renders', () => {
  const overrides = { '/wiki': { order: 0 }, '/': { order: 1 } }
  assert.deepEqual(labels(applyNavOverrides(FLAT, overrides)), ['Wiki', 'Home', 'News', 'Shard'])
  assert.deepEqual(rowLabels(buildNavRows(FLAT, overrides)), [[null, ['Wiki', 'Home', 'News', 'Shard']]])
})

test('rows keep an emptied group so something can be moved back into it', () => {
  // applyNavOverrides drops a group whose every item is hidden; the editor must
  // still show the header, or the section is unreachable forever.
  const overrides = { '/admin/posts': { hidden: true }, '/admin/wiki': { hidden: true } }
  assert.equal(applyNavOverrides(GROUPED, overrides).some((g) => g.title === 'Content'), false)
  assert.equal(buildNavRows(GROUPED, overrides).some((g) => g.title === 'Content'), true)
})

test('an untouched editor saves nothing at all', () => {
  // Opening the screen and pressing Save must not pin the position of every
  // item — the caller deletes the row when this comes back empty.
  assert.deepEqual(buildNavOverrides(buildNavRows(FLAT, null), FLAT), {})
  assert.deepEqual(buildNavOverrides(buildNavRows(GROUPED, null), GROUPED), {})
})

test('a rename alone writes a label and no orders', () => {
  const groups = buildNavRows(FLAT, null)
  groups[0].items[1].label = 'Announcements'
  assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/news': { label: 'Announcements' } })
})

test('a label typed back to the coded one is not stored as an override', () => {
  const groups = buildNavRows(FLAT, { '/site/news': { label: 'Announcements' } })
  groups[0].items[1].label = 'News'
  assert.deepEqual(buildNavOverrides(groups, FLAT), {})
  // Whitespace-only reads as "use the default" too.
  groups[0].items[1].label = '   '
  assert.deepEqual(buildNavOverrides(groups, FLAT), {})
})

test('hiding alone writes hidden and no orders', () => {
  const groups = buildNavRows(FLAT, null)
  groups[0].items[3].hidden = true
  assert.deepEqual(buildNavOverrides(groups, FLAT), { '/site/shard': { hidden: true } })
})

test('reordering writes an order for every row in the list', () => {
  // §7.1: explicit and implicit sort keys share one number line, so a partial
  // set of orders is the stale-row case rather than something the editor makes.
  const groups = buildNavRows(FLAT, null)
  const [home] = groups[0].items.splice(0, 1)
  groups[0].items.push(home)
  assert.deepEqual(buildNavOverrides(groups, FLAT), {
    '/site/news': { order: 0 },
    '/wiki': { order: 1 },
    '/site/shard': { order: 2 },
    '/': { order: 3 },
  })
})

test('the round trip is stable: save, reload, save again yields the same thing', () => {
  const groups = buildNavRows(FLAT, null)
  groups[0].items.reverse()
  groups[0].items[0].label = 'The Shard'
  const first = buildNavOverrides(groups, FLAT)
  const second = buildNavOverrides(buildNavRows(FLAT, first), FLAT)
  assert.deepEqual(second, first)
  // And it renders what the editor showed.
  assert.deepEqual(labels(applyNavOverrides(FLAT, first)), ['The Shard', 'Wiki', 'News', 'Home'])
})

test('moving an item to another section writes group, and moving it back clears it', () => {
  const groups = buildNavRows(GROUPED, null)
  const [posts] = groups[1].items.splice(0, 1)
  groups[2].items.push(posts)
  const saved = buildNavOverrides(groups, GROUPED)
  assert.equal(saved['/admin/posts'].group, 'System')
  assert.deepEqual(groupLabels(applyNavOverrides(GROUPED, saved)), [
    [null, ['Dashboard']],
    ['Content', ['Wiki']],
    ['System', ['Settings', 'Users', 'Posts']],
  ])
  const back = buildNavRows(GROUPED, saved)
  const [moved] = back[2].items.splice(2, 1)
  back[1].items.unshift(moved)
  assert.equal(buildNavOverrides(back, GROUPED)['/admin/posts'], undefined)
})

test('an override for an item outside this admin’s palette survives a save', () => {
  // §8.1 filters the editor to what the editing admin can themselves see. An
  // item filtered out has no row, and must not be quietly reset by their save.
  const visible = buildNavRows(FLAT, { '/site/shard': { hidden: true } }).map((g) => ({
    ...g,
    items: g.items.filter((i) => !i.feature),
  }))
  const stored = { '/site/shard': { hidden: true }, '/site/news': { label: 'Old' } }
  const out = buildNavOverrides(visible, FLAT, stored)
  assert.deepEqual(out['/site/shard'], { hidden: true })
  // The rows they *could* see still win over what was stored.
  assert.equal(out['/site/news'], undefined)
})

test('a stored entry for a route the code no longer declares is dropped on save', () => {
  const groups = buildNavRows(FLAT, null)
  const out = buildNavOverrides(groups, FLAT, { '/site/gone': { label: 'Ghost' } })
  assert.deepEqual(out, {})
})

test('degenerate input yields an empty result rather than throwing', () => {
  assert.deepEqual(buildNavRows(null, {}), [])
  assert.deepEqual(buildNavRows([], {}), [])
  assert.deepEqual(buildNavOverrides(null, FLAT), {})
  assert.deepEqual(buildNavOverrides([], null), {})
})

// ── The public header: sections and added links (phase 10) ────────────────
//
// The one nav an admin can restructure rather than only reorder. The invariant
// that has to survive is §7's, in its narrower form: a CODED entry still cannot
// have its `to` or `feature` touched, and everything that can name an arbitrary
// path lives in `links`, where the path rule applies.

const PUB = [
  { label: 'Home', to: '/', end: true },
  { label: 'News', to: '/site/news' },
  { label: 'Champions', to: '/site/champs', feature: 'champs' },
  { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
  { label: 'About', to: '/site/about' },
]
const shape = (tree) =>
  tree.map((n) => (n.kind === 'section' ? { [n.label]: n.items.map((i) => i.label) } : n.label))

test('no override yields the coded header, in code order', () => {
  assert.deepEqual(shape(buildPublicNav(PUB, null)), ['Home', 'News', 'Champions', 'Guilds', 'About'])
  assert.deepEqual(shape(buildPublicNav(PUB, {})), ['Home', 'News', 'Champions', 'Guilds', 'About'])
})

test('a phase 6-8 bare map still reads as the items map', () => {
  // Nothing has shipped, but a row written during review must not become
  // unreadable just because the wrapper arrived.
  assert.deepEqual(shape(buildPublicNav(PUB, { '/site/news': { label: 'Announcements' } })), [
    'Home',
    'Announcements',
    'Champions',
    'Guilds',
    'About',
  ])
})

const SECTIONED = {
  items: { '/site/champs': { section: 'sec_aaaa', order: 0 }, '/site/guilds': { section: 'sec_aaaa', order: 1 } },
  sections: [{ id: 'sec_aaaa', label: 'The World', order: 2 }],
  links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_aaaa', order: 2 }],
}

test('a section collects its members and sits in the top-level order', () => {
  assert.deepEqual(shape(buildPublicNav(PUB, SECTIONED)), [
    'Home',
    'News',
    { 'The World': ['Champions', 'Guilds', 'Guide'] },
    'About',
  ])
})

test('an added link is kept apart from the coded items', () => {
  const tree = buildPublicNav(PUB, SECTIONED)
  const link = tree.find((n) => n.kind === 'section').items.find((i) => i.kind === 'link')
  assert.equal(link.to, '/wiki/new-player-guide')
  assert.equal(link.id, 'lnk_bbbb')
  // It carries no gate of its own — that is the documented contract, and the
  // page behind it is what actually enforces access.
  assert.equal(link.feature, undefined)
  assert.equal(link.roles, undefined)
})

test('an off-origin link is dropped rather than rendered', () => {
  for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', '/x y', '/a"b']) {
    const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Bad', to }] })
    assert.equal(
      tree.some((n) => n.kind === 'link'),
      false,
      `${to} should be dropped`,
    )
  }
})

test('an item naming a section that does not exist stays at the top level', () => {
  const tree = buildPublicNav(PUB, { items: { '/site/champs': { section: 'sec_gone' } } })
  assert.deepEqual(shape(tree), ['Home', 'News', 'Champions', 'Guilds', 'About'])
})

test('an override still cannot introduce a coded route', () => {
  const tree = buildPublicNav(PUB, { items: { '/site/secret': { label: 'Secret' } } })
  assert.equal(
    tree.some((n) => n.to === '/site/secret'),
    false,
  )
})

test('hidden entries are dropped for the site and kept for the editor', () => {
  const overrides = { items: { '/site/news': { hidden: true } } }
  assert.equal(shape(buildPublicNav(PUB, overrides)).includes('News'), false)
  const rows = buildPublicNav(PUB, overrides, { keepHidden: true })
  assert.equal(rows.find((n) => n.to === '/site/news').hidden, true)
})

// ── pruneNav: the empty dropdown ──────────────────────────────────────────

test('a section keeps the entries the viewer may see', () => {
  const tree = buildPublicNav(PUB, SECTIONED)
  const out = pruneNav(tree, (i) => i.feature !== 'guilds')
  assert.deepEqual(shape(out), ['Home', 'News', { 'The World': ['Champions', 'Guide'] }, 'About'])
})

test('a section whose every entry is gated out does not render at all', () => {
  // The case that matters: a dropdown that opens onto nothing is worse than no
  // dropdown, and shard visibility can empty one at any time.
  const overrides = {
    items: { '/site/champs': { section: 'sec_aaaa' }, '/site/guilds': { section: 'sec_aaaa' } },
    sections: [{ id: 'sec_aaaa', label: 'The World' }],
  }
  const tree = buildPublicNav(PUB, overrides)
  assert.deepEqual(shape(pruneNav(tree, () => true)), [
    'Home',
    'News',
    'About',
    { 'The World': ['Champions', 'Guilds'] },
  ])
  assert.deepEqual(shape(pruneNav(tree, (i) => !i.feature)), ['Home', 'News', 'About'])
})

test('an added link is never pruned — it carries no gate', () => {
  const tree = buildPublicNav(PUB, { items: {}, links: [{ id: 'lnk_bbbb', label: 'Guide', to: '/wiki/g' }] })
  assert.equal(
    pruneNav(tree, () => false).some((n) => n.kind === 'link'),
    true,
  )
})

// ── The editor round trip ─────────────────────────────────────────────────

test('an untouched public editor saves nothing', () => {
  assert.deepEqual(buildPublicNavOverrides(buildPublicNav(PUB, null, { keepHidden: true }), PUB), {})
})

test('a nav with no sections still stores the plain items map', () => {
  // Adding this feature changed nothing for a nav that does not use it.
  const tree = buildPublicNav(PUB, null, { keepHidden: true })
  tree[1].label = 'Announcements'
  const out = buildPublicNavOverrides(tree, PUB)
  assert.deepEqual(out, { '/site/news': { label: 'Announcements' } })
  assert.equal(out.items, undefined)
})

test('the sectioned round trip is stable and renders what the editor showed', () => {
  const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
  const first = buildPublicNavOverrides(tree, PUB)
  const second = buildPublicNavOverrides(buildPublicNav(PUB, first, { keepHidden: true }), PUB)
  assert.deepEqual(second, first)
  assert.deepEqual(shape(buildPublicNav(PUB, first)), [
    'Home',
    'News',
    { 'The World': ['Champions', 'Guilds', 'Guide'] },
    'About',
  ])
})

test('deleting a section returns its entries to the top level, never deletes them', () => {
  // The one destructive act this screen could commit, so it is locked here.
  const tree = buildPublicNav(PUB, SECTIONED, { keepHidden: true })
  const section = tree.find((n) => n.kind === 'section')
  const flattened = [...tree.filter((n) => n.kind !== 'section'), ...section.items]
  const out = buildPublicNavOverrides(flattened, PUB)
  const rendered = buildPublicNav(PUB, out)
  assert.equal(
    rendered.some((n) => n.kind === 'section'),
    false,
  )
  assert.deepEqual(shape(rendered), ['Home', 'News', 'About', 'Champions', 'Guilds', 'Guide'])
})

test('an override for a feature-gated item outside the palette survives a save', () => {
  // §8.1 filters the editor to what this admin can see. The rows come from their
  // palette, but membership is judged against the FULL coded nav — otherwise a
  // row a shard feature hid from them is indistinguishable from a deleted route,
  // and their save would silently reset it.
  const palette = PUB.filter((i) => i.feature !== 'champs')
  // The editor was opened on a nav that only hides champs — which their palette
  // does not show them. `stored` additionally carries a label for a row they CAN
  // see, and which they have since reset.
  const tree = buildPublicNav(palette, { items: { '/site/champs': { hidden: true } } }, { keepHidden: true })
  const stored = { items: { '/site/champs': { hidden: true }, '/site/news': { label: 'Old' } } }
  const out = buildPublicNavOverrides(tree, PUB, stored)
  assert.deepEqual(out['/site/champs'], { hidden: true }, 'carried: they could not see it')
  assert.equal(out['/site/news'], undefined, 'not carried: their row is the authority for what they can see')
})

test('a stored entry for a route the code no longer declares is dropped on save', () => {
  const tree = buildPublicNav(PUB, null, { keepHidden: true })
  assert.deepEqual(buildPublicNavOverrides(tree, PUB, { items: { '/site/gone': { label: 'Ghost' } } }), {})
})
