package de.jeanlucmakiola.calendula.domain

import kotlinx.datetime.LocalDate
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import kotlin.time.Instant

/**
 * User input for creating an event (and, from v1.3, editing one). Times are
 * wall-clock values in [timezone]; the data layer translates them to provider
 * millis (all-day events normalise to UTC midnights there).
 */
data class EventForm(
    val calendarId: Long?,
    val title: String = "",
    val isAllDay: Boolean = false,
    val start: LocalDateTime,
    val end: LocalDateTime,
    /**
     * The zone [start]/[end] are wall-clock values in, or null to follow the
     * device — null is not "no zone", it is "whichever zone the device is in
     * when this is saved", which is what an event authored and lived in one
     * place wants. The data layer resolves it at write time and always stamps a
     * concrete `EVENT_TIMEZONE`.
     *
     * A non-null value pins the event to a zone regardless of where the device
     * is, so it keeps tracking that zone's offset across DST. [toEditForm] only
     * sets it when the stored zone differs from the device's, so merely opening
     * a local event never reveals the field — and re-opening a pinned one in
     * another zone round-trips it rather than silently re-anchoring it.
     *
     * Always null for all-day events: those are date-anchored, not zone-anchored
     * (see [EventFormField.Timezone] and the data layer's UTC-midnight rule).
     */
    val timezone: String? = null,
    val location: String = "",
    val description: String = "",
    /** Reminder lead times in minutes before the start, deduplicated. */
    val reminders: List<Int> = emptyList(),
    val availability: Availability = Availability.Busy,
    val accessLevel: AccessLevel = AccessLevel.Default,
    /**
     * Bare RRULE value (`Events.RRULE` convention, no "RRULE:" prefix); null
     * means a one-off event. May hold rules the simple picker can't express —
     * those are kept verbatim until the user picks something else.
     */
    val rrule: String? = null,
    /**
     * The event's own colour, or null to inherit the calendar's colour.
     * [colorKey] is a `Colors.COLOR_KEY` from the calendar account's published
     * event palette (Google, some CalDAV) — written as `EVENT_COLOR_KEY` so it
     * round-trips through sync. When it is null but [color] is set, [color] is
     * a raw ARGB written as `EVENT_COLOR` (local calendars, or synced ones the
     * user opted into despite no palette). [color] mirrors the key's swatch when
     * [colorKey] is set, so the picker can highlight it.
     */
    val colorKey: String? = null,
    val color: Int? = null,
    /**
     * Guests the user has added/kept on the event. Calendula only writes these
     * `Attendees` rows — it has no INTERNET and never sends an invitation
     * itself; whether a guest is notified is decided downstream by the
     * calendar's backend (local: no one; CalDAV/Google: the server/account).
     * Read-only rows the form doesn't model (the organizer, resources) are
     * preserved by the data layer, not carried here.
     */
    val attendees: List<EventAttendee> = emptyList(),
)

/**
 * One editable guest: the user controls the [email] (the identity we dedup and
 * write on), an optional display [name], and whether they're [optional] rather
 * than required. Response status and the organizer/resource distinction are not
 * user-editable, so they aren't modelled here.
 */
data class EventAttendee(
    val email: String,
    val name: String = "",
    val optional: Boolean = false,
)

/**
 * The form's optional sections. Which ones show by default is a user setting;
 * the rest unfold behind a "more fields" button. Declaration order is the order
 * they're offered in, so a new constant goes where it belongs on the form, not
 * at the end.
 */
enum class EventFormField {
    Location,
    Description,
    /**
     * Pins the event's wall-clock times to a zone. Offered right after the time
     * fields it qualifies, and suppressed entirely for all-day events, whose
     * dates are deliberately zone-free.
     */
    Timezone,
    Reminders,
    Recurrence,
    Availability,
    Visibility,
    Color,
    Attendees,
}

enum class EventFormProblem {
    /** No target calendar — none picked and no writable calendar exists. */
    NoCalendar,
    EndBeforeStart,
    /** The recurrence's UNTIL date lies before the event's first day. */
    RecurrenceEndsBeforeStart,
}

/**
 * Validation; an empty set means the form can be saved. A blank title is
 * allowed (display falls back to "(No title)", matching the provider), and a
 * zero-length timed event is allowed (spec §8: instant events exist).
 */
/**
 * Prefill the edit form from a loaded event. [beginMillis]/[endMillis] are the
 * tapped occurrence's own times (`Instances.BEGIN`/`END`), not the series
 * start — the data layer later turns a time edit into a delta on the series.
 *
 * All-day provider times are UTC midnights with an exclusive end; the form
 * shows the last covered day and keeps placeholder wall-clock times in case
 * the user switches the event to timed.
 *
 * A timed event stored in a zone other than [zone] is prefilled *in its own
 * zone* and keeps it pinned, so the wall-clock the form shows is the one the
 * event means ("the New York 09:00 call") and a later save re-anchors it to the
 * same zone rather than the device's.
 */
fun EventDetail.toEditForm(beginMillis: Long, endMillis: Long, zone: TimeZone): EventForm {
    // All-day events are date-anchored and carry a nominal "UTC" that is an
    // anchor, not a location, so they never pin a zone.
    val pinnedZone = if (instance.isAllDay) {
        null
    } else {
        eventTimezone
            ?.takeIf { it != zone.id }
            // An unparseable id (a malformed sync row) can't be honoured or
            // shown; fall back to the device zone rather than failing the open.
            ?.takeIf { runCatching { TimeZone.of(it) }.isSuccess }
    }
    val formZone = pinnedZone?.let { TimeZone.of(it) } ?: zone
    val (start, end) = if (instance.isAllDay) {
        val startDate = Instant.fromEpochMilliseconds(beginMillis)
            .toLocalDateTime(TimeZone.UTC).date
        val endExclusive = Instant.fromEpochMilliseconds(endMillis)
            .toLocalDateTime(TimeZone.UTC).date
        val endDate = maxOf(startDate, LocalDate.fromEpochDays(endExclusive.toEpochDays() - 1))
        LocalDateTime(startDate, LocalTime(9, 0)) to LocalDateTime(endDate, LocalTime(10, 0))
    } else {
        Instant.fromEpochMilliseconds(beginMillis).toLocalDateTime(formZone) to
            Instant.fromEpochMilliseconds(endMillis).toLocalDateTime(formZone)
    }
    return EventForm(
        calendarId = instance.calendarId,
        title = instance.title,
        isAllDay = instance.isAllDay,
        start = start,
        end = end,
        timezone = pinnedZone,
        location = instance.location.orEmpty(),
        description = description.orEmpty(),
        reminders = reminders.map { it.minutes }.distinct().sorted(),
        availability = availability,
        accessLevel = accessLevel,
        rrule = rrule?.removePrefix("RRULE:")?.takeIf { it.isNotBlank() },
        // The provider fills EVENT_COLOR from the key, so [color] is the
        // swatch either way; a null colour means the event inherits its
        // calendar's colour.
        colorKey = eventColorKey,
        color = eventColor,
        // Only editable guests ride in the form: drop the organizer and
        // resource rows (not user-editable) and any without an email (we key
        // edits and dedup on the address). The data layer preserves the rows we
        // don't carry here, so they're never clobbered on save.
        attendees = attendees
            .filter {
                it.relationship != AttendeeRelationship.Organizer &&
                    it.type != AttendeeType.Resource
            }
            .mapNotNull { a ->
                a.email?.takeIf { it.isNotBlank() }?.let { email ->
                    EventAttendee(
                        email = email,
                        name = a.name,
                        optional = a.type == AttendeeType.Optional,
                    )
                }
            },
    )
}

/**
 * What the edit form saw when it loaded — compared against a fresh read at
 * save time to detect external changes (sync, another device) that landed
 * while the form was open. The raw row times ride along because
 * [toEditForm] derives the form's times from the *tapped occurrence*, so
 * re-deriving with the same occurrence would mask an externally moved
 * event. Guests are covered (the form writes editable attendees), so an
 * external attendee change now also trips the conflict check. Still not
 * covered: status, the user's own response, reminder methods, the
 * organizer/resource rows, and a recurring event's duration.
 */
data class EditSnapshot(
    val form: EventForm,
    /** The raw Events-row times (for recurring events: the series anchor). */
    val rowStart: Instant,
    val rowEnd: Instant,
)

fun EventDetail.toEditSnapshot(beginMillis: Long, endMillis: Long, zone: TimeZone): EditSnapshot =
    EditSnapshot(
        form = toEditForm(beginMillis, endMillis, zone),
        rowStart = instance.start,
        rowEnd = instance.end,
    )

/**
 * The form's times as they land in [target] — what a pinned event's wall-clock
 * actually means where the user is standing. Null when there's nothing to
 * disambiguate: an unpinned event (already in [target]), one pinned to [target]
 * itself, an all-day event (no zone), or an unparseable pinned zone.
 *
 * The form edits a pinned event in its own zone, so this is what lets the UI
 * show the other side of the pair rather than making the user do the arithmetic.
 */
fun EventForm.timesIn(target: TimeZone): Pair<LocalDateTime, LocalDateTime>? {
    if (isAllDay) return null
    val pinned = timezone?.let { runCatching { TimeZone.of(it) }.getOrNull() } ?: return null
    if (pinned.id == target.id) return null
    return start.toInstant(pinned).toLocalDateTime(target) to
        end.toInstant(pinned).toLocalDateTime(target)
}

/**
 * The optional sections that hold a value in [form] — when editing, these
 * must be visible regardless of the user's default-fields setting, or the
 * data they carry would be invisible (though still preserved).
 */
fun EventForm.populatedFields(): Set<EventFormField> = buildSet {
    if (location.isNotBlank()) add(EventFormField.Location)
    if (description.isNotBlank()) add(EventFormField.Description)
    if (timezone != null) add(EventFormField.Timezone)
    if (reminders.isNotEmpty()) add(EventFormField.Reminders)
    if (rrule != null) add(EventFormField.Recurrence)
    if (availability != Availability.Busy) add(EventFormField.Availability)
    if (accessLevel != AccessLevel.Default) add(EventFormField.Visibility)
    if (colorKey != null || color != null) add(EventFormField.Color)
    if (attendees.isNotEmpty()) add(EventFormField.Attendees)
}

fun EventForm.problems(): Set<EventFormProblem> = buildSet {
    if (calendarId == null) add(EventFormProblem.NoCalendar)
    val endsTooEarly = if (isAllDay) end.date < start.date else end < start
    if (endsTooEarly) add(EventFormProblem.EndBeforeStart)
    // An UNTIL before the first day would make the provider generate zero
    // occurrences — the event would silently vanish from every view.
    val recurrenceEnd = rrule?.let(::parseSimpleRecurrence)?.end
    if (recurrenceEnd is RecurrenceEnd.Until && recurrenceEnd.date < start.date) {
        add(EventFormProblem.RecurrenceEndsBeforeStart)
    }
}
