// Entry point for wasm
//#[cfg(target_arch = "wasm32")]
//use wasm_bindgen::prelude::*;
use crate::app::NotedeckApp;
use crate::ChromeOptions;
use bitflags::bitflags;
use eframe::CreationContext;
use egui::{
    vec2, Color32, CornerRadius, Label, Layout, Margin, Rect, RichText, Sense, ThemePreference, Ui,
    Widget,
};
use egui_extras::{Size, StripBuilder};
use egui_nav::RouteResponse;
use egui_nav::{NavAction, NavDrawer};
use nostrdb::{ProfileRecord, Transaction};
use notedeck::fonts::get_font_size;
use notedeck::name::get_display_name;
use notedeck::ui::is_compiled_as_mobile;
use notedeck::AppResponse;
use notedeck::DrawerRouter;
use notedeck::Error;
use notedeck::SoftKeyboardContext;
use notedeck::{
    tr, App, AppAction, AppContext, Localization, Notedeck, NotedeckOptions, NotedeckTextStyle,
    TabNotifications, UserAccount, WalletType,
};
use notedeck_columns::{timeline::TimelineKind, Damus};
use oot_bitset::{bitset_clear, bitset_get, bitset_set};

#[cfg(feature = "dave")]
use notedeck_dave::{Dave, DaveAvatar};

#[cfg(feature = "messages")]
use notedeck_messages::MessagesApp;

#[cfg(feature = "dashboard")]
use notedeck_dashboard::Dashboard;

#[cfg(feature = "horizon")]
use notedeck_horizon::Horizon;

#[cfg(feature = "clndash")]
use notedeck_ui::expanding_button;

use notedeck_ui::{app_images, galley_centered_pos, ProfilePic};
use std::collections::HashMap;

/// Upper bound on the number of apps the running-app bitset can track. One bit
/// per app index; see [`Chrome::tools_snapshot`].
const MAX_APPS: usize = 256;

pub struct Chrome {
    active: i32,
    options: ChromeOptions,
    apps: Vec<NotedeckApp>,

    /// Which apps have been opened (activated), one bit per app index. Only
    /// opened apps receive `update()` calls each frame.
    opened: [u16; MAX_APPS / 16],

    /// The last-focused egui widget id for each app (keyed by app index).
    /// egui drops keyboard focus when a widget isn't rendered for a frame, so
    /// switching apps (Ctrl+Tab / tab click) would otherwise lose focus until
    /// the user clicks back in. We remember each app's focus and re-request it
    /// on switch-back. Generic — no per-app code needed.
    app_focus: HashMap<usize, egui::Id>,

    /// The active app index from the previous frame, used to detect switches.
    prev_active: i32,

    /// Running-app mask (`all_active || opened[i]`, one bit per app index) the
    /// agent-tool registry was last built from. When it changes we re-aggregate
    /// the running apps' tools; see the `App::take_tool_update` impl.
    tools_snapshot: [u16; MAX_APPS / 16],

    /// The state of the soft keyboard animation
    soft_kb_anim_state: AnimState,

    pub repaint_causes: HashMap<egui::RepaintCause, u64>,
    nav: DrawerRouter,

    #[cfg(feature = "auto-update")]
    updater: notedeck::updater::Updater,
}

#[derive(Clone)]
enum ChromeRoute {
    Chrome,
    App,
}

pub enum ChromePanelAction {
    Support,
    Settings,
    Account,
    Wallet,
    SaveTheme(ThemePreference),
    Profile(notedeck::enostr::Pubkey),
    #[cfg(feature = "auto-update")]
    ApplyUpdate,
    #[cfg(feature = "auto-update")]
    DismissUpdate,
}

bitflags! {
    #[repr(transparent)]
    #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
    pub struct SidebarOptions: u8 {
        const Compact = 1 << 0;
    }
}

impl ChromePanelAction {
    fn columns_navigate(ctx: &mut AppContext, chrome: &mut Chrome, route: notedeck_columns::Route) {
        chrome.switch_to_columns();

        if let Some(c) = chrome.get_columns_app().and_then(|columns| {
            columns
                .decks_cache
                .selected_column_mut(ctx.i18n, ctx.accounts)
        }) {
            if c.router().routes().iter().any(|r| r == &route) {
                // return if we are already routing to accounts
                c.router_mut().go_back();
            } else {
                c.router_mut().route_to(route);
                //c..route_to(Route::relays());
            }
        };
    }

    #[profiling::function]
    fn process(&self, ctx: &mut AppContext, chrome: &mut Chrome, ui: &mut egui::Ui) {
        match self {
            Self::SaveTheme(theme) => {
                ui.ctx().set_theme(*theme);
                ctx.settings.set_theme(*theme);
            }

            Self::Support => {
                Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Support);
            }

            Self::Account => {
                Self::columns_navigate(ctx, chrome, notedeck_columns::Route::accounts());
            }

            Self::Settings => {
                Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Settings);
            }

            Self::Wallet => {
                Self::columns_navigate(
                    ctx,
                    chrome,
                    notedeck_columns::Route::Wallet(WalletType::Auto),
                );
            }
            Self::Profile(pk) => {
                columns_route_to_profile(pk, chrome, ctx, ui);
            }

            #[cfg(feature = "auto-update")]
            Self::ApplyUpdate => {
                if let Err(e) = chrome.updater.apply_and_restart() {
                    tracing::error!("failed to apply update: {e}");
                }
            }

            #[cfg(feature = "auto-update")]
            Self::DismissUpdate => {
                chrome.updater.dismiss();
            }
        }
    }
}

/// Some people have been running notedeck in debug, let's catch that!
fn stop_debug_mode(options: NotedeckOptions) {
    if !options.contains(NotedeckOptions::Tests)
        && cfg!(debug_assertions)
        && !options.contains(NotedeckOptions::Debug)
    {
        println!("--- WELCOME TO DAMUS NOTEDECK! ---");
        println!(
            "It looks like are running notedeck in debug mode, unless you are a developer, this is not likely what you want."
        );
        println!("If you are a developer, run `cargo run -- --debug` to skip this message.");
        println!("For everyone else, try again with `cargo run --release`. Enjoy!");
        println!("---------------------------------");
        panic!();
    }
}

/// Register every app's startup-scoped registry contributions with the host: the
/// inline [`KindRenderer`](notedeck::KindRenderer)s (back half) and the
/// [`ReferenceParser`](notedeck::ReferenceParser)s (front half) of inline
/// references. Both are registered up front for *all* apps so a reference
/// resolves and renders even for apps the user never opened.
fn setup_app_registries(notedeck: &mut Notedeck, apps: &[NotedeckApp]) {
    for renderer in apps.iter().flat_map(|app| app.kind_renderers()) {
        notedeck.register_kind_renderer(renderer);
    }
    for parser in apps.iter().flat_map(|app| app.reference_parsers()) {
        notedeck.register_reference_parser(parser);
    }
}

impl Chrome {
    /// Create a new chrome with the default app setup
    pub fn new_with_apps(
        cc: &CreationContext,
        app_args: &[String],
        notedeck: &mut Notedeck,
    ) -> Result<Self, Error> {
        let notedeck_options = notedeck.options();
        stop_debug_mode(notedeck_options);

        // Named (not a borrowed temporary) so we can drop it below to release
        // the `&mut notedeck` borrow and register kind-renderers afterwards.
        let mut notedeck_ref = notedeck.notedeck_ref(&cc.egui_ctx);
        let app_ref = &mut notedeck_ref;
        #[cfg(feature = "dave")]
        let dave = Dave::new(
            cc.wgpu_render_state.as_ref(),
            app_ref.app_ctx.ndb.clone(),
            cc.egui_ctx.clone(),
            app_ref.app_ctx.path,
        );
        #[cfg(feature = "wasm")]
        let wasm_dir = app_ref
            .app_ctx
            .path
            .path(notedeck::DataPathType::Cache)
            .join("wasm_apps");

        let mut chrome = Chrome {
            active: 0,
            options: ChromeOptions::default(),
            apps: Vec::new(),
            opened: [0u16; MAX_APPS / 16],
            app_focus: HashMap::new(),
            prev_active: 0,
            tools_snapshot: [0u16; MAX_APPS / 16],
            soft_kb_anim_state: AnimState::default(),
            repaint_causes: HashMap::new(),
            nav: DrawerRouter::default(),
            #[cfg(feature = "auto-update")]
            updater: notedeck::updater::Updater::new(
                app_ref.app_ctx.path,
                &app_ref.app_ctx.ndb,
                &cc.egui_ctx,
                notedeck::updater::nostr::DEFAULT_RELEASE_PUBKEY,
                notedeck::updater::nostr::ReleaseChannel::from_setting(
                    app_ref.app_ctx.settings.release_channel(),
                ),
            ),
        };

        if !app_args.iter().any(|arg| arg == "--no-columns-app") {
            let columns = Damus::new(&mut app_ref.app_ctx, app_args);
            app_ref.internals.check_args(columns.unrecognized_args())?;
            chrome.add_app(NotedeckApp::Columns(Box::new(columns)));
        }

        #[cfg(feature = "dave")]
        chrome.add_app(NotedeckApp::Dave(Box::new(dave)));

        #[cfg(feature = "messages")]
        chrome.add_app(NotedeckApp::Messages(Box::new(MessagesApp::new())));

        #[cfg(feature = "dashboard")]
        chrome.add_app(NotedeckApp::Dashboard(Box::new(Dashboard::default())));

        #[cfg(feature = "horizon")]
        chrome.add_app(NotedeckApp::Horizon(Box::new(Horizon::default())));

        #[cfg(feature = "notebook")]
        chrome.add_app(NotedeckApp::Notebook(Box::default()));

        #[cfg(feature = "headway")]
        chrome.add_app(NotedeckApp::Headway(Box::default()));

        #[cfg(feature = "clndash")]
        chrome.add_app(NotedeckApp::ClnDash(Box::default()));

        #[cfg(feature = "nostrverse")]
        chrome.add_app(NotedeckApp::Nostrverse(Box::new(
            notedeck_nostrverse::NostrverseApp::demo(cc.wgpu_render_state.as_ref()),
        )));

        #[cfg(feature = "wasm")]
        {
            tracing::info!("looking for WASM apps in: {}", wasm_dir.display());
            if wasm_dir.is_dir() {
                if let Ok(entries) = std::fs::read_dir(&wasm_dir) {
                    for entry in entries.flatten() {
                        let path = entry.path();
                        if path.extension().is_some_and(|e| e == "wasm") {
                            match notedeck_wasm::WasmApp::from_file(&path) {
                                Ok(app) => {
                                    let name = app.name().to_string();
                                    tracing::info!(
                                        "loaded WASM app '{}': {}",
                                        name,
                                        path.display()
                                    );
                                    chrome.add_app(NotedeckApp::Other(name, Box::new(app)));
                                }
                                Err(e) => {
                                    tracing::error!(
                                        "failed to load WASM app {}: {e}",
                                        path.display()
                                    );
                                }
                            }
                        }
                    }
                }
            } else {
                tracing::info!("WASM apps directory not found: {}", wasm_dir.display());
            }
        }

        if notedeck_options.contains(NotedeckOptions::AllAppsActive) {
            chrome.options.set(ChromeOptions::AllAppsActive, true);
        }

        chrome.set_active(0);

        app_ref.app_ctx.sound.play(notedeck::SoundEffect::Startup);

        // Release the `&mut notedeck` borrow so we can register registries below.
        drop(notedeck_ref);

        setup_app_registries(notedeck, &chrome.apps);

        Ok(chrome)
    }

    /// Create a Chrome for snapshot tests — no eframe CreationContext needed.
    #[cfg(feature = "auto-update")]
    pub fn new_test(
        ctx: &mut notedeck::AppContext,
        egui_ctx: &egui::Context,
        args: &[String],
    ) -> Self {
        let damus = Damus::new(ctx, args);
        let mut chrome = Chrome {
            active: 0,
            options: ChromeOptions::default(),
            apps: Vec::new(),
            opened: [0u16; MAX_APPS / 16],
            app_focus: HashMap::new(),
            prev_active: 0,
            tools_snapshot: [0u16; MAX_APPS / 16],
            soft_kb_anim_state: AnimState::default(),
            repaint_causes: HashMap::new(),
            nav: DrawerRouter::default(),
            updater: notedeck::updater::Updater::new(
                ctx.path,
                &ctx.ndb,
                egui_ctx,
                notedeck::updater::nostr::DEFAULT_RELEASE_PUBKEY,
                notedeck::updater::nostr::ReleaseChannel::from_setting(
                    ctx.settings.release_channel(),
                ),
            ),
        };
        chrome.add_app(NotedeckApp::Columns(Box::new(damus)));
        chrome.set_active(0);
        chrome
    }

    /// Override the release signing pubkey and resubscribe to ndb.
    #[cfg(all(feature = "auto-update", feature = "snapshot-testing"))]
    pub fn set_release_pubkey(&mut self, ndb: &mut nostrdb::Ndb, pubkey: [u8; 32]) {
        self.updater.set_release_pubkey(ndb, pubkey);
    }

    /// Force the updater into ReadyToInstall state (for snapshot tests).
    #[cfg(all(feature = "auto-update", feature = "snapshot-testing"))]
    pub fn force_update_ready(&mut self, version: String) {
        self.updater.force_ready(version);
    }

    pub fn toggle(&mut self) {
        if self.nav.drawer_focused {
            self.nav.close();
        } else {
            self.nav.open();
        }
    }

    /// Chrome-level keybindings — consumed before apps render,
    /// so apps can never intercept them.
    fn handle_chrome_keybindings(&mut self, ctx: &egui::Context) {
        if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::F11)) {
            self.toggle();
        }

        // Ctrl+W (Cmd+W on macOS) closes the active app's tab.
        if ctx.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, egui::Key::W)) {
            self.close_active_app();
        }
    }

    /// Fallback keybindings — only fire if no app consumed the key.
    fn handle_fallback_keybindings(&mut self, ctx: &egui::Context) {
        if ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
            self.toggle();
        }
    }

    pub fn add_app(&mut self, app: NotedeckApp) {
        self.apps.push(app);
        // `opened` is a fixed bitset — the new app's bit is already clear.
    }

    /// Whether the app at index `i` has been opened.
    fn is_opened(&self, i: usize) -> bool {
        i < MAX_APPS && bitset_get(&self.opened, i as u16)
    }

    /// Mark the app at index `i` as opened.
    fn set_opened(&mut self, i: usize) {
        if i < MAX_APPS {
            bitset_set(&mut self.opened, i as u16);
        }
    }

    /// Mark the app at index `i` as closed.
    fn clear_opened(&mut self, i: usize) {
        if i < MAX_APPS {
            bitset_clear(&mut self.opened, i as u16);
        }
    }

    /// The number of currently-opened apps.
    fn opened_count(&self) -> usize {
        (0..self.apps.len()).filter(|&i| self.is_opened(i)).count()
    }

    fn get_columns_app(&mut self) -> Option<&mut Damus> {
        for app in &mut self.apps {
            if let NotedeckApp::Columns(cols) = app {
                return Some(cols);
            }
        }

        None
    }

    fn switch_to_columns(&mut self) {
        for i in 0..self.apps.len() {
            if let NotedeckApp::Columns(_) = self.apps[i] {
                self.set_active(i as i32);
            }
        }
    }

    #[cfg(feature = "dave")]
    fn get_dave_app(&mut self) -> Option<&mut Dave> {
        for app in &mut self.apps {
            if let NotedeckApp::Dave(dave) = app {
                return Some(dave);
            }
        }
        None
    }

    #[cfg(feature = "dave")]
    fn switch_to_dave(&mut self) {
        for i in 0..self.apps.len() {
            if let NotedeckApp::Dave(_) = self.apps[i] {
                self.set_active(i as i32);
            }
        }
    }

    #[cfg(feature = "messages")]
    fn switch_to_messages(&mut self) {
        for i in 0..self.apps.len() {
            if let NotedeckApp::Messages(_) = self.apps[i] {
                self.set_active(i as i32);
            }
        }
    }

    #[cfg(feature = "headway")]
    fn get_headway_app(&mut self) -> Option<&mut notedeck_headway::Headway> {
        for app in &mut self.apps {
            if let NotedeckApp::Headway(headway) = app {
                return Some(headway);
            }
        }
        None
    }

    #[cfg(feature = "headway")]
    fn switch_to_headway(&mut self) {
        for i in 0..self.apps.len() {
            if let NotedeckApp::Headway(_) = self.apps[i] {
                self.set_active(i as i32);
            }
        }
    }

    fn process_toolbar_action(&mut self, action: ChromeToolbarAction, ctx: &mut AppContext) {
        match action {
            ChromeToolbarAction::Home => {
                self.switch_to_columns();
                if let Some(columns) = self.get_columns_app() {
                    columns.navigate_home(ctx);
                }
            }
            #[cfg(feature = "messages")]
            ChromeToolbarAction::Chat => {
                self.switch_to_messages();
            }
            ChromeToolbarAction::Search => {
                self.switch_to_columns();
                if let Some(columns) = self.get_columns_app() {
                    columns.navigate_search(ctx);
                }
            }
            ChromeToolbarAction::Notifications => {
                self.switch_to_columns();
                if let Some(columns) = self.get_columns_app() {
                    columns.navigate_notifications(ctx);
                }
            }
        }
    }

    /// Returns which ChromeToolbarAction is currently "active" based on
    /// the active app and its route. Used to highlight the current tab.
    fn active_toolbar_tab(&self, accounts: &notedeck::Accounts) -> Option<ChromeToolbarAction> {
        let active_app = &self.apps[self.active as usize];
        match active_app {
            #[cfg(feature = "messages")]
            NotedeckApp::Messages(_) => Some(ChromeToolbarAction::Chat),
            NotedeckApp::Columns(columns) => match columns.active_toolbar_tab(accounts) {
                Some(0) => Some(ChromeToolbarAction::Home),
                Some(1) => Some(ChromeToolbarAction::Search),
                Some(2) => Some(ChromeToolbarAction::Notifications),
                _ => None,
            },
            _ => None,
        }
    }

    pub fn set_active(&mut self, app: i32) {
        self.active = app;
        self.set_opened(app as usize);
    }

    /// Close the active app's tab and switch to the nearest opened app.
    /// No-op if it's the only opened app — at least one app must stay open.
    /// Used by Ctrl+W.
    fn close_active_app(&mut self) {
        let n = self.apps.len();
        if n == 0 {
            return;
        }
        // at least one app must remain open
        if self.opened_count() <= 1 {
            return;
        }
        let active = self.active.clamp(0, n as i32 - 1) as usize;
        self.clear_opened(active);
        // switch to the nearest opened app after `active`, wrapping
        for offset in 1..=n {
            let idx = (active + offset) % n;
            if self.is_opened(idx) {
                self.set_active(idx as i32);
                return;
            }
        }
    }

    /// Cycle the active app to the next (`forward`) or previous opened app,
    /// wrapping around. Used by Ctrl+Tab / Ctrl+Shift+Tab.
    fn cycle_app(&mut self, forward: bool) {
        let n = self.apps.len();
        if n == 0 {
            return;
        }
        let active = self.active.clamp(0, n as i32 - 1) as usize;
        // walk opened slots starting from the one after active, wrapping
        if let Some(idx) = bitset_find(&self.opened, active as u16, forward) {
            self.set_active(idx as i32);
        }
    }

    /// The chrome side panel
    #[profiling::function]
    fn panel(
        &mut self,
        app_ctx: &mut AppContext,
        ui: &mut egui::Ui,
        amt_keyboard_open: f32,
    ) -> Option<ChromePanelAction> {
        let drawer = NavDrawer::new(&ChromeRoute::App, &ChromeRoute::Chrome)
            .navigating(self.nav.navigating)
            .returning(self.nav.returning)
            .drawer_focused(self.nav.drawer_focused)
            .drag(is_compiled_as_mobile())
            .opened_offset(240.0);

        let resp = drawer.show_mut(ui, |ui, route| match route {
            ChromeRoute::Chrome => {
                ui.painter().rect_filled(
                    ui.available_rect_before_wrap(),
                    CornerRadius::ZERO,
                    if ui.visuals().dark_mode {
                        egui::Color32::BLACK
                    } else {
                        egui::Color32::WHITE
                    },
                );
                egui::Frame::new()
                    .inner_margin(Margin::same(notedeck::tokens::SPACING_LG as i8))
                    .show(ui, |ui| {
                        let options = if amt_keyboard_open > 0.0 {
                            SidebarOptions::Compact
                        } else {
                            SidebarOptions::default()
                        };

                        let response = ui
                            .with_layout(Layout::top_down(egui::Align::Min), |ui| {
                                topdown_sidebar(self, app_ctx, ui, options)
                            })
                            .inner;

                        ui.with_layout(Layout::bottom_up(egui::Align::Center), |ui| {
                            ui.add(milestone_name(app_ctx.i18n));
                        });

                        RouteResponse {
                            response,
                            can_take_drag_from: Vec::new(),
                        }
                    })
                    .inner
            }
            ChromeRoute::App => {
                let resp = self.apps[self.active as usize].render(app_ctx, ui);

                if let Some(action) = resp.action {
                    chrome_handle_app_action(self, app_ctx, action, ui);
                }

                // Actions raised imperatively mid-render (e.g. a clicked inline
                // widget drawn by another app's KindRenderer) arrive here rather
                // than via `resp.action`; route them the same way.
                for action in app_ctx.app_actions.take() {
                    chrome_handle_app_action(self, app_ctx, action, ui);
                }

                RouteResponse {
                    response: None,
                    can_take_drag_from: resp.can_take_drag_from,
                }
            }
        });

        if let Some(action) = resp.action {
            if matches!(action, NavAction::Returned(_)) {
                self.nav.closed();
            } else if let NavAction::Navigating = action {
                self.nav.navigating = false;
            } else if let NavAction::Navigated = action {
                self.nav.opened();
            }
        }

        resp.drawer_response?
    }

    /// Show the side menu or bar, depending on if we're on a narrow
    /// or wide screen.
    ///
    /// The side menu should hover over the screen, while the side bar
    /// is collapsible but persistent on the screen.
    #[profiling::function]
    fn show(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<ChromePanelAction> {
        ui.spacing_mut().item_spacing.x = 0.0;

        let skb_anim =
            keyboard_visibility(ui, ctx, &mut self.options, &mut self.soft_kb_anim_state);

        let virtual_keyboard = self.options.contains(ChromeOptions::VirtualKeyboard);
        let keyboard_height = if self.options.contains(ChromeOptions::KeyboardVisibility) {
            skb_anim.anim_height
        } else {
            0.0
        };

        let is_narrow = notedeck::ui::is_narrow(ui.ctx());
        let toolbar_height = if is_narrow && ctx.settings.welcome_completed() {
            toolbar_visibility_height(skb_anim.skb_rect, ui)
        } else {
            0.0
        };

        // chrome-style app tab strip, desktop/wide only
        let show_app_tabs = !is_narrow && ctx.settings.welcome_completed();

        // Ctrl+Tab / Ctrl+Shift+Tab cycle through opened app tabs
        if show_app_tabs {
            let (mut next, mut prev) = (false, false);
            ui.input_mut(|i| {
                prev = i.consume_key(
                    egui::Modifiers::CTRL | egui::Modifiers::SHIFT,
                    egui::Key::Tab,
                );
                next = i.consume_key(egui::Modifiers::CTRL, egui::Key::Tab);

                // macOS swallows Ctrl+Tab via AppKit's keyboard-interface
                // control before it reaches us, so also accept the native
                // Cmd+Shift+[ / Cmd+Shift+] tab-cycling shortcuts there. The
                // MAC_CMD modifier only matches when the Cmd key is set, which
                // only happens on macOS, so this is inert on other platforms.
                //
                // Because Shift is held, the logical key egui reports is the
                // shifted glyph: `{` (OpenCurlyBracket) and `}`
                // (CloseCurlyBracket), not the bare `[` / `]`.
                prev |= i.consume_key(
                    egui::Modifiers::MAC_CMD | egui::Modifiers::SHIFT,
                    egui::Key::OpenCurlyBracket,
                );
                next |= i.consume_key(
                    egui::Modifiers::MAC_CMD | egui::Modifiers::SHIFT,
                    egui::Key::CloseCurlyBracket,
                );
            });
            if prev {
                self.cycle_app(false);
            } else if next {
                self.cycle_app(true);
            }
        }

        let (unseen_notifications, active_toolbar_tab) = if is_narrow {
            let unseen = self
                .get_columns_app()
                .map(|c| c.has_unseen_notifications(ctx.accounts))
                .unwrap_or(false);
            let active = self.active_toolbar_tab(ctx.accounts);
            (unseen, active)
        } else {
            (false, None)
        };

        // if the soft keyboard is open, shrink the chrome contents
        let mut action: Option<ChromePanelAction> = None;
        let mut toolbar_action: Option<ChromeToolbarAction> = None;
        // build a strip to carve out the soft keyboard inset
        let prev_spacing = ui.spacing().item_spacing;
        ui.spacing_mut().item_spacing.y = 0.0;
        StripBuilder::new(ui)
            .size(Size::remainder())
            .size(Size::exact(toolbar_height))
            .size(Size::exact(keyboard_height))
            .vertical(|mut strip| {
                // the actual content, shifted up because of the soft keyboard
                strip.cell(|ui| {
                    ui.spacing_mut().item_spacing = prev_spacing;
                    if show_app_tabs {
                        chrome_app_tabs(self, ctx, ui);
                    }
                    // If the active app changed this frame (Ctrl+Tab above or a
                    // tab click in chrome_app_tabs), restore that app's last
                    // keyboard focus before rendering it, so the user can type
                    // immediately instead of having to click back in. Skipped on
                    // mobile to avoid popping the virtual keyboard on every switch.
                    if self.active != self.prev_active {
                        if !is_compiled_as_mobile() {
                            if let Some(id) = self.app_focus.get(&(self.active as usize)) {
                                ui.ctx().memory_mut(|m| m.request_focus(*id));
                            } else {
                                // First activation, nothing remembered: ask the
                                // app's autofocus widget (if any) to grab focus,
                                // like browser autofocus.
                                notedeck_ui::request_autofocus(ui.ctx());
                            }
                        }
                        self.prev_active = self.active;
                    }
                    action = self.panel(ctx, ui, keyboard_height);
                });

                // mobile toolbar
                strip.cell(|ui| {
                    if toolbar_height > 0.0 {
                        toolbar_action =
                            chrome_toolbar(ui, unseen_notifications, active_toolbar_tab);
                    }
                });

                // the filler space taken up by the soft keyboard
                strip.cell(|ui| {
                    // keyboard-visibility virtual keyboard
                    if virtual_keyboard && keyboard_height > 0.0 {
                        virtual_keyboard_ui(ui, ui.available_rect_before_wrap())
                    }
                });
            });

        // hovering virtual keyboard
        if virtual_keyboard {
            if let Some(mut kb_rect) = skb_anim.skb_rect {
                let kb_height = if self.options.contains(ChromeOptions::KeyboardVisibility) {
                    keyboard_height
                } else {
                    400.0
                };
                kb_rect.min.y = kb_rect.max.y - kb_height;
                tracing::debug!("hovering virtual kb_height:{keyboard_height} kb_rect:{kb_rect}");
                virtual_keyboard_ui(ui, kb_rect)
            }
        }

        if let Some(tb_action) = toolbar_action {
            self.process_toolbar_action(tb_action, ctx);
        }

        // Remember the active app's currently-focused widget so we can restore
        // it when the user switches away and back. Only overwrite on Some so the
        // last real focus is retained even after focus is transiently dropped.
        if let Some(focused) = ui.ctx().memory(|m| m.focused()) {
            self.app_focus.insert(self.active as usize, focused);
        }

        action
    }
}

#[cfg(feature = "auto-update")]
fn poll_updater(updater: &mut notedeck::updater::Updater, ctx: &mut notedeck::AppContext) {
    // Sync release channel from settings (cheap string compare, only parses on change)
    let setting_str = ctx.settings.release_channel();
    if setting_str != updater.channel().as_str() {
        if let Some(ch) = notedeck::updater::nostr::ReleaseChannel::parse(setting_str) {
            updater.set_channel(ch);
        }
    }

    if updater.needs_relay_sub() {
        tracing::debug!("updater: sending release filter to relays");
        let release_pubkey = *updater.release_pubkey();
        let filters = notedeck::updater::nostr::release_filter(&release_pubkey);
        let mut oneshot = ctx.remote.oneshot(ctx.accounts);
        oneshot.oneshot(filters);
    }

    if updater.wants_release() {
        let release_sub = updater.release_sub();
        let nks = ctx.ndb.poll_for_notes(release_sub, 10);
        if !nks.is_empty() {
            tracing::debug!(
                "updater: got {} new note(s) from release subscription",
                nks.len()
            );
            updater.note_received();
        }
    }
    updater.check_gathering(ctx.ndb);
    updater.poll(ctx.ndb);
}

#[cfg(feature = "auto-update")]
fn update_sidebar_item_ui(
    updater: &notedeck::updater::Updater,
    ui: &mut egui::Ui,
) -> Option<ChromePanelAction> {
    let version = updater.update_ready()?;

    let accent = notedeck_ui::colors::PINK;
    let desired_size = egui::vec2(ui.available_width(), 40.0);
    let (rect, response) = ui.allocate_exact_size(desired_size, egui::Sense::click());

    if ui.is_rect_visible(rect) {
        let rounding = 8.0;
        let bg = if response.hovered() {
            accent.gamma_multiply(0.9)
        } else {
            accent
        };
        ui.painter().rect_filled(rect, rounding, bg);

        // Draw update arrow icon on the left
        let icon_size = 16.0;
        let icon_center = egui::pos2(rect.left() + 20.0, rect.center().y);
        notedeck_ui::icons::draw_update_icon(
            ui.painter(),
            icon_center,
            icon_size,
            Color32::WHITE,
            2.0,
        );

        // "Update available" text
        let text_pos = egui::pos2(rect.left() + 38.0, rect.center().y);
        let font = egui::FontId::new(
            notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body),
            egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()),
        );
        let galley =
            ui.painter()
                .layout_no_wrap(format!("Update to {version}"), font, Color32::WHITE);
        let text_y = text_pos.y - galley.size().y / 2.0;
        ui.painter()
            .galley(egui::pos2(text_pos.x, text_y), galley, Color32::WHITE);
    }

    if response.clicked() {
        Some(ChromePanelAction::ApplyUpdate)
    } else {
        None
    }
}

impl notedeck::App for Chrome {
    /// Re-derive the host's agent-tool registry from the currently-running apps
    /// whenever that set changes, so a backend (dave, `notedeck --mcp`) only
    /// sees tools whose app is live and syncing its data. Called by the host on
    /// the top app each frame (see `App::take_tool_update`); returns `None` when
    /// the running set is unchanged so no per-frame allocation happens.
    fn take_tool_update(&mut self) -> Option<Vec<notedeck::RegisteredTool>> {
        let all_active = self.options.contains(ChromeOptions::AllAppsActive);
        let mut running = [0u16; MAX_APPS / 16];
        for i in 0..self.apps.len().min(MAX_APPS) {
            if all_active || bitset_get(&self.opened, i as u16) {
                bitset_set(&mut running, i as u16);
            }
        }

        if running == self.tools_snapshot {
            return None;
        }
        self.tools_snapshot = running;

        Some(
            self.apps
                .iter()
                .enumerate()
                .filter(|(i, _)| bitset_get(&running, *i as u16))
                .flat_map(|(_, app)| app.tools())
                .collect(),
        )
    }

    fn update(&mut self, ctx: &mut notedeck::AppContext, _egui_ctx: &egui::Context) {
        ctx.sound.update();

        #[cfg(feature = "auto-update")]
        poll_updater(&mut self.updater, ctx);

        // Update opened apps every frame so background processing
        // (relay pools, subscriptions, etc.) stays alive.
        // Apps that haven't been opened yet are skipped unless
        // --all-apps-active is set.
        let all_active = self.options.contains(ChromeOptions::AllAppsActive);
        for (i, app) in self.apps.iter_mut().enumerate() {
            if all_active || bitset_get(&self.opened, i as u16) {
                app.update(ctx, _egui_ctx);
            }
        }
    }

    fn render(&mut self, ctx: &mut notedeck::AppContext, ui: &mut egui::Ui) -> AppResponse {
        #[cfg(feature = "tracy")]
        {
            ui.ctx().request_repaint();
        }

        // Chrome-level keybindings — consumed before apps render,
        // so apps can never intercept them.
        self.handle_chrome_keybindings(ui.ctx());

        if let Some(action) = self.show(ctx, ui) {
            action.process(ctx, self, ui);
            self.nav.close();
        }

        // Fallback keybindings — only fire if no app consumed the key.
        self.handle_fallback_keybindings(ui.ctx());

        // TODO: unify this constant with the columns side panel width. ui crate?
        AppResponse::none()
    }
}

const TOOLBAR_HEIGHT: f32 = 48.0;

#[derive(Debug, Eq, PartialEq)]
enum ChromeToolbarAction {
    Home,
    #[cfg(feature = "messages")]
    Chat,
    Search,
    Notifications,
}

/// Compute the animated toolbar height, auto-hiding on scroll and
/// when the soft keyboard is open.
fn toolbar_visibility_height(skb_rect: Option<Rect>, ui: &mut Ui) -> f32 {
    let toolbar_visible_id = egui::Id::new("chrome_toolbar_visible");

    let scroll_delta = scroll_delta(ui.ctx());
    let velocity_threshold = 1.0;

    if scroll_delta > velocity_threshold {
        ui.ctx()
            .data_mut(|d| d.insert_temp(toolbar_visible_id, true));
    } else if scroll_delta < -velocity_threshold {
        ui.ctx()
            .data_mut(|d| d.insert_temp(toolbar_visible_id, false));
    }

    let toolbar_visible = ui
        .ctx()
        .data(|d| d.get_temp::<bool>(toolbar_visible_id))
        .unwrap_or(true);

    let toolbar_anim = ui
        .ctx()
        .animate_bool_responsive(toolbar_visible_id.with("anim"), toolbar_visible);

    if skb_rect.is_none() {
        TOOLBAR_HEIGHT * toolbar_anim
    } else {
        0.0
    }
}

/// Detect vertical scroll intent from mouse wheel, trackpad, or touch drag.
fn scroll_delta(ctx: &egui::Context) -> f32 {
    ctx.input(|i| {
        let sd = i.smooth_scroll_delta.y;
        if sd.abs() > 0.5 {
            return sd;
        }
        if i.pointer.is_decidedly_dragging() {
            return i.pointer.velocity().y;
        }
        0.0
    })
}

/// Render the Chrome mobile toolbar (Home, Chat, Search, Notifications).
fn chrome_toolbar(
    ui: &mut Ui,
    unseen_notifications: bool,
    active_tab: Option<ChromeToolbarAction>,
) -> Option<ChromeToolbarAction> {
    use egui_tabs::{TabColor, Tabs};
    use notedeck_ui::icons::{home_button, notifications_button, search_button};

    let rect = ui.available_rect_before_wrap();
    let bg = if ui.visuals().dark_mode {
        Color32::BLACK
    } else {
        notedeck_ui::colors::ALMOST_WHITE
    };
    ui.painter().rect_filled(rect, 0.0, bg);
    ui.painter().hline(
        rect.x_range(),
        rect.top(),
        ui.visuals().widgets.noninteractive.bg_stroke,
    );

    let has_chat = cfg!(feature = "messages");
    let mut next_index = 0;
    let home_index = next_index;
    next_index += 1;
    let chat_index = if has_chat {
        let i = next_index;
        next_index += 1;
        Some(i)
    } else {
        None
    };
    let search_index = next_index;
    next_index += 1;
    let notif_index = next_index;
    let tab_count = notif_index + 1;

    let rs = Tabs::new(tab_count)
        .selected(0)
        .hover_bg(TabColor::none())
        .selected_fg(TabColor::none())
        .selected_bg(TabColor::none())
        .height(TOOLBAR_HEIGHT)
        .layout(Layout::centered_and_justified(egui::Direction::TopDown))
        .show(ui, |ui, state| {
            let index = state.index();
            let btn_size: f32 = 20.0;

            if index == home_index {
                let active = active_tab == Some(ChromeToolbarAction::Home);
                if home_button(ui, btn_size, active).clicked() {
                    return Some(ChromeToolbarAction::Home);
                }
            } else if Some(index) == chat_index {
                #[cfg(feature = "messages")]
                {
                    let active = active_tab == Some(ChromeToolbarAction::Chat);
                    if notedeck_ui::icons::chat_button(ui, btn_size, active).clicked() {
                        return Some(ChromeToolbarAction::Chat);
                    }
                }
            } else if index == search_index {
                let active = active_tab == Some(ChromeToolbarAction::Search);
                if ui
                    .add(search_button(ui.visuals().text_color(), 2.0, active))
                    .clicked()
                {
                    return Some(ChromeToolbarAction::Search);
                }
            } else if index == notif_index {
                let active = active_tab == Some(ChromeToolbarAction::Notifications);
                if notifications_button(ui, btn_size, active, unseen_notifications).clicked() {
                    return Some(ChromeToolbarAction::Notifications);
                }
            }

            None
        })
        .inner();

    for maybe_r in rs {
        if maybe_r.inner.is_some() {
            return maybe_r.inner;
        }
    }

    None
}

fn milestone_name<'a>(i18n: &'a mut Localization) -> impl Widget + 'a {
    let text = if notedeck::ui::is_compiled_as_mobile() {
        tr!(
            i18n,
            "Damus Android BETA",
            "Damus android beta version label"
        )
    } else {
        tr!(
            i18n,
            "Damus Notedeck BETA",
            "Damus notedeck beta version label"
        )
    };

    |ui: &mut egui::Ui| -> egui::Response {
        let font = egui::FontId::new(
            notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Tiny),
            egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()),
        );
        ui.add(
            Label::new(
                RichText::new(text)
                    .color(ui.style().visuals.noninteractive().fg_stroke.color)
                    .font(font),
            )
            .selectable(false),
        )
        .on_hover_text(tr!(
            i18n,
            "Notedeck is a beta product. Expect bugs and contact us when you run into issues.",
            "Beta product warning message"
        ))
        .on_hover_cursor(egui::CursorIcon::Help)
    }
}

#[cfg(feature = "clndash")]
fn clndash_button(ui: &mut egui::Ui) -> egui::Response {
    notedeck_ui::expanding_button(
        "clndash-button",
        24.0,
        app_images::cln_image(),
        app_images::cln_image(),
        ui,
        false,
    )
}

#[cfg(feature = "dave")]
fn dave_button(avatar: Option<&mut DaveAvatar>, ui: &mut egui::Ui, rect: Rect) -> egui::Response {
    if let Some(avatar) = avatar {
        avatar.render(rect, ui)
    } else {
        // plain icon if wgpu device not available??
        ui.label("fixme")
    }
}

/// The localized display name for a notedeck app, used in the sidebar and the
/// chrome tab strip.
fn app_label(loc: &mut Localization, app: &NotedeckApp) -> String {
    match app {
        #[cfg(feature = "dave")]
        NotedeckApp::Dave(_) => tr!(loc, "Dave", "Button to go to the Dave app"),
        NotedeckApp::Columns(_) => tr!(loc, "Columns", "Button to go to the Columns app"),

        #[cfg(feature = "messages")]
        NotedeckApp::Messages(_) => tr!(loc, "Messaging", "Button to go to the messaging app"),

        #[cfg(feature = "dashboard")]
        NotedeckApp::Dashboard(_) => tr!(loc, "Dashboard", "Button to go to the dashboard app"),

        #[cfg(feature = "horizon")]
        NotedeckApp::Horizon(_) => tr!(loc, "Horizon", "Button to go to the Horizon app"),

        #[cfg(feature = "notebook")]
        NotedeckApp::Notebook(_) => tr!(loc, "Notebook", "Button to go to the Notebook app"),

        #[cfg(feature = "headway")]
        NotedeckApp::Headway(_) => tr!(loc, "Headway", "Button to go to the Headway app"),

        #[cfg(feature = "clndash")]
        NotedeckApp::ClnDash(_) => tr!(loc, "ClnDash", "Button to go to the ClnDash app"),

        #[cfg(feature = "nostrverse")]
        NotedeckApp::Nostrverse(_) => tr!(loc, "Nostrverse", "Button to go to the Nostrverse app"),

        NotedeckApp::Other(name, _) => tr!(loc, name.as_str(), "Button to go to a WASM app"),
    }
}

/// Render a small (`size` square) app icon for the chrome tab strip.
fn tab_app_icon(ui: &mut egui::Ui, app: &mut NotedeckApp, size: f32) {
    match app {
        NotedeckApp::Columns(_) => {
            ui.add(app_images::columns_image().max_width(size).max_height(size));
        }

        #[cfg(feature = "dave")]
        NotedeckApp::Dave(dave) => {
            let (rect, _) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
            dave_button(dave.avatar_mut(), ui, rect);
        }

        #[cfg(feature = "dashboard")]
        NotedeckApp::Dashboard(_) => {
            notedeck_ui::icons::dashboard_icon(ui, size);
        }

        #[cfg(feature = "horizon")]
        NotedeckApp::Horizon(_) => {
            notedeck_ui::icons::horizon_icon(ui, size);
        }

        #[cfg(feature = "messages")]
        NotedeckApp::Messages(_) => {
            notedeck_ui::icons::messages_icon(ui, size);
        }

        #[cfg(feature = "clndash")]
        NotedeckApp::ClnDash(_) => {
            ui.add(app_images::cln_image().max_width(size).max_height(size));
        }

        #[cfg(feature = "notebook")]
        NotedeckApp::Notebook(_) => {
            notedeck_ui::icons::notebook_icon(ui, size);
        }

        #[cfg(feature = "headway")]
        NotedeckApp::Headway(_) => {
            notedeck_ui::icons::headway_icon(ui, size);
        }

        #[cfg(feature = "nostrverse")]
        NotedeckApp::Nostrverse(_) => {
            ui.add(
                app_images::universe_image()
                    .max_width(size)
                    .max_height(size),
            );
        }

        NotedeckApp::Other(_name, _) => {
            ui.label("W");
        }
    }
}

/// The app index of the `n`th opened app (the app backing the `n`th tab).
fn nth_opened(opened: &[u16], app_count: usize, n: usize) -> Option<usize> {
    (0..app_count)
        .filter(|&i| bitset_get(opened, i as u16))
        .nth(n)
}

/// A subtle separator beneath the tab strip: a faint line that softens into a
/// short gradient fading up into the tab strip, instead of a hard hairline.
fn tab_strip_fade(ui: &egui::Ui) {
    let rect = ui.available_rect_before_wrap();
    let top = rect.top();
    let fade_height = 6.0;
    let (left, right) = (rect.left(), rect.right());

    let base = ui.visuals().widgets.noninteractive.bg_stroke.color;
    let line = base.gamma_multiply(0.6);
    let transparent = Color32::TRANSPARENT;

    let mut mesh = egui::Mesh::default();
    mesh.colored_vertex(egui::pos2(left, top), line);
    mesh.colored_vertex(egui::pos2(right, top), line);
    mesh.colored_vertex(egui::pos2(right, top - fade_height), transparent);
    mesh.colored_vertex(egui::pos2(left, top - fade_height), transparent);
    mesh.add_triangle(0, 1, 2);
    mesh.add_triangle(0, 2, 3);
    ui.painter().add(egui::Shape::mesh(mesh));
}

/// A single tab in the chrome app tab strip: the app it represents, whether
/// it's the active tab, and any notification badge it wants to show.
struct ChromeTab<'a> {
    app: &'a mut NotedeckApp,
    selected: bool,
    notifications: TabNotifications,
}

impl ChromeTab<'_> {
    fn show(&mut self, loc: &mut Localization, ui: &mut egui::Ui) {
        ui.horizontal_centered(|ui| {
            ui.spacing_mut().item_spacing.x = 6.0;
            tab_app_icon(ui, self.app, 18.0);

            let txt = RichText::new(app_label(loc, self.app));
            let txt = if self.selected {
                txt
            } else {
                txt.color(ui.visuals().weak_text_color())
            };
            ui.add(Label::new(txt).selectable(false));

            tab_notification_badge(ui, self.notifications);
        });
    }
}

/// Render a small pill badge with the notification count, if any.
fn tab_notification_badge(ui: &mut egui::Ui, notifs: TabNotifications) {
    if notifs.is_empty() {
        return;
    }

    let label = if notifs.count > 99 {
        "99+".to_owned()
    } else {
        notifs.count.to_string()
    };

    let galley =
        ui.painter()
            .layout_no_wrap(label, egui::FontId::proportional(11.0), Color32::WHITE);

    let padding = vec2(5.0, 1.0);
    let size = galley.size() + padding * 2.0;
    let (rect, _) = ui.allocate_exact_size(size, Sense::hover());
    ui.painter()
        .rect_filled(rect, rect.height() / 2.0, notedeck_ui::colors::PINK);
    ui.painter()
        .galley(rect.center() - galley.size() / 2.0, galley, Color32::WHITE);
}

/// Chrome-browser-style tab strip across the top of the chrome frame. Shows one
/// tab per *opened* app (`Chrome::opened`); clicking a tab switches the active
/// app. New apps are opened from the left sidebar.
/// Horizontal space (logical points) to reserve at the left of the tab strip so
/// it isn't obscured by the macOS traffic-light window controls. These are only
/// drawn when we hide the native titlebar (the default, fullsize-content-view
/// mode); with `--title` the native titlebar owns the controls and no reserve is
/// needed. Returns 0 on other platforms, when the titlebar is shown, or in
/// fullscreen (where the controls are hidden).
fn macos_traffic_light_inset(ctx: &AppContext, ui: &egui::Ui) -> f32 {
    if cfg!(target_os = "macos")
        && !ctx.args.options.contains(NotedeckOptions::ShowTitle)
        && ui.input(|i| i.viewport().fullscreen) != Some(true)
    {
        72.0
    } else {
        0.0
    }
}

fn chrome_app_tabs(chrome: &mut Chrome, ctx: &mut AppContext, ui: &mut egui::Ui) {
    let inset = macos_traffic_light_inset(ctx, ui);

    // disjoint field borrows so the tab closure can read opened flags while
    // mutably rendering app icons (e.g. Dave's avatar)
    let opened = &chrome.opened;
    let apps = &mut chrome.apps;
    let n_apps = apps.len();

    // the active app is always opened, so there is always at least one tab
    let n_tabs = (0..n_apps)
        .filter(|&i| bitset_get(opened, i as u16))
        .count();
    if n_tabs == 0 {
        return;
    }

    // the selected tab is the number of opened apps before the active app
    let active = chrome.active.max(0) as usize;
    let sel = (0..active.min(n_apps))
        .filter(|&i| bitset_get(opened, i as u16))
        .count();

    ui.spacing_mut().item_spacing.y = 0.0;

    let mut render_tabs = |ui: &mut egui::Ui| {
        // egui_tabs keys its selection on `ui.id().with("tabs")` and prefers
        // that temp value over `.selected()`, so overwrite it each frame to
        // stay in sync with app switches that happen elsewhere (Ctrl+Tab, the
        // sidebar, note actions). It must be keyed off the *same* ui we hand to
        // `Tabs::show` — the traffic-light inset below wraps the strip in an
        // extra `ui.horizontal`, and writing to the outer id instead would
        // leave egui_tabs re-asserting the last-clicked tab every frame.
        let tabs_id = ui.id().with("tabs");
        ui.ctx().data_mut(|d| d.insert_temp(tabs_id, sel as i32));

        egui_tabs::Tabs::new(n_tabs as i32)
            .selected(sel as i32)
            .hover_bg(egui_tabs::TabColor::none())
            .selected_fg(egui_tabs::TabColor::none())
            .selected_bg(egui_tabs::TabColor::none())
            .height(30.0)
            .layout(Layout::centered_and_justified(egui::Direction::TopDown))
            .show(ui, |ui, state| {
                let Some(app_idx) = nth_opened(opened, n_apps, state.index() as usize) else {
                    return;
                };

                let notifications = apps[app_idx].tab_notifications(ctx);
                ChromeTab {
                    app: &mut apps[app_idx],
                    selected: state.is_selected(),
                    notifications,
                }
                .show(ctx.i18n, ui);
            })
    };

    // On macOS with the native titlebar hidden, indent the tab strip so the
    // traffic-light window controls don't sit on top of the first tab.
    let tab_res = if inset > 0.0 {
        ui.horizontal(|ui| {
            ui.spacing_mut().item_spacing.x = 0.0;
            ui.add_space(inset);
            render_tabs(ui)
        })
        .inner
    } else {
        render_tabs(ui)
    };

    tab_strip_fade(ui);

    // Switch on an actual click this frame rather than on the strip's current
    // selection. Reading the selection back would make the tab strip fight
    // every app switch that originates elsewhere (Ctrl+Tab, the sidebar, a
    // note action opening Headway) instead of merely reflecting it.
    let Some(clicked_tab) = tab_res.inner().iter().position(|r| r.response.clicked()) else {
        return;
    };

    let Some(app_idx) = nth_opened(&chrome.opened, chrome.apps.len(), clicked_tab) else {
        return;
    };

    chrome.set_active(app_idx as i32);
}

pub fn get_profile_url_owned(profile: Option<ProfileRecord<'_>>) -> &str {
    if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) {
        url
    } else {
        notedeck::profile::no_pfp_url()
    }
}

pub fn get_account_url<'a>(
    txn: &'a nostrdb::Transaction,
    ndb: &nostrdb::Ndb,
    account: &UserAccount,
) -> &'a str {
    if let Ok(profile) = ndb.get_profile_by_pubkey(txn, account.key.pubkey.bytes()) {
        get_profile_url_owned(Some(profile))
    } else {
        get_profile_url_owned(None)
    }
}

/// Whether `note_id` refers to a headway board/issue event, so a click on its
/// inline widget routes to the Headway app instead of the timeline.
#[cfg(feature = "headway")]
fn is_headway_note(ctx: &mut AppContext, note_id: notedeck::enostr::NoteId) -> bool {
    let Ok(txn) = Transaction::new(ctx.ndb) else {
        return false;
    };
    ctx.ndb
        .get_note_by_id(&txn, note_id.bytes())
        .map(|note| notedeck_headway::is_headway_kind(note.kind()))
        .unwrap_or(false)
}

fn chrome_handle_app_action(
    chrome: &mut Chrome,
    ctx: &mut AppContext,
    action: AppAction,
    ui: &mut egui::Ui,
) {
    match action {
        AppAction::ToggleChrome => {
            chrome.toggle();
        }

        AppAction::Note(note_action) => {
            // Intercept SummarizeThread — route to Dave instead of Columns
            #[cfg(feature = "dave")]
            if let notedeck::NoteAction::Context(ref context) = note_action {
                if let notedeck::NoteContextSelection::SummarizeThread(note_id) = context.action {
                    chrome.switch_to_dave();
                    if let Some(dave) = chrome.get_dave_app() {
                        dave.summarize_thread(note_id);
                    }
                    return;
                }
            }

            // Intercept a click on an inline headway board/issue widget — open it
            // in the Headway app rather than the timeline (see `is_headway_kind`).
            #[cfg(feature = "headway")]
            if let notedeck::NoteAction::Note { note_id, .. } = &note_action {
                if is_headway_note(ctx, *note_id) {
                    chrome.switch_to_headway();
                    if let Some(headway) = chrome.get_headway_app() {
                        headway.open(*note_id);
                    }
                    return;
                }
            }

            chrome.switch_to_columns();
            let Some(columns) = chrome.get_columns_app() else {
                return;
            };

            let txn = Transaction::new(ctx.ndb).unwrap();

            let cols = columns
                .decks_cache
                .active_columns_mut(ctx.i18n, ctx.accounts)
                .unwrap();
            let m_action = notedeck_columns::actionbar::execute_and_process_note_action(
                note_action,
                ctx.ndb,
                cols,
                0,
                &mut columns.timeline_cache,
                &mut columns.threads,
                ctx.note_cache,
                &mut ctx.remote,
                &txn,
                ctx.unknown_ids,
                ctx.accounts,
                ctx.global_wallet,
                ctx.zaps,
                ctx.img_cache,
                &mut columns.view_state,
                ctx.media_jobs.sender(),
                ui,
            );

            if let Some(action) = m_action {
                let col = cols.selected_mut();

                action.process_router_action(&mut col.router, &mut col.sheet_router, ctx.sound);
            }
        }
    }
}

fn columns_route_to_profile(
    pk: &notedeck::enostr::Pubkey,
    chrome: &mut Chrome,
    ctx: &mut AppContext,
    ui: &mut egui::Ui,
) {
    chrome.switch_to_columns();
    let Some(columns) = chrome.get_columns_app() else {
        return;
    };

    let cols = columns
        .decks_cache
        .active_columns_mut(ctx.i18n, ctx.accounts)
        .unwrap();

    let router = cols.get_selected_router();
    if router.routes().iter().any(|r| {
        matches!(
            r,
            notedeck_columns::Route::Timeline(TimelineKind::Profile(_))
        )
    }) {
        router.go_back();
        return;
    }

    let txn = Transaction::new(ctx.ndb).unwrap();
    let m_action = notedeck_columns::actionbar::execute_and_process_note_action(
        notedeck::NoteAction::Profile(*pk),
        ctx.ndb,
        cols,
        0,
        &mut columns.timeline_cache,
        &mut columns.threads,
        ctx.note_cache,
        &mut ctx.remote,
        &txn,
        ctx.unknown_ids,
        ctx.accounts,
        ctx.global_wallet,
        ctx.zaps,
        ctx.img_cache,
        &mut columns.view_state,
        ctx.media_jobs.sender(),
        ui,
    );

    if let Some(action) = m_action {
        let col = cols.selected_mut();

        action.process_router_action(&mut col.router, &mut col.sheet_router, ctx.sound);
    }
}

/// The section of the chrome sidebar that starts at the
/// bottom and goes up
fn topdown_sidebar(
    chrome: &mut Chrome,
    ctx: &mut AppContext,
    ui: &mut egui::Ui,
    options: SidebarOptions,
) -> Option<ChromePanelAction> {
    let previous_spacing = ui.spacing().item_spacing;
    ui.spacing_mut().item_spacing.y = 12.0;

    let loc = &mut ctx.i18n;

    // macos needs a bit of space to make room for window
    // minimize/close buttons
    if cfg!(target_os = "macos") {
        ui.add_space(8.0);
    }

    let txn = Transaction::new(ctx.ndb).expect("should be able to create txn");
    let profile = ctx
        .ndb
        .get_profile_by_pubkey(&txn, ctx.accounts.get_selected_account().key.pubkey.bytes());

    let disp_name = get_display_name(profile.as_ref().ok());
    let name = if let Some(username) = disp_name.username {
        format!("@{username}")
    } else {
        disp_name.username_or_displayname().to_owned()
    };

    let selected_acc = ctx.accounts.get_selected_account();
    let profile_url = get_account_url(&txn, ctx.ndb, selected_acc);
    if let Ok(profile) = profile {
        get_profile_url_owned(Some(profile))
    } else {
        get_profile_url_owned(None)
    };

    let pfp_resp = ui
        .add(&mut ProfilePic::new(ctx.img_cache, ctx.media_jobs.sender(), profile_url).size(64.0));

    ui.horizontal_wrapped(|ui| {
        ui.add(egui::Label::new(
            RichText::new(name)
                .color(ui.visuals().weak_text_color())
                .size(16.0),
        ));
    });

    if let Some(npub) = selected_acc.key.pubkey.npub() {
        if ui.add(copy_npub(&npub, 200.0)).clicked() {
            ui.ctx().copy_text(npub);
        }
    }

    // we skip this whole function in compact mode
    if options.contains(SidebarOptions::Compact) {
        return if pfp_resp.clicked() {
            Some(ChromePanelAction::Profile(
                ctx.accounts.get_selected_account().key.pubkey,
            ))
        } else {
            None
        };
    }

    let mut action = None;

    #[cfg(feature = "auto-update")]
    if let Some(update_action) = update_sidebar_item_ui(&chrome.updater, ui) {
        action = Some(update_action);
    }

    let theme = ui.ctx().theme();

    StripBuilder::new(ui)
        .sizes(Size::exact(40.0), 6)
        .clip(true)
        .vertical(|mut strip| {
            strip.strip(|b| {
                if drawer_item(
                    b,
                    |ui| {
                        let profile_img = if ui.visuals().dark_mode {
                            app_images::profile_image()
                        } else {
                            app_images::profile_image().tint(ui.visuals().text_color())
                        }
                        .max_size(ui.available_size());
                        ui.add(profile_img);
                    },
                    tr!(loc, "Profile", "Button to go to the user's profile"),
                )
                .clicked()
                {
                    action = Some(ChromePanelAction::Profile(
                        ctx.accounts.get_selected_account().key.pubkey,
                    ));
                }
            });

            strip.strip(|b| {
                if drawer_item(
                    b,
                    |ui| {
                        let account_img = if ui.visuals().dark_mode {
                            app_images::accounts_image()
                        } else {
                            app_images::accounts_image().tint(ui.visuals().text_color())
                        }
                        .max_size(ui.available_size());
                        ui.add(account_img);
                    },
                    tr!(loc, "Accounts", "Button to go to the accounts view"),
                )
                .clicked()
                {
                    action = Some(ChromePanelAction::Account);
                }
            });

            strip.strip(|b| {
                if drawer_item(
                    b,
                    |ui| {
                        let img = if ui.visuals().dark_mode {
                            app_images::wallet_dark_image()
                        } else {
                            app_images::wallet_light_image()
                        };

                        ui.add(img);
                    },
                    tr!(loc, "Wallet", "Button to go to the wallet view"),
                )
                .clicked()
                {
                    action = Some(ChromePanelAction::Wallet);
                }
            });

            strip.strip(|b| {
                if drawer_item(
                    b,
                    |ui| {
                        ui.add(if ui.visuals().dark_mode {
                            app_images::settings_dark_image()
                        } else {
                            app_images::settings_light_image()
                        });
                    },
                    tr!(loc, "Settings", "Button to go to the settings view"),
                )
                .clicked()
                {
                    action = Some(ChromePanelAction::Settings);
                }
            });

            strip.strip(|b| {
                if drawer_item(
                    b,
                    |ui| {
                        let c = match theme {
                            egui::Theme::Dark => "🔆",
                            egui::Theme::Light => "🌒",
                        };

                        let painter = ui.painter();
                        let galley = painter.layout_no_wrap(
                            c.to_owned(),
                            NotedeckTextStyle::Heading3.get_font_id(ui.ctx()),
                            ui.visuals().text_color(),
                        );

                        painter.galley(
                            galley_centered_pos(&galley, ui.available_rect_before_wrap().center()),
                            galley,
                            ui.visuals().text_color(),
                        );
                    },
                    tr!(loc, "Theme", "Button to change the theme (light or dark)"),
                )
                .clicked()
                {
                    match theme {
                        egui::Theme::Dark => {
                            action = Some(ChromePanelAction::SaveTheme(ThemePreference::Light));
                        }
                        egui::Theme::Light => {
                            action = Some(ChromePanelAction::SaveTheme(ThemePreference::Dark));
                        }
                    }
                }
            });

            strip.strip(|b| {
                if drawer_item(
                    b,
                    |ui| {
                        ui.add(if ui.visuals().dark_mode {
                            app_images::help_dark_image()
                        } else {
                            app_images::help_light_image()
                        });
                    },
                    tr!(loc, "Support", "Button to go to the support view"),
                )
                .clicked()
                {
                    action = Some(ChromePanelAction::Support);
                }
            });
        });

    // Scroll the app list so it doesn't overflow the sidebar as more apps
    // are added. Reserve a bit of space at the bottom for the milestone label.
    let apps_scroll_height = (ui.available_height() - 32.0).max(0.0);
    egui::ScrollArea::vertical()
        .auto_shrink([false, true])
        .max_height(apps_scroll_height)
        .show(ui, |ui| {
            // `set_active` needs `&mut chrome`, which the icon loop already
            // borrows mutably — record the click and apply it after the loop.
            let mut clicked_ind: Option<i32> = None;

            for (i, app) in chrome.apps.iter_mut().enumerate() {
                if chrome.active == i as i32 {
                    continue;
                }

                let text = app_label(loc, app);

                StripBuilder::new(ui)
                    .size(Size::exact(40.0))
                    .clip(true)
                    .vertical(|mut strip| {
                        strip.strip(|b| {
                            let resp = drawer_item(
                                b,
                                |ui| match app {
                                    NotedeckApp::Columns(_columns_app) => {
                                        ui.add(app_images::columns_image());
                                    }

                                    #[cfg(feature = "dave")]
                                    NotedeckApp::Dave(dave) => {
                                        dave_button(
                                            dave.avatar_mut(),
                                            ui,
                                            Rect::from_center_size(
                                                ui.available_rect_before_wrap().center(),
                                                vec2(30.0, 30.0),
                                            ),
                                        );
                                    }

                                    #[cfg(feature = "dashboard")]
                                    NotedeckApp::Dashboard(_columns_app) => {
                                        notedeck_ui::icons::dashboard_icon(ui, 24.0);
                                    }

                                    #[cfg(feature = "horizon")]
                                    NotedeckApp::Horizon(_horizon) => {
                                        notedeck_ui::icons::horizon_icon(ui, 24.0);
                                    }

                                    #[cfg(feature = "messages")]
                                    NotedeckApp::Messages(_dms) => {
                                        notedeck_ui::icons::messages_icon(ui, 24.0);
                                    }

                                    #[cfg(feature = "clndash")]
                                    NotedeckApp::ClnDash(_clndash) => {
                                        clndash_button(ui);
                                    }

                                    #[cfg(feature = "notebook")]
                                    NotedeckApp::Notebook(_notebook) => {
                                        notedeck_ui::icons::notebook_icon(ui, 24.0);
                                    }

                                    #[cfg(feature = "headway")]
                                    NotedeckApp::Headway(_headway) => {
                                        notedeck_ui::icons::headway_icon(ui, 24.0);
                                    }

                                    #[cfg(feature = "nostrverse")]
                                    NotedeckApp::Nostrverse(_nostrverse) => {
                                        ui.add(app_images::universe_image());
                                    }

                                    NotedeckApp::Other(_name, _other) => {
                                        ui.label("W");
                                    }
                                },
                                text,
                            )
                            .on_hover_cursor(egui::CursorIcon::PointingHand);

                            if resp.clicked() {
                                clicked_ind = Some(i as i32);
                                chrome.nav.close();
                            }
                        })
                    });
            }

            if let Some(i) = clicked_ind {
                chrome.set_active(i);
            }
        });

    if ctx.args.options.contains(NotedeckOptions::Debug) {
        let r = ui
            .weak(format!("{}", ctx.frame_history.fps() as i32))
            .union(ui.weak(format!(
                "{:10.1}",
                ctx.frame_history.mean_frame_time() * 1e3
            )))
            .on_hover_cursor(egui::CursorIcon::PointingHand);

        if r.clicked() {
            chrome.options.toggle(ChromeOptions::RepaintDebug);
        }

        if chrome.options.contains(ChromeOptions::RepaintDebug) {
            for cause in ui.ctx().repaint_causes() {
                chrome
                    .repaint_causes
                    .entry(cause)
                    .and_modify(|rc| {
                        *rc += 1;
                    })
                    .or_insert(1);
            }
            repaint_causes_window(ui, &chrome.repaint_causes)
        }

        #[cfg(feature = "memory")]
        {
            let mem_use = re_memory::MemoryUse::capture();
            if let Some(counted) = mem_use.counted {
                if ui
                    .label(format!("{}", format_bytes(counted as f64)))
                    .on_hover_cursor(egui::CursorIcon::PointingHand)
                    .clicked()
                {
                    chrome.options.toggle(ChromeOptions::MemoryDebug);
                }
            }
            if let Some(resident) = mem_use.resident {
                ui.weak(format!("{}", format_bytes(resident as f64)));
            }

            if chrome.options.contains(ChromeOptions::MemoryDebug) {
                egui::Window::new("Memory Debug").show(ui.ctx(), memory_debug_ui);
            }
        }
    }

    ui.spacing_mut().item_spacing = previous_spacing;

    action
}

fn drawer_item(builder: StripBuilder, icon: impl FnOnce(&mut Ui), text: String) -> egui::Response {
    builder
        .cell_layout(Layout::left_to_right(egui::Align::Center))
        .sense(Sense::click())
        .size(Size::exact(24.0))
        .size(Size::exact(8.0)) // free space
        .size(Size::remainder())
        .horizontal(|mut strip| {
            strip.cell(icon);

            strip.empty();

            strip.cell(|ui| {
                ui.add(drawer_label(ui.ctx(), &text));
            });
        })
        .on_hover_cursor(egui::CursorIcon::PointingHand)
}

fn drawer_label(ctx: &egui::Context, text: &str) -> egui::Label {
    egui::Label::new(RichText::new(text).size(get_font_size(ctx, &NotedeckTextStyle::Heading2)))
        .selectable(false)
}

fn copy_npub<'a>(npub: &'a String, width: f32) -> impl Widget + use<'a> {
    move |ui: &mut egui::Ui| -> egui::Response {
        let size = vec2(width, 24.0);
        let (rect, mut resp) = ui.allocate_exact_size(size, egui::Sense::click());
        resp = resp.on_hover_cursor(egui::CursorIcon::Copy);

        let painter = ui.painter_at(rect);

        painter.rect_filled(
            rect,
            CornerRadius::same(32),
            if resp.hovered() {
                ui.visuals().widgets.active.bg_fill
            } else {
                // ui.visuals().panel_fill
                ui.visuals().widgets.inactive.bg_fill
            },
        );

        let text =
            Label::new(RichText::new(npub).size(get_font_size(ui.ctx(), &NotedeckTextStyle::Tiny)))
                .truncate()
                .selectable(false);

        let (label_rect, copy_rect) = {
            let rect = rect.shrink(4.0);
            let (l, r) = rect.split_left_right_at_x(rect.right() - 24.0);
            (l, r.shrink2(vec2(4.0, 0.0)))
        };

        app_images::copy_to_clipboard_image()
            .tint(ui.visuals().text_color())
            .maintain_aspect_ratio(true)
            // .max_size(vec2(24.0, 24.0))
            .paint_at(ui, copy_rect);

        ui.put(label_rect, text);

        resp
    }
}

#[cfg(feature = "memory")]
fn memory_debug_ui(ui: &mut egui::Ui) {
    let Some(stats) = &re_memory::accounting_allocator::tracking_stats() else {
        ui.label("re_memory::accounting_allocator::set_tracking_callstacks(true); not set!!");
        return;
    };

    egui::ScrollArea::vertical().show(ui, |ui| {
        ui.label(format!(
            "track_size_threshold {}",
            stats.track_size_threshold
        ));
        ui.label(format!(
            "untracked {} {}",
            stats.untracked.count,
            format_bytes(stats.untracked.size as f64)
        ));
        ui.label(format!(
            "stochastically_tracked {} {}",
            stats.stochastically_tracked.count,
            format_bytes(stats.stochastically_tracked.size as f64),
        ));
        ui.label(format!(
            "fully_tracked {} {}",
            stats.fully_tracked.count,
            format_bytes(stats.fully_tracked.size as f64)
        ));
        ui.label(format!(
            "overhead {} {}",
            stats.overhead.count,
            format_bytes(stats.overhead.size as f64)
        ));

        ui.separator();

        for (i, callstack) in stats.top_callstacks.iter().enumerate() {
            let full_bt = format!("{}", callstack.readable_backtrace);
            let mut lines = full_bt.lines().skip(5);
            let bt_header = lines.nth(0).map_or("??", |v| v);
            let header = format!(
                "#{} {bt_header} {}x {}",
                i + 1,
                callstack.extant.count,
                format_bytes(callstack.extant.size as f64)
            );

            egui::CollapsingHeader::new(header)
                .id_salt(("mem_cs", i))
                .show(ui, |ui| {
                    ui.label(lines.collect::<Vec<_>>().join("\n"));
                });
        }
    });
}

/// Pretty format a number of bytes by using SI notation (base2), e.g.
///
/// ```
/// # use re_format::format_bytes;
/// assert_eq!(format_bytes(123.0), "123 B");
/// assert_eq!(format_bytes(12_345.0), "12.1 KiB");
/// assert_eq!(format_bytes(1_234_567.0), "1.2 MiB");
/// assert_eq!(format_bytes(123_456_789.0), "118 MiB");
/// ```
#[cfg(feature = "memory")]
pub fn format_bytes(number_of_bytes: f64) -> String {
    /// The minus character: <https://www.compart.com/en/unicode/U+2212>
    /// Looks slightly different from the normal hyphen `-`.
    const MINUS: char = '−';

    if number_of_bytes < 0.0 {
        format!("{MINUS}{}", format_bytes(-number_of_bytes))
    } else if number_of_bytes == 0.0 {
        "0 B".to_owned()
    } else if number_of_bytes < 1.0 {
        format!("{number_of_bytes} B")
    } else if number_of_bytes < 20.0 {
        let is_integer = number_of_bytes.round() == number_of_bytes;
        if is_integer {
            format!("{number_of_bytes:.0} B")
        } else {
            format!("{number_of_bytes:.1} B")
        }
    } else if number_of_bytes < 10.0_f64.exp2() {
        format!("{number_of_bytes:.0} B")
    } else if number_of_bytes < 20.0_f64.exp2() {
        let decimals = (10.0 * number_of_bytes < 20.0_f64.exp2()) as usize;
        format!("{:.*} KiB", decimals, number_of_bytes / 10.0_f64.exp2())
    } else if number_of_bytes < 30.0_f64.exp2() {
        let decimals = (10.0 * number_of_bytes < 30.0_f64.exp2()) as usize;
        format!("{:.*} MiB", decimals, number_of_bytes / 20.0_f64.exp2())
    } else {
        let decimals = (10.0 * number_of_bytes < 40.0_f64.exp2()) as usize;
        format!("{:.*} GiB", decimals, number_of_bytes / 30.0_f64.exp2())
    }
}

fn repaint_causes_window(ui: &mut egui::Ui, causes: &HashMap<egui::RepaintCause, u64>) {
    egui::Window::new("Repaint Causes").show(ui.ctx(), |ui| {
        use egui_extras::{Column, TableBuilder};
        TableBuilder::new(ui)
            .column(Column::auto().at_least(600.0).resizable(true))
            .column(Column::auto().at_least(50.0).resizable(true))
            .column(Column::auto().at_least(50.0).resizable(true))
            .column(Column::remainder())
            .header(20.0, |mut header| {
                header.col(|ui| {
                    ui.heading("file");
                });
                header.col(|ui| {
                    ui.heading("line");
                });
                header.col(|ui| {
                    ui.heading("count");
                });
                header.col(|ui| {
                    ui.heading("reason");
                });
            })
            .body(|mut body| {
                for (cause, hits) in causes.iter() {
                    body.row(30.0, |mut row| {
                        row.col(|ui| {
                            ui.label(cause.file.to_string());
                        });
                        row.col(|ui| {
                            ui.label(format!("{}", cause.line));
                        });
                        row.col(|ui| {
                            ui.label(format!("{hits}"));
                        });
                        row.col(|ui| {
                            ui.label(format!("{}", cause.reason));
                        });
                    });
                }
            });
    });
}

fn virtual_keyboard_ui(ui: &mut egui::Ui, rect: egui::Rect) {
    let painter = ui.painter_at(rect);

    painter.rect_filled(rect, 0.0, Color32::from_black_alpha(200));

    ui.put(rect, |ui: &mut egui::Ui| {
        ui.centered_and_justified(|ui| {
            ui.label("This is a keyboard");
        })
        .response
    });
}

struct SoftKeyboardAnim {
    skb_rect: Option<Rect>,
    anim_height: f32,
}

#[derive(Copy, Default, Clone, Eq, PartialEq, Debug)]
enum AnimState {
    /// It finished opening
    Opened,

    /// We started to open
    StartOpen,

    /// We started to close
    StartClose,

    /// We finished openning
    FinishedOpen,

    /// We finished to close
    FinishedClose,

    /// It finished closing
    #[default]
    Closed,

    /// We are animating towards open
    Opening,

    /// We are animating towards close
    Closing,
}

impl SoftKeyboardAnim {
    /// Advance the FSM based on current (anim_height) vs target (skb_rect.height()).
    /// Start*/Finished* are one-tick edge states used for signaling.
    fn changed(&self, state: AnimState) -> AnimState {
        const EPS: f32 = 0.01;

        let target = self.skb_rect.map_or(0.0, |r| r.height());
        let current = self.anim_height;

        let done = (current - target).abs() <= EPS;
        let going_up = target > current + EPS;
        let going_down = current > target + EPS;
        let target_is_closed = target <= EPS;

        match state {
            // Resting states: emit a Start* edge only when a move is requested,
            // and pick direction by the sign of (target - current).
            AnimState::Opened => {
                if done {
                    AnimState::Opened
                } else if going_up {
                    AnimState::StartOpen
                } else {
                    AnimState::StartClose
                }
            }
            AnimState::Closed => {
                if done {
                    AnimState::Closed
                } else if going_up {
                    AnimState::StartOpen
                } else {
                    AnimState::StartClose
                }
            }

            // Edge → flow
            AnimState::StartOpen => AnimState::Opening,
            AnimState::StartClose => AnimState::Closing,

            // Flow states: finish when we hit the target; if the target jumps across,
            // emit the opposite Start* to signal a reversal.
            AnimState::Opening => {
                if done {
                    if target_is_closed {
                        AnimState::FinishedClose
                    } else {
                        AnimState::FinishedOpen
                    }
                } else if going_down {
                    // target moved below current mid-flight → reversal
                    AnimState::StartClose
                } else {
                    AnimState::Opening
                }
            }
            AnimState::Closing => {
                if done {
                    if target_is_closed {
                        AnimState::FinishedClose
                    } else {
                        AnimState::FinishedOpen
                    }
                } else if going_up {
                    // target moved above current mid-flight → reversal
                    AnimState::StartOpen
                } else {
                    AnimState::Closing
                }
            }

            // Finish edges collapse to the stable resting states on the next tick.
            AnimState::FinishedOpen => AnimState::Opened,
            AnimState::FinishedClose => AnimState::Closed,
        }
    }
}

/// How "open" the softkeyboard is. This is an animated value
fn soft_keyboard_anim(
    ui: &mut egui::Ui,
    ctx: &mut AppContext,
    chrome_options: &mut ChromeOptions,
) -> SoftKeyboardAnim {
    let skb_ctx = if chrome_options.contains(ChromeOptions::VirtualKeyboard) {
        SoftKeyboardContext::Virtual
    } else {
        SoftKeyboardContext::Platform {
            ppp: ui.ctx().pixels_per_point(),
        }
    };

    // move screen up if virtual keyboard intersects with input_rect
    let screen_rect = ui.ctx().screen_rect();
    let mut skb_rect: Option<Rect> = None;

    let keyboard_height =
        if let Some(vkb_rect) = ctx.soft_keyboard_rect(screen_rect, skb_ctx.clone()) {
            skb_rect = Some(vkb_rect);
            vkb_rect.height()
        } else {
            0.0
        };

    let anim_height =
        ui.ctx()
            .animate_value_with_time(egui::Id::new("keyboard_anim"), keyboard_height, 0.1);

    SoftKeyboardAnim {
        anim_height,
        skb_rect,
    }
}

fn try_toggle_virtual_keyboard(
    ctx: &egui::Context,
    options: NotedeckOptions,
    chrome_options: &mut ChromeOptions,
) {
    // handle virtual keyboard toggle here because why not
    if options.contains(NotedeckOptions::Debug) && ctx.input(|i| i.key_pressed(egui::Key::F1)) {
        chrome_options.toggle(ChromeOptions::VirtualKeyboard);
    }
}

/// All the logic which handles our keyboard visibility
fn keyboard_visibility(
    ui: &mut egui::Ui,
    ctx: &mut AppContext,
    options: &mut ChromeOptions,
    soft_kb_anim_state: &mut AnimState,
) -> SoftKeyboardAnim {
    try_toggle_virtual_keyboard(ui.ctx(), ctx.args.options, options);

    let soft_kb_anim = soft_keyboard_anim(ui, ctx, options);

    let prev_state = *soft_kb_anim_state;
    let current_state = soft_kb_anim.changed(prev_state);
    *soft_kb_anim_state = current_state;

    if prev_state != current_state {
        tracing::debug!("soft kb state {prev_state:?} -> {current_state:?}");
    }

    match current_state {
        // we finished
        AnimState::FinishedOpen => {}

        // on first open, we setup our scroll target
        AnimState::StartOpen => {
            // when we first open the keyboard, check to see if the target soft
            // keyboard rect (the height at full open) intersects with any
            // input response rects from last frame
            //
            // If we do, then we set a bit that we need keyboard visibility.
            // We will use this bit to resize the screen based on the soft
            // keyboard animation state
            if let Some(skb_rect) = soft_kb_anim.skb_rect {
                if let Some(input_rect) = notedeck_ui::input_rect(ui) {
                    options.set(
                        ChromeOptions::KeyboardVisibility,
                        input_rect.intersects(skb_rect),
                    )
                }
            }
        }

        AnimState::FinishedClose => {
            // clear last input box position state
            notedeck_ui::clear_input_rect(ui);
        }

        AnimState::Closing => {}
        AnimState::Opened => {}
        AnimState::Closed => {}
        AnimState::Opening => {}
        AnimState::StartClose => {}
    };

    soft_kb_anim
}

/// The next set flag after `flag`, walking forwards (or backwards when
/// `forward` is false) and wrapping around the end of the bitset. `flag` itself
/// is only returned when it is the sole set flag, since the walk starts one step
/// away and comes all the way back. Returns `None` when nothing is set.
fn bitset_find<const N: usize>(set: &[u16; N], flag: u16, forward: bool) -> Option<u16> {
    let flag_count = (N * 16) as u32;
    let start = u32::from(flag);

    assert!(start < flag_count, "flag is outside the bitset");

    for offset in 1..=flag_count {
        let candidate = if forward {
            (start + offset) % flag_count
        } else {
            (start + flag_count - offset) % flag_count
        } as u16;

        if bitset_get(set, candidate) {
            return Some(candidate);
        }
    }

    None
}

#[cfg(test)]
mod tab_cycle_tests {
    use super::{bitset_find, MAX_APPS};
    use oot_bitset::bitset_set;

    const FORWARD: bool = true;
    const BACK: bool = false;

    fn bitset(flags: &[u16]) -> [u16; MAX_APPS / 16] {
        let mut set = [0u16; MAX_APPS / 16];
        for &f in flags {
            bitset_set(&mut set, f);
        }
        set
    }

    #[test]
    fn cycles_forward_and_wraps() {
        let set = bitset(&[0, 1, 6]);
        assert_eq!(bitset_find(&set, 0, FORWARD), Some(1));
        assert_eq!(bitset_find(&set, 1, FORWARD), Some(6));
        assert_eq!(bitset_find(&set, 6, FORWARD), Some(0));
    }

    #[test]
    fn cycles_backward_and_wraps() {
        let set = bitset(&[0, 1, 6]);
        assert_eq!(bitset_find(&set, 6, BACK), Some(1));
        assert_eq!(bitset_find(&set, 1, BACK), Some(0));
        assert_eq!(bitset_find(&set, 0, BACK), Some(6));
    }

    #[test]
    fn cycling_from_an_unset_flag_still_finds_neighbours() {
        // the active app is always opened, but a stale `active` must not
        // strand the cycle
        let set = bitset(&[0, 1, 6]);
        assert_eq!(bitset_find(&set, 3, FORWARD), Some(6));
        assert_eq!(bitset_find(&set, 3, BACK), Some(1));
    }

    #[test]
    fn lone_flag_cycles_to_itself_and_empty_finds_nothing() {
        assert_eq!(bitset_find(&bitset(&[2]), 2, FORWARD), Some(2));
        assert_eq!(bitset_find(&bitset(&[]), 0, FORWARD), None);
    }
}
