package com.fabledsword.minstrel.metadata

import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
import com.fabledsword.minstrel.cache.db.dao.CachedArtistDao
import com.fabledsword.minstrel.cache.db.dao.CachedTrackDao
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.library.data.LibraryRepository
import com.fabledsword.minstrel.library.data.toDomain
import com.fabledsword.minstrel.models.AlbumRef
import com.fabledsword.minstrel.models.ArtistRef
import com.fabledsword.minstrel.models.TrackRef
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton

private const val MAX_CONCURRENT_FETCHES = 4

/**
 * Cache-first metadata reads with eager on-miss live-fetch.
 *
 * Mirrors Flutter's tile-provider pattern (`albumTileProvider(id)` /
 * `artistTileProvider(id)` / `trackTileProvider(id)`): subscribers
 * observe a per-ID Flow that emits the Room row immediately if
 * present, and otherwise triggers a single background fetch that
 * upserts into Room — the flow then re-emits the populated row.
 *
 * Fetch dedup is per-ID via a ConcurrentHashMap of in-flight Jobs;
 * five tiles asking for the same missing album = one network call.
 * Fetch errors are swallowed (the row stays null and the tile keeps
 * rendering its skeleton); the [FreshnessSweeper] retries later.
 *
 * Fire-and-forget: subscribers see Flow<X?> where null means "not
 * cached yet" — they render skeletons in that case and re-render
 * automatically when the fetch lands.
 */
@Singleton
class MetadataProvider @Inject constructor(
    private val albumDao: CachedAlbumDao,
    private val artistDao: CachedArtistDao,
    private val trackDao: CachedTrackDao,
    private val libraryRepository: LibraryRepository,
    @ApplicationScope private val appScope: CoroutineScope,
) {
    private val inFlightAlbums = ConcurrentHashMap<String, Job>()
    private val inFlightArtists = ConcurrentHashMap<String, Job>()
    private val inFlightTracks = ConcurrentHashMap<String, Job>()

    // Caps concurrent on-miss fetches so a cold Home (~30 tiles) doesn't
    // fire ~30 parallel /api calls. Mirrors Flutter HydrationQueue's
    // _maxConcurrent = 4. Per-id dedup (above) is orthogonal: the gate
    // bounds distinct-id fetches; extras suspend on the semaphore.
    private val fetchGate = Semaphore(MAX_CONCURRENT_FETCHES)

    /**
     * Live AlbumRef? for [id]. Fires a background fetch the first
     * time the cache emits null. Safe to subscribe concurrently from
     * multiple tiles — fetch is deduplicated.
     */
    fun observeAlbum(id: String): Flow<AlbumRef?> =
        albumDao.observeById(id)
            .map { it?.toDomain() }
            .onEach { row -> if (row == null) fetchAlbum(id) }

    fun observeArtist(id: String): Flow<ArtistRef?> =
        combine(
            artistDao.observeById(id),
            albumDao.observeByArtist(id),
        ) { artist, albums ->
            artist?.toDomain(coverAlbumId = albums.minByOrNull { it.sortTitle }?.id ?: "")
        }.onEach { row -> if (row == null) fetchArtist(id) }

    fun observeTrack(id: String): Flow<TrackRef?> =
        trackDao.observeById(id)
            .map { it?.toDomain() }
            .onEach { row -> if (row == null) fetchTrack(id) }

    /**
     * Force-refresh [id] from the server even if the cache has it.
     * Used by [FreshnessSweeper] to walk stale rows. Same dedup as
     * the on-miss path — repeated calls during an in-flight fetch
     * are no-ops.
     */
    fun refreshAlbum(id: String): Job = fetchAlbum(id)
    fun refreshArtist(id: String): Job = fetchArtist(id)
    fun refreshTrack(id: String): Job = fetchTrack(id)

    private fun fetchAlbum(id: String): Job = launchDeduped(inFlightAlbums, id) {
        libraryRepository.refreshAlbumDetail(id)
    }

    private fun fetchArtist(id: String): Job = launchDeduped(inFlightArtists, id) {
        libraryRepository.refreshArtistDetail(id)
    }

    private fun fetchTrack(id: String): Job = launchDeduped(inFlightTracks, id) {
        libraryRepository.refreshTrack(id)
    }

    private fun launchDeduped(
        inFlight: ConcurrentHashMap<String, Job>,
        id: String,
        block: suspend () -> Unit,
    ): Job = inFlight.computeIfAbsent(id) {
        appScope.launch {
            try {
                fetchGate.withPermit { block() }
            } catch (
                @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
            ) {
                // Soft-fail. Row stays null; tile stays skeletal until
                // FreshnessSweeper retries or the user pulls to refresh.
            } finally {
                inFlight.remove(id)
            }
        }
    }
}
