package dev.privacyllc.period.notifications

import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.core.datastore.UserPreferences
import dev.privacyllc.period.core.datastore.UserPreferencesRepository
import dev.privacyllc.period.core.notifications.ReminderScheduler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import java.time.LocalDate
import java.time.LocalTime
import javax.inject.Inject
import javax.inject.Singleton

/**
 * Keeps the scheduled reminder pointed at the current forecast.
 *
 * §31 lists rescheduling after a confirmation or a forecast change explicitly,
 * and it is the requirement most likely to be missed — everything works, right
 * up until a "Not yet" moves the forecast and the queued reminder is aimed at a
 * day that no longer means anything.
 *
 * It lives in `app` rather than in the repository on purpose. `core:data` does
 * not depend on `core:datastore` or `core:notifications`, and it should not:
 * storing cycle history and deciding when to buzz a phone are different jobs,
 * and the module boundary guard would refuse the dependency anyway.
 */
@Singleton
class ReminderCoordinator @Inject constructor(
    private val repository: CycleRepository,
    private val preferences: UserPreferencesRepository,
    private val scheduler: ReminderScheduler,
) {

    /**
     * Watch for anything that changes what a reminder should say or when.
     *
     * The two chains are built by [scheduleUpdates] and [checkInResets] rather
     * than inline, so a test can hand them a flow that throws. Without that
     * seam the only way to reach the `catch` below is a repository failure,
     * and `CycleRepository` is final with an internal constructor — which is
     * the right shape for a data boundary and the wrong shape for faking.
     */
    fun start(scope: CoroutineScope) {
        scheduleUpdates(
            forecastDates = repository.forecast.map { it?.mostLikelyStartDate },
            preferences = preferences.preferences,
        ).launchIn(scope)

        checkInResets(
            confirmedStarts = repository.confirmedPeriods
                .map { periods -> periods.maxOfOrNull { it.startDate } },
        ).launchIn(scope)
    }

    /**
     * Reschedule when the forecast date, the reminder time, or whether anything
     * is enabled at all changes.
     *
     * `distinctUntilChanged` on a small key rather than on the whole state: the
     * forecast object is recreated on every recalculation even when it says the
     * same thing, and rescheduling on each of those would churn WorkManager for
     * no reason.
     *
     * ## Why this catches, and why it does nothing when it does
     *
     * This is collected on the process-long scope in `PeriodApplication`, in
     * processes started by WorkManager with no Activity in existence. Left
     * uncaught, a throw here ends the process — see that class's KDoc.
     *
     * Doing nothing is the deliberate choice rather than the lazy one. The
     * alternative — cancelling the scheduled work — would turn a failed *read*
     * into reminders silently switched off, and they would stay off until the
     * user next touched a notification setting, because those two places are
     * the only callers of the scheduler. Leaving the existing schedule alone
     * means the worker runs, finds it cannot say anything, and says nothing;
     * that is already its behaviour with no history.
     *
     * It cannot log the failure either: `checkNoHealthLogging` covers the `app` module,
     * and the likeliest throw on this path is a `Prediction` invariant failure
     * whose message is derived from cycle data.
     *
     * The flow terminates after a catch, so this degrades for the life of the
     * process rather than retrying. That is correct for the failure actually
     * expected here — an invariant violation is deterministic, and retrying it
     * would spin.
     */
    internal fun scheduleUpdates(
        forecastDates: Flow<LocalDate?>,
        preferences: Flow<UserPreferences>,
    ): Flow<*> =
        combine(
            forecastDates,
            preferences.map { it.reminderTime to anyEnabled(it) },
        ) { forecastDate, (time, enabled) -> Triple(forecastDate, time, enabled) }
            .distinctUntilChanged()
            .onEach { (_, time, enabled) -> apply(time, enabled) }
            .catch { }

    /**
     * A confirmed period answers the question the check-ins were asking, so the
     * count resets and the app is willing to ask again next cycle.
     *
     * Without this, somebody who ignored three check-ins once would never be
     * asked again — the stopping rule would become permanent.
     *
     * Catches for the same reason as [scheduleUpdates], and separately from it:
     * one collection failing must not stop the other, which is the whole point
     * of them being two chains rather than one.
     */
    internal fun checkInResets(confirmedStarts: Flow<LocalDate?>): Flow<*> =
        confirmedStarts
            .distinctUntilChanged()
            .onEach { latest -> if (latest != null) onPeriodConfirmed(latest) }
            .catch { }

    private suspend fun apply(time: LocalTime, enabled: Boolean) {
        if (enabled) scheduler.schedule(time) else scheduler.cancel()
    }

    private suspend fun onPeriodConfirmed(@Suppress("UNUSED_PARAMETER") start: LocalDate) {
        preferences.resetCheckIns()
    }

    private fun anyEnabled(p: UserPreferences) =
        p.periodApproachingEnabled || p.periodExpectedTodayEnabled || p.didItStartEnabled ||
            p.periodEndCheckInEnabled || p.fertileWindowReminderEnabled || p.ovulationReminderEnabled
}
