package dev.privacyllc.period.core.data

import dev.privacyllc.period.core.database.PeriodDatabase
import dev.privacyllc.period.core.database.entity.NotYetObservationEntity
import dev.privacyllc.period.core.database.entity.SpottingRecordEntity
import dev.privacyllc.period.domain.cycle.CycleRecord
import dev.privacyllc.period.domain.cycle.PeriodRecord
import dev.privacyllc.period.domain.cycle.PeriodRecordSource
import dev.privacyllc.period.domain.cycle.SpottingRecord
import dev.privacyllc.period.domain.cycle.toCycles
import dev.privacyllc.period.domain.prediction.FertilityEstimate
import dev.privacyllc.period.domain.prediction.NotYetObservation
import dev.privacyllc.period.domain.prediction.Prediction
import dev.privacyllc.period.domain.prediction.PredictionEngine
import dev.privacyllc.period.domain.prediction.PredictionInput
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import java.time.Clock
import java.time.LocalDate
import kotlin.math.abs

/**
 * The seam between storage and the rest of the app.
 *
 * Everything above this returns domain types; nothing above this touches a DAO.
 * Two consequences worth stating, because they are the reasons the module
 * exists rather than side effects of it:
 *
 *  - **Cycles are derived, never stored.** `toCycles()` recomputes them from the
 *    confirmed records on every read, so editing a record cannot leave a stale
 *    interval behind it. A `cycles` table would be a second copy of a fact the
 *    period records already hold, and the two would disagree the first time an
 *    edit missed one.
 *  - **The forecast is a function of the data, and recomputed with it.** There
 *    is no "current prediction" field anybody has to remember to refresh —
 *    [forecast] combines the history and the "not yet" observations and asks
 *    the engine. §11 requires recalculation after a confirmed start, after an
 *    edit and after a "Not yet"; deriving it makes all three automatic.
 *
 * [clock] is injected because §50 asks for timezone, midnight, DST and leap-year
 * cases to be tested, and none of those are testable against the system clock.
 *
 * **The constructor is internal on purpose.** It takes a [PeriodDatabase], and a
 * public constructor would force every caller to be able to name that type —
 * which means Room on their compile classpath, which is the exact leak this
 * module exists to prevent. Build one with [CycleData.repository] instead.
 */
class CycleRepository internal constructor(
    private val db: PeriodDatabase,
    private val engine: PredictionEngine,
    private val clock: Clock = Clock.systemDefaultZone(),
) {
    private val periodDao get() = db.periodRecordDao()
    private val spottingDao get() = db.spottingRecordDao()
    private val predictionDao get() = db.predictionRecordDao()
    private val notYetDao get() = db.notYetObservationDao()

    private fun today(): LocalDate = LocalDate.now(clock)

    // -----------------------------------------------------------------------
    // Reads
    // -----------------------------------------------------------------------

    val confirmedPeriods: Flow<List<PeriodRecord>> =
        periodDao.observeConfirmed().map { rows -> rows.map { it.toDomain() } }

    val allPeriods: Flow<List<PeriodRecord>> =
        periodDao.observeAll().map { rows -> rows.map { it.toDomain() } }

    val cycles: Flow<List<CycleRecord>> = confirmedPeriods.map { it.toCycles() }

    val spotting: Flow<List<SpottingRecord>> =
        spottingDao.observeAll().map { rows -> rows.map { it.toDomain() } }

    val notYetObservations: Flow<List<NotYetObservation>> =
        notYetDao.observeAll().map { rows -> rows.map { it.toDomain() } }

    /** Absolute errors of the most recent scored forecasts, newest first. */
    private val scoredErrors: Flow<List<Int>> =
        predictionDao.observeScored().map { rows -> rows.mapNotNull { it.absoluteErrorDays } }

    /**
     * The live forecast.
     *
     * Null when there is no confirmed history — [PredictionEngine] returns null
     * rather than a confident guess, and the UI shows an empty state rather
     * than a number it has not earned.
     */
    val forecast: Flow<Prediction?> =
        combine(confirmedPeriods, notYetObservations, scoredErrors) { periods, notYet, errors ->
            engine.predict(
                PredictionInput(
                    confirmedStarts = periods.map { it.startDate },
                    today = today(),
                    notYet = notYet,
                    // §12 step 5: the window widens from recent prediction error
                    // as well as from the user's variability. Without this the
                    // app stores every error it makes and never reads one back,
                    // which is measuring accuracy rather than learning from it.
                    recentAbsoluteErrors = errors,
                ),
            )
        }

    /**
     * Estimated ovulation and the fertile window.
     *
     * Derived from [forecast] rather than stored, for the same reason cycles
     * are: it is a function of the forecast, and a stored copy would contradict
     * the period estimate on the same screen the moment a "Not yet" moved one
     * and not the other.
     *
     * Null whenever the forecast is null — §17 hangs the estimate off the
     * predicted next period, and without one there is nothing to hang it on.
     */
    val fertility: Flow<FertilityEstimate?> = forecast.map { FertilityEstimate.from(it) }

    /** §16's accuracy figures, computed on-device from stored snapshots. */
    val accuracy: Flow<PredictionAccuracy> =
        predictionDao.observeScored().map { rows ->
            PredictionAccuracy.from(
                rows.mapNotNull { r ->
                    val actual = r.actualStartDate ?: return@mapNotNull null
                    (r.predictedStartDate.toEpochDay() - actual.toEpochDay()).toInt()
                },
            )
        }

    // -----------------------------------------------------------------------
    // Writes
    // -----------------------------------------------------------------------

    /**
     * The central operation: the user says the period started.
     *
     * Four things happen, in this order and in one transaction, because a
     * partial result here is a corrupt history rather than a failed action:
     *
     * 1. the record is written;
     * 2. any outstanding forecast is **scored** against it — that is the whole
     *    basis of §16, and it can only be done at this moment, because this is
     *    when the answer becomes known;
     * 3. the "not yet" observations that were censoring that forecast are
     *    cleared, since the question they answered is now resolved;
     * 4. a fresh forecast is snapshotted for the next cycle.
     *
     * Returns the new record's id.
     */
    suspend fun confirmPeriodStart(
        startDate: LocalDate,
        source: PeriodRecordSource = PeriodRecordSource.MANUAL,
    ): PeriodWriteResult = db.inTransaction {
        // Checked inside the transaction, before inserting. Tapping the primary
        // button twice is an ordinary thing for a person to do when they are not
        // sure the first tap registered — it must not be an error, and it
        // certainly must not be a crash. See PeriodWriteResult.
        periodDao.byStartDate(startDate)?.let {
            return@inTransaction PeriodWriteResult.AlreadyRecorded(it.id)
        }

        val now = clock.instant()
        val id = periodDao.insert(
            PeriodRecord(id = 0, startDate = startDate, source = source).toEntity(now, now),
        )

        scoreOutstanding(startDate)
        notYetDao.deleteBefore(startDate.plusDays(1))
        snapshotForecast(basedOnPeriodId = id)
        PeriodWriteResult.Added(id)
    }

    /** "Still going" answered, or a date chosen. Never invents an end date. */
    suspend fun setPeriodEnd(id: Long, endDate: LocalDate?): PeriodWriteResult = db.inTransaction {
        val existing = periodDao.byId(id) ?: return@inTransaction PeriodWriteResult.NotFound
        require(endDate == null || !endDate.isBefore(existing.startDate)) {
            "a period cannot end ($endDate) before it started (${existing.startDate})"
        }
        periodDao.update(existing.copy(endDate = endDate, updatedAt = clock.instant()))
        PeriodWriteResult.Updated
    }

    /**
     * Correcting a record the user got wrong.
     *
     * The source becomes [PeriodRecordSource.EDITED] and `updatedAt` moves.
     * §14: health history is never modified silently — even by its owner, the
     * change is recorded as a change.
     */
    suspend fun editPeriod(id: Long, startDate: LocalDate, endDate: LocalDate?): PeriodWriteResult =
        db.inTransaction {
            val existing = periodDao.byId(id) ?: return@inTransaction PeriodWriteResult.NotFound
            require(endDate == null || !endDate.isBefore(startDate)) {
                "a period cannot end ($endDate) before it started ($startDate)"
            }

            // Same UNIQUE constraint, same reason to answer with a value: moving
            // a record onto a date another record already holds is a question
            // only the user can settle. Merging would delete a period they
            // entered, and throwing would take the app down.
            periodDao.byStartDate(startDate)?.let { clash ->
                if (clash.id != id) return@inTransaction PeriodWriteResult.Conflict(clash.id)
            }

            periodDao.update(
                existing.copy(
                    startDate = startDate,
                    endDate = endDate,
                    source = PeriodRecordSource.EDITED.name,
                    updatedAt = clock.instant(),
                ),
            )
            snapshotForecast(basedOnPeriodId = id)
            PeriodWriteResult.Updated
        }

    suspend fun deletePeriod(id: Long) = db.inTransaction {
        periodDao.deleteById(id)
        snapshotForecast(basedOnPeriodId = null)
    }

    /**
     * "Not yet."
     *
     * A censoring observation, not a nudge — the next forecast is conditioned on
     * the period not having begun by this date, rather than shifted a day. §13.
     */
    suspend fun recordNotYet(date: LocalDate = today()) = db.inTransaction {
        val latest = predictionDao.latestId()
        notYetDao.insert(
            NotYetObservationEntity(date = date, predictionId = latest, createdAt = clock.instant()),
        )
        snapshotForecast(basedOnPeriodId = null)
    }

    /** Spotting. Deliberately incapable of starting or resetting a cycle — §25. */
    suspend fun recordSpotting(date: LocalDate) {
        spottingDao.insert(SpottingRecordEntity(date = date, createdAt = clock.instant()))
    }

    suspend fun removeSpotting(date: LocalDate) = spottingDao.deleteByDate(date)

    /**
     * Delete My Data.
     *
     * Health history only. The user's settings live in `core/datastore` and are
     * deliberately untouched: somebody exercising a privacy control has not
     * asked to have notification privacy reset to a default they did not pick.
     */
    suspend fun deleteAllHealthData() = db.deleteEverything()

    // -----------------------------------------------------------------------
    // Internals
    // -----------------------------------------------------------------------

    /**
     * Record what actually happened against the forecast that was standing.
     *
     * Only `actualStartDate` and `absoluteErrorDays` are written — what was
     * predicted is never touched. A snapshot editable after the fact can only
     * ever report that the app was right.
     *
     * **Backfill is not a prediction, and this is where that is enforced.**
     * Onboarding asks for earlier periods (§19, screen 4) and a user may add one
     * at any time. Each of those confirmations runs through here, and scoring
     * the current forecast against a date in the past would invent an error for
     * a prediction nobody was ever shown — filling §16's "your predictions are
     * getting better" with figures the app made up about itself.
     *
     * So a forecast is only scored when the confirmed start falls on or after
     * the day the forecast was made. Otherwise it survives untouched, still
     * waiting for the period it is actually about.
     */
    private suspend fun scoreOutstanding(actualStart: LocalDate) {
        val standing = predictionDao.unscored().firstOrNull() ?: return
        // NOT LocalDate.ofInstant — that is API 34, and minSdk here is 26.
        // Lint caught it; an API 36 emulator never would have.
        val madeOn = standing.generatedAt.atZone(clock.zone).toLocalDate()
        if (madeOn.isAfter(actualStart)) return  // backfill

        val error = abs(standing.predictedStartDate.toEpochDay() - actualStart.toEpochDay()).toInt()
        predictionDao.score(standing.id, actualStart, error)
    }

    /**
     * Replace the standing forecast.
     *
     * Exactly one unscored snapshot exists at any moment, which is what makes
     * [scoreOutstanding] unambiguous about which forecast a period answers. The
     * ones dropped here were superseded before their outcome was known — a
     * forecast nobody was looking at when the period arrived is not a wrong
     * forecast, and counting it as one would let a single cycle contribute
     * several errors to the accuracy figures.
     */
    private suspend fun snapshotForecast(basedOnPeriodId: Long?) {
        val starts = periodDao.confirmedStartDates()
        // LocalDate.EPOCH is API 34. ofEpochDay(0) is the same date and is API 26.
        val notYet = notYetDao.since(LocalDate.ofEpochDay(0)).map { it.toDomain() }
        val errors = predictionDao.recentAbsoluteErrors()
        val prediction = engine.predict(
            PredictionInput(starts, today(), notYet, errors),
        ) ?: return
        predictionDao.deleteUnscored()
        predictionDao.insert(prediction.toEntity(clock.instant(), basedOnPeriodId))
    }
}
