package dev.privacyllc.period.feature.onboarding

import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.test.core.app.ApplicationProvider
import dev.privacyllc.period.core.data.CycleData
import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.core.datastore.NotificationPrivacy
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import kotlinx.coroutines.withTimeout
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneOffset

/**
 * The onboarding flow's decisions, as tests.
 *
 * Every one of these is something §19 states and something a later change could
 * quietly undo — a default flipped, a write moved earlier, a skip that stops
 * skipping.
 */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class OnboardingViewModelTest {

    @get:Rule val tmp = TemporaryFolder()

    /**
     * Unconfined, so a `viewModelScope.launch` starts eagerly rather than
     * waiting to be advanced.
     *
     * Virtual time is the wrong tool here and it cost a confusing red first:
     * Room runs its queries on its own executor, which no test scheduler
     * drives, so `advanceUntilIdle()` returned with the writes still in flight
     * and the assertions read an empty database. Anything that crosses into
     * Room is awaited on the real clock by [awaitUntil].
     */
    private val dispatcher = UnconfinedTestDispatcher()
    private lateinit var repo: CycleRepository
    private lateinit var prefs: UserPreferencesRepository
    private lateinit var vm: OnboardingViewModel

    private val today = LocalDate.of(2026, 8, 18)
    private val clock = Clock.fixed(today.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC)

    @Before fun setUp() {
        Dispatchers.setMain(dispatcher)
        // Built through the public factory, like the app does. The repository's
        // constructor is internal to core:data on purpose, and reaching around
        // that for a test would put Room on this module's classpath — the exact
        // leak the boundary guard exists to prevent, waved through because it
        // was "only a test".
        repo = CycleData.repository(
            ApplicationProvider.getApplicationContext(),
            PersonalPredictionEngine(),
            clock,
        )
        prefs = UserPreferencesRepository(
            PreferenceDataStoreFactory.create(
                scope = CoroutineScope(dispatcher),
                produceFile = { tmp.newFile("prefs.preferences_pb") },
            ),
        )
        vm = OnboardingViewModel(repo, prefs, clock)

        // Robolectric reuses the app's data directory across tests in a class,
        // so the file-backed database survives between them. Cleared here
        // rather than assumed empty.
        runBlocking { repo.deleteAllHealthData() }
    }

    @After fun tearDown() {
        Dispatchers.resetMain()
    }

    /** Wait on the real clock for something Room is doing on its own executor. */
    private fun awaitUntil(what: String, predicate: suspend () -> Boolean) = runBlocking {
        withTimeout(5_000) {
            while (!predicate()) delay(10)
        }
        Unit
    }.also { }

    @Test fun `it starts at welcome and walks forward through the seven steps`() {
        assertEquals(Step.WELCOME, vm.state.value.step)
        vm.toWelcomeNext()
        assertEquals(Step.LAST_PERIOD, vm.state.value.step)
        vm.setLastPeriodStart(today.minusDays(3))
        vm.confirmLastPeriod()
        assertEquals(Step.PERIOD_END, vm.state.value.step)
        vm.setPeriodEnd(today)
        assertEquals(Step.PREVIOUS_HISTORY, vm.state.value.step)
        vm.donePreviousHistory()
        assertEquals(Step.PRIVACY_PROMISE, vm.state.value.step)
        vm.donePrivacyPromise()
        assertEquals(Step.NOTIFICATION_PRIVACY, vm.state.value.step)
    }

    @Test fun `continuing is refused until a last period date exists`() {
        vm.toWelcomeNext()
        assertFalse("nothing to continue from yet", vm.state.value.canContinueFromLastPeriod)
        vm.setLastPeriodStart(today.minusDays(2))
        assertTrue(vm.state.value.canContinueFromLastPeriod)
    }

    @Test fun `still going and not sure both leave the end date empty`() {
        vm.toWelcomeNext()
        vm.setLastPeriodStart(today.minusDays(2))
        vm.confirmLastPeriod()
        // §24: never invent an end date.
        vm.setPeriodEnd(null)
        assertEquals(null, vm.state.value.lastPeriodEnd)
    }

    @Test fun `notification privacy is discreet before the user touches anything`() {
        // §28. A default of Direct would put menstrual detail on a lock screen
        // before a single question has been asked.
        assertEquals(NotificationPrivacy.DISCREET, vm.state.value.notificationPrivacy)
    }

    @Test fun `previous history is optional and de-duplicates`() {
        vm.toWelcomeNext()
        vm.setLastPeriodStart(today.minusDays(2))
        vm.confirmLastPeriod()
        vm.setPeriodEnd(null)

        val earlier = today.minusDays(31)
        vm.addPreviousStart(earlier)
        vm.addPreviousStart(earlier)
        assertEquals(1, vm.state.value.previousStarts.size)
        assertNotNull("and says why rather than silently ignoring", vm.state.value.message)

        vm.removePreviousStart(earlier)
        assertTrue(vm.state.value.previousStarts.isEmpty())
    }

    @Test fun `nothing is written until the final step`() = runBlocking {
        vm.toWelcomeNext()
        vm.setLastPeriodStart(today.minusDays(2))
        vm.confirmLastPeriod()
        vm.setPeriodEnd(null)
        vm.addPreviousStart(today.minusDays(31))
        vm.donePreviousHistory()
        vm.donePrivacyPromise()

        // Somebody who abandons onboarding here has not asked this app to
        // remember anything about them.
        assertTrue("nothing stored before finishSetup", repo.confirmedPeriods.first().isEmpty())
        assertFalse("and onboarding is not marked complete", prefs.preferences.first().onboardingCompleted)
    }

    @Test fun `finishing writes the history, the preference and produces a forecast`() = runBlocking {
        vm.toWelcomeNext()
        vm.setLastPeriodStart(today.minusDays(2))
        vm.confirmLastPeriod()
        vm.setPeriodEnd(null)
        vm.addPreviousStart(today.minusDays(31))
        vm.addPreviousStart(today.minusDays(60))
        vm.donePreviousHistory()
        vm.donePrivacyPromise()
        vm.setNotificationPrivacy(NotificationPrivacy.MAXIMUM_PRIVACY)
        vm.finishSetup()
        awaitUntil("setup to finish") { vm.state.value.step == Step.FIRST_FORECAST }

        val periods = repo.confirmedPeriods.first()
        assertEquals(3, periods.size)
        assertEquals(
            "earlier entries are recorded as historical, not as things that just happened",
            2,
            periods.count { it.source == PeriodRecordSource.HISTORICAL_ENTRY },
        )
        assertEquals(NotificationPrivacy.MAXIMUM_PRIVACY, prefs.preferences.first().notificationPrivacy)

        assertEquals(Step.FIRST_FORECAST, vm.state.value.step)
        assertNotNull("§19: a forecast, before anything else is asked", vm.state.value.forecast)
    }

    @Test fun `the completion flag is set only at the very end`() = runBlocking {
        vm.toWelcomeNext()
        vm.setLastPeriodStart(today.minusDays(2))
        vm.confirmLastPeriod()
        vm.setPeriodEnd(null)
        vm.donePreviousHistory()
        vm.donePrivacyPromise()
        vm.finishSetup()
        awaitUntil("setup to finish") { vm.state.value.step == Step.FIRST_FORECAST }

        // Still false: reaching the forecast is not finishing.
        assertFalse(prefs.preferences.first().onboardingCompleted)

        var called = false
        vm.complete { called = true }
        awaitUntil("completion to be written") { prefs.preferences.first().onboardingCompleted }

        assertTrue(called)
    }

    @Test fun `back steps return without losing what was entered`() {
        vm.toWelcomeNext()
        vm.setLastPeriodStart(today.minusDays(5))
        vm.confirmLastPeriod()
        vm.back()
        assertEquals(Step.LAST_PERIOD, vm.state.value.step)
        assertEquals(today.minusDays(5), vm.state.value.lastPeriodStart)
    }
}
