package dev.privacyllc.period.lock

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.core.security.AppLockRepository
import dev.privacyllc.period.core.security.UnlockResult
import dev.privacyllc.period.notifications.NotificationActionHandler
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject

/** What the gate should show. */
sealed interface LockState {
    /** The stores have not answered yet. Renders nothing — see [AppLockGate]. */
    data object Unknown : LockState

    data object Locked : LockState

    data object Unlocked : LockState
}

/** What the lock screen should say right now. */
data class LockScreenState(
    val checking: Boolean = false,
    val wrong: Boolean = false,
    val waitMillis: Long = 0L,
    val keyUnavailable: Boolean = false,
    val biometricOffered: Boolean = false,
)

/**
 * The gate's state, and the one place a PIN is offered.
 *
 * ## The lock is on exactly when a PIN exists
 *
 * There is no separate "app lock enabled" flag, deliberately. Two records of one
 * fact eventually disagree, and both ways of disagreeing are bad: a lock with no
 * PIN can never be opened, and a PIN with the lock off protects nothing.
 * `UserPreferences.biometricLockEnabled` survives as what its name says — whether
 * a fingerprint may be used *instead of* the PIN — and it is meaningless without
 * one, which is why it is combined with `hasPin` rather than read alone.
 */
@HiltViewModel
class AppLockViewModel @Inject constructor(
    private val lock: AppLockRepository,
    private val preferences: UserPreferencesRepository,
    private val controller: AppLockController,
    private val notificationActions: NotificationActionHandler,
) : ViewModel() {

    /** Non-null while a notification action is waiting to be applied. */
    val pendingNotificationAction: StateFlow<String?> = controller.pendingNotificationAction

    /**
     * Apply a parked notification action, now that somebody has authenticated.
     *
     * Called only from the unlocked branch of the gate. Tapping "Not yet" on a
     * reminder writes to the health record, and that button sits on the phone's
     * own lock screen where anybody can reach it — so with an app lock on, the
     * write waits for the unlock. A session that never unlocks never applies it.
     */
    fun deliverPendingNotificationAction() {
        val action = controller.takeNotificationAction() ?: return
        viewModelScope.launch(handler) { notificationActions.handle(action) }
    }

    private val _screen = MutableStateFlow(LockScreenState())
    val screen: StateFlow<LockScreenState> = _screen.asStateFlow()

    /**
     * `Unknown` until both stores have answered.
     *
     * The initial value must not be `Unlocked` — that would flash the Today
     * screen, with a forecast on it, before the lock had a chance to close.
     */
    val state: StateFlow<LockState> =
        combine(lock.hasPin, controller.unlocked, ::lockStateOf)
            .stateIn(viewModelScope, SharingStarted.Eagerly, LockState.Unknown)

    /** True only when a fingerprint may stand in for the PIN, which needs a PIN to stand in for. */
    val biometricAllowed: StateFlow<Boolean> = combine(
        preferences.preferences.map { it.biometricLockEnabled },
        lock.hasPin,
    ) { enabled, hasPin -> enabled && hasPin }
        .stateIn(viewModelScope, SharingStarted.Eagerly, false)

    private val handler = CoroutineExceptionHandler { _, _ ->
        // Never logged: `app` is in modulesSeeingHealthData and an exception on
        // this path can carry key material. A failure here reads as "not now".
        _screen.value = _screen.value.copy(checking = false, keyUnavailable = true)
    }

    fun submit(pin: CharArray) {
        if (_screen.value.checking) return
        _screen.value = _screen.value.copy(checking = true, wrong = false)
        viewModelScope.launch(handler) {
            when (val outcome = lock.check(pin)) {
                is UnlockResult.Unlocked -> {
                    _screen.value = LockScreenState()
                    controller.unlock()
                }
                is UnlockResult.Wrong ->
                    _screen.value = LockScreenState(wrong = true, waitMillis = outcome.waitMillis)
                is UnlockResult.TooSoon ->
                    _screen.value = LockScreenState(waitMillis = outcome.waitMillis)
                is UnlockResult.KeyUnavailable ->
                    _screen.value = LockScreenState(keyUnavailable = true)
                is UnlockResult.NoPin -> {
                    // The lock was turned off in another window; nothing to check.
                    _screen.value = LockScreenState()
                    controller.unlock()
                }
            }
            pin.fill('\u0000')
        }
    }

    /** Called after the biometric prompt succeeds. The PIN is not involved. */
    fun unlockFromBiometric() {
        _screen.value = LockScreenState()
        controller.unlock()
    }

    /**
     * Close the lock again, on leaving the app.
     *
     * The screen state is reset with it: a half-typed PIN and a "that is not the
     * PIN" message must not still be on screen when the app is reopened, which
     * would tell whoever opens it next that somebody was recently guessing.
     */
    fun relock() {
        controller.lock()
        _screen.value = LockScreenState()
    }

    /** True while the system biometric prompt is up; the gate must not re-lock under it. */
    val authInProgress: Boolean get() = controller.authInProgress

    fun beginBiometricAuth() {
        controller.authInProgress = true
    }

    fun endBiometricAuth() {
        controller.authInProgress = false
    }

    /**
     * Turn the fingerprint offer off, because the hardware says it cannot work.
     *
     * Only for `ERROR_NO_BIOMETRICS` and `ERROR_HW_NOT_PRESENT` — no enrolment
     * left, or no sensor. A transient failure must not switch a setting the user
     * chose; they would have to go and find it again to turn it back on.
     */
    fun disableBiometric() {
        viewModelScope.launch(handler) { preferences.setBiometricLockEnabled(false) }
    }

    fun refreshLockout() {
        viewModelScope.launch(handler) {
            val wait = lock.lockoutRemainingMillis()
            _screen.value = _screen.value.copy(waitMillis = wait)
        }
    }
}

/**
 * The whole rule, in one place: locked exactly when a PIN exists and this
 * session has not opened it.
 *
 * Extracted from the gate's `combine` so a test can assert against the rule the
 * app actually uses rather than a restatement of it. That matters here more than
 * usual — the thing worth proving about setting a PIN is that this function
 * never returns [LockState.Locked] while it happens, and a copy of the rule in
 * the test would prove that about the copy.
 */
internal fun lockStateOf(hasPin: Boolean, unlocked: Boolean): LockState = when {
    !hasPin -> LockState.Unlocked
    unlocked -> LockState.Unlocked
    else -> LockState.Locked
}
