DefaultMovieRepository.kt

package com.louisfn.somovie.data.repository

import androidx.annotation.AnyThread
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import com.louisfn.somovie.core.common.annotation.DefaultDispatcher
import com.louisfn.somovie.data.database.DatabaseHelper
import com.louisfn.somovie.data.database.datasource.MovieLocalDataSource
import com.louisfn.somovie.data.database.datasource.RemoteKeyLocalDataSource
import com.louisfn.somovie.data.datastore.datasource.DataStoreLocalDataSource
import com.louisfn.somovie.data.datastore.model.SessionData
import com.louisfn.somovie.data.mapper.ExploreCategoryMapper
import com.louisfn.somovie.data.mapper.MovieMapper
import com.louisfn.somovie.data.network.Constants.PAGINATION_FIRST_PAGE_INDEX
import com.louisfn.somovie.data.network.datasource.MovieRemoteDataSource
import com.louisfn.somovie.data.repository.paging.MoviesRemoteMediator
import com.louisfn.somovie.data.repository.paging.mapPaging
import com.louisfn.somovie.domain.model.ExploreCategory
import com.louisfn.somovie.domain.model.Movie
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.invoke
import java.util.concurrent.TimeUnit
import javax.inject.Inject

interface MovieRepository {

    @AnyThread
    fun moviesPagingChanges(
        category: ExploreCategory,
        pagingConfig: PagingConfig,
        cacheTimeout: Long = DEFAULT_CACHE_TIMEOUT,
    ): Flow<PagingData<Movie>>

    @AnyThread
    fun moviesChanges(category: ExploreCategory, limit: Int): Flow<List<Movie>>

    @AnyThread
    suspend fun refreshMovies(category: ExploreCategory, cacheTimeout: Long = DEFAULT_CACHE_TIMEOUT)

    @AnyThread
    fun movieChanges(movieId: Long): Flow<Movie>

    @AnyThread
    suspend fun refreshMovie(movieId: Long)

    companion object {
        private val DEFAULT_CACHE_TIMEOUT = TimeUnit.HOURS.toMillis(12)
    }
}

internal class DefaultMovieRepository @Inject constructor(
    private val remoteDataSource: MovieRemoteDataSource,
    private val localDataSource: MovieLocalDataSource,
    private val remoteKeyLocalDataSource: RemoteKeyLocalDataSource,
    private val mapper: MovieMapper,
    private val categoryMapper: ExploreCategoryMapper,
    private val databaseHelper: DatabaseHelper,
    private val sessionLocalDataSource: DataStoreLocalDataSource<SessionData>,
    @DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher,
) : MovieRepository {

    //region Explore movies

    override fun moviesPagingChanges(
        category: ExploreCategory,
        pagingConfig: PagingConfig,
        cacheTimeout: Long,
    ): Flow<PagingData<Movie>> =
        Pager(
            config = pagingConfig,
            remoteMediator = createMoviesRemoteMediator(category, cacheTimeout),
            pagingSourceFactory = {
                localDataSource.getPagingMovies(categoryMapper.mapToEntity(category))
            },
        )
            .flow
            .mapPaging { mapper.mapToDomain(it) }
            .flowOn(defaultDispatcher)

    @AnyThread
    private fun createMoviesRemoteMediator(
        category: ExploreCategory,
        cacheTimeout: Long,
    ) = MoviesRemoteMediator(
        cacheTimeout = cacheTimeout,
        remoteKeyLocalDataSource = remoteKeyLocalDataSource,
        localDataSource = localDataSource,
        remoteDataSource = remoteDataSource,
        movieMapper = mapper,
        categoryMapper = categoryMapper,
        databaseHelper = databaseHelper,
        category = category,
    )

    override fun moviesChanges(category: ExploreCategory, limit: Int): Flow<List<Movie>> =
        localDataSource.moviesChanges(categoryMapper.mapToEntity(category), limit)
            .map(mapper::mapToDomain)
            .flowOn(defaultDispatcher)

    override suspend fun refreshMovies(category: ExploreCategory, cacheTimeout: Long) =
        defaultDispatcher {
            val remoteKeyTypeEntity = categoryMapper.mapToRemoteKeyTypeEntity(category)
            if (!remoteKeyLocalDataSource.isExpired(remoteKeyTypeEntity, cacheTimeout)) {
                return@defaultDispatcher
            }

            val response = remoteDataSource.getMovies(category, PAGINATION_FIRST_PAGE_INDEX).results

            val categoryEntity = categoryMapper.mapToEntity(category)
            databaseHelper.withTransaction {
                with(localDataSource) {
                    deleteExploreMovies(categoryEntity)
                    insertOrIgnoreMovies(
                        category = categoryEntity,
                        movies = mapper.mapToEntity(response, false),
                        page = PAGINATION_FIRST_PAGE_INDEX,
                    )
                }
                remoteKeyLocalDataSource.updateNextKey(
                    type = remoteKeyTypeEntity,
                    nextKey = (PAGINATION_FIRST_PAGE_INDEX + 1).toString(),
                    reset = true,
                )
            }
        }

    //endregion

    //region Movie

    override fun movieChanges(movieId: Long): Flow<Movie> =
        localDataSource.movieChanges(movieId)
            .map(mapper::mapToDomain)
            .flowOn(defaultDispatcher)

    override suspend fun refreshMovie(movieId: Long) {
        defaultDispatcher {
            val detailsDeferred = async { remoteDataSource.getMovieDetails(movieId) }
            val accountStateDeferred = async {
                if (sessionLocalDataSource.getData().account != null) {
                    remoteDataSource.getMovieAccountStates(movieId)
                } else {
                    null
                }
            }
            localDataSource.insertOrUpdateMovie(
                mapper.mapToEntity(
                    detailsResponse = detailsDeferred.await(),
                    accountStateResponse = accountStateDeferred.await(),
                ),
            )
        }
    }

    //endregion
}