scroll-chat 0.1.1

A secure terminal chat over SSH - host or join chatrooms with end-to-end encryption
//! TUI rendering for the chat interface

use crate::room::{ChatRoom, Message, MessageContent};
use chrono::Local;
use crossterm::{
    event::{KeyCode, KeyEvent, KeyModifiers},
    style::Color,
};
use std::sync::Arc;

/// Color palette for usernames
const USERNAME_COLORS: [Color; 12] = [
    Color::Rgb { r: 255, g: 107, b: 107 }, // Red
    Color::Rgb { r: 78, g: 205, b: 196 },  // Cyan
    Color::Rgb { r: 255, g: 217, b: 61 },  // Yellow
    Color::Rgb { r: 199, g: 125, b: 255 }, // Purple
    Color::Rgb { r: 107, g: 185, b: 240 }, // Blue
    Color::Rgb { r: 255, g: 159, b: 67 },  // Orange
    Color::Rgb { r: 46, g: 213, b: 115 },  // Green
    Color::Rgb { r: 255, g: 121, b: 198 }, // Pink
    Color::Rgb { r: 189, g: 147, b: 249 }, // Light purple
    Color::Rgb { r: 139, g: 233, b: 253 }, // Light cyan
    Color::Rgb { r: 80, g: 250, b: 123 },  // Light green
    Color::Rgb { r: 255, g: 184, b: 108 }, // Peach
];

/// Get a consistent color for a username
fn username_color(username: &str) -> Color {
    let hash: usize = username.bytes().map(|b| b as usize).sum();
    USERNAME_COLORS[hash % USERNAME_COLORS.len()]
}

/// TUI state
pub struct ChatTui {
    /// Terminal size (cols, rows)
    size: (u16, u16),
    /// Input buffer
    input: String,
    /// Cursor position in input
    cursor_pos: usize,
    /// Message scroll offset
    scroll_offset: usize,
    /// Room reference
    room: Arc<ChatRoom>,
    /// Current session ID
    session_id: u64,
    /// Username
    username: String,
}

impl ChatTui {
    pub fn new(room: Arc<ChatRoom>, session_id: u64, username: String, cols: u16, rows: u16) -> Self {
        Self {
            size: (cols, rows),
            input: String::new(),
            cursor_pos: 0,
            scroll_offset: 0,
            room,
            session_id,
            username,
        }
    }

    /// Update terminal size
    pub fn resize(&mut self, cols: u16, rows: u16) {
        self.size = (cols, rows);
    }

    /// Process a key event
    pub async fn handle_key(&mut self, key: KeyEvent) -> Option<String> {
        match (key.modifiers, key.code) {
            (KeyModifiers::CONTROL, KeyCode::Char('c')) => {
                return Some("__QUIT__".to_string());
            }
            (_, KeyCode::Enter) => {
                if !self.input.trim().is_empty() {
                    let msg = self.input.clone();
                    self.input.clear();
                    self.cursor_pos = 0;
                    return Some(msg);
                }
            }
            (_, KeyCode::Char(c)) => {
                self.input.insert(self.cursor_pos, c);
                self.cursor_pos += 1;
            }
            (_, KeyCode::Backspace) => {
                if self.cursor_pos > 0 {
                    self.cursor_pos -= 1;
                    self.input.remove(self.cursor_pos);
                }
            }
            (_, KeyCode::Delete) => {
                if self.cursor_pos < self.input.len() {
                    self.input.remove(self.cursor_pos);
                }
            }
            (_, KeyCode::Left) => {
                if self.cursor_pos > 0 {
                    self.cursor_pos -= 1;
                }
            }
            (_, KeyCode::Right) => {
                if self.cursor_pos < self.input.len() {
                    self.cursor_pos += 1;
                }
            }
            (_, KeyCode::Home) => {
                self.cursor_pos = 0;
            }
            (_, KeyCode::End) => {
                self.cursor_pos = self.input.len();
            }
            (_, KeyCode::PageUp) => {
                self.scroll_offset = self.scroll_offset.saturating_add(5);
            }
            (_, KeyCode::PageDown) => {
                self.scroll_offset = self.scroll_offset.saturating_sub(5);
            }
            _ => {}
        }
        None
    }

    /// Render the TUI to a string buffer (ANSI escape codes)
    pub async fn render(&self) -> String {
        let (cols, rows) = self.size;
        let mut output = String::new();

        // Layout:
        // Row 0: Header
        // Row 1 to (rows-3): Messages
        // Row (rows-2): Input separator
        // Row (rows-1): Input line

        let header_row = 0u16;
        let messages_start = 1u16;
        let messages_end = rows.saturating_sub(3);
        let input_sep_row = rows.saturating_sub(2);
        let input_row = rows.saturating_sub(1);
        let messages_height = messages_end.saturating_sub(messages_start) as usize;

        // Clear screen
        output.push_str(&format!("\x1b[2J\x1b[H"));

        // ===== HEADER =====
        let room_name = &self.room.name;
        let user_count = self.room.user_count().await;
        let tunnel_url = self.room.tunnel_url.read().await;
        
        let header_left = format!(" 📜 {} ", room_name);
        let header_right = format!(" {} online ", user_count);
        let tunnel_info = tunnel_url
            .as_ref()
            .map(|u| format!(" 🔗 {} ", u))
            .unwrap_or_default();

        // Header background
        output.push_str(&format!("\x1b[{};{}H", header_row + 1, 1));
        output.push_str("\x1b[48;2;40;42;54m\x1b[38;2;255;255;255m"); // Dark bg, white fg
        output.push_str(&" ".repeat(cols as usize));
        output.push_str(&format!("\x1b[{};{}H", header_row + 1, 1));
        output.push_str(&header_left);
        
        // Tunnel URL in middle (if available)
        if !tunnel_info.is_empty() {
            let tunnel_pos = (cols as usize / 2).saturating_sub(tunnel_info.len() / 2);
            output.push_str(&format!("\x1b[{};{}H", header_row + 1, tunnel_pos + 1));
            output.push_str("\x1b[38;2;139;233;253m"); // Cyan
            output.push_str(&tunnel_info);
        }
        
        // User count on right
        let right_pos = (cols as usize).saturating_sub(header_right.len());
        output.push_str(&format!("\x1b[{};{}H", header_row + 1, right_pos + 1));
        output.push_str("\x1b[38;2;80;250;123m"); // Green
        output.push_str(&header_right);
        output.push_str("\x1b[0m");

        // ===== MESSAGES =====
        let messages = self.room.get_messages().await;
        let visible_messages: Vec<_> = messages
            .iter()
            .rev()
            .skip(self.scroll_offset)
            .take(messages_height)
            .collect::<Vec<_>>()
            .into_iter()
            .rev()
            .collect();

        let start_row = if visible_messages.len() < messages_height {
            messages_end - visible_messages.len() as u16
        } else {
            messages_start
        };

        for (i, msg) in visible_messages.iter().enumerate() {
            let row = start_row + i as u16;
            if row >= messages_end {
                break;
            }
            output.push_str(&format!("\x1b[{};{}H", row + 1, 1));
            output.push_str(&self.format_message(msg, cols as usize));
        }

        // ===== INPUT SEPARATOR =====
        output.push_str(&format!("\x1b[{};{}H", input_sep_row + 1, 1));
        output.push_str("\x1b[38;2;68;71;90m"); // Gray
        output.push_str(&"".repeat(cols as usize));
        output.push_str("\x1b[0m");

        // ===== INPUT LINE =====
        output.push_str(&format!("\x1b[{};{}H", input_row + 1, 1));
        output.push_str("\x1b[38;2;189;147;249m"); // Purple
        output.push_str(&format!("{} > ", self.username));
        output.push_str("\x1b[0m");
        
        let prompt_len = self.username.len() + 3;
        let input_width = (cols as usize).saturating_sub(prompt_len);
        let display_input: String = self.input.chars().take(input_width).collect();
        output.push_str(&display_input);

        // Position cursor
        let cursor_x = prompt_len + self.cursor_pos.min(input_width);
        output.push_str(&format!("\x1b[{};{}H", input_row + 1, cursor_x + 1));

        output
    }

    /// Format a single message for display
    fn format_message(&self, msg: &Message, width: usize) -> String {
        let timestamp = msg.timestamp.with_timezone(&Local).format("%H:%M");
        
        match &msg.content {
            MessageContent::Text(text) => {
                let color = username_color(&msg.username);
                let (r, g, b) = match color {
                    Color::Rgb { r, g, b } => (r, g, b),
                    _ => (255, 255, 255),
                };
                
                // Format: [HH:MM] username: message
                let prefix = format!(
                    "\x1b[38;2;98;114;164m[{}]\x1b[0m \x1b[38;2;{};{};{}m{}\x1b[0m: ",
                    timestamp, r, g, b, msg.username
                );
                let prefix_display_len = 8 + msg.username.len() + 2; // [HH:MM] + username + ": "
                
                let available = width.saturating_sub(prefix_display_len);
                let truncated_text: String = text.chars().take(available).collect();
                
                format!("{}{}", prefix, truncated_text)
            }
            MessageContent::System(text) => {
                // System messages in gray/italic
                let available = width.saturating_sub(10);
                let truncated_text: String = text.chars().take(available).collect();
                format!(
                    "\x1b[38;2;98;114;164m[{}] *** {} ***\x1b[0m",
                    timestamp, truncated_text
                )
            }
        }
    }
}

/// Render TUI to a byte buffer for sending over SSH
pub async fn render_frame(tui: &ChatTui) -> Vec<u8> {
    tui.render().await.into_bytes()
}