@file:Suppress("TooManyFunctions") // one dispatcher per mutation kind + replay helpers

package com.fabledsword.minstrel.cache.mutations

import com.fabledsword.minstrel.api.endpoints.AppendTracksRequest
import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
import com.fabledsword.minstrel.api.endpoints.QuarantineApi
import com.fabledsword.minstrel.api.endpoints.RequestsApi
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.connectivity.ServerHealth
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
import com.fabledsword.minstrel.models.wire.PlayOfflineRequest
import com.fabledsword.minstrel.models.wire.SnoozeSuggestionBody
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import kotlinx.coroutines.CoroutineScope
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.coroutines.sync.withLock
import kotlinx.datetime.Clock
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.create
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Drains the offline MutationQueue. Reads pending rows in FIFO order,
 * re-fires each call against the raw Retrofit API (NOT the high-level
 * Repository, which would re-enqueue on failure → infinite loop), and
 * resolves each to an [Outcome]:
 *  - SENT  — server accepted (2xx) → delete the row.
 *  - DROP  — permanently un-sendable (4xx, unknown kind/variant, corrupt
 *            payload) → delete the row so it can't wedge the queue forever.
 *  - RETRY — transient (transport, 5xx, 408, 429) → leave queued, bump
 *            attempts, retry on the next trigger.
 *
 * Triggered by:
 *   - app start (MinstrelApplication @Inject forces the singleton's init)
 *   - AuthStore.sessionCookie transitioning to non-null (sign-in / cold
 *     start with a persisted cookie)
 *   - NetworkStatusController recovering to Healthy (server reachable again
 *     mid-session — covers offline→online without needing a cold start)
 *
 * Single in-flight via the [mutex]; back-to-back triggers collapse into one
 * drain pass. No exponential backoff / max-attempts: the rows are few for
 * typical use, and permanent failures are now dropped rather than retried
 * forever.
 */
@Singleton
class MutationReplayer @Inject constructor(
    private val dao: CachedMutationDao,
    private val authStore: AuthStore,
    private val json: Json,
    @ApplicationScope private val scope: CoroutineScope,
    networkStatus: NetworkStatusController,
    retrofit: Retrofit,
) {
    private val likesApi: LikesApi = retrofit.create()
    private val discoverApi: DiscoverApi = retrofit.create()
    private val quarantineApi: QuarantineApi = retrofit.create()
    private val playlistsApi: PlaylistsApi = retrofit.create()
    private val eventsApi: EventsApi = retrofit.create()
    private val requestsApi: RequestsApi = retrofit.create()
    private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()

    private val mutex = Mutex()

    private enum class Outcome { SENT, DROP, RETRY }

    init {
        scope.launch {
            authStore.sessionCookie
                .filterNotNull()
                .distinctUntilChanged()
                .collect { drainSafe() }
        }
        // Server-reachable-again: replay queued writes the moment the
        // health signal recovers, so an offline→online transition mid-
        // session doesn't wait for the next cold start / re-auth.
        scope.launch {
            networkStatus.state
                .map { it == ServerHealth.Healthy }
                .distinctUntilChanged()
                .filter { it }
                .collect { drainSafe() }
        }
    }

    /** Public entry-point; coalesces concurrent triggers via a Mutex. */
    suspend fun drainSafe() {
        if (!mutex.tryLock()) return
        try {
            drain()
        } finally {
            mutex.unlock()
        }
    }

    private suspend fun drain() {
        val rows = dao.getAll()
        // Collapse superseded toggles (likes, suggestion snoozes): only the
        // latest desired state per entity is replayed; older toggles for the
        // same entity are dropped unsent. Without this, partial-failure +
        // differential retry could replay an older toggle last and invert the
        // final state — a snooze the user already undid would come back.
        val superseded = supersededToggleIds(rows, json)
        for (row in rows) {
            if (row.id in superseded) {
                dao.delete(row.id)
                continue
            }
            when (outcomeFor(row)) {
                Outcome.SENT, Outcome.DROP -> dao.delete(row.id)
                Outcome.RETRY -> dao.recordAttempt(row.id, Clock.System.now())
            }
        }
    }

    private suspend fun outcomeFor(row: CachedMutationEntity): Outcome = try {
        dispatch(row)
    } catch (e: HttpException) {
        if (isPermanent(e.code())) Outcome.DROP else Outcome.RETRY
    } catch (@Suppress("SwallowedException") e: SerializationException) {
        // Corrupt / schema-incompatible payload — permanent, drop it.
        Outcome.DROP
    } catch (@Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable) {
        // Transport / unknown — leave queued for the next pass.
        Outcome.RETRY
    }

    /** 4xx are permanent except the two "retry me" statuses. */
    private fun isPermanent(code: Int): Boolean =
        code in HTTP_CLIENT_ERR_MIN..HTTP_CLIENT_ERR_MAX &&
            code != HTTP_TIMEOUT && code != HTTP_TOO_MANY

    /**
     * Returns the outcome of replaying one row. SENT on a 2xx; DROP for an
     * unknown kind/variant. Network / HTTP failures THROW and are classified
     * by [outcomeFor] — dispatchers no longer swallow.
     */
    private suspend fun dispatch(row: CachedMutationEntity): Outcome = when (row.kind) {
        MutationKind.LIKE_TOGGLE -> dispatchLikeToggle(row.payload)
        MutationKind.REQUEST_CREATE -> dispatchRequestCreate(row.payload)
        MutationKind.QUARANTINE_UNFLAG -> dispatchQuarantineUnflag(row.payload)
        MutationKind.PLAYLIST_APPEND -> dispatchPlaylistAppend(row.payload)
        MutationKind.QUARANTINE_FLAG -> dispatchQuarantineFlag(row.payload)
        MutationKind.PLAY_OFFLINE -> dispatchPlayOffline(row.payload)
        MutationKind.PLAY_ENDED -> dispatchPlayEnded(row.payload)
        MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
        MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
        MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
        // Unknown kind — drop so a stale schema entry can't wedge the queue.
        else -> Outcome.DROP
    }

    private suspend fun dispatchLikeToggle(payload: String): Outcome {
        val decoded = json.decodeFromString(LikeTogglePayload.serializer(), payload)
        val kindPath = when (decoded.entityType) {
            LikesRepository.ENTITY_ARTIST -> "artists"
            LikesRepository.ENTITY_ALBUM -> "albums"
            LikesRepository.ENTITY_TRACK -> "tracks"
            else -> return Outcome.DROP
        }
        if (decoded.desiredState) {
            likesApi.like(kindPath, decoded.entityId)
        } else {
            likesApi.unlike(kindPath, decoded.entityId)
        }
        return Outcome.SENT
    }

    private suspend fun dispatchRequestCreate(payload: String): Outcome {
        val decoded = json.decodeFromString(RequestCreatePayload.serializer(), payload)
        discoverApi.createRequest(
            CreateRequestBody(
                kind = decoded.kind,
                artistMbid = decoded.artistMbid,
                artistName = decoded.artistName,
                albumMbid = decoded.albumMbid,
                albumTitle = decoded.albumTitle,
                trackMbid = decoded.trackMbid,
                trackTitle = decoded.trackTitle,
            ),
        )
        return Outcome.SENT
    }

    private suspend fun dispatchQuarantineUnflag(payload: String): Outcome {
        val decoded = json.decodeFromString(QuarantineUnflagPayload.serializer(), payload)
        quarantineApi.unflag(decoded.trackId)
        return Outcome.SENT
    }

    private suspend fun dispatchPlaylistAppend(payload: String): Outcome {
        val decoded = json.decodeFromString(PlaylistAppendPayload.serializer(), payload)
        playlistsApi.appendTracks(
            playlistId = decoded.playlistId,
            body = AppendTracksRequest(trackIds = decoded.trackIds),
        )
        return Outcome.SENT
    }

    private suspend fun dispatchQuarantineFlag(payload: String): Outcome {
        val decoded = json.decodeFromString(QuarantineFlagPayload.serializer(), payload)
        quarantineApi.flag(
            FlagRequest(
                trackId = decoded.trackId,
                reason = decoded.reason,
                notes = decoded.notes,
            ),
        )
        return Outcome.SENT
    }

    private suspend fun dispatchPlayOffline(payload: String): Outcome {
        val decoded = json.decodeFromString(PlayOfflinePayload.serializer(), payload)
        eventsApi.playOffline(
            PlayOfflineRequest(
                trackId = decoded.trackId,
                clientId = decoded.clientId,
                at = decoded.atIso,
                durationPlayedMs = decoded.durationPlayedMs,
                source = decoded.source,
                deviceClass = decoded.deviceClass,
            ),
        )
        return Outcome.SENT
    }

    private suspend fun dispatchPlayEnded(payload: String): Outcome {
        val decoded = json.decodeFromString(PlayEndedPayload.serializer(), payload)
        eventsApi.playEnded(
            PlayEndedRequest(
                playEventId = decoded.playEventId,
                durationPlayedMs = decoded.durationPlayedMs,
            ),
        )
        return Outcome.SENT
    }

    private suspend fun dispatchRequestCancel(payload: String): Outcome {
        val decoded = json.decodeFromString(RequestCancelPayload.serializer(), payload)
        requestsApi.cancel(decoded.requestId)
        return Outcome.SENT
    }

    /**
     * Replays a suggestion snooze in whichever direction the payload asks for.
     *
     * The un-snooze branch can legitimately 404 (the row already lapsed, or a
     * previous attempt landed and the response was lost). [outcomeFor] classes
     * 404 as permanent → DROP, which is right: the user's intended end state
     * already holds, so there is nothing left to send.
     */
    private suspend fun dispatchSuggestionSnoozeToggle(payload: String): Outcome {
        val decoded = json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), payload)
        if (decoded.desiredSnoozed) {
            discoverApi.snoozeSuggestion(decoded.mbid, SnoozeSuggestionBody(name = decoded.name))
        } else {
            discoverApi.unsnoozeSuggestion(decoded.mbid)
        }
        return Outcome.SENT
    }

    private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
        val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
        playbackErrorsApi.report(
            PlaybackErrorReportRequest(
                trackId = decoded.trackId,
                kind = decoded.kind,
                detail = decoded.detail,
                clientId = decoded.clientId,
            ),
        )
        return Outcome.SENT
    }

    private companion object {
        const val HTTP_CLIENT_ERR_MIN = 400
        const val HTTP_CLIENT_ERR_MAX = 499
        const val HTTP_TIMEOUT = 408
        const val HTTP_TOO_MANY = 429
    }
}

/**
 * Row ids of desired-state toggles superseded by a later toggle for the same
 * entity. Applies to every kind whose payload encodes a TARGET state rather
 * than an action — like-toggles and suggestion snoozes (#2374) — because
 * replaying a stale one last would invert the final state.
 *
 * Top-level and pure so it can be unit-tested without standing up a Retrofit
 * instance. [rows] must be ascending by id (FIFO), which is what
 * `CachedMutationDao.getAll()` returns.
 */
internal fun supersededToggleIds(rows: List<CachedMutationEntity>, json: Json): Set<Long> {
    val latestByEntity = HashMap<String, Long>()
    val superseded = HashSet<Long>()
    rows.asSequence()
        .mapNotNull { row -> toggleKeyOf(row, json)?.let { key -> key to row.id } }
        .forEach { (key, id) ->
            // Ascending ids mean a prior entry for this key is always older.
            latestByEntity.put(key, id)?.let(superseded::add)
        }
    return superseded
}

/**
 * Collapse key for a toggle row, or null when the row isn't a toggle — or its
 * payload won't decode. Undecodable rows are deliberately left alone rather
 * than grouped under a shared "corrupt" key, so one bad row can't suppress a
 * good one behind it; the dispatcher DROPs it on its own.
 *
 * The kind is part of the key so two toggle kinds can never collide on the
 * same entity id.
 */
private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (row.kind) {
    MutationKind.LIKE_TOGGLE -> runCatching {
        json.decodeFromString(LikeTogglePayload.serializer(), row.payload)
    }.getOrNull()?.let { "${row.kind}:${it.entityType}:${it.entityId}" }

    MutationKind.SUGGESTION_SNOOZE_TOGGLE -> runCatching {
        json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
    }.getOrNull()?.let { "${row.kind}:${it.mbid}" }

    else -> null
}
