//! Portal Monitoring — Phase 1: section-based layout from the CLI observability snapshot
//! (same data as before: `gateway.observability` → healthLines + Prometheus stats).
//!
//! ## Snapshot layout contract (fragile)
//! `parse_snapshot` splits on **substring markers** that must stay aligned across:
//! - `packages/os-cli/src/tui-handlers.ts` (`observabilityHealthLines`, `summarizeGatewayPrometheusMetrics` output)
//! - `main.rs` (where `content_lines` are assembled for `PortalMonitoringState`)
//!
//! If any layer renames or drops these strings, `unwrap_or(lines.len())` paths can put most of the
//! text into `overview_prefix` and leave `health` / `dependencies` empty. When changing those call
//! sites, run `cargo test -p mk3-tui portal_snapshot` and update the fixture in this module.
//!
//! Marker constants in this file (`SNAPSHOT_MARKER_*`) are the single source of truth for those strings.

use ratatui::layout::{Alignment, Constraint, Direction, Layout};
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};

use crate::app::AppState;
use crate::monitoring::{MetricsDrillPanel, MonitoringSection, SectionStatus};

const BRAND_PURPLE: Color = Color::Rgb(138, 43, 226);
const CYBER_CYAN: Color = Color::Rgb(0, 255, 255);
const NEON_GREEN: Color = Color::Rgb(57, 255, 20);
const TEXT_DIM: Color = Color::Rgb(100, 100, 120);
const TEXT_PRIMARY: Color = Color::Rgb(230, 230, 240);
const TEXT_WARN: Color = Color::Rgb(255, 180, 80);
const TEXT_HEADER: Color = Color::Rgb(0, 200, 255);
const BG_PANEL: Color = Color::Rgb(18, 18, 25);
const SEPARATOR_COLOR: Color = Color::Rgb(60, 60, 80);
const SEL_BG: Color = Color::Rgb(35, 40, 55);

// Substrings searched with `contains()` — keep in sync with tui-handlers + main.rs assembly.
/// First line of `observabilityHealthLines` in `tui-handlers.ts` must still include this.
pub(crate) const SNAPSHOT_MARKER_LIVE: &str = "Live link check";
/// `Dependency checks (from /ready):` block header from CLI.
pub(crate) const SNAPSHOT_MARKER_DEPS: &str = "Dependency checks (from /ready):";
/// Title line before Prometheus stats block (see `main.rs` portal observability handler).
pub(crate) const SNAPSHOT_MARKER_PROM: &str = "Prometheus /metrics";
/// Lines after stats; route table header from `main.rs`.
pub(crate) const SNAPSHOT_MARKER_TOP_ROUTES: &str = "Top HTTP routes";

fn gateway_display_url(state: &AppState) -> String {
    state
        .gateway_url
        .as_deref()
        .or(state.connection_portal.last_successful_url.as_deref())
        .unwrap_or("(not linked)")
        .to_string()
}

/// Partition `content_lines` from `gateway.observability` into sections.
#[derive(Debug, Clone, Default)]
struct ParsedSnapshot {
    overview_prefix: Vec<String>,
    health: Vec<String>,
    dependencies: Vec<String>,
    metrics: Vec<String>,
    queue: Vec<String>,
    routes: Vec<String>,
}

fn trim_strings(lines: &[String]) -> Vec<String> {
    lines.iter().map(|s| s.trim_end().to_string()).collect()
}

fn parse_snapshot(lines: &[String]) -> ParsedSnapshot {
    let mut out = ParsedSnapshot::default();
    if lines.is_empty() {
        return out;
    }

    let live_idx = lines.iter().position(|l| l.contains(SNAPSHOT_MARKER_LIVE));
    let deps_idx = lines.iter().position(|l| l.contains(SNAPSHOT_MARKER_DEPS));
    let prom_idx = lines.iter().position(|l| l.contains(SNAPSHOT_MARKER_PROM));
    let top_idx = lines
        .iter()
        .position(|l| l.contains(SNAPSHOT_MARKER_TOP_ROUTES));

    let live = live_idx.unwrap_or(lines.len());
    out.overview_prefix = trim_strings(&lines[..live]);

    let after_live = if live < lines.len() {
        live
    } else {
        lines.len()
    };
    let deps_start = deps_idx.unwrap_or(lines.len());
    let prom_start = prom_idx.unwrap_or(lines.len());

    if deps_start < lines.len() && deps_start > after_live {
        out.health = trim_strings(&lines[after_live..deps_start]);
    } else if prom_start > after_live && deps_idx.is_none() {
        // No dependency block — treat remainder before Prometheus as health only
        let end_h = prom_start.min(lines.len());
        out.health = trim_strings(&lines[after_live..end_h]);
    }

    if deps_start < lines.len() && prom_start > deps_start {
        out.dependencies = trim_strings(&lines[deps_start..prom_start]);
    }

    let routes_start = top_idx.unwrap_or(lines.len());
    if prom_start < lines.len() && routes_start >= prom_start {
        // Skip Prometheus title line(s) and blank → stats
        let mut i = prom_start;
        while i < lines.len() && i < routes_start {
            let l = lines[i].trim();
            if l.is_empty() {
                i += 1;
                continue;
            }
            if l.contains(SNAPSHOT_MARKER_PROM) {
                i += 1;
                continue;
            }
            break;
        }
        let stats_end = routes_start.min(lines.len());
        if i < stats_end {
            let stats_block = trim_strings(&lines[i..stats_end]);
            for row in stats_block {
                if row.contains("Queue jobs") {
                    out.queue.push(row);
                } else if !row.is_empty() {
                    out.metrics.push(row);
                }
            }
        }
    }

    if top_idx.is_some() && routes_start < lines.len() {
        // Include header line "Top HTTP routes..." and column header + rows
        out.routes = trim_strings(&lines[routes_start..]);
    }

    out
}

fn status_from_text(block: &[String]) -> SectionStatus {
    let joined = block.join("\n");
    let not_ready_bad =
        joined.contains("not ready") && !joined.contains("✓ /ready: all critical dependencies OK");
    if joined.contains("✗")
        || joined.contains(": down")
        || joined.contains("Verify failed")
        || not_ready_bad
    {
        return SectionStatus::Unhealthy;
    }
    if joined.contains('⚠') || joined.contains("degraded") || joined.contains("not OK") {
        return SectionStatus::Degraded;
    }
    if joined.contains('✓') || joined.contains(": up") {
        return SectionStatus::Healthy;
    }
    SectionStatus::Unknown
}

fn overall_status(parsed: &ParsedSnapshot) -> SectionStatus {
    let mut s = status_from_text(&parsed.health);
    let ds = status_from_text(&parsed.dependencies);
    if matches!(ds, SectionStatus::Unhealthy) {
        s = SectionStatus::Unhealthy;
    } else if matches!(s, SectionStatus::Healthy) && matches!(ds, SectionStatus::Degraded) {
        s = SectionStatus::Degraded;
    } else if matches!(s, SectionStatus::Healthy) && matches!(ds, SectionStatus::Unhealthy) {
        s = SectionStatus::Unhealthy;
    }
    s
}

fn summarize_overview(parsed: &ParsedSnapshot, url: &str) -> String {
    let st = overall_status(parsed);
    let tag = match st {
        SectionStatus::Healthy => "healthy",
        SectionStatus::Degraded => "degraded",
        SectionStatus::Unhealthy => "unhealthy",
        _ => "unknown",
    };
    format!("{} · {}", url, tag)
}

fn summarize_health(health: &[String]) -> String {
    for row in health {
        if row.contains("Uptime:") {
            return row.trim().to_string();
        }
        if row.contains("/health OK") {
            return row.trim().chars().take(64).collect();
        }
    }
    if health.is_empty() {
        "—".into()
    } else {
        health[0].trim().chars().take(56).collect()
    }
}

fn summarize_dependencies(deps: &[String]) -> String {
    let mut down = 0;
    let mut warn = 0;
    for row in deps {
        if row.contains(": down") || row.contains('✗') {
            down += 1;
        }
        if row.contains('⚠') || row.contains("degraded") {
            warn += 1;
        }
    }
    if down > 0 {
        format!("{} dependency down", down)
    } else if warn > 0 {
        format!("{} warning(s)", warn)
    } else if deps.is_empty() {
        "—".into()
    } else {
        "all checks OK".into()
    }
}

fn summarize_metrics(metrics: &[String]) -> String {
    let mut req = None;
    let mut err = None;
    for row in metrics {
        let t = row.trim();
        if t.starts_with("HTTP requests total") {
            if let Some(n) = t.split_whitespace().last() {
                req = Some(n.to_string());
            }
        }
        if t.starts_with("HTTP request errors") {
            if let Some(rest) = t.strip_prefix("HTTP request errors") {
                let parts: Vec<&str> = rest.split_whitespace().collect();
                if let Some(first) = parts.first() {
                    err = Some(first.to_string());
                }
            }
        }
    }
    match (req, err) {
        (Some(r), Some(e)) => format!("→ {} req, {} err", r, e),
        (Some(r), None) => format!("→ {} req", r),
        _ => "—".into(),
    }
}

fn summarize_queue(queue: &[String]) -> String {
    if queue.is_empty() {
        "—".into()
    } else {
        queue
            .iter()
            .map(|s| s.trim().chars().take(48).collect::<String>())
            .collect::<Vec<_>>()
            .join(" · ")
    }
}

/// First unsigned integer token after stripping a line prefix (handles `packages/os-cli` stats formatting).
fn first_u64_after_line_prefix(line: &str, prefix: &str) -> Option<u64> {
    let rest = line.trim().strip_prefix(prefix)?.trim();
    let tok = rest.split_whitespace().next()?;
    tok.replace(",", "").parse().ok()
}

/// Metrics row tint: non-zero **HTTP request errors** total ⇒ degraded (Prometheus text from CLI).
fn metrics_section_status(metrics: &[String]) -> SectionStatus {
    for row in metrics {
        let t = row.trim();
        if t.starts_with("HTTP request errors") {
            return match first_u64_after_line_prefix(t, "HTTP request errors") {
                Some(n) if n > 0 => SectionStatus::Degraded,
                _ => SectionStatus::Healthy,
            };
        }
    }
    // No matching row (unusual) — avoid implying “all green”.
    SectionStatus::Unknown
}

/// Queue stats tint: non-zero **Queue jobs failed** gauge ⇒ degraded (`gateway-observability.ts` labels).
fn queue_section_status(queue: &[String]) -> SectionStatus {
    for row in queue {
        let t = row.trim();
        if t.starts_with("Queue jobs failed") {
            return match first_u64_after_line_prefix(t, "Queue jobs failed") {
                Some(n) if n > 0 => SectionStatus::Degraded,
                _ => SectionStatus::Healthy,
            };
        }
    }
    SectionStatus::Healthy
}

/// Log lines from CLI use `timestamp [level] message`; derive status from worst `[level]` seen.
fn logs_section_status(lines: &[String]) -> SectionStatus {
    if lines.is_empty() {
        return SectionStatus::Unknown;
    }
    let mut worst: u8 = 0; // 0 ok, 1 warn, 2 error
    for row in lines {
        let lower = row.to_ascii_lowercase();
        if lower.contains("[fatal]") || lower.contains("[error]") || lower.contains("[critical]") {
            worst = worst.max(2);
        } else if lower.contains("[warn]") || lower.contains("[warning]") {
            worst = worst.max(1);
        }
    }
    match worst {
        2 => SectionStatus::Unhealthy,
        1 => SectionStatus::Degraded,
        _ => SectionStatus::Healthy,
    }
}

/// Phase 3: tiny ASCII sparkline from monotonic `http_requests_total` samples (full snapshot polling).
fn ascii_sparkline(samples: &[u64]) -> String {
    if samples.len() < 2 {
        return String::new();
    }
    const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
    let mn = *samples.iter().min().unwrap();
    let mx = *samples.iter().max().unwrap();
    let span = (mx - mn).max(1);
    samples
        .iter()
        .map(|&v| {
            let i = (((v - mn) as f64 / span as f64) * 7.0).round() as usize;
            BLOCKS[i.min(7)]
        })
        .collect()
}

fn metric_history_sparkline<F>(state: &AppState, f: F) -> String
where
    F: Fn(&crate::app::MetricHistoryEntry) -> u64,
{
    let samples: Vec<u64> = state
        .portal_monitoring
        .metric_history
        .iter()
        .map(f)
        .collect();
    ascii_sparkline(&samples)
}

fn metric_history_lines(state: &AppState) -> Vec<String> {
    let hist = &state.portal_monitoring.metric_history;
    if hist.is_empty() {
        return vec!["History: no samples yet (wait for a full snapshot refresh).".into()];
    }
    let first = hist.front().map(|x| x.timestamp.as_str()).unwrap_or("?");
    let last = hist.back().map(|x| x.timestamp.as_str()).unwrap_or("?");
    vec![
        format!(
            "History: {} / 720 samples in memory (first {}  last {}; wall-clock span depends on refresh cadence)",
            hist.len(),
            first,
            last
        ),
        format!(
            "  HTTP requests  {}",
            metric_history_sparkline(state, |x| x.http_requests)
        ),
        format!(
            "  HTTP errors    {}",
            metric_history_sparkline(state, |x| x.http_errors)
        ),
        format!(
            "  Queue waiting  {}",
            metric_history_sparkline(state, |x| x.queue_waiting)
        ),
        format!(
            "  Queue failed   {}",
            metric_history_sparkline(state, |x| x.queue_failed)
        ),
        format!(
            "  Runs active    {}",
            metric_history_sparkline(state, |x| x.runs_active)
        ),
        format!(
            "  SSE active     {}",
            metric_history_sparkline(state, |x| x.sse_active)
        ),
    ]
}

fn section_row_style(status: SectionStatus) -> Style {
    match status {
        SectionStatus::Healthy => Style::default().fg(NEON_GREEN),
        SectionStatus::Degraded => Style::default().fg(TEXT_WARN),
        SectionStatus::Unhealthy => Style::default().fg(Color::Red),
        SectionStatus::Loading => Style::default().fg(CYBER_CYAN),
        SectionStatus::Unknown => Style::default().fg(TEXT_DIM),
    }
}

const FIXED_TOP_LINES: usize = 3;

fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
    let vertical = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(area);
    Layout::default()
        .direction(Direction::Horizontal)
        .constraints([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(vertical[1])[1]
}

fn summary_max_chars(area_width: u16) -> usize {
    (area_width as usize).saturating_sub(26).clamp(24, 120)
}

/// Max `scroll_offset` for section list (excludes fixed gateway header inside body panel).
pub fn portal_monitoring_scroll_max(
    state: &AppState,
    summary_width: usize,
    body_inner_h: usize,
) -> usize {
    let total = build_body_lines(state, summary_width).len();
    let content_h = body_inner_h.saturating_sub(FIXED_TOP_LINES).max(1);
    total.saturating_sub(content_h.min(total.max(1)))
}

/// Best-effort scroll offset that brings a section header into view.
pub fn portal_monitoring_section_offset(
    state: &AppState,
    summary_width: usize,
    body_inner_h: usize,
    section: MonitoringSection,
) -> usize {
    let body_lines = build_body_lines(state, summary_width);
    let target = section.display_name();
    let raw_offset = body_lines
        .iter()
        .position(|line| {
            line.spans
                .iter()
                .any(|span| span.content.as_ref().contains(target))
        })
        .unwrap_or(0);
    let max_scroll = portal_monitoring_scroll_max(state, summary_width, body_inner_h);
    raw_offset.min(max_scroll)
}

/// Build scrollable section lines. **Phase 1:** collapsed summaries and ●/⚠/✖ colors are derived only from
/// `parse_snapshot` of `content_lines`; `MonitoringState.sections` controls **expand** + **selection** only
/// (not `SectionState.status` / `summary`).
fn build_body_lines(state: &AppState, summary_width: usize) -> Vec<Line<'static>> {
    let mut out: Vec<Line<'static>> = Vec::new();

    if state.portal_monitoring.loading && state.portal_monitoring.content_lines.is_empty() {
        out.push(Line::from(Span::styled(
            "⏳ Fetching Gateway observability snapshot…",
            Style::default().fg(TEXT_WARN),
        )));
        return out;
    }
    if let Some(ref err) = state.portal_monitoring.error {
        out.push(Line::from(vec![
            Span::styled("✗ ", Style::default().fg(Color::Red).bold()),
            Span::styled(err.clone(), Style::default().fg(TEXT_PRIMARY)),
        ]));
        let hint = if state.portal_monitoring.content_lines.is_empty() {
            "Press R to retry"
        } else {
            "Showing last known snapshot below. Press R to retry."
        };
        out.push(Line::from(Span::styled(
            hint,
            Style::default().fg(TEXT_DIM),
        )));
        if state.portal_monitoring.content_lines.is_empty() {
            return out;
        }
        out.push(Line::from(""));
    }
    if state.portal_monitoring.content_lines.is_empty() {
        out.push(Line::from(Span::styled(
            "No snapshot yet. Select Overview and press R for a full snapshot.",
            Style::default().fg(TEXT_DIM),
        )));
        return out;
    }

    let parsed = parse_snapshot(&state.portal_monitoring.content_lines);
    let url = gateway_display_url(state);
    let sections_order = MonitoringSection::all();
    let ms = &state.advanced_monitoring.monitoring_state;
    let sel = ms.selected_index;
    let overrides = &state.portal_monitoring.section_overrides;

    if let Some(alert) = state.portal_monitoring.dependency_alerts.back() {
        if alert.current != "healthy" {
            out.push(Line::from(vec![
                Span::styled("⚠ ", Style::default().fg(TEXT_WARN).bold()),
                Span::styled(
                    format!(
                        "Dependency status changed: {} → {} ({})",
                        alert.previous, alert.current, alert.timestamp
                    ),
                    Style::default().fg(TEXT_WARN),
                ),
            ]));
            out.push(Line::from(""));
        }
    }

    for (idx, section) in sections_order.iter().enumerate() {
        let sec_state = ms.sections.get(section);
        let expanded = sec_state.map(|s| s.expanded).unwrap_or(false);
        let selected = idx == sel;
        let ov = overrides.get(section);

        let (status, summary) = match section {
            MonitoringSection::Overview => {
                let st = overall_status(&parsed);
                (st, summarize_overview(&parsed, &url))
            }
            MonitoringSection::Health => {
                if let Some(lines) = ov {
                    (status_from_text(lines), summarize_health(lines))
                } else {
                    let st = status_from_text(&parsed.health);
                    (st, summarize_health(&parsed.health))
                }
            }
            MonitoringSection::Dependencies => {
                if let Some(lines) = ov {
                    (status_from_text(lines), summarize_dependencies(lines))
                } else {
                    let st = status_from_text(&parsed.dependencies);
                    (st, summarize_dependencies(&parsed.dependencies))
                }
            }
            MonitoringSection::Metrics => {
                if let Some(lines) = ov {
                    (metrics_section_status(lines), summarize_metrics(lines))
                } else {
                    let st = metrics_section_status(&parsed.metrics);
                    (st, summarize_metrics(&parsed.metrics))
                }
            }
            MonitoringSection::Queue => {
                if let Some(lines) = ov {
                    (queue_section_status(lines), summarize_queue(lines))
                } else {
                    let st = queue_section_status(&parsed.queue);
                    (st, summarize_queue(&parsed.queue))
                }
            }
            MonitoringSection::Logs => {
                if let Some(lines) = ov {
                    let summ = if lines.is_empty() {
                        "no entries".into()
                    } else {
                        format!("{} entries", lines.len())
                    };
                    (logs_section_status(lines), summ)
                } else if state.portal_monitoring.logs_fetch_loading {
                    (SectionStatus::Loading, "loading…".into())
                } else {
                    (SectionStatus::Unknown, "Press L to fetch logs".to_string())
                }
            }
            MonitoringSection::System => {
                if let Some(lines) = ov {
                    let summ = lines
                        .first()
                        .map(|s| s.trim().chars().take(56).collect::<String>())
                        .unwrap_or_else(|| "—".into());
                    (SectionStatus::Healthy, summ)
                } else {
                    (
                        SectionStatus::Unknown,
                        "Press S for CLI/host stats".to_string(),
                    )
                }
            }
        };

        let sym = status.symbol();
        let arrow = if expanded { "▼" } else { "▶" };
        let prefix = if selected { "› " } else { "  " };

        let header_style = section_row_style(status).patch(
            Style::default()
                .bg(if selected { SEL_BG } else { BG_PANEL })
                .bold(),
        );

        let spans = vec![
            Span::styled(
                prefix,
                Style::default().fg(if selected { CYBER_CYAN } else { TEXT_DIM }),
            ),
            Span::styled(format!("{} ", arrow), Style::default().fg(TEXT_HEADER)),
            Span::styled(
                format!("{:<14}", section.display_name()),
                Style::default().fg(TEXT_PRIMARY),
            ),
            Span::styled(format!("{} ", sym), header_style),
            Span::styled(
                summary.chars().take(summary_width).collect::<String>(),
                Style::default().fg(TEXT_PRIMARY),
            ),
        ];
        out.push(Line::from(spans));

        if expanded {
            let loading_detail = state.portal_monitoring.section_refresh_loading == Some(*section)
                || (matches!(section, MonitoringSection::Logs)
                    && state.portal_monitoring.logs_fetch_loading)
                || (matches!(section, MonitoringSection::Metrics)
                    && state.portal_monitoring.metrics_drill_loading);
            let using_override = ov.is_some();

            let mut detail: Vec<String> = if loading_detail {
                vec!["⏳ Loading…".into()]
            } else if matches!(section, MonitoringSection::Metrics)
                && state.portal_monitoring.metrics_drill.is_some()
            {
                state.portal_monitoring.metrics_drill_lines.clone()
            } else if let Some(lines) = ov {
                if matches!(section, MonitoringSection::Logs) && lines.is_empty() {
                    vec![
                        "No lines returned — the Gateway monitoring log buffer is empty.".into(),
                        "Entries require the Gateway to record events (e.g. addMonitoringLog). Traffic alone may not appear here.".into(),
                    ]
                } else if matches!(section, MonitoringSection::System) {
                    // System diagnostics lines come only from the CLI (`collectSystemStats` at S press).
                    // Do not merge per-frame TUI introspection here: values like `scroll_offset` and
                    // `render_count` legitimately change while scrolling and looked like "fake" host metrics.
                    lines.clone()
                } else {
                    lines.clone()
                }
            } else {
                match section {
                    MonitoringSection::Overview => parsed.overview_prefix.clone(),
                    MonitoringSection::Health => parsed.health.clone(),
                    MonitoringSection::Dependencies => parsed.dependencies.clone(),
                    MonitoringSection::Metrics => {
                        let mut m = parsed.metrics.clone();
                        if state.portal_monitoring.trend_view {
                            let mut v = metric_history_lines(state);
                            v.push(String::new());
                            v.extend(m);
                            m = v;
                        } else if state.portal_monitoring.metrics_drill.is_none()
                            && !state.portal_monitoring.http_total_sparkline.is_empty()
                        {
                            let samples: Vec<u64> = state
                                .portal_monitoring
                                .http_total_sparkline
                                .iter()
                                .copied()
                                .collect();
                            let spark = ascii_sparkline(&samples);
                            if !spark.is_empty() {
                                let mut v = vec![format!(
                                    "HTTP total (from recent full snapshots) {}",
                                    spark
                                )];
                                v.push(String::new());
                                v.extend(m);
                                m = v;
                            }
                        }
                        m
                    }
                    MonitoringSection::Queue => parsed.queue.clone(),
                    MonitoringSection::Logs => vec![
                        "Press L to fetch Gateway logs (monitoring.logs → GET /api/monitoring/logs).".into(),
                        "If results stay empty, nothing may be writing to the Gateway log ring buffer yet.".into(),
                    ],
                    MonitoringSection::System => vec![
                        "Press S to fetch CLI process + host OS stats and run diagnostics.".into(),
                        "Press S again to hide diagnostics when they are shown.".into(),
                        String::new(),
                        "MK3 TUI runtime".into(),
                        format!(
                            "  uptime={}s  renders={}  scheduled={}",
                            state.uptime_secs,
                            state.render_count,
                            state.render_scheduled_count
                        ),
                    ],
                }
            };
            if matches!(section, MonitoringSection::Dependencies)
                && !state.portal_monitoring.dependency_alerts.is_empty()
            {
                detail.push(String::new());
                detail.push(
                    "Recent dependency status changes (text-derived from health lines)".into(),
                );
                for alert in state
                    .portal_monitoring
                    .dependency_alerts
                    .iter()
                    .rev()
                    .take(10)
                {
                    detail.push(format!(
                        "  {}  {} → {}",
                        alert.timestamp, alert.previous, alert.current
                    ));
                }
            }
            if matches!(section, MonitoringSection::Overview) {
                if let Some(path) = &state.portal_monitoring.last_export_path {
                    detail.push(String::new());
                    detail.push(format!("Last export: {}", path));
                }
            }
            if matches!(section, MonitoringSection::Metrics)
                && !using_override
                && state.portal_monitoring.metrics_drill.is_none()
                && !parsed.routes.is_empty()
            {
                let mut m = detail;
                m.push(String::new());
                m.extend(parsed.routes.clone());
                for row in m {
                    out.push(Line::from(vec![Span::styled(
                        format!("    {}", row),
                        Style::default().fg(TEXT_DIM),
                    )]));
                }
            } else {
                for row in detail {
                    out.push(Line::from(vec![Span::styled(
                        format!("    {}", row),
                        Style::default().fg(TEXT_DIM),
                    )]));
                }
            }
            if matches!(section, MonitoringSection::Overview) {
                out.push(Line::from(vec![Span::styled(
                    "    Tip: Overview+R = full snapshot · other rows+R = section · E exports JSON to current working directory · H trends · L logs · A auto.",
                    Style::default().fg(TEXT_DIM),
                )]));
            }
            if matches!(section, MonitoringSection::System) && expanded {
                out.push(Line::from(vec![Span::styled(
                    "    One CLI+host snapshot per S (see timestamp in block). Scrolling only moves the view; press S to refresh stats.",
                    Style::default().fg(TEXT_DIM),
                )]));
            }
            if matches!(section, MonitoringSection::Metrics) && expanded {
                let sub = state
                    .portal_monitoring
                    .metrics_drill
                    .map(|p: MetricsDrillPanel| p.label())
                    .unwrap_or("(snapshot)");
                out.push(Line::from(vec![Span::styled(
                    format!(
                        "    Phase 3/5: {}  │  → next panel  │  ← leave drill  │  H trends {}",
                        sub,
                        if state.portal_monitoring.trend_view {
                            "on"
                        } else {
                            "off"
                        }
                    ),
                    Style::default().fg(TEXT_DIM),
                )]));
            }
        }
    }

    out
}

/// Full-screen Portal Monitoring (standalone base screen).
pub fn render(f: &mut Frame, state: &mut AppState) {
    let area = f.size();
    if area.width == 0 || area.height == 0 {
        return;
    }
    let summary_width = summary_max_chars(area.width);
    state.portal_monitoring.summary_clip_width = summary_width;

    // Auto-refresh flag + one mirror of `AppState.gateway_url` for code that reads `MonitoringState` only.
    // (URL shown in the panel always comes from `gateway_display_url` → `AppState`.)
    state
        .advanced_monitoring
        .monitoring_state
        .gateway_url
        .clone_from(&state.gateway_url);
    state.advanced_monitoring.monitoring_state.live_mode =
        state.portal_monitoring.auto_refresh_enabled;

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(3),
            Constraint::Min(6),
            Constraint::Length(3),
        ])
        .split(area);

    let body_inner_h = chunks[1].height.saturating_sub(2) as usize;
    state.portal_monitoring.viewport_lines = body_inner_h.max(5);

    f.render_widget(Clear, area);
    f.render_widget(Block::default().style(Style::default().bg(BG_PANEL)), area);

    let title = Block::default()
        .title(" Portal Monitoring ")
        .title_style(Style::default().fg(BRAND_PURPLE).bold())
        .borders(Borders::ALL)
        .border_style(Style::default().fg(CYBER_CYAN))
        .style(Style::default().bg(BG_PANEL));
    f.render_widget(title, chunks[0]);

    let url = gateway_display_url(state);
    let header_line = Line::from(vec![
        Span::styled("Gateway: ", Style::default().fg(TEXT_DIM)),
        Span::styled(url, Style::default().fg(CYBER_CYAN).bold()),
    ]);

    let auto_iv = state.portal_monitoring.auto_refresh_interval.as_secs();
    let live_txt = if state.portal_monitoring.auto_refresh_enabled {
        format!("Live ✓ {}s", auto_iv)
    } else {
        "Live ✗".into()
    };
    let meta = Line::from(vec![
        Span::styled("                    ", Style::default()),
        Span::styled(live_txt, Style::default().fg(NEON_GREEN)),
    ]);

    let keys_hint = Line::from(vec![Span::styled(
        "R full refresh · L logs · wheel/Pg scroll · ↑↓ scroll when Logs or System (S) diag · H metrics trends · → metrics drill · T dependencies · S system · A live",
        Style::default().fg(TEXT_DIM),
    )]);

    let body_lines = build_body_lines(state, summary_width);
    let total = body_lines.len();
    let content_h = body_inner_h.saturating_sub(FIXED_TOP_LINES).max(1);
    let max_scroll = total.saturating_sub(content_h.min(total.max(1)));
    let start = state.portal_monitoring.scroll_offset.min(max_scroll);
    let end = (start + content_h).min(total);
    let window: Vec<Line> = if start < total {
        body_lines[start..end].to_vec()
    } else {
        vec![]
    };

    let header_lines = vec![header_line, meta, keys_hint];
    let combined: Vec<Line> = header_lines.into_iter().chain(window.into_iter()).collect();

    let body = Paragraph::new(combined)
        .wrap(Wrap { trim: true })
        .style(Style::default().bg(BG_PANEL))
        .block(
            Block::default()
                .title(vec![
                    Span::styled(" ", Style::default()),
                    Span::styled(
                        "Sections (same Gateway data as before)",
                        Style::default().fg(NEON_GREEN).bold(),
                    ),
                    Span::styled(" ", Style::default()),
                ])
                .borders(Borders::ALL)
                .border_style(Style::default().fg(SEPARATOR_COLOR))
                .style(Style::default().bg(BG_PANEL)),
        );
    f.render_widget(body, chunks[1]);

    // Footer (Phase 1 keys) — align with body when the last observability pull failed
    let status = if state.portal_monitoring.loading {
        vec![
            Span::styled("⏳ ", Style::default().fg(TEXT_WARN)),
            Span::styled("loading", Style::default().fg(TEXT_WARN)),
        ]
    } else if state.portal_monitoring.section_refresh_loading.is_some()
        || state.portal_monitoring.logs_fetch_loading
        || state.portal_monitoring.metrics_drill_loading
    {
        vec![
            Span::styled("◉ ", Style::default().fg(CYBER_CYAN)),
            Span::styled("section…", Style::default().fg(CYBER_CYAN)),
        ]
    } else if state.portal_monitoring.error.is_some() {
        vec![
            Span::styled("⚠ ", Style::default().fg(TEXT_WARN)),
            Span::styled("error", Style::default().fg(TEXT_WARN)),
        ]
    } else {
        vec![
            Span::styled("● ", Style::default().fg(NEON_GREEN)),
            Span::styled("ok", Style::default().fg(NEON_GREEN)),
        ]
    };

    let auto_status = if state.portal_monitoring.auto_refresh_enabled {
        Span::styled("auto ✓", Style::default().fg(NEON_GREEN))
    } else {
        Span::styled("auto ✗", Style::default().fg(TEXT_DIM))
    };

    let refresh_info = if let Some(last) = state.portal_monitoring.last_refresh {
        let elapsed = last.elapsed().as_secs();
        format!(" ~{}s ago", elapsed)
    } else {
        " never".to_string()
    };

    let mut footer_spans = if area.width < 100 {
        vec![
            Span::styled("? ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("help  ", Style::default().fg(TEXT_DIM)),
            Span::styled("ESC ", Style::default().fg(BRAND_PURPLE).bold()),
            Span::styled("main  ", Style::default().fg(TEXT_DIM)),
            Span::styled("↑↓ ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("sel  ", Style::default().fg(TEXT_DIM)),
            Span::styled("Enter ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("open  ", Style::default().fg(TEXT_DIM)),
            Span::styled("R ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("refresh  ", Style::default().fg(TEXT_DIM)),
        ]
    } else {
        vec![
            Span::styled("? ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("help  ", Style::default().fg(TEXT_DIM)),
            Span::styled("│  ", Style::default().fg(SEPARATOR_COLOR)),
            Span::styled("ESC ", Style::default().fg(BRAND_PURPLE).bold()),
            Span::styled("Main  ", Style::default().fg(TEXT_DIM)),
            Span::styled("│  ", Style::default().fg(SEPARATOR_COLOR)),
            Span::styled("↑↓ ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("section  ", Style::default().fg(TEXT_DIM)),
            Span::styled("│  ", Style::default().fg(SEPARATOR_COLOR)),
            Span::styled("Enter ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("expand  ", Style::default().fg(TEXT_DIM)),
            Span::styled("│  ", Style::default().fg(SEPARATOR_COLOR)),
            Span::styled("R ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("refresh  ", Style::default().fg(TEXT_DIM)),
            Span::styled("│  ", Style::default().fg(SEPARATOR_COLOR)),
            Span::styled("L ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("logs  ", Style::default().fg(TEXT_DIM)),
            Span::styled("│  ", Style::default().fg(SEPARATOR_COLOR)),
            Span::styled("H ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("trend  ", Style::default().fg(TEXT_DIM)),
            Span::styled("│  ", Style::default().fg(SEPARATOR_COLOR)),
            Span::styled("E ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("export  ", Style::default().fg(TEXT_DIM)),
            Span::styled("│  ", Style::default().fg(SEPARATOR_COLOR)),
            Span::styled("S ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("diag  ", Style::default().fg(TEXT_DIM)),
            Span::styled("│  ", Style::default().fg(SEPARATOR_COLOR)),
            Span::styled("A ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("auto  ", Style::default().fg(TEXT_DIM)),
            Span::styled("│  ", Style::default().fg(SEPARATOR_COLOR)),
            Span::styled("Pg ", Style::default().fg(CYBER_CYAN).bold()),
            Span::styled("scroll  ", Style::default().fg(TEXT_DIM)),
            Span::styled("│  ", Style::default().fg(SEPARATOR_COLOR)),
        ]
    };
    footer_spans.extend(status);
    footer_spans.push(Span::styled(
        refresh_info.as_str(),
        Style::default().fg(TEXT_DIM),
    ));
    footer_spans.push(Span::styled("  ", Style::default()));
    footer_spans.push(auto_status);

    let footer = Paragraph::new(Line::from(footer_spans))
        .alignment(Alignment::Center)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(SEPARATOR_COLOR))
                .style(Style::default().bg(BG_PANEL)),
        );
    f.render_widget(footer, chunks[2]);

    if state.portal_monitoring.help_visible {
        let help_area = centered_rect(72, 62, area);
        let help_lines = vec![
            Line::from(vec![Span::styled(
                "Portal Monitoring Help",
                Style::default().fg(CYBER_CYAN).bold(),
            )]),
            Line::from(""),
            Line::from("Navigation"),
            Line::from(
                "  ↑/↓ select section    Enter/Space expand/collapse    PgUp/PgDn/mouse scroll",
            ),
            Line::from("  When Logs is selected, or System with diagnostics (after S), ↑/↓ scroll that content first."),
            Line::from("  ESC close help / return to Main"),
            Line::from(""),
            Line::from("Actions"),
            Line::from("  R full snapshot refresh without closing your current section"),
            Line::from("  L jump to Logs + fetch Gateway logs"),
            Line::from("  H jump to Metrics + toggle trends    → Metrics drill    ← leave drill"),
            Line::from("  T jump to Dependencies detail    S System stats + diagnostics (toggle)"),
            Line::from("  A toggle live auto-refresh (off by default for stable reading)"),
            Line::from("  E export diagnostics JSON to the current working directory"),
            Line::from(""),
            Line::from("Recovery"),
            Line::from(
                "  If a refresh fails, the last known snapshot remains visible when available.",
            ),
            Line::from("  Dependency alert banners are text-derived from Gateway health lines."),
            Line::from(""),
            Line::from("Press ? or ESC to close."),
        ];
        let help = Paragraph::new(help_lines)
            .wrap(Wrap { trim: true })
            .style(Style::default().bg(BG_PANEL).fg(TEXT_PRIMARY))
            .block(
                Block::default()
                    .title(" Help ")
                    .title_style(Style::default().fg(BRAND_PURPLE).bold())
                    .borders(Borders::ALL)
                    .border_style(Style::default().fg(CYBER_CYAN))
                    .style(Style::default().bg(BG_PANEL)),
            );
        f.render_widget(Clear, help_area);
        f.render_widget(help, help_area);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Ordering mirrors `main.rs` (observability handler) + strings from `tui-handlers` healthLines.
    fn fixture_representative() -> Vec<String> {
        vec![
            "Portal snapshot (health + metrics on each refresh / auto poll).".into(),
            "Fetched: 2026-01-01T00:00:00.000Z".into(),
            String::new(),
            "LIVE: +0 HTTP since last screen refresh (~0/min)  --".into(),
            String::new(),
            "Live link check (/health + /ready)".into(),
            "✓ /health OK — Gateway alive (5 ms)".into(),
            String::new(),
            "Dependency checks (from /ready):".into(),
            "  • ✓ PostgreSQL (4Runr DB): up".into(),
            String::new(),
            "Prometheus /metrics (HTTP total includes this screen’s /health, /ready, and /metrics polls; add API/agent traffic for runs/queue/SSE)."
                .into(),
            String::new(),
            "HTTP requests total     10".into(),
            "HTTP request errors     0 (0.00%)".into(),
            "Queue jobs waiting      0".into(),
            "Queue jobs failed       0".into(),
            String::new(),
            "Top HTTP routes (from http_requests_total labels)".into(),
            "  GET /health                         3".into(),
        ]
    }

    #[test]
    fn portal_snapshot_parse_partitions_sections() {
        let p = parse_snapshot(&fixture_representative());
        assert!(
            !p.overview_prefix.is_empty(),
            "overview should hold preamble + LIVE block"
        );
        assert!(
            p.health.iter().any(|l| l.contains(SNAPSHOT_MARKER_LIVE)),
            "health should contain live marker"
        );
        assert!(
            p.dependencies
                .iter()
                .any(|l| l.contains(SNAPSHOT_MARKER_DEPS)),
            "dependencies block present"
        );
        assert!(
            p.metrics.iter().any(|l| l.contains("HTTP requests total")),
            "metrics stats extracted"
        );
        assert!(
            p.queue.iter().any(|l| l.contains("Queue jobs")),
            "queue stats extracted"
        );
        assert!(
            p.routes
                .iter()
                .any(|l| l.contains(SNAPSHOT_MARKER_TOP_ROUTES)),
            "routes tail present"
        );
    }

    #[test]
    fn metrics_section_status_errors_nonzero_is_degraded() {
        let m = vec!["HTTP request errors     3 (2.00%)".into()];
        assert_eq!(metrics_section_status(&m), SectionStatus::Degraded);
        let ok = vec!["HTTP request errors     0 (0.00%)".into()];
        assert_eq!(metrics_section_status(&ok), SectionStatus::Healthy);
    }

    #[test]
    fn queue_section_status_failed_nonzero_is_degraded() {
        let q = vec!["Queue jobs failed       2".into()];
        assert_eq!(queue_section_status(&q), SectionStatus::Degraded);
        let ok = vec!["Queue jobs failed       0".into()];
        assert_eq!(queue_section_status(&ok), SectionStatus::Healthy);
    }

    #[test]
    fn logs_section_status_picks_worst_level() {
        assert_eq!(logs_section_status(&[]), SectionStatus::Unknown);
        let ok = vec!["2026-01-01T00:00:00.000Z [info] up".into()];
        assert_eq!(logs_section_status(&ok), SectionStatus::Healthy);
        let warn = vec!["t [warn] slow".into(), "t [info] fine".into()];
        assert_eq!(logs_section_status(&warn), SectionStatus::Degraded);
        let err = vec!["t [warn] x".into(), "t [error] boom".into()];
        assert_eq!(logs_section_status(&err), SectionStatus::Unhealthy);
    }
}
