package com.fabledsword.minstrel.library.ui

import app.cash.turbine.test
import com.fabledsword.minstrel.cache.sync.SyncController
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.shared.UiState
import com.fabledsword.minstrel.testutil.MainDispatcherExtension
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import kotlin.test.assertEquals

@ExtendWith(MainDispatcherExtension::class)
class LibraryViewModelTest {

    // lastSyncError needs a real StateFlow: the VM combines it into uiState,
    // and a relaxed-mock flow never emits, so the combine would never fire.
    private fun syncControllerMock(): SyncController =
        mockk<SyncController>(relaxed = true) {
            every { lastSyncError } returns MutableStateFlow(null)
        }

    @Test
    fun `initial state is Loading before any DAO emission`() = runTest {
        val repo = mockk<LibraryRepository>()
        every { repo.observeArtists() } returns MutableSharedFlow()
        every { repo.observeAlbums() } returns MutableSharedFlow()

        val vm = LibraryViewModel(
            repo,
            syncControllerMock(),
            mockk<PlayerController>(relaxed = true),
        )

        assertEquals(UiState.Loading, vm.uiState.value)
    }

    // Note: tests below collect the resolved state directly. With
    // UnconfinedTestDispatcher, stateIn's upstream Flow runs synchronously
    // when the first subscriber attaches — so the `Loading` initialValue
    // is replaced by the upstream emission before .test{} sees it. The
    // observable behavior we care about is the resolved state, not the
    // intermediate Loading.

    @Test
    fun `Empty when DAOs emit empty lists`() = runTest {
        val repo = mockk<LibraryRepository>()
        every { repo.observeArtists() } returns flowOf(emptyList())
        every { repo.observeAlbums() } returns flowOf(emptyList())

        val vm = LibraryViewModel(
            repo,
            syncControllerMock(),
            mockk<PlayerController>(relaxed = true),
        )

        vm.uiState.test {
            assertEquals(UiState.Empty, awaitItem())
            cancelAndConsumeRemainingEvents()
        }
    }

    @Test
    fun `Success when DAOs emit content`() = runTest {
        val repo = mockk<LibraryRepository>()
        every { repo.observeArtists() } returns
            flowOf(listOf(ArtistRef(id = "a1", name = "Boards of Canada", sortName = "Boards")))
        every { repo.observeAlbums() } returns
            flowOf(listOf(AlbumRef(id = "al1", title = "Geogaddi", artistId = "a1")))

        val vm = LibraryViewModel(
            repo,
            syncControllerMock(),
            mockk<PlayerController>(relaxed = true),
        )

        vm.uiState.test {
            val success = awaitItem() as UiState.Success<LibraryData>
            assertEquals(1, success.data.artists.size)
            assertEquals(1, success.data.albums.size)
            assertEquals("a1", success.data.artists[0].id)
            assertEquals("al1", success.data.albums[0].id)
            cancelAndConsumeRemainingEvents()
        }
    }

    @Test
    fun `Error when an upstream Flow throws`() = runTest {
        val repo = mockk<LibraryRepository>()
        every { repo.observeArtists() } returns
            flow { throw IllegalStateException("DAO blew up") }
        every { repo.observeAlbums() } returns flowOf(emptyList())

        val vm = LibraryViewModel(
            repo,
            syncControllerMock(),
            mockk<PlayerController>(relaxed = true),
        )

        vm.uiState.test {
            val error = awaitItem() as UiState.Error
            // ErrorCopy maps a non-HTTP/non-IO throwable to the generic
            // fallback rather than leaking the raw exception text.
            assertEquals("Something went wrong.", error.message)
            cancelAndConsumeRemainingEvents()
        }
    }
}
