package dev.privacyllc.period.domain.prediction

import java.time.LocalDate
import kotlin.math.roundToLong

/**
 * How likely conception is on a given day, in the only vocabulary §18 permits.
 *
 * The prohibition is three words long — *avoid "safe" or "unsafe" labels* — and
 * it is not squeamishness about wording. Somebody reading "safe" will take a
 * decision on it; the estimate is derived from a *predicted* date carrying days
 * of uncertainty; and §18 has already promised this is not contraception.
 * "Lower likelihood" is true. "Safe" is a claim the model cannot support.
 */
enum class FertilityLikelihood {
    LOWER,
    HIGHER,

    /**
     * The forecast is too uncertain to say either way.
     *
     * A day can sit outside the estimated window and still not deserve "lower
     * likelihood", because the window itself is only as good as the period
     * prediction it hangs off. Where that is weak, the honest answer is the
     * vaguer one rather than a confident-sounding one.
     */
    UNKNOWN,
    ;

    /** User-facing text. §18's words, not the enum's. */
    val label: String
        get() = when (this) {
            LOWER -> "Lower likelihood"
            HIGHER -> "Higher likelihood"
            UNKNOWN -> "Not enough history to estimate"
        }
}

/**
 * Estimated ovulation and the fertile window around it.
 *
 * **Everything here is derived from the period forecast**, so nothing here can
 * be more certain than that forecast. §17: estimate ovulation relative to the
 * predicted next period; do not imply a fixed day-14 rule is biologically exact.
 *
 * [uncertaintyDays] carries that inheritance explicitly rather than leaving it
 * implied, because the tempting mistake is to present a single crisp ovulation
 * date computed from a nine-day period window — precision the app invented.
 */
data class FertilityEstimate(
    val ovulationDate: LocalDate,
    val fertileWindowStart: LocalDate,
    val fertileWindowEnd: LocalDate,
    /** Half-width, in days, inherited from the forecast the estimate hangs off. */
    val uncertaintyDays: Int,
    val lutealPhaseDays: Int,
) {
    val fertileWindow: ClosedRange<LocalDate> get() = fertileWindowStart..fertileWindowEnd

    init {
        require(!fertileWindowStart.isAfter(fertileWindowEnd)) {
            "fertile window starts after it ends"
        }
        require(ovulationDate in fertileWindowStart..fertileWindowEnd) {
            "ovulation $ovulationDate falls outside its own fertile window"
        }
    }

    /**
     * The label for [date]. Never "safe", by construction — the type has no such
     * value.
     */
    fun likelihoodOn(date: LocalDate): FertilityLikelihood =
        if (date in fertileWindowStart..fertileWindowEnd) {
            FertilityLikelihood.HIGHER
        } else {
            FertilityLikelihood.LOWER
        }

    companion object {
        /**
         * Days from ovulation to the next period.
         *
         * The luteal phase is the more stable half of the cycle, which is why
         * estimating backwards from a predicted period is more defensible than
         * counting forwards from the last one — and why §17 asks for exactly
         * that. Fourteen is the common assumption and it is **an assumption**;
         * it is a named, overridable constant rather than a literal buried in
         * arithmetic, so the day this app can measure it, one value changes.
         */
        const val DEFAULT_LUTEAL_PHASE_DAYS = 14

        /** Sperm survive several days; the window opens before ovulation, not on it. */
        const val FERTILE_DAYS_BEFORE_OVULATION = 5L

        /** The egg is viable for about a day. */
        const val FERTILE_DAYS_AFTER_OVULATION = 1L

        /**
         * Past this much forecast uncertainty, no estimate is offered at all.
         *
         * The window is inherently seven days wide before any uncertainty is
         * added, so a forecast carrying ±5 produces a "fertile window" of
         * seventeen days — over half a cycle. That is honest arithmetic and
         * useless information, and it was on screen before anybody noticed:
         * a user one cycle into the app was shown *8 Aug – 24 Aug*.
         *
         * Declining is the better answer. "We do not know your cycle well
         * enough yet" is true, is useful, and gives the user a reason to keep
         * logging. A seventeen-day window says nothing and looks like a
         * feature.
         */
        const val MAX_USEFUL_UNCERTAINTY_DAYS = 3

        /**
         * Estimate from a period forecast, or **null when it would not mean
         * anything**.
         *
         * Two ways to get null, and both are the app declining to invent
         * precision:
         *
         *  - there is no forecast. §17's estimate hangs off the predicted next
         *    period, and a day-14 guess without one is exactly the fixed-rule
         *    implication §17 rules out;
         *  - the forecast is too vague. See [MAX_USEFUL_UNCERTAINTY_DAYS].
         */
        fun from(
            forecast: Prediction?,
            lutealPhaseDays: Int = DEFAULT_LUTEAL_PHASE_DAYS,
        ): FertilityEstimate? {
            forecast ?: return null

            val ovulation = forecast.mostLikelyStartDate.minusDays(lutealPhaseDays.toLong())

            // Inherited, not invented. The forecast's own window is the floor on
            // how precisely ovulation can possibly be known.
            val forecastHalfWidth =
                ((forecast.windowEnd.toEpochDay() - forecast.windowStart.toEpochDay()) / 2.0)
                    .roundToLong().toInt()

            val start = ovulation
                .minusDays(FERTILE_DAYS_BEFORE_OVULATION)
                .minusDays(forecastHalfWidth.toLong())
            val end = ovulation
                .plusDays(FERTILE_DAYS_AFTER_OVULATION)
                .plusDays(forecastHalfWidth.toLong())

            if (forecastHalfWidth > MAX_USEFUL_UNCERTAINTY_DAYS) return null

            return FertilityEstimate(
                ovulationDate = ovulation,
                fertileWindowStart = start,
                fertileWindowEnd = end,
                uncertaintyDays = forecastHalfWidth,
                lutealPhaseDays = lutealPhaseDays,
            )
        }
    }
}
