package wang.yaojia.webterm.viewmodels

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import wang.yaojia.webterm.api.enroll.DeviceEnrollmentError
import wang.yaojia.webterm.clienttls.CertificateSummary
import java.net.URI
import java.security.GeneralSecurityException
import java.time.Instant
import java.time.ZoneId

/**
 * # EnrollmentViewModel (B4) — the zero-`.p12` device-enrollment presenter.
 *
 * The Android port of iOS `EnrollmentViewModel` (`EnrollmentScreen.swift`). Drives the "自动获取证书"
 * screen reached from the session-list host menu: one operator login (password) → a short-lived
 * `device:enroll` bearer → a NON-EXPORTABLE hardware key + PKCS#10 CSR → `POST /device/enroll` → the
 * returned leaf is committed to the shared cert store and presented AUTOMATICALLY on the existing mTLS
 * path (no manual `.p12` import). Cache freshness (FIX 3) means the enrolled cert is live without a restart.
 *
 * A **plain presenter** (mirrors [ClientCertViewModel] / [PairingViewModel]), NOT an
 * `androidx.lifecycle.ViewModel`, so it runs under `runTest` virtual time with no `Dispatchers.Main`. The
 * enroll flow and the installed-summary read are injected as suspend closures ([enrollOperation] /
 * [loadSummary]) so the VM is unit-testable without a keystore or network; production wires them (in the
 * enrollment pane) to [wang.yaojia.webterm.wiring.EnrollmentFlowFactory] over a `Dispatchers.IO` hop.
 *
 * ### Secrets discipline (plan §8)
 * The operator password lives only in [EnrollmentUiState] and is **cleared after every attempt** (success
 * OR failure) so it never lingers in memory; it is never logged and never placed in error copy. Errors are
 * a coarse [EnrollError] enum the screen maps to app-authored, inert copy — never a server/exception string.
 *
 * @param enrollOperation login → hardware keygen+CSR → enroll → commit, yielding the installed leaf summary.
 * @param loadSummary the currently-installed device-cert summary (for the "已安装证书" section), or null.
 * @param defaultControlPlaneUrl prefilled control-plane URL (the operator can edit it).
 * @param defaultDeviceName prefilled device name (the Android device/model in production).
 * @param zone / now formatting + expiry clock for the installed-cert summary (fixed in tests).
 */
public class EnrollmentViewModel(
    private val enrollOperation: suspend (password: String, subdomain: String, deviceName: String, controlPlaneUrl: String) -> CertificateSummary,
    private val loadSummary: suspend () -> CertificateSummary?,
    defaultControlPlaneUrl: String = DEFAULT_CONTROL_PLANE_URL,
    defaultDeviceName: String = "",
    private val zone: ZoneId = ZoneId.systemDefault(),
    private val now: () -> Instant = Instant::now,
) {
    private val _uiState = MutableStateFlow(
        EnrollmentUiState(controlPlaneUrl = defaultControlPlaneUrl, deviceName = defaultDeviceName),
    )

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

    private var scope: CoroutineScope? = null
    private var job: Job? = null

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

    // ── Field edits (two-way bound from the Compose form) ─────────────────────────────────────────────

    public fun onControlPlaneUrlChange(value: String) {
        _uiState.value = _uiState.value.copy(controlPlaneUrl = value)
    }

    public fun onSubdomainChange(value: String) {
        _uiState.value = _uiState.value.copy(subdomain = value)
    }

    public fun onDeviceNameChange(value: String) {
        _uiState.value = _uiState.value.copy(deviceName = value)
    }

    public fun onPasswordChange(value: String) {
        _uiState.value = _uiState.value.copy(password = value)
    }

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

    private fun refresh() {
        val scope = scope ?: return
        job?.cancel()
        _uiState.value = _uiState.value.copy(phase = EnrollPhase.LOADING)
        job = scope.launch {
            // loadSummary is designed to degrade to null on a storage fault (never throw); guard anyway.
            val summary = runCatching { loadSummary() }.getOrNull()
            _uiState.value = _uiState.value.copy(phase = EnrollPhase.IDLE, summary = summary?.toView())
        }
    }

    /**
     * Run enrollment: validate the control-plane URL (must be `https` with a host) and the fields BEFORE
     * any network, then drive [enrollOperation]. The password is cleared after every attempt so it never
     * lingers. A double-tap while an enroll is in flight is ignored.
     */
    public fun enroll() {
        val scope = scope ?: return
        val snapshot = _uiState.value
        if (snapshot.phase == EnrollPhase.ENROLLING) return

        val url = validControlPlaneUrl(snapshot.controlPlaneUrl)
        if (url == null) {
            _uiState.value = snapshot.copy(error = EnrollError.INVALID_URL, didSucceed = false)
            return
        }
        val subdomain = snapshot.subdomain.trim()
        val deviceName = snapshot.deviceName.trim()
        val password = snapshot.password
        if (subdomain.isEmpty() || deviceName.isEmpty() || password.isEmpty()) {
            _uiState.value = snapshot.copy(error = EnrollError.MISSING_FIELDS, didSucceed = false)
            return
        }

        _uiState.value = snapshot.copy(phase = EnrollPhase.ENROLLING, error = null, didSucceed = false)
        job?.cancel()
        job = scope.launch {
            val outcome = try {
                Result.success(enrollOperation(password, subdomain, deviceName, url))
            } catch (cancel: CancellationException) {
                throw cancel
            } catch (error: Throwable) {
                Result.failure(error)
            }
            // Clear the password whatever the outcome — never linger after an attempt.
            _uiState.value = outcome.fold(
                onSuccess = { summary ->
                    _uiState.value.copy(
                        phase = EnrollPhase.IDLE,
                        summary = summary.toView(),
                        password = "",
                        didSucceed = true,
                        error = null,
                    )
                },
                onFailure = { e ->
                    _uiState.value.copy(
                        phase = EnrollPhase.IDLE,
                        password = "",
                        didSucceed = false,
                        error = classifyEnroll(e),
                    )
                },
            )
        }
    }

    /** Fields all present + not already enrolling → the button is enabled (deeper URL check on tap). */
    private fun CertificateSummary.toView() = toSummaryView(now(), zone)

    public companion object {
        /** The default control-plane URL (matches iOS `EnrollmentCopy.defaultControlPlaneURL`). */
        public const val DEFAULT_CONTROL_PLANE_URL: String = "https://cp.terminal.yaojia.wang"
    }
}

/**
 * Validate a control-plane URL exactly as iOS does before any network: it must parse, be `https`, and
 * carry a non-blank host. Returns the trimmed URL on success, or null (→ [EnrollError.INVALID_URL]).
 */
internal fun validControlPlaneUrl(raw: String): String? {
    val trimmed = raw.trim()
    if (trimmed.isEmpty()) return null
    val uri = runCatching { URI(trimmed) }.getOrNull() ?: return null
    if (!"https".equals(uri.scheme, ignoreCase = true)) return null
    if (uri.host.isNullOrBlank()) return null
    return trimmed
}

/**
 * Map an enroll throwable to the coarse, non-secret [EnrollError] the screen renders inertly. Android's
 * login + enroll share one [DeviceEnrollmentError.Http] type, so the HTTP status disambiguates (401 login
 * vs 403 subdomain-not-owned vs 429 vs 400). Keystore/hardware faults (incl. StrongBox-unavailable, a
 * [GeneralSecurityException] subclass) map to KEYGEN. Never carries the password/bearer.
 */
internal fun classifyEnroll(error: Throwable): EnrollError = when (error) {
    is DeviceEnrollmentError.Http -> when (error.status) {
        401 -> EnrollError.BAD_CREDENTIAL
        403 -> EnrollError.SUBDOMAIN_NOT_OWNED
        429 -> EnrollError.RATE_LIMITED
        400 -> EnrollError.REJECTED
        else -> EnrollError.ENROLL_FAILED
    }
    is DeviceEnrollmentError.MalformedResponse -> EnrollError.SERVER
    is DeviceEnrollmentError -> EnrollError.ENROLL_FAILED // InvalidRequest etc. (post-validation, rare)
    is GeneralSecurityException -> EnrollError.KEYGEN // hardware key / StrongBox / keystore fault
    else -> EnrollError.UNKNOWN
}

/** The load/action phase the enrollment screen renders. */
public enum class EnrollPhase {
    /** The initial installed-summary read is in flight. */
    LOADING,

    /** Idle — the form is editable and [EnrollmentUiState.summary] shows any installed cert. */
    IDLE,

    /** An enroll is in flight (login → keygen+CSR → enroll → commit). */
    ENROLLING,
}

/** The coarse, non-secret enrollment failure taxonomy — the screen maps each to app-authored inert copy (§8). */
public enum class EnrollError {
    /** The control-plane URL is not a valid `https://…` URL with a host (set before any network). */
    INVALID_URL,

    /** A required field (subdomain / device name / password) was empty (set before any network). */
    MISSING_FIELDS,

    /** Operator login rejected (401) — the password is wrong. */
    BAD_CREDENTIAL,

    /** The account does not own the requested subdomain (403) — no cert can be issued. */
    SUBDOMAIN_NOT_OWNED,

    /** Too many requests (429) — back off and retry. */
    RATE_LIMITED,

    /** The subdomain or CSR was rejected (400). */
    REJECTED,

    /** Any other enroll failure (server-side or transport). */
    ENROLL_FAILED,

    /** The server returned a malformed response. */
    SERVER,

    /** Hardware key generation failed (no StrongBox/TEE, or a keystore fault). */
    KEYGEN,

    /** An unclassified failure. */
    UNKNOWN,
}

/** The immutable snapshot the enrollment screen renders. */
public data class EnrollmentUiState(
    val controlPlaneUrl: String = "",
    val subdomain: String = "",
    val deviceName: String = "",
    val password: String = "",
    /** The currently-installed device identity's display summary, or `null` when none is installed. */
    val summary: CertSummaryView? = null,
    val phase: EnrollPhase = EnrollPhase.LOADING,
    /** The last enroll failure, or `null`. Surfaced as inert, app-authored copy. */
    val error: EnrollError? = null,
    /** Whether the most recent enroll succeeded (drives the success affordance). */
    val didSucceed: Boolean = false,
) {
    /** Every field present and no enroll in flight → the submit button is enabled. */
    val canEnroll: Boolean
        get() = phase != EnrollPhase.ENROLLING &&
            controlPlaneUrl.trim().isNotEmpty() &&
            subdomain.trim().isNotEmpty() &&
            deviceName.trim().isNotEmpty() &&
            password.isNotEmpty()
}
