package dev.privacyllc.period.core.data

import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import dev.privacyllc.period.core.database.PeriodDatabase
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import dev.privacyllc.period.domain.prediction.BaselinePredictionEngine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
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.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import java.time.Clock
import java.time.LocalDate
import java.time.ZoneOffset

/**
 * The repository is where the four writes that make up "my period started" are
 * held together. These tests are mostly about that: not that a row was written,
 * but that the other three things happened with it.
 */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class CycleRepositoryTest {

    private lateinit var db: PeriodDatabase
    private lateinit var repo: CycleRepository

    /** Fixed, so nothing here depends on the day it is run. §50. */
    private var today: LocalDate = LocalDate.of(2026, 8, 18)

    private fun clockAt(date: LocalDate) =
        Clock.fixed(date.atStartOfDay(ZoneOffset.UTC).toInstant(), ZoneOffset.UTC)

    private fun repoAt(date: LocalDate): CycleRepository {
        today = date
        return CycleRepository(db, BaselinePredictionEngine(), clockAt(date))
    }

    @Before fun open() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            PeriodDatabase::class.java,
        ).allowMainThreadQueries().build()
        repo = repoAt(LocalDate.of(2026, 8, 18))
    }

    @After fun close() = db.close()

    private suspend fun seed(vararg dates: String) =
        dates.forEach { repo.confirmPeriodStart(LocalDate.parse(it)) }

    // -----------------------------------------------------------------------
    // Domain types out, entities never
    // -----------------------------------------------------------------------

    @Test fun `periods come back as domain records, not rows`() = runTest {
        repo.confirmPeriodStart(LocalDate.of(2026, 8, 1))
        val p = repo.confirmedPeriods.first().single()

        assertEquals(LocalDate.of(2026, 8, 1), p.startDate)
        assertEquals(PeriodRecordSource.MANUAL, p.source)
        assertTrue(p.isConfirmed)
    }

    @Test fun `cycles are derived from the records and never stored`() = runTest {
        seed("2026-06-01", "2026-06-30", "2026-07-29")

        assertEquals(listOf(29, 29), repo.cycles.first().map { it.cycleLengthDays })

        // Edit the middle record; the derived cycles must follow immediately.
        // A stored cycles table is what would go stale here, which is why there
        // is not one.
        val middle = repo.confirmedPeriods.first()[1]
        repo.editPeriod(middle.id, LocalDate.of(2026, 7, 1), null)

        assertEquals(listOf(30, 28), repo.cycles.first().map { it.cycleLengthDays })
    }

    // -----------------------------------------------------------------------
    // Confirming a period: the four things that must happen together
    // -----------------------------------------------------------------------

    @Test fun `confirming a period produces a fresh forecast`() = runTest {
        assertNull("no history means no forecast, not a guess", repo.forecast.first())

        seed("2026-06-01", "2026-06-30", "2026-07-29")
        val f = repo.forecast.first()

        assertNotNull(f)
        assertEquals(LocalDate.of(2026, 8, 27), f!!.mostLikelyStartDate)
        assertTrue(f.windowStart <= f.mostLikelyStartDate && f.mostLikelyStartDate <= f.windowEnd)
    }

    @Test fun `confirming a period scores the forecast that was outstanding`() = runTest {
        seed("2026-06-01", "2026-06-30", "2026-07-29")
        // The snapshot taken above predicted 2026-08-27.

        val late = repoAt(LocalDate.of(2026, 8, 29))
        late.confirmPeriodStart(LocalDate.of(2026, 8, 29))

        val acc = late.accuracy.first()
        assertEquals(1, acc.scoredCount)
        // Predicted the 27th, arrived on the 29th: two days early.
        assertEquals(2, acc.lastErrorDays)
        assertEquals(-2, acc.lastSignedErrorDays)
    }

    @Test fun `a scored snapshot keeps what it predicted`() = runTest {
        seed("2026-06-01", "2026-06-30", "2026-07-29")
        val late = repoAt(LocalDate.of(2026, 8, 29))
        late.confirmPeriodStart(LocalDate.of(2026, 8, 29))

        val scored = db.predictionRecordDao().observeScored().first()
        assertTrue(scored.isNotEmpty())
        // Whatever it said before the answer was known, it still says.
        assertEquals(LocalDate.of(2026, 8, 27), scored.first().predictedStartDate)
        assertEquals(LocalDate.of(2026, 8, 29), scored.first().actualStartDate)
    }

    @Test fun `confirming a period clears the not-yet observations it resolved`() = runTest {
        seed("2026-06-01", "2026-06-30", "2026-07-29")

        val waiting = repoAt(LocalDate.of(2026, 8, 27))
        waiting.recordNotYet(LocalDate.of(2026, 8, 27))
        assertEquals(1, waiting.notYetObservations.first().size)

        val started = repoAt(LocalDate.of(2026, 8, 29))
        started.confirmPeriodStart(LocalDate.of(2026, 8, 29))

        // The question they answered is resolved; leaving them would censor
        // every future forecast with a date that no longer means anything.
        assertTrue(started.notYetObservations.first().isEmpty())
    }

    // -----------------------------------------------------------------------
    // Not yet
    // -----------------------------------------------------------------------

    @Test fun `not yet moves the window past the ruled-out date`() = runTest {
        seed("2026-06-01", "2026-06-30", "2026-07-29")

        val onTheDay = repoAt(LocalDate.of(2026, 8, 27))
        val before = onTheDay.forecast.first()!!
        assertTrue(before.windowStart <= LocalDate.of(2026, 8, 27))

        onTheDay.recordNotYet(LocalDate.of(2026, 8, 27))
        val after = onTheDay.forecast.first()!!

        assertTrue(
            "the 27th was ruled out and is still in ${after.windowStart}..${after.windowEnd}",
            after.windowStart.isAfter(LocalDate.of(2026, 8, 27)),
        )
        assertTrue("confidence must fall too, not just the date move",
            after.confidenceScore < before.confidenceScore)
    }

    // -----------------------------------------------------------------------
    // Editing and deleting
    // -----------------------------------------------------------------------

    @Test fun `editing a record marks it as edited rather than changing it silently`() = runTest {
        val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) as PeriodWriteResult.Added).id
        repo.editPeriod(id, LocalDate.of(2026, 8, 3), null)

        val p = repo.confirmedPeriods.first().single()
        assertEquals(LocalDate.of(2026, 8, 3), p.startDate)
        // §14: health history is never modified silently — even by its owner.
        assertEquals(PeriodRecordSource.EDITED, p.source)
    }

    @Test fun `a period cannot be made to end before it started`() = runTest {
        val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 10)) as PeriodWriteResult.Added).id
        var refused = false
        try {
            repo.setPeriodEnd(id, LocalDate.of(2026, 8, 1))
        } catch (e: IllegalArgumentException) {
            refused = true
        }
        assertTrue("an end before the start is nonsense and must be refused", refused)
        assertNull(repo.confirmedPeriods.first().single().endDate)
    }

    @Test fun `an ongoing period has a null end rather than an invented one`() = runTest {
        val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 10)) as PeriodWriteResult.Added).id
        assertNull(repo.confirmedPeriods.first().single().endDate)

        repo.setPeriodEnd(id, LocalDate.of(2026, 8, 14))
        assertEquals(LocalDate.of(2026, 8, 14), repo.confirmedPeriods.first().single().endDate)

        // "Actually, still going."
        repo.setPeriodEnd(id, null)
        assertNull(repo.confirmedPeriods.first().single().endDate)
    }

    @Test fun `deleting a record removes it from the derived cycles`() = runTest {
        seed("2026-06-01", "2026-06-30", "2026-07-29")
        val middle = repo.confirmedPeriods.first()[1]

        repo.deletePeriod(middle.id)

        assertEquals(2, repo.confirmedPeriods.first().size)
        assertEquals(listOf(58), repo.cycles.first().map { it.cycleLengthDays })
    }

    // -----------------------------------------------------------------------
    // Spotting
    // -----------------------------------------------------------------------

    @Test fun `spotting never becomes a period or a cycle boundary`() = runTest {
        seed("2026-06-01", "2026-06-30")
        repo.recordSpotting(LocalDate.of(2026, 6, 14))

        // §25. The structural guarantee is that spotting cannot reach any of
        // these three answers.
        assertEquals(2, repo.confirmedPeriods.first().size)
        assertEquals(listOf(29), repo.cycles.first().map { it.cycleLengthDays })
        assertEquals(1, repo.spotting.first().size)

        repo.removeSpotting(LocalDate.of(2026, 6, 14))
        assertTrue(repo.spotting.first().isEmpty())
    }

    // -----------------------------------------------------------------------
    // Delete My Data
    // -----------------------------------------------------------------------

    @Test fun `deleting health data leaves nothing behind`() = runTest {
        seed("2026-06-01", "2026-06-30", "2026-07-29")
        repo.recordSpotting(LocalDate.of(2026, 7, 10))
        repo.recordNotYet(LocalDate.of(2026, 8, 18))

        repo.deleteAllHealthData()

        assertTrue(repo.confirmedPeriods.first().isEmpty())
        assertTrue(repo.spotting.first().isEmpty())
        assertTrue(repo.notYetObservations.first().isEmpty())
        assertTrue(repo.cycles.first().isEmpty())
        assertNull(repo.forecast.first())
        assertEquals(PredictionAccuracy.Empty, repo.accuracy.first())
    }

    // -----------------------------------------------------------------------
    // Accuracy
    // -----------------------------------------------------------------------

    @Test fun `accuracy stays quiet until there is enough of it to mean anything`() = runTest {
        assertFalse(repo.accuracy.first().hasEnoughToShow)

        seed("2026-06-01", "2026-06-30", "2026-07-29")
        val late = repoAt(LocalDate.of(2026, 8, 28))
        late.confirmPeriodStart(LocalDate.of(2026, 8, 28))

        // One lucky prediction reading "average error: 0 days" is an
        // overstatement, not a measurement. §27: do not overstate accuracy.
        assertFalse(late.accuracy.first().hasEnoughToShow)
        assertEquals(1, late.accuracy.first().scoredCount)
    }

    @Test fun `backfilled history does not fabricate accuracy figures`() = runTest {
        // Onboarding asks for earlier periods (§19 screen 4) and a user can add
        // one at any time. Scoring the standing forecast against a date in the
        // past would invent an error for a prediction nobody was ever shown,
        // and fill "your predictions are getting better" with figures the app
        // made up about itself. Regression: this scored 3 before it was fixed.
        seed("2026-06-01", "2026-06-30", "2026-07-29")

        assertEquals(0, repo.accuracy.first().scoredCount)

        // Remembering one from further back, entered today, must change nothing.
        repo.confirmPeriodStart(LocalDate.of(2026, 5, 3), PeriodRecordSource.HISTORICAL_ENTRY)
        assertEquals(0, repo.accuracy.first().scoredCount)

        // A period that actually arrives after the forecast was made does score.
        val later = repoAt(LocalDate.of(2026, 8, 30))
        later.confirmPeriodStart(LocalDate.of(2026, 8, 30))
        assertEquals(1, later.accuracy.first().scoredCount)
    }

    @Test fun `only one forecast stands at a time`() = runTest {
        seed("2026-06-01", "2026-06-30", "2026-07-29")
        repo.recordNotYet(LocalDate.of(2026, 8, 18))
        repo.recordNotYet(LocalDate.of(2026, 8, 19))

        // Each recalculation replaces the standing forecast rather than adding
        // to a pile. Otherwise one cycle contributes several "errors" to the
        // accuracy figures and the sample is inflated rather than measured.
        assertEquals(1, db.predictionRecordDao().unscored().size)
    }

    // -----------------------------------------------------------------------
    // Regression: logging the same day twice crashed the app on a device
    // -----------------------------------------------------------------------

    @Test fun `logging the same day twice is not an error and does not duplicate`() = runTest {
        // Found by tapping "Started today" twice on an emulator:
        // SQLiteConstraintException out of viewModelScope, process dead. The
        // constraint is right — a duplicate must not overwrite the original —
        // but "already recorded" is an ordinary thing for a person to do, not
        // a fault, and it must never be an exception.
        val first = repo.confirmPeriodStart(LocalDate.of(2026, 8, 18))
        assertTrue(first is PeriodWriteResult.Added)

        val second = repo.confirmPeriodStart(LocalDate.of(2026, 8, 18))
        assertTrue("a repeat tap must not throw", second is PeriodWriteResult.AlreadyRecorded)
        assertEquals((first as PeriodWriteResult.Added).id, (second as PeriodWriteResult.AlreadyRecorded).id)

        assertEquals("and must not duplicate the record", 1, repo.confirmedPeriods.first().size)
    }

    @Test fun `editing onto an occupied date is refused rather than crashing or merging`() = runTest {
        seed("2026-06-01", "2026-06-30")
        val first = repo.confirmedPeriods.first().first()

        val result = repo.editPeriod(first.id, LocalDate.of(2026, 6, 30), null)

        // Merging would delete a period the user entered; throwing would take
        // the app down. Neither is ours to choose — it is refused and reported.
        assertTrue(result is PeriodWriteResult.Conflict)
        assertEquals(2, repo.confirmedPeriods.first().size)
        assertEquals(
            "the record must be untouched after a refused edit",
            LocalDate.of(2026, 6, 1),
            repo.confirmedPeriods.first().first().startDate,
        )
    }

    @Test fun `editing a record onto its own date is allowed`() = runTest {
        val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) as PeriodWriteResult.Added).id
        // Changing only the end date must not read as a collision with itself.
        assertTrue(repo.editPeriod(id, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 5))
            is PeriodWriteResult.Updated)
        assertEquals(LocalDate.of(2026, 8, 5), repo.confirmedPeriods.first().single().endDate)
    }

    @Test fun `writing to a record that is gone reports it rather than throwing`() = runTest {
        val id = (repo.confirmPeriodStart(LocalDate.of(2026, 8, 1)) as PeriodWriteResult.Added).id
        repo.deletePeriod(id)

        assertEquals(PeriodWriteResult.NotFound, repo.editPeriod(id, LocalDate.of(2026, 8, 2), null))
        assertEquals(PeriodWriteResult.NotFound, repo.setPeriodEnd(id, LocalDate.of(2026, 8, 5)))
    }

    // -----------------------------------------------------------------------
    // The stored errors reach the engine
    // -----------------------------------------------------------------------

    @Test fun `past prediction errors are fed back into the next forecast`() = runTest {
        // §12 step 5. Without this wiring the app stores every error it makes
        // and never reads one back, which is measuring accuracy rather than
        // learning from it — and the widening only ever happens in a unit test.
        val engine = RecordingEngine()
        val repo = CycleRepository(db, engine, clockAt(LocalDate.of(2026, 8, 18)))

        repo.confirmPeriodStart(LocalDate.of(2026, 6, 1))
        repo.confirmPeriodStart(LocalDate.of(2026, 6, 30))

        // Score the standing forecast by confirming a start after it was made.
        val later = CycleRepository(db, engine, clockAt(LocalDate.of(2026, 8, 20)))
        later.confirmPeriodStart(LocalDate.of(2026, 8, 20))

        later.forecast.first()

        assertTrue(
            "the engine was never given a scored error: ${engine.seen.map { it.recentAbsoluteErrors }}",
            engine.seen.any { it.recentAbsoluteErrors.isNotEmpty() },
        )
    }

    /** Records what it was asked, and otherwise behaves like the real thing. */
    private class RecordingEngine : dev.privacyllc.period.domain.prediction.PredictionEngine {
        val seen = mutableListOf<dev.privacyllc.period.domain.prediction.PredictionInput>()
        private val delegate = dev.privacyllc.period.domain.prediction.PersonalPredictionEngine()
        override val modelVersion get() = delegate.modelVersion
        override fun predict(input: dev.privacyllc.period.domain.prediction.PredictionInput) =
            delegate.predict(input).also { seen += input }
    }

    @Test fun `accuracy arithmetic is signed for the user and absolute for the average`() {
        // predicted − actual: negative is early, positive is late.
        val a = PredictionAccuracy.from(listOf(-1, 2, 0, -3))
        assertEquals(4, a.scoredCount)
        assertEquals(1, a.lastErrorDays)
        assertEquals(-1, a.lastSignedErrorDays)
        assertEquals(1.5, a.meanAbsoluteErrorDays!!, 0.001)
        assertEquals(1.5, a.medianAbsoluteErrorDays!!, 0.001)
        assertEquals(2, a.withinOneDay)
        assertEquals(3, a.withinTwoDays)
        assertTrue(a.hasEnoughToShow)
    }
}
