package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
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.assertSame
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.clienttls.CertificateSummary
import wang.yaojia.webterm.clienttls.NoClientIdentityException
import wang.yaojia.webterm.clienttls.Pkcs12DecodeException
import wang.yaojia.webterm.tlsandroid.ClientSslMaterial
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import java.security.UnrecoverableKeyException
import java.time.Instant
import java.time.ZoneId
import kotlin.coroutines.EmptyCoroutineContext

/**
 * [ClientCertViewModel] / [toSummaryView] / [classify] (A27) — the JVM-tested device-cert core.
 *
 * Covers the presentation of an installed [CertificateSummary] (subject/issuer/expiry formatting +
 * the EXPIRED warning flag), the security-load-bearing "a bad passphrase can't clobber the prior
 * identity" rule (import validates before persisting → error surfaced, on-screen cert unchanged), and
 * the rotate/remove state transitions. The SAF picker / AndroidKeyStore / Compose shell is device-QA
 * (plan §7); THIS is the pure core. `runTest` + [UnconfinedTestDispatcher] so a launched load/mutation
 * runs inline; [EmptyCoroutineContext] as the "io" hop keeps everything in virtual time.
 */
@OptIn(ExperimentalCoroutinesApi::class)
class ClientCertViewModelTest {

    /** A canned [IdentityRepository]: `installed` is the live summary; imports validate-before-persist. */
    private class FakeIdentityRepository(initial: CertificateSummary? = null) : IdentityRepository {
        var installed: CertificateSummary? = initial

        /** What the next import/rotation yields; throwing simulates a rejected passphrase / bad file. */
        var nextResult: () -> CertificateSummary = { error("no import configured") }
        val passphrasesSeen = mutableListOf<String>()
        var removeCount = 0

        override fun sslMaterial(): ClientSslMaterial = error("sslMaterial is not exercised in unit tests")
        override fun currentSummary(): CertificateSummary? = installed
        override fun hasInstalledIdentity(): Boolean = installed != null

        override suspend fun importIdentity(p12Bytes: ByteArray, passphrase: String): CertificateSummary {
            // Validate-before-persist: nextResult() may throw BEFORE `installed` is touched.
            val result = nextResult()
            installed = result
            passphrasesSeen += passphrase
            return result
        }

        override suspend fun rotate(p12Bytes: ByteArray, passphrase: String): CertificateSummary =
            importIdentity(p12Bytes, passphrase)

        override suspend fun remove() {
            installed = null
            removeCount++
        }
    }

    private val notExpiredNow = Instant.parse("2026-01-01T00:00:00Z")
    private val utc = ZoneId.of("UTC")

    private fun summary(
        subject: String? = "t1-android",
        issuer: String? = "webterm-device-ca",
        notAfter: Instant? = Instant.parse("2027-06-15T12:00:00Z"),
    ) = CertificateSummary(subjectCommonName = subject, issuerCommonName = issuer, notAfter = notAfter)

    private fun newVm(repo: IdentityRepository) = ClientCertViewModel(
        repository = repo,
        io = EmptyCoroutineContext,
        zone = utc,
        now = { notExpiredNow },
    )

    private val bytes = byteArrayOf(1, 2, 3)

    // ── Pure summary presentation ─────────────────────────────────────────────────────────────────────

    @Test
    fun `a valid summary formats subject, issuer and expiry and is not expired`() {
        val view = summary().toSummaryView(now = notExpiredNow, zone = utc)

        assertEquals("t1-android", view.subjectCommonName)
        assertEquals("webterm-device-ca", view.issuerCommonName)
        assertTrue(view.expiry.contains("2027"), "expiry should be a formatted date carrying the year")
        assertFalse(view.isExpired)
    }

    @Test
    fun `a not-after in the past flags the summary as expired`() {
        val view = summary(notAfter = Instant.parse("2020-01-01T00:00:00Z")).toSummaryView(notExpiredNow, utc)
        assertTrue(view.isExpired, "a past not-after must raise the expired warning")
    }

    @Test
    fun `absent CN or expiry degrade to UNKNOWN and unknown expiry is not expired`() {
        val view = summary(subject = null, issuer = null, notAfter = null).toSummaryView(notExpiredNow, utc)

        assertEquals(CertSummaryView.UNKNOWN, view.subjectCommonName)
        assertEquals(CertSummaryView.UNKNOWN, view.issuerCommonName)
        assertEquals(CertSummaryView.UNKNOWN, view.expiry)
        assertFalse(view.isExpired, "unknown expiry is treated as not expired (display-only, fail-open)")
    }

    // ── Load ────────────────────────────────────────────────────────────────────────────────────────

    @Test
    fun `bind loads the installed identity summary`() = runTest(UnconfinedTestDispatcher()) {
        val repo = FakeIdentityRepository(initial = summary())
        val vm = newVm(repo)

        vm.bind(backgroundScope)

        val state = vm.uiState.value
        assertEquals(CertPhase.READY, state.phase)
        assertEquals("t1-android", state.summary?.subjectCommonName)
        assertEquals("webterm-device-ca", state.summary?.issuerCommonName)
    }

    @Test
    fun `bind with no installed identity shows an empty summary`() = runTest(UnconfinedTestDispatcher()) {
        val repo = FakeIdentityRepository(initial = null)
        val vm = newVm(repo)

        vm.bind(backgroundScope)

        assertEquals(CertPhase.READY, vm.uiState.value.phase)
        assertNull(vm.uiState.value.summary)
    }

    // ── Import ────────────────────────────────────────────────────────────────────────────────────────

    @Test
    fun `importing a p12 installs it and shows its summary`() = runTest(UnconfinedTestDispatcher()) {
        val repo = FakeIdentityRepository(initial = null)
        repo.nextResult = { summary(subject = "new-device") }
        val vm = newVm(repo)
        vm.bind(backgroundScope)

        vm.import(bytes, "correct-passphrase")

        val state = vm.uiState.value
        assertEquals(CertPhase.READY, state.phase)
        assertEquals("new-device", state.summary?.subjectCommonName)
        assertNull(state.error)
        assertEquals(listOf("correct-passphrase"), repo.passphrasesSeen)
    }

    @Test
    fun `a bad-passphrase import surfaces the error and keeps the prior identity`() =
        runTest(UnconfinedTestDispatcher()) {
            val prior = summary(subject = "prior-device")
            val repo = FakeIdentityRepository(initial = prior)
            repo.nextResult = { throw UnrecoverableKeyException("MAC check failed") }
            val vm = newVm(repo)
            vm.bind(backgroundScope)

            vm.import(bytes, "wrong-passphrase")

            val state = vm.uiState.value
            assertEquals(CertError.BAD_PASSPHRASE, state.error, "a rejected passphrase must be surfaced")
            // The on-screen cert is UNCHANGED — validate-before-persist means the prior cert stays live.
            assertEquals("prior-device", state.summary?.subjectCommonName)
            assertSame(prior, repo.installed, "the repository's live identity must be untouched")
            assertTrue(repo.passphrasesSeen.isEmpty(), "a rejected import never reaches the persist step")
        }

    @Test
    fun `clearError dismisses a surfaced error`() = runTest(UnconfinedTestDispatcher()) {
        val repo = FakeIdentityRepository(initial = summary())
        repo.nextResult = { throw Pkcs12DecodeException("corrupt") }
        val vm = newVm(repo)
        vm.bind(backgroundScope)
        vm.import(bytes, "pw")
        assertEquals(CertError.INVALID_FILE, vm.uiState.value.error)

        vm.clearError()

        assertNull(vm.uiState.value.error)
    }

    // ── Rotate ────────────────────────────────────────────────────────────────────────────────────────

    @Test
    fun `rotating replaces the installed identity with the new summary`() =
        runTest(UnconfinedTestDispatcher()) {
            val repo = FakeIdentityRepository(initial = summary(subject = "old-device"))
            repo.nextResult = { summary(subject = "rotated-device") }
            val vm = newVm(repo)
            vm.bind(backgroundScope)

            vm.rotate(bytes, "pw")

            assertEquals("rotated-device", vm.uiState.value.summary?.subjectCommonName)
            assertNull(vm.uiState.value.error)
        }

    @Test
    fun `a failed rotation keeps the prior identity on screen`() = runTest(UnconfinedTestDispatcher()) {
        val prior = summary(subject = "old-device")
        val repo = FakeIdentityRepository(initial = prior)
        repo.nextResult = { throw UnrecoverableKeyException("bad") }
        val vm = newVm(repo)
        vm.bind(backgroundScope)

        vm.rotate(bytes, "pw")

        assertEquals("old-device", vm.uiState.value.summary?.subjectCommonName)
        assertEquals(CertError.BAD_PASSPHRASE, vm.uiState.value.error)
    }

    // ── Remove (confirm-gated) ──────────────────────────────────────────────────────────────────────

    @Test
    fun `requestRemove then confirm clears the identity`() = runTest(UnconfinedTestDispatcher()) {
        val repo = FakeIdentityRepository(initial = summary())
        val vm = newVm(repo)
        vm.bind(backgroundScope)

        vm.requestRemove()
        assertTrue(vm.uiState.value.confirmingRemove, "requestRemove raises the confirm dialog")

        vm.confirmRemove()

        val state = vm.uiState.value
        assertFalse(state.confirmingRemove)
        assertNull(state.summary, "the identity is gone after a confirmed remove")
        assertEquals(1, repo.removeCount)
        assertNull(repo.installed)
    }

    @Test
    fun `cancelRemove dismisses the dialog without removing`() = runTest(UnconfinedTestDispatcher()) {
        val repo = FakeIdentityRepository(initial = summary())
        val vm = newVm(repo)
        vm.bind(backgroundScope)

        vm.requestRemove()
        vm.cancelRemove()

        assertFalse(vm.uiState.value.confirmingRemove)
        assertEquals(0, repo.removeCount, "cancelling must not remove the identity")
        assertEquals("t1-android", vm.uiState.value.summary?.subjectCommonName)
    }

    @Test
    fun `requestRemove is a no-op when nothing is installed`() = runTest(UnconfinedTestDispatcher()) {
        val repo = FakeIdentityRepository(initial = null)
        val vm = newVm(repo)
        vm.bind(backgroundScope)

        vm.requestRemove()

        assertFalse(vm.uiState.value.confirmingRemove)
    }

    // ── Error classification (pure) ──────────────────────────────────────────────────────────────────

    @Test
    fun `error classification maps each throwable to its coarse CertError`() {
        assertEquals(CertError.BAD_PASSPHRASE, classify(UnrecoverableKeyException("x")))
        assertEquals(CertError.INVALID_FILE, classify(Pkcs12DecodeException("x")))
        assertEquals(CertError.NO_IDENTITY, classify(NoClientIdentityException("x")))
        assertEquals(CertError.IMPORT_FAILED, classify(IllegalStateException("x")))
    }
}
