/*
 * SPDX-License-Identifier: GPL-3.0-or-later
 */
package com.runicgateway.app.ui.navigation

import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull

/**
 * Apply the admin's stored public-nav overrides to the app's coded menu
 * (THEMING_AND_NAV.md §6). The Kotlin counterpart of the website's
 * `client/src/lib/navOverrides.js`, narrowed to what a drawer can express.
 *
 * **This is presentation, never authorization.** An override carries `label`,
 * `order` and `hidden` and nothing else: it cannot introduce a route, cannot
 * touch [MenuEntry.access] or [MenuEntry.feature], and cannot un-hide anything —
 * `hidden: false` is simply the absence of hiding. [visibleEntries] therefore runs
 * **after** this merge, unchanged, and remains the actual boundary (§6.1, AC-3).
 *
 * Fail-safe throughout, matching the web: anything unrecognized — an unknown path,
 * a non-string label, a path the app doesn't surface in its menu — is ignored
 * rather than rejected, so a stale or hand-edited settings row degrades to the
 * coded menu instead of rendering a broken drawer.
 */

/** A usable override for one menu row. Absent fields mean "as coded". */
internal data class NavOverride(
    val label: String? = null,
    val order: Double? = null,
    val hidden: Boolean = false,
    /**
     * The id of the section this row was dropped into, or null for a top-level
     * row. Read here but honored only by the tree build (`NavTree.kt`) — the flat
     * [applyNavOverrides] has nowhere to put it. Not validated against the stored
     * sections here; that is the tree's job, since only it knows them.
     */
    val section: String? = null,
) {
    /**
     * Nothing a **flat** list can express. [section] is deliberately not part of
     * this: to [applyNavOverrides] a section-only override says nothing, so an
     * instance that only ever grouped rows still gets its coded list back by
     * identity. The tree build adds its own check.
     */
    val isEmpty: Boolean get() = label == null && order == null && !hidden
}

/**
 * The `items` map out of a stored `nav_public` value.
 *
 * Two shapes exist, because website phase 10 added sections and links without
 * migrating what phases 6-8 had already stored: `{items, sections, links}` and a
 * bare map of path → override. A bare map is unambiguous — every key is a path,
 * so a key can never be the string `items`.
 *
 * `sections` and `links` come out of the same wrapper, and only ever out of the
 * wrapped shape — see [sectionsOf] and [linksOf].
 */
internal fun itemsOf(navPublic: JsonObject?): Map<String, JsonObject> {
    if (navPublic == null) return emptyMap()
    val items = wrapperOf(navPublic)?.get("items") as? JsonObject ?: navPublic
    return items.entries
        .mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
        .toMap()
}

/**
 * The stored value as the wrapped `{items, sections, links}` shape, or null when
 * it is the bare items map phases 6-8 wrote. The discriminator is the web's: an
 * `items` **object**, which a bare map can never carry because every key in one is
 * a path.
 */
private fun wrapperOf(navPublic: JsonObject?): JsonObject? =
    navPublic?.takeIf { it["items"] is JsonObject }

internal fun sectionsOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"sections")

internal fun linksOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"links")

private fun jsonObjectsAt(navPublic: JsonObject?, key: String): List<JsonObject> =
    (wrapperOf(navPublic)?.get(key) as? JsonArray)
        ?.mapNotNull { it as? JsonObject }
        .orEmpty()

// Field by field, like every other read in M12: a bad `label` must not discard a
// good `order` beside it.
//
// `group` is ignored — it names a section of the *admin sidebar*, a nav the app
// never renders, and a value it cannot honor is better dropped than half-applied.
internal fun cleanOverride(raw: JsonObject): NavOverride {
    val label = (raw["label"] as? JsonPrimitive)
        ?.takeIf { it.isString }
        ?.content
        ?.trim()
        ?.takeIf { it.isNotEmpty() }
    val order = (raw["order"] as? JsonPrimitive)
        ?.takeIf { !it.isString }
        ?.doubleOrNull
        ?.takeIf { it.isFinite() }
    val hidden = (raw["hidden"] as? JsonPrimitive)
        ?.takeIf { !it.isString }
        ?.booleanOrNull == true
    val section = (raw["section"] as? JsonPrimitive)
        ?.takeIf { it.isString }
        ?.content
        ?.takeIf { it.isNotEmpty() }
    return NavOverride(label = label, order = order, hidden = hidden, section = section)
}

/**
 * [base] with the admin's overrides applied: rows relabeled, reordered and
 * dropped as the stored row asks.
 *
 * @param base the coded menu — the only source of `route`, `access` and `feature`
 * @param navPublic the parsed `nav_public` row, or null when the admin never
 *   edited the nav. Null, malformed, and "nothing usable in it" all return [base]
 *   itself, which is what makes an untouched instance's drawer provably today's
 *   (§2, AC-1).
 */
fun applyNavOverrides(base: List<MenuEntry>, navPublic: JsonObject?): List<MenuEntry> {
    val items = itemsOf(navPublic)
    if (items.isEmpty()) return base

    val coded = base.map { it.route }.toSet()
    // Keyed by app route, and only for a route the coded menu actually declares.
    // This is where an override for a path the app doesn't surface in its drawer —
    // a news category tab, a Shard hub board — is dropped (§6.2). The web does the
    // same with an unknown `to`.
    val overrides = buildMap {
        for ((path, raw) in items) {
            val route = appRouteForWebPath(path) ?: continue
            if (route !in coded) continue
            val override = cleanOverride(raw)
            if (!override.isEmpty) put(route, override)
        }
    }
    if (overrides.isEmpty()) return base

    // Rows the website's nav knows about are the ones an override can move; the
    // app's own surfaces (Contact, Account, the player groups, the staff rows)
    // have no counterpart to be reordered against and keep their coded order,
    // appended after the public block — which is exactly where they sit today, so
    // this partition is the current layout rather than a new one (§6.2).
    val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }

    val sorted = mapped
        // An untouched row's sort key is its index in the WEBSITE's nav, not the
        // app's: a stored `order` is a position in that list, so both keys have to
        // sit on one number line to be comparable at all.
        //
        // Two tie-breaks, the web's: an explicit order beats a coincidental index
        // (the admin said "first", so first), and two explicit orders keep code
        // order, because the sort is stable.
        .sortedWith(
            compareBy<MenuEntry> { entry ->
                overrides[entry.route]?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble()
            }.thenByDescending { overrides[it.route]?.order != null },
        )

    return (sorted + appOnly).mapNotNull { entry ->
        val override = overrides[entry.route] ?: return@mapNotNull entry
        when {
            override.hidden -> null
            override.label != null -> entry.copy(label = override.label)
            else -> entry
        }
    }
}
