// Navigation overrides — the `nav_public` / `nav_admin` / `nav_player` rows.
//
//   { "/site/news": { "label": "Announcements", "order": 2 },
//     "/site/market": { "hidden": true },
//     "/admin/houses": { "order": 1, "group": "Moderation" } }
//
// Keyed by an item's existing `to`; every field is optional and an absent one
// falls back to the code default (docs/website/THEMING_AND_NAV.md §6.4). The
// merge itself happens on the client — client/src/lib/navOverrides.js — and the
// role/feature filters in the layouts run *after* it, so this layer is
// presentation and never authorization (§7).
//
// **What this module cannot check, deliberately: whether a `to` exists.** The
// three base NAV arrays are client constants (SiteHeader.jsx, AdminLayout.jsx,
// PlayerPortalLayout.jsx). Shipping a copy of them to the server would create a
// second source of truth for navigation that drifts the first time a route is
// added, and it would buy nothing: `applyNavOverrides` already drops an entry
// whose `to` the base array does not declare, which is the right place for it —
// deleting a route in code stops mattering immediately, with no migration and no
// stale row doing something unexpected later. So the server validates *shape*
// and the client owns *membership*.
//
// Same strict-on-write / forgiving-on-read asymmetry as the theme and the brand
// assets (utils/themeResolve.js, utils/brandAssets.js): a bad write is rejected
// with the offending key named, while a bad stored value is dropped entry by
// entry so one hand-edited row does not cost the admin the rest of their nav.

// The overridable fields on a CODED item. `group` is only meaningful on the
// grouped admin nav and `section` only on the public header, but accepting both
// everywhere costs nothing — the merge util drops a group the base nav does not
// declare, and a section id no `sections` entry declares.
const FIELDS = ['label', 'order', 'hidden', 'group', 'section']

// Bounds. None of these is a security control on its own — the row is written by
// an admin and rendered as text by React — they keep a single settings row from
// growing without limit, and they are what makes "the admin nav has 21 items"
// the shape this store is sized for.
const MAX_ENTRIES = 200
const MAX_PATH = 128
const MAX_LABEL = 64
const MAX_GROUP = 64
const MAX_SECTIONS = 12
const MAX_LINKS = 40

// Only the public header supports admin-created dropdown sections and
// admin-authored links (THEMING_AND_NAV.md §7, Phase 10). The admin sidebar has
// its own coded sections and the player portal is three flat rows, so both keep
// the bare items map; `sections`/`links` are dropped for them rather than
// rejected, the same posture as every other unusable field here.
const SECTIONED_KEYS = ['nav_public']

// Generated by the editor, never typed. Constrained so a stored id is safe to
// use as a React key and as a DOM id fragment without further escaping.
const SECTION_ID = /^sec_[a-z0-9]{4,16}$/
const LINK_ID = /^lnk_[a-z0-9]{4,16}$/

// The one item an override may never hide: the nav editor itself. An admin who
// hid it would lose the only screen that can un-hide it, and "type the URL from
// memory" is not a recovery path. Enforced here as well as in the editor's UI so
// a hand-written row cannot do it either.
const UNHIDEABLE = { nav_admin: ['/admin/navigation'] }

/**
 * Is this a usable key — that is, something that could be a `to` in a nav array?
 * An app-internal path: absolute, same-origin, no scheme and no whitespace.
 * Whether it *is* one of the declared routes is the client's question (above).
 * @param {unknown} value
 * @returns {boolean}
 */
function isNavPath(value) {
  if (typeof value !== 'string' || value.length === 0 || value.length > MAX_PATH) return false
  if (!value.startsWith('/')) return false
  // `//host` is protocol-relative and would leave the origin despite looking
  // like a path; whitespace and quotes have no business in a route.
  if (value.startsWith('//') || /[\s<>"'\\]/.test(value)) return false
  return true
}

/**
 * Split a stored value into its three parts.
 *
 * The public header grew dropdown sections in Phase 10, so `nav_public` may be
 * a wrapper — `{ items, sections, links }` — while the other two navs stay the
 * bare items map phases 6-8 wrote. **A bare map is still read as the items
 * map**, which is unambiguous because every item key is a path beginning with
 * `/` and so can never be the string `items`.
 *
 * @param {object} value  a parsed, non-array object
 * @returns {{items: object, sections: unknown, links: unknown, wrapped: boolean}}
 */
function unwrap(value) {
  const wrapped = value.items && typeof value.items === 'object' && !Array.isArray(value.items)
  if (!wrapped) return { items: value, sections: undefined, links: undefined, wrapped: false }
  return { items: value.items, sections: value.sections, links: value.links, wrapped: true }
}

// A section is a dropdown an admin created: a label and a position, no route.
// It is never itself a link — it only opens — so there is no `to` to validate.
function validateSections(sections, key) {
  if (sections === undefined || sections === null) return { ok: true }
  if (!Array.isArray(sections)) return { ok: false, message: `${key}.sections must be an array` }
  if (sections.length > MAX_SECTIONS) {
    return { ok: false, message: `${key} may hold at most ${MAX_SECTIONS} sections` }
  }
  const seen = new Set()
  for (const section of sections) {
    if (!section || typeof section !== 'object' || Array.isArray(section)) {
      return { ok: false, message: `${key}.sections entries must be objects` }
    }
    if (typeof section.id !== 'string' || !SECTION_ID.test(section.id)) {
      return { ok: false, message: `${key}.sections has an entry with an invalid id` }
    }
    if (seen.has(section.id)) {
      return { ok: false, message: `${key}.sections has a duplicate id '${section.id}'` }
    }
    seen.add(section.id)
    if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) {
      return { ok: false, message: `${key}.sections['${section.id}'].label must be text of at most ${MAX_LABEL} characters` }
    }
    if (section.order !== undefined && (typeof section.order !== 'number' || !Number.isFinite(section.order))) {
      return { ok: false, message: `${key}.sections['${section.id}'].order must be a number` }
    }
  }
  return { ok: true }
}

// A link is the one thing an admin may ADD to a nav, and the only place a `to`
// is not required to already exist in code. It is kept in its own array rather
// than in `items` on purpose: `items` may only key routes the base array
// declares, so an override structurally cannot invent a route, and everything
// that CAN name an arbitrary path is here where the path rule is applied.
//
// A link carries no `roles` or `feature` of its own. It does not need one: the
// page behind it enforces its own access, so a link to somewhere the viewer
// cannot reach 403s exactly as typing the URL would (§7).
function validateLinks(links, key) {
  if (links === undefined || links === null) return { ok: true }
  if (!Array.isArray(links)) return { ok: false, message: `${key}.links must be an array` }
  if (links.length > MAX_LINKS) {
    return { ok: false, message: `${key} may hold at most ${MAX_LINKS} added links` }
  }
  const seen = new Set()
  for (const link of links) {
    if (!link || typeof link !== 'object' || Array.isArray(link)) {
      return { ok: false, message: `${key}.links entries must be objects` }
    }
    if (typeof link.id !== 'string' || !LINK_ID.test(link.id)) {
      return { ok: false, message: `${key}.links has an entry with an invalid id` }
    }
    if (seen.has(link.id)) {
      return { ok: false, message: `${key}.links has a duplicate id '${link.id}'` }
    }
    seen.add(link.id)
    if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) {
      return { ok: false, message: `${key}.links['${link.id}'].label must be text of at most ${MAX_LABEL} characters` }
    }
    // The whole point of the restriction: an added link points somewhere on this
    // site. No scheme, no `//host` — the nav is not a place to send visitors off
    // to an origin the operator does not control.
    if (!isNavPath(link.to)) {
      return { ok: false, message: `${key}.links['${link.id}'].to must be a path on this site, such as /wiki/new-player-guide` }
    }
    if (link.order !== undefined && (typeof link.order !== 'number' || !Number.isFinite(link.order))) {
      return { ok: false, message: `${key}.links['${link.id}'].order must be a number` }
    }
    if (link.section !== undefined && link.section !== null && typeof link.section !== 'string') {
      return { ok: false, message: `${key}.links['${link.id}'].section must be a section id` }
    }
  }
  return { ok: true }
}

/**
 * Validate a nav-override object for WRITING. Strict: names the offending key.
 * @param {unknown} value  the parsed object, or null to clear every override
 * @param {string} [key]  which nav row this is, for the messages
 * @returns {{ok: true} | {ok: false, message: string}}
 */
function validateNavOverrides(value, key = 'nav') {
  if (value === null || value === undefined) return { ok: true }
  if (typeof value !== 'object' || Array.isArray(value)) {
    return { ok: false, message: `${key} must be a JSON object` }
  }
  const { items, sections, links } = unwrap(value)
  if (!items || typeof items !== 'object' || Array.isArray(items)) {
    return { ok: false, message: `${key}.items must be a JSON object` }
  }
  const sectionCheck = validateSections(sections, key)
  if (!sectionCheck.ok) return sectionCheck
  const linkCheck = validateLinks(links, key)
  if (!linkCheck.ok) return linkCheck

  const entries = Object.entries(items)
  if (entries.length > MAX_ENTRIES) {
    return { ok: false, message: `${key} may hold at most ${MAX_ENTRIES} entries` }
  }
  for (const [to, entry] of entries) {
    if (!isNavPath(to)) {
      return { ok: false, message: `${key} key '${to}' must be an app path such as /site/news` }
    }
    if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
      return { ok: false, message: `${key}['${to}'] must be an object` }
    }
    for (const [field, fieldValue] of Object.entries(entry)) {
      if (!FIELDS.includes(field)) {
        return { ok: false, message: `Unknown nav field '${field}' on ${key}['${to}']` }
      }
      if (field === 'label' && (typeof fieldValue !== 'string' || fieldValue.length > MAX_LABEL)) {
        return { ok: false, message: `${key}['${to}'].label must be text of at most ${MAX_LABEL} characters` }
      }
      if (field === 'group' && (typeof fieldValue !== 'string' || fieldValue.length > MAX_GROUP)) {
        return { ok: false, message: `${key}['${to}'].group must be text of at most ${MAX_GROUP} characters` }
      }
      if (field === 'order' && (typeof fieldValue !== 'number' || !Number.isFinite(fieldValue))) {
        return { ok: false, message: `${key}['${to}'].order must be a number` }
      }
      if (field === 'section' && fieldValue !== null && typeof fieldValue !== 'string') {
        return { ok: false, message: `${key}['${to}'].section must be a section id` }
      }
      // `hidden: false` is not an error — it is simply the default, and the
      // editor sends it while a row is being edited. It is dropped below, never
      // stored, because hiding is subtractive only (§7): a stored `false` could
      // read as "force visible" to a later reader, and nothing may un-hide.
      if (field === 'hidden' && typeof fieldValue !== 'boolean') {
        return { ok: false, message: `${key}['${to}'].hidden must be true or false` }
      }
    }
  }
  return { ok: true }
}

/**
 * Keep only the entries and fields that would actually do something. Serves both
 * directions, like resolveBrandAssets:
 *
 *   • writing — an admin who cleared every override stores nothing, and the
 *     caller deletes the row instead, so "a row exists" keeps meaning "this nav
 *     was customised" (§4.1);
 *   • reading — a hand-edited entry is dropped and its neighbours kept.
 *
 * Sections and added links are honored only for the navs that can render them
 * (`nav_public`), and a `section` naming no surviving section falls back to the
 * top level rather than stranding the item in a dropdown that is not there.
 *
 * The return shape mirrors the input: a nav with no sections and no added links
 * resolves to the bare items map phases 6-8 wrote, so adding this feature
 * changed nothing at all for a nav that does not use it.
 *
 * @param {object|null} value  an object, or a parseJsonSetting result
 * @param {string} [key]  the settings key, so the un-hideable rule can apply
 * @returns {object} a new object, `{}` when nothing survives
 */
function resolveNavOverrides(value, key = 'nav') {
  if (!value || typeof value !== 'object' || Array.isArray(value)) return {}
  const { items, sections, links } = unwrap(value)
  if (!items || typeof items !== 'object' || Array.isArray(items)) return {}

  const sectioned = SECTIONED_KEYS.includes(key)
  const cleanSections = sectioned ? resolveSections(sections) : []
  const known = new Set(cleanSections.map((s) => s.id))
  const cleanLinks = sectioned ? resolveLinks(links, known) : []

  const out = resolveItems(items, key, known)
  if (cleanSections.length === 0 && cleanLinks.length === 0) return out
  // A section with nothing in it renders as an empty dropdown, so an admin who
  // emptied one has simply stopped using it — but it is theirs to keep until
  // they delete it, and the editor is where that happens. Kept here; the
  // renderer drops it (client/src/lib/navOverrides.js pruneNav).
  const wrapper = { items: out }
  if (cleanSections.length) wrapper.sections = cleanSections
  if (cleanLinks.length) wrapper.links = cleanLinks
  return wrapper
}

function resolveSections(sections) {
  const out = []
  const seen = new Set()
  if (!Array.isArray(sections)) return out
  for (const section of sections.slice(0, MAX_SECTIONS)) {
    if (!section || typeof section !== 'object' || Array.isArray(section)) continue
    if (typeof section.id !== 'string' || !SECTION_ID.test(section.id) || seen.has(section.id)) continue
    if (typeof section.label !== 'string' || !section.label.trim() || section.label.length > MAX_LABEL) continue
    seen.add(section.id)
    const clean = { id: section.id, label: section.label.trim() }
    if (typeof section.order === 'number' && Number.isFinite(section.order)) clean.order = section.order
    out.push(clean)
  }
  return out
}

function resolveLinks(links, knownSections) {
  const out = []
  const seen = new Set()
  if (!Array.isArray(links)) return out
  for (const link of links.slice(0, MAX_LINKS)) {
    if (!link || typeof link !== 'object' || Array.isArray(link)) continue
    if (typeof link.id !== 'string' || !LINK_ID.test(link.id) || seen.has(link.id)) continue
    if (typeof link.label !== 'string' || !link.label.trim() || link.label.length > MAX_LABEL) continue
    if (!isNavPath(link.to)) continue
    seen.add(link.id)
    const clean = { id: link.id, label: link.label.trim(), to: link.to }
    if (typeof link.order === 'number' && Number.isFinite(link.order)) clean.order = link.order
    if (typeof link.section === 'string' && knownSections.has(link.section)) clean.section = link.section
    out.push(clean)
  }
  return out
}

function resolveItems(items, key, knownSections) {
  const out = {}
  const unhideable = UNHIDEABLE[key] || []
  for (const [to, entry] of Object.entries(items)) {
    if (!isNavPath(to) || !entry || typeof entry !== 'object' || Array.isArray(entry)) continue
    const clean = {}
    // A label that is only whitespace is not a label — it would render an
    // unclickable-looking gap — so it falls back to the coded one.
    if (typeof entry.label === 'string' && entry.label.trim() && entry.label.length <= MAX_LABEL) {
      clean.label = entry.label.trim()
    }
    if (typeof entry.order === 'number' && Number.isFinite(entry.order)) clean.order = entry.order
    // Only the literal `true` is stored: `hidden: false` is the default and
    // carrying it would suggest an override that can un-hide something.
    if (entry.hidden === true && !unhideable.includes(to)) clean.hidden = true
    if (typeof entry.group === 'string' && entry.group.trim() && entry.group.length <= MAX_GROUP) {
      clean.group = entry.group.trim()
    }
    // Only a section that survived resolution: an item pointing at a deleted or
    // malformed one belongs at the top level, visible, rather than inside a
    // dropdown that no longer exists.
    if (typeof entry.section === 'string' && knownSections.has(entry.section)) clean.section = entry.section
    if (Object.keys(clean).length > 0) out[to] = clean
  }
  return out
}

module.exports = { validateNavOverrides, resolveNavOverrides, NAV_KEYS: ['nav_public', 'nav_admin', 'nav_player'] }
