use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
    Frame,
};

use super::state::{
    AppState, BuildScope, FocusedPane, InfraViewState, LogSource, ShutdownPhase,
};

/// Border style for a focusable side panel. Bright cyan + bold when focused,
/// dark gray otherwise. Used by all three panes (services / apps / logs) so
/// the user has a single, consistent visual cue for "where am I."
fn focus_border_style(focused: bool) -> Style {
    if focused {
        Style::default()
            .fg(Color::Cyan)
            .add_modifier(Modifier::BOLD)
    } else {
        Style::default().fg(Color::DarkGray)
    }
}

pub fn draw(frame: &mut Frame, app: &mut AppState) {
    match app.view {
        super::state::TuiView::Dev => draw_dev(frame, app),
        super::state::TuiView::Infra => draw_infra(frame, app),
    }
}

fn draw_dev(frame: &mut Frame, app: &mut AppState) {
    let area = frame.area();
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(0), Constraint::Length(1)])
        .split(area);

    render_body(frame, rows[0], app);
    render_footer(frame, rows[1], app);

    // Overlay the rebuild-scope popup last so it sits on top of everything.
    if app.build_dialog.is_some() {
        render_build_dialog(frame, area, app);
    }
}

/// Centered modal that lets the user choose what `[b]` should rebuild.
/// Renders on top of the dev view; the key handler intercepts input while it
/// is open (see `handle_build_dialog_keys`).
fn render_build_dialog(frame: &mut Frame, area: Rect, app: &AppState) {
    let Some(dialog) = app.build_dialog.as_ref() else {
        return;
    };
    // Compute a popup roughly 60×10 (or smaller if the terminal is tiny),
    // centered in the available area.
    let width = 60.min(area.width.saturating_sub(4));
    let height = (BuildScope::ALL.len() as u16 + 6).min(area.height.saturating_sub(2));
    let x = area.x + area.width.saturating_sub(width) / 2;
    let y = area.y + area.height.saturating_sub(height) / 2;
    let popup = Rect { x, y, width, height };

    // Clear the cells under the popup so log content doesn't bleed through.
    frame.render_widget(Clear, popup);

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
        .title(Span::styled(
            " Rebuild — choose scope ",
            Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
        ));

    let mut lines: Vec<Line> = Vec::with_capacity(BuildScope::ALL.len() + 3);
    for (i, scope) in BuildScope::ALL.iter().enumerate() {
        let selected = i == dialog.selected;
        let marker = if selected { "▶ " } else { "  " };
        let hint = format!("[{}]", scope.key_hint());
        let row_style = if selected {
            Style::default().fg(Color::Black).bg(Color::Cyan).add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::Gray)
        };
        lines.push(Line::from(vec![
            Span::styled(marker, row_style),
            Span::styled(format!("{:<4}", hint), row_style),
            Span::styled(scope.label().to_string(), row_style),
        ]));
    }
    lines.push(Line::from(""));
    lines.push(Line::from(vec![
        Span::styled("↑↓", Style::default().fg(Color::Cyan)),
        Span::raw(" move  "),
        Span::styled("Enter", Style::default().fg(Color::Cyan)),
        Span::raw(" rebuild  "),
        Span::styled("A/s/a/u", Style::default().fg(Color::Cyan)),
        Span::raw(" shortcut  "),
        Span::styled("Esc", Style::default().fg(Color::Cyan)),
        Span::raw(" cancel"),
    ]));

    let para = Paragraph::new(lines).block(block);
    frame.render_widget(para, popup);
}


fn render_body(frame: &mut Frame, area: Rect, app: &mut AppState) {
    // Top row: services as a horizontal tab strip (3 rows with borders).
    // Bottom row: left sidebar (apps + nodes) + right log pane.
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(3), Constraint::Min(0)])
        .split(area);
    let services_rect = rows[0];
    let body_rect = rows[1];

    app.layout.service_list = services_rect;
    render_services_tabs(frame, services_rect, app);

    let has_apps = !app.app_list.is_empty();
    let has_nodes = !app.node_list.is_empty();

    if !has_apps && !has_nodes {
        // No sidebar content — give the log pane the full bottom width.
        app.layout.app_list = Rect::default();
        app.layout.node_list = Rect::default();
        render_log_pane(frame, body_rect, app);
        return;
    }

    let cols = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Length(30), Constraint::Min(0)])
        .split(body_rect);
    let sidebar_rect = cols[0];

    // Sidebar holds Apps + Nodes (or whichever exists). When both are
    // present, give nodes a height proportional to node count.
    let (apps_rect, nodes_rect) = match (has_apps, has_nodes) {
        (true, true) => {
            let halves = Layout::default()
                .direction(Direction::Vertical)
                .constraints([Constraint::Min(0), Constraint::Length(node_panel_height(app))])
                .split(sidebar_rect);
            (halves[0], halves[1])
        }
        (true, false) => (sidebar_rect, Rect::default()),
        (false, true) => (Rect::default(), sidebar_rect),
        (false, false) => (Rect::default(), Rect::default()),
    };

    app.layout.app_list = apps_rect;
    app.layout.node_list = nodes_rect;

    if has_apps {
        render_app_list(frame, apps_rect, app);
    }
    if has_nodes {
        render_node_list(frame, nodes_rect, app);
    }
    render_log_pane(frame, cols[1], app);
}

/// Render the services as a horizontal tab strip at the top of the body.
/// Each tab shows its number (1-5), status indicator, and label. The
/// active tab gets a highlighted background; the focused-pane border lights
/// up when keyboard focus is on Services.
fn render_services_tabs(frame: &mut Frame, area: Rect, app: &AppState) {
    let focused = app.focused_pane == FocusedPane::Services;

    // Filter out the dead "app" service row in platform mode (same rule the
    // old left-column list used). Numbering uses the order in active_services
    // so the 1-5 keybindings stay consistent.
    let hide_app_row = !app.app_list.is_empty();
    let mut spans: Vec<Span> = Vec::new();
    let mut number = 1u8;
    for (source, svc) in app.active_services().iter() {
        if hide_app_row && *source == LogSource::App {
            continue;
        }
        let is_active = *source == app.active_pane;
        let label = format!("{}:{}", number, svc.label);

        // Number prefix in cyan, then indicator in its colour, then label.
        spans.push(Span::raw(" "));
        spans.push(Span::styled(
            label,
            if is_active {
                Style::default()
                    .fg(Color::Black)
                    .bg(Color::Cyan)
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::Gray)
            },
        ));
        spans.push(Span::styled(
            format!(" {}", svc.status.indicator()),
            Style::default().fg(svc.status.color()),
        ));
        spans.push(Span::raw(" "));
        number += 1;
    }

    // Title bar shows platform name + env-file hint (replaces the old left
    // sidebar title). The detail of the active service is in the log title.
    let version_part = if app.app_version.is_empty() {
        String::new()
    } else {
        format!(" v{}", app.app_version)
    };
    let env_part = app
        .active_daemon_env_file()
        .map(|p| format!("  env={}", compact_path(p)))
        .unwrap_or_default();
    let title = format!(" {}{}{} ", app.app_name, version_part, env_part);

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(focus_border_style(focused))
        .title(title);
    frame.render_widget(Paragraph::new(Line::from(spans)).block(block), area);
}

/// Vertical space the nodes box wants — 3 rows per node + 2 for borders/title.
fn node_panel_height(app: &AppState) -> u16 {
    let n = app.node_list.len() as u16;
    (n.saturating_mul(3)).saturating_add(2).max(5)
}

/// Render the "nodes" sidebar section. One entry per running instance with
/// status indicator, name, status summary, and compact env path. Mirrors
/// `render_app_list` so the two sections feel consistent.
fn render_node_list(frame: &mut Frame, area: Rect, app: &AppState) {
    let focused = app.focused_pane == FocusedPane::Nodes;
    let active_name = app.focused_node_name().map(str::to_owned);

    let items: Vec<ListItem> = app
        .node_list
        .iter()
        .map(|n| {
            let active = active_name.as_deref() == Some(n.name.as_str());
            let name_style = if active {
                Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::Gray)
            };
            let dim = Style::default().fg(Color::DarkGray);

            let line1 = Line::from(vec![
                Span::styled(
                    if active { "▶" } else { " " },
                    Style::default().fg(Color::Cyan),
                ),
                Span::styled(
                    format!("{} ", n.status.indicator()),
                    Style::default().fg(n.status.color()),
                ),
                Span::styled(format!("{:<14}", n.name), name_style),
                Span::styled(n.status.summary(), Style::default().fg(n.status.color())),
            ]);

            let line2 = if n.detail.is_empty() {
                Line::raw("")
            } else {
                Line::from(Span::styled(format!("   {}", n.detail), dim))
            };

            // Show the env file path on line 3 if known for this node.
            let line3 = match app.daemon_env_files.get(&n.name) {
                Some(p) => Line::from(Span::styled(
                    format!("   env={}", compact_path(p)),
                    dim,
                )),
                None => Line::raw(""),
            };

            ListItem::new(vec![line1, line2, line3])
        })
        .collect();

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(focus_border_style(focused))
        .title(" nodes ");
    let list = List::new(items).block(block);
    frame.render_widget(list, area);
}

/// Render the platform-mode "Apps" sidebar section below the service list.
/// One entry per loaded node-app, with status indicator + line count.
fn render_app_list(frame: &mut Frame, area: Rect, app: &AppState) {
    let active_name = app.instance_filter_label().map(str::to_owned);
    let items: Vec<ListItem> = app
        .app_list
        .iter()
        .map(|a| {
            let active = active_name.as_deref() == Some(a.name.as_str())
                && app.active_pane == LogSource::App;
            let name_style = if active {
                Style::default().fg(Color::White).add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::Gray)
            };
            let dim = Style::default().fg(Color::DarkGray);
            let count = app.app_log_count(&a.name);

            let line1 = Line::from(vec![
                Span::styled(
                    if active { "▶" } else { " " },
                    Style::default().fg(Color::Cyan),
                ),
                Span::styled(
                    format!("{} ", a.status.indicator()),
                    Style::default().fg(a.status.color()),
                ),
                Span::styled(format!("{:<14}", short_app_name(&a.name)), name_style),
                Span::styled(
                    if count > 0 { format!("{}", count) } else { "·".into() },
                    dim,
                ),
            ]);

            let line2 = if a.detail.is_empty() {
                Line::from(Span::styled(
                    format!("   {}", a.status.summary()),
                    Style::default().fg(a.status.color()),
                ))
            } else {
                Line::from(Span::styled(format!("   {}", a.detail), dim))
            };

            // Line 3: compact path of the app's source directory, when known.
            // Tagged with `~`/`%cache%/` shortcuts so the path actually fits in
            // the sidebar. Full path shows in the log-pane title.
            let line3 = match &a.path {
                Some(p) => Line::from(Span::styled(format!("   {}", compact_path(p)), dim)),
                None => Line::raw(""),
            };

            ListItem::new(vec![line1, line2, line3])
        })
        .collect();

    let focused = app.focused_pane == FocusedPane::Apps;
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(focus_border_style(focused))
        .title(" apps ");
    let list = List::new(items).block(block);
    frame.render_widget(list, area);
}

/// Truncate a too-long app name with an ellipsis so the sidebar stays aligned.
fn short_app_name(name: &str) -> String {
    if name.chars().count() <= 14 {
        name.to_string()
    } else {
        let mut s: String = name.chars().take(13).collect();
        s.push('…');
        s
    }
}

/// Shorten a path for the narrow apps sidebar (≤24 cols of usable width).
/// Replaces `$HOME` with `~`, the XDG cache root with `%cache%`, and ellipsises
/// the middle of anything still too long. Designed to be informative at a
/// glance — the log-pane title shows the full path.
fn compact_path(p: &std::path::Path) -> String {
    const MAX: usize = 24;
    let raw = p.to_string_lossy().into_owned();

    let cache_root = std::env::var("XDG_CACHE_HOME")
        .ok()
        .filter(|s| !s.is_empty())
        .map(std::path::PathBuf::from)
        .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".cache")))
        .map(|c| c.join("node-app"));

    let shortened = if let Some(cache) = cache_root.as_ref() {
        let cache_str = cache.to_string_lossy().into_owned();
        if let Some(rest) = raw.strip_prefix(&cache_str) {
            format!("%cache%{}", rest)
        } else if let Some(home) = std::env::var_os("HOME") {
            let home_str = std::path::PathBuf::from(home).to_string_lossy().into_owned();
            raw.strip_prefix(&home_str)
                .map(|rest| format!("~{}", rest))
                .unwrap_or(raw)
        } else {
            raw
        }
    } else {
        raw
    };

    if shortened.chars().count() <= MAX {
        return shortened;
    }
    // Truncate from the left so the basename remains visible (most useful).
    let chars: Vec<char> = shortened.chars().collect();
    let tail = chars[chars.len().saturating_sub(MAX - 1)..]
        .iter()
        .collect::<String>();
    format!("…{}", tail)
}

/// Build the title shown on the log pane border. Includes the active log
/// source, status detail, source-specific hint (env path for Daemon, app
/// source path for App), and timing metadata. This replaces the lines that
/// used to live on each service row in the old left-column sidebar.
fn build_log_title(app: &AppState, source: LogSource) -> String {
    let svc = app
        .active_services()
        .iter()
        .find(|(s, _)| *s == source)
        .map(|(_, v)| v);
    let timings = app.active_timings();

    let mut parts: Vec<String> = vec![app.numbered_label(source)];

    // Status summary (e.g. "ready", "building", "FAILED: …").
    if let Some(svc) = svc {
        parts.push(svc.status.summary());
    }

    // Source-specific hints: app path for App pane, env path for Daemon pane.
    if source == LogSource::App {
        if let Some(inst) = app.instance_filter_label() {
            let mut chunk = format!("[{inst}]");
            if let Some(p) = app
                .app_list
                .iter()
                .find(|a| a.name == inst)
                .and_then(|a| a.path.as_deref())
            {
                chunk.push_str(&format!(" {}", p.display()));
            }
            parts.push(chunk);
        }
    }
    if source == LogSource::Daemon {
        if let Some(p) = app.active_daemon_env_file() {
            parts.push(format!("env={}", p.display()));
        }
    }

    // Detail (port/url/etc) and timing — only when not redundant with the
    // source-specific hint above.
    if let Some(svc) = svc {
        if !svc.detail.is_empty() && source != LogSource::App {
            parts.push(svc.detail.clone());
        }
        let timing = build_timing_line(source, svc, timings);
        if !timing.is_empty() {
            parts.push(timing);
        }
    }

    format!(" {} ", parts.join("  ·  "))
}

/// Build the timing metadata string for a service sidebar entry.
/// Returns an empty string when there is nothing useful to show.
fn build_timing_line(
    source: LogSource,
    svc: &super::state::ServiceState,
    t: &super::state::Timings,
) -> String {
    match source {
        LogSource::Build => {
            match &svc.status {
                super::state::ServiceStatus::Building => {
                    let elapsed = t
                        .build_started_at
                        .map(|s| s.elapsed().as_millis())
                        .unwrap_or(0);
                    let count_hint = if t.build_count > 0 {
                        format!("  ×{} prev", t.build_count)
                    } else {
                        String::new()
                    };
                    format!("elapsed {}{}", fmt_ms(elapsed as u64), count_hint)
                }
                super::state::ServiceStatus::Ready => {
                    let builtins = t.builtin_apps_build_ms
                        .map(|ms| format!("builtins {}  ", fmt_ms(ms)))
                        .unwrap_or_default();
                    match t.last_build_ms {
                        Some(ms) => format!("{}last {}  ×{} builds", builtins, fmt_ms(ms), t.build_count),
                        None if !builtins.is_empty() => builtins.trim_end().to_string(),
                        None => String::new(),
                    }
                }
                super::state::ServiceStatus::Failed(_) => {
                    match t.last_build_ms {
                        Some(ms) => format!("failed {}  ×{} total", fmt_ms(ms), t.build_count),
                        None => String::new(),
                    }
                }
                _ => String::new(),
            }
        }
        LogSource::App => {
            if let Some(ms) = t.last_reload_ms {
                format!("reload {}", fmt_ms(ms))
            } else {
                String::new()
            }
        }
        LogSource::Daemon => {
            let build_part = t.platform_build_ms
                .map(|ms| format!("build {}  ", fmt_ms(ms)))
                .unwrap_or_default();
            if let Some(started) = t.daemon_started_at {
                format!("{}up {}", build_part, fmt_duration(started.elapsed().as_secs()))
            } else if !build_part.is_empty() {
                build_part.trim_end().to_string()
            } else {
                String::new()
            }
        }
        _ => String::new(),
    }
}

/// Format milliseconds compactly: <1 000 ms → "123ms", ≥1 000 ms → "1.2s".
fn fmt_ms(ms: u64) -> String {
    if ms < 1_000 {
        format!("{ms}ms")
    } else {
        format!("{:.1}s", ms as f64 / 1_000.0)
    }
}

/// Format seconds as "Xm Ys" or just "Xs".
fn fmt_duration(secs: u64) -> String {
    if secs < 60 {
        format!("{secs}s")
    } else {
        format!("{}m {}s", secs / 60, secs % 60)
    }
}

fn render_log_pane(frame: &mut Frame, area: Rect, app: &mut AppState) {
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Length(1), Constraint::Min(0)])
        .split(area);
    app.layout.log_scroll = rows[1];

    render_timings_bar(frame, rows[0], app);
    render_log_scroll(frame, rows[1], app);
}

fn render_timings_bar(frame: &mut Frame, area: Rect, app: &AppState) {
    let t = app.active_timings();
    let dim = Style::default().fg(Color::DarkGray);
    let val = Style::default().fg(Color::Cyan);
    let sep = Style::default().fg(Color::DarkGray);

    let mut spans: Vec<Span> = Vec::new();

    macro_rules! kv {
        ($label:expr, $value:expr) => {
            if !spans.is_empty() {
                spans.push(Span::styled("  ", sep));
            }
            spans.push(Span::styled(concat!($label, ":"), dim));
            spans.push(Span::styled($value, val));
        };
    }

    if let Some(ms) = t.platform_build_ms {
        kv!("platform", fmt_ms(ms));
    } else if t.platform_build_started_at.is_some() {
        let elapsed = t.platform_build_started_at.map(|s| s.elapsed().as_millis()).unwrap_or(0);
        kv!("platform", format!("{}…", fmt_ms(elapsed as u64)));
    }
    if let Some(ms) = t.builtin_apps_build_ms {
        kv!("builtins", fmt_ms(ms));
    }
    match (t.last_build_ms, t.build_count) {
        (Some(ms), n) => {
            let v = if n > 1 { format!("{} ×{}", fmt_ms(ms), n) } else { fmt_ms(ms) };
            kv!("app", v);
        }
        (None, _) if t.build_started_at.is_some() => {
            let elapsed = t.build_started_at.map(|s| s.elapsed().as_millis()).unwrap_or(0);
            kv!("app", format!("{}…", fmt_ms(elapsed as u64)));
        }
        _ => {}
    }
    if let Some(ms) = t.last_reload_ms {
        kv!("reload", fmt_ms(ms));
    }
    if let Some(started) = t.daemon_started_at {
        kv!("up", fmt_duration(started.elapsed().as_secs()));
    }

    // Right side: active pane label (+ match count when search is active).
    let pane_label = {
        let src = app.active_pane;
        if !app.search_query.is_empty() {
            let total = app.log_lines(src).len();
            let matched = app.log_lines(src).iter().filter(|l| app.matches(l)).count();
            format!(" {matched}/{total} ")
        } else {
            format!(" {} ", app.numbered_label(src))
        }
    };

    let mut all_spans: Vec<Span> = vec![Span::raw(" ")];
    all_spans.extend(spans);

    // Pad the gap then right-align the pane label.
    let content_width: usize = all_spans.iter().map(|s| s.content.len()).sum();
    let label_width = pane_label.len();
    let area_width = area.width as usize;
    if content_width + label_width < area_width {
        let pad = area_width - content_width - label_width;
        all_spans.push(Span::raw(" ".repeat(pad)));
    }
    all_spans.push(Span::styled(
        pane_label,
        Style::default().fg(Color::Black).bg(Color::Cyan),
    ));

    frame.render_widget(Paragraph::new(Line::from(all_spans)), area);
}


fn render_log_scroll(frame: &mut Frame, area: Rect, app: &mut AppState) {
    // Borders reserve one row top + bottom; visible content height is 2 less.
    let height = (area.height as usize).saturating_sub(2);
    app.last_render_height = height;

    // Capture values we need without holding a borrow on app.
    let source = app.active_pane;
    let search_query = app.search_query.clone();
    let selection = app.selection;

    let text: Vec<Line> = if search_query.is_empty() {
        // No text filter — show raw lines with colorization and selection support.
        // `log_lines()` already returns the right buffer (global or instance-specific).
        let total = app.log_lines(source).len();
        let start = app.visible_start_for(total, height);
        app.log_scroll_start = start;
        app.log_lines(source)
            .iter()
            .enumerate()
            .skip(start)
            .take(height)
            .map(|(abs_idx, l)| {
                if selection.is_some_and(|s| s.contains(abs_idx)) {
                    colorize_selected(l.as_str())
                } else {
                    colorize(l.as_str(), source)
                }
            })
            .collect()
    } else {
        // Search filter — fuzzy-highlight matched lines.
        app.log_scroll_start = 0;
        let matched: Vec<(String, Vec<usize>)> = app
            .log_lines(source)
            .iter()
            .filter_map(|l| {
                app.match_positions(l.as_str())
                    .map(|pos| (l.clone(), pos))
            })
            .collect();
        let start = app.visible_start_for(matched.len(), height);
        matched
            .into_iter()
            .skip(start)
            .take(height)
            .map(|(l, pos)| render_fuzzy_highlighted(&l, &pos, source))
            .collect()
    };

    let focused = app.focused_pane == FocusedPane::Logs;
    // Title now carries the entire context for the active service — label,
    // status detail, source-specific hint (env / app path), and timing.
    // This replaces the per-service lines the old left-column sidebar
    // showed in lines 2 and 3.
    let title = build_log_title(app, source);
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(focus_border_style(focused))
        .title(title);
    frame.render_widget(Paragraph::new(text).block(block), area);
}

/// Render a log line with a selection highlight (blue background).
fn colorize_selected(line: &str) -> Line<'static> {
    Line::from(Span::styled(
        line.to_owned(),
        Style::default()
            .bg(Color::Rgb(50, 80, 130))
            .fg(Color::White),
    ))
}

/// Render a log line with fuzzy-matched character positions highlighted.
fn render_fuzzy_highlighted(line: &str, positions: &[usize], source: LogSource) -> Line<'static> {
    let base = base_style(line, source);
    let hl = Style::default()
        .fg(Color::Yellow)
        .add_modifier(Modifier::BOLD);

    if positions.is_empty() {
        return Line::from(Span::styled(line.to_owned(), base));
    }

    let chars: Vec<char> = line.chars().collect();
    let pos_set: std::collections::HashSet<usize> =
        positions.iter().copied().collect();

    let mut spans: Vec<Span<'static>> = Vec::new();
    let mut segment = String::new();
    let mut in_hl = false;

    for (i, c) in chars.iter().enumerate() {
        let want_hl = pos_set.contains(&i);
        if want_hl != in_hl {
            if !segment.is_empty() {
                spans.push(Span::styled(
                    segment.clone(),
                    if in_hl { hl } else { base },
                ));
                segment.clear();
            }
            in_hl = want_hl;
        }
        segment.push(*c);
    }
    if !segment.is_empty() {
        spans.push(Span::styled(segment, if in_hl { hl } else { base }));
    }
    Line::from(spans)
}

fn colorize(line: &str, source: LogSource) -> Line<'static> {
    Line::from(Span::styled(line.to_owned(), base_style(line, source)))
}

fn base_style(line: &str, source: LogSource) -> Style {
    if line.contains(" ERROR") || line.contains("error[") || line.contains("✗") {
        Style::default().fg(Color::Red)
    } else if line.contains(" WARN") || line.contains("warning[") || line.contains("⚠") {
        Style::default().fg(Color::Yellow)
    } else if line.contains("✓") || line.contains(" INFO") {
        Style::default().fg(Color::Green)
    } else if source == LogSource::Build
        && (line.contains("Compiling") || line.contains("Finished") || line.contains("→"))
    {
        Style::default().fg(Color::Cyan)
    } else {
        Style::default().fg(Color::Gray)
    }
}

fn render_footer(frame: &mut Frame, area: Rect, app: &AppState) {
    // Copy flash — 2 s confirmation after clipboard copy.
    if app
        .copy_flash
        .is_some_and(|t| t.elapsed() < std::time::Duration::from_secs(2))
    {
        let lines = app.selection.map_or(0, |s| {
            let (a, b) = s.range();
            b - a + 1
        });
        let text = Line::from(vec![
            Span::styled(
                " ✓ copied",
                Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
            ),
            Span::styled(
                format!(" {lines} line{}", if lines == 1 { "" } else { "s" }),
                Style::default().fg(Color::White),
            ),
            Span::styled(
                " to clipboard",
                Style::default().fg(Color::DarkGray),
            ),
        ]);
        frame.render_widget(Paragraph::new(text), area);
        return;
    }

    if app.shutdown_phase == ShutdownPhase::ShuttingDown {
        let text = Line::from(vec![
            Span::styled(
                " ◐ shutting down — ",
                Style::default().fg(Color::Yellow),
            ),
            Span::styled(
                "stopping daemon, ui-server and cleaning up…",
                Style::default().fg(Color::DarkGray),
            ),
        ]);
        frame.render_widget(Paragraph::new(text), area);
        return;
    }
    if app.shutdown_phase == ShutdownPhase::Done {
        let text = Line::from(Span::styled(
            " ✓ all processes stopped",
            Style::default().fg(Color::Green),
        ));
        frame.render_widget(Paragraph::new(text), area);
        return;
    }
    let text = if app.search_input_active {
        // Search input mode — show the query box.
        Line::from(vec![
            Span::styled(" /", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
            Span::raw(" "),
            Span::styled(
                app.search_query.clone(),
                Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
            ),
            Span::styled("█", Style::default().fg(Color::Yellow)), // cursor
            Span::styled(
                "  [Enter/Esc] lock filter  [Backspace] delete",
                Style::default().fg(Color::DarkGray),
            ),
        ])
    } else if !app.search_query.is_empty() {
        // Search filter active.
        Line::from(vec![
            Span::styled(" filter: ", Style::default().fg(Color::Yellow)),
            Span::styled(
                app.search_query.clone(),
                Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
            ),
            Span::styled("  [/]", Style::default().fg(Color::Cyan)),
            Span::raw(" edit  "),
            Span::styled("[Esc]", Style::default().fg(Color::Cyan)),
            Span::raw(" clear  "),
            Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
            Span::raw(" scroll  "),
            Span::styled("[q]", Style::default().fg(Color::Cyan)),
            Span::raw(" quit "),
        ])
    } else {
        // Normal mode. Footer adapts to which side panel has focus.
        // The arrow-key meaning differs per pane, so we surface that contextually.
        let arrows_label = match app.focused_pane {
            FocusedPane::Services => " service  ",
            FocusedPane::Apps => " app  ",
            FocusedPane::Nodes => " node  ",
            FocusedPane::Logs => " scroll  ",
        };
        let shift_arrows_label = match app.focused_pane {
            FocusedPane::Logs => " page  ",
            _ => " scroll  ",
        };

        let mut spans = vec![
            Span::styled(" [tab]", Style::default().fg(Color::Cyan)),
            Span::raw(" focus  "),
            Span::styled("[1-5/←→]", Style::default().fg(Color::Cyan)),
            Span::raw(" service  "),
            Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
            Span::raw(arrows_label),
            Span::styled("[⇧↑↓]", Style::default().fg(Color::Cyan)),
            Span::raw(shift_arrows_label),
            Span::styled("[/]", Style::default().fg(Color::Cyan)),
            Span::raw(" search  "),
            Span::styled("[r]", Style::default().fg(Color::Cyan)),
            Span::raw(" restart  "),
            Span::styled("[b]", Style::default().fg(Color::Cyan)),
            Span::raw(" build…  "),
            Span::styled("[c]", Style::default().fg(Color::Cyan)),
            Span::raw(" clear  "),
            Span::styled("[g/G]", Style::default().fg(Color::Cyan)),
            Span::raw(" top/btm  "),
            Span::styled("[q]", Style::default().fg(Color::Cyan)),
            Span::raw(" quit  "),
            Span::styled("[drag]", Style::default().fg(Color::Cyan)),
            Span::raw(" copy "),
        ];
        if app.infra.is_some() {
            spans.push(Span::styled("[I]", Style::default().fg(Color::Magenta)));
            spans.push(Span::raw(" infra "));
        }
        Line::from(spans)
    };
    frame.render_widget(Paragraph::new(text), area);
}

// ── Infra view ────────────────────────────────────────────────────────────────

fn draw_infra(frame: &mut Frame, app: &mut AppState) {
    let area = frame.area();
    let Some(iv) = &app.infra else {
        frame.render_widget(
            Paragraph::new(" No infrastructure found.")
                .style(Style::default().fg(Color::Red)),
            area,
        );
        return;
    };
    let rows = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1), // tab bar
            Constraint::Min(0),    // content
            Constraint::Length(1), // footer
        ])
        .split(area);
    render_infra_tab_bar(frame, rows[0], iv);
    match iv.active_tab {
        super::state::InfraTab::Status => render_tab_status(frame, rows[1], iv),
        super::state::InfraTab::Graph => render_tab_graph(frame, rows[1], iv),
        super::state::InfraTab::Snapshot => render_tab_snapshot(frame, rows[1], iv),
        super::state::InfraTab::Db => render_tab_db(frame, rows[1], iv),
        super::state::InfraTab::Logs => render_tab_logs(frame, rows[1], iv),
    }
    render_infra_footer(frame, rows[2], iv);
}

fn render_infra_tab_bar(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
    use super::state::InfraTab;
    let mut spans: Vec<Span> = vec![Span::raw(" ")];
    for &tab in InfraTab::all() {
        let active = tab == iv.active_tab;
        let style = if active {
            Style::default()
                .fg(Color::Black)
                .bg(Color::Cyan)
                .add_modifier(Modifier::BOLD)
        } else {
            Style::default().fg(Color::DarkGray)
        };
        spans.push(Span::styled(format!(" {} ", tab.label()), style));
        spans.push(Span::raw("  "));
    }
    // Right-align refresh indicator.
    let refresh_label = if let Some(t) = iv.last_refresh {
        let secs = t.elapsed().as_secs();
        format!(" refreshed {}s ago ", secs)
    } else {
        " polling… ".to_string()
    };
    let content_w: usize = spans.iter().map(|s| s.content.len()).sum();
    let label_w = refresh_label.len();
    let area_w = area.width as usize;
    if content_w + label_w < area_w {
        spans.push(Span::raw(" ".repeat(area_w - content_w - label_w)));
    }
    spans.push(Span::styled(
        refresh_label,
        Style::default().fg(Color::DarkGray),
    ));
    frame.render_widget(Paragraph::new(Line::from(spans)), area);
}

fn render_tab_status(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
    let mut lines: Vec<Line> = vec![];

    // Config line.
    lines.push(Line::from(vec![
        Span::styled("  rgs: ", Style::default().fg(Color::DarkGray)),
        Span::styled(iv.rgs_url.clone(), Style::default().fg(Color::Cyan)),
        Span::styled("  network: ", Style::default().fg(Color::DarkGray)),
        Span::styled(iv.ln_network.clone(), Style::default().fg(Color::White)),
        if iv.rgs_injected {
            Span::styled("  [RGS injected]", Style::default().fg(Color::Green))
        } else {
            Span::styled("  [RGS not injected]", Style::default().fg(Color::DarkGray))
        },
    ]));
    lines.push(Line::raw(""));

    if iv.services.is_empty() {
        lines.push(Line::from(Span::styled(
            "  Waiting for health data…",
            Style::default().fg(Color::DarkGray),
        )));
    } else {
        lines.push(Line::from(Span::styled(
            " SERVICE              STATUS     DETAIL",
            Style::default().fg(Color::DarkGray),
        )));
        lines.push(Line::raw(""));
        for svc in &iv.services {
            lines.push(Line::from(vec![
                Span::styled(
                    format!("  {} ", svc.status.indicator()),
                    Style::default().fg(svc.status.color()),
                ),
                Span::styled(
                    format!("{:<20}", svc.name),
                    Style::default().fg(Color::White),
                ),
                Span::styled(
                    format!("{:<10}", svc.status.label()),
                    Style::default().fg(svc.status.color()),
                ),
                Span::styled(svc.detail.clone(), Style::default().fg(Color::DarkGray)),
            ]));
        }
    }

    frame.render_widget(Paragraph::new(lines).block(Block::default()), area);
}

fn render_tab_graph(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
    let mut lines: Vec<Line> = vec![];
    if iv.graph_loading {
        lines.push(Line::from(Span::styled(
            "  Loading graph data…",
            Style::default().fg(Color::DarkGray),
        )));
    } else if let Some(ref g) = iv.graph {
        lines.push(Line::from(vec![
            Span::styled("  Nodes: ", Style::default().fg(Color::DarkGray)),
            Span::styled(g.node_count.to_string(), Style::default().fg(Color::Cyan)),
            Span::styled("   Channels: ", Style::default().fg(Color::DarkGray)),
            Span::styled(
                g.channel_count.to_string(),
                Style::default().fg(Color::Cyan),
            ),
        ]));
        lines.push(Line::raw(""));
        if g.channels.is_empty() {
            lines.push(Line::from(Span::styled(
                "  No channel data available.",
                Style::default().fg(Color::DarkGray),
            )));
        } else {
            lines.push(Line::from(Span::styled(
                " SCID                   NODE1",
                Style::default().fg(Color::DarkGray),
            )));
            lines.push(Line::raw(""));
            let height = area.height as usize;
            for ch in g.channels.iter().take(height.saturating_sub(4)) {
                let node1_short = if ch.node1.len() > 20 {
                    format!("{}…", &ch.node1[..20])
                } else {
                    ch.node1.clone()
                };
                lines.push(Line::from(vec![
                    Span::styled(
                        format!("  {:<22}", ch.scid),
                        Style::default().fg(Color::White),
                    ),
                    Span::styled(node1_short, Style::default().fg(Color::Gray)),
                ]));
            }
            if g.channels.len() > height.saturating_sub(4) {
                lines.push(Line::from(Span::styled(
                    format!(
                        "  … and {} more",
                        g.channels.len() - height.saturating_sub(4)
                    ),
                    Style::default().fg(Color::DarkGray),
                )));
            }
        }
    } else {
        lines.push(Line::from(Span::styled(
            "  No graph data yet — waiting for first poll…",
            Style::default().fg(Color::DarkGray),
        )));
    }
    frame.render_widget(Paragraph::new(lines).block(Block::default()), area);
}

fn render_tab_snapshot(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
    let mut lines: Vec<Line> = vec![];
    if iv.snapshot_loading {
        lines.push(Line::from(Span::styled(
            "  Loading snapshot…",
            Style::default().fg(Color::DarkGray),
        )));
    } else if let Some(ref s) = iv.snapshot {
        let kv = |label: &'static str, value: String| -> Line<'static> {
            Line::from(vec![
                Span::styled(
                    format!("  {:<20}", label),
                    Style::default().fg(Color::DarkGray),
                ),
                Span::styled(value, Style::default().fg(Color::White)),
            ])
        };
        lines.push(kv("version", s.version.to_string()));
        lines.push(kv("chain_hash", s.chain_hash.clone()));
        lines.push(kv("timestamp", s.timestamp_str.clone()));
        lines.push(kv("nodes", s.node_count.to_string()));
        lines.push(kv("channels", s.channel_count.to_string()));
        lines.push(kv("updates (est.)", s.update_count.to_string()));
    } else {
        lines.push(Line::from(Span::styled(
            "  No snapshot data yet — waiting for first poll…",
            Style::default().fg(Color::DarkGray),
        )));
    }
    frame.render_widget(Paragraph::new(lines).block(Block::default()), area);
}

fn render_tab_db(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
    let mut lines: Vec<Line> = vec![];
    if iv.db_loading {
        lines.push(Line::from(Span::styled(
            "  Loading database summary…",
            Style::default().fg(Color::DarkGray),
        )));
    } else if let Some(ref d) = iv.db {
        let kv = |label: &'static str, value: u64| -> Line<'static> {
            Line::from(vec![
                Span::styled(
                    format!("  {:<28}", label),
                    Style::default().fg(Color::DarkGray),
                ),
                Span::styled(value.to_string(), Style::default().fg(Color::Cyan)),
            ])
        };
        lines.push(kv("node_announcements", d.node_announcements));
        lines.push(kv("channel_announcements", d.channel_announcements));
        lines.push(kv("channel_updates", d.channel_updates));
        lines.push(kv("config_rows", d.config_rows));
    } else {
        lines.push(Line::from(Span::styled(
            "  No database data yet — waiting for first poll…",
            Style::default().fg(Color::DarkGray),
        )));
    }
    frame.render_widget(Paragraph::new(lines).block(Block::default()), area);
}

fn render_tab_logs(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
    let height = area.height as usize;
    let all_lines = iv.visible_lines();
    let total = all_lines.len();

    let start = if iv.auto_scroll {
        total.saturating_sub(height)
    } else {
        iv.scroll_pos.min(total.saturating_sub(height))
    };

    let filter_label = match iv.log_filter {
        super::state::InfraLogFilter::All => "all",
        super::state::InfraLogFilter::Rgs => "rgs",
        super::state::InfraLogFilter::Postgres => "postgres",
    };

    let text: Vec<Line> = std::iter::once(Line::from(vec![
        Span::styled(
            format!(" filter: {} ", filter_label),
            Style::default().fg(Color::DarkGray),
        ),
        Span::styled("[a]all ", Style::default().fg(Color::Cyan)),
        Span::styled("[r]rgs ", Style::default().fg(Color::Cyan)),
        Span::styled("[p]postgres", Style::default().fg(Color::Cyan)),
        Span::styled(
            format!("  ({}/{})", total, iv.logs_total()),
            Style::default().fg(Color::DarkGray),
        ),
    ]))
    .chain(
        all_lines
            .iter()
            .skip(start)
            .take(height.saturating_sub(1))
            .map(|l| colorize_infra_line(l)),
    )
    .collect();

    frame.render_widget(Paragraph::new(text).block(Block::default()), area);
}

fn colorize_infra_line(line: &str) -> Line<'static> {
    let style = if line.contains("ERROR") || line.contains("error") || line.contains("FATAL") {
        Style::default().fg(Color::Red)
    } else if line.contains("WARN") || line.contains("warn") {
        Style::default().fg(Color::Yellow)
    } else if line.contains("INFO") || line.contains("info") {
        Style::default().fg(Color::Green)
    } else {
        Style::default().fg(Color::Gray)
    };
    Line::from(Span::styled(line.to_owned(), style))
}

fn render_infra_footer(frame: &mut Frame, area: Rect, iv: &InfraViewState) {
    let tab_hints = match iv.active_tab {
        super::state::InfraTab::Logs => Line::from(vec![
            Span::styled(" [1-5]", Style::default().fg(Color::Cyan)),
            Span::raw(" tab  "),
            Span::styled("[Tab]", Style::default().fg(Color::Cyan)),
            Span::raw(" next  "),
            Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
            Span::raw(" scroll  "),
            Span::styled("[g/G]", Style::default().fg(Color::Cyan)),
            Span::raw(" top/btm  "),
            Span::styled("[a/r/p]", Style::default().fg(Color::Cyan)),
            Span::raw(" filter  "),
            Span::styled("[I/Esc]", Style::default().fg(Color::Magenta)),
            Span::raw(" back "),
        ]),
        _ => Line::from(vec![
            Span::styled(" [1-5]", Style::default().fg(Color::Cyan)),
            Span::raw(" tab  "),
            Span::styled("[Tab]", Style::default().fg(Color::Cyan)),
            Span::raw(" next  "),
            Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
            Span::raw(" scroll  "),
            Span::styled("[I/Esc]", Style::default().fg(Color::Magenta)),
            Span::raw(" back "),
        ]),
    };
    frame.render_widget(Paragraph::new(tab_hints), area);
}
