package com.fabledsword.minstrel.playlists.data

import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
import com.fabledsword.minstrel.auth.AuthController
import retrofit2.HttpException
import java.net.HttpURLConnection
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistDao
import com.fabledsword.minstrel.cache.db.dao.CachedPlaylistTrackDao
import com.fabledsword.minstrel.cache.db.dao.PlaylistCachedCount
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedPlaylistTrackEntity
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.models.PlaylistRef
import com.fabledsword.minstrel.models.PlaylistTrackRef
import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.models.wire.PlaylistDetailWire
import com.fabledsword.minstrel.models.wire.PlaylistTrackWire
import com.fabledsword.minstrel.models.wire.PlaylistWire
import com.fabledsword.minstrel.shared.resolveServerUrl
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
import retrofit2.create
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Cache-first reads of the caller's playlists plus per-playlist track
 * lists. Mirrors the Flutter `playlistsListProvider` + `playlistDetail`
 * pattern: Room is the source of truth for UI observation; refresh*
 * methods push wire data into Room and the Flow emissions propagate.
 *
 * Track-list hydration: `cached_playlist_tracks` only stores ordered
 * (playlistId, trackId, position) triples. The full per-track display
 * fields come back from `/api/playlists/{id}` in `PlaylistTrackWire`
 * — we cache the membership rows in Room (so the playlist tracks list
 * persists offline at the ID level) AND keep the freshest detail
 * response in memory via the returned `PlaylistDetailRef`. A proper
 * per-track hydration queue lands with the Phase 12 sync controller.
 */
@Singleton
class PlaylistsRepository @Inject constructor(
    private val playlistDao: CachedPlaylistDao,
    private val playlistTrackDao: CachedPlaylistTrackDao,
    private val mutationQueue: MutationQueue,
    private val authController: AuthController,
    retrofit: Retrofit,
) {
    private val api: PlaylistsApi = retrofit.create()

    // ── Reads (Flow, cache-first; the cache is the source of truth) ──

    /**
     * Owned + public; UI splits by isSystem / userId comparison. Joined with
     * per-playlist cache counts so [PlaylistRef.fullyCached] drives the offline
     * greying without each consumer re-querying the cache index.
     */
    fun observeAll(): Flow<List<PlaylistRef>> =
        combine(
            playlistDao.observeAll(),
            playlistDao.observeCachedCounts(),
        ) { rows, counts -> mergePlaylistsWithCache(rows, counts) }

    /** User-owned playlists only (systemVariant IS NULL). */
    fun observeUserPlaylists(): Flow<List<PlaylistRef>> =
        playlistDao.observeUserPlaylists().map { rows -> rows.map { it.toDomain() } }

    /** Server-generated system playlists (for_you / discover / etc.). */
    fun observeSystemPlaylists(): Flow<List<PlaylistRef>> =
        playlistDao.observeSystemPlaylists().map { rows -> rows.map { it.toDomain() } }

    suspend fun getPlaylist(id: String): PlaylistRef? = playlistDao.getById(id)?.toDomain()

    // ── Server refreshes ──────────────────────────────────────────────

    /**
     * Pulls `GET /api/playlists?kind=all` and replaces the local
     * playlist cache. Per-playlist track rows come from refreshDetail.
     */
    suspend fun refreshList() {
        val wire = api.list(kind = "all")
        val all = (wire.owned + wire.public).map { it.toEntity() }
        val userId = authController.currentUser.value?.id
        if (userId != null) {
            // Reconcile: BuildSystemPlaylists rotates system-playlist
            // UUIDs every rebuild, so upsert alone leaves stale rows
            // whose detail fetch 404s ("That playlist no longer
            // exists"). Mirrors playlists_provider.dart's deleteWhere.
            playlistDao.replaceList(
                userId = userId,
                freshOwnedIds = wire.owned.map { it.id },
                all = all,
            )
        } else {
            playlistDao.upsertAll(all)
        }
    }

    /**
     * Pulls `GET /api/playlists/{id}` and persists both the playlist
     * row and its ordered track membership. Returns the freshly-
     * hydrated detail (with full per-track display fields) for the
     * detail screen to render immediately — Room only persists the
     * track-membership IDs, not the display fields.
     */
    suspend fun refreshDetail(id: String): PlaylistDetailRef {
        val wire = try {
            api.get(id)
        } catch (e: HttpException) {
            // Server says this playlist is gone — drop the stale cache
            // row so the list stops showing it. Mirrors
            // playlists_provider.dart's deleteWhere on detail failure.
            if (e.code() == HttpURLConnection.HTTP_NOT_FOUND) {
                playlistDao.deleteByIds(listOf(id))
            }
            throw e
        }
        playlistDao.upsertAll(listOf(wire.toPlaylistEntity()))
        playlistTrackDao.replacePlaylistTracks(
            playlistId = id,
            rows = wire.tracks.mapNotNull { row ->
                row.trackId?.let { trackId ->
                    CachedPlaylistTrackEntity(
                        playlistId = id,
                        trackId = trackId,
                        position = row.position,
                    )
                }
            },
        )
        return PlaylistDetailRef(
            playlist = wire.toPlaylistRef(),
            tracks = wire.tracks.map { it.toDomain() },
        )
    }

    /**
     * Append a single track to a user-owned playlist. Follows the
     * project's offline-first write contract: optimistic Room insert
     * first (so the playlist detail screen shows the new row instantly),
     * then fire `POST /api/playlists/{id}/tracks`. On transport failure
     * the call is enqueued via MutationQueue for the next drain pass.
     *
     * The optimistic row's position is best-effort — server reasserts
     * canonical position on the next playlist fetch. The (playlistId,
     * trackId) composite PK guarantees no duplicate rows; if the track
     * is already in the playlist, the IGNORE on conflict leaves the
     * existing position unchanged.
     */
    /**
     * Rebuilds the caller's system playlist of [variant]. The server
     * rotates the playlist's UUID on rebuild — the returned id (null
     * when the library can't seed a build) is what callers should
     * navigate to. List cache is refreshed so home-row tiles point at
     * the fresh uuid.
     */
    suspend fun refreshSystemPlaylist(variant: String): String? {
        val response = api.refreshSystem(variant)
        runCatching { refreshList() }
        return response.playlistId
    }

    /**
     * Pulls the rotation-aware shuffled tracks for a refreshable system
     * playlist without rebuilding. Used by Home's play-button overlay
     * to advance For You / Discover / Today's mix rotation on tap. No
     * Room write — the server's rotation order is per-call, not stored.
     */
    suspend fun systemShuffle(variant: String): PlaylistDetailRef {
        val wire = api.systemShuffle(variant)
        return PlaylistDetailRef(
            playlist = wire.toPlaylistRef(),
            tracks = wire.tracks.map { it.toDomain() },
        )
    }

    suspend fun appendTrack(playlistId: String, trackId: String): AppendOutcome {
        val nextPos = (playlistTrackDao.maxPosition(playlistId) ?: -1) + 1
        playlistTrackDao.insertOrIgnore(
            CachedPlaylistTrackEntity(
                playlistId = playlistId,
                trackId = trackId,
                position = nextPos,
            ),
        )
        return try {
            api.appendTracks(
                playlistId = playlistId,
                body = AppendTracksRequest(trackIds = listOf(trackId)),
            )
            AppendOutcome.Synced
        } catch (
            @Suppress("SwallowedException") _: IOException,
        ) {
            mutationQueue.enqueuePlaylistAppend(playlistId, listOf(trackId))
            AppendOutcome.Queued
        }
    }
}

enum class AppendOutcome { Synced, Queued }

/**
 * Snapshot of a playlist + its per-track display fields, as returned
 * by `PlaylistsRepository.refreshDetail`. UI uses this directly for
 * the initial render; offline fall-back reads playlist membership IDs
 * from Room and renders them as greyed-out placeholders until the
 * sync controller hydrates per-track details.
 */
data class PlaylistDetailRef(
    val playlist: PlaylistRef,
    val tracks: List<PlaylistTrackRef>,
)

/**
 * Convert each [PlaylistTrackRef] to a player-ready [TrackRef], dropping
 * any row that isn't actually playable (trackId missing OR streamUrl
 * missing). The two call sites (PlaylistDetailViewModel.play and
 * HomeViewModel.playPlaylist) used to hand-roll this conversion and
 * differed on whether they applied the filter, which let unplayable
 * tracks reach `player.setQueue` and silently no-op inside Media3
 * (setUri("") never errors). Centralise both the conversion AND the
 * defensive filter so the queue is always playable.
 */
fun List<PlaylistTrackRef>.toPlayableTrackRefs(): List<TrackRef> =
    filter { it.isAvailable && !it.streamUrl.isNullOrEmpty() }
        .map { row ->
            TrackRef(
                id = row.trackId.orEmpty(),
                title = row.title,
                albumId = row.albumId.orEmpty(),
                artistId = row.artistId.orEmpty(),
                albumTitle = row.albumTitle,
                artistName = row.artistName,
                durationSec = row.durationSec,
                streamUrl = row.streamUrl.orEmpty(),
            )
        }

// ── Mappers (internal — keep Room + wire types out of the UI layer) ──

/**
 * Maps cached playlist rows to domain refs, stamping [PlaylistRef.fullyCached]
 * from the cache-index counts. A playlist is fully cached when it has tracks
 * and every member track is resident (`cachedCount >= trackCount`). Pulled out
 * of the Flow so the predicate is unit-testable without a Room database.
 */
internal fun mergePlaylistsWithCache(
    rows: List<CachedPlaylistEntity>,
    counts: List<PlaylistCachedCount>,
): List<PlaylistRef> {
    val cachedById = counts.associate { it.playlistId to it.cachedCount }
    return rows.map { row ->
        val cached = cachedById[row.id] ?: 0
        row.toDomain().copy(fullyCached = row.trackCount > 0 && cached >= row.trackCount)
    }
}

private fun CachedPlaylistEntity.toDomain(): PlaylistRef =
    PlaylistRef(
        id = id,
        userId = userId,
        name = name,
        description = description,
        isPublic = isPublic,
        systemVariant = systemVariant,
        trackCount = trackCount,
        // Server returns a relative cover path like "/api/playlists/<id>/cover";
        // wrap through the placeholder host so BaseUrlInterceptor + the shared
        // Coil/OkHttpClient resolve it against the live server. Without this
        // wrap AsyncImage silently fails on the relative URL.
        coverUrl = resolveServerUrl(coverPath) ?: "",
    )

private fun PlaylistWire.toEntity(): CachedPlaylistEntity =
    CachedPlaylistEntity(
        id = id,
        userId = userId,
        name = name,
        description = description,
        isPublic = isPublic,
        coverPath = coverUrl.ifEmpty { null },
        trackCount = trackCount,
        systemVariant = systemVariant,
    )

private fun PlaylistDetailWire.toPlaylistEntity(): CachedPlaylistEntity =
    CachedPlaylistEntity(
        id = id,
        userId = userId,
        name = name,
        description = description,
        isPublic = isPublic,
        coverPath = coverUrl.ifEmpty { null },
        trackCount = trackCount,
        systemVariant = systemVariant,
    )

private fun PlaylistDetailWire.toPlaylistRef(): PlaylistRef =
    PlaylistRef(
        id = id,
        userId = userId,
        name = name,
        description = description,
        isPublic = isPublic,
        systemVariant = systemVariant,
        trackCount = trackCount,
        // Same relative-path wrap as CachedPlaylistEntity.toDomain — the wire
        // gives us /api/playlists/<id>/cover and Coil needs the placeholder
        // host for BaseUrlInterceptor to rewrite to the live server.
        coverUrl = resolveServerUrl(coverUrl) ?: "",
        ownerUsername = ownerUsername,
    )

private fun PlaylistTrackWire.toDomain(): PlaylistTrackRef =
    PlaylistTrackRef(
        position = position,
        trackId = trackId,
        title = title,
        albumId = albumId,
        albumTitle = albumTitle,
        artistId = artistId,
        artistName = artistName,
        durationSec = durationSec,
        streamUrl = streamUrl,
    )
