package com.fabledsword.minstrel.diagnostics

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.PowerManager
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.core.content.ContextCompat
import androidx.lifecycle.ProcessLifecycleOwner
import com.fabledsword.minstrel.auth.AuthController
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.DiagnosticEventDao
import com.fabledsword.minstrel.cache.db.entities.DiagnosticEventEntity
import com.fabledsword.minstrel.connectivity.ConnectivityObserver
import com.fabledsword.minstrel.connectivity.NetworkStatusController
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.PlayerController
import com.fabledsword.minstrel.player.RemotePlayerState
import com.fabledsword.minstrel.player.output.OutputPickerController
import com.fabledsword.minstrel.player.output.OutputRoute
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.combine
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.buildJsonObject
import kotlinx.serialization.json.put
import javax.inject.Inject
import javax.inject.Singleton

/**
 * On-device diagnostics collector (M9). Active only while the account's
 * debug mode is enabled (server flag, read via AuthController.currentUser)
 * AND the user hasn't opted out locally. When active it taps existing
 * connectivity / player / UPnP / power seams READ-ONLY and writes a
 * timeseries of events into the [DiagnosticEventDao] ring buffer; the
 * [DiagnosticsUploader] drains that buffer to the server.
 *
 * The aim is to explain two failure classes after the fact: roaming /
 * poor-data recovery (connectivity + server-health transitions) and
 * locked-phone UPnP desync (power/Doze transitions correlated with the
 * Sonos-vs-local position deltas captured in the heartbeat).
 */
@Suppress("TooManyFunctions") // one collector + one (un)register pair per signal; cohesive
@Singleton
class DiagnosticsReporter @Inject constructor(
    @ApplicationContext private val context: Context,
    @ApplicationScope private val scope: CoroutineScope,
    private val dao: DiagnosticEventDao,
    private val authController: AuthController,
    private val authStore: AuthStore,
    private val connectivity: ConnectivityObserver,
    private val networkStatus: NetworkStatusController,
    private val playerController: PlayerController,
    private val outputPicker: OutputPickerController,
    private val remoteState: RemotePlayerState,
    private val json: Json,
) {
    @Volatile private var enabled = false
    private var collectorJob: Job? = null
    private var powerReceiver: BroadcastReceiver? = null
    private var lifecycleObserver: DefaultLifecycleObserver? = null

    init {
        // Gate = account flag (currentUser.debugModeEnabled) AND not the
        // per-device local opt-out.
        scope.launch {
            combine(authController.currentUser, authStore.diagnosticsOptOut) { user, optOut ->
                (user?.debugModeEnabled == true) && !optOut
            }.distinctUntilChanged().collect { setEnabled(it) }
        }
        // Periodically re-pull /me so a remote admin flip propagates
        // without a re-login or app restart.
        scope.launch {
            while (true) {
                delay(GATE_REFRESH_MS)
                authController.refreshProfile()
            }
        }
    }

    private fun setEnabled(value: Boolean) {
        if (value == enabled) return
        enabled = value
        if (value) start() else stop()
    }

    private fun start() {
        registerPowerReceiver()
        registerLifecycleObserver()
        collectorJob = scope.launch {
            record("lifecycle", buildJsonObject { put("event", "reporting_started") })
            launch { collectConnectivity() }
            launch { collectServerHealth() }
            launch { collectUpnpDrops() }
            launch { collectPlayerState() }
            launch { collectTrackChanges() }
            launch { collectRoutes() }
            launch { heartbeatLoop() }
        }
    }

    private fun stop() {
        collectorJob?.cancel()
        collectorJob = null
        unregisterPowerReceiver()
        unregisterLifecycleObserver()
        // Off means off: drop any unsent buffer (honors the user OFF
        // intent and avoids leaking data captured before disable).
        scope.launch { runCatching { dao.clear() } }
    }

    private suspend fun collectConnectivity() {
        connectivity.online.distinctUntilChanged().collect { online ->
            record("connectivity", buildJsonObject {
                put("event", "online_changed")
                put("online", online)
            })
        }
    }

    private suspend fun collectServerHealth() {
        // state is a StateFlow — already conflated/distinct, so no
        // distinctUntilChanged (it's a deprecation warning = build error).
        networkStatus.state.collect { s ->
            record("connectivity", buildJsonObject {
                put("event", "server_health")
                put("state", s.name)
            })
        }
    }

    private suspend fun collectUpnpDrops() {
        playerController.dropEvents.collect { msg ->
            record("upnp_sync", buildJsonObject {
                put("event", "drop")
                put("message", msg)
            })
        }
    }

    private suspend fun collectPlayerState() {
        // 'playback', not 'upnp_sync' — player state applies to every output
        // route (phone speaker, Bluetooth, UPnP), not just casting.
        playerController.uiState
            .map { Triple(it.currentSource, it.isUpnpLoading, it.playbackError) }
            .distinctUntilChanged()
            .collect { (source, upnpLoading, err) ->
                record("playback", buildJsonObject {
                    put("event", "player_state")
                    put("source", source ?: "")
                    put("upnp_loading", upnpLoading)
                    if (err != null) put("error", err)
                })
            }
    }

    // Emit a snapshot at each track/queue-index change — captures a
    // skip-induced local↔Sonos desync at the INSTANT it happens, which the
    // 45s heartbeat misses. Note: uiState is a conflated StateFlow, so a
    // very rapid skip burst may coalesce intermediate indices (we still get
    // the boundaries + the local-vs-Sonos snapshot).
    private suspend fun collectTrackChanges() {
        playerController.uiState
            .map { it.queueIndex to it.currentTrack?.id }
            .distinctUntilChanged()
            .collect { (index, _) ->
                val ui = playerController.uiState.value
                val casting = outputPicker.routesState.value.current.protocol !=
                    OutputRoute.Protocol.SYSTEM
                record("playback", buildJsonObject {
                    put("event", "track_change")
                    put("local_index", index)
                    // Track IDENTITY, not just index — indices wobble across
                    // re-casts, so the id + Sonos URI make a desync unambiguous.
                    put("local_track_id", ui.currentTrack?.id ?: "")
                    put("local_pos_ms", ui.positionMs)
                    putSonos(this, casting)
                    put("upnp_loading", ui.isUpnpLoading)
                    put("server_health", networkStatus.state.value.name)
                    put("route", outputPicker.routesState.value.current.name)
                })
            }
    }

    private suspend fun collectRoutes() {
        // 'playback' — route changes happen for all outputs. This only ever
        // logs the ACTIVE route (routesState.current), so no "connected" flag.
        outputPicker.routesState.map { it.current }.distinctUntilChanged().collect { r ->
            record("playback", buildJsonObject {
                put("event", "route")
                put("id", r.id)
                put("name", r.name)
                put("kind", r.kind.name)
                put("protocol", r.protocol.name)
            })
        }
    }

    private suspend fun heartbeatLoop() {
        while (true) {
            delay(HEARTBEAT_MS)
            val ui = playerController.uiState.value
            val route = outputPicker.routesState.value.current
            val routeActive = route.protocol != OutputRoute.Protocol.SYSTEM
            // Idle = nothing worth sampling. Skip to keep the buffer lean.
            if (!ui.isPlaying && !routeActive) continue
            record("heartbeat", buildJsonObject {
                put("server_health", networkStatus.state.value.name)
                put("is_playing", ui.isPlaying)
                put("source", ui.currentSource ?: "")
                put("local_index", ui.queueIndex)
                put("local_track_id", ui.currentTrack?.id ?: "")
                put("local_pos_ms", ui.positionMs)
                put("route", route.name)
                put("route_protocol", route.protocol.name)
                putSonos(this, routeActive)
                addPowerFields(this)
            })
        }
    }

    // --- power + lifecycle taps -------------------------------------------

    private fun registerPowerReceiver() {
        if (powerReceiver != null) return
        val receiver = object : BroadcastReceiver() {
            override fun onReceive(c: Context?, intent: Intent?) {
                val action = intent?.action ?: return
                if (!enabled) return
                scope.launch { record("power", powerEvent(action)) }
            }
        }
        val filter = IntentFilter().apply {
            addAction(Intent.ACTION_SCREEN_ON)
            addAction(Intent.ACTION_SCREEN_OFF)
            addAction(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)
        }
        // System-protected broadcasts, but pass NOT_EXPORTED explicitly so
        // the API 34+ registerReceiver flag requirement is satisfied.
        ContextCompat.registerReceiver(
            context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED,
        )
        powerReceiver = receiver
    }

    private fun unregisterPowerReceiver() {
        powerReceiver?.let { runCatching { context.unregisterReceiver(it) } }
        powerReceiver = null
    }

    private fun registerLifecycleObserver() {
        if (lifecycleObserver != null) return
        val obs = object : DefaultLifecycleObserver {
            override fun onStart(owner: LifecycleOwner) = logLifecycle("app_foreground")
            override fun onStop(owner: LifecycleOwner) = logLifecycle("app_background")
        }
        lifecycleObserver = obs
        scope.launch(Dispatchers.Main) {
            ProcessLifecycleOwner.get().lifecycle.addObserver(obs)
        }
    }

    private fun unregisterLifecycleObserver() {
        val obs = lifecycleObserver ?: return
        lifecycleObserver = null
        scope.launch(Dispatchers.Main) {
            ProcessLifecycleOwner.get().lifecycle.removeObserver(obs)
        }
    }

    private fun logLifecycle(event: String) {
        if (!enabled) return
        scope.launch { record("lifecycle", buildJsonObject { put("event", event) }) }
    }

    private fun powerEvent(action: String): JsonObject = buildJsonObject {
        put("event", action.substringAfterLast('.'))
        addPowerFields(this)
    }

    // Sonos/UPnP remote-vs-local desync fields. Only meaningful while a
    // remote route is active; zeroed otherwise so a stale RemotePlayerState
    // from a just-ended cast can't masquerade as live Sonos data (the
    // cast→phone handoff artifact). currentTrackUri is the desync ground
    // truth — it carries the track the speaker is actually streaming.
    private fun putSonos(builder: kotlinx.serialization.json.JsonObjectBuilder, casting: Boolean) {
        builder.put("sonos_track", if (casting) remoteState.trackNumber else 0)
        builder.put("sonos_pos_ms", if (casting) remoteState.positionMs else 0)
        builder.put("sonos_playing", casting && remoteState.isPlaying)
        if (casting) builder.put("sonos_uri", remoteState.currentTrackUri)
    }

    private fun addPowerFields(builder: kotlinx.serialization.json.JsonObjectBuilder) {
        val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
        builder.put("doze", pm.isDeviceIdleMode)
        builder.put("screen_on", pm.isInteractive)
        builder.put("battery_opt_ignored", pm.isIgnoringBatteryOptimizations(context.packageName))
    }

    private suspend fun record(kind: String, payload: JsonObject) {
        runCatching {
            val id = dao.insert(
                DiagnosticEventEntity(
                    kind = kind,
                    occurredAtMillis = System.currentTimeMillis(),
                    payloadJson = json.encodeToString(JsonObject.serializer(), payload),
                ),
            )
            if (id % TRIM_INTERVAL == 0L) dao.trimToNewest(MAX_BUFFER)
        }
    }

    private companion object {
        const val HEARTBEAT_MS = 45_000L
        const val GATE_REFRESH_MS = 300_000L // re-pull /me every 5 min
        const val MAX_BUFFER = 5_000 // ring-buffer ceiling, oldest dropped
        const val TRIM_INTERVAL = 50L // trim every N inserts, not every one
    }
}
