package com.fabledsword.minstrel.player.output.upnp

import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
import timber.log.Timber

/**
 * Minimal SOAP/UPnP envelope builder + POST. Hand-rolled rather than
 * pulled in via jupnp; we control every line and integrate cleanly
 * with the app's OkHttpClient (shared connection pool, timeouts).
 *
 * Builds the standard SOAP 1.1 envelope, POSTs with the required
 * SOAPACTION + Content-Type headers, returns the parsed
 * `<action>Response` element as a Map<String, String> (UPnP responses
 * are flat string maps).
 *
 * Throws [SoapFaultException] on a `<s:Fault>` response (the UPnP
 * device's way of saying "I rejected your request"). Other transport
 * errors propagate as IOException.
 */
class SoapClient(
    private val okHttp: OkHttpClient,
    private val onRawResponse: ((action: String, body: String) -> Unit)? = null,
) {
    suspend fun call(
        controlUrl: HttpUrl,
        serviceType: String,
        action: String,
        args: Map<String, String> = emptyMap(),
    ): Map<String, String> = withContext(Dispatchers.IO) {
        val envelope = buildEnvelope(serviceType, action, args)
        val request = Request.Builder()
            .url(controlUrl)
            .post(envelope.toRequestBody(null))
            .header("Content-Type", SOAP_CONTENT_TYPE)
            .header("SOAPACTION", "\"$serviceType#$action\"")
            .build()
        okHttp.newCall(request).execute().use { response ->
            val body = response.body?.string().orEmpty()
            onRawResponse?.invoke(action, body)
            if (!response.isSuccessful) {
                throw SoapFaultException(faultCodeOf(body), faultDescriptionOf(body))
            }
            parseResponseArgs(body, action)
        }
    }

    private fun buildEnvelope(
        serviceType: String,
        action: String,
        args: Map<String, String>,
    ): String = buildString {
        append("<?xml version=\"1.0\"?>")
        append("<s:Envelope xmlns:s=\"http://schemas.xmlsoap.org/soap/envelope/\"")
        append(" s:encodingStyle=\"http://schemas.xmlsoap.org/soap/encoding/\">")
        append("<s:Body>")
        append("<u:").append(action)
        append(" xmlns:u=\"").append(serviceType).append("\">")
        args.forEach { (k, v) ->
            append("<").append(k).append(">")
            append(xmlEscape(v))
            append("</").append(k).append(">")
        }
        append("</u:").append(action).append(">")
        append("</s:Body>")
        append("</s:Envelope>")
    }

    private fun parseResponseArgs(body: String, action: String): Map<String, String> {
        val parser = XmlPullParserFactory.newInstance().newPullParser().apply {
            setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, true)
            setInput(body.reader())
        }
        val responseTag = "${action}Response"
        val args = mutableMapOf<String, String>()
        var inResponse = false
        while (parser.eventType != XmlPullParser.END_DOCUMENT) {
            inResponse = handleParserEvent(parser, responseTag, inResponse, args)
            parser.next()
        }
        return args
    }

    private fun handleParserEvent(
        parser: XmlPullParser,
        responseTag: String,
        inResponse: Boolean,
        args: MutableMap<String, String>,
    ): Boolean = when (parser.eventType) {
        XmlPullParser.START_TAG -> {
            if (parser.name == responseTag) {
                true
            } else {
                if (inResponse) {
                    val name = parser.name
                    val text = readElementContent(parser, name)
                    args[name] = text
                }
                inResponse
            }
        }
        XmlPullParser.END_TAG -> if (parser.name == responseTag) false else inResponse
        else -> inResponse
    }

    /**
     * Read the content of the currently-started element. Try nextText() first
     * (works when the content is text -- escaped XML included). If that throws
     * (because the content is nested elements), manually walk to the matching
     * END_TAG, accumulating text and re-serializing child elements.
     *
     * Some Sonos firmware sends the GetZoneGroupState payload as nested XML
     * elements without escaping; this fallback recovers that path.
     */
    private fun readElementContent(parser: XmlPullParser, tagName: String): String {
        return runCatching { parser.nextText() }.getOrElse {
            readUntilEndTag(parser, tagName)
        }
    }

    private fun readUntilEndTag(parser: XmlPullParser, tagName: String): String {
        val sb = StringBuilder()
        var depth = 1
        var done = false
        while (!done && depth > 0) {
            when (parser.next()) {
                XmlPullParser.START_TAG -> {
                    appendStartTag(sb, parser)
                    depth += 1
                }
                XmlPullParser.END_TAG -> {
                    depth -= 1
                    if (depth == 0 && parser.name == tagName) {
                        done = true
                    } else {
                        sb.append("</").append(parser.name).append('>')
                    }
                }
                XmlPullParser.TEXT -> sb.append(parser.text.orEmpty())
                XmlPullParser.END_DOCUMENT -> done = true
                else -> Unit
            }
        }
        return sb.toString()
    }

    private fun appendStartTag(sb: StringBuilder, parser: XmlPullParser) {
        sb.append('<').append(parser.name)
        for (i in 0 until parser.attributeCount) {
            sb.append(' ')
                .append(parser.getAttributeName(i))
                .append("=\"")
                .append(parser.getAttributeValue(i))
                .append('"')
        }
        sb.append('>')
    }

    private fun faultCodeOf(body: String): String =
        extractBetween(body, "<errorCode>", "</errorCode>") ?: "unknown"

    private fun faultDescriptionOf(body: String): String =
        extractBetween(body, "<errorDescription>", "</errorDescription>").orEmpty()

    private fun extractBetween(body: String, open: String, close: String): String? {
        val start = body.indexOf(open)
        if (start < 0) return null
        val contentStart = start + open.length
        val end = body.indexOf(close, contentStart)
        return if (end < 0) null else body.substring(contentStart, end)
    }

    private companion object {
        const val SOAP_CONTENT_TYPE = "text/xml; charset=\"utf-8\""
    }
}

/**
 * Thrown when a UPnP device responds with `<s:Fault>` — typically wraps
 * a UPnPError with `errorCode` + `errorDescription`. Code is preserved
 * as a string (UPnP codes are numeric in spec but we don't constrain).
 */
class SoapFaultException(val code: String, val description: String) :
    Exception("SOAP fault $code: $description")

private const val MAX_DIAGNOSTIC_RESPONSES = 6  // 3 polls x 2 action types
private const val MAX_BODY_LOG_CHARS = 2048

/**
 * Returns a [SoapClient] that logs the raw response body for the first
 * [MAX_DIAGNOSTIC_RESPONSES] calls for actions in [DIAGNOSTIC_ACTIONS]
 * (GetPositionInfo, GetTransportInfo, GetZoneGroupState). After that the
 * callback is a no-op so there is no persistent log spam. Logged at WARN
 * so release builds capture it without a separate log-level override.
 */
internal fun loggingSoapClient(okHttp: OkHttpClient, label: String): SoapClient {
    val counter = AtomicInteger(0)
    return SoapClient(okHttp) { action, body ->
        if (action !in DIAGNOSTIC_ACTIONS) return@SoapClient
        val n = counter.incrementAndGet()
        if (n <= MAX_DIAGNOSTIC_RESPONSES) {
            Timber.w(
                "UPnP %s response #%d (%s): %s",
                label, n, action,
                body.take(MAX_BODY_LOG_CHARS),
            )
        }
    }
}

private val DIAGNOSTIC_ACTIONS = setOf(
    "GetPositionInfo",
    "GetTransportInfo",
    "GetZoneGroupState",
)

/** XML-escapes a string value for embedding as text content inside a SOAP envelope. */
internal fun xmlEscape(v: String): String = v
    .replace("&", "&amp;")
    .replace("<", "&lt;")
    .replace(">", "&gt;")
    .replace("\"", "&quot;")
    .replace("'", "&apos;")
