Skip to main content

eventkit/
imp.rs

1use block2::RcBlock;
2use chrono::{DateTime, Datelike, Duration, Local, TimeZone, Timelike};
3use objc2::Message;
4use objc2::rc::Retained;
5use objc2::runtime::Bool;
6use objc2_event_kit::{
7    EKAlarm, EKAlarmProximity, EKAlarmType, EKAuthorizationStatus, EKCalendar,
8    EKCalendarEventAvailabilityMask, EKCalendarItem, EKEntityType, EKEvent, EKEventAvailability,
9    EKEventStatus, EKEventStore, EKRecurrenceDayOfWeek, EKRecurrenceEnd, EKRecurrenceFrequency,
10    EKRecurrenceRule, EKReminder, EKSource, EKSpan, EKStructuredLocation, EKWeekday,
11};
12use objc2_foundation::{
13    NSArray, NSCalendar, NSDate, NSDateComponents, NSError, NSNumber, NSString,
14};
15use std::sync::{Arc, Condvar, Mutex};
16use thiserror::Error;
17
18#[cfg(feature = "location")]
19#[path = "location.rs"]
20pub mod location;
21
22#[cfg(feature = "mcp")]
23#[path = "mcp.rs"]
24pub mod mcp;
25
26/// Errors that can occur when working with EventKit
27#[derive(Error, Debug)]
28pub enum EventKitError {
29    #[error("Authorization denied")]
30    AuthorizationDenied,
31
32    #[error("Authorization restricted by system policy")]
33    AuthorizationRestricted,
34
35    #[error("Authorization not determined")]
36    AuthorizationNotDetermined,
37
38    #[error("Failed to request authorization: {0}")]
39    AuthorizationRequestFailed(String),
40
41    #[error("No default calendar")]
42    NoDefaultCalendar,
43
44    #[error("Calendar not found: {0}")]
45    CalendarNotFound(String),
46
47    #[error("Item not found: {0}")]
48    ItemNotFound(String),
49
50    #[error("Failed to save: {0}")]
51    SaveFailed(String),
52
53    #[error("Failed to delete: {0}")]
54    DeleteFailed(String),
55
56    #[error("Failed to fetch: {0}")]
57    FetchFailed(String),
58
59    #[error("EventKit error: {0}")]
60    EventKitError(String),
61
62    #[error("Invalid date range")]
63    InvalidDateRange,
64
65    /// Returned when a string passed as a URL fails strict RFC 3986
66    /// validation (`+[NSURL URLWithString:encodingInvalidCharacters:NO]`
67    /// returned nil — invalid scheme, illegal characters, etc.).
68    #[error("Invalid URL: {0}")]
69    InvalidURL(String),
70}
71
72/// Backward compatibility alias
73pub type RemindersError = EventKitError;
74
75/// Result type for EventKit operations
76pub type Result<T> = std::result::Result<T, EventKitError>;
77
78/// Represents a reminder item with its properties
79#[derive(Debug, Clone)]
80#[allow(non_snake_case)]
81pub struct ReminderItem {
82    /// Unique identifier for the reminder
83    pub identifier: String,
84    /// Title of the reminder
85    pub title: String,
86    /// Optional notes/description
87    pub notes: Option<String>,
88    /// Whether the reminder is completed
89    pub completed: bool,
90    /// Priority (0 = none, 1-4 = high, 5 = medium, 6-9 = low)
91    pub priority: usize,
92    /// Calendar/list the reminder belongs to
93    pub calendar_title: Option<String>,
94    /// Calendar/list identifier
95    pub calendar_id: Option<String>,
96    /// Due date for the reminder
97    pub due_date: Option<DateTime<Local>>,
98    /// Start date (when to start working on it)
99    pub start_date: Option<DateTime<Local>>,
100    /// Completion date (when it was completed)
101    pub completion_date: Option<DateTime<Local>>,
102    /// External identifier for the reminder (server-provided)
103    pub external_identifier: Option<String>,
104    /// Location associated with the reminder
105    pub location: Option<String>,
106    /// URL associated with the reminder
107    #[allow(non_snake_case)]
108    pub URL: Option<String>,
109    /// Creation date of the reminder
110    pub creation_date: Option<DateTime<Local>>,
111    /// Last modified date of the reminder
112    pub last_modified_date: Option<DateTime<Local>>,
113    /// Timezone of the reminder
114    pub timezone: Option<String>,
115    /// Timezone applied specifically to the due date — distinct from
116    /// `timezone`, which is the item-level zone. EventKit lets these differ
117    /// so a reminder can be "due at 9am New York time" regardless of the
118    /// item's own zone or the device zone.
119    pub due_date_timezone: Option<String>,
120    /// Geofence attached to the reminder via a location-based alarm.
121    /// `Some` when at least one alarm has a `structuredLocation` and
122    /// non-`None` proximity. EventKit has no `structuredLocation` property
123    /// directly on `EKReminder` / `EKCalendarItem` — it lives on the alarm.
124    pub structured_location: Option<StructuredLocation>,
125    /// Identifier of the parent reminder (when this reminder is a subtask).
126    /// Read via KVC because the underlying type is the private `EKObjectID`.
127    pub parent_id: Option<String>,
128    /// Number of file attachments. Full metadata is not yet surfaced.
129    pub attachments_count: usize,
130    /// Whether the reminder has alarms
131    pub has_alarms: bool,
132    /// Whether the reminder has recurrence rules
133    pub has_recurrence_rules: bool,
134    /// Whether the reminder has attendees
135    pub has_attendees: bool,
136    /// Whether the reminder has notes
137    pub has_notes: bool,
138    /// Attendees on this reminder (usually empty, possible on shared lists)
139    pub attendees: Vec<ParticipantInfo>,
140}
141
142/// Input for `RemindersManager::create_reminder`. All fields except `title`
143/// are optional. Use `..Default::default()` for the unset ones.
144#[derive(Debug, Clone, Default)]
145#[allow(non_snake_case)]
146pub struct ReminderDraft<'a> {
147    pub title: &'a str,
148    pub notes: Option<&'a str>,
149    pub calendar_title: Option<&'a str>,
150    pub priority: Option<usize>,
151    pub due_date: Option<DateTime<Local>>,
152    pub start_date: Option<DateTime<Local>>,
153    #[allow(non_snake_case)]
154    pub URL: Option<&'a str>,
155    pub location: Option<&'a str>,
156    /// Rich location for Reminders.app's location chip — writes
157    /// `EKReminder.structuredLocation`. This is the field iCloud Reminders
158    /// actually persists; the plain-string `location` above is a legacy
159    /// CalDAV field that iCloud silently drops.
160    pub structured_location: Option<&'a StructuredLocation>,
161    /// IANA zone identifier for the due date, e.g. `"America/Los_Angeles"`.
162    pub due_date_timezone: Option<&'a str>,
163}
164
165/// Input for `RemindersManager::update_reminder`. Each field uses one of:
166/// `None` (don't touch), `Some(value)` (set), and for `Option<Option<T>>`
167/// fields, `Some(None)` (clear). Build with `..Default::default()`.
168#[derive(Debug, Clone, Default)]
169#[allow(non_snake_case)]
170pub struct ReminderPatch<'a> {
171    pub title: Option<&'a str>,
172    pub notes: Option<&'a str>,
173    pub completed: Option<bool>,
174    pub priority: Option<usize>,
175    pub due_date: Option<Option<DateTime<Local>>>,
176    pub start_date: Option<Option<DateTime<Local>>>,
177    pub calendar_title: Option<&'a str>,
178    #[allow(non_snake_case)]
179    pub URL: Option<Option<&'a str>>,
180    pub location: Option<Option<&'a str>>,
181    /// Rich location (the iCloud-persisted chip). `Some(Some(loc))` sets,
182    /// `Some(None)` clears, `None` leaves untouched.
183    pub structured_location: Option<Option<&'a StructuredLocation>>,
184    /// Same `Option<Option<...>>` semantics: `Some(Some("America/LA"))` sets,
185    /// `Some(None)` clears, `None` leaves untouched.
186    pub due_date_timezone: Option<Option<&'a str>>,
187    /// Explicit completion date. Apple's `setCompletionDate:` is the
188    /// authoritative completion toggle — a non-nil value implies
189    /// `isCompleted = YES` and a nil value implies `NO`. We apply this
190    /// **after** `completed` in `update_reminder`, so when both are
191    /// provided the date wins.
192    pub completion_date: Option<Option<DateTime<Local>>>,
193}
194
195/// Type of calendar/source.
196#[derive(Debug, Clone, Copy, PartialEq, Eq)]
197pub enum CalendarType {
198    Local,
199    CalDAV,
200    Exchange,
201    Subscription,
202    Birthday,
203    Unknown,
204}
205
206/// An account source (iCloud, Local, Exchange, etc.)
207#[derive(Debug, Clone)]
208pub struct SourceInfo {
209    pub identifier: String,
210    pub title: String,
211    pub source_type: String,
212}
213
214/// Represents a calendar (reminder list or event calendar).
215#[derive(Debug, Clone)]
216pub struct CalendarInfo {
217    /// Unique identifier
218    pub identifier: String,
219    /// Title of the calendar
220    pub title: String,
221    /// Source name (e.g., iCloud, Local)
222    pub source: Option<String>,
223    /// Source identifier
224    pub source_id: Option<String>,
225    /// Calendar type
226    pub calendar_type: CalendarType,
227    /// Whether items can be added/modified/deleted
228    pub allows_modifications: bool,
229    /// Whether the calendar itself can be modified (renamed/deleted)
230    pub is_immutable: bool,
231    /// Whether this is a URL-subscribed read-only calendar
232    pub is_subscribed: bool,
233    /// Calendar color as RGBA (0.0-1.0)
234    pub color: Option<(f64, f64, f64, f64)>,
235    /// Entity types this calendar supports ("event", "reminder")
236    pub allowed_entity_types: Vec<String>,
237    /// Which `EventAvailability` values this calendar accepts on its events
238    /// — string forms of `EKCalendarEventAvailabilityMask`: e.g.
239    /// `["busy", "free", "tentative"]`. Empty when none are supported
240    /// (`EKCalendarEventAvailabilityNone`, common on reminder-only calendars).
241    /// Useful pre-flight check before `EventsManager::set_event_availability`.
242    pub supported_event_availabilities: Vec<String>,
243}
244
245/// Proximity trigger for a location-based alarm.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
247pub enum AlarmProximity {
248    /// No proximity trigger.
249    #[default]
250    None,
251    /// Trigger when entering the location.
252    Enter,
253    /// Trigger when leaving the location.
254    Leave,
255}
256
257/// A structured location for geofenced alarms.
258#[derive(Debug, Clone)]
259pub struct StructuredLocation {
260    /// Display title for the location.
261    pub title: String,
262    /// Latitude of the location.
263    pub latitude: f64,
264    /// Longitude of the location.
265    pub longitude: f64,
266    /// Geofence radius in meters.
267    pub radius: f64,
268}
269
270/// An alarm attached to a reminder or event.
271#[derive(Debug, Clone, Default)]
272pub struct AlarmInfo {
273    /// Offset in seconds before the due date (negative = before).
274    pub relative_offset: Option<f64>,
275    /// Absolute date for the alarm (ISO 8601 string).
276    pub absolute_date: Option<DateTime<Local>>,
277    /// Proximity trigger (enter/leave geofence).
278    pub proximity: AlarmProximity,
279    /// Location for geofenced alarms.
280    pub location: Option<StructuredLocation>,
281    /// Email address for email-type alarms (CalDAV server-side notification).
282    pub email_address: Option<String>,
283    /// Custom alarm sound name (audio-type alarms).
284    pub sound_name: Option<String>,
285    /// URL opened when the alarm fires (procedure-type alarm). Distinct
286    /// from `EKCalendarItem.URL` — that's lowercase `url` on `EKAlarm`.
287    /// Apple deprecated this property in macOS 10.9 but the API still
288    /// functions; we surface it for parity with the framework.
289    pub url: Option<String>,
290    /// Derived alarm type — Display/Audio/Procedure/Email. Apple infers
291    /// this from which of the optional fields above are set. Read-only on
292    /// output; ignored on input.
293    pub alarm_type: AlarmType,
294}
295
296/// EKAlarm.type — derived by Apple from which optional fields are set:
297/// `soundName` → Audio, `url` → Procedure, `emailAddress` → Email, else Display.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
299pub enum AlarmType {
300    #[default]
301    Display,
302    Audio,
303    Procedure,
304    Email,
305    Unknown,
306}
307
308/// How often a recurrence repeats.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
310pub enum RecurrenceFrequency {
311    #[default]
312    Daily,
313    Weekly,
314    Monthly,
315    Yearly,
316}
317
318/// When a recurrence ends.
319#[derive(Debug, Clone, Default)]
320pub enum RecurrenceEndCondition {
321    /// Repeats forever.
322    #[default]
323    Never,
324    /// Ends after a number of occurrences.
325    AfterCount(usize),
326    /// Ends on a specific date.
327    OnDate(DateTime<Local>),
328}
329
330/// A recurrence rule describing how a reminder or event repeats.
331#[derive(Debug, Clone, Default)]
332pub struct RecurrenceRule {
333    /// How often it repeats (daily, weekly, monthly, yearly).
334    pub frequency: RecurrenceFrequency,
335    /// Repeat every N intervals (e.g., every 2 weeks).
336    pub interval: usize,
337    /// When the recurrence ends.
338    pub end: RecurrenceEndCondition,
339    /// Days of the week (1=Sun..7=Sat) for weekly/monthly rules.
340    pub days_of_week: Option<Vec<u8>>,
341    /// Days of the month (1-31, negatives count from end) for monthly rules.
342    pub days_of_month: Option<Vec<i32>>,
343    /// Months of the year (1=Jan..12=Dec) for yearly rules.
344    pub months_of_year: Option<Vec<i32>>,
345    /// Weeks of the year (1..=53, negatives count from end). Yearly rules only.
346    pub weeks_of_year: Option<Vec<i32>>,
347    /// Days of the year (1..=366, negatives count from end). Yearly rules only.
348    pub days_of_year: Option<Vec<i32>>,
349    /// Set positions — filter applied after the other fields. E.g. with
350    /// `frequency = Monthly`, `days_of_week = [2]` (Monday), and
351    /// `set_positions = [1]` you get "the first Monday of every month".
352    /// Negative values count from the end.
353    pub set_positions: Option<Vec<i32>>,
354}
355
356/// The main reminders manager providing access to EventKit functionality
357pub struct RemindersManager {
358    store: Retained<EKEventStore>,
359}
360
361impl RemindersManager {
362    /// Creates a new RemindersManager instance
363    pub fn new() -> Self {
364        let store = unsafe { EKEventStore::new() };
365        Self { store }
366    }
367
368    /// Gets the current authorization status for reminders
369    pub fn authorization_status() -> AuthorizationStatus {
370        let status =
371            unsafe { EKEventStore::authorizationStatusForEntityType(EKEntityType::Reminder) };
372        status.into()
373    }
374
375    /// Requests full access to reminders (blocking)
376    ///
377    /// Returns Ok(true) if access was granted, Ok(false) if denied
378    pub fn request_access(&self) -> Result<bool> {
379        let result = Arc::new((Mutex::new(None::<(bool, Option<String>)>), Condvar::new()));
380        let result_clone = Arc::clone(&result);
381
382        let completion = RcBlock::new(move |granted: Bool, error: *mut NSError| {
383            let error_msg = if !error.is_null() {
384                let error_ref = unsafe { &*error };
385                Some(format!("{:?}", error_ref))
386            } else {
387                None
388            };
389
390            let (lock, cvar) = &*result_clone;
391            let mut res = lock.lock().unwrap();
392            *res = Some((granted.as_bool(), error_msg));
393            cvar.notify_one();
394        });
395
396        unsafe {
397            // Convert RcBlock to raw pointer for the API
398            let block_ptr = &*completion as *const _ as *mut _;
399            self.store
400                .requestFullAccessToRemindersWithCompletion(block_ptr);
401        }
402
403        let (lock, cvar) = &*result;
404        let mut res = lock.lock().unwrap();
405        while res.is_none() {
406            res = cvar.wait(res).unwrap();
407        }
408
409        match res.take() {
410            Some((granted, None)) => Ok(granted),
411            Some((_, Some(error))) => Err(RemindersError::AuthorizationRequestFailed(error)),
412            None => Err(RemindersError::AuthorizationRequestFailed(
413                "Unknown error".to_string(),
414            )),
415        }
416    }
417
418    /// Ensures we have authorization, requesting if needed
419    pub fn ensure_authorized(&self) -> Result<()> {
420        match Self::authorization_status() {
421            AuthorizationStatus::FullAccess => Ok(()),
422            AuthorizationStatus::NotDetermined => {
423                if self.request_access()? {
424                    Ok(())
425                } else {
426                    Err(RemindersError::AuthorizationDenied)
427                }
428            }
429            AuthorizationStatus::Denied => Err(RemindersError::AuthorizationDenied),
430            AuthorizationStatus::Restricted => Err(RemindersError::AuthorizationRestricted),
431            AuthorizationStatus::WriteOnly => Ok(()), // Can still read with write-only in some cases
432        }
433    }
434
435    /// Lists all reminder calendars (lists)
436    pub fn list_calendars(&self) -> Result<Vec<CalendarInfo>> {
437        self.ensure_authorized()?;
438
439        let calendars = unsafe { self.store.calendarsForEntityType(EKEntityType::Reminder) };
440
441        let mut result = Vec::new();
442        for calendar in calendars.iter() {
443            result.push(calendar_to_info(&calendar));
444        }
445
446        Ok(result)
447    }
448
449    /// Lists all available sources (iCloud, Local, Exchange, etc.)
450    pub fn list_sources(&self) -> Result<Vec<SourceInfo>> {
451        self.ensure_authorized()?;
452        let sources = unsafe { self.store.sources() };
453        let mut result = Vec::new();
454        for source in sources.iter() {
455            result.push(source_to_info(&source));
456        }
457        Ok(result)
458    }
459
460    /// Gets the default calendar for new reminders
461    pub fn default_calendar(&self) -> Result<CalendarInfo> {
462        self.ensure_authorized()?;
463
464        let calendar = unsafe { self.store.defaultCalendarForNewReminders() };
465
466        match calendar {
467            Some(cal) => Ok(calendar_to_info(&cal)),
468            None => Err(RemindersError::NoDefaultCalendar),
469        }
470    }
471
472    /// Fetches all reminders (blocking)
473    pub fn fetch_all_reminders(&self) -> Result<Vec<ReminderItem>> {
474        self.fetch_reminders(None)
475    }
476
477    /// Fetches reminders from specific calendars (blocking)
478    pub fn fetch_reminders(&self, calendar_titles: Option<&[&str]>) -> Result<Vec<ReminderItem>> {
479        self.ensure_authorized()?;
480
481        let calendars: Option<Retained<NSArray<EKCalendar>>> = match calendar_titles {
482            Some(titles) => {
483                let all_calendars =
484                    unsafe { self.store.calendarsForEntityType(EKEntityType::Reminder) };
485                let mut matching: Vec<Retained<EKCalendar>> = Vec::new();
486
487                for cal in all_calendars.iter() {
488                    let title = unsafe { cal.title() };
489                    let title_str = title.to_string();
490                    if titles.iter().any(|t| *t == title_str) {
491                        matching.push(cal.retain());
492                    }
493                }
494
495                if matching.is_empty() {
496                    return Err(RemindersError::CalendarNotFound(titles.join(", ")));
497                }
498
499                Some(NSArray::from_retained_slice(&matching))
500            }
501            None => None,
502        };
503
504        let predicate = unsafe {
505            self.store
506                .predicateForRemindersInCalendars(calendars.as_deref())
507        };
508
509        let result = Arc::new((Mutex::new(None::<Vec<ReminderItem>>), Condvar::new()));
510        let result_clone = Arc::clone(&result);
511
512        let completion = RcBlock::new(move |reminders: *mut NSArray<EKReminder>| {
513            let items = if reminders.is_null() {
514                Vec::new()
515            } else {
516                let reminders = unsafe { Retained::retain(reminders).unwrap() };
517                reminders.iter().map(|r| reminder_to_item(&r)).collect()
518            };
519            let (lock, cvar) = &*result_clone;
520            let mut guard = lock.lock().unwrap();
521            *guard = Some(items);
522            cvar.notify_one();
523        });
524
525        unsafe {
526            self.store
527                .fetchRemindersMatchingPredicate_completion(&predicate, &completion);
528        }
529
530        let (lock, cvar) = &*result;
531        let mut guard = lock.lock().unwrap();
532        while guard.is_none() {
533            guard = cvar.wait(guard).unwrap();
534        }
535
536        guard
537            .take()
538            .ok_or_else(|| RemindersError::FetchFailed("Unknown error".to_string()))
539    }
540
541    /// Fetches incomplete reminders (no due-date filter, all calendars).
542    pub fn fetch_incomplete_reminders(&self) -> Result<Vec<ReminderItem>> {
543        self.fetch_incomplete_reminders_in_due_range(None, None, None)
544    }
545
546    /// Fetches incomplete reminders whose due date falls within the optional
547    /// `starting`..`ending` window. Either bound may be `None` to leave it
548    /// open-ended. `calendar_titles` filters to specific lists (passing
549    /// `None` searches all reminder calendars).
550    pub fn fetch_incomplete_reminders_in_due_range(
551        &self,
552        starting: Option<DateTime<Local>>,
553        ending: Option<DateTime<Local>>,
554        calendar_titles: Option<&[&str]>,
555    ) -> Result<Vec<ReminderItem>> {
556        self.ensure_authorized()?;
557        let calendars = self.resolve_reminder_calendars(calendar_titles)?;
558        let start_ns = starting.map(datetime_to_nsdate);
559        let end_ns = ending.map(datetime_to_nsdate);
560        let predicate = unsafe {
561            self.store
562                .predicateForIncompleteRemindersWithDueDateStarting_ending_calendars(
563                    start_ns.as_deref(),
564                    end_ns.as_deref(),
565                    calendars.as_deref(),
566                )
567        };
568        self.fetch_by_predicate(&predicate)
569    }
570
571    /// Fetches completed reminders whose completion date falls within the
572    /// optional `starting`..`ending` window. Either bound may be `None`.
573    /// `calendar_titles` filters to specific lists.
574    pub fn fetch_completed_reminders_in_range(
575        &self,
576        starting: Option<DateTime<Local>>,
577        ending: Option<DateTime<Local>>,
578        calendar_titles: Option<&[&str]>,
579    ) -> Result<Vec<ReminderItem>> {
580        self.ensure_authorized()?;
581        let calendars = self.resolve_reminder_calendars(calendar_titles)?;
582        let start_ns = starting.map(datetime_to_nsdate);
583        let end_ns = ending.map(datetime_to_nsdate);
584        let predicate = unsafe {
585            self.store
586                .predicateForCompletedRemindersWithCompletionDateStarting_ending_calendars(
587                    start_ns.as_deref(),
588                    end_ns.as_deref(),
589                    calendars.as_deref(),
590                )
591        };
592        self.fetch_by_predicate(&predicate)
593    }
594
595    /// Resolves `calendar_titles` (`Some(&["A", "B"])` etc.) to an NSArray
596    /// of `EKCalendar` references restricted to reminder calendars. `None`
597    /// → `Ok(None)` meaning "all calendars". Unknown title → error.
598    fn resolve_reminder_calendars(
599        &self,
600        calendar_titles: Option<&[&str]>,
601    ) -> Result<Option<Retained<NSArray<EKCalendar>>>> {
602        let Some(titles) = calendar_titles else {
603            return Ok(None);
604        };
605        let all_calendars = unsafe { self.store.calendarsForEntityType(EKEntityType::Reminder) };
606        let mut matching: Vec<Retained<EKCalendar>> = Vec::new();
607        for cal in all_calendars.iter() {
608            let title_str = unsafe { cal.title() }.to_string();
609            if titles.iter().any(|t| *t == title_str) {
610                matching.push(cal.retain());
611            }
612        }
613        if matching.is_empty() {
614            return Err(RemindersError::CalendarNotFound(titles.join(", ")));
615        }
616        Ok(Some(NSArray::from_retained_slice(&matching)))
617    }
618
619    /// Runs the standard block-completion fetch dance against any predicate
620    /// and converts the returned EKReminders to ReminderItems. Used by all
621    /// `fetch_*` methods.
622    fn fetch_by_predicate(
623        &self,
624        predicate: &objc2_foundation::NSPredicate,
625    ) -> Result<Vec<ReminderItem>> {
626        let result = Arc::new((Mutex::new(None::<Vec<ReminderItem>>), Condvar::new()));
627        let result_clone = Arc::clone(&result);
628
629        let completion = RcBlock::new(move |reminders: *mut NSArray<EKReminder>| {
630            let items = if reminders.is_null() {
631                Vec::new()
632            } else {
633                let reminders = unsafe { Retained::retain(reminders).unwrap() };
634                reminders.iter().map(|r| reminder_to_item(&r)).collect()
635            };
636            let (lock, cvar) = &*result_clone;
637            let mut guard = lock.lock().unwrap();
638            *guard = Some(items);
639            cvar.notify_one();
640        });
641
642        unsafe {
643            self.store
644                .fetchRemindersMatchingPredicate_completion(predicate, &completion);
645        }
646
647        let (lock, cvar) = &*result;
648        let mut guard = lock.lock().unwrap();
649        while guard.is_none() {
650            guard = cvar.wait(guard).unwrap();
651        }
652        guard
653            .take()
654            .ok_or_else(|| RemindersError::FetchFailed("Unknown error".to_string()))
655    }
656
657    /// Creates a new reminder. Build the input with `ReminderDraft` —
658    /// only `title` is required; spread `..Default::default()` for the rest.
659    pub fn create_reminder(&self, draft: &ReminderDraft<'_>) -> Result<ReminderItem> {
660        self.ensure_authorized()?;
661
662        let reminder = unsafe { EKReminder::reminderWithEventStore(&self.store) };
663
664        let ns_title = NSString::from_str(draft.title);
665        unsafe { reminder.setTitle(Some(&ns_title)) };
666
667        if let Some(notes_text) = draft.notes {
668            let ns_notes = NSString::from_str(notes_text);
669            unsafe { reminder.setNotes(Some(&ns_notes)) };
670        }
671
672        if let Some(p) = draft.priority {
673            unsafe { reminder.setPriority(p) };
674        }
675
676        if let Some(due) = draft.due_date {
677            let components = datetime_to_date_components(due);
678            unsafe { reminder.setDueDateComponents(Some(&components)) };
679        }
680
681        if let Some(start) = draft.start_date {
682            let components = datetime_to_date_components(start);
683            unsafe { reminder.setStartDateComponents(Some(&components)) };
684        }
685
686        if draft.URL.is_some() {
687            set_item_URL(&reminder, draft.URL)?;
688        }
689        if draft.location.is_some() {
690            set_item_location(&reminder, draft.location);
691        }
692        if let Some(sl) = draft.structured_location {
693            set_reminder_structured_location(&reminder, Some(sl));
694        }
695        if draft.due_date_timezone.is_some() {
696            set_reminder_due_date_timezone(&reminder, draft.due_date_timezone);
697        }
698
699        let calendar = if let Some(cal_title) = draft.calendar_title {
700            self.find_calendar_by_title(cal_title)?
701        } else {
702            unsafe { self.store.defaultCalendarForNewReminders() }
703                .ok_or(RemindersError::NoDefaultCalendar)?
704        };
705        unsafe { reminder.setCalendar(Some(&calendar)) };
706
707        self.save_reminder_and_refresh(&reminder)?;
708
709        Ok(reminder_to_item(&reminder))
710    }
711
712    /// Updates an existing reminder. Build the changeset with `ReminderPatch`
713    /// — only set the fields you want to touch. For nullable string/timezone
714    /// fields, `Some(Some(v))` writes `v` and `Some(None)` clears.
715    pub fn update_reminder(
716        &self,
717        identifier: &str,
718        patch: &ReminderPatch<'_>,
719    ) -> Result<ReminderItem> {
720        self.ensure_authorized()?;
721
722        let reminder = self.find_reminder_by_id(identifier)?;
723
724        if let Some(t) = patch.title {
725            let ns_title = NSString::from_str(t);
726            unsafe { reminder.setTitle(Some(&ns_title)) };
727        }
728
729        if let Some(n) = patch.notes {
730            let ns_notes = NSString::from_str(n);
731            unsafe { reminder.setNotes(Some(&ns_notes)) };
732        }
733
734        if let Some(c) = patch.completed {
735            unsafe { reminder.setCompleted(c) };
736        }
737
738        if let Some(p) = patch.priority {
739            unsafe { reminder.setPriority(p) };
740        }
741
742        if let Some(due_opt) = patch.due_date {
743            match due_opt {
744                Some(due) => {
745                    let components = datetime_to_date_components(due);
746                    unsafe { reminder.setDueDateComponents(Some(&components)) };
747                }
748                None => unsafe { reminder.setDueDateComponents(None) },
749            }
750        }
751
752        if let Some(start_opt) = patch.start_date {
753            match start_opt {
754                Some(start) => {
755                    let components = datetime_to_date_components(start);
756                    unsafe { reminder.setStartDateComponents(Some(&components)) };
757                }
758                None => unsafe { reminder.setStartDateComponents(None) },
759            }
760        }
761
762        if let Some(url_opt) = patch.URL {
763            set_item_URL(&reminder, url_opt)?;
764        }
765        if let Some(loc_opt) = patch.location {
766            set_item_location(&reminder, loc_opt);
767        }
768        if let Some(sl_opt) = patch.structured_location {
769            set_reminder_structured_location(&reminder, sl_opt);
770        }
771        if let Some(tz_opt) = patch.due_date_timezone {
772            set_reminder_due_date_timezone(&reminder, tz_opt);
773        }
774
775        // Apply completion_date AFTER `completed` so Apple's "completionDate
776        // is authoritative" semantics win when both are provided.
777        if let Some(cd_opt) = patch.completion_date {
778            match cd_opt {
779                Some(d) => {
780                    let nsdate = datetime_to_nsdate(d);
781                    unsafe { reminder.setCompletionDate(Some(&nsdate)) };
782                }
783                None => unsafe { reminder.setCompletionDate(None) },
784            }
785        }
786
787        if let Some(cal_title) = patch.calendar_title {
788            let calendar = self.find_calendar_by_title(cal_title)?;
789            unsafe { reminder.setCalendar(Some(&calendar)) };
790        }
791
792        self.save_reminder_and_refresh(&reminder)?;
793
794        Ok(reminder_to_item(&reminder))
795    }
796
797    /// Marks a reminder as complete
798    pub fn complete_reminder(&self, identifier: &str) -> Result<ReminderItem> {
799        self.update_reminder(
800            identifier,
801            &ReminderPatch {
802                completed: Some(true),
803                ..Default::default()
804            },
805        )
806    }
807
808    /// Marks a reminder as incomplete
809    pub fn uncomplete_reminder(&self, identifier: &str) -> Result<ReminderItem> {
810        self.update_reminder(
811            identifier,
812            &ReminderPatch {
813                completed: Some(false),
814                ..Default::default()
815            },
816        )
817    }
818
819    /// Deletes a reminder
820    pub fn delete_reminder(&self, identifier: &str) -> Result<()> {
821        self.ensure_authorized()?;
822
823        let reminder = self.find_reminder_by_id(identifier)?;
824
825        unsafe {
826            self.store
827                .removeReminder_commit_error(&reminder, true)
828                .map_err(|e| EventKitError::DeleteFailed(format!("{:?}", e)))?;
829        }
830
831        Ok(())
832    }
833
834    /// Gets a reminder by its identifier
835    pub fn get_reminder(&self, identifier: &str) -> Result<ReminderItem> {
836        self.ensure_authorized()?;
837        let reminder = self.find_reminder_by_id(identifier)?;
838        Ok(reminder_to_item(&reminder))
839    }
840
841    /// Dumps every Objective-C `@property` declared on the reminder, its
842    /// EKCalendar, and that calendar's EKSource — using runtime reflection
843    /// (`class_copyPropertyList`). Intended for figuring out which native
844    /// fields are not yet surfaced by [`ReminderItem`].
845    ///
846    /// If `read_values` is true, also reads each property via KVC
847    /// (`valueForKey:`). Some properties in EventKit are backed by Core Data
848    /// internals and hit C-level assertions when read this way — see the
849    /// hardcoded denylist in `reflect_object_full`. Use `false` for a fully
850    /// safe schema-only listing.
851    pub fn dump_reminder_raw(&self, identifier: &str, read_values: bool) -> Result<String> {
852        self.ensure_authorized()?;
853        let reminder = self.find_reminder_by_id(identifier)?;
854
855        let mut out = String::new();
856        out.push_str(&reflect_object_full(
857            "EKReminder (instance)",
858            &*reminder,
859            read_values,
860        ));
861
862        if let Some(calendar) = unsafe { reminder.calendar() } {
863            out.push('\n');
864            out.push_str(&reflect_object_full(
865                "EKCalendar (reminder.calendar)",
866                &*calendar,
867                read_values,
868            ));
869
870            if let Some(source) = unsafe { calendar.source() } {
871                out.push('\n');
872                out.push_str(&reflect_object_full(
873                    "EKSource (calendar.source)",
874                    &*source,
875                    read_values,
876                ));
877            }
878        }
879
880        Ok(out)
881    }
882
883    /// Probes a hardcoded list of suspected-private selectors on the reminder
884    /// — the kind of accessors that aren't generated by `objc2-event-kit`
885    /// and reject KVC `valueForKey:` (e.g. `structuredData`, `tags`,
886    /// `richLink`). Each call is wrapped in an Objective-C exception catch.
887    /// Read-only — never invokes any setter.
888    pub fn dump_reminder_private(&self, identifier: &str) -> Result<String> {
889        self.ensure_authorized()?;
890        let reminder = self.find_reminder_by_id(identifier)?;
891        Ok(probe_private_selectors(&reminder))
892    }
893
894    // ========================================================================
895    // Alarm Management
896    // ========================================================================
897
898    /// Lists all alarms on a reminder.
899    pub fn get_alarms(&self, identifier: &str) -> Result<Vec<AlarmInfo>> {
900        self.ensure_authorized()?;
901        let reminder = self.find_reminder_by_id(identifier)?;
902        Ok(get_item_alarms(&reminder))
903    }
904
905    /// Adds an alarm to a reminder.
906    pub fn add_alarm(&self, identifier: &str, alarm: &AlarmInfo) -> Result<()> {
907        self.ensure_authorized()?;
908        let reminder = self.find_reminder_by_id(identifier)?;
909        add_item_alarm(&reminder, alarm)?;
910        self.save_reminder_and_refresh(&reminder)?;
911        Ok(())
912    }
913
914    /// Removes all alarms from a reminder.
915    pub fn remove_all_alarms(&self, identifier: &str) -> Result<()> {
916        self.ensure_authorized()?;
917        let reminder = self.find_reminder_by_id(identifier)?;
918        clear_item_alarms(&reminder);
919        self.save_reminder_and_refresh(&reminder)?;
920        Ok(())
921    }
922
923    /// Removes a specific alarm from a reminder by index.
924    pub fn remove_alarm(&self, identifier: &str, index: usize) -> Result<()> {
925        self.ensure_authorized()?;
926        let reminder = self.find_reminder_by_id(identifier)?;
927        remove_item_alarm(&reminder, index)?;
928        self.save_reminder_and_refresh(&reminder)?;
929        Ok(())
930    }
931
932    // ========================================================================
933    // URL Management
934    // ========================================================================
935
936    /// Set or clear the URL on a reminder.
937    #[allow(non_snake_case)]
938    pub fn set_URL(&self, identifier: &str, url: Option<&str>) -> Result<()> {
939        self.ensure_authorized()?;
940        let reminder = self.find_reminder_by_id(identifier)?;
941        set_item_URL(&reminder, url)?;
942        self.save_reminder_and_refresh(&reminder)?;
943        Ok(())
944    }
945
946    /// Set or clear the free-text `location` on a reminder.
947    pub fn set_location(&self, identifier: &str, location: Option<&str>) -> Result<()> {
948        self.ensure_authorized()?;
949        let reminder = self.find_reminder_by_id(identifier)?;
950        set_item_location(&reminder, location);
951        self.save_reminder_and_refresh(&reminder)?;
952        Ok(())
953    }
954
955    /// Set or clear `EKReminder.dueDateTimeZone`. `tz_name` must be an
956    /// IANA zone identifier (e.g. `"America/Los_Angeles"`).
957    pub fn set_due_date_timezone(&self, identifier: &str, tz_name: Option<&str>) -> Result<()> {
958        self.ensure_authorized()?;
959        let reminder = self.find_reminder_by_id(identifier)?;
960        set_reminder_due_date_timezone(&reminder, tz_name);
961        self.save_reminder_and_refresh(&reminder)?;
962        Ok(())
963    }
964
965    /// Set (or clear) the reminder's rich location — `EKReminder.structuredLocation`
966    /// (not in `objc2-event-kit` 0.3 generated bindings — accessed via
967    /// `msg_send!`).
968    ///
969    /// **iCloud caveat:** verified empirically — iCloud silently drops this
970    /// mutation on reminder objects even though the save returns success.
971    /// Use [`set_geofence`](Self::set_geofence) instead for iCloud-synced
972    /// reminders; it writes the structured location on a proximity alarm,
973    /// which iCloud does persist. This method remains correct for local /
974    /// non-iCloud reminder sources.
975    pub fn set_structured_location(
976        &self,
977        identifier: &str,
978        loc: Option<&StructuredLocation>,
979    ) -> Result<()> {
980        self.ensure_authorized()?;
981        let reminder = self.find_reminder_by_id(identifier)?;
982        set_reminder_structured_location(&reminder, loc);
983        self.save_reminder_and_refresh(&reminder)?;
984        Ok(())
985    }
986
987    /// Attach (or clear) a geofence on the reminder. Implemented as a
988    /// location-based `EKAlarm` since EventKit has no `structuredLocation`
989    /// directly on `EKReminder` / `EKCalendarItem`. When `geofence` is
990    /// `Some`, also requests WhenInUse location authorization so the alarm
991    /// actually has permission to fire — if the user denies, the save is
992    /// aborted with an authorization error.
993    pub fn set_geofence(
994        &self,
995        identifier: &str,
996        geofence: Option<(&StructuredLocation, AlarmProximity)>,
997    ) -> Result<()> {
998        self.ensure_authorized()?;
999
1000        // Prompt for location auth before saving a geofence. Without this we
1001        // would silently create a reminder whose proximity trigger can't fire.
1002        #[cfg(feature = "location")]
1003        if geofence.is_some() {
1004            use crate::location::{LocationAuthorizationStatus as L, LocationManager};
1005            let loc_mgr = LocationManager::new();
1006            if loc_mgr.authorization_status() == L::NotDetermined {
1007                loc_mgr.request_when_in_use_authorization();
1008                // CLLocationManager delivers the result asynchronously on the
1009                // run loop. Match the polling pattern get_current_location uses.
1010                std::thread::sleep(std::time::Duration::from_millis(500));
1011            }
1012            match loc_mgr.authorization_status() {
1013                L::Authorized => {}
1014                L::Denied => return Err(EventKitError::AuthorizationDenied),
1015                L::Restricted => return Err(EventKitError::AuthorizationRestricted),
1016                L::NotDetermined => return Err(EventKitError::AuthorizationNotDetermined),
1017            }
1018        }
1019
1020        let reminder = self.find_reminder_by_id(identifier)?;
1021        set_reminder_geofence(&reminder, geofence);
1022        self.save_reminder_and_refresh(&reminder)?;
1023        Ok(())
1024    }
1025
1026    // ========================================================================
1027    // Recurrence Rule Management
1028    // ========================================================================
1029
1030    /// Gets recurrence rules on a reminder.
1031    pub fn get_recurrence_rules(&self, identifier: &str) -> Result<Vec<RecurrenceRule>> {
1032        self.ensure_authorized()?;
1033        let reminder = self.find_reminder_by_id(identifier)?;
1034        Ok(get_item_recurrence_rules(&reminder))
1035    }
1036
1037    /// Sets a recurrence rule on a reminder (replaces any existing rules).
1038    pub fn set_recurrence_rule(&self, identifier: &str, rule: &RecurrenceRule) -> Result<()> {
1039        self.ensure_authorized()?;
1040        let reminder = self.find_reminder_by_id(identifier)?;
1041        set_item_recurrence_rule(&reminder, rule);
1042        self.save_reminder_and_refresh(&reminder)?;
1043        Ok(())
1044    }
1045
1046    /// Removes all recurrence rules from a reminder.
1047    pub fn remove_recurrence_rules(&self, identifier: &str) -> Result<()> {
1048        self.ensure_authorized()?;
1049        let reminder = self.find_reminder_by_id(identifier)?;
1050        clear_item_recurrence_rules(&reminder);
1051        self.save_reminder_and_refresh(&reminder)?;
1052        Ok(())
1053    }
1054
1055    // ========================================================================
1056    // Calendar (Reminder List) Management
1057    // ========================================================================
1058
1059    /// Creates a new reminder list (calendar)
1060    ///
1061    /// The list will be created in the default source (usually iCloud or Local).
1062    pub fn create_calendar(&self, title: &str) -> Result<CalendarInfo> {
1063        self.ensure_authorized()?;
1064
1065        // Create a new calendar for reminders
1066        let calendar = unsafe {
1067            EKCalendar::calendarForEntityType_eventStore(EKEntityType::Reminder, &self.store)
1068        };
1069
1070        // Set the title
1071        let ns_title = NSString::from_str(title);
1072        unsafe { calendar.setTitle(&ns_title) };
1073
1074        // Find a suitable source (prefer iCloud, fall back to local)
1075        let source = self.find_best_source_for_reminders()?;
1076        unsafe { calendar.setSource(Some(&source)) };
1077
1078        // Save the calendar
1079        unsafe {
1080            self.store
1081                .saveCalendar_commit_error(&calendar, true)
1082                .map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
1083        }
1084
1085        Ok(calendar_to_info(&calendar))
1086    }
1087
1088    /// Renames an existing reminder list (calendar)
1089    /// Rename a reminder list (backward compat wrapper).
1090    pub fn rename_calendar(&self, identifier: &str, new_title: &str) -> Result<CalendarInfo> {
1091        self.update_calendar(identifier, Some(new_title), None)
1092    }
1093
1094    /// Update a reminder list — name, color, or both.
1095    pub fn update_calendar(
1096        &self,
1097        identifier: &str,
1098        new_title: Option<&str>,
1099        color_rgba: Option<(f64, f64, f64, f64)>,
1100    ) -> Result<CalendarInfo> {
1101        self.ensure_authorized()?;
1102        let calendar = self.find_calendar_by_id(identifier)?;
1103
1104        if !unsafe { calendar.allowsContentModifications() } {
1105            return Err(EventKitError::SaveFailed(
1106                "Calendar does not allow modifications".to_string(),
1107            ));
1108        }
1109
1110        if let Some(title) = new_title {
1111            let ns_title = NSString::from_str(title);
1112            unsafe { calendar.setTitle(&ns_title) };
1113        }
1114
1115        if let Some((r, g, b, a)) = color_rgba {
1116            let cg = objc2_core_graphics::CGColor::new_srgb(r, g, b, a);
1117            unsafe { calendar.setCGColor(Some(&cg)) };
1118        }
1119
1120        unsafe {
1121            self.store
1122                .saveCalendar_commit_error(&calendar, true)
1123                .map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
1124        }
1125
1126        Ok(calendar_to_info(&calendar))
1127    }
1128
1129    /// Deletes a reminder list (calendar)
1130    ///
1131    /// Warning: This will delete all reminders in the list!
1132    pub fn delete_calendar(&self, identifier: &str) -> Result<()> {
1133        self.ensure_authorized()?;
1134
1135        let calendar = self.find_calendar_by_id(identifier)?;
1136
1137        // Check if modifications are allowed
1138        if !unsafe { calendar.allowsContentModifications() } {
1139            return Err(EventKitError::DeleteFailed(
1140                "Calendar does not allow modifications".to_string(),
1141            ));
1142        }
1143
1144        unsafe {
1145            self.store
1146                .removeCalendar_commit_error(&calendar, true)
1147                .map_err(|e| EventKitError::DeleteFailed(format!("{:?}", e)))?;
1148        }
1149
1150        Ok(())
1151    }
1152
1153    /// Gets a calendar by its identifier
1154    pub fn get_calendar(&self, identifier: &str) -> Result<CalendarInfo> {
1155        self.ensure_authorized()?;
1156        let calendar = self.find_calendar_by_id(identifier)?;
1157        Ok(calendar_to_info(&calendar))
1158    }
1159
1160    // Helper to find the best source for creating new reminder calendars
1161    fn find_best_source_for_reminders(&self) -> Result<Retained<objc2_event_kit::EKSource>> {
1162        // Try to get the source from the default calendar first
1163        if let Some(default_cal) = unsafe { self.store.defaultCalendarForNewReminders() }
1164            && let Some(source) = unsafe { default_cal.source() }
1165        {
1166            return Ok(source);
1167        }
1168
1169        // Fall back to finding any source that supports reminders
1170        let sources = unsafe { self.store.sources() };
1171        for source in sources.iter() {
1172            // Check if this source supports reminder calendars
1173            let calendars = unsafe { source.calendarsForEntityType(EKEntityType::Reminder) };
1174            if !calendars.is_empty() {
1175                return Ok(source.retain());
1176            }
1177        }
1178
1179        Err(EventKitError::SaveFailed(
1180            "No suitable source found for creating reminder calendar".to_string(),
1181        ))
1182    }
1183
1184    // Helper to find a calendar by identifier
1185    fn find_calendar_by_id(&self, identifier: &str) -> Result<Retained<EKCalendar>> {
1186        let ns_id = NSString::from_str(identifier);
1187        let calendar = unsafe { self.store.calendarWithIdentifier(&ns_id) };
1188
1189        match calendar {
1190            Some(cal) => Ok(cal),
1191            None => Err(EventKitError::CalendarNotFound(identifier.to_string())),
1192        }
1193    }
1194
1195    // Helper to find a calendar by title
1196    fn find_calendar_by_title(&self, title: &str) -> Result<Retained<EKCalendar>> {
1197        let calendars = unsafe { self.store.calendarsForEntityType(EKEntityType::Reminder) };
1198
1199        for cal in calendars.iter() {
1200            let cal_title = unsafe { cal.title() };
1201            if cal_title.to_string() == title {
1202                return Ok(cal.retain());
1203            }
1204        }
1205
1206        Err(RemindersError::CalendarNotFound(title.to_string()))
1207    }
1208
1209    // Helper to find a reminder by identifier
1210    /// Commit a reminder to the store and refresh sources, so a subsequent
1211    /// `find_reminder_by_id` or fetch sees the just-saved state without
1212    /// waiting for the daemon to notice. All `pub fn` save paths on
1213    /// `RemindersManager` route through this helper.
1214    fn save_reminder_and_refresh(&self, reminder: &EKReminder) -> Result<()> {
1215        unsafe {
1216            self.store
1217                .saveReminder_commit_error(reminder, true)
1218                .map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
1219            self.store.refreshSourcesIfNecessary();
1220        }
1221        Ok(())
1222    }
1223
1224    fn find_reminder_by_id(&self, identifier: &str) -> Result<Retained<EKReminder>> {
1225        // Pick up any changes the user made in Reminders.app since the
1226        // store was created — without this, iCloud edits made on the same
1227        // machine may not be visible until next process launch.
1228        unsafe { self.store.refreshSourcesIfNecessary() };
1229
1230        let ns_id = NSString::from_str(identifier);
1231        let item = unsafe { self.store.calendarItemWithIdentifier(&ns_id) };
1232
1233        match item {
1234            Some(item) => {
1235                // Try to downcast to EKReminder
1236                if let Some(reminder) = item.downcast_ref::<EKReminder>() {
1237                    Ok(reminder.retain())
1238                } else {
1239                    Err(EventKitError::ItemNotFound(identifier.to_string()))
1240                }
1241            }
1242            None => Err(EventKitError::ItemNotFound(identifier.to_string())),
1243        }
1244    }
1245}
1246
1247impl Default for RemindersManager {
1248    fn default() -> Self {
1249        Self::new()
1250    }
1251}
1252
1253/// Authorization status for reminders access
1254#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1255pub enum AuthorizationStatus {
1256    /// User has not yet made a choice
1257    NotDetermined,
1258    /// Access restricted by system policy
1259    Restricted,
1260    /// User explicitly denied access
1261    Denied,
1262    /// Full access granted
1263    FullAccess,
1264    /// Write-only access granted
1265    WriteOnly,
1266}
1267
1268impl From<EKAuthorizationStatus> for AuthorizationStatus {
1269    fn from(status: EKAuthorizationStatus) -> Self {
1270        if status == EKAuthorizationStatus::NotDetermined {
1271            AuthorizationStatus::NotDetermined
1272        } else if status == EKAuthorizationStatus::Restricted {
1273            AuthorizationStatus::Restricted
1274        } else if status == EKAuthorizationStatus::Denied {
1275            AuthorizationStatus::Denied
1276        } else if status == EKAuthorizationStatus::FullAccess {
1277            AuthorizationStatus::FullAccess
1278        } else if status == EKAuthorizationStatus::WriteOnly {
1279            AuthorizationStatus::WriteOnly
1280        } else {
1281            AuthorizationStatus::NotDetermined
1282        }
1283    }
1284}
1285
1286impl std::fmt::Display for AuthorizationStatus {
1287    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1288        match self {
1289            AuthorizationStatus::NotDetermined => write!(f, "Not Determined"),
1290            AuthorizationStatus::Restricted => write!(f, "Restricted"),
1291            AuthorizationStatus::Denied => write!(f, "Denied"),
1292            AuthorizationStatus::FullAccess => write!(f, "Full Access"),
1293            AuthorizationStatus::WriteOnly => write!(f, "Write Only"),
1294        }
1295    }
1296}
1297
1298// Helper function to convert EKReminder to ReminderItem
1299/// Walks the class chain of `obj` and lists every declared `@property`,
1300/// reading each one via KVC (`-[NSObject valueForKey:]`). The returned string
1301/// is multi-section human-readable text — one section per class in the chain.
1302///
1303/// Only intended for diagnostics: KVC dispatch is slower than direct method
1304/// calls and may throw NSException for very unusual property declarations.
1305// Properties whose KVC read path hits a C-level assertion (CADObjectID etc.)
1306// and aborts the process. Skip these unconditionally when `read_values` is on.
1307// Discovered empirically — add to this list when a new abort is found.
1308const REFLECT_VALUE_DENYLIST: &[&str] =
1309    &["objectID", "objectIDURIRepresentation", "URIRepresentation"];
1310
1311fn reflect_object_full<T: Message>(header: &str, obj: &T, read_values: bool) -> String {
1312    use objc2::ffi::{class_copyPropertyList, free, property_getAttributes, property_getName};
1313    use objc2::msg_send;
1314    use objc2::rc::Retained;
1315    use objc2::runtime::{AnyClass, AnyObject};
1316    use std::ffi::CStr;
1317
1318    // Cast to AnyObject for runtime introspection. `T: Message` guarantees
1319    // this is an Objective-C object with a valid isa pointer.
1320    let obj: &AnyObject = unsafe { &*(obj as *const T as *const AnyObject) };
1321
1322    let mut out = String::new();
1323    out.push_str(&format!("=== {header} ===\n"));
1324
1325    // Build the class chain (most-derived first).
1326    let mut chain: Vec<&'static AnyClass> = Vec::new();
1327    let mut current = Some(obj.class());
1328    while let Some(cls) = current {
1329        chain.push(cls);
1330        current = cls.superclass();
1331    }
1332    out.push_str("Class chain: ");
1333    out.push_str(
1334        &chain
1335            .iter()
1336            .map(|c| c.name().to_string_lossy().into_owned())
1337            .collect::<Vec<_>>()
1338            .join(" -> "),
1339    );
1340    out.push_str("\n\n");
1341
1342    for cls in &chain {
1343        let cls_name = cls.name().to_string_lossy().into_owned();
1344        // Stop at NSObject — its properties are noise (hash, description, ...).
1345        if cls_name == "NSObject" {
1346            continue;
1347        }
1348
1349        let mut count: u32 = 0;
1350        let props_ptr =
1351            unsafe { class_copyPropertyList(*cls as *const AnyClass, &mut count as *mut u32) };
1352        if props_ptr.is_null() || count == 0 {
1353            if !props_ptr.is_null() {
1354                unsafe { free(props_ptr as *mut _) };
1355            }
1356            continue;
1357        }
1358
1359        let mut entries: Vec<(String, String, String)> = Vec::new();
1360        for i in 0..count as isize {
1361            let prop = unsafe { *props_ptr.offset(i) };
1362            if prop.is_null() {
1363                continue;
1364            }
1365            let name_ptr = unsafe { property_getName(prop) };
1366            let attrs_ptr = unsafe { property_getAttributes(prop) };
1367            let name = if name_ptr.is_null() {
1368                String::from("<?>")
1369            } else {
1370                unsafe { CStr::from_ptr(name_ptr) }
1371                    .to_string_lossy()
1372                    .into_owned()
1373            };
1374            let attrs = if attrs_ptr.is_null() {
1375                String::new()
1376            } else {
1377                unsafe { CStr::from_ptr(attrs_ptr) }
1378                    .to_string_lossy()
1379                    .into_owned()
1380            };
1381
1382            // Anything typed `EKObjectID` / `NSManagedObjectID` / `CADObjectID`
1383            // hits a CoreData `__assert_rtn` on KVC read and will SIGABRT.
1384            // Filter by type encoding rather than name — safer as new
1385            // properties are added in future SDKs.
1386            let type_is_objectid = attrs.contains("ObjectID");
1387
1388            let value_str = if !read_values {
1389                String::from("<value read skipped (pass --values to attempt)>")
1390            } else if REFLECT_VALUE_DENYLIST.contains(&name.as_str()) || type_is_objectid {
1391                String::from("<skipped: type/name denylisted (would abort process)>")
1392            } else {
1393                // Read via KVC, catching any NSException. Note: this catches
1394                // ObjC exceptions but NOT C-level assertions (`__assert_rtn`),
1395                // which abort the process directly — hence the denylist above.
1396                let name_for_closure = name.clone();
1397                let obj_ptr = obj as *const AnyObject;
1398                match objc2::exception::catch(std::panic::AssertUnwindSafe(move || {
1399                    let key = NSString::from_str(&name_for_closure);
1400                    let obj_ref: &AnyObject = unsafe { &*obj_ptr };
1401                    let value_obj: Option<Retained<AnyObject>> =
1402                        unsafe { msg_send![obj_ref, valueForKey: &*key] };
1403                    match value_obj {
1404                        None => String::from("(null)"),
1405                        Some(v) => describe_object(&v),
1406                    }
1407                })) {
1408                    Ok(s) => s,
1409                    Err(Some(exc)) => {
1410                        format!("<NSException: {}>", describe_object(&**exc as &AnyObject))
1411                    }
1412                    Err(None) => String::from("<unknown ObjC exception>"),
1413                }
1414            };
1415
1416            entries.push((name, attrs, value_str));
1417        }
1418        unsafe { free(props_ptr as *mut _) };
1419
1420        if entries.is_empty() {
1421            continue;
1422        }
1423        entries.sort_by(|a, b| a.0.cmp(&b.0));
1424
1425        out.push_str(&format!("[{cls_name}]\n"));
1426        for (name, attrs, value) in entries {
1427            out.push_str(&format!("  {name}\n"));
1428            out.push_str(&format!("    attrs: {attrs}\n"));
1429            // Indent multi-line values so they don't break the layout.
1430            let indented = value.replace('\n', "\n      ");
1431            out.push_str(&format!("    value: {indented}\n"));
1432        }
1433        out.push('\n');
1434    }
1435
1436    out
1437}
1438
1439/// Probes a curated list of selectors that are not generated by
1440/// `objc2-event-kit` and that KVC rejects on `EKReminder`. Each call is
1441/// wrapped in `objc2::exception::catch` so a missing selector ("unrecognized
1442/// selector sent to instance") becomes a printable line instead of an abort.
1443///
1444/// This is a fishing expedition — the selector names are educated guesses
1445/// based on Apple's public naming patterns and what Reminders.app surfaces.
1446/// Add entries as new candidates come up; the cost of an extra probe is one
1447/// caught NSException.
1448fn probe_private_selectors(reminder: &EKReminder) -> String {
1449    use objc2::msg_send;
1450    use objc2::rc::Retained;
1451    use objc2::runtime::{AnyObject, Sel};
1452    use std::ffi::CString;
1453
1454    const PROBES: &[&str] = &[
1455        // Rich-link / preview URL candidates
1456        "appLink",      // <- confirmed exists on EKCalendarItem (method dump)
1457        "URLString",    // <- confirmed exists on EKCalendarItem
1458        "externalData", // <- confirmed exists on EKCalendarItem
1459        "externalModificationTag",
1460        "richLink",
1461        "richLinkData",
1462        "URLWithPreview",
1463        "urlPreview",
1464        "linkPreview",
1465        "siriSuggestedURL",
1466        "displayURL",
1467        "flaggedURL",
1468        "_richLink",
1469        "_url",
1470        "primaryLink",
1471        // Structured-location candidates on the reminder itself
1472        "structuredLocation",
1473        "geoLocation",
1474        "place",
1475        "placemark",
1476        // Tag candidates
1477        "tags",
1478        "tagDictionaries",
1479        "tagNames",
1480        "userTags",
1481        "_tags",
1482        "_tagDictionaries",
1483        "hashtags",
1484        // Backing structured-data blobs (KVC-blocked, try typed accessor)
1485        "structuredData",
1486        "localStructuredData",
1487        "_structuredData",
1488        // "Melted" (mutable) vs frozen reminder layer
1489        "meltedObject",
1490        "unfrozen",
1491        "unfrozenObject",
1492        "writableInstance",
1493        // Misc EventKit private surfaces
1494        "flags",
1495        "flags2",
1496        "isFlagged",
1497        "image",
1498        "imageData",
1499        "thumbnail",
1500        "color",
1501        "creatorBundleID",
1502        // Subtask / parent
1503        "parentReminder",
1504        "subreminders",
1505        "subtasks",
1506        "childReminders",
1507    ];
1508
1509    let obj: &AnyObject = unsafe { &*(reminder as *const EKReminder as *const AnyObject) };
1510    let mut out = String::from("=== Private-selector probe on EKReminder ===\n");
1511    out.push_str("(each call wrapped in @try/@catch — caught exceptions mean the selector doesn't exist on this class)\n\n");
1512
1513    for name in PROBES {
1514        let c_name = CString::new(*name).expect("selector name has no NUL");
1515        let sel = Sel::register(&c_name);
1516        let name_owned = name.to_string();
1517        let obj_ptr = obj as *const AnyObject;
1518        let line = match objc2::exception::catch(std::panic::AssertUnwindSafe(move || {
1519            // Confirm the object responds before we send, so we get a clean
1520            // "doesn't respond" instead of an NSException for the common case.
1521            let obj_ref: &AnyObject = unsafe { &*obj_ptr };
1522            let responds: bool = unsafe { msg_send![obj_ref, respondsToSelector: sel] };
1523            if !responds {
1524                return format!("{name_owned}: <does not respond>");
1525            }
1526            let result: Option<Retained<AnyObject>> =
1527                unsafe { msg_send![obj_ref, performSelector: sel] };
1528            match result {
1529                None => format!("{name_owned}: (null)"),
1530                Some(v) => {
1531                    let cls: &objc2::runtime::AnyClass = (*v).class();
1532                    let cls_name = cls.name().to_string_lossy().into_owned();
1533                    let desc = describe_object(&v);
1534                    let one_line = desc.replace('\n', "\\n");
1535                    format!("{name_owned}: [{cls_name}] {one_line}")
1536                }
1537            }
1538        })) {
1539            Ok(s) => s,
1540            Err(Some(exc)) => format!(
1541                "{name}: <NSException: {}>",
1542                describe_object(&**exc as &AnyObject)
1543            ),
1544            Err(None) => format!("{name}: <unknown ObjC exception>"),
1545        };
1546        out.push_str(&line);
1547        out.push('\n');
1548    }
1549
1550    // Drill into the underlying persistent object — `backingObject` is the
1551    // EKPersistentObject the frozen wrapper proxies. If a method is
1552    // KVC-blocked on the frozen wrapper it may work on the backing object.
1553    let backing: Option<Retained<AnyObject>> = unsafe { msg_send![obj, backingObject] };
1554    if let Some(backing) = backing {
1555        let bcls = (*backing).class().name().to_string_lossy().into_owned();
1556        out.push_str(&format!("\n--- backingObject ({bcls}) probe ---\n"));
1557        for name in PROBES {
1558            let c_name = std::ffi::CString::new(*name).expect("no NUL");
1559            let sel = objc2::runtime::Sel::register(&c_name);
1560            let name_owned = name.to_string();
1561            let backing_ptr = &*backing as *const AnyObject;
1562            let line = match objc2::exception::catch(std::panic::AssertUnwindSafe(move || {
1563                let bref: &AnyObject = unsafe { &*backing_ptr };
1564                let responds: bool = unsafe { msg_send![bref, respondsToSelector: sel] };
1565                if !responds {
1566                    return format!("{name_owned}: <does not respond>");
1567                }
1568                let result: Option<Retained<AnyObject>> =
1569                    unsafe { msg_send![bref, performSelector: sel] };
1570                match result {
1571                    None => format!("{name_owned}: (null)"),
1572                    Some(v) => {
1573                        let cls = (*v).class().name().to_string_lossy().into_owned();
1574                        format!(
1575                            "{name_owned}: [{cls}] {}",
1576                            describe_object(&v).replace('\n', "\\n")
1577                        )
1578                    }
1579                }
1580            })) {
1581                Ok(s) => s,
1582                Err(Some(exc)) => format!(
1583                    "{name}: <NSException: {}>",
1584                    describe_object(&**exc as &AnyObject)
1585                ),
1586                Err(None) => format!("{name}: <unknown ObjC exception>"),
1587            };
1588            out.push_str(&line);
1589            out.push('\n');
1590        }
1591    }
1592
1593    // Bonus: enumerate every instance method via the runtime, filtered to
1594    // names that look URL/tag/link/data-related. This catches selectors we
1595    // didn't guess in PROBES.
1596    out.push_str(
1597        "\n--- All zero-arg instance methods on EKReminder containing url/tag/link/data ---\n",
1598    );
1599    out.push_str(&list_interesting_methods(obj.class()));
1600    out
1601}
1602
1603/// Lists every instance method on `cls` (walking up the class chain to EKObject)
1604/// whose selector name contains `url`, `tag`, `link`, `data`, `flag`, `image`,
1605/// `preview`, or `attachment`. Doesn't call them — just shows what exists.
1606fn list_interesting_methods(start: &objc2::runtime::AnyClass) -> String {
1607    use objc2::ffi::{class_copyMethodList, free, method_getName};
1608    use objc2::runtime::AnyClass;
1609    use std::ffi::CStr;
1610
1611    let needles = [
1612        "url",
1613        "tag",
1614        "link",
1615        "data",
1616        "flag",
1617        "image",
1618        "preview",
1619        "attachment",
1620        "rich",
1621        "location",
1622        "structured",
1623        "place",
1624        "address",
1625        "geo",
1626    ];
1627    let mut hits: Vec<(String, String)> = Vec::new();
1628
1629    let mut current: Option<&AnyClass> = Some(start);
1630    while let Some(cls) = current {
1631        let cls_name = cls.name().to_string_lossy().into_owned();
1632        if cls_name == "NSObject" {
1633            break;
1634        }
1635        let mut count: u32 = 0;
1636        let ptr = unsafe { class_copyMethodList(cls as *const AnyClass, &mut count as *mut u32) };
1637        if !ptr.is_null() {
1638            for i in 0..count as isize {
1639                let m = unsafe { *ptr.offset(i) };
1640                if m.is_null() {
1641                    continue;
1642                }
1643                let Some(sel) = (unsafe { method_getName(m) }) else {
1644                    continue;
1645                };
1646                let name_cstr_ptr = unsafe { objc2::ffi::sel_getName(sel) };
1647                if name_cstr_ptr.is_null() {
1648                    continue;
1649                }
1650                let name = unsafe { CStr::from_ptr(name_cstr_ptr) }
1651                    .to_string_lossy()
1652                    .into_owned();
1653                let lower = name.to_lowercase();
1654                if needles.iter().any(|n| lower.contains(n)) {
1655                    hits.push((cls_name.clone(), name));
1656                }
1657            }
1658            unsafe { free(ptr as *mut _) };
1659        }
1660        current = cls.superclass();
1661    }
1662    hits.sort();
1663    hits.dedup();
1664    if hits.is_empty() {
1665        return String::from("(none)\n");
1666    }
1667    let mut out = String::new();
1668    for (cls, sel) in hits {
1669        out.push_str(&format!("  [{cls}] {sel}\n"));
1670    }
1671    out
1672}
1673
1674/// `-[NSObject description]` as a Rust string. Returns `<nil>` for null,
1675/// `<no description>` if the object doesn't respond to description.
1676fn describe_object(obj: &objc2::runtime::AnyObject) -> String {
1677    use objc2::msg_send;
1678    use objc2::rc::Retained;
1679    use objc2::runtime::AnyObject;
1680
1681    let desc: Option<Retained<AnyObject>> = unsafe { msg_send![obj, description] };
1682    match desc {
1683        None => String::from("<no description>"),
1684        Some(d) => {
1685            // description always returns NSString — cast pointer-wise.
1686            let any: &AnyObject = &d;
1687            let ns: &NSString = unsafe { &*(any as *const AnyObject as *const NSString) };
1688            ns.to_string()
1689        }
1690    }
1691}
1692
1693fn reminder_to_item(reminder: &EKReminder) -> ReminderItem {
1694    let identifier = unsafe { reminder.calendarItemIdentifier() }.to_string();
1695    let title = unsafe { reminder.title() }.to_string();
1696    let notes = unsafe { reminder.notes() }.map(|n| n.to_string());
1697    let completed = unsafe { reminder.isCompleted() };
1698    let priority = unsafe { reminder.priority() };
1699    let cal = unsafe { reminder.calendar() };
1700    let calendar_title = cal.as_ref().map(|c| unsafe { c.title() }.to_string());
1701    let calendar_id = cal
1702        .as_ref()
1703        .map(|c| unsafe { c.calendarIdentifier() }.to_string());
1704
1705    // Extract due date from dueDateComponents
1706    let due_date = unsafe { reminder.dueDateComponents() }
1707        .and_then(|components| date_components_to_datetime(&components));
1708
1709    // Extract start date from startDateComponents
1710    let start_date = unsafe { reminder.startDateComponents() }
1711        .and_then(|components| date_components_to_datetime(&components));
1712
1713    // Extract completion date
1714    let completion_date =
1715        unsafe { reminder.completionDate() }.map(|date| nsdate_to_datetime(&date));
1716
1717    // Extract additional fields from EKCalendarItem parent class
1718    let external_identifier =
1719        unsafe { reminder.calendarItemExternalIdentifier() }.map(|id| id.to_string());
1720    let location = unsafe { reminder.location() }.map(|loc| loc.to_string());
1721    #[allow(non_snake_case)]
1722    let URL = unsafe { reminder.URL() }
1723        .as_ref()
1724        .and_then(|url_ref| url_ref.absoluteString())
1725        .map(|abs_str| abs_str.to_string());
1726    let creation_date = unsafe { reminder.creationDate() }.map(|date| nsdate_to_datetime(&date));
1727    let last_modified_date =
1728        unsafe { reminder.lastModifiedDate() }.map(|date| nsdate_to_datetime(&date));
1729    let timezone = unsafe { reminder.timeZone() }.map(|tz| tz.name().to_string());
1730    let due_date_timezone = get_reminder_due_date_timezone(reminder);
1731    let structured_location = get_reminder_structured_location(reminder);
1732    let parent_id = get_reminder_parent_id(reminder);
1733    let attachments_count = get_item_attachments_count(reminder);
1734    let has_alarms = unsafe { reminder.hasAlarms() };
1735    let has_recurrence_rules = unsafe { reminder.hasRecurrenceRules() };
1736    let has_attendees = unsafe { reminder.hasAttendees() };
1737    let has_notes = unsafe { reminder.hasNotes() };
1738
1739    ReminderItem {
1740        identifier,
1741        title,
1742        notes,
1743        completed,
1744        priority,
1745        calendar_title,
1746        calendar_id,
1747        due_date,
1748        start_date,
1749        completion_date,
1750        external_identifier,
1751        location,
1752        URL,
1753        creation_date,
1754        last_modified_date,
1755        timezone,
1756        due_date_timezone,
1757        structured_location,
1758        parent_id,
1759        attachments_count,
1760        has_alarms,
1761        has_recurrence_rules,
1762        has_attendees,
1763        has_notes,
1764        attendees: get_item_attendees(reminder),
1765    }
1766}
1767
1768/// Read `EKReminder.dueDateTimeZone` via msg_send — not exposed in
1769/// `objc2-event-kit` 0.3 generated bindings.
1770fn get_reminder_due_date_timezone(reminder: &EKReminder) -> Option<String> {
1771    use objc2::msg_send;
1772    use objc2::rc::Retained;
1773    let tz: Option<Retained<objc2_foundation::NSTimeZone>> =
1774        unsafe { msg_send![reminder, dueDateTimeZone] };
1775    tz.map(|tz| tz.name().to_string())
1776}
1777
1778/// Reads `EKReminder.structuredLocation` — the iCloud-native location chip
1779/// that Reminders.app displays. Apple shipped this on `EKCalendarItem` but
1780/// `objc2-event-kit` 0.3 doesn't generate bindings for it on reminders
1781/// (only on `EKEvent`/`EKAlarm`), so we go through `msg_send!`.
1782///
1783/// Falls back to the alarm-based geofence path for older reminders whose
1784/// location is only attached via a proximity alarm.
1785fn get_reminder_structured_location(reminder: &EKReminder) -> Option<StructuredLocation> {
1786    if let Some(sl) = direct_structured_location(reminder) {
1787        return Some(sl);
1788    }
1789    // Legacy fallback: scan alarms for proximity-bearing structured location.
1790    let alarms = unsafe { reminder.alarms() }?;
1791    for i in 0..alarms.len() {
1792        let alarm = alarms.objectAtIndex(i);
1793        let prox = unsafe { alarm.proximity() };
1794        if prox == EKAlarmProximity::None {
1795            continue;
1796        }
1797        let Some(structured) = (unsafe { alarm.structuredLocation() }) else {
1798            continue;
1799        };
1800        return Some(structured_to_plain(&structured));
1801    }
1802    None
1803}
1804
1805fn direct_structured_location(reminder: &EKReminder) -> Option<StructuredLocation> {
1806    use objc2::msg_send;
1807    use objc2::rc::Retained;
1808    let sl: Option<Retained<EKStructuredLocation>> =
1809        unsafe { msg_send![reminder, structuredLocation] };
1810    sl.map(|s| structured_to_plain(&s))
1811}
1812
1813fn structured_to_plain(structured: &EKStructuredLocation) -> StructuredLocation {
1814    let title = unsafe { structured.title() }
1815        .map(|t| t.to_string())
1816        .unwrap_or_default();
1817    let radius = unsafe { structured.radius() };
1818    let (latitude, longitude) = read_geo_location_coords(structured);
1819    StructuredLocation {
1820        title,
1821        latitude,
1822        longitude,
1823        radius,
1824    }
1825}
1826
1827/// Pull lat/lng off an `EKStructuredLocation`'s `geoLocation` (CLLocation).
1828/// Returns (0.0, 0.0) when the `location` feature is disabled — coordinates
1829/// require CoreLocation bindings.
1830#[cfg(feature = "location")]
1831fn read_geo_location_coords(structured: &EKStructuredLocation) -> (f64, f64) {
1832    let cl = unsafe { structured.geoLocation() };
1833    let Some(cl) = cl else { return (0.0, 0.0) };
1834    let coord = unsafe { cl.coordinate() };
1835    (coord.latitude, coord.longitude)
1836}
1837
1838#[cfg(not(feature = "location"))]
1839fn read_geo_location_coords(_structured: &EKStructuredLocation) -> (f64, f64) {
1840    (0.0, 0.0)
1841}
1842
1843/// Read `EKReminder.parentID` via KVC. The underlying type is the private
1844/// `EKObjectID`; we stringify via `-[description]`. Returns `None` if the
1845/// reminder has no parent or if the KVC read throws.
1846fn get_reminder_parent_id(reminder: &EKReminder) -> Option<String> {
1847    use objc2::msg_send;
1848    use objc2::rc::Retained;
1849    use objc2::runtime::AnyObject;
1850    let reminder_obj: &AnyObject = unsafe { &*(reminder as *const EKReminder as *const AnyObject) };
1851    let reminder_ptr = reminder_obj as *const AnyObject;
1852    let value = objc2::exception::catch(std::panic::AssertUnwindSafe(move || {
1853        let key = NSString::from_str("parentID");
1854        let obj_ref: &AnyObject = unsafe { &*reminder_ptr };
1855        let v: Option<Retained<AnyObject>> = unsafe { msg_send![obj_ref, valueForKey: &*key] };
1856        v
1857    }))
1858    .ok()
1859    .flatten()?;
1860    let desc: Retained<AnyObject> = unsafe { msg_send![&*value, description] };
1861    let ns: &NSString = unsafe { &*(&*desc as *const AnyObject as *const NSString) };
1862    Some(ns.to_string())
1863}
1864
1865/// Number of file attachments on the item. EventKit doesn't expose the
1866/// attachment objects via the public Rust bindings; we just count them.
1867fn get_item_attachments_count(item: &EKCalendarItem) -> usize {
1868    use objc2::msg_send;
1869    use objc2::rc::Retained;
1870    use objc2_foundation::NSArray;
1871    let attachments: Option<Retained<NSArray>> = unsafe { msg_send![item, attachments] };
1872    attachments.map(|a| a.len()).unwrap_or(0)
1873}
1874
1875// Helper function to convert EKCalendar to CalendarInfo
1876fn source_to_info(source: &EKSource) -> SourceInfo {
1877    let identifier = unsafe { source.sourceIdentifier() }.to_string();
1878    let title = unsafe { source.title() }.to_string();
1879    // EKSourceType: 0=Local, 1=Exchange, 2=CalDAV, 3=MobileMe, 4=Subscribed, 5=Birthdays
1880    let source_type = unsafe { source.sourceType() };
1881    let source_type = match source_type.0 {
1882        0 => "local",
1883        1 => "exchange",
1884        2 => "caldav",
1885        3 => "mobileme",
1886        4 => "subscribed",
1887        5 => "birthdays",
1888        _ => "unknown",
1889    }
1890    .to_string();
1891
1892    SourceInfo {
1893        identifier,
1894        title,
1895        source_type,
1896    }
1897}
1898
1899fn calendar_to_info(calendar: &EKCalendar) -> CalendarInfo {
1900    let identifier = unsafe { calendar.calendarIdentifier() }.to_string();
1901    let title = unsafe { calendar.title() }.to_string();
1902    let source = unsafe { calendar.source() }.map(|s| unsafe { s.title() }.to_string());
1903    let source_id =
1904        unsafe { calendar.source() }.map(|s| unsafe { s.sourceIdentifier() }.to_string());
1905    let allows_modifications = unsafe { calendar.allowsContentModifications() };
1906    let is_immutable = unsafe { calendar.isImmutable() };
1907    let is_subscribed = unsafe { calendar.isSubscribed() };
1908
1909    // Calendar type: Local=0, CalDAV=1, Exchange=2, Subscription=3, Birthday=4
1910    let cal_type = unsafe { calendar.r#type() };
1911    let calendar_type = match cal_type.0 {
1912        0 => CalendarType::Local,
1913        1 => CalendarType::CalDAV,
1914        2 => CalendarType::Exchange,
1915        3 => CalendarType::Subscription,
1916        4 => CalendarType::Birthday,
1917        _ => CalendarType::Unknown,
1918    };
1919
1920    // Read RGBA from CGColor
1921    let color: Option<(f64, f64, f64, f64)> = unsafe {
1922        calendar.CGColor().and_then(|cg| {
1923            use objc2_core_graphics::CGColor as CG;
1924            let n = CG::number_of_components(Some(&cg));
1925            if n >= 3 {
1926                let ptr = CG::components(Some(&cg));
1927                let r = *ptr;
1928                let g = *ptr.add(1);
1929                let b = *ptr.add(2);
1930                let a = if n >= 4 { *ptr.add(3) } else { 1.0 };
1931                Some((r, g, b, a))
1932            } else {
1933                None
1934            }
1935        })
1936    };
1937
1938    // Allowed entity types
1939    let entity_mask = unsafe { calendar.allowedEntityTypes() };
1940    let mut allowed_entity_types = Vec::new();
1941    if entity_mask.0 & 1 != 0 {
1942        allowed_entity_types.push("event".to_string());
1943    }
1944    if entity_mask.0 & 2 != 0 {
1945        allowed_entity_types.push("reminder".to_string());
1946    }
1947
1948    // EKCalendarEventAvailabilityMask: bitfield of which availability values
1949    // this calendar accepts on events. Bits: Busy=1, Free=2, Tentative=4,
1950    // Unavailable=8. Empty Vec = `EKCalendarEventAvailabilityNone`.
1951    let avail_mask = unsafe { calendar.supportedEventAvailabilities() };
1952    let mut supported_event_availabilities = Vec::new();
1953    if avail_mask.contains(EKCalendarEventAvailabilityMask::Busy) {
1954        supported_event_availabilities.push("busy".to_string());
1955    }
1956    if avail_mask.contains(EKCalendarEventAvailabilityMask::Free) {
1957        supported_event_availabilities.push("free".to_string());
1958    }
1959    if avail_mask.contains(EKCalendarEventAvailabilityMask::Tentative) {
1960        supported_event_availabilities.push("tentative".to_string());
1961    }
1962    if avail_mask.contains(EKCalendarEventAvailabilityMask::Unavailable) {
1963        supported_event_availabilities.push("unavailable".to_string());
1964    }
1965
1966    CalendarInfo {
1967        identifier,
1968        title,
1969        source,
1970        source_id,
1971        calendar_type,
1972        allows_modifications,
1973        is_immutable,
1974        is_subscribed,
1975        color,
1976        allowed_entity_types,
1977        supported_event_availabilities,
1978    }
1979}
1980
1981// ============================================================================
1982// Calendar Events Support
1983// ============================================================================
1984
1985/// Event availability for scheduling.
1986#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1987pub enum EventAvailability {
1988    NotSupported,
1989    #[default]
1990    Busy,
1991    Free,
1992    Tentative,
1993    Unavailable,
1994}
1995
1996impl EventAvailability {
1997    /// Convert to the raw `EKEventAvailability` for writing.
1998    pub fn to_ek(self) -> EKEventAvailability {
1999        match self {
2000            Self::NotSupported => EKEventAvailability::NotSupported,
2001            Self::Busy => EKEventAvailability::Busy,
2002            Self::Free => EKEventAvailability::Free,
2003            Self::Tentative => EKEventAvailability::Tentative,
2004            Self::Unavailable => EKEventAvailability::Unavailable,
2005        }
2006    }
2007
2008    /// Map an `EKEventAvailability` back to our enum. Unknown raw values
2009    /// (future Apple additions) fall back to `NotSupported`.
2010    pub fn from_ek(raw: EKEventAvailability) -> Self {
2011        match raw {
2012            EKEventAvailability::NotSupported => Self::NotSupported,
2013            EKEventAvailability::Busy => Self::Busy,
2014            EKEventAvailability::Free => Self::Free,
2015            EKEventAvailability::Tentative => Self::Tentative,
2016            EKEventAvailability::Unavailable => Self::Unavailable,
2017            _ => Self::NotSupported,
2018        }
2019    }
2020}
2021
2022/// Event status.
2023#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2024pub enum EventStatus {
2025    #[default]
2026    None,
2027    Confirmed,
2028    Tentative,
2029    Canceled,
2030}
2031
2032impl EventStatus {
2033    pub fn from_ek(raw: EKEventStatus) -> Self {
2034        match raw {
2035            EKEventStatus::None => Self::None,
2036            EKEventStatus::Confirmed => Self::Confirmed,
2037            EKEventStatus::Tentative => Self::Tentative,
2038            EKEventStatus::Canceled => Self::Canceled,
2039            _ => Self::None,
2040        }
2041    }
2042}
2043
2044/// Scope of a recurring-event edit or delete — mirrors Apple's `EKSpan`.
2045///
2046/// - `This`: only the specific occurrence you have a reference to.
2047/// - `Future`: this occurrence and every later occurrence in the same series.
2048#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
2049pub enum EventSpan {
2050    #[default]
2051    This,
2052    Future,
2053}
2054
2055impl EventSpan {
2056    pub(crate) fn to_ek(self) -> EKSpan {
2057        match self {
2058            Self::This => EKSpan::ThisEvent,
2059            Self::Future => EKSpan::FutureEvents,
2060        }
2061    }
2062}
2063
2064/// Participant role in an event.
2065#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2066pub enum ParticipantRole {
2067    Unknown,
2068    Required,
2069    Optional,
2070    Chair,
2071    NonParticipant,
2072}
2073
2074/// Participant RSVP status.
2075#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2076pub enum ParticipantStatus {
2077    Unknown,
2078    Pending,
2079    Accepted,
2080    Declined,
2081    Tentative,
2082    Delegated,
2083    Completed,
2084    InProcess,
2085}
2086
2087/// A participant (attendee) on an event or reminder.
2088#[derive(Debug, Clone)]
2089#[allow(non_snake_case)]
2090pub struct ParticipantInfo {
2091    pub name: Option<String>,
2092    pub URL: Option<String>,
2093    pub role: ParticipantRole,
2094    pub status: ParticipantStatus,
2095    pub is_current_user: bool,
2096}
2097
2098/// Represents a calendar event with its properties.
2099#[derive(Debug, Clone)]
2100#[allow(non_snake_case)]
2101pub struct EventItem {
2102    /// Unique identifier for the event
2103    pub identifier: String,
2104    /// Title of the event
2105    pub title: String,
2106    /// Optional notes/description
2107    pub notes: Option<String>,
2108    /// Optional location (string)
2109    pub location: Option<String>,
2110    /// Start date/time
2111    pub start_date: DateTime<Local>,
2112    /// End date/time
2113    pub end_date: DateTime<Local>,
2114    /// Whether this is an all-day event
2115    pub all_day: bool,
2116    /// Calendar the event belongs to
2117    pub calendar_title: Option<String>,
2118    /// Calendar identifier
2119    pub calendar_id: Option<String>,
2120    /// URL associated with the event
2121    #[allow(non_snake_case)]
2122    pub URL: Option<String>,
2123    /// Availability for scheduling
2124    pub availability: EventAvailability,
2125    /// Event status (read-only)
2126    pub status: EventStatus,
2127    /// Whether this occurrence was modified from its recurring series
2128    pub is_detached: bool,
2129    /// Original date in a recurring series
2130    pub occurrence_date: Option<DateTime<Local>>,
2131    /// Geo-coordinate location
2132    pub structured_location: Option<StructuredLocation>,
2133    /// Date the event was first created (`EKCalendarItem.creationDate`).
2134    pub creation_date: Option<DateTime<Local>>,
2135    /// Date the event was last modified (`EKCalendarItem.lastModifiedDate`).
2136    pub last_modified_date: Option<DateTime<Local>>,
2137    /// Server-provided external identifier (`EKCalendarItem.calendarItemExternalIdentifier`).
2138    pub external_identifier: Option<String>,
2139    /// Item-level timezone (`EKCalendarItem.timeZone`). Distinct from the
2140    /// start/end date timezones — this is the event's own zone hint.
2141    pub timezone: Option<String>,
2142    /// Number of file attachments on the event (bound-less today — just the count).
2143    pub attachments_count: usize,
2144    /// Attendees
2145    pub attendees: Vec<ParticipantInfo>,
2146    /// Event organizer
2147    pub organizer: Option<ParticipantInfo>,
2148}
2149
2150/// Input for `EventsManager::create_event`. Only `title`, `start`, and `end`
2151/// are required; spread `..Default::default()` for the rest. Mirrors
2152/// `ReminderDraft` for consistency.
2153#[derive(Debug, Clone, Default)]
2154#[allow(non_snake_case)]
2155pub struct EventDraft<'a> {
2156    pub title: &'a str,
2157    pub start: Option<DateTime<Local>>,
2158    pub end: Option<DateTime<Local>>,
2159    pub notes: Option<&'a str>,
2160    pub location: Option<&'a str>,
2161    pub calendar_title: Option<&'a str>,
2162    pub all_day: bool,
2163    pub URL: Option<&'a str>,
2164    pub availability: Option<EventAvailability>,
2165    pub structured_location: Option<&'a StructuredLocation>,
2166}
2167
2168/// Input for `EventsManager::update_event`. Each field uses one of:
2169/// `None` (don't touch), `Some(value)` (set). Nullable fields use
2170/// `Option<Option<T>>` so `Some(None)` clears the value. `span` controls
2171/// whether the edit applies to just this occurrence or all future ones in
2172/// a recurring series.
2173#[derive(Debug, Clone, Default)]
2174#[allow(non_snake_case)]
2175pub struct EventPatch<'a> {
2176    pub title: Option<&'a str>,
2177    pub notes: Option<Option<&'a str>>,
2178    pub location: Option<Option<&'a str>>,
2179    pub start: Option<DateTime<Local>>,
2180    pub end: Option<DateTime<Local>>,
2181    pub all_day: Option<bool>,
2182    /// Move to a different calendar by title.
2183    pub calendar_title: Option<&'a str>,
2184    pub URL: Option<Option<&'a str>>,
2185    pub availability: Option<EventAvailability>,
2186    pub structured_location: Option<Option<&'a StructuredLocation>>,
2187    /// `This` (default) edits only this occurrence; `Future` propagates.
2188    pub span: EventSpan,
2189}
2190
2191/// The events manager providing access to Calendar events via EventKit
2192pub struct EventsManager {
2193    store: Retained<EKEventStore>,
2194}
2195
2196impl EventsManager {
2197    /// Creates a new EventsManager instance
2198    pub fn new() -> Self {
2199        let store = unsafe { EKEventStore::new() };
2200        Self { store }
2201    }
2202
2203    /// Gets the current authorization status for calendar events
2204    pub fn authorization_status() -> AuthorizationStatus {
2205        let status = unsafe { EKEventStore::authorizationStatusForEntityType(EKEntityType::Event) };
2206        status.into()
2207    }
2208
2209    /// Requests full access to calendar events (blocking)
2210    ///
2211    /// Returns Ok(true) if access was granted, Ok(false) if denied
2212    pub fn request_access(&self) -> Result<bool> {
2213        let result = Arc::new((Mutex::new(None::<(bool, Option<String>)>), Condvar::new()));
2214        let result_clone = Arc::clone(&result);
2215
2216        let completion = RcBlock::new(move |granted: Bool, error: *mut NSError| {
2217            let error_msg = if !error.is_null() {
2218                let error_ref = unsafe { &*error };
2219                Some(format!("{:?}", error_ref))
2220            } else {
2221                None
2222            };
2223
2224            let (lock, cvar) = &*result_clone;
2225            let mut res = lock.lock().unwrap();
2226            *res = Some((granted.as_bool(), error_msg));
2227            cvar.notify_one();
2228        });
2229
2230        unsafe {
2231            let block_ptr = &*completion as *const _ as *mut _;
2232            self.store
2233                .requestFullAccessToEventsWithCompletion(block_ptr);
2234        }
2235
2236        let (lock, cvar) = &*result;
2237        let mut res = lock.lock().unwrap();
2238        while res.is_none() {
2239            res = cvar.wait(res).unwrap();
2240        }
2241
2242        match res.take() {
2243            Some((granted, None)) => Ok(granted),
2244            Some((_, Some(error))) => Err(EventKitError::AuthorizationRequestFailed(error)),
2245            None => Err(EventKitError::AuthorizationRequestFailed(
2246                "Unknown error".to_string(),
2247            )),
2248        }
2249    }
2250
2251    /// Ensures we have authorization, requesting if needed
2252    pub fn ensure_authorized(&self) -> Result<()> {
2253        match Self::authorization_status() {
2254            AuthorizationStatus::FullAccess => Ok(()),
2255            AuthorizationStatus::NotDetermined => {
2256                if self.request_access()? {
2257                    Ok(())
2258                } else {
2259                    Err(EventKitError::AuthorizationDenied)
2260                }
2261            }
2262            AuthorizationStatus::Denied => Err(EventKitError::AuthorizationDenied),
2263            AuthorizationStatus::Restricted => Err(EventKitError::AuthorizationRestricted),
2264            AuthorizationStatus::WriteOnly => Ok(()),
2265        }
2266    }
2267
2268    /// Lists all event calendars
2269    pub fn list_calendars(&self) -> Result<Vec<CalendarInfo>> {
2270        self.ensure_authorized()?;
2271
2272        let calendars = unsafe { self.store.calendarsForEntityType(EKEntityType::Event) };
2273
2274        let mut result = Vec::new();
2275        for calendar in calendars.iter() {
2276            result.push(calendar_to_info(&calendar));
2277        }
2278
2279        Ok(result)
2280    }
2281
2282    /// Gets the default calendar for new events
2283    pub fn default_calendar(&self) -> Result<CalendarInfo> {
2284        self.ensure_authorized()?;
2285
2286        let calendar = unsafe { self.store.defaultCalendarForNewEvents() };
2287
2288        match calendar {
2289            Some(cal) => Ok(calendar_to_info(&cal)),
2290            None => Err(EventKitError::NoDefaultCalendar),
2291        }
2292    }
2293
2294    /// Fetches events for today
2295    pub fn fetch_today_events(&self) -> Result<Vec<EventItem>> {
2296        let now = Local::now();
2297        let start = now.date_naive().and_hms_opt(0, 0, 0).unwrap();
2298        let end = now.date_naive().and_hms_opt(23, 59, 59).unwrap();
2299
2300        self.fetch_events(
2301            Local.from_local_datetime(&start).unwrap(),
2302            Local.from_local_datetime(&end).unwrap(),
2303            None,
2304        )
2305    }
2306
2307    /// Fetches events for the next N days
2308    pub fn fetch_upcoming_events(&self, days: i64) -> Result<Vec<EventItem>> {
2309        let now = Local::now();
2310        let end = now + Duration::days(days);
2311        self.fetch_events(now, end, None)
2312    }
2313
2314    /// Fetches events in a date range
2315    pub fn fetch_events(
2316        &self,
2317        start: DateTime<Local>,
2318        end: DateTime<Local>,
2319        calendar_titles: Option<&[&str]>,
2320    ) -> Result<Vec<EventItem>> {
2321        self.ensure_authorized()?;
2322
2323        if start >= end {
2324            return Err(EventKitError::InvalidDateRange);
2325        }
2326
2327        let calendars: Option<Retained<NSArray<EKCalendar>>> = match calendar_titles {
2328            Some(titles) => {
2329                let all_calendars =
2330                    unsafe { self.store.calendarsForEntityType(EKEntityType::Event) };
2331                let mut matching: Vec<Retained<EKCalendar>> = Vec::new();
2332
2333                for cal in all_calendars.iter() {
2334                    let title = unsafe { cal.title() };
2335                    let title_str = title.to_string();
2336                    if titles.iter().any(|t| *t == title_str) {
2337                        matching.push(cal.retain());
2338                    }
2339                }
2340
2341                if matching.is_empty() {
2342                    return Err(EventKitError::CalendarNotFound(titles.join(", ")));
2343                }
2344
2345                Some(NSArray::from_retained_slice(&matching))
2346            }
2347            None => None,
2348        };
2349
2350        let start_date = datetime_to_nsdate(start);
2351        let end_date = datetime_to_nsdate(end);
2352
2353        let predicate = unsafe {
2354            self.store
2355                .predicateForEventsWithStartDate_endDate_calendars(
2356                    &start_date,
2357                    &end_date,
2358                    calendars.as_deref(),
2359                )
2360        };
2361
2362        let events = unsafe { self.store.eventsMatchingPredicate(&predicate) };
2363
2364        let mut items = Vec::new();
2365        for event in events.iter() {
2366            items.push(event_to_item(&event));
2367        }
2368
2369        // Sort by start date
2370        items.sort_by_key(|a| a.start_date);
2371
2372        Ok(items)
2373    }
2374
2375    /// Creates a new event. Build the input with `EventDraft` — only
2376    /// `title`, `start`, and `end` are required; spread `..Default::default()`
2377    /// for the rest.
2378    pub fn create_event(&self, draft: &EventDraft<'_>) -> Result<EventItem> {
2379        self.ensure_authorized()?;
2380
2381        let start = draft.start.ok_or(EventKitError::InvalidDateRange)?;
2382        let end = draft.end.ok_or(EventKitError::InvalidDateRange)?;
2383
2384        let event = unsafe { EKEvent::eventWithEventStore(&self.store) };
2385
2386        let ns_title = NSString::from_str(draft.title);
2387        unsafe { event.setTitle(Some(&ns_title)) };
2388
2389        let start_date = datetime_to_nsdate(start);
2390        let end_date = datetime_to_nsdate(end);
2391        unsafe {
2392            event.setStartDate(Some(&start_date));
2393            event.setEndDate(Some(&end_date));
2394            event.setAllDay(draft.all_day);
2395        }
2396
2397        if let Some(notes_text) = draft.notes {
2398            let ns_notes = NSString::from_str(notes_text);
2399            unsafe { event.setNotes(Some(&ns_notes)) };
2400        }
2401
2402        if let Some(loc) = draft.location {
2403            let ns_location = NSString::from_str(loc);
2404            unsafe { event.setLocation(Some(&ns_location)) };
2405        }
2406
2407        if draft.URL.is_some() {
2408            set_item_URL(&event, draft.URL)?;
2409        }
2410
2411        if let Some(av) = draft.availability {
2412            unsafe { event.setAvailability(av.to_ek()) };
2413        }
2414
2415        if let Some(sl) = draft.structured_location {
2416            let built = build_ek_structured_location(sl);
2417            unsafe { event.setStructuredLocation(Some(&built)) };
2418        }
2419
2420        let calendar = if let Some(cal_title) = draft.calendar_title {
2421            self.find_calendar_by_title(cal_title)?
2422        } else {
2423            unsafe { self.store.defaultCalendarForNewEvents() }
2424                .ok_or(EventKitError::NoDefaultCalendar)?
2425        };
2426        unsafe { event.setCalendar(Some(&calendar)) };
2427
2428        self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
2429
2430        Ok(event_to_item(&event))
2431    }
2432
2433    /// Updates an existing event
2434    /// Updates an existing event. Build the changeset with `EventPatch` —
2435    /// only the fields you set are written. For nullable fields,
2436    /// `Some(Some(v))` writes, `Some(None)` clears. `span` controls
2437    /// whether the edit applies to just this occurrence or all future
2438    /// occurrences in a recurring series.
2439    pub fn update_event(&self, identifier: &str, patch: &EventPatch<'_>) -> Result<EventItem> {
2440        self.ensure_authorized()?;
2441
2442        let event = self.find_event_by_id(identifier)?;
2443
2444        if let Some(t) = patch.title {
2445            let ns_title = NSString::from_str(t);
2446            unsafe { event.setTitle(Some(&ns_title)) };
2447        }
2448
2449        if let Some(notes_opt) = patch.notes {
2450            let ns = notes_opt.map(NSString::from_str);
2451            unsafe { event.setNotes(ns.as_deref()) };
2452        }
2453
2454        if let Some(loc_opt) = patch.location {
2455            let ns = loc_opt.map(NSString::from_str);
2456            unsafe { event.setLocation(ns.as_deref()) };
2457        }
2458
2459        if let Some(s) = patch.start {
2460            let start_date = datetime_to_nsdate(s);
2461            unsafe { event.setStartDate(Some(&start_date)) };
2462        }
2463
2464        if let Some(e) = patch.end {
2465            let end_date = datetime_to_nsdate(e);
2466            unsafe { event.setEndDate(Some(&end_date)) };
2467        }
2468
2469        if let Some(ad) = patch.all_day {
2470            unsafe { event.setAllDay(ad) };
2471        }
2472
2473        if let Some(url_opt) = patch.URL {
2474            set_item_URL(&event, url_opt)?;
2475        }
2476
2477        if let Some(av) = patch.availability {
2478            unsafe { event.setAvailability(av.to_ek()) };
2479        }
2480
2481        if let Some(sl_opt) = patch.structured_location {
2482            let built = sl_opt.map(build_ek_structured_location);
2483            unsafe { event.setStructuredLocation(built.as_deref()) };
2484        }
2485
2486        if let Some(cal_title) = patch.calendar_title {
2487            let calendar = self.find_calendar_by_title(cal_title)?;
2488            unsafe { event.setCalendar(Some(&calendar)) };
2489        }
2490
2491        self.save_event_and_refresh(&event, patch.span.to_ek())?;
2492
2493        Ok(event_to_item(&event))
2494    }
2495
2496    /// Deletes an event
2497    pub fn delete_event(&self, identifier: &str, affect_future: bool) -> Result<()> {
2498        self.ensure_authorized()?;
2499
2500        let event = self.find_event_by_id(identifier)?;
2501        let span = if affect_future {
2502            EKSpan::FutureEvents
2503        } else {
2504            EKSpan::ThisEvent
2505        };
2506
2507        unsafe {
2508            self.store
2509                .removeEvent_span_commit_error(&event, span, true)
2510                .map_err(|e| EventKitError::DeleteFailed(format!("{:?}", e)))?;
2511        }
2512
2513        Ok(())
2514    }
2515
2516    /// Gets an event by its identifier
2517    pub fn get_event(&self, identifier: &str) -> Result<EventItem> {
2518        self.ensure_authorized()?;
2519        let event = self.find_event_by_id(identifier)?;
2520        Ok(event_to_item(&event))
2521    }
2522
2523    // ========================================================================
2524    // Event Calendar Management
2525    // ========================================================================
2526
2527    /// Creates a new event calendar.
2528    pub fn create_event_calendar(&self, title: &str) -> Result<CalendarInfo> {
2529        self.ensure_authorized()?;
2530        let calendar = unsafe {
2531            EKCalendar::calendarForEntityType_eventStore(EKEntityType::Event, &self.store)
2532        };
2533        let ns_title = NSString::from_str(title);
2534        unsafe { calendar.setTitle(&ns_title) };
2535
2536        // Use the default source
2537        if let Some(default_cal) = unsafe { self.store.defaultCalendarForNewEvents() }
2538            && let Some(source) = unsafe { default_cal.source() }
2539        {
2540            unsafe { calendar.setSource(Some(&source)) };
2541        }
2542
2543        unsafe {
2544            self.store
2545                .saveCalendar_commit_error(&calendar, true)
2546                .map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
2547        }
2548        Ok(calendar_to_info(&calendar))
2549    }
2550
2551    /// Renames an event calendar.
2552    /// Rename an event calendar (backward compat wrapper).
2553    pub fn rename_event_calendar(&self, identifier: &str, new_title: &str) -> Result<CalendarInfo> {
2554        self.update_event_calendar(identifier, Some(new_title), None)
2555    }
2556
2557    /// Update an event calendar — name, color, or both.
2558    pub fn update_event_calendar(
2559        &self,
2560        identifier: &str,
2561        new_title: Option<&str>,
2562        color_rgba: Option<(f64, f64, f64, f64)>,
2563    ) -> Result<CalendarInfo> {
2564        self.ensure_authorized()?;
2565        let calendar = unsafe {
2566            self.store
2567                .calendarWithIdentifier(&NSString::from_str(identifier))
2568        }
2569        .ok_or_else(|| EventKitError::CalendarNotFound(identifier.to_string()))?;
2570
2571        if let Some(title) = new_title {
2572            let ns_title = NSString::from_str(title);
2573            unsafe { calendar.setTitle(&ns_title) };
2574        }
2575
2576        if let Some((r, g, b, a)) = color_rgba {
2577            let cg = objc2_core_graphics::CGColor::new_srgb(r, g, b, a);
2578            unsafe { calendar.setCGColor(Some(&cg)) };
2579        }
2580
2581        unsafe {
2582            self.store
2583                .saveCalendar_commit_error(&calendar, true)
2584                .map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
2585        }
2586        Ok(calendar_to_info(&calendar))
2587    }
2588
2589    /// Deletes an event calendar.
2590    pub fn delete_event_calendar(&self, identifier: &str) -> Result<()> {
2591        self.ensure_authorized()?;
2592        let calendar = unsafe {
2593            self.store
2594                .calendarWithIdentifier(&NSString::from_str(identifier))
2595        }
2596        .ok_or_else(|| EventKitError::CalendarNotFound(identifier.to_string()))?;
2597
2598        unsafe {
2599            self.store
2600                .removeCalendar_commit_error(&calendar, true)
2601                .map_err(|e| EventKitError::DeleteFailed(format!("{:?}", e)))?;
2602        }
2603        Ok(())
2604    }
2605
2606    // ========================================================================
2607    // Event Alarm Management (shared via EKCalendarItem)
2608    // ========================================================================
2609
2610    /// Lists all alarms on an event.
2611    pub fn get_event_alarms(&self, identifier: &str) -> Result<Vec<AlarmInfo>> {
2612        self.ensure_authorized()?;
2613        let event = self.find_event_by_id(identifier)?;
2614        Ok(get_item_alarms(&event))
2615    }
2616
2617    /// Adds an alarm to an event.
2618    pub fn add_event_alarm(&self, identifier: &str, alarm: &AlarmInfo) -> Result<()> {
2619        self.ensure_authorized()?;
2620        let event = self.find_event_by_id(identifier)?;
2621        add_item_alarm(&event, alarm)?;
2622        self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
2623        Ok(())
2624    }
2625
2626    // ========================================================================
2627    // Event Recurrence Management (shared via EKCalendarItem)
2628    // ========================================================================
2629
2630    /// Gets recurrence rules on an event.
2631    pub fn get_event_recurrence_rules(&self, identifier: &str) -> Result<Vec<RecurrenceRule>> {
2632        self.ensure_authorized()?;
2633        let event = self.find_event_by_id(identifier)?;
2634        Ok(get_item_recurrence_rules(&event))
2635    }
2636
2637    /// Sets a recurrence rule on an event (replaces any existing rules).
2638    pub fn set_event_recurrence_rule(&self, identifier: &str, rule: &RecurrenceRule) -> Result<()> {
2639        self.ensure_authorized()?;
2640        let event = self.find_event_by_id(identifier)?;
2641        set_item_recurrence_rule(&event, rule);
2642        self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
2643        Ok(())
2644    }
2645
2646    /// Removes all recurrence rules from an event.
2647    pub fn remove_event_recurrence_rules(&self, identifier: &str) -> Result<()> {
2648        self.ensure_authorized()?;
2649        let event = self.find_event_by_id(identifier)?;
2650        clear_item_recurrence_rules(&event);
2651        self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
2652        Ok(())
2653    }
2654
2655    /// Removes a specific alarm from an event by index.
2656    pub fn remove_event_alarm(&self, identifier: &str, index: usize) -> Result<()> {
2657        self.ensure_authorized()?;
2658        let event = self.find_event_by_id(identifier)?;
2659        remove_item_alarm(&event, index)?;
2660        self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
2661        Ok(())
2662    }
2663
2664    /// Set or clear the URL on an event.
2665    #[allow(non_snake_case)]
2666    pub fn set_event_URL(&self, identifier: &str, url: Option<&str>) -> Result<()> {
2667        self.ensure_authorized()?;
2668        let event = self.find_event_by_id(identifier)?;
2669        set_item_URL(&event, url)?;
2670        self.save_event_and_refresh(&event, EKSpan::ThisEvent)?;
2671        Ok(())
2672    }
2673
2674    /// Set the event's `availability` field. Controls how the event shows
2675    /// on the timeline (Busy/Free/Tentative/Unavailable). Always uses
2676    /// `EKSpan::ThisEvent` since availability is typically per-instance.
2677    pub fn set_event_availability(
2678        &self,
2679        identifier: &str,
2680        availability: EventAvailability,
2681    ) -> Result<()> {
2682        self.ensure_authorized()?;
2683        let event = self.find_event_by_id(identifier)?;
2684        unsafe { event.setAvailability(availability.to_ek()) };
2685        self.save_event_and_refresh(&event, EKSpan::ThisEvent)
2686    }
2687
2688    // Helper to find a calendar by title
2689    fn find_calendar_by_title(&self, title: &str) -> Result<Retained<EKCalendar>> {
2690        let calendars = unsafe { self.store.calendarsForEntityType(EKEntityType::Event) };
2691
2692        for cal in calendars.iter() {
2693            let cal_title = unsafe { cal.title() };
2694            if cal_title.to_string() == title {
2695                return Ok(cal.retain());
2696            }
2697        }
2698
2699        Err(EventKitError::CalendarNotFound(title.to_string()))
2700    }
2701
2702    // Helper to find an event by identifier. Refreshes sources first so we
2703    // pick up edits made elsewhere (mirrors `RemindersManager::find_reminder_by_id`).
2704    fn find_event_by_id(&self, identifier: &str) -> Result<Retained<EKEvent>> {
2705        unsafe { self.store.refreshSourcesIfNecessary() };
2706        let ns_id = NSString::from_str(identifier);
2707        let event = unsafe { self.store.eventWithIdentifier(&ns_id) };
2708
2709        match event {
2710            Some(e) => Ok(e),
2711            None => Err(EventKitError::ItemNotFound(identifier.to_string())),
2712        }
2713    }
2714
2715    /// Save an event under the given span + refresh sources so subsequent
2716    /// reads see the just-committed state. All `pub fn` event save paths
2717    /// route through here, mirroring `RemindersManager::save_reminder_and_refresh`.
2718    fn save_event_and_refresh(&self, event: &EKEvent, span: EKSpan) -> Result<()> {
2719        unsafe {
2720            self.store
2721                .saveEvent_span_commit_error(event, span, true)
2722                .map_err(|e| EventKitError::SaveFailed(format!("{:?}", e)))?;
2723            self.store.refreshSourcesIfNecessary();
2724        }
2725        Ok(())
2726    }
2727}
2728
2729impl Default for EventsManager {
2730    fn default() -> Self {
2731        Self::new()
2732    }
2733}
2734
2735// Helper function to convert EKEvent to EventItem
2736fn event_to_item(event: &EKEvent) -> EventItem {
2737    let identifier = unsafe { event.eventIdentifier() }
2738        .map(|s| s.to_string())
2739        .unwrap_or_default();
2740    let title = unsafe { event.title() }.to_string();
2741    let notes = unsafe { event.notes() }.map(|n| n.to_string());
2742    let location = unsafe { event.location() }.map(|l| l.to_string());
2743    let all_day = unsafe { event.isAllDay() };
2744    let cal = unsafe { event.calendar() };
2745    let calendar_title = cal.as_ref().map(|c| unsafe { c.title() }.to_string());
2746    let calendar_id = cal
2747        .as_ref()
2748        .map(|c| unsafe { c.calendarIdentifier() }.to_string());
2749
2750    let start_ns: Retained<NSDate> = unsafe { event.startDate() };
2751    let end_ns: Retained<NSDate> = unsafe { event.endDate() };
2752    let start_date = nsdate_to_datetime(&start_ns);
2753    let end_date = nsdate_to_datetime(&end_ns);
2754
2755    #[allow(non_snake_case)]
2756    let URL = get_item_URL(event);
2757
2758    let availability = EventAvailability::from_ek(unsafe { event.availability() });
2759    let status = EventStatus::from_ek(unsafe { event.status() });
2760
2761    let is_detached = unsafe { event.isDetached() };
2762    let occurrence_date = unsafe { event.occurrenceDate() }.map(|d| nsdate_to_datetime(&d));
2763
2764    // Structured location
2765    let structured_location = unsafe { event.structuredLocation() }.map(|loc| {
2766        let title = unsafe { loc.title() }
2767            .map(|t| t.to_string())
2768            .unwrap_or_default();
2769        let radius = unsafe { loc.radius() };
2770        let (latitude, longitude) = unsafe { loc.geoLocation() }
2771            .map(|geo| {
2772                let coord = unsafe { geo.coordinate() };
2773                (coord.latitude, coord.longitude)
2774            })
2775            .unwrap_or((0.0, 0.0));
2776        StructuredLocation {
2777            title,
2778            latitude,
2779            longitude,
2780            radius,
2781        }
2782    });
2783
2784    // Attendees (shared via EKCalendarItem)
2785    let attendees = get_item_attendees(event);
2786
2787    // Organizer (event-only)
2788    let organizer = unsafe { event.organizer() }.map(|p| participant_to_info(&p));
2789
2790    // EKCalendarItem-inherited read fields (parity with ReminderItem)
2791    let creation_date = unsafe { event.creationDate() }.map(|d| nsdate_to_datetime(&d));
2792    let last_modified_date = unsafe { event.lastModifiedDate() }.map(|d| nsdate_to_datetime(&d));
2793    let external_identifier =
2794        unsafe { event.calendarItemExternalIdentifier() }.map(|s| s.to_string());
2795    let timezone = unsafe { event.timeZone() }.map(|tz| tz.name().to_string());
2796    let attachments_count = get_item_attachments_count(event);
2797
2798    EventItem {
2799        identifier,
2800        title,
2801        notes,
2802        location,
2803        start_date,
2804        end_date,
2805        all_day,
2806        calendar_title,
2807        calendar_id,
2808        URL,
2809        availability,
2810        status,
2811        is_detached,
2812        occurrence_date,
2813        structured_location,
2814        creation_date,
2815        last_modified_date,
2816        external_identifier,
2817        timezone,
2818        attachments_count,
2819        attendees,
2820        organizer,
2821    }
2822}
2823
2824// Read attendees from an EKCalendarItem (shared by events and reminders)
2825fn get_item_attendees(item: &EKCalendarItem) -> Vec<ParticipantInfo> {
2826    let attendees = unsafe { item.attendees() };
2827    let Some(attendees) = attendees else {
2828        return Vec::new();
2829    };
2830    let mut result = Vec::new();
2831    for i in 0..attendees.len() {
2832        let p = attendees.objectAtIndex(i);
2833        result.push(participant_to_info(&p));
2834    }
2835    result
2836}
2837
2838// Convert an EKParticipant to ParticipantInfo
2839fn participant_to_info(p: &objc2_event_kit::EKParticipant) -> ParticipantInfo {
2840    let name = unsafe { p.name() }.map(|n| n.to_string());
2841    #[allow(non_snake_case)]
2842    let URL = unsafe { p.URL() }.absoluteString().map(|s| s.to_string());
2843
2844    // Role: 0=Unknown, 1=Required, 2=Optional, 3=Chair, 4=NonParticipant
2845    let role = unsafe { p.participantRole() };
2846    let role = match role.0 {
2847        1 => ParticipantRole::Required,
2848        2 => ParticipantRole::Optional,
2849        3 => ParticipantRole::Chair,
2850        4 => ParticipantRole::NonParticipant,
2851        _ => ParticipantRole::Unknown,
2852    };
2853
2854    // Status: 0=Unknown, 1=Pending, 2=Accepted, 3=Declined, 4=Tentative,
2855    //         5=Delegated, 6=Completed, 7=InProcess
2856    let status = unsafe { p.participantStatus() };
2857    let status = match status.0 {
2858        1 => ParticipantStatus::Pending,
2859        2 => ParticipantStatus::Accepted,
2860        3 => ParticipantStatus::Declined,
2861        4 => ParticipantStatus::Tentative,
2862        5 => ParticipantStatus::Delegated,
2863        6 => ParticipantStatus::Completed,
2864        7 => ParticipantStatus::InProcess,
2865        _ => ParticipantStatus::Unknown,
2866    };
2867
2868    let is_current_user = unsafe { p.isCurrentUser() };
2869
2870    ParticipantInfo {
2871        name,
2872        URL,
2873        role,
2874        status,
2875        is_current_user,
2876    }
2877}
2878
2879// Helper to convert chrono DateTime to NSDate
2880fn datetime_to_nsdate(dt: DateTime<Local>) -> Retained<NSDate> {
2881    let timestamp = dt.timestamp() as f64;
2882    NSDate::dateWithTimeIntervalSince1970(timestamp)
2883}
2884
2885// Helper to convert NSDate to chrono DateTime
2886fn nsdate_to_datetime(date: &NSDate) -> DateTime<Local> {
2887    let timestamp = date.timeIntervalSince1970();
2888    Local.timestamp_opt(timestamp as i64, 0).unwrap()
2889}
2890
2891// Helper to convert NSDateComponents to chrono DateTime
2892fn date_components_to_datetime(components: &NSDateComponents) -> Option<DateTime<Local>> {
2893    // Get a calendar to convert components to a date
2894    let calendar = NSCalendar::currentCalendar();
2895
2896    // Convert components to NSDate using the calendar
2897    let date = calendar.dateFromComponents(components)?;
2898
2899    Some(nsdate_to_datetime(&date))
2900}
2901
2902// Helper to convert chrono DateTime to NSDateComponents
2903fn datetime_to_date_components(dt: DateTime<Local>) -> Retained<NSDateComponents> {
2904    let components = NSDateComponents::new();
2905
2906    components.setYear(dt.year() as isize);
2907    components.setMonth(dt.month() as isize);
2908    components.setDay(dt.day() as isize);
2909    components.setHour(dt.hour() as isize);
2910    components.setMinute(dt.minute() as isize);
2911    components.setSecond(dt.second() as isize);
2912
2913    components
2914}
2915
2916// ============================================================================
2917// Shared EKCalendarItem operations
2918// ============================================================================
2919// EKCalendarItem is the base class for both EKReminder and EKEvent.
2920// These functions operate on the shared interface — both types auto-deref to it.
2921
2922/// Read all alarms from a calendar item.
2923fn get_item_alarms(item: &EKCalendarItem) -> Vec<AlarmInfo> {
2924    let alarms = unsafe { item.alarms() };
2925    let Some(alarms) = alarms else {
2926        return Vec::new();
2927    };
2928    let mut result = Vec::new();
2929    for i in 0..alarms.len() {
2930        let alarm = alarms.objectAtIndex(i);
2931        result.push(alarm_to_info(&alarm));
2932    }
2933    result
2934}
2935
2936/// Add an alarm to a calendar item.
2937fn add_item_alarm(item: &EKCalendarItem, alarm: &AlarmInfo) -> Result<()> {
2938    let ek_alarm = create_ek_alarm(alarm)?;
2939    unsafe { item.addAlarm(&ek_alarm) };
2940    Ok(())
2941}
2942
2943/// Remove an alarm from a calendar item by index.
2944fn remove_item_alarm(item: &EKCalendarItem, index: usize) -> Result<()> {
2945    let alarms = unsafe { item.alarms() };
2946    let Some(alarms) = alarms else {
2947        return Err(EventKitError::ItemNotFound("No alarms on this item".into()));
2948    };
2949    if index >= alarms.len() {
2950        return Err(EventKitError::ItemNotFound(format!(
2951            "Alarm index {} out of range ({})",
2952            index,
2953            alarms.len()
2954        )));
2955    }
2956    let alarm = alarms.objectAtIndex(index);
2957    unsafe { item.removeAlarm(&alarm) };
2958    Ok(())
2959}
2960
2961/// Clear all alarms from a calendar item.
2962fn clear_item_alarms(item: &EKCalendarItem) {
2963    unsafe { item.setAlarms(None) };
2964}
2965
2966/// Read all recurrence rules from a calendar item.
2967fn get_item_recurrence_rules(item: &EKCalendarItem) -> Vec<RecurrenceRule> {
2968    let rules = unsafe { item.recurrenceRules() };
2969    let Some(rules) = rules else {
2970        return Vec::new();
2971    };
2972    let mut result = Vec::new();
2973    for i in 0..rules.len() {
2974        let rule = rules.objectAtIndex(i);
2975        result.push(recurrence_rule_to_info(&rule));
2976    }
2977    result
2978}
2979
2980/// Set a single recurrence rule on a calendar item (replaces any existing).
2981fn set_item_recurrence_rule(item: &EKCalendarItem, rule: &RecurrenceRule) {
2982    let ek_rule = create_ek_recurrence_rule(rule);
2983    unsafe {
2984        let rules = NSArray::from_retained_slice(&[ek_rule]);
2985        item.setRecurrenceRules(Some(&rules));
2986    }
2987}
2988
2989/// Clear all recurrence rules from a calendar item.
2990fn clear_item_recurrence_rules(item: &EKCalendarItem) {
2991    unsafe { item.setRecurrenceRules(None) };
2992}
2993
2994/// Set URL on a calendar item.
2995///
2996/// Uses `+[NSURL URLWithString:encodingInvalidCharacters:NO]` for strict
2997/// RFC 3986 validation — returns `Err(InvalidURL)` if the string isn't a
2998/// well-formed URL instead of silently auto-encoding or panicking.
2999#[allow(non_snake_case)]
3000fn set_item_URL(item: &EKCalendarItem, url: Option<&str>) -> Result<()> {
3001    let ns_url = match url {
3002        None => None,
3003        Some(s) => {
3004            let ns_str = NSString::from_str(s);
3005            let parsed =
3006                objc2_foundation::NSURL::URLWithString_encodingInvalidCharacters(&ns_str, false);
3007            match parsed {
3008                Some(u) => Some(u),
3009                None => return Err(EventKitError::InvalidURL(s.to_string())),
3010            }
3011        }
3012    };
3013    unsafe { item.setURL(ns_url.as_deref()) };
3014    Ok(())
3015}
3016
3017/// Read URL from a calendar item.
3018#[allow(non_snake_case)]
3019fn get_item_URL(item: &EKCalendarItem) -> Option<String> {
3020    unsafe { item.URL() }.map(|u| u.absoluteString().unwrap().to_string())
3021}
3022
3023/// Set the free-text `location` on a calendar item. Pass `None` to clear.
3024///
3025/// iCloud caveat: on iCloud-synced reminder lists the string `location`
3026/// property may silently revert to null even though `setLocation:` returns
3027/// success and the save commits. iCloud Reminders treats the location as a
3028/// derived view of `structuredLocation` set via a location-based alarm.
3029/// To attach a location that survives iCloud sync, use
3030/// `RemindersManager::set_geofence` (or pass `geofence` to a `ReminderDraft`).
3031fn set_item_location(item: &EKCalendarItem, location: Option<&str>) {
3032    match location {
3033        Some(text) => {
3034            let ns_loc = NSString::from_str(text);
3035            unsafe { item.setLocation(Some(&ns_loc)) };
3036        }
3037        None => unsafe { item.setLocation(None) },
3038    }
3039}
3040
3041/// Set the timezone applied to the reminder's due date. EventKit derives
3042/// `EKReminder.dueDateTimeZone` from `dueDateComponents.timeZone` (the
3043/// `dueDateTimeZone` property is readonly), so the setter mutates the
3044/// components and re-assigns them. Pass `None` to clear.
3045///
3046/// No-op if the reminder has no due date yet — without `dueDateComponents`
3047/// there's nothing to hang the timezone on. Callers that want a timezone-
3048/// scoped reminder should set the due date first.
3049fn set_reminder_due_date_timezone(reminder: &EKReminder, tz_name: Option<&str>) {
3050    use objc2_foundation::NSTimeZone;
3051    let Some(components) = (unsafe { reminder.dueDateComponents() }) else {
3052        return;
3053    };
3054    let tz = tz_name.and_then(|n| {
3055        let ns_n = NSString::from_str(n);
3056        NSTimeZone::timeZoneWithName(&ns_n)
3057    });
3058    components.setTimeZone(tz.as_deref());
3059    unsafe { reminder.setDueDateComponents(Some(&components)) };
3060}
3061
3062/// Set or clear the reminder's own `structuredLocation` — the iCloud-native
3063/// location chip Reminders.app reads/writes. Pass `None` to clear.
3064///
3065/// Uses `msg_send!` because `objc2-event-kit` 0.3 didn't generate
3066/// `setStructuredLocation:` on `EKCalendarItem` / `EKReminder` (only on
3067/// `EKEvent` and `EKAlarm`). The method exists at runtime — confirmed via
3068/// `class_copyMethodList`.
3069fn set_reminder_structured_location(reminder: &EKReminder, loc: Option<&StructuredLocation>) {
3070    use objc2::msg_send;
3071    let ek = loc.map(build_ek_structured_location);
3072    // EKCalendarItem exposes both — try the no-prediction variant first
3073    // (skips iCloud's "smart suggest based on title" pass), then the plain
3074    // setter. On iCloud-synced reminder lists the underlying daemon
3075    // silently drops both mutations; the alarm-based path (`set_geofence`)
3076    // is the only persistence route iCloud honors. We still call these
3077    // setters because local/CalDAV reminder sources accept them.
3078    let _: () =
3079        unsafe { msg_send![reminder, setStructuredLocationWithoutPrediction: ek.as_deref()] };
3080    let _: () = unsafe { msg_send![reminder, setStructuredLocation: ek.as_deref()] };
3081}
3082
3083/// Build an `EKStructuredLocation` from our plain `StructuredLocation`.
3084/// Geo coordinates only set when the `location` feature is on (CoreLocation).
3085fn build_ek_structured_location(loc: &StructuredLocation) -> Retained<EKStructuredLocation> {
3086    let title = NSString::from_str(&loc.title);
3087    let structured = unsafe { EKStructuredLocation::locationWithTitle(&title) };
3088    unsafe { structured.setRadius(loc.radius) };
3089    #[cfg(feature = "location")]
3090    {
3091        use objc2::AnyThread;
3092        use objc2_core_location::CLLocation;
3093        let cl = unsafe {
3094            CLLocation::initWithLatitude_longitude(CLLocation::alloc(), loc.latitude, loc.longitude)
3095        };
3096        unsafe { structured.setGeoLocation(Some(&cl)) };
3097    }
3098    structured
3099}
3100
3101/// Attach (or replace) a location-based alarm on the reminder. EventKit has
3102/// no `structuredLocation` directly on `EKCalendarItem`/`EKReminder` — the
3103/// geofence lives on an `EKAlarm` with `proximity`. Passing `None` clears
3104/// every proximity-bearing alarm.
3105fn set_reminder_geofence(
3106    reminder: &EKReminder,
3107    geofence: Option<(&StructuredLocation, AlarmProximity)>,
3108) {
3109    // Strip existing location-based alarms — leave time-based alarms alone.
3110    if let Some(alarms) = unsafe { reminder.alarms() } {
3111        for i in (0..alarms.len()).rev() {
3112            let alarm = alarms.objectAtIndex(i);
3113            if unsafe { alarm.proximity() } != EKAlarmProximity::None {
3114                unsafe { reminder.removeAlarm(&alarm) };
3115            }
3116        }
3117    }
3118
3119    let Some((loc, proximity)) = geofence else {
3120        return;
3121    };
3122
3123    let info = AlarmInfo {
3124        relative_offset: Some(0.0),
3125        proximity,
3126        location: Some(loc.clone()),
3127        ..Default::default()
3128    };
3129    // safe to expect — info has no `url`, the only field that can fail to parse
3130    let alarm = create_ek_alarm(&info).expect("geofence alarm has no URL to parse");
3131    unsafe { reminder.addAlarm(&alarm) };
3132}
3133
3134// ============================================================================
3135// Type conversion helpers
3136// ============================================================================
3137
3138// Helper to convert an EKRecurrenceRule to a RecurrenceRule
3139fn recurrence_rule_to_info(rule: &EKRecurrenceRule) -> RecurrenceRule {
3140    let frequency = unsafe { rule.frequency() };
3141    let frequency = match frequency {
3142        EKRecurrenceFrequency::Daily => RecurrenceFrequency::Daily,
3143        EKRecurrenceFrequency::Weekly => RecurrenceFrequency::Weekly,
3144        EKRecurrenceFrequency::Monthly => RecurrenceFrequency::Monthly,
3145        EKRecurrenceFrequency::Yearly => RecurrenceFrequency::Yearly,
3146        _ => RecurrenceFrequency::Daily,
3147    };
3148
3149    let interval = unsafe { rule.interval() } as usize;
3150
3151    let end = unsafe { rule.recurrenceEnd() }
3152        .map(|end| {
3153            let count = unsafe { end.occurrenceCount() };
3154            if count > 0 {
3155                RecurrenceEndCondition::AfterCount(count)
3156            } else if let Some(date) = unsafe { end.endDate() } {
3157                RecurrenceEndCondition::OnDate(nsdate_to_datetime(&date))
3158            } else {
3159                RecurrenceEndCondition::Never
3160            }
3161        })
3162        .unwrap_or(RecurrenceEndCondition::Never);
3163
3164    let days_of_week = unsafe { rule.daysOfTheWeek() }.map(|days| {
3165        let mut result = Vec::new();
3166        for i in 0..days.len() {
3167            let day = days.objectAtIndex(i);
3168            let weekday = unsafe { day.dayOfTheWeek() };
3169            result.push(weekday.0 as u8);
3170        }
3171        result
3172    });
3173
3174    fn nsnumber_array_to_vec(arr: Option<Retained<NSArray<NSNumber>>>) -> Option<Vec<i32>> {
3175        arr.map(|days| {
3176            let mut result = Vec::with_capacity(days.len());
3177            for i in 0..days.len() {
3178                result.push(days.objectAtIndex(i).intValue());
3179            }
3180            result
3181        })
3182    }
3183
3184    let days_of_month = nsnumber_array_to_vec(unsafe { rule.daysOfTheMonth() });
3185    let months_of_year = nsnumber_array_to_vec(unsafe { rule.monthsOfTheYear() });
3186    let weeks_of_year = nsnumber_array_to_vec(unsafe { rule.weeksOfTheYear() });
3187    let days_of_year = nsnumber_array_to_vec(unsafe { rule.daysOfTheYear() });
3188    let set_positions = nsnumber_array_to_vec(unsafe { rule.setPositions() });
3189
3190    RecurrenceRule {
3191        frequency,
3192        interval,
3193        end,
3194        days_of_week,
3195        days_of_month,
3196        months_of_year,
3197        weeks_of_year,
3198        days_of_year,
3199        set_positions,
3200    }
3201}
3202
3203// Helper to create an EKRecurrenceRule from a RecurrenceRule
3204fn create_ek_recurrence_rule(rule: &RecurrenceRule) -> Retained<EKRecurrenceRule> {
3205    let frequency = match rule.frequency {
3206        RecurrenceFrequency::Daily => EKRecurrenceFrequency::Daily,
3207        RecurrenceFrequency::Weekly => EKRecurrenceFrequency::Weekly,
3208        RecurrenceFrequency::Monthly => EKRecurrenceFrequency::Monthly,
3209        RecurrenceFrequency::Yearly => EKRecurrenceFrequency::Yearly,
3210    };
3211
3212    let end = match &rule.end {
3213        RecurrenceEndCondition::Never => None,
3214        RecurrenceEndCondition::AfterCount(count) => {
3215            Some(unsafe { EKRecurrenceEnd::recurrenceEndWithOccurrenceCount(*count) })
3216        }
3217        RecurrenceEndCondition::OnDate(date) => {
3218            let nsdate = datetime_to_nsdate(*date);
3219            Some(unsafe { EKRecurrenceEnd::recurrenceEndWithEndDate(&nsdate) })
3220        }
3221    };
3222
3223    let days_of_week: Option<Vec<Retained<EKRecurrenceDayOfWeek>>> =
3224        rule.days_of_week.as_ref().map(|days| {
3225            days.iter()
3226                .map(|&d| {
3227                    let weekday = EKWeekday(d as isize);
3228                    unsafe { EKRecurrenceDayOfWeek::dayOfWeek(weekday) }
3229                })
3230                .collect()
3231        });
3232
3233    fn vec_to_nsnumber_array(v: Option<&Vec<i32>>) -> Option<Vec<Retained<NSNumber>>> {
3234        v.map(|nums| nums.iter().map(|&n| NSNumber::new_i32(n)).collect())
3235    }
3236    let days_of_month = vec_to_nsnumber_array(rule.days_of_month.as_ref());
3237    let months_of_year = vec_to_nsnumber_array(rule.months_of_year.as_ref());
3238    let weeks_of_year = vec_to_nsnumber_array(rule.weeks_of_year.as_ref());
3239    let days_of_year = vec_to_nsnumber_array(rule.days_of_year.as_ref());
3240    let set_positions = vec_to_nsnumber_array(rule.set_positions.as_ref());
3241
3242    let days_of_week_arr = days_of_week
3243        .as_ref()
3244        .map(|v| NSArray::from_retained_slice(v));
3245    let days_of_month_arr = days_of_month
3246        .as_ref()
3247        .map(|v| NSArray::from_retained_slice(v));
3248    let months_of_year_arr = months_of_year
3249        .as_ref()
3250        .map(|v| NSArray::from_retained_slice(v));
3251    let weeks_of_year_arr = weeks_of_year
3252        .as_ref()
3253        .map(|v| NSArray::from_retained_slice(v));
3254    let days_of_year_arr = days_of_year
3255        .as_ref()
3256        .map(|v| NSArray::from_retained_slice(v));
3257    let set_positions_arr = set_positions
3258        .as_ref()
3259        .map(|v| NSArray::from_retained_slice(v));
3260
3261    unsafe {
3262        use objc2::AnyThread;
3263        EKRecurrenceRule::initRecurrenceWithFrequency_interval_daysOfTheWeek_daysOfTheMonth_monthsOfTheYear_weeksOfTheYear_daysOfTheYear_setPositions_end(
3264            EKRecurrenceRule::alloc(),
3265            frequency,
3266            rule.interval as isize,
3267            days_of_week_arr.as_deref(),
3268            days_of_month_arr.as_deref(),
3269            months_of_year_arr.as_deref(),
3270            weeks_of_year_arr.as_deref(),
3271            days_of_year_arr.as_deref(),
3272            set_positions_arr.as_deref(),
3273            end.as_deref(),
3274        )
3275    }
3276}
3277
3278// Helper to convert an EKAlarm to an AlarmInfo
3279fn alarm_to_info(alarm: &EKAlarm) -> AlarmInfo {
3280    let relative_offset = unsafe { alarm.relativeOffset() };
3281    let absolute_date = unsafe { alarm.absoluteDate() }.map(|d| nsdate_to_datetime(&d));
3282
3283    let proximity = unsafe { alarm.proximity() };
3284    let proximity = match proximity {
3285        EKAlarmProximity::Enter => AlarmProximity::Enter,
3286        EKAlarmProximity::Leave => AlarmProximity::Leave,
3287        _ => AlarmProximity::None,
3288    };
3289
3290    let location = unsafe { alarm.structuredLocation() }.map(|loc| {
3291        let title = unsafe { loc.title() }
3292            .map(|t| t.to_string())
3293            .unwrap_or_default();
3294        let radius = unsafe { loc.radius() };
3295        let (latitude, longitude) = unsafe { loc.geoLocation() }
3296            .map(|geo| {
3297                let coord = unsafe { geo.coordinate() };
3298                (coord.latitude, coord.longitude)
3299            })
3300            .unwrap_or((0.0, 0.0));
3301
3302        StructuredLocation {
3303            title,
3304            latitude,
3305            longitude,
3306            radius,
3307        }
3308    });
3309
3310    let email_address = unsafe { alarm.emailAddress() }.map(|s| s.to_string());
3311    let sound_name = unsafe { alarm.soundName() }.map(|s| s.to_string());
3312    #[allow(deprecated)]
3313    let url = unsafe { alarm.url() }
3314        .as_ref()
3315        .and_then(|u| u.absoluteString())
3316        .map(|s| s.to_string());
3317
3318    let alarm_type = match unsafe { alarm.r#type() } {
3319        EKAlarmType::Display => AlarmType::Display,
3320        EKAlarmType::Audio => AlarmType::Audio,
3321        EKAlarmType::Procedure => AlarmType::Procedure,
3322        EKAlarmType::Email => AlarmType::Email,
3323        _ => AlarmType::Unknown,
3324    };
3325
3326    AlarmInfo {
3327        // relativeOffset of 0 means "at time of event" — it's always set
3328        relative_offset: Some(relative_offset),
3329        absolute_date,
3330        proximity,
3331        location,
3332        email_address,
3333        sound_name,
3334        url,
3335        alarm_type,
3336    }
3337}
3338
3339// Helper to create an EKAlarm from an AlarmInfo. Returns Err if the
3340// caller-supplied `url` isn't a valid RFC 3986 URL.
3341fn create_ek_alarm(info: &AlarmInfo) -> Result<Retained<EKAlarm>> {
3342    let alarm = if let Some(date) = &info.absolute_date {
3343        let nsdate = datetime_to_nsdate(*date);
3344        unsafe { EKAlarm::alarmWithAbsoluteDate(&nsdate) }
3345    } else {
3346        let offset = info.relative_offset.unwrap_or(0.0);
3347        unsafe { EKAlarm::alarmWithRelativeOffset(offset) }
3348    };
3349
3350    let prox = match info.proximity {
3351        AlarmProximity::Enter => EKAlarmProximity::Enter,
3352        AlarmProximity::Leave => EKAlarmProximity::Leave,
3353        AlarmProximity::None => EKAlarmProximity::None,
3354    };
3355    unsafe { alarm.setProximity(prox) };
3356
3357    if let Some(loc) = &info.location {
3358        let structured = build_ek_structured_location(loc);
3359        unsafe { alarm.setStructuredLocation(Some(&structured)) };
3360    }
3361
3362    // Apple infers EKAlarmType from which of these are set:
3363    //   soundName → Audio, url → Procedure, emailAddress → Email, else Display.
3364    if let Some(email) = &info.email_address {
3365        let ns = NSString::from_str(email);
3366        unsafe { alarm.setEmailAddress(Some(&ns)) };
3367    }
3368    if let Some(sound) = &info.sound_name {
3369        let ns = NSString::from_str(sound);
3370        unsafe { alarm.setSoundName(Some(&ns)) };
3371    }
3372    if let Some(u) = &info.url {
3373        let ns_str = NSString::from_str(u);
3374        let parsed =
3375            objc2_foundation::NSURL::URLWithString_encodingInvalidCharacters(&ns_str, false)
3376                .ok_or_else(|| EventKitError::InvalidURL(u.clone()))?;
3377        #[allow(deprecated)]
3378        unsafe {
3379            alarm.setUrl(Some(&parsed))
3380        };
3381    }
3382
3383    Ok(alarm)
3384}
3385
3386#[cfg(test)]
3387mod tests {
3388    use super::*;
3389
3390    #[test]
3391    fn event_span_default_is_this_and_maps_to_ek() {
3392        assert_eq!(EventSpan::default(), EventSpan::This);
3393        assert_eq!(EventSpan::This.to_ek(), EKSpan::ThisEvent);
3394        assert_eq!(EventSpan::Future.to_ek(), EKSpan::FutureEvents);
3395    }
3396
3397    #[test]
3398    fn event_availability_round_trips_every_variant() {
3399        for v in [
3400            EventAvailability::NotSupported,
3401            EventAvailability::Busy,
3402            EventAvailability::Free,
3403            EventAvailability::Tentative,
3404            EventAvailability::Unavailable,
3405        ] {
3406            assert_eq!(EventAvailability::from_ek(v.to_ek()), v);
3407        }
3408    }
3409
3410    #[test]
3411    fn event_status_from_ek_covers_each_variant() {
3412        assert_eq!(EventStatus::from_ek(EKEventStatus::None), EventStatus::None);
3413        assert_eq!(
3414            EventStatus::from_ek(EKEventStatus::Confirmed),
3415            EventStatus::Confirmed
3416        );
3417        assert_eq!(
3418            EventStatus::from_ek(EKEventStatus::Tentative),
3419            EventStatus::Tentative
3420        );
3421        assert_eq!(
3422            EventStatus::from_ek(EKEventStatus::Canceled),
3423            EventStatus::Canceled
3424        );
3425    }
3426
3427    #[test]
3428    fn event_draft_default_has_empty_title_and_zero_dates() {
3429        // Documents that defaults aren't valid for actually creating —
3430        // callers must populate title/start/end. Just confirms the struct
3431        // builds with `..Default::default()` so the call sites can use it.
3432        let d: EventDraft<'_> = EventDraft::default();
3433        assert_eq!(d.title, "");
3434        assert!(d.start.is_none());
3435        assert!(d.end.is_none());
3436        assert!(!d.all_day);
3437        assert!(d.URL.is_none());
3438        assert!(d.availability.is_none());
3439    }
3440
3441    #[test]
3442    fn event_patch_default_touches_nothing() {
3443        let p: EventPatch<'_> = EventPatch::default();
3444        assert!(p.title.is_none());
3445        assert!(p.notes.is_none());
3446        assert!(p.URL.is_none());
3447        assert!(p.availability.is_none());
3448        assert!(p.all_day.is_none());
3449        assert_eq!(p.span, EventSpan::This);
3450    }
3451
3452    #[test]
3453    fn test_authorization_status_display() {
3454        assert_eq!(
3455            format!("{}", AuthorizationStatus::NotDetermined),
3456            "Not Determined"
3457        );
3458        assert_eq!(
3459            format!("{}", AuthorizationStatus::FullAccess),
3460            "Full Access"
3461        );
3462    }
3463
3464    #[test]
3465    fn test_event_item_debug() {
3466        let event = EventItem {
3467            identifier: "test".to_string(),
3468            title: "Test Event".to_string(),
3469            notes: None,
3470            location: None,
3471            start_date: Local::now(),
3472            end_date: Local::now(),
3473            all_day: false,
3474            calendar_title: None,
3475            calendar_id: None,
3476            URL: None,
3477            availability: EventAvailability::Busy,
3478            status: EventStatus::None,
3479            is_detached: false,
3480            occurrence_date: None,
3481            structured_location: None,
3482            creation_date: None,
3483            last_modified_date: None,
3484            external_identifier: None,
3485            timezone: None,
3486            attachments_count: 0,
3487            attendees: Vec::new(),
3488            organizer: None,
3489        };
3490        assert!(format!("{:?}", event).contains("Test Event"));
3491    }
3492}