package ca.ksamad.encore.mpd

import ca.ksamad.encore.mpd.model.MpdAlbum
import ca.ksamad.encore.mpd.model.MpdSong
import ca.ksamad.encore.mpd.model.MpdStatistics
import ca.ksamad.encore.mpd.model.MpdStatus
import ca.ksamad.encore.mpd.model.PlayerState
import java.io.IOException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext

/**
 * The high-level, coroutine-driven MPD client. It owns **two** connections, the pattern MALP and
 * other real clients use:
 *
 * - a **command** connection, guarded by a [Mutex], for request/response commands (play, setvol,
 *   playlistinfo, …);
 * - an **idle** connection parked in the blocking `idle` command, so the server can push change
 *   notifications. Each `changed:` event triggers a refresh on the command connection, which flows
 *   out through [status]/[currentSong].
 *
 * **Resilience.** A quiet command connection would otherwise be reaped by MPD's
 * `connection_timeout`, so a periodic [keepalive] `ping` keeps it warm and detects death early. Any
 * transport failure triggers a transparent [reconnect][triggerReconnect] (staying on the player,
 * not bouncing the user to the connect screen); only repeated failures surface as
 * [MpdConnectionState.Error]. Server-side `ACK` errors are *not* treated as connection failures.
 *
 * A [connectionFactory] is injectable so tests can supply fake transports.
 */
class MpdClient(
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
    private val connectionFactory: (host: String, port: Int, readTimeoutMs: Int) -> MpdConnection =
        { host, port, readTimeoutMs ->
            MpdConnection.connect(host, port, readTimeoutMs = readTimeoutMs)
        },
) {
    private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)

    private val commandMutex = Mutex()
    private var commandConn: MpdConnection? = null
    private var idleConn: MpdConnection? = null
    private var idleJob: Job? = null
    private var keepaliveJob: Job? = null
    private var reconnectJob: Job? = null

    // Remembered so a background reconnect can reopen without UI involvement.
    private var host: String? = null
    private var port: Int = MpdConnection.DEFAULT_PORT
    private var password: String? = null

    /** True while an intentional [disconnect]/[shutdown] is in progress. */
    @Volatile private var shuttingDown = false

    private val _connectionState =
        MutableStateFlow<MpdConnectionState>(MpdConnectionState.Disconnected)
    val connectionState = _connectionState.asStateFlow()

    private val _status = MutableStateFlow<MpdStatus?>(null)
    val status = _status.asStateFlow()

    private val _currentSong = MutableStateFlow<MpdSong?>(null)
    val currentSong = _currentSong.asStateFlow()

    // --- Lifecycle ----------------------------------------------------------

    /**
     * Open both connections, authenticate, prime the initial state, and start the idle + keepalive
     * loops. On failure the state becomes [MpdConnectionState.Error] and the call throws.
     */
    suspend fun connect(
        host: String,
        port: Int = MpdConnection.DEFAULT_PORT,
        password: String? = null,
    ) {
        disconnect() // ensure a clean slate if re-connecting
        shuttingDown = false
        this.host = host
        this.port = port
        this.password = password
        _connectionState.value = MpdConnectionState.Connecting
        try {
            openConnections()
            refresh()
            _connectionState.value = MpdConnectionState.Connected
            startIdleLoop()
            startKeepalive()
        } catch (e: IOException) {
            failAndClose(e.message ?: "connection failed")
            throw e
        }
    }

    /** (Re)open and authenticate both sockets using the remembered credentials. */
    private suspend fun openConnections() {
        withContext(ioDispatcher) {
            val h = host ?: throw MpdConnectionException("no host")
            // Command connection: finite read timeout so a wedged server surfaces
            // as an error instead of hanging a UI action forever.
            val command = connectionFactory(h, port, COMMAND_READ_TIMEOUT_MS)
            // Idle connection: no read timeout — it must park indefinitely.
            val idle = connectionFactory(h, port, 0)
            password?.let { pw ->
                command.execute(MpdCommands.password(pw))
                idle.execute(MpdCommands.password(pw))
            }
            // Bigger binary chunks → album art transfers in ~1 round-trip.
            // Best-effort: ignore if the server rejects it (old MPD).
            runCatching { command.execute(MpdCommands.binaryLimit(BINARY_LIMIT)) }
            commandConn = command
            idleConn = idle
        }
    }

    /** Tear everything down; returns to [MpdConnectionState.Disconnected]. */
    suspend fun disconnect() {
        shuttingDown = true
        // Close the sockets *before* joining the loops. The idle loop is parked in a blocking
        // socket read that coroutine cancellation cannot interrupt, so closing the socket is what
        // unblocks it. If we joined first (as this used to), disconnect() would hang until the
        // server happened to push an idle event — which on a quiet server can be a very long time.
        withContext(ioDispatcher) {
            idleConn?.close()
            commandConn?.close()
        }
        stopBackgroundJobs()
        closeArtConnections()
        idleConn = null
        commandConn = null
        if (_connectionState.value !is MpdConnectionState.Error) {
            _connectionState.value = MpdConnectionState.Disconnected
        }
        shuttingDown = false
    }

    /** Non-suspending teardown for owner destruction (e.g. `ViewModel.onCleared`). */
    fun shutdown() {
        shuttingDown = true
        idleJob?.cancel()
        keepaliveJob?.cancel()
        reconnectJob?.cancel()
        closeConnectionsQuietly()
        scope.cancel()
        _connectionState.value = MpdConnectionState.Disconnected
    }

    // --- Commands -----------------------------------------------------------
    //
    // These don't optimistically update the flows: the change they cause makes
    // the server emit an idle event, which refreshes state through the idle
    // loop, keeping us in lockstep with the server (and other clients).

    suspend fun play() = run(MpdCommands.play())

    suspend fun playPos(pos: Int) = run(MpdCommands.playPos(pos))

    suspend fun playId(songId: Int) = run(MpdCommands.playId(songId))

    suspend fun stop() = run(MpdCommands.stop())

    suspend fun clearQueue() = run(MpdCommands.clear())

    /** Remove a queue entry by its stable song id. */
    suspend fun removeQueueItem(songId: Int) = run(MpdCommands.deleteId(songId))

    /** Insert a single track at an absolute queue position (used to undo a removal). */
    suspend fun addTrackAt(
        uri: String,
        position: Int,
    ) = run(MpdCommands.add(uri, position.toString()))

    /** Append a single track to the end of the queue; playback is untouched. */
    suspend fun queueTrack(uri: String) = run(MpdCommands.add(uri))

    /**
     * Insert a single track right after the current one so it plays next. Falls back to a plain
     * append when nothing is playing — no current song to anchor the relative `"+0"` position to.
     */
    suspend fun playTrackNext(uri: String) = withCommand { conn ->
        val playing = MpdStatus.from(conn.execute(MpdCommands.status()).toMap()).song != null
        conn.execute(MpdCommands.add(uri, position = if (playing) "+0" else null))
    }

    suspend fun next() = run(MpdCommands.next())

    suspend fun previous() = run(MpdCommands.previous())

    suspend fun pause(paused: Boolean) = run(MpdCommands.pause(paused))

    /** Flip play/pause based on the latest known [status]. */
    suspend fun togglePause() {
        val playing = _status.value?.state == PlayerState.PLAY
        run(MpdCommands.pause(playing))
    }

    suspend fun seekCurrent(seconds: Double) = run(MpdCommands.seekCurrent(seconds))

    suspend fun setVolume(volume: Int) = run(MpdCommands.setVolume(volume))

    suspend fun setRepeat(on: Boolean) = run(MpdCommands.repeat(on))

    suspend fun setRandom(on: Boolean) = run(MpdCommands.random(on))

    suspend fun setSingle(on: Boolean) = run(MpdCommands.single(on))

    suspend fun setConsume(on: Boolean) = run(MpdCommands.consume(on))

    /** Fetch the current play queue. */
    suspend fun queue(): List<MpdSong> = withCommand { conn ->
        conn.execute(MpdCommands.playlistInfo()).split().mapNotNull { MpdSong.from(it) }
    }

    /** Database/server statistics (`stats`). */
    suspend fun statistics(): MpdStatistics = withCommand { conn ->
        MpdStatistics.from(conn.execute(MpdCommands.stats()).toMap())
    }

    /** Every album in the library. */
    suspend fun albums(): List<MpdAlbum> = withCommand { conn ->
        MpdAlbum.listFrom(conn.execute(MpdCommands.listAlbums()))
    }

    /** Trigger a database scan for new/changed/removed files (whole library). */
    suspend fun updateDatabase() = run(MpdCommands.update())

    /** Trigger a full re-read of the database, including files with an unchanged mtime. */
    suspend fun rescanDatabase() = run(MpdCommands.rescan())

    /**
     * Replace the queue with [uris] (in the given order) and start playing the first. Used when the
     * user taps a track to play an album from that track onward. No-op on an empty list.
     */
    suspend fun playTracks(uris: List<String>) {
        if (uris.isEmpty()) return
        withCommand { conn ->
            conn.executeList(
                MpdCommands.clear(),
                *uris.map { MpdCommands.add(it) }.toTypedArray(),
                MpdCommands.play(),
            )
        }
    }

    /** Replace the queue with an album and start playing it. */
    suspend fun playAlbum(
        album: String,
        albumArtist: String?,
    ) = withCommand { conn ->
        conn.execute(MpdCommands.clear())
        conn.execute(MpdCommands.findAddAlbum(album, albumArtist))
        conn.execute(MpdCommands.play())
    }

    /** Append an album's tracks to the end of the queue; playback is untouched. */
    suspend fun queueAlbum(
        album: String,
        albumArtist: String?,
    ) = withCommand { conn ->
        conn.execute(MpdCommands.findAddAlbum(album, albumArtist))
    }

    /**
     * Insert an album right after the current track so it plays next. Falls back to a plain append
     * when nothing is playing — there is no current song for the relative `"+0"` position to anchor
     * to.
     */
    suspend fun playAlbumNext(
        album: String,
        albumArtist: String?,
    ) = withCommand { conn ->
        val playing = MpdStatus.from(conn.execute(MpdCommands.status()).toMap()).song != null
        conn.execute(
            MpdCommands.findAddAlbum(album, albumArtist, position = if (playing) "+0" else null)
        )
    }

    /** Every track of an album, ordered by disc then track number. */
    suspend fun albumTracks(
        album: String,
        albumArtist: String?,
    ): List<MpdSong> = withCommand { conn ->
        conn
            .execute(MpdCommands.findAlbumTracks(album, albumArtist))
            .split()
            .mapNotNull { MpdSong.from(it) }
            .sortedWith(compareBy({ it.disc.leadingInt() }, { it.track.leadingInt() }))
    }

    /** Cover art bytes for a specific song URI, or null if the server has none. */
    suspend fun songArt(uri: String): ByteArray? = withArtConnection { conn -> readArt(conn, uri) }

    /** Cover art bytes for an album (resolves a representative track first). */
    suspend fun albumArt(
        album: String,
        albumArtist: String?,
    ): ByteArray? = withArtConnection { conn ->
        val trackUri =
            conn
                .execute(MpdCommands.findFirstTrack(album, albumArtist))
                .split()
                .firstOrNull()
                ?.get("file") ?: return@withArtConnection null
        readArt(conn, trackUri)
    }

    /** Try folder cover (`albumart`), then embedded art (`readpicture`). */
    private fun readArt(
        conn: MpdConnection,
        uri: String,
    ): ByteArray? =
        readArtChunks(conn, embedded = false, uri) ?: readArtChunks(conn, embedded = true, uri)

    /** Loop the chunked art protocol until the whole image (per `size:`) is read. */
    private fun readArtChunks(
        conn: MpdConnection,
        embedded: Boolean,
        uri: String,
    ): ByteArray? {
        val out = java.io.ByteArrayOutputStream()
        var offset = 0
        while (true) {
            val resp =
                try {
                    conn.execute(
                        if (embedded) MpdCommands.readPicture(uri, offset)
                        else MpdCommands.albumArt(uri, offset)
                    )
                } catch (e: MpdAckException) {
                    return null // no such art
                }
            val chunk = resp.binary
            if (chunk == null || chunk.isEmpty()) break
            out.write(chunk)
            offset += chunk.size
            val size = resp["size"]?.toIntOrNull() ?: break
            if (offset >= size) break
        }
        return if (out.size() > 0) out.toByteArray() else null
    }

    // --- Internals ----------------------------------------------------------

    private suspend fun run(commandLine: String) {
        withCommand { it.execute(commandLine) }
    }

    /**
     * Serialize access to the command connection. A server `ACK` is rethrown as-is (the connection
     * is fine); a transport failure triggers a reconnect.
     */
    private suspend fun <T> withCommand(block: (MpdConnection) -> T): T =
        withContext(ioDispatcher) {
            commandMutex.withLock {
                val conn = commandConn ?: throw MpdConnectionException("not connected")
                try {
                    block(conn)
                } catch (e: MpdAckException) {
                    throw e // server rejected the command; connection is healthy
                } catch (e: IOException) {
                    triggerReconnect("command failed: ${e.message}")
                    throw e
                }
            }
        }

    // --- Art connection pool -----------------------------------------------
    //
    // Cover fetches run on their own small pool of connections rather than the
    // command connection, so (a) many covers load in parallel while scrolling and
    // (b) art never blocks play/pause/volume. Connections are opened lazily, up
    // to ART_POOL_SIZE (bounded by the semaphore), and reused when idle.
    //
    // Unlike the command connection, these carry no keepalive, so MPD reaps them
    // after its connection_timeout (idle art is fetched rarely). A borrowed pooled
    // connection may therefore be dead; reusing it throws a transport error. When
    // that happens we discard it and retry once on a freshly opened connection, so
    // a stale socket can't blank a cover until the app is restarted.

    private val artSemaphore = Semaphore(ART_POOL_SIZE)
    private val artPoolLock = Any()
    private val idleArtConnections = ArrayDeque<MpdConnection>()

    private suspend fun <T> withArtConnection(block: (MpdConnection) -> T): T =
        artSemaphore.withPermit {
            // First try a pooled connection, if any. A transport failure here means
            // it went stale — discard it (runArt already closed it) and fall through
            // to a fresh one. A server ACK is a real result and is not retried.
            borrowPooledArtConnection()?.let { pooled ->
                try {
                    return@withPermit runArt(pooled, block)
                } catch (e: MpdAckException) {
                    throw e
                } catch (e: IOException) {
                    // stale pooled connection; retry on a fresh one below
                }
            }
            runArt(openArtConnection(), block)
        }

    /**
     * Run [block] on [conn], returning it to the pool on success (or a clean `ACK`, which leaves the
     * connection healthy) and closing it on any transport failure.
     */
    private suspend fun <T> runArt(
        conn: MpdConnection,
        block: (MpdConnection) -> T,
    ): T =
        try {
            val result = withContext(ioDispatcher) { block(conn) }
            returnArtConnection(conn)
            result
        } catch (e: MpdAckException) {
            returnArtConnection(conn) // command refused, but connection is healthy
            throw e
        } catch (e: Throwable) {
            runCatching { conn.close() } // discard a broken connection
            throw e
        }

    /** Pop an idle pooled art connection, or null if the pool is empty. */
    private fun borrowPooledArtConnection(): MpdConnection? =
        synchronized(artPoolLock) { idleArtConnections.removeFirstOrNull() }

    /** Open, authenticate, and prime a brand-new art connection. */
    private suspend fun openArtConnection(): MpdConnection =
        withContext(ioDispatcher) {
            val h = host ?: throw MpdConnectionException("not connected")
            val conn = connectionFactory(h, port, COMMAND_READ_TIMEOUT_MS)
            password?.let { conn.execute(MpdCommands.password(it)) }
            runCatching { conn.execute(MpdCommands.binaryLimit(BINARY_LIMIT)) }
            conn
        }

    private fun returnArtConnection(conn: MpdConnection) {
        val kept =
            synchronized(artPoolLock) {
                if (idleArtConnections.size < ART_POOL_SIZE) {
                    idleArtConnections.addLast(conn)
                    true
                } else {
                    false
                }
            }
        if (!kept) runCatching { conn.close() }
    }

    /** Close and drop all pooled art connections (on disconnect/reconnect/teardown). */
    private fun closeArtConnections() {
        val toClose =
            synchronized(artPoolLock) {
                val copy = idleArtConnections.toList()
                idleArtConnections.clear()
                copy
            }
        toClose.forEach { runCatching { it.close() } }
    }

    /** Re-read `status` and `currentsong` into the flows (single round-trip). */
    private suspend fun refresh() {
        val (status, song) =
            withCommand { conn ->
                val (statusResp, songResp) =
                    conn.executeList(
                        MpdCommands.status(),
                        MpdCommands.currentSong(),
                    )
                MpdStatus.from(statusResp.toMap()) to MpdSong.from(songResp.toMap())
            }
        _status.value = status
        _currentSong.value = song
    }

    private fun startIdleLoop() {
        val idle = idleConn ?: return
        idleJob = scope.launch {
            try {
                while (isActive) {
                    val changed =
                        withContext(ioDispatcher) {
                            idle.execute(MpdCommands.idle()).getAll("changed")
                        }
                    if (changed.any { it in REFRESHING_SUBSYSTEMS }) refresh()
                }
            } catch (e: IOException) {
                if (!shuttingDown) triggerReconnect("idle connection lost: ${e.message}")
            }
        }
    }

    /**
     * Periodic `ping` so MPD's `connection_timeout` never reaps our (often quiet) command
     * connection, and so a dead socket is noticed promptly.
     */
    private fun startKeepalive() {
        keepaliveJob = scope.launch {
            while (isActive) {
                delay(KEEPALIVE_INTERVAL_MS)
                try {
                    withCommand { it.execute(MpdCommands.ping()) }
                } catch (_: IOException) {
                    // withCommand already triggered a reconnect on a transport error.
                    return@launch
                }
            }
        }
    }

    /** Start a background reconnect unless one is already running or we're shutting down. */
    @Synchronized
    private fun triggerReconnect(reason: String) {
        if (shuttingDown || reconnectJob?.isActive == true) return
        reconnectJob = scope.launch { reconnectLoop() }
    }

    private suspend fun reconnectLoop() {
        // Close first, then join — same reason as disconnect(): closing the sockets unblocks the
        // idle loop's parked blocking read so stopBackgroundJobs() doesn't wait on it. (The idle
        // loop's own error handler no-ops here because a reconnect is already in flight.)
        closeConnectionsQuietly()
        stopBackgroundJobs()
        _connectionState.value = MpdConnectionState.Connecting
        var attempt = 0
        while (scope.isActive && !shuttingDown) {
            attempt++
            try {
                openConnections()
                refresh()
                _connectionState.value = MpdConnectionState.Connected
                startIdleLoop()
                startKeepalive()
                return
            } catch (e: IOException) {
                if (attempt >= MAX_RECONNECT_ATTEMPTS) {
                    failAndClose(e.message ?: "reconnect failed")
                    return
                }
                delay(RECONNECT_BACKOFF_MS * attempt)
            }
        }
    }

    /** Cancel the idle + keepalive loops (but not a running reconnect). */
    private suspend fun stopBackgroundJobs() {
        idleJob?.cancelAndJoin()
        keepaliveJob?.cancelAndJoin()
        idleJob = null
        keepaliveJob = null
    }

    private fun closeConnectionsQuietly() {
        try {
            idleConn?.close()
        } catch (_: IOException) {}
        try {
            commandConn?.close()
        } catch (_: IOException) {}
        idleConn = null
        commandConn = null
        closeArtConnections()
    }

    private fun failAndClose(message: String) {
        _connectionState.value = MpdConnectionState.Error(message)
        closeConnectionsQuietly()
    }

    companion object {
        private const val COMMAND_READ_TIMEOUT_MS = 10_000
        private const val BINARY_LIMIT = 512 * 1024
        private const val ART_POOL_SIZE = 4
        private const val KEEPALIVE_INTERVAL_MS = 25_000L
        private const val RECONNECT_BACKOFF_MS = 1_000L
        private const val MAX_RECONNECT_ATTEMPTS = 5

        /**
         * Idle subsystems that change something [status]/[currentSong] reflects. `update` fires when
         * a database scan starts and finishes (toggling `status.updating`); `database` fires when the
         * scan actually changed the library.
         */
        private val REFRESHING_SUBSYSTEMS =
            setOf("player", "mixer", "options", "playlist", "update", "database")
    }
}

/**
 * Leading integer of a `track`/`disc` tag for sorting — these can carry values like `"3/12"`, so we
 * read the digits up front. Missing/blank tags sort last.
 */
private fun String?.leadingInt(): Int =
    this?.takeWhile { it.isDigit() }?.toIntOrNull() ?: Int.MAX_VALUE
