//! Split-pane TUI for `node-app dev`.
//!
//! # Channel architecture
//!
//! ```text
//! ┌─ producers ──────────────────────────────────────────────────┐
//! │  orchestrator  ──────────────────────────────────────┐       │
//! │  daemon reader threads  ─────────────────────────────┤       │
//! │  ui-server reader threads  ──────────────────────────┤       │
//! │  build capture threads  ─────────────────────────────┤       │
//! └──────────────────────────────────────────────────────▼───────┘
//!                                                SyncSender<TuiEvent>
//!                                                        │
//!                                               TUI render thread
//!                                            (drains rx, draws frame)
//! ```
//!
//! Back-pressure (keyboard → orchestrator) uses `Arc<AtomicBool>` flags so
//! the orchestrator can poll cheaply inside the debounce loop.

pub mod render;
pub mod state;

use std::io;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{mpsc, Arc};
use std::time::{Duration, Instant};

use crossterm::{
    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
    execute,
    terminal::{self, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, Terminal};

pub use state::{
    BuildScope, InfraChannelEntry, InfraGraphData, InfraHealthStatus, InfraLogLine,
    InfraLogService, InfraServiceHealth, InfraViewState, LogSource, ServiceStatus,
};

const TICK: Duration = Duration::from_millis(30);

// ── Public types ─────────────────────────────────────────────────────────────

#[derive(Debug)]
pub struct LogEntry {
    pub source: LogSource,
    pub line: String,
}

/// All events flowing from producers into the TUI render thread.
#[derive(Debug)]
pub enum TuiEvent {
    Log(LogEntry),
    Status {
        source: LogSource,
        status: ServiceStatus,
        detail: Option<String>,
    },
    InfraHealth(Vec<state::InfraServiceHealth>),
    InfraLog(state::InfraLogLine),
    InfraGraph(state::InfraGraphData),
    InfraDb(state::InfraDbSummary),
    InfraSnapshot(state::InfraSnapshotInfo),
    /// Platform-mode: status update for one node-app in the sidebar app list.
    AppStatus {
        name: String,
        status: ServiceStatus,
        detail: Option<String>,
    },
    /// Platform-mode: resolved source path for one node-app. Sent once per
    /// app after sibling/cache/clone resolution so the sidebar and log-pane
    /// title can show where each app's source lives on disk.
    AppPath {
        name: String,
        path: std::path::PathBuf,
    },
    /// Generated env file path for an instance's daemon. Sent once by
    /// `MonorepoHost` after writing the env file so the TUI can surface it
    /// in the services-box title and Daemon log-pane title.
    DaemonEnvFile {
        instance: String,
        path: std::path::PathBuf,
    },
}

pub type LogTx = mpsc::SyncSender<TuiEvent>;
pub type LogRx = mpsc::Receiver<TuiEvent>;

/// Signals written by the TUI keyboard handler and read by the orchestrator,
/// plus one signal written by the orchestrator and read by the TUI.
#[derive(Clone)]
pub struct DevSignals {
    pub restart_requested: Arc<AtomicBool>,
    /// Scope selected from the rebuild popup. 0 = nothing pending; otherwise
    /// holds a `BuildScope` discriminant. `AtomicU8` keeps the signal
    /// lock-free so polling stays cheap inside the orchestrator loop.
    pub build_scope_requested: Arc<std::sync::atomic::AtomicU8>,
    /// Set by TUI on q / Ctrl+C — tells orchestrator to start shutting down.
    pub quit_requested: Arc<AtomicBool>,
    /// Set by orchestrator once host_impl.shutdown() fully completes — tells
    /// the TUI it is safe to restore the terminal and exit.
    pub shutdown_complete: Arc<AtomicBool>,
}

impl DevSignals {
    fn new() -> Self {
        Self {
            restart_requested: Arc::new(AtomicBool::new(false)),
            build_scope_requested: Arc::new(std::sync::atomic::AtomicU8::new(0)),
            quit_requested: Arc::new(AtomicBool::new(false)),
            shutdown_complete: Arc::new(AtomicBool::new(false)),
        }
    }

    /// Returns true and clears the flag if a restart was requested.
    pub fn take_restart(&self) -> bool {
        self.restart_requested.swap(false, Ordering::SeqCst)
    }

    /// Returns the pending build scope (and clears it) if the popup was
    /// confirmed since the last poll.
    pub fn take_build_scope(&self) -> Option<state::BuildScope> {
        let v = self.build_scope_requested.swap(0, Ordering::SeqCst);
        state::BuildScope::from_u8(v)
    }

    pub fn should_quit(&self) -> bool {
        self.quit_requested.load(Ordering::SeqCst)
    }

    /// Called by the orchestrator after shutdown is complete.
    pub fn mark_shutdown_complete(&self) {
        self.shutdown_complete.store(true, Ordering::SeqCst);
    }
}

// ── Setup ────────────────────────────────────────────────────────────────────

/// Create channels + signals. Returns `(producer tx, consumer rx, signals)`.
pub fn setup() -> (LogTx, LogRx, DevSignals) {
    let (tx, rx) = mpsc::sync_channel(512);
    (tx, rx, DevSignals::new())
}

/// Returns true when stdout is a real TTY (i.e. TUI should be enabled).
pub fn is_tty() -> bool {
    use std::io::IsTerminal;
    std::io::stdout().is_terminal()
}

// ── Helpers for producers ────────────────────────────────────────────────────

/// Send a System-level log line, or fall back to `eprintln!` when no sink.
pub fn sys_log(tx: Option<&LogTx>, line: impl Into<String>) {
    match tx {
        Some(t) => {
            let _ = t.send(TuiEvent::Log(LogEntry {
                source: LogSource::System,
                line: line.into(),
            }));
        }
        None => eprintln!("{}", line.into()),
    }
}

/// Send a service-status update.
pub fn update_status(
    tx: Option<&LogTx>,
    source: LogSource,
    status: ServiceStatus,
    detail: Option<String>,
) {
    if let Some(t) = tx {
        let _ = t.send(TuiEvent::Status {
            source,
            status,
            detail,
        });
    }
}

/// Platform-mode: send an app-status update. The TUI sidebar's "Apps" section
/// reflects whichever statuses arrive here.
pub fn update_app_status(
    tx: Option<&LogTx>,
    name: impl Into<String>,
    status: ServiceStatus,
    detail: Option<String>,
) {
    if let Some(t) = tx {
        let _ = t.send(TuiEvent::AppStatus {
            name: name.into(),
            status,
            detail,
        });
    }
}

/// Platform-mode: publish the resolved source path for an app. Sent once
/// after sources_resolver finishes, before staging starts.
pub fn update_app_path(
    tx: Option<&LogTx>,
    name: impl Into<String>,
    path: std::path::PathBuf,
) {
    if let Some(t) = tx {
        let _ = t.send(TuiEvent::AppPath {
            name: name.into(),
            path,
        });
    }
}

/// Publish the env file path the daemon was launched with for an instance.
/// Sent once by `MonorepoHost::ensure_running()` after writing the file.
pub fn update_daemon_env_file(
    tx: Option<&LogTx>,
    instance: impl Into<String>,
    path: std::path::PathBuf,
) {
    if let Some(t) = tx {
        let _ = t.send(TuiEvent::DaemonEnvFile {
            instance: instance.into(),
            path,
        });
    }
}

// ── TUI render loop ──────────────────────────────────────────────────────────

/// Run the TUI. Blocks until the user presses `q` / `Ctrl+C`.
/// Call from a dedicated thread; the main thread continues running the
/// orchestrator while this thread owns the terminal.
pub fn run(rx: LogRx, mut app: state::AppState, signals: DevSignals) -> io::Result<()> {
    terminal::enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;

    let result = render_loop(&mut terminal, rx, &mut app, &signals);

    // Disable mouse tracking before leaving the alternate screen.
    // Reversing this order causes mouse events buffered between LeaveAlternateScreen
    // and DisableMouseCapture to be printed as raw escape sequences in the shell.
    execute!(
        terminal.backend_mut(),
        DisableMouseCapture,
        LeaveAlternateScreen,
    )?;
    terminal::disable_raw_mode()?;
    terminal.show_cursor()?;
    result
}

fn render_loop<B: ratatui::backend::Backend>(
    terminal: &mut Terminal<B>,
    rx: LogRx,
    app: &mut state::AppState,
    signals: &DevSignals,
) -> io::Result<()> {
    use state::ShutdownPhase;
    let mut last_tick = Instant::now();
    loop {
        // Drain all pending events (non-blocking).
        loop {
            match rx.try_recv() {
                Ok(TuiEvent::Log(entry)) => app.push_log(entry),
                Ok(TuiEvent::Status {
                    source,
                    status,
                    detail,
                }) => app.update_service(source, status, detail),
                Ok(TuiEvent::InfraHealth(s)) => {
                    if let Some(ref mut iv) = app.infra {
                        iv.services = s;
                        iv.last_refresh = Some(std::time::Instant::now());
                    }
                }
                Ok(TuiEvent::InfraLog(e)) => {
                    if let Some(ref mut iv) = app.infra {
                        iv.push_log(e);
                    }
                }
                Ok(TuiEvent::InfraGraph(g)) => {
                    if let Some(ref mut iv) = app.infra {
                        iv.graph = Some(g);
                        iv.graph_loading = false;
                    }
                }
                Ok(TuiEvent::InfraDb(d)) => {
                    if let Some(ref mut iv) = app.infra {
                        iv.db = Some(d);
                        iv.db_loading = false;
                    }
                }
                Ok(TuiEvent::InfraSnapshot(s)) => {
                    if let Some(ref mut iv) = app.infra {
                        iv.snapshot = Some(s);
                        iv.snapshot_loading = false;
                    }
                }
                Ok(TuiEvent::AppStatus { name, status, detail }) => {
                    app.set_app_status(&name, status, detail);
                }
                Ok(TuiEvent::AppPath { name, path }) => {
                    app.set_app_path(&name, path);
                }
                Ok(TuiEvent::DaemonEnvFile { instance, path }) => {
                    app.set_daemon_env_file(&instance, path);
                }
                Err(_) => break,
            }
        }

        // Once the orchestrator signals shutdown is done, exit the render loop.
        if signals.shutdown_complete.load(Ordering::SeqCst) {
            app.shutdown_phase = ShutdownPhase::Done;
            terminal.draw(|f| render::draw(f, app))?;
            return Ok(());
        }

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

        // While shutting down, skip keyboard processing — just keep rendering
        // progress until the orchestrator sets shutdown_complete.
        if app.shutdown_phase == ShutdownPhase::ShuttingDown {
            if last_tick.elapsed() >= TICK {
                last_tick = Instant::now();
            }
            continue;
        }

        // Poll keyboard/mouse for the remaining tick budget.
        let remaining = TICK.saturating_sub(last_tick.elapsed());
        if event::poll(remaining)? {
            match event::read()? {
                Event::Key(key) => {
                    if key.code == KeyCode::Char('c')
                        && key.modifiers.contains(KeyModifiers::CONTROL)
                    {
                        begin_shutdown(app, signals);
                    } else if app.search_input_active {
                        handle_search_input(key, app);
                    } else {
                        handle_normal(key, app, signals);
                    }
                }
                Event::Mouse(mouse_event) => {
                    handle_mouse(mouse_event, app);
                }
                _ => {}
            }
        }

        // Clear expired copy flash.
        if app
            .copy_flash
            .is_some_and(|t| t.elapsed() > std::time::Duration::from_secs(2))
        {
            app.copy_flash = None;
        }

        if last_tick.elapsed() >= TICK {
            last_tick = Instant::now();
        }
    }
}

/// Switch the TUI to shutdown display mode and signal the orchestrator.
fn begin_shutdown(app: &mut state::AppState, signals: &DevSignals) {
    use state::ShutdownPhase;
    if app.shutdown_phase != ShutdownPhase::Running {
        return;
    }
    app.shutdown_phase = ShutdownPhase::ShuttingDown;
    // Switch to System pane so shutdown log messages are immediately visible.
    app.active_pane = LogSource::System;
    app.scroll_bottom();
    app.clear_search();
    signals.quit_requested.store(true, Ordering::SeqCst);
}

/// Key handling while the search box is open.
fn handle_search_input(key: event::KeyEvent, app: &mut state::AppState) {
    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
    match key.code {
        KeyCode::Enter | KeyCode::Esc => app.exit_search_input(),
        KeyCode::Backspace => app.search_backspace(),
        KeyCode::Up => {
            if shift { app.page_up() } else { app.scroll_up() }
        }
        KeyCode::Down => {
            if shift { app.page_down() } else { app.scroll_down() }
        }
        KeyCode::Char(c) => app.search_push(c),
        _ => {}
    }
}

/// Key handling in normal (non-search-input) mode.
fn handle_normal(key: event::KeyEvent, app: &mut state::AppState, signals: &DevSignals) {
    // 'I' (capital) toggles between dev and infra views.
    if key.code == KeyCode::Char('I') {
        app.toggle_view();
        return;
    }
    match app.view {
        state::TuiView::Infra => handle_infra_keys(key, app),
        state::TuiView::Dev => handle_dev_keys(key, app, signals),
    }
}

/// Key handling in the infra view.
fn handle_infra_keys(key: event::KeyEvent, app: &mut state::AppState) {
    if app.infra.is_none() {
        return;
    }
    match key.code {
        KeyCode::Esc => {
            app.view = state::TuiView::Dev;
        }
        KeyCode::Char('1') => {
            if let Some(iv) = &mut app.infra {
                iv.set_tab(state::InfraTab::Status);
            }
        }
        KeyCode::Char('2') => {
            if let Some(iv) = &mut app.infra {
                iv.set_tab(state::InfraTab::Graph);
            }
        }
        KeyCode::Char('3') => {
            if let Some(iv) = &mut app.infra {
                iv.set_tab(state::InfraTab::Snapshot);
            }
        }
        KeyCode::Char('4') => {
            if let Some(iv) = &mut app.infra {
                iv.set_tab(state::InfraTab::Db);
            }
        }
        KeyCode::Char('5') => {
            if let Some(iv) = &mut app.infra {
                iv.set_tab(state::InfraTab::Logs);
            }
        }
        KeyCode::Tab => {
            if let Some(iv) = &mut app.infra {
                iv.cycle_tab(1);
            }
        }
        KeyCode::BackTab => {
            if let Some(iv) = &mut app.infra {
                iv.cycle_tab(-1);
            }
        }
        KeyCode::Up => {
            if let Some(iv) = &mut app.infra {
                iv.scroll_up();
            }
        }
        KeyCode::Down => {
            if let Some(iv) = &mut app.infra {
                iv.scroll_down();
            }
        }
        KeyCode::Char('g') => {
            if let Some(iv) = &mut app.infra {
                iv.scroll_top();
            }
        }
        KeyCode::Char('G') => {
            if let Some(iv) = &mut app.infra {
                iv.scroll_bottom();
            }
        }
        // Log filter keys (only active on the Logs tab).
        KeyCode::Char('a') | KeyCode::Char('r') | KeyCode::Char('p') => {
            if let Some(iv) = &mut app.infra {
                if iv.active_tab == state::InfraTab::Logs {
                    iv.log_filter = match key.code {
                        KeyCode::Char('a') => state::InfraLogFilter::All,
                        KeyCode::Char('r') => state::InfraLogFilter::Rgs,
                        _ => state::InfraLogFilter::Postgres,
                    };
                    iv.scroll_bottom();
                }
            }
        }
        _ => {}
    }
}

/// Key handling in the dev view (extracted from the original handle_normal).
fn handle_dev_keys(key: event::KeyEvent, app: &mut state::AppState, signals: &DevSignals) {
    // Build-scope popup intercepts all keys while open — otherwise navigation
    // shortcuts like `↑↓`, number keys, or letters would leak through and act
    // on the panes behind the dialog.
    if app.build_dialog.is_some() {
        handle_build_dialog_keys(key, app, signals);
        return;
    }

    let shift = key.modifiers.contains(KeyModifiers::SHIFT);
    match key.code {
        KeyCode::Char('q') => begin_shutdown(app, signals),
        KeyCode::Char('/') => app.enter_search(),
        KeyCode::Esc => app.clear_search(),
        // Orchestrator signals.
        KeyCode::Char('r') => {
            signals.restart_requested.store(true, Ordering::SeqCst);
        }
        KeyCode::Char('b') => {
            // Open the rebuild-scope popup. The follow-up keypress is what
            // actually commits the build (see `handle_build_dialog_keys`).
            app.build_dialog = Some(state::BuildDialog::new());
        }
        // ── Focus & navigation ──────────────────────────────────────────────
        // Tab / Shift+Tab move focus between the three side panels
        // (services / apps / logs). Inside the focused panel, the arrow
        // keys move the *selection* for sidebar panes, or scroll for the
        // log pane. Shift+arrows always scroll/page the log so the user
        // never loses access to log nav.
        KeyCode::Tab => app.cycle_focus(1),
        KeyCode::BackTab => app.cycle_focus(-1),
        // Number keys jump to the Nth visible service tab, mirroring the
        // render order (Daemon, UiServer, Build, System — App hidden in
        // platform mode). Pre-tab-strip versions hard-coded a different
        // ordering; deriving from the rendered list keeps the key bindings
        // and the on-screen numbers in lockstep.
        KeyCode::Char(c @ '1'..='9') => {
            let idx = (c as u8 - b'1') as usize;
            if let Some(source) = state::nth_visible_service(app, idx) {
                app.active_pane = source;
                app.focused_pane = state::FocusedPane::Services;
            }
        }
        // Left/Right cycle services tabs when Services pane is focused.
        // Other panes ignore them (consistent with the existing sidebar's
        // vertical-only model).
        KeyCode::Left if app.focused_pane == state::FocusedPane::Services => {
            app.focus_next_service(-1);
        }
        KeyCode::Right if app.focused_pane == state::FocusedPane::Services => {
            app.focus_next_service(1);
        }
        // Arrow keys: dispatched on focused pane.
        KeyCode::Up => {
            if shift {
                app.page_up()
            } else {
                match app.focused_pane {
                    state::FocusedPane::Services => app.focus_next_service(-1),
                    state::FocusedPane::Apps => app.focus_next_app(-1),
                    state::FocusedPane::Nodes => app.focus_next_node(-1),
                    state::FocusedPane::Logs => app.scroll_up(),
                }
            }
        }
        KeyCode::Down => {
            if shift {
                app.page_down()
            } else {
                match app.focused_pane {
                    state::FocusedPane::Services => app.focus_next_service(1),
                    state::FocusedPane::Apps => app.focus_next_app(1),
                    state::FocusedPane::Nodes => app.focus_next_node(1),
                    state::FocusedPane::Logs => app.scroll_down(),
                }
            }
        }
        KeyCode::Char('g') => app.scroll_top(),
        KeyCode::Char('G') => app.scroll_bottom(),
        KeyCode::Char('c') => app.clear_active_pane(),
        KeyCode::Char('i') => app.cycle_instance_filter(),
        _ => {}
    }
}

/// Keys consumed by the rebuild-scope popup. Closes the dialog on confirm or
/// cancel; on confirm, publishes the chosen scope through `DevSignals` so the
/// orchestrator loop can dispatch.
fn handle_build_dialog_keys(
    key: event::KeyEvent,
    app: &mut state::AppState,
    signals: &DevSignals,
) {
    let Some(dialog) = app.build_dialog.as_mut() else {
        return;
    };
    match key.code {
        KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('b') => {
            // Cancel: just close the dialog. Don't requeue any signal.
            app.build_dialog = None;
        }
        KeyCode::Up | KeyCode::Char('k') => dialog.move_up(),
        KeyCode::Down | KeyCode::Char('j') => dialog.move_down(),
        KeyCode::Enter | KeyCode::Char(' ') => {
            let scope = dialog.current();
            signals
                .build_scope_requested
                .store(scope as u8, Ordering::SeqCst);
            app.build_dialog = None;
        }
        // Shortcut keys mirroring the labels: A=all, s=system, a=apps, u=ui.
        KeyCode::Char(c) => {
            let scope = match c {
                'A' => Some(state::BuildScope::All),
                's' | 'S' => Some(state::BuildScope::System),
                'a' => Some(state::BuildScope::Apps),
                'u' | 'U' => Some(state::BuildScope::Ui),
                _ => None,
            };
            if let Some(scope) = scope {
                signals
                    .build_scope_requested
                    .store(scope as u8, Ordering::SeqCst);
                app.build_dialog = None;
            }
        }
        _ => {}
    }
}

/// Copy text to the system clipboard.
/// Returns `true` if the clipboard command succeeded.
fn copy_to_clipboard(text: &str) -> bool {
    use std::io::Write as _;
    #[cfg(target_os = "macos")]
    {
        if let Ok(mut child) = std::process::Command::new("pbcopy")
            .stdin(std::process::Stdio::piped())
            .stdout(std::process::Stdio::null())
            .stderr(std::process::Stdio::null())
            .spawn()
        {
            if let Some(mut stdin) = child.stdin.take() {
                let _ = stdin.write_all(text.as_bytes());
            }
            return child.wait().map(|s| s.success()).unwrap_or(false);
        }
        false
    }
    #[cfg(target_os = "linux")]
    {
        for (cmd, args) in [
            ("wl-copy", vec![] as Vec<&str>),
            ("xclip", vec!["-selection", "clipboard"]),
        ] {
            if let Ok(mut child) = std::process::Command::new(cmd)
                .args(&args)
                .stdin(std::process::Stdio::piped())
                .stdout(std::process::Stdio::null())
                .stderr(std::process::Stdio::null())
                .spawn()
            {
                if let Some(mut stdin) = child.stdin.take() {
                    let _ = stdin.write_all(text.as_bytes());
                }
                if child.wait().map(|s| s.success()).unwrap_or(false) {
                    return true;
                }
            }
        }
        false
    }
    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
    {
        let _ = text;
        false
    }
}

/// Handle a mouse event, updating app state accordingly.
fn handle_mouse(mouse: crossterm::event::MouseEvent, app: &mut state::AppState) {
    use crossterm::event::{MouseButton, MouseEventKind};
    use state::Selection;

    let col = mouse.column;
    let row = mouse.row;
    let layout = app.layout;

    // ── Mouse wheel: scroll anywhere in the right panel ────────────────────────
    let in_log_panel = col >= layout.log_scroll.x;
    match mouse.kind {
        MouseEventKind::ScrollUp if in_log_panel => {
            app.scroll_up();
            return;
        }
        MouseEventKind::ScrollDown if in_log_panel => {
            app.scroll_down();
            return;
        }
        _ => {}
    }

    // ── Click in services tab strip (top bar) ──────────────────────────────────
    let sl = layout.service_list;
    if col >= sl.x && col < sl.x + sl.width && row >= sl.y && row < sl.y + sl.height {
        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
            // Tab strip lives on a single row inside the borders. Each tab is
            // rendered as ` N:<label> ●  ` — width depends on the label. We
            // re-derive the layout the same way `render_services_tabs` does
            // so click hit-testing stays aligned even if labels grow.
            let hide_app = !app.app_list.is_empty();
            let visible: Vec<(LogSource, &str)> = app
                .active_services()
                .iter()
                .filter(|(s, _)| !(hide_app && *s == LogSource::App))
                .map(|(s, v)| (*s, v.label))
                .collect();
            // Column 0 of inner content is sl.x + 1 (skip border).
            let mut x = sl.x + 1;
            let click_x = col;
            let click_row = row;
            // Only the single content row in the middle is clickable.
            if click_row == sl.y + 1 {
                for (i, (source, label)) in visible.iter().enumerate() {
                    // Width matches `render_services_tabs` layout:
                    //   " " + "N:label" + " " + "●" + " "
                    let label_text = format!("{}:{}", i + 1, label);
                    let tab_width = (1 + label_text.chars().count() + 1 + 1 + 1) as u16;
                    if click_x >= x && click_x < x + tab_width {
                        app.active_pane = *source;
                        app.focused_pane = state::FocusedPane::Services;
                        app.auto_scroll = true;
                        app.clear_search();
                        app.selection = None;
                        return;
                    }
                    x += tab_width;
                }
            }
            // Click was inside the services box but not on a tab — still
            // move focus to Services so arrow keys/`1-5` start working.
            app.focused_pane = state::FocusedPane::Services;
        }
        return;
    }

    // ── Click in nodes list (multi-instance platform mode) ─────────────────────
    let nl = layout.node_list;
    if nl.width > 0
        && col >= nl.x
        && col < nl.x + nl.width
        && row >= nl.y
        && row < nl.y + nl.height
    {
        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && row > nl.y {
            // nl.y is the "nodes" title row; entries start at nl.y + 1, each 3 rows tall.
            let row_in_items = (row - nl.y - 1) as usize;
            let idx = row_in_items / 3;
            if idx < app.node_list.len() {
                app.node_filter = Some(idx);
                app.focused_pane = state::FocusedPane::Nodes;
                app.auto_scroll = true;
                app.clear_search();
                app.selection = None;
            }
        }
        return;
    }

    // ── Click in apps list (platform mode only) ────────────────────────────────
    let al = layout.app_list;
    if al.width > 0
        && col >= al.x
        && col < al.x + al.width
        && row >= al.y
        && row < al.y + al.height
    {
        if matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) && row > al.y {
            // al.y is the "apps" title row; entries start at al.y + 1, each 3 rows tall
            // (name+count line, status line, compact path line).
            let row_in_items = (row - al.y - 1) as usize;
            let idx = row_in_items / 3;
            // Map app index → instance index. In platform mode the two lists
            // are 1:1; in app-developer mode (alice/bob) `app_list` is empty
            // so we never reach this branch.
            if let Some(entry) = app.app_list.get(idx) {
                let name = entry.name.clone();
                if let Some(inst_idx) = app.instance_names.iter().position(|n| n == &name) {
                    // Reset search/selection first — clear_search() also wipes
                    // instance_filter, which we set immediately afterwards.
                    app.clear_search();
                    app.selection = None;
                    app.instance_filter = Some(inst_idx);
                    app.active_pane = LogSource::App;
                    app.focused_pane = state::FocusedPane::Apps;
                    app.auto_scroll = true;
                }
            }
        }
        return;
    }

    // ── Drag selection in log scroll area ─────────────────────────────────────
    let ls = layout.log_scroll;
    if col < ls.x || col >= ls.x + ls.width || row < ls.y || row >= ls.y + ls.height {
        return;
    }
    // Only support selection when no filter is active.
    if !app.search_query.is_empty() {
        return;
    }

    let row_in_area = (row - ls.y) as usize;
    let total = app.log_lines(app.active_pane).len();
    let line_idx = app.log_scroll_start + row_in_area;
    if line_idx >= total {
        return;
    }

    match mouse.kind {
        MouseEventKind::Down(MouseButton::Left) => {
            app.focused_pane = state::FocusedPane::Logs;
            app.selection = Some(Selection { anchor: line_idx, head: line_idx });
            app.auto_scroll = false;
            if app.scroll_pos == 0 {
                app.scroll_pos = app.log_scroll_start;
            }
        }
        MouseEventKind::Drag(MouseButton::Left) => {
            if let Some(sel) = app.selection.as_mut() {
                sel.head = line_idx;
            }
        }
        MouseEventKind::Up(MouseButton::Left) => {
            if let Some(sel) = app.selection {
                // Copy on release (even single-line selections).
                let text = app.get_selected_text();
                if !text.is_empty() && copy_to_clipboard(&text) {
                    app.copy_flash = Some(std::time::Instant::now());
                }
                // Keep the selection visible so the user sees what was copied.
                let _ = sel;
            }
        }
        _ => {}
    }
}
