package com.fabledsword.minstrel.events

import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.sse.EventSource
import okhttp3.sse.EventSourceListener
import okhttp3.sse.EventSources
import javax.inject.Inject
import javax.inject.Singleton

private const val SSE_PATH = "/api/events/stream"
private const val EVENTS_BUFFER_CAPACITY = 64
private const val BASE_BACKOFF_MS = 1_000L
private const val MAX_BACKOFF_MS = 30_000L
private const val BACKOFF_FACTOR = 2

/**
 * Long-lived SSE subscription to `GET /api/events/stream`. Exposes
 * [events] as a process-wide [SharedFlow]; consumers (per-screen
 * ViewModels + the central [LiveEventsDispatcher]) collect filtered
 * subsets of the stream.
 *
 * Connection lifecycle mirrors
 * `flutter_client/lib/shared/live_events_provider.dart`:
 * - Gated on having a session cookie. Subscription opens when the
 *   cookie transitions to non-null and closes when it transitions
 *   back to null (sign-out).
 * - No client-side timeout — the server emits 15s heartbeats which
 *   okhttp-sse handles transparently.
 * - Reconnect-with-backoff: if the stream drops mid-session (server
 *   restart, network blip) it reconnects with exponential backoff
 *   (1s → 2s → … → 30s cap), reset to 1s on a successful open. Only
 *   reconnects while still signed in; a sign-out cancels the pending
 *   retry. Without this a single blip silently kills cross-device
 *   reactivity until the next app launch.
 *
 * The URL passes through the placeholder host that
 * `BaseUrlInterceptor` rewrites — same mechanism the rest of the
 * Retrofit traffic uses, so server-URL changes propagate without
 * needing a separate path through here.
 */
@Singleton
class EventsStream @Inject constructor(
    private val authStore: AuthStore,
    @ApplicationScope private val scope: CoroutineScope,
    private val okHttpClient: OkHttpClient,
    private val json: Json,
) {
    private val factory = EventSources.createFactory(okHttpClient)

    private val emitter = MutableSharedFlow<LiveEvent>(
        replay = 0,
        extraBufferCapacity = EVENTS_BUFFER_CAPACITY,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
    )
    val events: SharedFlow<LiveEvent> = emitter.asSharedFlow()

    private var currentSource: EventSource? = null
    @Volatile private var signedIn = false
    private var reconnectJob: Job? = null
    private var backoffMs = BASE_BACKOFF_MS

    init {
        scope.launch {
            authStore.sessionCookie
                .map { !it.isNullOrEmpty() }
                .distinctUntilChanged()
                .collect { isSignedIn ->
                    signedIn = isSignedIn
                    if (isSignedIn) {
                        backoffMs = BASE_BACKOFF_MS
                        connect()
                    } else {
                        disconnect()
                    }
                }
        }
    }

    @Synchronized
    private fun connect() {
        reconnectJob?.cancel()
        currentSource?.cancel()
        currentSource = openSubscription()
    }

    @Synchronized
    private fun disconnect() {
        reconnectJob?.cancel()
        currentSource?.cancel()
        currentSource = null
    }

    /**
     * Schedule a reconnect after the current backoff, then double it
     * (capped). No-op when signed out — sign-out's [disconnect]
     * cancels the pending job. A successful [Listener.onOpen] resets
     * the backoff to the floor.
     */
    @Synchronized
    private fun scheduleReconnect() {
        if (!signedIn) return
        reconnectJob?.cancel()
        val waitMs = backoffMs
        backoffMs = (backoffMs * BACKOFF_FACTOR).coerceAtMost(MAX_BACKOFF_MS)
        reconnectJob = scope.launch {
            delay(waitMs)
            if (signedIn) connect()
        }
    }

    private fun openSubscription(): EventSource {
        val request = Request.Builder()
            .url("http://placeholder.invalid$SSE_PATH")
            .header("Accept", "text/event-stream")
            .build()
        return factory.newEventSource(request, Listener())
    }

    private inner class Listener : EventSourceListener() {
        override fun onOpen(eventSource: EventSource, response: Response) {
            backoffMs = BASE_BACKOFF_MS
        }

        override fun onEvent(
            eventSource: EventSource,
            id: String?,
            type: String?,
            data: String,
        ) {
            parseFrame(kind = type, data = data)?.let(emitter::tryEmit)
        }

        override fun onClosed(eventSource: EventSource) {
            scheduleReconnect()
        }

        override fun onFailure(
            eventSource: EventSource,
            t: Throwable?,
            response: Response?,
        ) {
            scheduleReconnect()
        }
    }

    /**
     * Parses one SSE frame into a [LiveEvent]. The SSE `event:` field
     * carries the kind; the `data:` field is a JSON object with
     * `kind`/`user_id`/`data` (the kind in the payload is redundant
     * with the SSE event field — we prefer the SSE field, falling
     * back to the payload field). Heartbeat comment frames don't
     * reach here (okhttp-sse filters them).
     */
    private fun parseFrame(kind: String?, data: String): LiveEvent? = try {
        val payload = json.parseToJsonElement(data).jsonObject
        val resolvedKind = kind ?: payload["kind"]?.jsonPrimitive?.contentOrNull
        if (resolvedKind.isNullOrEmpty()) {
            null
        } else {
            LiveEvent(
                kind = resolvedKind,
                userId = payload["user_id"]?.jsonPrimitive?.contentOrNull.orEmpty(),
                data = (payload["data"] as? JsonObject) ?: JsonObject(emptyMap()),
            )
        }
    } catch (
        @Suppress("TooGenericExceptionCaught") _: Throwable,
    ) {
        null
    }
}
