package no.zyon.shade.blob

import no.zyon.shade.crypto.CryptoProvider
import org.json.JSONObject
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
import java.util.Base64

/**
 * Low-level HTTP client for the V4.9 encrypted-blob primitive
 * (`/v1/blob/<slotId>`). Mirror `@shade/inbox`'s `BlobClient` —
 * stateless, reusable, and protocol-compatible with the TypeScript
 * relay endpoints.
 *
 * The client doesn't care what the blob bytes mean — it just
 * transports them. Higher-level wrappers (e.g. `Profile`) compose
 * this client with AEAD-sealing of the actual payload.
 *
 * Auth model: every PUT/DELETE carries a detached Ed25519 signature
 * (base64) over a canonical-JSON form of the request body. The
 * canonicalization is deterministic — sorted keys, compact JSON, no
 * trailing whitespace — so signatures generated on Kotlin verify on
 * the TS server.
 */
class BlobClient(
    private val baseUrl: String,
    private val crypto: CryptoProvider,
    private val httpClient: HttpClient = HttpClient.newBuilder()
        .connectTimeout(Duration.ofSeconds(15))
        .build(),
) {

    data class GetResult(
        val blob: ByteArray,
        val etag: String,
        val updatedAt: Long,
    )

    data class PutResult(
        val created: Boolean,
        val etag: String,
        val updatedAt: Long,
    )

    /**
     * Read a slot. Returns null if no blob has ever been written there
     * (or if it was DELETE'd). GET is unauthenticated by design — the
     * slotId is itself a 256-bit secret derived from the master key.
     */
    fun get(slotIdHex: String): GetResult? {
        validateSlotIdHex(slotIdHex)
        val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex")))
            .GET()
            .build()
        val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString())
        if (res.statusCode() == 404) return null
        val json = parseJson(res, "GET")
        val blob = Base64.getDecoder().decode(json.getString("blob"))
        return GetResult(
            blob = blob,
            etag = json.getString("etag"),
            updatedAt = json.getLong("updatedAt"),
        )
    }

    /**
     * Create or update a slot.
     *
     * `ifMatch` semantics:
     *  - `null`: create-only. Slot must be empty (else 409).
     *  - `<etag-string>`: compare-and-swap. Must match (else 412).
     *  - `"*"`: unconditional overwrite. Slot must already exist (else 412).
     */
    fun put(
        slotIdHex: String,
        blob: ByteArray,
        signingSeed: ByteArray,
        ownerPubkey: ByteArray,
        ifMatch: String? = null,
    ): PutResult {
        validateSlotIdHex(slotIdHex)
        require(blob.isNotEmpty()) { "Empty blob" }
        require(ownerPubkey.size == 32) { "ownerPubkey must be 32 bytes (Ed25519)" }
        require(signingSeed.size == 32) { "signingSeed must be 32 bytes (Ed25519)" }

        // Canonical form for signing: sorted keys, slotId included,
        // signature field absent. The wire body strips slotId (it's
        // in the URL) but the signature is computed over the
        // slotId-bearing form.
        val signedAt = System.currentTimeMillis()
        val canonical = sortedMapOf<String, Any>().apply {
            put("blob", Base64.getEncoder().encodeToString(blob))
            if (ifMatch != null) put("ifMatch", ifMatch)
            put("ownerPubkey", Base64.getEncoder().encodeToString(ownerPubkey))
            put("signedAt", signedAt)
            put("slotId", slotIdHex)
        }
        val canonicalBytes = canonicalJson(canonical).toByteArray(Charsets.UTF_8)
        val sig = crypto.sign(signingSeed, canonicalBytes)

        // Wire body: same as canonical minus slotId, plus signature.
        val wire = JSONObject()
        wire.put("blob", Base64.getEncoder().encodeToString(blob))
        if (ifMatch != null) wire.put("ifMatch", ifMatch)
        wire.put("ownerPubkey", Base64.getEncoder().encodeToString(ownerPubkey))
        wire.put("signedAt", signedAt)
        wire.put("signature", Base64.getEncoder().encodeToString(sig))

        val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex")))
            .header("content-type", "application/json")
            .PUT(HttpRequest.BodyPublishers.ofString(wire.toString()))
            .build()
        val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString())
        val json = parseJson(res, "PUT")
        return PutResult(
            created = json.optBoolean("created"),
            etag = json.getString("etag"),
            updatedAt = json.getLong("updatedAt"),
        )
    }

    /**
     * Delete a slot. The next PUT TOFU-claims it again, possibly under
     * a fresh signing key (e.g. after rotation). Used by "forget
     * everything" flows.
     */
    fun delete(slotIdHex: String, signingSeed: ByteArray): Boolean {
        validateSlotIdHex(slotIdHex)
        require(signingSeed.size == 32) { "signingSeed must be 32 bytes (Ed25519)" }

        val signedAt = System.currentTimeMillis()
        val canonical = sortedMapOf<String, Any>().apply {
            put("signedAt", signedAt)
            put("slotId", slotIdHex)
        }
        val canonicalBytes = canonicalJson(canonical).toByteArray(Charsets.UTF_8)
        val sig = crypto.sign(signingSeed, canonicalBytes)

        val wire = JSONObject()
        wire.put("signedAt", signedAt)
        wire.put("signature", Base64.getEncoder().encodeToString(sig))

        val req = HttpRequest.newBuilder(URI.create(joinUrl(baseUrl, "/v1/blob/$slotIdHex")))
            .header("content-type", "application/json")
            .method("DELETE", HttpRequest.BodyPublishers.ofString(wire.toString()))
            .build()
        val res = httpClient.send(req, HttpResponse.BodyHandlers.ofString())
        val json = parseJson(res, "DELETE")
        return json.optBoolean("ok", false)
    }

    private fun parseJson(res: HttpResponse<String>, op: String): JSONObject {
        val text = res.body() ?: ""
        val json = if (text.isEmpty()) JSONObject() else try {
            JSONObject(text)
        } catch (e: Exception) {
            throw BlobClientException(
                code = "SHADE_NETWORK",
                statusCode = res.statusCode(),
                message = "Blob $op response not JSON: ${text.take(200)}",
            )
        }
        if (res.statusCode() !in 200..299) {
            throw BlobClientException(
                code = json.optString("code", "SHADE_NETWORK"),
                statusCode = res.statusCode(),
                message = json.optString("message", text),
            )
        }
        return json
    }

    private fun validateSlotIdHex(s: String) {
        require(s.matches(Regex("^[0-9a-f]{64}$"))) {
            "slotIdHex must be 64 lowercase hex chars (32 bytes)"
        }
    }

    private fun joinUrl(base: String, path: String): String =
        when {
            base.endsWith("/") && path.startsWith("/") -> base + path.substring(1)
            !base.endsWith("/") && !path.startsWith("/") -> "$base/$path"
            else -> base + path
        }
}

/**
 * Mirror of TS `signPayload`'s canonicalization: sorted keys, compact
 * JSON, signature field absent. Only handles the subset of types we
 * need (strings + longs + base64 strings) — keeping the implementation
 * narrow so it can't accidentally diverge from `JSON.stringify` on
 * structurally-different inputs.
 */
internal fun canonicalJson(map: Map<String, Any>): String {
    val sb = StringBuilder()
    sb.append('{')
    var first = true
    for ((key, value) in map.toSortedMap()) {
        if (!first) sb.append(',')
        first = false
        appendJsonString(sb, key)
        sb.append(':')
        appendJsonValue(sb, value)
    }
    sb.append('}')
    return sb.toString()
}

private fun appendJsonValue(sb: StringBuilder, value: Any) {
    when (value) {
        is String -> appendJsonString(sb, value)
        is Long, is Int -> sb.append(value.toString())
        is Boolean -> sb.append(value.toString())
        else -> throw IllegalArgumentException(
            "canonicalJson: unsupported value type ${value::class.java}",
        )
    }
}

private fun appendJsonString(sb: StringBuilder, s: String) {
    sb.append('"')
    for (c in s) {
        when (c) {
            '\\' -> sb.append("\\\\")
            '"' -> sb.append("\\\"")
            '\b' -> sb.append("\\b")
            '\u000C' -> sb.append("\\f")
            '\n' -> sb.append("\\n")
            '\r' -> sb.append("\\r")
            '\t' -> sb.append("\\t")
            else -> {
                if (c.code < 0x20) {
                    sb.append("\\u").append("%04x".format(c.code))
                } else {
                    sb.append(c)
                }
            }
        }
    }
    sb.append('"')
}

class BlobClientException(
    val code: String,
    val statusCode: Int,
    message: String,
) : RuntimeException("[$code @ $statusCode] $message")
