package dev.privacyllc.period.core.database

import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import dev.privacyllc.period.core.database.entity.NotYetObservationEntity
import dev.privacyllc.period.core.database.entity.PeriodRecordEntity
import dev.privacyllc.period.core.database.entity.PredictionRecordEntity
import dev.privacyllc.period.core.database.entity.SpottingRecordEntity
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
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.Instant
import java.time.LocalDate

/**
 * These do not test Room. They test the constraints this schema exists to
 * enforce — the ones that, if they quietly stopped holding, would corrupt a
 * cycle history rather than crash anything.
 */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [34])
class PeriodDatabaseTest {

    private lateinit var db: PeriodDatabase

    private val now: Instant = Instant.parse("2026-08-18T00:00:00Z")

    @Before fun open() {
        db = Room.inMemoryDatabaseBuilder(
            ApplicationProvider.getApplicationContext(),
            PeriodDatabase::class.java,
        ).allowMainThreadQueries().build()
    }

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

    private fun period(day: String, end: String? = null, confirmed: Boolean = true) =
        PeriodRecordEntity(
            startDate = LocalDate.parse(day),
            endDate = end?.let(LocalDate::parse),
            createdAt = now,
            updatedAt = now,
            source = "MANUAL",
            isConfirmed = confirmed,
        )

    @Test fun `a period round-trips through the database unchanged`() = runTest {
        val id = db.periodRecordDao().insert(period("2026-08-01", end = "2026-08-05"))
        val read = db.periodRecordDao().byId(id)

        assertNotNull(read)
        assertEquals(LocalDate.of(2026, 8, 1), read!!.startDate)
        assertEquals(LocalDate.of(2026, 8, 5), read.endDate)
        assertEquals("MANUAL", read.source)
        assertTrue(read.isConfirmed)
    }

    @Test fun `an ongoing period stores a null end date rather than a placeholder`() = runTest {
        val id = db.periodRecordDao().insert(period("2026-08-01"))
        assertNull(db.periodRecordDao().byId(id)!!.endDate)
    }

    @Test(expected = android.database.sqlite.SQLiteConstraintException::class)
    fun `a duplicate start date is refused rather than silently replacing the first`() = runTest {
        db.periodRecordDao().insert(period("2026-08-01"))
        // ABORT, not REPLACE: REPLACE would delete the original row and with it
        // its createdAt and source. Health history is not overwritten silently.
        db.periodRecordDao().insert(period("2026-08-01"))
    }

    @Test fun `unconfirmed records are excluded from the confirmed stream`() = runTest {
        db.periodRecordDao().insert(period("2026-08-01"))
        db.periodRecordDao().insert(period("2026-07-01", confirmed = false))

        val confirmed = db.periodRecordDao().observeConfirmed().first()
        assertEquals(1, confirmed.size)
        assertEquals(LocalDate.of(2026, 8, 1), confirmed.single().startDate)
        assertEquals(1, db.periodRecordDao().confirmedCount())
    }

    @Test fun `the confirmed stream is ordered by start date regardless of insert order`() = runTest {
        listOf("2026-08-01", "2026-06-01", "2026-07-01").forEach {
            db.periodRecordDao().insert(period(it))
        }
        assertEquals(
            listOf(LocalDate.of(2026, 6, 1), LocalDate.of(2026, 7, 1), LocalDate.of(2026, 8, 1)),
            db.periodRecordDao().observeConfirmed().first().map { it.startDate },
        )
    }

    @Test fun `spotting lives in its own table and never appears as a period`() = runTest {
        db.spottingRecordDao().insert(SpottingRecordEntity(date = LocalDate.of(2026, 8, 10), createdAt = now))

        // PRODUCT_PLAN.md §25: spotting must not start or reset a cycle. The
        // structural guarantee is that no query for periods can reach it.
        assertTrue(db.periodRecordDao().observeAll().first().isEmpty())
        assertEquals(1, db.spottingRecordDao().observeAll().first().size)
    }

    @Test fun `a prediction snapshot can be scored but not rewritten`() = runTest {
        val id = db.predictionRecordDao().insert(
            PredictionRecordEntity(
                generatedAt = now,
                basedOnLastConfirmedPeriodId = null,
                predictedStartDate = LocalDate.of(2026, 8, 22),
                predictedWindowStart = LocalDate.of(2026, 8, 21),
                predictedWindowEnd = LocalDate.of(2026, 8, 24),
                estimatedOvulationDate = null,
                fertileWindowStart = null,
                fertileWindowEnd = null,
                confidenceScore = 0.7,
                confidenceLabel = "HIGH",
                modelVersion = "baseline-1",
                actualStartDate = null,
                absoluteErrorDays = null,
            ),
        )

        assertEquals(1, db.predictionRecordDao().unscored().size)

        db.predictionRecordDao().score(id, LocalDate.of(2026, 8, 24), errorDays = 2)

        val scored = db.predictionRecordDao().observeScored().first().single()
        assertEquals(LocalDate.of(2026, 8, 24), scored.actualStartDate)
        assertEquals(2, scored.absoluteErrorDays)
        // What was predicted is untouched — that is the whole basis of §16's
        // accuracy figures. A snapshot editable after the fact can only ever
        // report that the app was right.
        assertEquals(LocalDate.of(2026, 8, 22), scored.predictedStartDate)
        assertTrue(db.predictionRecordDao().unscored().isEmpty())
    }

    @Test fun `a repeated not-yet on the same day is recorded once`() = runTest {
        val d = LocalDate.of(2026, 8, 22)
        db.notYetObservationDao().insert(NotYetObservationEntity(date = d, predictionId = null, createdAt = now))
        db.notYetObservationDao().insert(NotYetObservationEntity(date = d, predictionId = null, createdAt = now))

        // Tapping "Not yet" twice is one fact about one day, not two.
        assertEquals(1, db.notYetObservationDao().observeAll().first().size)
    }

    @Test fun `delete everything leaves nothing behind in any table`() = runTest {
        db.periodRecordDao().insert(period("2026-08-01"))
        db.spottingRecordDao().insert(SpottingRecordEntity(date = LocalDate.of(2026, 8, 10), createdAt = now))
        db.notYetObservationDao().insert(
            NotYetObservationEntity(date = LocalDate.of(2026, 8, 22), predictionId = null, createdAt = now),
        )

        db.deleteEverything()

        // A partial wipe would leave the cycle reconstructible from the very
        // tables the user asked to be rid of. §45: clear and irreversible.
        assertTrue(db.periodRecordDao().observeAll().first().isEmpty())
        assertTrue(db.spottingRecordDao().observeAll().first().isEmpty())
        assertTrue(db.notYetObservationDao().observeAll().first().isEmpty())
        assertNull(db.predictionRecordDao().observeLatest().first())
    }

    @Test fun `dates survive the round trip as whole days with no timezone drift`() = runTest {
        // §50 calls out timezone and DST edge cases. Epoch-day storage is what
        // makes this true; a locale-aware string formatter in the path is what
        // would break it, and this test is what would notice.
        val awkward = listOf("2026-03-29", "2026-10-25", "2028-02-29", "2026-12-31")
        awkward.forEach { db.periodRecordDao().insert(period(it)) }

        assertEquals(
            awkward.map(LocalDate::parse).sorted(),
            db.periodRecordDao().observeConfirmed().first().map { it.startDate },
        )
    }
}
