package dev.privacyllc.period.domain.prediction

import dev.privacyllc.period.domain.cycle.PeriodRecord
import java.time.LocalDate

/**
 * Which of PRODUCT_PLAN.md §22's states the user is in right now.
 *
 * Derived here rather than inside a composable so the boundaries are testable.
 * They are the kind of logic that is obviously right until a user is on the
 * boundary of two of them — the day a period is predicted, the day after it
 * ends — and every one of those days is somebody's actual Tuesday.
 */
sealed interface CycleStatus {

    /** Nothing logged. The empty state, not a forecast of nothing. */
    data object NoData : CycleStatus

    /** A period is on. §22's first state. */
    data class DuringPeriod(
        val dayOfPeriod: Int,
        val startedOn: LocalDate,
        /**
         * Set once the user has said when it ended — which can be today.
         *
         * The distinction matters on screen rather than in the maths: a period
         * ending today still includes today, so the state does not change when
         * "Period ended" is tapped. Without this field the screen showed exactly
         * the same thing afterwards and the tap looked like it had failed.
         */
        val endedOn: LocalDate?,
        val typicalDurationDays: IntRange?,
    ) : CycleStatus

    /**
     * The ordinary middle of a cycle.
     *
     * [daysToFertileWindow] is null until Batch 04 estimates fertility. §22
     * shows the line; showing a placeholder number instead would be inventing a
     * fertility estimate, which is the one thing this screen must not do.
     */
    data class BetweenPeriodAndFertile(
        val cycleDay: Int,
        val daysToNextPeriod: Int,
        val daysToFertileWindow: Int?,
    ) : CycleStatus

    /** Inside the estimated fertile window. Batch 04 makes this reachable. */
    data class DuringFertileWindow(
        val cycleDay: Int,
        val daysToOvulation: Int?,
        val daysToNextPeriod: Int,
    ) : CycleStatus

    /** The countdown. §22's hero state, and the one §38 says must dominate. */
    data class PeriodApproaching(
        val cycleDay: Int,
        val daysUntil: Int,
        val forecast: Prediction,
    ) : CycleStatus

    /** The day itself. */
    data class PredictedDay(val cycleDay: Int, val forecast: Prediction) : CycleStatus

    /**
     * Past the forecast, and **never described as late**.
     *
     * §22 is explicit: avoid "Your period is late!". Nothing is wrong with the
     * user, the model was imprecise, and the copy says so. [originalForecast] is
     * kept so the screen can show what it had said before re-conditioning —
     * which is the app being accountable rather than quietly moving the goalposts.
     */
    data class BeyondForecast(
        val cycleDay: Int,
        val daysPast: Int,
        val originalForecast: LocalDate,
        val updated: Prediction,
    ) : CycleStatus
}

object CycleStatusRules {
    /**
     * How long an unclosed period is assumed to still be running.
     *
     * Past this, the user simply never tapped "Period Ended" — keeping the app
     * on the during-period screen for three weeks would be obviously wrong and
     * would hide the next forecast. Not a medical claim; a UI timeout.
     */
    const val MAX_ASSUMED_PERIOD_DAYS = 10

    /** Within this many days, the countdown becomes the screen. */
    const val APPROACHING_DAYS = 7

    fun statusFor(
        periods: List<PeriodRecord>,
        forecast: Prediction?,
        today: LocalDate,
        typicalPeriodDuration: IntRange? = null,
        /** From Batch 04. Null means fertility is not estimated yet. */
        fertileWindow: ClosedRange<LocalDate>? = null,
        ovulation: LocalDate? = null,
        /** What the forecast said before any "Not yet" re-conditioned it. */
        originalForecastDate: LocalDate? = null,
    ): CycleStatus {
        val latest = periods.filter { it.isConfirmed }.maxByOrNull { it.startDate }
            ?: return CycleStatus.NoData

        val cycleDay = (today.toEpochDay() - latest.startDate.toEpochDay()).toInt() + 1
        if (cycleDay < 1) return CycleStatus.NoData

        // 1. A period that is on beats everything: it is a fact, and the rest of
        //    this function is estimates.
        val end = latest.endDate
        val running = when {
            end != null -> !today.isAfter(end)
            else -> cycleDay <= MAX_ASSUMED_PERIOD_DAYS
        }
        if (running && !latest.startDate.isAfter(today)) {
            return CycleStatus.DuringPeriod(
                dayOfPeriod = cycleDay,
                startedOn = latest.startDate,
                endedOn = end,
                typicalDurationDays = typicalPeriodDuration,
            )
        }

        forecast ?: return CycleStatus.BetweenPeriodAndFertile(cycleDay, daysToNextPeriod = 0, null)

        val daysUntil = (forecast.mostLikelyStartDate.toEpochDay() - today.toEpochDay()).toInt()

        return when {
            // 2. Past what was forecast. Checked before the predicted day so a
            //    re-conditioned forecast cannot keep saying "may start today"
            //    every day for a week.
            daysUntil < 0 || (originalForecastDate != null && today > originalForecastDate) ->
                CycleStatus.BeyondForecast(
                    cycleDay = cycleDay,
                    daysPast = ((originalForecastDate ?: forecast.mostLikelyStartDate).let {
                        today.toEpochDay() - it.toEpochDay()
                    }).toInt(),
                    originalForecast = originalForecastDate ?: forecast.mostLikelyStartDate,
                    updated = forecast,
                )

            daysUntil == 0 -> CycleStatus.PredictedDay(cycleDay, forecast)

            fertileWindow != null && today in fertileWindow ->
                CycleStatus.DuringFertileWindow(
                    cycleDay = cycleDay,
                    daysToOvulation = ovulation?.let { (it.toEpochDay() - today.toEpochDay()).toInt() },
                    daysToNextPeriod = daysUntil,
                )

            daysUntil <= APPROACHING_DAYS -> CycleStatus.PeriodApproaching(cycleDay, daysUntil, forecast)

            else -> CycleStatus.BetweenPeriodAndFertile(
                cycleDay = cycleDay,
                daysToNextPeriod = daysUntil,
                daysToFertileWindow = fertileWindow?.let {
                    (it.start.toEpochDay() - today.toEpochDay()).toInt().takeIf { d -> d > 0 }
                },
            )
        }
    }
}
