package dev.castarr.tv

import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import dev.castarr.tv.auth.DeviceAuth
import dev.castarr.tv.data.DispatcharrRepository
import dev.castarr.tv.data.NowNext
import dev.castarr.tv.data.isEpgPlaceholder
import dev.castarr.tv.data.SourceRepository
import dev.castarr.tv.player.PlayerController
import dev.castarr.tv.playlist.Channel

/**
 * Single source of truth the Compose UI observes. Mutations happen on the
 * main thread (activity callbacks, remote-control listener).
 */
class AppState(
    private val context: Context,
    val player: PlayerController,
    val source: SourceRepository,
    val auth: DeviceAuth,
    val dispatcharr: DispatcharrRepository,
) {
    private val prefs = context.getSharedPreferences("app", Context.MODE_PRIVATE)
    val crests = dev.castarr.tv.data.Crests(context)

    var screen by mutableStateOf(Screen.LIVE)
    var playerVisible by mutableStateOf(false)
    var playerState by mutableStateOf("idle")
    var currentChannel by mutableStateOf<Channel?>(null)
    var overlayVisible by mutableStateOf(true)
    var connectedRemote by mutableStateOf<String?>(null)
    var positionMs by mutableLongStateOf(0L)
    var durationMs by mutableLongStateOf(0L)
    var isLive by mutableStateOf(false)
    var favoritesOnly by mutableStateOf(false)
    var groupFilter by mutableStateOf<String?>(null)

    /** Club shortcut currently open in the rail, if any. */
    var activeTeam by mutableStateOf<String?>(null)

    /**
     * Club menus switched on for this TV. Seeded from the signed-in viewer's
     * own club; every club can be toggled on in the settings, so a household
     * can watch for more than one.
     */
    var enabledTeams by mutableStateOf(loadEnabledTeams())
        private set

    private fun loadEnabledTeams(): Set<String> {
        val stored = prefs.getStringSet("teams", null)
        if (stored != null) return stored.toSet()
        return dev.castarr.tv.data.TeamFilters.defaultKeysFor(auth.username).toSet()
    }

    /** Returns false when the cap of [TeamFilters.MAX_ACTIVE] is reached. */
    fun toggleTeam(key: String): Boolean {
        if (key !in enabledTeams &&
            enabledTeams.size >= dev.castarr.tv.data.TeamFilters.MAX_ACTIVE
        ) {
            return false
        }
        enabledTeams =
            if (key in enabledTeams) enabledTeams - key else enabledTeams + key
        prefs.edit().putStringSet("teams", enabledTeams).apply()
        if (activeTeam == key && key !in enabledTeams) activeTeam = null
        return true
    }
    var audioTracks by mutableStateOf<List<PlayerController.AudioTrack>>(emptyList())
        private set
    private var lastNowTitle: String? = null

    /** Digits typed on the remote's number pad (channel switching). */
    var digitBuffer by mutableStateOf("")

    /** False when the control server could not bind its port. */
    var remoteAvailable by mutableStateOf(true)

    /** Channel to restore focus to when the list comes back (#13). */
    var lastWatched by mutableStateOf<Channel?>(null)

    /**
     * True while the list is being restored after playback. The group rail
     * opens whatever gets focused, so without this the focus landing there
     * would silently switch the view back to "Alle Sender".
     */
    var restorePending by mutableStateOf(false)

    private val reentryHandler = android.os.Handler(android.os.Looper.getMainLooper())
    private var lastStoppedUrl: String = ""
    private var lastStoppedAt: Long = 0L

    /** Bumped on every interaction with the visible overlay to restart the
     * auto-hide timer. */
    var overlayPing by mutableLongStateOf(0L)
        private set

    fun pingOverlay() {
        overlayVisible = true
        overlayPing++
    }

    /** Phone-first onboarding progress (ADR-0007). */
    var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
    var welcomeUserCode by mutableStateOf("")

    /** Plain-language fullscreen error state (issue #13). */
    var appError by mutableStateOf(AppError.NONE)

    /** In-app updater state (issue #12). */
    var updateAvailable by mutableStateOf<String?>(null)

    var sourceMode by mutableStateOf(
        if (prefs.getString("source_mode", "generic") == "dispatcharr") SourceMode.DISPATCHARR
        else SourceMode.GENERIC
    )
        private set

    enum class Screen { WELCOME, LIVE, SETTINGS, ADVANCED }
    enum class SourceMode { GENERIC, DISPATCHARR }
    enum class WelcomePhase { WAIT_PHONE, WAIT_URL, WAIT_LOGIN, DONE }
    enum class AppError { NONE, OFFLINE, SERVER, RELOGIN }

    init {
        dispatcharr.outputProfile = prefs.getString("output_profile", "").orEmpty()
        val configured = auth.isLoggedIn || source.m3uUrl.isNotEmpty()
        if (!configured) {
            screen = Screen.WELCOME
        } else if (sourceMode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
            dispatcharr.refresh { classifyRefresh(it) }
        }
    }

    fun setMode(mode: SourceMode) {
        sourceMode = mode
        prefs.edit().putString(
            "source_mode",
            if (mode == SourceMode.DISPATCHARR) "dispatcharr" else "generic",
        ).apply()
        if (mode == SourceMode.DISPATCHARR && auth.isLoggedIn) {
            dispatcharr.refresh { classifyRefresh(it) }
        }
    }

    var outputProfile: String
        get() = dispatcharr.outputProfile
        set(value) {
            dispatcharr.outputProfile = value
            prefs.edit().putString("output_profile", value).apply()
            dispatcharr.rebuildStreamUrls()
        }

    fun activeChannels(): List<Channel> = when (sourceMode) {
        SourceMode.GENERIC -> source.channels.value
        SourceMode.DISPATCHARR -> dispatcharr.channels.value
    }

    fun nowNext(channel: Channel): NowNext = when (sourceMode) {
        SourceMode.GENERIC -> source.nowNext(channel)
        SourceMode.DISPATCHARR -> dispatcharr.nowNext(channel)
    }

    private fun upcoming(channel: Channel, windowMs: Long) = when (sourceMode) {
        SourceMode.GENERIC -> source.upcoming(channel, windowMs)
        SourceMode.DISPATCHARR -> dispatcharr.upcoming(channel, windowMs)
    }

    /** One channel's club broadcasts: the next one plus how many follow. */
    data class TeamHit(
        val programme: dev.castarr.tv.data.Programme,
        val further: Int,
    )

    /**
     * Channels showing the viewer's club within the scanned window, paired
     * with the programme that matched — earliest kick-off first, so whatever
     * is running right now sits on top.
     */
    fun teamMatches(key: String): List<Pair<Channel, TeamHit>> {
        val filter = dev.castarr.tv.data.TeamFilters.byKey(key) ?: return emptyList()
        val now = System.currentTimeMillis()
        val window = dev.castarr.tv.data.TeamFilters.windowEnd(now) - now
        return activeChannels()
            .filter { dev.castarr.tv.data.TeamFilters.scansGroup(it.group) }
            .mapNotNull { channel ->
                // A channel can carry several of the club's broadcasts in one
                // evening; the row names the next and counts the rest.
                val hits = upcoming(channel, window)
                    .filter { !isEpgPlaceholder(it.title) && filter.matches(it.title) }
                hits.firstOrNull()?.let { channel to TeamHit(it, hits.size - 1) }
            }
            .sortedBy { it.second.programme.start }
    }

    fun refreshActive() {
        when (sourceMode) {
            SourceMode.GENERIC -> source.refresh()
            SourceMode.DISPATCHARR -> dispatcharr.refresh { classifyRefresh(it) }
        }
    }

    /** Map a refresh failure to a family-friendly fullscreen state. */
    fun classifyRefresh(result: Result<Int>) {
        result.fold(
            onSuccess = { appError = AppError.NONE },
            onFailure = { throwable ->
                // An expired login must always surface: cached channels
                // would otherwise sit there without EPG or favourites and
                // nothing would say why.
                if (throwable.message == "not logged in") {
                    appError = AppError.RELOGIN
                    return
                }
                // Otherwise cached channels keep the app usable.
                if (activeChannels().isNotEmpty()) return
                appError = when {
                    throwable.message == "not logged in" -> AppError.RELOGIN
                    !isOnline() -> AppError.OFFLINE
                    else -> AppError.SERVER
                }
            },
        )
    }

    private companion object {
        /** Grace period before re-opening the channel just closed. */
        const val REENTRY_GRACE_MS = 2_500L
    }

    fun isOnline(): Boolean {
        val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return false
        return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
    }

    fun startOnboarding() {
        prefs.edit().remove("teams").apply()
        activeTeam = null
        auth.logout()
        appError = AppError.NONE
        welcomePhase = WelcomePhase.WAIT_PHONE
        welcomeUserCode = ""
        screen = Screen.WELCOME
    }

    fun play(channel: Channel) {
        lastWatched = channel
        currentChannel = channel
        playerVisible = true
        // Re-opening the very channel that was just closed can hit the
        // provider before it released the previous session, which comes back
        // as its "Stream Offline" still image. Give it a moment.
        val sinceStop = System.currentTimeMillis() - lastStoppedAt
        val sameChannel = channel.url == lastStoppedUrl
        if (sameChannel && sinceStop in 0 until REENTRY_GRACE_MS) {
            val wait = REENTRY_GRACE_MS - sinceStop
            playerState = "reconnecting"
            reentryHandler.removeCallbacksAndMessages(null)
            reentryHandler.postDelayed({
                player.play(channel.url, channel.name, channel.group)
            }, wait)
            return
        }
        player.play(channel.url, channel.name, channel.group)
    }

    fun stopPlayback() {
        restorePending = lastWatched != null
        reentryHandler.removeCallbacksAndMessages(null)
        lastStoppedUrl = currentChannel?.url.orEmpty()
        lastStoppedAt = System.currentTimeMillis()
        player.stop()
        playerVisible = false
        currentChannel = null
    }

    /** Retry the failed channel without leaving the player. */
    fun retryPlayback() {
        player.retryNow()
    }

    fun zap(direction: Int) {
        val list = activeChannels()
        if (list.isEmpty()) return
        val current = list.indexOfFirst { it.url == currentChannel?.url }
        val next = if (current < 0) 0 else (current + direction + list.size) % list.size
        play(list[next])
    }

    fun syncFromPlayer() {
        val previous = playerState
        playerState = player.state
        if (playerState != previous) overlayVisible = true
        isLive = player.player.isCurrentMediaItemLive
        positionMs = player.player.currentPosition.coerceAtLeast(0)
        durationMs = player.player.duration.coerceAtLeast(0)
        audioTracks = player.audioTracks()
        // Briefly resurface the overlay when the running programme changes.
        val nowTitle = currentChannel?.let { nowNext(it).now?.title }
        if (playerVisible && nowTitle != null && lastNowTitle != null && nowTitle != lastNowTitle) {
            overlayVisible = true
        }
        lastNowTitle = nowTitle
        // Keep the player on screen while an error or reconnect is pending —
        // otherwise the failure silently drops the viewer back to the list.
        if (!player.hasMedia && playerVisible &&
            player.errorMessage == null && !player.reconnecting
        ) {
            playerVisible = false
            currentChannel = null
        }
        if (player.errorMessage != null || player.reconnecting) overlayVisible = true
    }

    /** Steps to the next audio track (D-pad friendly: one key, cycles). */
    fun cycleAudio(direction: Int) {
        val tracks = audioTracks
        if (tracks.size < 2) return
        val current = tracks.indexOfFirst { it.selected }.coerceAtLeast(0)
        player.selectAudio((current + direction + tracks.size) % tracks.size)
        audioTracks = player.audioTracks()
    }
}
