package dev.privacyllc.period.core.security

import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder

/**
 * The lock's storage, on the JVM against a real DataStore in a temporary file.
 *
 * Possible for the same reason `UserPreferencesRepositoryTest` is: the
 * repository takes a `DataStore` rather than a `Context`. The Keystore is the
 * only part that cannot come along — `AndroidKeyStore not found` is what
 * Robolectric returns, checked rather than assumed — so [FakeMacProvider] stands
 * in for it and the instrumented test covers the real thing.
 */
class AppLockRepositoryTest {

    @get:Rule val tmp = TemporaryFolder()

    private lateinit var scope: TestScope
    private lateinit var store: DataStore<Preferences>
    private lateinit var macs: FakeMacProvider
    private lateinit var clocks: FakeClocks
    private lateinit var repo: AppLockRepository

    private val cheapPin get() = "2468".toCharArray()

    @Before fun setUp() {
        scope = TestScope(StandardTestDispatcher())
        store = PreferenceDataStoreFactory.create(
            scope = CoroutineScope(scope.coroutineContext),
            produceFile = { tmp.newFile("app_lock.preferences_pb") },
        )
        macs = FakeMacProvider()
        clocks = FakeClocks()
        repo = AppLockRepository(store, macs, clocks)
    }

    @After fun tearDown() = scope.cancel()

    @Test fun `a fresh install has no PIN`() = scope.runTest {
        assertFalse(repo.hasPin.first())
        assertEquals(UnlockResult.NoPin, repo.check(cheapPin))
    }

    @Test fun `setting a PIN turns the lock on, and the right PIN opens it`() = scope.runTest {
        assertTrue(repo.setPin(cheapPin))
        assertTrue(repo.hasPin.first())
        assertEquals(UnlockResult.Unlocked, repo.check(cheapPin))
    }

    @Test fun `the wrong PIN does not open it`() = scope.runTest {
        repo.setPin(cheapPin)
        assertTrue(repo.check("1111".toCharArray()) is UnlockResult.Wrong)
    }

    @Test fun `wrong PINs eventually cost time, and the right one clears it`() = scope.runTest {
        repo.setPin(cheapPin)
        repeat(LockoutPolicy.FREE_ATTEMPTS) {
            assertEquals(
                "the first few mistakes should cost nothing",
                0L,
                (repo.check("1111".toCharArray()) as UnlockResult.Wrong).waitMillis,
            )
        }
        assertTrue((repo.check("1111".toCharArray()) as UnlockResult.Wrong).waitMillis > 0L)

        // Guessing during a lockout is refused without being checked.
        assertTrue(repo.check(cheapPin) is UnlockResult.TooSoon)

        clocks.advance(LockoutPolicy.MAX_DELAY_MILLIS)
        assertEquals(UnlockResult.Unlocked, repo.check(cheapPin))
        assertEquals("a success forgets every failure", 0L, repo.lockoutRemainingMillis())
    }

    /**
     * The catastrophic bug this feature can produce, stated as a test.
     *
     * If the erase leaves the Keystore key behind, the user has destroyed
     * everything they recorded **and is still locked out** — a working lock over
     * an empty database, with no PIN that opens it. `prove-guard.sh` mutates the
     * `macs.deleteKey()` call out of [AppLockRepository.clearLock] and requires
     * exactly this test to go red.
     */
    @Test fun `clearing the lock destroys the key, not just the record`() = scope.runTest {
        repo.setPin(cheapPin)
        assertTrue(macs.hasKey())

        repo.clearLock()

        assertFalse("the stored verifier must go", repo.hasPin.first())
        assertFalse("the Keystore key must go with it", macs.hasKey())
        assertEquals(1, macs.deleteCalls)

        // And the app must be usable again afterwards, which is the point.
        assertEquals(UnlockResult.NoPin, repo.check(cheapPin))
        assertTrue("a new PIN can be set after an erase", repo.setPin("1357".toCharArray()))
    }

    /**
     * A key that vanished is not a wrong PIN. Reporting it as one would send
     * somebody into a growing backoff typing a PIN that was always correct,
     * with no way to find out.
     */
    @Test fun `a vanished key is reported as unavailable`() = scope.runTest {
        repo.setPin(cheapPin)
        macs.deleteKey()
        assertEquals(UnlockResult.KeyUnavailable, repo.check(cheapPin))
    }

    /**
     * Editing the counter must be the worst move available, not the best one.
     * The MAC will not check out, and an unverifiable counter is maximum
     * backoff rather than zero.
     */
    @Test fun `a tampered lockout counter fails closed`() = scope.runTest {
        repo.setPin(cheapPin)
        repeat(LockoutPolicy.FREE_ATTEMPTS + 1) { repo.check("1111".toCharArray()) }

        store.edit { it[stringPreferencesKey("lockout_state_v1")] = "v1|0|0|0|0" }

        val outcome = repo.check(cheapPin)
        assertTrue("a rewritten counter must not grant an attempt", outcome is UnlockResult.TooSoon)
        assertEquals(
            LockoutPolicy.MAX_DELAY_MILLIS,
            (outcome as UnlockResult.TooSoon).waitMillis,
        )
    }

    @Test fun `setting a new PIN clears any running lockout`() = scope.runTest {
        repo.setPin(cheapPin)
        repeat(LockoutPolicy.FREE_ATTEMPTS + 2) { repo.check("1111".toCharArray()) }
        assertTrue(repo.lockoutRemainingMillis() > 0L)

        repo.setPin("1357".toCharArray())

        assertEquals(0L, repo.lockoutRemainingMillis())
        assertEquals(UnlockResult.Unlocked, repo.check("1357".toCharArray()))
    }
}
