use std::io::{Cursor, Read};

use askama::Template;
use chrono::{Datelike, LocalResult, TimeZone, Utc};
use chrono_tz::Tz;
use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail};
use quick_xml::de::from_str as xml_from_str;
use tracing::{error, info, warn};
use zip::ZipArchive;

use crate::{
    error::ServerError,
    graphql::{Attachment, Body, DispositionType, Email, Html, PlainText, UnhandledContentType},
    linkify_html,
};

const APPLICATION_GZIP: &'static str = "application/gzip";

const APPLICATION_ZIP: &'static str = "application/zip";
const APPLICATION_TLSRPT_GZIP: &'static str = "application/tlsrpt+gzip";
const IMAGE_JPEG: &'static str = "image/jpeg";
const IMAGE_PJPEG: &'static str = "image/pjpeg";
const IMAGE_PNG: &'static str = "image/png";
const MESSAGE_DELIVERY_STATUS: &'static str = "message/delivery-status";
const MESSAGE_RFC822: &'static str = "message/rfc822";
const MULTIPART_ALTERNATIVE: &'static str = "multipart/alternative";
const MULTIPART_MIXED: &'static str = "multipart/mixed";
const MULTIPART_RELATED: &'static str = "multipart/related";
const MULTIPART_REPORT: &'static str = "multipart/report";
const TEXT_CALENDAR: &'static str = "text/calendar";
const TEXT_HTML: &'static str = "text/html";
const TEXT_PLAIN: &'static str = "text/plain";

// Inline Askama filters module for template use
mod filters {
    // Usage: {{ items|batch(7) }}
    #[askama::filter_fn]
    pub fn batch<T: Clone>(
        items: &[T],
        _: &dyn ::askama::Values,
        size: usize,
    ) -> askama::Result<Vec<Vec<T>>> {
        if size == 0 {
            return Ok(vec![]);
        }
        let mut out = Vec::new();
        let mut chunk = Vec::with_capacity(size);
        for item in items {
            chunk.push(item.clone());
            if chunk.len() == size {
                out.push(chunk);
                chunk = Vec::with_capacity(size);
            }
        }
        if !chunk.is_empty() {
            out.push(chunk);
        }
        Ok(out)
    }
}
#[derive(Debug, PartialEq)]
pub struct ExtractedCalendarMetadata {
    pub is_google_calendar_event: bool,
    pub summary: Option<String>,
    pub organizer: Option<String>,
    pub start_date: Option<String>,
    pub end_date: Option<String>,
    pub body_html: Option<String>,
}

/// Helper to extract Google Calendar event metadata from a ParsedMail (for tests and features)
pub fn extract_calendar_metadata_from_mail(
    m: &ParsedMail,
    body: &Body,
) -> ExtractedCalendarMetadata {
    let mut is_google = false;
    let mut summary = None;
    let mut organizer = None;
    let mut start_date = None;
    let mut end_date = None;
    let mut body_html = None;

    // Check sender
    if let Some(from) = m.headers.get_first_value("Sender") {
        if from.contains("calendar-notification@google.com") {
            is_google = true;
        }
    }
    // Check for Google Calendar subject
    if let Some(subject) = m.headers.get_first_value("Subject") {
        if subject.contains("New event:") || subject.contains("Google Calendar") {
            is_google = true;
        }
    }

    // Try to extract from text/calendar part if present
    fn find_ical<'a>(m: &'a ParsedMail) -> Option<String> {
        if m.ctype.mimetype == TEXT_CALENDAR {
            m.get_body().ok()
        } else {
            for sp in &m.subparts {
                if let Some(b) = find_ical(sp) {
                    return Some(b);
                }
            }
            None
        }
    }
    let ical_opt = find_ical(m);
    if let Some(ical) = ical_opt {
        // Use existing render_ical_summary to extract fields
        if let Ok(rendered) = render_ical_summary(&ical) {
            // Try to extract summary, organizer, start/end from the ical
            use ical::IcalParser;
            let mut parser = IcalParser::new(ical.as_bytes());
            if let Some(Ok(calendar)) = parser.next() {
                'event_loop: for event in calendar.events {
                    for prop in &event.properties {
                        match prop.name.as_str() {
                            "SUMMARY" => {
                                if summary.is_none() {
                                    summary = prop.value.clone();
                                    break 'event_loop;
                                }
                            }
                            "ORGANIZER" => organizer = prop.value.clone(),
                            "DTSTART" => {
                                if let Some(dt) = &prop.value {
                                    if dt.len() >= 8 {
                                        start_date = Some(dt[0..8].to_string());
                                    }
                                }
                            }
                            "DTEND" => {
                                if let Some(dt) = &prop.value {
                                    if dt.len() >= 8 {
                                        end_date = Some(dt[0..8].to_string());
                                    }
                                }
                            }
                            _ => {}
                        }
                    }
                }
            }
            body_html = Some(rendered);
        }
    }

    // Fallback extraction: if iCal did not provide metadata, extract from subject/body before generating fallback HTML
    if body_html.is_none() {
        // Try to extract summary from subject (e.g., "New event: <summary> @ ..." or "Updated invitation: <summary> @ ...")
        if summary.is_none() {
            if let Some(subject) = m.headers.get_first_value("Subject") {
                if let Some(caps) = regex::Regex::new(r"New event: ([^@]+) @")
                    .ok()
                    .and_then(|re| re.captures(&subject))
                {
                    summary = Some(caps[1].trim().to_string());
                } else if let Some(caps) = regex::Regex::new(r"Invitation: ([^@]+) @")
                    .ok()
                    .and_then(|re| re.captures(&subject))
                {
                    summary = Some(caps[1].trim().to_string());
                } else if let Some(caps) = regex::Regex::new(r"Updated invitation: ([^@]+) @")
                    .ok()
                    .and_then(|re| re.captures(&subject))
                {
                    summary = Some(caps[1].trim().to_string());
                }
            }
        }
        // Try to extract start/end dates from subject
        if start_date.is_none() || end_date.is_none() {
            if let Some(subject) = m.headers.get_first_value("Subject") {
                // Pattern: @ Tue Sep 23, 2025 3pm - 4pm (works for New event, Invitation, Updated invitation, etc.)
                if let Some(caps) = regex::Regex::new(r"@ ([A-Za-z]{3}) ([A-Za-z]{3}) (\d{1,2}), (\d{4}) (\d{1,2})(?::(\d{2}))? ?([ap]m) ?- ?(\d{1,2})(?::(\d{2}))? ?([ap]m)").ok().and_then(|re| re.captures(&subject)) {
                        let month = &caps[2];
                        let day = &caps[3];
                        let year = &caps[4];
                        let start_hour: u32 = caps[5].parse().unwrap_or(0);
                        let start_min: u32 = caps.get(6).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
                        let start_ampm = &caps[7];
                        let end_hour: u32 = caps[8].parse().unwrap_or(0);
                        let end_min: u32 = caps.get(9).map(|m| m.as_str().parse().unwrap_or(0)).unwrap_or(0);
                        let end_ampm = &caps[10];

                        // Convert 12-hour to 24-hour format
                        let start_hour_24 = if start_ampm == "pm" && start_hour != 12 {
                            start_hour + 12
                        } else if start_ampm == "am" && start_hour == 12 {
                            0
                        } else {
                            start_hour
                        };
                        let end_hour_24 = if end_ampm == "pm" && end_hour != 12 {
                            end_hour + 12
                        } else if end_ampm == "am" && end_hour == 12 {
                            0
                        } else {
                            end_hour
                        };

                        let date_str = format!("{} {} {}", month, day, year);
                        if let Ok(date) = chrono::NaiveDate::parse_from_str(&date_str, "%b %d %Y") {
                            // Store date with time in YYYYMMDDTHHMMSS format for start/end
                            let start_dt = format!("{}T{:02}{:02}00", date.format("%Y%m%d"), start_hour_24, start_min);
                            let end_dt = format!("{}T{:02}{:02}00", date.format("%Y%m%d"), end_hour_24, end_min);
                            start_date = Some(start_dt);
                            end_date = Some(end_dt);
                        }
                    } else {
                        // Pattern: from Thu Sep 11 to Fri Jan 30, 2026
                        if let Some(caps) = regex::Regex::new(r"from [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) to [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) {
                            let start_month = &caps[1];
                            let start_day = &caps[2];
                            let end_month = &caps[3];
                            let end_day = &caps[4];
                            let year = &caps[5];
                            fn month_num(mon: &str) -> Option<&'static str> {
                                match mon {
                                    "Jan" => Some("01"), "Feb" => Some("02"), "Mar" => Some("03"), "Apr" => Some("04"),
                                    "May" => Some("05"), "Jun" => Some("06"), "Jul" => Some("07"), "Aug" => Some("08"),
                                    "Sep" => Some("09"), "Oct" => Some("10"), "Nov" => Some("11"), "Dec" => Some("12"),
                                    _ => None
                                }
                            }
                            if let (Some(sm), Some(em)) = (month_num(start_month), month_num(end_month)) {
                                // If start month is later in calendar year than end month, start is in previous year
                                let sm_num: u32 = sm.parse().unwrap_or(1);
                                let em_num: u32 = em.parse().unwrap_or(1);
                                let end_year = year.parse::<i32>().unwrap_or_else(|_| chrono::Local::now().year());
                                let start_year: i32 = if sm_num > em_num {
                                    end_year - 1
                                } else {
                                    end_year
                                };
                                let start = format!("{}{}{}", start_year, sm, format!("{:0>2}", start_day));
                                let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok();
                                if let Some(d) = end_date_val.as_mut() {
                                    *d = d.succ_opt().unwrap_or(*d);
                                }
                                let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day)));
                                if start_date.is_none() { start_date = Some(start); }
                                if end_date.is_none() { end_date = Some(end); }
                            }
                        }
                        // Pattern: @ Tue Jun 24 - Mon Jun 30, 2025
                        if let Some(caps) = regex::Regex::new(r"@ [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) - [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) {
                            let start_month = &caps[1];
                            let start_day = &caps[2];
                            let end_month = &caps[3];
                            let end_day = &caps[4];
                            let year = &caps[5];
                            fn month_num(mon: &str) -> Option<&'static str> {
                                match mon {
                                    "Jan" => Some("01"), "Feb" => Some("02"), "Mar" => Some("03"), "Apr" => Some("04"),
                                    "May" => Some("05"), "Jun" => Some("06"), "Jul" => Some("07"), "Aug" => Some("08"),
                                    "Sep" => Some("09"), "Oct" => Some("10"), "Nov" => Some("11"), "Dec" => Some("12"),
                                    _ => None
                                }
                            }
                            if let (Some(sm), Some(em)) = (month_num(start_month), month_num(end_month)) {
                                let start = format!("{}{}{}", year, sm, format!("{:0>2}", start_day));
                                let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok();
                                if let Some(d) = end_date_val.as_mut() {
                                    *d = d.succ_opt().unwrap_or(*d);
                                }
                                let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day)));
                                if start_date.is_none() { start_date = Some(start); }
                                if end_date.is_none() { end_date = Some(end); }
                            }
                        }
                        // Pattern: single all-day event: @ Sun Jan 18, 2026 (no time range)
                        if start_date.is_none() {
                            if let Some(caps) = regex::Regex::new(r"@ [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})(?:\s*\(|$)").ok().and_then(|re| re.captures(&subject)) {
                                let month = &caps[1];
                                let day = &caps[2];
                                let year = &caps[3];
                                fn month_num(mon: &str) -> Option<&'static str> {
                                    match mon {
                                        "Jan" => Some("01"), "Feb" => Some("02"), "Mar" => Some("03"), "Apr" => Some("04"),
                                        "May" => Some("05"), "Jun" => Some("06"), "Jul" => Some("07"), "Aug" => Some("08"),
                                        "Sep" => Some("09"), "Oct" => Some("10"), "Nov" => Some("11"), "Dec" => Some("12"),
                                        _ => None
                                    }
                                }
                                if let Some(mm) = month_num(month) {
                                    let start = format!("{}{}{:0>2}", year, mm, day);
                                    // For all-day events, end date is the next day (exclusive)
                                    if let Ok(d) = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{:0>2}", year, mm, day), "%Y-%m-%d") {
                                        let end = d.succ_opt().unwrap_or(d).format("%Y%m%d").to_string();
                                        start_date = Some(start);
                                        end_date = Some(end);
                                    }
                                }
                            }
                        }
                    }
            }
        }
        // Try to extract summary from body if still missing
        if summary.is_none() {
            if let Body::PlainText(t) = body {
                for line in t.text.lines() {
                    let line = line.trim();
                    if !line.is_empty() && line.len() > 3 && line.len() < 100 {
                        summary = Some(line.to_string());
                        break;
                    }
                }
            }
            if summary.is_none() {
                if let Body::Html(h) = body {
                    let text = regex::Regex::new(r"<[^>]+>")
                        .unwrap()
                        .replace_all(&h.html, "");
                    for line in text.lines() {
                        let line = line.trim();
                        if !line.is_empty() && line.len() > 3 && line.len() < 100 {
                            summary = Some(line.to_string());
                            break;
                        }
                    }
                }
            }
        }
        // Try to extract organizer from From header if not found
        if organizer.is_none() {
            if let Some(from) = m.headers.get_first_value("From") {
                let email = from
                    .split('<')
                    .nth(1)
                    .and_then(|s| s.split('>').next())
                    .map(|s| s.trim().to_string())
                    .or_else(|| Some(from.trim().to_string()));
                organizer = email;
            }
        }
        // Use HTML body if present
        if let Body::Html(h) = body {
            body_html = Some(h.html.clone());
        }
    }
    // Fallback: if body_html is still None, generate a minimal calendar HTML using all available metadata
    // Improved recurrence detection: check for common recurrence phrases in subject, HTML, and plain text body
    let mut has_recurrence = false;
    let recurrence_phrases = [
        "recurr",
        "repeat",
        "every week",
        "every month",
        "every year",
        "weekly",
        "monthly",
        "annually",
        "biweekly",
        "daily",
        "RRULE",
    ];
    if let Some(ref s) = m.headers.get_first_value("Subject") {
        let subj = s.to_lowercase();
        if recurrence_phrases.iter().any(|p| subj.contains(p)) {
            has_recurrence = true;
        }
    }
    if !has_recurrence {
        if let Some(ref html) = body_html {
            let html_lc = html.to_lowercase();
            if recurrence_phrases.iter().any(|p| html_lc.contains(p)) {
                has_recurrence = true;
            }
        }
    }
    if !has_recurrence {
        if let Body::PlainText(t) = body {
            let text_lc = t.text.to_lowercase();
            if recurrence_phrases.iter().any(|p| text_lc.contains(p)) {
                has_recurrence = true;
            }
        }
    }
    let needs_ical_flex =
        summary.is_some() || start_date.is_some() || end_date.is_some() || has_recurrence;
    if needs_ical_flex {
        use chrono::{Datelike, NaiveDate};

        let summary_val = summary.clone().unwrap_or_default();
        let organizer_val = organizer.clone().unwrap_or_default();
        let start_val = start_date.clone().unwrap_or_default();
        let end_val = end_date.clone().unwrap_or_default();
        let recurrence_display = if has_recurrence {
            "Repeats".to_string()
        } else {
            String::new()
        };

        // Compute event_days and all_days for calendar grid rendering
        let mut event_days: Vec<NaiveDate> = Vec::new();
        let (local_fmt_start, local_fmt_end) = if let (Some(ref start_str), Some(ref end_str)) =
            (&start_date, &end_date)
        {
            // Parse dates - try YYYYMMDDTHHMMSS format first (with time), then YYYYMMDD (date only)
            let (start_d, start_time) = if start_str.contains('T') {
                let parts: Vec<&str> = start_str.split('T').collect();
                let date = NaiveDate::parse_from_str(parts[0], "%Y%m%d").ok();
                let time = if parts.len() > 1 {
                    chrono::NaiveTime::parse_from_str(parts[1], "%H%M%S").ok()
                } else {
                    None
                };
                (date, time)
            } else {
                (NaiveDate::parse_from_str(start_str, "%Y%m%d").ok(), None)
            };

            let (end_d, end_time) = if end_str.contains('T') {
                let parts: Vec<&str> = end_str.split('T').collect();
                let date = NaiveDate::parse_from_str(parts[0], "%Y%m%d").ok();
                let time = if parts.len() > 1 {
                    chrono::NaiveTime::parse_from_str(parts[1], "%H%M%S").ok()
                } else {
                    None
                };
                (date, time)
            } else {
                (NaiveDate::parse_from_str(end_str, "%Y%m%d").ok(), None)
            };

            if let (Some(start), Some(end)) = (start_d, end_d) {
                // For all-day events (no time), end date is exclusive, so we need to subtract one day
                let is_allday = start_time.is_none() && end_time.is_none();
                let display_end = if is_allday && end > start {
                    end.pred_opt().unwrap_or(end)
                } else {
                    end
                };

                // Add all days from start to display_end (inclusive) to event_days
                let mut day_iter = start;
                while day_iter <= display_end {
                    event_days.push(day_iter);
                    day_iter = day_iter.succ_opt().unwrap_or(day_iter);
                    if day_iter == display_end && day_iter == start {
                        // Single day event
                        break;
                    }
                }

                // Format dates for display - include time if available
                let fmt_start = if let Some(t) = start_time {
                    format!("{} {}", start.format("%a %b %e, %Y"), t.format("%-I:%M %p"))
                } else {
                    start.format("%a %b %e, %Y").to_string()
                };
                let fmt_end = if let Some(t) = end_time {
                    format!("{} {}", display_end.format("%a %b %e, %Y"), t.format("%-I:%M %p"))
                } else {
                    display_end.format("%a %b %e, %Y").to_string()
                };
                (fmt_start, fmt_end)
            } else {
                (start_val.clone(), end_val.clone())
            }
        } else {
            (start_val.clone(), end_val.clone())
        };

        // Compute calendar grid (all_days) from event_days
        let (all_days, caption) = if !event_days.is_empty() {
            let first_event = event_days.first().unwrap();
            let last_event = event_days.last().unwrap();

            let first_of_month =
                NaiveDate::from_ymd_opt(first_event.year(), first_event.month(), 1).unwrap();
            let last_of_month = {
                let next_month = if last_event.month() == 12 {
                    NaiveDate::from_ymd_opt(last_event.year() + 1, 1, 1).unwrap()
                } else {
                    NaiveDate::from_ymd_opt(last_event.year(), last_event.month() + 1, 1).unwrap()
                };
                next_month.pred_opt().unwrap()
            };

            // Start from Sunday of the week containing first_of_month
            let mut cal_start = first_of_month;
            while cal_start.weekday() != chrono::Weekday::Sun {
                cal_start = cal_start.pred_opt().unwrap();
            }
            // End on Saturday of the week containing last_of_month
            let mut cal_end = last_of_month;
            while cal_end.weekday() != chrono::Weekday::Sat {
                cal_end = cal_end.succ_opt().unwrap();
            }

            let mut all_days = vec![];
            let mut d = cal_start;
            while d <= cal_end {
                all_days.push(d);
                d = d.succ_opt().unwrap();
            }

            let start_month = first_event.format("%B %Y");
            let end_month = last_event.format("%B %Y");
            let caption = if start_month.to_string() == end_month.to_string() {
                start_month.to_string()
            } else {
                format!("{} – {}", start_month, end_month)
            };
            (all_days, caption)
        } else {
            (vec![], String::new())
        };

        let template = IcalSummaryTemplate {
            summary: &summary_val,
            local_fmt_start: &local_fmt_start,
            local_fmt_end: &local_fmt_end,
            organizer: &organizer_val,
            organizer_cn: "",
            all_days,
            event_days,
            caption,
            description_paragraphs: &[],
            today: Some(chrono::Local::now().date_naive()),
            recurrence_display,
        };
        let fallback_html = template
            .render()
            .unwrap_or_else(|_| String::from("<div class='ical-flex'></div>"));
        match &mut body_html {
            Some(existing) => {
                if !existing.starts_with(&fallback_html) {
                    *existing = format!("{}{}", fallback_html, existing);
                }
            }
            None => {
                body_html = Some(fallback_html);
            }
        }
    }
    // Final guarantee: if body_html is still None, set to minimal ical-flex HTML with empty fields using the template
    if body_html.is_none() {
        let template = IcalSummaryTemplate {
            summary: "",
            local_fmt_start: "",
            local_fmt_end: "",
            organizer: "",
            organizer_cn: "",
            all_days: vec![],
            event_days: vec![],
            caption: String::new(),
            description_paragraphs: &[],
            today: Some(chrono::Local::now().date_naive()),
            recurrence_display: String::new(),
        };
        body_html = Some(
            template
                .render()
                .unwrap_or_else(|_| String::from("<div class='ical-flex'></div>")),
        );
    }

    // Improved fallback: extract summary, start_date, end_date, and recurrence from subject/body if not found
    if let Some(subject) = m.headers.get_first_value("Subject") {
        // Try to extract summary from subject (e.g., "New event: <summary> @ ...")
        if summary.is_none() {
            if let Some(caps) = regex::Regex::new(r"New event: ([^@]+) @")
                .ok()
                .and_then(|re| re.captures(&subject))
            {
                summary = Some(caps[1].trim().to_string());
            } else if let Some(caps) = regex::Regex::new(r"Invitation: ([^@]+) @")
                .ok()
                .and_then(|re| re.captures(&subject))
            {
                summary = Some(caps[1].trim().to_string());
            }
        }
        // Try to extract start/end dates from subject
        if start_date.is_none() || end_date.is_none() {
            // Pattern: from Thu Sep 11 to Fri Jan 30, 2026
            if let Some(caps) = regex::Regex::new(r"from [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) to [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) {
                let start_month = &caps[1];
                let start_day = &caps[2];
                let end_month = &caps[3];
                let end_day = &caps[4];
                let year = &caps[5];
                fn month_num(mon: &str) -> Option<&'static str> {
                    match mon {
                        "Jan" => Some("01"), "Feb" => Some("02"), "Mar" => Some("03"), "Apr" => Some("04"),
                        "May" => Some("05"), "Jun" => Some("06"), "Jul" => Some("07"), "Aug" => Some("08"),
                        "Sep" => Some("09"), "Oct" => Some("10"), "Nov" => Some("11"), "Dec" => Some("12"),
                        _ => None
                    }
                }
                if let (Some(sm), Some(em)) = (month_num(start_month), month_num(end_month)) {
                    let current_year = chrono::Local::now().year().to_string();
                    let start = format!("{}{}{}", current_year, sm, format!("{:0>2}", start_day));
                    let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok();
                    if let Some(d) = end_date_val.as_mut() {
                        *d = d.succ_opt().unwrap_or(*d);
                    }
                    let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day)));
                    if start_date.is_none() { start_date = Some(start); }
                    if end_date.is_none() { end_date = Some(end); }
                }
            }
            // Pattern: @ Tue Jun 24 - Mon Jun 30, 2025
            if let Some(caps) = regex::Regex::new(r"@ [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}) - [A-Za-z]{3} ([A-Za-z]{3}) (\d{1,2}), (\d{4})").ok().and_then(|re| re.captures(&subject)) {
                let start_month = &caps[1];
                let start_day = &caps[2];
                let end_month = &caps[3];
                let end_day = &caps[4];
                let year = &caps[5];
                fn month_num(mon: &str) -> Option<&'static str> {
                    match mon {
                        "Jan" => Some("01"), "Feb" => Some("02"), "Mar" => Some("03"), "Apr" => Some("04"),
                        "May" => Some("05"), "Jun" => Some("06"), "Jul" => Some("07"), "Aug" => Some("08"),
                        "Sep" => Some("09"), "Oct" => Some("10"), "Nov" => Some("11"), "Dec" => Some("12"),
                        _ => None
                    }
                }
                if let (Some(sm), Some(em)) = (month_num(start_month), month_num(end_month)) {
                    let start = format!("{}{}{}", year, sm, format!("{:0>2}", start_day));
                    let mut end_date_val = chrono::NaiveDate::parse_from_str(&format!("{}-{}-{}", year, em, format!("{:0>2}", end_day)), "%Y-%m-%d").ok();
                    if let Some(d) = end_date_val.as_mut() {
                        *d = d.succ_opt().unwrap_or(*d);
                    }
                    let end = end_date_val.map(|d| d.format("%Y%m%d").to_string()).unwrap_or_else(|| format!("{}{}{}", year, em, format!("{:0>2}", end_day)));
                    if start_date.is_none() { start_date = Some(start); }
                    if end_date.is_none() { end_date = Some(end); }
                }
            }
        }
        // Try to detect recurrence from subject
        // recurrence detection and rendering is now handled by the template logic
    }
    // Try to extract summary from body if still missing
    if summary.is_none() {
        if let Body::PlainText(t) = body {
            for line in t.text.lines() {
                let line = line.trim();
                if !line.is_empty() && line.len() > 3 && line.len() < 100 {
                    summary = Some(line.to_string());
                    break;
                }
            }
        }
        if summary.is_none() {
            if let Body::Html(h) = body {
                let text = regex::Regex::new(r"<[^>]+>")
                    .unwrap()
                    .replace_all(&h.html, "");
                for line in text.lines() {
                    let line = line.trim();
                    if !line.is_empty() && line.len() > 3 && line.len() < 100 {
                        summary = Some(line.to_string());
                        break;
                    }
                }
            }
        }
    }

    // Try to extract organizer from From header if not found
    if organizer.is_none() {
        if let Some(from) = m.headers.get_first_value("From") {
            // Try to extract email address from From header
            let email = from
                .split('<')
                .nth(1)
                .and_then(|s| s.split('>').next())
                .map(|s| s.trim().to_string())
                .or_else(|| Some(from.trim().to_string()));
            organizer = email;
        }
    }

    ExtractedCalendarMetadata {
        is_google_calendar_event: is_google,
        summary,
        organizer,
        start_date,
        end_date,
        body_html,
    }
}
pub fn email_addresses(
    _path: &str,
    m: &ParsedMail,
    header_name: &str,
) -> Result<Vec<Email>, ServerError> {
    let mut addrs = Vec::new();
    for header_value in m.headers.get_all_values(header_name) {
        match mailparse::addrparse(&header_value) {
            Ok(mal) => {
                for ma in mal.into_inner() {
                    match ma {
                        mailparse::MailAddr::Group(gi) => {
                            if !gi.group_name.contains("ndisclosed") {}
                        }
                        mailparse::MailAddr::Single(s) => addrs.push(Email {
                            name: s.display_name,
                            addr: Some(s.addr),
                            photo_url: None,
                        }), //println!("Single: {s}"),
                    }
                }
            }
            Err(_) => {
                let v = header_value;
                if v.matches('@').count() == 1 {
                    if v.matches('<').count() == 1 && v.ends_with('>') {
                        let idx = v.find('<').unwrap();
                        let addr = &v[idx + 1..v.len() - 1].trim();
                        let name = &v[..idx].trim();
                        addrs.push(Email {
                            name: Some(name.to_string()),
                            addr: Some(addr.to_string()),
                            photo_url: None,
                        });
                    }
                } else {
                    addrs.push(Email {
                        name: Some(v),
                        addr: None,
                        photo_url: None,
                    });
                }
            }
        }
    }
    Ok(addrs)
}

pub fn extract_body(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
    let body = m.get_body()?;
    let ret = match m.ctype.mimetype.as_str() {
        TEXT_PLAIN => return Ok(Body::text(body)),
        TEXT_HTML => return Ok(Body::html(body)),
        MULTIPART_MIXED => extract_mixed(m, part_addr),
        MULTIPART_ALTERNATIVE => extract_alternative(m, part_addr),
        MULTIPART_RELATED => extract_related(m, part_addr),
        MULTIPART_REPORT => extract_report(m, part_addr),
        // APPLICATION_ZIP and APPLICATION_GZIP are handled in the thread function
        APPLICATION_ZIP => extract_unhandled(m),
        APPLICATION_GZIP => extract_unhandled(m),
        mt if mt.starts_with("application/") => Ok(Body::text("".to_string())),
        _ => extract_unhandled(m),
    };
    if let Err(err) = ret {
        error!("Failed to extract body: {:?}", err);
        return Ok(extract_unhandled(m)?);
    }
    ret
}

pub fn extract_zip(m: &ParsedMail) -> Result<Body, ServerError> {
    if let Ok(zip_bytes) = m.get_body_raw() {
        if let Ok(mut archive) = ZipArchive::new(Cursor::new(&zip_bytes)) {
            for i in 0..archive.len() {
                if let Ok(mut file) = archive.by_index(i) {
                    let name = file.name().to_lowercase();
                    // Google DMARC reports are typically named like "google.com!example.com!...xml"
                    // and may or may not contain "dmarc" in the filename.
                    if is_dmarc_report_filename(&name) {
                        let mut xml = String::new();
                        use std::io::Read;
                        if file.read_to_string(&mut xml).is_ok() {
                            match parse_dmarc_report(&xml) {
                                Ok(report) => {
                                    return Ok(Body::html(format!(
                                        "<div class=\"dmarc-report\">DMARC report summary:<br>{}</div>",
                                        report
                                    )));
                                }
                                Err(e) => {
                                    return Ok(Body::html(format!(
                                        "<div class=\"dmarc-report-error\">Failed to parse DMARC report XML: {}</div>",
                                        e
                                    )));
                                }
                            }
                        }
                    }
                }
            }
        }
    }
    // If no DMARC report found, fall through to unhandled
    extract_unhandled(m)
}

pub fn extract_gzip(m: &ParsedMail) -> Result<(Body, Option<String>), ServerError> {
    let pcd = m.get_content_disposition();
    let filename = pcd.params.get("filename").map(|s| s.to_lowercase());

    let is_dmarc_xml_file = filename.map_or(false, |name| is_dmarc_report_filename(&name));

    if is_dmarc_xml_file {
        if let Ok(gz_bytes) = m.get_body_raw() {
            let mut decoder = flate2::read::GzDecoder::new(&gz_bytes[..]);
            let mut xml = String::new();
            use std::io::Read;
            if decoder.read_to_string(&mut xml).is_ok() {
                match parse_dmarc_report(&xml) {
                    Ok(report) => {
                        return Ok((
                            Body::html(format!(
                                "<div class=\"dmarc-report\">DMARC report summary:<br>{}</div>",
                                report
                            )),
                            Some(xml),
                        ));
                    }
                    Err(e) => {
                        return Ok((Body::html(format!(
                            "<div class=\"dmarc-report-error\">Failed to parse DMARC report XML: {}</div>",
                            e
                        )), None));
                    }
                }
            }
        }
    }
    Ok((extract_unhandled(m)?, None))
}

pub fn extract_report(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
    let mut parts = Vec::new();

    for (idx, sp) in m.subparts.iter().enumerate() {
        part_addr.push(idx.to_string());

        match sp.ctype.mimetype.as_str() {
            APPLICATION_TLSRPT_GZIP => {
                let gz_bytes = sp.get_body_raw()?;
                let mut decoder = flate2::read::GzDecoder::new(&gz_bytes[..]);
                let mut buffer = Vec::new();
                if decoder.read_to_end(&mut buffer).is_ok() {
                    if let Ok(json_str) = String::from_utf8(buffer) {
                        match serde_json::from_str::<TlsRpt>(&json_str) {
                            Ok(tlsrpt) => {
                                let formatted_tlsrpt = FormattedTlsRpt {
                                    organization_name: tlsrpt.organization_name,
                                    date_range: FormattedTlsRptDateRange {
                                        start_datetime: tlsrpt.date_range.start_datetime,
                                        end_datetime: tlsrpt.date_range.end_datetime,
                                    },
                                    contact_info: tlsrpt
                                        .contact_info
                                        .unwrap_or_else(|| "".to_string()),
                                    report_id: tlsrpt.report_id,
                                    policies: tlsrpt
                                        .policies
                                        .into_iter()
                                        .map(|policy| FormattedTlsRptPolicy {
                                            policy: FormattedTlsRptPolicyDetails {
                                                policy_type: policy.policy.policy_type,
                                                policy_string: policy.policy.policy_string,
                                                policy_domain: policy.policy.policy_domain,
                                                mx_host: policy
                                                    .policy
                                                    .mx_host
                                                    .unwrap_or_else(|| Vec::new())
                                                    .into_iter()
                                                    .map(|mx| match mx {
                                                        MxHost::String(s) => {
                                                            FormattedTlsRptMxHost {
                                                                hostname: s,
                                                                failure_count: 0,
                                                                result_type: "".to_string(),
                                                            }
                                                        }
                                                        MxHost::Object(o) => {
                                                            FormattedTlsRptMxHost {
                                                                hostname: o.hostname,
                                                                failure_count: o.failure_count,
                                                                result_type: o.result_type,
                                                            }
                                                        }
                                                    })
                                                    .collect(),
                                            },
                                            summary: policy.summary,
                                            failure_details: policy
                                                .failure_details
                                                .unwrap_or_else(|| Vec::new())
                                                .into_iter()
                                                .map(|detail| FormattedTlsRptFailureDetails {
                                                    result_type: detail.result_type,
                                                    sending_mta_ip: detail
                                                        .sending_mta_ip
                                                        .unwrap_or_else(|| "".to_string()),
                                                    receiving_ip: detail
                                                        .receiving_ip
                                                        .unwrap_or_else(|| "".to_string()),
                                                    receiving_mx_hostname: detail
                                                        .receiving_mx_hostname
                                                        .unwrap_or_else(|| "".to_string()),
                                                    failed_session_count: detail
                                                        .failed_session_count,
                                                    additional_info: detail
                                                        .additional_info
                                                        .unwrap_or_else(|| "".to_string()),
                                                    failure_reason_code: detail
                                                        .failure_reason_code
                                                        .unwrap_or_else(|| "".to_string()),
                                                })
                                                .collect(),
                                        })
                                        .collect(),
                                };
                                let template = TlsReportTemplate {
                                    report: &formatted_tlsrpt,
                                };
                                let html = template.render().unwrap_or_else(|e| format!("<div class=\"tlsrpt-error\">Failed to render TLS report template: {}</div>", e));
                                parts.push(Body::html(html));
                            }
                            Err(e) => {
                                let html = format!(
                                    "<div class=\"tlsrpt-error\">Failed to parse TLS report JSON: {}</div>",
                                    e
                                );
                                parts.push(Body::html(html));
                            }
                        }
                    } else {
                        let html = format!("<div class=\"tlsrpt-error\">Failed to convert decompressed data to UTF-8.</div>");
                        parts.push(Body::html(html));
                    }
                } else {
                    let html =
                        format!("<div class=\"tlsrpt-error\">Failed to decompress data.</div>");
                    parts.push(Body::html(html));
                }
            }
            MESSAGE_RFC822 => {
                parts.push(extract_rfc822(&sp, part_addr)?);
            }
            TEXT_HTML => {
                let body = sp.get_body()?;
                parts.push(Body::html(body));
            }
            MESSAGE_DELIVERY_STATUS => {
                let body = extract_delivery_status(sp)?;
                parts.push(body);
            }
            TEXT_PLAIN => {
                let body = sp.get_body()?;
                parts.push(Body::text(body));
            }
            _ => {
                // For any other content type, try to extract the body using the general extract_body function
                match extract_body(sp, part_addr) {
                    Ok(body) => parts.push(body),
                    Err(_) => {
                        // If extraction fails, create an unhandled content type body
                        let msg = format!(
                            "Unhandled report subpart content type: {}\n{}",
                            sp.ctype.mimetype,
                            sp.get_body()
                                .unwrap_or_else(|_| "Failed to get body".to_string())
                        );
                        parts.push(Body::UnhandledContentType(UnhandledContentType {
                            text: msg,
                            content_tree: render_content_type_tree(sp),
                        }));
                    }
                }
            }
        }

        part_addr.pop();
    }

    if parts.is_empty() {
        return Ok(Body::html(
            "<div class=\"report-error\">No report content found</div>".to_string(),
        ));
    }

    // Add <hr> tags between subparts for better visual separation
    let html = parts
        .iter()
        .map(|p| match p {
            Body::PlainText(PlainText { text, .. }) => {
                format!(
                    r#"<p class="view-part-text-plain font-mono whitespace-pre-line">{}</p>"#,
                    linkify_html(&html_escape::encode_text(text).trim_matches('\n'))
                )
            }
            Body::Html(Html { html, .. }) => html.clone(),
            Body::UnhandledContentType(UnhandledContentType { text, .. }) => {
                format!(
                    r#"<p class="view-part-unhandled">{}</p>"#,
                    linkify_html(&html_escape::encode_text(text).trim_matches('\n'))
                )
            }
        })
        .collect::<Vec<_>>()
        .join("<hr>\n");

    Ok(Body::html(html))
}

pub fn extract_delivery_status(m: &ParsedMail) -> Result<Body, ServerError> {
    Ok(Body::text(m.get_body()?))
}

pub fn extract_unhandled(m: &ParsedMail) -> Result<Body, ServerError> {
    let msg = format!(
        "Unhandled body content type:\n{}\n{}",
        render_content_type_tree(m),
        m.get_body()?,
    );
    Ok(Body::UnhandledContentType(UnhandledContentType {
        text: msg,
        content_tree: render_content_type_tree(m),
    }))
}

pub fn is_dmarc_report_filename(name: &str) -> bool {
    let is = (name.ends_with(".xml.gz") || name.ends_with(".xml")) && name.contains('!');
    error!("info_span {}: {}", name, is);
    is
}

// multipart/alternative defines multiple representations of the same message, and clients should
// show the fanciest they can display. For this program, the priority is text/html, text/plain,
// then give up.
pub fn extract_alternative(
    m: &ParsedMail,
    part_addr: &mut Vec<String>,
) -> Result<Body, ServerError> {
    let handled_types = vec![
        MULTIPART_ALTERNATIVE,
        MULTIPART_MIXED,
        MULTIPART_RELATED,
        TEXT_CALENDAR,
        TEXT_HTML,
        TEXT_PLAIN,
    ];
    for sp in &m.subparts {
        if sp.ctype.mimetype.as_str() == MULTIPART_ALTERNATIVE {
            return extract_alternative(sp, part_addr);
        }
    }
    for sp in &m.subparts {
        if sp.ctype.mimetype.as_str() == MULTIPART_MIXED {
            return extract_mixed(sp, part_addr);
        }
    }
    for sp in &m.subparts {
        if sp.ctype.mimetype.as_str() == MULTIPART_RELATED {
            return extract_related(sp, part_addr);
        }
    }
    let mut ical_summary: Option<String> = None;
    // Try to find a text/calendar part as before
    for sp in &m.subparts {
        if sp.ctype.mimetype.as_str() == TEXT_CALENDAR {
            let body = sp.get_body()?;
            let summary = render_ical_summary(&body)?;
            ical_summary = Some(summary);
            break;
        }
    }
    // If not found, try to detect Google Calendar event and render summary from metadata
    if ical_summary.is_none() {
        let meta = extract_calendar_metadata_from_mail(m, &Body::text(String::new()));
        if meta.is_google_calendar_event {
            if let Some(rendered) = meta.body_html {
                ical_summary = Some(rendered);
            }
        }
    }
    for sp in &m.subparts {
        if sp.ctype.mimetype.as_str() == TEXT_HTML {
            let body = sp.get_body()?;
            if let Some(ref summary) = ical_summary {
                // Prepend summary to HTML body
                let combined = format!("{}<hr>{}", summary, body);
                return Ok(Body::html(combined));
            } else {
                return Ok(Body::html(body));
            }
        }
    }
    for sp in &m.subparts {
        if sp.ctype.mimetype.as_str() == TEXT_PLAIN {
            let body = sp.get_body()?;
            if let Some(ref summary) = ical_summary {
                // Prepend summary to plain text body (strip HTML tags)
                let summary_text = html2text::from_read(summary.as_bytes(), 80)?;
                let combined = format!("{}\n\n{}", summary_text.trim(), body);
                return Ok(Body::text(combined));
            } else {
                return Ok(Body::text(body));
            }
        }
    }
    if let Some(summary) = ical_summary {
        return Ok(Body::html(summary));
    }
    Err(ServerError::StringError(format!(
        "extract_alternative failed to find suitable subpart, searched: {:?}",
        handled_types
    )))
}

// multipart/mixed defines multiple types of context all of which should be presented to the user
// 'serially'.
pub fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
    //todo!("add some sort of visual indicator there are unhandled types, i.e. .ics files");
    let handled_types = vec![
        IMAGE_JPEG,
        IMAGE_PJPEG,
        IMAGE_PNG,
        MESSAGE_RFC822,
        MULTIPART_ALTERNATIVE,
        MULTIPART_RELATED,
        TEXT_HTML,
        TEXT_PLAIN,
        APPLICATION_GZIP,
    ];
    let mut unhandled_types: Vec<_> = m
        .subparts
        .iter()
        .map(|sp| sp.ctype.mimetype.as_str())
        .filter(|mt| !handled_types.contains(mt) && !mt.starts_with("application/"))
        .collect();
    unhandled_types.sort();
    if !unhandled_types.is_empty() {
        warn!(
            "{} contains the following unhandled mimetypes {:?}",
            MULTIPART_MIXED, unhandled_types
        );
    }
    let mut parts = Vec::new();
    for (idx, sp) in m.subparts.iter().enumerate() {
        part_addr.push(idx.to_string());
        match sp.ctype.mimetype.as_str() {
            MESSAGE_RFC822 => parts.push(extract_rfc822(&sp, part_addr)?),
            MULTIPART_RELATED => parts.push(extract_related(sp, part_addr)?),
            MULTIPART_ALTERNATIVE => parts.push(extract_alternative(sp, part_addr)?),
            TEXT_PLAIN => parts.push(Body::text(sp.get_body()?)),
            TEXT_HTML => parts.push(Body::html(sp.get_body()?)),
            IMAGE_PJPEG | IMAGE_JPEG | IMAGE_PNG => {
                let pcd = sp.get_content_disposition();
                let filename = pcd
                    .params
                    .get("filename")
                    .map(|s| s.clone())
                    .unwrap_or("".to_string());
                // Only add inline images, attachments are handled as an attribute of the top level Message and rendered separate client-side.
                if pcd.disposition == mailparse::DispositionType::Inline {
                    // TODO: make URL generation more programatic based on what the frontend has
                    // mapped
                    parts.push(Body::html(format!(
                        r#"<img src="/api/view/attachment/{}/{}/{}">"#,
                        part_addr[0],
                        part_addr
                            .iter()
                            .skip(1)
                            .map(|i| i.to_string())
                            .collect::<Vec<_>>()
                            .join("."),
                        filename
                    )));
                }
            }
            APPLICATION_GZIP => {
                let (html_body, raw_xml) = extract_gzip(sp)?;
                parts.push(html_body);
                if let Some(xml) = raw_xml {
                    let pretty_printed_content = if sp.ctype.mimetype.as_str() == MULTIPART_REPORT {
                        // This case is for TLS reports, not DMARC.
                        // For DMARC, it's always XML.
                        // Pretty print JSON (if it were TLS)
                        if let Ok(parsed_json) = serde_json::from_str::<serde_json::Value>(&xml) {
                            serde_json::to_string_pretty(&parsed_json).unwrap_or(xml.to_string())
                        } else {
                            xml.to_string()
                        }
                    } else {
                        // DMARC reports are XML
                        // Pretty print XML
                        match pretty_print_xml_with_trimming(&xml) {
                            Ok(pretty_xml) => pretty_xml,
                            Err(e) => {
                                error!("Failed to pretty print XML: {:?}", e);
                                xml.to_string()
                            }
                        }
                    };
                    parts.push(Body::html(format!(
                        "\n<pre>{}</pre>",
                        html_escape::encode_text(&pretty_printed_content)
                    )));
                }
            }
            mt => {
                if !mt.starts_with("application/") {
                    parts.push(unhandled_html(MULTIPART_MIXED, mt))
                }
            }
        }
        part_addr.pop();
    }
    Ok(flatten_body_parts(&parts))
}

pub fn unhandled_html(parent_type: &str, child_type: &str) -> Body {
    Body::Html(Html {
        html: format!(
            r#"
<div class="p-4 error">
Unhandled mimetype {} in a {} message
</div>
    "#,
            child_type, parent_type
        ),
        content_tree: String::new(),
    })
}
pub fn flatten_body_parts(parts: &[Body]) -> Body {
    let html = parts
        .iter()
        .map(|p| match p {
            Body::PlainText(PlainText { text, .. }) => {
                format!(
                    r#"<p class="view-part-text-plain font-mono whitespace-pre-line">{}</p>"#,
                    // Trim newlines to prevent excessive white space at the beginning/end of
                    // presenation. Leave tabs and spaces incase plain text attempts to center a
                    // header on the first line.
                    linkify_html(&html_escape::encode_text(text).trim_matches('\n'))
                )
            }
            Body::Html(Html { html, .. }) => html.clone(),
            Body::UnhandledContentType(UnhandledContentType { text, .. }) => {
                error!("text len {}", text.len());
                format!(
                    r#"<p class="view-part-unhandled">{}</p>"#,
                    // Trim newlines to prevent excessive white space at the beginning/end of
                    // presenation. Leave tabs and spaces incase plain text attempts to center a
                    // header on the first line.
                    linkify_html(&html_escape::encode_text(text).trim_matches('\n'))
                )
            }
        })
        .collect::<Vec<_>>()
        .join("\n");

    info!("flatten_body_parts {}", parts.len());
    Body::html(html)
}

pub fn extract_related(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
    // TODO(wathiede): collect related things and change return type to new Body arm.
    let handled_types = vec![
        MULTIPART_ALTERNATIVE,
        TEXT_HTML,
        TEXT_PLAIN,
        IMAGE_JPEG,
        IMAGE_PJPEG,
        IMAGE_PNG,
    ];
    let mut unhandled_types: Vec<_> = m
        .subparts
        .iter()
        .map(|sp| sp.ctype.mimetype.as_str())
        .filter(|mt| !handled_types.contains(mt) && !mt.starts_with("application/"))
        .collect();
    unhandled_types.sort();
    if !unhandled_types.is_empty() {
        warn!(
            "{} contains the following unhandled mimetypes {:?}",
            MULTIPART_RELATED, unhandled_types
        );
    }

    for (i, sp) in m.subparts.iter().enumerate() {
        if sp.ctype.mimetype == IMAGE_PNG
            || sp.ctype.mimetype == IMAGE_JPEG
            || sp.ctype.mimetype == IMAGE_PJPEG
        {
            info!("sp.ctype {:#?}", sp.ctype);
            //info!("sp.headers {:#?}", sp.headers);
            if let Some(cid) = sp.headers.get_first_value("Content-Id") {
                let mut part_id = part_addr.clone();
                part_id.push(i.to_string());
                info!("cid: {} part_id {:?}", cid, part_id);
            }
        }
    }
    for sp in &m.subparts {
        if sp.ctype.mimetype == MULTIPART_ALTERNATIVE {
            return extract_alternative(m, part_addr);
        }
    }
    for sp in &m.subparts {
        if sp.ctype.mimetype == TEXT_HTML {
            let body = sp.get_body()?;
            return Ok(Body::html(body));
        }
    }
    for sp in &m.subparts {
        if sp.ctype.mimetype == TEXT_PLAIN {
            let body = sp.get_body()?;
            return Ok(Body::text(body));
        }
    }
    Err(ServerError::StringError(format!(
        "extract_related failed to find suitable subpart, searched: {:?}",
        handled_types
    )))
}

pub fn walk_attachments<T, F: Fn(&ParsedMail, &[usize]) -> Option<T> + Copy>(
    m: &ParsedMail,
    visitor: F,
) -> Option<T> {
    let mut cur_addr = Vec::new();
    walk_attachments_inner(m, visitor, &mut cur_addr)
}

pub fn walk_attachments_inner<T, F: Fn(&ParsedMail, &[usize]) -> Option<T> + Copy>(
    m: &ParsedMail,
    visitor: F,
    cur_addr: &mut Vec<usize>,
) -> Option<T> {
    for (idx, sp) in m.subparts.iter().enumerate() {
        cur_addr.push(idx);
        let val = visitor(sp, &cur_addr);
        if val.is_some() {
            return val;
        }
        let val = walk_attachments_inner(sp, visitor, cur_addr);
        if val.is_some() {
            return val;
        }
        cur_addr.pop();
    }
    None
}

// TODO(wathiede): make this walk_attachments that takes a closure.
// Then implement one closure for building `Attachment` and imlement another that can be used to
// get the bytes for serving attachments of HTTP
pub fn extract_attachments(m: &ParsedMail, id: &str) -> Result<Vec<Attachment>, ServerError> {
    let mut attachments = Vec::new();

    if m.ctype.mimetype.starts_with("application/") {
        if let Some(attachment) = extract_attachment(m, id, &[]) {
            attachments.push(attachment);
        }
    }

    for (idx, sp) in m.subparts.iter().enumerate() {
        if let Some(attachment) = extract_attachment(sp, id, &[idx]) {
            // Filter out inline attachements, they're flattened into the body of the message.
            if attachment.disposition == DispositionType::Attachment {
                attachments.push(attachment);
            }
        }
    }
    Ok(attachments)
}

pub fn extract_attachment(m: &ParsedMail, id: &str, idx: &[usize]) -> Option<Attachment> {
    let pcd = m.get_content_disposition();
    let pct = m
        .get_headers()
        .get_first_value("Content-Type")
        .map(|s| parse_content_type(&s));
    let filename = match (
        pcd.params.get("filename").map(|f| f.clone()),
        pct.map(|pct| pct.params.get("name").map(|f| f.clone())),
    ) {
        // Use filename from Content-Disposition
        (Some(filename), _) => Some(filename),
        // Use filename from Content-Type
        (_, Some(Some(name))) => Some(name),
        // No known filename
        _ => None,
    };

    let filename = if let Some(fname) = filename {
        fname
    } else {
        if m.ctype.mimetype.starts_with("application/") {
            // Generate a default filename
            format!(
                "attachment-{}",
                idx.iter()
                    .map(|i| i.to_string())
                    .collect::<Vec<_>>()
                    .join(".")
            )
        } else {
            return None;
        }
    };

    info!("filename {}", filename);

    // TODO: grab this from somewhere
    let content_id = None;
    let bytes = match m.get_body_raw() {
        Ok(bytes) => bytes,
        Err(err) => {
            error!("failed to get body for attachment: {}", err);
            return None;
        }
    };
    return Some(Attachment {
        id: id.to_string(),
        idx: idx
            .iter()
            .map(|i| i.to_string())
            .collect::<Vec<String>>()
            .join("."),
        disposition: pcd.disposition.into(),
        filename: Some(filename),
        size: bytes.len(),
        // TODO: what is the default for ctype?
        // TODO: do we want to use m.ctype.params for anything?
        content_type: Some(m.ctype.mimetype.clone()),
        content_id,
        bytes,
    });
}
pub fn email_address_strings(emails: &[Email]) -> Vec<String> {
    emails
        .iter()
        .map(|e| e.to_string())
        .inspect(|e| info!("e {}", e))
        .collect()
}

pub fn extract_rfc822(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, ServerError> {
    fn extract_headers(m: &ParsedMail) -> Result<Body, ServerError> {
        let path = "<in-memory>";
        let from = email_address_strings(&email_addresses(path, &m, "from")?).join(", ");
        let to = email_address_strings(&email_addresses(path, &m, "to")?).join(", ");
        let cc = email_address_strings(&email_addresses(path, &m, "cc")?).join(", ");
        let date = m.headers.get_first_value("date").unwrap_or(String::new());
        let subject = m
            .headers
            .get_first_value("subject")
            .unwrap_or(String::new());
        let text = format!(
            r#"
---------- Forwarded message ----------
From: {}
To: {}
CC: {}
Date: {}
Subject: {}
"#,
            from, to, cc, date, subject
        );
        Ok(Body::text(text))
    }
    let inner_body = m.get_body()?;
    let inner_m = parse_mail(inner_body.as_bytes())?;
    let headers = extract_headers(&inner_m)?;
    let body = extract_body(&inner_m, part_addr)?;

    Ok(flatten_body_parts(&[headers, body]))
}

pub fn get_attachment_filename(header_value: &str) -> &str {
    info!("get_attachment_filename {}", header_value);
    // Strip last "
    let v = &header_value[..header_value.len() - 1];
    if let Some(idx) = v.rfind('"') {
        &v[idx + 1..]
    } else {
        ""
    }
}

pub fn get_content_type<'a>(headers: &[MailHeader<'a>]) -> Option<String> {
    if let Some(v) = headers.get_first_value("Content-Type") {
        if let Some(idx) = v.find(';') {
            return Some(v[..idx].to_string());
        } else {
            return Some(v);
        }
    }
    None
}

pub fn get_content_id<'a>(headers: &[MailHeader<'a>]) -> Option<String> {
    headers.get_first_value("Content-Id")
}

pub fn render_content_type_tree(m: &ParsedMail) -> String {
    const WIDTH: usize = 4;
    const SKIP_HEADERS: [&str; 4] = [
        "Authentication-Results",
        "DKIM-Signature",
        "Received",
        "Received-SPF",
    ];
    fn render_ct_rec(m: &ParsedMail, depth: usize) -> String {
        let mut parts = Vec::new();
        let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype);
        parts.push(msg);
        for sp in &m.subparts {
            parts.push(render_ct_rec(sp, depth + 1))
        }
        parts.join("\n")
    }
    fn render_rec(m: &ParsedMail, depth: usize) -> String {
        let mut parts = Vec::new();
        let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype);
        parts.push(msg);
        let indent = " ".repeat(depth * WIDTH);
        if !m.ctype.charset.is_empty() {
            parts.push(format!("{} Character Set: {}", indent, m.ctype.charset));
        }
        for (k, v) in m.ctype.params.iter() {
            parts.push(format!("{} {}: {}", indent, k, v));
        }
        if !m.headers.is_empty() {
            parts.push(format!("{} == headers ==", indent));
            for h in &m.headers {
                if h.get_key().starts_with('X') {
                    continue;
                }
                if SKIP_HEADERS.contains(&h.get_key().as_str()) {
                    continue;
                }

                parts.push(format!("{} {}: {}", indent, h.get_key_ref(), h.get_value()));
            }
        }
        for sp in &m.subparts {
            parts.push(render_rec(sp, depth + 1))
        }
        parts.join("\n")
    }
    format!(
        "Outline:\n{}\n\nDetailed:\n{}\n\nNot showing headers:\n  {}\n  X.*",
        render_ct_rec(m, 1),
        render_rec(m, 1),
        SKIP_HEADERS.join("\n  ")
    )
}

// Add this helper function to parse the DMARC XML and summarize it.
#[derive(Debug, serde::Deserialize)]
pub struct FormattedDateRange {
    pub begin: String,
    pub end: String,
}

pub struct FormattedReportMetadata {
    pub org_name: String,
    pub email: String,
    pub report_id: String,
    pub date_range: Option<FormattedDateRange>,
}

pub struct FormattedRecord {
    pub source_ip: String,
    pub count: String,
    pub header_from: String,
    pub envelope_to: String,
    pub disposition: String,
    pub dkim: String,
    pub spf: String,
    pub reason: Vec<String>,
    pub auth_results: Option<FormattedAuthResults>,
}

pub struct FormattedAuthResults {
    pub dkim: Vec<FormattedAuthDKIM>,
    pub spf: Vec<FormattedAuthSPF>,
}

pub struct FormattedAuthDKIM {
    pub domain: String,
    pub result: String,
    pub selector: String,
}

pub struct FormattedAuthSPF {
    pub domain: String,
    pub result: String,
    pub scope: String,
}

pub struct FormattedPolicyPublished {
    pub domain: String,
    pub adkim: String,
    pub aspf: String,
    pub p: String,
    pub sp: String,
    pub pct: String,
}

pub struct FormattedFeedback {
    pub report_metadata: Option<FormattedReportMetadata>,
    pub policy_published: Option<FormattedPolicyPublished>,
    pub record: Option<Vec<FormattedRecord>>,
    pub has_envelope_to: bool,
}

#[derive(Debug, serde::Deserialize)]
pub struct Feedback {
    pub report_metadata: Option<ReportMetadata>,
    pub policy_published: Option<PolicyPublished>,
    pub record: Option<Vec<Record>>,
}
#[derive(Debug, serde::Deserialize)]
pub struct ReportMetadata {
    pub org_name: Option<String>,
    pub email: Option<String>,
    pub report_id: Option<String>,
    pub date_range: Option<DateRange>,
}
#[derive(Debug, serde::Deserialize)]
pub struct DateRange {
    pub begin: Option<u64>,
    pub end: Option<u64>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PolicyPublished {
    pub domain: Option<String>,
    pub adkim: Option<String>,
    pub aspf: Option<String>,
    pub p: Option<String>,
    pub sp: Option<String>,
    pub pct: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct Record {
    pub row: Option<Row>,
    pub identifiers: Option<Identifiers>,
    pub auth_results: Option<AuthResults>,
}
#[derive(Debug, serde::Deserialize)]
pub struct Row {
    pub source_ip: Option<String>,
    pub count: Option<u64>,
    pub policy_evaluated: Option<PolicyEvaluated>,
}
#[derive(Debug, serde::Deserialize)]
pub struct PolicyEvaluated {
    pub disposition: Option<String>,
    pub dkim: Option<String>,
    pub spf: Option<String>,
    pub reason: Option<Vec<Reason>>,
}
#[derive(Debug, serde::Deserialize, Clone)]
pub struct Reason {
    #[serde(rename = "type")]
    pub reason_type: Option<String>,
    pub comment: Option<String>,
}

#[derive(Debug, serde::Deserialize)]
pub struct TlsRpt {
    #[serde(rename = "organization-name")]
    pub organization_name: String,
    #[serde(rename = "date-range")]
    pub date_range: TlsRptDateRange,
    #[serde(rename = "contact-info")]
    pub contact_info: Option<String>,
    #[serde(rename = "report-id")]
    pub report_id: String,
    pub policies: Vec<TlsRptPolicy>,
}

#[derive(Debug, serde::Deserialize)]
pub struct TlsRptDateRange {
    #[serde(rename = "start-datetime")]
    pub start_datetime: String,
    #[serde(rename = "end-datetime")]
    pub end_datetime: String,
}

#[derive(Debug, serde::Deserialize)]
pub struct TlsRptPolicy {
    pub policy: TlsRptPolicyDetails,
    pub summary: TlsRptSummary,
    #[serde(rename = "failure-details")]
    pub failure_details: Option<Vec<TlsRptFailureDetails>>,
}

#[derive(Debug, serde::Deserialize)]
pub struct TlsRptPolicyDetails {
    #[serde(rename = "policy-type")]
    pub policy_type: String,
    #[serde(rename = "policy-string")]
    pub policy_string: Vec<String>,
    #[serde(rename = "policy-domain")]
    pub policy_domain: String,
    #[serde(rename = "mx-host")]
    pub mx_host: Option<Vec<MxHost>>,
}

#[derive(Debug, serde::Deserialize)]
pub struct TlsRptSummary {
    #[serde(rename = "total-successful-session-count")]
    pub total_successful_session_count: u64,
    #[serde(rename = "total-failure-session-count")]
    pub total_failure_session_count: u64,
}

#[derive(Debug, serde::Deserialize)]
#[serde(untagged)]
pub enum MxHost {
    String(String),
    Object(TlsRptMxHost),
}

#[derive(Debug, serde::Deserialize)]
pub struct TlsRptMxHost {
    pub hostname: String,
    #[serde(rename = "failure-count")]
    pub failure_count: u64,
    #[serde(rename = "result-type")]
    pub result_type: String,
}

#[derive(Debug, serde::Deserialize)]
pub struct TlsRptFailureDetails {
    #[serde(rename = "result-type")]
    pub result_type: String,
    #[serde(rename = "sending-mta-ip")]
    pub sending_mta_ip: Option<String>,
    #[serde(rename = "receiving-ip")]
    pub receiving_ip: Option<String>,
    #[serde(rename = "receiving-mx-hostname")]
    pub receiving_mx_hostname: Option<String>,
    #[serde(rename = "failed-session-count")]
    pub failed_session_count: u64,
    #[serde(rename = "additional-info")]
    pub additional_info: Option<String>,
    #[serde(rename = "failure-reason-code")]
    pub failure_reason_code: Option<String>,
}

#[derive(Debug)]
pub struct FormattedTlsRpt {
    pub organization_name: String,
    pub date_range: FormattedTlsRptDateRange,
    pub contact_info: String,
    pub report_id: String,
    pub policies: Vec<FormattedTlsRptPolicy>,
}

#[derive(Debug)]
pub struct FormattedTlsRptDateRange {
    pub start_datetime: String,
    pub end_datetime: String,
}

#[derive(Debug)]
pub struct FormattedTlsRptPolicy {
    pub policy: FormattedTlsRptPolicyDetails,
    pub summary: TlsRptSummary,
    pub failure_details: Vec<FormattedTlsRptFailureDetails>,
}

#[derive(Debug)]
pub struct FormattedTlsRptPolicyDetails {
    pub policy_type: String,
    pub policy_string: Vec<String>,
    pub policy_domain: String,
    pub mx_host: Vec<FormattedTlsRptMxHost>,
}

#[derive(Debug)]
pub struct FormattedTlsRptMxHost {
    pub hostname: String,
    pub failure_count: u64,
    pub result_type: String,
}

#[derive(Debug)]
pub struct FormattedTlsRptFailureDetails {
    pub result_type: String,
    pub sending_mta_ip: String,
    pub receiving_ip: String,
    pub receiving_mx_hostname: String,
    pub failed_session_count: u64,
    pub additional_info: String,
    pub failure_reason_code: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct Identifiers {
    pub header_from: Option<String>,
    pub envelope_to: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct AuthResults {
    pub dkim: Option<Vec<AuthDKIM>>,
    pub spf: Option<Vec<AuthSPF>>,
}
#[derive(Debug, serde::Deserialize)]
pub struct AuthDKIM {
    pub domain: Option<String>,
    pub result: Option<String>,
    pub selector: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
pub struct AuthSPF {
    pub domain: Option<String>,
    pub result: Option<String>,
    pub scope: Option<String>,
}

#[derive(Template)]
#[template(path = "dmarc_report.html")]
pub struct DmarcReportTemplate<'a> {
    pub report: &'a FormattedFeedback,
}

#[derive(Template)]
#[template(path = "tls_report.html")]
pub struct TlsReportTemplate<'a> {
    pub report: &'a FormattedTlsRpt,
}

#[derive(Template)]
#[template(path = "ical_summary.html")]
pub struct IcalSummaryTemplate<'a> {
    pub summary: &'a str,
    pub local_fmt_start: &'a str,
    pub local_fmt_end: &'a str,
    pub organizer: &'a str,
    pub organizer_cn: &'a str,
    pub all_days: Vec<chrono::NaiveDate>,
    pub event_days: Vec<chrono::NaiveDate>,
    pub caption: String,
    pub description_paragraphs: &'a [String],
    pub today: Option<chrono::NaiveDate>,
    pub recurrence_display: String,
}

// Add this helper function to parse the DMARC XML and summarize it.
pub fn parse_dmarc_report(xml: &str) -> Result<String, ServerError> {
    let feedback: Feedback = xml_from_str(xml)
        .map_err(|e| ServerError::StringError(format!("DMARC XML parse error: {}", e)))?;

    let formatted_report_metadata = feedback.report_metadata.map(|meta| {
        let date_range = meta.date_range.map(|dr| FormattedDateRange {
            begin: match Utc.timestamp_opt(dr.begin.unwrap_or(0) as i64, 0) {
                chrono::LocalResult::Single(d) => Some(d),
                _ => None,
            }
            .map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string())
            .unwrap_or_else(|| "".to_string()),
            end: match Utc.timestamp_opt(dr.end.unwrap_or(0) as i64, 0) {
                chrono::LocalResult::Single(d) => Some(d),
                _ => None,
            }
            .map(|d| d.format("%Y-%m-%d %H:%M:%S").to_string())
            .unwrap_or_else(|| "".to_string()),
        });
        FormattedReportMetadata {
            org_name: meta.org_name.unwrap_or_else(|| "".to_string()),
            email: meta.email.unwrap_or_else(|| "".to_string()),
            report_id: meta.report_id.unwrap_or_else(|| "".to_string()),
            date_range,
        }
    });

    let formatted_record = feedback.record.map(|records| {
        records
            .into_iter()
            .map(|rec| {
                let auth_results = rec.auth_results.map(|auth| {
                    let dkim = auth
                        .dkim
                        .map(|dkims| {
                            dkims
                                .into_iter()
                                .map(|d| FormattedAuthDKIM {
                                    domain: d.domain.unwrap_or_else(|| "".to_string()),
                                    result: d.result.unwrap_or_else(|| "".to_string()),
                                    selector: d.selector.unwrap_or_else(|| "".to_string()),
                                })
                                .collect()
                        })
                        .unwrap_or_else(|| Vec::new());

                    let spf = auth
                        .spf
                        .map(|spfs| {
                            spfs.into_iter()
                                .map(|s| FormattedAuthSPF {
                                    domain: s.domain.unwrap_or_else(|| "".to_string()),
                                    result: s.result.unwrap_or_else(|| "".to_string()),
                                    scope: s.scope.unwrap_or_else(|| "".to_string()),
                                })
                                .collect()
                        })
                        .unwrap_or_else(|| Vec::new());

                    FormattedAuthResults { dkim, spf }
                });

                FormattedRecord {
                    source_ip: rec
                        .row
                        .as_ref()
                        .and_then(|r| r.source_ip.clone())
                        .unwrap_or_else(|| "".to_string()),
                    count: rec
                        .row
                        .as_ref()
                        .and_then(|r| r.count.map(|c| c.to_string()))
                        .unwrap_or_else(|| "".to_string()),
                    header_from: rec
                        .identifiers
                        .as_ref()
                        .and_then(|i| i.header_from.clone())
                        .unwrap_or_else(|| "".to_string()),
                    envelope_to: rec
                        .identifiers
                        .as_ref()
                        .and_then(|i| i.envelope_to.clone())
                        .unwrap_or_else(|| "".to_string()),
                    disposition: rec
                        .row
                        .as_ref()
                        .and_then(|r| r.policy_evaluated.as_ref())
                        .and_then(|p| p.disposition.clone())
                        .unwrap_or_else(|| "".to_string()),
                    dkim: rec
                        .row
                        .as_ref()
                        .and_then(|r| r.policy_evaluated.as_ref())
                        .and_then(|p| p.dkim.clone())
                        .unwrap_or_else(|| "".to_string()),
                    spf: rec
                        .row
                        .as_ref()
                        .and_then(|r| r.policy_evaluated.as_ref())
                        .and_then(|p| p.spf.clone())
                        .unwrap_or_else(|| "".to_string()),
                    reason: rec
                        .row
                        .as_ref()
                        .and_then(|r| r.policy_evaluated.as_ref())
                        .and_then(|p| p.reason.clone())
                        .unwrap_or_else(|| Vec::new())
                        .into_iter()
                        .map(|r| {
                            let mut s = String::new();
                            if let Some(reason_type) = r.reason_type {
                                s.push_str(&format!("Type: {}", reason_type));
                            }
                            if let Some(comment) = r.comment {
                                if !s.is_empty() {
                                    s.push_str(", ");
                                }
                                s.push_str(&format!("Comment: {}", comment));
                            }
                            s
                        })
                        .collect(),
                    auth_results,
                }
            })
            .collect()
    });

    let formatted_policy_published =
        feedback
            .policy_published
            .map(|pol| FormattedPolicyPublished {
                domain: pol.domain.unwrap_or_else(|| "".to_string()),
                adkim: pol.adkim.unwrap_or_else(|| "".to_string()),
                aspf: pol.aspf.unwrap_or_else(|| "".to_string()),
                p: pol.p.unwrap_or_else(|| "".to_string()),
                sp: pol.sp.unwrap_or_else(|| "".to_string()),
                pct: pol.pct.unwrap_or_else(|| "".to_string()),
            });

    let has_envelope_to = formatted_record
        .as_ref()
        .map_or(false, |r: &Vec<FormattedRecord>| {
            r.iter().any(|rec| !rec.envelope_to.is_empty())
        });

    let formatted_feedback = FormattedFeedback {
        report_metadata: formatted_report_metadata,
        policy_published: formatted_policy_published,
        record: formatted_record,
        has_envelope_to,
    };

    let template = DmarcReportTemplate {
        report: &formatted_feedback,
    };
    let html = template.render()?;
    Ok(html)
}

pub fn pretty_print_xml_with_trimming(xml_input: &str) -> Result<String, ServerError> {
    use std::io::Cursor;

    use quick_xml::{
        events::{BytesText, Event},
        reader::Reader,
        writer::Writer,
    };

    let mut reader = Reader::from_str(xml_input);
    reader.config_mut().trim_text(true);

    let mut writer = Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 4);

    let mut buf = Vec::new();
    loop {
        match reader.read_event_into(&mut buf) {
            Ok(Event::Eof) => break,
            Ok(Event::Text(e)) => {
                let trimmed_text = e.decode()?.trim().to_string();
                writer.write_event(Event::Text(BytesText::new(&trimmed_text)))?;
            }
            Ok(event) => {
                writer.write_event(event)?;
            }
            Err(e) => {
                return Err(ServerError::StringError(format!(
                    "XML parsing error: {}",
                    e
                )))
            }
        }
        buf.clear();
    }

    let result = writer.into_inner().into_inner();
    Ok(String::from_utf8(result)?)
}

use ical::IcalParser;

pub fn render_ical_summary(ical_data: &str) -> Result<String, ServerError> {
    let mut summary_parts = Vec::new();
    let mut parser = IcalParser::new(ical_data.as_bytes());

    use std::collections::HashSet;

    use chrono::{Datelike, NaiveDate};
    while let Some(Ok(calendar)) = parser.next() {
        for event in calendar.events {
            let mut summary = None;
            let mut description = None;
            let mut dtstart = None;
            let mut dtend = None;
            let mut organizer = None;
            let mut organizer_cn = None;
            let mut tzid: Option<String> = None;
            let mut rrule: Option<String> = None;
            for prop in &event.properties {
                match prop.name.as_str() {
                    "SUMMARY" => summary = prop.value.as_deref(),
                    "DESCRIPTION" => description = prop.value.as_deref(),
                    "DTSTART" => {
                        dtstart = prop.value.as_deref();
                        if let Some(params) = &prop.params {
                            if let Some((_, values)) = params.iter().find(|(k, _)| k == "TZID") {
                                if let Some(val) = values.get(0) {
                                    tzid = Some(val.clone());
                                }
                            }
                        }
                    }
                    "DTEND" => dtend = prop.value.as_deref(),
                    "ORGANIZER" => {
                        organizer = prop.value.as_deref();
                        if let Some(params) = &prop.params {
                            if let Some((_, values)) = params.iter().find(|(k, _)| k == "CN") {
                                if let Some(cn) = values.get(0) {
                                    organizer_cn = Some(cn.as_str());
                                }
                            }
                        }
                    }
                    "RRULE" => rrule = prop.value.clone(),
                    _ => {}
                }
            }

            // Always use America/Los_Angeles for Google Calendar events if no TZID is present
            let event_tz: Tz = tzid
                .as_deref()
                .unwrap_or("America/Los_Angeles")
                .parse()
                .unwrap_or(chrono_tz::America::Los_Angeles);

            // Parse start/end as chrono DateTime
            let (local_fmt_start, local_fmt_end, event_days, recurrence_display) =
                if let Some(dtstart) = dtstart {
                    let fallback = chrono::DateTime::<chrono::Utc>::from_timestamp(0, 0)
                        .map(|dt| dt.with_timezone(&event_tz))
                        .unwrap_or_else(|| {
                            event_tz
                                .with_ymd_and_hms(1970, 1, 1, 0, 0, 0)
                                .single()
                                .unwrap_or_else(|| event_tz.timestamp_opt(0, 0).single().unwrap())
                        });
                    let start = parse_ical_datetime_tz(dtstart, event_tz).unwrap_or(fallback);
                    let end = dtend
                        .and_then(|d| parse_ical_datetime_tz(d, event_tz))
                        .unwrap_or(start);
                    // Use the event's TZ for all calendar grid/highlighting logic
                    let allday =
                        dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false));
                    let fmt_start = if allday {
                        start.format("%a %b %e, %Y").to_string()
                    } else {
                        start.format("%-I:%M %p %a %b %e, %Y").to_string()
                    };
                    let fmt_end = if allday {
                        end.format("%a %b %e, %Y").to_string()
                    } else {
                        end.format("%-I:%M %p %a %b %e, %Y").to_string()
                    };
                    // All calendar grid and event_days logic below uses start/end in event's TZ

                    // Recurrence support: parse RRULE and generate event_days accordingly
                    let mut days = vec![];
                    let mut recurrence_display = String::new();
                    if let Some(rrule_str) = rrule {
                        // Very basic RRULE parser for FREQ, INTERVAL, BYDAY, UNTIL, COUNT
                        let mut freq = "DAILY";
                        let mut interval = 1;
                        let mut byday: Option<Vec<String>> = None;
                        let mut until: Option<NaiveDate> = None;
                        let mut count: Option<usize> = None;
                        for part in rrule_str.split(';') {
                            let mut kv = part.splitn(2, '=');
                            let k = kv.next().unwrap_or("");
                            let v = kv.next().unwrap_or("");
                            match k {
                                "FREQ" => freq = v,
                                "INTERVAL" => interval = v.parse().unwrap_or(1),
                                "BYDAY" => {
                                    byday = Some(v.split(',').map(|s| s.to_string()).collect())
                                }
                                "UNTIL" => {
                                    if v.len() >= 8 {
                                        until =
                                            chrono::NaiveDate::parse_from_str(&v[0..8], "%Y%m%d")
                                                .ok();
                                    }
                                }
                                "COUNT" => count = v.parse().ok(),
                                _ => {}
                            }
                        }
                        // Human-readable recurrence string
                        recurrence_display = match freq {
                            "DAILY" => format!(
                                "Every {} day{}",
                                interval,
                                if interval > 1 { "s" } else { "" }
                            ),
                            "WEEKLY" => {
                                let days = byday.as_ref().map(|v| v.join(", ")).unwrap_or_default();
                                if !days.is_empty() {
                                    format!(
                                        "Every {} week{} on {}",
                                        interval,
                                        if interval > 1 { "s" } else { "" },
                                        days
                                    )
                                } else {
                                    format!(
                                        "Every {} week{}",
                                        interval,
                                        if interval > 1 { "s" } else { "" }
                                    )
                                }
                            }
                            "MONTHLY" => format!(
                                "Every {} month{}",
                                interval,
                                if interval > 1 { "s" } else { "" }
                            ),
                            "YEARLY" => format!(
                                "Every {} year{}",
                                interval,
                                if interval > 1 { "s" } else { "" }
                            ),
                            _ => format!("Repeats: {}", freq),
                        };

                        // Generate event days for the recurrence
                        let cur = start.date_naive();
                        let max_span = 366; // safety: don't generate more than a year
                        let mut weekday_set = HashSet::new();
                        if let Some(ref byday_vec) = byday {
                            for wd in byday_vec {
                                let wd = wd.trim();
                                let chrono_wd = match wd {
                                    "MO" => Some(chrono::Weekday::Mon),
                                    "TU" => Some(chrono::Weekday::Tue),
                                    "WE" => Some(chrono::Weekday::Wed),
                                    "TH" => Some(chrono::Weekday::Thu),
                                    "FR" => Some(chrono::Weekday::Fri),
                                    "SA" => Some(chrono::Weekday::Sat),
                                    "SU" => Some(chrono::Weekday::Sun),
                                    _ => None,
                                };
                                if let Some(wd) = chrono_wd {
                                    weekday_set.insert(wd);
                                }
                            }
                        }
                        if freq == "WEEKLY" {
                            // For weekly, only add days that match BYDAY and are in the correct interval week
                            let mut cur_date = cur;
                            let mut added = 0;
                            let until_date =
                                until.unwrap_or(cur_date + chrono::Duration::days(max_span as i64));
                            // Find the first week start (the week containing the DTSTART)
                            let week_start = cur_date
                                - chrono::Duration::days(
                                    cur_date.weekday().num_days_from_monday() as i64
                                );
                            while cur_date <= until_date && added < count.unwrap_or(max_span) {
                                let weeks_since_start =
                                    ((cur_date - week_start).num_days() / 7) as usize;
                                if weeks_since_start % interval == 0
                                    && weekday_set.contains(&cur_date.weekday())
                                {
                                    days.push(cur_date);
                                    added += 1;
                                }
                                cur_date = cur_date.succ_opt().unwrap();
                            }
                        } else {
                            let mut cur_date = cur;
                            let mut added = 0;
                            while added < count.unwrap_or(max_span) {
                                if let Some(until_date) = until {
                                    if cur_date > until_date {
                                        break;
                                    }
                                }
                                match freq {
                                    "DAILY" => {
                                        days.push(cur_date);
                                        cur_date = cur_date.succ_opt().unwrap();
                                    }
                                    "MONTHLY" => {
                                        days.push(cur_date);
                                        cur_date =
                                            cur_date.with_day(1).unwrap().succ_opt().unwrap();
                                        while cur_date.day() != 1 {
                                            cur_date = cur_date.succ_opt().unwrap();
                                        }
                                    }
                                    "YEARLY" => {
                                        days.push(cur_date);
                                        cur_date = cur_date
                                            .with_year(cur_date.year() + interval as i32)
                                            .unwrap();
                                    }
                                    _ => {
                                        days.push(cur_date);
                                        cur_date = cur_date.succ_opt().unwrap();
                                    }
                                }
                                added += 1;
                            }
                        }
                        (fmt_start, fmt_end, days, recurrence_display)
                    } else {
                        // No RRULE: just add all days between start and end
                        let d = start.date_naive();
                        let mut end_d = end.date_naive();
                        let allday =
                            dtstart.len() == 8 && (dtend.map(|s| s.len() == 8).unwrap_or(false));
                        if allday {
                            // DTEND is exclusive for all-day events
                            if end_d > d {
                                end_d = end_d.pred_opt().unwrap();
                            }
                        }
                        let mut day_iter = d;
                        while day_iter <= end_d {
                            days.push(day_iter);
                            day_iter = day_iter.succ_opt().unwrap();
                        }
                        (fmt_start, fmt_end, days, recurrence_display)
                    }
                } else {
                    (String::new(), String::new(), vec![], String::new())
                };

            // Compute calendar grid for template rendering
            let (all_days, caption) = if !event_days.is_empty() {
                let first_event = event_days.first().unwrap();
                let last_event = event_days.last().unwrap();
                let first_of_month =
                    NaiveDate::from_ymd_opt(first_event.year(), first_event.month(), 1).unwrap();
                let last_of_month = {
                    let next_month = if last_event.month() == 12 {
                        NaiveDate::from_ymd_opt(last_event.year() + 1, 1, 1).unwrap()
                    } else {
                        NaiveDate::from_ymd_opt(last_event.year(), last_event.month() + 1, 1)
                            .unwrap()
                    };
                    next_month.pred_opt().unwrap()
                };
                let mut cal_start = first_of_month;
                while cal_start.weekday() != chrono::Weekday::Sun {
                    cal_start = cal_start.pred_opt().unwrap();
                }
                let mut cal_end = last_of_month;
                while cal_end.weekday() != chrono::Weekday::Sat {
                    cal_end = cal_end.succ_opt().unwrap();
                }
                let mut all_days = vec![];
                let mut d = cal_start;
                while d <= cal_end {
                    all_days.push(d);
                    d = d.succ_opt().unwrap();
                }
                let start_month = first_event.format("%B %Y");
                let end_month = last_event.format("%B %Y");
                let caption = if start_month.to_string() == end_month.to_string() {
                    start_month.to_string()
                } else {
                    format!("{} – {}", start_month, end_month)
                };
                (all_days, caption)
            } else {
                (vec![], String::new())
            };

            // Description paragraphs
            let description_paragraphs: Vec<String> = if let Some(desc) = description {
                let desc = desc.replace("\\n", "\n");
                desc.lines()
                    .map(|line| line.trim().to_string())
                    .filter(|line| !line.is_empty())
                    .collect::<Vec<_>>()
            } else {
                Vec::new()
            };

            let summary_val = summary.unwrap_or("");
            let organizer_val = organizer.unwrap_or("");
            let organizer_cn_val = organizer_cn.unwrap_or("");
            let local_fmt_start_val = &local_fmt_start;
            let local_fmt_end_val = &local_fmt_end;
            let description_paragraphs_val = &description_paragraphs;
            // Compute calendar grid for template rendering
            let template = IcalSummaryTemplate {
                summary: summary_val,
                local_fmt_start: local_fmt_start_val,
                local_fmt_end: local_fmt_end_val,
                organizer: organizer_val,
                organizer_cn: organizer_cn_val,
                all_days,
                event_days: event_days.clone(),
                caption,
                description_paragraphs: description_paragraphs_val,
                today: Some(chrono::Local::now().date_naive()),
                recurrence_display,
            };
            summary_parts.push(template.render()?);
        }
    }
    Ok(summary_parts.join("<hr>"))
}

fn parse_ical_datetime_tz(dt: &str, tz: Tz) -> Option<chrono::DateTime<Tz>> {
    let dt = dt.split(':').last().unwrap_or(dt);
    if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%SZ") {
        Some(tz.from_utc_datetime(&ndt))
    } else if let Ok(ndt) = chrono::NaiveDateTime::parse_from_str(dt, "%Y%m%dT%H%M%S") {
        match tz.from_local_datetime(&ndt) {
            LocalResult::Single(dt) => Some(dt),
            _ => None,
        }
    } else if let Ok(nd) = chrono::NaiveDate::parse_from_str(dt, "%Y%m%d") {
        // All-day event: treat as midnight in local time
        let ndt = nd.and_hms_opt(0, 0, 0).unwrap();
        match tz.from_local_datetime(&ndt) {
            LocalResult::Single(dt) => Some(dt),
            _ => None,
        }
    } else {
        None
    }
}

#[cfg(test)]
mod tests {
    #[test]
    fn google_calendar_email_thursday_highlights_thursday() {
        use mailparse::parse_mail;
        let raw_email = include_str!("../../server/testdata/google-calendar-example-thursday.eml");
        let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail");
        let mut part_addr = vec![];
        let body = extract_body(&parsed, &mut part_addr).expect("extract_body");
        let meta = extract_calendar_metadata_from_mail(&parsed, &body);
        // Assert detection as Google Calendar
        assert!(meta.is_google_calendar_event);
        let html = meta.body_html.expect("body_html");

        // Print event date info for debugging
        for part in parsed.subparts.iter() {
            if part.ctype.mimetype == TEXT_CALENDAR {
                if let Ok(ical) = part.get_body() {
                    println!("ICAL data: {}", ical);
                    if let Some(start) = ical.lines().find(|l| l.starts_with("DTSTART:")) {
                        println!("Start date: {}", start);
                    }
                }
            }
        }
        println!("Rendered HTML: {}", html);

        // Look for September 11 (Thursday) being highlighted
        // The calendar should show Sept 11 highlighted with background:#ffd700 and the correct data-event-day
        assert!(html.contains(r#"data-event-day="2025-09-11""#));
        assert!(html.contains(r#"background:#ffd700"#));

        // Since 1:00 AM UTC on Friday 9/12 is 6:00 PM PDT on Thursday 9/11, verify times are correct
        assert!(html.contains("6:00 PM Thu Sep 11, 2025"));
    }
    use super::*;
    #[test]
    fn google_calendar_email_3_single_event_metadata() {
        use mailparse::parse_mail;
        let raw_email = include_str!("../../server/testdata/google-calendar-example-3.eml");
        let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail");
        let mut part_addr = vec![];
        let body = extract_body(&parsed, &mut part_addr).expect("extract_body");
        let meta = extract_calendar_metadata_from_mail(&parsed, &body);
        // Assert detection as Google Calendar
        assert!(meta.is_google_calendar_event);
        // Assert metadata extraction
        assert_eq!(meta.summary, Some("Dentist appt".to_string()));
        // Organizer: from From header, extract email address
        assert_eq!(meta.organizer, Some("tconvertino@gmail.com".to_string()));
        // Dates: should extract Sep 23, 2025, 3pm-4pm (15:00-16:00)
        assert_eq!(meta.start_date, Some("20250923T150000".to_string()));
        assert_eq!(meta.end_date, Some("20250923T160000".to_string()));
        // Should not be recurring
        if let Some(ref html) = meta.body_html {
            assert!(
                html.contains("Dentist appt"),
                "HTML should contain the summary"
            );
            // Date is now formatted as human-readable "Tue Sep 23, 2025"
            assert!(
                html.contains("Sep 23, 2025") || html.contains("20250923"),
                "HTML should contain the event date"
            );
            assert!(
                !html.contains("Repeats"),
                "HTML should not mention recurrence"
            );
        } else {
            panic!("No body_html rendered");
        }
    }
    #[test]
    fn google_calendar_email_2_metadata_no_recurrence() {
        use mailparse::parse_mail;
        let raw_email = include_str!("../../server/testdata/google-calendar-example-2.eml");
        let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail");
        let mut part_addr = vec![];
        let body = extract_body(&parsed, &mut part_addr).expect("extract_body");
        let meta = extract_calendar_metadata_from_mail(&parsed, &body);
        // Assert detection as Google Calendar
        assert!(meta.is_google_calendar_event);
        // Assert metadata extraction (update these values to match the new .eml)
        assert_eq!(meta.summary, Some("McClure BLT".to_string()));
        // Organizer: from From header, extract email address
        assert_eq!(
            meta.organizer,
            Some("calendar-notification@google.com".to_string())
        );
        // Dates: from subject, Thu Sep 11 to Fri Jan 30, 2026
        // Start date is Sep 11, 2025 (one year before end since Sep > Jan)
        assert_eq!(meta.start_date, Some("20250911".to_string()));
        assert_eq!(meta.end_date, Some("20260131".to_string()));
    }
    #[test]
    fn google_calendar_email_2_renders_calendar_and_recurrence() {
        // ...existing code...
        use mailparse::parse_mail;
        let raw_email = include_str!("../../server/testdata/google-calendar-example-2.eml");
        let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail");
        let mut part_addr = vec![];
        let body = extract_body(&parsed, &mut part_addr).expect("extract_body");
        let meta = extract_calendar_metadata_from_mail(&parsed, &body);
        // Calendar widget should be rendered
        let html = meta.body_html.expect("body_html");
        println!("Rendered HTML for verification:\n{}", html);
        // Check that the HTML contains the summary, organizer, start, and end times with labels
        assert!(
            html.contains("<b>Summary:</b> McClure BLT"),
            "HTML should contain the labeled summary/title"
        );
        assert!(
            html.contains("<b>Organizer:</b> calendar-notification@google.com"),
            "HTML should contain the labeled organizer"
        );
        // Dates are now formatted as human-readable
        assert!(
            html.contains("<b>Start:</b> Thu Sep 11, 2025") || html.contains("<b>Start:</b> 20250911"),
            "HTML should contain the labeled start time"
        );
        assert!(
            html.contains("<b>End:</b> Fri Jan 30, 2026") || html.contains("<b>End:</b> 20260131"),
            "HTML should contain the labeled end time"
        );
        if !html.contains("ical-flex") {
            println!("FAIL: html did not contain 'ical-flex':\n{}", html);
        }
        assert!(
            html.contains("ical-flex"),
            "Calendar widget should be rendered"
        );
        // Recurrence info should be present
        if !(html.contains("<b>Repeats:</b> Repeats")
            || html.contains("recurr")
            || html.contains("RRULE"))
        {
            println!("FAIL: html did not contain recurrence info:\n{}", html);
        }
        assert!(
            html.contains("<b>Repeats:</b> Repeats")
                || html.contains("recurr")
                || html.contains("RRULE"),
            "Recurrence info should be present in HTML"
        );
    }
    #[test]
    fn google_calendar_email_renders_ical_summary() {
        use mailparse::parse_mail;
        let raw_email = include_str!("../testdata/google-calendar-example.eml");
        let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail");
        let mut part_addr = vec![];
        let body = extract_body(&parsed, &mut part_addr).expect("extract_body");
        let meta = extract_calendar_metadata_from_mail(&parsed, &body);
        // Assert detection as Google Calendar
        assert!(meta.is_google_calendar_event);
        // Assert metadata extraction
        assert_eq!(meta.summary, Some("Tamara and Scout in Alaska".to_string()));
        assert_eq!(meta.organizer, Some("tconvertino@gmail.com".to_string()));
        assert_eq!(meta.start_date, Some("20250624".to_string()));
        assert_eq!(meta.end_date, Some("20250701".to_string()));
        // Debug: print the rendered HTML for inspection
        if let Some(ref html) = meta.body_html {
            println!("Rendered HTML: {}", html);
        } else {
            println!("No body_html rendered");
        }
        // Assert ical summary is rendered and prepended (look for 'ical-flex' class)
        assert!(meta
            .body_html
            .as_ref()
            .map(|h| h.contains("ical-flex"))
            .unwrap_or(false));
    }

    #[test]
    fn google_calendar_email_2_renders_ical_summary() {
        use mailparse::parse_mail;
        let raw_email = include_str!("../../server/testdata/google-calendar-example-2.eml");
        let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail");
        let mut part_addr = vec![];
        let body = extract_body(&parsed, &mut part_addr).expect("extract_body");
        let meta = extract_calendar_metadata_from_mail(&parsed, &body);
        // Assert detection as Google Calendar
        assert!(meta.is_google_calendar_event);
        // Assert metadata extraction (update these values to match the new .eml)
        assert_eq!(meta.summary, Some("McClure BLT".to_string()));
        // Organizer: from From header, extract email address
        assert_eq!(
            meta.organizer,
            Some("calendar-notification@google.com".to_string())
        );
        // Dates: from subject, Thu Sep 11 to Fri Jan 30, 2026
        // Start date is Sep 11, 2025 (one year before end since Sep > Jan)
        assert_eq!(meta.start_date, Some("20250911".to_string()));
        assert_eq!(meta.end_date, Some("20260131".to_string()));
        // Debug: print the rendered HTML for inspection
        if let Some(ref html) = meta.body_html {
            println!("Rendered HTML: {}", html);
        } else {
            println!("No body_html rendered");
        }
        // Assert ical summary is rendered and prepended (look for 'ical-flex' class)
        assert!(meta
            .body_html
            .as_ref()
            .map(|h| h.contains("ical-flex"))
            .unwrap_or(false));
    }

    #[test]
    fn google_calendar_email_2_recurring_metadata() {
        use mailparse::parse_mail;
        let raw_email = include_str!("../../server/testdata/google-calendar-example-2.eml");
        let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail");
        let mut part_addr = vec![];
        let body = extract_body(&parsed, &mut part_addr).expect("extract_body");
        let meta = extract_calendar_metadata_from_mail(&parsed, &body);
        // Assert detection as Google Calendar
        assert!(meta.is_google_calendar_event);
        // Assert that the summary and organizer are present
        assert_eq!(meta.summary, Some("McClure BLT".to_string()));
        assert_eq!(
            meta.organizer,
            Some("calendar-notification@google.com".to_string())
        );
        // Assert that the start and end dates are present
        // Start date is Sep 11, 2025 (one year before end since Sep > Jan)
        assert_eq!(meta.start_date, Some("20250911".to_string()));
        assert_eq!(meta.end_date, Some("20260131".to_string()));
        // Assert that the HTML body contains recurrence info
        if let Some(ref html) = meta.body_html {
            if !(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE")) {
                println!("FAIL: html did not contain recurrence info:\n{}", html);
            }
            assert!(
                html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE"),
                "Recurrence info should be present in HTML"
            );
        } else {
            panic!("No body_html rendered");
        }
    }

    #[test]
    fn google_calendar_email_4_single_allday_event() {
        use mailparse::parse_mail;
        let raw_email = include_str!("../../server/testdata/google-calendar-example-4.eml");
        let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail");
        let mut part_addr = vec![];
        let body = extract_body(&parsed, &mut part_addr).expect("extract_body");
        let meta = extract_calendar_metadata_from_mail(&parsed, &body);
        // Assert detection as Google Calendar
        assert!(meta.is_google_calendar_event);
        // Assert metadata extraction for single all-day event
        assert_eq!(meta.summary, Some("Emery Sleeps Over".to_string()));
        assert_eq!(meta.organizer, Some("tconvertino@gmail.com".to_string()));
        // Dates: Sunday Jan 18, 2026 (all-day event)
        assert_eq!(meta.start_date, Some("20260118".to_string()));
        assert_eq!(meta.end_date, Some("20260119".to_string())); // All-day events end next day
        // Assert ical summary is rendered and shows Jan 18 highlighted
        let html = meta.body_html.expect("body_html");
        println!("Rendered HTML: {}", html);
        assert!(html.contains("ical-flex"), "Calendar widget should be rendered");
        assert!(html.contains(r#"data-event-day="2026-01-18""#), "Jan 18 should be highlighted");
    }

    #[test]
    fn google_calendar_email_5_updated_invitation() {
        use mailparse::parse_mail;
        let raw_email = include_str!("../../server/testdata/google-calendar-example-5.eml");
        let parsed = parse_mail(raw_email.as_bytes()).expect("parse_mail");
        let mut part_addr = vec![];
        let body = extract_body(&parsed, &mut part_addr).expect("extract_body");
        let meta = extract_calendar_metadata_from_mail(&parsed, &body);
        // Assert detection as Google Calendar
        assert!(meta.is_google_calendar_event);
        // Assert metadata extraction for updated invitation
        assert_eq!(meta.summary, Some("painting class".to_string()));
        assert_eq!(meta.organizer, Some("tconvertino@gmail.com".to_string()));
        // Dates: Thursday Feb 12, 2026 7pm - 9pm (same day event with time)
        // Start: 7pm = 19:00, End: 9pm = 21:00
        assert_eq!(meta.start_date, Some("20260212T190000".to_string()));
        assert_eq!(meta.end_date, Some("20260212T210000".to_string()));
        // Assert ical summary is rendered and shows Feb 12 highlighted
        let html = meta.body_html.expect("body_html");
        println!("Rendered HTML: {}", html);
        assert!(html.contains("ical-flex"), "Calendar widget should be rendered");
        assert!(html.contains(r#"data-event-day="2026-02-12""#), "Feb 12 should be highlighted");
        // Verify time is displayed in the HTML
        assert!(html.contains("7:00 PM") || html.contains("7pm") || html.contains("19:00"), 
            "HTML should contain the start time");
    }

    #[test]
    fn recurring_event_rrule_metadata_and_highlight() {
        use super::render_ical_summary;
        // This .ics should contain an RRULE for recurrence
        let ical = include_str!("../../server/testdata/ical-straddle.ics");
        let html = render_ical_summary(&ical).expect("render ical summary");
        // Should mention recurrence in the display
        assert!(html.contains("Repeats") || html.contains("recurr") || html.contains("RRULE"));

        // Should only highlight the correct days (not all days between start and end)
        // For a weekly event, check that only the correct weekday cells are highlighted
        // (e.g., if event is every Monday, only Mondays are highlighted)
        // This is a weak test: just check that not all days in the range are highlighted
        let highlighted = html.matches("background:#ffd700").count();
        let total_days = html.matches("<td").count();
        assert!(highlighted > 0, "Should highlight at least one day");
        assert!(
            highlighted < total_days / 2,
            "Should not highlight all days"
        );
    }
}
