package com.fabledsword.minstrel.player

import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import com.fabledsword.minstrel.cache.mutations.PlayEndedPayload
import com.fabledsword.minstrel.cache.mutations.PlayOfflinePayload
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.wire.PlayEndedRequest
import com.fabledsword.minstrel.models.wire.PlayStartedRequest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import retrofit2.Retrofit
import retrofit2.create
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Mobile play-event lifecycle reporter. Closes the parity gap where
 * Android-played tracks never reached the server — without this,
 * History stayed empty for mobile listening, recommendation scoring
 * was biased toward web/desktop, ListenBrainz scrobbles never fired,
 * and system-playlist rotation didn't advance on mobile activity.
 *
 * State machine over (current track id, playing) observed against
 * [PlayerController.uiState]. The client always calls `playEnded`
 * with the actual position played to; the server applies its
 * configurable skip rule (`skip_max_completion_ratio` AND
 * `skip_max_duration_played_ms`, default 50% AND 30s) to decide
 * was_skipped. Earlier shape made the classification client-side via
 * a "within 3s of duration" rule, which left the server's threshold
 * dead code and treated any "next" tap as a skip — too strict
 * (operator 2026-06-01).
 *
 * `playSkipped` endpoint stays on the wire for a future explicit-
 * dislike affordance, but isn't called from normal track-change
 * transitions any more.
 *
 * Live calls go through [EventsApi]; failures fall through to the
 * offline [MutationQueue] so a flaky connection never loses a play.
 * App-background / detached states close the current play durably
 * via the offline queue (a fire-and-forget POST during teardown is
 * unreliable; the queue survives a process kill).
 */
@Singleton
class PlayEventsReporter @Inject constructor(
    private val playerController: PlayerController,
    private val authStore: AuthStore,
    private val mutationQueue: MutationQueue,
    @ApplicationScope private val scope: CoroutineScope,
    retrofit: Retrofit,
) : DefaultLifecycleObserver {

    private val api: EventsApi = retrofit.create()

    private var curTrackId: String? = null
    private var curStartedAt: Instant? = null
    private var curSource: String? = null
    private var curLastPositionMs: Long = 0
    private var curDurationMs: Long = 0

    // Whether the currently-tracked play is actively playing (vs paused).
    // onStop reads this: a backgrounded play that's still playing is left
    // to the live path (the foreground service keeps us alive), while a
    // paused one is durably closed so a kill can't orphan its open row.
    private var curPlaying: Boolean = false

    // Server play_event_id when the live play_started succeeded;
    // null if start failed/offline — then the close is captured into
    // the offline mutation queue instead of a live ended/skipped.
    private var openPlayEventId: String? = null

    // True from beginTrack until startLive's play_started resolves. When a
    // close races an in-flight start (id still null), the server row is
    // being created but we've lost its id — we must NOT also enqueue an
    // offline play (that would duplicate); the server auto-closes the
    // orphan on the next play_started instead.
    private var startInFlight: Boolean = false

    private var prevTrackId: String? = null

    init {
        ProcessLifecycleOwner.get().lifecycle.addObserver(this)
        scope.launch {
            playerController.uiState.collect { state ->
                evaluate(
                    trackId = state.currentTrack?.id,
                    playing = state.isPlaying,
                    positionMs = state.positionMs,
                    durationMs = state.durationMs,
                    source = state.currentSource,
                )
            }
        }
    }

    private fun evaluate(
        trackId: String?,
        playing: Boolean,
        positionMs: Long,
        durationMs: Long,
        source: String?,
    ) {
        // Track-change → close the prior tracked play.
        if (trackId != prevTrackId && curTrackId != null) {
            closeCurrent()
        }

        // Entered playing for a new track → begin tracking it.
        if (trackId != null && playing && curTrackId != trackId) {
            beginTrack(trackId = trackId, source = source, durationMs = durationMs)
        }

        // Advance the tracked play's progress. The gate `trackId ==
        // curTrackId` keeps a finishing track's last-known position
        // intact across the moment the listener fires for a track
        // change (where currentTrack is briefly the new one).
        if (curTrackId != null && trackId == curTrackId) {
            curLastPositionMs = positionMs
            if (durationMs > 0) curDurationMs = durationMs
            curPlaying = playing
        }

        prevTrackId = trackId
    }

    private fun beginTrack(trackId: String, source: String?, durationMs: Long) {
        curTrackId = trackId
        curStartedAt = Clock.System.now()
        curSource = source
        curLastPositionMs = 0
        curDurationMs = durationMs
        curPlaying = true
        openPlayEventId = null
        startInFlight = true
        startLive(trackId = trackId, source = source)
    }

    private fun startLive(trackId: String, source: String?) {
        val cid = resolveClientId()
        if (cid == null) {
            startInFlight = false
            return
        }
        scope.launch {
            try {
                val response = api.playStarted(
                    PlayStartedRequest(
                        trackId = trackId,
                        clientId = cid,
                        source = source.takeIf { !it.isNullOrEmpty() },
                        deviceClass = DEVICE_CLASS,
                    ),
                )
                if (curTrackId == trackId) {
                    openPlayEventId = response.playEventId
                }
            } catch (
                @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
            ) {
                // Offline / flaky — openPlayEventId stays null; close
                // path will enqueue the completed play for replay.
            } finally {
                startInFlight = false
            }
        }
    }

    /**
     * Closes the tracked play. Routes by whether the server already has an
     * open row, NOT by caller context — this is what keeps background /
     * track-change / failure paths from each minting a second row:
     *  - open server row (id) → close it by id; on failure enqueue a durable
     *    close-by-id (never an offline play).
     *  - start in flight (no id yet) → leave it; the server auto-closes the
     *    orphan on the next play_started. Enqueuing offline here would
     *    duplicate the in-flight row.
     *  - no server row at all → record the whole play offline.
     *
     * Always uses the actual position played; the server's skip rule
     * (50%/30s defaults) classifies was_skipped from it.
     */
    private fun closeCurrent() {
        val trackId = curTrackId
        val startedAt = curStartedAt
        if (trackId == null || startedAt == null) {
            resetCurrent()
            return
        }
        val durationMs = curLastPositionMs
        val source = curSource
        val id = openPlayEventId
        val inFlight = startInFlight
        when {
            id != null -> closeById(id, durationMs)
            inFlight -> Unit
            else -> enqueueOffline(trackId, startedAt, source, durationMs)
        }
        resetCurrent()
    }

    private fun closeById(id: String, durationMs: Long) {
        scope.launch {
            try {
                api.playEnded(
                    PlayEndedRequest(playEventId = id, durationPlayedMs = durationMs),
                )
            } catch (
                @Suppress("TooGenericExceptionCaught", "SwallowedException") e: Throwable,
            ) {
                // Durable close-by-id (idempotent server-side) — survives a
                // process kill without orphaning the open row or duplicating.
                mutationQueue.enqueuePlayEnded(
                    PlayEndedPayload(playEventId = id, durationPlayedMs = durationMs),
                )
            }
        }
    }

    private fun resetCurrent() {
        curTrackId = null
        curStartedAt = null
        curSource = null
        curLastPositionMs = 0
        curDurationMs = 0
        curPlaying = false
        openPlayEventId = null
        startInFlight = false
    }

    private fun enqueueOffline(
        trackId: String,
        startedAt: Instant,
        source: String?,
        durationPlayedMs: Long,
    ) {
        val cid = resolveClientId() ?: return
        scope.launch {
            mutationQueue.enqueuePlayOffline(
                PlayOfflinePayload(
                    trackId = trackId,
                    clientId = cid,
                    atIso = startedAt.toString(),
                    durationPlayedMs = durationPlayedMs,
                    source = source.takeIf { !it.isNullOrEmpty() },
                    deviceClass = DEVICE_CLASS,
                ),
            )
        }
    }

    /**
     * Returns the persisted client id, generating + persisting one on
     * first read. The setter is fire-and-forget on the app scope so
     * this stays synchronous; the in-memory StateFlow update is
     * immediate so the next caller observes the new value.
     */
    private fun resolveClientId(): String? {
        authStore.clientId.value?.let { return it }
        val fresh = UUID.randomUUID().toString()
        authStore.setClientId(fresh)
        return fresh
    }

    // ── Lifecycle: durable-close on app background / detach ────────

    companion object {
        // #1551: Android is a mobile client. Refine to tablet/tv via device
        // configuration later if the metrics trend view shows it matters.
        private const val DEVICE_CLASS = "mobile"
    }

    override fun onStop(owner: LifecycleOwner) {
        // App backgrounded. If the tracked play is still PLAYING, leave it
        // alone: the foreground media service keeps the process — and this
        // app-scope uiState collector — alive, so the live state machine
        // closes the play accurately on the next real track change. Closing
        // here (the old always-offline behavior) raced the collector's
        // re-begin and minted a duplicate, skip-misclassified row on every
        // screen-lock. Only durably close a PAUSED play, where no further
        // progress will happen and a process kill would otherwise orphan the
        // open server row. See docs/superpowers/2026-06-11 contract audit.
        if (curTrackId != null && !curPlaying) {
            closeCurrent()
        }
    }
}
