package ca.ksamad.encore.playback

import android.content.Context
import ca.ksamad.encore.data.AlbumViewMode
import ca.ksamad.encore.data.ConnectionSettings
import ca.ksamad.encore.data.SettingsRepository
import ca.ksamad.encore.mpd.MpdClient
import ca.ksamad.encore.mpd.MpdConnectionState
import ca.ksamad.encore.mpd.model.MpdAlbum
import ca.ksamad.encore.mpd.model.MpdSong
import ca.ksamad.encore.mpd.model.MpdStatistics
import coil3.SingletonImageLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

/**
 * App-scoped owner of the single [MpdClient]. Living at the [Application] level (not in a
 * ViewModel) means the connection survives Activity recreation and keeps running while
 * [PlaybackService] holds the app in the foreground — which is what makes the OS media session /
 * notification / cast volume work when the user has left the UI.
 *
 * Both the UI (via `PlayerViewModel`) and [PlaybackService] talk to this same instance: the UI
 * observes the flows and issues commands; the service mirrors the flows into a `MediaSessionCompat`
 * and routes media-button / volume-key callbacks back here.
 */
class MpdConnectionManager(context: Context) {
    private val appContext = context.applicationContext
    private val client = MpdClient()
    private val settingsRepo = SettingsRepository(appContext)
    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

    val connectionState = client.connectionState
    val status = client.status
    val currentSong = client.currentSong

    private val _serverHost = MutableStateFlow<String?>(null)
    val serverHost: StateFlow<String?> = _serverHost

    /** Persisted settings (defaulted) for seeding the connect form. */
    val settings: StateFlow<ConnectionSettings> =
        settingsRepo.settings.stateIn(scope, SharingStarted.Eagerly, ConnectionSettings.DEFAULT)

    /** Persisted albums-library layout preference. */
    val albumViewMode: StateFlow<AlbumViewMode> =
        settingsRepo.albumViewMode.stateIn(scope, SharingStarted.Eagerly, AlbumViewMode.DEFAULT)

    fun setAlbumViewMode(mode: AlbumViewMode) {
        scope.launch { settingsRepo.setAlbumViewMode(mode) }
    }

    /** Persisted volume-key step (percent per press). */
    val volumeStep: StateFlow<Int> =
        settingsRepo.volumeStep.stateIn(
            scope,
            SharingStarted.Eagerly,
            SettingsRepository.DEFAULT_VOLUME_STEP,
        )

    fun setVolumeStep(step: Int) {
        scope.launch { settingsRepo.setVolumeStep(step) }
    }

    /** Persisted albums-grid column count. */
    val gridColumns: StateFlow<Int> =
        settingsRepo.gridColumns.stateIn(
            scope,
            SharingStarted.Eagerly,
            SettingsRepository.DEFAULT_GRID_COLUMNS,
        )

    fun setGridColumns(columns: Int) {
        scope.launch { settingsRepo.setGridColumns(columns) }
    }

    // True until the startup auto-connect decision has been made.
    private val _bootstrapping = MutableStateFlow(true)
    val bootstrapping: StateFlow<Boolean> = _bootstrapping

    // Optimistic volume target so rapid nudges accumulate instead of each reading
    // the same stale server value; cleared once the server confirms it.
    @Volatile private var pendingVolume: Int? = null

    // Volume writes are coalesced through a conflated channel + throttle so that
    // holding a volume key (or dragging the OS remote-volume slider) sends at
    // most one `setvol` per interval with the latest value — a tiny MPD server
    // (e.g. a Pi Zero) is easily overwhelmed by a flood of them otherwise.
    private val volumeRequests = Channel<Int>(Channel.CONFLATED)

    init {
        scope.launch {
            for (target in volumeRequests) {
                runCatching { client.setVolume(target) }
                delay(VOLUME_THROTTLE_MS)
            }
        }
        // Auto-connect on startup if a server was saved.
        scope.launch {
            val saved = settingsRepo.settingsOrNull.first()
            if (saved != null) {
                connect(saved.host, saved.port, saved.password)
                connectionState.first { it !is MpdConnectionState.Disconnected }
            }
            _bootstrapping.value = false
        }
        scope.launch {
            status.collect { s ->
                if (s?.volume != null && s.volume == pendingVolume) pendingVolume = null
            }
        }
        // Bring the foreground media service up while connected, down otherwise.
        scope.launch {
            connectionState.collect { st ->
                when (st) {
                    is MpdConnectionState.Connected -> PlaybackService.start(appContext)

                    is MpdConnectionState.Disconnected,
                    is MpdConnectionState.Error -> PlaybackService.stop(appContext)

                    is MpdConnectionState.Connecting -> Unit
                }
            }
        }
    }

    fun connect(
        host: String,
        port: Int,
        password: String? = null,
    ) {
        val trimmed = host.trim()
        // Treat a blank password as "no password" everywhere downstream.
        val pw = password?.trim()?.ifBlank { null }
        _serverHost.value = trimmed
        scope.launch {
            settingsRepo.save(ConnectionSettings(trimmed, port, pw))
            runCatching { client.connect(trimmed, port, pw) }
        }
    }

    fun disconnect() {
        _serverHost.value = null
        fire { disconnect() }
    }

    /** Forget saved settings and drop the connection (back to first-run state). */
    fun resetSettings() {
        scope.launch { settingsRepo.clear() }
        disconnect()
    }

    fun resume() = fire { pause(false) }

    fun pause() = fire { pause(true) }

    fun stop() = fire { stop() }

    fun clearQueue() = fire { clearQueue() }

    fun next() = fire { next() }

    fun previous() = fire { previous() }

    fun togglePlayPause() = fire { togglePause() }

    fun seekTo(seconds: Double) = fire { seekCurrent(seconds) }

    fun setVolume(volume: Int) {
        // Coalesced + throttled by the volumeRequests consumer (see init).
        volumeRequests.trySend(volume.coerceIn(0, 100))
    }

    fun setRepeat(on: Boolean) = fire { setRepeat(on) }

    fun setRandom(on: Boolean) = fire { setRandom(on) }

    fun setConsume(on: Boolean) = fire { setConsume(on) }

    fun playAlbum(
        album: String,
        albumArtist: String?,
    ) = fire { playAlbum(album, albumArtist) }

    /** Append an album to the end of the queue without changing playback. */
    fun queueAlbum(
        album: String,
        albumArtist: String?,
    ) = fire { queueAlbum(album, albumArtist) }

    /** Insert an album right after the current track so it plays next. */
    fun playAlbumNext(
        album: String,
        albumArtist: String?,
    ) = fire { playAlbumNext(album, albumArtist) }

    /** Replace the queue with [uris] (in order) and start playing the first. */
    fun playTracks(uris: List<String>) = fire { playTracks(uris) }

    /** Append a single track to the end of the queue. */
    fun queueTrack(uri: String) = fire { queueTrack(uri) }

    /** Insert a single track right after the current one so it plays next. */
    fun playTrackNext(uri: String) = fire { playTrackNext(uri) }

    /** One-shot album track-list fetch; returns empty on any failure. */
    suspend fun loadAlbumTracks(
        album: String,
        albumArtist: String?,
    ): List<MpdSong> =
        runCatching { client.albumTracks(album, albumArtist) }.getOrDefault(emptyList())

    /** One-shot server statistics fetch; returns null on any failure. */
    suspend fun loadStatistics(): MpdStatistics? = runCatching { client.statistics() }.getOrNull()

    /** Jump to a queue entry by its stable song id. */
    fun playQueueItem(songId: Int) = fire { playId(songId) }

    /** Remove a queue entry by its stable song id. */
    fun removeQueueItem(songId: Int) = fire { removeQueueItem(songId) }

    /** Re-insert a track at an absolute queue position (undo a removal). */
    fun addTrackAt(
        uri: String,
        position: Int,
    ) = fire { addTrackAt(uri, position) }

    /** One-shot library fetch; returns empty on any failure. */
    suspend fun loadAlbums(): List<MpdAlbum> =
        runCatching { client.albums() }.getOrDefault(emptyList())

    /** Ask the server to scan for new/changed/removed files. */
    fun updateDatabase() = fire { updateDatabase() }

    /** Ask the server to fully re-read the database, including unchanged files. */
    fun rescanDatabase() = fire { rescanDatabase() }

    /** One-shot play-queue fetch; returns empty on any failure. */
    suspend fun loadQueue(): List<MpdSong> =
        runCatching { client.queue() }.getOrDefault(emptyList())

    /**
     * Drop every cached cover (memory + disk) so the next load refetches from the server. Backs the
     * "Refresh artwork" action — for art that isn't mtime-keyed (album covers, and folder cover
     * files MPD serves without touching the track's mtime), this is the way to pick up changes made
     * on the server. Album-keyed art then reloads as each screen is reopened.
     */
    fun refreshArtwork() {
        val loader = SingletonImageLoader.get(appContext)
        loader.memoryCache?.clear()
        // Disk clear touches the filesystem — keep it off the main thread.
        scope.launch(Dispatchers.IO) { loader.diskCache?.clear() }
    }

    /** Fetch cover-art bytes for Coil (null on miss/failure). */
    suspend fun fetchArt(data: MpdArtData): ByteArray? =
        runCatching {
                when (data) {
                    is SongArt -> client.songArt(data.uri)
                    is AlbumArt -> client.albumArt(data.album, data.albumArtist)
                }
            }
            .getOrNull()

    /** Relative volume change (from the media session's remote VolumeProvider), accumulating. */
    fun nudgeVolume(up: Boolean) {
        val base = pendingVolume ?: status.value?.volume ?: return
        val step = volumeStep.value
        val next = (base + if (up) step else -step).coerceIn(0, 100)
        pendingVolume = next
        setVolume(next)
    }

    private inline fun fire(crossinline action: suspend MpdClient.() -> Unit) {
        scope.launch { runCatching { client.action() } }
    }

    private companion object {
        const val VOLUME_THROTTLE_MS = 120L
    }
}
