package dev.privacyllc.period.notifications

import android.content.Context
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.preferencesDataStoreFile
import androidx.test.core.app.ApplicationProvider
import dev.privacyllc.period.core.data.CycleData
import dev.privacyllc.period.core.datastore.UserPreferences
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.core.notifications.ReminderScheduler
import dev.privacyllc.period.domain.prediction.PersonalPredictionEngine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.time.Clock

/**
 * The two collections that run on the process-long scope must not throw.
 *
 * [ApplicationScopeTest][dev.privacyllc.period.ApplicationScopeTest] covers the
 * backstop — that the scope survives if something does escape. This covers the
 * layer above it: each chain catches its own failure, so one collection dying
 * does not silently take the other with it.
 *
 * The chains are built by `internal` functions taking flows rather than read
 * from the repository inline, purely so this test can hand them a flow that
 * throws. `CycleRepository` is final with an internal constructor — the right
 * shape for a data boundary and the wrong shape for faking — and adding a
 * mocking library to reach one `catch` would be a poor trade.
 */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class ReminderCoordinatorTest {

    private lateinit var coordinator: ReminderCoordinator
    private lateinit var preferences: UserPreferencesRepository

    @Before fun setUp() {
        val context = ApplicationProvider.getApplicationContext<Context>()
        preferences = UserPreferencesRepository(
            PreferenceDataStoreFactory.create { context.preferencesDataStoreFile("coordinator_test") },
        )
        coordinator = ReminderCoordinator(
            repository = CycleData.repository(context, PersonalPredictionEngine(), Clock.systemUTC()),
            preferences = preferences,
            scheduler = ReminderScheduler(context),
        )
    }

    /**
     * The realistic failure. `repository.forecast` runs the prediction engine
     * inside the flow, and `Prediction`'s init block enforces its window
     * invariants with `require` — so a bad engine result throws here rather
     * than returning something wrong.
     */
    @Test fun `a throwing forecast does not escape the schedule chain`() = runBlocking {
        coordinator.scheduleUpdates(
            forecastDates = flow { throw IllegalArgumentException("a prediction invariant failed") },
            preferences = flowOf(UserPreferences.Defaults),
        ).collect { }
    }

    @Test fun `a throwing preferences flow does not escape the schedule chain`() = runBlocking {
        coordinator.scheduleUpdates(
            forecastDates = flowOf(null),
            preferences = flow { throw IllegalStateException("the preferences file is unreadable") },
        ).collect { }
    }

    /**
     * The positive control, so a `catch` that swallowed everything would not
     * pass the two tests above by accident.
     *
     * A repeated key must collapse: the forecast object is rebuilt on every
     * recalculation even when it says the same thing, and rescheduling on each
     * of those would churn WorkManager for nothing.
     */
    @Test fun `a healthy flow still reaches the collector`() = runBlocking {
        var emissions = 0
        coordinator.scheduleUpdates(
            forecastDates = flowOf(null, null),
            preferences = flowOf(UserPreferences.Defaults),
        ).collect { emissions++ }
        assertEquals("distinctUntilChanged should collapse the repeated value", 1, emissions)
    }

    /**
     * The check-in count is not the coordinator's business any more, and this
     * is the regression that made it so.
     *
     * The old chain reset the count when the newest confirmed start changed —
     * except `distinctUntilChanged` only dedups within one collection, and
     * `PeriodApplication` starts the coordinator in EVERY process, including the
     * one WorkManager spawns to run the reminder. So the first emission always
     * passed, the count was wiped moments before the worker read it, and §30's
     * "do not nag forever" could never be reached.
     *
     * Starting twice on the same history now leaves the tally alone, because
     * nothing is watching the history at all.
     */
    @Test fun `starting twice on the same history leaves the check-in tally alone`() = runBlocking {
        preferences.recordCheckIn(periodId = 7L)
        preferences.recordCheckIn(periodId = 7L)

        coordinator.start(CoroutineScope(Dispatchers.Unconfined))
        coordinator.start(CoroutineScope(Dispatchers.Unconfined))

        assertEquals(2, preferences.checkInTally.first().countFor(7L))
    }
}
