// The interleave of module nav items into core's nav.
//
// Phase 2, PR 8 of docs/website/MODULE_SYSTEM.md §2.7 (§1.4 states the problem);
// the normative contract is docs/website/MODULE_API.md §3.3.
//
// **Module items join the BASE array, before anything else happens to it.** That
// is the whole design of this file and the override merge next door forces it:
// `applyNavOverrides` / `buildPublicNav` are keyed by `to` and drop any key the
// base array does not declare (lib/navOverrides.js — deliberately, so a deleted
// route cannot leave a stale row doing something unexpected later). Append
// module items *after* that merge and they are unreachable to Admin →
// Navigation: unorderable, unrelabellable, unhideable. Today's UO rows are all
// three of those things, so appending would make the extraction a visible
// regression for every operator who has ever touched their nav.
//
// So the pipeline gains one step at the front and nothing else changes:
//
//   withModuleNav(NAV, area) → admin overrides → role/feature filter → rendered
//
// and the filter stays last, which is what keeps it the boundary an override
// cannot cross (THEMING_AND_NAV.md §7). MODULE_API.md §3.3 wrote those last two
// the other way round; the code is right and the contract was amended.
//
// The result is that a module row is, to everything downstream, an ordinary row.
// Nothing in navOverrides.js, NavEditor.jsx or the layouts knows a module exists.

import { navFor } from './registry.js'
import { isGrouped } from '../lib/navOverrides.js'

// Rows with no group of their own are collected under this key. A Symbol rather
// than a string so it cannot collide with a group an admin or a module names.
const UNGROUPED = Symbol('ungrouped')

/**
 * Sort by effective position, where a row that asked for nothing keeps the index
 * it already had. Three tie-breaks, in this order: an explicit `order` beats a
 * coincidental index (the module said "third", so third), and two explicit
 * orders keep registration order, which `navFor` has already put in scan order.
 *
 * The same rule byOrder/place use in lib/navOverrides.js, and it has to be — an
 * admin who then drags that row is editing the position this produced.
 */
function place(entries) {
  return entries
    .map((entry, index) => ({ ...entry, index }))
    .sort((a, b) => a.key - b.key || Number(b.explicit) - Number(a.explicit) || a.index - b.index)
    .map(({ item }) => item)
}

function entryFor(item, fallbackKey) {
  return { item, key: item.order ?? fallbackKey, explicit: item.order !== undefined }
}

function coreEntries(items) {
  return items.map((item, index) => ({ item, key: index, explicit: false }))
}

/** The `to`s a base nav already claims, flat or grouped. */
function claimedPaths(baseNav, grouped) {
  return new Set(grouped ? baseNav.flatMap((g) => g.items.map((i) => i.to)) : baseNav.map((i) => i.to))
}

/**
 * Drop a module row whose `to` is already on the nav, and say so.
 *
 * Not a policy about where a module may link — it is that `to` is the KEY the
 * override layer stores under and React renders by. Two rows sharing one would
 * give an admin a single editor row that silently moves both, and a duplicate
 * key in the rendered list. Dropping the newcomer keeps core's row, which is the
 * one any existing override was written against.
 *
 * Fail-safe like every other read in this area: the offending row goes, its
 * neighbours stay.
 */
function withoutCollisions(items, claimed) {
  const out = []
  for (const item of items) {
    if (!item || typeof item.to !== 'string' || !item.to) continue
    if (claimed.has(item.to)) {
      console.warn(
        `[modules] nav item "${item.to}" from module "${item.moduleId}" collides with an existing row and was dropped`,
      )
      continue
    }
    claimed.add(item.to)
    out.push(item)
  }
  return out
}

// The flat navs — the public header and the player portal.
//
// No groups, so `order` is a position in the one list: core rows are keyed by
// their index and a module row by the `order` it asked for. A module row with no
// order appends after the coded ones, in registration order, rather than jumping
// to the front on a 0 default — the same choice buildPublicNav makes for an
// admin-created link.
function mergeFlat(baseNav, items) {
  return place([...coreEntries(baseNav), ...items.map((item, i) => entryFor(item, baseNav.length + i))])
}

// The grouped nav — the admin sidebar.
//
// `group` names an existing core group and the row lands inside it: Moderation
// and System, where today's UO rows already sit (§1.4). An unknown group name
// creates a group at the end rather than dropping the row — a typo must cost a
// position, never a link. A row with no `group` at all lands in a trailing
// untitled group, which renders as ungrouped links; core does not invent a
// display title out of a module id.
//
// An ungrouped row is NOT folded into one of core's own untitled groups
// (Dashboard's, Account's): those are furniture pinned to the top and bottom of
// the sidebar, and a module page does not belong beside "Account".
//
// A group created here is a group as far as everything downstream is concerned,
// including as a destination in Admin → Navigation's "move to section" control:
// `readOverrides` builds its set of legal destinations from the base nav it is
// handed, which is this one.
function mergeGrouped(baseNav, items) {
  const titles = new Set(baseNav.map((g) => g.title).filter((t) => typeof t === 'string'))
  const into = new Map() // existing group title → rows
  const fresh = new Map() // new group title (or UNGROUPED) → rows, first-seen order

  for (const item of items) {
    const named = typeof item.group === 'string' && item.group ? item.group : null
    const key = named ?? UNGROUPED
    const bucket = named !== null && titles.has(named) ? into : fresh
    if (!bucket.has(key)) bucket.set(key, [])
    bucket.get(key).push(item)
  }

  const kept = baseNav.map((g) => {
    const incoming = into.get(g.title)
    if (!incoming) return g
    return {
      ...g,
      items: place([...coreEntries(g.items), ...incoming.map((item, i) => entryFor(item, g.items.length + i))]),
    }
  })

  const created = [...fresh.entries()].map(([key, rows]) => {
    const items_ = place(rows.map((item, i) => entryFor(item, i)))
    return key === UNGROUPED ? { items: items_ } : { title: key, items: items_ }
  })

  return [...kept, ...created]
}

/**
 * The base nav a layout should render: core's coded array with every installed
 * module's rows for this area interleaved into it.
 *
 * Returns `baseNav` ITSELF when no module registered anything for this area, so
 * an instance with no modules installed renders the identical array it renders
 * today — the same "untouched path" guarantee applyNavOverrides makes, and what
 * makes a `useMemo` with an empty dependency list around this call honest.
 *
 * Safe to call once per component and cache: registration completes before the
 * first render (main.jsx waits for DOMContentLoaded — MODULE_API.md §3.1) and
 * there is no unregistering, so this answer cannot change during a session.
 *
 * @param {Array} baseNav  the coded NAV, flat or grouped
 * @param {'public'|'admin'|'player'} area
 * @returns {Array} a nav of the same shape
 */
export function withModuleNav(baseNav, area) {
  if (!Array.isArray(baseNav)) return []
  const grouped = isGrouped(baseNav)
  const items = withoutCollisions(navFor(area), claimedPaths(baseNav, grouped))
  if (items.length === 0) return baseNav
  return grouped ? mergeGrouped(baseNav, items) : mergeFlat(baseNav, items)
}

export default withModuleNav
