//! Microsoft Graph backends for the non-Apple platforms (car#520).
//!
//! macOS binds Calendar/Contacts/Mail to the OS accounts via EventKit /
//! Contacts / Mail automation. On Windows (and Linux) this module talks to
//! **Microsoft Graph** over REST instead, authenticated with the OAuth 2.0
//! **device-code** flow (no embedded browser, works headless).
//!
//! ## Configuration (the one external prerequisite)
//!
//! Graph requires an **Azure AD app registration** — a public client with the
//! delegated scopes `Contacts.Read`, `Calendars.ReadWrite`, `Mail.ReadWrite`,
//! `Mail.Send`, `offline_access` (read-write since car#531 adds event
//! create/update/delete and mail send). Supply its client id via
//! `CAR_MSGRAPH_CLIENT_ID` (and,
//! optionally, a tenant via `CAR_MSGRAPH_TENANT`, default `common`). A cached
//! access token may be provided directly via `CAR_MSGRAPH_TOKEN` for headless
//! use; otherwise [`device_code_login`] performs the interactive flow.
//!
//! Everything in this module is a pure REST/JSON mapping — the request builders
//! and response parsers are unit-tested; only the network hop needs the live
//! app registration + a signed-in Microsoft account.

use crate::calendar::{Attendee, Event, EventCreateInput, EventUpdateInput};
use crate::contacts::Contact;
use crate::mail::{InboxSummary, SendRequest};
use chrono::{DateTime, TimeZone, Utc};

const GRAPH_BASE: &str = "https://graph.microsoft.com/v1.0";
// Read-write scopes (car#531): Calendars.ReadWrite and Mail.ReadWrite subsume
// their .Read counterparts (list_events/list_inbox), and Mail.Send authorizes
// `POST /me/sendMail`.
const DEFAULT_SCOPES: &str =
    "offline_access Contacts.Read Calendars.ReadWrite Mail.ReadWrite Mail.Send User.Read";

/// Env var holding the Azure AD app (client) id.
pub const CLIENT_ID_ENV: &str = "CAR_MSGRAPH_CLIENT_ID";
/// Env var overriding the tenant (`common` | `organizations` | `consumers` | a
/// tenant id). Default `common`.
pub const TENANT_ENV: &str = "CAR_MSGRAPH_TENANT";
/// Env var supplying a ready access token (skips the device-code flow).
pub const TOKEN_ENV: &str = "CAR_MSGRAPH_TOKEN";

#[derive(Debug, thiserror::Error)]
pub enum GraphError {
    #[error("Microsoft Graph is not configured: set {CLIENT_ID_ENV} (and sign in) or {TOKEN_ENV}")]
    NotConfigured,
    #[error("Microsoft Graph auth: {0}")]
    Auth(String),
    #[error("Microsoft Graph request failed: {0}")]
    Request(String),
    #[error("Microsoft Graph returned malformed data: {0}")]
    Parse(String),
}

/// True when a client id or a direct token is configured — the signal the
/// per-OS backends use to decide "Graph" vs. "pending".
pub fn is_configured() -> bool {
    non_empty_env(CLIENT_ID_ENV).is_some() || non_empty_env(TOKEN_ENV).is_some()
}

fn non_empty_env(key: &str) -> Option<String> {
    std::env::var(key).ok().filter(|v| !v.trim().is_empty())
}

fn tenant() -> String {
    non_empty_env(TENANT_ENV).unwrap_or_else(|| "common".to_string())
}

// --- OAuth device-code flow (pure builders + parsers; live poll) ------------

/// The device-code initiation endpoint for the configured tenant.
fn device_code_url() -> String {
    format!(
        "https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
        tenant()
    )
}

/// The token endpoint for the configured tenant.
fn token_url() -> String {
    format!(
        "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
        tenant()
    )
}

/// Parsed device-code response the user acts on.
#[derive(Debug, Clone)]
pub struct DeviceCode {
    pub device_code: String,
    pub user_code: String,
    pub verification_uri: String,
    pub message: String,
    pub interval_secs: u64,
    pub expires_in_secs: u64,
}

/// Parse the `/devicecode` JSON response. Pure — unit-tested.
fn parse_device_code(v: &serde_json::Value) -> Result<DeviceCode, GraphError> {
    let s = |k: &str| v.get(k).and_then(|x| x.as_str()).map(|s| s.to_string());
    Ok(DeviceCode {
        device_code: s("device_code").ok_or_else(|| GraphError::Auth("no device_code".into()))?,
        user_code: s("user_code").unwrap_or_default(),
        verification_uri: s("verification_uri").unwrap_or_default(),
        message: s("message").unwrap_or_default(),
        interval_secs: v.get("interval").and_then(|x| x.as_u64()).unwrap_or(5),
        expires_in_secs: v.get("expires_in").and_then(|x| x.as_u64()).unwrap_or(900),
    })
}

/// Outcome of one token poll. Pure — unit-tested.
enum TokenPoll {
    Token(String),
    Pending,
    Slow,
    Error(String),
}

fn parse_token_poll(v: &serde_json::Value) -> TokenPoll {
    if let Some(tok) = v.get("access_token").and_then(|x| x.as_str()) {
        return TokenPoll::Token(tok.to_string());
    }
    match v.get("error").and_then(|x| x.as_str()) {
        Some("authorization_pending") => TokenPoll::Pending,
        Some("slow_down") => TokenPoll::Slow,
        Some(other) => TokenPoll::Error(other.to_string()),
        None => TokenPoll::Error("no access_token and no error".into()),
    }
}

/// Run the device-code login: request a code, print instructions, poll until the
/// user authorizes (or it expires). Returns the access token. Requires
/// [`CLIENT_ID_ENV`]. `sleep` is injected so the polling loop is deterministic
/// in tests; production passes `std::thread::sleep`.
pub fn device_code_login(sleep: &dyn Fn(std::time::Duration)) -> Result<String, GraphError> {
    let client_id = non_empty_env(CLIENT_ID_ENV).ok_or(GraphError::NotConfigured)?;
    let client = blocking_client()?;

    let resp = client
        .post(device_code_url())
        .form(&[("client_id", client_id.as_str()), ("scope", DEFAULT_SCOPES)])
        .send()
        .map_err(|e| GraphError::Auth(format!("device code request: {e}")))?;
    let dc = parse_device_code(
        &resp
            .json::<serde_json::Value>()
            .map_err(|e| GraphError::Auth(format!("device code json: {e}")))?,
    )?;
    // The user-facing instruction: open the URL and enter the code.
    tracing::info!("{}", dc.message);
    eprintln!("{}", dc.message);

    let mut interval = dc.interval_secs.max(1);
    let deadline = dc.expires_in_secs;
    let mut elapsed = 0u64;
    loop {
        if elapsed >= deadline {
            return Err(GraphError::Auth(
                "device code expired before authorization".into(),
            ));
        }
        sleep(std::time::Duration::from_secs(interval));
        elapsed += interval;
        let resp = client
            .post(token_url())
            .form(&[
                ("client_id", client_id.as_str()),
                ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
                ("device_code", dc.device_code.as_str()),
            ])
            .send()
            .map_err(|e| GraphError::Auth(format!("token poll: {e}")))?;
        let json = resp
            .json::<serde_json::Value>()
            .map_err(|e| GraphError::Auth(format!("token json: {e}")))?;
        match parse_token_poll(&json) {
            TokenPoll::Token(t) => return Ok(t),
            TokenPoll::Pending => {}
            TokenPoll::Slow => interval += 5,
            TokenPoll::Error(e) => return Err(GraphError::Auth(e)),
        }
    }
}

// --- Live Graph access ------------------------------------------------------

fn blocking_client() -> Result<reqwest::blocking::Client, GraphError> {
    reqwest::blocking::Client::builder()
        .timeout(std::time::Duration::from_secs(30))
        .build()
        .map_err(|e| GraphError::Request(format!("http client: {e}")))
}

/// Resolve an access token: `CAR_MSGRAPH_TOKEN` if present, else run the
/// device-code login (which requires `CAR_MSGRAPH_CLIENT_ID`).
fn access_token() -> Result<String, GraphError> {
    if let Some(t) = non_empty_env(TOKEN_ENV) {
        return Ok(t);
    }
    device_code_login(&std::thread::sleep)
}

/// GET a Graph path (e.g. `/me/contacts`) and return the parsed JSON body. The
/// `Prefer: outlook.timezone="UTC"` header makes calendar times come back in
/// UTC so the parsers can trust them.
fn graph_get(path: &str) -> Result<serde_json::Value, GraphError> {
    if !is_configured() {
        return Err(GraphError::NotConfigured);
    }
    let token = access_token()?;
    let client = blocking_client()?;
    let resp = client
        .get(format!("{GRAPH_BASE}{path}"))
        .bearer_auth(token)
        .header("Prefer", "outlook.timezone=\"UTC\"")
        .send()
        .map_err(|e| GraphError::Request(format!("GET {path}: {e}")))?;
    if !resp.status().is_success() {
        let status = resp.status();
        let detail = resp.text().unwrap_or_default();
        return Err(GraphError::Request(format!(
            "GET {path} -> {status}: {detail}"
        )));
    }
    resp.json::<serde_json::Value>()
        .map_err(|e| GraphError::Parse(format!("GET {path} json: {e}")))
}

/// Issue a Graph request with an optional JSON body; returns the parsed
/// response (`None` for an empty 202/204 body). Backs the POST/PATCH/DELETE
/// mutations (car#531).
fn graph_request(
    method: reqwest::Method,
    path: &str,
    body: Option<&serde_json::Value>,
) -> Result<Option<serde_json::Value>, GraphError> {
    if !is_configured() {
        return Err(GraphError::NotConfigured);
    }
    let token = access_token()?;
    let client = blocking_client()?;
    let mut req = client
        .request(method, format!("{GRAPH_BASE}{path}"))
        .bearer_auth(token)
        .header("Prefer", "outlook.timezone=\"UTC\"");
    if let Some(b) = body {
        req = req.json(b);
    }
    let resp = req
        .send()
        .map_err(|e| GraphError::Request(format!("{path}: {e}")))?;
    if !resp.status().is_success() {
        let status = resp.status();
        let detail = resp.text().unwrap_or_default();
        return Err(GraphError::Request(format!("{path} -> {status}: {detail}")));
    }
    // `sendMail` (202) and `delete` (204) return no body.
    let text = resp.text().unwrap_or_default();
    if text.trim().is_empty() {
        return Ok(None);
    }
    serde_json::from_str(&text)
        .map(Some)
        .map_err(|e| GraphError::Parse(format!("{path} json: {e}")))
}

// --- Pure request-body builders (car-integrations input -> Graph JSON) ------

/// A Graph `dateTimeTimeZone` value in UTC.
fn graph_datetime(dt: DateTime<Utc>) -> serde_json::Value {
    serde_json::json!({
        "dateTime": dt.format("%Y-%m-%dT%H:%M:%S").to_string(),
        "timeZone": "UTC",
    })
}

/// Fold optional `notes` + `url` into a Graph text `body` (events have no
/// dedicated URL field).
fn event_body_content(notes: &Option<String>, url: &Option<String>) -> Option<serde_json::Value> {
    let mut content = notes.clone().unwrap_or_default();
    if let Some(u) = url {
        if !content.is_empty() {
            content.push('\n');
        }
        content.push_str(u);
    }
    (!content.is_empty()).then(|| serde_json::json!({ "contentType": "text", "content": content }))
}

/// Build the `POST /me/events` body from an [`EventCreateInput`]. Pure — tested.
pub(crate) fn event_create_body(input: &EventCreateInput) -> serde_json::Value {
    let mut body = serde_json::json!({
        "subject": input.title,
        "start": graph_datetime(input.start),
        "end": graph_datetime(input.end),
        "isAllDay": input.all_day,
    });
    if let Some(b) = event_body_content(&input.notes, &input.url) {
        body["body"] = b;
    }
    if let Some(loc) = &input.location {
        body["location"] = serde_json::json!({ "displayName": loc });
    }
    body
}

/// Build the `PATCH /me/events/{id}` body — only the set fields. Pure — tested.
pub(crate) fn event_update_body(input: &EventUpdateInput) -> serde_json::Value {
    let mut body = serde_json::Map::new();
    if let Some(t) = &input.title {
        body.insert("subject".into(), serde_json::json!(t));
    }
    if let Some(s) = input.start {
        body.insert("start".into(), graph_datetime(s));
    }
    if let Some(e) = input.end {
        body.insert("end".into(), graph_datetime(e));
    }
    if let Some(a) = input.all_day {
        body.insert("isAllDay".into(), serde_json::json!(a));
    }
    if input.notes.is_some() || input.url.is_some() {
        if let Some(b) = event_body_content(&input.notes, &input.url) {
            body.insert("body".into(), b);
        }
    }
    if let Some(loc) = &input.location {
        body.insert("location".into(), serde_json::json!({ "displayName": loc }));
    }
    serde_json::Value::Object(body)
}

/// Build the `POST /me/sendMail` body from a [`SendRequest`]. Pure — tested.
pub(crate) fn send_mail_body(req: &SendRequest) -> serde_json::Value {
    let recips = |addrs: &[String]| -> serde_json::Value {
        serde_json::Value::Array(
            addrs
                .iter()
                .map(|a| serde_json::json!({ "emailAddress": { "address": a } }))
                .collect(),
        )
    };
    let mut message = serde_json::json!({
        "subject": req.subject,
        "body": { "contentType": "text", "content": req.body },
        "toRecipients": recips(&req.to),
    });
    if !req.cc.is_empty() {
        message["ccRecipients"] = recips(&req.cc);
    }
    if !req.bcc.is_empty() {
        message["bccRecipients"] = recips(&req.bcc);
    }
    serde_json::json!({ "message": message, "saveToSentItems": true })
}

// --- Public mutation entry points (car#531) ---------------------------------

fn parse_single_event(resp: serde_json::Value) -> Result<Event, GraphError> {
    parse_events(&serde_json::json!({ "value": [resp] }), "graph")
        .into_iter()
        .next()
        .ok_or_else(|| GraphError::Parse("event response not parseable".into()))
}

/// Create an event (`POST /me/events`) → the created [`Event`].
pub fn create_event(input: &EventCreateInput) -> Result<Event, GraphError> {
    let body = event_create_body(input);
    let resp = graph_request(reqwest::Method::POST, "/me/events", Some(&body))?
        .ok_or_else(|| GraphError::Parse("create event returned no body".into()))?;
    parse_single_event(resp)
}

/// Update an event (`PATCH /me/events/{id}`) → the updated [`Event`].
pub fn update_event(input: &EventUpdateInput) -> Result<Event, GraphError> {
    let body = event_update_body(input);
    let path = format!("/me/events/{}", input.event_id);
    let resp = graph_request(reqwest::Method::PATCH, &path, Some(&body))?
        .ok_or_else(|| GraphError::Parse("update event returned no body".into()))?;
    parse_single_event(resp)
}

/// Delete an event (`DELETE /me/events/{id}`).
pub fn delete_event(event_id: &str) -> Result<(), GraphError> {
    graph_request(
        reqwest::Method::DELETE,
        &format!("/me/events/{event_id}"),
        None,
    )?;
    Ok(())
}

/// Send (`POST /me/sendMail`) or draft (`POST /me/messages`, `draft_only`) mail.
/// Returns the draft message id when drafting; `None` when sent (sendMail is a
/// 202 with no body).
pub fn send_mail(req: &SendRequest) -> Result<Option<String>, GraphError> {
    if req.draft_only {
        let body = send_mail_body(req);
        // A draft posts the message envelope directly, not wrapped in `message`.
        let message = body.get("message").cloned().unwrap_or(body);
        let resp = graph_request(reqwest::Method::POST, "/me/messages", Some(&message))?
            .ok_or_else(|| GraphError::Parse("draft returned no body".into()))?;
        Ok(resp.get("id").and_then(|v| v.as_str()).map(String::from))
    } else {
        let body = send_mail_body(req);
        graph_request(reqwest::Method::POST, "/me/sendMail", Some(&body))?;
        Ok(None)
    }
}

// --- Pure parsers (Graph JSON -> car-integrations types) --------------------

/// Parse a Graph `/me/contacts` collection into [`Contact`]s. Pure.
pub(crate) fn parse_contacts(v: &serde_json::Value) -> Vec<Contact> {
    let items = v
        .get("value")
        .and_then(|x| x.as_array())
        .cloned()
        .unwrap_or_default();
    items
        .iter()
        .map(|c| {
            let emails = c
                .get("emailAddresses")
                .and_then(|x| x.as_array())
                .map(|arr| {
                    arr.iter()
                        .filter_map(|e| e.get("address").and_then(|a| a.as_str()).map(String::from))
                        .collect()
                })
                .unwrap_or_default();
            let mut phones: Vec<String> = Vec::new();
            for key in ["businessPhones", "homePhones"] {
                if let Some(arr) = c.get(key).and_then(|x| x.as_array()) {
                    phones.extend(arr.iter().filter_map(|p| p.as_str().map(String::from)));
                }
            }
            if let Some(m) = c.get("mobilePhone").and_then(|x| x.as_str()) {
                phones.push(m.to_string());
            }
            Contact {
                id: c
                    .get("id")
                    .and_then(|x| x.as_str())
                    .unwrap_or_default()
                    .to_string(),
                container_id: None,
                display_name: c
                    .get("displayName")
                    .and_then(|x| x.as_str())
                    .unwrap_or_default()
                    .to_string(),
                emails,
                phone_numbers: phones,
                organization: c
                    .get("companyName")
                    .and_then(|x| x.as_str())
                    .filter(|s| !s.is_empty())
                    .map(String::from),
            }
        })
        .collect()
}

/// Parse a Graph `dateTimeTimeZone` value (UTC, thanks to the Prefer header).
fn parse_graph_datetime(v: &serde_json::Value) -> Option<DateTime<Utc>> {
    let s = v.get("dateTime").and_then(|x| x.as_str())?;
    // Graph emits e.g. "2026-07-05T09:00:00.0000000" (no offset; UTC via Prefer).
    let trimmed = s.split('.').next().unwrap_or(s);
    chrono::NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S")
        .ok()
        .map(|ndt| Utc.from_utc_datetime(&ndt))
}

/// Parse a Graph `/me/events` collection into [`Event`]s. Pure.
pub(crate) fn parse_events(v: &serde_json::Value, calendar_id: &str) -> Vec<Event> {
    let items = v
        .get("value")
        .and_then(|x| x.as_array())
        .cloned()
        .unwrap_or_default();
    items
        .iter()
        .filter_map(|e| {
            let start = parse_graph_datetime(e.get("start")?)?;
            let end = parse_graph_datetime(e.get("end")?).unwrap_or(start);
            let attendees = e
                .get("attendees")
                .and_then(|x| x.as_array())
                .map(|arr| {
                    arr.iter()
                        .map(|a| {
                            let ea = a.get("emailAddress");
                            Attendee {
                                name: ea
                                    .and_then(|x| x.get("name"))
                                    .and_then(|x| x.as_str())
                                    .map(String::from),
                                email: ea
                                    .and_then(|x| x.get("address"))
                                    .and_then(|x| x.as_str())
                                    .map(String::from),
                                status: a
                                    .get("status")
                                    .and_then(|x| x.get("response"))
                                    .and_then(|x| x.as_str())
                                    .map(String::from),
                                role: a.get("type").and_then(|x| x.as_str()).map(String::from),
                                is_current_user: false,
                            }
                        })
                        .collect()
                })
                .unwrap_or_default();
            Some(Event {
                id: e
                    .get("id")
                    .and_then(|x| x.as_str())
                    .unwrap_or_default()
                    .to_string(),
                calendar_id: calendar_id.to_string(),
                title: e
                    .get("subject")
                    .and_then(|x| x.as_str())
                    .unwrap_or_default()
                    .to_string(),
                start,
                end,
                all_day: e.get("isAllDay").and_then(|x| x.as_bool()).unwrap_or(false),
                location: e
                    .get("location")
                    .and_then(|x| x.get("displayName"))
                    .and_then(|x| x.as_str())
                    .filter(|s| !s.is_empty())
                    .map(String::from),
                notes: e
                    .get("bodyPreview")
                    .and_then(|x| x.as_str())
                    .filter(|s| !s.is_empty())
                    .map(String::from),
                attendees,
                status: e.get("showAs").and_then(|x| x.as_str()).map(String::from),
            })
        })
        .collect()
}

/// Parse a Graph `/me/mailFolders/inbox` object into an [`InboxSummary`]. Pure.
pub(crate) fn parse_inbox_summary(v: &serde_json::Value, account_id: &str) -> InboxSummary {
    InboxSummary {
        account_id: account_id.to_string(),
        unread: v
            .get("unreadItemCount")
            .and_then(|x| x.as_u64())
            .unwrap_or(0) as u32,
        total: v
            .get("totalItemCount")
            .and_then(|x| x.as_u64())
            .unwrap_or(0) as u32,
        most_recent_subject: None,
    }
}

// --- Public backend entry points -------------------------------------------

/// Contacts from Graph (`/me/contacts`), optionally filtered by a substring.
pub fn contacts(query: &str, limit: usize) -> Result<Vec<Contact>, GraphError> {
    let top = limit.clamp(1, 999);
    let mut list = parse_contacts(&graph_get(&format!("/me/contacts?$top={top}"))?);
    if !query.is_empty() {
        let q = query.to_lowercase();
        list.retain(|c| {
            c.display_name.to_lowercase().contains(&q)
                || c.emails.iter().any(|e| e.to_lowercase().contains(&q))
        });
    }
    Ok(list)
}

/// Calendar events in `[start, end)` from Graph (`/me/calendarView`).
pub fn events(start: DateTime<Utc>, end: DateTime<Utc>) -> Result<Vec<Event>, GraphError> {
    let path = format!(
        "/me/calendarView?startDateTime={}&endDateTime={}&$top=200",
        start.format("%Y-%m-%dT%H:%M:%SZ"),
        end.format("%Y-%m-%dT%H:%M:%SZ")
    );
    Ok(parse_events(&graph_get(&path)?, "graph"))
}

/// Inbox unread/total for the signed-in account (`/me/mailFolders/inbox`).
pub fn inbox_summary(account_id: &str) -> Result<InboxSummary, GraphError> {
    Ok(parse_inbox_summary(
        &graph_get("/me/mailFolders/inbox")?,
        account_id,
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn device_code_parse() {
        let v = serde_json::json!({
            "device_code": "DEV", "user_code": "ABC-123",
            "verification_uri": "https://microsoft.com/devicelogin",
            "message": "go here", "interval": 5, "expires_in": 900
        });
        let dc = parse_device_code(&v).unwrap();
        assert_eq!(dc.device_code, "DEV");
        assert_eq!(dc.user_code, "ABC-123");
        assert_eq!(dc.interval_secs, 5);
    }

    #[test]
    fn token_poll_states() {
        assert!(matches!(
            parse_token_poll(&serde_json::json!({"access_token": "T"})),
            TokenPoll::Token(_)
        ));
        assert!(matches!(
            parse_token_poll(&serde_json::json!({"error": "authorization_pending"})),
            TokenPoll::Pending
        ));
        assert!(matches!(
            parse_token_poll(&serde_json::json!({"error": "slow_down"})),
            TokenPoll::Slow
        ));
        assert!(matches!(
            parse_token_poll(&serde_json::json!({"error": "expired_token"})),
            TokenPoll::Error(_)
        ));
    }

    #[test]
    fn contacts_parse() {
        let v = serde_json::json!({"value": [{
            "id": "1", "displayName": "Ada Lovelace",
            "emailAddresses": [{"address": "ada@example.com"}],
            "businessPhones": ["+1 555 0100"], "mobilePhone": "+1 555 0199",
            "companyName": "Analytical Engines"
        }]});
        let cs = parse_contacts(&v);
        assert_eq!(cs.len(), 1);
        assert_eq!(cs[0].display_name, "Ada Lovelace");
        assert_eq!(cs[0].emails, vec!["ada@example.com"]);
        assert_eq!(cs[0].phone_numbers.len(), 2);
        assert_eq!(cs[0].organization.as_deref(), Some("Analytical Engines"));
    }

    #[test]
    fn events_parse_utc() {
        let v = serde_json::json!({"value": [{
            "id": "e1", "subject": "Standup",
            "start": {"dateTime": "2026-07-05T09:00:00.0000000", "timeZone": "UTC"},
            "end": {"dateTime": "2026-07-05T09:15:00.0000000", "timeZone": "UTC"},
            "location": {"displayName": "Room 1"}, "isAllDay": false,
            "attendees": [{"emailAddress": {"name": "Bob", "address": "bob@x.com"}, "type": "required"}]
        }]});
        let es = parse_events(&v, "cal");
        assert_eq!(es.len(), 1);
        assert_eq!(es[0].title, "Standup");
        assert_eq!(
            es[0].start.format("%Y-%m-%dT%H:%M:%SZ").to_string(),
            "2026-07-05T09:00:00Z"
        );
        assert_eq!(es[0].location.as_deref(), Some("Room 1"));
        assert_eq!(es[0].attendees.len(), 1);
        assert_eq!(es[0].attendees[0].email.as_deref(), Some("bob@x.com"));
    }

    #[test]
    fn inbox_parse() {
        let v =
            serde_json::json!({"displayName": "Inbox", "unreadItemCount": 3, "totalItemCount": 42});
        let s = parse_inbox_summary(&v, "acct");
        assert_eq!(s.unread, 3);
        assert_eq!(s.total, 42);
        assert_eq!(s.account_id, "acct");
    }

    #[test]
    fn not_configured_by_default() {
        // Guard against a runner that already exports the vars.
        if super::non_empty_env(CLIENT_ID_ENV).is_none()
            && super::non_empty_env(TOKEN_ENV).is_none()
        {
            assert!(!is_configured());
        }
    }

    // --- write body builders (car#531) -------------------------------------

    fn utc(y: i32, m: u32, d: u32, h: u32, mi: u32) -> DateTime<Utc> {
        Utc.with_ymd_and_hms(y, m, d, h, mi, 0).unwrap()
    }

    #[test]
    fn event_create_body_shape() {
        let input = EventCreateInput {
            calendar_id: "graph".into(),
            title: "Standup".into(),
            start: utc(2026, 7, 5, 9, 0),
            end: utc(2026, 7, 5, 9, 15),
            all_day: false,
            notes: Some("daily sync".into()),
            location: Some("Room 1".into()),
            url: Some("https://meet.example/x".into()),
        };
        let b = event_create_body(&input);
        assert_eq!(b["subject"], "Standup");
        assert_eq!(b["start"]["dateTime"], "2026-07-05T09:00:00");
        assert_eq!(b["start"]["timeZone"], "UTC");
        assert_eq!(b["isAllDay"], false);
        assert_eq!(b["location"]["displayName"], "Room 1");
        // notes + url fold into the text body.
        assert_eq!(b["body"]["contentType"], "text");
        let content = b["body"]["content"].as_str().unwrap();
        assert!(content.contains("daily sync") && content.contains("https://meet.example/x"));
    }

    #[test]
    fn event_update_body_only_sets_present_fields() {
        let input = EventUpdateInput {
            event_id: "e1".into(),
            title: Some("Renamed".into()),
            start: None,
            end: Some(utc(2026, 7, 5, 10, 0)),
            all_day: None,
            notes: None,
            location: None,
            url: None,
        };
        let b = event_update_body(&input);
        let obj = b.as_object().unwrap();
        assert_eq!(obj["subject"], "Renamed");
        assert_eq!(obj["end"]["dateTime"], "2026-07-05T10:00:00");
        assert!(!obj.contains_key("start"), "unset fields omitted");
        assert!(!obj.contains_key("isAllDay"));
        assert!(!obj.contains_key("location"));
        assert!(!obj.contains_key("body"));
    }

    #[test]
    fn send_mail_body_shape() {
        let req = SendRequest {
            account_id: "msgraph".into(),
            to: vec!["a@x.com".into(), "b@x.com".into()],
            cc: vec!["c@x.com".into()],
            bcc: vec![],
            subject: "Hi".into(),
            body: "Body text".into(),
            draft_only: false,
        };
        let b = send_mail_body(&req);
        assert_eq!(b["saveToSentItems"], true);
        assert_eq!(b["message"]["subject"], "Hi");
        assert_eq!(b["message"]["body"]["content"], "Body text");
        let to = b["message"]["toRecipients"].as_array().unwrap();
        assert_eq!(to.len(), 2);
        assert_eq!(to[0]["emailAddress"]["address"], "a@x.com");
        assert_eq!(b["message"]["ccRecipients"].as_array().unwrap().len(), 1);
        // No bcc → omitted.
        assert!(b["message"].get("bccRecipients").is_none());
    }
}
