//! v2 REPL — thin shell over `quicproquo_sdk::QpqClient`.
//!
//! Provides an interactive command-line interface with categorized `/help`,
//! tab-completion, and a background event listener. Delegates all crypto,
//! MLS, and RPC work to the SDK.
//!
//! Build: `cargo build -p quicproquo-client --features v2`

use std::path::PathBuf;
use std::process::{Child, Command as ProcessCommand};
use std::sync::Arc;
use std::time::Duration;

use anyhow::Context;
use quicproquo_core::{GroupMember, IdentityKeypair};
use quicproquo_sdk::client::QpqClient;
use quicproquo_sdk::conversation::{ConversationId, ConversationKind, StoredMessage};
use quicproquo_sdk::events::ClientEvent;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Config, Editor, Helper};
use tokio::sync::broadcast;

use super::display;

// ── ANSI helpers ────────────────────────────────────────────────────────────

const RESET: &str = "\x1b[0m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const GREEN: &str = "\x1b[32m";
const CYAN: &str = "\x1b[36m";
const YELLOW: &str = "\x1b[33m";

// ── Command categories ──────────────────────────────────────────────────────

#[derive(Clone, Copy, PartialEq, Eq)]
enum Category {
    Messaging,
    Groups,
    Account,
    Keys,
    Utility,
    Debug,
}

impl Category {
    fn label(self) -> &'static str {
        match self {
            Self::Messaging => "Messaging",
            Self::Groups => "Groups",
            Self::Account => "Account",
            Self::Keys => "Keys",
            Self::Utility => "Utility",
            Self::Debug => "Debug",
        }
    }

    fn all() -> &'static [Category] {
        &[
            Self::Messaging,
            Self::Groups,
            Self::Account,
            Self::Keys,
            Self::Utility,
            Self::Debug,
        ]
    }
}

// ── Static command table ────────────────────────────────────────────────────

struct CmdDef {
    name: &'static str,
    aliases: &'static [&'static str],
    category: Category,
    description: &'static str,
    usage: &'static str,
}

const COMMANDS: &[CmdDef] = &[
    CmdDef { name: "/send",          aliases: &["/s"],           category: Category::Messaging, description: "Send a message to the current conversation", usage: "/send <message>" },
    CmdDef { name: "/dm",            aliases: &[],               category: Category::Messaging, description: "Start or switch to a DM with a user",        usage: "/dm <username>" },
    CmdDef { name: "/recv",          aliases: &["/r"],           category: Category::Messaging, description: "Fetch and display new messages",              usage: "/recv" },
    CmdDef { name: "/history",       aliases: &[],               category: Category::Messaging, description: "Show recent message history",                 usage: "/history [count]" },
    CmdDef { name: "/list",          aliases: &["/ls"],          category: Category::Messaging, description: "List all conversations",                      usage: "/list" },
    CmdDef { name: "/switch",        aliases: &["/sw"],          category: Category::Messaging, description: "Switch active conversation",                  usage: "/switch <name>" },
    CmdDef { name: "/group",         aliases: &["/g"],           category: Category::Groups,    description: "create | invite | leave | list | members | remove | rename | rotate-keys",    usage: "/group <sub> [args]" },
    CmdDef { name: "/devices",        aliases: &[],               category: Category::Account,   description: "list | add | remove — manage linked devices",  usage: "/devices <sub> [args]" },
    CmdDef { name: "/register",      aliases: &[],               category: Category::Account,   description: "Register a new account",                      usage: "/register <user> <pass>" },
    CmdDef { name: "/login",         aliases: &[],               category: Category::Account,   description: "Log in to an existing account",               usage: "/login <user> <pass>" },
    CmdDef { name: "/logout",        aliases: &[],               category: Category::Account,   description: "Log out (clear session)",                     usage: "/logout" },
    CmdDef { name: "/whoami",        aliases: &[],               category: Category::Account,   description: "Show current identity",                       usage: "/whoami" },
    CmdDef { name: "/refresh-key",   aliases: &[],               category: Category::Keys,      description: "Upload a fresh KeyPackage",                   usage: "/refresh-key" },
    CmdDef { name: "/safety-number", aliases: &["/verify"],      category: Category::Keys,      description: "Show safety number for verification",         usage: "/safety-number <user>" },
    CmdDef { name: "/resolve",       aliases: &[],               category: Category::Utility,   description: "Resolve username to identity key",            usage: "/resolve <username>" },
    CmdDef { name: "/help",          aliases: &["/?"],           category: Category::Utility,   description: "Show this help message",                      usage: "/help" },
    CmdDef { name: "/quit",          aliases: &["/q", "/exit"],  category: Category::Utility,   description: "Exit the REPL",                               usage: "/quit" },
    CmdDef { name: "/clear",         aliases: &[],               category: Category::Utility,   description: "Clear the terminal",                          usage: "/clear" },
    CmdDef { name: "/health",        aliases: &[],               category: Category::Debug,     description: "Check server connection health",               usage: "/health" },
    CmdDef { name: "/status",        aliases: &[],               category: Category::Debug,     description: "Show connection and auth state",               usage: "/status" },
];

// ── REPL state ──────────────────────────────────────────────────────────────

struct ReplState {
    current_conversation: Option<ConversationId>,
    current_display_name: Option<String>,
    identity: Option<Arc<IdentityKeypair>>,
}

impl ReplState {
    fn new() -> Self {
        Self {
            current_conversation: None,
            current_display_name: None,
            identity: None,
        }
    }

    fn prompt(&self) -> String {
        let name = self
            .current_display_name
            .as_deref()
            .unwrap_or("no conversation");
        format!("{DIM}[{RESET}{BOLD}{name}{RESET}{DIM}]{RESET} > ")
    }

    fn set_conversation(&mut self, id: ConversationId, name: String) {
        self.current_conversation = Some(id);
        self.current_display_name = Some(name);
    }

    fn require_identity(&self) -> anyhow::Result<Arc<IdentityKeypair>> {
        self.identity
            .clone()
            .ok_or_else(|| anyhow::anyhow!("not logged in — use /login or /register first"))
    }

    fn require_conversation(&self) -> anyhow::Result<&ConversationId> {
        self.current_conversation
            .as_ref()
            .ok_or_else(|| anyhow::anyhow!("no active conversation — use /dm or /group first"))
    }
}

// ── Tab completion ──────────────────────────────────────────────────────────

struct QpqCompleter {
    names: Vec<String>,
}

impl QpqCompleter {
    fn new() -> Self {
        let mut names = Vec::new();
        for cmd in COMMANDS {
            names.push(cmd.name.to_string());
            for a in cmd.aliases {
                names.push(a.to_string());
            }
        }
        for sub in &["create", "invite", "leave", "list", "members", "remove", "rename", "rotate-keys"] {
            names.push(format!("/group {sub}"));
        }
        for sub in &["list", "add", "remove"] {
            names.push(format!("/devices {sub}"));
        }
        Self { names }
    }
}

impl Completer for QpqCompleter {
    type Candidate = Pair;
    fn complete(
        &self,
        line: &str,
        pos: usize,
        _ctx: &rustyline::Context<'_>,
    ) -> rustyline::Result<(usize, Vec<Pair>)> {
        let prefix = &line[..pos];
        if !prefix.starts_with('/') {
            return Ok((0, Vec::new()));
        }
        let matches: Vec<Pair> = self
            .names
            .iter()
            .filter(|n| n.starts_with(prefix))
            .map(|n| Pair {
                display: n.clone(),
                replacement: n.clone(),
            })
            .collect();
        Ok((0, matches))
    }
}
impl Hinter for QpqCompleter { type Hint = String; }
impl Highlighter for QpqCompleter {}
impl Validator for QpqCompleter {}
impl Helper for QpqCompleter {}

// ── Auto-start server ───────────────────────────────────────────────────────

struct ServerGuard(Option<Child>);

impl Drop for ServerGuard {
    fn drop(&mut self) {
        if let Some(ref mut child) = self.0 {
            let _ = child.kill();
            let _ = child.wait();
        }
    }
}

fn find_server_binary() -> Option<PathBuf> {
    if let Ok(exe) = std::env::current_exe() {
        let sibling = exe.with_file_name("qpq-server");
        if sibling.exists() {
            return Some(sibling);
        }
    }
    std::env::var_os("PATH").and_then(|paths| {
        std::env::split_paths(&paths)
            .map(|dir| dir.join("qpq-server"))
            .find(|p| p.exists())
    })
}

async fn auto_start_server(addr: &str) -> ServerGuard {
    if tokio::net::TcpStream::connect(addr).await.is_ok() {
        return ServerGuard(None);
    }
    let binary = match find_server_binary() {
        Some(b) => b,
        None => {
            display::print_status("server not reachable and qpq-server binary not found");
            return ServerGuard(None);
        }
    };
    display::print_status(&format!("starting server on {addr}..."));
    let child = match ProcessCommand::new(&binary)
        .args(["--allow-insecure-auth", "--listen", addr])
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn()
    {
        Ok(c) => c,
        Err(e) => {
            display::print_error(&format!("failed to spawn server: {e}"));
            return ServerGuard(None);
        }
    };
    let guard = ServerGuard(Some(child));
    let mut delay = Duration::from_millis(100);
    let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
    loop {
        tokio::time::sleep(delay).await;
        if tokio::net::TcpStream::connect(addr).await.is_ok() {
            display::print_status("server ready");
            return guard;
        }
        if tokio::time::Instant::now() > deadline {
            display::print_error("server did not become ready within 5 s");
            return guard;
        }
        delay = (delay * 2).min(Duration::from_secs(1));
    }
}

// ── Background event listener ───────────────────────────────────────────────

fn spawn_event_listener(mut rx: broadcast::Receiver<ClientEvent>) {
    tokio::spawn(async move {
        loop {
            match rx.recv().await {
                Ok(event) => show_event(&event),
                Err(broadcast::error::RecvError::Lagged(n)) => {
                    display::print_status(&format!("(skipped {n} events)"));
                }
                Err(broadcast::error::RecvError::Closed) => break,
            }
        }
    });
}

fn show_event(event: &ClientEvent) {
    match event {
        ClientEvent::MessageReceived { sender_name, sender_key, body, .. } => {
            let sender = match sender_name.as_deref() {
                Some(n) if !n.is_empty() => n.to_string(),
                _ => hex::encode(&sender_key[..4.min(sender_key.len())]),
            };
            display::print_incoming(&sender, body);
        }
        ClientEvent::ConversationCreated { display_name, .. } => {
            display::print_status(&format!("new conversation: {display_name}"));
        }
        ClientEvent::MemberAdded { member_key, .. } => {
            display::print_status(&format!(
                "member added: {}",
                hex::encode(&member_key[..4.min(member_key.len())])
            ));
        }
        ClientEvent::Error { message } => display::print_error(message),
        _ => {}
    }
}

// ── Help ────────────────────────────────────────────────────────────────────

fn print_help() {
    println!("\n{BOLD}quicproquo v2 REPL{RESET}\n");
    for cat in Category::all() {
        println!("{BOLD}{}{RESET}", cat.label());
        for cmd in COMMANDS.iter().filter(|c| c.category == *cat) {
            let aliases = if cmd.aliases.is_empty() {
                String::new()
            } else {
                format!(" {DIM}({}){RESET}", cmd.aliases.join(", "))
            };
            println!("  {GREEN}{:<24}{RESET} {}{aliases}", cmd.usage, cmd.description);
        }
        println!();
    }
    println!("{DIM}Bare text (without /) sends to the current conversation.{RESET}\n");
}

// ── Formatting helpers ──────────────────────────────────────────────────────

fn ts(ms: u64) -> String {
    let s = ms / 1000;
    format!("{:02}:{:02}:{:02}", (s / 3600) % 24, (s / 60) % 60, s % 60)
}

fn print_stored(msg: &StoredMessage) {
    let t = ts(msg.timestamp_ms);
    if msg.is_outgoing {
        println!("{DIM}[{t}]{RESET} {GREEN}> {}{RESET}", msg.body);
    } else {
        let fallback = hex::encode(&msg.sender_key[..4.min(msg.sender_key.len())]);
        let sender = msg.sender_name.as_deref().unwrap_or(&fallback);
        println!("{DIM}[{t}]{RESET} {CYAN}{BOLD}{sender}{RESET}: {}", msg.body);
    }
}

// ── Command parsing ─────────────────────────────────────────────────────────

fn split_cmd(input: &str) -> Option<(&str, &str)> {
    let s = input.trim();
    if !s.starts_with('/') {
        return None;
    }
    match s.find(char::is_whitespace) {
        Some(i) => Some((&s[..i], s[i..].trim())),
        None => Some((s, "")),
    }
}

// ── Command dispatch ────────────────────────────────────────────────────────

/// Returns `Ok(true)` when the REPL should exit.
async fn dispatch(
    cmd: &str,
    args: &str,
    client: &mut QpqClient,
    st: &mut ReplState,
) -> anyhow::Result<bool> {
    match cmd {
        "/quit" | "/q" | "/exit" => return Ok(true),
        "/help" | "/?"           => print_help(),
        "/clear"                 => print!("\x1b[2J\x1b[H"),
        "/status"                => do_status(client, st),
        "/health"                => do_health(client),
        "/whoami"                => do_whoami(client),
        "/register"              => do_register(client, st, args).await?,
        "/login"                 => do_login(client, st, args).await?,
        "/logout"                => do_logout(client)?,
        "/resolve"               => do_resolve(client, args).await?,
        "/safety-number" | "/verify" => do_safety(client, st, args).await?,
        "/refresh-key"           => do_refresh_key(client, st).await?,
        "/dm"                    => do_dm(client, st, args).await?,
        "/send" | "/s"           => do_send(client, st, args).await?,
        "/recv" | "/r"           => do_recv(client, st).await?,
        "/history"               => do_history(client, st, args)?,
        "/list" | "/ls"          => do_list(client)?,
        "/switch" | "/sw"        => do_switch(client, st, args)?,
        "/group" | "/g"          => do_group(client, st, args).await?,
        "/devices"               => do_devices(client, args).await?,
        _ => display::print_error(&format!("unknown command: {cmd} (try /help)")),
    }
    Ok(false)
}

// ── Command implementations ─────────────────────────────────────────────────

fn do_status(client: &QpqClient, st: &ReplState) {
    println!("{BOLD}Status{RESET}");
    println!("  connected:     {}", if client.is_connected() { "yes" } else { "no" });
    println!("  authenticated: {}", if client.is_authenticated() { "yes" } else { "no" });
    println!("  username:      {}", client.username().unwrap_or("(none)"));
    println!("  conversation:  {}", st.current_display_name.as_deref().unwrap_or("(none)"));
    if let Some(key) = client.identity_key() {
        println!("  identity:      {}", hex::encode(key));
    }
}

fn do_health(client: &QpqClient) {
    if client.is_connected() {
        display::print_status("connected to server");
    } else {
        display::print_error("not connected");
    }
}

fn do_whoami(client: &QpqClient) {
    match client.username() {
        Some(u) => {
            println!("{BOLD}{u}{RESET}");
            if let Some(key) = client.identity_key() {
                println!("{DIM}identity: {}{RESET}", hex::encode(key));
            }
        }
        None => display::print_status("not logged in"),
    }
}

async fn do_register(client: &mut QpqClient, _st: &mut ReplState, args: &str) -> anyhow::Result<()> {
    let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect();
    if parts.len() < 2 || parts[1].is_empty() {
        display::print_error("usage: /register <username> <password>");
        return Ok(());
    }
    let (user, pass) = (parts[0], parts[1]);
    client.register(user, pass).await.map_err(|e| anyhow::anyhow!("{e}"))?;

    // After registration the SDK has set the identity key (public).
    // To get the full keypair we need the seed. Since `register` internally
    // generates a fresh keypair, we load it from the state file if available,
    // or generate a stand-in for this session.
    if let Some(pub_key) = client.identity_key() {
        if pub_key.len() == 32 {
            display::print_status(&format!("registered as {user}"));
            display::print_status(&format!("identity: {}", hex::encode(pub_key)));
        }
    }
    // Note: identity keypair is set during login (which gives us the seed via state).
    display::print_status("use /login to authenticate");
    Ok(())
}

async fn do_login(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> {
    let parts: Vec<&str> = args.splitn(2, char::is_whitespace).collect();
    if parts.len() < 2 || parts[1].is_empty() {
        display::print_error("usage: /login <username> <password>");
        return Ok(());
    }
    let (user, pass) = (parts[0], parts[1]);
    client.login(user, pass).await.map_err(|e| anyhow::anyhow!("{e}"))?;

    // Try to load identity keypair from state file.
    let state_path = &client.config_state_path();
    if state_path.exists() {
        match quicproquo_sdk::state::load_state(state_path, Some(pass)) {
            Ok(stored) => {
                let kp = IdentityKeypair::from_seed(stored.identity_seed);
                st.identity = Some(Arc::new(kp));
            }
            Err(_) => {
                // Try without password (unencrypted state).
                if let Ok(stored) = quicproquo_sdk::state::load_state(state_path, None) {
                    let kp = IdentityKeypair::from_seed(stored.identity_seed);
                    st.identity = Some(Arc::new(kp));
                }
            }
        }
    }
    display::print_status(&format!("logged in as {user}"));
    Ok(())
}

fn do_logout(client: &mut QpqClient) -> anyhow::Result<()> {
    client.logout().map_err(|e| anyhow::anyhow!("{e}"))?;
    display::print_status("logged out");
    Ok(())
}

async fn do_resolve(client: &QpqClient, args: &str) -> anyhow::Result<()> {
    let name = args.trim();
    if name.is_empty() {
        display::print_error("usage: /resolve <username>");
        return Ok(());
    }
    let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
    match quicproquo_sdk::users::resolve_user(rpc, name).await? {
        Some(key) => println!("  {name} -> {}", hex::encode(&key)),
        None => display::print_error(&format!("user '{name}' not found")),
    }
    Ok(())
}

async fn do_safety(client: &QpqClient, st: &ReplState, args: &str) -> anyhow::Result<()> {
    let name = args.trim();
    if name.is_empty() {
        display::print_error("usage: /safety-number <username>");
        return Ok(());
    }
    let identity = st.require_identity()?;
    let my_key = identity.public_key_bytes();

    let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
    let peer_key = quicproquo_sdk::users::resolve_user(rpc, name)
        .await?
        .ok_or_else(|| anyhow::anyhow!("user '{name}' not found"))?;
    if peer_key.len() != 32 {
        anyhow::bail!("peer key is not 32 bytes");
    }
    let mut peer_arr = [0u8; 32];
    peer_arr.copy_from_slice(&peer_key);

    let sn = quicproquo_core::compute_safety_number(&my_key, &peer_arr);
    println!("\n{BOLD}Safety number with {name}:{RESET}");
    println!("  {sn}\n");
    println!("{DIM}Compare with {name} over a trusted channel.{RESET}");
    Ok(())
}

async fn do_refresh_key(client: &QpqClient, st: &ReplState) -> anyhow::Result<()> {
    let identity = st.require_identity()?;
    let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;

    let mut member = GroupMember::new(Arc::clone(&identity));
    let kp_bytes = member
        .generate_key_package()
        .map_err(|e| anyhow::anyhow!("generate key package: {e}"))?;

    let pub_key = identity.public_key_bytes();
    let fp = quicproquo_sdk::keys::upload_key_package(rpc, &pub_key, &kp_bytes).await?;
    display::print_status(&format!(
        "KeyPackage uploaded (fp: {})",
        hex::encode(&fp[..8.min(fp.len())])
    ));
    Ok(())
}

async fn do_dm(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> {
    let username = args.trim();
    if username.is_empty() {
        display::print_error("usage: /dm <username>");
        return Ok(());
    }
    let identity = st.require_identity()?;
    let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
    let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;

    let peer_key = quicproquo_sdk::users::resolve_user(rpc, username)
        .await?
        .ok_or_else(|| anyhow::anyhow!("user '{username}' not found"))?;

    // Check for existing DM.
    if let Some(existing) = conv_store.find_dm_by_peer(&peer_key)? {
        st.set_conversation(existing.id, format!("@{username}"));
        display::print_status(&format!("switched to DM with @{username}"));
        return Ok(());
    }

    let peer_kp = quicproquo_sdk::keys::fetch_key_package(rpc, &peer_key)
        .await?
        .ok_or_else(|| anyhow::anyhow!("peer has no available KeyPackage"))?;

    let mut member = GroupMember::new(Arc::clone(&identity));

    let (conv_id, was_new) = quicproquo_sdk::groups::create_dm(
        rpc, conv_store, &mut member, &identity,
        &peer_key, &peer_kp, None, None,
    ).await?;

    st.set_conversation(conv_id, format!("@{username}"));
    if was_new {
        display::print_status(&format!("DM created with @{username}"));
    } else {
        display::print_status(&format!("DM with @{username} — waiting for Welcome"));
    }
    Ok(())
}

async fn do_send(client: &QpqClient, st: &ReplState, msg: &str) -> anyhow::Result<()> {
    if msg.is_empty() {
        display::print_error("usage: /send <message>");
        return Ok(());
    }
    let conv_id = st.require_conversation()?;
    let identity = st.require_identity()?;
    let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
    let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;

    let conv = conv_store
        .load_conversation(conv_id)?
        .ok_or_else(|| anyhow::anyhow!("conversation not found"))?;

    let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;

    let my_pub = identity.public_key_bytes();
    let recipients: Vec<Vec<u8>> = conv
        .member_keys
        .iter()
        .filter(|k| k.as_slice() != my_pub.as_slice())
        .cloned()
        .collect();
    if recipients.is_empty() {
        display::print_error("no recipients in conversation");
        return Ok(());
    }

    let hybrid_keys = vec![None; recipients.len()];
    quicproquo_sdk::messaging::send_message(
        rpc, &mut member, &identity, msg, &recipients, &hybrid_keys, conv_id.0.as_slice(),
    ).await?;

    quicproquo_sdk::groups::save_mls_state(conv_store, conv_id, &member)?;

    let now = quicproquo_sdk::conversation::now_ms();
    conv_store.save_message(&StoredMessage {
        conversation_id: conv_id.clone(),
        message_id: None,
        sender_key: my_pub.to_vec(),
        sender_name: client.username().map(|s| s.to_string()),
        body: msg.to_string(),
        msg_type: "chat".to_string(),
        ref_msg_id: None,
        timestamp_ms: now,
        is_outgoing: true,
    })?;

    println!("{GREEN}> {msg}{RESET}");
    Ok(())
}

async fn do_recv(client: &QpqClient, st: &ReplState) -> anyhow::Result<()> {
    let conv_id = st.require_conversation()?;
    let identity = st.require_identity()?;
    let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
    let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;

    let conv = conv_store
        .load_conversation(conv_id)?
        .ok_or_else(|| anyhow::anyhow!("conversation not found"))?;

    let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
    let my_pub = identity.public_key_bytes();

    let messages = quicproquo_sdk::messaging::receive_messages(
        rpc, &mut member, &my_pub, None, conv_id.0.as_slice(), &[],
    ).await?;

    if messages.is_empty() {
        display::print_status("no new messages");
        return Ok(());
    }

    quicproquo_sdk::groups::save_mls_state(conv_store, conv_id, &member)?;

    for m in &messages {
        let sender_name = quicproquo_sdk::users::resolve_identity(rpc, &m.sender_key)
            .await
            .ok()
            .flatten();
        let sender_hex = hex::encode(&m.sender_key[..4]);
        let sender = sender_name.as_deref().unwrap_or(&sender_hex);

        let body = match &m.message {
            quicproquo_core::AppMessage::Chat { body, .. } => {
                String::from_utf8_lossy(body).to_string()
            }
            other => format!("{other:?}"),
        };

        let now = quicproquo_sdk::conversation::now_ms();
        println!("{DIM}[{}]{RESET} {CYAN}{BOLD}{sender}{RESET}: {body}", ts(now));

        conv_store.save_message(&StoredMessage {
            conversation_id: conv_id.clone(),
            message_id: None,
            sender_key: m.sender_key.to_vec(),
            sender_name: sender_name.clone(),
            body,
            msg_type: "chat".to_string(),
            ref_msg_id: None,
            timestamp_ms: now,
            is_outgoing: false,
        })?;
    }
    display::print_status(&format!("{} message(s) received", messages.len()));
    Ok(())
}

fn do_history(client: &QpqClient, st: &ReplState, args: &str) -> anyhow::Result<()> {
    let conv_id = st.require_conversation()?;
    let count = args.trim().parse::<usize>().unwrap_or(20);
    let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
    let msgs = conv_store.load_recent_messages(conv_id, count)?;
    if msgs.is_empty() {
        display::print_status("no messages yet");
    } else {
        for m in &msgs {
            print_stored(m);
        }
    }
    Ok(())
}

fn do_list(client: &QpqClient) -> anyhow::Result<()> {
    let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
    let convs = conv_store.list_conversations()?;
    if convs.is_empty() {
        display::print_status("no conversations — try /dm <username>");
        return Ok(());
    }
    println!("\n{BOLD}Conversations{RESET}");
    for c in &convs {
        let kind_label = match &c.kind {
            ConversationKind::Dm { .. } => "dm",
            ConversationKind::Group { .. } => "group",
        };
        let unread = if c.unread_count > 0 {
            format!(" {YELLOW}({} new){RESET}", c.unread_count)
        } else {
            String::new()
        };
        println!(
            "  {BOLD}{}{RESET} {DIM}[{kind_label}, {} members]{RESET}{unread}",
            c.display_name,
            c.member_keys.len()
        );
    }
    println!();
    Ok(())
}

fn do_switch(client: &QpqClient, st: &mut ReplState, name: &str) -> anyhow::Result<()> {
    let name = name.trim();
    if name.is_empty() {
        display::print_error("usage: /switch <name>");
        return Ok(());
    }
    let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
    let convs = conv_store.list_conversations()?;
    let lower = name.to_lowercase();
    let found = convs.iter().find(|c| c.display_name.to_lowercase().contains(&lower));
    match found {
        Some(c) => {
            st.set_conversation(c.id.clone(), c.display_name.clone());
            display::print_status(&format!("switched to {}", c.display_name));
        }
        None => display::print_error(&format!("no conversation matching '{name}'")),
    }
    Ok(())
}

async fn do_group(client: &mut QpqClient, st: &mut ReplState, args: &str) -> anyhow::Result<()> {
    let parts: Vec<&str> = args.splitn(3, char::is_whitespace).collect();
    let sub = parts.first().copied().unwrap_or("");

    match sub {
        "create" => {
            let name = parts.get(1).copied().unwrap_or("").trim();
            if name.is_empty() {
                display::print_error("usage: /group create <name>");
                return Ok(());
            }
            let identity = st.require_identity()?;
            let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
            let mut member = GroupMember::new(Arc::clone(&identity));
            let conv_id = quicproquo_sdk::groups::create_group(conv_store, &mut member, name)?;
            st.set_conversation(conv_id, format!("#{name}"));
            display::print_status(&format!("group #{name} created"));
        }

        "invite" => {
            let group = parts.get(1).copied().unwrap_or("").trim();
            let user = parts.get(2).copied().unwrap_or("").trim();
            if group.is_empty() || user.is_empty() {
                display::print_error("usage: /group invite <group> <username>");
                return Ok(());
            }
            let identity = st.require_identity()?;
            let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
            let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;

            let peer_key = quicproquo_sdk::users::resolve_user(rpc, user)
                .await?
                .ok_or_else(|| anyhow::anyhow!("user '{user}' not found"))?;
            let peer_kp = quicproquo_sdk::keys::fetch_key_package(rpc, &peer_key)
                .await?
                .ok_or_else(|| anyhow::anyhow!("peer has no KeyPackage"))?;

            let conv_id = ConversationId::from_group_name(group);
            let conv = conv_store
                .load_conversation(&conv_id)?
                .ok_or_else(|| anyhow::anyhow!("group '{group}' not found"))?;
            let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;

            quicproquo_sdk::groups::invite_to_group(
                rpc, conv_store, &mut member, &identity,
                &conv_id, &peer_key, &peer_kp, None, None,
            ).await?;
            display::print_status(&format!("invited @{user} to #{group}"));
        }

        "leave" => {
            let identity = st.require_identity()?;
            let conv_id = st.require_conversation()?.clone();
            let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
            let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
            let conv = conv_store
                .load_conversation(&conv_id)?
                .ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
            let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
            quicproquo_sdk::groups::leave_group(rpc, conv_store, &mut member, &conv_id).await?;
            display::print_status("left group");
        }

        "list" => do_list(client)?,

        "members" => {
            let conv_id = st.require_conversation()?;
            let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
            let conv = conv_store
                .load_conversation(conv_id)?
                .ok_or_else(|| anyhow::anyhow!("conversation not found"))?;

            println!("\n{BOLD}Members{RESET} ({})", conv.member_keys.len());
            for key in &conv.member_keys {
                let short = hex::encode(&key[..4.min(key.len())]);
                if let Ok(rpc) = client.rpc() {
                    if let Ok(Some(n)) = quicproquo_sdk::users::resolve_identity(rpc, key).await {
                        println!("  @{n} {DIM}({short}){RESET}");
                        continue;
                    }
                }
                println!("  {short}");
            }
            println!();
        }

        "remove" => {
            let user = parts.get(1).copied().unwrap_or("").trim();
            if user.is_empty() {
                display::print_error("usage: /group remove <username>");
                return Ok(());
            }
            let identity = st.require_identity()?;
            let conv_id = st.require_conversation()?.clone();
            let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
            let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;

            let peer_key = quicproquo_sdk::users::resolve_user(rpc, user)
                .await?
                .ok_or_else(|| anyhow::anyhow!("user '{user}' not found"))?;
            let conv = conv_store
                .load_conversation(&conv_id)?
                .ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
            let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
            quicproquo_sdk::groups::remove_member_from_group(
                rpc, conv_store, &mut member, &conv_id, &peer_key,
            ).await?;
            display::print_status(&format!("removed @{user} from group"));
        }

        "rename" => {
            let new_name = parts.get(1).copied().unwrap_or("").trim();
            if new_name.is_empty() {
                display::print_error("usage: /group rename <new-name>");
                return Ok(());
            }
            let conv_id = st.require_conversation()?.clone();
            let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
            let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
            quicproquo_sdk::groups::set_group_metadata(
                rpc, conv_store, &conv_id, new_name, "", &[],
            ).await?;
            st.set_conversation(conv_id, format!("#{new_name}"));
            display::print_status(&format!("group renamed to #{new_name}"));
        }

        "rotate-keys" => {
            let identity = st.require_identity()?;
            let conv_id = st.require_conversation()?.clone();
            let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
            let conv_store = client.conversations().map_err(|e| anyhow::anyhow!("{e}"))?;
            let conv = conv_store
                .load_conversation(&conv_id)?
                .ok_or_else(|| anyhow::anyhow!("conversation not found"))?;
            let mut member = quicproquo_sdk::groups::restore_mls_state(&conv, &identity)?;
            quicproquo_sdk::groups::rotate_group_keys(rpc, conv_store, &mut member, &conv_id).await?;
            display::print_status("group keys rotated");
        }

        _ => display::print_error("usage: /group <create|invite|leave|list|members|remove|rename|rotate-keys> [args]"),
    }
    Ok(())
}

// ── Device management ──────────────────────────────────────────────────────

async fn do_devices(client: &mut QpqClient, args: &str) -> anyhow::Result<()> {
    let parts: Vec<&str> = args.splitn(3, char::is_whitespace).collect();
    let sub = parts.first().copied().unwrap_or("");

    match sub {
        "list" => {
            let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
            let devices = quicproquo_sdk::devices::list_devices(rpc).await?;
            if devices.is_empty() {
                display::print_status("no devices registered");
            } else {
                println!("\n{BOLD}Devices{RESET} ({})", devices.len());
                println!("{:<36}  {:<20}  REGISTERED AT", "DEVICE ID", "NAME");
                for d in &devices {
                    println!(
                        "{:<36}  {:<20}  {}",
                        hex::encode(&d.device_id),
                        d.device_name,
                        d.registered_at,
                    );
                }
                println!();
            }
        }

        "add" => {
            let name = parts.get(1).copied().unwrap_or("").trim();
            if name.is_empty() {
                display::print_error("usage: /devices add <name>");
                return Ok(());
            }
            let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
            // Generate a random device ID (16 bytes).
            use rand::RngCore;
            let mut dev_id = vec![0u8; 16];
            rand::rngs::OsRng.fill_bytes(&mut dev_id);
            let was_new =
                quicproquo_sdk::devices::register_device(rpc, &dev_id, name).await?;
            if was_new {
                display::print_status(&format!(
                    "device registered: {name} (id: {})",
                    hex::encode(&dev_id)
                ));
            } else {
                display::print_status(&format!("device already exists: {name}"));
            }
        }

        "remove" => {
            let id_hex = parts.get(1).copied().unwrap_or("").trim();
            if id_hex.is_empty() {
                display::print_error("usage: /devices remove <device-id-hex>");
                return Ok(());
            }
            let id_bytes = hex::decode(id_hex)
                .map_err(|e| anyhow::anyhow!("invalid device_id hex: {e}"))?;
            let rpc = client.rpc().map_err(|e| anyhow::anyhow!("{e}"))?;
            let revoked = quicproquo_sdk::devices::revoke_device(rpc, &id_bytes).await?;
            if revoked {
                display::print_status(&format!("device revoked: {id_hex}"));
            } else {
                display::print_error(&format!("device not found: {id_hex}"));
            }
        }

        _ => display::print_error("usage: /devices <list|add|remove> [args]"),
    }
    Ok(())
}

// ── Entry point ─────────────────────────────────────────────────────────────

/// Run the v2 REPL over a `QpqClient`.
///
/// If `username` and `password` are provided, auto-login is attempted.
pub async fn run_v2_repl(
    client: &mut QpqClient,
    username: Option<&str>,
    password: Option<&str>,
) -> anyhow::Result<()> {
    // Auto-start server.
    let _server_guard = auto_start_server(&client.server_addr_string()).await;

    // Connect to server.
    client.connect().await.context("connect to server")?;

    // Background event listener.
    let rx = client.subscribe();
    spawn_event_listener(rx);

    let mut st = ReplState::new();

    // Auto-login if credentials provided.
    if let (Some(user), Some(pass)) = (username, password) {
        match client.login(user, pass).await {
            Ok(()) => {
                display::print_status(&format!("logged in as {user}"));
                // Load identity from state.
                let state_path = client.config_state_path();
                if state_path.exists() {
                    if let Ok(stored) = quicproquo_sdk::state::load_state(&state_path, Some(pass))
                        .or_else(|_| quicproquo_sdk::state::load_state(&state_path, None))
                    {
                        let kp = IdentityKeypair::from_seed(stored.identity_seed);
                        st.identity = Some(Arc::new(kp));
                    }
                }
            }
            Err(e) => display::print_error(&format!("auto-login failed: {e}")),
        }
    }

    println!("\n{BOLD}quicproquo v2 REPL{RESET}");
    println!("{DIM}Type /help for commands, /quit to exit.{RESET}\n");
    if let Some(u) = client.username() {
        display::print_status(&format!("authenticated as {u}"));
    }

    // Rustyline editor with tab-completion.
    let config = Config::builder().auto_add_history(true).build();
    let mut rl: Editor<QpqCompleter, rustyline::history::DefaultHistory> =
        Editor::with_config(config).context("init readline")?;
    rl.set_helper(Some(QpqCompleter::new()));

    loop {
        let prompt = st.prompt();
        match rl.readline(&prompt) {
            Ok(line) => {
                let trimmed = line.trim();
                if trimmed.is_empty() {
                    continue;
                }
                if let Some((cmd, args)) = split_cmd(trimmed) {
                    match dispatch(cmd, args, client, &mut st).await {
                        Ok(true) => break,
                        Ok(false) => {}
                        Err(e) => display::print_error(&format!("{e:#}")),
                    }
                } else {
                    // Bare text → send to current conversation.
                    if let Err(e) = do_send(client, &st, trimmed).await {
                        display::print_error(&format!("{e:#}"));
                    }
                }
            }
            Err(ReadlineError::Interrupted | ReadlineError::Eof) => {
                display::print_status("goodbye");
                break;
            }
            Err(e) => {
                display::print_error(&format!("readline: {e}"));
                break;
            }
        }
    }

    client.disconnect();
    Ok(())
}

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

    #[test]
    fn split_cmd_basic_command() {
        assert_eq!(split_cmd("/help"), Some(("/help", "")));
    }

    #[test]
    fn split_cmd_command_with_args() {
        assert_eq!(split_cmd("/send hello world"), Some(("/send", "hello world")));
    }

    #[test]
    fn split_cmd_leading_whitespace() {
        assert_eq!(split_cmd("   /quit"), Some(("/quit", "")));
    }

    #[test]
    fn split_cmd_non_command() {
        assert_eq!(split_cmd("hello"), None);
    }

    #[test]
    fn split_cmd_empty_input() {
        assert_eq!(split_cmd(""), None);
        assert_eq!(split_cmd("   "), None);
    }

    #[test]
    fn split_cmd_slash_only() {
        assert_eq!(split_cmd("/"), Some(("/", "")));
    }

    #[test]
    fn split_cmd_args_trimmed() {
        assert_eq!(split_cmd("/dm    alice"), Some(("/dm", "alice")));
    }

    #[test]
    fn split_cmd_special_characters() {
        assert_eq!(
            split_cmd("/send hello! @alice #chan"),
            Some(("/send", "hello! @alice #chan"))
        );
    }
}
