package dev.privacyllc.period.domain.prediction

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

/**
 * A deliberately simple baseline, and it is NOT the product.
 *
 * docs/planning/PRODUCT_PLAN.md §11 names `mean(all cycles)` as an acceptable
 * prototype baseline and an unacceptable final engine. This is that prototype:
 * a median rather than a mean so a single outlier cannot drag it, a window from
 * the median absolute deviation, and confidence from agreement rather than
 * volume.
 *
 * It exists so the skeleton has something honest to render and something to
 * measure the real engine against. Batch 02 replaces it with the recency
 * weighted, trend-aware, "not yet"-conditioned engine §12 specifies — at which
 * point these tests become the regression suite that says the replacement is
 * better rather than merely different.
 */
class BaselinePredictionEngine : PredictionEngine {

    override val modelVersion: String = "baseline-1"

    override fun predict(input: PredictionInput): Prediction? {
        val (confirmedStarts, today, notYet, _) = input
        val starts = confirmedStarts.distinct().sorted()
        if (starts.isEmpty()) return null

        val intervals = starts.zipWithNext { a, b -> b.toEpochDay() - a.toEpochDay() }
            .filter { it > 0 }

        // No history at all: the population default, said with the lowest
        // confidence the type can express. Never presented as knowledge.
        val centre = if (intervals.isEmpty()) DEFAULT_CYCLE_DAYS else median(intervals)
        val spread = if (intervals.size < 2) DEFAULT_SPREAD_DAYS else medianAbsoluteDeviation(intervals, centre)

        val lastStart = starts.last()
        var likely = lastStart.plusDays(centre.roundToLong())
        val halfWidth = spread.roundToLong().coerceIn(MIN_HALF_WIDTH_DAYS, MAX_HALF_WIDTH_DAYS)
        var windowStart = likely.minusDays(halfWidth)
        var windowEnd = likely.plusDays(halfWidth)

        // "Not yet" removes dates from the front of the window. A date the user
        // has told us was not the start cannot remain a future start candidate,
        // which is the specific requirement in §51's "Not yet" acceptance case.
        val latestRuledOut = notYet.map { it.date }.maxOrNull()
        val floor = listOfNotNull(latestRuledOut, today.minusDays(1)).maxOrNull()
        if (floor != null && !windowStart.isAfter(floor)) {
            windowStart = floor.plusDays(1)
            if (windowEnd.isBefore(windowStart)) windowEnd = windowStart
            if (likely.isBefore(windowStart)) likely = windowStart
        }

        val confidence = confidenceFor(intervals.size, spread, notYet.size)

        return Prediction(
            mostLikelyStartDate = likely,
            windowStart = windowStart,
            windowEnd = windowEnd,
            confidenceScore = confidence,
            confidenceLabel = labelFor(confidence, intervals.size),
            modelVersion = modelVersion,
        )
    }

    /**
     * Confidence is agreement, not volume.
     *
     * §15 is explicit that a large number of recorded cycles must not on its own
     * produce "High" — a user whose cycles run 25, 33, 28, 37, 26, 32 has plenty
     * of data and an unpredictable cycle, and telling them otherwise is the
     * failure. So spread dominates, count only caps.
     */
    private fun confidenceFor(cycleCount: Int, spread: Double, notYetCount: Int): Double {
        if (cycleCount == 0) return 0.1
        val agreement = 1.0 / (1.0 + spread / 2.0)
        val evidence = (cycleCount.toDouble() / SATURATION_CYCLES).coerceAtMost(1.0)
        val uncertainty = notYetCount * NOT_YET_PENALTY
        return (agreement * evidence - uncertainty).coerceIn(0.0, 1.0)
    }

    private fun labelFor(confidence: Double, cycleCount: Int): ConfidenceLabel = when {
        cycleCount < 2 -> ConfidenceLabel.LOW
        confidence >= HIGH_THRESHOLD -> ConfidenceLabel.HIGH
        confidence >= MEDIUM_THRESHOLD -> ConfidenceLabel.MEDIUM
        else -> ConfidenceLabel.LOW
    }

    private fun median(values: List<Long>): Double {
        val sorted = values.sorted()
        val mid = sorted.size / 2
        return if (sorted.size % 2 == 1) sorted[mid].toDouble()
        else (sorted[mid - 1] + sorted[mid]) / 2.0
    }

    private fun medianAbsoluteDeviation(values: List<Long>, centre: Double): Double {
        val deviations = values.map { abs(it - centre) }.sorted()
        val mid = deviations.size / 2
        val mad = if (deviations.size % 2 == 1) deviations[mid]
        else (deviations[mid - 1] + deviations[mid]) / 2.0
        return maxOf(mad, MIN_SPREAD_DAYS)
    }

    private companion object {
        const val DEFAULT_CYCLE_DAYS = 28.0
        const val DEFAULT_SPREAD_DAYS = 4.0
        const val MIN_SPREAD_DAYS = 0.5
        const val MIN_HALF_WIDTH_DAYS = 1L
        const val MAX_HALF_WIDTH_DAYS = 10L
        const val SATURATION_CYCLES = 6.0
        const val NOT_YET_PENALTY = 0.08
        const val HIGH_THRESHOLD = 0.6
        const val MEDIUM_THRESHOLD = 0.4
    }
}
