package com.fabledsword.minstrel.cache.mutations

import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton

private const val QUEUED_MESSAGE = "Saved — will sync when online"

/**
 * Stable mutation kinds the queue knows how to replay. Strings are
 * persisted in `cached_mutations.kind` so renaming a variant breaks
 * the on-disk queue — only ever add new kinds, never rename.
 */
object MutationKind {
    const val LIKE_TOGGLE: String = "like_toggle"
    const val REQUEST_CREATE: String = "request_create"
    const val QUARANTINE_UNFLAG: String = "quarantine_unflag"
    const val PLAYLIST_APPEND: String = "playlist_append"
    const val QUARANTINE_FLAG: String = "quarantine_flag"
    const val PLAY_OFFLINE: String = "play_offline"
    const val REQUEST_CANCEL: String = "request_cancel"
    const val PLAYBACK_ERROR_REPORT: String = "playback_error_report"

    // Durable close-by-id for a play whose server row is already open
    // (play_started succeeded). Distinct from PLAY_OFFLINE, which records
    // a *whole* play when no server row exists. Using close-by-id on
    // background avoids the duplicate + orphan row the old offline-on-stop
    // path produced (see 2026-06-11 contract audit).
    const val PLAY_ENDED: String = "play_ended"

    // #2374 suggestion snooze. ONE toggle kind rather than separate
    // snooze/unsnooze kinds, mirroring LIKE_TOGGLE, so a snooze followed by
    // an undo collapses to the latest intent instead of replaying as two
    // opposed calls whose order decides the outcome.
    const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
}

/**
 * Persisted payload for `MutationKind.LIKE_TOGGLE`. `entityType` is
 * one of "artist" | "album" | "track" matching the server's like-path
 * segment; `desiredState` is the *target* (true = liked, false = not
 * liked) so a single replay slot can collapse repeated toggles.
 */
@Serializable
data class LikeTogglePayload(
    val entityType: String,
    val entityId: String,
    val desiredState: Boolean,
)

/**
 * Persisted payload for `MutationKind.REQUEST_CREATE` (Discover →
 * Lidarr request that landed during a connectivity hiccup). Optional
 * fields stay null when not applicable; the replayer rebuilds the
 * `CreateRequestBody` from these.
 */
@Serializable
data class RequestCreatePayload(
    val kind: String,
    val artistMbid: String,
    val artistName: String,
    val albumMbid: String? = null,
    val albumTitle: String? = null,
    val trackMbid: String? = null,
    val trackTitle: String? = null,
)

/**
 * Minimal write-side of the offline mutation queue. Phase 8 v1 only
 * enqueues — the connectivity-aware replayer that drains the queue
 * lands with Phase 12.2's SyncController.
 *
 * Callers (LikesRepository et al.) follow the optimistic-write
 * pattern: mutate Room first so the UI reflects the change
 * immediately, fire the REST call as a best-effort attempt, and on
 * failure enqueue here so the replayer eventually pushes it through.
 * This matches `feedback_offline_first_for_server_writes` — writes
 * never go fire-and-forget.
 */
@Singleton
class MutationQueue @Inject constructor(
    private val dao: CachedMutationDao,
    private val json: Json,
) {
    // capacity=1 DROP_OLDEST so a burst of user enqueues (e.g. liking N
    // tracks while offline) surfaces as one snackbar rather than queueing
    // N. replay=0 because a hint observed at enqueue time isn't useful
    // to a screen that mounts later.
    private val _userEnqueueHints = MutableSharedFlow<String>(
        replay = 0,
        extraBufferCapacity = 1,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )

    /**
     * Hint stream consumed by [com.fabledsword.minstrel.shared.widgets.ShellScaffold]
     * to surface "Saved — will sync when online" as a snackbar whenever a
     * user-driven write hits the offline-fallback path. Background-only
     * enqueues (play-events, playback-error reports) do not emit — those
     * fire from non-foreground paths where a snackbar would be either
     * dropped (no shell mounted) or jarring (lock-screen toggle).
     */
    val userEnqueueHints: SharedFlow<String> = _userEnqueueHints.asSharedFlow()

    suspend fun enqueueLikeToggle(
        entityType: String,
        entityId: String,
        desiredState: Boolean,
    ): Long = insertUserDriven(
        MutationKind.LIKE_TOGGLE,
        json.encodeToString(
            LikeTogglePayload.serializer(),
            LikeTogglePayload(entityType, entityId, desiredState),
        ),
    )

    suspend fun enqueueRequestCreate(payload: RequestCreatePayload): Long = insertUserDriven(
        MutationKind.REQUEST_CREATE,
        json.encodeToString(RequestCreatePayload.serializer(), payload),
    )

    suspend fun enqueueQuarantineUnflag(trackId: String): Long = insertUserDriven(
        MutationKind.QUARANTINE_UNFLAG,
        json.encodeToString(
            QuarantineUnflagPayload.serializer(),
            QuarantineUnflagPayload(trackId),
        ),
    )

    suspend fun enqueuePlaylistAppend(
        playlistId: String,
        trackIds: List<String>,
    ): Long = insertUserDriven(
        MutationKind.PLAYLIST_APPEND,
        json.encodeToString(
            PlaylistAppendPayload.serializer(),
            PlaylistAppendPayload(playlistId, trackIds),
        ),
    )

    suspend fun enqueueQuarantineFlag(
        trackId: String,
        reason: String,
        notes: String,
    ): Long = insertUserDriven(
        MutationKind.QUARANTINE_FLAG,
        json.encodeToString(
            QuarantineFlagPayload.serializer(),
            QuarantineFlagPayload(trackId, reason, notes),
        ),
    )

    /**
     * Queues a suggestion snooze (or its undo) for replay. [desiredSnoozed]
     * is the TARGET state, so repeated taps collapse to one replay.
     *
     * [name] is carried even for an un-snooze, where the server ignores it,
     * so a single payload shape serves both directions.
     */
    suspend fun enqueueSuggestionSnoozeToggle(
        mbid: String,
        name: String,
        desiredSnoozed: Boolean,
    ): Long = insertUserDriven(
        MutationKind.SUGGESTION_SNOOZE_TOGGLE,
        json.encodeToString(
            SuggestionSnoozeTogglePayload.serializer(),
            SuggestionSnoozeTogglePayload(mbid, name, desiredSnoozed),
        ),
    )

    suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
        MutationKind.REQUEST_CANCEL,
        json.encodeToString(
            RequestCancelPayload.serializer(),
            RequestCancelPayload(requestId),
        ),
    )

    suspend fun enqueuePlayOffline(payload: PlayOfflinePayload): Long = dao.insert(
        CachedMutationEntity(
            kind = MutationKind.PLAY_OFFLINE,
            payload = json.encodeToString(PlayOfflinePayload.serializer(), payload),
        ),
    )

    suspend fun enqueuePlaybackErrorReport(payload: PlaybackErrorReportPayload): Long = dao.insert(
        CachedMutationEntity(
            kind = MutationKind.PLAYBACK_ERROR_REPORT,
            payload = json.encodeToString(PlaybackErrorReportPayload.serializer(), payload),
        ),
    )

    /**
     * Durable close-by-id for an already-open server play row. Background
     * path only (no user hint) — same as [enqueuePlayOffline].
     */
    suspend fun enqueuePlayEnded(payload: PlayEndedPayload): Long = dao.insert(
        CachedMutationEntity(
            kind = MutationKind.PLAY_ENDED,
            payload = json.encodeToString(PlayEndedPayload.serializer(), payload),
        ),
    )

    private suspend fun insertUserDriven(kind: String, payload: String): Long {
        val id = dao.insert(CachedMutationEntity(kind = kind, payload = payload))
        _userEnqueueHints.tryEmit(QUEUED_MESSAGE)
        return id
    }
}

/**
 * Persisted payload for `MutationKind.SUGGESTION_SNOOZE_TOGGLE` (#2374).
 * `desiredSnoozed` is the *target* state, matching [LikeTogglePayload], so
 * the replayer can collapse repeated toggles for one candidate down to the
 * last intent. Both directions are idempotent server-side: re-snoozing
 * extends the window, and un-snoozing something already back is a 404 the
 * replayer treats as permanent (nothing left to do).
 */
@Serializable
data class SuggestionSnoozeTogglePayload(
    val mbid: String,
    val name: String,
    val desiredSnoozed: Boolean,
)

/**
 * Persisted payload for `MutationKind.QUARANTINE_UNFLAG` — the
 * `DELETE /api/quarantine/{trackId}` call lost during a connectivity
 * hiccup. The replayer just re-fires the DELETE; idempotent server-side.
 */
@Serializable
data class QuarantineUnflagPayload(val trackId: String)

/**
 * Persisted payload for `MutationKind.PLAYLIST_APPEND`. Mirrors the
 * Flutter wire shape: list of track IDs to append to a specific
 * playlist. Server is idempotent at the (playlist_id, track_id) pair
 * level — re-firing the same payload yields no duplicates.
 */
@Serializable
data class PlaylistAppendPayload(
    val playlistId: String,
    val trackIds: List<String>,
)

/**
 * Persisted payload for `MutationKind.QUARANTINE_FLAG`. The replayer
 * re-fires `POST /api/quarantine` with this body when the original
 * call failed; server-side, re-flagging an already-flagged (user,
 * track) pair is idempotent.
 */
@Serializable
data class QuarantineFlagPayload(
    val trackId: String,
    val reason: String,
    val notes: String,
)

/**
 * Persisted payload for `MutationKind.PLAY_OFFLINE`. The replayer
 * re-fires `POST /api/events` with type=play_offline; the server
 * records start+end from `atIso` (the original play-start time)
 * + `durationPlayedMs`, applies the canonical skip rule, and
 * advances #415 rotation when source is a system playlist.
 */
@Serializable
data class PlayOfflinePayload(
    val trackId: String,
    val clientId: String,
    val atIso: String,
    val durationPlayedMs: Long,
    val source: String? = null,
    // #1551: device class for context conditioning; null on payloads queued
    // before this field existed (decodes to null → server stores NULL).
    val deviceClass: String? = null,
)

/**
 * Persisted payload for `MutationKind.PLAY_ENDED`. The replayer re-fires
 * `POST /api/events` with type=play_ended, closing the already-open server
 * row [playEventId] at [durationPlayedMs]. Server-side close-by-id is
 * idempotent (re-ending updates the same row and skips a duplicate
 * skip_events insert), so a lost-response retry can't duplicate history.
 */
@Serializable
data class PlayEndedPayload(
    val playEventId: String,
    val durationPlayedMs: Long,
)

/**
 * Persisted payload for `MutationKind.REQUEST_CANCEL` — the
 * `POST /api/requests/{id}/cancel` call lost during a connectivity
 * hiccup. Server is idempotent on re-cancel (already-cancelled
 * request stays cancelled).
 */
@Serializable
data class RequestCancelPayload(val requestId: String)

/**
 * Persisted payload for `MutationKind.PLAYBACK_ERROR_REPORT` — the
 * `POST /api/playback-errors` call lost during a connectivity hiccup.
 * The replayer re-fires the POST with this body. Server is naturally
 * idempotent enough — multiple reports of the same (track, user,
 * kind) become multiple rows in the admin inbox, which is acceptable
 * (the admin can resolve them all with one action). `clientId` is
 * the existing playback client identifier from AuthStore.
 */
@Serializable
data class PlaybackErrorReportPayload(
    val trackId: String,
    val kind: String,
    val detail: String? = null,
    val clientId: String,
)
