use std::collections::{HashMap, VecDeque};
use std::time::Instant;

use ratatui::layout::Rect;

use super::LogEntry;

const MAX_LOG_LINES: usize = 2000;
const MAX_INFRA_LOG_LINES: usize = 1000;

// ── Infra TUI types ───────────────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TuiView {
    Dev,
    Infra,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InfraTab {
    Status,
    Graph,
    Snapshot,
    Db,
    Logs,
}

impl InfraTab {
    pub fn all() -> &'static [InfraTab] {
        &[
            InfraTab::Status,
            InfraTab::Graph,
            InfraTab::Snapshot,
            InfraTab::Db,
            InfraTab::Logs,
        ]
    }

    pub fn label(self) -> &'static str {
        match self {
            InfraTab::Status => "1:status",
            InfraTab::Graph => "2:graph",
            InfraTab::Snapshot => "3:snapshot",
            InfraTab::Db => "4:db",
            InfraTab::Logs => "5:logs",
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub enum InfraHealthStatus {
    Healthy,
    Running,
    Degraded,
    Down,
    External,
}

impl InfraHealthStatus {
    pub fn indicator(&self) -> &'static str {
        match self {
            Self::Healthy => "✓",
            Self::Running => "◐",
            Self::Degraded => "⚠",
            Self::Down => "✗",
            Self::External => "⚡",
        }
    }

    pub fn label(&self) -> &'static str {
        match self {
            Self::Healthy => "healthy",
            Self::Running => "running",
            Self::Degraded => "degraded",
            Self::Down => "down",
            Self::External => "external",
        }
    }

    pub fn color(&self) -> ratatui::style::Color {
        use ratatui::style::Color;
        match self {
            Self::Healthy => Color::Green,
            Self::Running => Color::Yellow,
            Self::Degraded => Color::Yellow,
            Self::Down => Color::Red,
            Self::External => Color::Cyan,
        }
    }
}

#[derive(Debug, Clone)]
pub struct InfraServiceHealth {
    pub name: String,
    pub status: InfraHealthStatus,
    pub detail: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InfraLogFilter {
    All,
    Rgs,
    Postgres,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum InfraLogService {
    Rgs,
    Postgres,
    Other,
}

#[derive(Debug, Clone)]
pub struct InfraLogLine {
    pub line: String,
    pub service: InfraLogService,
}

#[derive(Debug, Clone)]
pub struct InfraChannelEntry {
    pub scid: String,
    pub node1: String,
}

#[derive(Debug, Clone, Default)]
pub struct InfraGraphData {
    pub node_count: u64,
    pub channel_count: u64,
    pub channels: Vec<InfraChannelEntry>,
}

#[derive(Debug, Clone, Default)]
pub struct InfraDbSummary {
    pub node_announcements: u64,
    pub channel_announcements: u64,
    pub channel_updates: u64,
    pub config_rows: u64,
}

#[derive(Debug, Clone)]
pub struct InfraSnapshotInfo {
    pub version: u8,
    pub chain_hash: String,
    #[allow(dead_code)]
    pub timestamp: u32,
    pub timestamp_str: String,
    pub node_count: usize,
    pub channel_count: usize,
    pub update_count: usize,
}

pub struct InfraViewState {
    pub active_tab: InfraTab,
    pub services: Vec<InfraServiceHealth>,
    pub rgs_url: String,
    #[allow(dead_code)]
    pub ln_peers: Vec<String>,
    pub ln_network: String,
    pub rgs_injected: bool,
    pub graph: Option<InfraGraphData>,
    pub snapshot: Option<InfraSnapshotInfo>,
    pub db: Option<InfraDbSummary>,
    pub graph_loading: bool,
    pub snapshot_loading: bool,
    pub db_loading: bool,
    logs: VecDeque<InfraLogLine>,
    pub log_filter: InfraLogFilter,
    pub last_refresh: Option<Instant>,
    pub scroll_pos: usize,
    pub auto_scroll: bool,
}

impl InfraViewState {
    pub fn new(
        rgs_url: String,
        ln_network: String,
        ln_peers: Vec<String>,
        rgs_injected: bool,
    ) -> Self {
        Self {
            active_tab: InfraTab::Status,
            services: vec![],
            rgs_url,
            ln_network,
            ln_peers,
            rgs_injected,
            graph: None,
            snapshot: None,
            db: None,
            graph_loading: false,
            snapshot_loading: false,
            db_loading: false,
            logs: VecDeque::new(),
            log_filter: InfraLogFilter::All,
            last_refresh: None,
            scroll_pos: 0,
            auto_scroll: true,
        }
    }

    pub fn push_log(&mut self, entry: InfraLogLine) {
        self.logs.push_back(entry);
        if self.logs.len() > MAX_INFRA_LOG_LINES {
            self.logs.pop_front();
        }
        if self.auto_scroll {
            self.scroll_pos = self.visible_lines().len();
        }
    }

    pub fn visible_lines(&self) -> Vec<&str> {
        self.logs
            .iter()
            .filter(|e| match self.log_filter {
                InfraLogFilter::All => true,
                InfraLogFilter::Rgs => e.service == InfraLogService::Rgs,
                InfraLogFilter::Postgres => e.service == InfraLogService::Postgres,
            })
            .map(|e| e.line.as_str())
            .collect()
    }

    pub fn set_tab(&mut self, tab: InfraTab) {
        self.active_tab = tab;
        self.scroll_pos = 0;
        self.auto_scroll = true;
    }

    pub fn cycle_tab(&mut self, delta: i32) {
        let tabs = InfraTab::all();
        let pos = tabs
            .iter()
            .position(|&t| t == self.active_tab)
            .unwrap_or(0);
        self.set_tab(
            tabs[((pos as i32 + delta).rem_euclid(tabs.len() as i32)) as usize],
        );
    }

    #[allow(dead_code)]
    pub fn cycle_log_filter(&mut self) {
        self.log_filter = match self.log_filter {
            InfraLogFilter::All => InfraLogFilter::Rgs,
            InfraLogFilter::Rgs => InfraLogFilter::Postgres,
            InfraLogFilter::Postgres => InfraLogFilter::All,
        };
        self.scroll_pos = 0;
        self.auto_scroll = true;
    }

    pub fn scroll_up(&mut self) {
        if self.auto_scroll {
            self.scroll_pos = self.visible_lines().len();
            self.auto_scroll = false;
        }
        self.scroll_pos = self.scroll_pos.saturating_sub(1);
    }

    pub fn scroll_down(&mut self) {
        if !self.auto_scroll {
            self.scroll_pos =
                (self.scroll_pos + 1).min(self.visible_lines().len());
        }
    }

    pub fn scroll_top(&mut self) {
        self.auto_scroll = false;
        self.scroll_pos = 0;
    }

    pub fn scroll_bottom(&mut self) {
        self.auto_scroll = true;
    }

    /// Total number of log lines (unfiltered).
    pub fn logs_total(&self) -> usize {
        self.logs.len()
    }
}

// ── Dev TUI log source enum ───────────────────────────────────────────────────

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LogSource {
    Daemon,
    UiServer,
    Build,
    App,
    System,
}

impl LogSource {
    /// Bare label without a number. Use `AppState::numbered_label` when you
    /// want the `N:label` form rendered to the user — the number must match
    /// the rendered tab strip order, which is derived from `active_services`.
    pub fn tab_label(self) -> &'static str {
        match self {
            LogSource::System => "system",
            LogSource::Daemon => "daemon",
            LogSource::UiServer => "ui",
            LogSource::Build => "build",
            LogSource::App => "app",
        }
    }
}

#[derive(Debug, Clone, PartialEq)]
pub enum ServiceStatus {
    Pending,
    Building,
    Starting,
    Ready,
    Watching,
    Changed,
    Loaded { reloads: usize },
    Failed(String),
    Disabled,
}

impl ServiceStatus {
    pub fn indicator(&self) -> &'static str {
        match self {
            ServiceStatus::Ready | ServiceStatus::Loaded { .. } | ServiceStatus::Watching => "●",
            ServiceStatus::Building | ServiceStatus::Starting | ServiceStatus::Changed => "◐",
            ServiceStatus::Failed(_) => "✗",
            ServiceStatus::Pending => "◌",
            ServiceStatus::Disabled => "○",
        }
    }

    pub fn color(&self) -> ratatui::style::Color {
        use ratatui::style::Color;
        match self {
            ServiceStatus::Ready | ServiceStatus::Loaded { .. } | ServiceStatus::Watching => {
                Color::Green
            }
            ServiceStatus::Building | ServiceStatus::Starting | ServiceStatus::Changed => {
                Color::Yellow
            }
            ServiceStatus::Failed(_) => Color::Red,
            ServiceStatus::Pending | ServiceStatus::Disabled => Color::DarkGray,
        }
    }

    pub fn summary(&self) -> String {
        match self {
            ServiceStatus::Pending => "pending".into(),
            ServiceStatus::Building => "building…".into(),
            ServiceStatus::Starting => "starting…".into(),
            ServiceStatus::Ready => "ready".into(),
            ServiceStatus::Watching => "watching".into(),
            ServiceStatus::Changed => "changed!".into(),
            ServiceStatus::Loaded { reloads } => format!("loaded (×{reloads})"),
            ServiceStatus::Failed(msg) => {
                format!("FAILED: {}", msg.chars().take(18).collect::<String>())
            }
            ServiceStatus::Disabled => "disabled".into(),
        }
    }
}

#[derive(Clone)]
pub struct ServiceState {
    pub label: &'static str,
    pub status: ServiceStatus,
    pub detail: String,
}

/// One node-app the daemon has loaded (or attempted to load). Populated in
/// platform-mode dev: each app in `infra/debian/platform-depends` gets one
/// entry. Empty in app-developer mode.
#[derive(Clone)]
pub struct AppStatus {
    pub name: String,
    pub status: ServiceStatus,
    pub detail: String,
    /// Resolved source directory on disk — set when the orchestrator has
    /// finished sibling/cache/clone resolution. Used by the sidebar (compact
    /// display) and the log-pane title (full path) so developers can find
    /// the actual source files behind each running app.
    pub path: Option<std::path::PathBuf>,
}

/// Timing metadata tracked per service from status-transition events.
/// Computed once per status change; live elapsed is derived at render time.
pub struct Timings {
    // Developing-app build
    pub build_started_at: Option<Instant>,
    pub last_build_ms: Option<u64>,
    pub build_count: usize,
    // Builtin-apps pre-build (make builtin-apps during monorepo startup)
    pub builtin_apps_build_ms: Option<u64>,
    // Daemon uptime
    pub daemon_started_at: Option<Instant>,
    // Platform build (cargo build -p node-server during monorepo startup)
    pub platform_build_started_at: Option<Instant>,
    pub platform_build_ms: Option<u64>,
    // App reload
    pub last_reload_ms: Option<u64>,
}

impl Timings {
    fn new() -> Self {
        Self {
            build_started_at: None,
            last_build_ms: None,
            build_count: 0,
            builtin_apps_build_ms: None,
            daemon_started_at: None,
            platform_build_started_at: None,
            platform_build_ms: None,
            last_reload_ms: None,
        }
    }
}

/// Which side panel currently has keyboard focus. Tab cycles forward,
/// Shift+Tab back. Arrow keys are dispatched to the focused pane.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FocusedPane {
    /// Services list (daemon, ui-server, build, system).
    Services,
    /// Apps list (platform mode only; non-empty `app_list`).
    Apps,
    /// Nodes list (multi-instance platform mode only; non-empty `node_list`).
    Nodes,
    /// Right-hand log scroller.
    Logs,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ShutdownPhase {
    Running,
    /// `q` / Ctrl+C pressed — waiting for host_impl.shutdown() to complete.
    ShuttingDown,
    /// Orchestrator finished shutdown — TUI may now exit.
    Done,
}

/// Which slice of the running stack a manual rebuild should target.
/// Values are stable u8 discriminants because they're shuttled through an
/// `AtomicU8` between the TUI key handler and the orchestrator loop.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum BuildScope {
    Ui = 1,
    Apps = 2,
    System = 3,
    All = 4,
}

impl BuildScope {
    pub const ALL: [BuildScope; 4] = [
        BuildScope::All,
        BuildScope::System,
        BuildScope::Apps,
        BuildScope::Ui,
    ];

    pub fn label(self) -> &'static str {
        match self {
            BuildScope::All => "All  (apps + ui + system)",
            BuildScope::System => "System  (cargo build + daemon restart)",
            BuildScope::Apps => "Apps  (rebuild + restage node-app deps)",
            BuildScope::Ui => "UI  (restart Vite dev server)",
        }
    }

    pub fn key_hint(self) -> char {
        match self {
            BuildScope::All => 'A',
            BuildScope::System => 's',
            BuildScope::Apps => 'a',
            BuildScope::Ui => 'u',
        }
    }

    pub fn from_u8(v: u8) -> Option<Self> {
        match v {
            1 => Some(BuildScope::Ui),
            2 => Some(BuildScope::Apps),
            3 => Some(BuildScope::System),
            4 => Some(BuildScope::All),
            _ => None,
        }
    }
}

/// State of the centered "what should I rebuild?" popup. Present in
/// `AppState.build_dialog` while the user is choosing a scope. The key
/// handler intercepts all input while this is `Some`.
#[derive(Debug, Clone)]
pub struct BuildDialog {
    /// Index into `BuildScope::ALL`.
    pub selected: usize,
}

impl BuildDialog {
    pub fn new() -> Self {
        Self { selected: 0 }
    }

    pub fn move_up(&mut self) {
        if self.selected == 0 {
            self.selected = BuildScope::ALL.len() - 1;
        } else {
            self.selected -= 1;
        }
    }

    pub fn move_down(&mut self) {
        self.selected = (self.selected + 1) % BuildScope::ALL.len();
    }

    pub fn current(&self) -> BuildScope {
        BuildScope::ALL[self.selected.min(BuildScope::ALL.len() - 1)]
    }
}

impl Default for BuildDialog {
    fn default() -> Self {
        Self::new()
    }
}

/// Saved layout rects from the last rendered frame, used for mouse hit-testing.
#[derive(Clone, Copy, Default)]
pub struct TuiLayout {
    pub service_list: Rect,
    pub log_scroll: Rect,
    /// Apps sidebar section in platform mode. Zero-sized when not rendered.
    pub app_list: Rect,
    /// Nodes sidebar section in multi-instance platform mode. Zero-sized when
    /// not rendered.
    pub node_list: Rect,
}

/// A drag selection over absolute log-line indices in the VecDeque.
#[derive(Clone, Copy, Debug)]
pub struct Selection {
    /// Absolute log-line index (in the VecDeque) where drag started.
    pub anchor: usize,
    /// Current drag endpoint.
    pub head: usize,
}

impl Selection {
    pub fn range(self) -> (usize, usize) {
        (self.anchor.min(self.head), self.anchor.max(self.head))
    }
    pub fn contains(self, idx: usize) -> bool {
        let (s, e) = self.range();
        idx >= s && idx <= e
    }
}

pub struct AppState {
    pub app_name: String,
    pub app_version: String,
    services: Vec<(LogSource, ServiceState)>,
    /// Global (all-instances) log buffers, with instance prefixes intact.
    logs: HashMap<LogSource, VecDeque<String>>,
    /// Per-instance log buffers. Lines are stored without the `[name] ` prefix.
    /// Shared log lines (build, system, app) are copied to every instance buffer.
    instance_logs: HashMap<String, HashMap<LogSource, VecDeque<String>>>,
    /// Per-node log buffers (multi-instance platform mode). Populated when a
    /// log line carries an outer `[<node>] ` prefix matching `tracked_nodes`.
    /// Lines are stored with the node prefix stripped (inner `[<app>]` prefix
    /// preserved for readability inside the per-node view).
    node_logs: HashMap<String, HashMap<LogSource, VecDeque<String>>>,
    pub active_pane: LogSource,
    pub auto_scroll: bool,
    pub scroll_pos: usize,
    // Search / filter
    pub search_query: String,
    pub search_input_active: bool,
    // Set by the render function so page_up/page_down know the viewport height.
    pub last_render_height: usize,
    pub shutdown_phase: ShutdownPhase,
    pub timings: Timings,
    // Mouse support
    pub layout: TuiLayout,
    /// Absolute log-line index of the first visible row (set by render each frame).
    pub log_scroll_start: usize,
    pub selection: Option<Selection>,
    /// Set when text is copied; shown in footer for 2 s.
    pub copy_flash: Option<std::time::Instant>,
    /// Instance names available for context switching (e.g. ["alice", "bob"]).
    /// Empty when running a single anonymous instance.
    pub instance_names: Vec<String>,
    /// Index into `instance_names` for the active context.
    /// `None` means show the global (all-instances) view.
    pub instance_filter: Option<usize>,
    /// Per-instance service states, keyed by instance name.
    instance_services: HashMap<String, Vec<(LogSource, ServiceState)>>,
    /// Per-instance timings.
    instance_timings: HashMap<String, Timings>,
    /// Which top-level view is active (dev or infra).
    pub view: TuiView,
    /// Infra panel state — `None` when no infra was resolved.
    pub infra: Option<InfraViewState>,
    /// Loaded node-apps in platform mode (empty in app-developer mode).
    /// Order is preserved so sidebar indices stay stable across status updates.
    pub app_list: Vec<AppStatus>,
    /// Which side panel has keyboard focus. Tab cycles forward, Shift+Tab back.
    /// Arrow keys are dispatched to this pane. Mouse clicks on a panel set it.
    pub focused_pane: FocusedPane,
    /// Generated env file paths per instance. Surfaced in the services box
    /// title (compact) and the Daemon log-pane title (full path), so the
    /// developer can find the exact env file the running daemon is using.
    pub daemon_env_files: HashMap<String, std::path::PathBuf>,
    /// Running node instances (alice, bob, …) in multi-instance platform mode.
    /// Updated automatically when an `update_service` event arrives with a
    /// `[<node>] ` prefix in its detail whose name matches `tracked_nodes`.
    pub node_list: Vec<AppStatus>,
    /// Which node is currently focused (index into `node_list`), or None when
    /// the user hasn't picked one. Drives the title bar and (eventually) the
    /// per-node log filter.
    pub node_filter: Option<usize>,
    /// Names registered via `seed_node_list`, used solely for the `[<node>] `
    /// prefix routing in `update_service`. Empty in single-instance / app-dev
    /// modes — those flows reuse `instance_names` for their primary filter.
    pub tracked_nodes: Vec<String>,
    /// Centered "what should I rebuild?" popup. `Some` while the user is
    /// choosing a scope (opened by `b`); the key handler intercepts input
    /// while it is set.
    pub build_dialog: Option<BuildDialog>,
}

impl AppState {
    pub fn new(app_name: String, app_version: String, instance_names: Vec<String>) -> Self {
        let services = vec![
            (
                LogSource::Daemon,
                ServiceState {
                    label: "daemon",
                    status: ServiceStatus::Pending,
                    detail: String::new(),
                },
            ),
            (
                LogSource::UiServer,
                ServiceState {
                    label: "ui-server",
                    status: ServiceStatus::Pending,
                    detail: String::new(),
                },
            ),
            (
                LogSource::Build,
                ServiceState {
                    label: "build",
                    status: ServiceStatus::Pending,
                    detail: String::new(),
                },
            ),
            (
                LogSource::App,
                ServiceState {
                    label: "app",
                    status: ServiceStatus::Pending,
                    detail: String::new(),
                },
            ),
            (
                LogSource::System,
                ServiceState {
                    label: "system",
                    status: ServiceStatus::Ready,
                    detail: String::new(),
                },
            ),
        ];
        // Pre-populate per-instance service maps with the same default states.
        let instance_services: HashMap<String, Vec<(LogSource, ServiceState)>> = instance_names
            .iter()
            .map(|name| (name.clone(), services.iter().map(|(s, v)| (*s, v.clone())).collect()))
            .collect();
        let instance_timings: HashMap<String, Timings> = instance_names
            .iter()
            .map(|name| (name.clone(), Timings::new()))
            .collect();

        Self {
            app_name,
            app_version,
            services,
            logs: HashMap::new(),
            instance_logs: HashMap::new(),
            node_logs: HashMap::new(),
            active_pane: LogSource::System,
            auto_scroll: true,
            scroll_pos: 0,
            search_query: String::new(),
            search_input_active: false,
            last_render_height: 24,
            shutdown_phase: ShutdownPhase::Running,
            timings: Timings::new(),
            layout: TuiLayout::default(),
            log_scroll_start: 0,
            selection: None,
            copy_flash: None,
            instance_names,
            instance_filter: None,
            instance_services,
            instance_timings,
            view: TuiView::Dev,
            infra: None,
            app_list: Vec::new(),
            focused_pane: FocusedPane::Logs,
            daemon_env_files: HashMap::new(),
            node_list: Vec::new(),
            node_filter: None,
            tracked_nodes: Vec::new(),
            build_dialog: None,
        }
    }

    /// Seed the nodes sidebar with one entry per running instance. Called by
    /// the platform-mode orchestrator before booting any daemons so that
    /// `update_service` can route `[<node>] `-prefixed events to the right
    /// row. Single-instance runs may skip this; the sidebar then stays empty.
    pub fn seed_node_list(&mut self, names: &[String]) {
        self.tracked_nodes = names.to_vec();
        self.node_list = names
            .iter()
            .map(|n| AppStatus {
                name: n.clone(),
                status: ServiceStatus::Pending,
                detail: String::new(),
                path: None,
            })
            .collect();
        // Auto-focus the first node when running multi-instance — gives the
        // user immediate visual selection without an extra keystroke.
        if !self.node_list.is_empty() {
            self.node_filter = Some(0);
        }
    }

    /// Cycle the focused node forward (delta=+1) or back (-1). Used by the
    /// arrow keys when the Nodes pane is active. Wraps around.
    pub fn focus_next_node(&mut self, delta: i32) {
        let n = self.node_list.len();
        if n == 0 {
            return;
        }
        let cur = self.node_filter.unwrap_or(0);
        let next = ((cur as i32 + delta).rem_euclid(n as i32)) as usize;
        self.node_filter = Some(next);
        self.scroll_pos = 0;
        self.auto_scroll = true;
        self.selection = None;
    }

    /// Name of the currently focused node, when one is selected.
    pub fn focused_node_name(&self) -> Option<&str> {
        self.node_filter
            .and_then(|i| self.node_list.get(i))
            .map(|a| a.name.as_str())
    }

    /// Record the env file the daemon for `instance` was launched with. Called
    /// by `MonorepoHost` right after writing the generated file.
    pub fn set_daemon_env_file(&mut self, instance: &str, path: std::path::PathBuf) {
        self.daemon_env_files.insert(instance.to_string(), path);
    }

    /// Return the env file path for the currently focused node, falling back
    /// to the currently filtered instance, then to the first known entry.
    pub fn active_daemon_env_file(&self) -> Option<&std::path::Path> {
        // Node filter (multi-instance platform mode) takes precedence over
        // the legacy instance filter so the env path matches the daemon you
        // actually have focused.
        if let Some(name) = self.focused_node_name() {
            if let Some(p) = self.daemon_env_files.get(name) {
                return Some(p.as_path());
            }
        }
        if let Some(name) = self.instance_filter_label() {
            if let Some(p) = self.daemon_env_files.get(name) {
                return Some(p.as_path());
            }
        }
        self.daemon_env_files.values().next().map(|p| p.as_path())
    }

    /// Cycle keyboard focus through the visible side panels. Apps and Nodes
    /// panes are only in the rotation when their respective lists are
    /// non-empty.
    pub fn cycle_focus(&mut self, delta: i32) {
        let mut panes: Vec<FocusedPane> = vec![FocusedPane::Services];
        if !self.app_list.is_empty() {
            panes.push(FocusedPane::Apps);
        }
        if !self.node_list.is_empty() {
            panes.push(FocusedPane::Nodes);
        }
        panes.push(FocusedPane::Logs);

        let cur = panes
            .iter()
            .position(|p| *p == self.focused_pane)
            .unwrap_or(0);
        let next_idx = ((cur as i32 + delta).rem_euclid(panes.len() as i32)) as usize;
        self.focused_pane = panes[next_idx];
        self.auto_scroll = true;
        self.scroll_pos = 0;
        self.selection = None;
    }

    /// Move the service selection forward (delta=+1) or back (-1). The "app"
    /// service row is excluded in platform mode (it's hidden in render).
    /// Wraps around.
    pub fn focus_next_service(&mut self, delta: i32) {
        let hide_app = !self.app_list.is_empty();
        let visible: Vec<LogSource> = self
            .services
            .iter()
            .map(|(s, _)| *s)
            .filter(|s| !(hide_app && *s == LogSource::App))
            .collect();
        if visible.is_empty() {
            return;
        }
        let cur = visible
            .iter()
            .position(|s| *s == self.active_pane)
            .unwrap_or(0);
        let next_idx = ((cur as i32 + delta).rem_euclid(visible.len() as i32)) as usize;
        self.active_pane = visible[next_idx];
        self.auto_scroll = true;
        self.scroll_pos = 0;
        self.selection = None;
        self.clear_search();
    }

    /// Seed the app sidebar with one entry per name in `Pending` state. Called
    /// once by the orchestrator before staging begins. Also flips the default
    /// focused pane to `Apps` so platform users land on the new sidebar with
    /// arrow-key navigation ready to go.
    pub fn seed_app_list(&mut self, names: &[String]) {
        self.app_list = names
            .iter()
            .map(|n| AppStatus {
                name: n.clone(),
                status: ServiceStatus::Pending,
                detail: String::new(),
                path: None,
            })
            .collect();
        if !self.app_list.is_empty() {
            self.focused_pane = FocusedPane::Apps;
        }
    }

    /// Update one app's status. Creates a new entry if the name is unknown
    /// (e.g. an extra `--dep` not in platform-depends).
    pub fn set_app_status(&mut self, name: &str, status: ServiceStatus, detail: Option<String>) {
        let detail = detail.unwrap_or_default();
        if let Some(entry) = self.app_list.iter_mut().find(|a| a.name == name) {
            entry.status = status;
            entry.detail = detail;
        } else {
            self.app_list.push(AppStatus {
                name: name.to_string(),
                status,
                detail,
                path: None,
            });
        }
    }

    /// Record the resolved on-disk path for an app. Creates a new entry if
    /// the name isn't already known (mirrors `set_app_status`).
    pub fn set_app_path(&mut self, name: &str, path: std::path::PathBuf) {
        if let Some(entry) = self.app_list.iter_mut().find(|a| a.name == name) {
            entry.path = Some(path);
        } else {
            self.app_list.push(AppStatus {
                name: name.to_string(),
                status: ServiceStatus::Pending,
                detail: String::new(),
                path: Some(path),
            });
        }
    }

    /// Number of buffered App-pane log lines tagged with `[<name>]`. Used by
    /// the sidebar to show activity.
    pub fn app_log_count(&self, name: &str) -> usize {
        self.instance_logs
            .get(name)
            .and_then(|m| m.get(&LogSource::App))
            .map(|v| v.len())
            .unwrap_or(0)
    }

    /// Cycle the focused app forward (delta=+1) or back (delta=-1). Used by the
    /// arrow keys when the App pane is active in platform mode. Wraps around;
    /// never sets `instance_filter` to `None` (the user explicitly picked an
    /// app — `Esc` / `c` are the way back to the "all apps" view).
    pub fn focus_next_app(&mut self, delta: i32) {
        let n = self.app_list.len();
        if n == 0 {
            return;
        }
        // Resolve the current index (in app_list) from instance_filter via name lookup.
        let current_idx = self.instance_filter_label()
            .and_then(|name| self.app_list.iter().position(|a| a.name == name))
            .unwrap_or(0);
        let next_idx = ((current_idx as i32 + delta).rem_euclid(n as i32)) as usize;
        let next_name = &self.app_list[next_idx].name;
        if let Some(inst_idx) = self.instance_names.iter().position(|n| n == next_name) {
            self.instance_filter = Some(inst_idx);
            self.active_pane = LogSource::App;
            self.auto_scroll = true;
            self.scroll_pos = 0;
            self.selection = None;
        }
    }

    /// Toggle between the Dev and Infra views.
    /// No-op when infra is not available.
    pub fn toggle_view(&mut self) {
        if self.infra.is_none() {
            return;
        }
        self.view = match self.view {
            TuiView::Dev => TuiView::Infra,
            TuiView::Infra => TuiView::Dev,
        };
    }

    /// The currently filtered instance name, or `None` when showing all instances.
    pub fn instance_filter_label(&self) -> Option<&str> {
        self.instance_filter
            .and_then(|i| self.instance_names.get(i))
            .map(String::as_str)
    }

    /// Cycle: all → alice → bob → all.  No-op when fewer than 2 instances.
    pub fn cycle_instance_filter(&mut self) {
        if self.instance_names.len() < 2 {
            return;
        }
        self.instance_filter = match self.instance_filter {
            None => Some(0),
            Some(i) if i + 1 < self.instance_names.len() => Some(i + 1),
            Some(_) => None,
        };
        self.scroll_pos = 0;
        self.auto_scroll = true;
    }

    pub fn push_log(&mut self, entry: LogEntry) {
        // Strip ANSI escape sequences and bare \r before storing.
        let line = strip_ansi(&entry.line);
        if line.is_empty() {
            return;
        }

        // Peel up to two `[<name>] ` prefixes off the front, classifying each
        // as either a tracked node or a tracked instance (app). Lines coming
        // from MonorepoHost::tail_logs in multi-instance platform mode look
        // like `[alice][esp32-bridge] foo`; daemon stdout looks like
        // `[alice] foo`; single-axis legacy use cases look like `[foo] bar`.
        let (node_match, instance_match) = peel_prefixes(
            &line,
            &self.tracked_nodes,
            &self.instance_names,
        );

        fn push_capped(buf: &mut VecDeque<String>, line: String) {
            buf.push_back(line);
            if buf.len() > MAX_LOG_LINES {
                buf.pop_front();
            }
        }
        // Always push the original line (with all prefixes intact) to the
        // global buffer — that's what the unfiltered view shows.
        push_capped(self.logs.entry(entry.source).or_default(), line.clone());

        // Per-node buffer: strip the outer node prefix only, so any inner
        // app prefix stays readable inside the per-node view.
        if let Some((node_name, node_prefix_len)) = &node_match {
            let stripped = line[*node_prefix_len..].to_string();
            push_capped(
                self.node_logs
                    .entry(node_name.clone()).or_default()
                    .entry(entry.source).or_default(),
                stripped,
            );
        } else if !self.tracked_nodes.is_empty() {
            // Untagged line (no node prefix) — fan out to every node so
            // build/system/shared messages remain visible in per-node view.
            let names: Vec<String> = self.tracked_nodes.clone();
            for name in names {
                push_capped(
                    self.node_logs
                        .entry(name).or_default()
                        .entry(entry.source).or_default(),
                    line.clone(),
                );
            }
        }

        // Per-instance (app) buffer: strip both node and app prefixes so
        // the per-app view shows clean lines.
        if let Some((inst_name, end_offset)) = &instance_match {
            let stripped = line[*end_offset..].to_string();
            push_capped(
                self.instance_logs
                    .entry(inst_name.clone()).or_default()
                    .entry(entry.source).or_default(),
                stripped,
            );
        } else if !self.instance_names.is_empty() && node_match.is_none() {
            // Shared line (build output, system messages): copy to every
            // instance's buffer. Skip if a node matched (per-node-only line).
            let names: Vec<String> = self.instance_names.clone();
            for name in names {
                push_capped(
                    self.instance_logs
                        .entry(name).or_default()
                        .entry(entry.source).or_default(),
                    line.clone(),
                );
            }
        }

        // Auto-scroll: advance scroll_pos for whichever buffer is currently displayed.
        if self.auto_scroll && entry.source == self.active_pane && self.search_query.is_empty() {
            let len = self.log_lines(entry.source).len();
            self.scroll_pos = len;
        }
    }

    pub fn update_service(
        &mut self,
        source: LogSource,
        status: ServiceStatus,
        detail: Option<String>,
    ) {
        // Record timing from status transitions before updating the service.
        match (&source, &status) {
            (LogSource::Build, ServiceStatus::Building) => {
                self.timings.build_started_at = Some(Instant::now());
            }
            (LogSource::Build, ServiceStatus::Ready) => {
                // "builtins:Xms" detail = monorepo pre-build (make builtin-apps), not the app.
                let is_builtins = detail.as_deref()
                    .map(|d| d.starts_with("builtins:"))
                    .unwrap_or(false);
                if is_builtins {
                    if let Some(ms) = detail.as_deref()
                        .and_then(|d| d.strip_prefix("builtins:"))
                        .and_then(|r| r.strip_suffix("ms"))
                        .and_then(|n| n.parse::<u64>().ok())
                    {
                        self.timings.builtin_apps_build_ms = Some(ms);
                    }
                } else if let Some(started) = self.timings.build_started_at.take() {
                    self.timings.last_build_ms = Some(started.elapsed().as_millis() as u64);
                    self.timings.build_count += 1;
                }
            }
            (LogSource::Build, ServiceStatus::Failed(_)) => {
                if let Some(started) = self.timings.build_started_at.take() {
                    self.timings.last_build_ms = Some(started.elapsed().as_millis() as u64);
                    // Count failed builds too so the developer can see retry patterns.
                    self.timings.build_count += 1;
                }
            }
            (LogSource::Daemon, ServiceStatus::Building) => {
                self.timings.platform_build_started_at = Some(Instant::now());
            }
            (LogSource::Daemon, ServiceStatus::Starting) => {
                if let Some(started) = self.timings.platform_build_started_at.take() {
                    self.timings.platform_build_ms = Some(started.elapsed().as_millis() as u64);
                }
                self.timings.daemon_started_at = Some(Instant::now());
            }
            (LogSource::App, ServiceStatus::Loaded { .. }) => {
                // cycle() sends detail = "last reload: Xms"; extract the ms value.
                if let Some(d) = &detail {
                    if let Some(rest) = d.strip_prefix("last reload: ") {
                        if let Some(ms_str) = rest.strip_suffix("ms") {
                            if let Ok(ms) = ms_str.parse::<u64>() {
                                self.timings.last_reload_ms = Some(ms);
                            }
                        }
                    }
                }
            }
            _ => {}
        }

        // Detect which instance this update belongs to (from "[name]" prefix in detail).
        let instance_name = detail.as_deref().and_then(|d| {
            let inner = d.strip_prefix('[')?;
            let (name, _) = inner.split_once(']')?;
            if self.instance_names.iter().any(|n| n == name) {
                Some(name.to_string())
            } else {
                None
            }
        });

        // Multi-instance platform mode: also surface `[<node>] `-prefixed
        // updates in the nodes sidebar. Only Daemon/UiServer events carry
        // meaningful per-node info; Build/App/System are shared and stay
        // out of the per-node display.
        if matches!(source, LogSource::Daemon | LogSource::UiServer) {
            if let Some(d) = detail.as_deref() {
                if let Some((node_name, rest)) =
                    d.strip_prefix('[').and_then(|inner| inner.split_once(']'))
                {
                    if self.tracked_nodes.iter().any(|n| n == node_name) {
                        if let Some(entry) =
                            self.node_list.iter_mut().find(|a| a.name == node_name)
                        {
                            // Daemon updates carry the canonical status; UiServer
                            // updates only refresh the detail line.
                            if source == LogSource::Daemon {
                                entry.status = status.clone();
                            }
                            entry.detail = rest.trim_start().to_string();
                        }
                    }
                }
            }
        }

        // Update global service state.
        if let Some((_, svc)) = self.services.iter_mut().find(|(s, _)| *s == source) {
            svc.status = status.clone();
            if let Some(d) = detail.as_ref() {
                svc.detail = d.clone();
            }
        }

        match &instance_name {
            Some(name) => {
                // Instance-tagged update (e.g. daemon, ui-server from MonorepoHost).
                // Write to that instance's slot; strip the "[name] " prefix from the
                // detail so it shows cleanly in the per-instance left panel.
                if let Some(inst_svcs) = self.instance_services.get_mut(name) {
                    if let Some((_, svc)) = inst_svcs.iter_mut().find(|(s, _)| *s == source) {
                        svc.status = status;
                        if let Some(d) = detail {
                            let prefix = format!("[{name}] ");
                            svc.detail = d.strip_prefix(&prefix).unwrap_or(&d).to_string();
                        }
                    }
                }
            }
            None => {
                // Untagged update (build, app, system — shared across all instances).
                // Fan out to every instance slot so the per-instance view stays current.
                let names: Vec<String> = self.instance_names.clone();
                for inst_name in &names {
                    if let Some(inst_svcs) = self.instance_services.get_mut(inst_name) {
                        if let Some((_, svc)) = inst_svcs.iter_mut().find(|(s, _)| *s == source) {
                            svc.status = status.clone();
                            if let Some(ref d) = detail {
                                svc.detail = d.clone();
                            }
                        }
                    }
                }
            }
        }
    }

    /// Returns the service list and timings to display in the left panel.
    /// When an instance filter is active, returns that instance's own data.
    pub fn active_services(&self) -> &[(LogSource, ServiceState)] {
        if let Some(name) = self.instance_filter_label() {
            if let Some(svcs) = self.instance_services.get(name) {
                return svcs.as_slice();
            }
        }
        &self.services
    }

    pub fn active_timings(&self) -> &Timings {
        if let Some(name) = self.instance_filter_label() {
            if let Some(t) = self.instance_timings.get(name) {
                return t;
            }
        }
        &self.timings
    }

    pub fn log_lines(&self, source: LogSource) -> &VecDeque<String> {
        static EMPTY: std::sync::OnceLock<VecDeque<String>> = std::sync::OnceLock::new();
        let empty = EMPTY.get_or_init(VecDeque::new);
        // Node filter (multi-instance platform mode) wins over the legacy
        // instance filter: the user explicitly picked a node, and a per-node
        // view is the most useful drill-down. The inner [<app>] prefix is
        // preserved in node_logs so per-app distinction stays visible.
        if let Some(name) = self.focused_node_name() {
            return self.node_logs
                .get(name)
                .and_then(|m| m.get(&source))
                .unwrap_or(empty);
        }
        if let Some(name) = self.instance_filter_label() {
            return self.instance_logs
                .get(name)
                .and_then(|m| m.get(&source))
                .unwrap_or(empty);
        }
        self.logs.get(&source).unwrap_or(empty)
    }

    /// Returns the first visible line index for a given pane height and total
    /// (which may be the filtered total when a query is active).
    pub fn visible_start_for(&self, total: usize, height: usize) -> usize {
        if self.auto_scroll {
            total.saturating_sub(height)
        } else {
            self.scroll_pos.min(total.saturating_sub(height))
        }
    }

    pub fn scroll_up(&mut self) {
        if self.auto_scroll {
            let total = self.log_lines(self.active_pane).len();
            self.scroll_pos = total;
            self.auto_scroll = false;
        }
        self.scroll_pos = self.scroll_pos.saturating_sub(1);
    }

    pub fn scroll_down(&mut self) {
        if !self.auto_scroll {
            let total = self.log_lines(self.active_pane).len();
            self.scroll_pos = (self.scroll_pos + 1).min(total);
        }
    }

    pub fn page_up(&mut self) {
        let step = (self.last_render_height / 2).max(1);
        if self.auto_scroll {
            let total = self.log_lines(self.active_pane).len();
            self.scroll_pos = total;
            self.auto_scroll = false;
        }
        self.scroll_pos = self.scroll_pos.saturating_sub(step);
    }

    pub fn page_down(&mut self) {
        if !self.auto_scroll {
            let step = (self.last_render_height / 2).max(1);
            let total = self.log_lines(self.active_pane).len();
            self.scroll_pos = (self.scroll_pos + step).min(total);
        }
    }

    pub fn scroll_top(&mut self) {
        self.auto_scroll = false;
        self.scroll_pos = 0;
    }

    pub fn scroll_bottom(&mut self) {
        self.auto_scroll = true;
    }

    pub fn clear_active_pane(&mut self) {
        if let Some(name) = self.instance_filter_label().map(str::to_owned) {
            if let Some(m) = self.instance_logs.get_mut(&name) {
                m.remove(&self.active_pane);
            }
        } else {
            self.logs.remove(&self.active_pane);
        }
        self.scroll_pos = 0;
        self.auto_scroll = true;
        self.clear_search();
    }

    // ── Search / filter ───────────────────────────────────────────────────────

    /// Open the search input box.
    pub fn enter_search(&mut self) {
        self.search_input_active = true;
    }

    /// Close the input box but leave the filter active.
    pub fn exit_search_input(&mut self) {
        self.search_input_active = false;
    }

    /// Clear the query, close the input box, and reset the instance filter.
    pub fn clear_search(&mut self) {
        self.search_query.clear();
        self.search_input_active = false;
        self.instance_filter = None;
        self.auto_scroll = true;
    }

    /// Append a character to the search query.
    pub fn search_push(&mut self, c: char) {
        self.search_query.push(c);
        self.scroll_pos = 0;
        self.auto_scroll = false;
    }

    /// Remove the last character from the query.
    pub fn search_backspace(&mut self) {
        self.search_query.pop();
        self.scroll_pos = 0;
        if self.search_query.is_empty() {
            self.auto_scroll = true;
        }
    }

    /// Returns `true` if `line` matches the current query (fuzzy, case-insensitive).
    /// Always returns `true` when the query is empty.
    pub fn matches(&self, line: &str) -> bool {
        fuzzy_match(line, &self.search_query)
    }

    /// For a line that matches the query, returns the byte indices of every
    /// matched character (for highlight rendering). Returns `None` if no match.
    pub fn match_positions(&self, line: &str) -> Option<Vec<usize>> {
        if self.search_query.is_empty() {
            return None;
        }
        fuzzy_positions(line, &self.search_query)
    }

    /// Collect the currently selected log lines as a newline-separated string.
    pub fn get_selected_text(&self) -> String {
        let sel = match self.selection {
            Some(s) => s,
            None => return String::new(),
        };
        let (start, end) = sel.range();
        self.log_lines(self.active_pane)
            .iter()
            .enumerate()
            .skip(start)
            .take_while(|(i, _)| *i <= end)
            .map(|(_, l)| l.as_str())
            .collect::<Vec<_>>()
            .join("\n")
    }
}

/// Return the Nth visible service in the tab strip, applying the same
/// "hide app row in platform mode" filter as the renderer. Used by the
/// number-key handler and the tab-strip click handler so both agree with
/// what the user sees on screen.
pub fn nth_visible_service(app: &AppState, n: usize) -> Option<LogSource> {
    let hide_app = !app.app_list.is_empty();
    app.active_services()
        .iter()
        .map(|(s, _)| *s)
        .filter(|s| !(hide_app && *s == LogSource::App))
        .nth(n)
}

impl AppState {
    /// 1-based number assigned to `source` in the rendered services tab
    /// strip, or `None` when the source is hidden (e.g. the App row in
    /// platform mode). Source of truth for any UI element that wants to
    /// show the same number the tab strip displays.
    pub fn service_number(&self, source: LogSource) -> Option<usize> {
        let hide_app = !self.app_list.is_empty();
        self.active_services()
            .iter()
            .map(|(s, _)| *s)
            .filter(|s| !(hide_app && *s == LogSource::App))
            .position(|s| s == source)
            .map(|i| i + 1)
    }

    /// Pretty `N:label` (e.g. `2:daemon`) when the source is visible in
    /// the tab strip, else just the bare label. Used everywhere the user
    /// sees a "current pane" indicator so the numbers stay in lockstep
    /// with what the top bar shows.
    pub fn numbered_label(&self, source: LogSource) -> String {
        let label = source.tab_label();
        match self.service_number(source) {
            Some(n) => format!("{n}:{label}"),
            None => label.to_string(),
        }
    }
}

// ── Bracket-prefix peeling ────────────────────────────────────────────────────

/// Try to peel a single `[<name>] ` prefix off the start of `line`. Returns
/// the name and the byte offset just past the trailing space if successful.
fn peel_one_bracket_prefix(line: &str) -> Option<(&str, usize)> {
    let rest = line.strip_prefix('[')?;
    let close_idx = rest.find(']')?;
    let name = &rest[..close_idx];
    // The full prefix is `[` + name + `]` + ` ` — require the trailing space
    // so we don't accidentally peel JSON-style `[123]nope`.
    let after_close = 1 + close_idx + 1; // `[` + name + `]`
    let mut chars = line[after_close..].chars();
    if chars.next() != Some(' ') {
        return None;
    }
    Some((name, after_close + 1))
}

/// A matched bracket prefix: `(name, end_offset)`. `end_offset` is the byte
/// index immediately after the trailing space of the matched prefix.
pub type PrefixMatch = Option<(String, usize)>;

/// Classify the leading bracket prefixes of a log line into (node_match,
/// instance_match). Lines may carry an outer `[<node>] ` prefix followed by
/// an inner `[<app>] ` prefix (the multi-instance platform-mode tail_logs
/// format), or just one prefix, or none.
fn peel_prefixes(
    line: &str,
    tracked_nodes: &[String],
    tracked_instances: &[String],
) -> (PrefixMatch, PrefixMatch) {
    let mut node_match = None;
    let mut instance_match = None;

    if let Some((name, after_idx)) = peel_one_bracket_prefix(line) {
        if tracked_nodes.iter().any(|n| n == name) {
            node_match = Some((name.to_string(), after_idx));
            // Look for an inner [<app>] prefix after the node prefix.
            if let Some((name2, after_idx2)) = peel_one_bracket_prefix(&line[after_idx..]) {
                if tracked_instances.iter().any(|n| n == name2) {
                    instance_match = Some((name2.to_string(), after_idx + after_idx2));
                }
            }
        } else if tracked_instances.iter().any(|n| n == name) {
            instance_match = Some((name.to_string(), after_idx));
        }
    }

    (node_match, instance_match)
}

// ── ANSI stripping ────────────────────────────────────────────────────────────

/// Remove ANSI/VT escape sequences and bare `\r` from a log line.
///
/// Handles:
/// - CSI sequences: `ESC [ <params> <final-byte>`  (colours, cursor movement)
/// - OSC sequences: `ESC ] … ST`                   (title sets, hyperlinks)
/// - Bare `\r`                                      (cargo progress-bar rewind)
fn strip_ansi(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut it = s.chars().peekable();
    while let Some(c) = it.next() {
        match c {
            '\x1b' => match it.peek() {
                // CSI: ESC [ <param bytes 0x30-0x3f> <intermediate 0x20-0x2f> <final 0x40-0x7e>
                Some('[') => {
                    it.next();
                    for ch in it.by_ref() {
                        if ch.is_ascii() && (0x40..=0x7e).contains(&(ch as u8)) {
                            break;
                        }
                    }
                }
                // OSC: ESC ] … BEL  or  … ESC \
                Some(']') => {
                    it.next();
                    loop {
                        match it.next() {
                            None | Some('\x07') => break,
                            Some('\x1b') => {
                                if it.peek() == Some(&'\\') {
                                    it.next();
                                }
                                break;
                            }
                            _ => {}
                        }
                    }
                }
                // Any other ESC — skip the ESC byte itself; let next char through.
                _ => {}
            },
            // Carriage return — cargo uses \r to rewrite progress lines.
            // We treat it as a line-reset: discard everything written so far on
            // this "visual line" (i.e. clear `out`) so only the final state shows.
            '\r' => out.clear(),
            _ => out.push(c),
        }
    }
    out
}

// ── Fuzzy matching ─────────────────────────────────────────────────────────────

/// True if every character in `query` appears in `line` in order (case-insensitive).
pub fn fuzzy_match(line: &str, query: &str) -> bool {
    if query.is_empty() {
        return true;
    }
    let mut line_chars = line.chars().flat_map(char::to_lowercase);
    query
        .chars()
        .flat_map(char::to_lowercase)
        .all(|q| line_chars.any(|c| c == q))
}

/// Returns the *char* indices (into `line`) of matched query characters.
/// Returns `None` if the line doesn't match.
pub fn fuzzy_positions(line: &str, query: &str) -> Option<Vec<usize>> {
    if query.is_empty() {
        return Some(vec![]);
    }
    let line_chars: Vec<char> = line.chars().collect();
    let query_lower: Vec<char> = query.chars().flat_map(char::to_lowercase).collect();

    let mut positions = Vec::with_capacity(query_lower.len());
    let mut li = 0usize;

    for qc in &query_lower {
        let found = line_chars[li..].iter().enumerate().find(|(_, lc)| {
            lc.to_lowercase().next() == Some(*qc)
        });
        match found {
            Some((offset, _)) => {
                positions.push(li + offset);
                li += offset + 1;
            }
            None => return None,
        }
    }
    Some(positions)
}
