use std::{
    fs,
    io::{self, Write},
    path::{Path, PathBuf},
    process::Command,
    sync::Arc,
};

use crate::discord::ids::{
    Id,
    marker::{ChannelMarker, MessageMarker},
};
use chrono::{Duration as ChronoDuration, SecondsFormat, Utc};
use tokio::sync::{Semaphore, mpsc};
use tokio::time::{Duration, sleep, timeout};

use crate::{
    DiscordClient, Result,
    discord::{
        AppCommand, AppEvent, AttachmentUpdate, ChannelNotificationOverrideInfo,
        GuildNotificationSettingsInfo, MessageHistoryLoadTarget, MessageInfo, MuteDuration,
        ReactionUsersInfo, VoiceConnectionStatus, read_profile_avatar_image, validate_token_header,
    },
    error::AppError,
    logging, token_store, tui,
    url_policy::normalize_openable_url,
    version_check,
};

const MESSAGE_HISTORY_LIMIT: u16 = 50;
const THREAD_PREVIEW_LIMIT: u16 = 1;
const MENTION_MEMBER_SEARCH_LIMIT: u16 = 10;
const MAX_ATTACHMENT_PREVIEW_BYTES: usize = 8 * 1024 * 1024;
const MAX_ATTACHMENT_DOWNLOAD_BYTES: usize = 64 * 1024 * 1024;
const ATTACHMENT_PREVIEW_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_CONCURRENT_ATTACHMENT_PREVIEWS: usize = 4;

#[derive(Default)]
pub struct App;

impl App {
    pub fn new() -> Self {
        Self
    }

    pub async fn run(self) -> Result<()> {
        let resolved_token = resolve_token().await?;
        let token = resolved_token.token;
        let token_warnings = resolved_token.warnings;
        let client = DiscordClient::new(token)?;
        let effects = client.take_effects();
        let snapshots = client.subscribe_snapshots();
        let (commands_tx, commands_rx) = mpsc::channel(64);
        let gateway_task = client.start_gateway();
        let command_task = start_command_loop(client.clone(), commands_rx);

        // Warm the REST pool before the first user-triggered request pays the
        // TCP, TLS, and HTTP/2 setup cost.
        let prime_client = client.clone();
        tokio::spawn(async move {
            if let Err(error) = prime_client.prime_rest_pool().await {
                logging::error("app", format!("rest pool warmup failed: {error}"));
            }
        });

        let version_client = client.clone();
        tokio::spawn(async move {
            match version_check::check_latest_version().await {
                Ok(Some(latest_version)) => {
                    version_client
                        .publish_event(AppEvent::UpdateAvailable { latest_version })
                        .await;
                }
                Ok(None) => {}
                Err(error) => {
                    logging::debug("version", format!("latest version check failed: {error}"))
                }
            }
        });

        let result = async {
            for warning in token_warnings {
                logging::error("app", &warning);
                client
                    .publish_event(AppEvent::GatewayError { message: warning })
                    .await;
            }

            tui::run(effects, snapshots, commands_tx, client.clone()).await
        }
        .await;

        command_task.abort();
        leave_current_voice_channel_on_shutdown(&client);
        shutdown_gateway(&client, gateway_task).await;
        result
    }
}

fn start_command_loop(
    client: DiscordClient,
    mut commands: mpsc::Receiver<AppCommand>,
) -> tokio::task::JoinHandle<()> {
    tokio::spawn(async move {
        let attachment_preview_permits =
            Arc::new(Semaphore::new(MAX_CONCURRENT_ATTACHMENT_PREVIEWS));
        // Spawn commands independently so slow REST calls do not block the
        // whole UI command queue.
        while let Some(command) = commands.recv().await {
            let client = client.clone();
            let attachment_preview_permits = attachment_preview_permits.clone();
            tokio::spawn(async move {
                match command {
                    AppCommand::LoadMessageHistory { channel_id, before } => {
                        if let Some(before) = before
                            && !client.begin_older_message_history_request(channel_id, before)
                        {
                            return;
                        }
                        let endpoint = format_message_history_endpoint(
                            channel_id,
                            before,
                            MESSAGE_HISTORY_LIMIT,
                        );
                        match client
                            .load_message_history(channel_id, before, MESSAGE_HISTORY_LIMIT)
                            .await
                        {
                            Ok(messages) => {
                                client
                                    .publish_event(AppEvent::MessageHistoryLoaded {
                                        channel_id,
                                        before,
                                        messages,
                                    })
                                    .await;
                            }
                            Err(error) => {
                                let message = format!("load message history failed: {error}");
                                let detail = error.log_detail();
                                logging::error(
                                    "history",
                                    format!(
                                        "op=load_message_history channel_id={} before={} limit={} endpoint=\"{endpoint}\" {message}; detail={detail}",
                                        channel_id.get(),
                                        before.map(|id| id.get()).unwrap_or_default(),
                                        MESSAGE_HISTORY_LIMIT,
                                    ),
                                );
                                client
                                    .publish_event(AppEvent::MessageHistoryLoadFailed {
                                        channel_id,
                                        target: before
                                            .map(|before| MessageHistoryLoadTarget::Older {
                                                before,
                                            })
                                            .unwrap_or(MessageHistoryLoadTarget::Latest),
                                        message,
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::RefreshMessageHistory { channel_id } => {
                        let endpoint = format_message_history_endpoint(
                            channel_id,
                            None,
                            MESSAGE_HISTORY_LIMIT,
                        );
                        match client
                            .load_message_history(channel_id, None, MESSAGE_HISTORY_LIMIT)
                            .await
                        {
                            Ok(messages) => {
                                client
                                    .publish_event(AppEvent::MessageHistoryRefreshed {
                                        channel_id,
                                        messages,
                                    })
                                    .await;
                            }
                            Err(error) => {
                                let message = format!("refresh message history failed: {error}");
                                let detail = error.log_detail();
                                logging::error(
                                    "history",
                                    format!(
                                        "op=refresh_message_history channel_id={} limit={} endpoint=\"{endpoint}\" {message}; detail={detail}",
                                        channel_id.get(),
                                        MESSAGE_HISTORY_LIMIT,
                                    ),
                                );
                                client
                                    .publish_event(AppEvent::MessageHistoryLoadFailed {
                                        channel_id,
                                        target: MessageHistoryLoadTarget::Latest,
                                        message,
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::LoadMessageHistoryAfter { channel_id, after } => {
                        if !client.begin_newer_message_history_request(channel_id, after) {
                            return;
                        }
                        let endpoint = format_message_history_anchor_endpoint(
                            channel_id,
                            "after",
                            after,
                            MESSAGE_HISTORY_LIMIT,
                        );
                        match client
                            .load_message_history_after(channel_id, after, MESSAGE_HISTORY_LIMIT)
                            .await
                        {
                            Ok(messages) => {
                                let has_more = messages.len() >= usize::from(MESSAGE_HISTORY_LIMIT);
                                client
                                    .publish_event(AppEvent::MessageHistoryAfterLoaded {
                                        channel_id,
                                        after,
                                        messages,
                                        has_more,
                                    })
                                    .await;
                            }
                            Err(error) => {
                                let message = format!("load message history failed: {error}");
                                let detail = error.log_detail();
                                logging::error(
                                    "history",
                                    format!(
                                        "op=load_message_history_after channel_id={} after={} limit={} endpoint=\"{endpoint}\" {message}; detail={detail}",
                                        channel_id.get(),
                                        after.get(),
                                        MESSAGE_HISTORY_LIMIT,
                                    ),
                                );
                                client
                                    .publish_event(AppEvent::MessageHistoryLoadFailed {
                                        channel_id,
                                        target: MessageHistoryLoadTarget::Newer { after },
                                        message,
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::CatchUpMessageHistoryAfter { channel_id, after } => {
                        if !client.begin_catch_up_message_history_request(channel_id, after) {
                            return;
                        }
                        let endpoint = format_message_history_anchor_endpoint(
                            channel_id,
                            "after",
                            after,
                            MESSAGE_HISTORY_LIMIT,
                        );
                        match client
                            .load_message_history_after(channel_id, after, MESSAGE_HISTORY_LIMIT)
                            .await
                        {
                            Ok(messages) => {
                                let has_more = messages.len() >= usize::from(MESSAGE_HISTORY_LIMIT);
                                client
                                    .publish_event(AppEvent::MessageHistoryCatchUpLoaded {
                                        channel_id,
                                        after,
                                        messages,
                                        has_more,
                                    })
                                    .await;
                            }
                            Err(error) => {
                                let message = format!("catch up message history failed: {error}");
                                let detail = error.log_detail();
                                logging::error(
                                    "history",
                                    format!(
                                        "op=catch_up_message_history_after channel_id={} after={} limit={} endpoint=\"{endpoint}\" {message}; detail={detail}",
                                        channel_id.get(),
                                        after.get(),
                                        MESSAGE_HISTORY_LIMIT,
                                    ),
                                );
                                client
                                    .publish_event(AppEvent::MessageHistoryLoadFailed {
                                        channel_id,
                                        target: MessageHistoryLoadTarget::Newer { after },
                                        message,
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::LoadMessageHistoryAround {
                        channel_id,
                        message_id,
                    } => {
                        let endpoint = format_message_history_anchor_endpoint(
                            channel_id,
                            "around",
                            message_id,
                            MESSAGE_HISTORY_LIMIT,
                        );
                        match client
                            .load_message_history_around(
                                channel_id,
                                message_id,
                                MESSAGE_HISTORY_LIMIT,
                            )
                            .await
                        {
                            Ok(messages) => {
                                client
                                    .publish_event(AppEvent::MessageHistoryAroundLoaded {
                                        channel_id,
                                        message_id,
                                        messages,
                                    })
                                    .await;
                            }
                            Err(error) => {
                                let message = format!("load message history failed: {error}");
                                let detail = error.log_detail();
                                logging::error(
                                    "history",
                                    format!(
                                        "op=load_message_history_around channel_id={} message_id={} limit={} endpoint=\"{endpoint}\" {message}; detail={detail}",
                                        channel_id.get(),
                                        message_id.get(),
                                        MESSAGE_HISTORY_LIMIT,
                                    ),
                                );
                                client
                                    .publish_event(AppEvent::MessageHistoryLoadFailed {
                                        channel_id,
                                        target: MessageHistoryLoadTarget::Around { message_id },
                                        message,
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::LoadThreadPreview {
                        channel_id,
                        message_id,
                    } => {
                        match client
                            .load_message_history(channel_id, None, THREAD_PREVIEW_LIMIT)
                            .await
                        {
                            Ok(messages) => {
                                if let Some(message) = messages
                                    .into_iter()
                                    .next()
                                    .filter(|message| message.message_id == message_id)
                                {
                                    client
                                        .publish_event(AppEvent::ThreadPreviewLoaded {
                                            channel_id,
                                            message,
                                        })
                                        .await;
                                } else {
                                    logging::error(
                                        "history",
                                        format!(
                                            "load thread preview missing requested message: channel_id={} message_id={}",
                                            channel_id.get(),
                                            message_id.get(),
                                        ),
                                    );
                                    client
                                        .publish_event(AppEvent::ThreadPreviewLoadFailed {
                                            channel_id,
                                            message_id,
                                        })
                                        .await;
                                }
                            }
                            Err(error) => {
                                let message = format!("load thread preview failed: {error}");
                                let detail = error.log_detail();
                                logging::error(
                                    "history",
                                    format!(
                                        "op=load_thread_preview channel_id={} message_id={} {message}; detail={detail}",
                                        channel_id.get(),
                                        message_id.get(),
                                    ),
                                );
                                client
                                    .publish_event(AppEvent::ThreadPreviewLoadFailed {
                                        channel_id,
                                        message_id,
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::LoadForumPosts {
                        guild_id,
                        channel_id,
                        archive_state,
                        offset,
                    } => {
                        match client
                            .load_forum_posts(guild_id, channel_id, archive_state, offset)
                            .await
                        {
                            Ok(page) => {
                                client
                                    .publish_event(AppEvent::ForumPostsLoaded {
                                        channel_id,
                                        archive_state,
                                        offset,
                                        next_offset: page.next_offset,
                                        threads: page.threads,
                                        first_messages: page.first_messages,
                                        has_more: page.has_more,
                                    })
                                    .await;
                            }
                            Err(error) => {
                                let message = format!("load forum posts failed: {error}");
                                let detail = error.log_detail();
                                logging::error(
                                    "history",
                                    format!(
                                        "op=load_forum_posts guild_id={} channel_id={} archive_state={} offset={} {message}; detail={detail}",
                                        guild_id.get(),
                                        channel_id.get(),
                                        archive_state.as_log_label(),
                                        offset,
                                    ),
                                );
                                client
                                    .publish_event(AppEvent::ForumPostsLoadFailed {
                                        channel_id,
                                        archive_state,
                                        offset,
                                        message,
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::SearchMessages { query } => {
                        match client.search_messages(query.clone()).await {
                            Ok(page) => {
                                client
                                    .publish_event(AppEvent::MessageSearchLoaded { page })
                                    .await;
                            }
                            Err(error) => {
                                let message = format!("message search failed: {error}");
                                let detail = error.log_detail();
                                logging::error(
                                    "search",
                                    format!(
                                        "op=message_search offset={} {message}; detail={detail}",
                                        query.offset,
                                    ),
                                );
                                client
                                    .publish_event(AppEvent::MessageSearchLoadFailed {
                                        query,
                                        message,
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::LoadGuildMembers { guild_id } => {
                        if let Err(message) = client.request_guild_members(guild_id) {
                            logging::error("app", &message);
                            client
                                .publish_event(AppEvent::GatewayError { message })
                                .await;
                        }
                    }
                    AppCommand::LoadGuildMembersByIds { guild_id, user_ids } => {
                        if let Err(message) =
                            client.request_guild_members_by_ids(guild_id, user_ids)
                        {
                            logging::error("app", &message);
                            client
                                .publish_event(AppEvent::GatewayError { message })
                                .await;
                        }
                    }
                    AppCommand::SearchGuildMembers { guild_id, query } => {
                        if let Err(message) = client.search_guild_members(
                            guild_id,
                            query,
                            MENTION_MEMBER_SEARCH_LIMIT,
                        ) {
                            logging::error("app", &message);
                            client
                                .publish_event(AppEvent::GatewayError { message })
                                .await;
                        }
                    }
                    AppCommand::SetSelectedGuild { guild_id } => {
                        client
                            .publish_event(AppEvent::SelectedGuildChanged { guild_id })
                            .await;
                    }
                    AppCommand::SetSelectedMessageChannel { channel_id } => {
                        client
                            .publish_event(AppEvent::SelectedMessageChannelChanged { channel_id })
                            .await;
                    }
                    AppCommand::SubscribeDirectMessage { channel_id } => {
                        if let Err(message) = client.subscribe_direct_message(channel_id) {
                            logging::error("app", &message);
                            client
                                .publish_event(AppEvent::GatewayError { message })
                                .await;
                        }
                    }
                    AppCommand::SubscribeGuildChannel {
                        guild_id,
                        channel_id,
                    } => {
                        if let Err(message) = client.subscribe_guild_channel(guild_id, channel_id) {
                            logging::error("app", &message);
                            client
                                .publish_event(AppEvent::GatewayError { message })
                                .await;
                        }
                    }
                    AppCommand::UpdateMemberListSubscription {
                        guild_id,
                        channel_id,
                        ranges,
                    } => {
                        if let Err(message) =
                            client.update_member_list_subscription(guild_id, channel_id, ranges)
                        {
                            logging::error("app", &message);
                            client
                                .publish_event(AppEvent::GatewayError { message })
                                .await;
                        }
                    }
                    AppCommand::JoinVoiceChannel {
                        guild_id,
                        channel_id,
                        self_mute,
                        self_deaf,
                        allow_microphone_transmit,
                        microphone_sensitivity,
                        microphone_volume,
                        voice_output_volume,
                    } => {
                        if let Err(message) = client.update_voice_state(
                            guild_id,
                            Some(channel_id),
                            self_mute,
                            self_deaf,
                        ) {
                            logging::error("app", &message);
                            client
                                .publish_event(AppEvent::VoiceConnectionStatusChanged {
                                    guild_id,
                                    channel_id: Some(channel_id),
                                    status: VoiceConnectionStatus::Failed,
                                    message: Some(message),
                                })
                                .await;
                        } else {
                            client.update_voice_capture_permission(
                                guild_id,
                                channel_id,
                                allow_microphone_transmit,
                                microphone_sensitivity,
                                microphone_volume,
                                voice_output_volume,
                            );
                            client
                                .publish_event(AppEvent::VoiceConnectionStatusChanged {
                                    guild_id,
                                    channel_id: Some(channel_id),
                                    status: VoiceConnectionStatus::Connecting,
                                    message: Some("Voice join requested".to_owned()),
                                })
                                .await;
                        }
                    }
                    AppCommand::UpdateVoiceState {
                        guild_id,
                        channel_id,
                        self_mute,
                        self_deaf,
                    } => {
                        if let Err(message) = client.update_voice_state(
                            guild_id,
                            Some(channel_id),
                            self_mute,
                            self_deaf,
                        ) {
                            logging::error("app", &message);
                            client
                                .publish_event(AppEvent::GatewayError { message })
                                .await;
                        }
                    }
                    AppCommand::UpdateVoiceCapturePermission {
                        guild_id,
                        channel_id,
                        allow_microphone_transmit,
                        microphone_sensitivity,
                        microphone_volume,
                        voice_output_volume,
                    } => {
                        client.update_voice_capture_permission(
                            guild_id,
                            channel_id,
                            allow_microphone_transmit,
                            microphone_sensitivity,
                            microphone_volume,
                            voice_output_volume,
                        );
                    }
                    AppCommand::LeaveVoiceChannel {
                        guild_id,
                        self_mute,
                        self_deaf,
                    } => {
                        if let Err(message) =
                            client.update_voice_state(guild_id, None, self_mute, self_deaf)
                        {
                            logging::error("app", &message);
                            client
                                .publish_event(AppEvent::VoiceConnectionStatusChanged {
                                    guild_id,
                                    channel_id: None,
                                    status: VoiceConnectionStatus::Failed,
                                    message: Some(message),
                                })
                                .await;
                        } else {
                            client
                                .publish_event(AppEvent::VoiceConnectionStatusChanged {
                                    guild_id,
                                    channel_id: None,
                                    status: VoiceConnectionStatus::Disconnected,
                                    message: Some("Voice leave requested".to_owned()),
                                })
                                .await;
                        }
                    }
                    AppCommand::LoadAttachmentPreview { url } => {
                        let Ok(_permit) = attachment_preview_permits.acquire_owned().await else {
                            let message = "attachment preview limiter closed".to_owned();
                            logging::error("preview", &message);
                            client
                                .publish_event(AppEvent::AttachmentPreviewLoadFailed {
                                    url,
                                    message,
                                })
                                .await;
                            return;
                        };
                        match timeout(ATTACHMENT_PREVIEW_TIMEOUT, fetch_attachment_preview(&url))
                            .await
                        {
                            Err(_) => {
                                let message = "download image preview timed out".to_owned();
                                logging::error("preview", &message);
                                client
                                    .publish_event(AppEvent::AttachmentPreviewLoadFailed {
                                        url,
                                        message,
                                    })
                                    .await;
                            }
                            Ok(bytes) => match bytes {
                                Ok(bytes) => {
                                    client
                                        .publish_event(AppEvent::AttachmentPreviewLoaded {
                                            url,
                                            bytes,
                                        })
                                        .await
                                }
                                Err(message) => {
                                    logging::error("preview", &message);
                                    client
                                        .publish_event(AppEvent::AttachmentPreviewLoadFailed {
                                            url,
                                            message,
                                        })
                                        .await;
                                }
                            },
                        }
                    }
                    AppCommand::LoadProfileAvatarPreview { key, upload } => {
                        match read_profile_avatar_image(&upload).await {
                            Ok(image) => {
                                client
                                    .publish_event(AppEvent::AttachmentPreviewLoaded {
                                        url: key,
                                        bytes: image.bytes,
                                    })
                                    .await;
                            }
                            Err(message) => {
                                logging::error("preview", &message);
                                client
                                    .publish_event(AppEvent::AttachmentPreviewLoadFailed {
                                        url: key,
                                        message,
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::SendMessage {
                        channel_id,
                        content,
                        reply_to,
                        attachments,
                    } => match client
                        .send_message(channel_id, &content, reply_to, &attachments)
                        .await
                    {
                        Ok(message) => client.publish_event(message_create_event(message)).await,
                        Err(error) => {
                            log_app_error("send message failed", &error);
                            client
                                .publish_event(AppEvent::GatewayError {
                                    message: format!("send message failed: {error}"),
                                })
                                .await;
                        }
                    },
                    AppCommand::LoadApplicationCommands { guild_id } => {
                        match client.load_application_commands(guild_id).await {
                            Ok(Some(commands)) => {
                                client
                                    .publish_event(AppEvent::ApplicationCommandsLoaded {
                                        guild_id,
                                        commands,
                                    })
                                    .await;
                            }
                            Ok(None) => {}
                            Err(error) => log_app_error("load application commands failed", &error),
                        }
                    }
                    AppCommand::RunApplicationCommand { invocation } => {
                        if let Err(error) = client.run_application_command(&invocation).await {
                            log_app_error("run application command failed", &error);
                            client
                                .publish_event(AppEvent::GatewayError {
                                    message: format!("run application command failed: {error}"),
                                })
                                .await;
                        }
                    }
                    AppCommand::EditMessage {
                        channel_id,
                        message_id,
                        content,
                    } => match client.edit_message(channel_id, message_id, &content).await {
                        Ok(message) => {
                            client.publish_event(message_update_event(message)).await;
                        }
                        Err(error) => {
                            log_app_error("edit message failed", &error);
                            client
                                .publish_event(AppEvent::GatewayError {
                                    message: format!("edit message failed: {error}"),
                                })
                                .await;
                        }
                    },
                    AppCommand::DeleteMessage {
                        channel_id,
                        message_id,
                    } => match client.delete_message(channel_id, message_id).await {
                        Ok(()) => {
                            client
                                .publish_event(AppEvent::MessageDelete {
                                    guild_id: None,
                                    channel_id,
                                    message_id,
                                })
                                .await;
                        }
                        Err(error) => {
                            log_app_error("delete message failed", &error);
                            client
                                .publish_event(AppEvent::GatewayError {
                                    message: format!("delete message failed: {error}"),
                                })
                                .await;
                        }
                    },
                    AppCommand::LeaveGuild { guild_id, label } => {
                        match client.leave_guild(guild_id).await {
                            Ok(()) => {
                                client
                                    .publish_event(AppEvent::GuildDelete { guild_id })
                                    .await;
                            }
                            Err(error) => {
                                log_app_error("leave guild failed", &error);
                                client
                                    .publish_event(AppEvent::GatewayError {
                                        message: format!("leave server {label} failed: {error}"),
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::OpenUrl { url } => {
                        if let Err(error) = open_url(&url) {
                            logging::error("app", format!("open url failed: {error}"));
                            client
                                .publish_event(AppEvent::GatewayError {
                                    message: format!("open url failed: {error}"),
                                })
                                .await;
                        }
                    }
                    AppCommand::DownloadAttachment {
                        url,
                        filename,
                        source,
                    } => {
                        match timeout(
                            ATTACHMENT_PREVIEW_TIMEOUT,
                            download_attachment(&url, &filename),
                        )
                        .await
                        {
                            Err(_) => {
                                let message = "download attachment timed out".to_owned();
                                logging::error("attachment", &message);
                                client
                                    .publish_event(AppEvent::GatewayError { message })
                                    .await;
                            }
                            Ok(Ok(path)) => {
                                client
                                    .publish_event(AppEvent::AttachmentDownloadCompleted {
                                        path: path.display().to_string(),
                                        source,
                                    })
                                    .await
                            }
                            Ok(Err(message)) => {
                                logging::error("attachment", &message);
                                client
                                    .publish_event(AppEvent::GatewayError { message })
                                    .await;
                            }
                        }
                    }
                    AppCommand::AddReaction {
                        channel_id,
                        message_id,
                        emoji,
                    } => match client.add_reaction(channel_id, message_id, &emoji).await {
                        Ok(()) => {
                            client
                                .publish_event(AppEvent::CurrentUserReactionAdd {
                                    channel_id,
                                    message_id,
                                    emoji: emoji.clone(),
                                })
                                .await;
                        }
                        Err(error) => {
                            log_app_error("add reaction failed", &error);
                            client
                                .publish_event(AppEvent::GatewayError {
                                    message: format!("add reaction failed: {error}"),
                                })
                                .await;
                        }
                    },
                    AppCommand::RemoveReaction {
                        channel_id,
                        message_id,
                        emoji,
                    } => match client
                        .remove_current_user_reaction(channel_id, message_id, &emoji)
                        .await
                    {
                        Ok(()) => {
                            client
                                .publish_event(AppEvent::CurrentUserReactionRemove {
                                    channel_id,
                                    message_id,
                                    emoji: emoji.clone(),
                                })
                                .await;
                        }
                        Err(error) => {
                            log_app_error("remove reaction failed", &error);
                            client
                                .publish_event(AppEvent::GatewayError {
                                    message: format!("remove reaction failed: {error}"),
                                })
                                .await;
                        }
                    },
                    AppCommand::LoadReactionUsers {
                        channel_id,
                        message_id,
                        reactions,
                    } => {
                        let mut loaded_reactions = Vec::with_capacity(reactions.len());
                        let mut failed = false;
                        for emoji in reactions {
                            match client
                                .load_reaction_users(channel_id, message_id, &emoji)
                                .await
                            {
                                Ok(users) => {
                                    loaded_reactions.push(ReactionUsersInfo { emoji, users })
                                }
                                Err(error) => {
                                    log_app_error("load reaction users failed", &error);
                                    client
                                        .publish_event(AppEvent::GatewayError {
                                            message: format!("load reaction users failed: {error}"),
                                        })
                                        .await;
                                    failed = true;
                                    break;
                                }
                            }
                        }
                        if !failed {
                            client
                                .publish_event(AppEvent::ReactionUsersLoaded {
                                    channel_id,
                                    message_id,
                                    reactions: loaded_reactions,
                                })
                                .await;
                        }
                    }
                    AppCommand::LoadPinnedMessages { channel_id } => {
                        match client.load_pinned_messages(channel_id).await {
                            Ok(messages) => {
                                client
                                    .publish_event(AppEvent::PinnedMessagesLoaded {
                                        channel_id,
                                        messages,
                                    })
                                    .await;
                            }
                            Err(error) => {
                                log_app_error("load pinned messages failed", &error);
                                client
                                    .publish_event(AppEvent::PinnedMessagesLoadFailed {
                                        channel_id,
                                        message: format!("load pinned messages failed: {error}"),
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::SetMessagePinned {
                        channel_id,
                        message_id,
                        pinned,
                    } => match client
                        .set_message_pinned(channel_id, message_id, pinned)
                        .await
                    {
                        Ok(()) => {
                            client
                                .publish_event(AppEvent::MessagePinnedUpdate {
                                    channel_id,
                                    message_id,
                                    pinned,
                                })
                                .await;
                        }
                        Err(error) => {
                            log_app_error("set pin failed", &error);
                            client
                                .publish_event(AppEvent::GatewayError {
                                    message: format!("set pin failed: {error}"),
                                })
                                .await;
                        }
                    },
                    AppCommand::VotePoll {
                        channel_id,
                        message_id,
                        answer_ids,
                    } => match client.vote_poll(channel_id, message_id, &answer_ids).await {
                        Ok(()) => {
                            client
                                .publish_event(AppEvent::CurrentUserPollVoteUpdate {
                                    channel_id,
                                    message_id,
                                    answer_ids,
                                })
                                .await;
                        }
                        Err(error) => {
                            log_app_error("poll vote failed", &error);
                            client
                                .publish_event(AppEvent::GatewayError {
                                    message: format!("poll vote failed: {error}"),
                                })
                                .await;
                        }
                    },
                    AppCommand::LoadUserProfile { user_id, guild_id } => {
                        let profile_request = client.next_user_profile_request(user_id, guild_id);
                        let note_request = client.next_user_note_request(user_id);
                        if let Some((user_id, guild_id, is_self)) = profile_request {
                            match client.load_user_profile(user_id, guild_id, is_self).await {
                                Ok(profile) => {
                                    client
                                        .publish_event(AppEvent::UserProfileLoaded {
                                            guild_id,
                                            profile,
                                        })
                                        .await;
                                }
                                Err(error) => {
                                    log_app_error("load user profile failed", &error);
                                    client
                                        .publish_event(AppEvent::UserProfileLoadFailed {
                                            user_id,
                                            guild_id,
                                            message: error.to_string(),
                                        })
                                        .await;
                                }
                            }
                        }
                        if let Some(user_id) = note_request {
                            match client.load_user_note(user_id).await {
                                Ok(note) => {
                                    client
                                        .publish_event(AppEvent::UserNoteLoaded { user_id, note })
                                        .await;
                                }
                                Err(error) => {
                                    client.mark_user_note_request_failed(user_id);
                                    log_app_error("load user note failed", &error);
                                }
                            }
                        }
                    }
                    AppCommand::LoadUserNote { user_id } => {
                        let Some(user_id) = client.next_user_note_request(user_id) else {
                            return;
                        };
                        match client.load_user_note(user_id).await {
                            Ok(note) => {
                                client
                                    .publish_event(AppEvent::UserNoteLoaded { user_id, note })
                                    .await;
                            }
                            Err(error) => {
                                client.mark_user_note_request_failed(user_id);
                                log_app_error("load user note failed", &error);
                            }
                        }
                    }
                    AppCommand::UpdateUserProfile { update } => {
                        let user_id = update.user_id;
                        let guild_id = update.guild_id;
                        if client.current_user_id() != Some(user_id) {
                            client
                                .publish_event(AppEvent::UserProfileUpdateFailed {
                                    user_id,
                                    guild_id,
                                    message: "profile update can only edit the current user"
                                        .to_owned(),
                                })
                                .await;
                            return;
                        }
                        match client.update_user_profile(&update).await {
                            Ok(()) => match client.load_user_profile(user_id, guild_id, true).await
                            {
                                Ok(profile) => {
                                    client
                                        .publish_event(AppEvent::UserProfileLoaded {
                                            guild_id,
                                            profile,
                                        })
                                        .await;
                                }
                                Err(error) => {
                                    log_app_error(
                                        "reload user profile after update failed",
                                        &error,
                                    );
                                    client
                                        .publish_event(AppEvent::UserProfileLoadFailed {
                                            user_id,
                                            guild_id,
                                            message: error.to_string(),
                                        })
                                        .await;
                                }
                            },
                            Err(error) => {
                                log_app_error("update user profile failed", &error);
                                client
                                    .publish_event(AppEvent::UserProfileUpdateFailed {
                                        user_id,
                                        guild_id,
                                        message: error.to_string(),
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::UpdateCurrentUserStatus { status } => {
                        match client.update_presence_status(status).await {
                            Ok(activities) => {
                                if let Some(user_id) = client.current_user_id() {
                                    client
                                        .publish_event(AppEvent::UserPresenceUpdate {
                                            user_id,
                                            status,
                                            activities,
                                        })
                                        .await;
                                }
                            }
                            Err(error) => {
                                log_app_error("update presence status failed", &error);
                                client
                                    .publish_event(AppEvent::GatewayError {
                                        message: error.to_string(),
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::UpdateCurrentUserActivity { status, activities } => {
                        if let Err(error) =
                            client.update_presence_activity(status, activities.clone())
                        {
                            log_app_error("update presence activity failed", &error);
                            client
                                .publish_event(AppEvent::GatewayError {
                                    message: error.to_string(),
                                })
                                .await;
                        } else if let Some(user_id) = client.current_user_id() {
                            client
                                .publish_event(AppEvent::UserPresenceUpdate {
                                    user_id,
                                    status,
                                    activities,
                                })
                                .await;
                        }
                    }
                    AppCommand::AckChannel {
                        channel_id,
                        message_id,
                    } => {
                        client.clear_read_ack(channel_id);
                        client
                            .publish_optimistic_read_ack(channel_id, message_id)
                            .await;
                        // A failure here only loses cross-client sync because
                        // the backend has already published the local read
                        // state update.
                        if let Err(error) = client.ack_channel(channel_id, message_id).await {
                            log_app_error("ack channel failed", &error);
                        }
                    }
                    AppCommand::ScheduleAckChannel {
                        channel_id,
                        message_id,
                    } => {
                        client
                            .publish_optimistic_read_ack(channel_id, message_id)
                            .await;
                        client.schedule_read_ack(channel_id, message_id, std::time::Instant::now());
                    }
                    AppCommand::SetGuildMuted {
                        guild_id,
                        muted,
                        duration,
                        label: _,
                    } => {
                        let mute_end_time = mute_end_time_from_duration(duration, muted);
                        let selected_time_window =
                            selected_time_window_from_duration(duration, muted);
                        match client
                            .set_guild_muted(guild_id, muted, mute_end_time, selected_time_window)
                            .await
                        {
                            Ok(()) => {
                                client
                                    .publish_event(AppEvent::UserGuildNotificationSettingsUpdate {
                                        settings: guild_notification_settings_update(
                                            &client,
                                            Some(guild_id),
                                            Some((muted, mute_end_time)),
                                            None,
                                        ),
                                    })
                                    .await;
                            }
                            Err(error) => {
                                log_app_error("set guild mute failed", &error);
                                client
                                    .publish_event(AppEvent::GatewayError {
                                        message: format!("set guild mute failed: {error}"),
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::SetChannelMuted {
                        guild_id,
                        channel_id,
                        muted,
                        duration,
                        label: _,
                    } => {
                        let mute_end_time = mute_end_time_from_duration(duration, muted);
                        let selected_time_window =
                            selected_time_window_from_duration(duration, muted);
                        match client
                            .set_channel_muted(
                                guild_id,
                                channel_id,
                                muted,
                                mute_end_time,
                                selected_time_window,
                            )
                            .await
                        {
                            Ok(()) => {
                                client
                                    .publish_event(AppEvent::UserGuildNotificationSettingsUpdate {
                                        settings: guild_notification_settings_update(
                                            &client,
                                            guild_id,
                                            None,
                                            Some((channel_id, muted, mute_end_time)),
                                        ),
                                    })
                                    .await;
                            }
                            Err(error) => {
                                log_app_error("set channel mute failed", &error);
                                client
                                    .publish_event(AppEvent::GatewayError {
                                        message: format!("set channel mute failed: {error}"),
                                    })
                                    .await;
                            }
                        }
                    }
                    AppCommand::AckChannels { targets } => {
                        client.clear_read_acks(targets.iter().map(|(channel_id, _)| *channel_id));
                        client.publish_optimistic_read_acks(&targets).await;
                        // A failure here only loses cross-client sync because
                        // the backend has already published the local read
                        // state updates.
                        if let Err(error) = client.ack_channels(&targets).await {
                            log_app_error("ack channels failed", &error);
                        }
                    }
                }
            });
        }
    })
}

fn log_app_error(context: &str, error: &AppError) {
    logging::error(
        "app",
        format!("{context}: {}; detail={}", error, error.log_detail()),
    );
}

fn mute_end_time_from_duration(
    duration: Option<MuteDuration>,
    muted: bool,
) -> Option<chrono::DateTime<Utc>> {
    if !muted {
        return None;
    }
    duration
        .and_then(MuteDuration::minutes)
        .filter(|minutes| *minutes > 0)
        .and_then(|minutes| i64::try_from(minutes).ok())
        .map(|minutes| Utc::now() + ChronoDuration::minutes(minutes))
}

fn selected_time_window_from_duration(duration: Option<MuteDuration>, muted: bool) -> Option<i64> {
    muted.then(|| {
        duration
            .unwrap_or(MuteDuration::Permanent)
            .selected_time_window_seconds()
    })
}

fn guild_notification_settings_update(
    client: &DiscordClient,
    guild_id: Option<Id<crate::discord::ids::marker::GuildMarker>>,
    guild_update: Option<(bool, Option<chrono::DateTime<Utc>>)>,
    channel_override: Option<(
        Id<crate::discord::ids::marker::ChannelMarker>,
        bool,
        Option<chrono::DateTime<Utc>>,
    )>,
) -> GuildNotificationSettingsInfo {
    let snapshot = client.current_discord_snapshot();
    let mut settings = snapshot
        .to_state()
        .guild_notification_settings_info(guild_id);
    if let Some((muted, mute_end_time)) = guild_update {
        settings.muted = muted;
        settings.mute_end_time =
            mute_end_time.map(|value| value.to_rfc3339_opts(SecondsFormat::Millis, true));
    }
    if let Some((channel_id, muted, mute_end_time)) = channel_override {
        if let Some(override_info) = settings
            .channel_overrides
            .iter_mut()
            .find(|override_info| override_info.channel_id == channel_id)
        {
            override_info.muted = muted;
            override_info.mute_end_time =
                mute_end_time.map(|value| value.to_rfc3339_opts(SecondsFormat::Millis, true));
        } else {
            settings
                .channel_overrides
                .push(ChannelNotificationOverrideInfo {
                    channel_id,
                    message_notifications: None,
                    muted,
                    mute_end_time: mute_end_time
                        .map(|value| value.to_rfc3339_opts(SecondsFormat::Millis, true)),
                });
        }
    }
    settings
}

/// Builds the Discord REST endpoint string for a message-history request so
/// debug logs name exactly what was attempted, e.g.
/// `GET /channels/123/messages?limit=50&before=789`.
fn format_message_history_endpoint(
    channel_id: Id<ChannelMarker>,
    before: Option<Id<MessageMarker>>,
    limit: u16,
) -> String {
    match before {
        Some(message_id) => format!(
            "GET /channels/{}/messages?limit={limit}&before={}",
            channel_id.get(),
            message_id.get(),
        ),
        None => format!("GET /channels/{}/messages?limit={limit}", channel_id.get(),),
    }
}

fn format_message_history_anchor_endpoint(
    channel_id: Id<ChannelMarker>,
    anchor_name: &str,
    message_id: Id<MessageMarker>,
    limit: u16,
) -> String {
    format!(
        "GET /channels/{}/messages?limit={limit}&{anchor_name}={}",
        channel_id.get(),
        message_id.get(),
    )
}

fn message_create_event(message: MessageInfo) -> AppEvent {
    AppEvent::MessageCreate {
        guild_id: message.guild_id,
        channel_id: message.channel_id,
        message_id: message.message_id,
        author_id: message.author_id,
        author: message.author,
        author_avatar_url: message.author_avatar_url,
        author_is_bot: message.author_is_bot,
        author_role_ids: message.author_role_ids,
        message_kind: message.message_kind,
        interaction: message.interaction,
        reference: message.reference,
        reply: message.reply,
        poll: message.poll,
        content: message.content,
        sticker_names: message.sticker_names,
        mentions: message.mentions,
        attachments: message.attachments,
        embeds: message.embeds,
        forwarded_snapshots: message.forwarded_snapshots,
    }
}

fn message_update_event(message: MessageInfo) -> AppEvent {
    AppEvent::MessageUpdate {
        guild_id: message.guild_id,
        channel_id: message.channel_id,
        message_id: message.message_id,
        poll: message.poll,
        content: message.content,
        sticker_names: Some(message.sticker_names),
        mentions: Some(message.mentions),
        attachments: AttachmentUpdate::Replace(message.attachments),
        embeds: Some(message.embeds),
        edited_timestamp: message.edited_timestamp,
    }
}

async fn fetch_attachment_preview(url: &str) -> std::result::Result<Vec<u8>, String> {
    fetch_limited_bytes(
        url,
        MAX_ATTACHMENT_PREVIEW_BYTES,
        "image preview",
        "download image preview failed",
        "read image preview failed",
    )
    .await
}

async fn download_attachment(url: &str, filename: &str) -> std::result::Result<PathBuf, String> {
    let bytes = fetch_limited_bytes(
        url,
        MAX_ATTACHMENT_DOWNLOAD_BYTES,
        "attachment",
        "download attachment failed",
        "read attachment failed",
    )
    .await?;

    let directory = downloads_directory()?;
    fs::create_dir_all(&directory)
        .map_err(|error| format!("create download directory failed: {error}"))?;
    write_unique_download_file(&directory, &sanitize_filename(filename), &bytes)
}

async fn fetch_limited_bytes(
    url: &str,
    max_bytes: usize,
    size_label: &str,
    download_error: &str,
    read_error: &str,
) -> std::result::Result<Vec<u8>, String> {
    let response = reqwest::get(url)
        .await
        .map_err(|error| format!("{download_error}: {error}"))?
        .error_for_status()
        .map_err(|error| format!("{download_error}: {error}"))?;

    if let Some(length) = response.content_length()
        && length > max_bytes as u64
    {
        return Err(format!(
            "{size_label} is too large: {length} bytes (max {max_bytes})"
        ));
    }

    let mut response = response;
    let mut bytes = Vec::new();
    while let Some(chunk) = response
        .chunk()
        .await
        .map_err(|error| format!("{read_error}: {error}"))?
    {
        if bytes.len().saturating_add(chunk.len()) > max_bytes {
            return Err(format!(
                "{size_label} is too large: {} bytes (max {max_bytes})",
                bytes.len().saturating_add(chunk.len())
            ));
        }
        bytes.extend_from_slice(&chunk);
    }

    Ok(bytes)
}

fn downloads_directory() -> std::result::Result<PathBuf, String> {
    crate::paths::download_dir()
        .ok_or_else(|| "could not resolve user download directory".to_owned())
}

fn sanitize_filename(filename: &str) -> String {
    let sanitized: String = filename
        .chars()
        .map(|character| {
            if character.is_control() || matches!(character, '/' | '\\') {
                '_'
            } else {
                character
            }
        })
        .collect();
    let sanitized = sanitized.trim_matches([' ', '.']);
    if sanitized.is_empty() {
        "attachment".to_owned()
    } else {
        sanitized.to_owned()
    }
}

fn write_unique_download_file(
    directory: &Path,
    filename: &str,
    bytes: &[u8],
) -> std::result::Result<PathBuf, String> {
    let original = Path::new(filename);
    let stem = original
        .file_stem()
        .and_then(|value| value.to_str())
        .unwrap_or("attachment");
    let extension = original.extension().and_then(|value| value.to_str());

    for index in 0.. {
        let candidate = if index == 0 {
            directory.join(filename)
        } else {
            match extension {
                Some(extension) => directory.join(format!("{stem} ({index}).{extension}")),
                None => directory.join(format!("{stem} ({index})")),
            }
        };

        match fs::OpenOptions::new()
            .create_new(true)
            .write(true)
            .open(&candidate)
        {
            Ok(mut file) => {
                file.write_all(bytes)
                    .map_err(|error| format!("write attachment failed: {error}"))?;
                return Ok(candidate);
            }
            Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {}
            Err(error) => return Err(format!("write attachment failed: {error}")),
        }
    }

    unreachable!("unbounded search returns a path before exhausting usize")
}

fn open_url(url: &str) -> io::Result<()> {
    let url = normalize_openable_url(url)
        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "unsupported URL scheme"))?;
    let status = open_url_command(&url).status()?;
    if status.success() {
        Ok(())
    } else {
        Err(io::Error::other(format!(
            "open command exited with status {status}"
        )))
    }
}

fn open_url_command(url: &str) -> Command {
    let spec = current_open_url_command_spec(url);
    let mut command = Command::new(spec.program);
    command.args(spec.args);
    command
}

struct UrlOpenCommandSpec {
    program: &'static str,
    args: Vec<String>,
}

fn current_open_url_command_spec(url: &str) -> UrlOpenCommandSpec {
    #[cfg(target_os = "macos")]
    {
        UrlOpenCommandSpec {
            program: "open",
            args: vec![url.to_owned()],
        }
    }

    #[cfg(target_os = "windows")]
    {
        windows_open_url_command_spec(url)
    }

    #[cfg(all(unix, not(target_os = "macos")))]
    {
        UrlOpenCommandSpec {
            program: "xdg-open",
            args: vec![url.to_owned()],
        }
    }
}

#[cfg(any(test, target_os = "windows"))]
fn windows_open_url_command_spec(url: &str) -> UrlOpenCommandSpec {
    UrlOpenCommandSpec {
        program: "rundll32",
        args: vec!["url.dll,FileProtocolHandler".to_owned(), url.to_owned()],
    }
}

struct ResolvedToken {
    token: String,
    warnings: Vec<String>,
}

async fn resolve_token() -> Result<ResolvedToken> {
    let mut warnings = Vec::new();

    match token_store::load_token() {
        Ok(Some(token)) => {
            if let Err(error) = validate_token_header(&token) {
                warnings.push(format!(
                    "saved Discord token is invalid: {error}; enter a new token"
                ));
            } else {
                return Ok(ResolvedToken { token, warnings });
            }
        }
        Ok(None) => {}
        Err(error) => warnings.push(format!(
            "credential store unavailable: {error}; enter a token to continue for this session"
        )),
    }

    let login_notice = login_notice_for_token_warnings(&warnings);

    let token = tui::prompt_login(login_notice).await?;
    validate_token_header(&token)?;
    if let Err(error) = token_store::save_token(&token) {
        warnings.push(format!("token was not saved: {error}"));
    }

    Ok(ResolvedToken { token, warnings })
}

fn login_notice_for_token_warnings(warnings: &[String]) -> Option<String> {
    if warnings
        .iter()
        .any(|warning| warning.starts_with("saved Discord token"))
    {
        Some("Saved Discord token is invalid; enter a new token.".to_owned())
    } else if warnings.is_empty() {
        None
    } else {
        Some("Credential storage is unavailable; token may not be saved.".to_owned())
    }
}

fn leave_current_voice_channel_on_shutdown(client: &DiscordClient) {
    let Some(voice) = client.requested_voice_connection() else {
        return;
    };
    if let Err(message) =
        client.update_voice_state(voice.guild_id, None, voice.self_mute, voice.self_deaf)
    {
        logging::error("app", format!("voice shutdown leave failed: {message}"));
    }
}

async fn shutdown_gateway(client: &DiscordClient, mut gateway_task: tokio::task::JoinHandle<()>) {
    if let Err(message) = client.shutdown_gateway() {
        logging::error("app", format!("gateway shutdown request failed: {message}"));
        gateway_task.abort();
    }

    tokio::select! {
        result = &mut gateway_task => {
            if let Err(error) = result
                && !error.is_cancelled()
            {
                logging::error("app", format!("gateway task ended unexpectedly: {error}"));
            }
        }
        () = sleep(Duration::from_secs(2)) => {
            gateway_task.abort();
            if let Err(error) = gateway_task.await
                && !error.is_cancelled()
            {
                logging::error("app", format!("gateway task ended unexpectedly: {error}"));
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use std::{fs, process};

    use super::{
        login_notice_for_token_warnings, open_url, sanitize_filename,
        windows_open_url_command_spec, write_unique_download_file,
    };

    fn unix_timestamp_nanos() -> u128 {
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|duration| duration.as_nanos())
            .unwrap_or_default()
    }

    #[test]
    fn write_unique_download_file_uses_next_available_name() {
        let directory = std::env::temp_dir().join(format!(
            "concord-download-test-{}-{}",
            process::id(),
            unix_timestamp_nanos()
        ));
        fs::create_dir_all(&directory).expect("test directory should be created");
        let existing = directory.join("cat.png");
        fs::write(&existing, b"old").expect("existing file should be written");

        let path = write_unique_download_file(&directory, "cat.png", b"new")
            .expect("download file should be written");

        assert_eq!(
            path.file_name().and_then(|name| name.to_str()),
            Some("cat (1).png")
        );
        assert_eq!(
            fs::read(&existing).expect("existing file should remain"),
            b"old"
        );
        assert_eq!(fs::read(&path).expect("new file should be written"), b"new");

        fs::remove_dir_all(&directory).expect("test directory should be removed");
    }

    #[test]
    fn login_notice_for_token_warnings_reports_user_action() {
        let cases = [
            (
                "saved Discord token is invalid: bad; enter a new token",
                "Saved Discord token is invalid; enter a new token.",
            ),
            (
                "credential store unavailable: permission denied",
                "Credential storage is unavailable; token may not be saved.",
            ),
        ];

        for (warning, expected) in cases {
            let warnings = vec![warning.to_owned()];
            assert_eq!(
                login_notice_for_token_warnings(&warnings).as_deref(),
                Some(expected)
            );
        }
    }

    #[test]
    fn sanitize_filename_replaces_path_separators() {
        assert_eq!(sanitize_filename("../cat\\dog.png"), "_cat_dog.png");
    }

    #[test]
    fn open_url_rejects_non_web_schemes_before_spawning_opener() {
        let error = open_url("file:///etc/passwd").expect_err("file URLs should be rejected");

        assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
    }

    #[test]
    fn windows_url_opener_avoids_cmd_shell_parsing() {
        let spec = windows_open_url_command_spec("https://example.com/?a=1&b=2");

        assert_eq!(spec.program, "rundll32");
        assert_eq!(
            spec.args,
            vec![
                "url.dll,FileProtocolHandler".to_owned(),
                "https://example.com/?a=1&b=2".to_owned(),
            ]
        );
    }
}
