package no.zyon.shade

import no.zyon.shade.approval.ApprovalRequestFrame
import no.zyon.shade.approval.ApprovalRequestingDevice
import no.zyon.shade.approval.CanonicalProfileBlob
import no.zyon.shade.approval.DEFAULT_APPROVAL_DOMAIN
import no.zyon.shade.approval.ProfileClientEntry
import no.zyon.shade.approval.ProfileHostEntry
import no.zyon.shade.approval.ProxyApprovalFrame
import no.zyon.shade.approval.VerifyProxyApprovalResult
import no.zyon.shade.approval.buildApprovalRequest
import no.zyon.shade.approval.canonicalApprovalSigningBytes
import no.zyon.shade.approval.emptyCanonicalProfile
import no.zyon.shade.approval.findClientByAddress
import no.zyon.shade.approval.findClientByFingerprint
import no.zyon.shade.approval.isTrustedApprover
import no.zyon.shade.approval.parseCanonicalProfile
import no.zyon.shade.approval.removeClient
import no.zyon.shade.approval.serializeCanonicalProfile
import no.zyon.shade.approval.setTrustedApprover
import no.zyon.shade.approval.signProxyApproval
import no.zyon.shade.approval.upsertClient
import no.zyon.shade.approval.upsertHost
import no.zyon.shade.approval.verifyProxyApproval
import no.zyon.shade.blob.aeadOpen
import no.zyon.shade.blob.aeadSeal
import no.zyon.shade.blob.blobAadForSlot
import no.zyon.shade.blob.deriveBlobKey
import no.zyon.shade.blob.deriveBlobSigningSeed
import no.zyon.shade.blob.deriveBlobSlotId
import no.zyon.shade.blob.ed25519PublicKeyFromSeed
import no.zyon.shade.blob.slotIdToHex
import no.zyon.shade.crypto.TinkProvider
import no.zyon.shade.storage.Argon2idParams
import no.zyon.shade.storage.ScryptParams
import no.zyon.shade.storage.deriveMasterKey
import no.zyon.shade.storage.deriveMasterKeyArgon2id
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue
import org.junit.Test

/**
 * Unit tests for the V4.9 blob primitive ports + V4.10 approval
 * helpers + scrypt/argon2id wrappers. Cross-platform vector parity
 * lives in `CrossPlatformVectorTest`; this file tests Kotlin-side
 * round-trip behavior independent of the TS reference.
 */
class BlobAndApprovalTest {

    private val crypto = TinkProvider()

    private fun hex(bytes: ByteArray): String =
        bytes.joinToString("") { "%02x".format(it) }

    // ─── V4.9 blob KDF ─────────────────────────────────────────

    @Test
    fun deriveBlobSlotIdIsDeterministicPerMasterAndApp() {
        val km = ByteArray(32) { it.toByte() }
        val a1 = deriveBlobSlotId(crypto, km, "foo")
        val a2 = deriveBlobSlotId(crypto, km, "foo")
        assertArrayEquals(a1, a2)

        val b = deriveBlobSlotId(crypto, km, "bar")
        assertFalse(a1.contentEquals(b))

        val km2 = ByteArray(32) { (it + 1).toByte() }
        val c = deriveBlobSlotId(crypto, km2, "foo")
        assertFalse(a1.contentEquals(c))
    }

    @Test
    fun blobKdfHelpersAreIndependent() {
        val km = ByteArray(32) { it.toByte() }
        val slot = deriveBlobSlotId(crypto, km, "x")
        val key = deriveBlobKey(crypto, km, "x")
        val seed = deriveBlobSigningSeed(crypto, km, "x")
        assertFalse(slot.contentEquals(key))
        assertFalse(slot.contentEquals(seed))
        assertFalse(key.contentEquals(seed))
    }

    @Test
    fun ed25519PublicKeyFromSeedIsDeterministic() {
        val seed = ByteArray(32) { it.toByte() }
        val pk1 = ed25519PublicKeyFromSeed(seed)
        val pk2 = ed25519PublicKeyFromSeed(seed)
        assertArrayEquals(pk1, pk2)
        assertEquals(32, pk1.size)
    }

    @Test
    fun slotIdToHexProducesLowercase64Chars() {
        val s = ByteArray(32) { 0xab.toByte() }
        val hex = slotIdToHex(s)
        assertEquals(64, hex.length)
        assertEquals("a".repeat(0) + "ab".repeat(32), hex)
    }

    // ─── V4.9 AEAD round-trip ──────────────────────────────────

    @Test
    fun aeadSealOpenRoundTrip() {
        val key = crypto.randomBytes(32)
        val nonce = crypto.randomBytes(12)
        val pt = "hello".toByteArray()
        val aad = blobAadForSlot("00".repeat(32))
        val sealed = aeadSeal(key, nonce, pt, aad)
        val opened = aeadOpen(key, sealed, aad)
        assertArrayEquals(pt, opened)
    }

    @Test
    fun aeadOpenWithWrongAadFails() {
        val key = crypto.randomBytes(32)
        val nonce = crypto.randomBytes(12)
        val pt = "hello".toByteArray()
        val aad1 = blobAadForSlot("00".repeat(32))
        val aad2 = blobAadForSlot("ff".repeat(32))
        val sealed = aeadSeal(key, nonce, pt, aad1)
        try {
            aeadOpen(key, sealed, aad2)
            org.junit.Assert.fail("expected AEAD to reject wrong AAD")
        } catch (_: Exception) {
            // expected
        }
    }

    @Test
    fun aeadOpenWithExpectedNonceMismatchFails() {
        val key = crypto.randomBytes(32)
        val nonce = crypto.randomBytes(12)
        val wrongNonce = crypto.randomBytes(12)
        val pt = "hello".toByteArray()
        val aad = blobAadForSlot("00".repeat(32))
        val sealed = aeadSeal(key, nonce, pt, aad)
        try {
            aeadOpen(key, sealed, aad, expectedNonce = wrongNonce)
            org.junit.Assert.fail("expected expectedNonce check to reject")
        } catch (_: IllegalArgumentException) {
            // expected
        }
    }

    // ─── Password KDFs ─────────────────────────────────────────

    @Test
    fun scryptDerivesDeterministically() {
        val pw = "correct-horse-battery-staple"
        val salt = ByteArray(16) { 0x42.toByte() }
        val k1 = deriveMasterKey(pw, salt, ScryptParams(n = 1024))
        val k2 = deriveMasterKey(pw, salt, ScryptParams(n = 1024))
        assertArrayEquals(k1, k2)
        assertEquals(32, k1.size)
    }

    @Test
    fun argon2idDerivesDeterministically() {
        val pw = "1234"
        val salt = ByteArray(16) { 0x55.toByte() }
        val k1 = deriveMasterKeyArgon2id(pw, salt, Argon2idParams(m = 256, t = 2))
        val k2 = deriveMasterKeyArgon2id(pw, salt, Argon2idParams(m = 256, t = 2))
        assertArrayEquals(k1, k2)
        assertEquals(32, k1.size)
    }

    @Test
    fun nfkcNormalizationMakesEquivalentInputsConverge() {
        // "café" can be encoded either as 'c','a','f','é' (NFC) or
        // 'c','a','f','e','́' (NFD). NFKC normalization on both
        // should converge to the same bytes.
        val nfc = "café"
        val nfd = "café"
        assertNotEquals(nfc, nfd)
        val salt = ByteArray(16) { 1.toByte() }
        val k1 = deriveMasterKey(nfc, salt, ScryptParams(n = 1024))
        val k2 = deriveMasterKey(nfd, salt, ScryptParams(n = 1024))
        assertArrayEquals(k1, k2)
    }

    // ─── Canonical profile schema ──────────────────────────────

    private fun makeClient(name: String, trusted: Boolean = false): Pair<ProfileClientEntry, ByteArray> {
        val seed = crypto.randomBytes(32)
        val pubkey = ed25519PublicKeyFromSeed(seed)
        val fp = "fp-$name-${hex(pubkey).take(8)}"
        return ProfileClientEntry(
            address = "device:$name",
            identityPublicKey = hex(pubkey),
            identityFingerprint = fp,
            name = name,
            kind = "mobile",
            addedAt = 1_700_000_000_000L,
            trustedApprover = trusted,
        ) to seed
    }

    private fun makeHost(): ProfileHostEntry = ProfileHostEntry(
        address = "device:host-server",
        name = "Server",
        kind = "server",
        addedAt = 1_700_000_000_000L,
    )

    @Test
    fun emptyCanonicalProfileRoundTrips() {
        val blob = emptyCanonicalProfile(now = 123L)
        val bytes = serializeCanonicalProfile(blob)
        val parsed = parseCanonicalProfile(bytes)
        assertEquals(1, parsed.version)
        assertTrue(parsed.hosts.isEmpty())
        assertTrue(parsed.clients.isEmpty())
        assertTrue(parsed.trustedApproverFingerprints.isEmpty())
        assertEquals(123L, parsed.updatedAt)
    }

    @Test
    fun upsertClientDenormalizesTrustedApprovers() {
        var blob = emptyCanonicalProfile(0)
        val (a, _) = makeClient("phone-a", trusted = true)
        val (b, _) = makeClient("phone-b", trusted = false)
        blob = upsertClient(blob, a)
        blob = upsertClient(blob, b)
        assertEquals(2, blob.clients.size)
        assertEquals(listOf(a.identityFingerprint), blob.trustedApproverFingerprints)
        assertTrue(isTrustedApprover(blob, a.identityFingerprint))
        assertFalse(isTrustedApprover(blob, b.identityFingerprint))
    }

    @Test
    fun setTrustedApproverIsIdempotentNoOpReturnsSameInstance() {
        var blob = emptyCanonicalProfile(0)
        val (c, _) = makeClient("phone", trusted = false)
        blob = upsertClient(blob, c)
        val before = blob
        blob = setTrustedApprover(blob, c.identityFingerprint, false, now = 999L)
        assertTrue(blob === before)
    }

    @Test
    fun setTrustedApproverFlipsFlagAndDenormalizedList() {
        var blob = emptyCanonicalProfile(0)
        val (c, _) = makeClient("phone", trusted = false)
        blob = upsertClient(blob, c)
        blob = setTrustedApprover(blob, c.identityFingerprint, true, now = 100L)
        assertEquals(listOf(c.identityFingerprint), blob.trustedApproverFingerprints)
        assertEquals(true, blob.clients[0].trustedApprover)

        blob = setTrustedApprover(blob, c.identityFingerprint, false, now = 200L)
        assertTrue(blob.trustedApproverFingerprints.isEmpty())
        assertEquals(false, blob.clients[0].trustedApprover)
    }

    @Test
    fun removeClientCleansUpDenormalizedList() {
        var blob = emptyCanonicalProfile(0)
        val (c, _) = makeClient("phone", trusted = true)
        blob = upsertClient(blob, c)
        blob = removeClient(blob, c.identityFingerprint)
        assertTrue(blob.clients.isEmpty())
        assertTrue(blob.trustedApproverFingerprints.isEmpty())
    }

    @Test
    fun findClientByFingerprintAndAddress() {
        var blob = emptyCanonicalProfile(0)
        val (c, _) = makeClient("phone")
        blob = upsertClient(blob, c)
        assertEquals(c.address, findClientByFingerprint(blob, c.identityFingerprint)?.address)
        assertEquals(
            c.identityFingerprint,
            findClientByAddress(blob, c.address)?.identityFingerprint,
        )
        assertNull(findClientByFingerprint(blob, "unknown"))
        assertNull(findClientByAddress(blob, "unknown"))
    }

    @Test
    fun parseRejectsMalformedProfile() {
        try {
            parseCanonicalProfile("not json")
            org.junit.Assert.fail("expected throw")
        } catch (_: IllegalArgumentException) {}
        try {
            parseCanonicalProfile("""{"version":2}""")
            org.junit.Assert.fail("expected throw")
        } catch (_: IllegalArgumentException) {}
        try {
            parseCanonicalProfile(
                """{"version":1,"clients":[{"address":"x","name":"x","kind":"m","addedAt":0}]}""",
            )
            org.junit.Assert.fail("expected throw — missing identityPublicKey")
        } catch (_: IllegalArgumentException) {}
        try {
            parseCanonicalProfile(
                """{"version":1,"clients":[{"address":"x","identityPublicKey":"NOTHEX","identityFingerprint":"x","name":"x","kind":"m","addedAt":0}]}""",
            )
            org.junit.Assert.fail("expected throw — bad pubkey hex")
        } catch (_: IllegalArgumentException) {}
    }

    @Test
    fun fullProfileSerializeParsePreservesAllFields() {
        var blob = emptyCanonicalProfile(1L)
        blob = upsertHost(blob, makeHost(), now = 2L)
        val (c, _) = makeClient("phone", trusted = true)
        blob = upsertClient(blob, c, now = 3L)
        blob = blob.copy(signedBy = "aabbccdd")

        val bytes = serializeCanonicalProfile(blob)
        val parsed = parseCanonicalProfile(bytes)
        assertEquals(blob, parsed)
    }

    // ─── Approval signing payload ──────────────────────────────

    @Test
    fun canonicalApprovalSigningBytesIsDeterministic() {
        val a = canonicalApprovalSigningBytes(
            domain = DEFAULT_APPROVAL_DOMAIN,
            requestId = "aabbccddeeff00112233445566778899",
            hostFingerprint = "11111 22222 33333 44444",
            requestingDeviceFingerprint = "55555 66666 77777 88888",
            decision = "approve",
        )
        val b = canonicalApprovalSigningBytes(
            domain = DEFAULT_APPROVAL_DOMAIN,
            requestId = "aabbccddeeff00112233445566778899",
            hostFingerprint = "11111 22222 33333 44444",
            requestingDeviceFingerprint = "55555 66666 77777 88888",
            decision = "approve",
        )
        assertArrayEquals(a, b)
    }

    @Test
    fun differentDecisionProducesDifferentSigningBytes() {
        val approve = canonicalApprovalSigningBytes(
            DEFAULT_APPROVAL_DOMAIN, "r", "h", "d", "approve",
        )
        val reject = canonicalApprovalSigningBytes(
            DEFAULT_APPROVAL_DOMAIN, "r", "h", "d", "reject",
        )
        assertFalse(approve.contentEquals(reject))
    }

    @Test
    fun differentDomainProducesDifferentSigningBytes() {
        val a = canonicalApprovalSigningBytes("shade-link-approve-v1", "r", "h", "d", "approve")
        val b = canonicalApprovalSigningBytes("prism-link-approve-v1", "r", "h", "d", "approve")
        assertFalse(a.contentEquals(b))
    }

    // ─── Build / sign / verify ─────────────────────────────────

    private data class Scenario(
        val phone: ProfileClientEntry,
        val phoneSeed: ByteArray,
        val profile: CanonicalProfileBlob,
        val request: ApprovalRequestFrame,
    )

    private fun buildScenario(): Scenario {
        val (phone, seed) = makeClient("phone", trusted = true)
        var profile = emptyCanonicalProfile(0)
        profile = upsertHost(profile, makeHost())
        profile = upsertClient(profile, phone)

        val request = buildApprovalRequest(
            crypto = crypto,
            hostAddress = "device:host-server",
            hostFingerprint = "host-fp-12345",
            requestingDeviceFingerprint = "cafe-laptop-fp-67890",
            deviceName = "cafe-laptop",
            userAgent = "Mozilla/5.0",
            ipHint = "203.0.113.7",
        )
        return Scenario(phone, seed, profile, request)
    }

    @Test
    fun happyPathApproveVerifies() {
        val s = buildScenario()
        val approval = signProxyApproval(
            crypto, s.request,
            decision = "approve",
            approverFingerprint = s.phone.identityFingerprint,
            approverSigningKey = s.phoneSeed,
        )
        assertEquals("linkApproveByProxy", approval.kind)
        assertEquals(s.request.requestId, approval.requestId)
        assertEquals(128, approval.signature.length)

        val r = verifyProxyApproval(crypto, s.request, approval, s.profile)
        assertTrue(r is VerifyProxyApprovalResult.Ok)
        assertEquals(s.phone.address, (r as VerifyProxyApprovalResult.Ok).approver.address)
    }

    @Test
    fun happyPathRejectVerifies() {
        val s = buildScenario()
        val approval = signProxyApproval(
            crypto, s.request, "reject",
            s.phone.identityFingerprint, s.phoneSeed,
        )
        val r = verifyProxyApproval(crypto, s.request, approval, s.profile)
        assertTrue(r is VerifyProxyApprovalResult.Ok)
    }

    @Test
    fun replayAgainstDifferentRequestFails() {
        val s = buildScenario()
        val approval = signProxyApproval(
            crypto, s.request, "approve",
            s.phone.identityFingerprint, s.phoneSeed,
        )
        val other = s.request.copy(requestId = "f".repeat(32))
        val r = verifyProxyApproval(crypto, other, approval, s.profile)
        assertEquals(
            VerifyProxyApprovalResult.Reason.REQUEST_ID_MISMATCH,
            (r as VerifyProxyApprovalResult.Failed).reason,
        )
    }

    @Test
    fun decisionTamperingFails() {
        val s = buildScenario()
        val approval = signProxyApproval(
            crypto, s.request, "approve",
            s.phone.identityFingerprint, s.phoneSeed,
        )
        val tampered = approval.copy(decision = "reject")
        val r = verifyProxyApproval(crypto, s.request, tampered, s.profile)
        assertEquals(
            VerifyProxyApprovalResult.Reason.BAD_SIGNATURE,
            (r as VerifyProxyApprovalResult.Failed).reason,
        )
    }

    @Test
    fun hostFingerprintSwapFails() {
        val s = buildScenario()
        val approval = signProxyApproval(
            crypto, s.request, "approve",
            s.phone.identityFingerprint, s.phoneSeed,
        )
        val swapped = s.request.copy(hostFingerprint = "evil-host-fp")
        val r = verifyProxyApproval(crypto, swapped, approval, s.profile)
        assertEquals(
            VerifyProxyApprovalResult.Reason.BAD_SIGNATURE,
            (r as VerifyProxyApprovalResult.Failed).reason,
        )
    }

    @Test
    fun domainMismatchIsRejectedBeforeSignature() {
        val s = buildScenario()
        val approval = signProxyApproval(
            crypto, s.request, "approve",
            s.phone.identityFingerprint, s.phoneSeed,
        )
        val r = verifyProxyApproval(
            crypto, s.request, approval.copy(domain = "prism-link-approve-v1"), s.profile,
        )
        assertEquals(
            VerifyProxyApprovalResult.Reason.DOMAIN_MISMATCH,
            (r as VerifyProxyApprovalResult.Failed).reason,
        )
    }

    @Test
    fun unknownApproverFails() {
        val s = buildScenario()
        val approval = signProxyApproval(
            crypto, s.request, "approve",
            s.phone.identityFingerprint, s.phoneSeed,
        )
        val lying = approval.copy(approverFingerprint = "no-such-fingerprint")
        val r = verifyProxyApproval(crypto, s.request, lying, s.profile)
        assertEquals(
            VerifyProxyApprovalResult.Reason.UNKNOWN_APPROVER,
            (r as VerifyProxyApprovalResult.Failed).reason,
        )
    }

    @Test
    fun revokedApproverFailsWithNotTrusted() {
        val s = buildScenario()
        val approval = signProxyApproval(
            crypto, s.request, "approve",
            s.phone.identityFingerprint, s.phoneSeed,
        )
        val revoked = setTrustedApprover(s.profile, s.phone.identityFingerprint, false)
        val r = verifyProxyApproval(crypto, s.request, approval, revoked)
        assertEquals(
            VerifyProxyApprovalResult.Reason.NOT_TRUSTED,
            (r as VerifyProxyApprovalResult.Failed).reason,
        )
    }

    @Test
    fun expiredRequestIsRejected() {
        val s = buildScenario()
        val approval = signProxyApproval(
            crypto, s.request, "approve",
            s.phone.identityFingerprint, s.phoneSeed,
        )
        val r = verifyProxyApproval(crypto, s.request, approval, s.profile, now = s.request.expiresAt + 1)
        assertEquals(
            VerifyProxyApprovalResult.Reason.EXPIRED,
            (r as VerifyProxyApprovalResult.Failed).reason,
        )
    }

    @Test
    fun signatureWithWrongKeyFails() {
        val s = buildScenario()
        val wrongSeed = crypto.randomBytes(32)
        val approval = signProxyApproval(
            crypto, s.request, "approve",
            approverFingerprint = s.phone.identityFingerprint, // claim phone
            approverSigningKey = wrongSeed,                    // sign with different key
        )
        val r = verifyProxyApproval(crypto, s.request, approval, s.profile)
        assertEquals(
            VerifyProxyApprovalResult.Reason.BAD_SIGNATURE,
            (r as VerifyProxyApprovalResult.Failed).reason,
        )
    }

    @Test
    fun customDomainSurvivesRoundTrip() {
        val s = buildScenario()
        val request = s.request.copy(domain = "prism-link-approve-v1")
        val approval = signProxyApproval(
            crypto, request, "approve",
            s.phone.identityFingerprint, s.phoneSeed,
        )
        assertEquals("prism-link-approve-v1", approval.domain)
        val r = verifyProxyApproval(crypto, request, approval, s.profile)
        assertTrue(r is VerifyProxyApprovalResult.Ok)
    }

    @Test
    fun requestIdIs32LowercaseHexChars() {
        val r = buildApprovalRequest(
            crypto, "device:h", "h", "r",
        )
        assertTrue(r.requestId.matches(Regex("^[0-9a-f]{32}$")))
    }

    @Test
    fun consecutiveBuildsProduceDistinctRequestIds() {
        val a = buildApprovalRequest(crypto, "device:h", "h", "r")
        val b = buildApprovalRequest(crypto, "device:h", "h", "r")
        assertNotEquals(a.requestId, b.requestId)
    }

    // ─── Sanity glue: TS-side reference frame parses on Kotlin ──

    @Test
    fun tsStyleProxyApprovalFrameParsesAndStructurallyMatches() {
        // Construct a frame the way @shade/sdk would emit it via JSON,
        // and check our Kotlin types accept the same field names.
        val expected = ProxyApprovalFrame(
            requestId = "00112233445566778899aabbccddeeff",
            decision = "approve",
            approverFingerprint = "fp",
            signature = "ab".repeat(64),
            domain = DEFAULT_APPROVAL_DOMAIN,
        )
        assertEquals("linkApproveByProxy", expected.kind)
        assertEquals("approve", expected.decision)

        val req = ApprovalRequestFrame(
            requestId = "00112233445566778899aabbccddeeff",
            hostAddress = "device:h",
            hostFingerprint = "host-fp",
            requestingDevice = ApprovalRequestingDevice(
                fingerprint = "req-fp",
                receivedAt = 1L,
            ),
            expiresAt = 2L,
            domain = DEFAULT_APPROVAL_DOMAIN,
        )
        assertNotNull(req)
    }
}
