// Apply an admin's stored navigation overrides to a hardcoded NAV array.
//
// The three navs (public header, admin sidebar, player portal) stay declared in
// code; this layer only reorders, relabels and hides what is already there.
// See docs/website/THEMING_AND_NAV.md §7.
//
// **This is presentation, never authorization.** The override can carry
// `label`, `order`, `hidden` and — admin nav only — `group`, and nothing else.
// It cannot introduce a `to`, and it cannot touch `roles`, `feature`, `icon` or
// `end`, so the existing role/feature filters in SiteHeader and AdminLayout run
// *after* this merge, unchanged, and remain the actual boundary. An override
// saying `hidden: false` on a role-gated item still shows nothing to a viewer
// whose role check fails: hiding is subtractive here, never additive.
//
// Fail-safe throughout: anything unrecognized — an unknown `to`, a non-string
// label, a group that does not exist — is ignored rather than rejected, so a
// stale or hand-edited settings row degrades to the code default instead of
// rendering a broken nav.

// Two shapes are supported, because two exist:
//   flat    [{ to, label, ... }]                      — public header, player portal
//   grouped [{ title?, items: [{ to, label, ... }] }] — admin sidebar
// Exported for modules/nav.js, which has to answer the same question about the
// same array a moment earlier — one implementation, so the interleave and the
// merge can never disagree about which shape they are looking at.
export function isGrouped(nav) {
  return nav.length > 0 && nav.every((g) => g && Array.isArray(g.items))
}

// A stored override entry is usable only field by field: a bad `label` must not
// discard a good `order` alongside it.
function cleanEntry(raw, groupTitles) {
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
  const out = {}
  if (typeof raw.label === 'string' && raw.label.trim()) out.label = raw.label.trim()
  if (typeof raw.order === 'number' && Number.isFinite(raw.order)) out.order = raw.order
  if (raw.hidden === true) out.hidden = true
  // `group` may only name a section the base nav already declares. Anything else
  // — a renamed group, a typo, an invented category — is dropped, so an item can
  // never land in a header that does not exist.
  if (typeof raw.group === 'string' && groupTitles.has(raw.group)) out.group = raw.group
  return out
}

// Sort by effective order, where an item the admin never reordered keeps its
// index in the base array as its key. Two tie-breaks, in order: an explicit
// order beats a coincidental index (the admin said "first", so first), and two
// explicit orders stay in code order (the sort is stable).
//
// In practice the editor writes an order for every item in a list, the way
// drag-and-drop reordering does, so ties are the stale-row case rather than the
// normal one. They still have to resolve predictably.
function byOrder(items) {
  return items
    .map((item, index) => ({ item, key: item.__order ?? index, explicit: item.__order !== undefined }))
    .sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
    .map(({ item }) => {
      const { __order, ...rest } = item
      return rest
    })
}

// Apply label/hidden/order to one flat list, with the sort key parked on
// `__order` for byOrder to consume.
//
// `keepHidden` is what the admin editor needs and the site must not have: the
// editor has to render a hidden row in its right place so it can be un-hidden,
// while a layout must simply not render it. Same merge either way, so the two
// can never disagree about where an item sits.
function mergeItems(items, entries, keepHidden = false) {
  const out = []
  for (const item of items) {
    const o = entries.get(item.to)
    if (o?.hidden && !keepHidden) continue
    // Spread the base item first so `to`, `roles`, `feature`, `icon` and `end`
    // survive verbatim — the override only ever lands on `label`.
    out.push({
      ...item,
      ...(o?.label ? { label: o.label } : {}),
      ...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
      __order: o?.order,
    })
  }
  return out
}

// The stored overrides, cleaned and keyed, plus the group titles the base nav
// declares. Shared by the merge and the editor so both read a row the same way.
function readOverrides(baseNav, overrides, grouped) {
  const groupTitles = new Set(
    grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [],
  )
  const entries = new Map()
  if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return { entries, groupTitles }
  // Keyed by `to`, and only for a `to` the base nav actually declares. An
  // override for a route that no longer exists is dropped here, so deleting a
  // route in code can never leave a dangling override that does something
  // unexpected later.
  const known = new Set(
    grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to),
  )
  for (const [to, raw] of Object.entries(overrides)) {
    if (!known.has(to)) continue
    const entry = cleanEntry(raw, groupTitles)
    if (entry && Object.keys(entry).length > 0) entries.set(to, entry)
  }
  return { entries, groupTitles }
}

// Move items whose override names a different existing section. Groups keep
// their coded order — only membership and within-group order move.
function regroup(baseNav, entries) {
  const moved = new Map() // destination title → items pulled in from elsewhere
  const kept = baseNav.map((g) => {
    const items = []
    for (const item of g.items) {
      const o = entries.get(item.to)
      if (o?.group && o.group !== g.title) {
        if (!moved.has(o.group)) moved.set(o.group, [])
        moved.get(o.group).push(item)
        continue
      }
      items.push(item)
    }
    return { ...g, items }
  })
  return { kept, moved }
}

/**
 * @param {Array} baseNav  the hardcoded nav — the source of truth for `to`,
 *   `roles`, `feature`, `icon` and `end`
 * @param {object|null} overrides  the parsed settings JSON, keyed by `to`, or
 *   null when the admin never touched this nav
 * @returns {Array} a new array of the same shape, or `baseNav` itself when there
 *   is nothing to apply
 */
export function applyNavOverrides(baseNav, overrides) {
  if (!Array.isArray(baseNav)) return []
  // The untouched path, and the one that matters most: no row, a malformed row,
  // or a row with nothing usable in it all render the nav exactly as coded.
  if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) return baseNav

  const grouped = isGrouped(baseNav)
  const { entries } = readOverrides(baseNav, overrides, grouped)
  if (entries.size === 0) return baseNav

  if (!grouped) return byOrder(mergeItems(baseNav, entries))

  // Grouped: an item may also be moved into another *existing* titled section.
  const { kept, moved } = regroup(baseNav, entries)

  return kept
    .map((g) => ({
      ...g,
      items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries)),
    }))
    // A group whose every item was hidden must not leave an orphaned header.
    // AdminLayout drops empty groups again after its own role filter; doing it
    // here too keeps the util correct on its own.
    .filter((g) => g.items.length > 0)
}

// ── The admin editor's round trip ────────────────────────────────────────
//
// Two functions, inverse to each other, kept in this file rather than beside the
// editor screen so the thing that *writes* an override and the thing that
// *applies* one can never drift: the rows the admin drags are produced by the
// same merge the site renders, hidden ones included.

/**
 * The base nav plus its stored overrides, as editable rows — always in the
 * grouped shape, so one editor handles both navs.
 *
 * Unlike applyNavOverrides this keeps hidden rows (marked `hidden: true`, so
 * they can be un-hidden) and keeps empty groups (so something can be moved back
 * into one). Each row carries `defaultLabel`, which is what "reset this label"
 * restores and what the input shows as its placeholder.
 *
 * @param {Array} baseNav  the hardcoded nav, flat or grouped
 * @param {object|null} overrides  the parsed settings JSON
 * @returns {Array<{title: string|null, items: Array}>}
 */
export function buildNavRows(baseNav, overrides) {
  if (!Array.isArray(baseNav) || baseNav.length === 0) return []
  const grouped = isGrouped(baseNav)
  const { entries } = readOverrides(baseNav, overrides, grouped)

  if (!grouped) {
    return [{ title: null, items: byOrder(mergeItems(baseNav, entries, true)) }]
  }
  const { kept, moved } = regroup(baseNav, entries)
  return kept.map((g) => ({
    ...g,
    title: g.title ?? null,
    items: byOrder(mergeItems([...g.items, ...(moved.get(g.title) || [])], entries, true)),
  }))
}

// Did the admin actually move anything? Comparing the edited sequence with the
// coded one is what decides whether orders are written at all: an admin who only
// renamed an item should not pin the position of every other one, or a route
// added in code later would land in an arbitrary place.
//
// The base side is restricted to the rows the editor is actually holding: §8.1
// filters the palette to what this admin can themselves see, and an item that
// their role or a module's feature gate kept off the screen is not a reorder.
function orderMatchesBase(groups, baseNav) {
  const flatten = (gs) => gs.flatMap((g) => g.items.map((i) => `${g.title ?? ''}::${i.to}`))
  const base = isGrouped(baseNav)
    ? baseNav.map((g) => ({ title: g.title ?? null, items: g.items }))
    : [{ title: null, items: baseNav }]
  const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
  const a = flatten(groups)
  const b = flatten(base.map((g) => ({ ...g, items: g.items.filter((i) => shown.has(i.to)) })))
  return a.length === b.length && a.every((v, i) => v === b[i])
}

/**
 * The rows the admin has been editing, back as an overrides object to store.
 * Only differences from the code default are written — a field that matches the
 * default is absent, so the row stays a small statement of intent rather than a
 * snapshot of the nav.
 *
 * @param {Array} groups  the editor's groups, in their current order
 * @param {Array} baseNav  the hardcoded nav these rows came from
 * @param {object|null} stored  the overrides as loaded, so entries for items
 *   this admin could not see (role- or feature-gated out of their palette) are
 *   carried through rather than silently dropped on save
 * @returns {object} the overrides to store — `{}` when nothing differs
 */
export function buildNavOverrides(groups, baseNav, stored = null) {
  if (!Array.isArray(groups) || !Array.isArray(baseNav)) return {}
  const grouped = isGrouped(baseNav)
  const baseItems = new Map(
    (grouped ? baseNav.flatMap((g) => g.items.map((i) => [i, g.title ?? null])) : baseNav.map((i) => [i, null])).map(
      ([item, title]) => [item.to, { label: item.label, group: title }],
    ),
  )

  const out = {}
  // Carry through what this admin's palette never showed them. An entry for a
  // `to` the base nav no longer declares is NOT carried: dropping it is the
  // cleanup, and applyNavOverrides ignores it anyway.
  const shown = new Set(groups.flatMap((g) => g.items.map((i) => i.to)))
  if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
    for (const [to, entry] of Object.entries(stored)) {
      if (!shown.has(to) && baseItems.has(to) && entry && typeof entry === 'object') out[to] = entry
    }
  }

  const writeOrder = !orderMatchesBase(groups, baseNav)
  for (const group of groups) {
    group.items.forEach((row, index) => {
      const base = baseItems.get(row.to)
      if (!base) return
      const entry = {}
      const label = typeof row.label === 'string' ? row.label.trim() : ''
      if (label && label !== base.label) entry.label = label
      if (row.hidden === true) entry.hidden = true
      if (grouped && (group.title ?? null) !== base.group && group.title) entry.group = group.title
      if (writeOrder) entry.order = index
      if (Object.keys(entry).length > 0) out[row.to] = entry
    })
  }
  return out
}

// ── The public header: dropdown sections and added links ────────────────
//
// Phase 10. The public nav is the one nav an admin can restructure rather than
// only reorder: they may create dropdown **sections**, drop coded entries into
// them, and add **links** of their own to pages on this site.
//
// The invariant §7 rests on survives, and it survives structurally rather than
// by vigilance: coded entries stay keyed by a `to` the base array must declare,
// so an override still cannot invent a route or touch a `roles`/`feature` gate,
// while everything that CAN name an arbitrary path lives in `links` where the
// path rule is applied. An added link carries no gate of its own and needs none
// — the page behind it enforces its own access, so a link to somewhere the
// viewer cannot reach 403s exactly as typing the URL would.
//
// Stored shape (server/src/utils/navOverrides.js is the writer):
//   { items: {"<to>": {...}}, sections: [{id,label,order}], links: [{id,label,to,order,section}] }
// A bare map is still read as the items map — unambiguous, because every item
// key is a path and so can never be the string `items`.

function unwrapPublic(overrides) {
  if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
    return { items: {}, sections: [], links: [] }
  }
  const wrapped = overrides.items && typeof overrides.items === 'object' && !Array.isArray(overrides.items)
  const items = wrapped ? overrides.items : overrides
  const sections = wrapped && Array.isArray(overrides.sections) ? overrides.sections : []
  const links = wrapped && Array.isArray(overrides.links) ? overrides.links : []
  return { items, sections, links }
}

// Forgiving, like every other read here: an entry that is not usable is dropped
// and its neighbours kept.
function readSections(sections) {
  const out = []
  const seen = new Set()
  for (const s of sections) {
    if (!s || typeof s !== 'object' || typeof s.id !== 'string' || seen.has(s.id)) continue
    if (typeof s.label !== 'string' || !s.label.trim()) continue
    seen.add(s.id)
    out.push({ id: s.id, label: s.label.trim(), order: typeof s.order === 'number' && Number.isFinite(s.order) ? s.order : undefined })
  }
  return out
}

function readLinks(links, knownSections) {
  const out = []
  const seen = new Set()
  for (const l of links) {
    if (!l || typeof l !== 'object' || typeof l.id !== 'string' || seen.has(l.id)) continue
    if (typeof l.label !== 'string' || !l.label.trim()) continue
    // Same rule the server writes by. A stored value that would leave the origin
    // is dropped rather than rendered, so a hand-edited row cannot put an
    // off-site link in the header.
    if (typeof l.to !== 'string' || !l.to.startsWith('/') || l.to.startsWith('//') || /[\s<>"'\\]/.test(l.to)) continue
    seen.add(l.id)
    out.push({
      id: l.id,
      label: l.label.trim(),
      to: l.to,
      order: typeof l.order === 'number' && Number.isFinite(l.order) ? l.order : undefined,
      section: typeof l.section === 'string' && knownSections.has(l.section) ? l.section : null,
    })
  }
  return out
}

/**
 * The public nav as a one-level tree of `{kind: 'item' | 'link' | 'section'}`.
 *
 * @param {Array} baseNav  the hardcoded public NAV — still the only source of
 *   `to`, `feature` and `end` for a coded entry
 * @param {object|null} overrides  the parsed nav_public row
 * @param {{keepHidden?: boolean}} [opts]  the editor keeps hidden entries so
 *   they can be un-hidden, and gets `defaultLabel` for the reset affordance;
 *   the header must not render them at all
 * @returns {Array}
 */
export function buildPublicNav(baseNav, overrides, { keepHidden = false } = {}) {
  if (!Array.isArray(baseNav)) return []
  const { items, sections: rawSections, links: rawLinks } = unwrapPublic(overrides)

  const sections = readSections(rawSections)
  const knownSections = new Set(sections.map((s) => s.id))
  const links = readLinks(rawLinks, knownSections)

  // Coded entries, keyed by a `to` the base array declares. Anything else in the
  // map is dropped here, exactly as in applyNavOverrides.
  const known = new Set(baseNav.map((i) => i.to))
  const entries = new Map()
  for (const [to, raw] of Object.entries(items)) {
    if (!known.has(to)) continue
    const entry = cleanEntry(raw, new Set())
    if (!entry) continue
    if (typeof raw?.section === 'string' && knownSections.has(raw.section)) entry.section = raw.section
    entries.set(to, entry)
  }

  const nodes = []
  baseNav.forEach((item, index) => {
    const o = entries.get(item.to)
    if (o?.hidden && !keepHidden) return
    nodes.push({
      kind: 'item',
      ...item,
      ...(o?.label ? { label: o.label } : {}),
      ...(keepHidden ? { defaultLabel: item.label, hidden: o?.hidden === true } : {}),
      section: o?.section ?? null,
      __order: o?.order,
      __index: index,
    })
  })
  // An admin-created entity with no stored order appends after the coded ones,
  // in creation order, rather than jumping to the front on a 0 default.
  let next = baseNav.length
  for (const section of sections) {
    nodes.push({ kind: 'section', id: section.id, label: section.label, section: null, __order: section.order, __index: next++ })
  }
  for (const link of links) {
    nodes.push({ kind: 'link', id: link.id, to: link.to, label: link.label, section: link.section, __order: link.order, __index: next++ })
  }

  const place = (list) =>
    list
      .map((n) => ({ n, key: n.__order ?? n.__index, explicit: n.__order !== undefined }))
      .sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit))
      .map(({ n }) => {
        const { __order, __index, section, ...rest } = n
        return rest
      })

  const top = place(nodes.filter((n) => n.kind === 'section' || !n.section))
  return top.map((node) =>
    node.kind === 'section'
      ? { ...node, items: place(nodes.filter((n) => n.section === node.id)) }
      : node,
  )
}

/**
 * Apply the caller's visibility gate — and drop a section it leaves empty.
 *
 * Kept here rather than in SiteHeader because the empty-dropdown case is the one
 * with real correctness risk: a section whose every entry is hidden by a
 * module's visibility rules must not render as a menu that opens onto nothing.
 * The predicate stays the caller's, so this module still knows nothing about
 * what any module gates on.
 *
 * Added links carry no gate, so they are always visible — see the note above.
 *
 * @param {Array} tree  from buildPublicNav
 * @param {(item: object) => boolean} isVisible  applied to coded items only
 * @returns {Array}
 */
export function pruneNav(tree, isVisible) {
  if (!Array.isArray(tree)) return []
  const keep = (node) => node.kind !== 'item' || isVisible(node)
  return tree
    .map((node) => (node.kind === 'section' ? { ...node, items: (node.items || []).filter(keep) } : node))
    .filter((node) => (node.kind === 'section' ? node.items.length > 0 : keep(node)))
}

/**
 * The editor's tree back as a nav_public value to store.
 *
 * Returns the **bare items map** when there are no sections and no added links,
 * so a nav that does not use this feature stores exactly what phases 6-8 stored.
 *
 * @param {Array} tree  the editor's current tree
 * @param {Array} baseNav  the hardcoded public NAV
 * @param {object|null} stored  as loaded, so an entry for a feature-gated item
 *   this admin could not see survives their save
 * @returns {object} `{}` when nothing differs from the code default
 */
export function buildPublicNavOverrides(tree, baseNav, stored = null) {
  if (!Array.isArray(tree) || !Array.isArray(baseNav)) return {}
  const baseLabels = new Map(baseNav.map((i) => [i.to, i.label]))
  const sections = []
  const links = []
  const items = {}

  // Flatten to (node, containerId, indexInContainer), which is all the writer
  // needs: a section's own position is its index in the top-level list.
  const placed = []
  tree.forEach((node, index) => {
    placed.push({ node, section: null, index })
    if (node.kind === 'section') (node.items || []).forEach((child, i) => placed.push({ node: child, section: node.id, index: i }))
  })

  // Orders are written whenever this nav has any structure of its own: a section
  // exists only because the admin put it somewhere, so its position is never
  // "whatever the code says". Without sections the rule is phase 6-8's — write
  // orders only if the sequence actually moved.
  const hasStructure = tree.some((n) => n.kind === 'section' || n.kind === 'link')
  const shown = new Set(tree.flatMap((n) => (n.kind === 'section' ? (n.items || []) : [n])).filter((n) => n.kind === 'item').map((n) => n.to))
  const sequence = tree.filter((n) => n.kind === 'item').map((n) => n.to)
  const baseSequence = baseNav.filter((i) => shown.has(i.to)).map((i) => i.to)
  const moved = sequence.length !== baseSequence.length || sequence.some((to, i) => to !== baseSequence[i])
  const writeOrder = hasStructure || moved

  for (const { node, section, index } of placed) {
    if (node.kind === 'section') {
      sections.push({ id: node.id, label: (node.label || '').trim() || 'Section', ...(writeOrder ? { order: index } : {}) })
      continue
    }
    if (node.kind === 'link') {
      links.push({
        id: node.id,
        label: (node.label || '').trim() || node.to,
        to: node.to,
        ...(section ? { section } : {}),
        ...(writeOrder ? { order: index } : {}),
      })
      continue
    }
    const entry = {}
    const label = typeof node.label === 'string' ? node.label.trim() : ''
    if (label && label !== baseLabels.get(node.to)) entry.label = label
    if (node.hidden === true) entry.hidden = true
    if (section) entry.section = section
    if (writeOrder) entry.order = index
    if (Object.keys(entry).length > 0) items[node.to] = entry
  }

  // Carry through an entry for a coded item this admin's palette never showed
  // them (feature-gated by its module), so their save does not silently reset it.
  const { items: storedItems } = unwrapPublic(stored)
  for (const [to, entry] of Object.entries(storedItems)) {
    if (!shown.has(to) && baseLabels.has(to) && entry && typeof entry === 'object') items[to] = entry
  }

  if (sections.length === 0 && links.length === 0) return items
  const out = { items }
  if (sections.length) out.sections = sections
  if (links.length) out.links = links
  return out
}

export default applyNavOverrides
