;; lib/events/calendar.sx — civil date arithmetic + RRULE expansion in a window.
;;
;; Datetimes are integer "epoch minutes": days-since-1970-01-01 * 1440 plus
;; minute-of-day. Ordering, window bounds, and durations are plain integer
;; arithmetic. Civil <-> day-number conversion uses Howard Hinnant's algorithm
;; (exact, branch-free, correct for the proleptic Gregorian calendar).
;;
;; RRULE expansion is the bridge to Datalog: a recurring event expands to a
;; bounded list of occurrence dicts within an explicit (win-start, win-end)
;; window. Expansion is ALWAYS windowed — an RRULE without a window is an
;; infinite computation and is never permitted. Supported subset (RFC 5545):
;; FREQ=DAILY|WEEKLY|MONTHLY, INTERVAL, COUNT, UNTIL, BYDAY (weekly: weekday
;; numbers; monthly: {:ord N :wd W} ordinal weekdays), BYMONTHDAY (monthly,
;; negative = from month end). YEARLY and the rest are deferred.

;; ---- integer helpers ----

;; Floored integer division (modulo is already floored, so the remainder
;; subtraction makes the quotient exact and floor-correct for any sign).
(define ev-floor-div (fn (a b) (quotient (- a (modulo a b)) b)))

(define ev-or (fn (x d) (if (nil? x) d x)))

(define ev-filter-nil (fn (xs) (filter (fn (x) (not (nil? x))) xs)))

;; ---- civil date core (Hinnant) ----

;; Days since 1970-01-01 for civil (y, m, d). m in [1,12], d in [1,31].
(define
  ev-days-from-civil
  (fn
    (y0 m d)
    (let
      ((y (if (<= m 2) (- y0 1) y0)))
      (let
        ((era (ev-floor-div (if (>= y 0) y (- y 399)) 400)))
        (let
          ((yoe (- y (* era 400)))
            (doy
              (+
                (ev-floor-div
                  (+
                    (*
                      153
                      (+ m (if (> m 2) -3 9)))
                    2)
                  5)
                (- d 1))))
          (let
            ((doe (+ (* yoe 365) (ev-floor-div yoe 4) (- (ev-floor-div yoe 100)) doy)))
            (+ (* era 146097) doe -719468)))))))

;; Civil (y m d) list from a day-number.
(define
  ev-civil-from-days
  (fn
    (z0)
    (let
      ((z (+ z0 719468)))
      (let
        ((era (ev-floor-div (if (>= z 0) z (- z 146096)) 146097)))
        (let
          ((doe (- z (* era 146097))))
          (let
            ((yoe (ev-floor-div (+ (- doe (ev-floor-div doe 1460)) (ev-floor-div doe 36524) (- (ev-floor-div doe 146096))) 365)))
            (let
              ((y (+ yoe (* era 400)))
                (doy
                  (-
                    doe
                    (+
                      (* 365 yoe)
                      (ev-floor-div yoe 4)
                      (- (ev-floor-div yoe 100))))))
              (let
                ((mp (ev-floor-div (+ (* 5 doy) 2) 153)))
                (let
                  ((d (+ (- doy (ev-floor-div (+ (* 153 mp) 2) 5)) 1))
                    (m
                      (if
                        (< mp 10)
                        (+ mp 3)
                        (- mp 9))))
                  (list (if (<= m 2) (+ y 1) y) m d))))))))))

;; Weekday of a day-number: 0=Mon .. 6=Sun (1970-01-01 is Thursday = 3).
(define ev-weekday-of-days (fn (z) (modulo (+ z 3) 7)))

(define
  ev-days-in-month
  (fn
    (y m)
    (-
      (ev-days-from-civil
        (if (= m 12) (+ y 1) y)
        (if (= m 12) 1 (+ m 1))
        1)
      (ev-days-from-civil y m 1))))

;; Add k months to (y,m), returning (list y2 m2).
(define
  ev-add-months
  (fn
    (y m k)
    (let
      ((total (+ (* y 12) (- m 1) k)))
      (list
        (ev-floor-div total 12)
        (+ (modulo total 12) 1)))))

;; ---- datetime (epoch minutes) ----

(define
  ev-dt
  (fn
    (y m d hh mm)
    (+ (* (ev-days-from-civil y m d) 1440) (* hh 60) mm)))

(define ev-date (fn (y m d) (ev-dt y m d 0 0)))

(define ev-dt->days (fn (t) (ev-floor-div t 1440)))

(define ev-dt->civil (fn (t) (ev-civil-from-days (ev-dt->days t))))

(define ev-dt-weekday (fn (t) (ev-weekday-of-days (ev-dt->days t))))

(define ev-dt-tod (fn (t) (modulo t 1440)))

(define ev-civ-y (fn (c) (first c)))
(define ev-civ-m (fn (c) (first (rest c))))
(define ev-civ-d (fn (c) (first (rest (rest c)))))

;; ---- event + occurrence constructors ----

;; rrule is nil (single event) or a dict:
;;   {:freq :daily|:weekly|:monthly :interval N :count N|nil :until DT|nil
;;    :byday ...|nil :bymonthday (list 15 -1)|nil}
;;   weekly  :byday -> (list 0 2 4)            weekday numbers, 0=Mon
;;   monthly :byday -> (list {:ord 2 :wd 1})   nth weekday (ord<0 from end)
;;   monthly :bymonthday -> (list 15 -1)       day of month (negative from end)
(define ev-event (fn (id dtstart duration rrule capacity) {:duration duration :id id :dtstart dtstart :capacity capacity :rrule rrule}))

;; Event with EXDATE/RDATE exceptions. exdate/rdate are lists of epoch-minute
;; starts to exclude from / add to the expansion (RFC 5545 VEVENT properties).
(define
  ev-event-full
  (fn
    (id dtstart duration rrule capacity exdate rdate)
    {:duration duration
      :id id
      :dtstart dtstart
      :capacity capacity
      :rrule rrule
      :exdate exdate
      :rdate rdate}))

(define ev-occ (fn (id start dur) {:id id :start start :end (+ start dur)}))

;; ---- DAILY expansion ----
;; occ starts at dtstart; n counts every generated occurrence (window-
;; independent, so COUNT/UNTIL bound the rule, not the view). Emits only
;; occurrences inside [win-start, win-end].
(define
  ev-daily-loop
  (fn
    (id occ duration step count until dtstart win-start win-end acc n)
    (cond
      ((> occ win-end) acc)
      ((and (not (nil? count)) (>= n count)) acc)
      ((and (not (nil? until)) (> occ until)) acc)
      (else
        (begin
          (when (>= occ win-start) (append! acc (ev-occ id occ duration)))
          (ev-daily-loop
            id
            (+ occ step)
            duration
            step
            count
            until
            dtstart
            win-start
            win-end
            acc
            (+ n 1)))))))

;; ---- shared per-period emit ----
;; Walk a start-ascending list of candidate occurrence datetimes for one
;; period, generating (count toward COUNT) those >= dtstart within UNTIL, and
;; emitting those also inside the window. Returns the updated running n.
(define
  ev-emit-occs
  (fn
    (id occs duration count until dtstart win-start win-end acc n)
    (if
      (empty? occs)
      n
      (let
        ((occ (first occs)))
        (let
          ((generates? (and (>= occ dtstart) (or (nil? until) (<= occ until)) (or (nil? count) (< n count)))))
          (begin
            (when
              (and generates? (>= occ win-start) (<= occ win-end))
              (append! acc (ev-occ id occ duration)))
            (ev-emit-occs
              id
              (rest occs)
              duration
              count
              until
              dtstart
              win-start
              win-end
              acc
              (if generates? (+ n 1) n))))))))

;; ---- WEEKLY expansion ----
;; Iterate week by week from the Monday of dtstart's week; within each active
;; week emit each BYDAY (sorted). n counts every generated occurrence.

(define
  ev-week0-days
  (fn (dtstart) (- (ev-dt->days dtstart) (ev-dt-weekday dtstart))))

(define
  ev-byday-default
  (fn
    (byday dtstart)
    (if (nil? byday) (list (ev-dt-weekday dtstart)) (sort byday))))

(define
  ev-weekly-loop
  (fn
    (id
      week-days
      tod
      duration
      week-step
      bd
      count
      until
      dtstart
      win-start
      win-end
      acc
      n)
    (let
      ((week-start-dt (* week-days 1440)))
      (cond
        ((> week-start-dt win-end) acc)
        ((and (not (nil? count)) (>= n count)) acc)
        (else
          (let
            ((occs (map (fn (wd) (+ (* (+ week-days wd) 1440) tod)) bd)))
            (let
              ((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n)))
              (ev-weekly-loop
                id
                (+ week-days week-step)
                tod
                duration
                week-step
                bd
                count
                until
                dtstart
                win-start
                win-end
                acc
                n2))))))))

;; ---- MONTHLY expansion ----
;; Iterate month by month from dtstart's month, stepping by INTERVAL months.
;; Candidate days per month come from BYMONTHDAY, then ordinal BYDAY, else the
;; day-of-month of dtstart (skipped in months too short to contain it).

;; Resolve a BYMONTHDAY value to a valid day-of-month, or nil.
(define
  ev-resolve-monthday
  (fn
    (y m bmd)
    (let
      ((dim (ev-days-in-month y m)))
      (let
        ((day (if (< bmd 0) (+ dim 1 bmd) bmd)))
        (if (and (>= day 1) (<= day dim)) day nil)))))

;; Resolve an ordinal weekday {:ord :wd} to a day-of-month, or nil.
(define
  ev-resolve-nth-weekday
  (fn
    (y m ord wd)
    (let
      ((dim (ev-days-in-month y m)))
      (if
        (> ord 0)
        (let
          ((first-wd (ev-weekday-of-days (ev-days-from-civil y m 1))))
          (let
            ((day (+ 1 (modulo (- wd first-wd) 7) (* (- ord 1) 7))))
            (if (<= day dim) day nil)))
        (let
          ((last-wd (ev-weekday-of-days (ev-days-from-civil y m dim))))
          (let
            ((day (- dim (modulo (- last-wd wd) 7) (* (- (- ord) 1) 7))))
            (if (>= day 1) day nil)))))))

(define
  ev-month-candidates
  (fn
    (y m rrule dtstart)
    (let
      ((bmd (get rrule :bymonthday)) (byday (get rrule :byday)))
      (cond
        ((not (nil? bmd))
          (ev-filter-nil (map (fn (d) (ev-resolve-monthday y m d)) bmd)))
        ((not (nil? byday))
          (ev-filter-nil
            (map
              (fn
                (e)
                (ev-resolve-nth-weekday y m (get e :ord) (get e :wd)))
              byday)))
        (else
          (ev-filter-nil
            (list
              (ev-resolve-monthday y m (ev-civ-d (ev-dt->civil dtstart))))))))))

(define
  ev-monthly-loop
  (fn
    (id
      y
      m
      rrule
      duration
      tod
      interval
      count
      until
      dtstart
      win-start
      win-end
      acc
      n)
    (let
      ((month-start (ev-dt y m 1 0 0)))
      (cond
        ((> month-start win-end) acc)
        ((and (not (nil? count)) (>= n count)) acc)
        (else
          (let
            ((days (sort (ev-month-candidates y m rrule dtstart))))
            (let
              ((occs (map (fn (d) (+ (* (ev-days-from-civil y m d) 1440) tod)) days)))
              (let
                ((n2 (ev-emit-occs id occs duration count until dtstart win-start win-end acc n))
                  (nm (ev-add-months y m interval)))
                (ev-monthly-loop
                  id
                  (ev-civ-y nm)
                  (ev-civ-m nm)
                  rrule
                  duration
                  tod
                  interval
                  count
                  until
                  dtstart
                  win-start
                  win-end
                  acc
                  n2)))))))))

;; ---- top-level expansion ----
;; Raw expansion (RRULE / single event), before EXDATE/RDATE are applied.
;; Returns a list of occurrence dicts {:id :start :end} within the window.
(define
  ev-expand-base
  (fn
    (event win-start win-end)
    (let
      ((id (get event :id))
        (dtstart (get event :dtstart))
        (duration (get event :duration))
        (rrule (get event :rrule)))
      (if
        (nil? rrule)
        (if
          (and (>= dtstart win-start) (<= dtstart win-end))
          (list (ev-occ id dtstart duration))
          (list))
        (let
          ((freq (get rrule :freq))
            (interval (ev-or (get rrule :interval) 1))
            (count (get rrule :count))
            (until (get rrule :until))
            (byday (get rrule :byday))
            (acc (list)))
          (begin
            (cond
              ((= freq :daily)
                (ev-daily-loop
                  id
                  dtstart
                  duration
                  (* interval 1440)
                  count
                  until
                  dtstart
                  win-start
                  win-end
                  acc
                  0))
              ((= freq :weekly)
                (ev-weekly-loop
                  id
                  (ev-week0-days dtstart)
                  (ev-dt-tod dtstart)
                  duration
                  (* interval 7)
                  (ev-byday-default byday dtstart)
                  count
                  until
                  dtstart
                  win-start
                  win-end
                  acc
                  0))
              ((= freq :monthly)
                (let
                  ((civ (ev-dt->civil dtstart)))
                  (ev-monthly-loop
                    id
                    (ev-civ-y civ)
                    (ev-civ-m civ)
                    rrule
                    duration
                    (ev-dt-tod dtstart)
                    interval
                    count
                    until
                    dtstart
                    win-start
                    win-end
                    acc
                    0)))
              (else (error (str "ev-expand-base: unsupported freq: " freq))))
            acc))))))

;; ---- EXDATE / RDATE (RFC 5545 exceptions) ----
;; Applied AFTER raw expansion: RDATE adds explicit occurrences within the
;; window, EXDATE removes occurrences whose start matches (EXDATE wins over
;; RDATE). Both are VEVENT-level: (get event :exdate) / (get event :rdate) are
;; lists of epoch-minute starts; nil for plain events.

(define
  ev-num-member?
  (fn
    (n xs)
    (cond
      ((empty? xs) false)
      ((= n (first xs)) true)
      (else (ev-num-member? n (rest xs))))))

;; Drop duplicate-start occurrences from a start-sorted list (keep one).
(define
  ev-dedupe-by-start
  (fn
    (occs)
    (cond
      ((empty? occs) occs)
      ((empty? (rest occs)) occs)
      ((= (get (first occs) :start) (get (first (rest occs)) :start))
        (ev-dedupe-by-start (rest occs)))
      (else (cons (first occs) (ev-dedupe-by-start (rest occs)))))))

(define
  ev-apply-exceptions
  (fn
    (event base win-start win-end)
    (let
      ((id (get event :id))
        (duration (get event :duration))
        (exdate (ev-or (get event :exdate) (list)))
        (rdate (ev-or (get event :rdate) (list))))
      (let
        ((rdate-occs
           (reduce
             (fn
               (acc d)
               (if
                 (and (>= d win-start) (<= d win-end))
                 (cons (ev-occ id d duration) acc)
                 acc))
             (list)
             rdate)))
        (let
          ((no-ex
             (filter
               (fn (o) (not (ev-num-member? (get o :start) exdate)))
               (append base rdate-occs))))
          (ev-dedupe-by-start (ev-sort-occs no-ex)))))))

;; ---- per-occurrence overrides (RFC 5545 RECURRENCE-ID) ----
;; A single instance of a recurring series can be detached and rescheduled. The
;; event carries :overrides — a list of (orig-start {:start :duration}) — keyed
;; by the occurrence's ORIGINAL start. Applied after EXDATE/RDATE. A moved
;; instance whose new start leaves the window is dropped from this window (the
;; original slot is vacated); an instance moved INTO the window from outside is
;; out of scope for a windowed expansion (known stub limitation).

(define
  ev-assoc-lookup
  (fn
    (k pairs)
    (cond
      ((empty? pairs) nil)
      ((= (first (first pairs)) k) (first (rest (first pairs))))
      (else (ev-assoc-lookup k (rest pairs))))))

(define
  ev-apply-overrides
  (fn
    (id base overrides)
    (map
      (fn
        (o)
        (let
          ((ov (ev-assoc-lookup (get o :start) overrides)))
          (if (nil? ov) o (ev-occ id (get ov :start) (get ov :duration)))))
      base)))

;; Add an override that reschedules the occurrence originally at `orig-start`
;; to `new-start` with `new-duration`.
(define
  ev-with-override
  (fn
    (event orig-start new-start new-duration)
    (assoc
      event
      :overrides
      (cons
        (list orig-start {:start new-start :duration new-duration})
        (ev-or (get event :overrides) (list))))))

;; Naive (single time-domain) expansion: RRULE + EXDATE/RDATE + overrides.
(define
  ev-expand-naive
  (fn
    (event win-start win-end)
    (let
      ((excepted
         (ev-apply-exceptions
           event
           (ev-expand-base event win-start win-end)
           win-start
           win-end))
        (overrides (ev-or (get event :overrides) (list)))
        (id (get event :id)))
      (if
        (empty? overrides)
        excepted
        (filter
          (fn (o) (and (>= (get o :start) win-start) (<= (get o :start) win-end)))
          (ev-sort-occs (ev-apply-overrides id excepted overrides)))))))

;; Public entry point. A tz-aware event (`:tz` set) expands in local wall-clock
;; time and converts each occurrence to UTC (ev-expand-tz, timezone.sx); a plain
;; event expands naively in a single time domain. The window is UTC either way.
(define
  ev-expand
  (fn
    (event win-start win-end)
    (let
      ((tz (get event :tz)))
      (if
        (nil? tz)
        (ev-expand-naive event win-start win-end)
        (ev-expand-tz event tz win-start win-end)))))

;; ---- multi-event expansion (sorted by start) ----

;; Insertion of one occurrence into a start-ascending list.
(define
  ev-occ-insert
  (fn
    (o sorted)
    (cond
      ((empty? sorted) (list o))
      ((<= (get o :start) (get (first sorted) :start)) (cons o sorted))
      (else (cons (first sorted) (ev-occ-insert o (rest sorted)))))))

(define
  ev-sort-occs
  (fn (occs) (reduce (fn (acc o) (ev-occ-insert o acc)) (list) occs)))

;; Expand many events into one occurrence list, ascending by start.
(define
  ev-expand-all
  (fn
    (events win-start win-end)
    (let
      ((acc (list)))
      (begin
        (for-each
          (fn
            (ev)
            (for-each
              (fn (o) (append! acc o))
              (ev-expand ev win-start win-end)))
          events)
        (ev-sort-occs acc)))))
