;;; countdown-modeline.el --- Display a color-coded countdown in the modeline -*- lexical-binding: t -*-

;; Author: Jeffrey Holland <jeff.holland@gmail.com>
;; Maintainer: Jeffrey Holland <jeff.holland@gmail.com>
;; Package-Version: 1.4.0
;; Package-Revision: v1.4.0-0-ga07cf0b1d0e7
;; URL: https://github.com/jholland82/countdown-modeline
;; Keywords: convenience, modeline
;; Package-Requires: ((emacs "27.1"))
;; SPDX-License-Identifier: GPL-3.0-or-later

;; This file is NOT part of GNU Emacs.

;; 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:

;; countdown-modeline displays the soonest upcoming event from
;; `countdown-modeline-events' as a day countdown in the Emacs modeline.
;; Past events are skipped automatically, and the countdown refreshes at
;; local midnight via an internal timer (no external setup required).
;;
;; Anniversaries (birthdays, wedding anniversaries, memorial dates,
;; etc.) are recurring events: the countdown advances to the next
;; annual occurrence rather than expiring.  An anniversary's stored
;; date may be a full YYYY-MM-DD (preserving the original year, e.g.
;; a birth year) or a yearless MM-DD.
;;
;; Events may also be read from Org files.  When
;; `countdown-modeline-org-enabled' is non-nil, headlines matching
;; `countdown-modeline-org-match' (by default those tagged :countdown:)
;; are scanned out of the Org agenda files and merged into the event
;; list.  Org events are derived, not owned: they are never written to
;; `countdown-modeline-events-file', and are refreshed with
;; `countdown-modeline-org-refresh'.
;;
;; The text color changes as the event approaches:
;;
;;   - Green  : 10+ days remaining
;;   - Yellow : 5-9 days remaining
;;   - Red    : fewer than 5 days remaining
;;
;; Usage:
;;
;;   (require 'countdown-modeline)
;;   (setq countdown-modeline-events
;;         '(("Launch Day" "2026-12-25" "🚀")
;;           ("Vacation"   "2026-07-01" "🏖️")
;;           ("Standup"    "2026-05-10")            ; emoji is optional
;;           ("Mom's Birthday" "1955-06-15" "🎂" t) ; t = anniversary
;;           ("Wedding"        "06-15"      "💍" t)))
;;   (setq countdown-modeline-org-enabled t)        ; optional Org source
;;   (countdown-modeline-mode 1)
;;
;; Run M-x countdown-modeline-list-events to discover the available
;; commands.  Works with doom-modeline and the default modeline.

;;; Code:

(require 'seq)
(require 'subr-x)
(require 'time-date)

;; Org is loaded on demand by `countdown-modeline--org-scan'; it is not a
;; hard dependency of this package.
(declare-function org-map-entries "org" (func &optional match scope &rest skip))
(declare-function org-entry-get "org" (epom property &optional inherit literal-nil))
(declare-function org-entry-is-done-p "org" ())
(declare-function org-get-heading "org"
                  (&optional no-tags no-todo no-priority no-comment))
(declare-function org-get-tags "org" (&optional epom local))

(defgroup countdown-modeline nil
  "Display days until an event in the modeline."
  :group 'mode-line)

(defcustom countdown-modeline-events nil
  "List of events to count down to.
Each entry is a list (NAME DATE &optional PREFIX ANNIVERSARY-P):
  - NAME is the event's display name.
  - DATE is a string in YYYY-MM-DD format, or MM-DD for a yearless
    anniversary.
  - PREFIX is an optional string (typically an emoji) shown
    before the name.
  - ANNIVERSARY-P, when non-nil, marks the event as recurring; the
    countdown then advances to the next annual occurrence of DATE
    rather than expiring.  A yearless MM-DD date is only valid
    when ANNIVERSARY-P is non-nil.

The soonest upcoming event is shown in the modeline; past events
are skipped, but anniversaries always remain visible because their
\"days until\" is computed against the next occurrence.

Example:
  (setq countdown-modeline-events
        \\='((\"Launch Day\" \"2026-12-25\" \"🚀\")
          (\"Vacation\"   \"2026-07-01\" \"🏖️\")
          (\"Standup\"    \"2026-05-10\")
          (\"Mom's Birthday\" \"1955-06-15\" \"🎂\" t)
          (\"Wedding\"        \"06-15\"      \"💍\" t)))"
  :type '(repeat (list (string :tag "Name")
                       (string :tag "Date (YYYY-MM-DD or MM-DD)")
                       (choice (const :tag "No prefix" nil)
                               (string :tag "Prefix"))
                       (boolean :tag "Anniversary")))
  :set (lambda (sym val)
         (set-default sym val)
         (when (bound-and-true-p countdown-modeline-mode)
           (countdown-modeline--update)))
  :group 'countdown-modeline)

(defcustom countdown-modeline-events-file
  (locate-user-emacs-file "countdown-modeline-events.eld")
  "File used to persist `countdown-modeline-events'.
`countdown-modeline-save-events' writes the current value here, and
`countdown-modeline-load-events' reads it back.  The file holds a
single Lisp form, safe to edit by hand:

  ;;; countdown-modeline events.  Auto-generated; safe to edit.
  (:format-version 2
   :events ((\"Launch Day\" \"2026-12-25\" \"🚀\")
            (\"Vacation\"   \"2026-07-01\" \"🏖️\")
            (\"Standup\"    \"2026-05-10\")
            (\"Mom's Birthday\" \"1955-06-15\" \"🎂\" t)
            (\"Wedding\"        \"06-15\"      \"💍\" t)))

For backward compatibility, files containing only a bare events
list (no envelope) are also accepted on load."
  :type 'file
  :group 'countdown-modeline)

(defcustom countdown-modeline-pinned-event nil
  "Name of the event to display in the modeline, or nil for auto.
When nil, the soonest upcoming event is shown (the default behavior).
When set to a string naming an upcoming event, that event is shown
instead.  If the named event is missing or has passed, the display
silently falls back to the soonest upcoming event.

Set interactively via `countdown-modeline-pin-event' and cleared via
`countdown-modeline-unpin-event'."
  :type '(choice (const :tag "Auto (soonest upcoming)" nil)
                 (string :tag "Event name"))
  :set (lambda (sym val)
         (set-default sym val)
         (when (bound-and-true-p countdown-modeline-mode)
           (countdown-modeline--update)))
  :group 'countdown-modeline)

(defcustom countdown-modeline-save-events-on-change nil
  "If non-nil, write events to disk after every add or remove.
Applies whether the change is made interactively or programmatically.
When nil (the default), call `countdown-modeline-save-events'
explicitly to persist changes."
  :type 'boolean
  :group 'countdown-modeline)

;; Declared ahead of the Org options below: `custom-initialize-reset' runs
;; each `:set' function once at definition time, before the rest of the
;; Org section has been evaluated.
(defvar countdown-modeline--org-cache nil
  "Events from the most recent Org scan.")

(defvar countdown-modeline--org-cache-valid nil
  "Non-nil when `countdown-modeline--org-cache' holds a usable scan.
Tracked separately from the cache itself because an empty scan is a
legitimate result that should not trigger a rescan.")

(defvar countdown-modeline--org-cache-time nil
  "Time of the most recent Org scan, for `countdown-modeline-org-cache-ttl'.")

(defun countdown-modeline--org-invalidate-cache ()
  "Discard the cached Org scan so the next lookup rescans."
  (setq countdown-modeline--org-cache nil
        countdown-modeline--org-cache-valid nil
        countdown-modeline--org-cache-time nil))

(defun countdown-modeline--org-set (sym val)
  "Set SYM to VAL, drop the Org scan cache, and refresh the modeline.
Used as the `:set' function for the Org-source options, all of which
change what a scan would return."
  (set-default sym val)
  (countdown-modeline--org-invalidate-cache)
  (when (bound-and-true-p countdown-modeline-mode)
    (countdown-modeline--update)))

(defcustom countdown-modeline-org-enabled nil
  "If non-nil, also read events from Org files.
Headlines matching `countdown-modeline-org-match' in
`countdown-modeline-org-files' are scanned and merged into the event
list.  Off by default: scanning opens every agenda file, which is
noticeable on a large setup.

Org events are derived rather than owned.  They are never written to
`countdown-modeline-events-file' by `countdown-modeline-save-events',
and `countdown-modeline-remove-event' refuses to delete them.  Results
are cached; see `countdown-modeline-org-refresh'."
  :type 'boolean
  :set #'countdown-modeline--org-set
  :group 'countdown-modeline)

(defcustom countdown-modeline-org-match "countdown"
  "Match string selecting which Org headlines become events.
Passed as the MATCH argument to `org-map-entries', so it accepts the
full Org tags/property match syntax.  The default, \"countdown\",
selects headlines tagged :countdown:.

Examples:
  \"countdown\"                    ; tagged :countdown:
  \"countdown|birthday\"           ; either tag
  \"DEADLINE<\\\"<+90d>\\\"\"           ; any deadline in the next 90 days"
  :type 'string
  :set #'countdown-modeline--org-set
  :group 'countdown-modeline)

(defcustom countdown-modeline-org-files 'agenda
  "Which Org files `countdown-modeline' scans for events.
Either the symbol `agenda', meaning the current `org-agenda-files',
or an explicit list of file names."
  :type '(choice (const :tag "Org agenda files" agenda)
                 (repeat :tag "Specific files" file))
  :set #'countdown-modeline--org-set
  :group 'countdown-modeline)

(defcustom countdown-modeline-org-timestamp-order
  '(deadline scheduled timestamp)
  "Order in which an Org headline's timestamps are consulted for a date.
The first entry that yields a valid date wins.  Recognized symbols:

  `deadline'  - the headline's DEADLINE
  `scheduled' - the headline's SCHEDULED
  `timestamp' - the first active timestamp in the entry

Drop an entry from the list to ignore that kind of timestamp entirely."
  :type '(repeat (choice (const :tag "DEADLINE" deadline)
                         (const :tag "SCHEDULED" scheduled)
                         (const :tag "Active timestamp" timestamp)))
  :set #'countdown-modeline--org-set
  :group 'countdown-modeline)

(defcustom countdown-modeline-org-skip-done t
  "If non-nil, Org headlines in a DONE state are not turned into events."
  :type 'boolean
  :set #'countdown-modeline--org-set
  :group 'countdown-modeline)

(defcustom countdown-modeline-org-tag-prefixes nil
  "Alist mapping an Org tag to a prefix string, typically an emoji.
Consulted only when a headline has no COUNTDOWN_PREFIX property; the
first tag with an entry here wins.  Inherited tags count.

Example:
  (setq countdown-modeline-org-tag-prefixes
        \\='((\"birthday\" . \"🎂\")
          (\"travel\"   . \"✈️\")))"
  :type '(alist :key-type (string :tag "Tag")
                :value-type (string :tag "Prefix"))
  :set #'countdown-modeline--org-set
  :group 'countdown-modeline)

(defcustom countdown-modeline-org-cache-ttl nil
  "How long a cached Org scan stays fresh, in seconds, or nil.
When nil, a scan is reused until it is explicitly invalidated: by
`countdown-modeline-org-refresh', by `countdown-modeline-refresh', by
changing an Org source option, or by the nightly midnight tick.  Set a
number to additionally expire the cache after that many seconds."
  :type '(choice (const :tag "Until explicitly refreshed" nil)
                 (integer :tag "Seconds"))
  :group 'countdown-modeline)

(defface countdown-modeline-green
  '((((class color) (min-colors 88) (background light))
     :foreground "#1d7a1d")
    (((class color) (min-colors 88) (background dark))
     :foreground "#51cf66")
    (t :inherit success))
  "Face for countdown when more than 10 days remain."
  :group 'countdown-modeline)

(defface countdown-modeline-yellow
  '((((class color) (min-colors 88) (background light))
     :foreground "#946a00")
    (((class color) (min-colors 88) (background dark))
     :foreground "#fcc419")
    (t :inherit warning))
  "Face for countdown when 5-9 days remain."
  :group 'countdown-modeline)

(defface countdown-modeline-red
  '((((class color) (min-colors 88) (background light))
     :foreground "#c92a2a")
    (((class color) (min-colors 88) (background dark))
     :foreground "#ff6b6b")
    (t :inherit error))
  "Face for countdown when fewer than 5 days remain."
  :group 'countdown-modeline)

(defvar countdown-modeline--string nil
  "Current modeline string.")

(defvar countdown-modeline--timer nil
  "Timer that refreshes the countdown at the next local midnight.")

(defconst countdown-modeline--save-format-version 2
  "Current version of the persisted events file format.
Files written by `countdown-modeline-save-events' carry this version.
`countdown-modeline-load-events' rejects files whose version is
greater than this number.

Version history:
  1 - entries of shape (NAME DATE) or (NAME DATE PREFIX).
  2 - adds optional ANNIVERSARY-P flag and yearless MM-DD dates;
      entries may be 4 elements long: (NAME DATE PREFIX ANNIVERSARY-P).")

(defun countdown-modeline--today ()
  "Return today's absolute day number in local time."
  (time-to-days (current-time)))

(defun countdown-modeline--parse-date (date)
  "Return the absolute day number for DATE, or nil if DATE is invalid.
DATE is a string in YYYY-MM-DD format.  Validation is both syntactic
and semantic: \"2026-13-45\" is rejected by round-tripping through
`encode-time'."
  (when (and (stringp date)
             (string-match-p "\\`[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}\\'" date))
    (let* ((year    (string-to-number (substring date 0 4)))
           (month   (string-to-number (substring date 5 7)))
           (day     (string-to-number (substring date 8 10)))
           (encoded (encode-time 0 0 0 day month year)))
      (when (equal date (format-time-string "%Y-%m-%d" encoded))
        (time-to-days encoded)))))

(defun countdown-modeline--parse-yearless-date (date)
  "Return (MONTH . DAY) for DATE if it is a valid MM-DD string, else nil.
Feb 29 is treated as valid (handled per-year by callers)."
  (when (and (stringp date)
             (string-match-p "\\`[0-9]\\{2\\}-[0-9]\\{2\\}\\'" date))
    (let ((month (string-to-number (substring date 0 2)))
          (day   (string-to-number (substring date 3 5))))
      (when (and (<= 1 month 12) (<= 1 day 31))
        ;; Round-trip through a leap year so Feb 29 is accepted.
        (let ((encoded (encode-time 0 0 0 day month 2024)))
          (when (equal (format "2024-%02d-%02d" month day)
                       (format-time-string "%Y-%m-%d" encoded))
            (cons month day)))))))

(defun countdown-modeline--mmdd-abs (year month day)
  "Return the absolute day number for YEAR/MONTH/DAY, or nil if invalid.
Feb 29 in a non-leap YEAR is mapped to Feb 28 of the same YEAR (the
common civil convention for leap-day anniversaries)."
  (let* ((encoded (encode-time 0 0 0 day month year))
         (expected (format "%04d-%02d-%02d" year month day))
         (actual (format-time-string "%Y-%m-%d" encoded)))
    (cond
     ((equal expected actual) (time-to-days encoded))
     ((and (= month 2) (= day 29))
      (time-to-days (encode-time 0 0 0 28 2 year)))
     (t nil))))

(defun countdown-modeline--days-until (date)
  "Return days from today until DATE, or nil if DATE is invalid.
The result may be negative for past dates."
  (let ((target (countdown-modeline--parse-date date)))
    (when target (- target (countdown-modeline--today)))))

(defun countdown-modeline--days-until-anniversary (date)
  "Return days from today until the next annual occurrence of DATE.
DATE may be YYYY-MM-DD or MM-DD; only the month and day are used.
Returns nil if DATE is malformed.  The result is always non-negative:
if this year's occurrence has passed, the result counts to next year."
  (let ((md (or (countdown-modeline--parse-yearless-date date)
                (when (countdown-modeline--parse-date date)
                  (cons (string-to-number (substring date 5 7))
                        (string-to-number (substring date 8 10)))))))
    (when md
      (let* ((month (car md))
             (day (cdr md))
             (today (countdown-modeline--today))
             (this-year (decoded-time-year (decode-time)))
             (target (countdown-modeline--mmdd-abs this-year month day)))
        (if (and target (>= (- target today) 0))
            (- target today)
          (- (countdown-modeline--mmdd-abs (1+ this-year) month day)
             today))))))

(defun countdown-modeline--event-anniversary-p (event)
  "Return non-nil if EVENT is flagged as an anniversary."
  (nth 3 event))

(defun countdown-modeline--days-until-event (event)
  "Return days until EVENT's next display occurrence, or nil if invalid.
For anniversaries, always returns a non-negative count to the next
annual occurrence.  For non-anniversaries, returns the literal signed
days from today to the stored date (negative for past)."
  (if (countdown-modeline--event-anniversary-p event)
      (countdown-modeline--days-until-anniversary (nth 1 event))
    (countdown-modeline--days-until (nth 1 event))))

;;; Org source
;;
;; Events read from Org are shaped exactly like configured ones, with an
;; extra fifth element carrying provenance:
;;
;;   (NAME DATE PREFIX ANNIVERSARY-P (:source org :file FILE :point POS))
;;
;; Everything downstream reads elements 0-3 only, so the tail is inert to
;; the countdown logic and is used just for display and error messages.

(defconst countdown-modeline--org-prefix-property "COUNTDOWN_PREFIX"
  "Org property holding an event's prefix string (typically an emoji).")

(defconst countdown-modeline--org-anniversary-property "COUNTDOWN_ANNIVERSARY"
  "Org property that marks a headline as a recurring anniversary.
Overrides the repeater-based inference either way; set it to \"nil\"
to stop a yearly repeater from being read as an anniversary.")

(defconst countdown-modeline--org-date-regexp
  "[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}"
  "Regexp matching the date part of an Org timestamp.")

(defconst countdown-modeline--org-yearly-repeater-regexp
  "[.+]?\\+[0-9]+y"
  "Regexp matching a yearly Org repeater: +1y, ++1y or .+1y.")

(defun countdown-modeline--org-timestamp-date (timestamp)
  "Return the YYYY-MM-DD date inside Org TIMESTAMP, or nil.
TIMESTAMP is a raw Org timestamp string such as
\"<2026-12-25 Fri +1y>\".  The date is validated with
`countdown-modeline--parse-date', so a syntactically well-formed but
impossible date yields nil."
  (when (and (stringp timestamp)
             (string-match countdown-modeline--org-date-regexp timestamp))
    (let ((date (match-string 0 timestamp)))
      (when (countdown-modeline--parse-date date) date))))

(defun countdown-modeline--org-yearly-repeater-p (timestamp)
  "Return t if Org TIMESTAMP carries a yearly repeater, else nil.
Normalized to a boolean so the anniversary flag stored in an Org event
looks like the one on a configured event rather than a match position."
  (and (stringp timestamp)
       (string-match-p countdown-modeline--org-yearly-repeater-regexp
                       timestamp)
       t))

(defun countdown-modeline--org-entry-timestamp ()
  "Return the raw Org timestamp to use for the entry at point, or nil.
`countdown-modeline-org-timestamp-order' decides which kinds are
consulted and in what order; the first one holding a valid date wins."
  (seq-some
   (lambda (kind)
     (let ((property (pcase kind
                       ('deadline  "DEADLINE")
                       ('scheduled "SCHEDULED")
                       ('timestamp "TIMESTAMP"))))
       (when property
         (let ((timestamp (org-entry-get nil property)))
           (and (countdown-modeline--org-timestamp-date timestamp)
                timestamp)))))
   countdown-modeline-org-timestamp-order))

(defun countdown-modeline--org-entry-prefix ()
  "Return the prefix string for the Org entry at point, or nil.
The COUNTDOWN_PREFIX property wins; otherwise the first of the entry's
tags found in `countdown-modeline-org-tag-prefixes' is used."
  (let ((explicit (org-entry-get nil countdown-modeline--org-prefix-property)))
    (if (and explicit (not (string-blank-p explicit)))
        explicit
      (when countdown-modeline-org-tag-prefixes
        (seq-some (lambda (tag)
                    (cdr (assoc tag countdown-modeline-org-tag-prefixes)))
                  (org-get-tags))))))

(defun countdown-modeline--org-entry-anniversary-p (timestamp)
  "Return non-nil if the Org entry at point is a recurring anniversary.
The COUNTDOWN_ANNIVERSARY property decides when present; otherwise a
yearly repeater on TIMESTAMP implies an anniversary."
  ;; LITERAL-NIL keeps a value of "nil" as the string, so an explicit
  ;; :COUNTDOWN_ANNIVERSARY: nil is distinguishable from an absent property.
  (let ((property
         (org-entry-get nil countdown-modeline--org-anniversary-property
                        nil t)))
    (cond
     ((null property) (countdown-modeline--org-yearly-repeater-p timestamp))
     ((member (downcase (string-trim property)) '("" "nil" "no" "false")) nil)
     (t t))))

(defun countdown-modeline--org-entry-event ()
  "Return an event for the Org entry at point, or nil.
Returns nil when the entry is DONE and
`countdown-modeline-org-skip-done' is set, when it has no usable
timestamp, or when its headline text is empty.  Intended as the FUNC
argument to `org-map-entries'."
  (unless (and countdown-modeline-org-skip-done (org-entry-is-done-p))
    (let* ((timestamp (countdown-modeline--org-entry-timestamp))
           (date (countdown-modeline--org-timestamp-date timestamp))
           (name (org-get-heading t t t t)))
      (when (and date name (not (string-blank-p name)))
        (list name
              date
              (countdown-modeline--org-entry-prefix)
              (countdown-modeline--org-entry-anniversary-p timestamp)
              (list :source 'org
                    :file (buffer-file-name (buffer-base-buffer))
                    :point (point)))))))

(defun countdown-modeline--org-scan ()
  "Scan the configured Org files and return the events found.
Returns nil, with a warning, if Org is unavailable."
  (if (not (require 'org nil t))
      (progn
        (display-warning
         'countdown-modeline
         "Org is not available; no Org events were read" :warning)
        nil)
    (delq nil
          (org-map-entries #'countdown-modeline--org-entry-event
                           countdown-modeline-org-match
                           (if (eq countdown-modeline-org-files 'agenda)
                               'agenda
                             countdown-modeline-org-files)))))

(defun countdown-modeline--org-cache-expired-p ()
  "Return non-nil if the cached Org scan has outlived its TTL."
  (and countdown-modeline-org-cache-ttl
       countdown-modeline--org-cache-time
       (> (float-time (time-since countdown-modeline--org-cache-time))
          countdown-modeline-org-cache-ttl)))

(defun countdown-modeline--org-events ()
  "Return events read from Org, rescanning only when the cache is stale.
Returns nil when `countdown-modeline-org-enabled' is nil.  A scan that
signals is reported as a warning and cached as an empty result, so one
broken Org file cannot take the modeline down or cause a rescan storm."
  (when countdown-modeline-org-enabled
    (when (or (not countdown-modeline--org-cache-valid)
              (countdown-modeline--org-cache-expired-p))
      (setq countdown-modeline--org-cache
            (condition-case err
                (countdown-modeline--org-scan)
              (error
               (display-warning
                'countdown-modeline
                (format "Org scan failed: %s" (error-message-string err))
                :warning)
               nil))
            countdown-modeline--org-cache-valid t
            countdown-modeline--org-cache-time (current-time)))
    countdown-modeline--org-cache))

(defun countdown-modeline--event-source (event)
  "Return the symbol naming where EVENT came from: `manual' or `org'."
  (or (plist-get (nth 4 event) :source) 'manual))

(defun countdown-modeline--event-org-p (event)
  "Return non-nil if EVENT was read from an Org file."
  (eq (countdown-modeline--event-source event) 'org))

(defun countdown-modeline--event-file (event)
  "Return the Org file EVENT came from, or nil for a configured event."
  (plist-get (nth 4 event) :file))

(defun countdown-modeline--all-events ()
  "Return every known event: `countdown-modeline-events' plus Org events.
Names are unique across the result.  A configured event shadows an Org
event of the same name, and among Org events the first one scanned
wins, since NAME is the identity used for pinning and lookup."
  (let ((names (mapcar #'car countdown-modeline-events))
        (extra nil))
    (dolist (event (countdown-modeline--org-events))
      (let ((name (car event)))
        (unless (member name names)
          (push name names)
          (push event extra))))
    (append countdown-modeline-events (nreverse extra))))

(defun countdown-modeline--next-event ()
  "Return (NAME DAYS PREFIX) for the event to display, or nil.
If `countdown-modeline-pinned-event' names an event whose effective
days remaining is non-negative, that event is returned.  Otherwise
the event with the smallest non-negative days remaining is returned.
Anniversaries always have a non-negative count (to their next annual
occurrence) and so always remain eligible; one-time past events and
events with invalid dates are skipped.  A stale pin silently falls
back to the soonest event.

Configured and Org events are considered together; see
`countdown-modeline--all-events'."
  (let (pinned soonest)
    (dolist (event (countdown-modeline--all-events))
      (let ((days (countdown-modeline--days-until-event event)))
        (when (and days (>= days 0))
          (let ((name (nth 0 event))
                (prefix (nth 2 event)))
            (when (and countdown-modeline-pinned-event
                       (equal name countdown-modeline-pinned-event))
              (setq pinned (list name days prefix)))
            (when (or (null soonest) (< days (cadr soonest)))
              (setq soonest (list name days prefix)))))))
    (or pinned soonest)))

(defun countdown-modeline--valid-events-p (events)
  "Return non-nil if EVENTS is a well-formed events list.
A well-formed list contains entries of the form
  (NAME DATE)
  (NAME DATE PREFIX)
  (NAME DATE PREFIX ANNIVERSARY-P)
where NAME is a string, DATE is a YYYY-MM-DD or MM-DD string, PREFIX
is a string or nil, and ANNIVERSARY-P is a boolean.  A yearless
MM-DD DATE is only valid when ANNIVERSARY-P is non-nil."
  (and (listp events)
       (seq-every-p
        (lambda (e)
          (and (listp e)
               (<= 2 (length e) 4)
               (stringp (nth 0 e))
               (stringp (nth 1 e))
               (or (null (nth 2 e)) (stringp (nth 2 e)))
               (or (countdown-modeline--parse-date (nth 1 e))
                   (and (countdown-modeline--parse-yearless-date (nth 1 e))
                        (nth 3 e)))))
        events)))

(defun countdown-modeline--extract-events (form)
  "Return the validated events list contained in FORM.
FORM is the value `read' from `countdown-modeline-events-file'.
Two shapes are recognized:

  - A bare events list (legacy/unversioned), treated as
    `countdown-modeline--save-format-version' = 1.
  - A versioned plist of the shape
    \(:format-version N :events EVENTS).

Signals `user-error' if FORM is unrecognized, the version is
newer than this build supports, or :events is malformed."
  (let ((events
         (cond
          ;; Legacy bare events list.
          ((countdown-modeline--valid-events-p form) form)
          ;; Versioned plist envelope.
          ((and (listp form) (keywordp (car-safe form)))
           (let ((version (plist-get form :format-version)))
             (unless (integerp version)
               (user-error "Missing or invalid :format-version"))
             (when (> version countdown-modeline--save-format-version)
               (user-error
                "File format version %d is newer than supported (%d)"
                version countdown-modeline--save-format-version))
             (unless (plist-member form :events)
               (user-error "File envelope is missing the :events key"))
             (plist-get form :events)))
          (t (user-error "Unrecognized events file format")))))
    (unless (countdown-modeline--valid-events-p events)
      (user-error "Malformed :events list"))
    events))

(defun countdown-modeline--face (days)
  "Return the appropriate face for DAYS remaining."
  (cond
   ((< days 5)  'countdown-modeline-red)
   ((< days 10) 'countdown-modeline-yellow)
   (t           'countdown-modeline-green)))

(defun countdown-modeline--update ()
  "Update the modeline countdown string."
  (let ((next (countdown-modeline--next-event)))
    (setq countdown-modeline--string
          (if next
              (pcase-let* ((`(,name ,days ,prefix) next)
                           (face (countdown-modeline--face days)))
                (propertize (format " %s%s %d Day%s "
                                    (if prefix (concat prefix " ") "")
                                    name
                                    days
                                    (if (= days 1) "" "s"))
                            'face face))
            ""))
    (force-mode-line-update)))

(defun countdown-modeline--schedule-midnight ()
  "(Re)schedule a timer to refresh the countdown at the next local midnight.
The timer's handler also reschedules itself, so the countdown keeps
refreshing every day for as long as `countdown-modeline-mode' is on."
  (when (timerp countdown-modeline--timer)
    (cancel-timer countdown-modeline--timer))
  (let* ((d (decode-time))
         ;; encode-time normalizes day overflow, so day+1 handles month
         ;; and year boundaries.
         (next-midnight (encode-time 0 0 0
                                     (1+ (decoded-time-day d))
                                     (decoded-time-month d)
                                     (decoded-time-year d))))
    (setq countdown-modeline--timer
          (run-at-time next-midnight nil
                       #'countdown-modeline--midnight-tick))))

(defvar countdown-modeline-mode)

(defun countdown-modeline--midnight-tick ()
  "Refresh the countdown and reschedule the next midnight tick.
The Org scan cache is dropped as well, so edits made to Org files
during the day are picked up at least once a night.  No-ops if
`countdown-modeline-mode' was disabled between scheduling and firing."
  (when countdown-modeline-mode
    (countdown-modeline--org-invalidate-cache)
    (countdown-modeline--update)
    (countdown-modeline--schedule-midnight)))

;;;###autoload
(defun countdown-modeline-refresh ()
  "Recompute the modeline countdown string, rescanning Org files.
Call this after setting `countdown-modeline-events' with `setq'.
Not needed when the variable is changed via `customize' or `setopt',
or via `countdown-modeline-add-event'/`-remove-event'/`-load-events'."
  (interactive)
  (countdown-modeline--org-invalidate-cache)
  (countdown-modeline--update))

;;;###autoload
(defun countdown-modeline-org-refresh ()
  "Rescan the Org files for events and refresh the modeline.
Returns the number of Org events found.  Use this after editing an Org
file, since scans are cached until invalidated (see
`countdown-modeline-org-cache-ttl')."
  (interactive)
  (countdown-modeline--org-invalidate-cache)
  (let ((count (length (countdown-modeline--org-events))))
    (countdown-modeline--update)
    (when (called-interactively-p 'interactive)
      (message
       (if countdown-modeline-org-enabled
           (format "Found %d Org event%s" count (if (= count 1) "" "s"))
         "Org events are off; enable `countdown-modeline-org-enabled'")))
    count))

;;;###autoload
(defun countdown-modeline-add-event (name date &optional prefix anniversary-p)
  "Add an event with NAME, DATE, optional PREFIX, and optional ANNIVERSARY-P.
DATE is a YYYY-MM-DD string, or MM-DD when ANNIVERSARY-P is non-nil.
PREFIX is a string (typically an emoji) shown before the name; an
empty string is treated as nil.  ANNIVERSARY-P, when non-nil, marks
the event as recurring: its countdown advances to the next annual
occurrence rather than expiring after the date passes.

If an event with the same NAME already exists, it is updated.
Giving NAME that of an Org event does not touch the Org file: it
creates a configured event that shadows it, since configured events
win on a name collision.

Signals a `user-error' when NAME is empty, DATE is malformed, or
DATE is yearless without ANNIVERSARY-P set.

Interactively, completion is offered over existing names, Org
events included, and their values prefill the prompts.  A
yearless or past date triggers a y/n prompt for anniversary status;
for a yearless date, declining is rejected as an incorrect event."
  (interactive
   (let* ((all (countdown-modeline--all-events))
          (existing-names (mapcar #'car all))
          (name (completing-read "Event name: " existing-names nil nil))
          (existing (cdr (assoc name all)))
          (default-date (or (car existing) ""))
          (default-prefix (or (cadr existing) ""))
          (default-anniv (nth 2 existing))
          (date (countdown-modeline--read-date-interactively default-date))
          (anniversary-p (countdown-modeline--read-anniversary-interactively
                          date default-anniv))
          (raw-prefix (read-string
                       (format "Prefix, e.g. emoji (RET to %s): "
                               (if (string-blank-p default-prefix)
                                   "skip"
                                 (format "keep %s" default-prefix)))
                       nil nil default-prefix)))
     (list name date
           (and (not (string-blank-p raw-prefix)) raw-prefix)
           anniversary-p)))
  (when (or (not (stringp name)) (string-blank-p name))
    (user-error "Event name must be a non-empty string"))
  (let ((full (countdown-modeline--parse-date date))
        (partial (countdown-modeline--parse-yearless-date date)))
    (cond
     ((not (or full partial))
      (user-error "Invalid event date %S (expected YYYY-MM-DD or MM-DD)" date))
     ((and partial (not anniversary-p))
      (user-error
       "Yearless date %S requires an anniversary marker" date))))
  (when (and prefix (string-blank-p prefix))
    (setq prefix nil))
  (setf (alist-get name countdown-modeline-events nil nil #'equal)
        (cond
         (anniversary-p (list date prefix t))
         (prefix        (list date prefix))
         (t             (list date))))
  (countdown-modeline--update)
  (countdown-modeline--maybe-auto-save))

(defun countdown-modeline--read-date-interactively (default-date)
  "Prompt for a date, looping until the input is valid.
Accepts YYYY-MM-DD or MM-DD.  DEFAULT-DATE, when non-empty, is
shown as the prompt default and pre-filled in the minibuffer."
  (let ((prompt (format "Event date (YYYY-MM-DD or MM-DD)%s: "
                        (if (string-blank-p default-date) ""
                          (format " (default %s)" default-date))))
        (input nil))
    (while (not (or (countdown-modeline--parse-date
                     (setq input (read-string prompt nil nil default-date)))
                    (countdown-modeline--parse-yearless-date input)))
      (message "Invalid date %S; expected YYYY-MM-DD or MM-DD" input)
      (sit-for 1))
    input))

(defun countdown-modeline--read-anniversary-interactively (date default-anniv)
  "Return non-nil if DATE should be treated as an anniversary.
For a yearless DATE, the user is asked to confirm; declining
signals `user-error' since a yearless non-anniversary event is
invalid.  For a past full DATE, the user is asked and the answer
is returned.  For a future full DATE, returns DEFAULT-ANNIV
unchanged (preserving the existing flag when updating)."
  (cond
   ((countdown-modeline--parse-yearless-date date)
    (unless (y-or-n-p
             (format "Date %s has no year; mark as anniversary? " date))
      (user-error
       "Yearless date %S requires anniversary confirmation" date))
    t)
   ((let ((days (countdown-modeline--days-until date)))
      (and days (< days 0)))
    (y-or-n-p
     (format "Date %s is in the past; mark as anniversary? " date)))
   (t default-anniv)))

;;;###autoload
(defun countdown-modeline-remove-event (name)
  "Remove the event with NAME from `countdown-modeline-events'.
Removing a name that is not configured is a no-op, except when it
belongs to an Org event: those are owned by their Org file, and
deleting one here would only have it reappear on the next scan, so
this signals a `user-error' naming the file to edit instead.

Interactively, completion covers configured events only."
  (interactive
   (list (completing-read "Remove event: "
                          (mapcar #'car countdown-modeline-events)
                          nil t)))
  (unless (assoc name countdown-modeline-events)
    (when-let* ((org (assoc name (countdown-modeline--org-events))))
      (user-error "%s comes from %s; remove it there instead"
                  name
                  (or (countdown-modeline--event-file org) "an Org file"))))
  (setf (alist-get name countdown-modeline-events nil t #'equal) nil)
  (countdown-modeline--update)
  (countdown-modeline--maybe-auto-save))

(defun countdown-modeline--maybe-auto-save ()
  "Save events to disk if `countdown-modeline-save-events-on-change' is on.
A failed save is reported as a warning rather than a signaled error,
so the in-memory change (which already succeeded) is not surfaced as
data loss to callers."
  (when countdown-modeline-save-events-on-change
    (condition-case err
        (countdown-modeline-save-events)
      (error
       (display-warning 'countdown-modeline
                        (format "Auto-save failed: %s.  In-memory \
events changed; call `countdown-modeline-save-events' to retry."
                                (error-message-string err))
                        :warning)))))

(defun countdown-modeline--upcoming-events-by-soonest ()
  "Return every upcoming event, soonest first.
Draws on `countdown-modeline--all-events', so Org events are included.
One-time past events and events with invalid dates are excluded;
anniversaries are always included (their countdown is to the next
annual occurrence).  Each returned entry preserves its original
shape."
  (let ((decorated
         (delq nil
               (mapcar
                (lambda (event)
                  (let ((days (countdown-modeline--days-until-event event)))
                    (when (and days (>= days 0))
                      (cons days event))))
                (countdown-modeline--all-events)))))
    (mapcar #'cdr (sort decorated (lambda (a b) (< (car a) (car b)))))))

(defun countdown-modeline--sort-key (days)
  "Return a (GROUP . SUB) sort key for DAYS remaining.
GROUP orders future before past before invalid; SUB orders within
each group: future ascending (soonest first), past by recency
\(most recent first)."
  (cond
   ((null days)  (cons 2 0))
   ((>= days 0)  (cons 0 days))
   (t            (cons 1 (- days)))))

(defun countdown-modeline--count-when (predicate)
  "Return the number of events whose days-until result satisfies PREDICATE.
For anniversaries the days-until is to the next annual occurrence
\(so it is never negative); for one-time events it is the literal
signed days from today.  Events with invalid dates have nil
days-until and are never counted.  Org events are included."
  (seq-count
   (lambda (event)
     (let ((days (countdown-modeline--days-until-event event)))
       (and days (funcall predicate days))))
   (countdown-modeline--all-events)))

;;;###autoload
(defun countdown-modeline-count-upcoming-events ()
  "Return the number of upcoming events, Org events included.
Past events and events with invalid dates are not counted.  An
event whose date is today counts as upcoming.

When called interactively, also display the count in the echo area."
  (interactive)
  (let ((count (countdown-modeline--count-when (lambda (d) (>= d 0)))))
    (when (called-interactively-p 'interactive)
      (message "%d upcoming event%s"
               count
               (if (= count 1) "" "s")))
    count))

;;;###autoload
(defun countdown-modeline-count-past-events ()
  "Return the number of past events, Org events included.
Upcoming events (including today) and events with invalid dates
are not counted.

When called interactively, also display the count in the echo area."
  (interactive)
  (let ((count (countdown-modeline--count-when (lambda (d) (< d 0)))))
    (when (called-interactively-p 'interactive)
      (message "%d past event%s"
               count
               (if (= count 1) "" "s")))
    count))

;;;###autoload
(defun countdown-modeline-count-all-events ()
  "Return the total number of events, Org events included.
Includes past events and events with invalid dates.

When called interactively, also display the count in the echo area."
  (interactive)
  (let ((count (length (countdown-modeline--all-events))))
    (when (called-interactively-p 'interactive)
      (message "%d event%s configured"
               count
               (if (= count 1) "" "s")))
    count))

;;;###autoload
(defun countdown-modeline-list-events ()
  "Display all known events with days remaining in a help buffer.
Upcoming events are shown first (soonest at the top), followed by
past events (most recent first) and any with invalid dates.
Anniversaries are marked and always sort with the upcoming group.

The Source column distinguishes events configured in
`countdown-modeline-events' from those read out of Org files."
  (interactive)
  (let ((rows (mapcar
               (lambda (event)
                 (list (countdown-modeline--days-until-event event)
                       (nth 0 event)
                       (nth 1 event)
                       (nth 2 event)
                       (countdown-modeline--event-anniversary-p event)
                       (countdown-modeline--event-source event)))
               (countdown-modeline--all-events))))
    (setq rows (sort rows
                     (lambda (a b)
                       (let ((ka (countdown-modeline--sort-key (car a)))
                             (kb (countdown-modeline--sort-key (car b))))
                         (or (< (car ka) (car kb))
                             (and (= (car ka) (car kb))
                                  (< (cdr ka) (cdr kb))))))))
    (with-help-window "*countdown-modeline events*"
      (with-current-buffer standard-output
        (insert (format "%-6s  %-12s  %-7s  %s\n"
                        "Days" "Date" "Source" "Event"))
        (insert (make-string 60 ?-) "\n")
        (if (null rows)
            (insert "  (no events configured)\n")
          (dolist (row rows)
            (pcase-let ((`(,days ,name ,date ,prefix ,anniv ,source) row))
              (insert (format "%-6s  %-12s  %-7s  %s%s%s\n"
                              (if days (number-to-string days) "?")
                              date
                              (symbol-name source)
                              (if prefix (concat prefix " ") "")
                              name
                              (if anniv " (anniversary)" ""))))))))))

;;;###autoload
(defun countdown-modeline-pin-event (name)
  "Pin NAME as the event to display in the modeline.
Interactively, completion is offered over upcoming-or-today events
only, sorted soonest first and annotated with each event's date and
days remaining.  The current pin (when still upcoming) or otherwise
the soonest upcoming event is offered as the default, so an empty
RET accepts it; use TAB or your completion UI's navigation keys to
choose a different event.

Programmatically, an empty or nil NAME clears the pin.  A name
that does not match any current upcoming event is still stored,
but the display silently falls back to the soonest upcoming event
until the pin matches an upcoming event again (or is cleared)."
  (interactive
   (let ((upcoming (countdown-modeline--upcoming-events-by-soonest)))
     ;; Outer `let' validates non-empty before the `let*' below pays the
     ;; cost of computing the alignment width and per-event annotations.
     (when (null upcoming)
       (user-error "No upcoming events to pin"))
     (let* ((names (mapcar #'car upcoming))
            (current countdown-modeline-pinned-event)
            (default (if (and current (seq-contains-p names current))
                         current
                       (car names)))
            (max-name-len (apply #'max 0 (mapcar #'length names)))
            ;; Pre-build name -> annotation once.  Each annotation begins
            ;; with leading whitespace sized to push the date column past
            ;; the longest candidate, so columns line up across entries.
            (annotations
             (mapcar
              (lambda (event)
                (let* ((n (nth 0 event))
                       (date (nth 1 event))
                       (prefix (nth 2 event))
                       (anniv (countdown-modeline--event-anniversary-p event))
                       (days (countdown-modeline--days-until-event event))
                       (pad (make-string
                             (+ 2 (- max-name-len (length n)))
                             ?\s)))
                  (cons n
                        (format "%s%s%s  %d day%s%s%s"
                                pad
                                (if prefix (concat prefix " ") "")
                                date
                                days
                                (if (= days 1) "" "s")
                                (if anniv " (anniversary)" "")
                                (if (countdown-modeline--event-org-p event)
                                    " [org]" "")))))
              upcoming))
            (annotation-fn (lambda (cand) (cdr (assoc cand annotations))))
            (completion-extra-properties
             (list :annotation-function annotation-fn)))
       (list (completing-read
              (format "Pin event (default %s): " default)
              names nil t nil nil default)))))
  (when (and (stringp name) (string-blank-p name))
    (setq name nil))
  (setq countdown-modeline-pinned-event name)
  (countdown-modeline--update)
  (message (if name
               (format "Pinned event: %s" name)
             "Cleared event pin")))

;;;###autoload
(defun countdown-modeline-unpin-event ()
  "Clear the event pin, reverting to the soonest upcoming event."
  (interactive)
  (countdown-modeline-pin-event nil))

;;;###autoload
(defun countdown-modeline-save-events ()
  "Save `countdown-modeline-events' to `countdown-modeline-events-file'.
Only configured events are written.  Org events are derived from their
Org files on each scan, so persisting them here would duplicate them.

Refuses to write if the in-memory value is malformed, so a corrupt
state cannot clobber a previously valid file.  The parent directory
is created if it does not already exist.

The file is written as a versioned plist envelope so future format
changes can be detected and migrated; legacy bare-list files remain
loadable for backward compatibility."
  (interactive)
  (unless (countdown-modeline--valid-events-p countdown-modeline-events)
    (user-error "Refusing to save: countdown-modeline-events is malformed"))
  (when-let* ((dir (file-name-directory countdown-modeline-events-file)))
    (make-directory dir t))
  (with-temp-file countdown-modeline-events-file
    (insert ";;; countdown-modeline events.  Auto-generated; safe to edit.\n")
    (let ((print-level nil)
          (print-length nil))
      (pp (list :format-version countdown-modeline--save-format-version
                :events countdown-modeline-events)
          (current-buffer))))
  (message "Saved %d event(s) to %s"
           (length countdown-modeline-events)
           countdown-modeline-events-file))

;;;###autoload
(defun countdown-modeline-load-events ()
  "Load events from `countdown-modeline-events-file'.
Replaces the current value of `countdown-modeline-events'.  The
file is validated before its contents are installed; an invalid
file or one written by a newer version of this package leaves the
current events untouched."
  (interactive)
  (unless (file-readable-p countdown-modeline-events-file)
    (user-error "Cannot read %s" countdown-modeline-events-file))
  (let* ((form (with-temp-buffer
                 (insert-file-contents countdown-modeline-events-file)
                 (goto-char (point-min))
                 (read (current-buffer))))
         (events (countdown-modeline--extract-events form)))
    (setq countdown-modeline-events events))
  (countdown-modeline--update)
  (message "Loaded %d event(s) from %s"
           (length countdown-modeline-events)
           countdown-modeline-events-file))

;;;###autoload
(define-minor-mode countdown-modeline-mode
  "Toggle the countdown display in the modeline (a global minor mode).

When enabled, the modeline shows the soonest upcoming event from
`countdown-modeline-events'; past events are skipped.  An internal
timer refreshes the display at the next local midnight, and is
canceled when the mode is disabled.

Add or update events with `countdown-modeline-add-event' and
inspect them with `countdown-modeline-list-events'.  Use
`countdown-modeline-save-events' / -load-events' to persist them
to `countdown-modeline-events-file' across sessions.

Set `countdown-modeline-org-enabled' to also draw events from Org
files; `countdown-modeline-org-refresh' rescans them."
  :global t
  (if countdown-modeline-mode
      (progn
        (countdown-modeline--org-invalidate-cache)
        (countdown-modeline--update)
        (countdown-modeline--schedule-midnight)
        (unless (listp global-mode-string)
          (setq global-mode-string (list global-mode-string)))
        (add-to-list 'global-mode-string '(:eval countdown-modeline--string) t))
    (when (timerp countdown-modeline--timer)
      (cancel-timer countdown-modeline--timer)
      (setq countdown-modeline--timer nil))
    (when (listp global-mode-string)
      (setq global-mode-string
            (delete '(:eval countdown-modeline--string) global-mode-string)))
    (setq countdown-modeline--string nil)
    (force-mode-line-update)))

(provide 'countdown-modeline)
;;; countdown-modeline.el ends here
