package dev.privacyllc.period.feature.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.lock.AppLockController
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 App lock settings screen is showing. */
data class LockSettings(
    val hasPin: Boolean = false,
    val biometricEnabled: Boolean = false,
    val busy: Boolean = false,
    val message: Message? = null,
) {
    enum class Message { PIN_SET, PIN_REMOVED, WRONG_PIN, COULD_NOT_SET }
}

/**
 * Turning the lock on and off, and changing the PIN.
 *
 * ## Removing the lock asks for the PIN first
 *
 * Otherwise the lock protects nothing: anybody who picks up an unlocked phone
 * could open Settings and switch it off, which is precisely the adversary
 * `SECURITY.md` names first. Setting a PIN for the first time needs no
 * authentication, because there is nothing yet to authenticate against.
 */
@HiltViewModel
class LockSettingsViewModel @Inject constructor(
    private val lock: AppLockRepository,
    private val preferences: UserPreferencesRepository,
    private val controller: AppLockController,
) : ViewModel() {

    private val _busy = MutableStateFlow(false)
    private val _message = MutableStateFlow<LockSettings.Message?>(null)

    val state: StateFlow<LockSettings> = combine(
        lock.hasPin,
        preferences.preferences.map { it.biometricLockEnabled },
        _busy,
        _message,
    ) { hasPin, biometric, busy, message ->
        LockSettings(
            hasPin = hasPin,
            // Meaningless without a PIN to stand in for, so never shown as on
            // without one. The stored flag is left alone rather than corrected,
            // so turning the lock back on restores the choice the user made.
            biometricEnabled = biometric && hasPin,
            busy = busy,
            message = message,
        )
    }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), LockSettings())

    private val handler = CoroutineExceptionHandler { _, _ ->
        _busy.value = false
        _message.value = LockSettings.Message.COULD_NOT_SET
    }

    /**
     * True between a successful check of the current PIN and the replacement
     * being written or abandoned.
     *
     * Not a detail of the screen. The screen is *also* supposed to enforce the
     * order, and it did not: it fired the check and advanced to "choose a PIN"
     * in the same breath, without waiting for the answer, so any four digits
     * reached the replacement screen. The place that writes the PIN is the place
     * that has to refuse — a screen can be rewritten by somebody who never reads
     * this file.
     */
    private var replacementAuthorised = false

    /** First PIN, or a replacement once [authoriseChange] has passed. */
    fun setPin(pin: CharArray) {
        if (_busy.value) return
        _busy.value = true
        viewModelScope.launch(handler) {
            // A replacement needs the current PIN first; a first PIN has nothing
            // to check against. Refusing here rather than trusting the caller is
            // what makes "you cannot change the PIN without knowing it" a
            // property of the lock instead of a property of one composable.
            if (lock.hasPinNow() && !replacementAuthorised) {
                pin.fill('\u0000')
                _busy.value = false
                _message.value = LockSettings.Message.WRONG_PIN
                return@launch
            }

            // Open THIS session before the PIN exists, not after.
            //
            // The gate is combine(hasPin, unlocked): it closes only on the pair
            // (true, false). `unlock()` sets a MutableStateFlow synchronously on
            // this thread, before setPin begins the write that makes hasPin
            // true, and combine always emits with the latest of both — so that
            // pair is never observable. The old order wrote first and unlocked
            // second, and DataStore's emission could reach the gate in between:
            // AppLockGate disposes the whole app subtree when it closes, this
            // ViewModel is scoped to a destination inside it, and the unlock was
            // cancelled with the scope. The user was thrown to the lock screen
            // to type the PIN she had just chosen.
            //
            // If setPin fails there is no PIN, and Unlocked is the right state
            // anyway — so nothing is undone here. Never call controller.lock()
            // on failure: during a change that strands the user behind her old
            // PIN on a screen that has just told her the write did not happen.
            controller.unlock()
            val ok = lock.setPin(pin)
            pin.fill('\u0000')
            replacementAuthorised = false
            _busy.value = false
            _message.value =
                if (ok) LockSettings.Message.PIN_SET else LockSettings.Message.COULD_NOT_SET
        }
    }

    /**
     * Check the current PIN, then run [onVerified].
     *
     * Used by both "turn the lock off" and "change PIN". A wrong answer here
     * goes through the same backoff as the lock screen, because otherwise
     * Settings would be an unlimited guessing oracle for the same secret.
     */
    fun verifyCurrent(pin: CharArray, onVerified: suspend () -> Unit) {
        if (_busy.value) return
        _busy.value = true
        viewModelScope.launch(handler) {
            val outcome = lock.check(pin)
            pin.fill('\u0000')
            if (outcome is UnlockResult.Unlocked) {
                // Clear a stale "that is not the PIN" before moving on, or the
                // next screen opens still showing the last wrong answer.
                _message.value = null
                onVerified()
                _busy.value = false
            } else {
                _busy.value = false
                _message.value = LockSettings.Message.WRONG_PIN
            }
        }
    }

    /**
     * The first step of changing the PIN: prove the current one, and only then
     * let a replacement be written.
     *
     * [onAuthorised] runs after the check passes, so a caller can move to the
     * "choose a PIN" step from inside it. It is deliberately not the caller's
     * job to know that — [setPin] refuses a replacement that was not authorised
     * here, whatever the screen does.
     */
    fun authoriseChange(current: CharArray, onAuthorised: () -> Unit) = verifyCurrent(current) {
        replacementAuthorised = true
        onAuthorised()
    }

    /** Abandoning the change withdraws the permission it was granted. */
    fun cancelChange() {
        replacementAuthorised = false
    }

    fun removePin(current: CharArray, onRemoved: () -> Unit = {}) = verifyCurrent(current) {
        lock.clearLock()
        preferences.setBiometricLockEnabled(false)
        _message.value = LockSettings.Message.PIN_REMOVED
        onRemoved()
    }

    fun setBiometricEnabled(enabled: Boolean) {
        viewModelScope.launch(handler) { preferences.setBiometricLockEnabled(enabled) }
    }

    fun clearMessage() {
        _message.value = null
    }
}
