/*
 * SPDX-License-Identifier: GPL-3.0-or-later
 */
package com.runicgateway.app.core.net

import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.isActive
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
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 java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Consumes the public live-event SSE stream (`GET /public/shard/stream`, safe kinds
 * only) and re-emits each frame as a [ShardStreamEvent] (PLAN.md §6.2, §7).
 *
 * Unlike the browser's `EventSource`, OkHttp's does **not** auto-reconnect, so the
 * reconnect/backoff loop lives here: on any disconnect the connection is torn down
 * and re-opened after a growing delay (reset once a connection opens), and while no
 * shard site is configured yet the flow simply idles. The stream is exposed as a
 * cold [Flow]; a `viewModelScope` collect opens it and cancellation closes it, so a
 * dropped feed degrades to "offline" rather than crashing.
 */
@Singleton
class ShardStreamClient @Inject constructor(
    baseClient: OkHttpClient,
    private val baseUrlHolder: BaseUrlHolder,
    private val json: Json,
) : ShardStream {
    // SSE is a long-lived, mostly-idle connection (keepalive comments every ~25s),
    // so the read timeout must be disabled or the idle stream would be killed.
    private val sseClient: OkHttpClient = baseClient.newBuilder()
        .readTimeout(0, TimeUnit.MILLISECONDS)
        .retryOnConnectionFailure(true)
        .build()

    private val factory = EventSources.createFactory(sseClient)

    /**
     * A cold flow of stream lifecycle + frame events, reconnecting with backoff
     * until the collector cancels. [ShardStreamEvent.Open] / [ShardStreamEvent.Closed]
     * drive a live/offline indicator; [ShardStreamEvent.Frame] carries a decoded
     * `{ kind, … }` payload the boards merge in place.
     */
    override fun events(): Flow<ShardStreamEvent> = channelFlow {
        var backoffMs = INITIAL_BACKOFF_MS
        while (isActive) {
            val url = baseUrlHolder.current?.resolve(STREAM_PATH)
            if (url == null) {
                // No shard site configured (or an unresolvable base) — idle, don't spin.
                trySend(ShardStreamEvent.Closed)
                delay(backoffMs)
                backoffMs = grow(backoffMs)
                continue
            }

            val request = Request.Builder()
                .url(url)
                .header("Accept", "text/event-stream")
                .build()

            val opened = AtomicBoolean(false)
            val ended = CompletableDeferred<Unit>()
            val listener = object : EventSourceListener() {
                override fun onOpen(eventSource: EventSource, response: Response) {
                    opened.set(true)
                    trySend(ShardStreamEvent.Open)
                }

                override fun onEvent(
                    eventSource: EventSource,
                    id: String?,
                    type: String?,
                    data: String,
                ) {
                    parseFrame(data)?.let { (kind, obj) ->
                        trySend(ShardStreamEvent.Frame(kind, obj))
                    }
                }

                override fun onClosed(eventSource: EventSource) {
                    trySend(ShardStreamEvent.Closed)
                    ended.complete(Unit)
                }

                override fun onFailure(
                    eventSource: EventSource,
                    t: Throwable?,
                    response: Response?,
                ) {
                    trySend(ShardStreamEvent.Closed)
                    ended.complete(Unit)
                }
            }

            val source = factory.newEventSource(request, listener)
            try {
                // Park until this connection ends; collector cancellation propagates
                // out of await() and is handled by the finally + the while guard.
                ended.await()
            } finally {
                source.cancel()
            }

            // A connection that opened before dropping reconnects promptly; a run of
            // failures that never opened backs off further to avoid hammering a down site.
            backoffMs = if (opened.get()) INITIAL_BACKOFF_MS else grow(backoffMs)
            delay(backoffMs)
        }
    }

    /**
     * Parse an SSE `data:` line into `(kind, object)`, dropping keepalive comments
     * and any frame without a string `kind`. Kept internal + pure for unit testing.
     */
    internal fun parseFrame(data: String): Pair<String, JsonObject>? {
        val trimmed = data.trim()
        if (trimmed.isEmpty() || trimmed.startsWith(":")) return null
        return try {
            val obj = json.parseToJsonElement(trimmed).jsonObject
            val kindEl = obj["kind"] ?: return null
            if (kindEl is JsonNull) return null
            val kind = kindEl.jsonPrimitive.content
            if (kind.isEmpty()) null else kind to obj
        } catch (_: Exception) {
            null
        }
    }

    private fun grow(current: Long): Long = (current * 2).coerceAtMost(MAX_BACKOFF_MS)

    private companion object {
        const val STREAM_PATH = "api/v1/public/shard/stream"
        const val INITIAL_BACKOFF_MS = 2_000L
        const val MAX_BACKOFF_MS = 30_000L
    }
}
