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

import com.runicgateway.app.core.auth.Role
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionUser
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test

/**
 * Drawer sections and added links (THEMING_AND_NAV.md §6.3) — the tree build and
 * the gate that prunes it.
 *
 * Three things these tests are really about. **AC-1**: an admin who created no
 * structure gets phase 5 back untouched, and an untouched instance gets the coded
 * [APP_MENU] entries themselves. **AC-3**: [pruneNav] runs after the build and
 * remains the boundary — including inside a section, and including the case where
 * it empties one. And the link path rule, which is what keeps "an override may
 * never introduce navigation" true of a feature whose whole job is to add entries:
 * a link may name any page **on this site**, and nothing else.
 */
class NavTreeTest {

    // ── Fixtures ─────────────────────────────────────────────────────────

    private fun stored(
        items: JsonObject = buildJsonObject { },
        sections: List<JsonObject> = emptyList(),
        links: List<JsonObject> = emptyList(),
    ): JsonObject = buildJsonObject {
        put("items", items)
        put("sections", buildJsonArray { sections.forEach { add(it) } })
        put("links", buildJsonArray { links.forEach { add(it) } })
    }

    private fun items(vararg entries: Pair<String, JsonObject>): JsonObject =
        buildJsonObject { for ((path, entry) in entries) put(path, entry) }

    private fun item(
        label: String? = null,
        order: Int? = null,
        hidden: Boolean? = null,
        section: String? = null,
    ): JsonObject = buildJsonObject {
        label?.let { put("label", it) }
        order?.let { put("order", it) }
        hidden?.let { put("hidden", it) }
        section?.let { put("section", it) }
    }

    private fun section(id: String, label: String? = "Lore", order: Int? = null): JsonObject =
        buildJsonObject {
            put("id", id)
            label?.let { put("label", it) }
            order?.let { put("order", it) }
        }

    private fun link(
        id: String = "l1",
        label: String? = "Donate",
        to: String? = "/donate",
        order: Int? = null,
        section: String? = null,
    ): JsonObject = buildJsonObject {
        put("id", id)
        label?.let { put("label", it) }
        to?.let { put("to", it) }
        order?.let { put("order", it) }
        section?.let { put("section", it) }
    }

    private fun tree(navPublic: JsonObject?) = buildNavTree(APP_MENU, navPublic)

    /** Top-level routes, with a section standing in as `section:<id>`. */
    private fun List<NavNode>.shape(): List<String> = map {
        when (it) {
            is NavNode.Item -> it.entry.route
            is NavNode.Link -> "link:${it.id}"
            is NavNode.Section -> "section:${it.id}"
        }
    }

    private fun List<NavNode>.section(id: String): NavNode.Section =
        filterIsInstance<NavNode.Section>().first { it.id == id }

    private fun List<NavNode>.link(id: String): NavNode.Link =
        filterIsInstance<NavNode.Link>().first { it.id == id }

    // ── AC-1: no structure means phase 5, unchanged ──────────────────────

    @Test fun noStoredRowIsTheCodedMenu() {
        val nodes = tree(null)

        assertEquals(APP_MENU.size, nodes.size)
        // The entries themselves, not copies: with nothing stored, nothing was
        // rebuilt to arrive at the drawer the app shipped with.
        APP_MENU.forEachIndexed { index, entry ->
            assertSame(entry, (nodes[index] as NavNode.Item).entry)
        }
    }

    @Test fun withoutSectionsOrLinksTheBuildIsTheFlatMerge() {
        // Phase 6 adds structure; it does not re-implement phase 5. An items-only
        // row must give exactly what applyNavOverrides gives.
        val row = stored(items = items("/site/about" to item(order = 0)))

        assertEquals(
            applyNavOverrides(APP_MENU, row).map { it.route },
            tree(row).shape(),
        )
    }

    @Test fun malformedSectionsAndLinksAreNotStructure() {
        // Wrong kinds where the arrays should be — a hand-edited row, or the bare
        // items map phases 6-8 stored. Neither is structure, so neither may cost
        // the coded menu.
        val row = buildJsonObject {
            put("items", buildJsonObject { })
            put("sections", buildJsonObject { put("id", "lore") })
            put("links", "nope")
        }

        assertEquals(APP_MENU.map { it.route }, tree(row).shape())
    }

    // ── Sections ─────────────────────────────────────────────────────────

    @Test fun aSectionCollectsItsMembersBeneathIt() {
        val row = stored(
            items = items(
                "/wiki" to item(section = "lore"),
                "/site/about" to item(section = "lore"),
            ),
            sections = listOf(section("lore", label = "  The Realm  ")),
        )

        val nodes = tree(row)

        assertTrue(Routes.WIKI !in nodes.shape())
        assertEquals("The Realm", nodes.section("lore").label)
        assertEquals(
            listOf(Routes.WIKI, Routes.page("about")),
            nodes.section("lore").items.shape(),
        )
    }

    @Test fun aSectionWithNoOrderAppendsAfterTheCodedRows() {
        // An admin-created entity with no stored order appends in creation order
        // rather than jumping to the front on a 0 default. The app's own rows stay
        // behind it, where they already sit (§6.2).
        val row = stored(
            items = items("/wiki" to item(section = "lore")),
            sections = listOf(section("lore")),
        )

        val shape = tree(row).shape()

        // Eight public rows are left at the top level (Wiki moved into the section),
        // then the section, then the app's own rows.
        assertEquals("section:lore", shape[8])
        assertEquals(Routes.CONTACT, shape[9])
    }

    @Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
        // Sections sort on the same number line as everything else: the website's
        // sixteen indices, then admin-created entities after them.
        val row = stored(
            items = items("/wiki" to item(section = "lore")),
            sections = listOf(section("lore", order = 0)),
        )

        assertEquals("section:lore", tree(row).shape().first())
    }

    @Test fun aSectionWithoutAUsableLabelIsDroppedAndItsMembersStayPut() {
        val row = stored(
            items = items("/wiki" to item(section = "lore")),
            sections = listOf(section("lore", label = "   ")),
        )

        val nodes = tree(row)

        assertTrue(nodes.filterIsInstance<NavNode.Section>().isEmpty())
        // The section never existed, so the reference to it is dangling and the row
        // is an ordinary top-level one — not a row that vanished with its section.
        assertTrue(Routes.WIKI in nodes.shape())
    }

    @Test fun aRepeatedSectionIdKeepsTheFirst() {
        val row = stored(
            items = items("/wiki" to item(section = "lore")),
            sections = listOf(section("lore", label = "First"), section("lore", label = "Second")),
        )

        val sections = tree(row).filterIsInstance<NavNode.Section>()

        assertEquals(1, sections.size)
        assertEquals("First", sections.single().label)
    }

    @Test fun anItemNamingAnUnknownSectionStaysTopLevel() {
        val row = stored(
            items = items("/wiki" to item(section = "nope")),
            sections = listOf(section("lore")),
        )

        val nodes = tree(row)

        assertTrue(Routes.WIKI in nodes.shape())
        assertTrue(nodes.section("lore").items.isEmpty())
    }

    @Test fun aHiddenItemIsDroppedEvenInsideASection() {
        val row = stored(
            items = items("/wiki" to item(hidden = true, section = "lore")),
            sections = listOf(section("lore")),
        )

        val nodes = tree(row)

        assertTrue(Routes.WIKI !in nodes.shape())
        assertTrue(nodes.section("lore").items.isEmpty())
    }

    @Test fun aLabelStillLandsOnASectionedRow() {
        val row = stored(
            items = items("/wiki" to item(label = "Codex", section = "lore")),
            sections = listOf(section("lore")),
        )

        val wiki = tree(row).section("lore").items.filterIsInstance<NavNode.Item>().single()

        assertEquals("Codex", wiki.entry.label)
        // The override lands on the label and nothing else — the gates are untouched.
        assertEquals(MenuAccess.PUBLIC, wiki.entry.access)
        assertNull(wiki.entry.feature)
    }

    @Test fun aSectionRequestForARowTheDrawerDoesNotSurfaceIsIgnored() {
        // Same rule as phase 5's: the app puts the hub boards behind the Shard hub
        // deliberately, and grouping is no more an invitation to surface one than
        // relabelling was (§6.2).
        val row = stored(
            items = items("/site/champs" to item(section = "lore", label = "Champs")),
            sections = listOf(section("lore")),
        )

        val nodes = tree(row)

        assertTrue(nodes.section("lore").items.isEmpty())
        assertTrue(Routes.SHARD_CHAMPS !in nodes.shape())
    }

    // ── Added links ──────────────────────────────────────────────────────

    @Test fun aLinkTheAppCanResolveCarriesItsRoute() {
        val row = stored(links = listOf(link(to = "/wiki/smithing")))

        assertEquals(Routes.wikiPage("smithing"), tree(row).link("l1").route)
    }

    @Test fun aLinkTheAppCannotResolveHandsOff() {
        // A null route is the Custom Tab; the path is kept verbatim so the browser
        // gets exactly what the admin wrote.
        val row = stored(links = listOf(link(to = "/site/status")))

        val node = tree(row).link("l1")

        assertNull(node.route)
        assertEquals("/site/status", node.path)
    }

    @Test fun aLinkThatWouldLeaveTheOriginIsDropped() {
        // The website's own read rule, ported: a stored value that is not a
        // single-slash site path is dropped rather than rendered, so a hand-edited
        // row cannot put an off-site link in the drawer.
        val bad = listOf(
            "//evil.example/x", "https://evil.example", "donate", "/don ate",
            "/don\"ate", "/don'ate", "/don<ate", "/don\\ate",
        )

        for (to in bad) {
            assertTrue(to, tree(stored(links = listOf(link(to = to)))).filterIsInstance<NavNode.Link>().isEmpty())
        }
    }

    @Test fun aLinkWithoutAnIdLabelOrPathIsDropped() {
        val row = stored(
            links = listOf(
                buildJsonObject {
                    put("label", "No id")
                    put("to", "/a")
                },
                link(id = "no-label", label = null),
                link(id = "no-to", to = null),
                link(id = "blank-label", label = "   "),
                link(id = "good"),
            ),
        )

        assertEquals(listOf("good"), tree(row).filterIsInstance<NavNode.Link>().map { it.id })
    }

    @Test fun aRepeatedLinkIdKeepsTheFirst() {
        val row = stored(links = listOf(link(id = "l1", label = "First"), link(id = "l1", label = "Second")))

        assertEquals("First", tree(row).link("l1").label)
    }

    @Test fun linksAppendAfterTheCodedRowsInCreationOrder() {
        val row = stored(links = listOf(link(id = "a"), link(id = "b")))

        val shape = tree(row).shape()

        assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") })
        assertEquals(Routes.page("about"), shape[shape.indexOf("link:a") - 1])
    }

    @Test fun aLinksOrderPlacesItAmongTheCodedRows() {
        val row = stored(links = listOf(link(order = 0)))

        assertEquals("link:l1", tree(row).shape().first())
    }

    @Test fun aLinkCanSitInsideASection() {
        val row = stored(
            items = items("/wiki" to item(section = "lore")),
            sections = listOf(section("lore")),
            links = listOf(link(section = "lore"), link(id = "top")),
        )

        val nodes = tree(row)

        assertEquals(listOf(Routes.WIKI, "link:l1"), nodes.section("lore").items.shape())
        assertTrue("link:top" in nodes.shape())
    }

    @Test fun aLinkNamingAnUnknownSectionStaysTopLevel() {
        // Its destination is still good; only the grouping was wrong.
        val row = stored(links = listOf(link(section = "nope")))

        assertTrue("link:l1" in tree(row).shape())
    }

    // ── AC-3: the gates run after the build, and empty a section honestly ──

    private val admin = Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN))

    private fun prune(nodes: List<NavNode>, session: Session, features: ShardFeatures?) =
        pruneNav(nodes) { isEntryVisible(it, session, features) }

    @Test fun aSectionEmptiedByTheGatesIsDropped() {
        // The case the rule exists for: a group whose every member is withheld by
        // the shard's visibility config must not draw as a header over nothing.
        val row = stored(
            items = items("/site/market" to item(section = "lore")),
            sections = listOf(section("lore")),
        )

        val pruned = prune(
            tree(row),
            admin,
            ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
        )

        assertTrue(pruned.filterIsInstance<NavNode.Section>().isEmpty())
    }

    @Test fun aSectionKeepsTheMembersThisCallerMaySee() {
        val row = stored(
            items = items(
                "/site/market" to item(section = "lore"),
                "/wiki" to item(section = "lore"),
            ),
            sections = listOf(section("lore")),
        )

        val pruned = prune(
            tree(row),
            admin,
            ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
        )

        assertEquals(listOf(Routes.WIKI), pruned.section("lore").items.shape())
    }

    @Test fun anOverrideCannotUnhideAGatedRowByGroupingIt() {
        // Relabelled, moved to the front, marked `hidden: false` and tucked into a
        // section of its own — and still not shown, because the shard does not
        // publish the market and an admin does not outrank that.
        val row = stored(
            items = items("/site/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")),
            sections = listOf(section("lore", order = 0)),
        )

        val pruned = prune(
            tree(row),
            admin,
            ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
        )

        assertTrue(pruned.filterIsInstance<NavNode.Section>().isEmpty())
        assertTrue(pruned.none { it is NavNode.Item && it.entry.route == Routes.SHARD_MARKET })
    }

    @Test fun aSectionSurvivesOnALinkAlone() {
        // Links carry no gate — the page behind one enforces its own access — so a
        // section holding one is never emptied by the caller's role.
        val row = stored(
            items = items("/site/market" to item(section = "lore")),
            sections = listOf(section("lore")),
            links = listOf(link(section = "lore")),
        )

        val pruned = prune(tree(row), Session.SignedOut, ShardFeatures(level = "anonymous", visible = emptySet()))

        assertEquals(listOf("link:l1"), pruned.section("lore").items.shape())
    }

    @Test fun theAppsOwnRowsAreStillGatedInTheTree() {
        val row = stored(
            items = items("/wiki" to item(section = "lore")),
            sections = listOf(section("lore")),
        )

        val shape = prune(tree(row), Session.SignedOut, features = null).shape()

        assertTrue(Routes.ACCOUNT !in shape)
        assertTrue(Routes.ADMIN_DASHBOARD !in shape)
        assertTrue(Routes.PLAYER_CHARACTERS !in shape)
        assertTrue(Routes.CONTACT in shape)
    }

    @Test fun pruningAnUntouchedTreeIsTheCodedMenusVisibleEntries() {
        // The two paths through the drawer have to agree: prune(tree) for a caller
        // is exactly visibleEntries of the coded menu for that caller.
        val features = ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS, ShardFeature.MARKET))

        assertEquals(
            visibleEntries(APP_MENU, admin, features).map { it.route },
            prune(tree(null), admin, features).shape(),
        )
    }
}
