package wang.yaojia.webterm.tlsandroid

import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentClient
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.testsupport.FakeHttpTransport
import wang.yaojia.webterm.wire.HttpMethod
import java.security.KeyPairGenerator
import java.security.interfaces.ECPublicKey
import java.security.spec.ECGenParameterSpec

/**
 * B4 · JVM unit coverage for the [DeviceEnroller] enroll/renew ORCHESTRATION — the layer that runs
 * the security-critical two-store commit. Driven with a software P-256 key ([DeviceKeyProvider]
 * double) + the shared [FakeHttpTransport], so request shaping, error handling, and — most
 * importantly — the commit SEQUENCING run without an emulator or a real AndroidKeyStore.
 *
 * The security-critical invariant under test: the enrollment record is persisted BEFORE the cert
 * live-pointer flip (the mTLS commit), so a successful cert-store save always means the identity is
 * fully live (see [DeviceEnroller.commitIdentity]).
 */
class DeviceEnrollerTest {
    private companion object {
        const val BASE = "https://cp.terminal.yaojia.wang"
        const val ALIAS = "test-device-key"

        // Real self-signed P-256 X.509 certs (base64 DER) so commitIdentity's CertificateFactory /
        // CertificateSummaryReader parse them exactly as they parse a server-issued leaf.
        const val LEAF_CN = "t1-device"
        const val CA_CN = "webterm-device-ca"
        const val LEAF_B64 =
            "MIIBfzCCASWgAwIBAgIUH+MotJdtckTE7470KQz73GPZa+IwCgYIKoZIzj0EAwIwFDESMBAGA1UEAwwJdDEt" +
                "ZGV2aWNlMCAXDTI2MDcxODExMjExMVoYDzIxMjYwNjI0MTEyMTExWjAUMRIwEAYDVQQDDAl0MS1kZXZpY2Uw" +
                "WTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQLKEwBsNSMTDfKsdr0qtKUtZCcglWICSMJYRowgIN546ctWw+h" +
                "cXXeZ7ru9F198rt3k2Z4Wesf0n3tUm9jdn/Oo1MwUTAdBgNVHQ4EFgQU1+o809OaRKV3p/P5dhY5yAdOrr0w" +
                "HwYDVR0jBBgwFoAU1+o809OaRKV3p/P5dhY5yAdOrr0wDwYDVR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNI" +
                "ADBFAiEAotIxEXaCEp2rtEG6KLOtmJYS6Jc/JaJFERGRH4Q/qsMCIB4Rkb06AB7pQUsAHLj81BXcYEd04GY" +
                "cdoleWDlqcMKU"
        const val CA_B64 =
            "MIIBjzCCATWgAwIBAgIUXGwe1gOYBewwVZQoVj1IgiirwnUwCgYIKoZIzj0EAwIwHDEaMBgGA1UEAwwRd2Vi" +
                "dGVybS1kZXZpY2UtY2EwIBcNMjYwNzE4MTEyMTExWhgPMjEyNjA2MjQxMTIxMTFaMBwxGjAYBgNVBAMMEXdl" +
                "YnRlcm0tZGV2aWNlLWNhMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEkwVx9McuEN+rTZwYfsYl8YPhpyWt" +
                "e8PT06OpifVsIdCyDH3bPoENOsPJf8mjRqkgoLSHgetuUf2T2Ot28qRiuaNTMFEwHQYDVR0OBBYEFGkPHz9w" +
                "4FVyZRgo8g1PO8F/v6ggMB8GA1UdIwQYMBaAFGkPHz9w4FVyZRgo8g1PO8F/v6ggMA8GA1UdEwEB/wQFMAMB" +
                "Af8wCgYIKoZIzj0EAwIDSAAwRQIhAJlUm4M4K2fHMOtip2Hs5LxvS0T7RJwUbflz5wHGQiyJAiAHXp1oNUkQ" +
                "YloHuEAg+kngzA33m52aWtublai4L+eybg=="

        fun loginBody(): ByteArray =
            """{"enrollToken":"tok-xyz","accountId":"acct-1","expiresIn":600}""".toByteArray()

        fun enrollBody(deviceId: String = "dev-1"): ByteArray =
            """
            {"deviceId":"$deviceId","cert":"$LEAF_B64","caChain":["$CA_B64"],
             "notBefore":"2026-07-08T00:00:00.000Z","notAfter":"2026-10-06T00:00:00.000Z",
             "renewAfter":"2026-09-05T00:00:00.000Z"}
            """.trimIndent().toByteArray()

        fun softwareKey(alias: String): HardwareBackedKey {
            val kpg = KeyPairGenerator.getInstance("EC")
            kpg.initialize(ECGenParameterSpec("secp256r1"))
            val kp = kpg.generateKeyPair()
            return HardwareBackedKey(alias, kp.private, kp.public as ECPublicKey)
        }
    }

    private val events = mutableListOf<String>()
    private val transport = FakeHttpTransport()
    private val certStore = RecordingCertStore(events)
    private val recordStore = RecordingRecordStore(events)
    private val keyProvider = RecordingKeyProvider(events)
    private val refresher = RecordingRefresher(events)

    private fun enroller(): DeviceEnroller =
        DeviceEnroller(
            client = DeviceEnrollmentClient(BASE, transport),
            certStore = certStore,
            recordStore = recordStore,
            sharedClient = OkHttpClient(),
            keyAlias = ALIAS,
            keyProvider = keyProvider,
            cacheRefresher = refresher,
        )

    // ── enroll: commit sequencing (the security-critical invariant) ────────────────────────────

    @Test
    fun enrollPersistsTheRecordBeforeTheCertLivePointerFlip() = runTest {
        transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
        transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())

        val summary = enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")

        // Record.save strictly precedes cert.save — the mTLS pointer flip is written LAST.
        assertTrue(events.contains("record.save") && events.contains("cert.save"))
        assertTrue(
            events.indexOf("record.save") < events.indexOf("cert.save"),
            "the enrollment record must be committed BEFORE the cert live-pointer flip",
        )
        // Both stores received the issued identity; the stored chain is leaf + issuer (from caChain).
        assertEquals("dev-1", recordStore.saved!!.deviceId)
        assertEquals(ALIAS, recordStore.saved!!.keyStoreAlias)
        val chain = certStore.saved!!.certificateChain
        assertEquals(2, chain.size, "stored chain = leaf + one caChain issuer")
        assertTrue(chain[0].subjectX500Principal.name.contains(LEAF_CN), "chain[0] is the leaf")
        assertTrue(chain[1].subjectX500Principal.name.contains(CA_CN), "chain[1] is the device-CA issuer")
        // The install summary is read off the leaf via the production CertificateSummaryReader.
        assertEquals(LEAF_CN, summary.subjectCommonName)
    }

    @Test
    fun enrollRefreshesTheIdentityCacheAfterTheCommit() = runTest {
        transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
        transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())

        enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")

        // FIX 3: the in-memory identity cache is refreshed AFTER the durable cert live-pointer flip, so
        // the newly enrolled leaf is presented on the next handshake with no restart.
        assertTrue(events.contains("cache.refresh"), "the identity cache must be refreshed on enroll")
        assertTrue(
            events.indexOf("cert.save") < events.indexOf("cache.refresh"),
            "the cache refresh must run AFTER the cert live-pointer commit (never publish an un-committed leaf)",
        )
    }

    @Test
    fun enrollShapesTheLoginAndEnrollRequests() = runTest {
        transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
        transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 201, body = enrollBody())

        enroller().enroll(password = "hunter2", subdomain = "alice", deviceName = "Alice Pixel")

        val login = transport.recordedRequests[0]
        assertEquals("$BASE/auth/login", login.url)
        assertTrue(login.body!!.decodeToString().contains("\"password\":\"hunter2\""))

        val enroll = transport.recordedRequests[1]
        assertEquals("$BASE/device/enroll", enroll.url)
        assertEquals("Bearer tok-xyz", enroll.headers["Authorization"], "enroll rides the login bearer")
        val enrollBodyStr = enroll.body!!.decodeToString()
        assertTrue(enrollBodyStr.contains("\"subdomain\":\"alice\""))
        assertTrue(enrollBodyStr.contains("\"deviceName\":\"Alice Pixel\""))
    }

    // ── enroll: error handling ─────────────────────────────────────────────────────────────────

    @Test
    fun enrollDropsTheOrphanKeyWhenEnrollFailsAfterKeygen() = runTest {
        transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 201, body = loginBody())
        transport.queueSuccess(HttpMethod.POST, "$BASE/device/enroll", 403, body = """{"error":"rejected"}""".toByteArray())

        val error = runCatching {
            enroller().enroll(password = "hunter2", subdomain = "bob", deviceName = "Bob Pixel")
        }.exceptionOrNull()

        assertEquals(DeviceEnrollmentError.Http(403, "rejected"), error)
        assertEquals(listOf(ALIAS), keyProvider.generatedAliases, "the key was generated after login")
        assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the orphan key is dropped on enroll failure")
        assertNull(recordStore.saved, "no record is committed when enroll fails")
        assertNull(certStore.saved, "the cert live-pointer is never flipped when enroll fails")
    }

    @Test
    fun enrollNeverBurnsAKeyWhenLoginIsRejected() = runTest {
        transport.queueSuccess(HttpMethod.POST, "$BASE/auth/login", 401, body = """{"error":"rejected"}""".toByteArray())

        val error = runCatching {
            enroller().enroll(password = "wrong", subdomain = "alice", deviceName = "Alice Pixel")
        }.exceptionOrNull()

        assertEquals(DeviceEnrollmentError.Http(401, "rejected"), error)
        assertTrue(keyProvider.generatedAliases.isEmpty(), "a rejected credential must not burn a key slot")
        assertNull(recordStore.saved)
        assertNull(certStore.saved)
    }

    // ── renew: preconditions + request shaping + commit ────────────────────────────────────────

    @Test
    fun renewThrowsWhenNothingIsEnrolled() = runTest {
        val error = runCatching { enroller().renew() }.exceptionOrNull()
        assertTrue(error is DeviceEnroller.EnrollmentStateException)
        assertTrue(transport.recordedRequests.isEmpty(), "no network I/O when there is nothing to renew")
    }

    @Test
    fun renewThrowsWhenTheDeviceKeyIsMissing() = runTest {
        recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
        // keyProvider has no key at ALIAS → load() returns null.
        val error = runCatching { enroller().renew() }.exceptionOrNull()
        assertTrue(error is DeviceEnroller.EnrollmentStateException)
        assertTrue(transport.recordedRequests.isEmpty(), "no renew call when the hardware key is gone")
    }

    @Test
    fun renewReCsrsFromTheSameKeyOverMtlsWithNoBearerAndACsrOnlyBody() = runTest {
        recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
        keyProvider.seed(ALIAS, softwareKey(ALIAS))
        transport.queueSuccess(HttpMethod.POST, "$BASE/device/dev-1/renew", 201, body = enrollBody())

        // FIX 2: production renew passes NO bearer — the endpoint authenticates by the current cert (mTLS).
        enroller().renew()

        val renew = transport.recordedRequests.single()
        assertEquals("$BASE/device/dev-1/renew", renew.url)
        assertNull(renew.headers["Authorization"], "renew authenticates by mTLS — it must send NO Authorization header")
        val body = renew.body!!.decodeToString()
        // The renew body is {csr}-only — the server's .strict() schema rejects any enroll-only extra.
        assertTrue(body.contains("\"csr\":"), "renew sends the fresh CSR")
        assertFalse(body.contains("keyAlg"), "renew must not send the enroll-only keyAlg")
        assertFalse(body.contains("subdomain"), "renew must not send subdomain")
        assertFalse(body.contains("deviceName"), "renew must not send deviceName")
        // Same commit sequencing on the rotation path: record before cert, then cache refresh last.
        assertTrue(events.indexOf("record.save") < events.indexOf("cert.save"))
        assertTrue(events.indexOf("cert.save") < events.indexOf("cache.refresh"), "cache refresh runs after the renew commit")
    }

    // ── remove: full teardown ──────────────────────────────────────────────────────────────────

    @Test
    fun removeClearsBothStoresAndDeletesTheKey() = runTest {
        recordStore.seed(EnrollmentRecord("dev-1", "Alice Pixel", ALIAS, renewAfterEpochSeconds = 0L))
        keyProvider.seed(ALIAS, softwareKey(ALIAS))

        enroller().remove()

        assertTrue(certStore.cleared)
        assertTrue(recordStore.cleared)
        assertEquals(listOf(ALIAS), keyProvider.deletedAliases, "the hardware key is deleted on remove")
    }

    // ── recording doubles ──────────────────────────────────────────────────────────────────────

    private class RecordingCertStore(private val events: MutableList<String>) : CertStore {
        var saved: StoredIdentityMetadata? = null
        var cleared = false

        override fun save(metadata: StoredIdentityMetadata) {
            saved = metadata
            events += "cert.save"
        }

        override fun load(): StoredIdentityMetadata? = saved

        override fun clear() {
            cleared = true
            saved = null
            events += "cert.clear"
        }
    }

    private class RecordingRecordStore(private val events: MutableList<String>) : EnrollmentRecordStore {
        var saved: EnrollmentRecord? = null
        var cleared = false
        private var current: EnrollmentRecord? = null

        fun seed(record: EnrollmentRecord) {
            current = record
        }

        override fun save(record: EnrollmentRecord) {
            saved = record
            current = record
            events += "record.save"
        }

        override fun load(): EnrollmentRecord? = current

        override fun clear() {
            cleared = true
            current = null
            events += "record.clear"
        }
    }

    private class RecordingRefresher(private val events: MutableList<String>) : IdentityCacheRefresher {
        override fun refreshFromStore() {
            events += "cache.refresh"
        }
    }

    private class RecordingKeyProvider(private val events: MutableList<String>) : DeviceKeyProvider {
        private val keys = mutableMapOf<String, HardwareBackedKey>()
        val generatedAliases = mutableListOf<String>()
        val deletedAliases = mutableListOf<String>()

        fun seed(alias: String, key: HardwareBackedKey) {
            keys[alias] = key
        }

        override fun generate(alias: String): HardwareBackedKey {
            val key = softwareKey(alias)
            keys[alias] = key
            generatedAliases += alias
            events += "generate:$alias"
            return key
        }

        override fun load(alias: String): HardwareBackedKey? = keys[alias]

        override fun delete(alias: String) {
            keys.remove(alias)
            deletedAliases += alias
            events += "delete:$alias"
        }
    }
}
