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

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

/**
 * The drawer as a one-level tree: the coded menu, plus the **sections** an admin
 * grouped rows into and the **links** they added of their own (THEMING_AND_NAV.md
 * §6.3). The Kotlin counterpart of the website's `buildPublicNav` + `pruneNav`.
 *
 * The public nav is the one nav an admin can restructure rather than only reorder,
 * and §6.1's invariant survives that structurally rather than by vigilance: a
 * coded row is still keyed by a website path the app's own table declares, so an
 * override still cannot invent a destination or touch a gate, while everything
 * that *can* name an arbitrary path lives in [NavNode.Link], where the path rule
 * is applied and the result is resolved through [resolveWebPath].
 *
 * An added link carries no gate and needs none — the screen behind it enforces its
 * own access, so a link to somewhere this caller cannot reach lands on that
 * screen's own honest state, exactly as typing the URL on the web does.
 */
sealed interface NavNode {

    /** A coded [MenuEntry], relabeled/reordered by the merge but never re-gated. */
    data class Item(val entry: MenuEntry) : NavNode

    /**
     * An admin-authored link to a page on this site.
     *
     * @param path the stored website path, already validated — this is what a
     *   Custom Tab opens, resolved against the site's base URL
     * @param route the app route [path] maps to, or null when the app has no
     *   screen for it and the link must hand off (§6.3)
     */
    data class Link(
        val id: String,
        val label: String,
        val path: String,
        val route: String?,
    ) : NavNode

    /**
     * A drawer group: its [label] as a header, its [items] beneath it.
     *
     * The website renders these as click-to-open dropdowns; a drawer is already a
     * vertical list, so the app renders the group open (§6.3). Never empty — see
     * [pruneNav].
     */
    data class Section(
        val id: String,
        val label: String,
        val items: List<NavNode>,
    ) : NavNode
}

/** A usable `sections` entry. */
private data class SectionSpec(val id: String, val label: String, val order: Double?)

/** A usable `links` entry, with its section already checked against the stored ones. */
private data class LinkSpec(
    val id: String,
    val label: String,
    val to: String,
    val order: Double?,
    val section: String?,
)

/** One node waiting to be placed: its sort key, and whether that key was stored. */
private data class Placed(val node: NavNode, val section: String?, val key: Double, val explicit: Boolean)

/**
 * Characters that must never appear in a stored link path. The same rule the
 * website applies on read: a value that would leave the origin, or carry markup
 * into a link, is dropped rather than rendered.
 */
private val FORBIDDEN_IN_PATH = Regex("""[\s<>"'\\]""")

// Forgiving, like every other read in M12: an entry that is not usable is dropped
// and its neighbours kept. A repeated id is dropped too — the first wins, since
// the id is what a link's identity in the drawer is.
private fun readSections(raw: List<JsonObject>): List<SectionSpec> {
    val seen = mutableSetOf<String>()
    return raw.mapNotNull { section ->
        val id = section.stringOrNull("id") ?: return@mapNotNull null
        val label = section.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
        if (!seen.add(id)) return@mapNotNull null
        SectionSpec(id = id, label = label, order = section.orderOrNull())
    }
}

private fun readLinks(raw: List<JsonObject>, knownSections: Set<String>): List<LinkSpec> {
    val seen = mutableSetOf<String>()
    return raw.mapNotNull { link ->
        val id = link.stringOrNull("id") ?: return@mapNotNull null
        val label = link.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
        val to = link.stringOrNull("to") ?: return@mapNotNull null
        if (!to.startsWith("/") || to.startsWith("//") || FORBIDDEN_IN_PATH.containsMatchIn(to)) {
            return@mapNotNull null
        }
        if (!seen.add(id)) return@mapNotNull null
        LinkSpec(
            id = id,
            label = label,
            to = to,
            order = link.orderOrNull(),
            // A link naming a section that does not exist is a top-level link, not
            // a dropped one: the admin's destination is still good.
            section = link.stringOrNull("section")?.takeIf { it in knownSections },
        )
    }
}

private fun JsonObject.stringOrNull(key: String): String? =
    (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content

private fun JsonObject.orderOrNull(): Double? =
    (this["order"] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() }

// Two tie-breaks, the web's and phase 5's: an explicit order beats a coincidental
// index (the admin said "first", so first), and two explicit orders keep
// declaration order, because the sort is stable.
private fun List<Placed>.place(): List<NavNode> =
    sortedWith(compareBy<Placed> { it.key }.thenByDescending { it.explicit }).map { it.node }

/**
 * The coded menu with the admin's `nav_public` applied in full: relabeled,
 * reordered and hidden as phase 5 already did, plus grouped into sections and
 * joined by added links.
 *
 * With no sections and no links this **is** phase 5 — [applyNavOverrides] answers,
 * so an untouched instance still gets [APP_MENU] back by identity and AC-1's proof
 * is unchanged (§2). The tree build only runs when the admin actually created
 * structure.
 *
 * @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
 */
fun buildNavTree(base: List<MenuEntry>, navPublic: JsonObject?): List<NavNode> {
    val sections = readSections(sectionsOf(navPublic))
    val links = readLinks(linksOf(navPublic), sections.map { it.id }.toSet())
    if (sections.isEmpty() && links.isEmpty()) {
        return applyNavOverrides(base, navPublic).map { NavNode.Item(it) }
    }

    val knownSections = sections.map { it.id }.toSet()
    val coded = base.map { it.route }.toSet()
    // Keyed by app route, and only for a route the coded menu declares — the same
    // narrowing as the flat merge, so an override for a path the app maps but does
    // not surface (a news category tab, a Shard hub board) is dropped here too.
    val overrides = buildMap {
        for ((path, raw) in itemsOf(navPublic)) {
            val route = appRouteForWebPath(path) ?: continue
            if (route !in coded) continue
            val override = cleanOverride(raw)
            // A section the stored value never declares is no section at all.
            val section = override.section?.takeIf { it in knownSections }
            if (!override.isEmpty || section != null) put(route, override.copy(section = section))
        }
    }

    // The app's own surfaces (Contact, Account, the player groups, the staff rows)
    // have no website counterpart to be reordered against or grouped under, so they
    // keep their coded order after the public block — where they already sit (§6.2).
    val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }

    val placed = mutableListOf<Placed>()
    for (entry in mapped) {
        val override = overrides[entry.route]
        if (override?.hidden == true) continue
        placed += Placed(
            node = NavNode.Item(override?.label?.let { entry.copy(label = it) } ?: entry),
            section = override?.section,
            // An untouched row's key is its index in the WEBSITE's nav, so stored
            // and implicit keys sit on one number line (phase 5).
            key = override?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble(),
            explicit = override?.order != null,
        )
    }
    // An admin-created entity with no stored order appends after the coded rows, in
    // creation order, rather than jumping to the front on a 0 default.
    var next = WEBSITE_PUBLIC_NAV.size
    for (section in sections) {
        placed += Placed(
            node = NavNode.Section(section.id, section.label, emptyList()),
            section = null,
            key = section.order ?: (next++).toDouble(),
            explicit = section.order != null,
        )
    }
    for (link in links) {
        placed += Placed(
            node = NavNode.Link(link.id, link.label, link.to, resolveWebPath(link.to)),
            section = link.section,
            key = link.order ?: (next++).toDouble(),
            explicit = link.order != null,
        )
    }

    val top = placed.filter { it.node is NavNode.Section || it.section == null }.place()
    return top.map { node ->
        if (node !is NavNode.Section) {
            node
        } else {
            node.copy(items = placed.filter { it.section == node.id }.place())
        }
    } + appOnly.map { NavNode.Item(it) }
}

/**
 * The tree with this caller's gates applied — and a section they empty dropped.
 *
 * This is the boundary, and it runs **after** [buildNavTree], never before: an
 * override is presentation, so a row it relabels, moves or marks `hidden: false`
 * is still shown only if [isVisible] says so (§6.1, AC-3).
 *
 * The empty-section case is the one with real correctness risk and the reason the
 * rule is ported rather than left to the drawer: a group whose every member is
 * withheld by the caller's role or by the shard's visibility config must not draw
 * as a header with nothing under it.
 *
 * Links are not gated — see [NavNode].
 *
 * @param isVisible the caller's own predicate, applied to coded items only, so
 *   this file stays ignorant of sessions and shard features
 */
fun pruneNav(tree: List<NavNode>, isVisible: (MenuEntry) -> Boolean): List<NavNode> {
    fun keep(node: NavNode) = node !is NavNode.Item || isVisible(node.entry)
    return tree.mapNotNull { node ->
        when (node) {
            is NavNode.Section -> node.copy(items = node.items.filter(::keep)).takeIf { it.items.isNotEmpty() }
            else -> node.takeIf { keep(it) }
        }
    }
}
