// 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
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. Returns visible items only, with
// the sort key parked on `__order` for byOrder to consume.
function mergeItems(items, entries) {
  const out = []
  for (const item of items) {
    const o = entries.get(item.to)
    if (o?.hidden) 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 } : {}), __order: o?.order })
  }
  return out
}

/**
 * @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 groupTitles = new Set(
    grouped ? baseNav.map((g) => g.title).filter((t) => typeof t === 'string') : [],
  )

  // 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),
  )
  const entries = new Map()
  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)
  }
  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.
  // Groups keep their coded order — only membership and within-group order move.
  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
    .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)
}

export default applyNavOverrides
