package ca.ksamad.encore.mpd

import java.io.BufferedInputStream
import java.io.BufferedOutputStream
import java.io.ByteArrayOutputStream
import java.io.Closeable
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.InetSocketAddress
import java.net.Socket

/**
 * A single synchronous MPD connection: the raw text protocol over a byte stream. One command is
 * written, one response is read back; calls are **not** thread-safe and must be serialized by the
 * caller (a higher-level client will own a [MpdConnection] behind a mutex, and a second one parked
 * in `idle`).
 *
 * The transport is an [InputStream]/[OutputStream] pair rather than a [Socket] so the protocol can
 * be exercised against in-memory streams in tests. Use [connect] for a real TCP connection.
 *
 * Reading is done at the byte level (not via a [java.io.Reader]) because responses can interleave
 * UTF-8 text lines with raw binary payloads, and a buffered character reader would happily swallow
 * bytes past a line boundary.
 */
class MpdConnection(
    private val input: InputStream,
    private val output: OutputStream,
    private val closer: Closeable? = null,
) : Closeable {
    /** Protocol version from the greeting, e.g. `0.23.5`. Null until [handshake]. */
    var protocolVersion: String? = null
        private set

    /**
     * Read and validate the server greeting (`OK MPD <version>`). Must be called exactly once,
     * before any command. Returns the protocol version.
     */
    fun handshake(): String {
        val line = readLine() ?: throw MpdConnectionException("connection closed before greeting")
        if (!line.startsWith(MpdProtocol.GREETING_PREFIX)) {
            throw MpdConnectionException("unexpected greeting: $line")
        }
        val version = line.removePrefix(MpdProtocol.GREETING_PREFIX).trim()
        protocolVersion = version
        return version
    }

    /**
     * Send one command line (name + already-assembled string, no newline) and read its response up
     * to the terminating `OK`.
     *
     * @throws MpdAckException if the server replied `ACK …`.
     * @throws MpdConnectionException on EOF or a malformed response.
     */
    fun execute(commandLine: String): MpdResponse {
        write(commandLine)
        return readResponse()
    }

    /** Convenience: [MpdProtocol.command] + [execute]. */
    fun execute(
        name: String,
        vararg args: String,
    ): MpdResponse = execute(MpdProtocol.command(name, *args))

    /**
     * Run several commands as a single batch (`command_list_ok_begin`), returning one [MpdResponse]
     * per command in order. One network round-trip instead of N — worth it on a slow server. Each
     * command's response is delimited by `list_OK`, and the batch ends with a final `OK`.
     */
    fun executeList(vararg commandLines: String): List<MpdResponse> {
        val payload = buildString {
            append("command_list_ok_begin")
            for (c in commandLines) {
                append('\n')
                append(c)
            }
            append('\n')
            append("command_list_end")
        }
        write(payload)
        val responses = List(commandLines.size) { readResponse() } // each stops at list_OK
        readResponse() // consume the final OK terminating the batch
        return responses
    }

    private fun write(commandLine: String) {
        output.write(commandLine.toByteArray(Charsets.UTF_8))
        output.write('\n'.code)
        output.flush()
    }

    private fun readResponse(): MpdResponse {
        val values = ArrayList<Pair<String, String>>()
        var binary: ByteArray? = null
        while (true) {
            val line = readLine() ?: throw MpdConnectionException("connection closed mid-response")
            when {
                // OK ends a command; list_OK ends one command within a command list.
                line == MpdProtocol.OK || line == MpdProtocol.LIST_OK -> {
                    return MpdResponse(values, binary)
                }

                line.startsWith("ACK ") -> {
                    throw MpdAckException.parse(line)
                        ?: MpdConnectionException("malformed ACK: $line")
                }

                else -> {
                    val sep = line.indexOf(": ")
                    if (sep < 0) continue // tolerate stray lines rather than fail
                    val key = line.substring(0, sep)
                    val value = line.substring(sep + 2)
                    if (key == "binary") {
                        binary =
                            readBinary(
                                value.toIntOrNull()
                                    ?: throw MpdConnectionException("bad binary size: $value")
                            )
                    } else {
                        values.add(key to value)
                    }
                }
            }
        }
    }

    /** Read raw bytes until `\n` (exclusive), decode UTF-8. Null on immediate EOF. */
    private fun readLine(): String? {
        val buf = ByteArrayOutputStream(64)
        while (true) {
            val b = input.read()
            when (b) {
                -1 -> return if (buf.size() == 0) null else buf.toString("UTF-8")
                '\n'.code -> return buf.toString("UTF-8")
                else -> buf.write(b)
            }
        }
    }

    /** Read exactly [size] bytes of a binary payload, then consume the trailing `\n`. */
    private fun readBinary(size: Int): ByteArray {
        val data = ByteArray(size)
        var off = 0
        while (off < size) {
            val n = input.read(data, off, size - off)
            if (n == -1) throw MpdConnectionException("EOF after $off/$size binary bytes")
            off += n
        }
        input.read() // trailing newline that follows the binary block
        return data
    }

    override fun close() {
        // Best-effort: closing the socket tears down both streams.
        try {
            closer?.close() ?: output.close()
        } catch (_: IOException) {}
    }

    companion object {
        const val DEFAULT_PORT = 6600

        /**
         * Open a TCP connection to an MPD server and complete the greeting handshake. Blocking —
         * call off the main thread.
         *
         * @param connectTimeoutMs socket connect timeout.
         * @param readTimeoutMs `SO_TIMEOUT`; 0 means block forever (needed for the `idle`
         *   connection, which parks indefinitely).
         */
        fun connect(
            host: String,
            port: Int = DEFAULT_PORT,
            connectTimeoutMs: Int = 5_000,
            readTimeoutMs: Int = 0,
        ): MpdConnection {
            val socket = Socket()
            try {
                socket.connect(InetSocketAddress(host, port), connectTimeoutMs)
                socket.soTimeout = readTimeoutMs
                socket.tcpNoDelay = true
                val conn =
                    MpdConnection(
                        input = BufferedInputStream(socket.getInputStream()),
                        output = BufferedOutputStream(socket.getOutputStream()),
                        closer = socket,
                    )
                conn.handshake()
                return conn
            } catch (e: IOException) {
                socket.close()
                throw MpdConnectionException("failed to connect to $host:$port", e)
            }
        }
    }
}
