package no.zyon.shade.blob

import no.zyon.shade.crypto.CryptoProvider

/**
 * V4.9 — high-level profile namespace. Mirror
 * `@shade/sdk`'s `createProfileNamespace`. The relay never sees
 * plaintext; AAD binds the slotId so a relay returning the wrong
 * slot's blob fails to open.
 *
 * Usage:
 *
 *   val crypto = TinkProvider()
 *   val masterKey = deriveMasterKey("password", salt) // V4.5 KDF chain
 *   val profile = createProfileNamespace(
 *       baseUrl = "https://shade.example/",
 *       crypto = crypto,
 *       masterKey = masterKey,
 *       app = "prism-profile-v1",
 *   )
 *
 *   val current = profile.get()  // null if no blob yet
 *   profile.put(serializeCanonicalProfile(...), ifMatch = current?.etag)
 *   profile.delete()
 *
 * Apps with the same master key + app namespace converge on the same
 * slot — that's the whole point: a brand new device with the right
 * credentials can locate, decrypt, and update the blob.
 */
class ProfileNamespace internal constructor(
    /** Lower-hex 64-char slotId. Stable per (master, app). */
    val slotIdHex: String,
    private val blobKey: ByteArray,
    private val signingSeed: ByteArray,
    private val ownerPubkey: ByteArray,
    private val aad: ByteArray,
    private val client: BlobClient,
    private val crypto: CryptoProvider,
) {

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

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

    /** Returns null when the slot has never been written (or was deleted). */
    fun get(): GetResult? {
        val raw = client.get(slotIdHex) ?: return null
        val plaintext = aeadOpen(blobKey, raw.blob, aad)
        return GetResult(plaintext = plaintext, etag = raw.etag, updatedAt = raw.updatedAt)
    }

    /**
     * Create or update. `ifMatch`:
     *  - null: create-only (fails with 409 if slot populated).
     *  - "<etag>": CAS (fails with 412 on stale).
     *  - "*": unconditional overwrite.
     */
    fun put(plaintext: ByteArray, ifMatch: String? = null): PutResult {
        // Fresh random nonce per write — see `BlobAead`. Re-uploading
        // the same plaintext after a transient error reuses neither
        // (key, nonce, plaintext) nor (key, nonce).
        val nonce = crypto.randomBytes(BLOB_AEAD_NONCE_LEN)
        val sealed = aeadSeal(blobKey, nonce, plaintext, aad)
        val r = client.put(
            slotIdHex = slotIdHex,
            blob = sealed,
            signingSeed = signingSeed,
            ownerPubkey = ownerPubkey,
            ifMatch = ifMatch,
        )
        return PutResult(created = r.created, etag = r.etag, updatedAt = r.updatedAt)
    }

    /** "Forget everything" path — the next PUT TOFU-claims it again. */
    fun delete(): Boolean = client.delete(slotIdHex, signingSeed)
}

/**
 * Build a Profile namespace bound to a (master key, app) pair. The
 * derivations are deterministic: any device with the same master
 * key + app namespace produces the same slot, so a fresh device
 * after credential entry can locate the existing profile blob.
 */
fun createProfileNamespace(
    baseUrl: String,
    crypto: CryptoProvider,
    masterKey: ByteArray,
    app: String,
): ProfileNamespace {
    require(masterKey.size == 32) { "masterKey must be 32 bytes" }
    require(app.isNotEmpty()) { "app namespace must be non-empty" }

    val slotIdBytes = deriveBlobSlotId(crypto, masterKey, app)
    val slotIdHex = slotIdToHex(slotIdBytes)
    val blobKey = deriveBlobKey(crypto, masterKey, app)
    val signingSeed = deriveBlobSigningSeed(crypto, masterKey, app)
    val ownerPubkey = ed25519PublicKeyFromSeed(signingSeed)
    val aad = blobAadForSlot(slotIdHex)

    val client = BlobClient(baseUrl = baseUrl, crypto = crypto)
    return ProfileNamespace(
        slotIdHex = slotIdHex,
        blobKey = blobKey,
        signingSeed = signingSeed,
        ownerPubkey = ownerPubkey,
        aad = aad,
        client = client,
        crypto = crypto,
    )
}
