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
import java.time.YearMonth

/**
 * A year of calendar, without pretending to know a year of body.
 *
 * The trap this feature walks into if nobody is watching is the one the widget
 * library it was inspired by fell straight into: stamp a fixed cycle length
 * twelve months forward and draw it exactly like the next period. Every test
 * here exists to stop one part of that — the window has to widen with distance,
 * the confidence has to fall, the marks have to be visibly a different claim,
 * and past the point the history can support it the app has to stop.
 */
class CycleProjectionTest {

    private val today = LocalDate.of(2026, 8, 20)

    private fun forecast(
        start: LocalDate = LocalDate.of(2026, 8, 28),
        halfWidth: Long = 1,
        label: ConfidenceLabel = ConfidenceLabel.HIGH,
    ) = Prediction(
        mostLikelyStartDate = start,
        windowStart = start.minusDays(halfWidth),
        windowEnd = start.plusDays(halfWidth),
        confidenceScore = when (label) {
            ConfidenceLabel.HIGH -> 0.7
            ConfidenceLabel.MEDIUM -> 0.45
            ConfidenceLabel.LOW -> 0.2
        },
        confidenceLabel = label,
        modelVersion = "test",
    )

    private fun widthOf(c: ProjectedCycle) = c.windowEnd.toEpochDay() - c.windowStart.toEpochDay()

    // -----------------------------------------------------------------------
    // Reach
    // -----------------------------------------------------------------------

    @Test
    fun `a stable cycle is projected across the whole year`() {
        val p = CycleProjection.from(forecast(), typicalCycleDays = 29.0, today = today)

        assertTrue("only reached ${p.cycles.size} cycles", p.cycles.size >= 12)
        assertTrue("stopped short of the horizon for a stable user", p.reachedHorizon)
        // Reaching next spring, without asserting a particular day is inside a
        // window — most days are between periods, which is rather the point.
        assertNotNull(
            "nothing projected as far as spring 2027",
            p.cycles.lastOrNull()?.takeIf { it.windowStart >= LocalDate.of(2027, 4, 1) },
        )
    }

    @Test
    fun `the first projected cycle is the engine's own forecast, untouched`() {
        // The calendar and the Today screen must never disagree about the next
        // period. Re-deriving it here is how they would start to.
        val f = forecast()
        val first = CycleProjection.from(f, 29.0, today).cycles.first()

        assertEquals(1, first.cyclesAhead)
        assertEquals(f.mostLikelyStartDate, first.mostLikelyStartDate)
        assertEquals(f.windowStart, first.windowStart)
        assertEquals(f.windowEnd, first.windowEnd)
        assertEquals(f.confidenceLabel, first.confidenceLabel)
    }

    @Test
    fun `no forecast means no projection rather than a year of guesses`() {
        assertEquals(CycleProjection.Empty, CycleProjection.from(null, 29.0, today))
        assertTrue(CycleProjection.from(forecast(), typicalCycleDays = 0.0, today = today).cycles.isEmpty())
    }

    // -----------------------------------------------------------------------
    // Honesty about distance
    // -----------------------------------------------------------------------

    @Test
    fun `the window widens the further out it reaches`() {
        val cycles = CycleProjection.from(forecast(), 29.0, today).cycles

        cycles.zipWithNext { near, far ->
            assertTrue(
                "cycle ${far.cyclesAhead} (${widthOf(far)}d) is not wider than " +
                    "cycle ${near.cyclesAhead} (${widthOf(near)}d)",
                widthOf(far) >= widthOf(near),
            )
        }
        // And strictly wider across a real distance, not merely non-decreasing.
        assertTrue(widthOf(cycles.last()) > widthOf(cycles.first()))
    }

    @Test
    fun `confidence falls with distance and never rises`() {
        val cycles = CycleProjection.from(forecast(label = ConfidenceLabel.HIGH), 29.0, today).cycles
        val rank = mapOf(ConfidenceLabel.LOW to 0, ConfidenceLabel.MEDIUM to 1, ConfidenceLabel.HIGH to 2)

        cycles.zipWithNext { near, far ->
            assertTrue(
                "confidence rose from ${near.confidenceLabel} to ${far.confidenceLabel}",
                rank.getValue(far.confidenceLabel) <= rank.getValue(near.confidenceLabel),
            )
        }
        assertEquals(ConfidenceLabel.LOW, cycles.last().confidenceLabel)
    }

    @Test
    fun `a projection is never more confident than the forecast it hangs off`() {
        CycleProjection.from(forecast(label = ConfidenceLabel.LOW), 29.0, today).cycles.forEach {
            assertEquals(ConfidenceLabel.LOW, it.confidenceLabel)
        }
    }

    // -----------------------------------------------------------------------
    // Declining rather than stretching
    // -----------------------------------------------------------------------

    @Test
    fun `an uncertain cycle stops early instead of drawing a shrug`() {
        // Six days either side already; sqrt growth passes the useful ceiling
        // within a couple of cycles. FertilityEstimate's precedent: a window
        // that covers half a cycle says nothing and looks like it said something.
        val p = CycleProjection.from(forecast(halfWidth = 6, label = ConfidenceLabel.LOW), 29.0, today)

        assertFalse("claimed to reach the horizon on a vague forecast", p.reachedHorizon)
        assertTrue("projected ${p.cycles.size} cycles from a six-day window", p.cycles.size < 6)
        p.cycles.forEach {
            assertTrue(
                "cycle ${it.cyclesAhead} is ${widthOf(it)} days wide",
                widthOf(it) <= CycleProjection.MAX_USEFUL_HALF_WIDTH * 2L,
            )
        }
    }

    @Test
    fun `stopping for want of certainty is distinguishable from stopping for want of calendar`() {
        val stable = CycleProjection.from(forecast(), 29.0, today)
        val vague = CycleProjection.from(forecast(halfWidth = 6, label = ConfidenceLabel.LOW), 29.0, today)

        // Same empty month, two different sentences — and only one of them is
        // an invitation to keep logging.
        assertTrue(stable.reachedHorizon)
        assertFalse(vague.reachedHorizon)
    }

    @Test
    fun `nothing is projected past a year`() {
        val p = CycleProjection.from(forecast(), 29.0, today)
        val horizon = today.plusMonths(CycleProjection.MAX_MONTHS_AHEAD)

        p.cycles.forEach {
            assertTrue(
                "cycle ${it.cyclesAhead} opens ${it.windowStart} — past the year",
                !it.windowStart.isAfter(horizon),
            )
        }
    }

    @Test
    fun `the same inputs give the same projection`() {
        assertEquals(
            CycleProjection.from(forecast(), 29.0, today),
            CycleProjection.from(forecast(), 29.0, today),
        )
    }

    // -----------------------------------------------------------------------
    // What the calendar makes of it
    // -----------------------------------------------------------------------

    @Test
    fun `a far month is marked as projected, not as the forecast`() {
        val f = forecast()
        val p = CycleProjection.from(f, 29.0, today)

        val thisMonth = CalendarMarks.forMonth(
            YearMonth.of(2026, 8), emptyList(), emptyList(), f, today, projection = p,
        )
        val nextSpring = CalendarMarks.forMonth(
            YearMonth.of(2027, 3), emptyList(), emptyList(), f, today, projection = p,
        )

        // The next period keeps the mark the app scores itself on.
        assertTrue(thisMonth.any { it.mark == DayMark.PREDICTED_PERIOD })
        assertTrue(thisMonth.none { it.mark == DayMark.PROJECTED_PERIOD })

        // Months away it is a different claim, and says so.
        assertTrue(nextSpring.any { it.mark == DayMark.PROJECTED_PERIOD })
        assertTrue(nextSpring.none { it.mark == DayMark.PREDICTED_PERIOD })
    }

    @Test
    fun `a projected day tells a screen reader how far out it is`() {
        val f = forecast()
        val day = CalendarMarks.forMonth(
            YearMonth.of(2027, 3), emptyList(), emptyList(), f, today,
            projection = CycleProjection.from(f, 29.0, today),
        ).first { it.mark == DayMark.PROJECTED_PERIOD }

        assertTrue(day.accessibilityLabel, day.accessibilityLabel.contains("period projected"))
        assertTrue(day.accessibilityLabel, day.accessibilityLabel.contains("months away"))
    }

    @Test
    fun `a confirmed period still outranks a projection on the same day`() {
        val f = forecast()
        val p = CycleProjection.from(f, 29.0, today)
        val projectedDay = CalendarMarks.forMonth(
            YearMonth.of(2027, 3), emptyList(), emptyList(), f, today, projection = p,
        ).first { it.mark == DayMark.PROJECTED_PERIOD }.date

        val marks = CalendarMarks.forMonth(
            YearMonth.of(2027, 3),
            listOf(
                dev.privacyllc.period.domain.cycle.PeriodRecord(
                    id = 1, startDate = projectedDay, endDate = projectedDay,
                ),
            ),
            emptyList(), f, today, projection = p,
        )

        // A fact outranks an estimate — the existing rule, still holding with a
        // new estimate in the list.
        assertEquals(DayMark.CONFIRMED_PERIOD, marks.first { it.date == projectedDay }.mark)
    }

    @Test
    fun `a caller that does not ask for projections gets the calendar it had`() {
        val f = forecast()
        val nextSpring = CalendarMarks.forMonth(
            YearMonth.of(2027, 3), emptyList(), emptyList(), f, today,
        )
        assertTrue(nextSpring.all { it.mark == DayMark.NONE })
        assertNull(nextSpring.first().monthsAhead)
    }
}
