package dev.privacyllc.period.domain.prediction

import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate
import kotlin.math.abs

/**
 * The behaviour §12 and §15 ask for that the acceptance cases do not pin.
 *
 * The §51 cases say what any engine must not get wrong. These say what *this*
 * engine is for: learning from being wrong, following a drift without chasing
 * noise, and treating "Not yet" as information rather than as a nudge.
 */
class PersonalEngineTest {

    private val engine = PersonalPredictionEngine()
    private val anchor = LocalDate.of(2026, 8, 1)

    /** Confirmed starts ending on [anchor], from a run of cycle lengths. */
    private fun starts(vararg gaps: Long): List<LocalDate> {
        var cursor = anchor
        val out = mutableListOf(cursor)
        gaps.reversed().forEach { cursor = cursor.minusDays(it); out += cursor }
        return out.sorted()
    }

    private fun predict(
        gaps: LongArray,
        today: LocalDate = anchor.plusDays(1),
        notYet: List<LocalDate> = emptyList(),
        errors: List<Int> = emptyList(),
    ) = engine.predict(
        PredictionInput(
            confirmedStarts = starts(*gaps),
            today = today,
            notYet = notYet.map { NotYetObservation(it) },
            recentAbsoluteErrors = errors,
        ),
    )!!

    private fun width(p: Prediction) = p.windowEnd.toEpochDay() - p.windowStart.toEpochDay()

    // -----------------------------------------------------------------------
    // §15's two worked examples, as tests
    // -----------------------------------------------------------------------

    @Test fun `the stable history from section 15 reads high with a tight window`() {
        val p = predict(longArrayOf(28, 29, 28, 29, 28, 29))
        assertEquals(ConfidenceLabel.HIGH, p.confidenceLabel)
        assertTrue("window was ${width(p)} days", width(p) <= 4)
    }

    @Test fun `the variable history from section 15 reads low with a wide window`() {
        val p = predict(longArrayOf(25, 33, 28, 37, 26, 32))
        assertEquals(ConfidenceLabel.LOW, p.confidenceLabel)
        assertTrue("window was only ${width(p)} days", width(p) >= 6)
    }

    @Test fun `volume alone never buys high confidence`() {
        // §15, stated as plainly as the document does: do not assign High purely
        // because the user has entered a large number of cycles. Twenty
        // disagreeing cycles is more data and no more predictability.
        val many = LongArray(20) { if (it % 2 == 0) 25L else 37L }
        assertTrue(predict(many).confidenceLabel != ConfidenceLabel.HIGH)
    }

    // -----------------------------------------------------------------------
    // §12 step 5 — the app learns from being wrong
    // -----------------------------------------------------------------------

    @Test fun `a run of bad forecasts widens the window even when the cycles look tidy`() {
        val gaps = longArrayOf(28, 29, 28, 29, 28)

        val trusted = predict(gaps, errors = listOf(0, 1, 0, 1))
        val burned = predict(gaps, errors = listOf(4, 5, 4, 6))

        assertTrue(
            "identical histories: window ${width(trusted)} when accurate vs ${width(burned)} when not",
            width(burned) > width(trusted),
        )
        assertTrue(
            "and confidence must fall with it",
            burned.confidenceScore < trusted.confidenceScore,
        )
    }

    @Test fun `being right does not widen anything`() {
        val gaps = longArrayOf(28, 29, 28, 29, 28)
        assertTrue(width(predict(gaps, errors = listOf(0, 0, 1))) <= width(predict(gaps)))
    }

    // -----------------------------------------------------------------------
    // §12 step 6 — trend, without overfitting
    // -----------------------------------------------------------------------

    @Test fun `a consistent lengthening is followed`() {
        val drifting = predict(longArrayOf(27, 28, 29, 30, 31))
        val steady = predict(longArrayOf(29, 29, 29, 29, 29))

        assertTrue(
            "a lengthening history should forecast later than a flat one at the same centre",
            drifting.mostLikelyStartDate >= steady.mostLikelyStartDate,
        )
    }

    @Test fun `one unusual cycle does not manufacture a trend`() {
        // §12: do not overfit one cycle. The difference between these two
        // histories is a single recent value, and the forecast must barely move.
        val flat = predict(longArrayOf(29, 29, 29, 29, 29, 29))
        val blip = predict(longArrayOf(29, 29, 29, 29, 29, 34))

        val moved = abs(blip.mostLikelyStartDate.toEpochDay() - flat.mostLikelyStartDate.toEpochDay())
        assertTrue("the forecast moved $moved days on one cycle", moved <= 2)
    }

    // -----------------------------------------------------------------------
    // §13 — "Not yet" as conditioning
    // -----------------------------------------------------------------------

    @Test fun `not yet moves the date, the window and the confidence together`() {
        val gaps = longArrayOf(29, 29, 29, 29)
        val due = anchor.plusDays(29)

        val before = predict(gaps, today = due)
        val after = predict(gaps, today = due, notYet = listOf(due))

        assertTrue("the ruled-out day must be gone", after.windowStart.isAfter(due))
        assertTrue("the most likely date must move", after.mostLikelyStartDate > before.mostLikelyStartDate)
        assertTrue("and confidence must fall", after.confidenceScore < before.confidenceScore)
    }

    @Test fun `repeated not yet keeps updating rather than stepping evenly`() {
        // §13: repeated observations should progressively update the forecast.
        // A design that moves the date by one day each time is the thing §13
        // explicitly rules out, so the steps must not all be equal.
        val gaps = longArrayOf(29, 29, 29, 29)
        val due = anchor.plusDays(29)

        val observed = mutableListOf<LocalDate>()
        val modes = mutableListOf<LocalDate>()
        val confidences = mutableListOf<Double>()

        repeat(5) { i ->
            observed += due.plusDays(i.toLong())
            val p = predict(gaps, today = observed.last(), notYet = observed.toList())
            modes += p.mostLikelyStartDate
            confidences += p.confidenceScore
            assertTrue("every ruled-out day must stay out", p.windowStart.isAfter(observed.last()))
            assertTrue("the window must never collapse", p.windowEnd >= p.windowStart)
        }

        assertTrue("the forecast must keep moving forward", modes == modes.sorted())
        assertTrue("confidence must never rise on being told it was wrong",
            confidences.zipWithNext().all { (a, b) -> b <= a })
    }

    @Test fun `a user far past every candidate still gets a usable answer`() {
        // Someone who has answered "Not yet" for three weeks. The distribution
        // has no mass left where it started; it must restart rather than divide
        // by zero or assert a date it has no evidence for.
        val gaps = longArrayOf(29, 29, 29)
        val due = anchor.plusDays(29)
        val observed = (0..20L).map { due.plusDays(it) }

        val p = predict(gaps, today = observed.last(), notYet = observed)

        assertTrue(p.windowStart.isAfter(observed.last()))
        assertTrue(p.mostLikelyStartDate >= p.windowStart)
        assertTrue(p.mostLikelyStartDate <= p.windowEnd)
        assertTrue("three weeks of not-yet is not a confident state", p.confidenceScore < 0.4)
    }

    // -----------------------------------------------------------------------
    // §14 — a questionable interval reduces confidence without being dropped
    // -----------------------------------------------------------------------

    @Test fun `a probable missed period lowers confidence rather than being ignored`() {
        val clean = predict(longArrayOf(29, 28, 30, 29, 29))
        val gapped = predict(longArrayOf(29, 28, 30, 29, 61))

        assertTrue(
            "a history with a suspected missing entry must not read as confidently as a clean one",
            gapped.confidenceScore < clean.confidenceScore,
        )
        // And it must not have dragged the forecast out to a 45-day cycle.
        val interval = gapped.mostLikelyStartDate.toEpochDay() - anchor.toEpochDay()
        assertTrue("forecast interval was $interval days", interval in 27..32)
    }
}
