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

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 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

/**
 * The embedded distributor's transport (PLAN.md §11, M7 Part 2 work item 1/3):
 * a persistent connection to the shard's self-hosted ntfy that subscribes to the
 * app's own topic and re-emits each content-free tickle. It reuses the same
 * OkHttp-SSE + reconnect/backoff shape as [com.runicgateway.app.core.net.ShardStreamClient],
 * but on a **bare** client — no host-retargeting or bearer interceptors — because it
 * talks straight to ntfy (`<ntfy>/<topic>/sse`), not the website API. Held open by
 * [PushService]'s foreground service so tickles arrive in the background without
 * Google Play Services.
 */
@Singleton
class NtfyStreamClient @Inject constructor(
    private val json: Json,
) {
    // A dedicated client with the read timeout disabled for the mostly-idle stream
    // (ntfy sends keepalive frames); no interceptors so nothing rewrites the host or
    // attaches a bearer to the relay.
    private val client: OkHttpClient = OkHttpClient.Builder()
        .readTimeout(0, TimeUnit.MILLISECONDS)
        .retryOnConnectionFailure(true)
        .build()

    private val factory = EventSources.createFactory(client)

    /** Connection lifecycle + decoded tickles for a subscribed topic. */
    sealed interface Event {
        data object Open : Event
        data object Closed : Event
        data class Message(val tickle: PushTickle) : Event
    }

    /**
     * A cold flow subscribing to `<ntfyBaseUrl>/<topic>/sse`, reconnecting with
     * backoff until the collector cancels. A dropped relay simply reconnects; a bad
     * config (null URL) idles rather than spinning.
     */
    fun events(ntfyBaseUrl: String?, topic: String): Flow<Event> = channelFlow {
        var backoffMs = INITIAL_BACKOFF_MS
        while (isActive) {
            val url = NtfyTopic.sseUrl(ntfyBaseUrl, topic)
            if (url == null) {
                trySend(Event.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(Event.Open)
                }

                override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
                    parseNtfyTickle(json, data)?.let { trySend(Event.Message(it)) }
                }

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

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

            val source = factory.newEventSource(request, listener)
            try {
                ended.await()
            } finally {
                source.cancel()
            }

            backoffMs = if (opened.get()) INITIAL_BACKOFF_MS else grow(backoffMs)
            delay(backoffMs)
        }
    }

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

    private companion object {
        const val INITIAL_BACKOFF_MS = 2_000L
        const val MAX_BACKOFF_MS = 30_000L
    }
}
