package dev.castarr.tv.server

import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import dev.castarr.tv.pairing.Pairing
import dev.castarr.tv.playlist.Channel
import fi.iki.elonen.NanoHTTPD
import fi.iki.elonen.NanoWSD
import org.json.JSONObject
import java.io.IOException
import java.util.Timer
import java.util.TimerTask
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.Executors

/**
 * Embedded HTTP + WebSocket server. Serves the phone remote (a single HTML
 * page) over HTTP and takes playback commands over a WebSocket. A client
 * authorizes itself with the token from the QR code or the four-digit
 * pairing code.
 */
class ControlServer(
    private val context: Context,
    private val listener: Listener,
) : NanoWSD(Pairing.PORT) {

    interface Listener {
        fun onPlay(url: String, name: String, group: String)
        fun onTogglePlay()
        fun onPauseCast()
        fun onResumeCast()
        fun onStopCast()
        fun onSeek(deltaSeconds: Long)
        fun onSetAudio(index: Int)
        fun onVolume(value: Float)
        fun onSetPlaylist(url: String)
        fun onToggleFavorite(channelId: Int)
        fun onConfigureServer(url: String)
        fun setupNeeded(): Boolean
        fun onClientsChanged(count: Int, newestName: String?)
        fun currentStatus(): JSONObject
        fun currentChannels(): List<Channel>
        fun channelsExtras(): JSONObject
        fun currentPlaylistUrl(): String
    }

    private val mainHandler = Handler(Looper.getMainLooper())
    // Socket writes must stay off the main thread (NetworkOnMainThread),
    // while payloads reading player/UI state must be BUILT on it — so all
    // sends are serialized through this worker.
    private val sendExecutor = Executors.newSingleThreadExecutor()
    private val clients = CopyOnWriteArrayList<RemoteSocket>()
    private var pingTimer: Timer? = null

    /** False when the port could not be bound; the app stays usable. */
    @Volatile
    var running = false
        private set

    // Failed authentication attempts per remote address. Counting every
    // failure (not just the ones carrying a code) keeps a hostile client
    // from spending someone else's budget.
    private val attempts = HashMap<String, ArrayDeque<Long>>()

    @Synchronized
    private fun attemptAllowed(address: String): Boolean {
        val now = System.currentTimeMillis()
        val queue = attempts.getOrPut(address) { ArrayDeque() }
        while (queue.isNotEmpty() && now - queue.first() > ATTEMPT_WINDOW_MS) {
            queue.removeFirst()
        }
        if (attempts.size > MAX_TRACKED_ADDRESSES) {
            attempts.entries.removeAll { it.value.isEmpty() }
        }
        if (queue.size >= ATTEMPT_MAX) return false
        queue.addLast(now)
        return true
    }

    fun startServer() {
        // A busy port must not take the whole app down — the remote is
        // optional, everything else keeps working.
        running = runCatching { start(NanoHTTPD.SOCKET_READ_TIMEOUT, true) }
            .onFailure { Log.w(TAG, "control server unavailable: ${it.javaClass.simpleName}") }
            .isSuccess
        if (!running) return
        pingTimer = Timer("ws-ping", true).also {
            it.schedule(object : TimerTask() {
                override fun run() = maintainClients()
            }, PING_INTERVAL_MS, PING_INTERVAL_MS)
        }
    }

    fun stopServer() {
        pingTimer?.cancel()
        pingTimer = null
        sendExecutor.shutdown()
        if (running) runCatching { stop() }
        running = false
    }

    // --- HTTP ---

    /**
     * Rejects cross-origin upgrades. NanoWSD itself never looks at `Origin`,
     * and WebSockets are exempt from the same-origin policy, so without this
     * any page on the LAN could talk to the control socket.
     */
    override fun serve(session: IHTTPSession): Response {
        val origin = session.headers["origin"]
        if (origin != null && !isOwnOrigin(origin, session.headers["host"])) {
            Log.w(TAG, "rejected cross-origin request")
            return newFixedLengthResponse(
                Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "forbidden",
            )
        }
        return super.serve(session)
    }

    private fun isOwnOrigin(origin: String, host: String?): Boolean {
        if (host.isNullOrEmpty()) return false
        return origin.equals("http://$host", ignoreCase = true) ||
            origin.equals("https://$host", ignoreCase = true)
    }

    override fun serveHttp(session: IHTTPSession): Response {
        return when (session.uri) {
            "/", "/index.html" -> {
                val html = context.assets.open("remote/index.html").bufferedReader().use { it.readText() }
                newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", html).apply {
                    addHeader("Cache-Control", "no-store")
                    addHeader("Referrer-Policy", "no-referrer")
                }
            }
            else -> newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "not found")
        }
    }

    // --- WebSocket ---

    override fun openWebSocket(handshake: IHTTPSession): WebSocket =
        RemoteSocket(handshake, handshake.remoteIpAddress.orEmpty())

    fun broadcast(message: JSONObject) {
        val payload = message.toString()
        sendExecutor.execute {
            clients.forEach { client ->
                if (client.authorized) client.trySend(payload)
            }
        }
    }

    fun broadcastStatus() {
        broadcast(listener.currentStatus().put("type", "status"))
    }

    fun broadcastChannels() {
        broadcast(channelsMessage())
    }

    fun broadcastToast(message: String) {
        broadcast(JSONObject().put("type", "toast").put("message", message))
    }

    fun broadcastLoginLink(url: String, code: String) {
        broadcast(JSONObject().put("type", "login_link").put("url", url).put("code", code))
    }

    fun broadcastSetupDone() {
        broadcast(JSONObject().put("type", "setup_done"))
    }

    private fun channelsMessage(): JSONObject = JSONObject()
        .put("type", "channels")
        .put("playlistUrl", listener.currentPlaylistUrl())
        .put("channels", Channel.listToJson(listener.currentChannels()))
        .put("extras", listener.channelsExtras())

    /**
     * Pings live clients and drops sockets that never authenticated. Without
     * this an unauthenticated connection would pin a thread forever.
     */
    private fun maintainClients() {
        val now = System.currentTimeMillis()
        clients.forEach { client ->
            if (!client.authorized && now - client.openedAt > HANDSHAKE_TIMEOUT_MS) {
                Log.d(TAG, "dropping client that never authenticated")
                client.dropSilently()
                return@forEach
            }
            try {
                client.ping(PING_PAYLOAD)
            } catch (e: IOException) {
                Log.d(TAG, "ping failed, dropping client", e)
                clients.remove(client)
            }
        }
        notifyClientsChanged(null)
    }

    private fun notifyClientsChanged(newestName: String?) {
        val count = clients.count { it.authorized }
        mainHandler.post { listener.onClientsChanged(count, newestName) }
    }

    inner class RemoteSocket(
        handshake: IHTTPSession,
        private val remoteAddress: String,
    ) : WebSocket(handshake) {

        @Volatile
        var authorized = false
            private set

        val openedAt = System.currentTimeMillis()
        private var deviceName: String = ""

        fun trySend(payload: String) {
            try {
                send(payload)
            } catch (e: IOException) {
                Log.d(TAG, "send failed, dropping client", e)
                clients.remove(this)
            }
        }

        fun dropSilently() {
            clients.remove(this)
            runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "timeout", false) }
        }

        override fun onOpen() {
            // Cap concurrent sockets — a TV remote needs a handful, and an
            // unbounded count is a free resource-exhaustion vector.
            if (clients.size >= MAX_CLIENTS) {
                Log.w(TAG, "client limit reached, refusing connection")
                runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "too many clients", false) }
                return
            }
            clients.add(this)
        }

        override fun onClose(code: WebSocketFrame.CloseCode?, reason: String?, initiatedByRemote: Boolean) {
            clients.remove(this)
            notifyClientsChanged(null)
        }

        override fun onMessage(message: WebSocketFrame) {
            val msg = runCatching { JSONObject(message.textPayload) }.getOrNull() ?: return
            val type = msg.optString("type")
            if (!authorized) {
                if (type == "hello") handleHello(msg)
                return
            }
            when (type) {
                "play" -> post {
                    listener.onPlay(
                        msg.optString("url"),
                        msg.optString("name"),
                        msg.optString("group"),
                    )
                }
                "toggle" -> post { listener.onTogglePlay() }
                "pause" -> post { listener.onPauseCast() }
                "resume" -> post { listener.onResumeCast() }
                "stop" -> post { listener.onStopCast() }
                "seek" -> post { listener.onSeek(msg.optLong("delta")) }
                "set_audio" -> post { listener.onSetAudio(msg.optInt("index", -1)) }
                "volume" -> post { listener.onVolume(msg.optDouble("value", 1.0).toFloat().coerceIn(0f, 1f)) }
                "set_playlist" -> post { listener.onSetPlaylist(msg.optString("url")) }
                "set_favorite" -> post { listener.onToggleFavorite(msg.optInt("id")) }
                "configure_server" -> post { listener.onConfigureServer(msg.optString("url")) }
                "get_state" -> post {
                    // Read state on main, write on the send worker.
                    val status = listener.currentStatus().put("type", "status").toString()
                    val channels = channelsMessage().toString()
                    sendExecutor.execute {
                        trySend(status)
                        trySend(channels)
                    }
                }
                else -> Unit
            }
        }

        override fun onPong(pong: WebSocketFrame?) = Unit

        override fun onException(exception: IOException?) {
            clients.remove(this)
        }

        private fun handleHello(msg: JSONObject) {
            // Every failed attempt counts against this address, whether it
            // carried a token or a code.
            if (!attemptAllowed(remoteAddress)) {
                trySend(JSONObject().put("type", "error").put("error", "rate_limited").toString())
                runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "rate limited", false) }
                return
            }
            val tokenOk = Pairing.isValidToken(context, msg.optString("token"))
            val codeOk = !tokenOk && Pairing.isValidCode(context, msg.optString("code"))
            if (!tokenOk && !codeOk) {
                trySend(JSONObject().put("type", "error").put("error", "bad_code").toString())
                runCatching { close(WebSocketFrame.CloseCode.PolicyViolation, "bad code", false) }
                return
            }
            authorized = true
            deviceName = msg.optString("name").ifEmpty { "Handy" }
            // A code-authenticated client gets its own revocable token, never
            // the QR token — otherwise the guessable path would yield the
            // master credential.
            val grantedToken =
                if (tokenOk) msg.optString("token") else Pairing.newSessionToken(context)
            // Build the welcome payload on the main thread — it reads the
            // player and UI state, which must not happen on the WS thread.
            post {
                val welcome = JSONObject()
                    .put("type", "welcome")
                    .put("setup", listener.setupNeeded())
                    .put("device", android.os.Build.MODEL)
                    .put("token", grantedToken)
                    .put("status", listener.currentStatus())
                    .put("playlistUrl", listener.currentPlaylistUrl())
                    .put("channels", Channel.listToJson(listener.currentChannels()))
                    .put("extras", listener.channelsExtras())
                    .toString()
                sendExecutor.execute { trySend(welcome) }
            }
            notifyClientsChanged(deviceName)
        }

        private fun post(action: () -> Unit) {
            mainHandler.post(action)
        }
    }

    private companion object {
        const val TAG = "ControlServer"
        const val PING_INTERVAL_MS = 8_000L
        const val ATTEMPT_WINDOW_MS = 60_000L
        const val ATTEMPT_MAX = 5
        const val MAX_TRACKED_ADDRESSES = 64
        const val MAX_CLIENTS = 8
        const val HANDSHAKE_TIMEOUT_MS = 10_000L
        val PING_PAYLOAD = byteArrayOf(0x6e, 0x63)
    }
}
