// Point the DB at a closed port before anything builds the pool — this file only
// exercises pure functions, but requiring the util pulls in nothing else.
process.env.DB_HOST = '127.0.0.1'
process.env.DB_PORT = '59999'

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

// Phases 6-8 of docs/website/THEMING_AND_NAV.md: the server half of the nav
// overrides. The merge itself is the client's (client/src/lib/navOverrides.js,
// tested there); this module decides only what may be *stored*, and the two
// properties worth locking are the asymmetry — strict on write, forgiving on
// read — and the two things a stored row may never carry: a field that is not
// one of the four, and `hidden` on the nav editor's own row.
const { validateNavOverrides, resolveNavOverrides, NAV_KEYS } = require('../src/utils/navOverrides')

// ── validateNavOverrides — strict, and names what it rejected ──────────

test('null and undefined are valid — that is how every override is cleared', () => {
  assert.equal(validateNavOverrides(null).ok, true)
  assert.equal(validateNavOverrides(undefined).ok, true)
})

test('an empty object is valid — the caller deletes the row instead of storing it', () => {
  assert.equal(validateNavOverrides({}).ok, true)
})

test('a non-object is rejected under the key it was written to', () => {
  for (const bad of [4, 'x', true, [], [{ to: '/' }]]) {
    const check = validateNavOverrides(bad, 'nav_public')
    assert.equal(check.ok, false, `${JSON.stringify(bad)} should be rejected`)
    assert.match(check.message, /nav_public must be a JSON object/)
  }
})

test('a key that is not an app path is rejected and named', () => {
  for (const bad of [
    'site/news', // relative
    '//evil.example/x', // protocol-relative: looks like a path, leaves the origin
    'https://evil.example', // scheme
    '/site/ news', // whitespace
    '/site/"news"', // quotes
    '',
  ]) {
    const check = validateNavOverrides({ [bad]: { order: 1 } }, 'nav_public')
    assert.equal(check.ok, false, `'${bad}' should be rejected as a key`)
    assert.match(check.message, /must be an app path/)
  }
})

test('a valid app path is accepted as a key even when no nav declares it', () => {
  // Membership is the client's question: applyNavOverrides drops an unknown `to`
  // at merge time, so a route deleted in code needs no migration here.
  assert.equal(validateNavOverrides({ '/site/gone': { order: 3 } }).ok, true)
})

test('an entry that is not an object is rejected', () => {
  for (const bad of ['x', 4, null, []]) {
    const check = validateNavOverrides({ '/site/news': bad }, 'nav_public')
    assert.equal(check.ok, false, `${JSON.stringify(bad)} should be rejected as an entry`)
    assert.match(check.message, /must be an object/)
  }
})

test('an unknown field is rejected rather than silently ignored', () => {
  // Storing a value that will never apply is a bad admin experience, and a
  // field nobody validates is where a future `to`/`roles` would try to sneak in.
  for (const field of ['to', 'roles', 'feature', 'icon', 'end', 'href']) {
    const check = validateNavOverrides({ '/site/news': { [field]: 'x' } }, 'nav_public')
    assert.equal(check.ok, false, `'${field}' should be rejected`)
    assert.match(check.message, new RegExp(`Unknown nav field '${field}'`))
  }
})

test('each of the four fields is type-checked and named on failure', () => {
  const cases = [
    [{ label: 4 }, /label must be text/],
    [{ label: 'x'.repeat(65) }, /label must be text/],
    [{ group: 4 }, /group must be text/],
    [{ group: 'x'.repeat(65) }, /group must be text/],
    [{ order: '1' }, /order must be a number/],
    [{ order: Number.NaN }, /order must be a number/],
    [{ order: Number.POSITIVE_INFINITY }, /order must be a number/],
    [{ hidden: 'true' }, /hidden must be true or false/],
    [{ hidden: 1 }, /hidden must be true or false/],
  ]
  for (const [entry, pattern] of cases) {
    const check = validateNavOverrides({ '/site/news': entry }, 'nav_public')
    assert.equal(check.ok, false, `${JSON.stringify(entry)} should be rejected`)
    assert.match(check.message, pattern)
    assert.match(check.message, /\/site\/news/)
  }
})

test('all four fields together are accepted', () => {
  const check = validateNavOverrides({
    '/admin/houses': { label: 'Houses', order: 2, hidden: true, group: 'Moderation' },
  })
  assert.equal(check.ok, true)
})

test('an absurd number of entries is refused', () => {
  const many = {}
  for (let i = 0; i < 201; i += 1) many[`/site/p${i}`] = { order: i }
  const check = validateNavOverrides(many, 'nav_public')
  assert.equal(check.ok, false)
  assert.match(check.message, /at most 200 entries/)
})

// ── resolveNavOverrides — forgiving, and drops what would do nothing ───

test('unusable entries are dropped and their neighbours kept', () => {
  const out = resolveNavOverrides({
    '/site/news': { label: 'Announcements' },
    'not-a-path': { label: 'Ignored' },
    '/site/wiki': 'garbage',
    '/site/market': { hidden: true },
  })
  assert.deepEqual(out, {
    '/site/news': { label: 'Announcements' },
    '/site/market': { hidden: true },
  })
})

test('a label is trimmed, and a whitespace-only label falls back to the coded one', () => {
  assert.deepEqual(resolveNavOverrides({ '/x': { label: '  News  ' } }), { '/x': { label: 'News' } })
  assert.deepEqual(resolveNavOverrides({ '/x': { label: '   ' } }), {})
})

test('hidden: false is never stored — hiding is subtractive only', () => {
  // A stored `false` could read to a later consumer as "force visible", and
  // nothing in this layer may un-hide a role- or feature-gated item (§7).
  assert.deepEqual(resolveNavOverrides({ '/x': { hidden: false } }), {})
  assert.deepEqual(resolveNavOverrides({ '/x': { hidden: false, order: 2 } }), { '/x': { order: 2 } })
})

test('the nav editor cannot be hidden, even by a hand-written row', () => {
  // Hiding /admin/navigation would remove the only screen that can un-hide it.
  const out = resolveNavOverrides({ '/admin/navigation': { hidden: true, order: 9 } }, 'nav_admin')
  assert.deepEqual(out, { '/admin/navigation': { order: 9 } })
  // An entry that carried nothing else disappears entirely rather than storing
  // an empty object.
  assert.deepEqual(resolveNavOverrides({ '/admin/navigation': { hidden: true } }, 'nav_admin'), {})
})

test('the un-hideable rule is scoped to the admin nav', () => {
  // The same path in another row is meaningless, but it is also not special:
  // the rule protects the admin sidebar, which is the nav that renders it.
  assert.deepEqual(resolveNavOverrides({ '/admin/navigation': { hidden: true } }, 'nav_public'), {
    '/admin/navigation': { hidden: true },
  })
})

test('an entry left with no usable field is dropped, so `{}` is never stored', () => {
  assert.deepEqual(resolveNavOverrides({ '/x': {}, '/y': { label: 4 } }), {})
})

test('order survives as a number, including zero and negatives', () => {
  const out = resolveNavOverrides({ '/a': { order: 0 }, '/b': { order: -3 }, '/c': { order: 1.5 } })
  assert.deepEqual(out, { '/a': { order: 0 }, '/b': { order: -3 }, '/c': { order: 1.5 } })
})

test('a non-object resolves to {} rather than throwing', () => {
  for (const bad of [null, undefined, 'x', 4, []]) {
    assert.deepEqual(resolveNavOverrides(bad), {})
  }
})

test('NAV_KEYS names the three rows the controller validates', () => {
  assert.deepEqual(NAV_KEYS, ['nav_public', 'nav_admin', 'nav_player'])
})

// ── Sections and added links (phase 10) ───────────────────────────────────
//
// The public header may carry admin-created dropdown sections and links the
// admin authored. The invariant that has to survive is structural: `items` may
// only key routes the code declares, so it can never introduce one, while
// `links` is the one place an arbitrary path may be named — and is therefore
// the one place the path rule is applied.

const WRAPPED = {
  items: { '/site/champs': { section: 'sec_abcd', order: 0 } },
  sections: [{ id: 'sec_abcd', label: 'The World', order: 3 }],
  links: [{ id: 'lnk_wxyz', label: 'Guide', to: '/wiki/new-player-guide', section: 'sec_abcd', order: 1 }],
}

test('a bare items map is still valid and still stored as-is', () => {
  // Phases 6-8 wrote this shape, and a nav that does not use sections keeps it.
  assert.equal(validateNavOverrides({ '/site/news': { label: 'N' } }, 'nav_public').ok, true)
  assert.deepEqual(resolveNavOverrides({ '/site/news': { label: 'N' } }, 'nav_public'), {
    '/site/news': { label: 'N' },
  })
})

test('a wrapped value round-trips with its sections and links', () => {
  assert.equal(validateNavOverrides(WRAPPED, 'nav_public').ok, true)
  assert.deepEqual(resolveNavOverrides(WRAPPED, 'nav_public'), WRAPPED)
})

test('an added link must point at this site', () => {
  for (const to of ['https://evil.example', '//evil.example/x', 'javascript:alert(1)', 'wiki/guide', '/a b', '/a"b']) {
    const check = validateNavOverrides(
      { items: {}, links: [{ id: 'lnk_wxyz', label: 'Bad', to }] },
      'nav_public',
    )
    assert.equal(check.ok, false, `${to} should be refused`)
    assert.match(check.message, /must be a path on this site/)
  }
})

test('a link to a path that happens to be gated is allowed — the page is the gate', () => {
  // An added link carries no roles/feature of its own and does not need one: the
  // route behind it enforces its own access, exactly as typing the URL would.
  const check = validateNavOverrides(
    { items: {}, links: [{ id: 'lnk_wxyz', label: 'Admin', to: '/admin/users' }] },
    'nav_public',
  )
  assert.equal(check.ok, true)
})

test('section and link ids are constrained, and duplicates refused', () => {
  const bad = [
    [{ sections: [{ id: 'nope', label: 'X' }] }, /invalid id/],
    [{ sections: [{ id: 'sec_AB', label: 'X' }] }, /invalid id/],
    [{ sections: [{ id: 'sec_abcd', label: '' }] }, /label must be text/],
    [{ sections: [{ id: 'sec_abcd', label: 'A' }, { id: 'sec_abcd', label: 'B' }] }, /duplicate id/],
    [{ links: [{ id: 'sec_abcd', label: 'X', to: '/x' }] }, /invalid id/],
    [{ links: [{ id: 'lnk_abcd', label: 'A', to: '/a' }, { id: 'lnk_abcd', label: 'B', to: '/b' }] }, /duplicate id/],
  ]
  for (const [extra, pattern] of bad) {
    const check = validateNavOverrides({ items: {}, ...extra }, 'nav_public')
    assert.equal(check.ok, false, JSON.stringify(extra))
    assert.match(check.message, pattern)
  }
})

test('sections and links are bounded', () => {
  const sections = Array.from({ length: 13 }, (_, i) => ({ id: `sec_a${String(i).padStart(3, '0')}`, label: 'S' }))
  assert.match(validateNavOverrides({ items: {}, sections }, 'nav_public').message, /at most 12 sections/)
  const links = Array.from({ length: 41 }, (_, i) => ({ id: `lnk_a${String(i).padStart(3, '0')}`, label: 'L', to: '/x' }))
  assert.match(validateNavOverrides({ items: {}, links }, 'nav_public').message, /at most 40 added links/)
})

test('sections and links are dropped for the navs that cannot render them', () => {
  // The admin sidebar has its own coded sections and the player portal is three
  // flat rows; only the public header supports this.
  for (const key of ['nav_admin', 'nav_player']) {
    const out = resolveNavOverrides(WRAPPED, key)
    assert.equal(out.sections, undefined, key)
    assert.equal(out.links, undefined, key)
    // The item survives, minus the section it can no longer belong to.
    assert.deepEqual(out, { '/site/champs': { order: 0 } })
  }
})

test('an item or link naming a section that does not exist falls to the top level', () => {
  const out = resolveNavOverrides(
    {
      items: { '/site/champs': { section: 'sec_gone', order: 2 } },
      sections: [{ id: 'sec_abcd', label: 'Real' }],
      links: [{ id: 'lnk_wxyz', label: 'L', to: '/x', section: 'sec_gone' }],
    },
    'nav_public',
  )
  assert.equal(out.items['/site/champs'].section, undefined)
  assert.equal(out.links[0].section, undefined)
})

test('an unusable section or link is dropped, its neighbours kept', () => {
  const out = resolveNavOverrides(
    {
      items: {},
      sections: [{ id: 'sec_abcd', label: 'Keep' }, { id: 'bad', label: 'Drop' }],
      links: [
        { id: 'lnk_aaaa', label: 'Keep', to: '/keep' },
        { id: 'lnk_bbbb', label: 'Drop', to: 'https://evil.example' },
      ],
    },
    'nav_public',
  )
  assert.deepEqual(out.sections.map((s) => s.label), ['Keep'])
  assert.deepEqual(out.links.map((l) => l.label), ['Keep'])
})

test('a wrapper that resolves to nothing usable comes back empty', () => {
  // The caller deletes the row rather than storing a wrapper that says nothing.
  assert.deepEqual(resolveNavOverrides({ items: {}, sections: [], links: [] }, 'nav_public'), {})
  assert.deepEqual(resolveNavOverrides({ items: 'nope' }, 'nav_public'), {})
})
