package dev.privacyllc.period.domain.prediction

import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.time.LocalDate

/**
 * The acceptance cases from docs/planning/PRODUCT_PLAN.md §51, written against
 * the interface rather than any implementation, and run against **every** engine
 * in the tree — see the concrete subclasses at the bottom of this file.
 *
 * These are the tests the product's headline claim rests on. A change that
 * makes any of them fail is a core product defect, not a tuning regression:
 * §3 names a 35-day user being predicted at 28 in exactly those words.
 */
abstract class PredictionAcceptanceTest {

    /**
     * Every engine is held to this same contract.
     *
     * That is what makes "the new one is better" a measurable claim rather than
     * an opinion: if the replacement could quietly relax one of these, better
     * would only mean different.
     */
    protected abstract val engine: PredictionEngine

    /** Build confirmed start dates from a run of cycle lengths, ending [ending]. */
    private fun startsFromIntervals(vararg intervals: Long, ending: LocalDate): List<LocalDate> {
        val dates = ArrayDeque<LocalDate>()
        var cursor = ending
        dates.addFirst(cursor)
        for (gap in intervals.reversed()) {
            cursor = cursor.minusDays(gap)
            dates.addFirst(cursor)
        }
        return dates.toList()
    }

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

    // -----------------------------------------------------------------------
    // §51 — Stable longer-cycle user
    // -----------------------------------------------------------------------

    @Test
    fun `a stable 35-day user is not predicted at 28 days`() {
        val lastStart = LocalDate.of(2026, 8, 1)
        val starts = startsFromIntervals(35, 35, 34, 36, 35, ending = lastStart)

        val p = engine.predict(starts, today = lastStart.plusDays(1))
        assertNotNull(p)
        val predictedInterval = p!!.mostLikelyStartDate.toEpochDay() - lastStart.toEpochDay()

        assertTrue(
            "forecast interval was $predictedInterval days; a 35-day user must not be predicted near 28",
            predictedInterval in 34..36,
        )
    }

    @Test
    fun `a stable user gets high confidence and a tight window`() {
        val lastStart = LocalDate.of(2026, 8, 1)
        val starts = startsFromIntervals(35, 35, 34, 36, 35, ending = lastStart)

        val p = engine.predict(starts, today = lastStart.plusDays(1))!!
        assertEquals(ConfidenceLabel.HIGH, p.confidenceLabel)
        assertTrue("window was ${windowWidth(p)} days wide", windowWidth(p) <= 4)
    }

    // -----------------------------------------------------------------------
    // §51 — Variable user
    // -----------------------------------------------------------------------

    @Test
    fun `a variable user gets a wider window and lower confidence than a stable one`() {
        val lastStart = LocalDate.of(2026, 8, 1)
        val today = lastStart.plusDays(1)

        val stable = engine.predict(startsFromIntervals(35, 35, 34, 36, 35, ending = lastStart), today)!!
        val variable = engine.predict(startsFromIntervals(25, 34, 29, 37, 26, 32, ending = lastStart), today)!!

        assertTrue(
            "variable window ${windowWidth(variable)} was not wider than stable ${windowWidth(stable)}",
            windowWidth(variable) > windowWidth(stable),
        )
        assertTrue(
            "variable confidence ${variable.confidenceScore} was not below stable ${stable.confidenceScore}",
            variable.confidenceScore < stable.confidenceScore,
        )
        assertEquals(ConfidenceLabel.LOW, variable.confidenceLabel)
    }

    @Test
    fun `confidence is agreement rather than volume`() {
        // Six recorded cycles, all disagreeing. §15: do not assign High purely
        // because the user has entered a large number of cycles.
        val lastStart = LocalDate.of(2026, 8, 1)
        val p = engine.predict(startsFromIntervals(25, 34, 29, 37, 26, 32, ending = lastStart), lastStart.plusDays(1))!!
        assertFalse("six disagreeing cycles must not read as High", p.confidenceLabel == ConfidenceLabel.HIGH)
    }

    // -----------------------------------------------------------------------
    // §51 — Outlier
    // -----------------------------------------------------------------------

    @Test
    fun `a single 45-day outlier does not dominate the forecast`() {
        val lastStart = LocalDate.of(2026, 8, 1)
        val starts = startsFromIntervals(29, 29, 28, 30, 45, 29, ending = lastStart)

        val p = engine.predict(starts, today = lastStart.plusDays(1))!!
        val predictedInterval = p.mostLikelyStartDate.toEpochDay() - lastStart.toEpochDay()

        // The arithmetic mean of that history is ~31.7. A robust centre stays near 29.
        assertTrue(
            "forecast interval was $predictedInterval days; the 45-day observation dominated",
            predictedInterval in 28..30,
        )
    }

    // -----------------------------------------------------------------------
    // §51 — Not yet
    // -----------------------------------------------------------------------

    @Test
    fun `a not-yet date can no longer be a future start candidate`() {
        val starts = listOf(
            LocalDate.of(2026, 5, 28),
            LocalDate.of(2026, 6, 26),
            LocalDate.of(2026, 7, 25),
        )
        val today = LocalDate.of(2026, 8, 22)

        val before = engine.predict(starts, today)!!
        assertEquals(LocalDate.of(2026, 8, 22), before.windowStart)

        val after = engine.predict(starts, today, listOf(NotYetObservation(date = today)))!!
        assertTrue(
            "Aug 22 was ruled out and is still in the window ${after.windowStart}..${after.windowEnd}",
            after.windowStart.isAfter(today),
        )
        assertTrue(
            "the window must stay valid after re-conditioning",
            !after.windowStart.isAfter(after.windowEnd),
        )
    }

    @Test
    fun `a not-yet observation lowers confidence rather than only shifting the date`() {
        val starts = listOf(
            LocalDate.of(2026, 5, 28),
            LocalDate.of(2026, 6, 26),
            LocalDate.of(2026, 7, 25),
        )
        val today = LocalDate.of(2026, 8, 22)

        val before = engine.predict(starts, today)!!
        val after = engine.predict(starts, today, listOf(NotYetObservation(date = today)))!!

        assertTrue(
            "confidence did not fall: ${before.confidenceScore} -> ${after.confidenceScore}",
            after.confidenceScore < before.confidenceScore,
        )
    }

    // -----------------------------------------------------------------------
    // Boundaries the UI will actually hit
    // -----------------------------------------------------------------------

    @Test
    fun `no history produces no prediction rather than a confident guess`() {
        assertNull(engine.predict(emptyList(), LocalDate.of(2026, 8, 18)))
    }

    @Test
    fun `a single confirmed period predicts with the lowest confidence`() {
        val p = engine.predict(listOf(LocalDate.of(2026, 8, 1)), LocalDate.of(2026, 8, 2))!!
        assertEquals(ConfidenceLabel.LOW, p.confidenceLabel)
    }

    @Test
    fun `a window never opens in the past`() {
        // A user who stopped logging months ago still gets a usable answer.
        val starts = listOf(LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 30))
        val today = LocalDate.of(2026, 8, 18)

        val p = engine.predict(starts, today)!!
        assertFalse("window opened at ${p.windowStart}, before today", p.windowStart.isBefore(today))
    }

    @Test
    fun `the same input and model version give the same answer`() {
        val starts = startsFromIntervals(29, 28, 30, 29, ending = LocalDate.of(2026, 8, 1))
        val today = LocalDate.of(2026, 8, 18)
        assertEquals(engine.predict(starts, today), engine.predict(starts, today))
    }

    @Test
    fun `duplicate and unordered starts do not change the answer`() {
        val ordered = startsFromIntervals(29, 28, 30, ending = LocalDate.of(2026, 8, 1))
        val messy = (ordered + ordered.first() + ordered.last()).shuffled(kotlin.random.Random(7))
        val today = LocalDate.of(2026, 8, 18)
        assertEquals(engine.predict(ordered, today), engine.predict(messy, today))
    }
}

/** The prototype. Kept as the control the replacement has to beat. */
class BaselineAcceptanceTest : PredictionAcceptanceTest() {
    override val engine: PredictionEngine = BaselinePredictionEngine()
}

/** The engine §12 specifies. */
class PersonalAcceptanceTest : PredictionAcceptanceTest() {
    override val engine: PredictionEngine = PersonalPredictionEngine()
}
