package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import wang.yaojia.webterm.clienttls.CertificateSummary
import wang.yaojia.webterm.clienttls.NoClientIdentityException
import wang.yaojia.webterm.clienttls.Pkcs12DecodeException
import wang.yaojia.webterm.tlsandroid.IdentityRepository
import java.security.UnrecoverableKeyException
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import kotlin.coroutines.CoroutineContext

/**
 * # ClientCertViewModel (A27) — the device-certificate (mTLS) management presenter.
 *
 * Drives the "设备证书" screen reached from the session-list host menu (A20): import a `.p12`, rotate
 * it, remove it, and show the installed identity's summary (subject-CN / issuer-CN / expiry, with an
 * **EXPIRED** warning). Ports the iOS `ClientCertViewModel` over the shared [IdentityRepository]
 * (`:client-tls-android`) — the ONE key home; this presenter never touches AndroidKeyStore/Tink
 * directly, it only funnels bytes+passphrase into the repository and renders the returned
 * [CertificateSummary].
 *
 * ### The security invariant this screen must not break (plan §8 "Cert storage")
 * A `.p12` import **validates before persisting**, so a bad passphrase can NEVER clobber a
 * previously-installed identity — the repository throws before mutating either store, the prior cert
 * stays live, and this VM merely surfaces the error and leaves [ClientCertUiState.summary] untouched
 * (the on-screen cert is unchanged). Rotate and remove additionally evict pooled connections inside
 * the repository so nothing reuses the old identity; that is the repository's concern, not this VM's.
 *
 * ### Secrets discipline (plan §8)
 * The passphrase and the `.p12` bytes are passed straight to the repository and are **never logged,
 * never persisted, and never placed in fallback/error copy** — errors are a coarse [CertError] enum
 * the screen maps to app-authored, inert copy. No server/cert string is ever rendered as anything but
 * inert [androidx.compose.material3.Text].
 *
 * ### A plain presenter (JVM-testable, mirrors [PairingViewModel] / [DiffViewModel])
 * Not an `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no
 * `Dispatchers.Main` / Robolectric. The screen calls [bind] with a lifecycle scope (which kicks the
 * initial summary load) and then the import/rotate/remove actions. Because the repository's reads and
 * mutations do blocking AndroidKeyStore/Tink I/O, every touch hops to [io] (`Dispatchers.IO` in
 * production) so nothing blocks the UI thread; the SAF file read itself happens in the Compose shell.
 *
 * @param repository the shared mTLS device identity ([IdentityRepository]); a fake in tests.
 * @param io the dispatcher the blocking keystore reads/mutations hop to (off `Main`); overridable in tests.
 * @param zone the zone the not-after date is formatted in (default device zone; fixed in tests).
 * @param now the clock used for the EXPIRED check (default wall clock; fixed in tests).
 */
public class ClientCertViewModel(
    private val repository: IdentityRepository,
    private val io: CoroutineContext = Dispatchers.IO,
    private val zone: ZoneId = ZoneId.systemDefault(),
    private val now: () -> Instant = Instant::now,
) {
    private val _uiState = MutableStateFlow(ClientCertUiState())

    /** The single snapshot the cert screen renders from. */
    public val uiState: StateFlow<ClientCertUiState> = _uiState.asStateFlow()

    private var scope: CoroutineScope? = null

    // A single in-flight op handle. The initial read may be superseded by a mutation; a mutation is
    // never cancelled by another (the `BUSY` guard blocks a concurrent one), so a commit can't be
    // interrupted mid-flight by this VM.
    private var job: Job? = null

    /** Bind the scope actions launch into (the screen passes a lifecycle scope) and load the summary. */
    public fun bind(scope: CoroutineScope) {
        this.scope = scope
        load()
    }

    private fun load() {
        val scope = scope ?: return
        job?.cancel()
        _uiState.value = _uiState.value.copy(phase = CertPhase.LOADING)
        job = scope.launch {
            // currentSummary() is designed to degrade to null on a storage fault (never throw), but
            // guard anyway so a read fault becomes "no cert shown" rather than a crash.
            val summary = runCatching { withContext(io) { repository.currentSummary() } }.getOrNull()
            _uiState.value = _uiState.value.copy(phase = CertPhase.READY, summary = summary?.toView())
        }
    }

    /** Import a `.p12` as the device identity (no cert currently installed). Validates before persisting. */
    public fun import(p12Bytes: ByteArray, passphrase: String) {
        install(p12Bytes, passphrase, rotate = false)
    }

    /** Replace the installed identity with a new `.p12` (rotation). The prior cert stays live on failure. */
    public fun rotate(p12Bytes: ByteArray, passphrase: String) {
        install(p12Bytes, passphrase, rotate = true)
    }

    private fun install(p12Bytes: ByteArray, passphrase: String, rotate: Boolean) {
        val scope = scope ?: return
        if (_uiState.value.phase == CertPhase.BUSY) return // ignore a double-tap while an op is in flight
        job?.cancel() // supersede only the (safe-to-drop) initial read; never an in-flight mutation
        _uiState.value = _uiState.value.copy(phase = CertPhase.BUSY, error = null)
        job = scope.launch {
            val outcome = try {
                val summary = withContext(io) {
                    if (rotate) repository.rotate(p12Bytes, passphrase)
                    else repository.importIdentity(p12Bytes, passphrase)
                }
                Result.success(summary)
            } catch (cancel: CancellationException) {
                throw cancel
            } catch (error: Throwable) {
                Result.failure(error)
            }
            _uiState.value = outcome.fold(
                // Success: the returned summary is the newly-committed identity.
                onSuccess = { s -> _uiState.value.copy(phase = CertPhase.READY, summary = s.toView(), error = null) },
                // Failure: validate-before-persist means the PRIOR identity is untouched — leave
                // `summary` exactly as it was and only surface the classified error.
                onFailure = { e -> _uiState.value.copy(phase = CertPhase.READY, error = classify(e)) },
            )
        }
    }

    /** Ask to remove the installed identity — raises the confirm dialog (destructive, so gated). */
    public fun requestRemove() {
        if (_uiState.value.summary == null) return // nothing to remove
        _uiState.value = _uiState.value.copy(confirmingRemove = true)
    }

    /** Dismiss the remove-confirm dialog without removing anything. */
    public fun cancelRemove() {
        _uiState.value = _uiState.value.copy(confirmingRemove = false)
    }

    /** Confirm removal: clear the device identity (idempotent in the repository). */
    public fun confirmRemove() {
        val scope = scope ?: return
        _uiState.value = _uiState.value.copy(confirmingRemove = false, phase = CertPhase.BUSY, error = null)
        job?.cancel()
        job = scope.launch {
            val outcome = try {
                withContext(io) { repository.remove() }
                Result.success(Unit)
            } catch (cancel: CancellationException) {
                throw cancel
            } catch (error: Throwable) {
                Result.failure(error)
            }
            _uiState.value = outcome.fold(
                onSuccess = { _uiState.value.copy(phase = CertPhase.READY, summary = null) },
                // A remove fault keeps whatever summary was showing (the identity may still be live).
                onFailure = { _uiState.value.copy(phase = CertPhase.READY, error = CertError.REMOVE_FAILED) },
            )
        }
    }

    /** Dismiss the current error banner. */
    public fun clearError() {
        _uiState.value = _uiState.value.copy(error = null)
    }

    private fun CertificateSummary.toView(): CertSummaryView = toSummaryView(now(), zone)
}

/** Map an import/rotation throwable to the coarse, non-secret [CertError] the screen renders inertly. */
internal fun classify(error: Throwable): CertError = when (error) {
    is UnrecoverableKeyException -> CertError.BAD_PASSPHRASE
    is Pkcs12DecodeException -> CertError.INVALID_FILE
    is NoClientIdentityException -> CertError.NO_IDENTITY
    else -> CertError.IMPORT_FAILED
}

/** The load/action phase the screen renders (spinner vs. content vs. an in-flight import/rotate/remove). */
public enum class CertPhase {
    /** The initial summary read is in flight. */
    LOADING,

    /** Idle — [ClientCertUiState.summary] is either an installed cert or `null` (none installed). */
    READY,

    /** An import / rotate / remove is committing. */
    BUSY,
}

/** The coarse, non-secret failure taxonomy — the screen maps each to app-authored inert copy (§8). */
public enum class CertError {
    /** Wrong passphrase (the `.p12` MAC/integrity check failed) — the prior identity is untouched. */
    BAD_PASSPHRASE,

    /** The file is not a decodable PKCS#12 blob (truncated / not a `.p12` / unsupported). */
    INVALID_FILE,

    /** Decoded, but the `.p12` carried no importable private-key entry (certs-only / truststore). */
    NO_IDENTITY,

    /** Any other import/rotation failure (keystore/storage fault). */
    IMPORT_FAILED,

    /** Removal failed — the identity may still be installed. */
    REMOVE_FAILED,
}

/** The immutable snapshot the device-cert screen renders. */
public data class ClientCertUiState(
    /** The installed device identity's display summary, or `null` when none is installed. */
    val summary: CertSummaryView? = null,
    /** The load/action phase. */
    val phase: CertPhase = CertPhase.LOADING,
    /** The last import/rotate/remove failure, or `null`. Surfaced as inert, app-authored copy. */
    val error: CertError? = null,
    /** Whether the destructive remove-confirm dialog is up. */
    val confirmingRemove: Boolean = false,
)

/**
 * The rendered device-certificate summary — the presentation view of a [CertificateSummary]. Absent
 * fields degrade to [UNKNOWN] (display-only; the TLS stack, not this label, is the real gate) and the
 * not-after instant is pre-formatted to a local date. [isExpired] drives the on-screen warning.
 */
public data class CertSummaryView(
    val subjectCommonName: String,
    val issuerCommonName: String,
    val expiry: String,
    val isExpired: Boolean,
) {
    public companion object {
        /** Shown for a CN/expiry the certificate did not carry (or that failed to parse). */
        public const val UNKNOWN: String = "未知"
    }
}

// Medium date style (e.g. locale-formatted "2027年1月8日" / "Jan 8, 2027") — date only; time is noise here.
private val EXPIRY_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)

/**
 * Pure presentation of a [CertificateSummary] (independently unit-tested): CN fields fall back to
 * [CertSummaryView.UNKNOWN], the not-after instant is formatted in [zone], and [now] decides the
 * EXPIRED flag (delegated to [CertificateSummary.isExpired] — unknown expiry is treated as not
 * expired, matching iOS).
 */
public fun CertificateSummary.toSummaryView(now: Instant, zone: ZoneId): CertSummaryView =
    CertSummaryView(
        subjectCommonName = subjectCommonName ?: CertSummaryView.UNKNOWN,
        issuerCommonName = issuerCommonName ?: CertSummaryView.UNKNOWN,
        expiry = notAfter?.let { EXPIRY_FORMATTER.withZone(zone).format(it) } ?: CertSummaryView.UNKNOWN,
        isExpired = isExpired(now),
    )
