use std::io;
use std::time::Duration;

use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::execute;
use crossterm::terminal::{
    EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use serde_json::Value;
use tokio::sync::mpsc;

use chrono::Local;

use crate::api_client::ApiClient;
use crate::ui;
use crate::ws_client::{self, WsEvent};
use roboticus_channels::formatter::formatter_for;

/// Return the current local time as "HH:MM:SS".
fn now_hms() -> String {
    Local::now().format("%H:%M:%S").to_string()
}

/// Shape assistant markdown for terminal display (shared `ChannelFormatter` registry).
fn format_tui_assistant_body(text: &str) -> String {
    formatter_for("tui").format(text)
}

/// Which panel currently has keyboard focus.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusedPanel {
    Input,
    Chat,
    Logs,
}

/// A single chat message for display.
#[derive(Debug, Clone)]
pub struct ChatMessage {
    pub role: String,
    pub content: String,
    pub timestamp: String,
}

/// Application state.
pub struct App {
    pub focused: FocusedPanel,
    pub input: String,
    pub cursor_pos: usize,
    pub messages: Vec<ChatMessage>,
    pub logs: Vec<String>,
    pub session_id: Option<String>,
    pub session_name: Option<String>,
    pub status: ConnectionStatus,
    pub model: Option<String>,
    pub agent_name: String,
    pub agent_activity: Option<String>,
    pub streaming: bool,
    pub spinner_tick: usize,
    pub chat_scroll: u16,
    pub log_scroll: u16,
    pub should_quit: bool,
    pub waiting_for_response: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConnectionStatus {
    Connecting,
    Connected,
    Disconnected(String),
}

impl App {
    fn new() -> Self {
        Self {
            focused: FocusedPanel::Input,
            input: String::new(),
            cursor_pos: 0,
            messages: Vec::new(),
            logs: Vec::new(),
            session_id: None,
            session_name: None,
            status: ConnectionStatus::Connecting,
            model: None,
            agent_name: "Roboticus".to_string(),
            agent_activity: None,
            streaming: false,
            spinner_tick: 0,
            chat_scroll: 0,
            log_scroll: 0,
            should_quit: false,
            waiting_for_response: false,
        }
    }

    fn add_log(&mut self, msg: String) {
        let ts = Local::now().format("%H:%M:%S");
        self.logs.push(format!("{ts} {msg}"));
        // Keep log buffer bounded
        if self.logs.len() > 500 {
            self.logs.drain(..100);
        }
    }

    fn handle_key(&mut self, key: KeyEvent) -> Option<AppAction> {
        // Global shortcuts
        if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
            self.should_quit = true;
            return None;
        }

        if key.code == KeyCode::Tab {
            self.focused = match self.focused {
                FocusedPanel::Input => FocusedPanel::Chat,
                FocusedPanel::Chat => FocusedPanel::Logs,
                FocusedPanel::Logs => FocusedPanel::Input,
            };
            return None;
        }

        match self.focused {
            FocusedPanel::Input => self.handle_input_key(key),
            FocusedPanel::Chat => {
                self.handle_scroll_key(key, true);
                None
            }
            FocusedPanel::Logs => {
                self.handle_scroll_key(key, false);
                None
            }
        }
    }

    fn handle_input_key(&mut self, key: KeyEvent) -> Option<AppAction> {
        match key.code {
            KeyCode::Enter => {
                let content = self.input.trim().to_string();
                if content.is_empty() {
                    return None;
                }
                self.input.clear();
                self.cursor_pos = 0;
                self.messages.push(ChatMessage {
                    role: "user".into(),
                    content: content.clone(),
                    timestamp: now_hms(),
                });
                // Auto-scroll to bottom
                self.chat_scroll = 0;
                self.waiting_for_response = true;
                Some(AppAction::SendMessage(content))
            }
            KeyCode::Char(c) => {
                self.input.insert(self.cursor_pos, c);
                self.cursor_pos += c.len_utf8();
                None
            }
            KeyCode::Backspace => {
                if self.cursor_pos > 0 {
                    let prev = prev_char_boundary(&self.input, self.cursor_pos);
                    self.input.drain(prev..self.cursor_pos);
                    self.cursor_pos = prev;
                }
                None
            }
            KeyCode::Delete => {
                if self.cursor_pos < self.input.len() {
                    let next = next_char_boundary(&self.input, self.cursor_pos);
                    self.input.drain(self.cursor_pos..next);
                }
                None
            }
            KeyCode::Left => {
                if self.cursor_pos > 0 {
                    self.cursor_pos = prev_char_boundary(&self.input, self.cursor_pos);
                }
                None
            }
            KeyCode::Right => {
                if self.cursor_pos < self.input.len() {
                    self.cursor_pos = next_char_boundary(&self.input, self.cursor_pos);
                }
                None
            }
            KeyCode::Home => {
                self.cursor_pos = 0;
                None
            }
            KeyCode::End => {
                self.cursor_pos = self.input.len();
                None
            }
            _ => None,
        }
    }

    fn handle_scroll_key(&mut self, key: KeyEvent, is_chat: bool) {
        let scroll = if is_chat {
            &mut self.chat_scroll
        } else {
            &mut self.log_scroll
        };
        match key.code {
            KeyCode::Up => *scroll = scroll.saturating_add(1),
            KeyCode::Down => *scroll = scroll.saturating_sub(1),
            KeyCode::PageUp => *scroll = scroll.saturating_add(10),
            KeyCode::PageDown => *scroll = scroll.saturating_sub(10),
            KeyCode::Home => *scroll = u16::MAX,
            KeyCode::End => *scroll = 0,
            _ => {}
        }
    }

    fn handle_ws_event(&mut self, event: WsEvent) {
        match event {
            WsEvent::Connected => {
                self.status = ConnectionStatus::Connected;
                self.add_log("WebSocket connected".into());
            }
            WsEvent::StreamStart { turn_id, model } => {
                self.streaming = true;
                if let Some(m) = model {
                    if !m.is_empty() {
                        self.model = Some(m);
                    }
                }
                self.add_log(format!("Stream started: {turn_id}"));
            }
            WsEvent::StreamEnd { turn_id } => {
                self.streaming = false;
                self.agent_activity = None;
                self.waiting_for_response = false;
                self.add_log(format!("Stream ended: {turn_id}"));
            }
            WsEvent::ModelSelection { model } => {
                self.add_log(format!("Model: {model}"));
                self.model = Some(model);
            }
            WsEvent::AgentWorking { activity } => {
                self.agent_activity = Some(activity.clone());
                self.add_log(format!("Event: agent_working ({activity})"));
            }
            WsEvent::AgentIdle => {
                self.agent_activity = None;
                self.add_log("Event: agent_idle".into());
            }
            WsEvent::Raw(val) => {
                // Check for message content in raw events
                if let Some(content) = extract_assistant_content(&val) {
                    self.waiting_for_response = false;
                    self.messages.push(ChatMessage {
                        role: "assistant".into(),
                        content,
                        timestamp: now_hms(),
                    });
                    self.chat_scroll = 0;
                } else {
                    let event_type = val
                        .get("type")
                        .and_then(|v| v.as_str())
                        .unwrap_or("unknown");
                    self.add_log(format!("Event: {event_type}"));
                }
            }
            WsEvent::Disconnected(reason) => {
                self.status = ConnectionStatus::Disconnected(reason.clone());
                self.add_log(format!("Disconnected: {reason}"));
            }
        }
    }
}

enum AppAction {
    SendMessage(String),
}

/// Find the byte offset of the previous character boundary before `pos`.
fn prev_char_boundary(s: &str, pos: usize) -> usize {
    let mut p = pos.saturating_sub(1);
    while p > 0 && !s.is_char_boundary(p) {
        p -= 1;
    }
    p
}

/// Find the byte offset of the next character boundary after `pos`.
fn next_char_boundary(s: &str, pos: usize) -> usize {
    let mut p = pos + 1;
    while p < s.len() && !s.is_char_boundary(p) {
        p += 1;
    }
    p.min(s.len())
}

/// Extract assistant message content from a raw WS event.
fn extract_assistant_content(val: &Value) -> Option<String> {
    let event_type = val.get("type").and_then(|v| v.as_str())?;
    match event_type {
        "message" | "assistant_message" | "turn_complete" => val
            .get("content")
            .and_then(|v| v.as_str())
            .map(format_tui_assistant_body)
            .or_else(|| {
                val.get("message")
                    .and_then(|v| v.get("content"))
                    .and_then(|v| v.as_str())
                    .map(format_tui_assistant_body)
            }),
        "error" => val
            .get("content")
            .and_then(|v| v.as_str())
            .or_else(|| val.get("error").and_then(|v| v.as_str()))
            .map(|s| format!("⚠ {s}")),
        _ => None,
    }
}

/// Load message history from an existing session.
fn load_history(messages_val: &Value) -> Vec<ChatMessage> {
    let mut result = Vec::new();
    let items = messages_val
        .get("messages")
        .and_then(|v| v.as_array())
        .or_else(|| messages_val.as_array());
    if let Some(arr) = items {
        for msg in arr {
            let role = msg
                .get("role")
                .and_then(|v| v.as_str())
                .unwrap_or("unknown")
                .to_string();
            let raw_content = msg
                .get("content")
                .and_then(|v| v.as_str())
                .unwrap_or("")
                .to_string();
            if !raw_content.is_empty() {
                let content = if matches!(role.as_str(), "assistant" | "bot") {
                    format_tui_assistant_body(&raw_content)
                } else {
                    raw_content
                };
                let timestamp = msg
                    .get("created_at")
                    .and_then(|v| v.as_str())
                    .and_then(|s: &str| {
                        chrono::DateTime::parse_from_rfc3339(s)
                            .ok()
                            .map(|dt| dt.with_timezone(&Local).format("%H:%M:%S").to_string())
                    })
                    .unwrap_or_default();
                result.push(ChatMessage {
                    role,
                    content,
                    timestamp,
                });
            }
        }
    }
    result
}

/// Entry point: run the TUI application.
pub async fn run_tui(
    url: &str,
    session_id: Option<&str>,
    api_key: Option<String>,
) -> Result<(), Box<dyn std::error::Error>> {
    let api_key_for_sends = api_key.clone();
    let api = ApiClient::new(url, api_key);
    let mut app = App::new();

    // Fetch agent name from health endpoint
    if let Ok(health) = api.health().await {
        if let Some(name) = health.get("agent").and_then(|v| v.as_str()) {
            app.agent_name = name.to_string();
        }
        // Also pick up model info early if available
        if let Some(models) = health.get("models") {
            if let Some(m) = models.get("current").and_then(|v| v.as_str()) {
                app.model = Some(m.to_string());
            } else if let Some(m) = models.get("primary").and_then(|v| v.as_str()) {
                app.model = Some(m.to_string());
            }
        }
    }

    // Create or resume session
    let sid = match session_id {
        Some(id) => {
            app.add_log(format!("Resuming session {id}"));
            match api.get_session_messages(id).await {
                Ok(history) => {
                    if let Some(name) = history.get("nickname").and_then(|v| v.as_str()) {
                        if !name.is_empty() {
                            app.session_name = Some(name.to_string());
                        }
                    }
                    app.messages = load_history(&history);
                    app.add_log(format!("Loaded {} messages", app.messages.len()));
                }
                Err(e) => {
                    app.add_log(format!("Could not load history: {e}"));
                }
            }
            id.to_string()
        }
        None => {
            app.add_log("Creating new session...".into());
            let resp = api.create_session().await?;
            let id = resp
                .get("session_id")
                .or_else(|| resp.get("id"))
                .and_then(|v| v.as_str())
                .ok_or_else(|| -> Box<dyn std::error::Error> {
                    format!(
                        "server did not return a session ID (is the server running? response: {})",
                        serde_json::to_string(&resp).unwrap_or_else(|_| "<unparseable>".into())
                    )
                    .into()
                })?
                .to_string();
            if let Some(name) = resp.get("nickname").and_then(|v| v.as_str()) {
                if !name.is_empty() {
                    app.session_name = Some(name.to_string());
                }
            }
            app.add_log(format!("Session created: {id}"));
            id
        }
    };
    app.session_id = Some(sid.clone());

    // Set up WebSocket
    let (ws_tx, mut ws_rx) = mpsc::unbounded_channel::<WsEvent>();
    let api_ws_tx = ws_tx.clone(); // Clone for API response delivery
    let ws_url = url.to_string();
    let ticket = api.get_ws_ticket().await.unwrap_or(None);
    tokio::spawn(async move {
        let ticket_ref = ticket.as_deref();
        if let Err(e) = ws_client::connect(&ws_url, ticket_ref, ws_tx).await {
            eprintln!("WebSocket error: {e}");
        }
    });

    // Set up terminal
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    // Main event loop
    loop {
        // Advance spinner tick each frame for the braille thinking indicator
        if app.agent_activity.is_some() || app.streaming || app.waiting_for_response {
            app.spinner_tick = app.spinner_tick.wrapping_add(1);
        }

        terminal.draw(|f| ui::render(f, &app))?;

        if app.should_quit {
            break;
        }

        // Use tokio::select! to multiplex terminal events and WS events
        let action = tokio::select! {
            // Poll for terminal events with a short timeout
            result = tokio::task::spawn_blocking(|| {
                if event::poll(Duration::from_millis(50)).unwrap_or(false) {
                    event::read().ok()
                } else {
                    None
                }
            }) => {
                match result {
                    Ok(Some(Event::Key(key))) => app.handle_key(key),
                    _ => None,
                }
            }
            // Receive WebSocket events
            ws_event = ws_rx.recv() => {
                if let Some(ev) = ws_event {
                    app.handle_ws_event(ev);
                }
                None
            }
        };

        // Process actions that need async work
        if let Some(AppAction::SendMessage(content)) = action {
            let api_url = url.to_string();
            let api_key_clone = api_key_for_sends.clone();
            let sid_clone = sid.clone();
            let tx = api_ws_tx.clone();
            // Send via agent endpoint; deliver response through ws channel
            tokio::spawn(async move {
                let send_api = ApiClient::new(&api_url, api_key_clone);
                match send_api.send_message(&sid_clone, &content).await {
                    Ok(resp) => {
                        if let Some(model) = resp.get("model").and_then(|v| v.as_str()) {
                            let _ = tx.send(WsEvent::ModelSelection {
                                model: model.to_string(),
                            });
                        }
                        let assistant_content = resp
                            .get("content")
                            .and_then(|v| v.as_str())
                            .unwrap_or_default();
                        if !assistant_content.is_empty() {
                            let _ = tx.send(WsEvent::Raw(serde_json::json!({
                                "type": "assistant_message",
                                "content": assistant_content,
                            })));
                        } else {
                            let _ = tx.send(WsEvent::Raw(serde_json::json!({
                                "type": "error",
                                "content": "Empty response from agent",
                            })));
                        }
                    }
                    Err(e) => {
                        let _ = tx.send(WsEvent::Raw(serde_json::json!({
                            "type": "error",
                            "content": format!("Send failed: {e}"),
                        })));
                    }
                }
            });
        }
    }

    // Restore terminal
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;

    Ok(())
}

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

    // ── prev_char_boundary ──────────────────────────────────────────

    #[test]
    fn prev_char_boundary_ascii() {
        let s = "hello";
        assert_eq!(prev_char_boundary(s, 3), 2);
        assert_eq!(prev_char_boundary(s, 1), 0);
    }

    #[test]
    fn prev_char_boundary_at_zero() {
        let s = "abc";
        // saturating_sub(1) from 0 gives 0, loop doesn't run
        assert_eq!(prev_char_boundary(s, 0), 0);
    }

    #[test]
    fn prev_char_boundary_empty_string() {
        let s = "";
        assert_eq!(prev_char_boundary(s, 0), 0);
    }

    #[test]
    fn prev_char_boundary_multibyte_emoji() {
        // 😀 is 4 bytes: byte offsets 0..4
        let s = "a😀b";
        // 'a' = offset 0, '😀' = offset 1..5, 'b' = offset 5
        assert_eq!(prev_char_boundary(s, 5), 1); // before 'b' → start of emoji
        assert_eq!(prev_char_boundary(s, 1), 0); // before emoji → 'a'
    }

    #[test]
    fn prev_char_boundary_cjk() {
        // '中' is 3 bytes
        let s = "中文";
        // '中' = 0..3, '文' = 3..6
        assert_eq!(prev_char_boundary(s, 6), 3);
        assert_eq!(prev_char_boundary(s, 3), 0);
    }

    #[test]
    fn prev_char_boundary_from_middle_of_multibyte() {
        let s = "a😀b";
        // Position 3 is inside the emoji (bytes 1..5). Should walk back to 1.
        assert_eq!(prev_char_boundary(s, 3), 1);
    }

    // ── next_char_boundary ──────────────────────────────────────────

    #[test]
    fn next_char_boundary_ascii() {
        let s = "hello";
        assert_eq!(next_char_boundary(s, 0), 1);
        assert_eq!(next_char_boundary(s, 3), 4);
    }

    #[test]
    fn next_char_boundary_at_end() {
        let s = "abc";
        // pos + 1 = 4, clamped to s.len() = 3
        assert_eq!(next_char_boundary(s, 3), 3);
    }

    #[test]
    fn next_char_boundary_empty_string() {
        let s = "";
        assert_eq!(next_char_boundary(s, 0), 0);
    }

    #[test]
    fn next_char_boundary_multibyte_emoji() {
        let s = "a😀b";
        // From start of emoji (offset 1), next boundary is at 5
        assert_eq!(next_char_boundary(s, 1), 5);
    }

    #[test]
    fn next_char_boundary_cjk() {
        let s = "中文";
        assert_eq!(next_char_boundary(s, 0), 3);
        assert_eq!(next_char_boundary(s, 3), 6);
    }

    #[test]
    fn next_char_boundary_penultimate() {
        let s = "a😀";
        // From 'a' (offset 0), next = 1 (start of emoji)
        assert_eq!(next_char_boundary(s, 0), 1);
        // From emoji start (offset 1), next = 5 = len
        assert_eq!(next_char_boundary(s, 1), 5);
    }

    // ── extract_assistant_content ───────────────────────────────────

    #[test]
    fn extract_content_from_message_event() {
        let val = json!({"type": "message", "content": "Hello!"});
        assert_eq!(extract_assistant_content(&val), Some("Hello!".to_string()));
    }

    #[test]
    fn extract_content_from_assistant_message_event() {
        let val = json!({"type": "assistant_message", "content": "Hi there"});
        assert_eq!(
            extract_assistant_content(&val),
            Some("Hi there".to_string())
        );
    }

    #[test]
    fn extract_content_from_nested_message_field() {
        let val = json!({"type": "turn_complete", "message": {"content": "Done"}});
        assert_eq!(extract_assistant_content(&val), Some("Done".to_string()));
    }

    #[test]
    fn extract_content_returns_none_for_unknown_type() {
        let val = json!({"type": "heartbeat"});
        assert_eq!(extract_assistant_content(&val), None);
    }

    #[test]
    fn extract_content_returns_none_when_no_type() {
        let val = json!({"content": "orphan"});
        assert_eq!(extract_assistant_content(&val), None);
    }

    #[test]
    fn extract_content_formats_error_events() {
        let val = json!({"type": "error", "error": "send failed"});
        assert_eq!(
            extract_assistant_content(&val),
            Some("⚠ send failed".to_string())
        );
    }

    #[test]
    fn now_hms_has_clock_shape() {
        let ts = now_hms();
        assert_eq!(ts.len(), 8);
        assert_eq!(&ts[2..3], ":");
        assert_eq!(&ts[5..6], ":");
    }

    #[test]
    fn format_tui_assistant_body_preserves_plain_text() {
        assert_eq!(format_tui_assistant_body("hello"), "hello");
    }

    // ── load_history ────────────────────────────────────────────────

    #[test]
    fn load_history_from_messages_wrapper() {
        let val = json!({
            "messages": [
                {"role": "user", "content": "Hi"},
                {"role": "assistant", "content": "Hello"}
            ]
        });
        let msgs = load_history(&val);
        assert_eq!(msgs.len(), 2);
        assert_eq!(msgs[0].role, "user");
        assert_eq!(msgs[1].content, "Hello");
    }

    #[test]
    fn load_history_from_bare_array() {
        let val = json!([
            {"role": "user", "content": "Ping"},
        ]);
        let msgs = load_history(&val);
        assert_eq!(msgs.len(), 1);
        assert_eq!(msgs[0].content, "Ping");
    }

    #[test]
    fn load_history_skips_empty_content() {
        let val = json!([
            {"role": "user", "content": ""},
            {"role": "assistant", "content": "ok"},
        ]);
        let msgs = load_history(&val);
        assert_eq!(msgs.len(), 1);
        assert_eq!(msgs[0].role, "assistant");
    }

    #[test]
    fn load_history_empty_input() {
        let val = json!({});
        let msgs = load_history(&val);
        assert!(msgs.is_empty());
    }

    #[test]
    fn load_history_formats_assistant_markdown_and_timestamp() {
        let val = json!({
            "messages": [{
                "role": "assistant",
                "content": "```rust\nfn main() {}\n```",
                "created_at": "2026-03-24T12:34:56Z"
            }]
        });
        let msgs = load_history(&val);
        assert_eq!(msgs.len(), 1);
        assert!(msgs[0].content.contains("fn main() {}"));
        assert_eq!(msgs[0].timestamp.len(), 8);
    }

    #[test]
    fn load_history_accepts_bot_role_and_invalid_timestamp_falls_back_empty() {
        let val = json!({
            "messages": [{
                "role": "bot",
                "content": "**hi**",
                "created_at": "not-a-timestamp"
            }]
        });
        let msgs = load_history(&val);
        assert_eq!(msgs.len(), 1);
        assert!(msgs[0].content.contains("hi"));
        assert!(msgs[0].timestamp.is_empty());
    }

    // ── App state tests ─────────────────────────────────────────────

    #[test]
    fn app_add_log_bounds_buffer() {
        let mut app = App::new();
        for i in 0..510 {
            app.add_log(format!("log {i}"));
        }
        // After exceeding 500, drains first 100, so 510 - 100 = 410
        assert!(app.logs.len() <= 500);
    }

    #[test]
    fn app_handle_ws_connected() {
        let mut app = App::new();
        app.handle_ws_event(WsEvent::Connected);
        assert_eq!(app.status, ConnectionStatus::Connected);
        assert!(
            app.logs
                .iter()
                .any(|line| line.contains("WebSocket connected"))
        );
    }

    #[test]
    fn app_handle_ws_stream_start_end() {
        let mut app = App::new();
        app.agent_activity = Some("Thinking".into());
        app.waiting_for_response = true;
        app.handle_ws_event(WsEvent::StreamStart {
            turn_id: "t1".into(),
            model: None,
        });
        assert!(app.streaming);
        app.handle_ws_event(WsEvent::StreamEnd {
            turn_id: "t1".into(),
        });
        assert!(!app.streaming);
        assert!(app.agent_activity.is_none());
        assert!(!app.waiting_for_response);
    }

    #[test]
    fn app_handle_ws_stream_start_updates_model_when_present() {
        let mut app = App::new();
        app.handle_ws_event(WsEvent::StreamStart {
            turn_id: "t1".into(),
            model: Some("ollama/qwen3:8b".into()),
        });
        assert!(app.streaming);
        assert_eq!(app.model.as_deref(), Some("ollama/qwen3:8b"));
    }

    #[test]
    fn app_handle_ws_model_selection() {
        let mut app = App::new();
        app.handle_ws_event(WsEvent::ModelSelection {
            model: "gpt-4".into(),
        });
        assert_eq!(app.model, Some("gpt-4".to_string()));
    }

    #[test]
    fn app_handle_ws_raw_with_assistant_content() {
        let mut app = App::new();
        let val = json!({"type": "message", "content": "Hi from bot"});
        app.handle_ws_event(WsEvent::Raw(val));
        assert_eq!(app.messages.len(), 1);
        assert_eq!(app.messages[0].role, "assistant");
        assert_eq!(app.messages[0].content, "Hi from bot");
    }

    #[test]
    fn app_handle_ws_raw_unknown_event_logs_type() {
        let mut app = App::new();
        app.handle_ws_event(WsEvent::Raw(json!({
            "type": "heartbeat"
        })));
        assert!(
            app.logs
                .iter()
                .any(|line| line.contains("Event: heartbeat"))
        );
    }

    #[test]
    fn app_handle_ws_raw_error_event_appends_assistant_warning() {
        let mut app = App::new();
        app.handle_ws_event(WsEvent::Raw(json!({
            "type": "error",
            "error": "boom"
        })));
        assert_eq!(app.messages.len(), 1);
        assert_eq!(app.messages[0].role, "assistant");
        assert_eq!(app.messages[0].content, "⚠ boom");
        assert!(!app.waiting_for_response);
    }

    #[test]
    fn app_handle_ws_raw_without_type_logs_unknown() {
        let mut app = App::new();
        app.handle_ws_event(WsEvent::Raw(json!({
            "content": "orphan"
        })));
        assert!(app.logs.iter().any(|line| line.contains("Event: unknown")));
    }

    #[test]
    fn app_handle_ws_agent_activity_updates_and_clears() {
        let mut app = App::new();
        app.handle_ws_event(WsEvent::AgentWorking {
            activity: "Thinking".into(),
        });
        assert_eq!(app.agent_activity.as_deref(), Some("Thinking"));
        app.handle_ws_event(WsEvent::AgentIdle);
        assert!(app.agent_activity.is_none());
    }

    #[test]
    fn app_handle_ws_disconnected() {
        let mut app = App::new();
        app.handle_ws_event(WsEvent::Disconnected("timeout".into()));
        assert_eq!(app.status, ConnectionStatus::Disconnected("timeout".into()));
        assert!(
            app.logs
                .iter()
                .any(|line| line.contains("Disconnected: timeout"))
        );
    }

    #[test]
    fn app_tab_cycles_focus() {
        let mut app = App::new();
        assert_eq!(app.focused, FocusedPanel::Input);

        let tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE);
        app.handle_key(tab);
        assert_eq!(app.focused, FocusedPanel::Chat);
        app.handle_key(tab);
        assert_eq!(app.focused, FocusedPanel::Logs);
        app.handle_key(tab);
        assert_eq!(app.focused, FocusedPanel::Input);
    }

    #[test]
    fn app_ctrl_c_sets_quit() {
        let mut app = App::new();
        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
        app.handle_key(key);
        assert!(app.should_quit);
    }

    // ── Input editing ──────────────────────────────────────────────

    #[test]
    fn input_char_insertion_and_cursor_advance() {
        let mut app = App::new();
        app.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE));
        app.handle_key(KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE));
        assert_eq!(app.input, "ab");
        assert_eq!(app.cursor_pos, 2);
    }

    #[test]
    fn input_backspace_removes_prev_char() {
        let mut app = App::new();
        app.input = "abc".into();
        app.cursor_pos = 3;
        app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
        assert_eq!(app.input, "ab");
        assert_eq!(app.cursor_pos, 2);
    }

    #[test]
    fn input_backspace_at_start_is_noop() {
        let mut app = App::new();
        app.input = "x".into();
        app.cursor_pos = 0;
        app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
        assert_eq!(app.input, "x");
        assert_eq!(app.cursor_pos, 0);
    }

    #[test]
    fn input_delete_removes_next_char() {
        let mut app = App::new();
        app.input = "abc".into();
        app.cursor_pos = 1;
        app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
        assert_eq!(app.input, "ac");
        assert_eq!(app.cursor_pos, 1);
    }

    #[test]
    fn input_delete_at_end_is_noop() {
        let mut app = App::new();
        app.input = "abc".into();
        app.cursor_pos = app.input.len();
        app.handle_key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE));
        assert_eq!(app.input, "abc");
        assert_eq!(app.cursor_pos, 3);
    }

    #[test]
    fn input_left_right_cursor_movement() {
        let mut app = App::new();
        app.input = "hello".into();
        app.cursor_pos = 3;
        app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
        assert_eq!(app.cursor_pos, 2);
        app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
        assert_eq!(app.cursor_pos, 3);
    }

    #[test]
    fn input_left_at_start_and_right_at_end_are_noops() {
        let mut app = App::new();
        app.input = "hi".into();
        app.cursor_pos = 0;
        app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
        assert_eq!(app.cursor_pos, 0);
        app.cursor_pos = app.input.len();
        app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE));
        assert_eq!(app.cursor_pos, 2);
    }

    #[test]
    fn input_home_end_keys() {
        let mut app = App::new();
        app.input = "hello".into();
        app.cursor_pos = 3;
        app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
        assert_eq!(app.cursor_pos, 0);
        app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
        assert_eq!(app.cursor_pos, 5);
    }

    #[test]
    fn input_multibyte_char_insertion() {
        let mut app = App::new();
        app.handle_key(KeyEvent::new(KeyCode::Char('中'), KeyModifiers::NONE));
        assert_eq!(app.input, "中");
        assert_eq!(app.cursor_pos, 3); // '中' is 3 bytes
    }

    #[test]
    fn input_backspace_multibyte_char() {
        let mut app = App::new();
        app.input = "a😀b".into();
        app.cursor_pos = 5; // after emoji
        app.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
        assert_eq!(app.input, "ab");
        assert_eq!(app.cursor_pos, 1);
    }

    #[test]
    fn input_enter_sends_and_clears() {
        let mut app = App::new();
        app.chat_scroll = 5;
        app.input = "hello".into();
        app.cursor_pos = 5;
        let action = app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        assert!(matches!(action, Some(AppAction::SendMessage(ref s)) if s == "hello"));
        assert!(app.input.is_empty());
        assert_eq!(app.cursor_pos, 0);
        assert_eq!(app.messages.len(), 1);
        assert_eq!(app.messages[0].role, "user");
        assert_eq!(app.chat_scroll, 0);
        assert!(app.waiting_for_response);
    }

    #[test]
    fn input_enter_empty_is_noop() {
        let mut app = App::new();
        let action = app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        assert!(action.is_none());
        assert!(app.messages.is_empty());
    }

    #[test]
    fn input_enter_whitespace_only_is_noop() {
        let mut app = App::new();
        app.input = "   ".into();
        app.cursor_pos = 3;
        let action = app.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
        assert!(action.is_none());
    }

    // ── Scroll ─────────────────────────────────────────────────────

    #[test]
    fn scroll_up_down_in_chat_panel() {
        let mut app = App::new();
        app.focused = FocusedPanel::Chat;
        app.handle_key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
        assert_eq!(app.chat_scroll, 1);
        app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
        assert_eq!(app.chat_scroll, 0);
    }

    #[test]
    fn scroll_page_and_home_end_in_logs_panel() {
        let mut app = App::new();
        app.focused = FocusedPanel::Logs;
        app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
        assert_eq!(app.log_scroll, 10);
        app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(app.log_scroll, 0);
        app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
        assert_eq!(app.log_scroll, u16::MAX);
        app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
        assert_eq!(app.log_scroll, 0);
    }

    #[test]
    fn scroll_page_up_down() {
        let mut app = App::new();
        app.focused = FocusedPanel::Logs;
        app.handle_key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
        assert_eq!(app.log_scroll, 10);
        app.handle_key(KeyEvent::new(KeyCode::PageDown, KeyModifiers::NONE));
        assert_eq!(app.log_scroll, 0);
    }

    #[test]
    fn scroll_home_end() {
        let mut app = App::new();
        app.focused = FocusedPanel::Chat;
        app.handle_key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
        assert_eq!(app.chat_scroll, u16::MAX);
        app.handle_key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
        assert_eq!(app.chat_scroll, 0);
    }

    #[test]
    fn scroll_saturating_sub_at_zero() {
        let mut app = App::new();
        app.focused = FocusedPanel::Chat;
        app.chat_scroll = 0;
        app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
        assert_eq!(app.chat_scroll, 0); // should not underflow
    }
}
