@file:Suppress("TooManyFunctions") // discovery + Sonos topology + transport-lookup density
package com.fabledsword.minstrel.player.output.upnp

import android.content.Context
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.player.output.upnp.sonos.SonosZoneGroup
import com.fabledsword.minstrel.player.output.upnp.sonos.ZoneGroupTopologyClient
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Owns the SSDP listener lifecycle, fetches each discovered LOCATION's
 * device description XML, and projects discovered MediaRenderers into a
 * `StateFlow<List<UpnpRoute>>` that [OutputPickerController] merges with
 * system routes.
 *
 * Passive listen runs from process start (via `init`). Active discovery
 * (an M-SEARCH burst) is triggered by [upgradeDiscovery] when the
 * picker sheet opens; SSDP has no symmetric "downgrade" — the passive
 * NOTIFY listener stays on for the process lifetime, so
 * [downgradeDiscovery] is a no-op preserved for API parity with the
 * MediaRouter-side controller.
 *
 * Discovered devices are de-duplicated by UDN: a repeated NOTIFY for
 * the same speaker replaces the prior entry rather than appending a
 * duplicate row to the picker.
 */
@Singleton
class UpnpDiscoveryController @Inject constructor(
    @ApplicationContext context: Context,
    @ApplicationScope private val appScope: CoroutineScope,
    private val okHttp: OkHttpClient,
) {
    private val ssdp = SsdpDiscovery(context)

    // Transport commands (play/pause/seek/next) and the 1 Hz poll go through
    // this client. A dead renderer should fail fast so OutputPickerController's
    // drop-recovery reverts to the phone in ~2 s instead of the shared client's
    // 10 s connect timeout. Derived via newBuilder() so the connection pool +
    // dispatcher stay shared with the app client.
    private val controlHttp: OkHttpClient = okHttp.newBuilder()
        .connectTimeout(CONTROL_CONNECT_TIMEOUT_SECONDS, TimeUnit.SECONDS)
        .build()

    private val routesInternal = MutableStateFlow<List<UpnpRoute>>(emptyList())
    val routes: StateFlow<List<UpnpRoute>> = routesInternal.asStateFlow()

    // Wall-clock of the last NOTIFY / M-SEARCH response per route id. Drives
    // post-active-scan pruning so a powered-off renderer leaves the picker
    // (and can't pin a stale selection) instead of lingering for the process
    // lifetime -- upsert only ever added before.
    private val lastSeenMs = ConcurrentHashMap<String, Long>()

    private val sonosTopologyInternal = MutableStateFlow<List<SonosZoneGroup>>(emptyList())
    val sonosTopology: StateFlow<List<SonosZoneGroup>> = sonosTopologyInternal.asStateFlow()

    init {
        ssdp.start(appScope)
        // appScope is process-lifetime (SupervisorJob + Dispatchers.Default),
        // so the launched collector dies with the process — no explicit
        // cancellation needed.
        appScope.launch(Dispatchers.IO) {
            ssdp.discoveries.collect { locationUrl -> handleDiscovery(locationUrl) }
        }
    }

    /**
     * Fire an M-SEARCH burst so newly-paired speakers appear within a
     * few seconds rather than waiting for the next NOTIFY beacon
     * (typical SSDP cadence: every ~30min — far too slow for a UI
     * that just opened).
     */
    fun upgradeDiscovery() {
        appScope.launch {
            val scanStartMs = System.currentTimeMillis()
            ssdp.requestActiveScan()
            // Give present devices time to answer the M-SEARCH (+ our follow-up
            // description fetch), then drop any route that didn't re-announce.
            // Pruning is tied to picker-open scans only -- no background timer,
            // so route rows never flicker while the user isn't looking.
            delay(ACTIVE_SCAN_PRUNE_DELAY_MS)
            pruneRoutesNotSeenSince(scanStartMs)
        }
    }

    /**
     * Drop routes whose last NOTIFY / M-SEARCH response predates [cutoffMs]
     * -- i.e. they didn't answer the active scan that just ran, so they're
     * gone from the LAN. Called only after [upgradeDiscovery]'s scan window.
     */
    private fun pruneRoutesNotSeenSince(cutoffMs: Long) {
        val before = routesInternal.value
        val survivors = before.filter { (lastSeenMs[it.id] ?: 0L) >= cutoffMs }
        if (survivors.size == before.size) return
        val removed = before.filterNot { it in survivors }.map { it.id }
        removed.forEach { lastSeenMs.remove(it) }
        Timber.w("UPnP discovery: pruned %d stale route(s): %s", removed.size, removed)
        routesInternal.value = survivors
    }

    /**
     * No-op for the SSDP socket — passive NOTIFY listen is always on
     * once [init] has run. Kept symmetric with the MediaRouter side's
     * `downgradeDiscovery` so the picker controller fans out to both
     * without conditional logic.
     */
    fun downgradeDiscovery() {
        // intentionally empty: see kdoc
    }

    /**
     * Build an [AVTransportClient] bound to the previously-discovered
     * route's control URL. Returns null when the routeId isn't in the
     * current snapshot — the speaker disappeared between picker open
     * and tap, or the caller passed a non-UPnP routeId. Callers handle
     * the null by abandoning the selection (no crash, no fallback).
     */
    fun transportFor(routeId: String): AVTransportClient? {
        val route = routesInternal.value.firstOrNull { it.id == routeId } ?: return null
        return AVTransportClient(
            loggingSoapClient(controlHttp, route.name),
            route.avTransportControlUrl,
        )
    }

    private suspend fun handleDiscovery(locationUrl: String) {
        val route = fetchRoute(locationUrl) ?: return
        upsertRoute(route)
        if (route.manufacturer.contains("Sonos", ignoreCase = true)) {
            refreshSonosTopology(route)
        }
    }

    private fun upsertRoute(route: UpnpRoute) {
        lastSeenMs[route.id] = System.currentTimeMillis()
        val current = routesInternal.value
        val idx = current.indexOfFirst { it.id == route.id }
        routesInternal.value = if (idx < 0) {
            current + route
        } else {
            current.toMutableList().also { it[idx] = route }
        }
    }

    private suspend fun refreshSonosTopology(anySonos: UpnpRoute) {
        val zgtUrl = anySonos.zoneGroupTopologyControlUrl ?: run {
            Timber.w(
                "Sonos %s has no ZoneGroupTopology URL -- topology grouping disabled",
                anySonos.id,
            )
            return
        }
        val groups = runCatching {
            ZoneGroupTopologyClient(
                loggingSoapClient(okHttp, "ZGT-${anySonos.id}"),
                zgtUrl,
            ).getZoneGroupState()
        }
            .onFailure { Timber.w(it, "refreshSonosTopology failed for %s", anySonos.id) }
            .getOrNull() ?: return
        Timber.w(
            "Sonos topology refreshed for %s: %d group(s)",
            anySonos.id,
            groups.size,
        )
        sonosTopologyInternal.value = groups
    }

    /**
     * Returns the coordinator UDN's full route for the group [udn] belongs
     * to, or null if topology hasn't loaded / the udn is in no group. UDN
     * normalization strips the "uuid:" prefix because DeviceDescription's
     * <UDN> carries it but Sonos's ZoneGroupState Coordinator attr does not.
     */
    fun coordinatorRouteFor(udn: String): UpnpRoute? {
        val bare = udn.bareUdn()
        val coord = sonosTopologyInternal.value
            .firstOrNull { g -> g.members.any { it.udn.bareUdn() == bare } }
            ?.coordinatorUdn ?: return null
        return routesInternal.value.firstOrNull { it.id.bareUdn() == coord.bareUdn() }
    }

    /**
     * UDNs of every NON-coordinator Sonos group member. The picker
     * controller suppresses these from the visible list.
     */
    fun nonCoordinatorMemberUdns(): Set<String> {
        return sonosTopologyInternal.value.flatMap { g ->
            g.members.map { it.udn.bareUdn() }
                .filter { it != g.coordinatorUdn.bareUdn() }
        }.toSet()
    }

    /**
     * Fetch + parse the device description at [locationUrl] into a
     * [UpnpRoute]. Returns null on URL parse failure, transport
     * failure, empty body, or non-renderer device. Single-return form
     * (chained `let`s + an early null guard) so detekt's default
     * ReturnCount cap is respected.
     */
    private fun fetchRoute(locationUrl: String): UpnpRoute? {
        val url = locationUrl.toHttpUrlOrNull() ?: return null
        return fetchBody(url)
            ?.let { DeviceDescription.parse(it, url) }
            ?.let { desc ->
                UpnpRoute(
                    id = desc.udn,
                    name = displayName(desc.friendlyName, desc.manufacturer),
                    manufacturer = desc.manufacturer,
                    modelName = desc.modelName,
                    avTransportControlUrl = desc.avTransportControlUrl,
                    renderingControlUrl = desc.renderingControlUrl,
                    zoneGroupTopologyControlUrl = desc.zoneGroupTopologyControlUrl,
                )
            }
    }

    /**
     * Clean the raw UPnP friendlyName for picker display. Sonos uses
     * the format `Room - Device Type - RINCON_<UDN>`; we strip
     * everything after the first " - " so the chip shows just "Living
     * Room" / "Kitchen" / etc. Generic UPnP devices (Yamaha, Samsung
     * TV, etc.) often append a "(192.168.x.x)" IP suffix; strip that
     * too. Empty / blank → "Network speaker" fallback.
     *
     * Device subtitle (manufacturer + model) is rendered separately
     * by the picker sheet, so room-only here doesn't lose information.
     */
    private fun displayName(friendlyName: String, manufacturer: String): String {
        val trimmed = friendlyName.trim()
        if (trimmed.isBlank()) return "Network speaker"
        val byVendor = when {
            manufacturer.contains("Sonos", ignoreCase = true) -> {
                trimmed.substringBefore(" - ", trimmed)
            }
            else -> trimmed
        }
        val withoutIpSuffix = byVendor.replace(IP_SUFFIX_REGEX, "").trim()
        return withoutIpSuffix.ifBlank { "Network speaker" }
    }

    private fun fetchBody(url: HttpUrl): String? {
        val body = runCatching {
            okHttp.newCall(Request.Builder().url(url).build()).execute().use {
                it.body?.string()
            }
        }.getOrNull().orEmpty()
        return body.ifEmpty { null }
    }

    private companion object {
        // " (192.168.0.77)" trailing host suffix some generic UPnP
        // devices append. Anchored to end-of-string so it never eats
        // a legitimate parenthetical inside a name.
        val IP_SUFFIX_REGEX = Regex("""\s*\(\d+\.\d+\.\d+\.\d+\)$""")

        // Aggressive connect timeout for transport SOAP so a dead renderer
        // trips the drop-recovery quickly. Conservative enough not to false-drop
        // a slow-but-alive LAN; read timeout still inherits the shared client.
        const val CONTROL_CONNECT_TIMEOUT_SECONDS = 2L

        // Grace window after an M-SEARCH burst before pruning non-responders.
        // Covers the SSDP round-trip plus our per-device description fetch;
        // generous enough that a present-but-slow renderer isn't false-pruned.
        const val ACTIVE_SCAN_PRUNE_DELAY_MS = 4_000L
    }
}

/**
 * Normalize a Sonos UDN to its bare RINCON form for cross-comparison.
 *
 * Three places use UDN strings with different conventions:
 *  - DeviceDescription's <UDN> tag carries the `uuid:` prefix.
 *  - Sonos exposes one UDN per embedded device. The MediaRenderer adds
 *    a `_MR` suffix and the MediaServer adds `_MS`.
 *  - The ZGT GetZoneGroupState response uses bare `RINCON_xxx` with
 *    neither the `uuid:` prefix nor any device suffix.
 *
 * To compare any pair of those, strip the prefix and the suffix so
 * everything reduces to the underlying speaker identity.
 */
internal fun String.bareUdn(): String = removePrefix("uuid:")
    .removeSuffix("_MR")
    .removeSuffix("_MS")
