package com.fabledsword.minstrel.connectivity

import androidx.compose.runtime.staticCompositionLocalOf
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.BuildConfig
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.update.api.HealthzApi
import com.fabledsword.minstrel.update.api.HealthzResponse
import com.fabledsword.minstrel.update.data.VersionResult
import com.fabledsword.minstrel.update.data.isVersionNewer
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import retrofit2.Retrofit
import timber.log.Timber
import java.util.concurrent.atomic.AtomicLong
import javax.inject.Inject
import javax.inject.Singleton

private const val POLL_HEALTHY_MS = 5 * 60 * 1000L
private const val POLL_DEGRADED_MS = 20_000L
private const val ARBITRATE_MIN_GAP_MS = 2_000L

/**
 * THE single authority on network/server reachability. Absorbs the former
 * VersionCheckController (the /healthz poll + version parsing) and
 * ServerHealthController (the tri-state derive) into one signal-driven unit so
 * the app has one place that answers "can we reach Minstrel?" — not three
 * half-systems reporting differently.
 *
 * Inputs (all funnel through a single-consumer intent channel for thread
 * safety — [reportSuccess]/[reportFailure] are called from the audio read path
 * and OkHttp threads):
 *  - device link transitions ([ConnectivityObserver]); link-return triggers an
 *    immediate probe so recovery is near-instant (fixes the sticky banner).
 *  - periodic /healthz probe, adaptive cadence (calm when Healthy, fast when down).
 *  - reportSuccess / reportFailure from the API interceptor, the audio data
 *    source, and the playback-error reporter.
 *  - recheck() from pull-to-refresh and the banner.
 *  - a forced probe when the app returns to the foreground (#1209). Without
 *    it a stale ServerDown outlived the condition that caused it: the poll
 *    loop's delay() is throttled while screen-off/doze, so recovery waited on
 *    whenever the OS next let the loop run. Meanwhile ServerDown makes
 *    OfflineGatedDataSource refuse every uncached track, so the app declined
 *    to play music that would have played fine.
 *
 * Version compatibility is a byproduct of the same /healthz response.
 *
 * Constructed at launch via the construct-the-singleton trick in
 * [com.fabledsword.minstrel.MinstrelApplication].
 */
@Singleton
class NetworkStatusController @Inject constructor(
    @ApplicationScope private val scope: CoroutineScope,
    connectivity: ConnectivityObserver,
    private val authStore: AuthStore,
    retrofit: Retrofit,
) : DefaultLifecycleObserver {
    private val api: HealthzApi = retrofit.create(HealthzApi::class.java)
    private val machine = ReachabilityMachine()
    private val lastProbeAtMs = AtomicLong(0)

    private val stateInternal = MutableStateFlow(ServerHealth.Healthy)
    val state: StateFlow<ServerHealth> = stateInternal.asStateFlow()

    private val versionInternal = MutableStateFlow(VersionResult.SKIPPED)
    val versionResult: StateFlow<VersionResult> = versionInternal.asStateFlow()

    private sealed interface Intent {
        data class Link(val up: Boolean) : Intent
        data class Probe(val resp: HealthzResponse?) : Intent
        object OpSuccess : Intent
        object OpFailure : Intent
    }

    private val intents = Channel<Intent>(Channel.UNLIMITED)

    init {
        ProcessLifecycleOwner.get().lifecycle.addObserver(this)
        scope.launch { reduceLoop() }
        scope.launch {
            connectivity.online.collect { up ->
                intents.trySend(Intent.Link(up))
                if (up) probeOnce()
            }
        }
        scope.launch { pollLoop() }
    }

    /** A real server op verifiably succeeded — self-proving recovery. Cheap; safe on hot paths. */
    fun reportSuccess() {
        if (stateInternal.value != ServerHealth.Healthy) intents.trySend(Intent.OpSuccess)
    }

    /** A real network op failed — triggers /healthz arbitration. No-op when already Offline. */
    fun reportFailure() {
        if (stateInternal.value == ServerHealth.Offline) return
        intents.trySend(Intent.OpFailure)
    }

    /** One-shot recheck for pull-to-refresh and the banner. */
    fun recheck() {
        scope.launch { probeOnce(force = true) }
    }

    /**
     * App returned to the foreground — probe now rather than waiting for the
     * poll loop (#1209).
     *
     * The link-return probe in `init` does NOT cover this: it fires on a
     * connectivity *change*, and an app backgrounded on stable Wi-Fi sees none.
     * force = true so this also bypasses the ARBITRATE_MIN_GAP_MS throttle —
     * a user bringing the app up is exactly when a stale banner and a refused
     * track are most visible, and it's a once-per-foreground cost.
     */
    override fun onStart(owner: LifecycleOwner) {
        recheck()
    }

    private suspend fun reduceLoop() {
        for (intent in intents) {
            val now = System.currentTimeMillis()
            when (intent) {
                is Intent.Link -> machine.onLinkChange(intent.up)
                is Intent.Probe -> applyProbe(intent.resp, now)
                Intent.OpSuccess -> machine.onSuccess()
                Intent.OpFailure -> {
                    machine.onOpFailure(now)
                    // Arbitrate off the reducer thread: awaiting a stalled
                    // /healthz here would block a concurrent self-proving
                    // success from snapping us straight back to Healthy.
                    scope.launch { probeOnce() }
                }
            }
            emit(machine.health())
        }
    }

    private fun applyProbe(resp: HealthzResponse?, now: Long) {
        if (resp == null) {
            machine.onProbeFailure(now)
        } else {
            machine.onSuccess()
            versionInternal.value = versionResultFor(resp)
        }
    }

    private fun emit(next: ServerHealth) {
        if (stateInternal.value != next) {
            Timber.w("NetworkStatus -> %s", next)
            stateInternal.value = next
        }
    }

    private suspend fun pollLoop() {
        probeOnce()
        while (true) {
            val interval =
                if (stateInternal.value == ServerHealth.Healthy) POLL_HEALTHY_MS
                else POLL_DEGRADED_MS
            delay(interval)
            probeOnce()
        }
    }

    private suspend fun probeOnce(force: Boolean = false) {
        // Startup guard: don't poll the localhost placeholder before AuthStore
        // hydrates the real base URL — that false failure used to flash the banner.
        if (authStore.baseUrl.value == AuthStore.DEFAULT_BASE_URL) return
        val now = System.currentTimeMillis()
        if (!force && now - lastProbeAtMs.get() < ARBITRATE_MIN_GAP_MS) return
        lastProbeAtMs.set(now)
        val resp = runCatching { api.check() }.getOrNull()
        intents.trySend(Intent.Probe(resp))
    }

    private fun versionResultFor(resp: HealthzResponse): VersionResult {
        val min = resp.minClientVersion
        return when {
            min.isEmpty() -> VersionResult.SKIPPED
            isVersionNewer(min, BuildConfig.VERSION_NAME) -> VersionResult.TOO_OLD
            else -> VersionResult.OK
        }
    }
}

/**
 * Reactive [ServerHealth] snapshot provided once at the app root. Lets leaf
 * composables (TrackRow gating, write-affordance disabling) branch on health
 * without re-injecting the controller. Defaults to Healthy so previews/tests
 * don't crash.
 */
val LocalServerHealth = staticCompositionLocalOf { ServerHealth.Healthy }
