package dev.privacyllc.period.core.security

/**
 * What happens after a wrong PIN.
 *
 * Four free attempts, then a delay that grows 30s → 1m → 2m → 5m → 15m and
 * stays at fifteen minutes **forever**. There is no attempt limit and there is
 * no automatic erase, and both of those are decisions rather than omissions.
 *
 * ## Why it never wipes
 *
 * A wipe-after-N-attempts policy is common, and here it would be a weapon.
 * `SECURITY.md`'s threat model names people with physical access to the phone —
 * a partner, someone on a shared device — and under the no-recovery policy an
 * automatic erase would let any of them destroy a cycle history permanently
 * without knowing anything at all. So could a child, or a pocket. The thing this
 * app is protecting is the one thing a wrong guess must never be able to
 * destroy.
 *
 * Fifteen minutes forever is enough. A million six-digit PINs at four per
 * fifteen minutes is longer than the phone will exist.
 *
 * ## Why it never shows a count
 *
 * "3 attempts remaining" tells an attacker how much room they have and tells the
 * owner they are about to lose everything. The screen says only how long to
 * wait.
 */
internal object LockoutPolicy {

    /** Wrong PINs allowed before any delay. A mistyped digit should not cost a wait. */
    const val FREE_ATTEMPTS = 4

    /** Applied after [FREE_ATTEMPTS]; the last value repeats for every failure after it. */
    val DELAYS_MILLIS: List<Long> = listOf(
        30_000L,
        60_000L,
        120_000L,
        300_000L,
        900_000L,
    )

    val MAX_DELAY_MILLIS: Long = DELAYS_MILLIS.last()

    /** The delay earned by [failures] wrong attempts in total. */
    fun delayFor(failures: Int): Long {
        val over = failures - FREE_ATTEMPTS
        if (over <= 0) return 0L
        return DELAYS_MILLIS[minOf(over - 1, DELAYS_MILLIS.lastIndex)]
    }

    /** A wrong PIN. Advances the count and arms both deadlines. */
    fun onFailure(state: LockoutState, clocks: Clocks): LockoutState {
        val failures = state.failedAttempts + 1
        val delay = delayFor(failures)
        val wall = clocks.wallMillis()
        val elapsed = clocks.elapsedMillis()
        return LockoutState(
            failedAttempts = failures,
            wallDeadline = wall + delay,
            elapsedDeadline = elapsed + delay,
            elapsedMark = elapsed,
        )
    }

    /** A correct PIN clears everything. Nothing is remembered across a success. */
    fun onSuccess(): LockoutState = LockoutState()

    /**
     * Milliseconds still to wait, or zero when the user may try again.
     *
     * Three rules, and each closes something:
     *
     * 1. **Take the longer of the two clocks.** Moving the device clock forward
     *    expires the wall deadline; the elapsed one is untouched and still
     *    holds. Moving it backward inflates the wall remainder; rule 3 caps it.
     * 2. **A reboot re-applies the full delay.** `elapsedRealtime` restarts at
     *    zero, so a reading *below* the mark taken when the deadline was written
     *    can only mean the phone restarted — at which point the elapsed deadline
     *    is meaningless and the wall one may have been tampered with. Failing
     *    safe costs an honest user one more wait and costs an attacker the
     *    entire reboot bypass.
     * 3. **Never longer than the delay actually earned.** Without this, setting
     *    the clock back a year would lock the owner out for a year — turning a
     *    defence into the denial-of-service it was meant to prevent.
     */
    fun remainingMillis(state: LockoutState?, clocks: Clocks): Long {
        // A counter that exists and does not verify is maximum backoff, never
        // zero — so editing it is the worst move available rather than the best
        // one. Note the caller distinguishes this from "no counter yet", which
        // is the ordinary state of a fresh install and passes LockoutState().
        if (state == null) return MAX_DELAY_MILLIS

        val earned = delayFor(state.failedAttempts)
        if (earned == 0L) return 0L

        val elapsed = clocks.elapsedMillis()
        if (elapsed < state.elapsedMark) return earned

        val byWall = state.wallDeadline - clocks.wallMillis()
        val byElapsed = state.elapsedDeadline - elapsed
        val remaining = maxOf(byWall, byElapsed)

        return remaining.coerceIn(0L, earned)
    }

    fun isLockedOut(state: LockoutState?, clocks: Clocks): Boolean =
        remainingMillis(state, clocks) > 0L
}
