// Copyright 2025 Lablup Inc. and Jeongkyu Shin
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

//! Frame content assembly operating on a `RenderSnapshot`.
//!
//! All methods here are pure functions: they read the snapshot and produce
//! a `String` (or write into a `BufferWriter`) without touching shared state.
//! This means the `AppState` mutex is not held during any of this work.

use std::borrow::Cow;
use std::collections::HashMap;
use std::io::Write;

use chrono::Local;
use crossterm::{
    queue,
    style::{Color, Print},
};

use crate::app_state::AppState;
use crate::cli::ViewArgs;
use crate::device::ProcessInfo;
use crate::ui::activity_panel;
use crate::ui::buffer::BufferWriter;
use crate::ui::dashboard::{draw_dashboard_items, draw_system_view};
use crate::ui::gpu_sparkline_panel;
use crate::ui::layout::LayoutCalculator;
use crate::ui::local_header::draw_local_header_bar;
use crate::ui::renderer::{
    print_chassis_info, print_cpu_info, print_function_keys, print_gpu_info,
    print_loading_indicator, print_memory_info, print_mig_section, print_process_info,
    print_storage_info, print_vgpu_section,
};
use crate::ui::renderers::print_chassis_energy_row;
use crate::ui::tabs::draw_tabs;
use crate::ui::text::print_colored_text;
use crate::view::render_snapshot::RenderSnapshot;
use crate::view::view_cache::ViewCache;

/// Stateless frame renderer that operates on a `RenderSnapshot`.
///
/// This struct holds no mutable state of its own. Each method takes an
/// immutable snapshot and returns the assembled frame content as a `String`.
pub struct FrameRenderer;

impl FrameRenderer {
    /// Render help popup content from the snapshot.
    pub fn render_help(snapshot: &RenderSnapshot, args: &ViewArgs, cols: u16, rows: u16) -> String {
        let is_remote = args.hosts.is_some() || args.hostfile.is_some();
        let view_state = snapshot.as_app_state();
        crate::ui::help::generate_help_popup_content(cols, rows, &view_state, is_remote)
    }

    /// Render the alert history panel (`A` key). Lists the most recent
    /// transitions newest-first with their timestamp, host, rule, and
    /// transition.
    pub fn render_alert_panel(snapshot: &RenderSnapshot, cols: u16, rows: u16) -> String {
        let mut buffer = BufferWriter::new();
        let width = cols as usize;
        let title = "Alert History (press A or ESC to close)";
        let header = format!(" {title:<width$}");
        print_colored_text(
            &mut buffer,
            &header,
            Color::Black,
            Some(Color::Yellow),
            None,
        );
        queue!(buffer, Print("\r\n")).unwrap();
        if snapshot.alert_history.is_empty() {
            let msg = "No alert transitions yet. Thresholds are configured — keep \
                      monitoring.";
            print_colored_text(&mut buffer, msg, Color::DarkGrey, None, None);
            queue!(buffer, Print("\r\n")).unwrap();
        } else {
            let limit = (rows as usize).saturating_sub(3).max(1);
            for t in snapshot.alert_history.iter().take(limit) {
                let ts = t.timestamp.format("%H:%M:%S").to_string();
                let line = format!("  {ts}  {msg}", msg = t.message);
                let color = match t.to {
                    crate::ui::alerts::AlertLevel::Crit => Color::Red,
                    crate::ui::alerts::AlertLevel::Warn => Color::Yellow,
                    crate::ui::alerts::AlertLevel::Ok => Color::Green,
                };
                print_colored_text(&mut buffer, &line, color, None, None);
                queue!(buffer, Print("\r\n")).unwrap();
            }
        }
        buffer.get_buffer().to_string()
    }

    /// Render loading screen content from the snapshot.
    pub fn render_loading(
        snapshot: &RenderSnapshot,
        is_remote: bool,
        cols: u16,
        rows: u16,
    ) -> String {
        let mut buffer = BufferWriter::new();
        let view_state = snapshot.as_app_state();
        print_function_keys(&mut buffer, cols, rows, &view_state, is_remote);
        print_loading_indicator(
            &mut buffer,
            cols,
            rows,
            snapshot.frame_counter,
            &snapshot.startup_status_lines,
        );
        buffer.get_buffer().to_string()
    }

    /// Render main content (the primary monitoring view) from the snapshot.
    ///
    /// When a `ViewCache` is provided, pre-computed sorted/filtered indices
    /// are used instead of re-sorting and re-filtering on every frame.
    /// Render the main TUI view.
    ///
    /// Returns `(content, visible_process_rows)` where `visible_process_rows`
    /// is the actual number of process rows that fit on screen. The caller
    /// should store this value so the event handler can scroll correctly.
    pub fn render_main(
        snapshot: &RenderSnapshot,
        args: &ViewArgs,
        cols: u16,
        rows: u16,
        cache: Option<&ViewCache>,
    ) -> (String, usize) {
        let width = cols as usize;
        let mut buffer = BufferWriter::new();

        let view_state = snapshot.as_app_state();

        // Write time/date header to buffer first
        let current_time = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
        let version = env!("CARGO_PKG_VERSION");
        let header_text = format!("all-smi - {current_time}");
        let version_text = format!("v{version}");

        // Get runtime environment info
        let runtime_shield =
            if let Some((name, color)) = snapshot.runtime_environment.display_info() {
                let shield_content = format!(" {name} ");
                let shield_len = shield_content.len();
                Some((shield_content, color, shield_len))
            } else {
                None
            };

        // Calculate spacing to right-align version, accounting for runtime shield
        let total_width = cols as usize;
        let runtime_shield_len = runtime_shield
            .as_ref()
            .map(|(_, _, len)| len + 1)
            .unwrap_or(0);
        let content_length = header_text.len() + runtime_shield_len + version_text.len();
        let spacing = if total_width > content_length {
            " ".repeat(total_width - content_length)
        } else {
            " ".to_string()
        };

        // Print header with runtime environment shield
        print_colored_text(&mut buffer, &header_text, Color::White, None, None);

        if let Some((shield_content, shield_color, _)) = runtime_shield {
            print_colored_text(&mut buffer, " ", Color::White, None, None);
            print_colored_text(
                &mut buffer,
                &shield_content,
                Color::Black,
                Some(shield_color),
                None,
            );
        }

        print_colored_text(
            &mut buffer,
            &format!("{spacing}{version_text}\r\n"),
            Color::White,
            None,
            None,
        );

        let is_remote = args.hosts.is_some() || args.hostfile.is_some();

        // Cluster Overview, dashboard items, and the tabs row are only meaningful
        // when monitoring multiple remote hosts. `is_local_mode` is false the moment
        // any --hosts / --hostfile argument is supplied (see the assignment sites in
        // `src/view/runner.rs::run_view_mode` / `run_local_mode`), so a single remote
        // host still shows these widgets.
        //
        // In local mode we show the compact two-line host summary bar instead.
        if view_state.is_local_mode {
            draw_local_header_bar(&mut buffer, &view_state, cols);

            // Activity panel: CPU per-core bars (left) + GPU sparklines (right)
            if activity_panel::should_show_panel(cols) {
                gpu_sparkline_panel::render_combined_activity_panel(
                    &mut buffer,
                    &view_state,
                    &snapshot.cpu_info,
                    width,
                );
            }
        } else {
            // Write remaining header content to buffer
            print_colored_text(&mut buffer, "Cluster Overview\r\n", Color::Cyan, None, None);
            draw_system_view(&mut buffer, &view_state, cols);

            draw_dashboard_items(&mut buffer, &view_state, cols);
            draw_tabs(&mut buffer, &view_state, cols);
        }

        // Users tab (issue #189) owns its own section and skips the
        // normal GPU / chassis / device pipeline.  The tab is only
        // present in remote / replay mode; local mode never inserts
        // the `"Users"` entry into `AppState::tabs`.
        let on_users_tab = is_users_tab_selected(snapshot);
        if on_users_tab {
            // Budget: rows minus function-key footer minus the 4-row
            // heading we already printed above.
            let heading_rows = buffer.line_count() as u16;
            let avail = rows.saturating_sub(heading_rows).saturating_sub(1).max(1);
            crate::ui::renderers::user_renderer::render_users_tab(
                &mut buffer,
                &snapshot.users_aggregation,
                &snapshot.users_tab_state,
                &snapshot.remote_process_info,
                cols,
                avail,
            );
            print_function_keys(&mut buffer, cols, rows, &view_state, is_remote);
            return (buffer.get_buffer().to_string(), 0);
        }

        // Topology tab (issue #190): similar short-circuit — owns the
        // body of the frame, emits its own header, and skips the normal
        // GPU / chassis / device pipeline.
        if is_topology_tab_selected(snapshot) {
            let target_host = topology_target_host(snapshot);
            crate::ui::renderers::topology_renderer::render_topology_tab(
                &mut buffer,
                &snapshot.gpu_info,
                &target_host,
                snapshot.topology_view_mode,
                cols,
                rows,
            );
            print_function_keys(&mut buffer, cols, rows, &view_state, is_remote);
            return (buffer.get_buffer().to_string(), 0);
        }

        // Render chassis information (node-level metrics)
        Self::render_chassis_section(&mut buffer, snapshot, width, cache);

        // Render GPU information (reuse the single view_state for layout calculation)
        Self::render_gpu_section(&mut buffer, snapshot, &view_state, args, cols, rows, cache);

        // Render other device information based on mode
        let visible_process_rows = if is_remote {
            Self::render_remote_devices(&mut buffer, snapshot, width, cache);
            0
        } else {
            Self::render_local_devices(&mut buffer, snapshot, cols, rows, cache)
        };

        // Add function keys to main content view
        print_function_keys(&mut buffer, cols, rows, &view_state, is_remote);

        (buffer.get_buffer().to_string(), visible_process_rows)
    }

    fn render_gpu_section(
        buffer: &mut BufferWriter,
        snapshot: &RenderSnapshot,
        view_state: &AppState,
        args: &ViewArgs,
        cols: u16,
        rows: u16,
        cache: Option<&ViewCache>,
    ) {
        // Use cached sorted indices when available, otherwise fall back to
        // the previous per-frame filter + sort path.
        let cached_indices;
        let fallback_indices;
        let display_indices: &[usize] = if let Some(indices) = cache.and_then(|c| c.gpu_indices()) {
            cached_indices = indices;
            cached_indices
        } else {
            // Fallback: filter + sort inline (only reached when cache is None).
            // Use .get() to guard against out-of-bounds current_tab.
            let mut indices: Vec<usize> =
                if let Some(tab_name) = snapshot.tabs.get(snapshot.current_tab) {
                    if tab_name == "All" {
                        (0..snapshot.gpu_info.len()).collect()
                    } else {
                        snapshot
                            .gpu_info
                            .iter()
                            .enumerate()
                            .filter(|(_, info)| info.host_id == *tab_name)
                            .map(|(i, _)| i)
                            .collect()
                    }
                } else {
                    // Out-of-bounds tab index: show all (defensive)
                    (0..snapshot.gpu_info.len()).collect()
                };
            indices.sort_by(|&a, &b| {
                snapshot
                    .sort_criteria
                    .sort_gpus(&snapshot.gpu_info[a], &snapshot.gpu_info[b])
            });
            fallback_indices = indices;
            &fallback_indices
        };

        // Calculate content area and GPU display parameters using the shared
        // view_state from render_main, avoiding a second as_app_state() call.
        let content_area = LayoutCalculator::calculate_content_area(view_state, cols, rows);
        let gpu_display_params =
            LayoutCalculator::calculate_gpu_display_params(view_state, args, &content_area);
        let max_gpu_items = gpu_display_params.max_items;

        // Display GPUs with scrolling
        let start_gpu_index = snapshot.gpu_scroll_offset;
        let end_gpu_index = (start_gpu_index + max_gpu_items).min(display_indices.len());

        // Build O(1) lookup maps for vGPU and MIG matching, replacing the
        // previous per-GPU linear scans that were O(G*V) + O(G*M) per frame.
        let vgpu_lookup = build_vgpu_lookup(&snapshot.vgpu_info);
        let mig_lookup = build_mig_lookup(&snapshot.mig_info);

        for (i, &gpu_idx) in display_indices
            .iter()
            .enumerate()
            .skip(start_gpu_index)
            .take(end_gpu_index.saturating_sub(start_gpu_index))
        {
            let gpu_info = &snapshot.gpu_info[gpu_idx];
            let device_name_scroll_offset = snapshot
                .device_name_scroll_offsets
                .get(&gpu_info.uuid)
                .copied()
                .unwrap_or(0);
            let hostname_scroll_offset = snapshot
                .host_id_scroll_offsets
                .get(&gpu_info.host_id)
                .copied()
                .unwrap_or(0);

            // Filter matching (issue #186). Non-matching rows are dimmed
            // by default and hidden when `filter_hide_nonmatching` is on.
            let matched = crate::ui::filter_dsl::apply(snapshot.filter_query.as_ref(), gpu_info);
            if !matched && snapshot.filter_hide_nonmatching {
                continue;
            }

            // Border-flash support: when this GPU has a pending alert
            // flash we prefix/postfix the row with red marker characters.
            // Alternating at ~1 Hz is driven by `frame_counter`.
            let flashing = snapshot.alerter.is_flashing(gpu_info.uuid.as_str())
                && (snapshot.frame_counter / 10).is_multiple_of(2);

            if matched && !flashing {
                // Fast path: no filter mismatch, no flash — emit directly.
                print_gpu_info(
                    buffer,
                    i,
                    gpu_info,
                    cols as usize,
                    device_name_scroll_offset,
                    hostname_scroll_offset,
                    !view_state.is_local_mode,
                );
                if let Some(vgpu_host) =
                    lookup_vgpu_host(&vgpu_lookup, &snapshot.vgpu_info, gpu_info)
                {
                    print_vgpu_section(buffer, vgpu_host, cols as usize);
                }
                if let Some(mig_host) = lookup_mig_gpu(&mig_lookup, &snapshot.mig_info, gpu_info) {
                    print_mig_section(buffer, mig_host, cols as usize);
                }
            } else {
                // Render into a scratch buffer so we can post-process
                // (dim for filter-mismatch, prefix for flash) before the
                // bytes reach the main output.
                let mut scratch = BufferWriter::new();
                print_gpu_info(
                    &mut scratch,
                    i,
                    gpu_info,
                    cols as usize,
                    device_name_scroll_offset,
                    hostname_scroll_offset,
                    !view_state.is_local_mode,
                );
                if let Some(vgpu_host) =
                    lookup_vgpu_host(&vgpu_lookup, &snapshot.vgpu_info, gpu_info)
                {
                    print_vgpu_section(&mut scratch, vgpu_host, cols as usize);
                }
                if let Some(mig_host) = lookup_mig_gpu(&mig_lookup, &snapshot.mig_info, gpu_info) {
                    print_mig_section(&mut scratch, mig_host, cols as usize);
                }

                let raw = scratch.get_buffer().to_string();
                let processed = if !matched {
                    crate::ui::renderers::dim::dim_ansi(&raw)
                } else {
                    raw
                };

                if flashing {
                    // Cheap border marker: bright red `!` in the first
                    // column of every line of the GPU block for 2 s.
                    let marker = "\x1b[1;31m!\x1b[0m ";
                    let with_marker = processed
                        .split_inclusive('\n')
                        .map(|line| format!("{marker}{line}"))
                        .collect::<String>();
                    write!(buffer, "{with_marker}").ok();
                } else {
                    write!(buffer, "{processed}").ok();
                }
            }
        }
    }

    fn render_chassis_section(
        buffer: &mut BufferWriter,
        snapshot: &RenderSnapshot,
        width: usize,
        cache: Option<&ViewCache>,
    ) {
        if snapshot.chassis_info.is_empty() {
            return;
        }

        // Use cached chassis indices when available
        if let Some(hd) = cache.and_then(|c| c.host_device_indices()) {
            if hd.chassis_indices.is_empty() {
                return;
            }
            for (i, &idx) in hd.chassis_indices.iter().enumerate() {
                let chassis = &snapshot.chassis_info[idx];
                let hostname_scroll_offset = snapshot
                    .host_id_scroll_offsets
                    .get(&chassis.host_id)
                    .copied()
                    .unwrap_or(0);
                print_chassis_info(buffer, i, chassis, width, hostname_scroll_offset);
                // Energy session + cost row (issue #191). Self-hides
                // when no chassis samples have been recorded yet.
                print_chassis_energy_row(
                    buffer,
                    chassis,
                    snapshot.energy.integrator(),
                    &snapshot.energy_config,
                );
            }
            return;
        }

        // Fallback: filter inline (only reached when cache is None)
        let chassis_to_display: Vec<_> = if snapshot.is_local_mode {
            snapshot.chassis_info.iter().collect()
        } else if snapshot.current_tab == 0 {
            return;
        } else if snapshot.current_tab < snapshot.tabs.len() {
            let current_host = &snapshot.tabs[snapshot.current_tab];
            snapshot
                .chassis_info
                .iter()
                .filter(|c| c.host_id == *current_host || c.hostname == *current_host)
                .collect()
        } else {
            snapshot.chassis_info.iter().collect()
        };

        for (i, chassis) in chassis_to_display.iter().enumerate() {
            let hostname_scroll_offset = snapshot
                .host_id_scroll_offsets
                .get(&chassis.host_id)
                .copied()
                .unwrap_or(0);
            print_chassis_info(buffer, i, chassis, width, hostname_scroll_offset);
            // Energy session + cost row (issue #191). Self-hides when no
            // chassis samples have been recorded yet.
            print_chassis_energy_row(
                buffer,
                chassis,
                snapshot.energy.integrator(),
                &snapshot.energy_config,
            );
        }
    }

    fn render_remote_devices(
        buffer: &mut BufferWriter,
        snapshot: &RenderSnapshot,
        width: usize,
        cache: Option<&ViewCache>,
    ) {
        if snapshot.current_tab == 0 || snapshot.current_tab >= snapshot.tabs.len() {
            return;
        }

        let current_hostname = &snapshot.tabs[snapshot.current_tab];

        // Check connection status for the current node
        let is_connected = if let Some(host_id) = snapshot.hostname_to_host_id.get(current_hostname)
        {
            snapshot
                .connection_status
                .get(host_id)
                .map(|status| status.is_connected)
                .unwrap_or(false)
        } else {
            snapshot
                .connection_status
                .get(current_hostname)
                .map(|status| status.is_connected)
                .unwrap_or(true)
        };

        if !is_connected {
            Self::render_disconnection_notification(buffer, current_hostname, width);
            return;
        }

        // Resolve host-device indices: use cache when available, otherwise
        // build a temporary index list from an inline filter.
        let fallback_cpu;
        let fallback_mem;
        let fallback_stor;
        let (cpu_idx, mem_idx, stor_idx) = if let Some(hd) =
            cache.and_then(|c| c.host_device_indices())
        {
            (
                hd.cpu_indices.as_slice(),
                hd.memory_indices.as_slice(),
                hd.storage_indices.as_slice(),
            )
        } else {
            fallback_cpu =
                Self::filter_indices(&snapshot.cpu_info, |c| c.host_id == *current_hostname);
            fallback_mem =
                Self::filter_indices(&snapshot.memory_info, |m| m.host_id == *current_hostname);
            fallback_stor =
                Self::filter_indices(&snapshot.storage_info, |s| s.host_id == *current_hostname);
            (
                fallback_cpu.as_slice(),
                fallback_mem.as_slice(),
                fallback_stor.as_slice(),
            )
        };

        // CPU
        for (i, &idx) in cpu_idx.iter().enumerate() {
            let cpu_info = &snapshot.cpu_info[idx];
            let cpu_name_scroll_offset = snapshot
                .cpu_name_scroll_offsets
                .get(&format!("{}-{}", cpu_info.hostname, cpu_info.cpu_model))
                .copied()
                .unwrap_or(0);
            let hostname_scroll_offset = snapshot
                .host_id_scroll_offsets
                .get(&cpu_info.host_id)
                .copied()
                .unwrap_or(0);
            print_cpu_info(
                buffer,
                i,
                cpu_info,
                width,
                false,
                cpu_name_scroll_offset,
                hostname_scroll_offset,
                true,
            );
        }

        // Memory
        for (i, &idx) in mem_idx.iter().enumerate() {
            let memory_info = &snapshot.memory_info[idx];
            let hostname_scroll_offset = snapshot
                .host_id_scroll_offsets
                .get(&memory_info.host_id)
                .copied()
                .unwrap_or(0);
            print_memory_info(buffer, i, memory_info, width, hostname_scroll_offset, true);
        }

        // Storage with scroll offset
        for (i, &idx) in stor_idx
            .iter()
            .skip(snapshot.storage_scroll_offset)
            .take(10)
            .enumerate()
        {
            let storage_info = &snapshot.storage_info[idx];
            let hostname_scroll_offset = snapshot
                .host_id_scroll_offsets
                .get(&storage_info.host_id)
                .copied()
                .unwrap_or(0);
            print_storage_info(buffer, i, storage_info, width, hostname_scroll_offset, true);
        }
    }

    /// Collect indices of elements matching a predicate.
    fn filter_indices<T>(items: &[T], predicate: impl Fn(&T) -> bool) -> Vec<usize> {
        items
            .iter()
            .enumerate()
            .filter(|(_, item)| predicate(item))
            .map(|(i, _)| i)
            .collect()
    }

    fn render_disconnection_notification(buffer: &mut BufferWriter, hostname: &str, width: usize) {
        writeln!(buffer).unwrap();
        writeln!(buffer).unwrap();

        let box_width = width.saturating_sub(4).min(60);
        // Ensure minimum box width for the border characters
        if box_width < 6 {
            return;
        }
        let margin = width.saturating_sub(box_width) / 2;
        let margin_str = " ".repeat(margin);

        // Top border
        write!(buffer, "{margin_str}").unwrap();
        print_colored_text(buffer, "\u{250c}", Color::Red, None, None);
        print_colored_text(
            buffer,
            &"\u{2500}".repeat(box_width.saturating_sub(2)),
            Color::Red,
            None,
            None,
        );
        print_colored_text(buffer, "\u{2510}", Color::Red, None, None);
        writeln!(buffer).unwrap();

        // Content rows: title, blank, hostname, status, blank
        let rows: &[(&str, Color)] = &[
            ("CONNECTION LOST", Color::Red),
            ("", Color::White),
            (&format!("Node: {hostname}"), Color::Yellow),
            ("Unable to retrieve node information", Color::DarkGrey),
            ("", Color::White),
        ];
        // Inner width available for text content (between "| " and " |")
        let inner_width = box_width.saturating_sub(4);
        for (text, color) in rows {
            write!(buffer, "{margin_str}").unwrap();
            if text.is_empty() {
                // Empty row
                print_colored_text(buffer, "\u{2502}", Color::Red, None, None);
                print_colored_text(
                    buffer,
                    &" ".repeat(box_width.saturating_sub(2)),
                    Color::White,
                    None,
                    None,
                );
                print_colored_text(buffer, "\u{2502}", Color::Red, None, None);
            } else {
                // Truncate text if it exceeds available inner width
                let display_text: Cow<'_, str> = if text.len() > inner_width {
                    Cow::Owned(text.chars().take(inner_width).collect())
                } else {
                    Cow::Borrowed(text)
                };
                let pad_left = inner_width.saturating_sub(display_text.len()) / 2;
                let pad_right = inner_width.saturating_sub(pad_left + display_text.len());
                print_colored_text(buffer, "\u{2502} ", Color::Red, None, None);
                print_colored_text(buffer, &" ".repeat(pad_left), Color::White, None, None);
                print_colored_text(buffer, &display_text, *color, None, None);
                print_colored_text(buffer, &" ".repeat(pad_right), Color::White, None, None);
                print_colored_text(buffer, " \u{2502}", Color::Red, None, None);
            }
            writeln!(buffer).unwrap();
        }

        // Bottom border
        write!(buffer, "{margin_str}").unwrap();
        print_colored_text(buffer, "\u{2514}", Color::Red, None, None);
        print_colored_text(
            buffer,
            &"\u{2500}".repeat(box_width.saturating_sub(2)),
            Color::Red,
            None,
            None,
        );
        print_colored_text(buffer, "\u{2518}", Color::Red, None, None);
        writeln!(buffer).unwrap();
    }

    /// Returns the number of visible process rows for event handler scroll calculation.
    fn render_local_devices(
        buffer: &mut BufferWriter,
        snapshot: &RenderSnapshot,
        cols: u16,
        rows: u16,
        cache: Option<&ViewCache>,
    ) -> usize {
        let width = cols as usize;

        // CPU information for local mode
        // Per-core bars are now always shown in the Activity panel above,
        // so we pass show_per_core=false here to avoid duplication.
        for (i, cpu_info) in snapshot.cpu_info.iter().enumerate() {
            let cpu_name_scroll_offset = snapshot
                .cpu_name_scroll_offsets
                .get(&format!("{}-{}", cpu_info.hostname, cpu_info.cpu_model))
                .copied()
                .unwrap_or(0);
            let hostname_scroll_offset = snapshot
                .host_id_scroll_offsets
                .get(&cpu_info.host_id)
                .copied()
                .unwrap_or(0);
            print_cpu_info(
                buffer,
                i,
                cpu_info,
                width,
                false,
                cpu_name_scroll_offset,
                hostname_scroll_offset,
                false,
            );
        }

        // Memory information for local mode
        for (i, memory_info) in snapshot.memory_info.iter().enumerate() {
            let hostname_scroll_offset = snapshot
                .host_id_scroll_offsets
                .get(&memory_info.host_id)
                .copied()
                .unwrap_or(0);
            print_memory_info(buffer, i, memory_info, width, hostname_scroll_offset, false);
        }

        // Storage information for local mode
        for (i, storage_info) in snapshot.storage_info.iter().enumerate() {
            let hostname_scroll_offset = snapshot
                .host_id_scroll_offsets
                .get(&storage_info.host_id)
                .copied()
                .unwrap_or(0);
            print_storage_info(
                buffer,
                i,
                storage_info,
                width,
                hostname_scroll_offset,
                false,
            );
        }

        // Process information for local mode (if available)
        if !snapshot.process_info.is_empty() {
            let lines_used = buffer.line_count();

            // Add a blank line before process list
            queue!(buffer, Print("\r\n")).unwrap();

            // Reserve 1 line for function keys at the bottom
            let function_key_rows = 1;

            let available_rows = rows.saturating_sub(lines_used as u16 + 1 + function_key_rows);

            // Calculate actual visible process rows (must match process_renderer logic)
            // RESERVED_HEADER_ROWS = 4 ("Processes:" title, column header, separator, blank)
            // footer_rows = 2 ("Showing..." + "Active..." stats)
            let visible = (available_rows as usize).saturating_sub(4 + 2);

            // Get current user for process coloring
            let current_user = whoami::username().unwrap_or_default();

            // Use cached GPU-filtered process list when available, avoiding
            // a per-frame clone of the entire process vector.
            let base_processes: Cow<'_, [ProcessInfo]> =
                if let Some(pl) = cache.and_then(|c| c.process_display_list()) {
                    match &pl.filtered {
                        Some(filtered) => Cow::Borrowed(filtered.as_slice()),
                        None => Cow::Borrowed(&snapshot.process_info),
                    }
                } else if snapshot.gpu_filter_enabled {
                    // Fallback: filter inline (only when cache is None)
                    Cow::Owned(
                        snapshot
                            .process_info
                            .iter()
                            .filter(|p| p.used_memory > 0)
                            .cloned()
                            .collect(),
                    )
                } else {
                    Cow::Borrowed(&snapshot.process_info)
                };

            // Issue #186: also narrow by the interactive filter query. In
            // hide-non-matching mode this removes non-matches; otherwise
            // we leave the list as-is (process rows have far less real
            // estate than GPU rows so dimming them is less useful).
            let processes_to_display: Cow<'_, [ProcessInfo]> = if snapshot.filter_hide_nonmatching
                && let Some(expr) = snapshot.filter_query.as_ref()
            {
                Cow::Owned(
                    base_processes
                        .iter()
                        .filter(|p| crate::ui::filter_dsl::apply(Some(expr), *p))
                        .cloned()
                        .collect(),
                )
            } else {
                base_processes
            };

            print_process_info(
                buffer,
                &processes_to_display,
                snapshot.selected_process_index,
                snapshot.start_index,
                available_rows,
                cols,
                snapshot.process_horizontal_scroll_offset,
                &current_user,
                &snapshot.sort_criteria,
                &snapshot.sort_direction,
            );

            return visible;
        }
        0
    }
}

/// True when the snapshot's current tab is the cluster-wide Users tab
/// (issue #189).  Kept outside of [`FrameRenderer`] so it can be used
/// by tests and `render_main` alike without plumbing a snapshot through
/// multiple call sites.
fn is_users_tab_selected(snapshot: &RenderSnapshot) -> bool {
    snapshot
        .tabs
        .get(snapshot.current_tab)
        .map(|t| t == crate::ui::tabs::USERS_TAB_NAME)
        .unwrap_or(false)
}

/// True when the snapshot's current tab is the per-host Topology tab
/// (issue #190).
fn is_topology_tab_selected(snapshot: &RenderSnapshot) -> bool {
    snapshot
        .tabs
        .get(snapshot.current_tab)
        .map(|t| t == crate::ui::tabs::TOPOLOGY_TAB_NAME)
        .unwrap_or(false)
}

/// Pick the host to display in the Topology tab.
///
/// When the operator last pointed at a specific host tab (e.g. "node-03"),
/// we stash its name in `snapshot.topology_last_host_tab`. The Topology
/// tab itself has no host, so we have to derive one:
///
/// * In local mode the lone known host (or empty string ⇒ "(local)") is
///   returned.
/// * In remote mode we first honour the operator's remembered selection
///   (still present in the tab strip), falling back to the first host tab
///   if the remembered tab is absent or stale.
fn topology_target_host(snapshot: &RenderSnapshot) -> String {
    if snapshot.is_local_mode {
        return snapshot
            .gpu_info
            .first()
            .map(|g| g.host_id.clone())
            .unwrap_or_default();
    }
    // Remote mode: honour the operator's last-selected host tab when it
    // is still in the tab strip.
    if let Some(last) = snapshot.topology_last_host_tab.as_ref()
        && snapshot.tabs.iter().any(|t| t == last)
    {
        return last.clone();
    }
    // Fall through: first host-shaped tab after the reserved entries.
    for tab in &snapshot.tabs {
        if tab != "All"
            && tab != crate::ui::tabs::USERS_TAB_NAME
            && tab != crate::ui::tabs::TOPOLOGY_TAB_NAME
        {
            return tab.clone();
        }
    }
    String::new()
}

/// Locate the [`crate::device::VgpuHostInfo`] record matching a given GPU row.
///
/// Build an O(1) lookup map from `gpu_uuid` to index in the vGPU info slice.
/// Called once per frame to replace per-GPU linear scans.
fn build_vgpu_lookup(vgpu_info: &[crate::device::VgpuHostInfo]) -> HashMap<&str, usize> {
    let mut map = HashMap::with_capacity(vgpu_info.len());
    for (i, host) in vgpu_info.iter().enumerate() {
        map.entry(host.gpu_uuid.as_str()).or_insert(i);
    }
    map
}

/// Build an O(1) lookup map from `gpu_uuid` to index in the MIG info slice.
/// Called once per frame to replace per-GPU linear scans.
fn build_mig_lookup(mig_info: &[crate::device::MigGpuInfo]) -> HashMap<&str, usize> {
    let mut map = HashMap::with_capacity(mig_info.len());
    for (i, host) in mig_info.iter().enumerate() {
        map.entry(host.gpu_uuid.as_str()).or_insert(i);
    }
    map
}

/// O(1) vGPU host lookup by UUID with hostname+gpu_name fallback.
///
/// Matching precedence:
/// 1. Exact `gpu_uuid` match via HashMap (authoritative, O(1)).
/// 2. Fallback: same `hostname` + matching `gpu_name` — used when UUID
///    propagation is missing (e.g. remote mode with incomplete metrics).
///    This path is a rare linear scan only hit for entries not found by UUID.
///
/// Returns `None` when no match is found, which keeps the vGPU section from
/// appearing under unrelated GPU rows.
fn lookup_vgpu_host<'a>(
    lookup: &HashMap<&str, usize>,
    vgpu_info: &'a [crate::device::VgpuHostInfo],
    gpu: &crate::device::GpuInfo,
) -> Option<&'a crate::device::VgpuHostInfo> {
    if let Some(&idx) = lookup.get(gpu.uuid.as_str()) {
        return Some(&vgpu_info[idx]);
    }
    // Fallback: hostname + gpu_name linear scan for entries without UUID match.
    vgpu_info
        .iter()
        .find(|v| v.hostname == gpu.hostname && v.gpu_name == gpu.name)
}

/// O(1) MIG GPU lookup by UUID with hostname+gpu_name fallback.
///
/// Same precedence as [`lookup_vgpu_host`]:
/// 1. Exact `gpu_uuid` match via HashMap (authoritative, O(1)).
/// 2. Fallback: same `hostname` + matching `gpu_name` — used when UUID
///    propagation is missing (e.g. remote mode with incomplete metrics).
///
/// Returns `None` when no match is found, keeping the MIG section from
/// appearing under unrelated GPU rows.
fn lookup_mig_gpu<'a>(
    lookup: &HashMap<&str, usize>,
    mig_info: &'a [crate::device::MigGpuInfo],
    gpu: &crate::device::GpuInfo,
) -> Option<&'a crate::device::MigGpuInfo> {
    if let Some(&idx) = lookup.get(gpu.uuid.as_str()) {
        return Some(&mig_info[idx]);
    }
    // Fallback: hostname + gpu_name linear scan for entries without UUID match.
    mig_info
        .iter()
        .find(|m| m.hostname == gpu.hostname && m.gpu_name == gpu.name)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app_state::AppState;
    use crate::view::render_snapshot::RenderSnapshot;

    fn make_local_args() -> ViewArgs {
        ViewArgs::empty()
    }

    fn make_snapshot() -> RenderSnapshot {
        let mut state = AppState::new();
        RenderSnapshot::capture(&mut state)
    }

    // -----------------------------------------------------------------------
    // FrameRenderer: construction
    // -----------------------------------------------------------------------

    #[test]
    fn test_frame_renderer_is_zero_sized() {
        // FrameRenderer is a unit struct; assert it holds no state.
        assert_eq!(std::mem::size_of::<FrameRenderer>(), 0);
    }

    // -----------------------------------------------------------------------
    // render_loading: smoke tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_render_loading_does_not_panic() {
        let snapshot = make_snapshot();
        let output = FrameRenderer::render_loading(&snapshot, false, 80, 24);
        // Loading screen must produce some output even when state is empty.
        assert!(!output.is_empty());
    }

    #[test]
    fn test_render_loading_remote_does_not_panic() {
        let snapshot = make_snapshot();
        let output = FrameRenderer::render_loading(&snapshot, true, 80, 24);
        assert!(!output.is_empty());
    }

    #[test]
    fn test_render_loading_with_startup_status_lines() {
        let mut state = AppState::new();
        state
            .startup_status_lines
            .push("Connecting to GPUs...".to_string());
        let snapshot = RenderSnapshot::capture(&mut state);
        // Should not panic and produce output with the status line.
        let output = FrameRenderer::render_loading(&snapshot, false, 80, 24);
        assert!(!output.is_empty());
    }

    // -----------------------------------------------------------------------
    // render_main: smoke tests
    // -----------------------------------------------------------------------

    #[test]
    fn test_render_main_does_not_panic_empty_state() {
        let snapshot = make_snapshot();
        let args = make_local_args();
        let (output, _) = FrameRenderer::render_main(&snapshot, &args, 80, 24, None);
        // Header must be present.
        assert!(output.contains("all-smi"));
    }

    #[test]
    fn test_render_main_contains_header_timestamp() {
        let snapshot = make_snapshot();
        let args = make_local_args();
        let (output, _) = FrameRenderer::render_main(&snapshot, &args, 120, 40, None);
        // The header includes the current year which is deterministic for the test run.
        assert!(output.contains("all-smi - 20"));
    }

    #[test]
    fn test_render_main_contains_version() {
        let snapshot = make_snapshot();
        let args = make_local_args();
        let (output, _) = FrameRenderer::render_main(&snapshot, &args, 80, 24, None);
        let version = env!("CARGO_PKG_VERSION");
        assert!(output.contains(version));
    }

    // -----------------------------------------------------------------------
    // render_disconnection_notification: box geometry
    // -----------------------------------------------------------------------

    #[test]
    fn test_disconnection_notification_width_too_narrow_produces_no_box() {
        // width=9 → box_width = min(9-4, 60) = 5 which is < 6, so nothing is rendered
        // (only the two leading blank lines appear).
        let mut buffer = BufferWriter::new();
        FrameRenderer::render_disconnection_notification(&mut buffer, "node1", 9);
        let output = buffer.get_buffer().to_string();
        // The box should NOT be rendered; the output must not contain the box corner.
        assert!(!output.contains('\u{250c}'));
    }

    #[test]
    fn test_disconnection_notification_normal_width_contains_hostname() {
        let mut buffer = BufferWriter::new();
        FrameRenderer::render_disconnection_notification(&mut buffer, "my-node", 80);
        let output = buffer.get_buffer().to_string();
        assert!(output.contains("my-node"));
        assert!(output.contains("CONNECTION LOST"));
    }

    #[test]
    fn test_disconnection_notification_box_max_width_capped_at_60() {
        // With a very wide terminal (200 cols) the box should be capped at 60 chars.
        let mut buffer = BufferWriter::new();
        FrameRenderer::render_disconnection_notification(&mut buffer, "node1", 200);
        let output = buffer.get_buffer().to_string();
        // The box top border is: "─" repeated (box_width-2) times, capped at 58 for width=200.
        // Count the number of consecutive box-drawing horizontal lines.
        let horizontal_line_count = output.matches('\u{2500}').count();
        // max box_width = 60, so max horizontal lines per border = 58
        // Two borders (top + bottom) → at most 116.
        assert!(horizontal_line_count <= 116);
        // But there must be at least some lines (it renders).
        assert!(horizontal_line_count > 0);
    }

    #[test]
    fn test_disconnection_notification_long_hostname_is_truncated() {
        // A hostname that exceeds inner_width (box_width - 4) should be truncated.
        let long_hostname = "a".repeat(200);
        let mut buffer = BufferWriter::new();
        FrameRenderer::render_disconnection_notification(&mut buffer, &long_hostname, 80);
        let output = buffer.get_buffer().to_string();
        // "Node: " prefix plus some of the hostname must appear, but not all 200 'a's.
        assert!(output.contains("Node: "));
        assert!(!output.contains(&long_hostname));
    }

    // -----------------------------------------------------------------------
    // render_help: smoke test
    // -----------------------------------------------------------------------

    #[test]
    fn test_render_help_does_not_panic() {
        let snapshot = make_snapshot();
        let args = make_local_args();
        let output = FrameRenderer::render_help(&snapshot, &args, 80, 24);
        // Help popup must produce output.
        assert!(!output.is_empty());
    }

    // -----------------------------------------------------------------------
    // topology_target_host: remote-mode host selection
    // -----------------------------------------------------------------------

    /// Build a minimal remote-mode snapshot whose tab strip mirrors the
    /// layout produced by `update_remote_tabs`:
    /// `[All, Users, Topology, host1, host2]`. Helpers for the host-
    /// selection tests below.
    fn make_remote_topology_snapshot(last_host: Option<&str>) -> RenderSnapshot {
        let mut state = AppState::new();
        state.is_local_mode = false;
        state.tabs = vec![
            "All".to_string(),
            crate::ui::tabs::USERS_TAB_NAME.to_string(),
            crate::ui::tabs::TOPOLOGY_TAB_NAME.to_string(),
            "host1".to_string(),
            "host2".to_string(),
        ];
        state.topology_last_host_tab = last_host.map(|s| s.to_string());
        RenderSnapshot::capture(&mut state)
    }

    #[test]
    fn topology_target_host_uses_last_host_tab_when_set() {
        // When the operator has previously selected "host2", the Topology
        // tab should render that host — not fall through to the first
        // host-shaped tab ("host1").
        let snapshot = make_remote_topology_snapshot(Some("host2"));
        assert_eq!(topology_target_host(&snapshot), "host2");
    }

    #[test]
    fn topology_target_host_falls_back_to_first_host_when_unset() {
        // With no remembered selection the renderer falls back to the
        // first host-shaped tab after the reserved ones.
        let snapshot = make_remote_topology_snapshot(None);
        assert_eq!(topology_target_host(&snapshot), "host1");
    }

    #[test]
    fn topology_target_host_falls_back_when_remembered_host_missing() {
        // The remembered host "ghost" is not in the tab strip → fall back
        // to the first host tab rather than returning the stale name.
        let snapshot = make_remote_topology_snapshot(Some("ghost"));
        assert_eq!(topology_target_host(&snapshot), "host1");
    }
}
