package dev.privacyllc.period.domain.prediction

import java.time.LocalDate

enum class ConfidenceLabel { LOW, MEDIUM, HIGH }

/**
 * A forecast, taken before the outcome is known.
 *
 * The window is not decoration. docs/planning/PRODUCT_PLAN.md §8 and §15 require
 * a range and a confidence rather than a single date presented as fact, so this
 * type has no way to express a bare certain date.
 */
data class Prediction(
    val mostLikelyStartDate: LocalDate,
    val windowStart: LocalDate,
    val windowEnd: LocalDate,
    val confidenceScore: Double,
    val confidenceLabel: ConfidenceLabel,
    val modelVersion: String,
) {
    init {
        // These messages name the invariant and not the dates, deliberately.
        //
        // §45 forbids raw cycle dates in crash reports, and an `IllegalArgumentException`
        // message is a crash report — it is the one string guaranteed to be
        // collected, by any reporter, without anybody choosing to log it. These
        // three requires previously interpolated five predicted dates between
        // them, which is a health-data leak that needed no logging statement to
        // exist.
        //
        // Nothing diagnostic is lost: which invariant broke is the useful half,
        // and the values are in a debugger for anyone who can reproduce it.
        // `confidenceScore` stays because a number between 0 and 1 says nothing
        // about when anything happened.
        require(!windowStart.isAfter(windowEnd)) { "window start is after window end" }
        require(mostLikelyStartDate in windowStart..windowEnd) {
            "most likely start falls outside the window"
        }
        require(confidenceScore in 0.0..1.0) { "confidence $confidenceScore is outside 0..1" }
    }

    /**
     * Dateless, for the reason [dev.privacyllc.period.domain.cycle.PeriodRecord]
     * gives: a data class renders itself into any string that touches it, and a
     * forecast is four dates.
     */
    override fun toString(): String =
        "Prediction(confidence=$confidenceLabel, model=$modelVersion)"
}

/**
 * The user told us the period had not started by [date].
 *
 * A censoring observation, not a nudge: the next forecast is re-conditioned on
 * it rather than shifted by a day. See PRODUCT_PLAN.md §13.
 */
data class NotYetObservation(
    val date: LocalDate,
    val predictionId: Long? = null,
) {
    /** Dateless. It records that the user answered, not when their body did. */
    override fun toString(): String = "NotYetObservation(predictionId=$predictionId)"
}

/**
 * Everything an engine is allowed to know.
 *
 * A parameter object rather than a growing argument list, because §15 lists
 * eight things confidence should reflect and they will not all arrive at once.
 *
 * [recentAbsoluteErrors] is how the app learns from being wrong — §12 step 5
 * asks for the window to widen from *recent prediction error* as well as from
 * the user's variability, and every scored `PredictionRecord` already holds one.
 * Newest first. Passed in rather than fetched, because this module must not know
 * that storage exists.
 */
data class PredictionInput(
    val confirmedStarts: List<LocalDate>,
    val today: LocalDate,
    val notYet: List<NotYetObservation> = emptyList(),
    val recentAbsoluteErrors: List<Int> = emptyList(),
)

/**
 * The contract every version of the engine satisfies.
 *
 * Deterministic for the same inputs and model version — PRODUCT_PLAN.md §11 —
 * which is what makes the acceptance cases in §51 testable at all, and what
 * makes one engine comparable to the next.
 */
interface PredictionEngine {
    val modelVersion: String

    fun predict(input: PredictionInput): Prediction?
}

/** The common call, for callers with no scored history to offer. */
fun PredictionEngine.predict(
    confirmedStarts: List<LocalDate>,
    today: LocalDate,
    notYet: List<NotYetObservation> = emptyList(),
): Prediction? = predict(PredictionInput(confirmedStarts, today, notYet))
