dlt-tui 0.2.0

A fast, keyboard-centric TUI viewer for Automotive DLT (Diagnostic Log and Trace) files
use ratatui::{
    Frame,
    layout::{Constraint, Direction, Layout},
    style::{Color, Style},
    widgets::{Block, Borders, List, ListItem, Paragraph},
};

use crate::app::{App, AppScreen};

fn format_timestamp(us: u64) -> String {
    let total_secs = us / 1_000_000;
    let micros = us % 1_000_000;
    let hours = (total_secs / 3600) % 24;
    let minutes = (total_secs / 60) % 60;
    let seconds = total_secs % 60;
    format!("{:02}:{:02}:{:02}.{:06}", hours, minutes, seconds, micros)
}

pub fn draw(f: &mut Frame, app: &App) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([Constraint::Min(0), Constraint::Length(3)].as_ref())
        .split(f.area());

    f.render_widget(ratatui::widgets::Clear, f.area());

    match app.screen {
        AppScreen::Explorer => {
            let items: Vec<ListItem> = app
                .explorer_items
                .iter()
                .map(|entry| {
                    let symbol = if entry.is_dir { "[DIR] " } else { "[FILE]" };
                    let content = format!("{} {}", symbol, entry.name);
                    ListItem::new(content)
                })
                .collect();

            let list = List::new(items)
                .block(
                    Block::default()
                        .title("File Explorer")
                        .borders(Borders::ALL)
                        .border_style(Style::default().fg(Color::Cyan)),
                )
                .highlight_style(Style::default().bg(Color::DarkGray).fg(Color::Yellow))
                .highlight_symbol(">> ");

            let mut state = ratatui::widgets::ListState::default();
            state.select(Some(app.explorer_selected_index));

            f.render_stateful_widget(list, chunks[0], &mut state);
        }
        AppScreen::LogViewer => {
            let header_cells = ["Level", "Time", "ECU", "APP", "CTX", "Payload"]
                .iter()
                .map(|h| ratatui::widgets::Cell::from(*h).style(Style::default().fg(Color::Cyan)));

            let header = ratatui::widgets::Row::new(header_cells)
                .style(Style::default().bg(Color::DarkGray))
                .height(1)
                .bottom_margin(1);

            let rows = app.filtered_log_indices.iter().map(|&idx| {
                let log = &app.logs[idx];
                let (level_str, level_color) = match &log.log_level {
                    Some(crate::parser::LogLevel::Fatal) => ("FTL", Color::Red),
                    Some(crate::parser::LogLevel::Error) => ("ERR", Color::LightRed),
                    Some(crate::parser::LogLevel::Warn) => ("WRN", Color::Yellow),
                    Some(crate::parser::LogLevel::Info) => ("INF", Color::Green),
                    Some(crate::parser::LogLevel::Debug) => ("DBG", Color::Blue),
                    Some(crate::parser::LogLevel::Verbose) => ("VRB", Color::Gray),
                    Some(crate::parser::LogLevel::Unknown(_)) => ("UNK", Color::DarkGray),
                    None => ("---", Color::Reset),
                };

                let cells = vec![
                    ratatui::widgets::Cell::from(level_str).style(Style::default().fg(level_color)),
                    ratatui::widgets::Cell::from(format_timestamp(log.timestamp_us)),
                    ratatui::widgets::Cell::from(log.ecu_id.clone()),
                    ratatui::widgets::Cell::from(
                        log.apid.clone().unwrap_or_else(|| "-".to_string()),
                    ),
                    ratatui::widgets::Cell::from(
                        log.ctid.clone().unwrap_or_else(|| "-".to_string()),
                    ),
                    ratatui::widgets::Cell::from(log.payload_text.clone()),
                ];
                ratatui::widgets::Row::new(cells).height(1)
            });

            // Table widths: Level(5), Time(15), ECU(5), APP(5), CTX(5), Payload(Min(20))
            let widths = [
                Constraint::Length(5),
                Constraint::Length(21),
                Constraint::Length(5),
                Constraint::Length(5),
                Constraint::Length(5),
                Constraint::Min(20),
            ];

            let table = ratatui::widgets::Table::new(rows, widths)
                .header(header)
                .block(
                    Block::default()
                        .title("Log Viewer")
                        .borders(Borders::ALL)
                        .border_style(Style::default().fg(Color::Green)),
                )
                .row_highlight_style(Style::default().bg(Color::Indexed(8)).fg(Color::White))
                .highlight_symbol(">> ");

            // Note: ratatui::widgets::Table uses TableState instead of ListState
            let mut state = ratatui::widgets::TableState::default();
            state.select(Some(app.logs_selected_index));
            f.render_stateful_widget(table, chunks[0], &mut state);
        }
        AppScreen::LogDetail => {
            if let Some(&idx) = app.filtered_log_indices.get(app.logs_selected_index) {
                let log = &app.logs[idx];

                let detail_chunks = Layout::default()
                    .direction(Direction::Vertical)
                    .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
                    .split(chunks[0]);

                let meta_text = format!(
                    "Timestamp: {} ({} μs)\nECU ID: {}\nAPP ID: {}\nCTX ID: {}\nLevel: {:?}\n\nPayload Default Text: \n{}",
                    format_timestamp(log.timestamp_us),
                    log.timestamp_us,
                    log.ecu_id,
                    log.apid.as_deref().unwrap_or("-"),
                    log.ctid.as_deref().unwrap_or("-"),
                    log.log_level,
                    log.payload_text
                );

                let meta_para = Paragraph::new(meta_text).block(
                    Block::default()
                        .title("Log Metadata & Extracted Text")
                        .borders(Borders::ALL)
                        .border_style(Style::default().fg(Color::Cyan)),
                );
                f.render_widget(meta_para, detail_chunks[0]);

                let mut hex_lines = String::new();
                for chunk in log.payload_raw.chunks(16) {
                    let hex_parts: Vec<String> =
                        chunk.iter().map(|b| format!("{:02X}", b)).collect();
                    let char_parts: String = chunk
                        .iter()
                        .map(|&b| {
                            if (32..=126).contains(&b) {
                                b as char
                            } else {
                                '.'
                            }
                        })
                        .collect();

                    let mut hex_padded = hex_parts.join(" ");
                    while hex_padded.len() < 47 {
                        hex_padded.push(' ');
                    }

                    hex_lines.push_str(&format!("{}  |{}\n", hex_padded, char_parts));
                }

                let hex_para = Paragraph::new(hex_lines).block(
                    Block::default()
                        .title(format!(
                            "Payload Hex Dump ({} bytes)",
                            log.payload_raw.len()
                        ))
                        .borders(Borders::ALL)
                        .border_style(Style::default().fg(Color::Magenta)),
                );
                f.render_widget(hex_para, detail_chunks[1]);
            }
        }
    }

    let (status_str, status_style) = if let Some(ref err) = app.error_message {
        (
            format!("ERROR: {} | [Press any key to dismiss]", err),
            Style::default().bg(Color::Red).fg(Color::White),
        )
    } else {
        let mut string = match app.screen {
            AppScreen::Explorer => format!(
                "Mode: Explorer | Files: {} | (j/k) Move | (Enter) Open | (q) Quit",
                app.explorer_items.len()
            ),
            AppScreen::LogViewer => {
                let mut actives = Vec::new();
                if let Some(ref t) = app.filter.text {
                    actives.push(format!("Text='{}'", t));
                }
                if let Some(ref t) = app.filter.app_id {
                    actives.push(format!("APP='{}'", t));
                }
                if let Some(ref t) = app.filter.ctx_id {
                    actives.push(format!("CTX='{}'", t));
                }
                if let Some(ref t) = app.filter.min_level {
                    actives.push(format!("Level={:?}", t));
                }
                let filter_str = if actives.is_empty() {
                    String::new()
                } else {
                    format!("Filters: [{}] | ", actives.join(", "))
                };

                let conn_str = if let Some(ref addr) = app.connection_info {
                    format!("[TCP: {}] ", addr)
                } else if app.is_loading {
                    "[LOADING...] ".to_string()
                } else {
                    String::new()
                };

                let tail_str = if app.auto_scroll { "[TAIL] " } else { "" };

                format!(
                    "Mode: Viewer | {}{}{}Logs: {}/{} | (/) Text | (l) Level | (a) APP | (c) CTX | (C) Clear | (F) Tail",
                    conn_str,
                    tail_str,
                    filter_str,
                    app.filtered_log_indices.len(),
                    app.logs.len()
                )
            }
            AppScreen::LogDetail => format!(
                "Mode: Detail | Log {}/{} | (j/k) Scroll Logs | (Esc) Back to Viewer",
                app.logs_selected_index + 1,
                app.filtered_log_indices.len()
            ),
        };

        if let Some(ref mode) = app.filter_input_mode {
            let prefix = match mode {
                crate::app::FilterInputMode::Text => "Search Text",
                crate::app::FilterInputMode::AppId => "Filter APP ID",
                crate::app::FilterInputMode::CtxId => "Filter CTX ID",
                crate::app::FilterInputMode::MinLevel => "Filter Min Level (F/E/W/I/D/V)",
            };
            string = format!("{}: {}_", prefix, app.filter_input);
        }

        (string, Style::default())
    };

    let status = Paragraph::new(status_str)
        .style(status_style)
        .block(Block::default().title("Status").borders(Borders::ALL));
    f.render_widget(status, chunks[1]);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::explorer::FileEntry;
    use ratatui::{Terminal, backend::TestBackend};
    use std::path::PathBuf;

    #[test]
    fn test_draw_explorer_screen() {
        let mut app = App::new();
        app.screen = AppScreen::Explorer;
        app.explorer_items.push(FileEntry {
            name: "test_file.dlt".to_string(),
            is_dir: false,
            path: PathBuf::from("test_file.dlt"),
        });

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();

        terminal.draw(|f| draw(f, &app)).unwrap();

        // Check if the output contains the file name
        let buffer = terminal.backend().buffer();
        // The file name should be visible somewhere
        let content = buffer.content(); // A slice of cells
        let _has_text = content
            .iter()
            .any(|cell| cell.symbol() == "t" || cell.symbol() == "test_file.dlt");

        // This is a naive assertion. TestBackend provides more precise cell checks,
        // but for MVP TDD, checking that it renders something without panicking is a good start.
    }

    #[test]
    fn test_draw_log_viewer_screen() {
        let mut app = App::new();
        app.screen = AppScreen::LogViewer;

        let backend = TestBackend::new(80, 24);
        let mut terminal = Terminal::new(backend).unwrap();

        terminal.draw(|f| draw(f, &app)).unwrap();
        // Checks that Log Viewer does not panic and renders.
    }
}