package dev.privacyllc.period.core.security

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test

/**
 * Wrong PINs cost time and never cost data.
 *
 * The two bypasses each get a test, because both are things a person can
 * actually do with no tools: change the device clock, and restart the phone.
 */
class LockoutPolicyTest {

    private fun fail(times: Int, clocks: FakeClocks): LockoutState {
        var s = LockoutState()
        repeat(times) { s = LockoutPolicy.onFailure(s, clocks) }
        return s
    }

    @Test fun `a mistyped digit costs nothing`() {
        val clocks = FakeClocks()
        val s = fail(LockoutPolicy.FREE_ATTEMPTS, clocks)
        assertEquals(0L, LockoutPolicy.remainingMillis(s, clocks))
        assertFalse(LockoutPolicy.isLockedOut(s, clocks))
    }

    @Test fun `the delay escalates and then stops escalating`() {
        val clocks = FakeClocks()
        val seen = (1..10).map { LockoutPolicy.delayFor(LockoutPolicy.FREE_ATTEMPTS + it) }
        assertEquals(LockoutPolicy.DELAYS_MILLIS, seen.take(LockoutPolicy.DELAYS_MILLIS.size))
        assertTrue(
            "every failure past the table stays at the cap, forever",
            seen.drop(LockoutPolicy.DELAYS_MILLIS.size).all { it == LockoutPolicy.MAX_DELAY_MILLIS },
        )
        assertEquals(900_000L, LockoutPolicy.MAX_DELAY_MILLIS)
    }

    @Test fun `waiting it out clears it`() {
        val clocks = FakeClocks()
        val s = fail(LockoutPolicy.FREE_ATTEMPTS + 1, clocks)
        assertTrue(LockoutPolicy.isLockedOut(s, clocks))

        clocks.advance(30_000L)

        assertEquals(0L, LockoutPolicy.remainingMillis(s, clocks))
    }

    @Test fun `a correct PIN forgets every previous failure`() {
        val clocks = FakeClocks()
        fail(9, clocks)
        assertEquals(LockoutState(), LockoutPolicy.onSuccess())
        assertEquals(0L, LockoutPolicy.remainingMillis(LockoutPolicy.onSuccess(), clocks))
    }

    /** Bypass one: Settings, move the date forward, come back. */
    @Test fun `moving the device clock forward does not end a lockout`() {
        val clocks = FakeClocks()
        val s = fail(LockoutPolicy.FREE_ATTEMPTS + 5, clocks)

        clocks.wall += 10 * 24 * 60 * 60 * 1000L // ten days, wall only

        assertTrue(
            "the monotonic clock still holds the deadline",
            LockoutPolicy.remainingMillis(s, clocks) > 0L,
        )
    }

    /**
     * The mirror of the above, and the reason the wait is capped: moving the
     * clock *backwards* must not turn a fifteen-minute wait into a year.
     */
    @Test fun `moving the device clock backward cannot extend a lockout beyond what was earned`() {
        val clocks = FakeClocks()
        val s = fail(LockoutPolicy.FREE_ATTEMPTS + 5, clocks)

        clocks.wall -= 365L * 24 * 60 * 60 * 1000L

        assertTrue(LockoutPolicy.remainingMillis(s, clocks) <= LockoutPolicy.MAX_DELAY_MILLIS)
    }

    /** Bypass two: elapsedRealtime restarts at zero, so the deadline looks passed. */
    @Test fun `rebooting re-applies the full delay rather than clearing it`() {
        val clocks = FakeClocks()
        val s = fail(LockoutPolicy.FREE_ATTEMPTS + 5, clocks)

        clocks.reboot()

        assertEquals(
            "a reboot must cost the full earned delay, not zero",
            LockoutPolicy.MAX_DELAY_MILLIS,
            LockoutPolicy.remainingMillis(s, clocks),
        )
    }

    /**
     * Deleting or editing the counter must be the worst move available, not the
     * best one. `null` here stands for both "file gone" and "MAC did not check
     * out", which is how [AppLockRepository] reports tampering.
     */
    @Test fun `a missing or tampered counter is maximum backoff and never zero`() {
        assertEquals(LockoutPolicy.MAX_DELAY_MILLIS, LockoutPolicy.remainingMillis(null, FakeClocks()))
        assertTrue(LockoutPolicy.isLockedOut(null, FakeClocks()))
    }

    /** There is no attempt limit and nothing that erases. A thousand wrong guesses is still just a wait. */
    @Test fun `a thousand wrong attempts still only costs time`() {
        val clocks = FakeClocks()
        val s = fail(1_000, clocks)
        assertEquals(1_000, s.failedAttempts)
        assertEquals(LockoutPolicy.MAX_DELAY_MILLIS, LockoutPolicy.remainingMillis(s, clocks))
        clocks.advance(LockoutPolicy.MAX_DELAY_MILLIS)
        assertEquals(
            "after the wait, the user may try again — there is no terminal state",
            0L,
            LockoutPolicy.remainingMillis(s, clocks),
        )
    }

    @Test fun `the state survives a round trip through its stored form`() {
        val clocks = FakeClocks()
        val s = fail(6, clocks)
        assertEquals(s, LockoutState.decode(s.encode()))
    }

    @Test fun `an unparseable stored form decodes to null rather than throwing`() {
        listOf(null, "", "nonsense", "v9|1|2|3|4", "v1|1|2|3", "v1|x|2|3|4", "v1|-1|2|3|4")
            .forEach { assertEquals("input: $it", null, LockoutState.decode(it)) }
    }
}
