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

import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.MarketListingDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.api.fake.FakeShardStream
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test

/**
 * The four Protocol 3.0 content screens (PLAN.md §9 M11): loading, the live merge, and
 * the states that are easy to get wrong — "published nothing" vs "switched off", and a
 * gated feature reading as unavailable rather than as a fault.
 */
class ShardContentViewModelTest {

    @get:Rule val mainDispatcherRule = MainDispatcherRule()

    private val api = FakePublicApi()
    private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true }

    private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json)

    // ── Rules ────────────────────────────────────────────────────────────

    @Test fun rulesLoadTheRuleset() {
        api.ruleset = RulesetDto(shard = "UOMysticmoon", expansion = "EJ")

        val state = RulesViewModel(repo()).state.value

        assertEquals("UOMysticmoon", (state as UiState.Success).data?.shard)
    }

    @Test fun anUnpublishedRulesetIsASuccessWithNoBody() {
        // Distinct from the feature being switched off: the shard is reachable and
        // simply hasn't emitted world.ruleset yet.
        api.ruleset = null

        val state = RulesViewModel(repo()).state.value

        assertTrue(state is UiState.Success)
        assertNull((state as UiState.Success).data)
    }

    @Test fun aGatedRulesetFeatureReadsAsUnavailableNotAsAFault() {
        api.error = httpError(404)

        val state = RulesViewModel(repo()).state.value

        assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind)
    }

    @Test fun aLiveRulesetFrameReplacesTheLoadedCopy() {
        // The frame IS the whole ruleset — the shard re-emits it on every reconnect, so
        // a restart with edited config updates an open screen.
        api.ruleset = RulesetDto(shard = "Old", expansion = "EJ")
        val stream = FakeShardStream(
            listOf(
                ShardStreamEvent.Frame(
                    "world.ruleset",
                    buildJsonObject { put("shard", "New"); put("expansion", "EJ") },
                ),
            ),
        )

        val state = RulesViewModel(repo(stream)).state.value

        assertEquals("New", (state as UiState.Success).data?.shard)
    }

    // ── Leaderboards ─────────────────────────────────────────────────────

    @Test fun leaderboardsSeedAndMergeLiveBoards() {
        api.pointsBoards = listOf(
            PointsBoardDto(system = "QueensLoyalty", players = 800),
            PointsBoardDto(system = "VoidPool", players = 10),
        )
        val stream = FakeShardStream(
            listOf(
                ShardStreamEvent.Frame(
                    "points.board",
                    buildJsonObject { put("system", "VoidPool"); put("players", 999) },
                ),
            ),
        )

        val boards = (LeaderboardsViewModel(repo(stream)).state.value as UiState.Success).data

        // The merged board overtook the seeded one on the contested ordering.
        assertEquals(listOf("VoidPool", "QueensLoyalty"), boards.map { it.system })
    }

    @Test fun aGatedLeaderboardsFeatureReadsAsUnavailable() {
        api.error = httpError(403)

        val state = LeaderboardsViewModel(repo()).state.value

        assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind)
    }

    // ── Market ───────────────────────────────────────────────────────────

    @Test fun marketLoadsListingsAndMeta() {
        api.market = MarketPageDto(
            listings = listOf(MarketListingDto(serial = "0x1", displayName = "hatchet", price = 250)),
            total = 1,
        )

        val vm = MarketViewModel(repo())

        assertEquals(1, (vm.state.value as UiState.Success).data.listings.size)
    }

    @Test fun aBlankMarketQueryIsNotSentAsAnEmptyFilter() {
        MarketViewModel(repo())
        assertNull(api.lastMarketQuery)
    }

    @Test fun theMarketQueryIsBoundedToWhatTheServerAccepts() {
        // Trimmed here rather than bounced as a 400.
        val vm = MarketViewModel(repo())
        vm.onQueryChange("x".repeat(200))
        assertEquals(MarketViewModel.MAX_QUERY, vm.query.value.length)
    }

    @Test fun aFailedMetaLookupDoesNotBlankTheResults() {
        // Meta drives the staleness banner and the filter options; it is secondary.
        api.market = MarketPageDto(listings = listOf(MarketListingDto(serial = "0x1")), total = 1)

        val vm = MarketViewModel(repo())

        assertTrue(vm.state.value is UiState.Success)
    }

    @Test fun aVendorLoadsBySerialAndKeepsItForRetry() {
        api.marketVendor = MarketVendorDto(serial = "0x40001234", shopName = "Darrow's Wares", truncated = true)

        val vm = MarketVendorViewModel(repo())
        vm.load("0x40001234")

        val vendor = (vm.state.value as UiState.Success).data
        assertEquals("Darrow's Wares", vendor.shopName)
        assertTrue(vendor.truncated)

        // Retry re-uses the serial rather than needing it passed again.
        api.error = httpError(500)
        vm.retry()
        assertTrue(vm.state.value is UiState.Error)
    }

    // ── Atlas ────────────────────────────────────────────────────────────

    @Test fun atlasLoadsCreaturesAndDiscoversTheShardsFacets() {
        // Nothing may NAME a facet — a shard can add, replace or rename them, so the
        // filter options come from the shard's own data.
        api.atlasCreatures = AtlasCreaturePageDto(
            creatures = listOf(
                AtlasCreatureDto(slug = "lizardman", facets = mapOf("Felucca" to 30, "Sosaria" to 2)),
                AtlasCreatureDto(slug = "orc", facets = mapOf("Underdark" to 5)),
            ),
            total = 2,
        )

        val vm = AtlasViewModel(repo())

        assertEquals(2, (vm.state.value as UiState.Success).data.creatures.size)
        assertEquals(listOf("Felucca", "Sosaria", "Underdark"), vm.facets.value)
    }

    @Test fun aFilteredPageDoesNotNarrowTheFacetOptions() {
        api.atlasCreatures = AtlasCreaturePageDto(
            creatures = listOf(AtlasCreatureDto(slug = "a", facets = mapOf("Felucca" to 1, "Trammel" to 1))),
        )
        val vm = AtlasViewModel(repo())
        assertEquals(listOf("Felucca", "Trammel"), vm.facets.value)

        // Filtering to one facet must not leave the picker with only that option.
        api.atlasCreatures = AtlasCreaturePageDto(
            creatures = listOf(AtlasCreatureDto(slug = "a", facets = mapOf("Felucca" to 1))),
        )
        vm.onFacetChange("Felucca")

        assertEquals(listOf("Felucca", "Trammel"), vm.facets.value)
        assertEquals("Felucca", api.lastAtlasFacet)
    }

    @Test fun aGatedAtlasReadsAsUnavailable() {
        api.error = httpError(404)

        val state = AtlasViewModel(repo()).state.value

        assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind)
    }

    @Test fun aCreatureLoadsBySlug() {
        api.atlasCreature = AtlasCreatureDto(slug = "lizardman", name = "Lizardman", total = 214)

        val vm = AtlasCreatureViewModel(repo())
        vm.load("lizardman")

        assertEquals(214, (vm.state.value as UiState.Success).data.total)
    }
}
