package dev.privacyllc.period.domain.prediction

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

/**
 * §14 and §12 step 2, which together say something narrower than "find
 * outliers": detect what is unusual **for this user**, keep it, reduce its
 * influence, and ask only when the shape of the gap actually suggests a missed
 * entry.
 */
class IntervalAnalysisTest {

    private fun startsFrom(vararg gaps: Long, from: String = "2026-01-01"): List<LocalDate> {
        var cursor = LocalDate.parse(from)
        val out = mutableListOf(cursor)
        gaps.forEach { cursor = cursor.plusDays(it); out += cursor }
        return out
    }

    private fun analyse(vararg gaps: Long) = IntervalAnalysis.intervals(startsFrom(*gaps))

    @Test fun `intervals carry the gaps in order with the newest aged zero`() {
        val i = analyse(29, 28, 30)
        assertEquals(listOf(29, 28, 30), i.map { it.days })
        assertEquals(listOf(2, 1, 0), i.map { it.ageIndex })
    }

    @Test fun `recency decay makes the newest cycle count most`() {
        val i = analyse(29, 28, 30, 29)
        val byAge = i.sortedBy { it.ageIndex }
        assertEquals(1.0, byAge.first().weight, 0.0001)
        assertTrue("weights must decrease with age", byAge.zipWithNext().all { (a, b) -> a.weight > b.weight })
    }

    // -----------------------------------------------------------------------
    // §14 — a probable missed period
    // -----------------------------------------------------------------------

    @Test fun `a gap near double the usual is flagged as a probable missed period`() {
        // §14's worked example: 29, 28, 30, 29, 61, 29.
        val i = analyse(29, 28, 30, 29, 61, 29)
        val flagged = i.filter { it.isQuestionable }

        assertEquals(1, flagged.size)
        assertEquals(61, flagged.single().days)
        assertEquals(QuestionableReason.POSSIBLE_MISSED_PERIOD, flagged.single().questionable)
    }

    @Test fun `a flagged interval is kept and down-weighted, never dropped`() {
        val i = analyse(29, 28, 30, 29, 61, 29)

        // §12 step 2: do not silently delete unusual data.
        assertEquals("the interval must still be there", 6, i.size)
        val odd = i.single { it.days == 61 }
        assertTrue("and must still carry some weight", odd.weight > 0.0)
        val comparable = i.single { it.ageIndex == odd.ageIndex - 1 }
        assertTrue("but much less than an ordinary neighbour", odd.weight < comparable.weight / 2)
    }

    // -----------------------------------------------------------------------
    // The half of §14 that is easy to get wrong
    // -----------------------------------------------------------------------

    @Test fun `a long cycle is normal for a user whose cycles are long`() {
        // The whole product promise is that this user is not treated as average.
        // A global "over 40 days is suspicious" rule would flag every one of
        // these, which is exactly the app she has already deleted once.
        val i = analyse(41, 44, 42, 45, 43)
        assertTrue("nothing here is unusual for her", i.none { it.isQuestionable })
    }

    @Test fun `45 against a usual of 29 is unusual but not a missed period`() {
        // §51's outlier case. 45/29 is 1.55 — nowhere near a whole multiple, so
        // it is not a forgotten entry and asking would be over-questioning (§25).
        val i = analyse(29, 29, 28, 30, 45, 29)
        val odd = i.single { it.days == 45 }
        assertEquals(QuestionableReason.UNUSUAL_LENGTH, odd.questionable)
    }

    @Test fun `nothing is flagged before there is a usual to compare against`() {
        // Two intervals do not establish a pattern, and calling one of them
        // questionable is the model asserting something it cannot know.
        assertTrue(analyse(29, 61).none { it.isQuestionable })
    }

    @Test fun `a steady history flags nothing`() {
        assertTrue(analyse(28, 29, 28, 29, 28, 29).none { it.isQuestionable })
    }

    @Test fun `a variable but genuine history is not carpet-flagged`() {
        // §51's variable user: 25, 34, 29, 37, 26, 32. She is hard to predict,
        // not wrong. Flagging most of her history would down-weight the data the
        // model most needs and make her forecast worse.
        val i = analyse(25, 34, 29, 37, 26, 32)
        assertTrue("at most one of six may be questioned, got ${i.count { it.isQuestionable }}",
            i.count { it.isQuestionable } <= 1)
        assertTrue("and none as a missed period",
            i.none { it.questionable == QuestionableReason.POSSIBLE_MISSED_PERIOD })
    }

    // -----------------------------------------------------------------------
    // Boundaries
    // -----------------------------------------------------------------------

    @Test fun `implausibly short gaps are not cycles at all`() {
        // Two starts three days apart is a re-entry or a typo, not a 3-day
        // cycle, and letting it into the weighting would wreck the centre.
        val i = analyse(29, 3, 28)
        assertTrue("the 3-day gap must not appear", i.none { it.days == 3 })
    }

    @Test fun `fewer than two starts yields nothing rather than failing`() {
        assertTrue(IntervalAnalysis.intervals(emptyList()).isEmpty())
        assertTrue(IntervalAnalysis.intervals(listOf(LocalDate.of(2026, 1, 1))).isEmpty())
    }

    @Test fun `the weighted median respects weights`() {
        // Equal weights: ordinary median.
        assertEquals(29.0, IntervalAnalysis.weightedMedian(listOf(28.0 to 1.0, 29.0 to 1.0, 30.0 to 1.0)), 0.0001)
        // The 35 carries almost all the weight, so it wins despite being extreme.
        assertEquals(35.0, IntervalAnalysis.weightedMedian(listOf(28.0 to 0.05, 29.0 to 0.05, 35.0 to 10.0)), 0.0001)
    }
}
