package dev.privacyllc.period.domain.prediction

import java.time.LocalDate
import kotlin.math.roundToLong
import kotlin.math.sqrt

/**
 * One forecast further out than the next one — *"will I have my period on the
 * week of the wedding?"*
 *
 * ## Why this is a separate type rather than a longer forecast
 *
 * [Prediction] answers a question the app can score: the period after the last
 * confirmed start either arrives inside its window or it does not, and
 * `PredictionRecord` writes down which. A projection eleven cycles out is not
 * that. Nothing scores it, nothing learns from it, and by the time it could be
 * scored the user will have logged four periods that replaced it.
 *
 * Giving it its own type keeps that difference honest in the type system: a
 * projection cannot be handed to anything expecting a forecast, cannot be
 * snapshotted into the accuracy figures, and carries [cyclesAhead] so nothing
 * downstream can forget how far it is reaching.
 *
 * ## The assumption, stated once
 *
 * Every projection past the first assumes **nothing changes** — that her cycles
 * go on behaving as they have, including a trend if one is running. That is a
 * real assumption about a real body, and it is the one thing a user must be told
 * rather than left to infer from a dashed outline. [PROJECTION_ASSUMPTION] is
 * that sentence, and it lives here beside the arithmetic it describes rather
 * than in a screen, because any surface that renders these has to say it.
 *
 * ## Why the window widens, and how fast
 *
 * Each cycle's length is an independent draw, so uncertainty accumulates as the
 * square root of the number of cycles rather than linearly — eleven cycles out
 * is about three times as uncertain as one, not eleven times. That is the whole
 * of [scaleFor].
 *
 * The independence is an approximation and worth naming: cycles are correlated
 * in real bodies, and a woman entering a period of upheaval will see them move
 * together. Square-root growth is therefore the optimistic edge of the range,
 * which is the second reason the assumption sentence is mandatory rather than
 * decorative.
 *
 * ## It stops rather than stretching
 *
 * Past [MAX_USEFUL_HALF_WIDTH] days of uncertainty a projection stops being an
 * answer. Nineteen days either side of a date is not a forecast, it is a
 * shrug — and the app already has a precedent for what to do about that, in
 * `FertilityEstimate`, which declines rather than showing a seventeen-day
 * fertile window. So does this: [project] returns the cycles it can stand
 * behind and stops, and the caller is told where it stopped rather than left to
 * work it out from an empty month.
 */
data class ProjectedCycle(
    /** 1 is the next period — the same date [Prediction] forecasts. */
    val cyclesAhead: Int,
    val mostLikelyStartDate: LocalDate,
    val windowStart: LocalDate,
    val windowEnd: LocalDate,
    val confidenceLabel: ConfidenceLabel,
) {
    init {
        require(cyclesAhead >= 1) { "a projection reaches at least one cycle ahead" }
        require(!windowStart.isAfter(windowEnd)) { "window start is after window end" }
        require(mostLikelyStartDate in windowStart..windowEnd) {
            "most likely start falls outside its own window"
        }
    }

    /** Dateless, for the reason [Prediction] gives. */
    override fun toString(): String =
        "ProjectedCycle(cyclesAhead=$cyclesAhead, confidence=$confidenceLabel)"
}

/**
 * A year of projections, and where they had to stop.
 *
 * [reachedHorizon] is false when uncertainty ran out before the calendar did.
 * Callers need the difference: "nothing to show here because your period is not
 * due" and "nothing to show here because we cannot see that far" are different
 * sentences, and only one of them is an invitation to keep logging.
 */
data class CycleProjection(
    val cycles: List<ProjectedCycle>,
    val reachedHorizon: Boolean,
) {
    val lastProjectedDate: LocalDate? get() = cycles.lastOrNull()?.windowEnd

    fun cycleCovering(date: LocalDate): ProjectedCycle? =
        cycles.firstOrNull { date >= it.windowStart && date <= it.windowEnd }

    companion object {
        val Empty = CycleProjection(emptyList(), reachedHorizon = false)

        /**
         * The sentence any surface showing a projection has to carry.
         *
         * Not "your period will start on the 14th of March". The difference
         * between those two is the entire honesty of this feature.
         */
        const val PROJECTION_ASSUMPTION =
            "If your cycles continue as they have, this is the forecast."

        /** What to say instead when the history cannot reach that far. */
        const val BEYOND_HORIZON =
            "Too far ahead for your history to say. Keep logging and this will reach further."

        /** How far out the calendar will ever look. A year, and not one cycle more. */
        const val MAX_MONTHS_AHEAD = 12L

        /**
         * Past this much uncertainty either side, a projection is a shrug.
         *
         * Ten days either side is already a three-week window. Wider than that
         * and marking a calendar with it tells the user nothing while looking
         * like it told her something, which is the failure `FertilityEstimate`
         * ran into on a real screen and now refuses.
         */
        const val MAX_USEFUL_HALF_WIDTH = 10

        /**
         * Project forward from a scored forecast.
         *
         * [forecast] is cycle 1 and is copied through unchanged — the near
         * forecast is the engine's, not a re-derivation of it, so the calendar
         * and the Today screen can never disagree about the next period.
         */
        fun from(
            forecast: Prediction?,
            typicalCycleDays: Double,
            today: LocalDate,
        ): CycleProjection {
            forecast ?: return Empty
            if (typicalCycleDays <= 0.0) return Empty

            val firstHalfWidth =
                (forecast.windowEnd.toEpochDay() - forecast.windowStart.toEpochDay()) / 2.0
            val horizon = today.plusMonths(MAX_MONTHS_AHEAD)

            val cycles = mutableListOf(
                ProjectedCycle(
                    cyclesAhead = 1,
                    mostLikelyStartDate = forecast.mostLikelyStartDate,
                    windowStart = forecast.windowStart,
                    windowEnd = forecast.windowEnd,
                    confidenceLabel = forecast.confidenceLabel,
                ),
            )

            var reachedHorizon = true
            var ahead = 2
            while (true) {
                val centre = forecast.mostLikelyStartDate
                    .plusDays((typicalCycleDays * (ahead - 1)).roundToLong())
                val halfWidth = scaleFor(firstHalfWidth, ahead).roundToLong()

                if (halfWidth > MAX_USEFUL_HALF_WIDTH) {
                    // Ran out of certainty before running out of calendar.
                    reachedHorizon = false
                    break
                }
                if (centre.minusDays(halfWidth).isAfter(horizon)) break   // ran out of calendar

                cycles += ProjectedCycle(
                    cyclesAhead = ahead,
                    mostLikelyStartDate = centre,
                    windowStart = centre.minusDays(halfWidth),
                    windowEnd = centre.plusDays(halfWidth),
                    confidenceLabel = labelFor(forecast.confidenceLabel, ahead),
                )
                ahead++
            }

            return CycleProjection(cycles, reachedHorizon)
        }

        /**
         * Half-width [ahead] cycles out, from the half-width of the first.
         *
         * Independent draws accumulate as sqrt(n). A floor of half a day keeps a
         * very confident user's later projections from collapsing to a point,
         * which would be the same overclaim in the other direction.
         */
        private fun scaleFor(firstHalfWidth: Double, ahead: Int): Double =
            maxOf(firstHalfWidth, 0.5) * sqrt(ahead.toDouble())

        /**
         * Confidence decays with distance, and can only fall.
         *
         * A projection is never more trustworthy than the forecast it is built
         * on, so this starts from that label and steps down — High survives a
         * few cycles, Medium fewer, and Low was never going to become anything
         * else. The thresholds are deliberately coarse: this decides whether a
         * user sees three dots or one, not a number.
         */
        private fun labelFor(base: ConfidenceLabel, ahead: Int): ConfidenceLabel = when (base) {
            ConfidenceLabel.HIGH -> when {
                ahead <= 3 -> ConfidenceLabel.HIGH
                ahead <= 7 -> ConfidenceLabel.MEDIUM
                else -> ConfidenceLabel.LOW
            }
            ConfidenceLabel.MEDIUM -> if (ahead <= 3) ConfidenceLabel.MEDIUM else ConfidenceLabel.LOW
            ConfidenceLabel.LOW -> ConfidenceLabel.LOW
        }
    }
}
