MovieDetailsViewModel.kt

package com.louisfn.somovie.feature.moviedetails

import androidx.annotation.AnyThread
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.louisfn.somovie.core.common.Result
import com.louisfn.somovie.core.common.annotation.DefaultDispatcher
import com.louisfn.somovie.core.common.asFlowResult
import com.louisfn.somovie.core.common.extension.combine
import com.louisfn.somovie.core.common.extension.safeCollect
import com.louisfn.somovie.core.common.onResultError
import com.louisfn.somovie.domain.model.BackdropPath
import com.louisfn.somovie.domain.model.Movie
import com.louisfn.somovie.domain.model.MovieCredits
import com.louisfn.somovie.domain.model.YoutubeVideo
import com.louisfn.somovie.domain.usecase.movie.MovieCreditsInteractor
import com.louisfn.somovie.domain.usecase.movie.MovieImageInteractor
import com.louisfn.somovie.domain.usecase.movie.MovieInteractor
import com.louisfn.somovie.domain.usecase.video.MovieVideosInteractor
import com.louisfn.somovie.domain.usecase.watchlist.WatchlistInteractor
import com.louisfn.somovie.feature.moviedetails.WatchlistFabState.WatchlistState
import com.louisfn.somovie.ui.common.error.ErrorsDispatcher
import com.louisfn.somovie.ui.common.extension.toDollarFormat
import com.louisfn.somovie.ui.common.extension.toReleaseFormat
import com.louisfn.somovie.ui.common.model.ImmutableList
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
internal class MovieDetailsViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val movieInteractor: MovieInteractor,
    private val movieImageInteractor: MovieImageInteractor,
    private val movieCreditsInteractor: MovieCreditsInteractor,
    private val movieVideosInteractor: MovieVideosInteractor,
    private val watchlistInteractor: WatchlistInteractor,
    private val errorsDispatcher: ErrorsDispatcher,
    @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
) : ViewModel() {

    private val movieId: Long =
        checkNotNull(savedStateHandle[MovieDetailsNavigation.ARGS_MOVIE_ID]) {
            "SavedStateHandle key ${MovieDetailsNavigation.ARGS_MOVIE_ID} not found"
        }

    private val refreshMovieResultState = MutableStateFlow<Result<Unit>?>(null)
    private val updateWatchlistResultState = MutableStateFlow<Result<Unit>?>(null)

    val uiState: StateFlow<MovieDetailsUiState> =
        combine(
            movieChanges(),
            movieBackdropsChanges(),
            movieCreditsChanges(),
            movieYoutubeVideoChanges(),
            refreshMovieResultState,
            updateWatchlistResultState,
        ) { movie, backdrops, credits, videos, refreshMovieResult, updateWatchlistResult ->
            MovieDetailsUiState(
                headerUiState = createHeaderUiState(movie, backdrops),
                contentUiState = createContentUiState(movie, credits, videos, refreshMovieResult),
                watchlistFabState = createWatchlistUiState(movie, updateWatchlistResult),
            )
        }
            .flowOn(defaultDispatcher)
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(),
                initialValue = MovieDetailsUiState(),
            )

    init {
        refreshMovieDetails()
    }

    @AnyThread
    private fun createHeaderUiState(
        movie: Movie,
        backdrops: List<BackdropPath>,
    ) = HeaderUiState(
        title = movie.title,
        posterPath = movie.posterPath,
        backdropPaths = (
            backdrops.takeIf { it.isNotEmpty() }
                ?: listOfNotNull(movie.backdropPath)
            ).let(::ImmutableList),
        tagline = movie.details?.tagline,
        voteAverage = movie.voteAverage,
        voteCount = movie.details?.voteCount,
        tmdbUrl = movie.tmdbUrl,
        releaseDate = movie.releaseDate?.toReleaseFormat(),
    )

    @AnyThread
    private fun createContentUiState(
        movie: Movie,
        credits: MovieCredits,
        videos: List<YoutubeVideo>,
        refreshMovieResult: Result<Unit>?,
    ): ContentUiState {
        val details = movie.details

        if (details != null) {
            return ContentUiState.Content(
                runtime = details.runtime,
                popularity = details.popularity,
                budget = details.budget.toDollarFormat(),
                revenue = details.revenue.toDollarFormat(),
                genres = details.genres.let(::ImmutableList),
                crew = credits.crewMembers.takeIf { it.isNotEmpty() }?.let(::ImmutableList),
                cast = credits.actors.takeIf { it.isNotEmpty() }?.let(::ImmutableList),
                videos = videos.takeIf { it.isNotEmpty() }?.let(::ImmutableList),
                overview = movie.overview,
                originalLanguage = movie.originalLanguage,
                originalTitle = movie.originalTitle,
            )
        }

        return if (refreshMovieResult is Result.Error) {
            ContentUiState.Retry
        } else {
            ContentUiState.Loading
        }
    }

    @AnyThread
    private fun createWatchlistUiState(
        movie: Movie,
        updateWatchlistResultState: Result<Unit>?,
    ): WatchlistFabState? =
        movie.watchlist?.let { watchlist ->
            WatchlistFabState(
                isLoading = updateWatchlistResultState is Result.Loading,
                state = if (watchlist) WatchlistState.REMOVE_FROM_WATCHLIST else WatchlistState.ADD_TO_WATCHLIST,
            )
        }

    @AnyThread
    fun switchWatchlistState() {
        viewModelScope.launch(defaultDispatcher) {
            val flow = when (uiState.value.watchlistFabState?.state) {
                WatchlistState.ADD_TO_WATCHLIST ->
                    asFlowResult { watchlistInteractor.addToWatchlist(movieId) }
                WatchlistState.REMOVE_FROM_WATCHLIST ->
                    asFlowResult { watchlistInteractor.removeFromWatchlist(movieId) }
                else -> null
            }

            flow
                ?.onResultError(errorsDispatcher::dispatch)
                ?.safeCollect(
                    onEach = updateWatchlistResultState::emit,
                    onError = errorsDispatcher::dispatch,
                )
        }
    }

    @AnyThread
    fun retry() {
        refreshMovieDetails()
    }

    @AnyThread
    private fun refreshMovieDetails() {
        viewModelScope.launch(defaultDispatcher) {
            asFlowResult { movieInteractor.refreshMovie(movieId) }
                .onResultError(errorsDispatcher::dispatch)
                .safeCollect(
                    onEach = refreshMovieResultState::emit,
                    onError = errorsDispatcher::dispatch,
                )
        }
    }

    @AnyThread
    private fun movieChanges(): Flow<Movie> =
        movieInteractor.movieChanges(movieId)
            .catch { handleError(it) }

    @AnyThread
    private fun movieBackdropsChanges(): Flow<List<BackdropPath>> =
        movieImageInteractor.movieImagesChanges(movieId)
            .catch { handleError(it) }
            .map { images -> images.backdrops.map { it.path } }

    @AnyThread
    private fun movieCreditsChanges(): Flow<MovieCredits> =
        movieCreditsInteractor.movieCreditsChanges(movieId)
            .catch { handleError(it) }

    @AnyThread
    private fun movieYoutubeVideoChanges(): Flow<List<YoutubeVideo>> =
        movieVideosInteractor.movieVideosYoutubeChanges(movieId)
            .catch { handleError(it) }

    @AnyThread
    private fun handleError(e: Throwable) {
        errorsDispatcher.dispatch(e)
    }
}