package dev.privacyllc.period.domain.prediction

import dev.privacyllc.period.domain.cycle.PeriodRecord
import dev.privacyllc.period.domain.cycle.SpottingRecord
import java.time.LocalDate

/**
 * What a calendar day is, at most one thing.
 *
 * Ordered by precedence, highest first. A day can qualify for several — the
 * predicted window can overlap a fertile window on a short cycle — and showing
 * two markers on one square makes both unreadable at the size a month grid
 * gives you.
 *
 * The rule for the ordering: **a fact outranks an estimate, and the more
 * specific estimate outranks the vaguer one.**
 */
enum class DayMark {
    /** Recorded by the user. The only certain one. */
    CONFIRMED_PERIOD,

    /** Recorded by the user, and deliberately not a period. */
    SPOTTING,

    /** Inside the forecast window — the next period, the one the app scores itself on. */
    PREDICTED_PERIOD,

    /**
     * Inside a projected window further out than the next period.
     *
     * Ranked below [PREDICTED_PERIOD] and above the fertility estimates, which
     * is the same rule the rest of this list follows: an estimate the app will
     * be held to outranks one it will not, and both outrank a vaguer estimate
     * derived from them.
     *
     * Distinct from [PREDICTED_PERIOD] rather than reusing it, because the two
     * make different promises. The next period is a forecast conditioned on
     * everything the app knows today; a projection assumes nothing changes for
     * months. Drawing them identically would be the calendar making the second
     * claim in the first one's voice.
     */
    PROJECTED_PERIOD,

    /** The single estimated ovulation day. Batch 04. */
    OVULATION,

    /** Inside the estimated fertile window. Batch 04. */
    FERTILE_WINDOW,

    NONE,
}

data class CalendarDay(
    val date: LocalDate,
    val mark: DayMark,
    val isToday: Boolean,
    /** Set only on [DayMark.PROJECTED_PERIOD]: how far out this square is reaching. */
    val monthsAhead: Int? = null,
) {
    /**
     * What a screen reader says.
     *
     * §43: a calendar that reads as a grid of bare numbers carries none of its
     * information. The marker shape is what a sighted user reads; this is the
     * same fact for everybody else, and it is not optional.
     */
    val accessibilityLabel: String = buildString {
        append(date.dayOfMonth)
        when (mark) {
            DayMark.CONFIRMED_PERIOD -> append(", period")
            DayMark.SPOTTING -> append(", spotting")
            DayMark.PREDICTED_PERIOD -> append(", period predicted")
            DayMark.PROJECTED_PERIOD -> {
                append(", period projected")
                // How far out, in the label itself. A screen reader user cannot
                // see that this square is months away from the one the forecast
                // covers, and "period projected" alone would read as the same
                // claim. §43: the marker shape is what a sighted user reads;
                // this is the same fact for everybody else.
                monthsAhead?.let { append(", about $it month${if (it == 1) "" else "s"} away") }
            }
            DayMark.OVULATION -> append(", estimated ovulation")
            DayMark.FERTILE_WINDOW -> append(", estimated fertile window")
            DayMark.NONE -> Unit
        }
        if (isToday) append(", today")
    }
}

object CalendarMarks {

    /**
     * Mark every day in [month].
     *
     * Everything is derived per call rather than stored. A `calendar_days` table
     * would be a second copy of facts the period records and the forecast
     * already hold, and it would be stale the moment either changed.
     */
    fun forMonth(
        month: java.time.YearMonth,
        periods: List<PeriodRecord>,
        spotting: List<SpottingRecord>,
        forecast: Prediction?,
        today: LocalDate,
        fertileWindow: ClosedRange<LocalDate>? = null,
        ovulation: LocalDate? = null,
        /**
         * Cycles beyond the next one. Empty by default, so a caller that has not
         * opted into projecting a year ahead gets exactly the calendar it had.
         */
        projection: CycleProjection = CycleProjection.Empty,
    ): List<CalendarDay> {
        val confirmed = buildSet {
            periods.filter { it.isConfirmed }.forEach { p ->
                // An unclosed period marks its start day only. Filling forward
                // to today would draw a period the user never said was still
                // running — the calendar would be asserting it.
                val end = p.endDate ?: p.startDate
                var d = p.startDate
                while (!d.isAfter(end)) { add(d); d = d.plusDays(1) }
            }
        }
        val spotted = spotting.map { it.date }.toSet()

        return (1..month.lengthOfMonth()).map { day ->
            val date = month.atDay(day)
            val projected = projection.cycleCovering(date)?.takeIf { it.cyclesAhead > 1 }
            val mark = markFor(date, confirmed, spotted, forecast, projected, fertileWindow, ovulation)
            CalendarDay(
                date = date,
                mark = mark,
                isToday = date == today,
                monthsAhead = projected
                    ?.takeIf { mark == DayMark.PROJECTED_PERIOD }
                    ?.let { monthsBetween(today, date) },
            )
        }
    }

    private fun markFor(
        date: LocalDate,
        confirmed: Set<LocalDate>,
        spotted: Set<LocalDate>,
        forecast: Prediction?,
        projected: ProjectedCycle?,
        fertileWindow: ClosedRange<LocalDate>?,
        ovulation: LocalDate?,
    ): DayMark = when {
        date in confirmed -> DayMark.CONFIRMED_PERIOD
        date in spotted -> DayMark.SPOTTING
        forecast != null && date >= forecast.windowStart && date <= forecast.windowEnd ->
            DayMark.PREDICTED_PERIOD
        projected != null -> DayMark.PROJECTED_PERIOD
        ovulation != null && date == ovulation -> DayMark.OVULATION
        fertileWindow != null && date in fertileWindow -> DayMark.FERTILE_WINDOW
        else -> DayMark.NONE
    }

    /** Rounded to the nearest month, for a label that says "about". */
    private fun monthsBetween(today: LocalDate, date: LocalDate): Int =
        maxOf(1, Math.round((date.toEpochDay() - today.toEpochDay()) / 30.44).toInt())
}
