package no.zyon.shade.approval

import org.json.JSONArray
import org.json.JSONObject

/**
 * V4.10 — canonical profile-blob schema. Mirror
 * `@shade/sdk/approval.ts` byte-for-byte: same field names, same
 * JSON shape, same denormalization invariants.
 *
 * The blob is the AEAD plaintext stored in the V4.9 profile slot. It
 * holds the user's list of paired hosts + clients; cross-host
 * approval routing reads `clients[]` to find trusted approvers when
 * a headless host needs to dispatch a `linkRequest` to a phone.
 *
 * Mutators (`upsertHost`, `setTrustedApprover`, ...) are immutable —
 * they return a new blob and never modify the input. The denormalized
 * `trustedApproverFingerprints[]` is rederived on every mutation so it
 * can never drift from the per-client `trustedApprover` flag.
 */

/** A host: a device that receives `linkRequest` frames and runs pairing. */
data class ProfileHostEntry(
    val address: String,
    val name: String,
    /** Open enum: `"desktop" | "server" | "laptop" | ...`. */
    val kind: String,
    val addedAt: Long,
)

/**
 * A client: a device that initiates link/approval flows and may
 * proxy-approve when `trustedApprover == true`. Stores both the
 * 32-byte Ed25519 identity public key (hex) and the safety-number
 * fingerprint — the public key is what `verifyProxyApproval` checks
 * signatures against; the fingerprint is what UIs display.
 */
data class ProfileClientEntry(
    val address: String,
    /** 32-byte Ed25519 long-term identity public key, lowercase hex (64 chars). */
    val identityPublicKey: String,
    /** Safety-number fingerprint of the identity key (computeFingerprint output). */
    val identityFingerprint: String,
    val name: String,
    /** Open enum: `"mobile" | "tablet" | "browser" | ...`. */
    val kind: String,
    val addedAt: Long,
    val trustedApprover: Boolean = false,
)

/**
 * Canonical profile blob. `version=1` is the only currently-supported
 * shape; bump when an incompatible field is added. Unknown top-level
 * fields are dropped on parse — additive changes need a coordinated
 * schema bump on both platforms.
 */
data class CanonicalProfileBlob(
    val version: Int = 1,
    val hosts: List<ProfileHostEntry> = emptyList(),
    val clients: List<ProfileClientEntry> = emptyList(),
    /** Denormalized list of trusted-approver fingerprints. Rederived on mutate. */
    val trustedApproverFingerprints: List<String> = emptyList(),
    val updatedAt: Long = 0,
    /** Optional hex-encoded pubkey of the writer; informational only. */
    val signedBy: String? = null,
)

/** Build a fresh empty profile blob with `updatedAt = now ?? System.currentTimeMillis()`. */
fun emptyCanonicalProfile(now: Long? = null): CanonicalProfileBlob =
    CanonicalProfileBlob(updatedAt = now ?: System.currentTimeMillis())

/**
 * Decode a profile-blob plaintext (the AEAD-opened bytes) into the
 * canonical shape. Throws `IllegalArgumentException` on malformed JSON
 * or wrong shape.
 */
fun parseCanonicalProfile(plaintext: ByteArray): CanonicalProfileBlob =
    parseCanonicalProfile(plaintext.toString(Charsets.UTF_8))

fun parseCanonicalProfile(plaintext: String): CanonicalProfileBlob {
    val obj = try {
        JSONObject(plaintext)
    } catch (e: Exception) {
        throw IllegalArgumentException("profile blob is not valid JSON: ${e.message}")
    }
    val version = obj.optInt("version", -1)
    require(version == 1) { "unsupported profile blob version: $version" }

    val hosts = parseArray(obj.optJSONArray("hosts"), "hosts", ::parseHostEntry)
    val clients = parseArray(obj.optJSONArray("clients"), "clients", ::parseClientEntry)
    val trustedApproverFingerprints = parseStringArray(
        obj.optJSONArray("trustedApproverFingerprints"),
        "trustedApproverFingerprints",
    )
    val updatedAt = if (obj.has("updatedAt") && !obj.isNull("updatedAt"))
        obj.getLong("updatedAt") else 0L
    val signedBy = obj.optString("signedBy", "").takeIf { it.isNotEmpty() }

    return CanonicalProfileBlob(
        version = 1,
        hosts = hosts,
        clients = clients,
        trustedApproverFingerprints = trustedApproverFingerprints,
        updatedAt = updatedAt,
        signedBy = signedBy,
    )
}

/** Serialize a profile blob to UTF-8 JSON ready for `Profile.put`. */
fun serializeCanonicalProfile(blob: CanonicalProfileBlob): ByteArray {
    val json = JSONObject()
    json.put("version", blob.version)
    json.put("hosts", JSONArray().apply {
        blob.hosts.forEach { put(hostEntryToJson(it)) }
    })
    json.put("clients", JSONArray().apply {
        blob.clients.forEach { put(clientEntryToJson(it)) }
    })
    json.put("trustedApproverFingerprints", JSONArray(blob.trustedApproverFingerprints))
    json.put("updatedAt", blob.updatedAt)
    if (blob.signedBy != null) json.put("signedBy", blob.signedBy)
    return json.toString().toByteArray(Charsets.UTF_8)
}

private fun parseHostEntry(o: JSONObject): ProfileHostEntry =
    ProfileHostEntry(
        address = o.requireString("address", "hosts"),
        name = o.requireString("name", "hosts"),
        kind = o.requireString("kind", "hosts"),
        addedAt = o.requireLong("addedAt", "hosts"),
    )

private fun parseClientEntry(o: JSONObject): ProfileClientEntry {
    val identityPublicKey = o.requireString("identityPublicKey", "clients")
    require(identityPublicKey.matches(Regex("^[0-9a-f]{64}$"))) {
        "clients[].identityPublicKey must be 64 lowercase hex chars"
    }
    return ProfileClientEntry(
        address = o.requireString("address", "clients"),
        identityPublicKey = identityPublicKey,
        identityFingerprint = o.requireString("identityFingerprint", "clients"),
        name = o.requireString("name", "clients"),
        kind = o.requireString("kind", "clients"),
        addedAt = o.requireLong("addedAt", "clients"),
        trustedApprover = o.optBoolean("trustedApprover", false),
    )
}

private fun hostEntryToJson(e: ProfileHostEntry): JSONObject = JSONObject().apply {
    put("address", e.address)
    put("name", e.name)
    put("kind", e.kind)
    put("addedAt", e.addedAt)
}

private fun clientEntryToJson(e: ProfileClientEntry): JSONObject = JSONObject().apply {
    put("address", e.address)
    put("identityPublicKey", e.identityPublicKey)
    put("identityFingerprint", e.identityFingerprint)
    put("name", e.name)
    put("kind", e.kind)
    put("addedAt", e.addedAt)
    if (e.trustedApprover) put("trustedApprover", true)
}

private fun <T> parseArray(
    arr: JSONArray?,
    field: String,
    parse: (JSONObject) -> T,
): List<T> {
    if (arr == null) return emptyList()
    return (0 until arr.length()).map { i ->
        val item = arr.opt(i)
        require(item is JSONObject) { "$field[$i] must be an object" }
        parse(item)
    }
}

private fun parseStringArray(arr: JSONArray?, field: String): List<String> {
    if (arr == null) return emptyList()
    return (0 until arr.length()).map { i ->
        val item = arr.opt(i)
        require(item is String) { "$field[$i] must be a string" }
        item
    }
}

private fun JSONObject.requireString(key: String, ctx: String): String {
    require(has(key) && !isNull(key)) { "$ctx[].$key is required" }
    val v = get(key)
    require(v is String) { "$ctx[].$key must be a string" }
    return v
}

private fun JSONObject.requireLong(key: String, ctx: String): Long {
    require(has(key) && !isNull(key)) { "$ctx[].$key is required" }
    return when (val v = get(key)) {
        is Number -> v.toLong()
        else -> throw IllegalArgumentException("$ctx[].$key must be a number")
    }
}

// ─── Mutators (immutable; return new blob, never mutate input) ──

/** Insert or replace a host entry by address. Bumps `updatedAt`. */
fun upsertHost(
    blob: CanonicalProfileBlob,
    host: ProfileHostEntry,
    now: Long? = null,
): CanonicalProfileBlob {
    val hosts = blob.hosts.filter { it.address != host.address } + host
    return blob.copy(hosts = hosts, updatedAt = now ?: System.currentTimeMillis())
}

/** Remove the host with the given address, if any. */
fun removeHost(
    blob: CanonicalProfileBlob,
    address: String,
    now: Long? = null,
): CanonicalProfileBlob {
    val hosts = blob.hosts.filter { it.address != address }
    if (hosts.size == blob.hosts.size) return blob
    return blob.copy(hosts = hosts, updatedAt = now ?: System.currentTimeMillis())
}

/**
 * Insert or replace a client entry by `identityFingerprint`. Re-derives
 * `trustedApproverFingerprints` from the resulting `clients[]` so the
 * denormalized list never drifts.
 */
fun upsertClient(
    blob: CanonicalProfileBlob,
    client: ProfileClientEntry,
    now: Long? = null,
): CanonicalProfileBlob {
    val clients = blob.clients
        .filter { it.identityFingerprint != client.identityFingerprint } + client
    return blob.copy(
        clients = clients,
        trustedApproverFingerprints = deriveTrustedApprovers(clients),
        updatedAt = now ?: System.currentTimeMillis(),
    )
}

/** Remove the client with the given identityFingerprint, if any. */
fun removeClient(
    blob: CanonicalProfileBlob,
    identityFingerprint: String,
    now: Long? = null,
): CanonicalProfileBlob {
    val clients = blob.clients.filter { it.identityFingerprint != identityFingerprint }
    if (clients.size == blob.clients.size) return blob
    return blob.copy(
        clients = clients,
        trustedApproverFingerprints = deriveTrustedApprovers(clients),
        updatedAt = now ?: System.currentTimeMillis(),
    )
}

/**
 * Toggle the `trustedApprover` flag on a client by fingerprint.
 * Returns the input unchanged if fingerprint isn't found OR the
 * desired state already matches (no spurious updatedAt bump).
 */
fun setTrustedApprover(
    blob: CanonicalProfileBlob,
    identityFingerprint: String,
    trusted: Boolean,
    now: Long? = null,
): CanonicalProfileBlob {
    var touched = false
    val clients = blob.clients.map { c ->
        if (c.identityFingerprint != identityFingerprint) c
        else if (c.trustedApprover == trusted) c
        else {
            touched = true
            c.copy(trustedApprover = trusted)
        }
    }
    if (!touched) return blob
    return blob.copy(
        clients = clients,
        trustedApproverFingerprints = deriveTrustedApprovers(clients),
        updatedAt = now ?: System.currentTimeMillis(),
    )
}

/**
 * True iff the given fingerprint resolves to a client with both
 * `trustedApprover == true` AND an entry in `trustedApproverFingerprints[]`.
 */
fun isTrustedApprover(blob: CanonicalProfileBlob, identityFingerprint: String): Boolean {
    if (!blob.trustedApproverFingerprints.contains(identityFingerprint)) return false
    val c = findClientByFingerprint(blob, identityFingerprint) ?: return false
    return c.trustedApprover
}

fun findClientByFingerprint(
    blob: CanonicalProfileBlob,
    identityFingerprint: String,
): ProfileClientEntry? = blob.clients.firstOrNull { it.identityFingerprint == identityFingerprint }

fun findClientByAddress(blob: CanonicalProfileBlob, address: String): ProfileClientEntry? =
    blob.clients.firstOrNull { it.address == address }

private fun deriveTrustedApprovers(clients: List<ProfileClientEntry>): List<String> =
    clients.filter { it.trustedApprover }.map { it.identityFingerprint }
