//! P5-41 — Native WinRT toast notifications (+ P5-35 click-opens-room, P5-41 quick reply).
//!
//! The web notification bridge calls `show_rich_toast` (see lib.rs
//! `NOTIFICATION_BRIDGE`) instead of the basic plugin notification so desktop
//! notifications gain a text reply box and a body-click that reopens the room.
//!
//! Windows: we build a `Windows.UI.Notifications.ToastNotification` from a toast
//! XML document (`Windows.Data.Xml.Dom.XmlDocument`) carrying the title + body,
//! an inline `<input id="reply" type="text"/>` and a Send `<action>`. Because the
//! app lives in the tray (always alive) we subscribe to the toast's **in-process**
//! `Activated` event rather than relying on COM activation: the handler downcasts
//! the event args to `ToastActivatedEventArgs`, reads the reply text from
//! `UserInput()` (keyed `"reply"`) and forwards it to the web client. A body click
//! (no reply text) forwards the launch `path` so the web side can route to the
//! room. Live `ToastNotification` objects are parked in a process-global `Vec`
//! (behind a `Mutex`) so their handlers survive until the toast is dismissed.
//!
//! If ANY WinRT step fails (most importantly: no registered AppUserModelID — see
//! the runtime note below), we fall back to the plain `tauri-plugin-notification`
//! notification so notifications always work.
//!
//! Other platforms always take the fallback path; the command keeps an identical
//! cross-platform signature so the web bridge stays unconditional.
//!
//! RUNTIME NOTE (AppUserModelID): WinRT toasts require the process to run under an
//! AppUserModelID that maps to a Start-menu shortcut. The installed app's bundle
//! id is `org.lotusguild.lotus-chat`; if no matching shortcut/AUMID is registered,
//! `CreateToastNotifier()` / `Show()` will error and we silently fall back. Wiring
//! `SetCurrentProcessExplicitAppUserModelID` (+ shortcut install) is handled
//! separately.

use tauri::AppHandle;

/// Show a rich desktop notification. On Windows this is a WinRT toast with a
/// reply box and click-to-open; elsewhere (or on any WinRT error) it degrades to
/// a basic plugin notification. `room_id` is the raw Matrix room id used for the
/// reply payload; `path` is the web hash route used for a body click.
#[tauri::command]
pub fn show_rich_toast(
    app: AppHandle,
    title: String,
    body: Option<String>,
    room_id: Option<String>,
    path: Option<String>,
) -> Result<(), String> {
    #[cfg(target_os = "windows")]
    {
        match show_windows_toast(
            &app,
            &title,
            body.as_deref(),
            room_id.as_deref(),
            path.as_deref(),
        ) {
            Ok(()) => return Ok(()),
            Err(err) => {
                // Most commonly a missing AppUserModelID (see module note). Fall
                // through to the plugin notification so the user still sees it.
                eprintln!("toast: WinRT toast failed, falling back to plugin: {err:?}");
            }
        }
    }

    // Bind the routing args so the signature is identical cross-platform and no
    // unused warnings fire on the fallback (non-Windows) path.
    let _ = (&room_id, &path);
    show_fallback(&app, &title, body.as_deref())
}

/// Cross-platform fallback: a basic notification via `tauri-plugin-notification`
/// (mirrors `send_notification` in lib.rs). Used off Windows and whenever the
/// WinRT toast path errors.
fn show_fallback(app: &AppHandle, title: &str, body: Option<&str>) -> Result<(), String> {
    use tauri_plugin_notification::NotificationExt;
    let mut builder = app.notification().builder().title(title);
    if let Some(b) = body {
        builder = builder.body(b);
    }
    builder.show().map_err(|e| e.to_string())
}

/// Process-global store keeping live `ToastNotification` objects (and therefore
/// their `Activated`/`Dismissed` handler registrations) alive until dismissed.
/// Lazily initialized so no `native::setup()` wiring is required.
#[cfg(target_os = "windows")]
fn toast_store() -> &'static std::sync::Mutex<Vec<windows::UI::Notifications::ToastNotification>> {
    static STORE: std::sync::OnceLock<
        std::sync::Mutex<Vec<windows::UI::Notifications::ToastNotification>>,
    > = std::sync::OnceLock::new();
    STORE.get_or_init(|| std::sync::Mutex::new(Vec::new()))
}

/// Escape text for inclusion in the toast XML (attribute or element content).
#[cfg(target_os = "windows")]
fn xml_escape(input: &str) -> String {
    input
        .replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

#[cfg(target_os = "windows")]
fn show_windows_toast(
    app: &AppHandle,
    title: &str,
    body: Option<&str>,
    room_id: Option<&str>,
    path: Option<&str>,
) -> windows::core::Result<()> {
    use windows::core::{HSTRING, IInspectable, Interface};
    use windows::Data::Xml::Dom::XmlDocument;
    use windows::Foundation::TypedEventHandler;
    use windows::UI::Notifications::{
        ToastActivatedEventArgs, ToastDismissedEventArgs, ToastNotification,
        ToastNotificationManager,
    };

    // A body click carries the launch arguments back to us; prefer the web hash
    // route (`path`), falling back to the raw room id so clicks are never inert.
    let launch = path.or(room_id).unwrap_or_default();

    let body_line = match body {
        Some(b) if !b.is_empty() => format!("<text>{}</text>", xml_escape(b)),
        _ => String::new(),
    };

    // ToastGeneric visual + an inline reply input and a foreground Send action.
    // `hint-inputId="reply"` binds the Send button to the text box so the reply
    // text arrives in `UserInput()` keyed "reply".
    let xml = format!(
        r#"<toast activationType="foreground" launch="{launch}">
  <visual>
    <binding template="ToastGeneric">
      <text>{title}</text>
      {body_line}
    </binding>
  </visual>
  <actions>
    <input id="reply" type="text" placeHolder="Reply..."/>
    <action content="Send" arguments="reply" activationType="foreground" hint-inputId="reply"/>
  </actions>
</toast>"#,
        launch = xml_escape(launch),
        title = xml_escape(title),
        body_line = body_line,
    );

    let doc = XmlDocument::new()?;
    doc.LoadXml(&HSTRING::from(xml))?;

    let toast = ToastNotification::CreateToastNotification(&doc)?;

    // In-process activation: the app is always alive in the tray, so we handle
    // clicks/replies directly instead of via COM activation.
    let app_activated = app.clone();
    let room_id_owned = room_id.map(|s| s.to_string());
    let path_owned = path.map(|s| s.to_string());
    let activated = TypedEventHandler::<ToastNotification, IInspectable>::new(
        move |sender, args| {
            // Activation means this toast is done; drop it from the keep-alive
            // store now. A Dismissed event doesn't reliably fire for a toast the
            // user activated, so pruning only on Dismissed would leak it.
            if let Some(sender) = sender.as_ref() {
                if let Ok(mut store) = toast_store().lock() {
                    store.retain(|t| t != sender);
                }
            }
            let Some(args) = args.as_ref() else {
                return Ok(());
            };
            let Ok(activated_args) = args.cast::<ToastActivatedEventArgs>() else {
                return Ok(());
            };

            // Extract the reply text (if the Send action / input was used).
            let reply = read_reply(&activated_args).unwrap_or_default();

            if !reply.is_empty() {
                // Quick reply: forward the room id + text to the web client.
                let payload = serde_json::json!({
                    "roomId": room_id_owned.as_deref(),
                    "text": reply,
                })
                .to_string();
                super::emit_to_web(&app_activated, "lotus-notification-reply", &payload);
            } else {
                // Plain body click: raise the window to the foreground (the
                // "foreground" activationType is unreliable for an unpackaged app,
                // so do it explicitly), then forward the launch path so the web
                // routes to the room. `show_main` is the shared tray/deep-link
                // helper. Not done for the reply branch — an inline quick-reply
                // shouldn't yank the window forward.
                crate::show_main(&app_activated);
                let payload = serde_json::json!({
                    "path": path_owned.as_deref(),
                })
                .to_string();
                super::emit_to_web(&app_activated, "lotus-notification-activate", &payload);
            }
            Ok(())
        },
    );
    let _ = toast.Activated(&activated)?;

    // Prune the store once the toast leaves the action center so we don't leak
    // handler registrations for the app's lifetime.
    let dismissed = TypedEventHandler::<ToastNotification, ToastDismissedEventArgs>::new(
        move |sender, _args| {
            if let Some(sender) = sender.as_ref() {
                if let Ok(mut store) = toast_store().lock() {
                    store.retain(|t| t != sender);
                }
            }
            Ok(())
        },
    );
    let _ = toast.Dismissed(&dismissed)?;

    // Keep the toast (and its handlers) alive until dismissed/activated.
    if let Ok(mut store) = toast_store().lock() {
        store.push(toast.clone());
        // Hard cap: if some Dismissed/Activated events are missed, retain only
        // the most recent 20 toasts (dropping the oldest) so the store can't
        // grow unbounded for the app's lifetime.
        let len = store.len();
        if len > 20 {
            store.drain(0..len - 20);
        }
    }

    // Bind the notifier to our registered AUMID (native::aumid) so it resolves to
    // the "Lotus Chat" Start-Menu shortcut rather than an ambient/absent default.
    let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(
        crate::native::aumid::APP_USER_MODEL_ID,
    ))?;
    notifier.Show(&toast)?;

    Ok(())
}

/// Read the quick-reply text from a toast activation. Returns `None` when the
/// toast was activated without submitting the "reply" input (a plain click).
#[cfg(target_os = "windows")]
fn read_reply(
    args: &windows::UI::Notifications::ToastActivatedEventArgs,
) -> Option<String> {
    use windows::core::{HSTRING, Interface};
    use windows::Foundation::IReference;

    // UserInput() returns a ValueSet; windows 0.61 exposes its IMap methods
    // (HasKey/Lookup) directly on the class (the generic IMap interface itself
    // moved to the separate windows-collections crate). The text input value is
    // boxed as an IReference<HSTRING>.
    let inputs = args.UserInput().ok()?;
    let key = HSTRING::from("reply");
    if !inputs.HasKey(&key).ok()? {
        return None;
    }
    let value = inputs.Lookup(&key).ok()?;
    let reference: IReference<HSTRING> = value.cast().ok()?;
    let text = reference.Value().ok()?.to_string();
    if text.is_empty() {
        None
    } else {
        Some(text)
    }
}
