package dev.privacyllc.period.feature.today

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dev.privacyllc.period.core.data.CycleRepository
import dev.privacyllc.period.core.data.PeriodWriteResult
import dev.privacyllc.period.core.data.PredictionAccuracy
import dev.privacyllc.period.domain.cycle.CycleRecord
import dev.privacyllc.period.domain.cycle.PeriodRecord
import dev.privacyllc.period.domain.prediction.CycleStatus
import dev.privacyllc.period.domain.prediction.CycleStatusRules
import dev.privacyllc.period.domain.prediction.FertilityEstimate
import dev.privacyllc.period.domain.prediction.FertilityLikelihood
import dev.privacyllc.period.domain.prediction.Prediction
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.time.Clock
import java.time.LocalDate
import javax.inject.Inject

/**
 * Screen state, as one immutable object.
 *
 * [daysUntil] is derived here rather than in the composable so the arithmetic
 * has a place it can be tested. It is null when there is no forecast, which is
 * a different thing from zero.
 */
data class TodayUiState(
    val loading: Boolean = true,
    val periods: List<PeriodRecord> = emptyList(),
    val cycles: List<CycleRecord> = emptyList(),
    val forecast: Prediction? = null,
    val accuracy: PredictionAccuracy = PredictionAccuracy.Empty,
    val today: LocalDate = LocalDate.ofEpochDay(0),
    /** What the forecast said before any "Not yet" moved it. §22's last state shows both. */
    val originalForecastDate: LocalDate? = null,
    val fertility: FertilityEstimate? = null,
) {
    /**
     * §18's "today's fertility estimate", in §18's own vocabulary.
     *
     * `UNKNOWN` when there is a forecast but it is too vague to locate
     * ovulation — which is a different thing from having no forecast at all, and
     * worth saying out loud. Silently omitting the section would leave the user
     * wondering whether the feature exists; telling her the app does not know
     * her cycle well enough yet is true and gives her a reason to keep logging.
     */
    val fertilityToday: FertilityLikelihood?
        get() = when {
            fertility != null -> fertility.likelihoodOn(today)
            forecast != null -> FertilityLikelihood.UNKNOWN
            else -> null
        }

    val daysUntil: Long?
        get() = forecast?.let { it.mostLikelyStartDate.toEpochDay() - today.toEpochDay() }

    val cycleDay: Long?
        get() = periods.maxByOrNull { it.startDate }
            ?.let { today.toEpochDay() - it.startDate.toEpochDay() + 1 }
            ?.takeIf { it > 0 }

    /**
     * Typical period length, from the records that actually have an end date.
     *
     * Null below three, for the same reason accuracy figures are: two closed
     * periods is not a typical anything, and "typical duration 4 days" from a
     * single observation is the app stating a pattern it has not seen.
     */
    val typicalPeriodDuration: IntRange?
        get() {
            val lengths = periods.mapNotNull { p ->
                p.endDate?.let { (it.toEpochDay() - p.startDate.toEpochDay()).toInt() + 1 }
            }.filter { it in 1..14 }
            if (lengths.size < 3) return null
            val sorted = lengths.sorted()
            return sorted[sorted.size / 4]..sorted[(sorted.size * 3) / 4].coerceAtLeast(sorted[sorted.size / 4])
        }

    val status: CycleStatus
        get() = CycleStatusRules.statusFor(
            periods = periods,
            forecast = forecast,
            today = today,
            typicalPeriodDuration = typicalPeriodDuration,
            fertileWindow = fertility?.fertileWindow,
            ovulation = fertility?.ovulationDate,
            originalForecastDate = originalForecastDate,
        )

    /** 0f at the last period start, 1f at the predicted next. Drives the ring on Today. */
    val cycleProgress: Float
        get() {
            val start = periods.maxByOrNull { it.startDate }?.startDate ?: return 0f
            val target = forecast?.mostLikelyStartDate ?: return 0f
            val total = (target.toEpochDay() - start.toEpochDay()).toFloat()
            if (total <= 0f) return 1f
            return ((today.toEpochDay() - start.toEpochDay()) / total).coerceIn(0f, 1f)
        }
}

/** One-off questions and sheets. */
sealed interface TodayPrompt {
    /** §23's two-tap path: "Yes — Today" or "Choose Another Date". */
    data object LogPeriod : TodayPrompt

    /** §25's single, gentle question about a very short entry. */
    data class PeriodOrSpotting(val recordId: Long, val startDate: LocalDate) : TodayPrompt
}

private const val SHORT_PERIOD_DAYS = 2

@HiltViewModel
class TodayViewModel @Inject constructor(
    private val repository: CycleRepository,
    private val clock: Clock,
) : ViewModel() {

    val state: StateFlow<TodayUiState> =
        combine(
            repository.confirmedPeriods,
            repository.cycles,
            repository.forecast,
            repository.accuracy,
            repository.notYetObservations,
        ) { periods, cycles, forecast, accuracy, notYet ->
            TodayUiState(
                loading = false,
                periods = periods.sortedByDescending { it.startDate },
                cycles = cycles,
                forecast = forecast,
                accuracy = accuracy,
                today = LocalDate.now(clock),
                // The earliest day the user was asked and said "not yet" is the
                // day the app originally expected. Keeping it is what lets §22's
                // last state show both forecasts instead of quietly moving the
                // goalposts.
                originalForecastDate = notYet.minOfOrNull { it.date },
                // Derived here rather than collected as a sixth flow: it is a
                // pure function of the forecast, so a separate stream would be
                // two sources for one fact — and combine() has no typed
                // overload past five anyway.
                fertility = FertilityEstimate.from(forecast),
            )
        }.stateIn(
            scope = viewModelScope,
            // Keeps the database subscription alive briefly across a rotation
            // rather than tearing it down and re-reading, which is what makes
            // the forecast look like it flickers.
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = TodayUiState(today = LocalDate.now(clock)),
        )

    /**
     * Which one-off prompt is on screen, if any.
     *
     * Deliberately not persisted. §25 warns against over-questioning: a prompt
     * that survives a restart is a prompt the user has already declined once and
     * is being asked again.
     */
    private val _prompt = MutableStateFlow<TodayPrompt?>(null)
    val prompt: StateFlow<TodayPrompt?> = _prompt.asStateFlow()

    fun dismissPrompt() { _prompt.value = null }

    private val _message = MutableStateFlow<String?>(null)

    /** A transient line for the user. Null when there is nothing to say. */
    val message: StateFlow<String?> = _message.asStateFlow()

    fun messageShown() { _message.value = null }

    /**
     * Nothing the user can tap may take the app down.
     *
     * `viewModelScope.launch` has no handler by default, so any exception from a
     * repository write reaches the default handler and kills the process. That
     * is how tapping **Started today** twice crashed the app on a device: the
     * UNIQUE constraint on `startDate` threw straight through.
     *
     * The ordinary outcomes are now values rather than exceptions
     * ([PeriodWriteResult]), which is the real fix. This is the backstop for
     * everything else — a full disk, a corrupt database — because in a health
     * app a crash mid-write is adjacent to losing what the user just entered,
     * and a message they can read beats a process that vanished.
     */
    private val handler = CoroutineExceptionHandler { _, e ->
        // No cycle date, no record contents — §45. The exception's type is
        // enough to act on and carries nothing sensitive.
        _message.value = "Could not save that (${e::class.simpleName}). Nothing was changed."
    }

    private fun write(block: suspend () -> Unit) = viewModelScope.launch(handler) { block() }

    fun confirmStart(date: LocalDate) = write {
        when (val r = repository.confirmPeriodStart(date)) {
            is PeriodWriteResult.AlreadyRecorded -> _message.value = "That day is already logged."
            is PeriodWriteResult.Added -> _message.value = "Logged ✓ Your predictions have been updated."
            else -> Unit.also { check(r !is PeriodWriteResult.Conflict) }
        }
        _prompt.value = null
    }

    fun logSpotting(date: LocalDate) = write {
        repository.recordSpotting(date)
        _prompt.value = null
        // Said explicitly, because the whole point of the separate record is
        // that nothing else changed — §25: spotting must not reset the cycle.
        _message.value = "Spotting logged. Your cycle is unchanged."
    }

    fun showLogSheet() { _prompt.value = TodayPrompt.LogPeriod }

    fun edit(id: Long, start: LocalDate, end: LocalDate?) = write {
        when (repository.editPeriod(id, start, end)) {
            is PeriodWriteResult.Conflict ->
                _message.value = "There is already a period logged on that date."
            PeriodWriteResult.NotFound -> _message.value = "That record is no longer there."
            else -> _message.value = null
        }
    }

    /** §24 shows "Updated ✓" on confirmation. A silent write reads as a failed tap. */
    fun setEnd(id: Long, end: LocalDate?) = write {
        repository.setPeriodEnd(id, end)
        _message.value = if (end == null) "Marked as still going." else "Updated ✓"

        // §25: an extremely short entry may have been spotting. Asked ONCE, and
        // only when the length actually suggests it — the same paragraph that
        // permits the question also says not to over-question the user.
        val record = repository.confirmedPeriods.first().firstOrNull { it.id == id }
        val days = record?.let { r -> end?.let { (it.toEpochDay() - r.startDate.toEpochDay()).toInt() + 1 } }
        if (days != null && days <= SHORT_PERIOD_DAYS && !askedAboutSpotting.contains(id)) {
            askedAboutSpotting += id
            _prompt.value = TodayPrompt.PeriodOrSpotting(id, record.startDate)
        }
    }

    /** Ids already queried this session. One question per record, per §25. */
    private val askedAboutSpotting = mutableSetOf<Long>()

    /** Reclassify a very short entry as spotting: delete the period, keep the day. */
    fun reclassifyAsSpotting(id: Long, date: LocalDate) = write {
        repository.deletePeriod(id)
        repository.recordSpotting(date)
        _prompt.value = null
        _message.value = "Recorded as spotting. Your cycle is unchanged."
    }

    fun delete(id: Long) = write { repository.deletePeriod(id) }

    fun notYet() = write { repository.recordNotYet(LocalDate.now(clock)) }
}
