package dev.privacyllc.period.domain.prediction

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

/**
 * One observed gap between confirmed period starts, and what the model thinks
 * of it.
 *
 * [weight] is how much this interval should influence the forecast: recency
 * decay, reduced further when the interval is [questionable]. It is never zero
 * for real data — PRODUCT_PLAN.md §12 step 2 is explicit that unusual data is
 * marked for review or down-weighted, **never silently discarded**.
 */
data class Interval(
    val days: Int,
    val startedOn: LocalDate,
    val endedOn: LocalDate,
    /** 0 is the most recent interval. */
    val ageIndex: Int,
    val weight: Double,
    val questionable: QuestionableReason? = null,
) {
    val isQuestionable: Boolean get() = questionable != null
}

/**
 * Why an interval was flagged.
 *
 * Carried rather than reduced to a boolean because the two produce different
 * questions: a gap roughly twice the usual is *"did you forget to record a
 * period?"*, while one merely far from usual is not a question worth asking at
 * all — §25 warns against over-questioning the user.
 */
enum class QuestionableReason {
    /** Close to a whole multiple of this user's usual cycle: a period was probably missed. */
    POSSIBLE_MISSED_PERIOD,

    /** Far from this user's usual, but not a plausible multiple. Kept, down-weighted, not queried. */
    UNUSUAL_LENGTH,
}

/**
 * Turn confirmed start dates into weighted intervals.
 *
 * ## Why detection is relative to the user, never to a constant
 *
 * A 45-day gap is unremarkable for somebody whose cycles run 40 and very odd for
 * somebody at 28. A global threshold gets one of those two wrong, and the one it
 * gets wrong is the user whose cycle is already unusual — exactly the person
 * this product exists for, and the one most tired of apps assuming she is
 * average.
 *
 * So the reference is a robust centre of the user's *other* intervals, and a gap
 * is only called a possible missed period when it sits near a whole multiple of
 * that. 61 against a usual of 29 is two cycles with a period unrecorded between
 * them; 45 against a usual of 29 is odd but not a clean multiple, so it is
 * down-weighted and left alone rather than turned into a question.
 */
object IntervalAnalysis {

    /** Newest cycle counts fully; each older one a little less. Tuned by test, not picked. */
    const val RECENCY_DECAY = 0.85

    /** How much a questionable interval still counts. Not zero — see §12 step 2. */
    const val QUESTIONABLE_WEIGHT_FACTOR = 0.25

    /** Below this many intervals there is no "usual" to compare against. */
    const val MINIMUM_FOR_DETECTION = 3

    /** Within this fraction of a whole multiple counts as "near" it. */
    private const val MULTIPLE_TOLERANCE = 0.18

    /** Beyond this fraction from the centre is unusual for this user. */
    private const val UNUSUAL_TOLERANCE = 0.35

    /** Nothing below this is a cycle; it is a re-entry or a data error. */
    private const val MINIMUM_PLAUSIBLE_DAYS = 10

    fun intervals(confirmedStarts: List<LocalDate>): List<Interval> {
        val starts = confirmedStarts.distinct().sorted()
        if (starts.size < 2) return emptyList()

        val raw = starts.zipWithNext { a, b ->
            a to (b.toEpochDay() - a.toEpochDay()).toInt()
        }.filter { it.second >= MINIMUM_PLAUSIBLE_DAYS }

        if (raw.isEmpty()) return emptyList()

        val lengths = raw.map { it.second }
        // The reference excludes nothing yet — a robust centre is already
        // resistant to the one value we are about to judge against it, which is
        // the reason for using a median rather than a mean here.
        val reference = median(lengths.map { it.toDouble() })

        val newestFirst = raw.indices.reversed().toList()
        return raw.mapIndexed { index, (start, days) ->
            val ageIndex = newestFirst.indexOf(index)
            val reason = classify(days, reference, lengths.size)
            val recency = Math.pow(RECENCY_DECAY, ageIndex.toDouble())
            Interval(
                days = days,
                startedOn = start,
                endedOn = start.plusDays(days.toLong()),
                ageIndex = ageIndex,
                weight = recency * if (reason != null) QUESTIONABLE_WEIGHT_FACTOR else 1.0,
                questionable = reason,
            )
        }
    }

    private fun classify(days: Int, reference: Double, sampleSize: Int): QuestionableReason? {
        if (sampleSize < MINIMUM_FOR_DETECTION || reference <= 0) return null

        val ratio = days / reference

        // Near a whole multiple of two or more: a period was probably missed.
        // Checked before the generic "unusual" test, because it is the one that
        // earns a question.
        for (multiple in 2..4) {
            if (abs(ratio - multiple) <= MULTIPLE_TOLERANCE) return QuestionableReason.POSSIBLE_MISSED_PERIOD
        }

        if (abs(ratio - 1.0) > UNUSUAL_TOLERANCE) return QuestionableReason.UNUSUAL_LENGTH

        return null
    }

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

    /** Weighted median: the value where cumulative weight first reaches half the total. */
    internal fun weightedMedian(values: List<Pair<Double, Double>>): Double {
        if (values.isEmpty()) return 0.0
        val sorted = values.sortedBy { it.first }
        val total = sorted.sumOf { it.second }
        if (total <= 0.0) return median(sorted.map { it.first })
        var cumulative = 0.0
        for ((value, weight) in sorted) {
            cumulative += weight
            if (cumulative >= total / 2.0) return value
        }
        return sorted.last().first
    }
}
