#[cfg(test)]
use crate::app_command::AppCommand as Op;
use codex_app_server_protocol::McpServerElicitationAction;
use codex_app_server_protocol::RequestId as AppServerRequestId;
use codex_protocol::ThreadId;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use textwrap::wrap;
use url::Url;

use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::measure_rows_height;
use super::selection_popup_common::render_rows;
use crate::app::app_server_requests::ResolvedAppServerRequest;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::key_hint::KeyBindingListExt;
use crate::keymap::ListKeymap;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::style::user_message_style;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_lines;

const MCP_CODEX_APPS_SERVER_NAME: &str = "codex_apps";
const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure";
const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure";
const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id";
const CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY: &str = "connector_name";

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AppLinkScreen {
    Link,
    InstallConfirmation,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum AppLinkSuggestionType {
    Install,
    Enable,
    Auth,
    ExternalAction,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AppLinkElicitationTarget {
    pub(crate) thread_id: ThreadId,
    pub(crate) server_name: String,
    pub(crate) request_id: AppServerRequestId,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct AppLinkViewParams {
    pub(crate) app_id: String,
    pub(crate) title: String,
    pub(crate) description: Option<String>,
    pub(crate) instructions: String,
    pub(crate) url: String,
    pub(crate) is_installed: bool,
    pub(crate) is_enabled: bool,
    pub(crate) suggest_reason: Option<String>,
    pub(crate) suggestion_type: Option<AppLinkSuggestionType>,
    pub(crate) elicitation_target: Option<AppLinkElicitationTarget>,
}

impl AppLinkViewParams {
    pub(crate) fn from_url_app_server_request(
        thread_id: ThreadId,
        server_name: &str,
        request_id: AppServerRequestId,
        request: &codex_app_server_protocol::McpServerElicitationRequest,
    ) -> Option<Self> {
        let codex_app_server_protocol::McpServerElicitationRequest::Url {
            meta,
            message,
            url,
            elicitation_id,
        } = request
        else {
            return None;
        };
        if server_name == MCP_CODEX_APPS_SERVER_NAME {
            let url = validate_external_url(url, /*require_chatgpt_host*/ true)?;
            return Self::from_codex_apps_auth_url_parts(
                thread_id,
                server_name,
                request_id,
                meta.as_ref(),
                message,
                url.as_str(),
                elicitation_id,
            );
        }

        let url = validate_external_url(url, /*require_chatgpt_host*/ false)?;
        Some(Self::from_generic_url_parts(
            thread_id,
            server_name,
            request_id,
            message,
            url.as_str(),
            elicitation_id,
        ))
    }

    fn from_codex_apps_auth_url_parts(
        thread_id: ThreadId,
        server_name: &str,
        request_id: AppServerRequestId,
        meta: Option<&serde_json::Value>,
        message: &str,
        url: &str,
        elicitation_id: &str,
    ) -> Option<Self> {
        let auth_failure = meta?
            .as_object()?
            .get(MCP_TOOL_CODEX_APPS_META_KEY)?
            .as_object()?
            .get(CONNECTOR_AUTH_FAILURE_META_KEY)?
            .as_object()?;
        if auth_failure
            .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY)
            .and_then(serde_json::Value::as_bool)
            != Some(true)
        {
            return None;
        }

        let app_id = auth_failure
            .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY)
            .and_then(serde_json::Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or(elicitation_id)
            .to_string();
        let title = auth_failure
            .get(CONNECTOR_AUTH_FAILURE_CONNECTOR_NAME_KEY)
            .and_then(serde_json::Value::as_str)
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .unwrap_or(app_id.as_str())
            .to_string();

        Some(Self {
            app_id,
            title,
            description: None,
            instructions: "Sign in to this app in your browser, then return here.".to_string(),
            url: url.to_string(),
            is_installed: true,
            is_enabled: true,
            suggest_reason: Some(message.to_string()),
            suggestion_type: Some(AppLinkSuggestionType::Auth),
            elicitation_target: Some(AppLinkElicitationTarget {
                thread_id,
                server_name: server_name.to_string(),
                request_id,
            }),
        })
    }

    fn from_generic_url_parts(
        thread_id: ThreadId,
        server_name: &str,
        request_id: AppServerRequestId,
        message: &str,
        url: &str,
        elicitation_id: &str,
    ) -> Self {
        Self {
            app_id: elicitation_id.to_string(),
            title: "Action required".to_string(),
            description: Some(format!("Server: {server_name}")),
            instructions: "Complete the requested action in your browser, then return here."
                .to_string(),
            url: url.to_string(),
            is_installed: true,
            is_enabled: true,
            suggest_reason: Some(message.to_string()),
            suggestion_type: Some(AppLinkSuggestionType::ExternalAction),
            elicitation_target: Some(AppLinkElicitationTarget {
                thread_id,
                server_name: server_name.to_string(),
                request_id,
            }),
        }
    }
}

fn validate_external_url(url: &str, require_chatgpt_host: bool) -> Option<Url> {
    let parsed = Url::parse(url).ok()?;
    if parsed.scheme() != "https" || parsed.host_str().is_none() {
        return None;
    }
    if !parsed.username().is_empty() || parsed.password().is_some() {
        return None;
    }
    if require_chatgpt_host && !is_allowed_chatgpt_auth_host(parsed.host_str()?) {
        return None;
    }
    Some(parsed)
}

fn is_allowed_chatgpt_auth_host(host: &str) -> bool {
    let host = host.to_ascii_lowercase();
    host == "chatgpt.com"
        || host == "chatgpt-staging.com"
        || host.ends_with(".chatgpt.com")
        || host.ends_with(".chatgpt-staging.com")
}

pub(crate) struct AppLinkView {
    app_id: String,
    title: String,
    description: Option<String>,
    instructions: String,
    url: String,
    is_installed: bool,
    is_enabled: bool,
    suggest_reason: Option<String>,
    suggestion_type: Option<AppLinkSuggestionType>,
    elicitation_target: Option<AppLinkElicitationTarget>,
    app_event_tx: AppEventSender,
    screen: AppLinkScreen,
    selected_action: usize,
    complete: bool,
    list_keymap: ListKeymap,
}

impl AppLinkView {
    #[cfg(test)]
    pub(crate) fn new(params: AppLinkViewParams, app_event_tx: AppEventSender) -> Self {
        Self::new_with_keymap(
            params,
            app_event_tx,
            crate::keymap::RuntimeKeymap::defaults().list,
        )
    }

    pub(crate) fn new_with_keymap(
        params: AppLinkViewParams,
        app_event_tx: AppEventSender,
        list_keymap: ListKeymap,
    ) -> Self {
        let AppLinkViewParams {
            app_id,
            title,
            description,
            instructions,
            url,
            is_installed,
            is_enabled,
            suggest_reason,
            suggestion_type,
            elicitation_target,
        } = params;
        Self {
            app_id,
            title,
            description,
            instructions,
            url,
            is_installed,
            is_enabled,
            suggest_reason,
            suggestion_type,
            elicitation_target,
            app_event_tx,
            screen: AppLinkScreen::Link,
            selected_action: 0,
            complete: false,
            list_keymap,
        }
    }

    fn action_labels(&self) -> Vec<&'static str> {
        if self.is_auth_suggestion() {
            return match self.screen {
                AppLinkScreen::Link => vec!["Open sign-in URL", "Back"],
                AppLinkScreen::InstallConfirmation => vec!["I already signed in", "Back"],
            };
        }
        if self.is_external_action_suggestion() {
            return match self.screen {
                AppLinkScreen::Link => vec!["Open link", "Back"],
                AppLinkScreen::InstallConfirmation => vec!["I finished", "Back"],
            };
        }

        match self.screen {
            AppLinkScreen::Link => {
                if self.is_installed {
                    vec![
                        "Manage on ChatGPT",
                        if self.is_enabled {
                            "Disable app"
                        } else {
                            "Enable app"
                        },
                        "Back",
                    ]
                } else {
                    vec!["Install on ChatGPT", "Back"]
                }
            }
            AppLinkScreen::InstallConfirmation => vec!["I already Installed it", "Back"],
        }
    }

    fn move_selection_prev(&mut self) {
        self.selected_action = self.selected_action.saturating_sub(1);
    }

    fn move_selection_next(&mut self) {
        self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1);
    }

    fn is_tool_suggestion(&self) -> bool {
        self.elicitation_target.is_some()
    }

    fn is_auth_suggestion(&self) -> bool {
        self.is_tool_suggestion() && self.suggestion_type == Some(AppLinkSuggestionType::Auth)
    }

    fn is_external_action_suggestion(&self) -> bool {
        self.is_tool_suggestion()
            && self.suggestion_type == Some(AppLinkSuggestionType::ExternalAction)
    }

    fn is_browser_action_suggestion(&self) -> bool {
        self.is_auth_suggestion() || self.is_external_action_suggestion()
    }

    fn resolve_elicitation(&self, decision: McpServerElicitationAction) {
        let Some(target) = self.elicitation_target.as_ref() else {
            return;
        };
        self.app_event_tx.resolve_elicitation(
            target.thread_id,
            target.server_name.clone(),
            target.request_id.clone(),
            decision,
            /*content*/ None,
            /*meta*/ None,
        );
    }

    fn decline_tool_suggestion(&mut self) {
        self.resolve_elicitation(McpServerElicitationAction::Decline);
        self.complete = true;
    }

    fn open_external_url(&mut self) {
        self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
            url: self.url.clone(),
        });
        if !self.is_installed || self.is_browser_action_suggestion() {
            self.screen = AppLinkScreen::InstallConfirmation;
            self.selected_action = 0;
        }
    }

    fn complete_external_flow_and_close(&mut self) {
        let should_refresh_connectors = self
            .elicitation_target
            .as_ref()
            .is_none_or(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME);
        if should_refresh_connectors {
            self.app_event_tx.send(AppEvent::RefreshConnectors {
                force_refetch: true,
            });
        }
        if self.is_tool_suggestion() {
            self.resolve_elicitation(McpServerElicitationAction::Accept);
        }
        self.complete = true;
    }

    fn back_to_link_screen(&mut self) {
        self.screen = AppLinkScreen::Link;
        self.selected_action = 0;
    }

    fn toggle_enabled(&mut self) {
        self.is_enabled = !self.is_enabled;
        self.app_event_tx.send(AppEvent::SetAppEnabled {
            id: self.app_id.clone(),
            enabled: self.is_enabled,
        });
        if self.is_tool_suggestion() {
            self.resolve_elicitation(McpServerElicitationAction::Accept);
            self.complete = true;
        }
    }

    fn activate_selected_action(&mut self) {
        if self.is_tool_suggestion() {
            match self.suggestion_type {
                Some(AppLinkSuggestionType::Enable) => match self.screen {
                    AppLinkScreen::Link => match self.selected_action {
                        0 => self.open_external_url(),
                        1 if self.is_installed => self.toggle_enabled(),
                        _ => self.decline_tool_suggestion(),
                    },
                    AppLinkScreen::InstallConfirmation => match self.selected_action {
                        0 => self.complete_external_flow_and_close(),
                        _ => self.decline_tool_suggestion(),
                    },
                },
                Some(AppLinkSuggestionType::Auth) => match self.screen {
                    AppLinkScreen::Link => match self.selected_action {
                        0 => self.open_external_url(),
                        _ => self.decline_tool_suggestion(),
                    },
                    AppLinkScreen::InstallConfirmation => match self.selected_action {
                        0 => self.complete_external_flow_and_close(),
                        _ => self.decline_tool_suggestion(),
                    },
                },
                Some(AppLinkSuggestionType::ExternalAction) => match self.screen {
                    AppLinkScreen::Link => match self.selected_action {
                        0 => self.open_external_url(),
                        _ => self.decline_tool_suggestion(),
                    },
                    AppLinkScreen::InstallConfirmation => match self.selected_action {
                        0 => self.complete_external_flow_and_close(),
                        _ => self.decline_tool_suggestion(),
                    },
                },
                Some(AppLinkSuggestionType::Install) | None => match self.screen {
                    AppLinkScreen::Link => match self.selected_action {
                        0 => self.open_external_url(),
                        _ => self.decline_tool_suggestion(),
                    },
                    AppLinkScreen::InstallConfirmation => match self.selected_action {
                        0 => self.complete_external_flow_and_close(),
                        _ => self.decline_tool_suggestion(),
                    },
                },
            }
            return;
        }

        match self.screen {
            AppLinkScreen::Link => match self.selected_action {
                0 => self.open_external_url(),
                1 if self.is_installed => self.toggle_enabled(),
                _ => self.complete = true,
            },
            AppLinkScreen::InstallConfirmation => match self.selected_action {
                0 => self.complete_external_flow_and_close(),
                _ => self.back_to_link_screen(),
            },
        }
    }

    fn content_lines(&self, width: u16) -> Vec<Line<'static>> {
        match self.screen {
            AppLinkScreen::Link => self.link_content_lines(width),
            AppLinkScreen::InstallConfirmation => self.install_confirmation_lines(width),
        }
    }

    fn link_content_lines(&self, width: u16) -> Vec<Line<'static>> {
        let usable_width = width.max(1) as usize;
        let mut lines: Vec<Line<'static>> = Vec::new();

        lines.push(Line::from(self.title.clone().bold()));
        if let Some(description) = self
            .description
            .as_deref()
            .map(str::trim)
            .filter(|description| !description.is_empty())
        {
            for line in wrap(description, usable_width) {
                lines.push(Line::from(line.into_owned().dim()));
            }
        }

        lines.push(Line::from(""));
        if let Some(suggest_reason) = self
            .suggest_reason
            .as_deref()
            .map(str::trim)
            .filter(|suggest_reason| !suggest_reason.is_empty())
        {
            for line in wrap(suggest_reason, usable_width) {
                lines.push(Line::from(line.into_owned().italic()));
            }
            lines.push(Line::from(""));
        }
        let is_browser_action_suggestion = self.is_browser_action_suggestion();
        if self.is_installed && !is_browser_action_suggestion {
            for line in wrap("Use $ to insert this app into the prompt.", usable_width) {
                lines.push(Line::from(line.into_owned()));
            }
            lines.push(Line::from(""));
        }

        if is_browser_action_suggestion {
            lines.push(Line::from("URL".dim()));
            for line in wrap(&self.url, usable_width) {
                lines.push(Line::from(line.into_owned()));
            }
            lines.push(Line::from(""));
        }

        let instructions = self.instructions.trim();
        if !instructions.is_empty() {
            for line in wrap(instructions, usable_width) {
                lines.push(Line::from(line.into_owned()));
            }
            if !is_browser_action_suggestion {
                for line in wrap(
                    "Newly installed apps can take a few minutes to appear in /apps.",
                    usable_width,
                ) {
                    lines.push(Line::from(line.into_owned()));
                }
                if !self.is_installed {
                    for line in wrap(
                        "After installed, use $ to insert this app into the prompt.",
                        usable_width,
                    ) {
                        lines.push(Line::from(line.into_owned()));
                    }
                }
            }
            lines.push(Line::from(""));
        }

        lines
    }

    fn install_confirmation_lines(&self, width: u16) -> Vec<Line<'static>> {
        let usable_width = width.max(1) as usize;
        let mut lines: Vec<Line<'static>> = Vec::new();

        let is_auth_suggestion = self.is_auth_suggestion();
        let is_external_action_suggestion = self.is_external_action_suggestion();
        let is_codex_apps_auth = is_auth_suggestion
            && self
                .elicitation_target
                .as_ref()
                .is_some_and(|target| target.server_name == MCP_CODEX_APPS_SERVER_NAME);
        lines.push(Line::from(
            if is_auth_suggestion {
                if is_codex_apps_auth {
                    "Finish App Sign In"
                } else {
                    "Finish Authentication"
                }
            } else if is_external_action_suggestion {
                "Finish in Browser"
            } else {
                "Finish App Setup"
            }
            .bold(),
        ));
        lines.push(Line::from(""));

        if is_auth_suggestion {
            for line in wrap(
                if is_codex_apps_auth {
                    "Sign in to the app on ChatGPT in the browser window that just opened."
                } else {
                    "Complete authentication in the browser window that just opened."
                },
                usable_width,
            ) {
                lines.push(Line::from(line.into_owned()));
            }
            for line in wrap(
                "Then return here and select \"I already signed in\".",
                usable_width,
            ) {
                lines.push(Line::from(line.into_owned()));
            }
        } else if is_external_action_suggestion {
            for line in wrap(
                "Complete the requested action in the browser window that just opened.",
                usable_width,
            ) {
                lines.push(Line::from(line.into_owned()));
            }
            for line in wrap("Then return here and select \"I finished\".", usable_width) {
                lines.push(Line::from(line.into_owned()));
            }
        } else {
            for line in wrap(
                "Complete app setup on ChatGPT in the browser window that just opened.",
                usable_width,
            ) {
                lines.push(Line::from(line.into_owned()));
            }
            for line in wrap(
                "Sign in there if needed, then return here and select \"I already Installed it\".",
                usable_width,
            ) {
                lines.push(Line::from(line.into_owned()));
            }
        }

        lines.push(Line::from(""));
        lines.push(Line::from(vec![
            if is_auth_suggestion {
                "Sign-in URL:"
            } else if is_external_action_suggestion {
                "Link:"
            } else {
                "Setup URL:"
            }
            .dim(),
        ]));
        let url_line = Line::from(vec![self.url.clone().cyan().underlined()]);
        lines.extend(adaptive_wrap_lines(
            vec![url_line],
            RtOptions::new(usable_width),
        ));

        lines
    }

    fn action_rows(&self) -> Vec<GenericDisplayRow> {
        self.action_labels()
            .into_iter()
            .enumerate()
            .map(|(index, label)| {
                let prefix = if self.selected_action == index {
                    '›'
                } else {
                    ' '
                };
                GenericDisplayRow {
                    name: format!("{prefix} {}. {label}", index + 1),
                    ..Default::default()
                }
            })
            .collect()
    }

    fn action_state(&self) -> ScrollState {
        let mut state = ScrollState::new();
        state.selected_idx = Some(self.selected_action);
        state
    }

    fn action_rows_height(&self, width: u16) -> u16 {
        let rows = self.action_rows();
        let state = self.action_state();
        measure_rows_height(&rows, &state, rows.len().max(1), width.max(1))
    }

    fn hint_line(&self) -> Line<'static> {
        Line::from(vec![
            "Use ".into(),
            key_hint::plain(KeyCode::Tab).into(),
            " / ".into(),
            key_hint::plain(KeyCode::Up).into(),
            " ".into(),
            key_hint::plain(KeyCode::Down).into(),
            " to move, ".into(),
            key_hint::plain(KeyCode::Enter).into(),
            " to select, ".into(),
            key_hint::plain(KeyCode::Esc).into(),
            " to close".into(),
        ])
    }
}

impl BottomPaneView for AppLinkView {
    fn handle_key_event(&mut self, key_event: KeyEvent) {
        match key_event {
            KeyEvent {
                code: KeyCode::Esc, ..
            } => {
                self.on_ctrl_c();
            }
            KeyEvent {
                code: KeyCode::Up, ..
            }
            | KeyEvent {
                code: KeyCode::BackTab,
                ..
            }
            | KeyEvent {
                code: KeyCode::Char('k'),
                modifiers: KeyModifiers::NONE,
                ..
            } => self.move_selection_prev(),
            _ if self.list_keymap.move_left.is_pressed(key_event) => self.move_selection_prev(),
            KeyEvent {
                code: KeyCode::Down,
                ..
            }
            | KeyEvent {
                code: KeyCode::Tab, ..
            }
            | KeyEvent {
                code: KeyCode::Char('j'),
                modifiers: KeyModifiers::NONE,
                ..
            } => self.move_selection_next(),
            _ if self.list_keymap.move_right.is_pressed(key_event) => self.move_selection_next(),
            KeyEvent {
                code: KeyCode::Char(c),
                modifiers: KeyModifiers::NONE,
                ..
            } => {
                if let Some(index) = c
                    .to_digit(10)
                    .and_then(|digit| digit.checked_sub(1))
                    .map(|index| index as usize)
                    && index < self.action_labels().len()
                {
                    self.selected_action = index;
                    self.activate_selected_action();
                }
            }
            KeyEvent {
                code: KeyCode::Enter,
                modifiers: KeyModifiers::NONE,
                ..
            } => self.activate_selected_action(),
            _ => {}
        }
    }

    fn on_ctrl_c(&mut self) -> CancellationEvent {
        if self.is_tool_suggestion() {
            self.resolve_elicitation(McpServerElicitationAction::Decline);
        }
        self.complete = true;
        CancellationEvent::Handled
    }

    fn is_complete(&self) -> bool {
        self.complete
    }

    fn dismiss_app_server_request(&mut self, request: &ResolvedAppServerRequest) -> bool {
        let ResolvedAppServerRequest::McpElicitation {
            server_name,
            request_id,
        } = request
        else {
            return false;
        };
        let Some(target) = self.elicitation_target.as_ref() else {
            return false;
        };
        if target.server_name != *server_name || target.request_id != *request_id {
            return false;
        }

        self.complete = true;
        true
    }

    fn terminal_title_requires_action(&self) -> bool {
        self.is_tool_suggestion()
    }
}

impl crate::render::renderable::Renderable for AppLinkView {
    fn desired_height(&self, width: u16) -> u16 {
        let content_width = width.saturating_sub(4).max(1);
        let content_lines = self.content_lines(content_width);
        let content_rows = Paragraph::new(content_lines)
            .wrap(Wrap { trim: false })
            .line_count(content_width)
            .max(1) as u16;
        let action_rows_height = self.action_rows_height(content_width);
        content_rows + action_rows_height + 3
    }

    fn render(&self, area: Rect, buf: &mut Buffer) {
        if area.height == 0 || area.width == 0 {
            return;
        }

        Block::default()
            .style(user_message_style())
            .render(area, buf);

        let actions_height = self.action_rows_height(area.width.saturating_sub(4));
        let [content_area, actions_area, hint_area] = Layout::vertical([
            Constraint::Fill(1),
            Constraint::Length(actions_height),
            Constraint::Length(1),
        ])
        .areas(area);

        let inner = content_area.inset(Insets::vh(/*v*/ 1, /*h*/ 2));
        let content_width = inner.width.max(1);
        let lines = self.content_lines(content_width);
        Paragraph::new(lines)
            .wrap(Wrap { trim: false })
            .render(inner, buf);
        crate::terminal_hyperlinks::mark_url_hyperlink(buf, inner, &self.url);

        if actions_area.height > 0 {
            let actions_area = Rect {
                x: actions_area.x.saturating_add(2),
                y: actions_area.y,
                width: actions_area.width.saturating_sub(2),
                height: actions_area.height,
            };
            let action_rows = self.action_rows();
            let action_state = self.action_state();
            render_rows(
                actions_area,
                buf,
                &action_rows,
                &action_state,
                action_rows.len().max(1),
                "No actions",
            );
        }

        if hint_area.height > 0 {
            let hint_area = Rect {
                x: hint_area.x.saturating_add(2),
                y: hint_area.y,
                width: hint_area.width.saturating_sub(2),
                height: hint_area.height,
            };
            self.hint_line().dim().render(hint_area, buf);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::app_server_requests::ResolvedAppServerRequest;
    use crate::app_event::AppEvent;
    use crate::render::renderable::Renderable;
    use insta::assert_snapshot;
    use pretty_assertions::assert_eq;
    use tokio::sync::mpsc::unbounded_channel;

    fn suggestion_target() -> AppLinkElicitationTarget {
        AppLinkElicitationTarget {
            thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001")
                .expect("valid thread id"),
            server_name: "codex_apps".to_string(),
            request_id: AppServerRequestId::String("request-1".to_string()),
        }
    }

    fn generic_url_target() -> AppLinkElicitationTarget {
        AppLinkElicitationTarget {
            thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002")
                .expect("valid thread id"),
            server_name: "payments".to_string(),
            request_id: AppServerRequestId::String("request-2".to_string()),
        }
    }

    fn auth_url_request(url: &str) -> codex_app_server_protocol::McpServerElicitationRequest {
        codex_app_server_protocol::McpServerElicitationRequest::Url {
            meta: Some(serde_json::json!({
                "_codex_apps": {
                    "connector_auth_failure": {
                        "is_auth_failure": true,
                        "connector_id": "connector_calendar",
                        "connector_name": "Google Calendar",
                    },
                },
            })),
            message: "Reconnect Google Calendar on ChatGPT.".to_string(),
            url: url.to_string(),
            elicitation_id: "codex_apps_auth_call_123".to_string(),
        }
    }

    #[test]
    fn codex_apps_auth_url_elicitation_builds_auth_app_link_params() {
        let target = suggestion_target();
        let request =
            auth_url_request("https://chatgpt.com/apps/google-calendar/connector_calendar");

        let params = AppLinkViewParams::from_url_app_server_request(
            target.thread_id,
            &target.server_name,
            target.request_id.clone(),
            &request,
        )
        .expect("expected auth app link params");

        assert_eq!(params.app_id, "connector_calendar");
        assert_eq!(params.title, "Google Calendar");
        assert_eq!(
            params.url,
            "https://chatgpt.com/apps/google-calendar/connector_calendar"
        );
        assert_eq!(params.suggestion_type, Some(AppLinkSuggestionType::Auth));
        assert_eq!(params.elicitation_target, Some(target));
    }

    #[test]
    fn non_codex_apps_url_elicitation_builds_generic_app_link_params() {
        let target = generic_url_target();
        let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
            meta: None,
            message: "Review the payment details to continue.".to_string(),
            url: "https://payments.example/checkout/123".to_string(),
            elicitation_id: "payment-123".to_string(),
        };

        let params = AppLinkViewParams::from_url_app_server_request(
            target.thread_id,
            &target.server_name,
            target.request_id.clone(),
            &request,
        )
        .expect("expected generic URL app link params");

        assert_eq!(
            params,
            AppLinkViewParams {
                app_id: "payment-123".to_string(),
                title: "Action required".to_string(),
                description: Some("Server: payments".to_string()),
                instructions: "Complete the requested action in your browser, then return here."
                    .to_string(),
                url: "https://payments.example/checkout/123".to_string(),
                is_installed: true,
                is_enabled: true,
                suggest_reason: Some("Review the payment details to continue.".to_string()),
                suggestion_type: Some(AppLinkSuggestionType::ExternalAction),
                elicitation_target: Some(target),
            }
        );
    }

    #[test]
    fn codex_apps_auth_url_elicitation_rejects_untrusted_urls() {
        let target = suggestion_target();
        for url in [
            "http://chatgpt.com/apps/google-calendar/connector_calendar",
            "https://user:pass@chatgpt.com/apps/google-calendar/connector_calendar",
            "https://chatgpt.com.evil.example/apps/google-calendar/connector_calendar",
            "https://evilchatgpt.com/apps/google-calendar/connector_calendar",
        ] {
            let request = auth_url_request(url);
            let params = AppLinkViewParams::from_url_app_server_request(
                target.thread_id,
                &target.server_name,
                target.request_id.clone(),
                &request,
            );
            assert!(params.is_none(), "expected {url} to be rejected");
        }
    }

    #[test]
    fn generic_url_elicitation_rejects_untrusted_urls() {
        let target = generic_url_target();
        for url in [
            "http://payments.example/checkout/123",
            "https://user:pass@payments.example/checkout/123",
        ] {
            let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
                meta: None,
                message: "Review the payment details to continue.".to_string(),
                url: url.to_string(),
                elicitation_id: "payment-123".to_string(),
            };
            let params = AppLinkViewParams::from_url_app_server_request(
                target.thread_id,
                &target.server_name,
                target.request_id.clone(),
                &request,
            );
            assert!(params.is_none(), "expected {url} to be rejected");
        }
    }

    fn render_snapshot(view: &AppLinkView, area: Rect) -> String {
        let mut buf = Buffer::empty(area);
        view.render(area, &mut buf);
        (0..area.height)
            .map(|y| {
                (0..area.width)
                    .map(|x| {
                        let symbol = buf[(x, y)].symbol();
                        if symbol.is_empty() {
                            ' '
                        } else {
                            crate::terminal_hyperlinks::strip_osc8(symbol)
                                .chars()
                                .next()
                                .unwrap_or(' ')
                        }
                    })
                    .collect::<String>()
                    .trim_end()
                    .to_string()
            })
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn installed_app_has_toggle_action() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_1".to_string(),
                title: "Notion".to_string(),
                description: None,
                instructions: "Manage app".to_string(),
                url: "https://example.test/notion".to_string(),
                is_installed: true,
                is_enabled: true,
                suggest_reason: None,
                suggestion_type: None,
                elicitation_target: None,
            },
            tx,
        );

        assert_eq!(
            view.action_labels(),
            vec!["Manage on ChatGPT", "Disable app", "Back"]
        );
    }

    #[test]
    fn regular_app_link_does_not_require_terminal_title_action() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_1".to_string(),
                title: "Notion".to_string(),
                description: None,
                instructions: "Manage app".to_string(),
                url: "https://example.test/notion".to_string(),
                is_installed: true,
                is_enabled: true,
                suggest_reason: None,
                suggestion_type: None,
                elicitation_target: None,
            },
            tx,
        );

        assert!(!view.terminal_title_requires_action());
    }

    #[test]
    fn tool_suggestion_requires_terminal_title_action() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_google_calendar".to_string(),
                title: "Google Calendar".to_string(),
                description: Some("Plan events and schedules.".to_string()),
                instructions: "Enable this app to use it for the current request.".to_string(),
                url: "https://example.test/google-calendar".to_string(),
                is_installed: true,
                is_enabled: false,
                suggest_reason: Some("Plan and reference events from your calendar".to_string()),
                suggestion_type: Some(AppLinkSuggestionType::Enable),
                elicitation_target: Some(suggestion_target()),
            },
            tx,
        );

        assert!(view.terminal_title_requires_action());
    }

    #[test]
    fn horizontal_list_keys_move_action_selection() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let mut view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_1".to_string(),
                title: "Notion".to_string(),
                description: None,
                instructions: "Manage app".to_string(),
                url: "https://example.test/notion".to_string(),
                is_installed: true,
                is_enabled: true,
                suggest_reason: None,
                suggestion_type: None,
                elicitation_target: None,
            },
            tx,
        );

        assert_eq!(view.selected_action, 0);
        view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL));
        assert_eq!(view.selected_action, 1);
        view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL));
        assert_eq!(view.selected_action, 0);
    }

    #[test]
    fn remapped_horizontal_list_keys_control_action_selection() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let mut list_keymap = crate::keymap::RuntimeKeymap::defaults().list;
        list_keymap.move_left = vec![key_hint::plain(KeyCode::Char('x'))];
        list_keymap.move_right = vec![key_hint::plain(KeyCode::Char('z'))];
        let mut view = AppLinkView::new_with_keymap(
            AppLinkViewParams {
                app_id: "connector_1".to_string(),
                title: "Notion".to_string(),
                description: None,
                instructions: "Manage app".to_string(),
                url: "https://example.test/notion".to_string(),
                is_installed: true,
                is_enabled: true,
                suggest_reason: None,
                suggestion_type: None,
                elicitation_target: None,
            },
            tx,
            list_keymap,
        );

        assert_eq!(view.selected_action, 0);
        view.handle_key_event(KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE));
        assert_eq!(view.selected_action, 0);
        view.handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
        assert_eq!(view.selected_action, 0);

        view.handle_key_event(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
        assert_eq!(view.selected_action, 1);
        view.handle_key_event(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::NONE));
        assert_eq!(view.selected_action, 1);
        view.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
        assert_eq!(view.selected_action, 1);

        view.handle_key_event(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
        assert_eq!(view.selected_action, 0);
    }

    #[test]
    fn toggle_action_sends_set_app_enabled_and_updates_label() {
        let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let mut view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_1".to_string(),
                title: "Notion".to_string(),
                description: None,
                instructions: "Manage app".to_string(),
                url: "https://example.test/notion".to_string(),
                is_installed: true,
                is_enabled: true,
                suggest_reason: None,
                suggestion_type: None,
                elicitation_target: None,
            },
            tx,
        );

        view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));

        match rx.try_recv() {
            Ok(AppEvent::SetAppEnabled { id, enabled }) => {
                assert_eq!(id, "connector_1");
                assert!(!enabled);
            }
            Ok(other) => panic!("unexpected app event: {other:?}"),
            Err(err) => panic!("missing app event: {err}"),
        }

        assert_eq!(
            view.action_labels(),
            vec!["Manage on ChatGPT", "Enable app", "Back"]
        );
    }

    #[test]
    fn generic_url_elicitation_resolves_without_connector_refresh() {
        let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let target = generic_url_target();
        let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
            meta: None,
            message: "Review the payment details to continue.".to_string(),
            url: "https://payments.example/checkout/123".to_string(),
            elicitation_id: "payment-123".to_string(),
        };
        let params = AppLinkViewParams::from_url_app_server_request(
            target.thread_id,
            &target.server_name,
            target.request_id.clone(),
            &request,
        )
        .expect("expected generic URL app link params");
        let mut view = AppLinkView::new(params, tx);

        view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        match rx.try_recv() {
            Ok(AppEvent::OpenUrlInBrowser { url }) => {
                assert_eq!(url, "https://payments.example/checkout/123");
            }
            Ok(other) => panic!("unexpected app event: {other:?}"),
            Err(err) => panic!("missing app event: {err}"),
        }
        assert_eq!(view.screen, AppLinkScreen::InstallConfirmation);

        view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        match rx.try_recv() {
            Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
                assert_eq!(thread_id, target.thread_id);
                assert_eq!(
                    op,
                    Op::ResolveElicitation {
                        server_name: "payments".to_string(),
                        request_id: AppServerRequestId::String("request-2".to_string()),
                        decision: McpServerElicitationAction::Accept,
                        content: None,
                        meta: None,
                    }
                );
            }
            Ok(other) => panic!("unexpected app event: {other:?}"),
            Err(err) => panic!("missing app event: {err}"),
        }
        assert!(rx.try_recv().is_err());
        assert!(view.is_complete());
    }

    #[test]
    fn install_confirmation_does_not_split_long_url_like_token_without_scheme() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let url_like =
            "example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890";
        let mut view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_1".to_string(),
                title: "Notion".to_string(),
                description: None,
                instructions: "Manage app".to_string(),
                url: url_like.to_string(),
                is_installed: true,
                is_enabled: true,
                suggest_reason: None,
                suggestion_type: None,
                elicitation_target: None,
            },
            tx,
        );
        view.screen = AppLinkScreen::InstallConfirmation;

        let rendered: Vec<String> = view
            .content_lines(/*width*/ 40)
            .into_iter()
            .map(|line| {
                line.spans
                    .into_iter()
                    .map(|span| span.content.into_owned())
                    .collect::<String>()
            })
            .collect();

        assert_eq!(
            rendered
                .iter()
                .filter(|line| line.contains(url_like))
                .count(),
            1,
            "expected full URL-like token in one rendered line, got: {rendered:?}"
        );
    }

    #[test]
    fn install_confirmation_render_keeps_url_tail_visible_when_narrow() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let url = "https://example.test/api/v1/projects/alpha-team/releases/2026-02-17/builds/1234567890/artifacts/reports/performance/summary/detail/with/a/very/long/path/tail42";
        let mut view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_1".to_string(),
                title: "Notion".to_string(),
                description: None,
                instructions: "Manage app".to_string(),
                url: url.to_string(),
                is_installed: true,
                is_enabled: true,
                suggest_reason: None,
                suggestion_type: None,
                elicitation_target: None,
            },
            tx,
        );
        view.screen = AppLinkScreen::InstallConfirmation;

        let width: u16 = 36;
        let height = view.desired_height(width);
        let area = Rect::new(0, 0, width, height);
        let mut buf = Buffer::empty(area);
        view.render(area, &mut buf);

        let rendered_blob = (0..area.height)
            .map(|y| {
                (0..area.width)
                    .map(|x| {
                        let symbol = buf[(x, y)].symbol();
                        if symbol.is_empty() {
                            ' '
                        } else {
                            crate::terminal_hyperlinks::strip_osc8(symbol)
                                .chars()
                                .next()
                                .unwrap_or(' ')
                        }
                    })
                    .collect::<String>()
            })
            .collect::<Vec<_>>()
            .join("\n");

        assert!(
            rendered_blob.contains("tail42"),
            "expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}"
        );
    }

    #[test]
    fn install_tool_suggestion_resolves_elicitation_after_confirmation() {
        let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let mut view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_google_calendar".to_string(),
                title: "Google Calendar".to_string(),
                description: Some("Plan events and schedules.".to_string()),
                instructions: "Install this app in your browser, then return here.".to_string(),
                url: "https://example.test/google-calendar".to_string(),
                is_installed: false,
                is_enabled: false,
                suggest_reason: Some("Plan and reference events from your calendar".to_string()),
                suggestion_type: Some(AppLinkSuggestionType::Install),
                elicitation_target: Some(suggestion_target()),
            },
            tx,
        );

        view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        match rx.try_recv() {
            Ok(AppEvent::OpenUrlInBrowser { url }) => {
                assert_eq!(url, "https://example.test/google-calendar".to_string());
            }
            Ok(other) => panic!("unexpected app event: {other:?}"),
            Err(err) => panic!("missing app event: {err}"),
        }
        assert_eq!(view.screen, AppLinkScreen::InstallConfirmation);

        view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        match rx.try_recv() {
            Ok(AppEvent::RefreshConnectors { force_refetch }) => {
                assert!(force_refetch);
            }
            Ok(other) => panic!("unexpected app event: {other:?}"),
            Err(err) => panic!("missing app event: {err}"),
        }
        match rx.try_recv() {
            Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
                assert_eq!(thread_id, suggestion_target().thread_id);
                assert_eq!(
                    op,
                    Op::ResolveElicitation {
                        server_name: "codex_apps".to_string(),
                        request_id: AppServerRequestId::String("request-1".to_string()),
                        decision: McpServerElicitationAction::Accept,
                        content: None,
                        meta: None,
                    }
                );
            }
            Ok(other) => panic!("unexpected app event: {other:?}"),
            Err(err) => panic!("missing app event: {err}"),
        }
        assert!(view.is_complete());
    }

    #[test]
    fn declined_tool_suggestion_resolves_elicitation_decline() {
        let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let mut view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_google_calendar".to_string(),
                title: "Google Calendar".to_string(),
                description: None,
                instructions: "Install this app in your browser, then return here.".to_string(),
                url: "https://example.test/google-calendar".to_string(),
                is_installed: false,
                is_enabled: false,
                suggest_reason: Some("Plan and reference events from your calendar".to_string()),
                suggestion_type: Some(AppLinkSuggestionType::Install),
                elicitation_target: Some(suggestion_target()),
            },
            tx,
        );

        view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));

        match rx.try_recv() {
            Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
                assert_eq!(thread_id, suggestion_target().thread_id);
                assert_eq!(
                    op,
                    Op::ResolveElicitation {
                        server_name: "codex_apps".to_string(),
                        request_id: AppServerRequestId::String("request-1".to_string()),
                        decision: McpServerElicitationAction::Decline,
                        content: None,
                        meta: None,
                    }
                );
            }
            Ok(other) => panic!("unexpected app event: {other:?}"),
            Err(err) => panic!("missing app event: {err}"),
        }
        assert!(view.is_complete());
    }

    #[test]
    fn enable_tool_suggestion_resolves_elicitation_after_enable() {
        let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let mut view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_google_calendar".to_string(),
                title: "Google Calendar".to_string(),
                description: Some("Plan events and schedules.".to_string()),
                instructions: "Enable this app to use it for the current request.".to_string(),
                url: "https://example.test/google-calendar".to_string(),
                is_installed: true,
                is_enabled: false,
                suggest_reason: Some("Plan and reference events from your calendar".to_string()),
                suggestion_type: Some(AppLinkSuggestionType::Enable),
                elicitation_target: Some(suggestion_target()),
            },
            tx,
        );

        view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));

        match rx.try_recv() {
            Ok(AppEvent::SetAppEnabled { id, enabled }) => {
                assert_eq!(id, "connector_google_calendar");
                assert!(enabled);
            }
            Ok(other) => panic!("unexpected app event: {other:?}"),
            Err(err) => panic!("missing app event: {err}"),
        }
        match rx.try_recv() {
            Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
                assert_eq!(thread_id, suggestion_target().thread_id);
                assert_eq!(
                    op,
                    Op::ResolveElicitation {
                        server_name: "codex_apps".to_string(),
                        request_id: AppServerRequestId::String("request-1".to_string()),
                        decision: McpServerElicitationAction::Accept,
                        content: None,
                        meta: None,
                    }
                );
            }
            Ok(other) => panic!("unexpected app event: {other:?}"),
            Err(err) => panic!("missing app event: {err}"),
        }
        assert!(view.is_complete());
    }

    #[test]
    fn resolved_tool_suggestion_dismisses_matching_view() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let mut view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_google_calendar".to_string(),
                title: "Google Calendar".to_string(),
                description: Some("Plan events and schedules.".to_string()),
                instructions: "Enable this app to use it for the current request.".to_string(),
                url: "https://example.test/google-calendar".to_string(),
                is_installed: true,
                is_enabled: false,
                suggest_reason: Some("Plan and reference events from your calendar".to_string()),
                suggestion_type: Some(AppLinkSuggestionType::Enable),
                elicitation_target: Some(suggestion_target()),
            },
            tx,
        );

        assert!(
            view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation {
                server_name: "codex_apps".to_string(),
                request_id: AppServerRequestId::String("request-1".to_string()),
            })
        );
        assert!(view.is_complete());
    }

    #[test]
    fn resolved_tool_suggestion_ignores_non_matching_request() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let mut view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_google_calendar".to_string(),
                title: "Google Calendar".to_string(),
                description: Some("Plan events and schedules.".to_string()),
                instructions: "Enable this app to use it for the current request.".to_string(),
                url: "https://example.test/google-calendar".to_string(),
                is_installed: true,
                is_enabled: false,
                suggest_reason: Some("Plan and reference events from your calendar".to_string()),
                suggestion_type: Some(AppLinkSuggestionType::Enable),
                elicitation_target: Some(suggestion_target()),
            },
            tx,
        );

        assert!(
            !view.dismiss_app_server_request(&ResolvedAppServerRequest::McpElicitation {
                server_name: "other_server".to_string(),
                request_id: AppServerRequestId::String("request-1".to_string()),
            })
        );
        assert!(!view.is_complete());
    }

    #[test]
    fn install_suggestion_with_reason_snapshot() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_google_calendar".to_string(),
                title: "Google Calendar".to_string(),
                description: Some("Plan events and schedules.".to_string()),
                instructions: "Install this app in your browser, then return here.".to_string(),
                url: "https://example.test/google-calendar".to_string(),
                is_installed: false,
                is_enabled: false,
                suggest_reason: Some("Plan and reference events from your calendar".to_string()),
                suggestion_type: Some(AppLinkSuggestionType::Install),
                elicitation_target: Some(suggestion_target()),
            },
            tx,
        );

        assert_snapshot!(
            "app_link_view_install_suggestion_with_reason",
            render_snapshot(
                &view,
                Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
            )
        );
    }

    #[test]
    fn enable_suggestion_with_reason_snapshot() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_google_calendar".to_string(),
                title: "Google Calendar".to_string(),
                description: Some("Plan events and schedules.".to_string()),
                instructions: "Enable this app to use it for the current request.".to_string(),
                url: "https://example.test/google-calendar".to_string(),
                is_installed: true,
                is_enabled: false,
                suggest_reason: Some("Plan and reference events from your calendar".to_string()),
                suggestion_type: Some(AppLinkSuggestionType::Enable),
                elicitation_target: Some(suggestion_target()),
            },
            tx,
        );

        assert_snapshot!(
            "app_link_view_enable_suggestion_with_reason",
            render_snapshot(
                &view,
                Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
            )
        );
    }

    #[test]
    fn auth_suggestion_with_reason_snapshot() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let view = AppLinkView::new(
            AppLinkViewParams {
                app_id: "connector_google_calendar".to_string(),
                title: "Google Calendar".to_string(),
                description: None,
                instructions: "Sign in to this app in your browser, then return here.".to_string(),
                url: "https://chatgpt.com/apps/google-calendar/connector_google_calendar"
                    .to_string(),
                is_installed: true,
                is_enabled: true,
                suggest_reason: Some("Reconnect Google Calendar on ChatGPT.".to_string()),
                suggestion_type: Some(AppLinkSuggestionType::Auth),
                elicitation_target: Some(suggestion_target()),
            },
            tx,
        );

        assert_snapshot!(
            "app_link_view_auth_suggestion_with_reason",
            render_snapshot(
                &view,
                Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
            )
        );
    }

    #[test]
    fn generic_url_elicitation_snapshot() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let target = generic_url_target();
        let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
            meta: None,
            message: "Review the payment details to continue.".to_string(),
            url: "https://payments.example/checkout/123".to_string(),
            elicitation_id: "payment-123".to_string(),
        };
        let params = AppLinkViewParams::from_url_app_server_request(
            target.thread_id,
            &target.server_name,
            target.request_id.clone(),
            &request,
        )
        .expect("expected generic URL app link params");
        let view = AppLinkView::new(params, tx);

        assert_snapshot!(
            "app_link_view_generic_url_elicitation",
            render_snapshot(
                &view,
                Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
            )
        );
    }

    #[test]
    fn generic_url_elicitation_confirmation_snapshot() {
        let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
        let tx = AppEventSender::new(tx_raw);
        let target = generic_url_target();
        let request = codex_app_server_protocol::McpServerElicitationRequest::Url {
            meta: None,
            message: "Review the payment details to continue.".to_string(),
            url: "https://payments.example/checkout/123".to_string(),
            elicitation_id: "payment-123".to_string(),
        };
        let params = AppLinkViewParams::from_url_app_server_request(
            target.thread_id,
            &target.server_name,
            target.request_id.clone(),
            &request,
        )
        .expect("expected generic URL app link params");
        let mut view = AppLinkView::new(params, tx);

        view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

        assert_snapshot!(
            "app_link_view_generic_url_elicitation_confirmation",
            render_snapshot(
                &view,
                Rect::new(0, 0, 72, view.desired_height(/*width*/ 72))
            )
        );
    }
}
