;;; org-habit-ng.el --- Full RRULE recurrence for org-habit -*- lexical-binding: t; -*-

;; Copyright (C) 2024 Trevoke

;; Author: Aldric Giacomoni <trevoke@gmail.com>
;; Assisted-by: Claude:claude-opus-4-8
;; Package-Version: 0.7.1
;; Package-Revision: 0.7.1-0-g7fdd0331edbc
;; Package-Requires: ((emacs "28.1") (org "9.6") (transient "0.13.5"))
;; Keywords: calendar, org, habits
;; URL: https://codeberg.org/Trevoke/org-habit-ng

;; This file is not part of GNU Emacs.

;; SPDX-License-Identifier: GPL-3.0-or-later

;; This program is free software: you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.

;;; Commentary:

;; org-habit-ng extends org-habit to support RFC 5545 RRULE recurrence
;; patterns that cannot be expressed with standard org repeaters.
;;
;; Supported RRULE components:
;;   FREQ: DAILY, WEEKLY, MONTHLY, YEARLY
;;   INTERVAL: repeat every N periods
;;   BYDAY: weekdays with optional ordinals (MO, 2SA, -1FR)
;;   BYMONTHDAY: days of month (1, 15, -1 for last)
;;   BYMONTH: specific months
;;   BYSETPOS: position in set (1 for first, -1 for last)
;;   BYWEEKNO, BYYEARDAY, BYHOUR, BYMINUTE, BYSECOND
;;   COUNT, UNTIL: termination conditions
;;
;; Extensions:
;;   X-REPEAT-FROM: completion | scheduled | scheduled-future
;;   X-WARN: warning duration (3d, 2w, 12h)
;;
;; Installation:
;;   (require 'org-habit-ng)
;;   (org-habit-ng-mode 1)
;;
;; RRULE Usage:
;;   Add a :RECURRENCE: property with RRULE syntax:
;;
;;   * TODO Monthly review
;;   SCHEDULED: <2024-01-13 Sat .+1m>
;;   :PROPERTIES:
;;   :STYLE: habit
;;   :RECURRENCE: FREQ=MONTHLY;BYDAY=2SA;X-REPEAT-FROM=completion
;;   :END:
;;
;; More RRULE examples:
;;   - Every weekday: FREQ=WEEKLY;BYDAY=MO,WE,FR
;;   - Last Friday of month: FREQ=MONTHLY;BYDAY=-1FR
;;   - Last day of month: FREQ=MONTHLY;BYMONTHDAY=-1
;;   - Last Sunday of December: FREQ=YEARLY;BYMONTH=12;BYDAY=-1SU
;;   - Every 3 days: FREQ=DAILY;INTERVAL=3
;;   - First weekday of month: FREQ=MONTHLY;BYDAY=MO,TU,WE,TH,FR;BYSETPOS=1
;;
;; The SCHEDULED timestamp must have a repeater (e.g., .+1m) for org-habit
;; compatibility, but org-habit-ng will override it with the correct date
;; based on the RECURRENCE rule when you mark the task as DONE.
;;
;; The consistency graph accurately reflects RRULE due dates for complex
;; recurrence patterns (e.g., "2nd Saturday" shows exact intervals of 28-35 days).

;;; Code:

(require 'org)
(require 'org-habit)
(require 'calendar)
(require 'cl-lib)
(require 'transient)

(defgroup org-habit-ng nil
  "Complex recurrence patterns for org-habit."
  :group 'org-habit
  :prefix "org-habit-ng-")

(defconst org-habit-ng--ordinal-alist
  '(("first" . 1) ("1st" . 1)
    ("second" . 2) ("2nd" . 2)
    ("third" . 3) ("3rd" . 3)
    ("fourth" . 4) ("4th" . 4)
    ("fifth" . 5) ("5th" . 5)
    ("last" . -1) ("second-to-last" . -2))
  "Alist mapping ordinal strings to numbers.
Negative numbers count from the end.")

(defun org-habit-ng--ordinal-to-number (ordinal)
  "Convert ORDINAL string to a number.
Returns negative for \"last\" (-1), \"second-to-last\" (-2), etc."
  (cdr (assoc (downcase ordinal) org-habit-ng--ordinal-alist)))

(defconst org-habit-ng--weekday-alist
  '(("sunday" . 0) ("sun" . 0) ("su" . 0)
    ("monday" . 1) ("mon" . 1) ("mo" . 1)
    ("tuesday" . 2) ("tue" . 2) ("tu" . 2)
    ("wednesday" . 3) ("wed" . 3) ("we" . 3)
    ("thursday" . 4) ("thu" . 4) ("th" . 4)
    ("friday" . 5) ("fri" . 5) ("fr" . 5)
    ("saturday" . 6) ("sat" . 6) ("sa" . 6))
  "Alist mapping weekday strings to numbers (0=Sunday).")

(defun org-habit-ng--weekday-to-number (weekday)
  "Convert WEEKDAY string to a number (0=Sunday, 6=Saturday)."
  (cdr (assoc (downcase weekday) org-habit-ng--weekday-alist)))

(defun org-habit-ng--nth-weekday-in-month (n weekday month year)
  "Return Gregorian date of Nth WEEKDAY in MONTH YEAR.
N can be negative (-1 = last, -2 = second-to-last).
WEEKDAY is 0-6 (Sunday-Saturday).
Returns (MONTH DAY YEAR)."
  (calendar-nth-named-day n weekday month year))

(defun org-habit-ng--first-weekday-in-month (n month year)
  "Return the first (N=1) or last (N=-1) weekday in MONTH YEAR.
A weekday is any day whose day-of-week is not in `calendar-weekend-days'
\(default (0 6) = Sat+Sun), so the user's weekend definition is honored.
Returns (MONTH DAY YEAR)."
  (let* ((last-dom (calendar-last-day-of-month month year))
         (weekend-p (lambda (dom)
                      (memq (calendar-day-of-week (list month dom year))
                            calendar-weekend-days))))
    (if (= n 1)
        ;; First weekday: walk forward from day-of-month 1.
        (let ((dom 1))
          (while (and (< dom last-dom) (funcall weekend-p dom))
            (setq dom (1+ dom)))
          (list month dom year))
      ;; Last weekday: walk backward from the last day of the month.
      (let ((dom last-dom))
        (while (and (> dom 1) (funcall weekend-p dom))
          (setq dom (1- dom)))
        (list month dom year)))))

(defun org-habit-ng--last-day-of-month (month year)
  "Return (MONTH DAY YEAR) for last day of MONTH in YEAR."
  (list month (calendar-last-day-of-month month year) year))

(defconst org-habit-ng-recurrence-property "RECURRENCE"
  "Property name for storing complex recurrence rules.")

(defun org-habit-ng--get-recurrence (&optional pom)
  "Get parsed recurrence rule from entry at POM.
Returns nil if no RECURRENCE property exists or if parsing fails."
  (let ((recurrence-string (org-entry-get pom org-habit-ng-recurrence-property)))
    (when (and recurrence-string (not (string-empty-p recurrence-string)))
      (condition-case err
          (org-habit-ng-parse-rrule recurrence-string)
        (error
         (message "org-habit-ng: Failed to parse RECURRENCE '%s': %s"
                  recurrence-string (error-message-string err))
         nil)))))

(defun org-habit-ng--gregorian-to-org-ts (date)
  "Convert Gregorian DATE (MONTH DAY YEAR) to org timestamp string."
  (let* ((month (nth 0 date))
         (day (nth 1 date))
         (year (nth 2 date))
         (time (encode-time 0 0 0 day month year))
         (dow (format-time-string "%a" time)))
    (format "<%04d-%02d-%02d %s>" year month day dow)))

(defun org-habit-ng--org-ts-to-gregorian (ts-string)
  "Convert org timestamp TS-STRING to Gregorian date (MONTH DAY YEAR)."
  (let ((time (org-time-string-to-time ts-string)))
    (let ((decoded (decode-time time)))
      (list (nth 4 decoded)   ; month
            (nth 3 decoded)   ; day
            (nth 5 decoded))))) ; year

(defun org-habit-ng--next-scheduled-day (rule scheduled-day today-day)
  "Next SCHEDULED day for RULE, or nil if the series is exhausted.
SCHEDULED-DAY and TODAY-DAY are absolute day numbers.  The anchor is
TODAY-DAY for X-REPEAT-FROM=completion, otherwise SCHEDULED-DAY.  For
X-REPEAT-FROM=scheduled-future the result is the first occurrence on or
after TODAY-DAY when overdue (bug B3 fix); plain `scheduled' is never
advanced and may return a past day.  Nil means UNTIL passed, COUNT
ordinal consumed, or no computable next occurrence."
  (let* ((repeat-from (or (plist-get rule :repeat-from) 'completion))
         (anchor (if (eq repeat-from 'completion) today-day scheduled-day)))
    (if (eq repeat-from 'scheduled-future)
        ;; First occurrence strictly after (max anchor (1- today-day)) is the
        ;; first occurrence >= today when overdue, and the plain next
        ;; occurrence when SCHEDULED is already in the future.  A single
        ;; engine call keeps COUNT/UNTIL semantics identical to the plain
        ;; case, avoids O(k^2) rescans, and avoids a silent nil for habits
        ;; more than 1000 periods overdue.
        (org-habit-ng-next-occurrence rule anchor (max anchor (1- today-day)))
      (org-habit-ng-next-occurrence rule anchor anchor))))

(defconst org-habit-ng--occurrence-period-cap 1000
  "Maximum number of periods scanned in either direction.")

(defun org-habit-ng--compute-next-schedule (rule base-scheduled-ts)
  "Next SCHEDULED org-timestamp string for RULE, or nil to stop rescheduling.
BASE-SCHEDULED-TS is the org SCHEDULED string to advance from; nil (habit
has no SCHEDULED) returns nil.  Returns a `<YYYY-MM-DD Dow>' string, or
nil when the series is complete: COUNT reached, UNTIL passed, or no
computable next occurrence.

COUNT contract: this runs inside `org-todo' BEFORE the DONE logbook line
is written (log notes are deferred to `post-command-hook'), so the logbook
holds only PRIOR completions and the in-flight completion is counted as
the `1+'.  A COUNT=N habit yields exactly N completions, then stops.
Point must be on the habit heading so completions can be counted."
  (when base-scheduled-ts
    (let* ((sched-greg (org-habit-ng--org-ts-to-gregorian base-scheduled-ts))
           (scheduled-day (org-habit-ng--gregorian-to-day
                           (nth 0 sched-greg) (nth 1 sched-greg) (nth 2 sched-greg)))
           (now (decode-time (org-current-effective-time)))
           (today-day (org-habit-ng--gregorian-to-day
                       (nth 4 now) (nth 3 now) (nth 5 now)))
           (count (plist-get rule :count))
           (times (or (plist-get rule :times) 1))
           ;; Pass COUNT as MAXDAYS to lift the display-window logbook cap:
           ;; scanning COUNT entries suffices to decide the gate below.
           (completions (and count
                             (length (org-habit-ng--get-closed-dates count)))))
      (cond
       ;; Row 1: COUNT completion gate (raw logbook lines, unchanged Phase-2 gate).
       ((and count (>= (1+ completions) count)) nil)
       ;; Rolling-window branch (F6, X-WINDOW=N): placed before the quota
       ;; branch -- :window and :period are mutually exclusive (validator),
       ;; so order is safe either way, but window-first keeps the read obvious.
       ((plist-get rule :window)
        (org-habit-ng--compute-next-schedule-rolling rule scheduled-day today-day))
       ;; Quota branch: period rule requiring N>1 distinct days per bucket.
       ((and (plist-get rule :period) (> times 1))
        (org-habit-ng--compute-next-schedule-quota
         rule base-scheduled-ts scheduled-day today-day times))
       ;; Default branch (times=1 / point habits): advance, then hop vacations.
       (t
        (let* ((repeat-from (or (plist-get rule :repeat-from) 'completion))
               (anchor (if (eq repeat-from 'completion) today-day scheduled-day))
               (next-day (org-habit-ng--next-scheduled-day rule scheduled-day today-day))
               (ranges (org-habit-ng--read-state-safe #'org-habit-ng--vacation-ranges))
               (final (and next-day
                           (org-habit-ng--skip-vacations rule anchor next-day ranges))))
          (when (and (null next-day) (org-habit-ng--rule-seasonal-p rule))
            (message "org-habit-ng: no in-season occurrence found within the scan cap; check that INTERVAL's grid can reach BYMONTH"))
          (when final
            (org-habit-ng--gregorian-to-org-ts
             (org-habit-ng--day-to-gregorian final)))))))))

(defvar org-habit-ng--quota-log-warned (make-hash-table :test 'equal)
  "Habits (file . pos) already warned about missing completion logging.
Keyed to fire the quota logging-precondition warning at most once per habit.")

(defun org-habit-ng--quota-spacing-message (done times-required counted rule)
  "Message the F7 \"too close\" signal for a spacing-suppressed completion.
DONE/TIMES-REQUIRED are the current greedy count and clamped quota; COUNTED is
the ascending list of greedy-counted days (its last element is the latest
credited completion); RULE supplies :min-gap (default 1) to compute the next
day that would count.  A direct `message', not routed through the
`org-habit-ng--quota-log-warned' hash -- this is per-action \"your click
didn't count\" feedback, so it must fire every time, not just once per habit."
  (let* ((gap (or (plist-get rule :min-gap) 1))
         (last-counted (car (last counted)))
         (next-ok (+ last-counted gap)))
    (message "org-habit-ng: logged, but too close to the previous counted completion to count toward the quota -- %d/%d, next countable day %s"
             done times-required
             (format-time-string "%a %b %d" (org-habit-ng--occ-day-to-time next-ok)))))

(defun org-habit-ng--compute-next-schedule-quota
    (rule base-scheduled-ts scheduled-day today-day times)
  "Quota-branch of `org-habit-ng--compute-next-schedule'.
RULE has :period and :times (TIMES) > 1.  SCHEDULED-DAY is the current
\(pre-move) SCHEDULED anchor, TODAY-DAY is today.  Returns a new SCHEDULED
`<YYYY-MM-DD Dow>' string, BASE-SCHEDULED-TS verbatim (stay), or nil
\(terminal).  Implements the 5-row decision table (design doc)."
  (let* ((bounds (org-habit-ng--period-bucket-bounds rule scheduled-day today-day))
         (bstart (car bounds))
         (bend (cdr bounds))
         (times-required (org-habit-ng--bucket-times-required rule bstart bend))
         (skip-events (org-habit-ng--read-state-safe #'org-habit-ng-skip-dates))
         (vacation-ranges (org-habit-ng--read-state-safe #'org-habit-ng--vacation-ranges))
         ;; --get-closed-dates MAXDAYS bounds logbook ENTRIES, not days; scan 4x
         ;; the bucket span so same-day duplicate records don't truncate coverage.
         ;; >4 duplicate records per bucket day could still truncate: accepted limit.
         (prior-days (delete-dups
                      (seq-filter (lambda (d) (and (>= d bstart) (<= d bend)))
                                  (org-habit-ng--get-closed-dates
                                   (* 4 (1+ (- bend bstart)))))))
         ;; MANDATORY review correction: compute NEW-DAY-P from PRIOR-DAYS
         ;; BEFORE the union below, and BEFORE any `delete-dups' touches
         ;; PRIOR-DAYS.  `(delete-dups (cons today-day prior-days))' mutates
         ;; PRIOR-DAYS' cons cells destructively (its tail is shared with the
         ;; unioned list); re-reading PRIOR-DAYS afterward for a same-day
         ;; check can spuriously come back nil when TODAY-DAY equals a
         ;; NON-HEAD prior element, wrongly firing "too close" on an
         ;; idempotent same-day re-DONE.  Hoisting the check first (it
         ;; captures a boolean, not a live reference to PRIOR-DAYS) sidesteps
         ;; the mutation entirely.
         (new-day-p (not (member today-day prior-days)))
         ;; Fold the in-flight completion in as a SET UNION, never (1+ prior):
         ;; a same-day re-DONE must not double-count.
         (union-days (delete-dups (cons today-day prior-days)))
         ;; Greedy-counted subset under X-MIN-GAP; the SAME helper the
         ;; evaluator uses, so the two counting sites cannot disagree.
         (counted (org-habit-ng--bucket-counted-days rule union-days))
         (done (length counted))
         ;; A genuinely new completion day the greedy did not credit: logged,
         ;; but too close to the previous counted completion.
         (spacing-suppressed (and new-day-p (not (member today-day counted))))
         (until-day (org-habit-ng--occ-until-day rule)))
    ;; Logging precondition: warn once per habit when this completion will leave
    ;; no detectable logbook record (else prior-days is always empty and the
    ;; quota never advances).
    (when (and (null org-log-repeat) (null org-log-done))
      ;; (buffer-file-name) is nil in non-file buffers, so same-position habits
      ;; there share a key (one warning covers them).  Accepted per design.
      (let ((key (cons (buffer-file-name)
                       (save-excursion (org-back-to-heading t) (point)))))
        (unless (gethash key org-habit-ng--quota-log-warned)
          (puthash key t org-habit-ng--quota-log-warned)
          (message "org-habit-ng: X-TIMES=%d habit needs completion logging \
(org-log-repeat or org-log-done); without it the quota never advances" times))))
    (cond
     ;; Rows 2 & 3 + suppressed row: quota met, bucket suppressed (skip range /
     ;; day-skips >= N / full vacation), or the bucket seals tonight -> advance
     ;; to the first occurrence after the bucket, then hop any vacation.  Nil
     ;; from the engine (UNTIL passed / COUNT ordinal / search cap) is terminal.
     ((or (>= done times-required)
          (org-habit-ng--bucket-skipped-p bstart bend times-required skip-events vacation-ranges)
          (= today-day bend))
      (let* ((next-day (org-habit-ng-next-occurrence rule scheduled-day bend))
             (final (and next-day
                         (org-habit-ng--skip-vacations rule scheduled-day next-day
                                                       vacation-ranges))))
        (when (and (null next-day) (org-habit-ng--rule-seasonal-p rule))
          (message "org-habit-ng: no in-season occurrence found within the scan cap; check that INTERVAL's grid can reach BYMONTH"))
        (when final
          (org-habit-ng--gregorian-to-org-ts
           (org-habit-ng--day-to-gregorian final)))))
     ;; Row 4: quota unmet, today < bend, whole bucket lies past UNTIL -> terminal.
     ((and until-day (< until-day bstart)) nil)
     ;; Row 5 refinement: quota unmet, but SCHEDULED sits in an earlier bucket
     ;; (stale anchor) -> normalize on-pattern into the live bucket.
     ((< scheduled-day bstart)
      (when spacing-suppressed
        (org-habit-ng--quota-spacing-message done times-required counted rule))
      (let ((next-day (org-habit-ng-next-occurrence rule scheduled-day (1- bstart))))
        (if next-day
            (org-habit-ng--gregorian-to-org-ts
             (org-habit-ng--day-to-gregorian next-day))
          base-scheduled-ts)))
     ;; Row 5: quota unmet -> SCHEDULED unchanged (verbatim, never nil).
     (t
      (when spacing-suppressed
        (org-habit-ng--quota-spacing-message done times-required counted rule))
      base-scheduled-ts))))

(defun org-habit-ng--compute-next-schedule-rolling
    (rule scheduled-day today-day)
  "Rolling-window branch (F6, X-WINDOW=N) of the scheduler for RULE.
Sibling of `org-habit-ng--compute-next-schedule'.  SCHEDULED-DAY is
accepted for signature parity with
`org-habit-ng--compute-next-schedule-quota' but unused: a rolling window has
no calendar-phased anchor to normalize -- SCHEDULED is always recomputed
outright, never left verbatim.  TODAY-DAY is today.  Implements Decision 2:
`SCHEDULED = max(c_K_eff + N, today + 1)' once >= K_eff completions are
counted (the past-date clamp -- a stale c_K + N can land weeks in the past);
`today + 1' during grace (< K_eff counted, the daily nag mitigating the
eternal-under-K wart).  Returns a `<YYYY-MM-DD Dow>' string, or nil when the
clamped day lands past `:until' (terminal)."
  (ignore scheduled-day)
  (let* ((n (plist-get rule :window))
         (k (or (plist-get rule :times) 1))
         (gap (or (plist-get rule :min-gap) 1))
         (k-eff (min k (1+ (/ (1- n) gap))))
         (until-day (org-habit-ng--occ-until-day rule))
         ;; --get-closed-dates MAXDAYS bounds logbook ENTRIES, not days; the
         ;; 4x(N+K) scan is provably sufficient (design doc "Logbook
         ;; lookback"): any counted day older than the trailing history it
         ;; misses would yield c_K + N <= today anyway, which the clamp below
         ;; turns into today + 1 -- the same result truncation would produce.
         (prior-days (delete-dups (org-habit-ng--get-closed-dates (* 4 (+ n k)))))
         ;; Hoist NEW-DAY-P and the PRIOR chain before the union (F7
         ;; mutation-order lesson, reused verbatim): a same-day re-DONE must
         ;; not double-count, and the "did this completion move c_K_eff"
         ;; comparison needs the pre-union chain intact.
         (new-day-p (not (member today-day prior-days)))
         (prior-chain (org-habit-ng--rolling-counted-chain rule prior-days))
         ;; Fold the in-flight completion in as a SET UNION, never (1+ prior).
         (union-days (delete-dups (cons today-day prior-days)))
         (chain (org-habit-ng--rolling-counted-chain rule union-days)))
    ;; Logging precondition: warn once per habit when this completion will
    ;; leave no detectable logbook record (else prior-days is always empty
    ;; and the window never advances) -- reuses the F1 hash verbatim.
    (when (and (null org-log-repeat) (null org-log-done))
      (let ((key (cons (buffer-file-name)
                       (save-excursion (org-back-to-heading t) (point)))))
        (unless (gethash key org-habit-ng--quota-log-warned)
          (puthash key t org-habit-ng--quota-log-warned)
          (message "org-habit-ng: X-WINDOW=%d habit needs completion logging \
(org-log-repeat or org-log-done); without it the rolling window never advances" n))))
    (if (>= (length chain) k-eff)
        (let* ((c-k (nth (1- k-eff) chain))
               (sched (max (+ c-k n) (1+ today-day))))
          (if (and until-day (> sched until-day))
              nil
            (progn
              ;; New trigger (F7's "today wasn't counted" never fires here --
              ;; today is always the chain's most recent element): the
              ;; honest rolling signal is NEW-DAY-P plus c_K_eff UNCHANGED by
              ;; today's completion, i.e. SCHEDULED did not advance.  Only
              ;; meaningful when the window was already binding before AND
              ;; after (grace-to-binding onset is a genuine advance, not a
              ;; suppression).
              (when (and new-day-p
                         (>= (length prior-chain) k-eff)
                         (= (nth (1- k-eff) prior-chain) c-k))
                (let ((w-today (length (seq-filter
                                        (lambda (d) (and (>= d (- today-day n -1))
                                                          (<= d today-day)))
                                        chain))))
                  (message "org-habit-ng: logged, but did not advance the rolling window -- %d/%d in the current window; SCHEDULED stays %s"
                           w-today k-eff
                           (format-time-string "%a %b %d" (org-habit-ng--occ-day-to-time sched)))))
              (org-habit-ng--gregorian-to-org-ts
               (org-habit-ng--day-to-gregorian sched)))))
      (let ((sched (1+ today-day)))
        (if (and until-day (> sched until-day))
            nil
          (org-habit-ng--gregorian-to-org-ts (org-habit-ng--day-to-gregorian sched)))))))

(defun org-habit-ng--skip-vacations (rule anchor next-day ranges)
  "Advance NEXT-DAY past any vacation range in RANGES.
While NEXT-DAY lies inside a range [VS, VE], recompute the next occurrence of
RULE strictly after VE (anchored at ANCHOR) and retry.  Returns the first
occurrence outside every range, NEXT-DAY unchanged when RANGES is nil, or nil
if the occurrence cap is hit or the engine returns nil (series exhausted)."
  (let ((day next-day) (guard 0))
    (while (and (integerp day)
                (< guard org-habit-ng--occurrence-period-cap)
                (org-habit-ng--in-vacation-p day ranges))
      (let ((ve (cdr (seq-find (lambda (r) (and (<= (car r) day) (>= (cdr r) day)))
                               ranges))))
        (setq day (org-habit-ng-next-occurrence rule anchor ve)))
      (setq guard (1+ guard)))
    (and (integerp day) (not (org-habit-ng--in-vacation-p day ranges)) day)))

(defun org-habit-ng--compute-next-schedule-skip (rule base-scheduled-ts)
  "Skip-flavored variant of the scheduler for RULE.
Sibling of `org-habit-ng--compute-next-schedule'.  Advances
BASE-SCHEDULED-TS past exactly ONE occurrence for a skip (uncounted:
COUNT is never consulted), or nil to stop.  Point/completion-anchored:
the advance is `org-habit-ng--next-scheduled-day' verbatim (minus the
COUNT gate), then
`--skip-vacations' -- so an overdue scheduled-anchored habit advances one owed
occurrence, not past all arrears.  Period: the current bucket is sealed --
advance past its end, then `--skip-vacations'.  Point must be on the heading."
  (when base-scheduled-ts
    (let* ((sched-greg (org-habit-ng--org-ts-to-gregorian base-scheduled-ts))
           (scheduled-day (org-habit-ng--gregorian-to-day
                           (nth 0 sched-greg) (nth 1 sched-greg) (nth 2 sched-greg)))
           (now (decode-time (org-current-effective-time)))
           (today-day (org-habit-ng--gregorian-to-day
                       (nth 4 now) (nth 3 now) (nth 5 now)))
           (repeat-from (or (plist-get rule :repeat-from) 'completion))
           (anchor (if (eq repeat-from 'completion) today-day scheduled-day))
           (ranges (org-habit-ng--read-state-safe #'org-habit-ng--vacation-ranges)))
      (if (plist-get rule :period)
          (let* ((bounds (org-habit-ng--period-bucket-bounds rule scheduled-day today-day))
                 (bend (cdr bounds))
                 (next-day (org-habit-ng-next-occurrence rule scheduled-day bend))
                 (final (and next-day
                             (org-habit-ng--skip-vacations rule scheduled-day next-day ranges))))
            (when final
              (org-habit-ng--gregorian-to-org-ts (org-habit-ng--day-to-gregorian final))))
        (let* ((next-day (org-habit-ng--next-scheduled-day rule scheduled-day today-day))
               (final (and next-day
                           (org-habit-ng--skip-vacations rule anchor next-day ranges))))
          (when final
            (org-habit-ng--gregorian-to-org-ts (org-habit-ng--day-to-gregorian final))))))))

(defun org-habit-ng--handle-repeat ()
  "Handle repeat for an org-habit-ng entry.
Computes the next occurrence via the day-based engine and updates
SCHEDULED, or leaves SCHEDULED unchanged when the series is complete."
  (let ((rule (org-habit-ng--get-recurrence)))
    (when (and rule (plist-get rule :freq))
      (let* ((scheduled-ts (org-entry-get nil "SCHEDULED"))
             (next-ts (org-habit-ng--compute-next-schedule rule scheduled-ts)))
        (if next-ts
            (org-schedule nil next-ts)
          (message "org-habit-ng: series complete or no computable next occurrence; SCHEDULED left unchanged"))))))

;; `org-last-state' is declared in org.el via a bare `(defvar org-last-state)'
;; (no value form), which only marks it special for the compilation of org.el
;; itself and does not persist as a global special-variable marking at load
;; time. Redeclare it here so the byte-compiler treats our dynamic read of it
;; (bound by `org-todo' around every completion, including ours) as a
;; reference to a known special variable rather than a free variable.
(defvar org-last-state)

(defun org-habit-ng--repeat-restore-todo-state (done-word)
  "Restore a still-DONE repeater-less habit to its TODO state.
Replicates the repeater-cookie branch of `org-auto-repeat-maybe'
\(org.el 10222-10256), which repeater-less habits never reach because
`org-get-repeat' returns nil there: the DONE->TODO flip, the explicit
removal of the CLOSED stamp (stock org calls `org-add-planning-info'
right after ITS flip because with `org-log-done' non-nil the outer
`org-todo' wrote CLOSED and the log-suppressed flip skips org's
CLOSED-removal branch), the LAST_REPEAT property, and the repeat log
record.  DONE-WORD is the completion keyword, used for that record.
State selection mirrors org exactly: REPEAT_TO_STATE property, then
`org-todo-repeat-to-state', then the sequence head or the
pre-completion state.  Must run inside `org-todo' so `org-last-state'
is bound to the pre-completion keyword.  The `org-log-setup' guard on
`org-add-log-setup' is mandatory: an unguarded call would clobber a
pending \\='done note's variables and move the note marker."
  (let* ((aa (assoc org-last-state org-todo-kwd-alist))
         (interpret (nth 1 aa))
         (head (nth 2 aa)))
    (when (eq org-log-repeat t) (setq org-log-repeat 'state))
    ;; Log-suppressed flip, exactly as stock org wraps its own org-todo.
    (let ((to-state (or (org-entry-get nil "REPEAT_TO_STATE" 'selective)
                        (and (stringp org-todo-repeat-to-state)
                             org-todo-repeat-to-state)
                        (and org-todo-repeat-to-state org-last-state)))
          (org-log-done nil)
          (org-todo-log-states nil))
      (org-todo (cond ((and to-state (member to-state org-todo-keywords-1))
                       to-state)
                      ((eq interpret 'type) org-last-state)
                      (head)
                      (t 'none))))
    ;; Drop the CLOSED stamp the outer org-todo wrote (org-log-done 'time).
    (org-back-to-heading t)
    (org-add-planning-info nil nil 'closed)
    ;; LAST_REPEAT parity + the repeat log record (the State "DONE" line).
    (when org-log-repeat
      (org-entry-put nil "LAST_REPEAT"
                     (format-time-string (org-time-stamp-format t t)
                                         (org-current-effective-time)))
      (if org-log-setup
          ;; A record (e.g. the DONE note) is already pending; only make
          ;; sure a note-mode repeat still takes a note.
          (when (eq org-log-repeat 'note)
            (setq org-log-note-how 'note))
        ;; No pending record: set up the repeat state record ourselves.
        (org-add-log-setup 'state
                           (or done-word (car org-done-keywords))
                           org-last-state
                           org-log-repeat)))))

(defun org-habit-ng--around-auto-repeat (orig-fun done-word)
  "Around advice for `org-auto-repeat-maybe'.
For non-habit entries (no RECURRENCE, or an unparseable rule) this is a
transparent pass-through to ORIG-FUN with DONE-WORD.

For an org-habit-ng habit the next occurrence is computed from the
CURRENT (pre-flip) SCHEDULED:
- When a next timestamp exists, ORIG-FUN runs first (org performs its
  normal DONE->TODO flip and log-repeat machinery when a repeater cookie
  is present).  SCHEDULED is then overridden with the RRULE-derived date.
  Repeater-less habits are not flipped by ORIG-FUN (`org-get-repeat'
  returns nil for them), so `--repeat-restore-todo-state' flips them back
  to TODO itself — including CLOSED removal, LAST_REPEAT, and the
  repeat-log record — leaving the cookie path, which already did all of
  this, as a no-op.
- When the series is complete (COUNT/UNTIL reached) `--compute-next-schedule'
  returns nil: ORIG-FUN is NOT called, so the entry keeps its DONE state
  and SCHEDULED is left untouched."
  (let ((rule (and (org-is-habit-p)
                   (org-habit-ng--get-recurrence))))
    (if (not (and rule (plist-get rule :freq)))
        (funcall orig-fun done-word)
      (let* ((base-ts (org-entry-get nil "SCHEDULED"))
             (next-ts (org-habit-ng--compute-next-schedule rule base-ts)))
        (if next-ts
            (progn
              (funcall orig-fun done-word)
              (org-schedule nil next-ts)
              ;; Repeater-less habits: org-auto-repeat-maybe no-ops (no cookie),
              ;; so the entry is still DONE here.  Restore TODO, drop CLOSED,
              ;; and log the repeat ourselves (B12).  The cookie path already
              ;; did all of this, so this is a no-op there.
              (when (org-entry-is-done-p)
                (org-habit-ng--repeat-restore-todo-state done-word)))
          (message "org-habit-ng: series complete (COUNT/UNTIL reached); habit stays DONE"))))))

(defun org-habit-ng--enable-advice ()
  "Enable advice on `org-auto-repeat-maybe'."
  (advice-add 'org-auto-repeat-maybe :around #'org-habit-ng--around-auto-repeat))

(defun org-habit-ng--disable-advice ()
  "Disable advice on `org-auto-repeat-maybe'."
  (advice-remove 'org-auto-repeat-maybe #'org-habit-ng--around-auto-repeat))

(defun org-habit-ng--approximate-interval (rule)
  "Calculate approximate interval in days for RULE.
Used for org-habit graph display."
  (let ((freq (plist-get rule :freq))
        (interval (or (plist-get rule :interval) 1))
        (byday (plist-get rule :byday)))
    (pcase freq
      ('daily interval)
      ('weekly
       (if (and byday (not (consp (car byday))))
           ;; Multiple days per week: calculate average gap
           (let ((num-days (length byday)))
             (max 1 (/ 7 num-days)))
         ;; Simple weekly
         (* 7 interval)))
      ('monthly (* 30 interval))  ; Approximate
      ('yearly (* 365 interval))
      (_ 7))))

;; Terminal-completion logbook gap (accepted, not fixed — Phase 3 Decision 4):
;; when a COUNT/UNTIL-terminal completion fires, `--around-auto-repeat' does not
;; call ORIG-FUN, so org's log-repeat machinery never writes that final rep to
;; LOGBOOK and the count below under-reports by one. This is accepted because
;; the terminated habit stays DONE, and DONE habits are never listed as habits
;; in the agenda — so the missing final cell is never rendered.
(defun org-habit-ng--get-closed-dates (&optional maxdays)
  "Extract past DONE dates from current heading's logbook.
Returns list of days-since-epoch, most recent first.
MAXDAYS bounds how many logbook entries are scanned; it defaults to
`org-habit-preceding-days' + `org-habit-following-days' (the display
window).  Pass the rule's COUNT when counting completions for COUNT
enforcement so the display cap cannot hide completions.
Must be called with point on or after the heading."
  (save-excursion
    (org-back-to-heading t)
    (let* ((end (org-entry-end-position))
           (maxdays (or maxdays
                        (+ org-habit-preceding-days org-habit-following-days)))
           (reversed org-log-states-order-reversed)
           (search (if reversed 're-search-forward 're-search-backward))
           (limit (if reversed end (point)))
           (count 0)
           (re (format
                "^[ \t]*-[ \t]+\\(?:State \"%s\".*%s%s\\)"
                (regexp-opt org-done-keywords)
                org-ts-regexp-inactive
                (let ((value (cdr (assq 'done org-log-note-headings))))
                  (if (not value) ""
                    (concat "\\|"
                            (org-replace-escapes
                             (regexp-quote value)
                             `(("%d" . ,org-ts-regexp-inactive)
                               ("%D" . ,org-ts-regexp)
                               ("%s" . "\"\\S-+\"")
                               ("%S" . "\"\\S-+\"")
                               ("%t" . ,org-ts-regexp-inactive)
                               ("%T" . ,org-ts-regexp)
                               ("%u" . ".*?")
                               ("%U" . ".*?"))))))))
           closed-dates)
      (unless reversed (goto-char end))
      (while (and (< count maxdays) (funcall search re limit t))
        (push (time-to-days
               (org-time-string-to-time
                (or (match-string-no-properties 1)
                    (match-string-no-properties 2))))
              closed-dates)
        (setq count (1+ count)))
      closed-dates)))

(defconst org-habit-ng-vacation-property "X-VACATION"
  "Property name holding per-habit vacation ranges.")

(defcustom org-habit-ng-vacation-ranges nil
  "Global vacation ranges suppressing expectations for ALL habits.
Each element is a cons (START . END) of `YYYY-MM-DD' date strings,
both required and inclusive; use the same date twice for a single day."
  :type '(repeat (cons (string :tag "Start (YYYY-MM-DD)")
                       (string :tag "End (YYYY-MM-DD)")))
  :group 'org-habit-ng)

(defcustom org-habit-ng-skipped-glyph ?-
  "Character drawn in the consistency graph for a skipped instance or bucket.
Neutral by design; `-' matches Loop's skip mark, and org-habit ships no skip
glyph.  Note: `-' can be near-invisible against the clear face in some themes;
pick another character here if so."
  :type 'character
  :group 'org-habit-ng)

(defvar org-habit-ng--state-read-warned (make-hash-table :test 'equal)
  "Habits (file . pos) already warned about an unreadable skip/vacation state.
Keyed to fire the degradation warning at most once per habit, mirroring
`org-habit-ng--quota-log-warned'.")

(defun org-habit-ng--read-state-safe (reader)
  "Call READER (a nullary function) with contained failure; nil on error.
On any error degrade to nil and emit a one-shot per-habit `message' (keyed
file+position in `org-habit-ng--state-read-warned', copying the quota
warning pattern), so one malformed skip/vacation token never aborts the
agenda or an `org-todo' state change.  Point must be on or after the heading."
  (condition-case err
      (funcall reader)
    (error
     (let ((key (cons (buffer-file-name)
                      (save-excursion (org-back-to-heading t) (point)))))
       (unless (gethash key org-habit-ng--state-read-warned)
         (puthash key t org-habit-ng--state-read-warned)
         (message "org-habit-ng: unreadable skip/vacation state (%s); ignoring for this habit"
                  (error-message-string err))))
     nil)))

(defconst org-habit-ng--skip-line-regexp
  "^[ \t]*-[ \t]+Skipped[ \t]+[[<]\\([^]>]+\\)[]>]\\(?:--[[<]\\([^]>]+\\)[]>]\\)?[ \t]*$"
  "Regexp for a LOGBOOK skip line, anchored to end-of-line (no trailing prose).
Group 1 is the START date text, group 2 the END date text (nil for a day
skip).  Angle brackets and daynames are tolerated; match with
`case-fold-search' bound to nil.")

(defconst org-habit-ng--vacation-token-regexp
  "[[<]\\([^]>]+\\)[]>]\\(?:--[[<]\\([^]>]+\\)[]>]\\)?"
  "Regexp for one X-VACATION token: `[START]' or `[START]--[END]'.
Group 1 is START date text, group 2 END date text (nil for a bare
single-day token).  Angle brackets and daynames are tolerated.")

(defun org-habit-ng--state-token-to-day (token)
  "Absolute day number for a skip/vacation date TOKEN string.
TOKEN is the text inside the brackets, e.g. \"2026-07-08 Wed\"; any dayname
and time are tolerated and ignored.  Uses `time-to-days', matching the day
numbering of `org-habit-ng--get-closed-dates'."
  (time-to-days (org-time-string-to-time token)))

(defun org-habit-ng-skip-dates (&optional maxdays)
  "Skip events from the current heading's LOGBOOK, mirroring `--get-closed-dates'.
A day-skip line `- Skipped [DATE]' yields an absolute day number; a bucket
skip `- Skipped [START]--[END]' yields a (START-DAY . END-DAY) cons.  Lenient
on dates (dayname optional, `[...]' or `<...>'), strict on shape:
`case-fold-search' is bound nil (lowercase `skipped' is ignored) and the line
is anchored to end-of-line, so a trailing comment prevents a match.  MAXDAYS
bounds how many LOGBOOK entries are scanned (defaults to the display window).
A range with END < START signals an error.  Point must be on/after the heading."
  (save-excursion
    (org-back-to-heading t)
    (let* ((end (org-entry-end-position))
           (maxdays (or maxdays
                        (+ org-habit-preceding-days org-habit-following-days)))
           (reversed org-log-states-order-reversed)
           (search (if reversed 're-search-forward 're-search-backward))
           (limit (if reversed end (point)))
           (case-fold-search nil)
           (count 0)
           events)
      (unless reversed (goto-char end))
      (while (and (< count maxdays)
                  (funcall search org-habit-ng--skip-line-regexp limit t))
        ;; Capture ALL match products BEFORE converting any token:
        ;; `--state-token-to-day' calls `org-parse-time-string', whose internal
        ;; `string-match' clobbers the global match-data, so any later
        ;; `match-string' / `match-end' would read corrupted positions.
        (let* ((line (match-string-no-properties 0))
               (tok1 (match-string-no-properties 1))
               (tok2 (match-string-no-properties 2))
               (s (org-habit-ng--state-token-to-day tok1))
               (e (and tok2 (org-habit-ng--state-token-to-day tok2))))
          (if e
              (progn
                (when (< e s)
                  (error "Skip range end precedes start: %s" line))
                (push (cons s e) events))
            (push s events)))
        (setq count (1+ count)))
      events)))

(defun org-habit-ng--vacation-global-ranges ()
  "Global `org-habit-ng-vacation-ranges' as ((START-DAY . END-DAY) ...)."
  (mapcar (lambda (ce)
            (cons (org-habit-ng--state-token-to-day (car ce))
                  (org-habit-ng--state-token-to-day (cdr ce))))
          org-habit-ng-vacation-ranges))

(defun org-habit-ng--vacation-ranges ()
  "Per-habit + global vacation ranges as ((START-DAY . END-DAY) ...).
Reads the raw `org-habit-ng-vacation-property' via `org-entry-get' (which
merges `X-VACATION+' accumulation lines) and tokenizes it with
`org-habit-ng--vacation-token-regexp' -- NOT
`org-entry-get-multivalued-property', which would shatter
`[2026-08-01 Sat]--[2026-08-15 Sat]' on its inner whitespace.  Each
`[START]--[END]' token becomes a (START-DAY . END-DAY) cons; a bare `[DATE]'
token becomes a single-day range.  The result is unioned with the global
`org-habit-ng-vacation-ranges'.  END < START signals an error; a non-token
between tokens signals a malformed-range error.  Point must be on the heading."
  (let ((raw (org-entry-get nil org-habit-ng-vacation-property))
        (ranges (org-habit-ng--vacation-global-ranges)))
    (when (and raw (not (string-empty-p (string-trim raw))))
      (let ((pos 0))
        (while (string-match "[^ \t]" raw pos)
          (setq pos (match-beginning 0))
          (unless (and (string-match org-habit-ng--vacation-token-regexp raw pos)
                       (= (match-beginning 0) pos))
            (error "Malformed X-VACATION range: %S (expected [YYYY-MM-DD]--[YYYY-MM-DD])"
                   (substring raw pos)))
          ;; Capture ALL match products BEFORE converting any token:
          ;; `--state-token-to-day' calls `org-parse-time-string', whose
          ;; internal `string-match' clobbers the global match-data, so any
          ;; later `match-string' / `match-end' would read corrupted positions.
          (let* ((token (match-string 0 raw))
                 (tok1 (match-string 1 raw))
                 (tok2 (match-string 2 raw))
                 (next-pos (match-end 0))
                 (s (org-habit-ng--state-token-to-day tok1))
                 (e (if tok2 (org-habit-ng--state-token-to-day tok2) s)))
            (when (< e s)
              (error "X-VACATION range end precedes start: %S" token))
            (push (cons s e) ranges)
            (setq pos next-pos)))))
    (nreverse ranges)))

(defun org-habit-ng--in-vacation-p (day ranges)
  "Non-nil if absolute DAY falls inside any (START . END) range in RANGES."
  (seq-some (lambda (r) (and (<= (car r) day) (>= (cdr r) day))) ranges))

(defun org-habit-ng--range-covered-p (start end ranges)
  "Non-nil if every day of [START, END] is covered by the union of RANGES."
  (let ((day start) (ok t))
    (while (and ok (<= day end))
      (unless (org-habit-ng--in-vacation-p day ranges) (setq ok nil))
      (setq day (1+ day)))
    ok))

(defun org-habit-ng--bucket-skipped-p (start end times skip-events vacation-ranges)
  "Non-nil if the bucket [START, END] (quota TIMES) is suppressed.
Suppressed iff a bucket-skip range in SKIP-EVENTS intersects it, OR the count
of distinct day-skip days inside it is >= TIMES (at TIMES=1 any skip day
skips it), OR it is fully covered by the union of VACATION-RANGES.  Shared by
the evaluator and the scheduler so they can never disagree mid-bucket."
  (or (seq-some (lambda (ev) (and (consp ev)
                                  (<= (car ev) end) (>= (cdr ev) start)))
                skip-events)
      (>= (length (delete-dups
                   (seq-filter (lambda (ev) (and (integerp ev)
                                                 (>= ev start) (<= ev end)))
                               skip-events)))
          times)
      (org-habit-ng--range-covered-p start end vacation-ranges)))

(defvar org-habit-ng--ceiling-log-warned (make-hash-table :test 'equal)
  "Habits (file . pos) already warned about a ceiling with no logging.
Keyed to fire the logging-precondition warning at most once per habit,
mirroring `org-habit-ng--quota-log-warned'.")

(defun org-habit-ng--build-habit-struct (rule)
  "Build habit structure from RRULE RULE.
Returns a list compatible with `org-habit-parse-todo':
  0: Scheduled date (days since epoch)
  1: Repeat interval in days
  2: Deadline (nil)
  3: Deadline repeat (nil)
  4: Past completion dates
  5: Repeater type string
  6: Skip events (org-habit ignores trailing slots; read contained)
  7: Vacation ranges (contained)"
  (let* ((scheduled (org-get-scheduled-time (point)))
         (scheduled-days (and scheduled (time-to-days scheduled)))
         (interval (org-habit-ng--approximate-interval rule))
         (repeat-from (or (plist-get rule :repeat-from) 'completion))
         (repeater-type (if (eq repeat-from 'completion) ".+" "++"))
         (closed-dates (org-habit-ng--get-closed-dates)))
    ;; Ceiling logging precondition (accepted degradation, warned once per
    ;; habit): without a State-"DONE" logbook record the evaluator's only anchor
    ;; is SCHEDULED (= last-completion + gap), so the horizon reads
    ;; completion + 2*gap and can never show overdue.
    (when (and (plist-get rule :ceiling)
               (null closed-dates)
               (null org-log-repeat) (null org-log-done))
      (let ((key (cons (buffer-file-name)
                       (save-excursion (org-back-to-heading t) (point)))))
        (unless (gethash key org-habit-ng--ceiling-log-warned)
          (puthash key t org-habit-ng--ceiling-log-warned)
          (message "org-habit-ng: X-CEILING habit needs completion logging \
(org-log-repeat or org-log-done); without it the horizon can never show overdue"))))
    (unless scheduled-days
      (error "Habit '%s' has no scheduled date"
             (org-no-properties (org-get-heading t t t t))))
    (list scheduled-days
          interval
          nil
          nil
          closed-dates
          repeater-type
          (org-habit-ng--read-state-safe #'org-habit-ng-skip-dates)
          (org-habit-ng--read-state-safe #'org-habit-ng--vacation-ranges))))

(defun org-habit-ng--around-parse-todo (orig-fun &optional pom)
  "Advice around `org-habit-parse-todo' to handle RRULE recurrence.
ORIG-FUN is the original `org-habit-parse-todo' function.
POM is the point or marker to check.
If RECURRENCE property exists, builds habit structure directly
without requiring an org repeater.  Otherwise, calls original function.
If RECURRENCE is present but fails to parse, this entry is not a stock
org-habit habit (it likely has no org repeater), so ORIG-FUN would
hard-error; degrade to nil instead of crashing the caller (e.g. the
agenda) over a single bad habit."
  (save-excursion
    (when pom (goto-char pom))
    (let* ((raw-recurrence (org-entry-get nil org-habit-ng-recurrence-property))
           (recurrence (org-habit-ng--get-recurrence)))
      (cond
       (recurrence
        ;; RECURRENCE exists and parsed: build synthetic habit struct
        (org-habit-ng--build-habit-struct recurrence))
       ((and raw-recurrence (not (string-empty-p raw-recurrence)))
        ;; RECURRENCE present but unparseable: degrade to non-habit
        ;; rather than falling back to orig-fun, which would hard-error.
        (message "org-habit-ng: invalid RECURRENCE %S on habit at point, treating as non-habit"
                 raw-recurrence)
        nil)
       (t
        ;; No RECURRENCE at all: use original org-habit behavior
        (funcall orig-fun pom))))))

(defun org-habit-ng--enable-parse-advice ()
  "Enable advice on `org-habit-parse-todo'."
  (advice-add 'org-habit-parse-todo :around #'org-habit-ng--around-parse-todo))

(defun org-habit-ng--disable-parse-advice ()
  "Disable advice on `org-habit-parse-todo'."
  (advice-remove 'org-habit-parse-todo #'org-habit-ng--around-parse-todo))

;;; RRULE Engine (Phase 1 Implementation)

;; Task 1: RRULE Data Structure and Tokenizer

(defun org-habit-ng--rrule-tokenize (rrule-string)
  "Tokenize RRULE-STRING into alist of (KEY . VALUE) pairs.
Example: \"FREQ=DAILY;INTERVAL=3\" -> ((\"FREQ\" . \"DAILY\") (\"INTERVAL\" . \"3\"))"
  (let ((parts (split-string rrule-string ";" t "[ \t\n]+")))
    (mapcar (lambda (part)
              (let ((kv (split-string part "=" t "[ \t]+")))
                (cons (car kv) (cadr kv))))
            parts)))

;; Task 2: RRULE Parser - Basic Structure

(defconst org-habit-ng--rrule-freq-alist
  '(("SECONDLY" . secondly)
    ("MINUTELY" . minutely)
    ("HOURLY" . hourly)
    ("DAILY" . daily)
    ("WEEKLY" . weekly)
    ("MONTHLY" . monthly)
    ("YEARLY" . yearly))
  "Mapping of RRULE FREQ values to symbols.")

(defconst org-habit-ng--rrule-day-alist
  '(("SU" . 0) ("MO" . 1) ("TU" . 2) ("WE" . 3)
    ("TH" . 4) ("FR" . 5) ("SA" . 6))
  "Mapping of RRULE day abbreviations to numbers (0=Sunday).")

(defun org-habit-ng--rrule-parse-byday (value)
  "Parse BYDAY VALUE into list of day specs.
Simple days like \"MO,WE,FR\" → (1 3 5)
Ordinal days like \"2SA,-1FR\" → ((2 . 6) (-1 . 5))"
  (let ((days (split-string value "," t)))
    (mapcar (lambda (day)
              (if (string-match "^\\(-?[0-9]+\\)?\\([A-Z][A-Z]\\)$" day)
                  (let ((ordinal (match-string 1 day))
                        (weekday (match-string 2 day)))
                    (if ordinal
                        (cons (string-to-number ordinal)
                              (cdr (assoc weekday org-habit-ng--rrule-day-alist)))
                      (cdr (assoc weekday org-habit-ng--rrule-day-alist))))
                (error "Invalid BYDAY value: %s" day)))
            days)))

(defun org-habit-ng--rrule-parse-numlist (value)
  "Parse VALUE as comma-separated numbers into list."
  (mapcar #'string-to-number (split-string value "," t)))

(defun org-habit-ng--validate-rule (rule)
  "Validate RULE for conflicting options.  Signal error if invalid.
Returns RULE if valid."
  ;; X-SKIP (F8: nearest-weekday shift on MONTHLY/YEARLY BYMONTHDAY rules).
  ;; Checked FIRST, before X-CEILING/X-WINDOW/X-PERIOD below: those blocks
  ;; already reject BYMONTHDAY with their own messages, so an X-SKIP block
  ;; placed after them could never fire its own mode-conflict message on a
  ;; BYMONTHDAY-carrying rule (and without BYMONTHDAY it would fire the wrong
  ;; "requires BYMONTHDAY" message instead).  The mode-conflict check runs
  ;; first WITHIN this block, ahead of the BYMONTHDAY-presence/FREQ/BYDAY/
  ;; BYSETPOS checks, for the same reason.
  (when (plist-get rule :skip)
    (when (or (plist-get rule :period) (plist-get rule :window) (plist-get rule :ceiling))
      (user-error "X-SKIP requires a point-based BYMONTHDAY rule; it has no meaning for period/window/ceiling modes"))
    (unless (plist-get rule :bymonthday)
      (user-error "X-SKIP requires BYMONTHDAY; it shifts a day-of-month occurrence off a weekend"))
    (unless (memq (plist-get rule :freq) '(monthly yearly))
      (user-error "X-SKIP requires FREQ=MONTHLY or FREQ=YEARLY"))
    (when (and (eq (plist-get rule :freq) 'yearly) (null (plist-get rule :bymonth)))
      (user-error "X-SKIP on a yearly rule requires BYMONTH (the engine only honors BYMONTHDAY under BYMONTH for yearly rules)"))
    (when (plist-get rule :byday)
      (user-error "X-SKIP not allowed with BYDAY; weekday-anchored occurrences never fall on the wrong day"))
    (when (plist-get rule :bysetpos)
      (user-error "X-SKIP not allowed with BYSETPOS in v1; shifting changes positions BYSETPOS selects")))
  ;; X-CEILING (maintenance ceiling / at-least-every-N): no calendar shape,
  ;; no period buckets, no discrete instances.  Checked FIRST so its messages
  ;; win over the general BYMONTH/period diagnostics.  Clearest message first.
  (when (plist-get rule :ceiling)
    (when (plist-get rule :byday)
      (user-error "BYDAY not allowed with X-CEILING.  A maintenance ceiling has no calendar shape; use FREQ+INTERVAL for the gap"))
    (when (plist-get rule :bymonthday)
      (user-error "BYMONTHDAY not allowed with X-CEILING.  A maintenance ceiling has no calendar shape"))
    (when (plist-get rule :bysetpos)
      (user-error "BYSETPOS not allowed with X-CEILING.  A maintenance ceiling has no calendar shape"))
    (when (plist-get rule :bymonth)
      (user-error "BYMONTH not allowed with X-CEILING.  Seasonal ceilings need off-season gap-pause semantics, which are not yet supported"))
    (when (plist-get rule :period)
      (user-error "X-CEILING and X-PERIOD are mutually exclusive.  A ceiling has no period buckets"))
    (when (plist-get rule :times)
      (user-error "X-TIMES not allowed with X-CEILING.  A ceiling has no period buckets to fill"))
    (when (or (plist-get rule :flex-before) (plist-get rule :flex-after))
      (user-error "X-FLEX-BEFORE/AFTER not allowed with X-CEILING.  The window is the whole gap up to the horizon"))
    (when (plist-get rule :min-gap)
      (user-error "X-MIN-GAP not allowed with X-CEILING.  A ceiling has no period buckets to space"))
    (when (and (plist-get rule :repeat-from)
               (not (eq (plist-get rule :repeat-from) 'completion)))
      (user-error "X-REPEAT-FROM must be 'completion' (or omitted) with X-CEILING.  The horizon is anchored to the last completion"))
    (when (plist-get rule :count)
      (user-error "COUNT not allowed with X-CEILING.  A maintenance ceiling has no discrete instances to count")))
  ;; X-WINDOW (rolling window / F6): a trailing N-day count, no calendar shape
  ;; at all.  Checked after :ceiling, before :period, so a rule combining
  ;; X-WINDOW with either gets X-WINDOW's own mutual-exclusion message rather
  ;; than a generic period/ceiling diagnostic.  The :period and :ceiling
  ;; checks must run before :wkst: the parser defaults :wkst (to
  ;; `calendar-week-start-day') for any :period rule before validation runs,
  ;; so a rule combining X-WINDOW
  ;; with X-PERIOD (but no explicit WKST) would otherwise trip the :wkst
  ;; check first and report a misleading WKST diagnostic instead of the
  ;; X-PERIOD/X-WINDOW mutual-exclusion message.
  (when (plist-get rule :window)
    (let ((n (plist-get rule :window)))
      (unless (and (integerp n) (>= n 2))
        (user-error "X-WINDOW must be an integer >= 2 days (a 1-day window is just a plain daily habit)"))
      (unless (eq (plist-get rule :freq) 'daily)
        (user-error "X-WINDOW requires FREQ=DAILY.  A rolling window is a trailing day count, not a calendar grid"))
      (when (> (or (plist-get rule :interval) 1) 1)
        (user-error "INTERVAL not allowed with X-WINDOW.  The window length is X-WINDOW's day count"))
      (when (plist-get rule :period)
        (user-error "X-PERIOD and X-WINDOW are mutually exclusive.  X-PERIOD is calendar buckets; X-WINDOW is a rolling trailing window"))
      (when (plist-get rule :ceiling)
        (user-error "X-CEILING and X-WINDOW are mutually exclusive.  A ceiling is the FREQ+INTERVAL grid form; X-WINDOW is the rolling day-count form"))
      (when (plist-get rule :wkst)
        (user-error "WKST not allowed with X-WINDOW.  A rolling window has no week boundaries"))
      (when (> (or (plist-get rule :times) 1) n)
        (user-error "X-TIMES cannot exceed X-WINDOW: K completions need K distinct days in an N-day window"))
      (when (plist-get rule :byday)
        (user-error "BYDAY not allowed with X-WINDOW.  A rolling window has no calendar shape"))
      (when (plist-get rule :bymonthday)
        (user-error "BYMONTHDAY not allowed with X-WINDOW.  A rolling window has no calendar shape"))
      (when (plist-get rule :bysetpos)
        (user-error "BYSETPOS not allowed with X-WINDOW.  A rolling window has no calendar shape"))
      (when (plist-get rule :bymonth)
        (user-error "BYMONTH not allowed with X-WINDOW.  Seasonal rolling windows need off-season aging semantics, which are not yet supported"))
      (when (or (plist-get rule :flex-before) (plist-get rule :flex-after))
        (user-error "X-FLEX-BEFORE/AFTER not allowed with X-WINDOW.  A rolling window has no due date to flex around"))
      (when (and (plist-get rule :repeat-from)
                 (not (eq (plist-get rule :repeat-from) 'completion)))
        (user-error "X-REPEAT-FROM must be 'completion' (or omitted) with X-WINDOW.  The window is anchored to your completions"))
      (when (plist-get rule :count)
        (user-error "COUNT not allowed with X-WINDOW.  A rolling obligation has no discrete instances to count"))))
  (when (plist-get rule :period)
    ;; BYDAY not allowed with X-PERIOD
    (when (plist-get rule :byday)
      (user-error "BYDAY and X-PERIOD are mutually exclusive.  Use BYDAY for point-based habits, X-PERIOD for period-based"))
    ;; X-REPEAT-FROM must be scheduled (or absent) with X-PERIOD
    (when (and (plist-get rule :repeat-from)
               (not (eq (plist-get rule :repeat-from) 'scheduled)))
      (user-error "X-REPEAT-FROM must be 'scheduled' (or omitted) with X-PERIOD.  Period boundaries are calendar-fixed"))
    ;; X-FLEX-* not allowed with X-PERIOD
    (when (or (plist-get rule :flex-before)
              (plist-get rule :flex-after))
      (user-error "X-FLEX-BEFORE/AFTER not allowed with X-PERIOD.  Period mode uses the entire period as the completion window"))
    ;; FREQ must match X-PERIOD
    (let ((period (plist-get rule :period))
          (freq (plist-get rule :freq)))
      (unless (or (and (eq period 'week) (eq freq 'weekly))
                  (and (eq period 'month) (eq freq 'monthly)))
        (user-error "FREQ must match X-PERIOD granularity (FREQ=WEEKLY for X-PERIOD=WEEK, FREQ=MONTHLY for X-PERIOD=MONTH)"))))
  ;; COUNT and UNTIL are mutually exclusive (RFC 5545)
  (when (and (plist-get rule :count) (plist-get rule :until))
    (user-error "COUNT and UNTIL are mutually exclusive.  Use one to bound the recurrence"))
  ;; Sub-daily FREQs are day-degenerate for habits; reject rather than pretend.
  (when (memq (plist-get rule :freq) '(secondly minutely hourly))
    (user-error "Sub-daily FREQ (%s) is not supported; org-habit-ng is day-granular"
                (plist-get rule :freq)))
  ;; BYWEEKNO / BYYEARDAY are parsed for round-trip fidelity but not supported
  ;; by the occurrence engine; reject rather than silently ignore.
  (when (plist-get rule :byweekno)
    (user-error "BYWEEKNO is not supported; it was previously ignored"))
  (when (plist-get rule :byyearday)
    (user-error "BYYEARDAY is not supported; it was previously ignored"))
  ;; BYMONTH activation window (F4): honored as a season gate on
  ;; DAILY/WEEKLY/MONTHLY (and period rules) and as occurrence shape on YEARLY.
  ;; Value-range and FREQ-presence checks apply to all.
  (when (plist-get rule :bymonth)
    (unless (plist-get rule :freq)
      (user-error "BYMONTH requires FREQ"))
    (unless (seq-every-p (lambda (m) (and (integerp m) (>= m 1) (<= m 12)))
                         (plist-get rule :bymonth))
      (user-error "BYMONTH months must be integers 1-12"))
    ;; Degenerate monthly grid: FREQ=MONTHLY with gcd(INTERVAL,12)>1 visits only
    ;; 12/gcd distinct months, so some anchors never reach BYMONTH.  Warn only
    ;; (never error -- the anchor is unknown at validation time).
    (let ((interval (or (plist-get rule :interval) 1)))
      (when (and (eq (plist-get rule :freq) 'monthly)
                 (> (org-habit-ng--gcd interval 12) 1))
        (let ((n (/ 12 (org-habit-ng--gcd interval 12))))
          (message "org-habit-ng: FREQ=MONTHLY;INTERVAL=%d visits only %d distinct \
month%s; BYMONTH may be unreachable from some anchors"
                   interval n (if (= n 1) "" "s"))))))
  ;; X-TIMES (quota-per-period, or rolling quota under X-WINDOW / F6): only
  ;; valid on a period rule or a rolling-window rule, positive integer.
  (when (plist-get rule :times)
    (unless (or (plist-get rule :period) (plist-get rule :window))
      (user-error "X-TIMES requires X-PERIOD or X-WINDOW"))
    (let ((n (plist-get rule :times)))
      (unless (and (integerp n) (>= n 1))
        (user-error "X-TIMES must be a positive integer"))
      ;; COUNT x X-TIMES>1 is legal but imperfect: COUNT counts completions,
      ;; so the series ends after ~ceil(COUNT/N) buckets, possibly mid-bucket.
      ;; Warn (do not error): the combination is meaningful, just surprising.
      ;; (X-WINDOW rejects COUNT outright above, so this only fires in period mode.)
      (when (and (> n 1) (plist-get rule :count))
        (message "org-habit-ng: COUNT counts completions, not satisfied buckets; \
with X-TIMES=%d the series ends after COUNT completions (about %d buckets) \
and may stop mid-bucket" n (ceiling (plist-get rule :count) n)))))
  ;; X-MIN-GAP (min-spacing within quota): only valid with (X-PERIOD or
  ;; X-WINDOW) and X-TIMES>=2; normalized value must be >= 2 days.  A soft
  ;; feasibility warning (not an error) fires when the gap cannot fit
  ;; X-TIMES completions in the tightest possible bucket/window --
  ;; `--bucket-times-required' (period) or the K_eff clamp (window) keeps
  ;; the quota satisfiable in that case, consistent with F4's
  ;; seasonal-straddle clamp.
  (when (plist-get rule :min-gap)
    (unless (or (plist-get rule :period) (plist-get rule :window))
      (user-error "X-MIN-GAP requires X-PERIOD or X-WINDOW"))
    (let ((times (or (plist-get rule :times) 1))
          (gap (plist-get rule :min-gap)))
      (when (< times 2)
        (user-error "X-MIN-GAP requires X-TIMES>=2.  Spacing is only meaningful between multiple completions in a bucket; cross-bucket spacing is not supported"))
      (unless (>= gap 2)
        (user-error "X-MIN-GAP must be at least 2 days (a 1-day gap is any distinct day, i.e. plain X-TIMES)"))
      (if (plist-get rule :window)
          ;; Rolling-window feasibility (F6): a trailing N-day window holds at
          ;; most 1 + floor((N-1)/G) G-spaced days.  K_eff = min(K, that bound)
          ;; is applied identically at the evaluator/scheduler/renderer; this
          ;; warning just announces the clamp (probe: K=3, N=7, G=4 -> K_eff=2).
          (let* ((n (plist-get rule :window))
                 (k-eff (min times (1+ (/ (1- n) gap)))))
            (when (< k-eff times)
              (message "org-habit-ng: X-MIN-GAP=%d with X-TIMES=%d needs %d spaced days but a rolling %d-day window holds at most %d; the effective quota is clamped to %d"
                       gap times (+ (* gap (1- times)) 1) n k-eff k-eff)))
        ;; Period-mode feasibility (F1/F7, unchanged): the tightest possible
        ;; bucket cannot fit N.  Review correction: the month bound (* interval
        ;; 28) underestimates a real quarter (Jan+Feb+Mar etc. run well past 84
        ;; days); use (+ 28 (* 30 (1- interval))) instead -- a tighter, more
        ;; realistic lower bound on an INTERVAL-month bucket's actual length.
        (let* ((interval (or (plist-get rule :interval) 1))
               (min-len (if (eq (plist-get rule :period) 'week)
                            (* interval 7)
                          (+ 28 (* 30 (1- interval))))))
          (when (> (+ (* gap (1- times)) 1) min-len)
            (message "org-habit-ng: X-MIN-GAP=%d with X-TIMES=%d needs %d spaced days but a %s bucket has at most ~%d; the quota will be clamped down"
                     gap times (+ (* gap (1- times)) 1)
                     (if (eq (plist-get rule :period) 'week) "week" "month") min-len))))))
  rule)

;; F8: @-macro authoring sugar (@daily/@weekly/@monthly/@yearly/@annually).

(defconst org-habit-ng--rrule-macro-alist
  '(("daily" . "FREQ=DAILY")
    ("weekly" . "FREQ=WEEKLY")
    ("monthly" . "FREQ=MONTHLY")
    ("yearly" . "FREQ=YEARLY")
    ("annually" . "FREQ=YEARLY"))
  "Map @-macro names to their FREQ= expansion.
Names are lowercase, no leading @.  @annually is a cron-compatible alias
for @yearly.")

(defun org-habit-ng--expand-rrule-macros (string)
  "Expand a leading @-macro in STRING to its canonical FREQ= form.
STRING is returned UNCHANGED when it contains no @ at all -- the common
case, zero cost.  When @ is present the macro must be the FIRST
;-delimited token and the SOLE source of FREQ:
- a non-leading @ (e.g. \"X-FLEX-AFTER=1d;@weekly\") signals `user-error'.
  The check is @-ANYWHERE, not leading-@ only: without it, today's parser
  silently ignores an embedded unknown token (a mid-string @weekly parses
  to a freq-less rule with no error), leaving that misparse reachable.
- an unrecognized macro name (`@hourly', `@reboot', `@foo') signals
  `user-error'.
- an explicit FREQ= token, or a second @-part, anywhere in the remainder
  signals `user-error' (two FREQ sources is ambiguous).
Matching the macro name is case-insensitive (`@WEEKLY' == `@weekly').
Downstream (tokenize/parse) never sees an @ -- the expansion is one-way
authoring sugar; `org-habit-ng-build-rrule' never re-emits it."
  (if (not (string-match-p "@" string))
      string
    (let* ((trimmed (string-trim string))
           (parts (split-string trimmed ";" t "[ \t\n]+"))
           (first (or (car parts) "")))
      (unless (string-prefix-p "@" first)
        (user-error "A recurrence macro must be the first token"))
      (let* ((name (downcase (substring first 1)))
             (expansion (cdr (assoc name org-habit-ng--rrule-macro-alist))))
        (unless expansion
          (user-error "Unknown recurrence macro '%s'; use @daily, @weekly, @monthly, @yearly, or @annually" first))
        (when (seq-some (lambda (p) (or (string-prefix-p "@" p)
                                        (string-prefix-p "FREQ=" p)))
                        (cdr parts))
          (user-error "A recurrence macro (@weekly etc.) already sets FREQ; remove the explicit FREQ"))
        (string-join (cons expansion (cdr parts)) ";")))))

(defun org-habit-ng-parse-rrule (rrule-string)
  "Parse RRULE-STRING into a rule plist."
  (setq rrule-string (org-habit-ng--expand-rrule-macros rrule-string))
  (let ((tokens (org-habit-ng--rrule-tokenize rrule-string))
        (rule nil))
    (dolist (token tokens)
      (let ((key (car token))
            (value (cdr token)))
        (cond
         ((string= key "FREQ")
          (setq rule (plist-put rule :freq
                                (cdr (assoc value org-habit-ng--rrule-freq-alist)))))
         ((string= key "INTERVAL")
          (setq rule (plist-put rule :interval (string-to-number value))))
         ((string= key "COUNT")
          (setq rule (plist-put rule :count (string-to-number value))))
         ((string= key "UNTIL")
          (setq rule (plist-put rule :until (org-habit-ng--parse-rrule-date value))))
         ((string= key "BYDAY")
          (setq rule (plist-put rule :byday (org-habit-ng--rrule-parse-byday value))))
         ((string= key "BYMONTHDAY")
          (setq rule (plist-put rule :bymonthday (org-habit-ng--rrule-parse-numlist value))))
         ((string= key "BYMONTH")
          (setq rule (plist-put rule :bymonth (org-habit-ng--rrule-parse-numlist value))))
         ((string= key "BYWEEKNO")
          (setq rule (plist-put rule :byweekno (org-habit-ng--rrule-parse-numlist value))))
         ((string= key "BYYEARDAY")
          (setq rule (plist-put rule :byyearday (org-habit-ng--rrule-parse-numlist value))))
         ((string= key "BYSETPOS")
          (setq rule (plist-put rule :bysetpos (org-habit-ng--rrule-parse-numlist value))))
         ((string= key "BYHOUR")
          (setq rule (plist-put rule :byhour (org-habit-ng--rrule-parse-numlist value))))
         ((string= key "BYMINUTE")
          (setq rule (plist-put rule :byminute (org-habit-ng--rrule-parse-numlist value))))
         ((string= key "BYSECOND")
          (setq rule (plist-put rule :bysecond (org-habit-ng--rrule-parse-numlist value))))
         ((string= key "X-REPEAT-FROM")
          (setq rule (plist-put rule :repeat-from (intern value))))
         ((string= key "X-WARN")
          (setq rule (plist-put rule :warn value)))
         ((string= key "X-FLEX-BEFORE")
          (setq rule (plist-put rule :flex-before (org-habit-ng--parse-flex-days value)))
          (when (string-match-p "w\\'" value)
            (setq rule (plist-put rule :flex-before-raw value))))
         ((string= key "X-FLEX-AFTER")
          (setq rule (plist-put rule :flex-after (org-habit-ng--parse-flex-days value)))
          (when (string-match-p "w\\'" value)
            (setq rule (plist-put rule :flex-after-raw value))))
         ((string= key "X-FLEXIBILITY")
          (unless (string-match-p "\\`[0-9]+\\'" value)
            (user-error "X-FLEXIBILITY is legacy and takes a bare integer day count; use X-FLEX-AFTER=Nw for units"))
          (setq rule (plist-put rule :flex-after (string-to-number value))))
         ((string= key "X-PERIOD")
          (setq rule (plist-put rule :period (intern (downcase value)))))
         ((string= key "X-CEILING")
          (unless (string= (downcase value) "t")
            (user-error "X-CEILING only accepts t"))
          (setq rule (plist-put rule :ceiling t)))
         ((string= key "X-WINDOW")
          (unless (string-match-p "\\`[0-9]+\\'" (or value ""))
            (user-error "X-WINDOW must be a positive integer day count"))
          (setq rule (plist-put rule :window (string-to-number value))))
         ((string= key "X-TIMES")
          (unless (string-match-p "\\`[0-9]+\\'" value)
            (user-error "X-TIMES must be a positive integer"))
          (setq rule (plist-put rule :times (string-to-number value))))
         ((string= key "X-MIN-GAP")
          ;; VALUE is nil for a bare "X-MIN-GAP=" token (tokenizer omits the
          ;; empty right-hand side); guard with (or value "") so that case
          ;; hits the same clear user-error instead of wrong-type-argument.
          (unless (string-match-p "\\`[0-9]+[dw]?\\'" (or value ""))
            (user-error "X-MIN-GAP must be a positive integer optionally suffixed with d or w (e.g. 2, 3d, 1w)"))
          (setq rule (plist-put rule :min-gap (org-habit-ng--parse-flex-days value))))
         ((string= key "WKST")
          (setq rule (plist-put rule :wkst
                                (cdr (assoc value org-habit-ng--rrule-day-alist)))))
         ((string= key "X-SKIP")
          (let ((upcased (upcase (or value ""))))
            (cond
             ((string= upcased "OMIT")
              (user-error "X-SKIP=OMIT (holiday/skip semantics) is reserved but not yet supported"))
             ((member upcased '("BACKWARD" "FORWARD" "NEAREST"))
              (setq rule (plist-put rule :skip (intern (downcase upcased)))))
             (t
              (user-error "X-SKIP must be BACKWARD, FORWARD, or NEAREST"))))))))
    ;; Default interval to 1 if not specified
    (unless (plist-get rule :interval)
      (setq rule (plist-put rule :interval 1)))
    ;; Default WKST from the user's `calendar-week-start-day' for period mode.
    ;; Our :wkst uses the same 0=Sunday .. 6=Saturday encoding, so no remap.
    (when (and (plist-get rule :period)
               (null (plist-get rule :wkst)))
      (setq rule (plist-put rule :wkst calendar-week-start-day)))
    ;; Period rules are always scheduled-anchored; normalize an omitted
    ;; X-REPEAT-FROM so the scheduler cannot fall back to 'completion.
    (when (and (plist-get rule :period)
               (null (plist-get rule :repeat-from)))
      (setq rule (plist-put rule :repeat-from 'scheduled)))
    ;; A ceiling is inherently completion-anchored; normalize an omitted
    ;; X-REPEAT-FROM so the dispatcher/scheduler read 'completion.
    (when (and (plist-get rule :ceiling)
               (null (plist-get rule :repeat-from)))
      (setq rule (plist-put rule :repeat-from 'completion)))
    ;; Validate rule for conflicts
    (org-habit-ng--validate-rule rule)))

;;; Day-based occurrence engine (Phase 1 refactor)
;;
;; Pure, DST-safe RFC 5545 expand-then-limit generator working in absolute day
;; numbers (calendar-absolute-from-gregorian integers). The old --rrule-next-*
;; datetime evaluator it replaced was deleted in Phase 3.

(defun org-habit-ng--gregorian-to-day (month day year)
  "Absolute day number for MONTH DAY YEAR."
  (calendar-absolute-from-gregorian (list month day year)))

(defun org-habit-ng--day-to-gregorian (day)
  "Return (MONTH DAY YEAR) for absolute DAY number."
  (calendar-gregorian-from-absolute day))

(defun org-habit-ng--day-dow (day)
  "Day of week for absolute DAY (0=Sunday .. 6=Saturday)."
  (mod day 7))

(defun org-habit-ng--skip-shift-day (day mode)
  "Shift absolute DAY off a weekend per MODE.
Weekend membership is read from `calendar-weekend-days' (a list of
day-of-week numbers, 0=Sunday .. 6=Saturday; default (0 6) = Sat+Sun),
so the user's weekend definition is honored.  MODE is a symbol, one of
backward, forward, or nearest (F8, cron `W' semantics).  Never crosses
the month boundary DAY falls in.  A weekday DAY (one whose day-of-week is
not in `calendar-weekend-days') is returned unchanged.

- NEAREST: shift to the nearest weekday in either direction, preferring
  the closer one; on a distance tie prefer the earlier (backward) day.
- BACKWARD: shift to the preceding weekday; if none exists in-month, fall
  FORWARD instead (stay in month).
- FORWARD: shift to the following weekday; if none exists in-month, fall
  BACKWARD instead (stay in month).

With the default Sat/Sun weekend: NEAREST turns a Saturday into the
preceding Friday and a Sunday into the following Monday; a Saturday on
the 1st reverses to Monday the 3rd; a Sunday on the last day reverses to
the preceding Friday."
  (let ((weekday-p (lambda (d)
                     (not (memq (org-habit-ng--day-dow d)
                                calendar-weekend-days)))))
    (if (funcall weekday-p day)
        day
      (let* ((greg (org-habit-ng--day-to-gregorian day))
             (month (nth 0 greg)) (year (nth 2 greg))
             (first-day (org-habit-ng--gregorian-to-day month 1 year))
             (last-day (org-habit-ng--gregorian-to-day
                        month (calendar-last-day-of-month month year) year))
             (walk (lambda (dir)
                     ;; First weekday from DAY stepping by DIR (+1/-1),
                     ;; bounded to the month; nil if none in that direction.
                     (let ((d (+ day dir)) (found nil))
                       (while (and (null found)
                                   (>= d first-day) (<= d last-day))
                         (if (funcall weekday-p d)
                             (setq found d)
                           (setq d (+ d dir))))
                       found))))
        (pcase mode
          ('nearest
           (let ((back (funcall walk -1))
                 (fwd (funcall walk +1)))
             (cond
              ((and back fwd)
               (if (<= (- day back) (- fwd day)) back fwd))
              (back back)
              (fwd fwd)
              (t day))))
          ('backward
           (or (funcall walk -1) (funcall walk +1) day))
          ('forward
           (or (funcall walk +1) (funcall walk -1) day))
          (_ (error "Unknown X-SKIP mode: %s" mode)))))))

(defun org-habit-ng--occ-check-supported (rule)
  "Signal a user-error if RULE has a sub-daily FREQ.
Sub-daily FREQs are unsupported by the day engine."
  (let ((freq (plist-get rule :freq)))
    (when (memq freq '(hourly minutely secondly))
      (user-error "Sub-daily FREQ (%s) is not supported by the day-based occurrence engine" freq))))

(defun org-habit-ng--occ-until-day (rule)
  "Absolute day number of RULE's :until, or nil."
  (let ((until (plist-get rule :until)))
    (when until
      (org-habit-ng--gregorian-to-day (plist-get until :month)
                                      (plist-get until :day)
                                      (plist-get until :year)))))

(defun org-habit-ng--occ-byday-dows (rule)
  "Weekday numbers (0-6) from RULE's :byday, dropping ordinals; nil if none."
  (let ((byday (plist-get rule :byday)))
    (when byday
      (mapcar (lambda (e) (if (consp e) (cdr e) e)) byday))))

(defun org-habit-ng--occ-apply-setpos (days bysetpos)
  "Select DAYS (sorted ascending) by 1-indexed BYSETPOS positions.
Negative positions count from the end.  Returns a sorted list."
  (let ((len (length days)) (result nil))
    (dolist (pos bysetpos)
      (let ((idx (if (> pos 0) (1- pos) (+ len pos))))
        (when (and (>= idx 0) (< idx len))
          (push (nth idx days) result))))
    (sort (nreverse result) #'<)))

(defun org-habit-ng--gcd (a b)
  "Greatest common divisor of non-negative integers A and B."
  (if (zerop b) a (org-habit-ng--gcd b (mod a b))))

(defun org-habit-ng--rule-seasonal-p (rule)
  "Non-nil if RULE has a BYMONTH activation window (a season gate).
True only for point rules whose FREQ is daily/weekly/monthly with :bymonth set
\(and, via FREQ, for period rules).  Nil for YEARLY, where BYMONTH is occurrence
shape, not a season -- so the renderer never dims a yearly habit's other months."
  (and (plist-get rule :bymonth)
       (memq (plist-get rule :freq) '(daily weekly monthly))))

(defun org-habit-ng--day-in-season-p (day bymonth)
  "Non-nil if DAY's month is in BYMONTH, or BYMONTH is nil (not seasonal).
BYMONTH is an order-independent set, so a cross-year season like (11 12 1 2)
matches January regardless of element order."
  (or (null bymonth)
      (member (nth 0 (org-habit-ng--day-to-gregorian day)) bymonth)))

(defun org-habit-ng--bucket-in-season-p (start end bymonth)
  "Non-nil if ANY day in [START, END] has its month in BYMONTH (or BYMONTH nil)."
  (or (null bymonth)
      (let ((d start) (hit nil))
        (while (and (not hit) (<= d end))
          (when (member (nth 0 (org-habit-ng--day-to-gregorian d)) bymonth)
            (setq hit t))
          (setq d (1+ d)))
        hit)))

(defun org-habit-ng--in-season-days (start end bymonth)
  "List of days in [START, END] (ascending) whose month is in BYMONTH."
  (let (acc (d start))
    (while (<= d end)
      (when (member (nth 0 (org-habit-ng--day-to-gregorian d)) bymonth)
        (push d acc))
      (setq d (1+ d)))
    (nreverse acc)))

(defun org-habit-ng--in-season-day-count (start end bymonth)
  "Number of days in [START, END] whose month is in BYMONTH."
  (length (org-habit-ng--in-season-days start end bymonth)))

(defun org-habit-ng--bucket-counted-days (rule days)
  "Greedy subset of DAYS that counts toward RULE's quota under X-MIN-GAP.
DAYS is a list of absolute day numbers.  Earliest-first activity-selection
greedy: sort ascending, include the first day, then include each later day
only when it is at least :min-gap day-numbers after the last INCLUDED day.
Returns the counted days ascending.  With gap 1 (absent X-MIN-GAP) every
distinct day counts, so the result equals the distinct-day set."
  (let ((gap (or (plist-get rule :min-gap) 1))
        (last nil) (acc nil))
    (dolist (d (delete-dups (sort (copy-sequence days) #'<)))
      (when (or (null last) (>= d (+ last gap)))
        (push d acc)
        (setq last d)))
    (nreverse acc)))

(defun org-habit-ng--rolling-counted-chain (rule days)
  "DESCENDING latest-first greedy counted chain of DAYS under RULE (F6).
DAYS is a list of absolute day numbers; same-day duplicates collapse to one
\(`delete-dups') before counting -- the quota is K DISTINCT counted days,
never K logbook lines.  Sort descending, take the most recent day, then
repeatedly the next day <= (previous - :min-gap) (:min-gap default 1, i.e.
every distinct day counts).  Returns the chain descending (most recent
first); its K_eff-th element is `c_K' (see the design doc's \"The counted
chain\").  This is a DELIBERATE sibling of `org-habit-ng--bucket-counted-days'
\(ascending, earliest-first activity-selection) -- NOT a reuse of it.  The two
answer different questions (per-bucket crediting vs. trailing-window
coverage) and provably disagree: completions {1, 2, 4}, :min-gap 2 -- the
ascending greedy counts {1, 4}, but the maximum G-spaced subset inside the
window ending day 5 is {2, 4}, which only the descending chain finds."
  (let ((gap (or (plist-get rule :min-gap) 1))
        (last nil) (acc nil))
    (dolist (d (delete-dups (sort (copy-sequence days) #'>)))
      (when (or (null last) (<= d (- last gap)))
        (push d acc)
        (setq last d)))
    (nreverse acc)))

(defun org-habit-ng--bucket-times-required (rule start end)
  "Effective quota for bucket [START, END] under RULE.
X-TIMES is clamped to the maximum number of completions actually
achievable in the bucket under both the season window (BYMONTH) and the
min-spacing constraint (X-MIN-GAP).  Distinct-day, spaced counting makes
an unclamped quota in-bucket-unsatisfiable; the clamp keeps every bucket
satisfiable by construction."
  (let* ((times (or (plist-get rule :times) 1))
         (bymonth (plist-get rule :bymonth))
         (gap (or (plist-get rule :min-gap) 1)))
    (if (and (null bymonth) (= gap 1))
        times                            ; F1 fast path: no season, no spacing
      (let ((in-season (if bymonth
                           (org-habit-ng--in-season-days start end bymonth)
                         (let (acc (d start))
                           (while (<= d end) (push d acc) (setq d (1+ d)))
                           (nreverse acc)))))
        (min times
             (if (= gap 1)
                 (length in-season)      ; F4 behavior unchanged
               (length (org-habit-ng--bucket-counted-days rule in-season))))))))

(defun org-habit-ng--occ-expand-daily (rule anchor-day n)
  "Candidate days for RULE's DAILY period index N, from ANCHOR-DAY.
Single day, limited by BYDAY/BYMONTH."
  (let* ((day (+ anchor-day n))
         (dows (org-habit-ng--occ-byday-dows rule))
         (bymonth (plist-get rule :bymonth)))
    (when (and (or (null dows) (member (org-habit-ng--day-dow day) dows))
               (or (null bymonth)
                   (member (nth 0 (org-habit-ng--day-to-gregorian day)) bymonth)))
      (list day))))

(defun org-habit-ng--occ-week-start (day wkst)
  "First day of the WKST-aligned week containing DAY.
WKST is the week-start weekday (0=Sunday .. 6=Saturday)."
  (- day (mod (- (org-habit-ng--day-dow day) wkst) 7)))

(defun org-habit-ng--occ-expand-weekly (rule anchor-day n)
  "Candidate days for RULE's WEEKLY period index N, from ANCHOR-DAY.
The week is WKST-aligned.  Days outside RULE's :bymonth activation
window (if any) are dropped, so a week wholly outside the season
yields nil."
  (let* ((wkst (or (plist-get rule :wkst) calendar-week-start-day))
         (aws (org-habit-ng--occ-week-start anchor-day wkst))
         (ws (+ aws (* 7 n)))
         (dows (org-habit-ng--occ-byday-dows rule))
         (bymonth (plist-get rule :bymonth))
         (result
          (if dows
              (let (acc)
                (dotimes (i 7)
                  (let ((d (+ ws i)))
                    (when (member (org-habit-ng--day-dow d) dows)
                      (push d acc))))
                (nreverse acc))
            ;; Default: the day in this week with the anchor's weekday.
            (list (+ ws (mod (- (org-habit-ng--day-dow anchor-day) wkst) 7))))))
    (seq-filter (lambda (d) (org-habit-ng--day-in-season-p d bymonth)) result)))

(defun org-habit-ng--occ-add-months (year month n)
  "Return (YEAR . MONTH) for MONTH/YEAR plus N months (N may be negative).
MONTH is 1-12. Assumes the resulting total months count is positive."
  (let* ((total (+ (* year 12) (1- month) n))
         (y (/ total 12))
         (m (1+ (mod total 12))))
    (cons y m)))

(defun org-habit-ng--occ-monthly-byday-days (byday month year)
  "Expand BYDAY within MONTH/YEAR to a list of absolute day numbers.
Ordinal entries (ORD . DOW) select the ORDth weekday (negative from end);
plain DOW numbers select every matching weekday."
  (let ((last (calendar-last-day-of-month month year))
        (result nil))
    (dolist (entry byday)
      (if (consp entry)
          (let ((ord (car entry)) (dow (cdr entry)) (matches nil))
            (dotimes (i last)
              (let ((d (1+ i)))
                (when (= (calendar-day-of-week (list month d year)) dow)
                  (push d matches))))
            (setq matches (nreverse matches))
            (let* ((len (length matches))
                   (idx (if (> ord 0) (1- ord) (+ len ord))))
              (when (and (>= idx 0) (< idx len))
                (push (org-habit-ng--gregorian-to-day month (nth idx matches) year)
                      result))))
        (dotimes (i last)
          (let ((d (1+ i)))
            (when (= (calendar-day-of-week (list month d year)) entry)
              (push (org-habit-ng--gregorian-to-day month d year) result))))))
    result))

(defun org-habit-ng--occ-monthly-bymonthday-days (bymonthday month year)
  "Expand BYMONTHDAY within MONTH/YEAR to absolute day numbers.
Negative values count from the end; days that do not exist are skipped."
  (let ((last (calendar-last-day-of-month month year))
        (result nil))
    (dolist (md bymonthday)
      (let ((dom (if (> md 0) md (+ last md 1))))
        (when (and (>= dom 1) (<= dom last))
          (push (org-habit-ng--gregorian-to-day month dom year) result))))
    result))

(defun org-habit-ng--occ-expand-monthly (rule anchor-day n)
  "Candidate days for RULE's MONTHLY period index N, from ANCHOR-DAY.
Return nil when the month is outside RULE's :bymonth activation window."
  (let* ((greg (org-habit-ng--day-to-gregorian anchor-day))
         (ay (nth 2 greg)) (am (nth 0 greg)) (adom (nth 1 greg))
         (ym (org-habit-ng--occ-add-months ay am n))
         (year (car ym)) (month (cdr ym))
         (bymonth (plist-get rule :bymonth))
         (byday (plist-get rule :byday))
         (bymonthday (plist-get rule :bymonthday))
         (bysetpos (plist-get rule :bysetpos))
         (days nil))
    (if (and bymonth (not (member month bymonth)))
        nil
      (progn
        (cond
         ;; RFC 5545: a PLAIN-weekday BYDAY and BYMONTHDAY together are
         ;; INTERSECTED (the wednesteenth idiom).  Both helpers return the
         ;; same absolute-day integers, so set-intersection is exact.
         ;; Restricted to plain BYDAY with no BYSETPOS so that ordinal-BYDAY
         ;; and BYSETPOS rules keep their pre-intersection (byday-only)
         ;; semantics, matching the human-text branch's guard exactly.
         ((and byday (integerp (car byday)) (not bysetpos) bymonthday)
          (let ((bmd (org-habit-ng--occ-monthly-bymonthday-days bymonthday month year)))
            (setq days (seq-filter (lambda (d) (memq d bmd))
                                   (org-habit-ng--occ-monthly-byday-days byday month year)))))
         (byday (setq days (org-habit-ng--occ-monthly-byday-days byday month year)))
         (bymonthday (setq days (org-habit-ng--occ-monthly-bymonthday-days bymonthday month year)))
         (t
          (let ((last (calendar-last-day-of-month month year)))
            (when (<= adom last)
              (setq days (list (org-habit-ng--gregorian-to-day month adom year)))))))
        (sort days #'<)))))

(defun org-habit-ng--occ-expand-yearly (rule anchor-day n)
  "Candidate days for RULE's YEARLY period index N, from ANCHOR-DAY."
  (let* ((greg (org-habit-ng--day-to-gregorian anchor-day))
         (ay (nth 2 greg)) (am (nth 0 greg)) (adom (nth 1 greg))
         (year (+ ay n))
         (bymonth (plist-get rule :bymonth))
         (byday (plist-get rule :byday))
         (bymonthday (plist-get rule :bymonthday))
         (bysetpos (plist-get rule :bysetpos))
         (days nil))
    (cond
     ;; RFC 5545: a PLAIN-weekday BYDAY and BYMONTHDAY together are
     ;; INTERSECTED per month (the yearly wednesteenth).  Must precede the
     ;; byday-only clause.  Restricted to plain BYDAY with no BYSETPOS so
     ;; ordinal-BYDAY and BYSETPOS rules keep their byday-only semantics,
     ;; matching the human-text branch's guard exactly.
     ((and bymonth byday (integerp (car byday)) (not bysetpos) bymonthday)
      (dolist (m bymonth)
        (let ((bmd (org-habit-ng--occ-monthly-bymonthday-days bymonthday m year)))
          (setq days (append days
                             (seq-filter (lambda (d) (memq d bmd))
                                         (org-habit-ng--occ-monthly-byday-days byday m year)))))))
     ((and bymonth byday)
      (dolist (m bymonth)
        (setq days (append days (org-habit-ng--occ-monthly-byday-days byday m year)))))
     ((and bymonth bymonthday)
      (dolist (m bymonth)
        (setq days (append days (org-habit-ng--occ-monthly-bymonthday-days bymonthday m year)))))
     (bymonth
      (dolist (m bymonth)
        (let ((last (calendar-last-day-of-month m year)))
          (when (<= adom last)
            (push (org-habit-ng--gregorian-to-day m adom year) days)))))
     (byday
      (dotimes (mi 12)
        (setq days (append days (org-habit-ng--occ-monthly-byday-days byday (1+ mi) year)))))
     (t
      (let ((last (calendar-last-day-of-month am year)))
        (when (<= adom last)
          (setq days (list (org-habit-ng--gregorian-to-day am adom year)))))))
    (sort days #'<)))

(defun org-habit-ng--occ-period-days (rule anchor-day n)
  "Selected occurrence days of RULE for period index N: sorted, BYSETPOS applied.
Period N is relative to ANCHOR-DAY.  Returns nil when period N is not live
under INTERVAL."
  (let ((interval (or (plist-get rule :interval) 1)))
    (when (zerop (mod n interval))
      (let* ((freq (plist-get rule :freq))
             (cands (pcase freq
                      ('daily   (org-habit-ng--occ-expand-daily rule anchor-day n))
                      ('weekly  (org-habit-ng--occ-expand-weekly rule anchor-day n))
                      ('monthly (org-habit-ng--occ-expand-monthly rule anchor-day n))
                      ('yearly  (org-habit-ng--occ-expand-yearly rule anchor-day n))
                      (_ (error "Unsupported FREQ in day engine: %s" freq))))
             (bysetpos (plist-get rule :bysetpos))
             (skip (plist-get rule :skip)))
        ;; F8 X-SKIP: shift each candidate off a weekend, THEN dedup at this
        ;; site (load-bearing, not defensive) before sorting.  Two BYMONTHDAY
        ;; targets can shift onto the same weekday within one period (e.g.
        ;; BYMONTHDAY=1,2;X-SKIP=NEAREST with a Saturday 1st both land on
        ;; Monday the 3rd) -- deduping here, before the COUNT ordinal counter
        ;; in `org-habit-ng-occurrences' walks these candidates, ensures the
        ;; merged occurrence consumes exactly ONE ordinal instead of two.
        ;; Validation restricts :skip to non-BYSETPOS MONTHLY/YEARLY
        ;; BYMONTHDAY rules, so :skip and BYSETPOS never combine here.
        (setq cands (if skip
                        (sort (delete-dups
                               (mapcar (lambda (d) (org-habit-ng--skip-shift-day d skip))
                                       cands))
                              #'<)
                      (sort (copy-sequence cands) #'<)))
        (if bysetpos
            (org-habit-ng--occ-apply-setpos cands bysetpos)
          cands)))))

(defun org-habit-ng--occ-period-bounds (rule anchor-day n)
  "Return (FIRST . LAST) absolute-day bounds of RULE's period index N.
Period N is relative to ANCHOR-DAY.  Used to terminate scanning; not
affected by INTERVAL liveness."
  (pcase (plist-get rule :freq)
    ('daily (let ((d (+ anchor-day n))) (cons d d)))
    ('weekly
     (let* ((wkst (or (plist-get rule :wkst) calendar-week-start-day))
            (aws (org-habit-ng--occ-week-start anchor-day wkst))
            (ws (+ aws (* 7 n))))
       (cons ws (+ ws 6))))
    ('monthly
     (let* ((greg (org-habit-ng--day-to-gregorian anchor-day))
            (ym (org-habit-ng--occ-add-months (nth 2 greg) (nth 0 greg) n))
            (y (car ym)) (m (cdr ym)))
       (cons (org-habit-ng--gregorian-to-day m 1 y)
             (org-habit-ng--gregorian-to-day m (calendar-last-day-of-month m y) y))))
    ('yearly
     (let ((y (+ (nth 2 (org-habit-ng--day-to-gregorian anchor-day)) n)))
       (cons (org-habit-ng--gregorian-to-day 1 1 y)
             (org-habit-ng--gregorian-to-day 12 31 y))))
    (_ (error "Unsupported FREQ in day engine: %s" (plist-get rule :freq)))))

(defun org-habit-ng-occurrences (rule anchor-day start-day end-day)
  "All occurrence days of RULE within [START-DAY, END-DAY] inclusive.
All day arguments and results are absolute day numbers as returned by
`calendar-absolute-from-gregorian' (day 1 = 0001-01-01); convert with
`org-habit-ng--gregorian-to-day' / `org-habit-ng--day-to-gregorian'.

ANCHOR-DAY is a day on the habit's schedule.  When RULE has no BY*
parts, the anchor's calendar position DEFINES the pattern: WEEKLY
repeats on the anchor's weekday, MONTHLY on its day-of-month, YEARLY
on its month+day.  Passing an off-pattern anchor silently shifts the
whole series.

Occurrences on or after ANCHOR-DAY form the counted series (COUNT/UNTIL
apply, relative to ANCHOR-DAY); occurrences before ANCHOR-DAY are
generated for display and are uncounted.  Returns a sorted ascending
list of day numbers.

For a ceiling rule (X-CEILING=t) this returns the scheduling grid -- the
successive horizons the habit would have if never completed -- NOT due
dates.  For a rolling-window rule (X-WINDOW=N, F6) this returns the plain
daily cadence [ANCHOR-DAY, ANCHOR-DAY+1, ...] -- a rolling window has no due
dates at all, only a continuously-evaluated trailing window.  In both cases,
package authors evaluating success must go through `org-habit-ng-instances'."
  (org-habit-ng--occ-check-supported rule)
  (let* ((count (plist-get rule :count))
         (until-day (org-habit-ng--occ-until-day rule))
         (cap org-habit-ng--occurrence-period-cap)
         (result nil))
    ;; Forward (n = 0,1,2,...): counted series, days >= anchor-day.
    (let ((n 0) (ordinal 0) (stop nil))
      (while (and (not stop) (< n cap))
        (let ((bounds (org-habit-ng--occ-period-bounds rule anchor-day n))
              (days (org-habit-ng--occ-period-days rule anchor-day n)))
          (dolist (d days)
            (when (and (not stop) (>= d anchor-day))
              (setq ordinal (1+ ordinal))
              (cond
               ((and count (> ordinal count)) (setq stop t))
               ((and until-day (> d until-day)) (setq stop t))
               ((and (>= d start-day) (<= d end-day)) (push d result)))))
          (when (and (not stop) (null count) (> (car bounds) end-day))
            (setq stop t)))
        (setq n (1+ n))))
    ;; n = 0,-1,-2,...: days strictly before anchor-day, uncounted display.
    (let ((n 0) (stop nil))
      (while (and (not stop) (> n (- cap)))
        (let ((bounds (org-habit-ng--occ-period-bounds rule anchor-day n))
              (days (org-habit-ng--occ-period-days rule anchor-day n)))
          (dolist (d days)
            (when (and (< d anchor-day) (>= d start-day) (<= d end-day))
              (push d result)))
          (when (< (cdr bounds) start-day)
            (setq stop t)))
        (setq n (1- n))))
    (sort (delete-dups result) #'<)))

(defun org-habit-ng-next-occurrence (rule anchor-day after-day)
  "First counted occurrence of RULE strictly after AFTER-DAY, or nil.
Day arguments and result are absolute day numbers (see
`org-habit-ng-occurrences', which also documents the anchor-defines-
pattern semantics for rules without BY* parts).

Nil means the series is exhausted (COUNT consumed or UNTIL passed) OR
the search hit `org-habit-ng--occurrence-period-cap' (1000 periods)
without a match — e.g. a rule whose expansion never yields a day, or an
AFTER-DAY more than ~1000 periods past ANCHOR-DAY.  Callers that must
distinguish the two cases should bound AFTER-DAY relative to the anchor.
Searches the counted series (days on or after ANCHOR-DAY)."
  (org-habit-ng--occ-check-supported rule)
  (let* ((count (plist-get rule :count))
         (until-day (org-habit-ng--occ-until-day rule))
         (cap org-habit-ng--occurrence-period-cap)
         (n 0) (ordinal 0) (found nil) (stop nil))
    (while (and (not stop) (not found) (< n cap))
      (let ((days (org-habit-ng--occ-period-days rule anchor-day n)))
        (dolist (d days)
          (when (and (not stop) (not found) (>= d anchor-day))
            (setq ordinal (1+ ordinal))
            (cond
             ((and count (> ordinal count)) (setq stop t))
             ((and until-day (> d until-day)) (setq stop t))
             ((> d after-day) (setq found d))))))
      (setq n (1+ n)))
    found))

;;; Success model (Phase 3): instances + satisfied-p (pure, no buffers/faces)

(defun org-habit-ng--instance-status (window-start window-end completion today
                                                   &optional suppressed)
  "Status symbol for a point instance window given TODAY (a day number).
WINDOW-START and WINDOW-END bound the window.  COMPLETION is the assigned
completion day or nil.  SUPPRESSED non-nil yields `skipped' unless COMPLETION
satisfies the instance (satisfaction always wins)."
  (cond
   (completion 'satisfied)
   (suppressed 'skipped)
   ((< window-end today) 'missed)
   ((<= window-start today) 'pending)
   (t 'upcoming)))

(defun org-habit-ng--instances-point (rule anchor-day completions start-day end-day today
                                            &optional skip-events vacation-ranges)
  "Point-model instances of RULE whose windows overlap [START-DAY, END-DAY].
Deterministic (scheduled-anchored) series anchored at ANCHOR-DAY.  COMPLETIONS
is a list of day numbers.  SKIP-EVENTS is a list of day-skip integers and
\(START . END) bucket-skip conses; VACATION-RANGES is a list of (START . END)
conses.  Each day-skip is assigned to the nearest not-yet-satisfied,
not-yet-skipped instance whose window contains it (same greedy loop as
completions), so a skip performed a day late still excuses the instance.  An
instance whose due day lies in a skip range or vacation range is suppressed.
Satisfaction always wins."
  (let* ((before (or (plist-get rule :flex-before) 0))
         (after (or (plist-get rule :flex-after) 0))
         (dues (org-habit-ng-occurrences rule anchor-day
                                         (- start-day after)
                                         (+ end-day before)))
         (comps (sort (copy-sequence completions) #'<))
         (day-skips (sort (seq-filter #'integerp skip-events) #'<))
         (skip-ranges (seq-filter #'consp skip-events))
         (instances (mapcar (lambda (due)
                              (list :model 'point :due due
                                    :window-start (- due before)
                                    :window-end (+ due after)
                                    :completion nil :assigned-skip nil :status nil))
                            dues)))
    ;; Pass 1: greedy completion assignment (unchanged).
    (dolist (c comps)
      (let ((best nil) (best-dist nil))
        (dolist (inst instances)
          (when (and (null (plist-get inst :completion))
                     (>= c (plist-get inst :window-start))
                     (<= c (plist-get inst :window-end)))
            (let ((d (abs (- c (plist-get inst :due)))))
              (when (or (null best-dist) (< d best-dist))
                (setq best inst best-dist d)))))
        (when best (plist-put best :completion c))))
    ;; Pass 2: greedy day-skip assignment -- same loop, over day-skips,
    ;; targeting instances with no completion and no assigned skip.
    (dolist (s day-skips)
      (let ((best nil) (best-dist nil))
        (dolist (inst instances)
          (when (and (null (plist-get inst :completion))
                     (null (plist-get inst :assigned-skip))
                     (>= s (plist-get inst :window-start))
                     (<= s (plist-get inst :window-end)))
            (let ((d (abs (- s (plist-get inst :due)))))
              (when (or (null best-dist) (< d best-dist))
                (setq best inst best-dist d)))))
        (when best (plist-put best :assigned-skip s))))
    ;; Assign status; keep only instances overlapping the display window.
    (let (result)
      (dolist (inst instances)
        (let* ((due (plist-get inst :due))
               (completion (plist-get inst :completion))
               (skipped-by-range (org-habit-ng--in-vacation-p due skip-ranges))
               (vacationed (org-habit-ng--in-vacation-p due vacation-ranges))
               (suppressed (or (plist-get inst :assigned-skip)
                               skipped-by-range vacationed)))
          (plist-put inst :status
                     (org-habit-ng--instance-status
                      (plist-get inst :window-start) (plist-get inst :window-end)
                      completion today suppressed))
          (plist-put inst :suppressed-by
                     (cond (completion nil)
                           ((or (plist-get inst :assigned-skip) skipped-by-range) 'skip)
                           (vacationed 'vacation)))
          (when (and (<= (plist-get inst :window-start) end-day)
                     (>= (plist-get inst :window-end) start-day))
            (push inst result))))
      (nreverse result))))

(defun org-habit-ng--occ-period-index (rule anchor-day day)
  "Base-period index of DAY relative to ANCHOR-DAY (INTERVAL ignored).
RULE supplies the FREQ (and WKST for weekly) used to compute periods.
0 = the anchor's own period; negatives are earlier periods."
  (pcase (plist-get rule :freq)
    ('daily (- day anchor-day))
    ('weekly
     (let ((wkst (or (plist-get rule :wkst) calendar-week-start-day)))
       (/ (- (org-habit-ng--occ-week-start day wkst)
             (org-habit-ng--occ-week-start anchor-day wkst))
          7)))
    ('monthly
     (let ((ag (org-habit-ng--day-to-gregorian anchor-day))
           (dg (org-habit-ng--day-to-gregorian day)))
       (- (+ (* 12 (nth 2 dg)) (nth 0 dg))
          (+ (* 12 (nth 2 ag)) (nth 0 ag)))))
    ('yearly
     (- (nth 2 (org-habit-ng--day-to-gregorian day))
        (nth 2 (org-habit-ng--day-to-gregorian anchor-day))))
    (_ (error "Unsupported FREQ for period index: %s" (plist-get rule :freq)))))

(defun org-habit-ng--period-bucket-bounds (rule anchor-day day)
  "Return (FIRST . LAST) absolute-day bounds of the bucket containing DAY.
Buckets are RULE's INTERVAL base-periods wide, indexed relative to
ANCHOR-DAY."
  (let* ((interval (or (plist-get rule :interval) 1))
         (k (* interval (floor (org-habit-ng--occ-period-index rule anchor-day day)
                               interval))))
    (cons (car (org-habit-ng--occ-period-bounds rule anchor-day k))
          (cdr (org-habit-ng--occ-period-bounds rule anchor-day (+ k interval -1))))))

(defun org-habit-ng--instances-period (rule anchor-day completions start-day end-day today
                                             &optional skip-events vacation-ranges)
  "Period-bucket instances of RULE overlapping [START-DAY, END-DAY].
Periods are indexed relative to ANCHOR-DAY.  COMPLETIONS is a list of day
numbers.  SKIP-EVENTS and VACATION-RANGES feed the shared
`org-habit-ng--bucket-skipped-p' predicate; a suppressed (but not satisfied)
bucket is `skipped'.  Satisfaction always wins.  A wholly-off-season bucket
\(no day in RULE's :bymonth) is not applicable and emits nothing; a straddle
bucket exists with its quota clamped by `org-habit-ng--bucket-times-required'."
  (let* ((comps (sort (copy-sequence completions) #'<))
         (bymonth (plist-get rule :bymonth))
         (day start-day)
         (guard 0)
         (result nil))
    (while (and (< guard org-habit-ng--occurrence-period-cap)
                (<= day end-day))
      (let* ((bounds (org-habit-ng--period-bucket-bounds rule anchor-day day))
             (start (car bounds))
             (end (cdr bounds)))
        (if (not (org-habit-ng--bucket-in-season-p start end bymonth))
            ;; Wholly-off-season bucket: not applicable, emit nothing.
            (setq day (1+ end))
          (let* ((times (org-habit-ng--bucket-times-required rule start end))
                 (in (delete-dups
                      (seq-filter (lambda (c) (and (>= c start) (<= c end))) comps)))
                 (counted (org-habit-ng--bucket-counted-days rule in))
                 (satisfiedp (>= (length counted) times))
                 (suppressedp (and (not satisfiedp)
                                   (org-habit-ng--bucket-skipped-p
                                    start end times skip-events vacation-ranges)))
                 ;; skip-fired = clauses 1-2 of --bucket-skipped-p (range /
                 ;; day-skips>=N), re-inlined only to pick the reason label; it
                 ;; MUST mirror --bucket-skipped-p clauses 1-2 exactly.
                 (skip-fired (or (seq-some (lambda (ev)
                                             (and (consp ev)
                                                  (<= (car ev) end) (>= (cdr ev) start)))
                                           skip-events)
                                 (>= (length (delete-dups
                                              (seq-filter (lambda (ev)
                                                            (and (integerp ev)
                                                                 (>= ev start) (<= ev end)))
                                                          skip-events)))
                                     times)))
                 (status (cond (satisfiedp 'satisfied)
                               (suppressedp 'skipped)
                               ((< end today) 'missed)
                               ((<= start today) 'pending)
                               (t 'upcoming)))
                 (suppressed-by (and suppressedp (if skip-fired 'skip 'vacation))))
            (push (list :model 'period :start start :end end
                        :completions in :counted counted :times-required times
                        :status status :suppressed-by suppressed-by)
                  result)
            (setq day (1+ end)))))
      (setq guard (1+ guard)))
    (nreverse result)))

(defun org-habit-ng--instances-completion-anchored
    (rule anchor-day completions start-day end-day today
     &optional skip-events vacation-ranges)
  "Point instances of RULE via the completion-anchored walk.
For X-REPEAT-FROM=completion.  COMPLETIONS (:done) and SKIP-EVENTS
day-skips (:skip) are merged into one chronological event stream; each
event yields one realized instance (`satisfied' for a done, `skipped'
for a skip) and re-anchors the walk (a skip moves the clock without
counting).  Future instances project from
max(ANCHOR-DAY, next occurrence after the last event); a projected due
inside a VACATION-RANGES entry (or a bucket-skip range) is `skipped'.  A
final sort dedups by :due with realized instances winning over projected
ones (TODAY determines a projected instance's status).  Only instances
whose window overlaps [START-DAY, END-DAY] are returned."
  (let* ((before (or (plist-get rule :flex-before) 0))
         (after (or (plist-get rule :flex-after) 0))
         (day-skips (seq-filter #'integerp skip-events))
         (skip-ranges (seq-filter #'consp skip-events))
         (events (sort (append (mapcar (lambda (c) (cons c :done)) completions)
                               (mapcar (lambda (s) (cons s :skip)) day-skips))
                       (lambda (a b) (< (car a) (car b)))))
         (instances nil)
         (prev nil)
         (last-event nil))
    ;; 1. Walk realized events: each produces one instance and re-anchors prev.
    (dolist (ev events)
      (let* ((eday (car ev))
             (donep (eq (cdr ev) :done))
             (expected (if prev
                           (or (org-habit-ng-next-occurrence rule prev prev) eday)
                         eday))
             (ws (- expected before)) (we (+ expected after)))
        (push (list :model 'point :due expected
                    :window-start ws :window-end we
                    :completion (and donep eday)
                    :status (if donep 'satisfied 'skipped)
                    :suppressed-by (unless donep 'skip)
                    :realized t)
              instances)
        (setq prev eday last-event eday)))
    ;; 2. Project future from max(anchor, next-occurrence after last event).
    (let* ((after-last (and last-event
                            (org-habit-ng-next-occurrence rule last-event last-event)))
           (expected (if (and (integerp after-last) (> after-last anchor-day))
                         after-last anchor-day))
           (guard 0))
      (while (and (integerp expected) (<= expected end-day)
                  (< guard org-habit-ng--occurrence-period-cap))
        (let* ((ws (- expected before)) (we (+ expected after))
               (vacationed (org-habit-ng--in-vacation-p expected vacation-ranges))
               (skipped-by-range (org-habit-ng--in-vacation-p expected skip-ranges))
               (suppressed (or vacationed skipped-by-range))
               (status (org-habit-ng--instance-status ws we nil today suppressed)))
          (push (list :model 'point :due expected
                      :window-start ws :window-end we
                      :completion nil :status status
                      :suppressed-by (cond (skipped-by-range 'skip)
                                           (vacationed 'vacation)))
                instances))
        (setq expected (org-habit-ng-next-occurrence rule anchor-day expected))
        (setq guard (1+ guard))))
    ;; 3. Dedup by :due (realized wins), then window-filter.
    (let ((by-due (make-hash-table :test 'eql)) deduped)
      (dolist (inst instances)
        (let* ((due (plist-get inst :due))
               (existing (gethash due by-due)))
          (when (or (null existing)
                    (and (not (plist-get existing :realized))
                         (plist-get inst :realized)))
            (puthash due inst by-due))))
      (maphash (lambda (_k v) (push v deduped)) by-due)
      (setq deduped
            (sort deduped (lambda (a b) (< (plist-get a :due) (plist-get b :due)))))
      (seq-filter (lambda (inst)
                    (and (<= (plist-get inst :window-start) end-day)
                         (>= (plist-get inst :window-end) start-day)))
                  deduped))))

(defun org-habit-ng--pause-day-p (day skip-events vacation-ranges)
  "Non-nil when DAY is paused for a ceiling/rolling deficit clock (D2).
DAY is paused when it is an integer day-skip, inside a skip range, or inside a
vacation range; a paused day is subtracted from active elapsed time and never
advances the miss counter.  SKIP-EVENTS is a mixed list of integer day-skips and
\(START . END) skip-range cons cells; VACATION-RANGES is a list of (START . END)
cons cells."
  (or (memq day skip-events)
      (seq-find (lambda (r) (and (consp r) (<= (car r) day) (>= (cdr r) day)))
                skip-events)
      (org-habit-ng--in-vacation-p day vacation-ranges)))

(defun org-habit-ng--ceiling-horizon (rule reset vacation-ranges)
  "Vacation-adjusted next grid occurrence after RESET for a ceiling RULE.
The horizon is the next grid occurrence after RESET; if that day lands
inside a VACATION-RANGES entry it is deferred to the next grid
occurrence after the vacation ends (grid-step deferral --
`org-habit-ng--skip-vacations' verbatim, exactly what SCHEDULED already
stores).  Return nil when the series is exhausted (UNTIL passed, COUNT
ordinal consumed, occurrence cap hit, or --skip-vacations nil).  Under D2
the evaluator walks the pure grid itself, so this now serves only the live
pending `:due' (so a habit is not shown overdue mid-vacation) and the
renderer."
  (let ((next (org-habit-ng-next-occurrence rule reset reset)))
    (and next
         (org-habit-ng--skip-vacations rule reset next vacation-ranges))))

(defun org-habit-ng--ceiling-emit-blocks (rule r1 limit start-day end-day
                                               skip-events vacation-ranges emit-fn)
  "Emit one ceiling instance per elapsed natural-interval block of RULE.
R1 is a reset (a completion or ANCHOR-DAY).  H starts at the grace boundary
`org-habit-ng-next-occurrence'(R1, R1); the grace interval (R1, H] is never a
miss.  For each grid block (H, NH] with NH < LIMIT (fully elapsed before LIMIT)
EMIT-FN gets one plist: `skipped' when every day (1+H)..NH is
`org-habit-ng--pause-day-p' (a fully-paused interval freezes the clock), else
`missed'.  A partially-paused block still counts `missed' (full block-shifting
for straddling pauses is a v1 non-goal).  Emitted blocks are window-filtered to
overlap [START-DAY, END-DAY], but the walk (hence the returned boundary) is
not.  Return the opening boundary of the first block that did NOT fully elapse
before LIMIT, or nil only when the first grid occurrence after R1 is nil
\(mid-walk exhaustion returns the last non-nil boundary).  SKIP-EVENTS and
VACATION-RANGES are as in `org-habit-ng--pause-day-p'."
  (let ((h (org-habit-ng-next-occurrence rule r1 r1))
        (continue t))
    (while (and h continue)
      (let ((nh (org-habit-ng-next-occurrence rule r1 h)))
        (if (and nh (< nh limit))
            (progn
              (when (and (<= (1+ h) end-day) (>= nh start-day))
                (let* ((days (number-sequence (1+ h) nh))
                       (paused-all
                        (cl-every (lambda (d)
                                    (org-habit-ng--pause-day-p d skip-events vacation-ranges))
                                  days))
                       ;; Label the pause cause like the other walks: prefer
                       ;; `vacation' when any day is in a vacation range, else
                       ;; `skip' (day-skip / skip range).
                       (cause (when paused-all
                                (if (seq-find (lambda (d)
                                                (org-habit-ng--in-vacation-p d vacation-ranges))
                                              days)
                                    'vacation
                                  'skip))))
                  (funcall emit-fn
                           (list :model 'ceiling
                                 :status (if paused-all 'skipped 'missed)
                                 :due h :window-start (1+ h) :window-end nh
                                 :completion nil
                                 :suppressed-by cause))))
              (setq h nh))
          (setq continue nil))))
    h))

(defun org-habit-ng--instances-ceiling (rule anchor-day completions start-day end-day today
                                             &optional skip-events vacation-ranges)
  "Maintenance-ceiling instances of RULE (D2: one cell per expected occurrence).
All carry `:model \\='ceiling'.  A RESET is a COMPLETION only (or ANCHOR-DAY at
the start of history) -- skips no longer reset, they PAUSE.  Each day in
COMPLETIONS within [START-DAY, END-DAY] emits a `satisfied' instance.  For each
consecutive reset pair (R1, R2) the natural-interval grid is walked from the
grace boundary next-occurrence(R1): every block (H, NH] that fully elapses
before R2 emits one instance -- `skipped' when the whole block is paused
\(`org-habit-ng--pause-day-p'), else `missed' (see
`org-habit-ng--ceiling-emit-blocks').  Grace is the first interval.  The live
stretch from the last completion (or ANCHOR-DAY) to TODAY emits its elapsed
blocks plus ONE `:ceiling-horizon' instance for the in-progress block: `:due'
is that block's opening boundary, vacation-hopped via
`org-habit-ng--ceiling-horizon' semantics so a habit is not shown overdue
mid-vacation; status `missed' when TODAY is past that boundary, else `pending'.
The live instance is emitted UNLESS the boundary is nil (series exhausted --
never `:due' nil).  A partially-paused block still counts `missed' (full
block-shifting for straddling pauses is a v1 non-goal); the vacation-hopping
horizon is used only for the live pending due, not for block counting.
SKIP-EVENTS/VACATION-RANGES pause the deficit clock."
  (let* ((comps (sort (copy-sequence completions) #'<))
         (result nil))
    ;; 1. Realized satisfied completions; window-filter to display.
    (dolist (c comps)
      (when (and (<= c end-day) (>= c start-day))
        (push (list :model 'ceiling :due c
                    :window-start c :window-end c
                    :completion c :status 'satisfied)
              result)))
    ;; 2. Closed stretches: per-interval blocks between consecutive completions.
    (let ((prev nil))
      (dolist (c comps)
        (when prev
          (org-habit-ng--ceiling-emit-blocks
           rule prev c start-day end-day skip-events vacation-ranges
           (lambda (block) (push block result))))
        (setq prev c)))
    ;; 3. Live stretch: elapsed blocks from the last completion (or ANCHOR-DAY)
    ;;    up to TODAY, plus the one live :ceiling-horizon instance.
    (let* ((last-reset (if comps (car (last comps)) anchor-day))
           (h (org-habit-ng--ceiling-emit-blocks
               rule last-reset (1+ today) start-day end-day
               skip-events vacation-ranges
               (lambda (block) (push block result)))))
      (when h
        (let ((live-due (org-habit-ng--skip-vacations rule last-reset h vacation-ranges)))
          (when live-due
            (push (list :model 'ceiling :ceiling-horizon t :due live-due
                        :window-start last-reset :window-end live-due
                        :completion nil
                        :status (if (> today live-due) 'missed 'pending))
                  result)))))
    (nreverse result)))

(defun org-habit-ng--rolling-emit-run (start end l start-day end-day
                                             skip-events vacation-ranges live-p emit-fn)
  "Partition rolling under-quota run [START, END] into L-active-day miss blocks.
The ACTIVE days of the run are the days in [START, END] that are NOT
`org-habit-ng--pause-day-p' -- paused days (skips/vacations) are subtracted from
the deficit clock (D2).  The active days are grouped into consecutive L-day
blocks; EMIT-FN is funcalled once per emitted block with a `missed' rolling
instance whose `:window-start'/`:window-end' are the block's first/last active
day and `:due' one less than `:window-start' (uniform with the pre-D2 stretch).
A CLOSED run (LIVE-P nil) emits only its floor(active / L) full blocks; a
trailing partial span shorter than L never completes a natural interval and is
dropped.  A LIVE run emits exactly one block marked `:live t' (excluded from
scoring): the trailing partial block when the active length is not a whole
multiple of L (or shorter than L), otherwise the final full block.  A LIVE run
whose every day is paused (no active days) still yields exactly one `:live'
instance -- `:status \\='skipped' (streak-neutral, matching how point/period
treat a vacationed obligation) spanning the whole run, `:suppressed-by' set to
`vacation' when any run day is in a vacation range else `skip' -- so a fully
vacationed rolling habit is never left with no live instance for the score's
`:current' to read.  Non-live blocks are window-filtered to overlap
[START-DAY, END-DAY]; the single live block is emitted unconditionally (never
window-filtered), mirroring the pre-D2 always-emitted live instance.
SKIP-EVENTS and VACATION-RANGES are as in `org-habit-ng--pause-day-p'.  The
return value is unspecified."
  (let* ((active (let (days (d start))
                   (while (<= d end)
                     (unless (org-habit-ng--pause-day-p d skip-events vacation-ranges)
                       (push d days))
                     (setq d (1+ d)))
                   (nreverse days)))
         (total (length active))
         (n-full (/ total l))
         (remainder (- total (* n-full l)))
         (blocks (if live-p (if (> remainder 0) (1+ n-full) n-full) n-full))
         (live-idx (when (and live-p (> blocks 0)) (1- blocks))))
    (if (and live-p (= blocks 0))
        ;; Fully-paused live run: no active days, so no miss block -- but a live
        ;; run must still surface exactly one `:live' instance.  Emit it as a
        ;; streak-neutral `skipped' block covering the whole run.
        (let ((cause (if (seq-find (lambda (d)
                                     (org-habit-ng--in-vacation-p d vacation-ranges))
                                   (number-sequence start end))
                         'vacation 'skip)))
          (funcall emit-fn
                   (list :model 'rolling :status 'skipped :due (1- start)
                         :window-start start :window-end end :completion nil
                         :suppressed-by cause :live t)))
      (dotimes (b blocks)
        (let* ((lo (* b l))
               (hi (min (1- total) (1- (+ lo l))))
               (ws (nth lo active))
               (we (nth hi active))
               (this-live (and live-idx (= b live-idx))))
          (when (or this-live (and (<= ws end-day) (>= we start-day)))
            (funcall emit-fn
                     (append (list :model 'rolling :status 'missed
                                   :due (1- ws)
                                   :window-start ws :window-end we
                                   :completion nil)
                             (when this-live (list :live t))))))))))

(defun org-habit-ng--instances-rolling (rule anchor-day completions start-day end-day today
                                             &optional skip-events vacation-ranges)
  "Rolling-window instances of RULE (F6) in [START-DAY, END-DAY].
All carry `:model \\='rolling'.  Day D is BINDING iff the K_eff-th
element of the descending counted chain over COMPLETIONS <= D
\(`org-habit-ng--rolling-counted-chain') exists -- call it `c_K(D)'; a
binding day is UNDER-QUOTA iff `D > c_K(D) + N - 1'.  Non-binding days
are grace and accumulate no history.  Emits:
1. one realized `satisfied' instance per distinct completion day
   \(window-filtered for display);
2. per maximal run of binding under-quota days that has ended, one `missed'
   instance per elapsed natural interval L = ceil(N / K_eff): the run's ACTIVE
   days (days that are NOT `org-habit-ng--pause-day-p') are partitioned into
   consecutive L-active-day blocks, floor(active / L) full blocks emitted, each
   with TRUE :window-start/:window-end and :due = 1- :window-start, window-
   filtered by overlap for display (see `org-habit-ng--rolling-emit-run');
3. for the live state at TODAY: binding+in-quota -> exactly ONE `pending' live
   instance with :due the horizon (c_K(today) + N - 1); binding+under-quota ->
   the ongoing run's elapsed L-blocks with EXACTLY ONE marked `:live t' (the
   trailing partial block, or the final full block when active is a whole
   multiple of L); non-binding (grace) -> ONE live instance at :due =
   ANCHOR-DAY + N - 1, `pending' while TODAY <= that day, else `missed' (the
   eternal-under-K mitigation).  A live instance whose :due lies past `:until'
   is suppressed, and an ongoing run past UNTIL is instead closed AT UNTIL
   \(:window-end clamped to until-day, no `:live') rather than left open.
Paused days (`org-habit-ng--pause-day-p': SKIP-EVENTS day-skips/ranges and
VACATION-RANGES) are subtracted from a run's active length before blocks are
counted, so a skip or vacation freezes the miss counter; window aging (`c_K')
stays calendar-based -- a completion still ages out of the trailing N-day window
during a pause, only the miss counter pauses."
  (let* ((n (plist-get rule :window))
         (k (or (plist-get rule :times) 1))
         (gap (or (plist-get rule :min-gap) 1))
         (k-eff (min k (1+ (/ (1- n) gap))))
         ;; L = ceil(N/k-eff), the natural miss interval; max 1 is defensive
         ;; (validation already forces it >= 1).
         (l (max 1 (/ (+ n k-eff -1) k-eff)))
         (cdays (delete-dups (sort (copy-sequence completions) #'<)))
         (until-day (org-habit-ng--occ-until-day rule))
         (scan-end (max end-day today))
         ;; One chain computation per distinct completion (not per day): the
         ;; snapshot at completion day C is valid for every D in
         ;; [C, next-completion - 1] since chain(D) only depends on
         ;; completions <= D, which is constant across that span.
         (chains (let (acc (seen nil))
                   (dolist (c cdays)
                     (push c seen)
                     (push (cons c (org-habit-ng--rolling-counted-chain rule seen)) acc))
                   (nreverse acc)))
         (onset (catch 'found
                  (dolist (pair chains)
                    (when (>= (length (cdr pair)) k-eff)
                      (throw 'found (car pair))))
                  nil))
         (result nil))
    ;; 1. Realized satisfied instances, window-filtered for display.
    (dolist (c cdays)
      (when (and (>= c start-day) (<= c end-day))
        (push (list :model 'rolling :status 'satisfied :due c
                    :window-start c :window-end c :completion c)
              result)))
    (cl-flet ((c-k-at (d)
                ;; c_K(D): the K_eff-th element of the latest chain snapshot
                ;; whose completion day is <= D, or nil (non-binding).
                (let ((best nil))
                  (dolist (pair chains)
                    (when (<= (car pair) d) (setq best pair)))
                  (when (and best (>= (length (cdr best)) k-eff))
                    (nth (1- k-eff) (cdr best))))))
      (if (or (null onset) (< today onset))
          ;; 2/3. GRACE at TODAY: never binding yet, so no history to close;
          ;; only the live instance (the eternal-under-K mitigation).
          (let* ((due (+ anchor-day n -1))
                 (status (if (<= today due) 'pending 'missed)))
            (when (or (null until-day) (<= due until-day))
              (push (list :model 'rolling :status status :due due
                          :window-start anchor-day :window-end due
                          :completion nil :live t)
                    result)))
        ;; BINDING at TODAY: day-scan from ONSET through SCAN-END, merging
        ;; under-quota runs into closed stretches; capture TODAY's own state
        ;; for the live instance.
        (let ((run-start nil) (d onset)
              (today-ck nil) (today-violate nil) (today-run-start nil))
          (while (<= d scan-end)
            (let* ((ck (c-k-at d))
                   (violate (> d (+ ck n -1))))
              (when (= d today) (setq today-ck ck today-violate violate))
              (if violate
                  (unless run-start (setq run-start d))
                (when run-start
                  ;; Closed run: partition its active days into L-blocks.
                  (org-habit-ng--rolling-emit-run
                   run-start (1- d) l start-day end-day
                   skip-events vacation-ranges nil
                   (lambda (block) (push block result)))
                  (setq run-start nil)))
              (when (= d today) (setq today-run-start run-start)))
            (setq d (1+ d)))
          ;; 3. The live run (multi-block under D2).
          (if today-violate
              (if (and until-day (> today until-day))
                  ;; UNTIL has passed: close the ongoing run there instead of
                  ;; leaving it live -- the obligation ends at UNTIL.
                  (org-habit-ng--rolling-emit-run
                   today-run-start (max today-run-start until-day)
                   l start-day end-day skip-events vacation-ranges nil
                   (lambda (block) (push block result)))
                (org-habit-ng--rolling-emit-run
                 today-run-start today l start-day end-day
                 skip-events vacation-ranges t
                 (lambda (block) (push block result))))
            (let ((horizon (+ today-ck n -1)))
              (when (or (null until-day) (<= horizon until-day))
                (push (list :model 'rolling :status 'pending :due horizon
                            :window-start today-ck :window-end horizon
                            :completion nil :live t)
                      result)))))))
    (nreverse result)))

(defun org-habit-ng-instances (rule anchor-day completions start-day end-day today
                                    &optional skip-events vacation-ranges)
  "Instances of RULE overlapping [START-DAY, END-DAY].
Each result is a plist (see the design doc for the shape).  ANCHOR-DAY is
the habit's SCHEDULED day.  COMPLETIONS and all day args are absolute day
numbers; TODAY is the reference \"now\" day.  Dispatches on success model:
rolling-window when RULE has :window (X-WINDOW=N, F6), period-bucket when
RULE has :period, the maintenance-ceiling walk when :ceiling is set
\(X-CEILING=t), the completion-anchored walk when X-REPEAT-FROM=completion
is set EXPLICITLY, else the deterministic point-window model.  Statuses are
meaningful for ACTIVE habits only (a terminated series stays DONE and is not
rendered)."
  (cond
   ;; Rolling window (X-WINDOW=N, F6): FIRST -- mutually exclusive with
   ;; :period (validator-enforced), but checked first so the read is obvious.
   ((plist-get rule :window)
    (org-habit-ng--instances-rolling rule anchor-day completions start-day end-day today
                                     skip-events vacation-ranges))
   ((plist-get rule :period)
    (org-habit-ng--instances-period rule anchor-day completions start-day end-day today
                                    skip-events vacation-ranges))
   ;; Maintenance ceiling (X-CEILING=t): BEFORE the completion arm.  A ceiling
   ;; auto-sets :repeat-from 'completion but must NOT flood per-period misses
   ;; like the completion-anchored walk -- an overrun is ONE stretch.
   ((plist-get rule :ceiling)
    (org-habit-ng--instances-ceiling rule anchor-day completions start-day end-day today
                                     skip-events vacation-ranges))
   ;; EXPLICIT X-REPEAT-FROM=completion only; absent defaults to the deterministic
   ;; point model for the graph (see plan Decision 2).  Note the known wart:
   ;; --build-habit-struct advertises ".+" (completion-anchored) for the same
   ;; absent default, so the org-fallback rendering path and this graph disagree
   ;; about what the default means for one and the same habit.
   ((eq (plist-get rule :repeat-from) 'completion)
    (org-habit-ng--instances-completion-anchored
     rule anchor-day completions start-day end-day today skip-events vacation-ranges))
   (t
    (org-habit-ng--instances-point rule anchor-day completions start-day end-day today
                                   skip-events vacation-ranges))))

(defun org-habit-ng-satisfied-p (instance)
  "Non-nil if INSTANCE's :status is `satisfied'."
  (eq (plist-get instance :status) 'satisfied))

(defun org-habit-ng-skipped-p (instance)
  "Non-nil if INSTANCE's :status is `skipped'.
Symmetric sibling of `org-habit-ng-satisfied-p', which stays nil for a
skipped instance."
  (eq (plist-get instance :status) 'skipped))

;;; F5: Forgiving Habit-Strength Score
;;
;; A pure read-side feature: folds `org-habit-ng-instances' into a single
;; frequency-aware exponentially-smoothed "strength" number (Loop Habit
;; Tracker's model, instance-indexed rather than day-indexed, so a weekly
;; habit is judged weekly and a daily habit daily with no FREQ-conditional
;; code).  Writes nothing.  See
;; docs/plans/2026-07-10-forgiving-score-design.md for the full design and
;; hand-traced examples.

(defcustom org-habit-ng-score-half-life 13
  "Number of scored instances over which the strength score half-decays.
Loop's choice; 13 instances is approximately a half-life.  Higher values
mean a longer memory (older verdicts matter more)."
  :type 'integer
  :group 'org-habit-ng)

(defcustom org-habit-ng-score-window-instances 52
  "Trailing scored-instance count the strength score folds over.
Approximately 4x the default half-life: older verdicts contribute under
~6% of the running weight and are dropped for speed.  Nil means fold over
all available scored history."
  :type '(choice (integer :tag "Trailing instance count")
                 (const :tag "Unbounded" nil))
  :group 'org-habit-ng)

(defcustom org-habit-ng-score-in-graph-echo t
  "When non-nil, fold a one-line strength summary into the graph help-echo.
The line is prepended to each cell's existing `help-echo' in
`org-habit-ng--render-graph'; set to nil to disable."
  :type 'boolean
  :group 'org-habit-ng)

(defun org-habit-ng--score-instance-value (inst)
  "The fold value v (a float in [0.0, 1.0]) for scored instance INST.
Dispatches on `(eq (plist-get inst :model) \\='period)' for period-quota
partial credit: `(min 1.0 (/ (float (length :counted)) :times-required))'.
`:counted' is a day LIST (not a count); the `float' call is mandatory --
Elisp integer division truncates, so `(/ 2 3)' is 0 and every partial
bucket would silently score as a full miss without it.  Every other model
scores 1.0 for a `satisfied' status and 0.0 for `missed'."
  (if (eq (plist-get inst :model) 'period)
      (min 1.0 (/ (float (length (plist-get inst :counted)))
                  (plist-get inst :times-required)))
    (if (eq (plist-get inst :status) 'satisfied) 1.0 0.0)))

(defun org-habit-ng--score-instance-time (inst)
  "Chronological ordering key for INST: `:due' if present, else `:end'.
Point / completion-anchored / ceiling / rolling instances carry `:due';
period instances carry `:end' instead."
  (or (plist-get inst :due) (plist-get inst :end)))

(defun org-habit-ng--score-scored-p (inst)
  "Non-nil when INST contributes a verdict to the score fold.
Selected iff `:status' is `satisfied' or `missed' AND INST is not the
current open `:live' or `:ceiling-horizon' instance.  Excludes
`pending'/`upcoming' (no verdict yet) and `skipped' (neutral by design)."
  (and (memq (plist-get inst :status) '(satisfied missed))
       (not (plist-get inst :live))
       (not (plist-get inst :ceiling-horizon))))

(defun org-habit-ng--score-lessp (a b)
  "Stable-sort predicate for scored instances A and B.
Earlier `org-habit-ng--score-instance-time' sorts first; AT EQUAL keys,
`satisfied' precedes `missed' (the rolling tie-break pin -- a completion
that closes an under-quota run can share its ordering key with the
stretch it closes, and the chronological truth is completion-then-lapse)."
  (let ((ta (org-habit-ng--score-instance-time a))
        (tb (org-habit-ng--score-instance-time b)))
    (cond ((< ta tb) t)
          ((> ta tb) nil)
          (t (and (eq (plist-get a :status) 'satisfied)
                  (eq (plist-get b :status) 'missed))))))

(defun org-habit-ng--score-fold (values half-life)
  "Seeded EWMA over VALUES (a chronological list of floats in [0.0, 1.0]).
d = 0.5^(1/HALF-LIFE).  score_1 = v_1 (the seed -- NOT 0 -- so an
all-satisfied stream is a true fixed point of the recurrence: d*1 +
1*(1-d) = 1).  score_i = score_{i-1}*d + v_i*(1-d) for i = 2..m.  Returns
the final float, or nil for an empty VALUES list (the empty-stream
pin: nil, never `0.0')."
  (when values
    (let ((d (expt 0.5 (/ 1.0 half-life)))
          (score (float (car values))))
      (dolist (v (cdr values))
        (setq score (+ (* score d) (* v (- 1.0 d)))))
      score)))

(defun org-habit-ng--score-streak (pairs)
  "Trailing `satisfied' run length in chronological PAIRS.
PAIRS is a list of (STATUS . VALUE).  0 when PAIRS is empty or ends
in `missed'."
  (let ((n 0))
    (catch 'org-habit-ng--score-streak-done
      (dolist (p (reverse pairs))
        (if (eq (car p) 'satisfied)
            (setq n (1+ n))
          (throw 'org-habit-ng--score-streak-done nil))))
    n))

(defun org-habit-ng--score-recovered-after (pairs)
  "Length of the closed miss-run preceding the trailing satisfied streak.
PAIRS is a chronological list of (STATUS . VALUE).  Nil when the
streak is empty (PAIRS ends in `missed', or is empty) or when the streak
reaches all the way back to the start of PAIRS (no closed run precedes
it -- an all-satisfied fold)."
  (let ((streak (org-habit-ng--score-streak pairs)))
    (when (> streak 0)
      (let ((rest (nbutlast (copy-sequence pairs) streak)))
        (when rest
          (let ((n 0))
            (catch 'org-habit-ng--score-recovered-after-done
              (dolist (p (reverse rest))
                (if (eq (car p) 'missed)
                    (setq n (1+ n))
                  (throw 'org-habit-ng--score-recovered-after-done nil))))
            (and (> n 0) n)))))))

(defun org-habit-ng--score-current-status (instances)
  "The `:current' field from raw (unfiltered, untrimmed) INSTANCES.
The status of the `:live'/`:ceiling-horizon' instance if one exists; else
`pending' if any `pending' instance exists; else nil."
  (let ((live (cl-find-if (lambda (i) (or (plist-get i :live)
                                          (plist-get i :ceiling-horizon)))
                          instances)))
    (cond (live (plist-get live :status))
          ((cl-some (lambda (i) (eq (plist-get i :status) 'pending)) instances)
           'pending)
          (t nil))))

(defun org-habit-ng-score (rule anchor-day completions start-day end-day today
                                &optional skip-events vacation-ranges)
  "Forgiving habit-strength score for RULE anchored at ANCHOR-DAY.
Same argument shape as `org-habit-ng-instances' (COMPLETIONS,
START-DAY, END-DAY, TODAY, SKIP-EVENTS, VACATION-RANGES).  Return a plist:
  :score            float in [0.0, 1.0], nil when no instance has a verdict
  :percent          integer round(:score * 100), or nil with :score
  :streak           trailing satisfied scored-instance count (0 when empty)
  :recovered-after  length of the last closed miss-run before the streak, nil
  :instances-scored number of instances contributing to :score (0 when empty)
  :current          live/earliest-pending instance status (pending/missed), nil
When :instances-scored is 0 (a brand-new habit, or a seasonal habit deep in
its off-season), :score and :percent are nil -- displays must suppress the
score line, never render \"0%\".
Pure: reads `org-habit-ng-instances', writes nothing."
  (let* ((instances (org-habit-ng-instances rule anchor-day completions
                                            start-day end-day today
                                            skip-events vacation-ranges))
         (scored (seq-filter #'org-habit-ng--score-scored-p instances))
         (sorted (cl-stable-sort (copy-sequence scored) #'org-habit-ng--score-lessp))
         (pairs (mapcar (lambda (i) (cons (plist-get i :status)
                                          (org-habit-ng--score-instance-value i)))
                        sorted))
         (window org-habit-ng-score-window-instances)
         (trimmed (if window (last pairs window) pairs))
         (values (mapcar #'cdr trimmed))
         (score (org-habit-ng--score-fold values org-habit-ng-score-half-life)))
    (list :score score
          :percent (and score (round (* score 100)))
          :streak (org-habit-ng--score-streak trimmed)
          :recovered-after (org-habit-ng--score-recovered-after trimmed)
          :instances-scored (length trimmed)
          :current (org-habit-ng--score-current-status instances))))

(defun org-habit-ng--score-summary-text (result count-mode)
  "Lowercase-first \"strength P% ...\" text from score RESULT plist.
COUNT-MODE controls the seed-honesty instance-count suffix: `below-10'
appends \"N instances\" only when :instances-scored is under 10 (the
graph help-echo's compact form); `always' always appends \"N instances
scored\" (the `org-habit-ng-show-score' verbose form).  Caller must have
already checked :instances-scored is positive."
  (let* ((percent (plist-get result :percent))
         (streak (plist-get result :streak))
         (recovered (plist-get result :recovered-after))
         (current (plist-get result :current))
         (n (plist-get result :instances-scored))
         (parts (list (format "strength %d%%" percent)
                      (format "streak %d" streak))))
    (when recovered
      (setq parts (append parts (list (format "recovered after %d" recovered)))))
    (when (eq current 'missed)
      (setq parts (append parts (list "overdue"))))
    (cond
     ((eq count-mode 'always)
      (setq parts (append parts (list (format "%d instance%s scored"
                                               n (if (= n 1) "" "s"))))))
     ((and (eq count-mode 'below-10) (< n 10))
      (setq parts (append parts (list (format "%d instance%s"
                                               n (if (= n 1) "" "s")))))))
    (mapconcat #'identity parts " · ")))

(defun org-habit-ng--score-echo-line (rule anchor-day completions start-day
                                           end-day today
                                           &optional skip-events vacation-ranges)
  "One-line \"Strength ...\" summary for RULE, or nil at m=0.
Computes `org-habit-ng-score' for RULE anchored at ANCHOR-DAY, over the
DISPLAY-BOUNDED COMPLETIONS already in hand (bounded by START-DAY,
END-DAY, TODAY, SKIP-EVENTS, and VACATION-RANGES) -- see the design
doc's windowing caveat: this is deliberately NOT a widened re-scan
\(unlike `org-habit-ng-show-score'), since widening here would change
the COMPLETIONS list feeding the renderer itself."
  (let* ((result (org-habit-ng-score rule anchor-day completions
                                     start-day end-day today
                                     skip-events vacation-ranges))
         (n (plist-get result :instances-scored)))
    (when (and n (> n 0))
      (let ((text (org-habit-ng--score-summary-text result 'below-10)))
        (concat (upcase (substring text 0 1)) (substring text 1))))))

(defun org-habit-ng--prepend-graph-echo (graph line)
  "Destructively prepend LINE + a newline to every cell's help-echo in GRAPH.
Per-cell prepend, never a whole-string `help-echo' wrap: the renderers
attach distinct per-cell echoes (dates, \"vacation\" reasons, quota
counts, period boundaries) that a blanket property would clobber."
  (dotimes (i (length graph))
    (let ((existing (get-text-property i 'help-echo graph)))
      (put-text-property i (1+ i) 'help-echo
                         (concat line "\n" (or existing ""))
                         graph)))
  graph)

;;;###autoload
(defun org-habit-ng-show-score ()
  "Message the strength-score breakdown for the habit at point.
Works from an org buffer or an agenda line, resolving the entry via the
same `org-marker' text-property pattern as
`org-habit-ng--get-rrule-from-agenda-marker'.  Re-scans the logbook with a
widened MAXDAYS (4x `org-habit-ng-score-window-instances') rather than the
display-bounded lookback the graph help-echo uses, so this command can
afford the full smoother-stable history -- see the design doc's windowing
section."
  (interactive)
  (let ((marker (or (get-text-property (point) 'org-marker)
                    (get-text-property (line-beginning-position) 'org-marker))))
    (if (and marker (marker-buffer marker))
        (with-current-buffer (marker-buffer marker)
          (save-excursion
            (goto-char marker)
            (org-habit-ng--show-score-at-point)))
      (org-habit-ng--show-score-at-point))))

(defun org-habit-ng--show-score-at-point ()
  "Message the strength-score breakdown for the habit heading at point."
  (save-excursion
    (org-back-to-heading t)
    (let ((rule (org-habit-ng--get-recurrence)))
      (if (not rule)
          (message "org-habit-ng: no RECURRENCE on this heading")
        (let ((anchor-day (let ((sch (org-get-scheduled-time (point))))
                            (and sch (time-to-days sch)))))
          (if (not anchor-day)
              (message "org-habit-ng: heading has no SCHEDULED date")
            (let* ((heading (org-no-properties (org-get-heading t t t t)))
                   (maxdays (* 4 (or org-habit-ng-score-window-instances 250)))
                   (completions (org-habit-ng--get-closed-dates maxdays))
                   (skip-events (org-habit-ng--read-state-safe
                                (lambda () (org-habit-ng-skip-dates maxdays))))
                   (vacation-ranges (org-habit-ng--read-state-safe
                                     #'org-habit-ng--vacation-ranges))
                   (today (time-to-days (org-current-effective-time)))
                   (start-day (if completions (apply #'min completions) anchor-day))
                   (end-day today)
                   (result (org-habit-ng-score rule anchor-day completions
                                               start-day end-day today
                                               skip-events vacation-ranges))
                   (n (plist-get result :instances-scored)))
              (if (or (null n) (= n 0))
                  (message "%s — no scored instances yet" heading)
                (message "%s — %s" heading
                         (org-habit-ng--score-summary-text result 'always))))))))))

;; Task 11: RRULE COUNT and UNTIL Termination

(defun org-habit-ng--parse-rrule-date (date-string)
  "Parse RRULE DATE-STRING (YYYYMMDD or YYYYMMDDTHHMMSS) to datetime.
If time component is omitted, defaults to 23:59:59 (end of day)."
  (if (string-match "^\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\(?:T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\)?$" date-string)
      (list :year (string-to-number (match-string 1 date-string))
            :month (string-to-number (match-string 2 date-string))
            :day (string-to-number (match-string 3 date-string))
            :hour (if (match-string 4 date-string)
                      (string-to-number (match-string 4 date-string))
                    23)
            :minute (if (match-string 5 date-string)
                        (string-to-number (match-string 5 date-string))
                      59)
            :second (if (match-string 6 date-string)
                        (string-to-number (match-string 6 date-string))
                      59))
    (error "Invalid RRULE date: %s" date-string)))

;; Task 13: X-WARN Extension

(defun org-habit-ng--parse-warn-days (warn-string)
  "Parse WARN-STRING into number of days.
Supports: Nd (days), Nw (weeks), Nh (hours)."
  (if (string-match "^\\([0-9]+\\)\\([dwh]\\)$" warn-string)
      (let ((n (string-to-number (match-string 1 warn-string)))
            (unit (match-string 2 warn-string)))
        (pcase unit
          ("d" n)
          ("w" (* n 7))
          ("h" (/ n 24.0))
          (_ n)))
    (error "Invalid warn duration: %s" warn-string)))

;; Task F9: X-FLEX-BEFORE/AFTER unit suffixes

(defun org-habit-ng--parse-flex-days (s)
  "Parse flex string S (\"3\", \"2d\", \"1w\") to an integer day count.
Unlike `org-habit-ng--parse-warn-days', the suffix here is OPTIONAL (a bare
integer means days) and only \"d\"/\"w\" are accepted -- no \"h\", no
fractional values.  Signals `user-error' (not `error') on a bad value so the
message surfaces cleanly at parse time rather than as an internal error."
  (if (string-match "\\`\\([0-9]+\\)\\([dw]\\)?\\'" s)
      (let ((n (string-to-number (match-string 1 s)))
            (unit (match-string 2 s)))
        (if (equal unit "w") (* n 7) n))
    (user-error "X-FLEX-BEFORE/AFTER must be an integer optionally suffixed with d or w (e.g. 3, 2d, 1w)")))

;;; RRULE Builder (Phase 2)

(defconst org-habit-ng--rrule-day-abbrevs
  ["SU" "MO" "TU" "WE" "TH" "FR" "SA"]
  "RRULE day abbreviations indexed by weekday number (0=Sunday).")

(defun org-habit-ng-build-rrule (rule)
  "Build RRULE string from RULE plist.
RULE can contain:
  :freq - daily, weekly, monthly, yearly
  :interval - repeat interval (default 1)
  :count - number of occurrences (emits COUNT)
  :until - datetime plist for the last occurrence (emits UNTIL as YYYYMMDD;
    time component ignored on emit)
  :byday - list of weekday numbers or (ordinal . weekday) pairs
  :bymonthday - list of day numbers
  :bysetpos - list of set positions (emits BYSETPOS; (1) = first, (-1) = last)
  :bymonth - list of month numbers
  :skip - backward, forward, or nearest: F8 weekend-shift mode for a
    MONTHLY/YEARLY BYMONTHDAY rule (emits X-SKIP)
  :repeat-from - completion, scheduled, or scheduled-future
  :flexibility - number of days (legacy, use :flex-after instead)
  :flex-before - days before due date in green window
  :flex-after - days after due date in green window
  :flex-before-raw - authored string (e.g. \"2w\") to emit verbatim in place
    of :flex-before's day count; nil means emit the bare day count
  :flex-after-raw - same as :flex-before-raw, for :flex-after
  :period - period mode: week or month (emits X-PERIOD)
  :wkst - week start day 0-6 (0=Sunday, only emitted when :period is set)
  :times - completions required per period (emits X-TIMES when > 1)
  :min-gap - minimum day-number spacing between counted completions within a
    period bucket (emits X-MIN-GAP; normalized day count, always >= 2)
  :ceiling - maintenance-ceiling mode (emits X-CEILING=t)
  :window - rolling-window mode: trailing day count N (emits X-WINDOW=N);
    :times/:min-gap are reused unchanged as the rolling quota K / spacing G
  :warn - approaching-alert band as a raw duration string (emits X-WARN)"
  (let ((parts nil))
    ;; FREQ (required)
    (push (format "FREQ=%s" (upcase (symbol-name (plist-get rule :freq)))) parts)
    ;; INTERVAL
    (let ((interval (or (plist-get rule :interval) 1)))
      (push (format "INTERVAL=%d" interval) parts))
    ;; COUNT (must be positive; COUNT=0 is invalid per RFC 5545)
    (when-let ((count (plist-get rule :count)))
      (when (> count 0)
        (push (format "COUNT=%d" count) parts)))
    ;; UNTIL (serialize the date portion as YYYYMMDD)
    ;; NOTE: wizard output is date-only; a datetime UNTIL round-trips lossily (rebuilt date-only, reparsed as 23:59:59) — follow-up, out of scope.
    (when-let ((until (plist-get rule :until)))
      (push (format "UNTIL=%04d%02d%02d"
                    (plist-get until :year)
                    (plist-get until :month)
                    (plist-get until :day))
            parts))
    ;; BYMONTH
    (when-let ((bymonth (plist-get rule :bymonth)))
      (push (format "BYMONTH=%s" (mapconcat #'number-to-string bymonth ",")) parts))
    ;; BYDAY
    (when-let ((byday (plist-get rule :byday)))
      (push (format "BYDAY=%s"
                    (mapconcat (lambda (d)
                                 (if (consp d)
                                     (format "%d%s" (car d) (aref org-habit-ng--rrule-day-abbrevs (cdr d)))
                                   (aref org-habit-ng--rrule-day-abbrevs d)))
                               byday ","))
            parts))
    ;; BYMONTHDAY
    (when-let ((bymonthday (plist-get rule :bymonthday)))
      (push (format "BYMONTHDAY=%s" (mapconcat #'number-to-string bymonthday ",")) parts))
    ;; BYSETPOS (parser reads it at :bysetpos as a numlist; without this branch
    ;; it silently drops on rebuild, breaking first-weekend and any BYSETPOS rule).
    (when-let ((bysetpos (plist-get rule :bysetpos)))
      (push (format "BYSETPOS=%s" (mapconcat #'number-to-string bysetpos ",")) parts))
    ;; X-SKIP (F8 nearest-weekday shift mode).  Without this branch the
    ;; field silently drops on any rebuild, including the wizard Modify path.
    (when-let ((skip (plist-get rule :skip)))
      (push (format "X-SKIP=%s" (upcase (symbol-name skip))) parts))
    ;; X-REPEAT-FROM
    (when-let ((repeat-from (plist-get rule :repeat-from)))
      (push (format "X-REPEAT-FROM=%s" repeat-from) parts))
    ;; X-WARN (stored as the raw duration string; emit verbatim).  Without this
    ;; branch any rebuild -- including the wizard modify-existing path --
    ;; silently drops X-WARN.
    (when-let ((warn (plist-get rule :warn)))
      (push (format "X-WARN=%s" warn) parts))
    ;; X-FLEXIBILITY (legacy, kept for backward compatibility)
    (when-let ((flexibility (plist-get rule :flexibility)))
      (push (format "X-FLEXIBILITY=%d" flexibility) parts))
    ;; X-FLEX-BEFORE (emits the authored raw form, e.g. "2w", when present;
    ;; otherwise the bare day count -- unchanged behavior)
    (when-let ((flex-before (plist-get rule :flex-before)))
      (when (> flex-before 0)
        (push (format "X-FLEX-BEFORE=%s"
                      (or (plist-get rule :flex-before-raw) flex-before))
              parts)))
    ;; X-FLEX-AFTER (same raw-form precedence as X-FLEX-BEFORE above)
    (when-let ((flex-after (plist-get rule :flex-after)))
      (when (> flex-after 0)
        (push (format "X-FLEX-AFTER=%s"
                      (or (plist-get rule :flex-after-raw) flex-after))
              parts)))
    ;; X-PERIOD
    (when-let ((period (plist-get rule :period)))
      (push (format "X-PERIOD=%s" (upcase (symbol-name period))) parts))
    ;; X-TIMES (omit when 1: default, keeps existing period habits byte-identical)
    (when-let ((times (plist-get rule :times)))
      (when (> times 1)
        (push (format "X-TIMES=%d" times) parts)))
    ;; X-MIN-GAP (min-spacing within the quota; emitted verbatim as a day count)
    (when-let ((gap (plist-get rule :min-gap)))
      (push (format "X-MIN-GAP=%d" gap) parts))
    ;; X-CEILING (maintenance-ceiling mode flag; boolean t)
    (when (plist-get rule :ceiling)
      (push "X-CEILING=t" parts))
    ;; X-WINDOW (rolling-window mode: trailing day count N)
    (when-let ((n (plist-get rule :window)))
      (push (format "X-WINDOW=%d" n) parts))
    ;; WKST (emit only when in period mode)
    (when-let ((wkst (plist-get rule :wkst)))
      (when (plist-get rule :period)
        (push (format "WKST=%s" (aref org-habit-ng--rrule-day-abbrevs wkst)) parts)))
    ;; Join in correct order
    (string-join (nreverse parts) ";")))

;;; Natural Language Generator (Phase 2)

(defconst org-habit-ng--weekday-names
  ["Sunday" "Monday" "Tuesday" "Wednesday" "Thursday" "Friday" "Saturday"]
  "Full weekday names indexed by number (0=Sunday).")

(defconst org-habit-ng--month-names
  ["" "January" "February" "March" "April" "May" "June"
   "July" "August" "September" "October" "November" "December"]
  "Full month names indexed by number (1=January).")

(defconst org-habit-ng--ordinal-names
  '((1 . "1st") (2 . "2nd") (3 . "3rd") (4 . "4th") (5 . "5th")
    (-1 . "last") (-2 . "2nd to last"))
  "Ordinal number names for human output.")

(defun org-habit-ng--human-termination-suffix (base rule)
  "Append COUNT/UNTIL termination text to BASE for RULE.
Returns BASE unchanged if RULE has neither :count nor :until."
  (let ((count (plist-get rule :count))
        (until (plist-get rule :until)))
    (cond
     (count
      (if (> (or (plist-get rule :times) 1) 1)
          (concat base (format ", %d completions total" count))
        (concat base (format ", %d time%s"
                             count (if (= count 1) "" "s")))))
     (until
      (concat base (format " until %04d-%02d-%02d"
                            (plist-get until :year)
                            (plist-get until :month)
                            (plist-get until :day))))
     (t base))))

(defun org-habit-ng--ceiling-to-human (freq interval)
  "Human phrase \"At least every N unit(s)\" for a ceiling FREQ/INTERVAL."
  (let ((unit (pcase freq
                ('daily "day") ('weekly "week")
                ('monthly "month") ('yearly "year") (_ "period"))))
    (if (= interval 1)
        (format "At least every %s" unit)
      (format "At least every %d %ss" interval unit))))

(defun org-habit-ng--format-season (bymonth)
  "Human season phrase for BYMONTH.
A contiguous run renders as \"April–October\" (including a cross-year
wrap like (11 12 1 2) -> \"November–February\"), else a comma list
\"April, June, August\"."
  (let* ((set (copy-sequence bymonth))
         (sorted (sort (copy-sequence set) #'<))
         ;; A rotation of 1..12 is contiguous iff, starting from some month and
         ;; stepping +1 (mod 12), we visit exactly the set.  Try each member as
         ;; the run start; a valid start has its predecessor month absent.
         (n (length sorted))
         (run-start
          (seq-find (lambda (m)
                      (not (memq (1+ (mod (+ (- m 2) 12) 12)) sorted)))
                    sorted))
         (contiguous
          (and run-start
               (let ((ok t) (cur run-start) (i 0))
                 (while (and ok (< i n))
                   (unless (memq cur sorted) (setq ok nil))
                   (setq cur (1+ (mod cur 12)))   ; next month, 12 -> 1
                   (setq i (1+ i)))
                 ok))))
    (if (and contiguous (> n 1))
        (format "%s–%s"
                (aref org-habit-ng--month-names run-start)
                (aref org-habit-ng--month-names
                      (1+ (mod (+ run-start n -2) 12))))
      (mapconcat (lambda (m) (aref org-habit-ng--month-names m)) sorted ", "))))

(defun org-habit-ng--flex-human-unit (days raw)
  "Format DAYS as \"N day(s)\", or as \"N week(s)\" per RAW.
RAW is an authored \"Nw\" string when weeks apply.  RAW is nil for a
bare-integer or \"Nd\" authored value, in which case this always
renders in days regardless of DAYS's magnitude."
  (if (and raw (string-match-p "w\\'" raw))
      (let ((weeks (/ days 7)))
        (format "%d week%s" weeks (if (= weeks 1) "" "s")))
    (format "%d day%s" days (if (= days 1) "" "s"))))

(defun org-habit-ng--skip-human-suffix (rule)
  "Human-text suffix for RULE's F8 :skip mode, or \"\" when absent."
  (pcase (plist-get rule :skip)
    ('nearest ", shifted to the nearest weekday")
    ('backward ", shifted back to a weekday")
    ('forward ", shifted forward to a weekday")
    (_ "")))

(defun org-habit-ng--oxford-join (items)
  "Join ITEMS, a list of strings, with commas and an Oxford `and'.
Two items join as \"X and Y\"; three or more as \"X, Y, and Z\"."
  (pcase (length items)
    (0 "")
    (1 (car items))
    (2 (concat (car items) " and " (cadr items)))
    (_ (concat (mapconcat #'identity (butlast items) ", ")
               ", and " (car (last items))))))

(defun org-habit-ng--byday-ordinal-human (ord day)
  "Render an ordinal ORD and weekday DAY as \"<ordinal> <weekday>\".
ORD keys `org-habit-ng--ordinal-names'; DAY indexes
`org-habit-ng--weekday-names' (0=Sunday)."
  (concat (cdr (assoc ord org-habit-ng--ordinal-names)) " "
          (aref org-habit-ng--weekday-names day)))

(defun org-habit-ng--or-join (items)
  "Join ITEMS, a list of strings, with commas and a final `or'.
Two items join as \"X or Y\"; three or more as \"X, Y, or Z\"."
  (pcase (length items)
    (0 "")
    (1 (car items))
    (2 (concat (car items) " or " (cadr items)))
    (_ (concat (mapconcat #'identity (butlast items) ", ")
               ", or " (car (last items))))))

(defun org-habit-ng--falls-on-day-phrase (bymonthday)
  "Render BYMONTHDAY as a `that falls on ...' phrase for an intersection rule.
Positive days join as \"day N, M, or K\"; -1 renders as \"the last day\"
so the phrase stays grammatical.  Other negative values render as-is."
  (let ((day-strs (mapcar (lambda (d)
                            (if (= d -1) "the last day" (number-to-string d)))
                          bymonthday)))
    (if (memq -1 bymonthday)
        (concat "that falls on " (org-habit-ng--or-join day-strs))
      (concat "that falls on day " (org-habit-ng--or-join day-strs)))))

(defun org-habit-ng--bysetpos-byday-human (bysetpos byday)
  "Render BYSETPOS positions against a pooled plain-weekday BYDAY list.
BYSETPOS and BYDAY are lists of integers.  The engine pools every BYDAY
weekday, sorts the resulting dates, and picks the BYSETPOS-th of that
pool, so a single weekday reads \"<ordinal> <Weekday>\" while several
read \"<ordinal> of <weekday>, ... or <weekday>\".  The ordinal comes
from BYSETPOS.  Multiple BYSETPOS values Oxford-join their phrases.  The
leading \"the \" is supplied by the caller."
  (let* ((weekdays (mapcar (lambda (d) (aref org-habit-ng--weekday-names d))
                           byday))
         (single (= (length weekdays) 1))
         (sep (if single " " " of "))
         (weekday-phrase (if single
                             (car weekdays)
                           (org-habit-ng--or-join weekdays))))
    (org-habit-ng--oxford-join
     (mapcar (lambda (pos)
               (concat (cdr (assoc pos org-habit-ng--ordinal-names))
                       sep weekday-phrase))
             bysetpos))))

(defun org-habit-ng-rrule-to-human (rule)
  "Convert RULE plist to human-readable string."
  (let* ((period (plist-get rule :period))
         (freq (plist-get rule :freq))
         (interval (or (plist-get rule :interval) 1))
         (byday (plist-get rule :byday))
         (bymonthday (plist-get rule :bymonthday))
         (bysetpos (plist-get rule :bysetpos))
         (bymonth (plist-get rule :bymonth)))
    (if (plist-get rule :window)
        ;; Rolling-window mode (F6): placed first, before :ceiling/:period/point.
        (let* ((n (plist-get rule :window))
               (times (or (plist-get rule :times) 1))
               (gap (or (plist-get rule :min-gap) 1))
               (base (if (> times 1)
                         (format "%d times in any rolling %d days" times n)
                       (format "At least once in any rolling %d days" n))))
          (when (> gap 1)
            (setq base (format "%s, at least %d days apart" base gap)))
          (org-habit-ng--human-termination-suffix base rule))
    (if (plist-get rule :ceiling)
        (org-habit-ng--human-termination-suffix
         (org-habit-ng--ceiling-to-human freq interval) rule)
    (if period
        ;; Period-based mode
        (let* ((period-name (pcase period
                              ('week "week")
                              ('month "month")
                              (_ "period")))
               (times (or (plist-get rule :times) 1))
               (base (cond
                      ((and (= interval 1) (= times 1)) (format "Once per %s" period-name))
                      ((= interval 1) (format "%d times per %s" times period-name))
                      ((= times 1)    (format "Once per %d %ss" interval period-name))
                      (t (format "%d times per %d %ss" times interval period-name)))))
          ;; Spacing phrase (F7): X-MIN-GAP > 1 (absent normalizes to 1, no phrase).
          (let ((gap (or (plist-get rule :min-gap) 1)))
            (when (> gap 1)
              (setq base (format "%s, at least %d days apart" base gap))))
          ;; Season phrase (F4): seasonal period rules read their window too.
          (when (org-habit-ng--rule-seasonal-p rule)
            (setq base (concat base ", " (org-habit-ng--format-season bymonth))))
          (org-habit-ng--human-termination-suffix base rule))
      ;; Point-based mode (existing logic)
      (let ((base (org-habit-ng--rrule-freq-to-human freq interval)))
        ;; Add frequency-specific details
        (setq base
              (pcase freq
                ('daily base)
                ('weekly
                 (if byday
                     (concat base " on "
                             (org-habit-ng--format-weekdays byday))
                   base))
                ('monthly
                 (cond
                  ((and byday (consp (car byday)))
                   ;; 9.A: render every ORDINAL.WEEKDAY pair, not just the first.
                   (concat base " on the "
                           (org-habit-ng--oxford-join
                            (mapcar (lambda (entry)
                                      (org-habit-ng--byday-ordinal-human
                                       (car entry) (cdr entry)))
                                    byday))))
                  ((and byday (integerp (car byday)) bysetpos)
                   ;; 9.C: plain weekday list + BYSETPOS -> ordinal from BYSETPOS.
                   (concat base " on the "
                           (org-habit-ng--bysetpos-byday-human bysetpos byday)))
                  ((and byday (integerp (car byday)) bymonthday)
                   ;; RFC 5545 intersection (wednesteenth): weekday AND day-of-month.
                   (concat base " on the "
                           (org-habit-ng--or-join
                            (mapcar (lambda (d) (aref org-habit-ng--weekday-names d))
                                    byday))
                           " "
                           (org-habit-ng--falls-on-day-phrase bymonthday)))
                  (bymonthday
                   (let* ((d (car bymonthday))
                          (s (if (= d -1)
                                 (concat base " on the last day")
                               (concat base " on day " (number-to-string d)))))
                     ;; F8: append the X-SKIP phrase; the helper returns ""
                     ;; when :skip is absent, so non-skip rules are unchanged.
                     (concat s (org-habit-ng--skip-human-suffix rule))))
                  (t base)))
                ('yearly
                 (let ((month-str (when bymonth
                                    (aref org-habit-ng--month-names (car bymonth)))))
                   (cond
                    ((and bymonth byday (consp (car byday)))
                     ;; 9.A: render every ORDINAL.WEEKDAY pair, not just the first.
                     (concat base " in " month-str " on the "
                             (org-habit-ng--oxford-join
                              (mapcar (lambda (entry)
                                        (org-habit-ng--byday-ordinal-human
                                         (car entry) (cdr entry)))
                                      byday))))
                    ((and bymonth byday (integerp (car byday)) bysetpos)
                     ;; 9.C: plain weekday list + BYSETPOS -> ordinal from BYSETPOS.
                     (concat base " in " month-str " on the "
                             (org-habit-ng--bysetpos-byday-human bysetpos byday)))
                    ((and bymonth byday (integerp (car byday)) bymonthday)
                     ;; RFC 5545 intersection (yearly wednesteenth).
                     (concat base " in " month-str " on the "
                             (org-habit-ng--or-join
                              (mapcar (lambda (d) (aref org-habit-ng--weekday-names d))
                                      byday))
                             " "
                             (org-habit-ng--falls-on-day-phrase bymonthday)))
                    ;; F8: a yearly BYMONTHDAY rule only gets day-of-month text
                    ;; when :skip is set (validation requires BYMONTH here) --
                    ;; gating on :skip keeps every pre-existing non-skip yearly
                    ;; BYMONTH+BYMONTHDAY rule's human text byte-identical.
                    ((and bymonth bymonthday (plist-get rule :skip))
                     (let ((d (car bymonthday)))
                       (concat base " in " month-str
                               (if (= d -1)
                                   " on the last day"
                                 (concat " on day " (number-to-string d)))
                               (org-habit-ng--skip-human-suffix rule))))
                    (t base))))
                (_ base)))
        ;; Season phrase (F4): only for a seasonal point rule (never yearly).
        (when (org-habit-ng--rule-seasonal-p rule)
          (setq base (concat base ", " (org-habit-ng--format-season bymonth))))
        ;; Add flexibility suffix
        (let ((flex-before (plist-get rule :flex-before))
              (flex-before-raw (plist-get rule :flex-before-raw))
              (flex-after (plist-get rule :flex-after))
              (flex-after-raw (plist-get rule :flex-after-raw))
              (flexibility (plist-get rule :flexibility)))
          (cond
           ;; Both flex-before and flex-after specified
           ((and flex-before (> flex-before 0) flex-after (> flex-after 0))
            (setq base (concat base (format " (within %s before and %s after)"
                                            (org-habit-ng--flex-human-unit flex-before flex-before-raw)
                                            (org-habit-ng--flex-human-unit flex-after flex-after-raw)))))
           ;; Only flex-before specified
           ((and flex-before (> flex-before 0))
            (setq base (concat base (format " (within %s before)"
                                            (org-habit-ng--flex-human-unit flex-before flex-before-raw)))))
           ;; Only flex-after specified
           ((and flex-after (> flex-after 0))
            (setq base (concat base (format " (within %s after)"
                                            (org-habit-ng--flex-human-unit flex-after flex-after-raw)))))
           ;; Legacy :flexibility (symmetric window, no direction)
           ((and flexibility (> flexibility 0))
            (setq base (concat base (format " (within %d day%s)"
                                            flexibility (if (= flexibility 1) "" "s")))))))
        ;; Add termination suffix (COUNT / UNTIL)
        (setq base (org-habit-ng--human-termination-suffix base rule))
        base))))))

(defun org-habit-ng--rrule-freq-to-human (freq interval)
  "Convert FREQ and INTERVAL to human-readable base string."
  (let ((unit (pcase freq
                ('daily "day")
                ('weekly "week")
                ('monthly "month")
                ('yearly "year")
                (_ "period"))))
    (if (= interval 1)
        (format "Every %s" unit)
      (format "Every %d %ss" interval unit))))

(defun org-habit-ng--format-weekdays (byday)
  "Format BYDAY list as human-readable weekday names."
  (mapconcat (lambda (d)
               (aref org-habit-ng--weekday-names d))
             byday ", "))

;;; Next Occurrences Helper (Phase 2)

(defun org-habit-ng-next-n-occurrences (rule anchor-day n)
  "The next N occurrence DAYS of RULE strictly after ANCHOR-DAY (ascending).
Stops early if the series is exhausted."
  (let ((result nil) (after anchor-day) (i 0) (done nil))
    (while (and (< i n) (not done))
      (let ((nxt (org-habit-ng-next-occurrence rule anchor-day after)))
        (if nxt (progn (push nxt result) (setq after nxt))
          (setq done t)))
      (setq i (1+ i)))
    (nreverse result)))

(defun org-habit-ng--format-occurrence (day)
  "Format absolute DAY number as a human-readable date string."
  (let ((greg (org-habit-ng--day-to-gregorian day)))
    (format-time-string "%a %b %d, %Y"
                        (encode-time 0 0 0 (nth 1 greg) (nth 0 greg) (nth 2 greg)))))

;;; Wizard State Management

(defconst org-habit-ng--author-models
  '((point   "On a schedule"                     "e.g. every Monday, or the 2nd Tuesday")
    (period  "So many times each period"         "e.g. 3 times each week")
    (ceiling "At least every so often"           "e.g. at least every 5 days")
    (rolling "So many times in a rolling window" "e.g. 3 times in any 7 days"))
  "Single source of truth for success-model display names.
Each entry is (MODEL NAME EXAMPLE): MODEL is a canonical `:model' symbol,
NAME is the intent-first human name shown on status surfaces, and EXAMPLE is
a short static illustration shown (joined to NAME) on chooser surfaces.  The
list order — point, period, ceiling, rolling — is the display order used by
every surface.  Every model-name-bearing code surface derives from this via
`org-habit-ng--author-model-name', `org-habit-ng--author-model-example', and
`org-habit-ng--author-model-name-and-example'; the manual is the one copy
kept in sync by hand.")

(defun org-habit-ng--author-model-name (model)
  "Return the human name string for success MODEL.
MODEL is a canonical `:model' symbol; an unknown MODEL falls back to its
printed symbol.  Looks the name up in `org-habit-ng--author-models'."
  (or (nth 1 (assq model org-habit-ng--author-models))
      (format "%s" model)))

(defun org-habit-ng--author-model-example (model)
  "Return the static example string for success MODEL.
MODEL is a canonical `:model' symbol; an unknown MODEL yields an empty
string.  Looks the example up in `org-habit-ng--author-models'."
  (or (nth 2 (assq model org-habit-ng--author-models)) ""))

(defun org-habit-ng--author-model-name-and-example (model)
  "Return \"NAME — EXAMPLE\" for success MODEL, for chooser surfaces.
Joins `org-habit-ng--author-model-name' and
`org-habit-ng--author-model-example' with a space-em-dash-space separator."
  (format "%s — %s"
          (org-habit-ng--author-model-name model)
          (org-habit-ng--author-model-example model)))

;;; Wizard Prompts (Completing-Read Fallback)

(defconst org-habit-ng--habit-type-options
  (mapcar (lambda (entry)
            (cons (org-habit-ng--author-model-name-and-example (car entry))
                  (car entry)))
          org-habit-ng--author-models)
  "Habit type options for the wizard, derived from `org-habit-ng--author-models'.
An ordered alist of (\"NAME — EXAMPLE\" . MODEL) consumed by
`org-habit-ng--wizard-prompt-habit-type' via `completing-read'; the chosen
string is mapped back to its MODEL symbol with `assoc'.")

(defun org-habit-ng--wizard-prompt-habit-type ()
  "Prompt user for habit type.  Return the chosen symbol."
  (let* ((choices (mapcar #'car org-habit-ng--habit-type-options))
         (choice (completing-read "What kind of habit? " choices nil t)))
    (cdr (assoc choice org-habit-ng--habit-type-options))))

(defun org-habit-ng--wizard-normalize-habit-type (value _state)
  "Normalize the habit-type step's raw VALUE.
An unmatched answer (`assoc' failure in the prompt function) reads as nil;
the three-branch flow this replaces falls through nil to the point branch,
so nil normalizes to `point' to preserve that behavior exactly (see
`test-wizard-golden-nil-habit-type-falls-through-to-point')."
  (or value 'point))

(defconst org-habit-ng--period-type-options
  '(("Weekly (once per Mon-Sun week)" . week)
    ("Monthly (once per calendar month)" . month))
  "Period type options for wizard.")

(defun org-habit-ng--wizard-prompt-period-type ()
  "Prompt for period type.  Return the chosen symbol."
  (let* ((choices (mapcar #'car org-habit-ng--period-type-options))
         (choice (completing-read "Period type: " choices nil t)))
    (cdr (assoc choice org-habit-ng--period-type-options))))

(defconst org-habit-ng--wkst-options
  '(("Monday (Mon-Sun weeks)" . 1)
    ("Sunday (Sun-Sat weeks)" . 0))
  "Week start options for wizard.")

(defun org-habit-ng--wizard-prompt-wkst ()
  "Prompt for week start day.  Return the chosen number."
  (let* ((choices (mapcar #'car org-habit-ng--wkst-options))
         (choice (completing-read "Week starts on: " choices nil t nil nil "Monday (Mon-Sun weeks)")))
    (cdr (assoc choice org-habit-ng--wkst-options))))

(defun org-habit-ng--wizard-prompt-times ()
  "Prompt for completions required per period.  Return a positive integer."
  (max 1 (read-number "How many times per period? " 1)))

(defun org-habit-ng--wizard-normalize-times (n state)
  "Normalize a raw `times' answer N for wizard STATE.
1 means no quota (nil), matching `(when (> times 1) ...)' in the original
flow.  For the rolling habit type, clamp K to the window size (X-WINDOW),
with a `message', mirroring F6's K_eff philosophy: an over-quota answer
would otherwise `user-error' at `org-habit-ng--wizard-finish's round-trip
validation."
  (let ((n (if (eq (plist-get state :model) 'rolling)
               (let ((window (plist-get state :window)))
                 (if (and window (> n window))
                     (progn
                       (message "org-habit-ng: clamping times to window size %d" window)
                       window)
                   n))
             n)))
    (if (> n 1) n nil)))

(defun org-habit-ng--wizard-prompt-min-gap ()
  "Prompt for an optional minimum gap between counted completions.
Return a normalized day count (>= 2) or nil for no spacing.  Reuses the F9
d/w unit parser; a blank answer or a value below 2 means no spacing."
  (let ((s (string-trim
            (read-string "Minimum days between counted completions? (e.g. 2 or 1w, blank = none) "))))
    (if (string-empty-p s) nil
      (let ((g (org-habit-ng--parse-flex-days s)))
        (and (>= g 2) g)))))

(defconst org-habit-ng--ceiling-unit-options
  '(("Days" . daily) ("Weeks" . weekly) ("Months" . monthly) ("Years" . yearly))
  "Gap-unit options for the maintenance-ceiling wizard flow.")

(defun org-habit-ng--wizard-prompt-ceiling-unit ()
  "Prompt for the ceiling gap unit.  Return a FREQ symbol."
  (let* ((choices (mapcar #'car org-habit-ng--ceiling-unit-options))
         (choice (completing-read "At least how often? Every N: " choices nil t)))
    (cdr (assoc choice org-habit-ng--ceiling-unit-options))))

(defun org-habit-ng--wizard-prompt-warn ()
  "Prompt for an optional approaching-alert band (X-WARN).
Return a raw duration string like \"3d\" or \"1w\", or nil for none.  Steers to
day/week units; hours give a fractional band the day-cell graph cannot use."
  (let ((s (string-trim
            (read-string "Warn how long before the horizon? (e.g. 3d or 1w, blank = none) "))))
    (if (string-empty-p s) nil s)))

(defconst org-habit-ng--frequency-options
  '(("Daily" . daily)
    ("Weekly" . weekly)
    ("Monthly" . monthly)
    ("Yearly" . yearly)
    ("Custom RRULE..." . custom))
  "Frequency options for wizard.")

(defun org-habit-ng--wizard-prompt-frequency ()
  "Prompt user for recurrence frequency.  Return the chosen symbol."
  (let* ((choices (mapcar #'car org-habit-ng--frequency-options))
         (choice (completing-read "How often does this repeat? " choices nil t)))
    (cdr (assoc choice org-habit-ng--frequency-options))))

(defun org-habit-ng--wizard-read-frequency (_state)
  "Compound reader for the `frequency' step.
Wraps `org-habit-ng--wizard-prompt-frequency'; when the answer is the
\"Custom RRULE...\" escape, reads the raw RRULE string, previews and saves
it directly, then throws to end the wizard immediately (the custom-RRULE
flow is a terminal escape, not a step in the declarative flow)."
  (let ((freq (org-habit-ng--wizard-prompt-frequency)))
    (if (eq freq 'custom)
        (let ((rrule (read-string "Enter RRULE: ")))
          (when (org-habit-ng--wizard-show-preview (org-habit-ng-parse-rrule rrule))
            (org-habit-ng--wizard-save-recurrence rrule))
          (throw 'org-habit-ng--wizard-terminal nil))
      freq)))

(defun org-habit-ng--wizard-prompt-interval (freq)
  "Prompt for interval based on FREQ.  Return the chosen number."
  (let ((unit (pcase freq
                ('daily "days")
                ('weekly "weeks")
                ('monthly "months")
                ('yearly "years")
                (_ "periods"))))
    (read-number (format "Every how many %s? " unit) 1)))

(defconst org-habit-ng--weekday-options
  '(("Sunday" . 0) ("Monday" . 1) ("Tuesday" . 2) ("Wednesday" . 3)
    ("Thursday" . 4) ("Friday" . 5) ("Saturday" . 6))
  "Weekday options for wizard.")

(defun org-habit-ng--wizard-prompt-weekdays ()
  "Prompt for weekday selection.  Return a sorted list of day numbers."
  (let* ((choices (mapcar #'car org-habit-ng--weekday-options))
         (selected (completing-read-multiple "Which days? " choices nil t)))
    (sort (mapcar (lambda (s) (cdr (assoc s org-habit-ng--weekday-options)))
                  selected)
          #'<)))

;;; Task 9: Monthly Type Selection

(defconst org-habit-ng--monthly-type-options
  '(("Specific day of month (e.g., the 15th)" . day-of-month)
    ("Nth weekday (e.g., 2nd Saturday)" . nth-weekday)
    ("Last specific weekday (e.g., last Friday)" . last-weekday)
    ("Last day of month" . last-day))
  "Monthly recurrence type options.")

(defun org-habit-ng--wizard-prompt-monthly-type ()
  "Prompt for monthly recurrence type.  Return the chosen symbol."
  (let* ((choices (mapcar #'car org-habit-ng--monthly-type-options))
         (choice (completing-read "Repeat on: " choices nil t)))
    (cdr (assoc choice org-habit-ng--monthly-type-options))))

;;; Task 10: Monthly Day Details

(defconst org-habit-ng--ordinal-options
  '(("First" . 1) ("Second" . 2) ("Third" . 3) ("Fourth" . 4) ("Fifth" . 5))
  "Ordinal options for nth weekday selection.")

(defun org-habit-ng--wizard-prompt-monthly-details (type)
  "Prompt for monthly details based on TYPE.  Return a partial rule plist."
  (pcase type
    ('day-of-month
     (let ((day (read-number "Which day of month? (1-31): " 1)))
       (list :bymonthday (list day))))
    ('nth-weekday
     (let ((day-choices (mapcar #'car org-habit-ng--weekday-options))
           (ord-choices (mapcar #'car org-habit-ng--ordinal-options))
           (byday nil)
           (again t))
       (while again
         (let* ((day-name (completing-read "Which day? " day-choices nil t))
                (day-num (cdr (assoc day-name org-habit-ng--weekday-options)))
                (ord-name (completing-read "Which occurrence? " ord-choices nil t))
                (ord-num (cdr (assoc ord-name org-habit-ng--ordinal-options))))
           ;; Reuse the panel's tested set toggle so both authoring paths
           ;; accumulate the exact same de-duplicated, sorted (ord . wd) set
           ;; and cannot diverge.
           (setq byday (org-habit-ng--author-toggle-ordinal byday ord-num day-num))
           (setq again (y-or-n-p "Add another ordinal weekday? "))))
       (list :byday byday)))
    ('last-weekday
     (let* ((day-choices (mapcar #'car org-habit-ng--weekday-options))
            (day-name (completing-read "Which day? " day-choices nil t))
            (day-num (cdr (assoc day-name org-habit-ng--weekday-options))))
       (list :byday (list (cons -1 day-num)))))
    ('last-day
     (list :bymonthday (list -1)))))

(defun org-habit-ng--wizard-read-monthly-details (_state)
  "Compound reader for the `monthly-details' step.
Wraps the existing type-then-details pair; runs for both point/monthly and
point/yearly (the yearly flow asks `yearly-month' first, then this same
compound question -- see the `monthly-details' step's `:when')."
  (let ((type (org-habit-ng--wizard-prompt-monthly-type)))
    (org-habit-ng--wizard-prompt-monthly-details type)))

;;; Task 11: Yearly Month Selection

(defconst org-habit-ng--month-options
  '(("January" . 1) ("February" . 2) ("March" . 3) ("April" . 4)
    ("May" . 5) ("June" . 6) ("July" . 7) ("August" . 8)
    ("September" . 9) ("October" . 10) ("November" . 11) ("December" . 12))
  "Month options for wizard.")

(defun org-habit-ng--wizard-prompt-month ()
  "Prompt for month selection.  Return the month number (1-12)."
  (let* ((choices (mapcar #'car org-habit-ng--month-options))
         (choice (completing-read "Which month? " choices nil t)))
    (cdr (assoc choice org-habit-ng--month-options))))

(defun org-habit-ng--wizard-prompt-months ()
  "Prompt for the active months of a seasonal habit.
Returns a sorted list of month numbers 1-12, or nil for an empty OR a full-12
selection (both meaning \"not seasonal\")."
  (let* ((names (completing-read-multiple
                 "Months active (comma-separated, empty = all): "
                 (mapcar #'car org-habit-ng--month-options) nil t))
         (months (sort (delq nil
                             (mapcar (lambda (n) (cdr (assoc n org-habit-ng--month-options)))
                                     names))
                       #'<)))
    (unless (or (null months) (= (length months) 12))
      months)))

;;; Task 12: Flexibility Window
(defun org-habit-ng--wizard-prompt-flex-before ()
  "Prompt for flex-before value.  Return a number or nil."
  (when (y-or-n-p "Allow completing early (before due date)? ")
    (read-number "How many days before due date count as on-time? " 1)))

(defun org-habit-ng--wizard-prompt-flex-after ()
  "Prompt for flex-after value.  Return a number or nil."
  (when (y-or-n-p "Allow completing late (after due date)? ")
    (read-number "How many days after due date count as on-time? " 1)))

;;; Task 13: Repeat From

(defconst org-habit-ng--repeat-from-options
  '(("Scheduled date" . scheduled)
    ("Scheduled date, but never in the past" . scheduled-future)
    ("Completion date" . completion))
  "Repeat-from options for wizard.")

(defun org-habit-ng--wizard-prompt-repeat-from ()
  "Prompt for repeat-from selection.  Return the chosen symbol."
  (let* ((prompt (concat "When you mark DONE, advance next occurrence from:\n\n"
                         "  Scheduled date\n"
                         "      If scheduled for Mon and you complete on Wed,\n"
                         "      next occurrence is calculated from Mon.\n\n"
                         "  Scheduled date, but never in the past\n"
                         "      Like scheduled, but if the result is overdue,\n"
                         "      advance forward until it lands today or later.\n\n"
                         "  Completion date\n"
                         "      If scheduled for Mon and you complete on Wed,\n"
                         "      next occurrence is calculated from Wed.\n\n"
                         "Advance from: "))
         (choices (mapcar #'car org-habit-ng--repeat-from-options))
         (choice (completing-read prompt choices nil t nil nil "Scheduled date")))
    (cdr (assoc choice org-habit-ng--repeat-from-options))))

;;; Task 13b: Termination (COUNT / UNTIL)

(defun org-habit-ng--wizard-read-until-date ()
  "Read an UNTIL date via `org-read-date'.  Return a YYYYMMDD string."
  (format-time-string
   "%Y%m%d"
   (org-read-date nil t nil "Stop recurring on or before date: ")))

(defconst org-habit-ng--termination-options
  '(("Runs forever (no end)" . none)
    ("Stop after N completions (COUNT)" . count)
    ("Stop on a date (UNTIL)" . until))
  "Termination options for wizard.")

(defconst org-habit-ng--termination-options-no-count
  '(("Runs forever (no end)" . none)
    ("Stop on a date (UNTIL)" . until))
  "Termination options for wizard habit types that hide COUNT.
Used by ceiling and rolling habit types.")

(defun org-habit-ng--wizard-prompt-termination (&optional options)
  "Prompt for a termination condition.
OPTIONS defaults to `org-habit-ng--termination-options'; ceiling mode passes
`org-habit-ng--termination-options-no-count' to hide COUNT.
Return a partial rule plist (`(:count N)' or `(:until DT)') or nil for none."
  (let* ((options (or options org-habit-ng--termination-options))
         (choices (mapcar #'car options))
         (choice (completing-read
                  "When should this habit stop recurring? "
                  choices nil t nil nil "Runs forever (no end)"))
         (kind (cdr (assoc choice options))))
    (pcase kind
      ('count (let ((n (read-number "Stop after how many times? " 30)))
                (when (> n 0) (list :count n))))
      ('until (list :until (org-habit-ng--parse-rrule-date
                            (org-habit-ng--wizard-read-until-date))))
      (_ nil))))

(defun org-habit-ng--wizard-termination-options-for (state)
  "Return the termination option alist applicable to wizard STATE.
Ceiling and rolling habit types hide COUNT (F3/F6 reject COUNT with
X-CEILING/X-WINDOW); point and period-based habits get the full list."
  (if (memq (plist-get state :model) '(ceiling rolling))
      org-habit-ng--termination-options-no-count
    org-habit-ng--termination-options))

(defun org-habit-ng--wizard-read-termination (state)
  "Compound reader for the `termination' step.
Wraps `org-habit-ng--wizard-prompt-termination', dispatching the option
list on STATE via `org-habit-ng--wizard-termination-options-for'."
  (org-habit-ng--wizard-prompt-termination
   (org-habit-ng--wizard-termination-options-for state)))

;;; Task 14: Preview Display

(defcustom org-habit-ng-preview-occurrences 6
  "Number of future occurrences to show in preview.
Wide enough that a just-added multi-component recurrence rule (e.g. several
BYDAY ordinals) surfaces the new component in the live preview rather than
falling past the end of the list."
  :type 'integer
  :group 'org-habit-ng)

(defun org-habit-ng--wizard-format-preview (rule)
  "Format RULE as preview string.
For a ceiling rule the meaningless \"Next N occurrences\" list is replaced with
the single horizon the habit would have if completed today.  A rolling
\(`:window') rule similarly has no due-date list -- its daily grid is not a
due-date list -- so it gets one line naming the engine-derived deadline if you
start today (delegated to `org-habit-ng--preview-rolling-deadline', the same
helper the panel preview uses)."
  (let* ((human (org-habit-ng-rrule-to-human rule))
         (rrule (org-habit-ng-build-rrule rule))
         (today (time-to-days (org-current-effective-time))))
    (cond
     ((plist-get rule :ceiling)
      (let* ((horizon (org-habit-ng-next-occurrence rule today today))
             (hstr (if horizon (org-habit-ng--format-occurrence horizon) "\u2014")))
        (concat "Recurrence Preview\n"
                (make-string 18 ?\u2500) "\n\n"
                "  " human "\n\n"
                "  RRULE: " rrule "\n\n"
                "  Horizon if completed today: " hstr "\n")))
     ((plist-get rule :window)
      (let* ((due (org-habit-ng--preview-rolling-deadline rule today))
             (bstr (if due (org-habit-ng--format-occurrence due) "\u2014")))
        (concat "Recurrence Preview\n"
                (make-string 18 ?\u2500) "\n\n"
                "  " human "\n\n"
                "  RRULE: " rrule "\n\n"
                "  Deadline if you start today: " bstr "\n")))
     (t
      (let* ((occurrences (org-habit-ng-next-n-occurrences rule today
                                                           org-habit-ng-preview-occurrences))
             (occ-strings (mapcar #'org-habit-ng--format-occurrence occurrences)))
        (concat "Recurrence Preview\n"
                (make-string 18 ?\u2500) "\n\n"
                "  " human "\n\n"
                "  RRULE: " rrule "\n\n"
                "  Next " (number-to-string org-habit-ng-preview-occurrences) " occurrences:\n"
                (mapconcat (lambda (s) (concat "    \u2022 " s)) occ-strings "\n")
                "\n"))))))

;;; Task 15: Save Recurrence Property

(defun org-habit-ng--wizard-save-recurrence (rrule-string)
  "Save RRULE-STRING to current org entry's RECURRENCE property."
  (org-entry-put nil org-habit-ng-recurrence-property rrule-string))

;;; Task 16: Main Wizard Flow (Completing-Read Version)

(defun org-habit-ng--plist-merge (base additions)
  "Merge ADDITIONS plist into BASE plist."
  (let ((result (copy-sequence base)))
    (while additions
      (setq result (plist-put result (car additions) (cadr additions)))
      (setq additions (cddr additions)))
    result))

(defconst org-habit-ng--wizard-preview-buffer "*org-habit-ng save preview*"
  "Buffer holding the `completing-read' walk's save-step preview.
Distinct from the author panel's persistent side-window companion
\(`org-habit-ng--author-preview-buffer'): this is a transient popup shown by
`org-habit-ng--wizard-show-preview' via `display-buffer' so the preview stays
VISIBLE while `read-char-choice' prompts (a bare `message' is clobbered by the
prompt), then torn down when the prompt returns.")

(defun org-habit-ng--wizard-show-preview (rule)
  "Show preview of RULE and prompt for confirmation.
Returns t if user confirms, nil to cancel.  \"Start over\" re-enters via
`org-habit-ng--wizard-run', which dispatches on the
`org-habit-ng-use-completing-read' option (the transient panel by default, the
`completing-read' walk when opted in).

Renders the preview into `org-habit-ng--wizard-preview-buffer' shown via
`display-buffer' so it stays on screen while the `read-char-choice' prompt runs
-- otherwise the prompt overwrites the echo area and the user decides blind --
then buries the buffer."
  (let ((buf (get-buffer-create org-habit-ng--wizard-preview-buffer))
        (preview (org-habit-ng--wizard-format-preview rule)))
    (with-current-buffer buf
      (let ((inhibit-read-only t))
        (erase-buffer)
        (insert preview)
        (insert "\n[y] Save  [n] Cancel  [e] Start over\n"))
      (goto-char (point-min)))
    (let ((win (display-buffer buf)))
      (unwind-protect
          (let ((response (read-char-choice "Save recurrence? [y/n/e]: " '(?y ?n ?e))))
            (pcase response
              (?y t)
              (?n nil)
              (?e (org-habit-ng--wizard-run) nil)))
        ;; Delete only the window `display-buffer' opened (never a pre-existing
        ;; user window that happened to show this buffer), mirroring the author
        ;; panel's `org-habit-ng--author-preview-teardown' guard.
        (when (window-live-p win)
          (delete-window win))
        (when (buffer-live-p buf)
          (bury-buffer buf))))))

;;; F11: Wizard Unification -- declarative step list + engine
;;;
;;; `org-habit-ng--wizard-steps' is the single source of truth for every
;;; wizard question: prompt text, answer type, options, state key,
;;; applicability (`:when'), normalization, and default.  Both renderers
;;; (completing-read and the generic transient prefix) walk this list;
;;; every branch decision -- point/period/ceiling/rolling dispatch, the
;;; yearly BYMONTH-bypass, termination's COUNT-hiding -- is encoded exactly
;;; once, as a `:when' or an `:options' function, instead of being
;;; duplicated across an imperative flow and a second set of static UI
;;; prefixes.
;;;
;;; Habit types: point-based (P), period-based (B, "bucket"), ceiling (C),
;;; rolling (R, X-WINDOW) -- all four reachable from both renderers via the
;;; `habit-type' step's fourth option.

(defun org-habit-ng--season-applicable-p (state)
  "Non-nil when a season (BYMONTH activation) row is valid for STATE.
Single source for both the wizard's `months-active' step `:when' and the
authoring panel's season row (`org-habit-ng--author-season-applicable-p' is a
`defalias' to this).  A season is offered for a point or period model whose
FREQ is not yearly: it is meaningless for rolling/ceiling (BYMONTH+X-WINDOW and
BYMONTH+X-CEILING are rejected) and clobbers a yearly point habit's own BYMONTH
\(set by the yearly submenu).  Period models never carry a yearly FREQ, so this
matches the historical `(point & not-yearly) or period' step predicate exactly."
  (and (not (memq (plist-get state :model) '(rolling ceiling)))
       (not (eq (plist-get state :freq) 'yearly))))

(defconst org-habit-ng--wizard-steps
  (list
   ;; 1. Habit type -- all flows.  An unmatched answer (`assoc' failure)
   ;; reads nil; :normalize maps that to point, preserving the
   ;; current three-branch dispatch's fall-through exactly.
   (list :key 'habit-type :type 'choice
         :prompt "What kind of habit? "
         :options 'org-habit-ng--habit-type-options
         :transient-keys '("p" "e" "l" "r")
         :state-key :model
         :rule-key nil
         :read (lambda (_st) (org-habit-ng--wizard-prompt-habit-type))
         :normalize 'org-habit-ng--wizard-normalize-habit-type)

   ;; 2. Period type -- period-based only.
   (list :key 'period-type :type 'choice
         :prompt "Period type: "
         :options 'org-habit-ng--period-type-options
         :state-key :period
         :when (lambda (st) (eq (plist-get st :model) 'period))
         :read (lambda (_st) (org-habit-ng--wizard-prompt-period-type)))

   ;; 2b. Constant companion: period type -> FREQ.
   (list :key 'period-freq :type 'constant
         :state-key :freq
         :when (lambda (st) (eq (plist-get st :model) 'period))
         :value (lambda (st) (if (eq (plist-get st :period) 'week) 'weekly 'monthly)))

   ;; 3. Frequency -- point-based only.  The "Custom RRULE..." answer is a
   ;; terminal escape handled inside the compound reader (throws to end
   ;; the wizard immediately; see `org-habit-ng--wizard-read-frequency').
   (list :key 'frequency :type 'choice
         :prompt "How often does this repeat? "
         :options 'org-habit-ng--frequency-options
         :transient-keys '("d" "w" "m" "y" "c")
         :state-key :freq
         :when (lambda (st) (eq (plist-get st :model) 'point))
         :read 'org-habit-ng--wizard-read-frequency)

   ;; 4. Ceiling gap unit -- ceiling only.
   (list :key 'ceiling-unit :type 'choice
         :prompt "At least how often? Every N: "
         :options 'org-habit-ng--ceiling-unit-options
         :transient-keys '("d" "w" "m" "y")
         :state-key :freq
         :when (lambda (st) (eq (plist-get st :model) 'ceiling))
         :read (lambda (_st) (org-habit-ng--wizard-prompt-ceiling-unit)))

   ;; 4b/4c. Constant companions: ceiling forces :ceiling t and
   ;; completion-anchoring.  (`org-habit-ng--wizard-state-to-rule' ALSO
   ;; hand-forces this -- see its residue -- so the contract holds even
   ;; for callers that build state by hand and never touch these steps.)
   (list :key 'ceiling-flag :type 'constant
         :state-key :ceiling
         :when (lambda (st) (eq (plist-get st :model) 'ceiling))
         :value (lambda (_st) t))
   (list :key 'ceiling-repeat-from :type 'constant
         :state-key :repeat-from
         :when (lambda (st) (eq (plist-get st :model) 'ceiling))
         :value (lambda (_st) 'completion))

   ;; 5. Rolling window length -- rolling only.  The one genuinely new
   ;; prompt.
   (list :key 'window :type 'number
         :prompt "In how many trailing days? "
         :state-key :window
         :when (lambda (st) (eq (plist-get st :model) 'rolling))
         :normalize (lambda (n _st) (max 2 n)))

   ;; 5b/5c. Constant companions: rolling emits FREQ=DAILY;INTERVAL=1
   ;; (`build-rrule' emits INTERVAL unconditionally; F6 rejects INTERVAL>1
   ;; with X-WINDOW, so the explicit 1 documents that constraint here).
   (list :key 'rolling-freq :type 'constant
         :state-key :freq
         :when (lambda (st) (eq (plist-get st :model) 'rolling))
         :value (lambda (_st) 'daily))
   (list :key 'rolling-interval :type 'constant
         :state-key :interval
         :when (lambda (st) (eq (plist-get st :model) 'rolling))
         :value (lambda (_st) 1))

   ;; 6. Interval -- point, period, ceiling (rolling's is the constant
   ;; above; F6 rejects INTERVAL>1 with X-WINDOW so rolling never prompts).
   (list :key 'interval :type 'number
         :prompt "Every how many %s? "
         :state-key :interval
         :when (lambda (st) (memq (plist-get st :model)
                                   '(point period ceiling)))
         :read (lambda (st) (org-habit-ng--wizard-prompt-interval (plist-get st :freq))))

   ;; 7. Week start -- period-based, weekly period only.
   (list :key 'wkst :type 'choice
         :prompt "Week starts on: "
         :options 'org-habit-ng--wkst-options
         :default "Monday (Mon-Sun weeks)"
         :state-key :wkst
         :when (lambda (st) (and (eq (plist-get st :model) 'period)
                                  (eq (plist-get st :period) 'week)))
         :read (lambda (_st) (org-habit-ng--wizard-prompt-wkst)))

   ;; 8. Times (quota) -- period-based and rolling.  1 means no quota.
   (list :key 'times :type 'number
         :prompt "How many times per period? "
         :state-key :times
         :when (lambda (st) (memq (plist-get st :model) '(period rolling)))
         :read (lambda (_st) (org-habit-ng--wizard-prompt-times))
         :normalize 'org-habit-ng--wizard-normalize-times)

   ;; 9. Min-gap -- period-based/rolling, only when a multi-visit quota
   ;; (post-normalize :times, i.e. >1) is in effect.
   (list :key 'min-gap :type 'string
         :prompt "Minimum days between counted completions? (e.g. 2 or 1w, blank = none) "
         :state-key :min-gap
         :when (lambda (st) (and (memq (plist-get st :model) '(period rolling))
                                  (let ((k (plist-get st :times))) (and k (> k 1)))))
         :read (lambda (_st) (org-habit-ng--wizard-prompt-min-gap)))

   ;; 10. Weekdays -- point-based, weekly only.
   ;; :normalize (sort) is idempotent on the already-sorted list
   ;; `org-habit-ng--wizard-prompt-weekdays' returns, so it is a no-op on
   ;; the completing-read path and the whole story on the transient
   ;; multi-select widget's toggle set, which never calls `:read'.
   (list :key 'weekdays :type 'multi-choice
         :prompt "Which days? "
         :options 'org-habit-ng--weekday-options
         ;; Sunday..Saturday, matching the old `--transient-weekdays' keys.
         :transient-keys '("U" "M" "T" "W" "R" "F" "S")
         :state-key :byday
         :when (lambda (st) (and (eq (plist-get st :model) 'point)
                                  (eq (plist-get st :freq) 'weekly)))
         :read (lambda (_st) (org-habit-ng--wizard-prompt-weekdays))
         :normalize (lambda (v _st) (sort (copy-sequence v) #'<)))

   ;; 11. Yearly month (occurrence shape, not seasonal) -- point-based,
   ;; yearly only.  Runs BEFORE monthly-details (the current flow's order:
   ;; pick the month, then the day-within-month shape).
   (list :key 'yearly-month :type 'choice
         :prompt "Which month? "
         :options 'org-habit-ng--month-options
         :state-key :bymonth
         :when (lambda (st) (and (eq (plist-get st :model) 'point)
                                  (eq (plist-get st :freq) 'yearly)))
         :read (lambda (_st) (org-habit-ng--wizard-prompt-month))
         :normalize (lambda (m _st) (list m)))

   ;; 12. Monthly details (day-of-month / nth-weekday / last-weekday /
   ;; last-day) -- point-based, monthly OR yearly.  Compound reader wraps
   ;; the existing monthly-type + monthly-details pair.
   (list :key 'monthly-details :type 'custom
         :prompt "Repeat on: "
         :merge-plist t
         :produces '(:byday :bymonthday)
         :when (lambda (st) (and (eq (plist-get st :model) 'point)
                                  (memq (plist-get st :freq) '(monthly yearly))))
         :read 'org-habit-ng--wizard-read-monthly-details)

   ;; 13. Months active (seasonal activation window) -- point-based EXCEPT
   ;; yearly (BYMONTH there is occurrence shape, not a season -- this
   ;; `:when' is the yearly bypass's ONE encoding), OR period-based.
   ;; :normalize mirrors the tail of `org-habit-ng--wizard-prompt-months'
   ;; (sort, then empty/full-12 -> nil) and is idempotent on its
   ;; already-normalized return value, so it is a no-op on the
   ;; completing-read path and the whole story on the transient
   ;; multi-select widget, which never calls `:read'.
   (list :key 'months-active :type 'multi-choice
         :prompt "Months active (comma-separated, empty = all): "
         :options 'org-habit-ng--month-options
         ;; Jan..Dec, matching the old `--transient-months' keys.
         :transient-keys '("1" "2" "3" "4" "5" "6" "7" "8" "9" "o" "n" "d")
         :state-key :bymonth
         :when #'org-habit-ng--season-applicable-p
         :read (lambda (_st) (org-habit-ng--wizard-prompt-months))
         :normalize (lambda (v _st)
                      (let ((months (sort (copy-sequence v) #'<)))
                        (unless (or (null months) (= (length months) 12)) months))))

   ;; 14. Warn band (X-WARN) -- ceiling and rolling.
   (list :key 'warn :type 'string
         :prompt "Warn how long before the horizon? (e.g. 3d or 1w, blank = none) "
         :state-key :warn
         :when (lambda (st) (memq (plist-get st :model) '(ceiling rolling)))
         :read (lambda (_st) (org-habit-ng--wizard-prompt-warn)))

   ;; 15/16. Asymmetric flexibility -- point-based only.
   (list :key 'flex-before :type 'yes-no-number
         :prompt "Allow completing early (before due date)? "
         :state-key :flex-before
         :when (lambda (st) (eq (plist-get st :model) 'point))
         :read (lambda (_st) (org-habit-ng--wizard-prompt-flex-before)))
   (list :key 'flex-after :type 'yes-no-number
         :prompt "Allow completing late (after due date)? "
         :state-key :flex-after
         :when (lambda (st) (eq (plist-get st :model) 'point))
         :read (lambda (_st) (org-habit-ng--wizard-prompt-flex-after)))

   ;; 17. Repeat-from -- point-based only (period forces `scheduled',
   ;; ceiling forces `completion' via their own constant companions;
   ;; rolling omits it entirely -- F6 dispatches on :window directly).
   (list :key 'repeat-from :type 'choice
         :prompt "Advance from: "
         :options 'org-habit-ng--repeat-from-options
         :transient-keys '("s" "f" "c")
         :default "Scheduled date"
         :state-key :repeat-from
         :when (lambda (st) (eq (plist-get st :model) 'point))
         :read (lambda (_st) (org-habit-ng--wizard-prompt-repeat-from)))
   (list :key 'repeat-from-period :type 'constant
         :state-key :repeat-from
         :when (lambda (st) (eq (plist-get st :model) 'period))
         :value (lambda (_st) 'scheduled))

   ;; 18. Termination (COUNT / UNTIL) -- all flows.  `:options' is a
   ;; function: ceiling and rolling hide COUNT (F3/F6 reject it).
   (list :key 'termination :type 'custom
         :prompt "When should this habit stop recurring? "
         :options 'org-habit-ng--wizard-termination-options-for
         :merge-plist t
         :produces '(:count :until)
         :read 'org-habit-ng--wizard-read-termination))
  "Single source of truth for the recurrence wizard.
Both renderers walk this list in order, asking each step whose `:when'
predicate passes (absent = always applicable) for the current state.  See
the F11 design doc (docs/plans/2026-07-10-wizard-unification-design.md)
for the full schema and the canonical per-flow step-order table this list
implements verbatim.")

(defun org-habit-ng--wizard-step-options (step state)
  "Resolve STEP's `:options' to a (LABEL . VALUE) alist for wizard STATE.
`:options' is either a symbol naming a defconst alist, or a symbol naming
a function `(state) -> alist' for state-dependent options (termination's
COUNT-hiding)."
  (let ((sym (plist-get step :options)))
    (cond
     ((null sym) nil)
     ((boundp sym) (symbol-value sym))
     ((fboundp sym) (funcall sym state))
     (t (error "Unresolvable :options %s in wizard step" sym)))))

(defun org-habit-ng--wizard-step-default (step state)
  "Resolve STEP's `:default' for wizard STATE.
The value is either a literal or a `(state) -> value' function."
  (let ((d (plist-get step :default)))
    (if (functionp d) (funcall d state) d)))

(defun org-habit-ng--wizard-step-applicable-p (step state)
  "Is STEP applicable given wizard STATE?
A step with no `:when' predicate is always applicable."
  (let ((pred (plist-get step :when)))
    (if pred (funcall pred state) t)))

(defun org-habit-ng--wizard-next-step (state &optional after-key)
  "Return the first step in `org-habit-ng--wizard-steps' applicable to STATE.
Searches after the step keyed AFTER-KEY (nil searches from the start).
Returns nil when no further step applies -- the wizard is done."
  (let ((steps org-habit-ng--wizard-steps))
    (when after-key
      (setq steps (cdr (cl-member after-key steps
                                   :key (lambda (s) (plist-get s :key))))))
    (seq-find (lambda (s) (org-habit-ng--wizard-step-applicable-p s state)) steps)))

(defun org-habit-ng--wizard-read-step (step state)
  "Read a raw (not yet normalized) answer for STEP given wizard STATE.
Uses the step's own `:read' function when present; otherwise a generic
reader keyed on `:type'.  `:read' is used by every step except the
`window' step, which exercises the generic `number' reader."
  (let ((reader (plist-get step :read)))
    (if reader
        (funcall reader state)
      (pcase (plist-get step :type)
        ('constant (funcall (plist-get step :value) state))
        ('choice
         (let* ((options (org-habit-ng--wizard-step-options step state))
                (choices (mapcar #'car options))
                (default (org-habit-ng--wizard-step-default step state))
                (choice (completing-read (plist-get step :prompt) choices nil t nil nil default)))
           (cdr (assoc choice options))))
        ('multi-choice
         (let* ((options (org-habit-ng--wizard-step-options step state))
                (choices (mapcar #'car options))
                (selected (completing-read-multiple (plist-get step :prompt) choices nil t)))
           (delq nil (mapcar (lambda (s) (cdr (assoc s options))) selected))))
        ('number
         (read-number (plist-get step :prompt)
                       (or (org-habit-ng--wizard-step-default step state) 0)))
        ('string (read-string (plist-get step :prompt)))
        ('date
         (format-time-string "%Y%m%d" (org-read-date nil t nil (plist-get step :prompt))))
        ('yes-no-number
         (when (y-or-n-p (plist-get step :prompt)) (read-number "How many? " 1)))
        (_ (error "Wizard step %s has no reader"
                  (plist-get step :key)))))))

(defun org-habit-ng--wizard-record (step value state)
  "Normalize VALUE per STEP, then store it into wizard STATE.
Return the new state.  A `:merge-plist' step's VALUE is a partial rule
plist merged into STATE (monthly-details' :byday/:bymonthday, termination's
:count/:until); other steps store VALUE at `:state-key'."
  (let* ((normalize (plist-get step :normalize))
         (value (if normalize (funcall normalize value state) value)))
    (if (plist-get step :merge-plist)
        (org-habit-ng--plist-merge state value)
      (let ((key (plist-get step :state-key)))
        (if key (plist-put state key value) state)))))

(defconst org-habit-ng--wizard-state-to-rule-mapping
  '((:freq . :freq) (:interval . :interval) (:byday . :byday)
    (:bymonthday . :bymonthday) (:bymonth . :bymonth)
    (:flexibility . :flexibility)      ; legacy: no step writes this key;
                                        ; kept for hand-built state (see
                                        ; design doc rationale)
    (:repeat-from . :repeat-from) (:period . :period) (:wkst . :wkst)
    (:flex-before . :flex-before) (:flex-after . :flex-after)
    (:count . :count) (:until . :until) (:times . :times)
    (:min-gap . :min-gap) (:warn . :warn) (:window . :window))
  "Map wizard STATE keys to rule keys, identity in every case.
Preserves the exact order the original hand-written
`org-habit-ng--wizard-state-to-rule' emitted them.
Load-bearing: `test-wizard-state-to-rule' asserts an exact `equal' on the
returned plist, so this order is not cosmetic.  Conceptually this is the
union of `:state-key' and `:produces' across `org-habit-ng--wizard-steps'
\(see `test-wizard-state-to-rule-mapping-covers-step-keys'), plus the
legacy `:flexibility' residue no step writes.  `:ceiling' and `:model'
are deliberately absent: `:model' is state-only (never emitted) and
`:ceiling' is emitted only by the hand-written residue below, together
with the :repeat-from forcing, so the contract holds even for a caller
that builds state by hand and never runs the ceiling steps.")

(defun org-habit-ng--wizard-state-create ()
  "Create initial wizard state.
Every step's `:state-key' and `:produces' key starts nil, plus the fixed
bookkeeping keys `:active'/`:step' and the legacy `:flexibility' key
\(kept in the key set only, so `test-wizard-state-init's key expectations
hold; no step ever writes it)."
  (let ((keys (delete-dups
               (append
                (list :flexibility)
                (delq nil (mapcar (lambda (s) (plist-get s :state-key))
                                   org-habit-ng--wizard-steps))
                (apply #'append
                       (delq nil (mapcar (lambda (s) (plist-get s :produces))
                                         org-habit-ng--wizard-steps)))))))
    (apply #'append (list :active t :step 1)
           (mapcar (lambda (k) (list k nil)) keys))))

(defun org-habit-ng--state-to-rule (state)
  "Convert authoring/wizard STATE to a rule plist for `org-habit-ng-build-rrule'.
Single source of truth for both the authoring panel and the recurrence wizard
\(the old `org-habit-ng--author-state-to-rule' and
`org-habit-ng--wizard-state-to-rule' are `defalias'es to it).  Dispatches on
`:model' -- one of `point', `period', `ceiling', `rolling' -- and within each
branch emits only the keys relevant to that model, dropping nil values via
`org-habit-ng--author-plist'.  Emitting keys present-only reproduces the two
historical per-renderer mappings byte-for-byte: panel-only keys (`:bysetpos',
`:skip') are nil in wizard state, wizard-only keys (`:wkst', `:warn') are nil in
panel state.  Never emits UI-only keys.

Three model-specific rules preserve behavior:
- period derives `:freq' from `:period' (`month' -> `monthly', else `weekly'),
  ignoring any `:freq' in STATE (both renderers set them consistently anyway);
- ceiling reads `:freq' from STATE (the panel deadline flow always carries
  daily, but the wizard ceiling flow lets the gap unit be weekly/monthly/yearly)
  and forces `:ceiling t' + `:repeat-from completion';
- period omits `:repeat-from': for a period rule the only legal value is
  `scheduled' and the parser normalizes an omitted token to `scheduled', so the
  emitted rule is byte-identical after parse (canonical no-token form)."
  (let ((model (plist-get state :model)))
    (pcase model
      ('point
       (org-habit-ng--author-plist
        :freq (plist-get state :freq) :interval (plist-get state :interval)
        :byday (plist-get state :byday) :bymonthday (plist-get state :bymonthday)
        :bymonth (plist-get state :bymonth) :bysetpos (plist-get state :bysetpos)
        :skip (plist-get state :skip) :wkst (plist-get state :wkst)
        :flex-before (plist-get state :flex-before)
        :flex-before-raw (plist-get state :flex-before-raw)
        :flex-after (plist-get state :flex-after)
        :flex-after-raw (plist-get state :flex-after-raw)
        :warn (plist-get state :warn)
        :count (plist-get state :count) :until (plist-get state :until)
        :repeat-from (or (plist-get state :repeat-from) 'scheduled)))
      ('period
       (org-habit-ng--author-plist
        :freq (if (eq (plist-get state :period) 'month) 'monthly 'weekly)
        :interval (plist-get state :interval)
        :period (plist-get state :period) :wkst (plist-get state :wkst)
        :times (plist-get state :times) :min-gap (plist-get state :min-gap)
        :bymonth (plist-get state :bymonth) :warn (plist-get state :warn)
        :count (plist-get state :count) :until (plist-get state :until)))
      ('ceiling
       (org-habit-ng--author-plist
        :freq (plist-get state :freq) :interval (plist-get state :interval)
        :ceiling t :repeat-from 'completion :warn (plist-get state :warn)
        :until (plist-get state :until)))
      ('rolling
       (org-habit-ng--author-plist
        :freq 'daily :interval (plist-get state :interval)
        :window (plist-get state :window)
        :times (plist-get state :times) :min-gap (plist-get state :min-gap)
        :warn (plist-get state :warn) :until (plist-get state :until)))
      (_ (error "Unknown success model %s" model)))))

(defalias 'org-habit-ng--wizard-state-to-rule 'org-habit-ng--state-to-rule
  "Compatibility alias for the unified `org-habit-ng--state-to-rule'.")

(defun org-habit-ng--wizard-finish (state)
  "Finish the wizard: build a rule from STATE and save it.
Validates the rule round-trips through the parser, previews it, and
saves on confirmation.  One finish path for both renderers (replaces
the three copies in the old `completing-read' branches and the
transient preview leaf).
The round-trip validation is a tripwire, not a behavior change: every
flow this step list can currently produce yields a valid rule, so it only
fires on a future step-list mistake."
  (let* ((rule (org-habit-ng--wizard-state-to-rule state))
         (rrule (org-habit-ng-build-rrule rule)))
    (org-habit-ng-parse-rrule rrule)
    (when (org-habit-ng--wizard-show-preview rule)
      (org-habit-ng--wizard-save-recurrence rrule))))

(defun org-habit-ng--wizard-run-completing-read ()
  "Run the recurrence wizard via `completing-read'.
A linear walk over `org-habit-ng--wizard-steps': read the next applicable
step, normalize-and-record its answer, repeat until no step applies, then
finish.  State is lexical here -- the global `org-habit-ng--wizard-state'
is never touched on this path.  The `frequency' step's custom-RRULE escape
throws `org-habit-ng--wizard-terminal' to end the wizard early; the catch
here is what makes that throw safe."
  (catch 'org-habit-ng--wizard-terminal
    (let ((state (org-habit-ng--wizard-state-create))
          (key nil))
      (let ((step (org-habit-ng--wizard-next-step state key)))
        (while step
          (let ((value (org-habit-ng--wizard-read-step step state)))
            (setq state (org-habit-ng--wizard-record step value state)))
          (setq key (plist-get step :key))
          (setq step (org-habit-ng--wizard-next-step state key))))
      (org-habit-ng--wizard-finish state))))

;;; Task 17: Entry Point Command

(defcustom org-habit-ng-use-completing-read nil
  "When non-nil, use `completing-read' instead of the transient panel.
When nil (the default) the authoring UI is the transient panel."
  :type 'boolean
  :group 'org-habit-ng)

;; Forward declaration: the authoring-panel state and its default/prefix are
;; defined further down (the F13 section); the Modify path and `--wizard-run'
;; seed them here.
(defvar org-habit-ng--author-state)

;; Modify-existing chooser: globals the transient's description + commands read.
;; They mirror the panel's existing global-state style (simpler than threading a
;; `:scope' through the prefix) and are set by `--wizard-handle-existing' right
;; before the transient is invoked.
(defvar org-habit-ng--modify-existing-rule nil
  "Parsed rule (nil if the RRULE failed to parse) for the modify chooser.")
(defvar org-habit-ng--modify-existing-rrule nil
  "Raw RRULE string being modified, for the modify chooser transient.")
(defvar org-habit-ng--modify-existing-human nil
  "Human sentence (or \"(invalid: ...)\") for the modify chooser transient.")

(defun org-habit-ng--parse-existing-rrule (rrule)
  "Parse RRULE for the modify chooser; return (RULE HUMAN PARSE-ERROR).
RULE is the parsed rule plist, or nil when RRULE fails to parse; HUMAN is the
human sentence, or the \"(invalid: ...)\" text on a parse error; PARSE-ERROR is
the error message string, or nil."
  (let* ((parse-error nil)
         (rule (condition-case err
                   (org-habit-ng-parse-rrule rrule)
                 (error (setq parse-error (error-message-string err)) nil)))
         (human (if parse-error
                    (format "(invalid: %s)" parse-error)
                  (org-habit-ng-rrule-to-human rule))))
    (list rule human parse-error)))

(defun org-habit-ng--modify-existing-description ()
  "Group heading for the modify chooser: the current recurrence + its RRULE.
Reads the `org-habit-ng--modify-existing-*' globals set by
`org-habit-ng--wizard-handle-existing'."
  (format "Current recurrence:\n  %s\n  RRULE: %s"
          org-habit-ng--modify-existing-human
          org-habit-ng--modify-existing-rrule))

(defun org-habit-ng--modify-existing-modify ()
  "Modify action: open the panel PRE-FILLED from the parsed rule.
Falls back to a FRESH `org-habit-ng--wizard-run' when the RRULE did not parse
\(no valid rule to seed from)."
  (interactive)
  (if (null org-habit-ng--modify-existing-rule)
      (org-habit-ng--wizard-run)
    (setq org-habit-ng--author-state
          (org-habit-ng--author-state-from-rule org-habit-ng--modify-existing-rule))
    (org-habit-ng--author-open-panel)))

(defun org-habit-ng--modify-existing-clear ()
  "Clear action: remove the recurrence property after a confirmation."
  (interactive)
  (when (y-or-n-p "Remove recurrence? ")
    (org-entry-delete nil org-habit-ng-recurrence-property)
    (message "Recurrence removed.")))

(defun org-habit-ng--modify-existing-cancel ()
  "Cancel action: leave the recurrence unchanged."
  (interactive)
  (message "Cancelled."))

(transient-define-prefix org-habit-ng--modify-existing-transient ()
  "Chooser for an existing habit's recurrence: modify, clear, or cancel.
The transient-first replacement for the old `completing-read' action prompt;
its heading shows the current recurrence (human sentence + RRULE)."
  [:description org-habit-ng--modify-existing-description
   ("m" "modify (open pre-filled panel)" org-habit-ng--modify-existing-modify)
   ("c" "clear (remove recurrence)" org-habit-ng--modify-existing-clear)
   ("q" "cancel" org-habit-ng--modify-existing-cancel)])

(defun org-habit-ng--wizard-handle-existing-completing-read (rrule)
  "Opt-in `completing-read' chooser for an existing RRULE (Modify/Clear/Cancel).
The behavior preserved from before the transient chooser; used when
`org-habit-ng-use-completing-read' is non-nil.  See
`org-habit-ng--wizard-handle-existing' for the semantics of each action."
  (pcase-let* ((`(,_rule ,human ,parse-error)
                (org-habit-ng--parse-existing-rrule rrule))
               (choice (completing-read
                  (format "Current recurrence:\n  %s\n  RRULE: %s\n\nAction: "
                          human rrule)
                  '("Modify" "Clear" "Cancel") nil t)))
    (pcase choice
      ("Modify"
       (cond
        ;; No valid state to seed from -- fall back to a fresh wizard run.
        (parse-error (org-habit-ng--wizard-run))
        ;; The completing-read walk builds its own fresh lexical state and
        ;; ignores the panel global, so this opt-in path opens NOT pre-filled.
        (t (org-habit-ng--wizard-run-completing-read))))
      ("Clear"
       (when (y-or-n-p "Remove recurrence? ")
         (org-entry-delete nil org-habit-ng-recurrence-property)
         (message "Recurrence removed.")))
      ("Cancel" (message "Cancelled.")))))

(defun org-habit-ng--wizard-handle-existing (rrule)
  "Handle existing RRULE - offer to modify, clear, or cancel.
By default a small transient chooser (`org-habit-ng--modify-existing-transient')
is shown; its Modify opens the authoring panel PRE-FILLED from the parsed rule
\(seeded via `org-habit-ng--author-state-from-rule', marked `:origin edit' so the
preview legend reads \"Editing ...\").  When `org-habit-ng-use-completing-read'
is non-nil the opt-in `completing-read' chooser runs instead; its Modify builds
its own fresh state and so opens NOT pre-filled.
If RRULE fails to parse (e.g. a legacy or otherwise invalid recurrence), the
chooser is still shown so the property can be cleared; Modify then falls back to
starting the wizard fresh since there is no valid state to seed it from (the
heading shows the \"(invalid: ...)\" text)."
  (if org-habit-ng-use-completing-read
      (org-habit-ng--wizard-handle-existing-completing-read rrule)
    (pcase-let ((`(,rule ,human ,_parse-error)
                 (org-habit-ng--parse-existing-rrule rrule)))
      (setq org-habit-ng--modify-existing-rrule rrule
            org-habit-ng--modify-existing-rule rule
            org-habit-ng--modify-existing-human human)
      (org-habit-ng--modify-existing-transient))))

(defun org-habit-ng--inactive-day-ts (day)
  "Inactive org timestamp string `[YYYY-MM-DD Dow]' for absolute DAY."
  (format-time-string (org-time-stamp-format nil t)
                      (org-habit-ng--occ-day-to-time day)))

(defun org-habit-ng--log-skip (line)
  "Insert LINE (a `- Skipped ...' note) into this heading's log location.
Resolves the target exactly as org's own log machinery does, via
`org-log-beginning' (honoring `org-log-into-drawer').  Point must be on/after
the heading."
  (save-excursion
    (org-back-to-heading t)
    (goto-char (org-log-beginning t))
    (insert line "\n")))

;;;###autoload
(defun org-habit-ng-skip ()
  "Mark the current habit's current instance/bucket as skipped.
Writes a `- Skipped [DATE]' log line (a `- Skipped [START]--[END]' bucket
range for period habits, bounds from `--period-bucket-bounds') and advances
SCHEDULED past exactly one occurrence WITHOUT counting a completion (the line
is not a `State \"DONE\"' line, so it is invisible to `--get-closed-dates').
Signals an error off a habit.  A mobile user can type the same line by hand."
  (interactive)
  (let ((rule (and (org-is-habit-p) (org-habit-ng--get-recurrence))))
    (unless (and rule (plist-get rule :freq))
      (user-error "Point is not on an org-habit-ng habit"))
    (let* ((now (org-current-effective-time))
           (line
            (if (plist-get rule :period)
                (let* ((sched (org-get-scheduled-time (point)))
                       (sday (and sched (time-to-days sched)))
                       (today (time-to-days now))
                       (bounds (org-habit-ng--period-bucket-bounds
                                rule (or sday today) today)))
                  (format "- Skipped %s--%s"
                          (org-habit-ng--inactive-day-ts (car bounds))
                          (org-habit-ng--inactive-day-ts (cdr bounds))))
              (format "- Skipped %s"
                      (format-time-string (org-time-stamp-format nil t) now)))))
      (org-habit-ng--log-skip line)
      (let* ((base-ts (org-entry-get nil "SCHEDULED"))
             (next-ts (org-habit-ng--compute-next-schedule-skip rule base-ts)))
        (if next-ts
            (progn
              (org-schedule nil next-ts)
              (message "org-habit-ng: skipped; SCHEDULED advanced to %s" next-ts))
          (message "org-habit-ng: skipped; series complete, SCHEDULED left unchanged"))))))

;;;###autoload
(defun org-habit-ng-set-recurrence ()
  "Set or modify the recurrence pattern for the current org heading.
Uses a progressive wizard to build an RRULE string."
  (interactive)
  (unless (derived-mode-p 'org-mode)
    (user-error "Not in org-mode"))
  (unless (org-at-heading-p)
    (org-back-to-heading t))
  ;; Check for existing recurrence
  (let ((existing (org-entry-get nil org-habit-ng-recurrence-property)))
    (if existing
        (org-habit-ng--wizard-handle-existing existing)
      ;; New recurrence
      (org-habit-ng--wizard-run))))

(defun org-habit-ng--wizard-run ()
  "Run the authoring UI: the transient panel, or the `completing-read' walk.
The panel is the default; `org-habit-ng-use-completing-read' opts into the
minibuffer walk instead."
  (if org-habit-ng-use-completing-read
      (org-habit-ng--wizard-run-completing-read)
    (progn (setq org-habit-ng--author-state (org-habit-ng--author-default-state))
           (org-habit-ng--author-open-panel))))

;;; F13: Authoring panel -- persistent transient + live preview.
;;;
;;; A single-panel transient over one working-state plist, evolution of the
;;; F11 wizard's per-step transient (docs/plans/2026-07-08-authoring-ux-port-plan.md).
;;; The pure core below (state, state->rule, preview, chips, :if gates) has no
;;; transient dependency; the prefix is thin glue over it.  Emits ONLY through
;;; `org-habit-ng-build-rrule' -- never its own RRULE.

(defvar org-habit-ng--author-state nil
  "Working-state plist for one run of the authoring panel `org-habit-ng-author'.
Rule-shaped schedule keys plus UI-only scratch (`:model', `:month-mode',
`:year-mode', `:pending-ord').  The panel keeps all fields at once, not a
one-step cursor.")

(defun org-habit-ng--author-plist (&rest kvs)
  "Build a plist from KVS keeping only keys whose value is non-nil."
  (let (out)
    (while kvs
      (let ((k (car kvs)) (v (cadr kvs)))
        (when v (push k out) (push v out)))
      (setq kvs (cddr kvs)))
    (nreverse out)))

(defconst org-habit-ng--author-slot-defaults
  '((:interval . 1) (:min-gap . 2) (:window . 7)
    (:flex-after . 0) (:flex-before . 0) (:times . 1))
  "Single source for per-slot fallback values in the authoring panel.
`org-habit-ng--author-default-state' stores these slots as nil, so each
setter's `read-number' default and each label builder's displayed value read
their fallback from here (via `org-habit-ng--author-default-for') rather than
duplicating a literal that could silently drift.  The period slot's nil->week
convention is a separate one (see `org-habit-ng--author-label-period').")

(defun org-habit-ng--author-default-for (slot)
  "Return the shared fallback value for authoring-panel SLOT.
Looks SLOT up in `org-habit-ng--author-slot-defaults'."
  (cdr (assq slot org-habit-ng--author-slot-defaults)))

(defun org-habit-ng--author-default-state ()
  "Return the initial authoring-panel working state: a due, daily habit."
  (list :model 'point :freq 'daily :interval 1
        :byday nil :bymonthday nil :bymonth nil :bysetpos nil :skip nil
        :times nil :period nil :min-gap nil :window nil
        :flex-before nil :flex-after nil :count nil :until nil
        :repeat-from 'scheduled :vacation nil
        :month-mode 'day :year-mode 'day :pending-ord 1))

(defalias 'org-habit-ng--author-state-to-rule 'org-habit-ng--state-to-rule
  "Compatibility alias for the unified `org-habit-ng--state-to-rule'.")

(defun org-habit-ng--author-state-from-rule (rule)
  "Build a canonical authoring state seeded from a parsed RULE plist.
Used by the \"Modify\" path (`org-habit-ng--wizard-handle-existing') to open
the panel pre-filled from an existing habit's recurrence.  Starts from
`org-habit-ng--author-default-state' (so every UI-scratch key is present with
its default), overlays every rule-bearing key the emitter reads, infers
`:model' from the rule's shape, and marks `:origin edit' so the preview legend
reads \"Editing ...\".

Model inference, in order: a truthy `:ceiling' is a ceiling; else a present
`:window' is a rolling window; else a present `:period' or `:times' is a
period / quota rule; else a point-and-window rule.  The keys copied are exactly
those `org-habit-ng--state-to-rule' reads per model, so the emitted RRULE
round-trips byte-for-byte (see `test-author-state-from-rule-round-trips')."
  (let ((state (org-habit-ng--author-default-state))
        (model (cond ((plist-get rule :ceiling) 'ceiling)
                     ((plist-get rule :window) 'rolling)
                     ((or (plist-get rule :period) (plist-get rule :times))
                      'period)
                     (t 'point))))
    (dolist (key '(:freq :interval :byday :bymonthday :bymonth :bysetpos
                   :skip :wkst :times :period :min-gap :window
                   :flex-before :flex-before-raw :flex-after :flex-after-raw
                   :warn :count :until :repeat-from))
      (setq state (plist-put state key (plist-get rule key))))
    (setq state (plist-put state :model model))
    (setq state (plist-put state :origin 'edit))
    state))

(defun org-habit-ng--preview-rolling-deadline (rule anchor)
  "Return the engine's rolling-window deadline day for RULE started at ANCHOR.
Assumes no completions; returns nil if none applies.  Delegates to the
authoritative `org-habit-ng--instances-rolling' (the same logic the graph uses)
and returns the live/pending instance's `:due' -- the last day still in-quota,
what the user is committing to -- so no preview re-derives the window math."
  (let* ((insts (org-habit-ng--instances-rolling rule anchor nil anchor anchor anchor))
         (live (or (seq-find (lambda (i) (plist-get i :live)) insts)
                   (seq-find (lambda (i) (eq (plist-get i :status) 'pending)) insts))))
    (and live (plist-get live :due))))

(defun org-habit-ng--author-preview-body (rule anchor n)
  "Preview BODY lines for RULE anchored at ANCHOR (absolute day), up to N dates.
Ceiling/rolling rules have no due-date list (mirrors `--wizard-format-preview'):
a ceiling shows its horizon-if-completed-today, a window its engine-derived
deadline-if-you-start-today (delegated to
`org-habit-ng--preview-rolling-deadline', the live rolling instance's :due)."
  (cond
   ((plist-get rule :ceiling)
    (let ((h (org-habit-ng-next-occurrence rule anchor anchor)))
      (format "  Horizon if completed today: %s"
              (if h (org-habit-ng--format-occurrence h) "\u2014"))))
   ((plist-get rule :window)
    (let ((due (org-habit-ng--preview-rolling-deadline rule anchor)))
      (format "  Deadline if you start today: %s"
              (if due (org-habit-ng--format-occurrence due) "—"))))
   (t
    (let ((occ (org-habit-ng-next-n-occurrences rule anchor n)))
      (if occ
          (mapconcat (lambda (d) (concat "  • " (org-habit-ng--format-occurrence d)))
                     occ "\n")
        "  (nothing recurs yet)")))))          ; soft advisory, port plan §8

(defun org-habit-ng--author-preview (rule anchor n)
  "Return the live-preview header for RULE anchored at ANCHOR, up to N dates.
The human sentence comes from `org-habit-ng-rrule-to-human', the body from
`org-habit-ng--author-preview-body' -- both authoritative engine calls."
  (concat (org-habit-ng-rrule-to-human rule) "\n\n"
          (org-habit-ng--author-preview-body rule anchor n)))

;;; F13 Task 9: the dedicated read-only side-window live preview.  The full
;;; multi-line preview (human sentence + dates) now lives in a magit-style side
;;; buffer instead of inside the transient menu; the menu's top `:description'
;;; keeps only a short legend header and centralizes the side-buffer sync.

(defconst org-habit-ng--author-preview-buffer "*org-habit-ng recurrence preview*"
  "Name of the read-only side buffer holding the authoring live preview.")

(define-derived-mode org-habit-ng-author-preview-mode special-mode "Habit-Preview"
  "Major mode for the org-habit-ng recurrence live-preview side buffer.
Derived from `special-mode', so the buffer is read-only; the authoring panel
writes its content under `inhibit-read-only'.")

(defun org-habit-ng--author-preview-legend (state)
  "Return the one-line preview legend for STATE.
For a fresh state (`:origin' absent or `new') returns
\"New habit · <model name>\" (a STATUS surface: model NAME only, no
example); for a state marked `:origin edit' returns \"Editing · <rrule-to-human
of the state's rule>\".  The model name comes from
`org-habit-ng--author-model-name'."
  (if (eq (plist-get state :origin) 'edit)
      (concat "Editing · "
              (org-habit-ng-rrule-to-human
               (org-habit-ng--author-state-to-rule state)))
    (let ((model (or (plist-get state :model) 'point)))
      (concat "New habit · " (org-habit-ng--author-model-name model)))))

(defun org-habit-ng--author-preview-render (state)
  "Return the full preview-buffer STRING for STATE.
Pure string builder (testable in --batch): the legend line, a blank line, the
RRULE string, then the `org-habit-ng--author-preview' output (human sentence +
dates) anchored at today with `org-habit-ng-preview-occurrences'.  The engine's
sentence conveys the success model on its own (the model-switch path seeds the
defining token), so no separate state-derived summary is emitted."
  (let* ((rule (org-habit-ng--author-state-to-rule state))
         (anchor (time-to-days (org-current-effective-time))))
    (concat (org-habit-ng--author-preview-legend state) "\n\n"
            (org-habit-ng-build-rrule rule) "\n\n"
            (org-habit-ng--author-preview rule anchor
                                          org-habit-ng-preview-occurrences))))

(defun org-habit-ng--author-preview-sync (state)
  "Get-or-create the preview buffer and fill it with STATE's render.
Puts the buffer in `org-habit-ng-author-preview-mode' and rewrites its content
under `inhibit-read-only'.  Returns the buffer.  Does NOT display it (kept
separate so the content path is testable without a window)."
  (let ((buf (get-buffer-create org-habit-ng--author-preview-buffer)))
    (with-current-buffer buf
      (unless (derived-mode-p 'org-habit-ng-author-preview-mode)
        (org-habit-ng-author-preview-mode))
      (let ((inhibit-read-only t))
        (erase-buffer)
        (insert (org-habit-ng--author-preview-render state))
        (goto-char (point-min))))
    buf))

(defun org-habit-ng--author-preview-show ()
  "Sync and display the preview buffer in a right-side window.
Uses an explicit `display-buffer-in-side-window' action so the user's
`display-buffer-alist' is left untouched."
  (display-buffer
   (org-habit-ng--author-preview-sync org-habit-ng--author-state)
   '(display-buffer-in-side-window
     (side . right)
     (window-width . 0.42)
     (window-parameters (no-delete-other-windows . t)))))

(defconst org-habit-ng--author-ordinal-labels
  '((1 . "1st") (2 . "2nd") (3 . "3rd") (4 . "4th") (-1 . "last"))
  "Ordinal labels for authoring-panel chips and menu rows.")

(defun org-habit-ng--author-toggle-ordinal (byday ord wd)
  "Toggle the (ORD . WD) pair in the ordinal set BYDAY; return the new set.
Sorted by ordinal (last=-1 sorts high) then weekday.  ORD is 1..4 or -1; WD is
0=Sun..6=Sat (`org-habit-ng--weekday-names' convention)."
  (let* ((pair (cons ord wd))
         (next (if (member pair byday) (remove pair byday)
                 (cons pair (copy-sequence byday)))))
    (sort next (lambda (a b)
                 (if (= (car a) (car b)) (< (cdr a) (cdr b))
                   (< (if (= (car a) -1) 99 (car a))
                      (if (= (car b) -1) 99 (car b))))))))

(defun org-habit-ng--author-ordinal-chips (byday)
  "Render the ordinal set BYDAY as dot-separated chips, or \"(none yet)\".
Each pair renders as \"<ordinal> <Weekday>\" using the title-case 3-letter
weekday form from `org-habit-ng--weekday-names' (e.g. (2 . 2) -> \"2nd Tue\")."
  (if (null byday) "(none yet)"
    (mapconcat (lambda (p)
                 (format "%s %s"
                         (cdr (assq (car p) org-habit-ng--author-ordinal-labels))
                         (substring (aref org-habit-ng--weekday-names (cdr p))
                                    0 3)))
               byday " · ")))

(defun org-habit-ng--author-monthly-weekday-label (state wd)
  "Label the monthly weekday-toggle row for WD given STATE.
Returns \"[X] Tue\" when the pair (STATE's `:pending-ord' . WD) is a member of
STATE's `:byday', else \"[ ] Tue\".  WD is 0=Sun..6=Sat and renders as the
3-letter weekday form from `org-habit-ng--weekday-names'."
  (format "%s %s"
          (if (member (cons (plist-get state :pending-ord) wd)
                      (plist-get state :byday))
              "[X]" "[ ]")
          (substring (aref org-habit-ng--weekday-names wd) 0 3)))

(defun org-habit-ng--author-monthly-ordinal-label (state ord)
  "Label the monthly ordinal-cursor row for ORD given STATE.
Shows a \">\" cursor when ORD is STATE's `:pending-ord' (a space otherwise), the
ordinal label from `org-habit-ng--author-ordinal-labels', and, when non-zero, a
parenthesised count of weekdays currently paired with ORD in STATE's `:byday'
\(e.g. \"> 2nd (2)\", \"  4th (1)\", \"  1st\")."
  (let ((cursor (if (equal ord (plist-get state :pending-ord)) ">" " "))
        (label (cdr (assq ord org-habit-ng--author-ordinal-labels)))
        (count (cl-count-if (lambda (p) (equal (car p) ord))
                            (plist-get state :byday))))
    (if (> count 0)
        (format "%s %s (%d)" cursor label count)
      (format "%s %s" cursor label))))

(defun org-habit-ng--author-schedule-visible-p (state)
  "Non-nil when the schedule groups apply: STATE is the point model."
  (eq (plist-get state :model) 'point))

(defun org-habit-ng--author-details-visible-p (state)
  "Non-nil for a period, ceiling, or rolling STATE.
Those three models drive the DETAILS group."
  (memq (plist-get state :model) '(period ceiling rolling)))

(defun org-habit-ng--author-min-gap-applicable-p (state)
  "Non-nil for a period STATE whose `:times' is >= 2.
Mirrors the X-MIN-GAP-requires-X-TIMES>=2 validator rule."
  (and (eq (plist-get state :model) 'period)
       (let ((times (plist-get state :times))) (and times (>= times 2)))))

(defun org-habit-ng--author-nearest-applicable-p (state)
  "Non-nil when the nearest-weekday shift is valid for STATE.
Mirrors the X-SKIP validator rows: point, FREQ MONTHLY/YEARLY, a BYMONTHDAY
set, no BYDAY, no BYSETPOS.  A yearly rule additionally requires BYMONTH
\(the engine only honors BYMONTHDAY under BYMONTH for yearly rules)."
  (and (eq (plist-get state :model) 'point)
       (memq (plist-get state :freq) '(monthly yearly))
       (plist-get state :bymonthday)
       (not (plist-get state :byday))
       (not (plist-get state :bysetpos))
       (if (eq (plist-get state :freq) 'yearly) (plist-get state :bymonth) t)))

(defalias 'org-habit-ng--author-season-applicable-p 'org-habit-ng--season-applicable-p
  "Panel alias for the shared `org-habit-ng--season-applicable-p'.
The panel `:if' season row and the wizard `months-active' step now single-source
one pure predicate (port plan §9).")

(defun org-habit-ng--author-set-first-weekend (state)
  "Set STATE to the yearly \"first weekend day of the year\" shape and return it.
Fills BYMONTH=(1), BYDAY = the weekend days from `calendar-weekend-days' (as
ints, Saturday-first so a Sat+Sun weekend serializes to the natural
`BYDAY=SA,SU'), and BYSETPOS=(1).  The BYDAY order is cosmetic: BYSETPOS=1
selects the chronologically first matching day regardless.  Config-aware
\(reads `calendar-weekend-days'); emits a real engine rule through
`org-habit-ng-build-rrule', never the mock's fake BYYEARDAY."
  (let ((state (plist-put state :year-mode 'first-weekend)))
    (setq state (plist-put state :bymonth '(1)))
    (setq state (plist-put state :byday (sort (copy-sequence calendar-weekend-days) #'>)))
    (plist-put state :bysetpos '(1))))

(defun org-habit-ng--author-save (state)
  "Emit STATE's rule and save it, mirroring `org-habit-ng--wizard-finish'.
Runs `org-habit-ng--author-state-to-rule' -> `org-habit-ng-build-rrule' ->
`org-habit-ng-parse-rrule' (the validate tripwire, which raises on an invalid
combo) -> `org-habit-ng--wizard-save-recurrence'.  Also writes any STATE
`:vacation' ranges to the `org-habit-ng-vacation-property' as a sibling
property -- bracketed org timestamps `[START]--[END]' that
`org-habit-ng--vacation-ranges' parses -- NOT an RRULE token.
Return the emitted RRULE string so callers can report it without rebuilding."
  (let* ((rule (org-habit-ng--author-state-to-rule state))
         (rrule (org-habit-ng-build-rrule rule)))
    (org-habit-ng-parse-rrule rrule)    ; raises on an invalid combo
    (org-habit-ng--wizard-save-recurrence rrule)
    (when-let ((vac (plist-get state :vacation)))
      (org-entry-put nil org-habit-ng-vacation-property
                     (mapconcat (lambda (r)
                                  (format "%s--%s"
                                          (org-habit-ng--inactive-day-ts (car r))
                                          (org-habit-ng--inactive-day-ts (cdr r))))
                                vac " ")))
    rrule))

;;; F13 Task 8: the persistent transient prefix (thin glue over the pure core).
;;;
;;; NOT unit-tested (F11 precedent): the only tests are the entry-command
;;; fallback smoke test and the examples-gallery parse test.  Every option
;;; suffix is a one-line state mutation -- where a tested pure function exists
;;; (`org-habit-ng--author-toggle-ordinal', `org-habit-ng--author-set-first-weekend')
;;; the suffix calls it; the rest are trivial plist writes that carry NO
;;; recurrence/date/RRULE logic.  All rule/date/human text still comes from the
;;; engine via the pure core above.

(defconst org-habit-ng--author-examples
  (list
   (cons "2nd & 4th Tuesday"
         (lambda ()
           (let ((s (org-habit-ng--author-default-state)))
             (setq s (plist-put s :freq 'monthly))
             (plist-put s :byday '((2 . 2) (4 . 2))))))
   (cons "1st of month, nearest weekday"
         (lambda ()
           (let ((s (org-habit-ng--author-default-state)))
             (setq s (plist-put s :freq 'monthly))
             (setq s (plist-put s :bymonthday '(1)))
             (plist-put s :skip 'nearest))))
   (cons "3 times per week"
         (lambda ()
           (let ((s (org-habit-ng--author-default-state)))
             (setq s (plist-put s :model 'period))
             (setq s (plist-put s :period 'week))
             (plist-put s :times 3))))
   (cons "At least every 3 days"
         (lambda ()
           (let ((s (org-habit-ng--author-default-state)))
             (setq s (plist-put s :model 'ceiling))
             (plist-put s :interval 3))))
   (cons "3 times in any rolling 7 days"
         (lambda ()
           (let ((s (org-habit-ng--author-default-state)))
             (setq s (plist-put s :model 'rolling))
             (setq s (plist-put s :window 7))
             (plist-put s :times 3))))
   (cons "First weekend of the year"
         (lambda ()
           (org-habit-ng--author-set-first-weekend
            (plist-put (org-habit-ng--author-default-state) :freq 'yearly)))))
  "Alist of (LABEL . THUNK) authoring-panel examples.
Each THUNK returns a full working-state whose emitted rule parses; the panel's
examples submenu loads the whole state.  Kept as data so
`test-author-examples-gallery-all-parse' can drive every one through
state->rule->build->parse.")

(defun org-habit-ng--author-transient-description ()
  "Sync the side-window preview and return the panel's short header.
This is the panel prefix's top-group `:description' function; with
`:refresh-suffixes' it runs on every `transient--redisplay', so it is the ONE
centralized side-buffer sync site.  It (a) rewrites the preview side buffer from
`org-habit-ng--author-state' and ensures the side window is shown, then (b)
returns only the one-line legend for the menu itself -- the full multi-line
preview (sentence + dates) now lives in the side buffer, not the menu.  Wrapped
in `condition-case' so an intermediate state never wedges the panel."
  (condition-case err
      (progn
        (org-habit-ng--author-preview-sync org-habit-ng--author-state)
        (unless (get-buffer-window org-habit-ng--author-preview-buffer)
          (org-habit-ng--author-preview-show))
        (org-habit-ng--author-preview-legend org-habit-ng--author-state))
    (error (format "(preview unavailable: %s)" (error-message-string err)))))

(defconst org-habit-ng--author-prefixes
  '(org-habit-ng-author-transient
    org-habit-ng--author-model-transient
    org-habit-ng--author-weekdays-transient
    org-habit-ng--author-monthly-transient
    org-habit-ng--author-yearly-transient
    org-habit-ng--author-examples-transient)
  "The authoring panel prefix and its four submenu prefixes.
Used by `org-habit-ng--author-prefix-active-p' to tell a submenu<->root
transition from a real quit.")

(defun org-habit-ng--author-prefix-active-p ()
  "Non-nil when an org-habit-ng authoring transient prefix is active.
Reads the live `transient--prefix' object's `command' slot (falling back to
`transient-current-command') and tests it against the author prefix list.
The deferred teardown uses this to distinguish a submenu<->root transition -- by
the time the deferred body runs after such a transition an author prefix is
active again -- from a real quit, after which no transient is active."
  (let ((cmd (or (and transient--prefix
                      (slot-boundp transient--prefix 'command)
                      (oref transient--prefix command))
                 transient-current-command)))
    (and cmd (memq cmd org-habit-ng--author-prefixes) t)))

(defun org-habit-ng--author-preview-teardown ()
  "Deferred teardown body for the authoring preview side window.
Tears down ONLY when no org-habit-ng author prefix is active (a real quit): it
deletes the preview window if shown, buries the buffer, and removes
`org-habit-ng--author-preview-on-exit' from `transient-exit-hook'.  When an
author prefix IS active (a submenu<->root transition) it does nothing, so the
preview survives navigation between the panel and its submenus.
Wrapped in `condition-case' so a stray error in the guard or the window
teardown can never strand the exit-hook entry on `transient-exit-hook'."
  (condition-case nil
      (unless (org-habit-ng--author-prefix-active-p)
        (let ((win (get-buffer-window org-habit-ng--author-preview-buffer)))
          (when (window-live-p win)
            (delete-window win)))
        (when (get-buffer org-habit-ng--author-preview-buffer)
          (bury-buffer org-habit-ng--author-preview-buffer))
        (remove-hook 'transient-exit-hook #'org-habit-ng--author-preview-on-exit))
    (error
     (remove-hook 'transient-exit-hook #'org-habit-ng--author-preview-on-exit))))

(defun org-habit-ng--author-preview-on-exit ()
  "Defer `org-habit-ng--author-preview-teardown' past the current exit.
Added to `transient-exit-hook' by the panel's teardown registration.
The exit hook fires on EVERY transient exit including submenu<->root
transitions, so teardown is deferred with `run-at-time' 0: by the time it runs
after a submenu transition the resumed author prefix is active again, and
`org-habit-ng--author-preview-teardown' then declines to tear down."
  (run-at-time 0 nil #'org-habit-ng--author-preview-teardown))

(defun org-habit-ng--author-preview-register-teardown ()
  "Register the deferred preview teardown on `transient-exit-hook'.
Idempotent (`add-hook' de-dups); the teardown removes the hook after a real
teardown."
  (add-hook 'transient-exit-hook #'org-habit-ng--author-preview-on-exit))

(defun org-habit-ng--author-open-panel ()
  "Show the preview side window, register teardown, and open the panel prefix.
DRY entry shared by `org-habit-ng-author' and `org-habit-ng--wizard-run'; each
caller seeds `org-habit-ng--author-state' first.  The panel `:description'
function also ensure-shows the preview as a redisplay-time safety net."
  (org-habit-ng--author-preview-show)
  (org-habit-ng--author-preview-register-teardown)
  (org-habit-ng-author-transient))

(defun org-habit-ng--author-monthly-heading ()
  "Group heading for the monthly submenu: current ordinal chips + cursor."
  (let ((bd (plist-get org-habit-ng--author-state :byday)))
    (format "Monthly -- %s   [cursor: %s]"
            (org-habit-ng--author-ordinal-chips (and bd (consp (car bd)) bd))
            (cdr (assq (plist-get org-habit-ng--author-state :pending-ord)
                       org-habit-ng--author-ordinal-labels)))))

;;; `:if' wrappers: transient calls `:if' predicates with no args, so each thin
;;; wrapper reads the global `org-habit-ng--author-state' and defers to the
;;; tested predicate (or a trivial `:model'/`:freq' `eq').

(defun org-habit-ng--author-if-schedule ()
  "Non-nil when the schedule groups apply to the current state."
  (org-habit-ng--author-schedule-visible-p org-habit-ng--author-state))

(defun org-habit-ng--author-if-details ()
  "Non-nil when the DETAILS group is applicable to the current state."
  (org-habit-ng--author-details-visible-p org-habit-ng--author-state))

(defun org-habit-ng--author-if-season ()
  "Non-nil when a season row is applicable to the current state."
  (org-habit-ng--author-season-applicable-p org-habit-ng--author-state))

(defun org-habit-ng--author-if-nearest ()
  "Non-nil when the nearest-weekday shift is applicable to the current state."
  (org-habit-ng--author-nearest-applicable-p org-habit-ng--author-state))

(defun org-habit-ng--author-if-min-gap ()
  "Non-nil when a min-gap row is applicable to the current state."
  (org-habit-ng--author-min-gap-applicable-p org-habit-ng--author-state))

(defun org-habit-ng--author-if-quota ()
  "Non-nil when the current state is the period model."
  (eq (plist-get org-habit-ng--author-state :model) 'period))

(defun org-habit-ng--author-if-window ()
  "Non-nil when the current state is the rolling-window model."
  (eq (plist-get org-habit-ng--author-state :model) 'rolling))

(defun org-habit-ng--author-if-weekly ()
  "Non-nil when the current due state repeats weekly."
  (eq (plist-get org-habit-ng--author-state :freq) 'weekly))

(defun org-habit-ng--author-if-monthly ()
  "Non-nil when the current due state repeats monthly."
  (eq (plist-get org-habit-ng--author-state :freq) 'monthly))

(defun org-habit-ng--author-if-yearly ()
  "Non-nil when the current due state repeats yearly."
  (eq (plist-get org-habit-ng--author-state :freq) 'yearly))

;;; Option suffix commands (each a one-line mutation of the working state).

(defun org-habit-ng--author-switch-model (state model)
  "Return STATE with `:model' set to MODEL, stale schedule fields cleared.
Switching the success model resets the fields that either leak into another
model's meaning (`:freq' is a ceiling's gap unit; `:interval' is read by every
model) or are point-only authoring artifacts (`:byday'/`:bymonthday'/
`:bysetpos'/`:skip'/`:bymonth'), so a model change always yields a clean rule.
Model-specific numerics (`:times'/`:period'/`:window'/`:min-gap'), termination,
`:warn', vacation, tolerances, and UI scratch are preserved.  Mirrors the
day-shape scrub `org-habit-ng--author-cycle-freq' does on a frequency change.

After the scrub, seed the model-defining token when the target MODEL would
otherwise be degenerate and that token is still unset: `rolling' with no
`:window' fills the default window (so the rule is a real rolling window rather
than a bare `FREQ=DAILY' that reads \"Every day\"), and `period' with no
`:period' fills a weekly period (so a fresh period reads a real quota rather
than a bare `FREQ=WEEKLY' that reads \"Every week\").  `ceiling' and `point'
need no seed.  Seeding only fills an unset token, so an explicit
window/period survives the switch."
  (let ((s (copy-sequence state)))
    (setq s (plist-put s :model model))
    (setq s (plist-put s :freq 'daily))
    (dolist (k '(:interval :byday :bymonthday :bysetpos :skip :bymonth))
      (setq s (plist-put s k nil)))
    (cond
     ((and (eq model 'rolling) (null (plist-get s :window)))
      (setq s (plist-put s :window (org-habit-ng--author-default-for :window))))
     ((and (eq model 'period) (null (plist-get s :period)))
      (setq s (plist-put s :period 'week))))
    s))

(defun org-habit-ng--author-select-model (state model)
  "Return STATE with its success `:model' set to MODEL.
Pure state write used by the model sub-transient's direct-jump rows; delegates
to `org-habit-ng--author-switch-model' so a jump scrubs stale schedule fields."
  (org-habit-ng--author-switch-model state model))

(defun org-habit-ng--author-model-row-label (state model)
  "Return the model sub-transient row label for MODEL given STATE.
Marks the currently-selected model with a `>' cursor; a CHOOSER surface, so
it shows the model NAME and EXAMPLE via
`org-habit-ng--author-model-name-and-example'."
  (format "%s %s"
          (if (eq (plist-get state :model) model) ">" " ")
          (org-habit-ng--author-model-name-and-example model)))

(defun org-habit-ng--author-set-model ()
  "Cycle the success model in-panel: point -> period -> ceiling -> rolling.
Mutates `:model' directly (no minibuffer prompt); the panel refreshes to
reveal the groups gated on the newly selected model."
  (interactive)
  (let* ((order '(point period ceiling rolling))
         (cur (plist-get org-habit-ng--author-state :model))
         (next (or (cadr (memq cur order)) (car order))))
    (setq org-habit-ng--author-state
          (org-habit-ng--author-switch-model org-habit-ng--author-state next))))

(defun org-habit-ng--author-cycle-freq ()
  "Cycle the due frequency, clearing any now-inapplicable day shape."
  (interactive)
  (let* ((order '(daily weekly monthly yearly))
         (cur (plist-get org-habit-ng--author-state :freq))
         (next (or (cadr (memq cur order)) (car order)))
         (s org-habit-ng--author-state))
    (setq s (plist-put s :freq next))
    (setq s (plist-put s :byday nil))
    (setq s (plist-put s :bymonthday nil))
    (setq s (plist-put s :bysetpos nil))
    (setq org-habit-ng--author-state s)))

(defun org-habit-ng--author-set-interval ()
  "Prompt for the due recurrence interval."
  (interactive)
  (setf (plist-get org-habit-ng--author-state :interval)
        (read-number "Every how many periods? "
                     (or (plist-get org-habit-ng--author-state :interval)
                         (org-habit-ng--author-default-for :interval)))))

(defun org-habit-ng--author-set-main ()
  "Prompt for the main count/interval of the current sustain model."
  (interactive)
  (pcase (plist-get org-habit-ng--author-state :model)
    ('period (setf (plist-get org-habit-ng--author-state :times)
                  (read-number "Times per period: "
                               (or (plist-get org-habit-ng--author-state :times)
                                   (org-habit-ng--author-default-for :times)))))
    ('ceiling (setf (plist-get org-habit-ng--author-state :interval)
                     (read-number "At least every how many days? "
                                  (or (plist-get org-habit-ng--author-state :interval)
                                      (org-habit-ng--author-default-for :interval)))))
    ('rolling (setf (plist-get org-habit-ng--author-state :times)
                   (read-number "Completions per window: "
                                (or (plist-get org-habit-ng--author-state :times)
                                    (org-habit-ng--author-default-for :times)))))))

(defun org-habit-ng--author-toggle-period ()
  "Toggle the quota period between week and month."
  (interactive)
  (setf (plist-get org-habit-ng--author-state :period)
        (if (eq (plist-get org-habit-ng--author-state :period) 'month) 'week 'month)))

(defun org-habit-ng--author-set-min-gap ()
  "Prompt for the quota minimum spacing (X-MIN-GAP)."
  (interactive)
  (setf (plist-get org-habit-ng--author-state :min-gap)
        (read-number "Minimum days between completions: "
                     (or (plist-get org-habit-ng--author-state :min-gap)
                         (org-habit-ng--author-default-for :min-gap)))))

(defun org-habit-ng--author-set-window ()
  "Prompt for the rolling-window length (X-WINDOW)."
  (interactive)
  (setf (plist-get org-habit-ng--author-state :window)
        (read-number "Rolling window (days): "
                     (or (plist-get org-habit-ng--author-state :window)
                         (org-habit-ng--author-default-for :window)))))

(defun org-habit-ng--author-set-flex-after ()
  "Prompt for the late tolerance (X-FLEX-AFTER)."
  (interactive)
  (setf (plist-get org-habit-ng--author-state :flex-after)
        (read-number "Late tolerance (days after due): "
                     (or (plist-get org-habit-ng--author-state :flex-after)
                         (org-habit-ng--author-default-for :flex-after)))))

(defun org-habit-ng--author-set-flex-before ()
  "Prompt for the early tolerance (X-FLEX-BEFORE)."
  (interactive)
  (setf (plist-get org-habit-ng--author-state :flex-before)
        (read-number "Early tolerance (days before due): "
                     (or (plist-get org-habit-ng--author-state :flex-before)
                         (org-habit-ng--author-default-for :flex-before)))))

(defun org-habit-ng--author-toggle-nearest ()
  "Toggle the nearest-weekday shift (X-SKIP=NEAREST) on the current state."
  (interactive)
  (setf (plist-get org-habit-ng--author-state :skip)
        (if (eq (plist-get org-habit-ng--author-state :skip) 'nearest) nil 'nearest)))

(defun org-habit-ng--author-set-end ()
  "Prompt for the recurrence end: never, after N occurrences, or on a date."
  (interactive)
  (pcase (completing-read "End: " '("never" "after N occurrences" "on date") nil t)
    ("after N occurrences"
     (setq org-habit-ng--author-state (plist-put org-habit-ng--author-state :until nil))
     (setf (plist-get org-habit-ng--author-state :count)
           (read-number "After how many occurrences? " 10)))
    ("on date"
     (setq org-habit-ng--author-state (plist-put org-habit-ng--author-state :count nil))
     (setf (plist-get org-habit-ng--author-state :until)
           (org-habit-ng--parse-rrule-date
            (format-time-string "%Y%m%d" (org-read-date nil t)))))
    (_ (setq org-habit-ng--author-state
             (plist-put (plist-put org-habit-ng--author-state :count nil) :until nil)))))

(defun org-habit-ng--author-set-season ()
  "Prompt for the season (BYMONTH) activation months; empty means all year."
  (interactive)
  (let ((names (cl-loop for m from 1 to 12
                        collect (cons (aref org-habit-ng--month-names m) m))))
    (setf (plist-get org-habit-ng--author-state :bymonth)
          (let ((chosen (completing-read-multiple
                         "Active months (empty = all year): " (mapcar #'car names) nil t)))
            (and chosen (sort (delq nil (mapcar (lambda (n) (cdr (assoc n names))) chosen))
                              #'<))))))

(defun org-habit-ng--author-vacation ()
  "Add a vacation range to, or clear all vacation ranges from, the working state."
  (interactive)
  (pcase (completing-read
          (format "Vacation (%d range(s) set): "
                  (length (plist-get org-habit-ng--author-state :vacation)))
          '("add range" "clear all") nil t)
    ("clear all"
     (setf (plist-get org-habit-ng--author-state :vacation) nil))
    (_
     (let ((start (time-to-days (org-read-date nil t nil "Vacation start")))
           (end (time-to-days (org-read-date nil t nil "Vacation end"))))
       (setf (plist-get org-habit-ng--author-state :vacation)
             (append (plist-get org-habit-ng--author-state :vacation)
                     (list (cons start end))))))))

(defun org-habit-ng--author-reset ()
  "Reset the authoring panel to the default working state."
  (interactive)
  (setq org-habit-ng--author-state (org-habit-ng--author-default-state)))

(defun org-habit-ng--author-do-save ()
  "Save the current working state and message the emitted RRULE (saved reveal)."
  (interactive)
  (message "org-habit-ng: saved %s"
           (org-habit-ng--author-save org-habit-ng--author-state)))

;;; Live current-value labels for the stateful panel suffixes.  Each is a PURE
;;; function of a STATE plist (so it is --batch-testable) returning the suffix
;;; description string; the panel wires each via a literal `(lambda () ...)'
;;; form so `:refresh-suffixes' re-runs it and the label tracks state live.
;;; Defaults mirror what the corresponding setter would use, so a nil value
;;; never leaks a bare "nil" into the label.

(defun org-habit-ng--author-label-model (state)
  "Label the success-model suffix for STATE (defaults to `point').
A STATUS surface: renders the model NAME only (via
`org-habit-ng--author-model-name'), matching the preview legend, so the
label and the legend read consistently."
  (let ((model (or (plist-get state :model) 'point)))
    (format "success model (%s)" (org-habit-ng--author-model-name model))))

(defun org-habit-ng--author-label-freq (state)
  "Label the frequency suffix for STATE (defaults to `daily')."
  (format "frequency (%s)" (or (plist-get state :freq) 'daily)))

(defun org-habit-ng--author-label-interval (state)
  "Label the interval suffix for STATE (defaults to every 1)."
  (format "interval (every %d)"
          (or (plist-get state :interval)
              (org-habit-ng--author-default-for :interval))))

(defun org-habit-ng--author-label-main (state)
  "Label the model-dependent main-count suffix for STATE.
Mirrors the prompts of `org-habit-ng--author-set-main'."
  (pcase (plist-get state :model)
    ('period (format "times per period (%d)"
                     (or (plist-get state :times)
                         (org-habit-ng--author-default-for :times))))
    ('ceiling (format "at least every (%d days)"
                      (or (plist-get state :interval)
                          (org-habit-ng--author-default-for :interval))))
    ('rolling (format "completions per window (%d)"
                      (or (plist-get state :times)
                          (org-habit-ng--author-default-for :times))))
    (_ "main count / interval")))

(defun org-habit-ng--author-label-period (state)
  "Label the quota-period suffix for STATE (nil shown as week)."
  (format "period (%s)" (if (eq (plist-get state :period) 'month) "month" "week")))

(defun org-habit-ng--author-label-min-gap (state)
  "Label the min-gap suffix for STATE (defaults to 2)."
  (format "min gap (%d)"
          (or (plist-get state :min-gap)
              (org-habit-ng--author-default-for :min-gap))))

(defun org-habit-ng--author-label-window (state)
  "Label the window-days suffix for STATE (defaults to 7)."
  (format "window days (%d)"
          (or (plist-get state :window)
              (org-habit-ng--author-default-for :window))))

(defun org-habit-ng--author-label-flex-after (state)
  "Label the late-tolerance suffix for STATE (defaults to 0)."
  (format "late tolerance (%d)"
          (or (plist-get state :flex-after)
              (org-habit-ng--author-default-for :flex-after))))

(defun org-habit-ng--author-label-flex-before (state)
  "Label the early-tolerance suffix for STATE (defaults to 0)."
  (format "early tolerance (%d)"
          (or (plist-get state :flex-before)
              (org-habit-ng--author-default-for :flex-before))))

(defun org-habit-ng--author-label-end (state)
  "Label the recurrence-end suffix for STATE: never / after N / until DATE."
  (let ((count (plist-get state :count))
        (until (plist-get state :until)))
    (cond
     (count (format "end (after %d)" count))
     (until (format "end (until %04d-%02d-%02d)"
                    (plist-get until :year)
                    (plist-get until :month)
                    (plist-get until :day)))
     (t "end (never)"))))

(defun org-habit-ng--author-label-season (state)
  "Label the season suffix for STATE: all year, or dot-joined 3-letter months.
Out-of-range months (outside 1..12) are dropped defensively so a malformed
`:bymonth' -- e.g. from a future seed-from-existing-rule path -- degrades to a
partial or all-year label instead of throwing `args-out-of-range' (which,
since transient does not wrap suffix descriptions in `condition-case', would
wedge the panel)."
  (let ((bymonth (seq-filter (lambda (m) (<= 1 m 12))
                             (plist-get state :bymonth))))
    (if (not bymonth)
        "season (all year)"
      (format "season (%s)"
              (mapconcat (lambda (m) (substring (aref org-habit-ng--month-names m) 0 3))
                         bymonth "·")))))

(defun org-habit-ng--author-label-nearest (state)
  "Label the nearest-weekday-shift suffix for STATE (on/off from `:skip')."
  (format "nearest-weekday shift (%s)"
          (if (eq (plist-get state :skip) 'nearest) "on" "off")))

;;; Submenu `:setup-children' builders (dynamic children via
;;; `transient-parse-suffixes'): dynamic descriptions are literal
;;; `(lambda ...)' FORMS, toggle commands are real closures.  A "back" suffix
;;; re-invokes the root prefix, which restores the panel from the persistent
;;; global state (more robust than relying on transient stacking).

(defun org-habit-ng--author-weekday-children (_children)
  "Build the weekly weekday multi-toggle suffixes for the weekdays submenu."
  (transient-parse-suffixes
   'org-habit-ng--author-weekdays-transient
   (append
    (cl-loop
     for (key . wd) in '(("u" . 0) ("m" . 1) ("t" . 2) ("w" . 3)
                         ("h" . 4) ("f" . 5) ("s" . 6))
     collect (list key
                   `(lambda ()
                      (if (memq ,wd (plist-get org-habit-ng--author-state :byday))
                          (format "[X] %s" ,(aref org-habit-ng--weekday-names wd))
                        (format "[ ] %s" ,(aref org-habit-ng--weekday-names wd))))
                   (let ((w wd))
                     (lambda ()
                       (interactive)
                       (let ((bd (plist-get org-habit-ng--author-state :byday)))
                         (setf (plist-get org-habit-ng--author-state :byday)
                               (sort (if (memq w bd) (remove w bd) (cons w bd))
                                     #'<)))))
                   :transient t))
    (list (list "^" "-- back"
                (lambda () (interactive) (org-habit-ng-author-transient)))))))

(defun org-habit-ng--author-monthly-ordinal-children (_children)
  "Build the ordinal-cursor rows for the monthly submenu's Ordinal column.
Each row selects the pending ordinal (1..4 or -1=last) that subsequent weekday
toggles pair with; its label (`org-habit-ng--author-monthly-ordinal-label')
shows the cursor and a per-ordinal selection count."
  (transient-parse-suffixes
   'org-habit-ng--author-monthly-transient
   (cl-loop
    for (key . ord) in '(("1" . 1) ("2" . 2) ("3" . 3) ("4" . 4) ("l" . -1))
    collect (list key
                  `(lambda ()
                     (org-habit-ng--author-monthly-ordinal-label
                      org-habit-ng--author-state ,ord))
                  (let ((o ord))
                    (lambda () (interactive)
                      (setf (plist-get org-habit-ng--author-state :pending-ord) o)))
                  :transient t))))

(defun org-habit-ng--author-monthly-weekday-children (_children)
  "Build the weekday-toggle rows for the monthly submenu's Weekday column.
Each letter toggles a (pending-ord . weekday) pair via the tested
`org-habit-ng--author-toggle-ordinal'; its label
\(`org-habit-ng--author-monthly-weekday-label') marks whether that pair is
currently selected for the active ordinal cursor."
  (transient-parse-suffixes
   'org-habit-ng--author-monthly-transient
   (cl-loop
    for (key . wd) in '(("u" . 0) ("m" . 1) ("t" . 2) ("w" . 3)
                        ("h" . 4) ("f" . 5) ("s" . 6))
    collect (list key
                  `(lambda ()
                     (org-habit-ng--author-monthly-weekday-label
                      org-habit-ng--author-state ,wd))
                  (let ((w wd))
                    (lambda () (interactive)
                      (setf (plist-get org-habit-ng--author-state :byday)
                            (org-habit-ng--author-toggle-ordinal
                             (plist-get org-habit-ng--author-state :byday)
                             (plist-get org-habit-ng--author-state :pending-ord) w))))
                  :transient t))))

(defun org-habit-ng--author-monthly-more-children (_children)
  "Build the day-of-month and back rows below the monthly submenu's columns."
  (transient-parse-suffixes
   'org-habit-ng--author-monthly-transient
   (list (list "x" "day-of-month..."
               (lambda () (interactive)
                 (setf (plist-get org-habit-ng--author-state :bymonthday)
                       (list (read-number "Day of month (1..31, -1 = last): " 1))))
               :transient t)
         (list "^" "-- back"
               (lambda () (interactive) (org-habit-ng-author-transient))))))

(defun org-habit-ng--author-yearly-children (_children)
  "Build the yearly submenu suffixes: month+day, or first-weekend-of-year."
  (transient-parse-suffixes
   'org-habit-ng--author-yearly-transient
   (list
    (list "d" "month + day..."
          (lambda () (interactive)
            (let ((mo (read-number "Month (1..12): " 1))
                  (dy (read-number "Day of month: " 1)))
              (setf (plist-get org-habit-ng--author-state :bymonth) (list mo))
              (setf (plist-get org-habit-ng--author-state :bymonthday) (list dy))))
          :transient t)
    (list "w" "first weekend of the year"
          (lambda () (interactive)
            (setq org-habit-ng--author-state
                  (org-habit-ng--author-set-first-weekend org-habit-ng--author-state)))
          :transient t)
    (list "^" "-- back"
          (lambda () (interactive) (org-habit-ng-author-transient))))))

(defun org-habit-ng--author-model-children (_children)
  "Build the direct-jump success-model rows for the model sub-transient.
Each row sets `:model' via `org-habit-ng--author-select-model' and re-invokes
the root panel prefix, which (`:refresh-suffixes' t) reshapes the panel and
re-syncs the side window.  Labels come from
`org-habit-ng--author-model-row-label' and mark the active model with a `>'
cursor."
  (transient-parse-suffixes
   'org-habit-ng--author-model-transient
   (append
    (cl-loop
     for (key . model) in '(("p" . point) ("q" . period)
                            ("c" . ceiling) ("r" . rolling))
     collect (list key
                   `(lambda ()
                      (org-habit-ng--author-model-row-label
                       org-habit-ng--author-state ',model))
                   (let ((m model))
                     (lambda () (interactive)
                       (setq org-habit-ng--author-state
                             (org-habit-ng--author-select-model
                              org-habit-ng--author-state m))
                       (org-habit-ng-author-transient)))))
    (list (list "^" "-- back"
                (lambda () (interactive) (org-habit-ng-author-transient)))))))

(defun org-habit-ng--author-examples-children (_children)
  "Build the examples-gallery suffixes from `org-habit-ng--author-examples'."
  (transient-parse-suffixes
   'org-habit-ng--author-examples-transient
   (append
    (cl-loop for (label . thunk) in org-habit-ng--author-examples
             for i from 1
             collect (list (number-to-string i) label
                           (let ((th thunk))
                             (lambda () (interactive)
                               (setq org-habit-ng--author-state (funcall th))
                               (org-habit-ng-author-transient)))))
    (list (list "^" "-- back"
                (lambda () (interactive) (org-habit-ng-author-transient)))))))

(transient-define-prefix org-habit-ng-author-transient ()
  "Persistent recurrence-authoring panel with a live preview.
Every option suffix mutates `org-habit-ng--author-state' via the tested pure
core; the group `:description' recomputes the human sentence + next dates on
each redraw.  Groups are gated by the tested `:if' predicates so no combination
the validator rejects is ever reachable."
  :refresh-suffixes t
  [:description org-habit-ng--author-transient-description
   ("s" (lambda () (org-habit-ng--author-label-model org-habit-ng--author-state))
    org-habit-ng--author-set-model :transient t)
   ("M" "model menu..." org-habit-ng--author-model-transient)]
  ["Repeats" :if org-habit-ng--author-if-schedule
   ("f" (lambda () (org-habit-ng--author-label-freq org-habit-ng--author-state))
    org-habit-ng--author-cycle-freq :transient t)
   ("i" (lambda () (org-habit-ng--author-label-interval org-habit-ng--author-state))
    org-habit-ng--author-set-interval :transient t)]
  ["On which days" :if org-habit-ng--author-if-schedule
   ("d" "weekdays..." org-habit-ng--author-weekdays-transient
    :if org-habit-ng--author-if-weekly)
   ("m" "days / ordinals..." org-habit-ng--author-monthly-transient
    :if org-habit-ng--author-if-monthly)
   ("o" "yearly..." org-habit-ng--author-yearly-transient
    :if org-habit-ng--author-if-yearly)
   ("~" (lambda () (org-habit-ng--author-label-nearest org-habit-ng--author-state))
    org-habit-ng--author-toggle-nearest
    :transient t :if org-habit-ng--author-if-nearest)]
  ["Tolerance" :if org-habit-ng--author-if-schedule
   ("l" (lambda () (org-habit-ng--author-label-flex-after org-habit-ng--author-state))
    org-habit-ng--author-set-flex-after :transient t)
   ("y" (lambda () (org-habit-ng--author-label-flex-before org-habit-ng--author-state))
    org-habit-ng--author-set-flex-before :transient t)]
  ["Details" :if org-habit-ng--author-if-details
   ("x" (lambda () (org-habit-ng--author-label-main org-habit-ng--author-state))
    org-habit-ng--author-set-main :transient t)
   ("p" (lambda () (org-habit-ng--author-label-period org-habit-ng--author-state))
    org-habit-ng--author-toggle-period
    :transient t :if org-habit-ng--author-if-quota)
   ("m" (lambda () (org-habit-ng--author-label-min-gap org-habit-ng--author-state))
    org-habit-ng--author-set-min-gap
    :transient t :if org-habit-ng--author-if-min-gap)
   ("w" (lambda () (org-habit-ng--author-label-window org-habit-ng--author-state))
    org-habit-ng--author-set-window
    :transient t :if org-habit-ng--author-if-window)]
  ["Bounds & scope"
   ("e" (lambda () (org-habit-ng--author-label-end org-habit-ng--author-state))
    org-habit-ng--author-set-end :transient t)
   ("n" (lambda () (org-habit-ng--author-label-season org-habit-ng--author-state))
    org-habit-ng--author-set-season
    :transient t :if org-habit-ng--author-if-season)
   ("k" "vacation (add / clear)..." org-habit-ng--author-vacation :transient t)]
  ["Do"
   ("g" "load an example..." org-habit-ng--author-examples-transient)
   ("RET" "save" org-habit-ng--author-do-save)
   ("q" "reset" org-habit-ng--author-reset :transient t)])

(transient-define-prefix org-habit-ng--author-model-transient ()
  "Success-model direct-jump submenu (one key per model)."
  [:description (lambda () "Success model")
   :setup-children org-habit-ng--author-model-children
   :class transient-column])

(transient-define-prefix org-habit-ng--author-weekdays-transient ()
  "Weekly weekday multi-toggle submenu."
  [:description (lambda () "Weekdays")
   :setup-children org-habit-ng--author-weekday-children
   :class transient-column])

(transient-define-prefix org-habit-ng--author-monthly-transient ()
  "Monthly day/ordinal selection submenu.
Laid out as two side-by-side columns -- the Ordinal cursor rows and the
Weekday toggle rows -- so the (ordinal . weekday) pairing is visually obvious;
the group heading summarizes the selected pairs as chips."
  [:description org-habit-ng--author-monthly-heading
   :class transient-columns
   ["Ordinal"
    :setup-children org-habit-ng--author-monthly-ordinal-children
    :class transient-column]
   ["Weekday"
    :setup-children org-habit-ng--author-monthly-weekday-children
    :class transient-column]]
  [:setup-children org-habit-ng--author-monthly-more-children
   :class transient-column])

(transient-define-prefix org-habit-ng--author-yearly-transient ()
  "Yearly day / first-weekend-of-year submenu."
  [:description (lambda () "Yearly")
   :setup-children org-habit-ng--author-yearly-children
   :class transient-column])

(transient-define-prefix org-habit-ng--author-examples-transient ()
  "Examples-gallery submenu: load a full working state."
  [:description (lambda () "Load an example")
   :setup-children org-habit-ng--author-examples-children
   :class transient-column])

;;;###autoload
(defun org-habit-ng-author ()
  "Open the persistent recurrence-authoring panel for the habit at point.
Authors a FRESH rule and replaces any existing recurrence on save, so on a
heading that already has one it asks for confirmation first (pointing you at
`org-habit-ng-set-recurrence' to modify in place instead).  The transient
panel is the default; set `org-habit-ng-use-completing-read' to opt into the
`completing-read' walk instead."
  (interactive)
  (unless (derived-mode-p 'org-mode)
    (user-error "Not in org-mode"))
  (unless (org-at-heading-p)
    (org-back-to-heading t))
  (let ((existing (org-entry-get nil org-habit-ng-recurrence-property)))
    (when (and existing
               (not (yes-or-no-p
                     (format "This habit already has a recurrence (%s).\nThe panel authors a FRESH rule and replaces it on save.  Continue? "
                             existing))))
      (user-error "Use `org-habit-ng-set-recurrence' to modify an existing habit")))
  (if org-habit-ng-use-completing-read
      (org-habit-ng--wizard-run-completing-read)
    (setq org-habit-ng--author-state (org-habit-ng--author-default-state))
    (org-habit-ng--author-open-panel)))


;;; Consistency Graph (Phase 3)

(defun org-habit-ng--get-nearest-due-date (day due-dates)
  "Find the due date nearest to DAY from DUE-DATES.
Returns (DISTANCE . DUE-DATE) where DISTANCE is negative if DAY is
before the due date, positive if after, zero if exact match."
  (let ((best-distance most-positive-fixnum)
        (best-due nil))
    (dolist (due due-dates)
      (let ((dist (abs (- day due))))
        (when (< dist (abs best-distance))
          (setq best-distance (- day due))
          (setq best-due due))))
    (cons best-distance best-due)))

(defface org-habit-ng-off-season-face
  '((((background light)) :background "gray93" :foreground "gray70")
    (((background dark)) :background "gray20" :foreground "gray45"))
  "Face for calendar cells outside a seasonal habit's activation window.
Deliberately dimmer/flatter than `org-habit-clear-face' so \"not applicable\"
reads as distinct from \"clear / nothing due here\"."
  :group 'org-habit-ng)

(defun org-habit-ng--get-graph-face (day-offset flex-before flex-after donep
                                                 &optional warn)
  "Get face for a day based on DAY-OFFSET from nearest due date.
FLEX-BEFORE is days before due that count as on-time.
FLEX-AFTER is days after due that count as on-time.
DONEP is non-nil if the habit was completed on this day.
WARN is the optional X-WARN band in days: a too-early day within WARN days
before due renders alert rather than clear.  When the warn band and flex
window overlap (WARN <= FLEX-BEFORE) the band collapses to empty, so
flex-window days never render alert.
Returns (PAST-FACE . FUTURE-FACE) cons cell."
  (let ((before (or flex-before 0))
        (after (or flex-after 0)))
    (cond
     ;; Done and show-done-always-green
     ((and donep org-habit-show-done-always-green)
      '(org-habit-ready-face . org-habit-ready-future-face))
     ;; Before the before-window (too early)
     ((< day-offset (- before))
      (if (and warn (> warn 0) (>= day-offset (- warn)))
          '(org-habit-alert-face . org-habit-alert-future-face)
        '(org-habit-clear-face . org-habit-clear-future-face)))
     ;; Within before-window or on due date or within after-window
     ((<= day-offset after)
      '(org-habit-ready-face . org-habit-ready-future-face))
     ;; At deadline (one day past after-window)
     ((= day-offset (1+ after))
      '(org-habit-alert-face . org-habit-alert-future-face))
     ;; Overdue
     (t
      '(org-habit-overdue-face . org-habit-overdue-future-face)))))

(defun org-habit-ng--occ-day-to-time (day)
  "Emacs time value at midnight of absolute DAY number.
NOT `days-to-time', which is 1970-epoch — absolute day numbers are year-1-epoch,
and feeding them to `days-to-time' yields year-3993 dates."
  (let ((g (org-habit-ng--day-to-gregorian day)))
    (encode-time 0 0 0 (nth 1 g) (nth 0 g) (nth 2 g))))

(defun org-habit-ng--instance-face (status future-p)
  "Face for a PERIOD cell given STATUS; FUTURE-P select the future variant.
Reproduces the old `--build-period-graph' colouring exactly."
  (pcase status
    ('satisfied (if future-p 'org-habit-ready-future-face 'org-habit-ready-face))
    ('missed 'org-habit-overdue-face)
    ('pending 'org-habit-alert-face)
    ('skipped (if future-p 'org-habit-clear-future-face 'org-habit-clear-face))
    (_ 'org-habit-clear-future-face)))          ; upcoming

(defun org-habit-ng--render-point (instances completions rule anchor-day
                                             start-day end-day today
                                             &optional skip-events vacation-ranges)
  "Render point-mode cells (one per day) from INSTANCES/COMPLETIONS.
Every cell whose nearest due belongs to a skipped instance renders clear face
\(the whole window, including would-be alert/overdue cells after the due, goes
quiet); the skipped due day additionally draws `org-habit-ng-skipped-glyph'.
A vacation-covered non-completion day dims to clear face with a `· vacation'
help-echo.  Otherwise the existing `--get-graph-face' proximity path is used
verbatim.  SKIP-EVENTS is unused here (skip suppression is already reflected in
INSTANCES' :status); VACATION-RANGES drives the vacation dim.  RULE and
ANCHOR-DAY drive the proximity face; the cells span [START-DAY, END-DAY]
relative to TODAY."
  (let* ((before (or (plist-get rule :flex-before) 0))
         (after (or (plist-get rule :flex-after) 0))
         (warn-days (org-habit-ng--parse-warn-days
                     (or (plist-get rule :warn) "0d")))
         (dues (or (mapcar (lambda (i) (plist-get i :due)) instances)
                   (list anchor-day)))
         (by-due (let ((h (make-hash-table :test 'eql)))
                   (dolist (i instances) (puthash (plist-get i :due) i h))
                   h))
         (graph (make-string (1+ (- end-day start-day)) ?\s))
         (day start-day) (index 0))
    (ignore skip-events)
    (while (<= day end-day)
      (let* ((in-past (< day today))
             (todayp (= day today))
             (donep (and (member day completions) t))
             (nearest (org-habit-ng--get-nearest-due-date day dues))
             (offset (car nearest))
             (nearest-due (cdr nearest))
             (nearest-inst (and nearest-due (gethash nearest-due by-due)))
             (nearest-skipped (and nearest-inst
                                   (eq (plist-get nearest-inst :status) 'skipped)))
             (vacationed (and (not donep)
                              (org-habit-ng--in-vacation-p day vacation-ranges)))
             (off-season (and (org-habit-ng--rule-seasonal-p rule)
                              (not (org-habit-ng--day-in-season-p
                                    day (plist-get rule :bymonth)))))
             (faces (org-habit-ng--get-graph-face offset before after donep
                                                  warn-days))
             (proximity-face (if (or in-past todayp) (car faces) (cdr faces)))
             (clear-face (if (or in-past todayp)
                             'org-habit-clear-face 'org-habit-clear-future-face))
             (face (cond ((and donep off-season) 'org-habit-ready-face)
                         (donep proximity-face)
                         (off-season 'org-habit-ng-off-season-face)
                         (nearest-skipped clear-face)
                         (vacationed clear-face)
                         (t proximity-face))))
        (cond (donep (aset graph index org-habit-completed-glyph))
              (todayp (aset graph index org-habit-today-glyph))
              ((and nearest-skipped (= day nearest-due))
               (aset graph index org-habit-ng-skipped-glyph)))
        (put-text-property index (1+ index) 'face face graph)
        (put-text-property
         index (1+ index) 'help-echo
         (let ((date (format-time-string (org-time-stamp-format)
                                         (org-habit-ng--occ-day-to-time day))))
           ;; Vacation FIRST: a vacationed due day reads "· vacation" (the
           ;; design's "Aug 03 · vacation" example) even though its instance
           ;; is also skipped; only a non-vacationed skipped due reads
           ;; "· skipped".
           (cond ((and (not donep) off-season) (format "%s · off-season" date))
                 (vacationed (format "%s · vacation" date))
                 ((and (not donep) nearest-skipped (= day nearest-due))
                  (format "%s · skipped" date))
                 (t date)))
         graph))
      (setq day (1+ day) index (1+ index)))
    graph))

(defun org-habit-ng--period-cell (glyph face echo)
  "One propertized period-graph cell: GLYPH char, FACE, ECHO help-echo string."
  (let ((s (string glyph)))
    (put-text-property 0 1 'face face s)
    (put-text-property 0 1 'help-echo echo s)
    s))

(defun org-habit-ng--period-off-season-cell (gap-start gap-end)
  "A dim off-season separator cell for an elided gap [GAP-START, GAP-END].
GAP-START/GAP-END nil renders a bare \"off-season\" cell (a wholly-off-season
display window)."
  (let ((s (string ?\s))
        (echo (if (and gap-start gap-end)
                  (format "off-season · %s - %s"
                          (format-time-string "%b %d" (org-habit-ng--occ-day-to-time gap-start))
                          (format-time-string "%b %d" (org-habit-ng--occ-day-to-time gap-end)))
                "off-season")))
    (put-text-property 0 1 'face 'org-habit-ng-off-season-face s)
    (put-text-property 0 1 'help-echo echo s)
    s))

(defun org-habit-ng--live-warn-face (today deadline warn-days)
  "Face for a live cell approaching DEADLINE, given TODAY and WARN-DAYS.
The shared X-WARN rule for the live/pending cell across the period, ceiling and
rolling models: ready (calm) while TODAY is at or before (- DEADLINE WARN-DAYS),
alert once it passes.  WARN-DAYS 0 keeps a cell ready right up to its DEADLINE."
  (if (> today (- deadline warn-days)) 'org-habit-alert-face 'org-habit-ready-face))

(defun org-habit-ng--render-period (instances today &optional seasonal warn-days)
  "Render period-mode cells (one per instance) from INSTANCES, given TODAY.
A skipped bucket draws `org-habit-ng-skipped-glyph' in clear face; its
help-echo appends the reason, e.g. \"2/3 · Jul 06 - Jul 12 · vacation\".
When SEASONAL is non-nil, a gap between two non-adjacent buckets (the elided
off-season) inserts ONE dim `org-habit-ng-off-season-face' separator cell, and
an empty INSTANCES list (a display window wholly off-season) renders a single
dim cell rather than a zero-width graph.

WARN-DAYS (default 0) is X-WARN for the period model: it governs ONLY the live
`pending' bucket (quota not yet met this period, whose deadline is its `:end').
That bucket shows the ready face while TODAY is at or before (- end WARN-DAYS)
and the alert face once TODAY passes that threshold -- mirroring the
ceiling/rolling live cell in `org-habit-ng--render-instances'.  With WARN-DAYS 0
the pending bucket is therefore ready for the whole live period (TODAY <= end),
a deliberate default change from the old unconditional alert.  Every other
status (satisfied/missed/skipped/upcoming) keeps its
`org-habit-ng--instance-face' colour unchanged."
  (let ((cells nil) (prev-end nil) (warn-days (or warn-days 0)))
    (dolist (inst instances)
      (let* ((start (plist-get inst :start))
             (end (plist-get inst :end))
             (status (plist-get inst :status))
             (suppressed-by (plist-get inst :suppressed-by))
             (done (length (or (plist-get inst :counted) (plist-get inst :completions))))
             (req (or (plist-get inst :times-required) 1))
             (face (if (eq status 'pending)
                       (org-habit-ng--live-warn-face today end warn-days)
                     (org-habit-ng--instance-face status (> start today))))
             (glyph (cond ((eq status 'satisfied) org-habit-completed-glyph)
                          ((eq status 'skipped) org-habit-ng-skipped-glyph)
                          ((eq status 'pending) org-habit-today-glyph)
                          (t ?\s)))
             (base (format "%d/%d%s · %s - %s"
                           done req (if (eq status 'satisfied) " DONE" "")
                           (format-time-string "%b %d" (org-habit-ng--occ-day-to-time start))
                           (format-time-string "%b %d" (org-habit-ng--occ-day-to-time end))))
             (echo (if (eq status 'skipped)
                       (format "%s · %s" base
                               (if (eq suppressed-by 'vacation) "vacation" "skipped"))
                     base)))
        (when (and seasonal prev-end (> start (1+ prev-end)))
          (push (org-habit-ng--period-off-season-cell (1+ prev-end) (1- start)) cells))
        (push (org-habit-ng--period-cell glyph face echo) cells)
        (setq prev-end end)))
    (when (and seasonal (null instances))
      (push (org-habit-ng--period-off-season-cell nil nil) cells))
    (apply #'concat (nreverse cells))))

(defun org-habit-ng--render-instances (instances today &optional warn-days)
  "Render one graph cell per member of INSTANCES (D2 walk), given TODAY.
Shared by `org-habit-ng--render-ceiling' and `org-habit-ng--render-rolling',
which under D2 emit one instance per expected occurrence (like the period
model), so both renderers reduce to this per-instance walk.  Each cell's face
comes from `org-habit-ng--instance-face' (satisfied->ready, missed->overdue,
pending->alert, skipped->clear, with a future variant when the instance's due
lies past TODAY); its glyph is the completed glyph for a satisfied completion,
the skip glyph for a paused (`skipped') block, the today glyph for the live
in-progress (`pending') instance (a `:ceiling-horizon' block), else a
space.

WARN-DAYS (default 0) redesigns X-WARN for the per-instance model: it
governs ONLY the LIVE PENDING cell (a `:ceiling-horizon' or `:live' instance
whose status is `pending'), whose horizon is its `:due'.  While TODAY is at
or before (- horizon WARN-DAYS) the live cell shows the ready face; once TODAY
passes that threshold it shows the alert face.  With WARN-DAYS 0 a pending cell
\(TODAY at or before its horizon) is therefore ready right up to the horizon --
the pre-D2 warn=0 behavior.  The today glyph stays on the live cell regardless
of face.  All non-pending and non-live cells keep the `--instance-face' colour;
this override never touches the period renderer, which does not call here.

Every cell carries a date-span help-echo \"STATUS · START - END\" naming
the block's `:window-start'/`:window-end' span (falling back to `:due' when
absent) -- a hard requirement so ceiling/rolling cells, which each cover a
natural interval rather than a single day, disclose their time scale."
  (let ((cells nil)
        (warn-days (or warn-days 0)))
    (dolist (inst instances)
      (let* ((status (plist-get inst :status))
             (due (or (plist-get inst :due)
                      (plist-get inst :window-start)))
             (ws (or (plist-get inst :window-start) due))
             (we (or (plist-get inst :window-end) due))
             (future-p (and due (> due today)))
             (live-p (or (plist-get inst :ceiling-horizon) (plist-get inst :live)))
             (face (if (and live-p (eq status 'pending) due)
                       (org-habit-ng--live-warn-face today due warn-days)
                     (org-habit-ng--instance-face status future-p)))
             (glyph (cond ((eq status 'satisfied) org-habit-completed-glyph)
                          ((eq status 'skipped) org-habit-ng-skipped-glyph)
                          ((and live-p (eq status 'pending)) org-habit-today-glyph)
                          (t ?\s)))
             (echo (format "%s · %s - %s"
                           (symbol-name status)
                           (format-time-string "%b %d" (org-habit-ng--occ-day-to-time ws))
                           (format-time-string "%b %d" (org-habit-ng--occ-day-to-time we)))))
        (push (org-habit-ng--period-cell glyph face echo) cells)))
    (apply #'concat (nreverse cells))))

(defun org-habit-ng--render-rolling (instances completions rule anchor-day
                                               start-day end-day today
                                               &optional skip-events vacation-ranges)
  "Render rolling-window cells, ONE per member of INSTANCES (D2).
Delegates to the shared per-instance walk `org-habit-ng--render-instances'.
The evaluator now emits one instance per `L = ceil(N / K_eff)' expected
occurrence (satisfied completion, missed block, skipped pause, or live block),
so this renderer is the plain per-instance walk shared with
`org-habit-ng--render-ceiling'.  COMPLETIONS, RULE, ANCHOR-DAY, START-DAY,
END-DAY, SKIP-EVENTS and VACATION-RANGES are accepted only for signature parity
with the `org-habit-ng--render-graph' dispatch: the walk is driven by INSTANCES
and TODAY (all pause/window math already lives in the evaluator), except RULE,
whose X-WARN (`:warn', default \"0d\") sets the WARN-DAYS controlling when this
rolling model's live pending cell turns from ready to alert as TODAY nears its
horizon."
  (ignore completions anchor-day start-day end-day skip-events vacation-ranges)
  (org-habit-ng--render-instances
   instances today
   (org-habit-ng--parse-warn-days (or (plist-get rule :warn) "0d"))))

(defun org-habit-ng--render-ceiling (instances completions rule anchor-day
                                              start-day end-day today
                                              &optional skip-events vacation-ranges)
  "Render maintenance-ceiling cells, ONE per member of INSTANCES (D2).
Delegates to the shared per-instance walk `org-habit-ng--render-instances'.
The evaluator now emits one instance per natural interval (satisfied completion,
missed block, skipped pause, or the live `:ceiling-horizon' block), so this
renderer is the plain per-instance walk shared with
`org-habit-ng--render-rolling'.  COMPLETIONS, RULE, ANCHOR-DAY, START-DAY,
END-DAY, SKIP-EVENTS and VACATION-RANGES are accepted only for signature parity
with the `org-habit-ng--render-graph' dispatch: the walk is driven by INSTANCES
and TODAY (reset/horizon/pause math already lives in the evaluator), except
RULE, whose X-WARN (`:warn', default \"0d\") sets the WARN-DAYS controlling when
this ceiling model's live pending cell turns from ready to alert as TODAY nears
its horizon."
  (ignore completions anchor-day start-day end-day skip-events vacation-ranges)
  (org-habit-ng--render-instances
   instances today
   (org-habit-ng--parse-warn-days (or (plist-get rule :warn) "0d"))))

(defun org-habit-ng--render-graph (habit starting current ending rule)
  "Unified RRULE consistency graph for HABIT from STARTING to ENDING.
CURRENT is today's reference day; RULE drives the success model, for
both point and period habits.  HABIT's SCHEDULED day anchors due-marks
\(fixes B1: due-marks are schedule-anchored, not window-edge-anchored).
Replaces `--build-graph' and `--build-period-graph'.  When
`org-habit-ng-score-in-graph-echo' is non-nil, prepends a one-line
\"Strength ...\" summary to every cell's help-echo (F5) -- computed in its OWN
`condition-case' so a scorer bug degrades to \"no score line\", never to
losing the graph (the surrounding `--around-build-graph' advice would
otherwise swap in the stock org-habit fallback on any uncaught error)."
  (let* ((completions (org-habit-done-dates habit))
         (anchor (org-habit-scheduled habit))
         (start-day (time-to-days starting))
         (today (time-to-days current))
         (end-day (time-to-days ending))
         (skip-events (nth 6 habit))
         (vacation-ranges (nth 7 habit))
         (instances (org-habit-ng-instances rule anchor completions
                                            start-day end-day today
                                            skip-events vacation-ranges))
         (graph (cond ((plist-get rule :window)
                       (org-habit-ng--render-rolling instances completions rule anchor
                                                     start-day end-day today
                                                     skip-events vacation-ranges))
                      ((plist-get rule :period)
                       (org-habit-ng--render-period
                        instances today
                        (org-habit-ng--rule-seasonal-p rule)
                        (org-habit-ng--parse-warn-days
                         (or (plist-get rule :warn) "0d"))))
                      ((plist-get rule :ceiling)
                       (org-habit-ng--render-ceiling instances completions rule anchor
                                                     start-day end-day today
                                                     skip-events vacation-ranges))
                      (t
                       (org-habit-ng--render-point instances completions rule anchor
                                                   start-day end-day today
                                                   skip-events vacation-ranges)))))
    (when org-habit-ng-score-in-graph-echo
      (condition-case err
          (let ((line (org-habit-ng--score-echo-line rule anchor completions
                                                      start-day end-day today
                                                      skip-events vacation-ranges)))
            (when line
              (org-habit-ng--prepend-graph-echo graph line)))
        (error
         (message "org-habit-ng: score echo error: %s" (error-message-string err)))))
    graph))

(defun org-habit-ng--get-rrule-from-agenda-marker ()
  "Get parsed RRULE from the org entry referenced by current agenda line.
Checks the `org-marker' text property at point first, then falls back to
`line-beginning-position'.  This matters because `org-habit-build-graph' is
called with point already moved to `org-habit-graph-column' (via
`move-to-column' with force, which pads short lines with unpropertized
spaces/EOL).  For any heading shorter than the graph column, point at call
time sits past the propertized heading text, so a point-only lookup would
return nil even though the line is a normal agenda entry.
Returns parsed RRULE plist or nil if no RECURRENCE property."
  (let ((marker (or (get-text-property (point) 'org-marker)
                     (get-text-property (line-beginning-position) 'org-marker))))
    (when (and marker (marker-buffer marker))
      (with-current-buffer (marker-buffer marker)
        (save-excursion
          (goto-char marker)
          (let ((recurrence (org-entry-get nil org-habit-ng-recurrence-property)))
            ;; F8: widened from a bare "FREQ=" check so an @-macro RECURRENCE
            ;; (e.g. "@monthly;BYMONTHDAY=1;X-SKIP=NEAREST", which contains no
            ;; literal "FREQ=" substring before macro expansion) still gates
            ;; the RRULE graph -- otherwise the habit silently renders the
            ;; stock org-habit graph instead (the B11 failure class).  The
            ;; surrounding caller is condition-case-wrapped, so a bad "@..."
            ;; string still degrades gracefully.
            (when (and recurrence (string-match-p "\\`[ \t]*@\\|FREQ=" recurrence))
              (org-habit-ng-parse-rrule recurrence))))))))

(defun org-habit-ng--around-build-graph (orig-fun habit starting current ending)
  "Advice: falls back to ORIG-FUN on any failure rendering HABIT's graph.
Renders RRULE-aware graphs over [STARTING, ENDING] relative to
CURRENT.  The RRULE parse happens INSIDE the `condition-case' so an
unparseable RECURRENCE degrades gracefully rather than aborting the
agenda render."
  (condition-case err
      (let ((rule (org-habit-ng--get-rrule-from-agenda-marker)))
        (if rule
            (org-habit-ng--render-graph habit starting current ending rule)
          (funcall orig-fun habit starting current ending)))
    (error
     (message "org-habit-ng graph error: %s, using fallback" (error-message-string err))
     (funcall orig-fun habit starting current ending))))

(defun org-habit-ng--enable-graph-advice ()
  "Enable advice on `org-habit-build-graph'."
  (advice-add 'org-habit-build-graph :around #'org-habit-ng--around-build-graph))

(defun org-habit-ng--disable-graph-advice ()
  "Disable advice on `org-habit-build-graph'."
  (advice-remove 'org-habit-build-graph #'org-habit-ng--around-build-graph))


;;;###autoload
(define-minor-mode org-habit-ng-mode
  "Minor mode for complex recurrence patterns in org-habit.
When enabled, habits with a RECURRENCE property use RRULE-based
scheduling instead of org-mode's simple repeaters.

This mode manages:
- Advice on `org-auto-repeat-maybe' for RRULE-based next occurrence
- Advice on `org-habit-parse-todo' for accurate interval reporting
- Advice on `org-habit-build-graph' for accurate consistency graphs"
  :global t
  :lighter nil
  (if org-habit-ng-mode
      (progn
        (org-habit-ng--enable-advice)
        (org-habit-ng--enable-parse-advice)
        (org-habit-ng--enable-graph-advice))
    (org-habit-ng--disable-advice)
    (org-habit-ng--disable-parse-advice)
    (org-habit-ng--disable-graph-advice)))

(provide 'org-habit-ng)
;;; org-habit-ng.el ends here
