use std::cell::Cell;
use std::io;
use std::time::{Duration, Instant};

use anyhow::Result;
use crossterm::{
    event::{
        self, poll, DisableMouseCapture, EnableMouseCapture, Event, MouseButton, MouseEventKind,
    },
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::prelude::*;
use ratatui::Terminal;

use base64::Engine;
use huddle_core::app::events::{AppEvent, DiscoveredRoom};
use huddle_core::app::{AppHandle, KnownPeerStatus};
use huddle_core::network::NetworkMode;
use huddle_core::storage::repo::{StoredAttachment, StoredRoomMessage};
use libp2p::PeerId;

use crate::input::{self, Action};

/// Default lifetime for transient status-bar messages.
const STATUS_TTL: Duration = Duration::from_secs(6);

/// Phase H: first-launch onboarding pages. Three cards explaining the
/// mental model + key shortcuts. Kept inline as a const so any change
/// only needs `cargo build` to refresh — no runtime config plumbing.
pub const ONBOARDING_PAGES: &[(&str, &[&str])] = &[
    (
        "huddle is not iMessage",
        &[
            "every member is a peer — no host, no central server.",
            "rooms outlive whoever created them.",
            "anyone with the room passphrase can join, send, rotate the key.",
            "leaderless + persistent mesh; the protocol has no admin tier",
            "by default, only the soft 'owner' role you can grant per room.",
            "",
            "press → / Tab / Enter / Space to continue.",
        ],
    ),
    (
        "passphrase ≠ password",
        &[
            "the master passphrase encrypts your LOCAL database (rooms,",
            "messages, members, Megolm sessions, attachments).",
            "room passphrases are the access keys to encrypted rooms.",
            "neither is recoverable — there's no reset, by design.",
            "",
            "for sharing access without leaking your passphrase, use",
            "  ^J  generate a 10-min single-use join code",
            "  ^V→s  SAS-verify a member's fingerprint",
            "  ^I  produce an invite link (passphrase still OOB)",
        ],
    ),
    (
        "what's new in 0.5",
        &[
            "  a    add friend by HD ID or username (lobby)",
            "  ,→u  set / clear your username (broadcast signed)",
            "  ,→!  delete account + wipe data dir (go dark)",
            "  ✓    green tag next to SAS-verified peers in chat",
            "",
            "still around from 0.3 / 0.4:",
            "  ^K   kick a member (signed ban + key rotation)",
            "  ^G   grant another member the owner role",
            "  ^J   generate a 10-minute join code (owner only)",
            "  ^V   verify fingerprints; press s inside for SAS",
            "  ^I   show an invite link for the current room",
            "  v    paste an invite link from the lobby",
            "  o    toggle 'only verified members may join' (in room)",
            "",
            "press Enter to dismiss.",
        ],
    ),
];

/// Top-level screen — the lobby or the in-room view.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Screen {
    Lobby,
    InRoom,
}

/// Modal overlays (mutually exclusive).
#[derive(Debug, Clone)]
pub enum Modal {
    None,
    StartRoom(StartRoomState),
    JoinRoom(JoinRoomState),
    DialPeer(DialPeerState),
    AttachPicker(AttachPickerState),
    /// "Rotate the current room's key" — entered with ^R.
    RotateRoom(RotateRoomState),
    /// Someone else rotated our room's key; ask the user for the new
    /// passphrase so we can continue receiving messages.
    AcceptRotation(AcceptRotationState),
    /// Verify member fingerprints (^V).
    Verify(VerifyState),
    /// Search room history (^F).
    Search(SearchState),
    /// QR code of our identity, scannable for fingerprint comparison.
    QrIdentity,
    /// Phase A: an unknown peer dialed us. User chooses accept/reject/
    /// trust-and-accept. Dismiss without choosing (Esc, 15s timeout) =
    /// auto-reject — anything more permissive would defeat the gate.
    InboundDial(InboundDialState),
    /// Phase B: owner-only picker for kick / grant-owner on a member.
    MemberAction(MemberActionState),
    /// Phase G: SAS verification in progress — initial waiting state
    /// before the partner's ephemeral pubkey arrives, then code-display
    /// + match-confirm.
    Sas(SasState),
    /// Phase E: app-wide settings (currently just the global
    /// verified-only inbound toggle). Opened with `,` from lobby.
    Settings(SettingsState),
    /// huddle 0.5: single-field text editor for the local user's
    /// self-declared username. Empty input clears (None) and the user
    /// renders as `[anonymous]` to themselves and peers. On confirm,
    /// triggers a signed ProfileUpdate broadcast to every joined room.
    EditUsername(EditUsernameState),
    /// huddle 0.5: irreversible account-delete modal. Two fields:
    /// master passphrase + a `DELETE EVERYTHING` confirmation phrase.
    /// Hitting Confirm with both filled calls `AppHandle::go_dark`.
    GoDark(GoDarkState),
    /// huddle 0.5.1: "add friend by HD ID or username" — single-field
    /// text input. On confirm, resolves the input to a fingerprint
    /// (HD-prefix / bare hex / username lookup) and dials via the
    /// usual flow.
    AddFriend(AddFriendState),
    /// Phase F: an owner just generated a short-lived join code for
    /// the current encrypted room. The modal shows it big so the
    /// owner can read it aloud / copy it / pass it OOB.
    ShowJoinCode(ShowJoinCodeState),
    /// Phase F: joiner enters a code shared by an owner of an
    /// encrypted room. On confirm, sends a signed CodeJoinRequest;
    /// the room opens read-only on the owner's response.
    JoinWithCode(JoinWithCodeState),
    /// Phase C: show a freshly-generated invite link (URL form),
    /// optionally room-scoped, so the user can copy + share.
    ShowInvite(ShowInviteState),
    /// Phase C: paste-an-invite text field.
    PasteInvite(PasteInviteState),
    /// Phase C: parsed invite — confirm before dialing.
    ConfirmInvite(ConfirmInviteState),
    /// Phase H: first-launch onboarding card. Three pages, dismissed
    /// once and persisted on the identity row.
    Onboarding {
        page: usize,
    },
    QuitConfirm,
    Help,
    Error(String),
    Info(String),
}

#[derive(Debug, Clone)]
pub struct SettingsState {
    pub verified_only_inbound: bool,
    /// Snapshot of the persistent blocklist size, taken when the modal
    /// opens. Re-snapped after `ClearBlockedPeers` so the row updates
    /// without a modal re-open.
    pub blocked_peer_count: usize,
    /// huddle 0.5: snapshot of the local user's self-declared username
    /// (None ⇒ `[anonymous]`). Refreshed when the Settings modal is
    /// re-opened so an edit propagates without a tab away.
    pub username: Option<String>,
}

#[derive(Debug, Clone, Default)]
pub struct EditUsernameState {
    pub input: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GoDarkField {
    #[default]
    Passphrase,
    Confirm,
}

#[derive(Debug, Clone, Default)]
pub struct GoDarkState {
    pub passphrase: String,
    pub confirm: String,
    pub focus: GoDarkField,
    /// Set after a wrong-passphrase attempt so the modal can flash an
    /// inline error without dismissing.
    pub last_error: Option<String>,
}

pub const GO_DARK_CONFIRM_PHRASE: &str = "DELETE EVERYTHING";

#[derive(Debug, Clone, Default)]
pub struct AddFriendState {
    pub input: String,
}

#[derive(Debug, Clone)]
pub struct ShowJoinCodeState {
    /// Room identifier so the modal can render a short hash next to
    /// the room name — useful when two rooms share a name (e.g.
    /// multiple "general"s discovered across networks).
    pub room_id: String,
    pub room_name: String,
    pub code: String,
}

#[derive(Debug, Clone)]
pub struct JoinWithCodeState {
    pub room_id: String,
    pub room_name: String,
    pub code: String,
}

#[derive(Debug, Clone)]
pub struct ShowInviteState {
    pub url: String,
    pub includes_room: Option<String>,
}

#[derive(Debug, Clone)]
pub struct PasteInviteState {
    pub url: String,
}

#[derive(Debug, Clone)]
pub struct ConfirmInviteState {
    pub invite: huddle_core::invite::InviteLink,
}

/// Phase G: stages of an in-flight SAS verification. The state advances
/// from `Waiting` (just started, no code yet) → `Comparing` (both sides
/// have the code, user is deciding) on `AppEvent::SasCodeReady`.
#[derive(Debug, Clone)]
pub enum SasStage {
    /// Initiator only — we sent SasInit, waiting for the partner's
    /// SasResponse to arrive so we can derive the code.
    Waiting,
    /// Both ephemeral pubkeys exchanged; the SAS code is shown for
    /// OOB comparison.
    Comparing {
        emoji_string: String,
        emoji_labels: String,
        decimal: String,
        /// True once we've broadcast our SasConfirm. Used to hide the
        /// "Match" button after pressing it (avoid double-fire).
        our_matched: bool,
    },
}

#[derive(Debug, Clone)]
pub struct SasState {
    /// Room the SAS exchange started in. Surfaced in the modal title
    /// ("SAS · #room-name") so the user knows which conversation
    /// they're verifying — important when multiple rooms are open.
    pub room_id: String,
    pub partner_fingerprint: String,
    pub tx_id: String,
    pub stage: SasStage,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MemberActionKind {
    Kick,
    Grant,
}

#[derive(Debug, Clone)]
pub struct MemberActionState {
    pub room_id: String,
    pub kind: MemberActionKind,
    /// (fingerprint, is_already_owner). For Grant, owners are filtered
    /// out at open-time so the list only shows promotable members.
    pub members: Vec<(String, bool)>,
    pub selected: usize,
}

#[derive(Debug, Clone)]
pub struct InboundDialState {
    pub peer_id: PeerId,
    pub fingerprint: String,
    pub address: String,
    /// When the modal opened. Used to auto-reject after 15s — the
    /// network connection stays in our `pending_inbound` map all that
    /// time, so a user who never sees the modal won't accidentally
    /// keep an unknown peer attached forever.
    pub opened_at: Instant,
}

#[derive(Debug, Clone)]
pub struct RotateRoomState {
    pub room_id: String,
    pub passphrase: String,
}

#[derive(Debug, Clone)]
pub struct AcceptRotationState {
    pub room_id: String,
    pub rotator_fingerprint: String,
    pub new_salt: Vec<u8>,
    pub passphrase: String,
}

#[derive(Debug, Clone)]
pub struct SearchState {
    pub room_id: String,
    pub query: String,
    pub results: Vec<StoredRoomMessage>,
    pub selected: usize,
    pub searched: bool,
}

#[derive(Debug, Clone)]
pub struct VerifyState {
    pub room_id: String,
    pub our_fingerprint: String,
    /// (fingerprint, currently-verified)
    pub members: Vec<(String, bool)>,
    pub selected: usize,
}

#[derive(Debug, Clone)]
pub struct AttachEntry {
    pub name: String,
    pub is_dir: bool,
}

#[derive(Debug, Clone)]
pub struct AttachPickerState {
    pub cwd: std::path::PathBuf,
    pub entries: Vec<AttachEntry>,
    pub selected: usize,
    pub error: Option<String>,
}

impl AttachPickerState {
    pub fn new() -> Self {
        let start = dirs::download_dir()
            .or_else(dirs::home_dir)
            .unwrap_or_else(|| std::path::PathBuf::from("/"));
        let mut s = Self {
            cwd: start,
            entries: Vec::new(),
            selected: 0,
            error: None,
        };
        s.reload();
        s
    }

    pub fn reload(&mut self) {
        self.error = None;
        self.entries.clear();
        self.selected = 0;
        match std::fs::read_dir(&self.cwd) {
            Ok(rd) => {
                let mut tmp: Vec<AttachEntry> = Vec::new();
                for entry in rd.flatten() {
                    let name = entry.file_name().to_string_lossy().to_string();
                    if name.starts_with('.') {
                        continue;
                    }
                    let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
                    tmp.push(AttachEntry { name, is_dir });
                }
                tmp.sort_by(|a, b| match (a.is_dir, b.is_dir) {
                    (true, false) => std::cmp::Ordering::Less,
                    (false, true) => std::cmp::Ordering::Greater,
                    _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
                });
                self.entries = tmp;
            }
            Err(e) => {
                self.error = Some(format!("cannot read {}: {}", self.cwd.display(), e));
            }
        }
    }

    pub fn descend(&mut self) {
        if let Some(e) = self.entries.get(self.selected) {
            if e.is_dir {
                self.cwd.push(&e.name);
                self.reload();
            }
        }
    }

    pub fn ascend(&mut self) {
        if let Some(parent) = self.cwd.parent() {
            self.cwd = parent.to_path_buf();
            self.reload();
        }
    }

    pub fn selected_path(&self) -> Option<std::path::PathBuf> {
        let e = self.entries.get(self.selected)?;
        if e.is_dir {
            None
        } else {
            Some(self.cwd.join(&e.name))
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct DialPeerState {
    pub address: String,
    pub status: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartField {
    Name,
    Encrypted,
    Passphrase,
}

#[derive(Debug, Clone)]
pub struct StartRoomState {
    pub name: String,
    pub encrypted: bool,
    pub passphrase: String,
    pub focus: StartField,
}

impl StartRoomState {
    pub fn new() -> Self {
        Self {
            name: String::new(),
            encrypted: false,
            passphrase: String::new(),
            focus: StartField::Name,
        }
    }
}

#[derive(Debug, Clone)]
pub struct JoinRoomState {
    pub room_id: String,
    pub room_name: String,
    pub encrypted: bool,
    pub passphrase: String,
}

/// Minimum gap between successive Typing broadcasts per room.
const TYPING_DEBOUNCE: Duration = Duration::from_millis(800);

/// A room we're currently in (a tab in the in-room view).
pub struct OpenRoom {
    pub room_id: String,
    pub name: String,
    pub encrypted: bool,
    pub members: Vec<String>,
    pub messages: Vec<StoredRoomMessage>,
    /// Attachments currently in this room, in chronological order.
    /// Refreshed from the AppHandle on render and on file events.
    pub attachments: Vec<StoredAttachment>,
    pub input: String,
    pub input_active: bool,
    /// Number of lines skipped from the top of the wrapped message
    /// buffer. Bounded by `last_max_scroll` at render time.
    pub scroll: u16,
    /// Last time we broadcast a Typing pulse for this room; used to
    /// debounce ChatTypeChar so we don't spam gossipsub.
    pub last_typing_sent: Option<Instant>,
    /// When true, render anchors to the bottom regardless of `scroll` —
    /// new messages stay visible. Any ScrollUp / PgUp / Home disables it.
    pub follow_mode: bool,
    /// Last-rendered maximum scroll value (total_lines − visible_height).
    /// Updated by `render_messages` so action handlers can clamp / detect
    /// "we just hit the bottom" without re-running the wrap.
    pub last_max_scroll: Cell<u16>,
    /// When true and input is blurred, j/k navigate file cards instead
    /// of scrolling. Enter activates the focused card.
    pub card_focus: bool,
    /// Index into the visible cards (filtered from `attachments`).
    pub focused_card_idx: usize,
    pub unread: bool,
}

/// Which list the cursor is on in the lobby — known dial peers or
/// discovered rooms. Used for `j/k` navigation and `Enter`/`r`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LobbyFocus {
    KnownPeers,
    Rooms,
}

pub struct TuiApp {
    pub handle: AppHandle,
    pub mode: NetworkMode,
    pub screen: Screen,
    pub modal: Modal,
    /// An async-event modal (an error, or a rotation request) that
    /// arrived while the user was mid-interaction with another modal.
    /// Surfaced once the foreground modal is dismissed.
    pub pending_modal: Option<Modal>,
    pub discovered_rooms: Vec<DiscoveredRoom>,
    pub known_peers: Vec<KnownPeerStatus>,
    pub lobby_focus: LobbyFocus,
    pub selected_room_idx: usize,
    pub selected_peer_idx: usize,
    pub open_rooms: Vec<OpenRoom>,
    pub active_tab: usize,
    pub listen_addresses: Vec<String>,
    /// Bottom-bar status: text + expiry instant. After expiry, treated
    /// as None by the renderer.
    pub status_message: Option<(String, Instant)>,
    /// Phase D follow-up: the lobby header renders this as a
    /// reachability badge. `None` until AutoNAT delivers its first
    /// transition; `Some("reachable")` once any external address
    /// passes a probe; `Some("private")` if a reachable address
    /// later disappears (all probes failing). The TUI maps this to
    /// '🌐 reachable' / '🏠 private' emoji in `lobby::render_status`.
    pub nat_status: Option<String>,
    /// huddle 0.5: set to `Instant::now()` when `AppEvent::WentDark`
    /// arrives. The main loop polls this and quits the process once
    /// `GO_DARK_FAREWELL` has elapsed so the goodbye modal stays
    /// visible for a beat.
    pub went_dark_at: Option<Instant>,
}

/// huddle 0.5: how long the goodbye modal stays on screen after
/// `WentDark` before the process exits.
pub const GO_DARK_FAREWELL: Duration = Duration::from_secs(2);

impl TuiApp {
    pub fn new(handle: AppHandle) -> Self {
        let mode = handle.mode();
        let known_peers = handle.known_peers();
        let lobby_focus = if mode == NetworkMode::Direct && !known_peers.is_empty() {
            LobbyFocus::KnownPeers
        } else {
            LobbyFocus::Rooms
        };
        // Phase H: surface the onboarding card on first launch via the
        // existing pending_modal slot. main_loop's promote-when-idle
        // step opens it on the first frame.
        let pending_modal = if !handle.onboarding_seen() {
            Some(Modal::Onboarding { page: 0 })
        } else {
            None
        };
        Self {
            handle,
            mode,
            screen: Screen::Lobby,
            modal: Modal::None,
            pending_modal,
            discovered_rooms: Vec::new(),
            known_peers,
            lobby_focus,
            selected_room_idx: 0,
            selected_peer_idx: 0,
            open_rooms: Vec::new(),
            active_tab: 0,
            listen_addresses: Vec::new(),
            status_message: None,
            nat_status: None,
            went_dark_at: None,
        }
    }

    pub fn refresh_known_peers(&mut self) {
        self.known_peers = self.handle.known_peers();
        if self.selected_peer_idx >= self.known_peers.len() && !self.known_peers.is_empty() {
            self.selected_peer_idx = self.known_peers.len() - 1;
        }
    }

    /// Set the bottom status line with the default 6s TTL.
    pub fn set_status(&mut self, msg: impl Into<String>) {
        self.status_message = Some((msg.into(), Instant::now() + STATUS_TTL));
    }

    /// Set the status line with an explicit TTL. Used for transient
    /// notifications worth a longer dwell than the default 6 s — e.g.
    /// DCUtR upgrade success ("direct connection to <peer>") which we
    /// want the user to actually see before it scrolls.
    pub fn set_status_for(&mut self, msg: impl Into<String>, ttl: Duration) {
        self.status_message = Some((msg.into(), Instant::now() + ttl));
    }

    /// Returns the current status text if it hasn't expired.
    pub fn current_status(&self) -> Option<&str> {
        self.status_message.as_ref().and_then(|(msg, exp)| {
            if *exp > Instant::now() {
                Some(msg.as_str())
            } else {
                None
            }
        })
    }

    /// Drop the stored status if it's already past its expiry. Cheap; safe
    /// to call every tick.
    pub fn tick_status(&mut self) {
        if let Some((_, exp)) = &self.status_message {
            if *exp <= Instant::now() {
                self.status_message = None;
            }
        }
    }

    pub fn active_room(&self) -> Option<&OpenRoom> {
        self.open_rooms.get(self.active_tab)
    }

    pub fn active_room_mut(&mut self) -> Option<&mut OpenRoom> {
        self.open_rooms.get_mut(self.active_tab)
    }

    pub fn refresh_discovered(&mut self) {
        self.discovered_rooms = self.handle.discovered_rooms();
        if self.selected_room_idx >= self.discovered_rooms.len() && !self.discovered_rooms.is_empty() {
            self.selected_room_idx = self.discovered_rooms.len() - 1;
        }
    }

    /// Refresh attachments for every open room from the AppHandle. Called
    /// on tick so card state stays in sync with chunks arriving.
    pub fn refresh_attachments(&mut self) {
        let handle = self.handle.clone();
        for room in &mut self.open_rooms {
            room.attachments = handle.list_room_attachments(&room.room_id).unwrap_or_default();
            if room.attachments.is_empty() {
                room.focused_card_idx = 0;
                room.card_focus = false;
            } else if room.focused_card_idx >= room.attachments.len() {
                room.focused_card_idx = room.attachments.len() - 1;
            }
        }
    }

    /// Show `m` now if the user isn't mid-interaction with a modal,
    /// otherwise queue it behind the current one. Dismissible modals
    /// (None / Error / Info) are pure output and safe to displace; input
    /// modals hold unsaved user state and must not be clobbered by an
    /// async event.
    fn replace_modal_if_idle(&mut self, m: Modal) {
        if matches!(self.modal, Modal::None | Modal::Error(_) | Modal::Info(_)) {
            self.modal = m;
        } else {
            self.pending_modal = Some(m);
        }
    }

    pub fn handle_app_event(&mut self, ev: AppEvent) {
        match ev {
            AppEvent::RoomDiscovered(_) | AppEvent::RoomLost { .. } => {
                self.refresh_discovered();
            }
            AppEvent::RoomJoined { room_id } => {
                let info = self.handle.active_room_info(&room_id);
                let members = self.handle.room_members(&room_id);
                let messages = self.handle.room_messages(&room_id, 200).unwrap_or_default();
                if !self.open_rooms.iter().any(|r| r.room_id == room_id) {
                    if let Some(info) = info {
                        let attachments = self
                            .handle
                            .list_room_attachments(&room_id)
                            .unwrap_or_default();
                        self.open_rooms.push(OpenRoom {
                            room_id: room_id.clone(),
                            name: info.name,
                            encrypted: info.encrypted,
                            members,
                            messages,
                            attachments,
                            input: String::new(),
                            input_active: true,
                            last_typing_sent: None,
                            scroll: 0,
                            follow_mode: true,
                            last_max_scroll: Cell::new(0),
                            card_focus: false,
                            focused_card_idx: 0,
                            unread: false,
                        });
                        self.active_tab = self.open_rooms.len() - 1;
                        self.screen = Screen::InRoom;
                    }
                }
            }
            AppEvent::RoomLeft { room_id } => {
                if let Some(idx) = self.open_rooms.iter().position(|r| r.room_id == room_id) {
                    self.open_rooms.remove(idx);
                    if self.open_rooms.is_empty() {
                        self.screen = Screen::Lobby;
                        self.active_tab = 0;
                    } else if self.active_tab >= self.open_rooms.len() {
                        self.active_tab = self.open_rooms.len() - 1;
                    }
                }
            }
            AppEvent::MemberJoined { room_id, fingerprint } => {
                if let Some(r) = self.open_rooms.iter_mut().find(|r| r.room_id == room_id) {
                    if !r.members.contains(&fingerprint) {
                        r.members.push(fingerprint);
                        r.members.sort();
                    }
                }
            }
            AppEvent::MemberLeft { room_id, fingerprint } => {
                if let Some(r) = self.open_rooms.iter_mut().find(|r| r.room_id == room_id) {
                    r.members.retain(|f| f != &fingerprint);
                }
            }
            AppEvent::MessageReceived {
                room_id,
                sender_fingerprint,
                body,
                sent_at,
            } => {
                let idx_opt = self.open_rooms.iter().position(|r| r.room_id == room_id);
                if let Some(idx) = idx_opt {
                    let is_active = idx == self.active_tab && self.screen == Screen::InRoom;
                    let r = &mut self.open_rooms[idx];
                    r.messages.push(StoredRoomMessage {
                        id: 0,
                        room_id: room_id.clone(),
                        sender_fingerprint,
                        direction: "in".into(),
                        body,
                        sent_at,
                    });
                    if !is_active {
                        r.unread = true;
                    }
                }
            }
            AppEvent::MessageSent {
                room_id,
                body,
                message_id,
            } => {
                if let Some(r) = self.open_rooms.iter_mut().find(|r| r.room_id == room_id) {
                    let now = std::time::SystemTime::now()
                        .duration_since(std::time::UNIX_EPOCH)
                        .unwrap()
                        .as_secs() as i64;
                    r.messages.push(StoredRoomMessage {
                        id: message_id,
                        room_id: room_id.clone(),
                        sender_fingerprint: self.handle.fingerprint().to_string(),
                        direction: "out".into(),
                        body,
                        sent_at: now,
                    });
                }
            }
            AppEvent::ListeningOn { address } => {
                if !self.listen_addresses.contains(&address) {
                    self.listen_addresses.push(address);
                }
            }
            AppEvent::PeerDiscovered { .. } => {}
            AppEvent::PeerExpired { .. } => {
                self.refresh_known_peers();
            }
            AppEvent::Dialing { address } => {
                self.set_status(format!("dialing {}…", address));
                if let Modal::DialPeer(s) = &mut self.modal {
                    s.status = Some(format!("dialing {}…", address));
                }
            }
            AppEvent::DialSucceeded { address, .. } => {
                self.set_status(format!("connected to {}", address));
                if matches!(self.modal, Modal::DialPeer(_)) {
                    self.modal = Modal::None;
                }
                self.refresh_known_peers();
            }
            AppEvent::DialFailed { address, error } => {
                let msg = format!("dial {} failed: {}", address, error);
                self.set_status(msg.clone());
                if matches!(self.modal, Modal::DialPeer(_)) {
                    self.modal = Modal::Error(msg);
                }
                self.refresh_known_peers();
            }
            AppEvent::Error { description } => {
                self.replace_modal_if_idle(Modal::Error(description));
            }
            AppEvent::FileOffered {
                room_id,
                file_id: _,
                name,
                size_bytes,
                sender_fingerprint: _,
            } => {
                let active_id = self
                    .open_rooms
                    .get(self.active_tab)
                    .map(|r| r.room_id.clone());
                let on_active = self.screen == Screen::InRoom && active_id.as_deref() == Some(&room_id);
                if let Some(r) = self.open_rooms.iter_mut().find(|r| r.room_id == room_id) {
                    if !on_active {
                        r.unread = true;
                    }
                }
                self.set_status(format!(
                    "file offered: {} ({} KB)",
                    name,
                    size_bytes / 1024
                ));
            }
            AppEvent::FileProgress { .. } => {
                // Progress is read on render from the attachments list;
                // no state change here.
            }
            AppEvent::FileReady { file_id: _ } => {
                self.set_status("file ready — press Enter to save");
            }
            AppEvent::FileSaved { file_id: _, path } => {
                self.set_status(format!("saved to {}", path));
            }
            AppEvent::FileFailed { file_id: _, reason } => {
                self.set_status(format!("transfer failed: {}", reason));
            }
            AppEvent::TypingChanged { .. } => {
                // The UI re-reads typers per-frame via handle.typers_in_room;
                // nothing else to do here.
            }
            AppEvent::MentionReceived { room_id, body } => {
                // BEL (0x07): most terminals beep or flash on this.
                use std::io::Write;
                let _ = write!(std::io::stdout(), "\x07");
                let _ = std::io::stdout().flush();
                self.set_status(format!("@you mentioned in #{}", short_room(&room_id)));
                let _ = body;
            }
            AppEvent::RotationRequested {
                room_id,
                rotator_fingerprint,
                new_salt,
            } => {
                self.replace_modal_if_idle(Modal::AcceptRotation(AcceptRotationState {
                    room_id,
                    rotator_fingerprint,
                    new_salt,
                    passphrase: String::new(),
                }));
            }
            AppEvent::InboundDial {
                peer_id,
                fingerprint,
                address,
            } => {
                self.replace_modal_if_idle(Modal::InboundDial(InboundDialState {
                    peer_id,
                    fingerprint,
                    address,
                    opened_at: Instant::now(),
                }));
            }
            AppEvent::SasCodeReady {
                room_id,
                partner_fingerprint,
                tx_id,
                emoji_string,
                emoji_labels,
                decimal,
            } => {
                // If our SAS modal already targets this tx_id, advance
                // it to Comparing. Otherwise this is a fresh inbound
                // SAS request — surface a new modal.
                let advanced = if let Modal::Sas(s) = &mut self.modal {
                    if s.tx_id == tx_id {
                        s.stage = SasStage::Comparing {
                            emoji_string: emoji_string.clone(),
                            emoji_labels: emoji_labels.clone(),
                            decimal: decimal.clone(),
                            our_matched: false,
                        };
                        true
                    } else {
                        false
                    }
                } else {
                    false
                };
                if !advanced {
                    self.replace_modal_if_idle(Modal::Sas(SasState {
                        room_id,
                        partner_fingerprint,
                        tx_id,
                        stage: SasStage::Comparing {
                            emoji_string,
                            emoji_labels,
                            decimal,
                            our_matched: false,
                        },
                    }));
                }
            }
            AppEvent::SasVerified {
                partner_fingerprint,
                ..
            } => {
                if matches!(self.modal, Modal::Sas(_)) {
                    self.modal = Modal::None;
                }
                self.set_status(format!(
                    "✓ verified {} via SAS",
                    short_fp(&partner_fingerprint)
                ));
            }
            AppEvent::CodeJoinTimedOut { room_id: _, reason } => {
                // The placeholder tab from `join_room_with_code` stays
                // open (the user might retry) — we just surface the
                // failure via the modal queue so it doesn't clobber
                // anything the user happens to be typing.
                self.replace_modal_if_idle(Modal::Error(format!("code join: {reason}")));
            }
            AppEvent::InviteFingerprintMismatch {
                address: _,
                claimed,
                actual,
            } => {
                // The connection has already been dropped by the app
                // handle; we just inform the user via the modal queue.
                let msg = format!(
                    "invite fingerprint mismatch — connection dropped.\nclaimed: {}\nactual:  {}\nthe invite link may be forged.",
                    short_fp(&claimed),
                    short_fp(&actual)
                );
                self.replace_modal_if_idle(Modal::Error(msg));
            }
            AppEvent::NatStatusChanged { label, reachable: _ } => {
                // The lobby renders this as an emoji badge via
                // `nat_status_badge()`; we just stash the raw label.
                self.nat_status = Some(label);
            }
            AppEvent::DcutrSucceeded { peer_label } => {
                self.set_status_for(
                    format!("direct connection to …{}", peer_label),
                    Duration::from_secs(10),
                );
            }
            AppEvent::PeerProfileUpdated { fingerprint, username } => {
                // huddle 0.5: a peer set / changed / cleared their
                // username. The chat + member list pull from the DB
                // every render, so a redraw is enough — but show a
                // transient hint so the user notices the rename live.
                let new_label = match &username {
                    Some(n) if !n.is_empty() => n.clone(),
                    _ => "[anonymous]".into(),
                };
                let short: String = fingerprint.chars().take(4).collect();
                self.set_status_for(
                    format!("{}… is now {}", short, new_label),
                    Duration::from_secs(4),
                );
            }
            AppEvent::WentDark => {
                // huddle 0.5: go_dark wiped everything. Show the final
                // farewell, then schedule a quit. The status TTL also
                // serves as the visibility window before exit.
                self.modal = Modal::Info(
                    "Goodbye. huddle has gone dark. Restart to begin fresh.".into(),
                );
                self.went_dark_at = Some(std::time::Instant::now());
            }
        }
    }

}

fn short_room(room_id: &str) -> String {
    room_id.chars().take(8).collect()
}

/// First chunk of a fingerprint for compact display — the first 4-4
/// groups of the `xxxx-xxxx-xxxx-...` form, which gives ~32 bits of
/// collision resistance, plenty for a status line.
fn short_fp(fp: &str) -> String {
    fp.split('-').take(2).collect::<Vec<_>>().join("-")
}

/// Phase B: gather a member list for the kick / grant picker. Filters:
/// - excludes ourselves (you can't kick yourself, granting yourself is
///   a no-op since `start_room` already made you an owner)
/// - for Grant, hides current owners so the picker only shows
///   promotable members
/// - returns None if we aren't an owner of the active room (gates the
///   feature in one place)
fn owner_action_members(
    app: &TuiApp,
    kind: MemberActionKind,
) -> Option<(String, Vec<(String, bool)>)> {
    let room_id = app.active_room()?.room_id.clone();
    let our_fp = app.handle.fingerprint().to_string();
    if !app.handle.is_owner(&room_id, &our_fp) {
        // Status message is set by the caller's wrapper if we'd like;
        // returning None here just makes the keybinding a no-op for
        // non-owners, which is the intended UX.
        return None;
    }
    let owners: std::collections::HashSet<String> =
        app.handle.room_owners(&room_id).into_iter().collect();
    let members: Vec<(String, bool)> = app
        .active_room()?
        .members
        .iter()
        .filter(|fp| *fp != &our_fp)
        .filter(|fp| match kind {
            MemberActionKind::Kick => true,
            // Already an owner → nothing to grant
            MemberActionKind::Grant => !owners.contains(*fp),
        })
        .map(|fp| (fp.clone(), owners.contains(fp)))
        .collect();
    if members.is_empty() {
        return None;
    }
    Some((room_id, members))
}

/// Scroll the active room by `delta` lines (negative = up). Maintains
/// `follow_mode` semantics: scrolling up disables it, reaching the bottom
/// enables it.
fn scroll_by(app: &mut TuiApp, delta: i32) {
    let r = match app.active_room_mut() {
        Some(r) => r,
        None => return,
    };
    let max = r.last_max_scroll.get();
    let current = if r.follow_mode { max } else { r.scroll };
    let next = if delta < 0 {
        current.saturating_sub(delta.unsigned_abs() as u16)
    } else {
        current.saturating_add(delta as u16).min(max)
    };
    r.scroll = next;
    r.follow_mode = next >= max;
}

/// Open a tab for a room we're already subscribed to in `active_rooms`
/// (e.g. one that was auto-restored at startup). Mirrors what the
/// `AppEvent::RoomJoined` handler does, minus the actual join call.
fn open_existing_room_tab(app: &mut TuiApp, room_id: &str) {
    let info = match app.handle.active_room_info(room_id) {
        Some(i) => i,
        None => return,
    };
    let members = app.handle.room_members(room_id);
    let messages = app.handle.room_messages(room_id, 200).unwrap_or_default();
    let attachments = app.handle.list_room_attachments(room_id).unwrap_or_default();
    app.open_rooms.push(OpenRoom {
        room_id: room_id.to_string(),
        name: info.name,
        encrypted: info.encrypted,
        members,
        messages,
        attachments,
        input: String::new(),
        input_active: true,
        last_typing_sent: None,
        scroll: 0,
        follow_mode: true,
        last_max_scroll: Cell::new(0),
        card_focus: false,
        focused_card_idx: 0,
        unread: false,
    });
    app.active_tab = app.open_rooms.len() - 1;
    app.screen = Screen::InRoom;
}

/// Restores the terminal on drop — raw mode off, alternate screen left,
/// mouse capture disabled. Holding one of these guarantees cleanup on
/// every exit path (normal return, early `?`, or a panic unwind), not
/// just the happy path.
struct TerminalGuard;

impl Drop for TerminalGuard {
    fn drop(&mut self) {
        let _ = disable_raw_mode();
        let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
    }
}

/// Install a panic hook that restores the terminal *before* the default
/// hook prints the panic message. Without this, a panic inside the TUI
/// prints into the alternate screen, which is then torn down — losing the
/// message. Call once at startup.
pub fn install_panic_hook() {
    let original = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        let _ = disable_raw_mode();
        let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
        original(info);
    }));
}

pub async fn run_tui(handle: AppHandle) -> Result<()> {
    enable_raw_mode()?;
    // From here on, every exit path restores the terminal.
    let _guard = TerminalGuard;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let mut app = TuiApp::new(handle);
    let mut event_rx = app.handle.subscribe();

    let result = main_loop(&mut terminal, &mut app, &mut event_rx).await;

    app.handle.shutdown().await;
    result
}

/// What `prompt_master_passphrase` hands back. An empty `passphrase`
/// means the user pressed Esc / Ctrl-C — caller should exit cleanly.
pub struct AuthPrompt {
    pub passphrase: String,
    /// Only populated on first-launch (sign-up) flow.
    pub username: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum AuthField {
    Username,
    Passphrase,
    Confirm,
}

/// Prompt for the master passphrase before bringing up `AppHandle`.
/// First-launch (`is_new=true`) collects username + passphrase + confirm;
/// returning users only enter their passphrase. Tab cycles between
/// fields; Enter advances or submits.
pub fn prompt_master_passphrase(is_new: bool) -> Result<AuthPrompt> {
    use crossterm::event::{KeyCode, KeyModifiers};
    use ratatui::widgets::{Block, Borders, Clear, Padding, Paragraph};

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

    let mut username = String::new();
    let mut passphrase = String::new();
    let mut confirm = String::new();
    let mut field = if is_new {
        AuthField::Username
    } else {
        AuthField::Passphrase
    };
    let mut error: Option<String> = None;
    let mut outcome: Option<AuthPrompt> = None;

    while outcome.is_none() {
        terminal.draw(|f| {
            let height: u16 = if is_new { 18 } else { 12 };
            let area = crate::ui::centered_rect(64, height, f.area());
            f.render_widget(Clear, area);
            let block = Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::Cyan))
                .padding(Padding::uniform(1))
                .title(Span::styled(
                    if is_new {
                        " welcome to huddle — sign up "
                    } else {
                        " unlock huddle "
                    },
                    Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
                ));

            let masked = |s: &str| -> String { s.chars().map(|_| '•').collect() };
            let label_style = |is_focused: bool| {
                if is_focused {
                    Style::default()
                        .fg(Color::Yellow)
                        .add_modifier(Modifier::BOLD)
                } else {
                    Style::default().fg(Color::DarkGray)
                }
            };
            let value_style = Style::default().fg(Color::White);
            let cursor = Span::styled("_", Style::default().fg(Color::DarkGray));

            let mut lines: Vec<Line> = Vec::new();
            lines.push(Line::from(""));
            if is_new {
                lines.push(Line::from(Span::styled(
                    "  pick a username (display name in chat — you can change it later)",
                    Style::default().fg(Color::DarkGray),
                )));
                lines.push(Line::from(Span::styled(
                    "  and a passphrase that encrypts your local database.",
                    Style::default().fg(Color::DarkGray),
                )));
                lines.push(Line::from(Span::styled(
                    "  forget the passphrase and your data is unrecoverable.",
                    Style::default().fg(Color::DarkGray),
                )));
                lines.push(Line::from(""));

                let u_focused = field == AuthField::Username;
                lines.push(Line::from(vec![
                    Span::styled("  username:    ", label_style(u_focused)),
                    Span::styled(username.clone(), value_style),
                    if u_focused {
                        cursor.clone()
                    } else {
                        Span::raw("")
                    },
                ]));
                let p_focused = field == AuthField::Passphrase;
                lines.push(Line::from(vec![
                    Span::styled("  passphrase:  ", label_style(p_focused)),
                    Span::styled(masked(&passphrase), value_style),
                    if p_focused {
                        cursor.clone()
                    } else {
                        Span::raw("")
                    },
                ]));
                let c_focused = field == AuthField::Confirm;
                lines.push(Line::from(vec![
                    Span::styled("  confirm:     ", label_style(c_focused)),
                    Span::styled(masked(&confirm), value_style),
                    if c_focused {
                        cursor.clone()
                    } else {
                        Span::raw("")
                    },
                ]));
            } else {
                lines.push(Line::from(Span::styled(
                    "  enter your passphrase to unlock the database.",
                    Style::default().fg(Color::DarkGray),
                )));
                lines.push(Line::from(""));
                lines.push(Line::from(vec![
                    Span::styled("  passphrase: ", label_style(true)),
                    Span::styled(masked(&passphrase), value_style),
                    cursor.clone(),
                ]));
            }

            if let Some(err) = &error {
                lines.push(Line::from(""));
                lines.push(Line::from(Span::styled(
                    format!("  ! {}", err),
                    Style::default().fg(Color::Red),
                )));
            }
            lines.push(Line::from(""));
            let hint_label = if is_new {
                if field == AuthField::Confirm {
                    " sign up   "
                } else {
                    " next field   "
                }
            } else {
                " unlock   "
            };
            lines.push(Line::from(vec![
                Span::styled(" Enter", Style::default().fg(Color::Yellow)),
                Span::styled(hint_label, Style::default().fg(Color::DarkGray)),
                Span::styled("Tab", Style::default().fg(Color::Yellow)),
                Span::styled(" cycle fields   ", Style::default().fg(Color::DarkGray)),
                Span::styled("Esc", Style::default().fg(Color::Yellow)),
                Span::styled(" cancel", Style::default().fg(Color::DarkGray)),
            ]));
            f.render_widget(Paragraph::new(lines).block(block), area);
        })?;

        if event::poll(Duration::from_millis(200))? {
            if let Event::Key(key) = event::read()? {
                if key.modifiers.contains(KeyModifiers::CONTROL)
                    && matches!(key.code, KeyCode::Char('c'))
                {
                    outcome = Some(AuthPrompt {
                        passphrase: String::new(),
                        username: None,
                    });
                    break;
                }
                match key.code {
                    KeyCode::Esc => {
                        outcome = Some(AuthPrompt {
                            passphrase: String::new(),
                            username: None,
                        });
                    }
                    KeyCode::Tab if is_new => {
                        field = match field {
                            AuthField::Username => AuthField::Passphrase,
                            AuthField::Passphrase => AuthField::Confirm,
                            AuthField::Confirm => AuthField::Username,
                        };
                    }
                    KeyCode::Backspace => match field {
                        AuthField::Username => {
                            username.pop();
                        }
                        AuthField::Passphrase => {
                            passphrase.pop();
                        }
                        AuthField::Confirm => {
                            confirm.pop();
                        }
                    },
                    KeyCode::Enter => {
                        if is_new {
                            match field {
                                AuthField::Username => {
                                    if username.trim().is_empty() {
                                        error = Some("username can't be empty".into());
                                    } else {
                                        error = None;
                                        field = AuthField::Passphrase;
                                    }
                                }
                                AuthField::Passphrase => {
                                    if passphrase.is_empty() {
                                        error = Some("passphrase can't be empty".into());
                                    } else {
                                        error = None;
                                        field = AuthField::Confirm;
                                    }
                                }
                                AuthField::Confirm => {
                                    if confirm != passphrase {
                                        error = Some("passphrases don't match — try again".into());
                                        confirm.clear();
                                    } else {
                                        outcome = Some(AuthPrompt {
                                            passphrase: passphrase.clone(),
                                            username: Some(username.trim().to_string()),
                                        });
                                    }
                                }
                            }
                        } else if passphrase.is_empty() {
                            error = Some("passphrase can't be empty".into());
                        } else {
                            outcome = Some(AuthPrompt {
                                passphrase: passphrase.clone(),
                                username: None,
                            });
                        }
                    }
                    KeyCode::Char(c) => match field {
                        AuthField::Username => username.push(c),
                        AuthField::Passphrase => passphrase.push(c),
                        AuthField::Confirm => confirm.push(c),
                    },
                    _ => {}
                }
            }
        }
    }

    Ok(outcome.unwrap_or(AuthPrompt {
        passphrase: String::new(),
        username: None,
    }))
}

/// Show the welcome card before bringing up `AppHandle`. Returns `Ok(true)`
/// when the user is ready to continue or `Ok(false)` if they pressed
/// Ctrl-C / q (caller exits without starting the app).
pub fn show_welcome() -> Result<bool> {
    use crossterm::event::{KeyCode, KeyModifiers};

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

    let outcome = loop {
        terminal.draw(crate::ui::picker::render_welcome)?;
        if poll(Duration::from_millis(200))? {
            if let Event::Key(key) = event::read()? {
                if key.modifiers.contains(KeyModifiers::CONTROL)
                    && matches!(key.code, KeyCode::Char('c'))
                {
                    break false;
                }
                match key.code {
                    KeyCode::Char('q') => break false,
                    _ => break true,
                }
            }
        }
    };
    Ok(outcome)
}

async fn main_loop(
    terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
    app: &mut TuiApp,
    event_rx: &mut tokio::sync::broadcast::Receiver<AppEvent>,
) -> Result<()> {
    let mut should_quit = false;
    let mut last_refresh = std::time::Instant::now();

    while !should_quit {
        terminal.draw(|f| crate::ui::render(f, app))?;

        // Drain any pending app events.
        while let Ok(ev) = event_rx.try_recv() {
            app.handle_app_event(ev);
        }

        // Refresh discovered rooms every second (covers TTL pruning).
        if last_refresh.elapsed() > Duration::from_secs(1) {
            app.refresh_discovered();
            app.refresh_attachments();
            last_refresh = std::time::Instant::now();
        }

        // Drop expired status-bar messages.
        app.tick_status();

        // huddle 0.5: if go_dark fired, hold the goodbye modal on
        // screen for `GO_DARK_FAREWELL`, then quit. The data dir is
        // already wiped at this point; the network task is down.
        if let Some(t) = app.went_dark_at {
            if t.elapsed() >= GO_DARK_FAREWELL {
                should_quit = true;
                continue;
            }
        }

        // Phase A: auto-reject an inbound-dial modal that's been
        // ignored for 15s. We don't want to leave an unknown peer
        // connected just because the user walked away.
        let auto_reject_state: Option<InboundDialState> =
            if let Modal::InboundDial(s) = &app.modal {
                if s.opened_at.elapsed() >= Duration::from_secs(15) {
                    Some(s.clone())
                } else {
                    None
                }
            } else {
                None
            };
        if let Some(s) = auto_reject_state {
            let _ = app.handle.reject_inbound(s.peer_id, &s.fingerprint).await;
            app.set_status(format!("auto-rejected {} (timeout)", short_fp(&s.fingerprint)));
            app.modal = Modal::None;
        }

        if poll(Duration::from_millis(33))? {
            match event::read()? {
                Event::Key(key) => {
                    let action = input::map_key(key, app);
                    should_quit = handle_action(action, app).await?;
                }
                Event::Mouse(m) => {
                    if matches!(m.kind, MouseEventKind::Down(MouseButton::Left))
                        && app.screen == Screen::InRoom
                    {
                        // Click-to-toggle: clicking anywhere inside the
                        // chat area while we're in a room enters card
                        // focus mode if there are cards. Precise hit-
                        // testing per-card requires Rect tracking from
                        // render — left as a follow-up.
                        if let Some(r) = app.active_room() {
                            if !r.attachments.is_empty() && !r.card_focus {
                                handle_action(input::Action::ToggleCardFocus, app).await?;
                            }
                        }
                    }
                }
                _ => {}
            }
        }

        // An async-event modal queued while the user was mid-interaction
        // (see `replace_modal_if_idle`) surfaces once the foreground modal
        // is dismissed — by any path, not just `Action::CloseModal`.
        if matches!(app.modal, Modal::None) {
            if let Some(m) = app.pending_modal.take() {
                app.modal = m;
            }
        }
    }

    Ok(())
}

async fn handle_action(action: Action, app: &mut TuiApp) -> Result<bool> {
    match action {
        Action::Nothing => Ok(false),
        Action::Quit => Ok(true),
        Action::OpenQuitConfirm => {
            app.modal = Modal::QuitConfirm;
            Ok(false)
        }
        Action::CloseModal => {
            app.modal = Modal::None;
            Ok(false)
        }
        Action::OpenStartRoom => {
            app.modal = Modal::StartRoom(StartRoomState::new());
            Ok(false)
        }
        Action::OpenHelp => {
            app.modal = Modal::Help;
            Ok(false)
        }
        Action::LobbyNavigateUp => {
            match app.lobby_focus {
                LobbyFocus::Rooms => {
                    if app.selected_room_idx > 0 {
                        app.selected_room_idx -= 1;
                    }
                }
                LobbyFocus::KnownPeers => {
                    if app.selected_peer_idx > 0 {
                        app.selected_peer_idx -= 1;
                    }
                }
            }
            Ok(false)
        }
        Action::LobbyNavigateDown => {
            match app.lobby_focus {
                LobbyFocus::Rooms => {
                    if app.selected_room_idx + 1 < app.discovered_rooms.len() {
                        app.selected_room_idx += 1;
                    }
                }
                LobbyFocus::KnownPeers => {
                    if app.selected_peer_idx + 1 < app.known_peers.len() {
                        app.selected_peer_idx += 1;
                    }
                }
            }
            Ok(false)
        }
        Action::LobbyRefresh => {
            app.refresh_discovered();
            app.refresh_known_peers();
            Ok(false)
        }
        Action::LobbyFocusToggle => {
            app.lobby_focus = match app.lobby_focus {
                LobbyFocus::Rooms => LobbyFocus::KnownPeers,
                LobbyFocus::KnownPeers => LobbyFocus::Rooms,
            };
            Ok(false)
        }
        Action::LobbyReconnectPeer => {
            if let Some(p) = app.known_peers.get(app.selected_peer_idx).cloned() {
                if let Err(e) = app.handle.redial(&p.address).await {
                    app.modal = Modal::Error(format!("dial failed: {e}"));
                }
            }
            Ok(false)
        }
        Action::LobbyForgetPeer => {
            if let Some(p) = app.known_peers.get(app.selected_peer_idx).cloned() {
                if let Err(e) = app.handle.forget_peer(&p.address).await {
                    app.modal = Modal::Error(format!("forget failed: {e}"));
                }
                app.refresh_known_peers();
                if app.selected_peer_idx >= app.known_peers.len() && !app.known_peers.is_empty() {
                    app.selected_peer_idx = app.known_peers.len() - 1;
                }
            }
            Ok(false)
        }
        Action::OpenDialPeer => {
            app.modal = Modal::DialPeer(DialPeerState::default());
            Ok(false)
        }
        Action::DialPeerTypeChar(c) => {
            if let Modal::DialPeer(s) = &mut app.modal {
                s.address.push(c);
            }
            Ok(false)
        }
        Action::DialPeerBackspace => {
            if let Modal::DialPeer(s) = &mut app.modal {
                s.address.pop();
            }
            Ok(false)
        }
        Action::DialPeerConfirm => {
            let address = match &app.modal {
                Modal::DialPeer(s) => s.address.clone(),
                _ => return Ok(false),
            };
            if address.trim().is_empty() {
                if let Modal::DialPeer(s) = &mut app.modal {
                    s.status = Some("address is empty".into());
                }
                return Ok(false);
            }
            match app.handle.dial(&address).await {
                Ok(()) => {
                    if let Modal::DialPeer(s) = &mut app.modal {
                        s.status = Some(format!("dialing {}…", address));
                    }
                }
                Err(e) => {
                    app.modal = Modal::Error(format!("invalid address: {e}"));
                }
            }
            Ok(false)
        }
        Action::LobbyJoinSelected => {
            if let Some(room) = app.discovered_rooms.get(app.selected_room_idx).cloned() {
                // Already have a tab open — just focus it.
                if let Some(idx) = app
                    .open_rooms
                    .iter()
                    .position(|r| r.room_id == room.room_id)
                {
                    app.active_tab = idx;
                    app.screen = Screen::InRoom;
                    return Ok(false);
                }
                // Already an active room (auto-restored, or joined earlier
                // this session) — open a tab without re-running the join
                // flow. Applies to encrypted rooms too: we already hold the
                // passphrase-derived key, so re-prompting would be wrong.
                if app.handle.active_room_info(&room.room_id).is_some() {
                    open_existing_room_tab(app, &room.room_id);
                    return Ok(false);
                }
                if room.encrypted {
                    app.modal = Modal::JoinRoom(JoinRoomState {
                        room_id: room.room_id.clone(),
                        room_name: room.name.clone(),
                        encrypted: true,
                        passphrase: String::new(),
                    });
                } else if let Err(e) = app.handle.join_room(&room.room_id, None).await {
                    app.modal = Modal::Error(format!("join failed: {e}"));
                }
            }
            Ok(false)
        }
        Action::StartRoomNextField => {
            if let Modal::StartRoom(s) = &mut app.modal {
                s.focus = match s.focus {
                    StartField::Name => StartField::Encrypted,
                    StartField::Encrypted => {
                        if s.encrypted {
                            StartField::Passphrase
                        } else {
                            StartField::Name
                        }
                    }
                    StartField::Passphrase => StartField::Name,
                };
            }
            Ok(false)
        }
        Action::StartRoomToggleEncrypted => {
            if let Modal::StartRoom(s) = &mut app.modal {
                s.encrypted = !s.encrypted;
                if !s.encrypted {
                    s.passphrase.clear();
                    // The passphrase field is hidden when encryption is
                    // off — don't strand focus on an invisible field.
                    if s.focus == StartField::Passphrase {
                        s.focus = StartField::Encrypted;
                    }
                }
            }
            Ok(false)
        }
        Action::StartRoomTypeChar(c) => {
            if let Modal::StartRoom(s) = &mut app.modal {
                match s.focus {
                    StartField::Name => s.name.push(c),
                    StartField::Passphrase => s.passphrase.push(c),
                    StartField::Encrypted => {}
                }
            }
            Ok(false)
        }
        Action::StartRoomBackspace => {
            if let Modal::StartRoom(s) = &mut app.modal {
                match s.focus {
                    StartField::Name => {
                        s.name.pop();
                    }
                    StartField::Passphrase => {
                        s.passphrase.pop();
                    }
                    StartField::Encrypted => {}
                }
            }
            Ok(false)
        }
        Action::StartRoomConfirm => {
            let (name, encrypted, passphrase) = match &app.modal {
                Modal::StartRoom(s) => (s.name.clone(), s.encrypted, s.passphrase.clone()),
                _ => return Ok(false),
            };
            if name.trim().is_empty() {
                app.modal = Modal::Error("room name cannot be empty".into());
                return Ok(false);
            }
            if encrypted && passphrase.is_empty() {
                app.modal = Modal::Error("encrypted room requires a passphrase".into());
                return Ok(false);
            }
            app.modal = Modal::None;
            let pp = if encrypted { Some(passphrase.as_str()) } else { None };
            if let Err(e) = app.handle.start_room(&name, encrypted, pp).await {
                app.modal = Modal::Error(format!("start failed: {e}"));
            }
            Ok(false)
        }
        Action::JoinRoomTypeChar(c) => {
            if let Modal::JoinRoom(j) = &mut app.modal {
                j.passphrase.push(c);
            }
            Ok(false)
        }
        Action::JoinRoomBackspace => {
            if let Modal::JoinRoom(j) = &mut app.modal {
                j.passphrase.pop();
            }
            Ok(false)
        }
        Action::JoinRoomConfirm => {
            let (room_id, passphrase) = match &app.modal {
                Modal::JoinRoom(j) => (j.room_id.clone(), j.passphrase.clone()),
                _ => return Ok(false),
            };
            app.modal = Modal::None;
            if let Err(e) = app.handle.join_room(&room_id, Some(&passphrase)).await {
                app.modal = Modal::Error(format!("join failed: {e}"));
            }
            Ok(false)
        }
        Action::TabNext => {
            if !app.open_rooms.is_empty() {
                app.active_tab = (app.active_tab + 1) % app.open_rooms.len();
                if let Some(r) = app.active_room_mut() {
                    r.unread = false;
                }
            }
            Ok(false)
        }
        Action::TabPrev => {
            if !app.open_rooms.is_empty() {
                app.active_tab = if app.active_tab == 0 {
                    app.open_rooms.len() - 1
                } else {
                    app.active_tab - 1
                };
                if let Some(r) = app.active_room_mut() {
                    r.unread = false;
                }
            }
            Ok(false)
        }
        Action::TabSelect(n) => {
            if n < app.open_rooms.len() {
                app.active_tab = n;
                if let Some(r) = app.active_room_mut() {
                    r.unread = false;
                }
            }
            Ok(false)
        }
        Action::BackToLobby => {
            app.screen = Screen::Lobby;
            Ok(false)
        }
        Action::LeaveRoom => {
            if let Some(room) = app.active_room() {
                let id = room.room_id.clone();
                match app.handle.leave_room(&id).await {
                    Ok(true) => {}
                    Ok(false) => app.set_status(
                        "left locally — peers may still see you until they time you out",
                    ),
                    Err(e) => app.modal = Modal::Error(format!("leave failed: {e}")),
                }
            }
            Ok(false)
        }
        Action::FocusInput => {
            if let Some(r) = app.active_room_mut() {
                r.input_active = true;
            }
            Ok(false)
        }
        Action::BlurInput => {
            if let Some(r) = app.active_room_mut() {
                r.input_active = false;
            }
            Ok(false)
        }
        Action::ScrollUp => {
            scroll_by(app, -1);
            Ok(false)
        }
        Action::ScrollDown => {
            scroll_by(app, 1);
            Ok(false)
        }
        Action::PageUp => {
            scroll_by(app, -10);
            Ok(false)
        }
        Action::PageDown => {
            scroll_by(app, 10);
            Ok(false)
        }
        Action::JumpTop => {
            if let Some(r) = app.active_room_mut() {
                r.scroll = 0;
                r.follow_mode = false;
            }
            Ok(false)
        }
        Action::JumpBottom => {
            if let Some(r) = app.active_room_mut() {
                r.follow_mode = true;
            }
            Ok(false)
        }
        Action::ChatTypeChar(c) => {
            let (room_id, should_pulse) = {
                let r = match app.active_room_mut() {
                    Some(r) if r.input_active => r,
                    _ => return Ok(false),
                };
                r.input.push(c);
                let pulse = match r.last_typing_sent {
                    Some(t) if t.elapsed() < TYPING_DEBOUNCE => false,
                    _ => true,
                };
                if pulse {
                    r.last_typing_sent = Some(Instant::now());
                }
                (r.room_id.clone(), pulse)
            };
            if should_pulse {
                app.handle.broadcast_typing(&room_id).await;
            }
            Ok(false)
        }
        Action::ChatBackspace => {
            if let Some(r) = app.active_room_mut() {
                if r.input_active {
                    r.input.pop();
                }
            }
            Ok(false)
        }
        Action::ChatSend => {
            let (room_id, body) = {
                match app.active_room_mut() {
                    Some(r) if r.input_active && !r.input.trim().is_empty() => {
                        let body = r.input.clone();
                        r.input.clear();
                        (r.room_id.clone(), body)
                    }
                    _ => return Ok(false),
                }
            };
            if let Err(e) = app.handle.send_room_message(&room_id, &body).await {
                app.modal = Modal::Error(format!("send failed: {e}"));
            }
            Ok(false)
        }
        Action::ChatInsertNewline => {
            if let Some(r) = app.active_room_mut() {
                if r.input_active {
                    r.input.push('\n');
                }
            }
            Ok(false)
        }
        Action::ToggleCardFocus => {
            if let Some(r) = app.active_room_mut() {
                if r.attachments.is_empty() {
                    return Ok(false);
                }
                r.card_focus = !r.card_focus;
                if r.card_focus {
                    r.input_active = false;
                    if r.focused_card_idx >= r.attachments.len() {
                        r.focused_card_idx = 0;
                    }
                }
            }
            Ok(false)
        }
        Action::CardNext => {
            if let Some(r) = app.active_room_mut() {
                if !r.attachments.is_empty() {
                    r.focused_card_idx = (r.focused_card_idx + 1) % r.attachments.len();
                }
            }
            Ok(false)
        }
        Action::CardPrev => {
            if let Some(r) = app.active_room_mut() {
                if !r.attachments.is_empty() {
                    r.focused_card_idx = if r.focused_card_idx == 0 {
                        r.attachments.len() - 1
                    } else {
                        r.focused_card_idx - 1
                    };
                }
            }
            Ok(false)
        }
        Action::ActivateFocusedCard => {
            let (room_id, file_id, status, encrypted) = match focused_card_info(app) {
                Some(t) => t,
                None => return Ok(false),
            };
            use huddle_core::storage::repo::AttachmentStatus;
            match status {
                AttachmentStatus::Offered | AttachmentStatus::Downloading => {
                    // Auto-pulled by gossipsub; just status nudge.
                    app.set_status("waiting for chunks…");
                }
                AttachmentStatus::Ready | AttachmentStatus::Saved => {
                    match app.handle.save_to_downloads(&room_id, &file_id).await {
                        Ok(path) => app.set_status(format!("saved to {}", path.display())),
                        Err(e) => app.modal = Modal::Error(format!("save failed: {e}")),
                    }
                }
                AttachmentStatus::Failed => {
                    app.set_status("retry not yet implemented — ask the sender to resend");
                }
                AttachmentStatus::Cancelled => {
                    app.set_status("transfer was cancelled");
                }
            }
            let _ = encrypted;
            Ok(false)
        }
        Action::OpenFocusedCard => {
            let (room_id, file_id, _, _) = match focused_card_info(app) {
                Some(t) => t,
                None => return Ok(false),
            };
            if let Err(e) = app.handle.open_saved(&room_id, &file_id) {
                app.modal = Modal::Error(format!("open failed: {e}"));
            }
            Ok(false)
        }
        Action::CancelFocusedCard => {
            let (room_id, file_id, _, _) = match focused_card_info(app) {
                Some(t) => t,
                None => return Ok(false),
            };
            if let Err(e) = app.handle.cancel_transfer(&room_id, &file_id).await {
                app.modal = Modal::Error(format!("cancel failed: {e}"));
            }
            Ok(false)
        }
        Action::SaveAgainFocusedCard => {
            let (room_id, file_id, _, _) = match focused_card_info(app) {
                Some(t) => t,
                None => return Ok(false),
            };
            match app.handle.save_to_downloads(&room_id, &file_id).await {
                Ok(path) => app.set_status(format!("saved to {}", path.display())),
                Err(e) => app.modal = Modal::Error(format!("save failed: {e}")),
            }
            Ok(false)
        }
        Action::OpenAttachmentPicker => {
            if app.active_room().is_none() {
                app.set_status("attach is only available inside a room");
                return Ok(false);
            }
            app.modal = Modal::AttachPicker(AttachPickerState::new());
            Ok(false)
        }
        Action::AttachPickerUp => {
            if let Modal::AttachPicker(s) = &mut app.modal {
                if s.selected > 0 {
                    s.selected -= 1;
                }
            }
            Ok(false)
        }
        Action::AttachPickerDown => {
            if let Modal::AttachPicker(s) = &mut app.modal {
                if s.selected + 1 < s.entries.len() {
                    s.selected += 1;
                }
            }
            Ok(false)
        }
        Action::AttachPickerAscend => {
            if let Modal::AttachPicker(s) = &mut app.modal {
                s.ascend();
            }
            Ok(false)
        }
        Action::OpenRotateRoom => {
            let room_id = match app.active_room() {
                Some(r) if r.encrypted => r.room_id.clone(),
                Some(_) => {
                    app.set_status("rotation only applies to encrypted rooms");
                    return Ok(false);
                }
                None => return Ok(false),
            };
            app.modal = Modal::RotateRoom(RotateRoomState {
                room_id,
                passphrase: String::new(),
            });
            Ok(false)
        }
        Action::RotateRoomTypeChar(c) => {
            if let Modal::RotateRoom(s) = &mut app.modal {
                s.passphrase.push(c);
            }
            Ok(false)
        }
        Action::RotateRoomBackspace => {
            if let Modal::RotateRoom(s) = &mut app.modal {
                s.passphrase.pop();
            }
            Ok(false)
        }
        Action::RotateRoomConfirm => {
            let (room_id, pp) = match &app.modal {
                Modal::RotateRoom(s) => (s.room_id.clone(), s.passphrase.clone()),
                _ => return Ok(false),
            };
            if pp.is_empty() {
                app.modal = Modal::Error("new passphrase cannot be empty".into());
                return Ok(false);
            }
            app.modal = Modal::None;
            match app.handle.rotate_room(&room_id, &pp).await {
                Ok(()) => app.set_status("rotation broadcast — share the new passphrase out-of-band"),
                Err(e) => app.modal = Modal::Error(format!("rotate failed: {e}")),
            }
            Ok(false)
        }
        Action::AcceptRotationTypeChar(c) => {
            if let Modal::AcceptRotation(s) = &mut app.modal {
                s.passphrase.push(c);
            }
            Ok(false)
        }
        Action::AcceptRotationBackspace => {
            if let Modal::AcceptRotation(s) = &mut app.modal {
                s.passphrase.pop();
            }
            Ok(false)
        }
        Action::AcceptRotationConfirm => {
            let (room_id, new_salt, pp) = match &app.modal {
                Modal::AcceptRotation(s) => {
                    (s.room_id.clone(), s.new_salt.clone(), s.passphrase.clone())
                }
                _ => return Ok(false),
            };
            if pp.is_empty() {
                return Ok(false);
            }
            app.modal = Modal::None;
            match app.handle.accept_rotation(&room_id, &new_salt, &pp).await {
                Ok(()) => app.set_status("accepted rotation — new key in use"),
                Err(e) => app.modal = Modal::Error(format!("accept rotation failed: {e}")),
            }
            Ok(false)
        }
        Action::OpenQrIdentity => {
            app.modal = Modal::QrIdentity;
            Ok(false)
        }
        Action::ToggleMute => {
            let room_id = match app.active_room() {
                Some(r) => r.room_id.clone(),
                None => return Ok(false),
            };
            let now_muted = app.handle.is_room_muted(&room_id);
            if let Err(e) = app.handle.set_room_muted(&room_id, !now_muted) {
                app.modal = Modal::Error(format!("mute toggle failed: {e}"));
            } else {
                app.set_status(if !now_muted { "muted" } else { "unmuted" });
            }
            Ok(false)
        }
        Action::OpenSearch => {
            let room_id = match app.active_room() {
                Some(r) => r.room_id.clone(),
                None => return Ok(false),
            };
            app.modal = Modal::Search(SearchState {
                room_id,
                query: String::new(),
                results: Vec::new(),
                selected: 0,
                searched: false,
            });
            Ok(false)
        }
        Action::SearchTypeChar(c) => {
            if let Modal::Search(s) = &mut app.modal {
                s.query.push(c);
            }
            Ok(false)
        }
        Action::SearchBackspace => {
            if let Modal::Search(s) = &mut app.modal {
                s.query.pop();
            }
            Ok(false)
        }
        Action::SearchSubmit => {
            let (room_id, query) = match &app.modal {
                Modal::Search(s) => (s.room_id.clone(), s.query.clone()),
                _ => return Ok(false),
            };
            if query.trim().is_empty() {
                return Ok(false);
            }
            let results = app
                .handle
                .search_room_messages(&room_id, &query, 100)
                .unwrap_or_default();
            if let Modal::Search(s) = &mut app.modal {
                s.results = results;
                s.selected = 0;
                s.searched = true;
            }
            Ok(false)
        }
        Action::SearchNext => {
            if let Modal::Search(s) = &mut app.modal {
                if s.selected + 1 < s.results.len() {
                    s.selected += 1;
                }
            }
            Ok(false)
        }
        Action::SearchPrev => {
            if let Modal::Search(s) = &mut app.modal {
                if s.selected > 0 {
                    s.selected -= 1;
                }
            }
            Ok(false)
        }
        Action::OpenVerify => {
            let room_id = match app.active_room() {
                Some(r) => r.room_id.clone(),
                None => return Ok(false),
            };
            let our_fp = app.handle.fingerprint().to_string();
            let verified_set: std::collections::HashSet<String> =
                app.handle.verified_fingerprints(&room_id).into_iter().collect();
            let members: Vec<(String, bool)> = app
                .active_room()
                .map(|r| {
                    r.members
                        .iter()
                        .filter(|fp| **fp != our_fp)
                        .map(|fp| (fp.clone(), verified_set.contains(fp)))
                        .collect()
                })
                .unwrap_or_default();
            if members.is_empty() {
                app.set_status("no other members to verify yet");
                return Ok(false);
            }
            app.modal = Modal::Verify(VerifyState {
                room_id,
                our_fingerprint: our_fp,
                members,
                selected: 0,
            });
            Ok(false)
        }
        Action::VerifyNext => {
            if let Modal::Verify(s) = &mut app.modal {
                if s.selected + 1 < s.members.len() {
                    s.selected += 1;
                }
            }
            Ok(false)
        }
        Action::VerifyPrev => {
            if let Modal::Verify(s) = &mut app.modal {
                if s.selected > 0 {
                    s.selected -= 1;
                }
            }
            Ok(false)
        }
        Action::VerifyToggle => {
            let (room_id, fp, new_state) = match &mut app.modal {
                Modal::Verify(s) => {
                    let m = match s.members.get_mut(s.selected) {
                        Some(x) => x,
                        None => return Ok(false),
                    };
                    m.1 = !m.1;
                    (s.room_id.clone(), m.0.clone(), m.1)
                }
                _ => return Ok(false),
            };
            if let Err(e) = app.handle.set_member_verified(&room_id, &fp, new_state) {
                app.modal = Modal::Error(format!("verify failed: {e}"));
            }
            Ok(false)
        }
        Action::OnboardingNext => {
            if let Modal::Onboarding { page } = &mut app.modal {
                if *page + 1 < ONBOARDING_PAGES.len() {
                    *page += 1;
                } else {
                    let _ = app.handle.mark_onboarding_seen();
                    app.modal = Modal::None;
                }
            }
            Ok(false)
        }
        Action::OnboardingPrev => {
            if let Modal::Onboarding { page } = &mut app.modal {
                if *page > 0 {
                    *page -= 1;
                }
            }
            Ok(false)
        }
        Action::OnboardingDismiss => {
            let _ = app.handle.mark_onboarding_seen();
            app.modal = Modal::None;
            Ok(false)
        }
        Action::GenerateInvite => {
            // Build an invite payload from what we know. host_multiaddr
            // comes from the first listen address + our peer_id. Room
            // section is included iff we're in a room view.
            let our_peer = app.handle.peer_id().to_string();
            let our_fp = app.handle.fingerprint().to_string();
            let listen = match app.listen_addresses.first() {
                Some(a) => a.clone(),
                None => {
                    app.set_status("no listen address yet — try again in a sec");
                    return Ok(false);
                }
            };
            // libp2p multiaddrs need /p2p/<peer-id> appended so the
            // dialer's pubkey check fires. Listen addr is usually
            // /ip4/0.0.0.0/tcp/<port> — substitute a real host on the
            // receiver's end isn't ideal but the IP is what the user
            // shared OOB anyway. For LAN we leave the 0.0.0.0 as-is;
            // the user can fix the URL before sharing if their auto-
            // detected addr isn't reachable from outside.
            let host_multiaddr = format!("{}/p2p/{}", listen, our_peer);

            let room = match (app.screen, app.active_room()) {
                (Screen::InRoom, Some(r)) => {
                    if let Some(info) = app.handle.active_room_info(&r.room_id) {
                        let salt_b64 = info.passphrase_salt.as_ref().map(|s| {
                            base64::engine::general_purpose::STANDARD.encode(s)
                        });
                        Some(huddle_core::invite::InviteRoom {
                            id: info.id.clone(),
                            name: info.name.clone(),
                            encrypted: info.encrypted,
                            salt_b64,
                            creator_fingerprint: info.creator_fingerprint.clone(),
                            owner_fingerprints: app.handle.room_owners(&info.id),
                        })
                    } else {
                        None
                    }
                }
                _ => None,
            };

            let invite = huddle_core::invite::InviteLink {
                v: 1,
                host_multiaddr,
                fingerprint: our_fp,
                room: room.clone(),
            };
            match huddle_core::invite::encode(&invite) {
                Ok(url) => {
                    app.modal = Modal::ShowInvite(ShowInviteState {
                        url,
                        includes_room: room.map(|r| r.name),
                    });
                }
                Err(e) => app.modal = Modal::Error(format!("encode invite: {e}")),
            }
            Ok(false)
        }
        Action::OpenPasteInvite => {
            app.modal = Modal::PasteInvite(PasteInviteState { url: String::new() });
            Ok(false)
        }
        Action::PasteInviteTypeChar(c) => {
            if let Modal::PasteInvite(s) = &mut app.modal {
                s.url.push(c);
            }
            Ok(false)
        }
        Action::PasteInviteBackspace => {
            if let Modal::PasteInvite(s) = &mut app.modal {
                s.url.pop();
            }
            Ok(false)
        }
        Action::PasteInviteConfirm => {
            let url = match &app.modal {
                Modal::PasteInvite(s) => s.url.clone(),
                _ => return Ok(false),
            };
            match huddle_core::invite::decode(url.trim()) {
                Ok(invite) => {
                    app.modal = Modal::ConfirmInvite(ConfirmInviteState { invite });
                }
                Err(e) => {
                    app.modal = Modal::Error(format!("bad invite link: {e}"));
                }
            }
            Ok(false)
        }
        Action::ConfirmInviteAccept => {
            let invite = match &app.modal {
                Modal::ConfirmInvite(s) => s.invite.clone(),
                _ => return Ok(false),
            };
            app.modal = Modal::None;
            // Dial via the invite-aware path. libp2p enforces the
            // embedded /p2p/<peer-id> check at the transport level
            // (structural MITM defense), AND once Identify lands the
            // app-layer post-dial arm compares the cryptographic fp
            // against `invite.fingerprint`, disconnecting on mismatch.
            // If a room was included, we open Join modal speculatively
            // — the joiner types the passphrase, and the actual
            // join_room call succeeds once the room announcement
            // arrives.
            match app
                .handle
                .dial_invite(&invite.host_multiaddr, &invite.fingerprint)
                .await
            {
                Ok(()) => app.set_status(format!("dialing {} via invite…", short_fp(&invite.fingerprint))),
                Err(e) => {
                    app.modal = Modal::Error(format!("dial failed: {e}"));
                    return Ok(false);
                }
            }
            if let Some(room) = invite.room {
                if room.encrypted {
                    app.modal = Modal::JoinRoom(JoinRoomState {
                        room_id: room.id.clone(),
                        room_name: room.name.clone(),
                        encrypted: true,
                        passphrase: String::new(),
                    });
                } else if let Err(e) = app.handle.join_room(&room.id, None).await {
                    app.modal = Modal::Error(format!("join failed: {e}"));
                }
            }
            Ok(false)
        }
        Action::OpenGenerateJoinCode => {
            let (room_id, room_name) = match app.active_room() {
                Some(r) => (r.room_id.clone(), r.name.clone()),
                None => return Ok(false),
            };
            match app.handle.generate_join_code(&room_id) {
                Ok(code) => {
                    app.modal = Modal::ShowJoinCode(ShowJoinCodeState {
                        room_id,
                        room_name,
                        code,
                    });
                }
                Err(e) => app.set_status(format!("can't generate code: {e}")),
            }
            Ok(false)
        }
        Action::OpenJoinWithCode => {
            // Only meaningful on the Rooms focus + encrypted selection.
            if !matches!(app.lobby_focus, LobbyFocus::Rooms) {
                return Ok(false);
            }
            let room = match app.discovered_rooms.get(app.selected_room_idx).cloned() {
                Some(r) => r,
                None => return Ok(false),
            };
            if !room.encrypted {
                app.set_status("code-join only applies to encrypted rooms");
                return Ok(false);
            }
            app.modal = Modal::JoinWithCode(JoinWithCodeState {
                room_id: room.room_id,
                room_name: room.name,
                code: String::new(),
            });
            Ok(false)
        }
        Action::JoinWithCodeTypeChar(c) => {
            if let Modal::JoinWithCode(s) = &mut app.modal {
                s.code.push(c);
            }
            Ok(false)
        }
        Action::JoinWithCodeBackspace => {
            if let Modal::JoinWithCode(s) = &mut app.modal {
                s.code.pop();
            }
            Ok(false)
        }
        Action::JoinWithCodeConfirm => {
            let (room_id, code) = match &app.modal {
                Modal::JoinWithCode(s) => (s.room_id.clone(), s.code.clone()),
                _ => return Ok(false),
            };
            if code.trim().is_empty() {
                return Ok(false);
            }
            app.modal = Modal::None;
            if let Err(e) = app.handle.join_room_with_code(&room_id, code.trim()).await {
                app.modal = Modal::Error(format!("code join failed: {e}"));
            } else {
                app.set_status("code submitted — waiting for owner (up to 30 s)");
            }
            Ok(false)
        }
        Action::OpenSettings => {
            app.modal = Modal::Settings(SettingsState {
                verified_only_inbound: app.handle.verified_only_inbound(),
                blocked_peer_count: app.handle.list_blocked_peers().len(),
                username: app.handle.display_name(),
            });
            Ok(false)
        }
        Action::OpenEditUsername => {
            // Pre-fill the editor with the current username so the user
            // can tweak rather than retype. Empty submission clears.
            let current = app.handle.display_name().unwrap_or_default();
            app.modal = Modal::EditUsername(EditUsernameState { input: current });
            Ok(false)
        }
        Action::EditUsernameTypeChar(c) => {
            if let Modal::EditUsername(s) = &mut app.modal {
                if s.input.chars().count() < 32 {
                    s.input.push(c);
                }
            }
            Ok(false)
        }
        Action::EditUsernameBackspace => {
            if let Modal::EditUsername(s) = &mut app.modal {
                s.input.pop();
            }
            Ok(false)
        }
        Action::EditUsernameConfirm => {
            let input = match &app.modal {
                Modal::EditUsername(s) => s.input.clone(),
                _ => return Ok(false),
            };
            let trimmed = input.trim();
            let new_name = if trimmed.is_empty() {
                None
            } else {
                Some(trimmed.to_string())
            };
            app.modal = Modal::None;
            if let Err(e) = app.handle.set_username(new_name.as_deref()).await {
                app.modal = Modal::Error(format!("set username failed: {e}"));
            } else {
                app.set_status(match &new_name {
                    Some(n) => format!("username set to {n}"),
                    None => "username cleared — you are now [anonymous]".into(),
                });
            }
            Ok(false)
        }
        Action::OpenGoDarkModal => {
            app.modal = Modal::GoDark(GoDarkState::default());
            Ok(false)
        }
        Action::OpenAddFriend => {
            app.modal = Modal::AddFriend(AddFriendState::default());
            Ok(false)
        }
        Action::AddFriendTypeChar(c) => {
            if let Modal::AddFriend(s) = &mut app.modal {
                if s.input.chars().count() < 64 {
                    s.input.push(c);
                }
            }
            Ok(false)
        }
        Action::AddFriendBackspace => {
            if let Modal::AddFriend(s) = &mut app.modal {
                s.input.pop();
            }
            Ok(false)
        }
        Action::AddFriendConfirm => {
            let input = match &app.modal {
                Modal::AddFriend(s) => s.input.clone(),
                _ => return Ok(false),
            };
            if input.trim().is_empty() {
                return Ok(false);
            }
            app.modal = Modal::None;
            match app.handle.dial_by_id_or_username(input.trim()).await {
                Ok(()) => {
                    app.set_status(format!("dialing {}…", input.trim()));
                }
                Err(e) => {
                    app.modal = Modal::Error(format!("add friend: {e}"));
                }
            }
            Ok(false)
        }
        Action::GoDarkNextField => {
            if let Modal::GoDark(s) = &mut app.modal {
                s.focus = match s.focus {
                    GoDarkField::Passphrase => GoDarkField::Confirm,
                    GoDarkField::Confirm => GoDarkField::Passphrase,
                };
            }
            Ok(false)
        }
        Action::GoDarkTypeChar(c) => {
            if let Modal::GoDark(s) = &mut app.modal {
                let field = s.focus;
                let target = match field {
                    GoDarkField::Passphrase => &mut s.passphrase,
                    GoDarkField::Confirm => &mut s.confirm,
                };
                if target.chars().count() < 128 {
                    target.push(c);
                }
            }
            Ok(false)
        }
        Action::GoDarkBackspace => {
            if let Modal::GoDark(s) = &mut app.modal {
                let field = s.focus;
                match field {
                    GoDarkField::Passphrase => {
                        s.passphrase.pop();
                    }
                    GoDarkField::Confirm => {
                        s.confirm.pop();
                    }
                };
            }
            Ok(false)
        }
        Action::GoDarkConfirm => {
            let (passphrase, confirm) = match &app.modal {
                Modal::GoDark(s) => (s.passphrase.clone(), s.confirm.clone()),
                _ => return Ok(false),
            };
            if confirm != GO_DARK_CONFIRM_PHRASE {
                if let Modal::GoDark(s) = &mut app.modal {
                    s.last_error = Some(format!(
                        "type `{}` exactly to confirm",
                        GO_DARK_CONFIRM_PHRASE
                    ));
                }
                return Ok(false);
            }
            match app.handle.go_dark(&passphrase).await {
                Ok(()) => {
                    // WentDark event fires from go_dark; the handler
                    // schedules the actual exit so the goodbye modal
                    // is visible for a beat.
                    Ok(false)
                }
                Err(e) => {
                    if let Modal::GoDark(s) = &mut app.modal {
                        s.last_error = Some(format!("{e}"));
                        s.passphrase.clear();
                        s.focus = GoDarkField::Passphrase;
                    }
                    Ok(false)
                }
            }
        }
        Action::SettingsToggleGlobalVerifiedOnly => {
            if let Modal::Settings(s) = &mut app.modal {
                s.verified_only_inbound = !s.verified_only_inbound;
                if let Err(e) = app
                    .handle
                    .set_verified_only_inbound(s.verified_only_inbound)
                {
                    app.modal = Modal::Error(format!("save failed: {e}"));
                    return Ok(false);
                }
            }
            Ok(false)
        }
        Action::ClearBlockedPeers => {
            // Iterate the persisted blocklist and unblock each. We do
            // this in two phases (snapshot, then loop) so the SQLite
            // connection isn't borrowed across the unblock_peer calls.
            let blocked = app.handle.list_blocked_peers();
            let n = blocked.len();
            let mut errors = 0usize;
            for fp in blocked {
                if app.handle.unblock_peer(&fp).is_err() {
                    errors += 1;
                }
            }
            // Re-sync the open Settings modal's count so it reflects
            // the cleared state without needing a modal reopen.
            if let Modal::Settings(s) = &mut app.modal {
                s.blocked_peer_count = app.handle.list_blocked_peers().len();
            }
            let msg = if errors == 0 {
                format!("cleared {} blocked peer(s)", n)
            } else {
                format!(
                    "cleared {} blocked peer(s); {} error(s)",
                    n.saturating_sub(errors),
                    errors
                )
            };
            app.set_status(msg);
            Ok(false)
        }
        Action::ToggleRoomVerifiedOnly => {
            let room_id = match app.active_room() {
                Some(r) => r.room_id.clone(),
                None => return Ok(false),
            };
            let our_fp = app.handle.fingerprint().to_string();
            if !app.handle.is_owner(&room_id, &our_fp) {
                app.set_status("only an owner can toggle room verified-only");
                return Ok(false);
            }
            let new_state = !app.handle.room_verified_only(&room_id);
            if let Err(e) = app.handle.set_room_verified_only(&room_id, new_state) {
                app.modal = Modal::Error(format!("toggle failed: {e}"));
                return Ok(false);
            }
            app.set_status(if new_state {
                "room is now verified-only — non-SAS-verified joiners refused"
            } else {
                "room verified-only mode off"
            });
            Ok(false)
        }
        Action::VerifyStartSas => {
            // From the Verify modal: kick off an SAS exchange with the
            // focused member. Replaces the Verify modal with the SAS
            // in-progress modal (Waiting → Comparing on response).
            let (room_id, partner_fp) = match &app.modal {
                Modal::Verify(v) => match v.members.get(v.selected) {
                    Some((fp, _)) => (v.room_id.clone(), fp.clone()),
                    None => return Ok(false),
                },
                _ => return Ok(false),
            };
            match app.handle.sas_start(&room_id, &partner_fp).await {
                Ok(tx_id) => {
                    app.modal = Modal::Sas(SasState {
                        room_id,
                        partner_fingerprint: partner_fp,
                        tx_id,
                        stage: SasStage::Waiting,
                    });
                }
                Err(e) => app.modal = Modal::Error(format!("SAS start failed: {e}")),
            }
            Ok(false)
        }
        Action::SasMatch => {
            if let Modal::Sas(s) = &mut app.modal {
                let tx_id = s.tx_id.clone();
                if let SasStage::Comparing {
                    ref mut our_matched,
                    ..
                } = s.stage
                {
                    *our_matched = true;
                }
                if let Err(e) = app.handle.sas_match(&tx_id).await {
                    app.modal = Modal::Error(format!("SAS match failed: {e}"));
                    return Ok(false);
                }
                app.set_status("SAS match sent — waiting for partner to confirm");
            }
            Ok(false)
        }
        Action::SasCancel => {
            if let Modal::Sas(s) = &app.modal {
                app.handle.sas_cancel(&s.tx_id);
            }
            app.modal = Modal::None;
            Ok(false)
        }
        Action::OpenKickPicker => {
            let (room_id, members) = match owner_action_members(app, MemberActionKind::Kick) {
                Some(t) => t,
                None => return Ok(false),
            };
            app.modal = Modal::MemberAction(MemberActionState {
                room_id,
                kind: MemberActionKind::Kick,
                members,
                selected: 0,
            });
            Ok(false)
        }
        Action::OpenGrantPicker => {
            let (room_id, members) = match owner_action_members(app, MemberActionKind::Grant) {
                Some(t) => t,
                None => return Ok(false),
            };
            app.modal = Modal::MemberAction(MemberActionState {
                room_id,
                kind: MemberActionKind::Grant,
                members,
                selected: 0,
            });
            Ok(false)
        }
        Action::ShowRoomBans => {
            // Owners only. We use the same `we_are_owner` gate as
            // kick/grant so the keybinding is silently ignored for
            // non-owners (matching the rest of the moderation surface).
            let room_id = match app.active_room() {
                Some(r) => r.room_id.clone(),
                None => return Ok(false),
            };
            if !app.handle.we_are_owner(&room_id) {
                return Ok(false);
            }
            let bans = app.handle.list_room_bans(&room_id);
            let body = if bans.is_empty() {
                "no bans in this room.".to_string()
            } else {
                let mut s = format!("{} ban(s) in this room:\n\n", bans.len());
                for fp in &bans {
                    s.push_str(&format!("  {}\n", short_fp(fp)));
                }
                s.push_str("\nban is enforced by key rotation (the banned peer can no longer derive the room key). press any key to dismiss.");
                s
            };
            app.replace_modal_if_idle(Modal::Info(body));
            Ok(false)
        }
        Action::MemberActionNext => {
            if let Modal::MemberAction(s) = &mut app.modal {
                if s.selected + 1 < s.members.len() {
                    s.selected += 1;
                }
            }
            Ok(false)
        }
        Action::MemberActionPrev => {
            if let Modal::MemberAction(s) = &mut app.modal {
                if s.selected > 0 {
                    s.selected -= 1;
                }
            }
            Ok(false)
        }
        Action::MemberActionConfirm => {
            let snapshot = if let Modal::MemberAction(s) = &app.modal {
                s.members
                    .get(s.selected)
                    .map(|(fp, _)| (s.room_id.clone(), s.kind, fp.clone()))
            } else {
                None
            };
            let (room_id, kind, target_fp) = match snapshot {
                Some(t) => t,
                None => return Ok(false),
            };
            app.modal = Modal::None;
            match kind {
                MemberActionKind::Grant => {
                    match app.handle.grant_owner(&room_id, &target_fp).await {
                        Ok(()) => app.set_status(format!("granted owner to {}", short_fp(&target_fp))),
                        Err(e) => app.modal = Modal::Error(format!("grant failed: {e}")),
                    }
                }
                MemberActionKind::Kick => {
                    match app.handle.kick_member(&room_id, &target_fp).await {
                        Ok(new_pp) if new_pp.is_empty() => {
                            app.set_status(format!("kicked {}", short_fp(&target_fp)));
                        }
                        Ok(new_pp) => {
                            // Show the new passphrase prominently so the
                            // owner can copy + share OOB with remaining
                            // members. Modal::Info dismisses on any key.
                            app.modal = Modal::Info(format!(
                                "kicked {}. new passphrase (share OOB with remaining members):\n\n  {}",
                                short_fp(&target_fp),
                                new_pp
                            ));
                        }
                        Err(e) => app.modal = Modal::Error(format!("kick failed: {e}")),
                    }
                }
            }
            Ok(false)
        }
        Action::InboundDialAccept => {
            if let Modal::InboundDial(s) = app.modal.clone() {
                app.handle.accept_inbound(s.peer_id, &s.address).await;
                app.set_status(format!("connected to {}", short_fp(&s.fingerprint)));
                app.modal = Modal::None;
                app.refresh_known_peers();
            }
            Ok(false)
        }
        Action::InboundDialReject => {
            if let Modal::InboundDial(s) = app.modal.clone() {
                if let Err(e) = app.handle.reject_inbound(s.peer_id, &s.fingerprint).await {
                    app.modal = Modal::Error(format!("reject failed: {e}"));
                    return Ok(false);
                }
                app.set_status(format!("rejected {}", short_fp(&s.fingerprint)));
                app.modal = Modal::None;
            }
            Ok(false)
        }
        Action::InboundDialTrust => {
            if let Modal::InboundDial(s) = app.modal.clone() {
                if let Err(e) = app
                    .handle
                    .trust_inbound(s.peer_id, &s.fingerprint, &s.address)
                    .await
                {
                    app.modal = Modal::Error(format!("trust failed: {e}"));
                    return Ok(false);
                }
                app.set_status(format!("trusted {} — won't ask again", short_fp(&s.fingerprint)));
                app.modal = Modal::None;
                app.refresh_known_peers();
            }
            Ok(false)
        }
        Action::AttachPickerDescendOrPick => {
            let pick: Option<std::path::PathBuf> = match &mut app.modal {
                Modal::AttachPicker(s) => {
                    if let Some(e) = s.entries.get(s.selected) {
                        if e.is_dir {
                            s.descend();
                            None
                        } else {
                            s.selected_path()
                        }
                    } else {
                        None
                    }
                }
                _ => None,
            };
            if let Some(path) = pick {
                let room_id = match app.active_room() {
                    Some(r) => r.room_id.clone(),
                    None => return Ok(false),
                };
                app.modal = Modal::None;
                match app.handle.send_file(&room_id, &path).await {
                    Ok(file_id) => {
                        app.set_status(format!("sending {} ({})", path.display(), &file_id[..12]));
                    }
                    Err(e) => {
                        app.modal = Modal::Error(format!("send failed: {e}"));
                    }
                }
            }
            Ok(false)
        }
    }
}

/// Extract (room_id, file_id, status, encrypted) for the active room's
/// focused card. Returns None when no card is focused / available.
fn focused_card_info(
    app: &TuiApp,
) -> Option<(String, String, huddle_core::storage::repo::AttachmentStatus, bool)> {
    let r = app.active_room()?;
    let a = r.attachments.get(r.focused_card_idx)?;
    Some((r.room_id.clone(), a.file_id.clone(), a.status, a.encrypted))
}
