/*
 * 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.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

/**
 * The public-nav override merge (THEMING_AND_NAV.md §6): label, order and hidden,
 * applied to the coded [APP_MENU] and nothing else.
 *
 * Two things these tests are really about. **AC-1** — an instance whose admin never
 * touched the nav must get the drawer the app shipped with, which here is the
 * strongest possible assertion: the same list instance back. And **AC-3** — the
 * merge runs before [visibleEntries] and cannot reach past it, so a `hidden: false`
 * on a gated row still shows nothing.
 */
class NavOverridesTest {

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

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

    private fun routes(nav: JsonObject?) = applyNavOverrides(APP_MENU, nav).map { it.route }

    /** The public block's routes, in coded order — the first nine of APP_MENU. */
    private val codedPublic = listOf(
        Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
        Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.page("about"),
    )

    // ── AC-1: the untouched instance ─────────────────────────────────────

    @Test fun noStoredRowReturnsTheCodedMenuItself() {
        // Identity, not equality: the drawer of an instance that never edited its
        // nav is the shipped one, and nothing was rebuilt to arrive at it.
        assertSame(APP_MENU, applyNavOverrides(APP_MENU, null))
    }

    @Test fun anEmptyRowReturnsTheCodedMenuItself() {
        assertSame(APP_MENU, applyNavOverrides(APP_MENU, buildJsonObject { }))
    }

    @Test fun aRowWithNothingUsableInItReturnsTheCodedMenuItself() {
        // A blank label, a non-finite order, `hidden: false`, a path the app has no
        // screen for, and a path it maps but doesn't put in the drawer. None of it
        // says anything, so none of it may cost the coded menu.
        val stored = nav(
            "/" to entry(label = "   "),
            "/site/news" to entry(hidden = false),
            "/admin/appearance" to entry(label = "Nope"),
            "/site/screenshots" to entry(label = "Shots", order = 0),
            "/site/champs" to entry(hidden = true),
        )

        assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
    }

    @Test fun aMalformedEntryIsDroppedAndItsNeighbourKept() {
        val stored = buildJsonObject {
            put("/site/news", "not an object")
            put("/wiki", entry(label = "Codex"))
        }

        val merged = applyNavOverrides(APP_MENU, stored)

        assertEquals(codedPublic, merged.take(9).map { it.route })
        assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
        assertNull(merged.first { it.route == Routes.NEWS }.label)
    }

    // ── Labels ───────────────────────────────────────────────────────────

    @Test fun aLabelOverridesTheBundledString() {
        val merged = applyNavOverrides(APP_MENU, nav("/site/shard" to entry(label = "  The Realm  ")))

        val shard = merged.first { it.route == Routes.SHARD }
        assertEquals("The Realm", shard.label)
        // The override lands on `label` and nothing else — the gates are untouched.
        assertEquals(ShardFeature.STATUS, shard.feature)
        assertEquals(MenuAccess.PUBLIC, shard.access)
        assertEquals(codedPublic, merged.take(9).map { it.route })
    }

    @Test fun aNonStringLabelIsIgnored() {
        val stored = buildJsonObject { put("/wiki", buildJsonObject { put("label", 7) }) }

        assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
    }

    // ── Hidden ───────────────────────────────────────────────────────────

    @Test fun hiddenDropsTheRow() {
        val routes = routes(nav("/site/market" to entry(hidden = true)))

        assertTrue(Routes.SHARD_MARKET !in routes)
        assertEquals(APP_MENU.size - 1, routes.size)
    }

    @Test fun homeCanBeHidden() {
        // Mirrors the website, where `/` is hideable too. Home stays the NavHost's
        // start destination and stays reachable by back-press; the app does not
        // invent a policy the site doesn't have.
        val routes = routes(nav("/" to entry(hidden = true)))

        assertTrue(Routes.HOME !in routes)
    }

    @Test fun hiddenFalseHidesNothing() {
        assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/site/market" to entry(hidden = false))))
    }

    @Test fun hiddenWinsOverALabelOnTheSameRow() {
        val routes = routes(nav("/wiki" to entry(label = "Codex", hidden = true)))

        assertTrue(Routes.WIKI !in routes)
    }

    // ── Order ────────────────────────────────────────────────────────────

    @Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
        // The website's own indices: About is 15 and Home is 0, so swapping them
        // is what an admin dragging About to the top writes.
        val routes = routes(
            nav(
                "/site/about" to entry(order = 0),
                "/" to entry(order = 15),
            ),
        )

        assertEquals(
            listOf(
                Routes.page("about"), Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
                Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.HOME,
            ),
            routes.take(9),
        )
    }

    @Test fun anUntouchedRowKeepsItsPlaceOnTheWebsitesNumberLine() {
        // The tie-break that needs the website's order rather than the app's: an
        // explicit 5 meets Wiki's implicit 5 (its index in the site's nav, where
        // the three news categories sit between News and Wiki). Explicit wins.
        val routes = routes(nav("/site/about" to entry(order = 5)))

        assertEquals(
            listOf(Routes.HOME, Routes.NEWS, Routes.page("about"), Routes.WIKI),
            routes.take(4),
        )
    }

    @Test fun theAppsOwnRowsKeepTheirCodedOrderAfterThePublicBlock() {
        // Contact, Account, Notifications, the three player groups and the four
        // staff rows have no website counterpart to be reordered against (§6.2).
        val tail = APP_MENU.drop(9).map { it.route }

        val merged = routes(nav("/site/about" to entry(order = 0)))

        assertEquals(tail, merged.drop(9))
    }

    @Test fun reorderingAndHidingCompose() {
        val routes = routes(
            nav(
                "/site/about" to entry(order = 0),
                "/" to entry(hidden = true),
            ),
        )

        assertEquals(Routes.page("about"), routes.first())
        assertTrue(Routes.HOME !in routes)
    }

    // ── The two stored shapes ────────────────────────────────────────────

    @Test fun theWrappedShapeIsRead() {
        // Website phase 10 wraps the map as {items, sections, links} without
        // migrating what phases 6-8 stored bare, so both shapes are live.
        val stored = buildJsonObject {
            put("items", nav("/wiki" to entry(label = "Codex")))
            put("sections", buildJsonObject { })
            put("links", buildJsonObject { })
        }

        val merged = applyNavOverrides(APP_MENU, stored)

        assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
    }

    @Test fun sectionsAndLinksDoNotDisturbTheItemsMerge() {
        // This merge is items-only; `buildNavTree` is what renders the structure
        // around them (§6.3), and it leans on this staying true — an `items` map
        // that says nothing still returns the coded menu itself.
        val stored = buildJsonObject {
            put("items", buildJsonObject { })
            put("sections", buildJsonObject { put("id", "lore") })
        }

        assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
    }

    // ── AC-3: the merge cannot reach past the gates ──────────────────────

    @Test fun anOverrideCannotUnhideAFeatureGatedRow() {
        val stored = nav(
            "/site/market" to entry(label = "Bazaar", hidden = false, order = 0),
        )

        val visible = visibleEntries(
            applyNavOverrides(APP_MENU, stored),
            Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN)),
            ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
        ).map { it.route }

        // Relabeled and moved to the front, and still not shown: the shard does not
        // publish the market, and an admin does not outrank that.
        assertTrue(Routes.SHARD_MARKET !in visible)
        assertTrue(Routes.SHARD in visible)
    }

    @Test fun anOverrideCannotUnhideARoleGatedRow() {
        val stored = nav("/" to entry(order = 99))

        val visible = visibleEntries(
            applyNavOverrides(APP_MENU, stored),
            Session.SignedOut,
            features = null,
        ).map { it.route }

        assertTrue(Routes.ACCOUNT !in visible)
        assertTrue(Routes.ADMIN_DASHBOARD !in visible)
        assertTrue(Routes.PLAYER_CHARACTERS !in visible)
    }

    @Test fun theGatesRunOnTheMergedListNotTheCodedOne() {
        // Hiding is subtractive on top of the gates, so the two compose: the row an
        // admin hid is gone, and so is the row this caller may not see.
        val stored = nav("/wiki" to entry(hidden = true))

        val visible = visibleEntries(
            applyNavOverrides(APP_MENU, stored),
            Session.SignedOut,
            ShardFeatures(level = "anonymous", visible = setOf(ShardFeature.STATUS)),
        ).map { it.route }

        assertTrue(Routes.WIKI !in visible)
        assertTrue(Routes.SHARD_MARKET !in visible)
        assertTrue(Routes.HOME in visible)
    }
}
