package com.fabledsword.minstrel.cache.sync

import com.fabledsword.minstrel.api.ErrorCopy
import com.fabledsword.minstrel.api.endpoints.SyncApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
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.cache.db.dao.SyncMetadataDao
import com.fabledsword.minstrel.cache.db.entities.CachedAlbumEntity
import com.fabledsword.minstrel.cache.db.entities.CachedArtistEntity
import com.fabledsword.minstrel.cache.db.entities.CachedTrackEntity
import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.wire.SyncAlbumWire
import com.fabledsword.minstrel.models.wire.SyncArtistWire
import com.fabledsword.minstrel.models.wire.SyncTrackWire
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.datetime.Clock
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Drives the delta sync against `/api/library/sync` (#357). Mirrors
 * the Flutter `SyncController`, scoped to the entity types the
 * native client cares about (artist/album/track). Likes and
 * playlists get their own dedicated refresh paths so the sync
 * controller doesn't need to track them.
 *
 * Self-starting: on construction, observes
 * AuthStore.sessionCookie and runs a sync any time the cookie
 * transitions from null to a value (fresh sign-in OR cold start
 * with a persisted cookie). Re-running on every cookie change is
 * what makes the Library tabs populate within seconds of sign-in.
 *
 * Wipes-on-410: when the server's compaction window has passed the
 * stored cursor, the response is 410 Gone. The controller drops the
 * three local caches and recurses with cursor=0 — same wipe behavior
 * as the Flutter port.
 */
@Singleton
class SyncController @Inject constructor(
    private val syncMetadataDao: SyncMetadataDao,
    private val artistDao: CachedArtistDao,
    private val albumDao: CachedAlbumDao,
    private val trackDao: CachedTrackDao,
    private val authStore: AuthStore,
    @ApplicationScope private val scope: CoroutineScope,
    networkStatus: NetworkStatusController,
    retrofit: Retrofit,
) {
    private val api: SyncApi = retrofit.create()

    // Single in-flight guard: the cookie + health-recovery triggers (and a
    // pull-to-refresh) can fire concurrently; coalesce them into one pass.
    private val mutex = Mutex()

    private val lastSyncErrorInternal = MutableStateFlow<String?>(null)

    /**
     * Human copy for the most recent sync failure; null after any clean
     * pass. Lets the Library screen distinguish "empty because the first
     * sync failed" (show error + retry) from "genuinely empty library"
     * (show welcome copy) — a failed sync over a populated cache stays
     * silent, since the cached content is still the better surface.
     */
    val lastSyncError: StateFlow<String?> = lastSyncErrorInternal.asStateFlow()

    init {
        scope.launch {
            authStore.sessionCookie
                .filterNotNull()
                .distinctUntilChanged()
                .collect { syncSafe() }
        }
        // Re-sync when the server becomes reachable again mid-session, so a
        // delta missed while offline lands without waiting for a cold start.
        scope.launch {
            networkStatus.state
                .map { it == ServerHealth.Healthy }
                .distinctUntilChanged()
                .filter { it }
                .collect { syncSafe() }
        }
    }

    /** Public entry-point for "Sync now" affordances. Swallows errors into [lastSyncError]. */
    suspend fun syncSafe() {
        if (!mutex.tryLock()) return
        try {
            runCatching { sync() }
                .onSuccess { lastSyncErrorInternal.value = null }
                .onFailure { lastSyncErrorInternal.value = ErrorCopy.fromThrowable(it) }
        } finally {
            mutex.unlock()
        }
    }

    private suspend fun sync() {
        val meta = syncMetadataDao.get()
        val cursor = meta?.cursor ?: 0L
        val response = api.sync(cursor)
        when (response.code()) {
            HTTP_NO_CONTENT -> {
                touchLastSyncAt(cursor)
            }
            HTTP_GONE -> {
                wipeAndRetry()
            }
            HTTP_OK -> {
                val body = response.body() ?: return
                applyDeltas(body.upserts.artist, body.upserts.album, body.upserts.track)
                applyDeletes(body.deletes.artist, body.deletes.album, body.deletes.track)
                syncMetadataDao.upsert(
                    SyncMetadataEntity(
                        id = 0,
                        cursor = body.cursor,
                        lastSyncAt = Clock.System.now(),
                    ),
                )
            }
            else -> {
                // Unhandled status — treat as a no-op for v1; the next
                // app start retries from the same cursor.
            }
        }
    }

    private suspend fun applyDeltas(
        artists: List<SyncArtistWire>,
        albums: List<SyncAlbumWire>,
        tracks: List<SyncTrackWire>,
    ) {
        if (artists.isNotEmpty()) artistDao.upsertAll(artists.map { it.toEntity() })
        if (albums.isNotEmpty()) albumDao.upsertAll(albums.map { it.toEntity() })
        if (tracks.isNotEmpty()) trackDao.upsertAll(tracks.map { it.toEntity() })
    }

    private suspend fun applyDeletes(
        artistIds: List<String>,
        albumIds: List<String>,
        trackIds: List<String>,
    ) {
        if (artistIds.isNotEmpty()) artistDao.deleteByIds(artistIds)
        if (albumIds.isNotEmpty()) albumDao.deleteByIds(albumIds)
        if (trackIds.isNotEmpty()) trackDao.deleteByIds(trackIds)
    }

    private suspend fun touchLastSyncAt(cursor: Long) {
        syncMetadataDao.upsert(
            SyncMetadataEntity(id = 0, cursor = cursor, lastSyncAt = Clock.System.now()),
        )
    }

    private suspend fun wipeAndRetry() {
        // The Library DAOs only expose deleteByIds; for the rare 410
        // path we re-issue the same request with cursor=0 after
        // resetting our cursor. The next response carries the full
        // entity set, which upsertAll overwrites the existing rows
        // with — old rows for entities the server no longer knows
        // about will linger until a later 410 or until the user clears
        // app data. Acceptable for v1; a future "wipe all" DAO method
        // tightens this up.
        syncMetadataDao.upsert(
            SyncMetadataEntity(id = 0, cursor = 0, lastSyncAt = null),
        )
        sync()
    }

    private companion object {
        const val HTTP_OK = 200
        const val HTTP_NO_CONTENT = 204
        const val HTTP_GONE = 410
    }
}

// ── Mappers (file-internal — sync wire types stay out of UI) ──

private fun SyncArtistWire.toEntity(): CachedArtistEntity = CachedArtistEntity(
    id = id,
    name = name,
    sortName = sortName,
    mbid = mbid,
    artistThumbPath = artistThumbPath,
    artistFanartPath = artistFanartPath,
)

private fun SyncAlbumWire.toEntity(): CachedAlbumEntity = CachedAlbumEntity(
    id = id,
    artistId = artistId,
    title = title,
    sortTitle = sortTitle,
    releaseDate = releaseDate,
    coverPath = coverArtPath,
    mbid = mbid,
)

private fun SyncTrackWire.toEntity(): CachedTrackEntity = CachedTrackEntity(
    id = id,
    albumId = albumId,
    artistId = artistId,
    title = title,
    durationMs = durationMs,
    trackNumber = trackNumber,
    discNumber = discNumber,
    filePath = filePath,
    fileFormat = fileFormat,
    genre = genre,
)
