complior-cli 0.9.1

AI Act Compliance Scanner & Fixer — CLI
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, Paragraph};
use ratatui::Frame;

use crate::app::App;
use crate::theme;
use crate::types::Zone;

pub fn render_sidebar(frame: &mut Frame, area: Rect, app: &App) {
    let t = theme::theme();

    let block = Block::default()
        .title(" Info ")
        .title_style(theme::title_style())
        .borders(Borders::ALL)
        .border_style(Style::default().fg(t.border));

    let inner = block.inner(area);
    frame.render_widget(block, area);

    // Divide sidebar into sections
    let has_scan = app.last_scan.is_some();
    let constraints = if has_scan {
        vec![
            Constraint::Length(5),  // Project
            Constraint::Length(6),  // Scan Summary
            Constraint::Length(3),  // Context + Zen
            Constraint::Length(3),  // Deadlines
            Constraint::Min(3),    // Quick Actions
        ]
    } else {
        vec![
            Constraint::Length(5), // Project
            Constraint::Length(3),  // Context + Zen
            Constraint::Length(3),  // Deadlines
            Constraint::Min(3),   // Quick Actions
        ]
    };

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints(constraints)
        .split(inner);

    // --- Project section ---
    render_project_section(frame, chunks[0], app, &t);

    if has_scan {
        render_scan_summary(frame, chunks[1], app, &t);
        render_context_zen_section(frame, chunks[2], app, &t);
        render_deadlines(frame, chunks[3], &t);
        render_quick_actions(frame, chunks[4], &t);
    } else {
        render_context_zen_section(frame, chunks[1], app, &t);
        render_deadlines(frame, chunks[2], &t);
        render_quick_actions(frame, chunks[3], &t);
    }
}

fn render_project_section(frame: &mut Frame, area: Rect, app: &App, t: &theme::ThemeColors) {
    let project_name = app
        .project_path
        .file_name().map_or_else(|| "project".to_string(), |n| n.to_string_lossy().to_string());

    let mut lines = vec![
        Line::from(Span::styled(
            format!(" {project_name}/"),
            Style::default()
                .fg(t.fg)
                .add_modifier(Modifier::BOLD),
        )),
    ];

    // Show score if available
    if let Some(scan) = &app.last_scan {
        let zone_emoji = match scan.score.zone {
            Zone::Green => "🟢",
            Zone::Yellow => "🟡",
            Zone::Red => "🔴",
        };
        lines.push(Line::from(vec![
            Span::raw(" Score: "),
            Span::styled(
                format!("{:.0}/100", scan.score.total_score),
                Style::default().fg(theme::zone_color(scan.score.zone)),
            ),
            Span::raw(format!(" {zone_emoji}")),
        ]));
        lines.push(Line::from(vec![
            Span::styled(
                format!(" {}", scan.score.passed_checks),
                Style::default().fg(t.zone_green),
            ),
            Span::raw(" "),
            Span::styled(
                format!("{}", scan.score.failed_checks),
                Style::default().fg(t.zone_red),
            ),
            Span::raw(format!(
                " {} files",
                scan.files_scanned
            )),
        ]));
    } else {
        lines.push(Line::from(Span::styled(
            " No scan yet",
            Style::default().fg(t.muted),
        )));
    }

    let p = Paragraph::new(lines);
    frame.render_widget(p, area);
}

fn render_scan_summary(frame: &mut Frame, area: Rect, app: &App, t: &theme::ThemeColors) {
    let Some(scan) = &app.last_scan else {
        return;
    };

    let items: Vec<ListItem<'_>> = scan
        .score
        .category_scores
        .iter()
        .take(area.height as usize)
        .map(|cat| {
            let failed = cat.obligation_count.saturating_sub(cat.passed_count);
            let (icon, color) = if failed == 0 {
                ("", t.zone_green)
            } else {
                ("", t.zone_red)
            };
            ListItem::new(Line::from(vec![
                Span::styled(format!(" {icon} "), Style::default().fg(color)),
                Span::raw(&cat.category),
            ]))
        })
        .collect();

    let list = List::new(items).block(
        Block::default()
            .title(" Checks ")
            .title_style(Style::default().fg(t.muted))
            .borders(Borders::TOP)
            .border_style(Style::default().fg(t.border)),
    );
    frame.render_widget(list, area);
}

fn render_context_zen_section(frame: &mut Frame, area: Rect, app: &App, t: &theme::ThemeColors) {
    // Compute context pct locally (field removed from App, always 32-message window)
    let ctx_pct = crate::widgets::context_meter::context_pct(app.messages.len(), 32);
    let ctx_color = crate::widgets::context_meter::context_color(ctx_pct);
    let zen_status = if app.zen_active {
        format!("Zen {}/{}", app.zen_messages_used, app.zen_messages_limit)
    } else {
        "Zen: off".to_string()
    };

    let lines = vec![
        Line::from(vec![
            Span::styled(" Ctx: ", Style::default().fg(t.muted)),
            Span::styled(
                format!("{ctx_pct}%"),
                Style::default().fg(ctx_color),
            ),
            Span::styled(
                format!("  {zen_status}"),
                Style::default().fg(if app.zen_active { t.zone_green } else { t.muted }),
            ),
        ]),
    ];

    let p = Paragraph::new(lines).block(
        Block::default()
            .borders(Borders::TOP)
            .border_style(Style::default().fg(t.border)),
    );
    frame.render_widget(p, area);
}

fn render_deadlines(frame: &mut Frame, area: Rect, t: &theme::ThemeColors) {
    // EU AI Act enforcement: 2026-08-02
    // Compute days remaining from system clock
    let days_remaining = {
        use std::time::{SystemTime, UNIX_EPOCH};
        let now_secs = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0);
        // 2026-08-02 00:00:00 UTC = 1785olean
        // 2026-01-01 = 1767225600, + 213 days = + 18403200
        const EU_AI_ACT_SECS: u64 = 1_785_628_800; // 2026-08-02 00:00 UTC
        EU_AI_ACT_SECS.saturating_sub(now_secs) / 86_400
    };

    let (icon, color) = if days_remaining <= 30 {
        ("🔴", t.zone_red)
    } else if days_remaining <= 90 {
        ("🟡", t.zone_yellow)
    } else {
        ("", t.accent)
    };

    let lines = vec![
        Line::from(vec![
            Span::styled(
                format!(" {icon} {days_remaining}d "),
                Style::default().fg(color).add_modifier(Modifier::BOLD),
            ),
            Span::styled("EU AI Act", Style::default().fg(t.muted)),
        ]),
    ];

    let p = Paragraph::new(lines).block(
        Block::default()
            .title(" Deadlines ")
            .title_style(Style::default().fg(t.muted))
            .borders(Borders::TOP)
            .border_style(Style::default().fg(t.border)),
    );
    frame.render_widget(p, area);
}

fn render_quick_actions(frame: &mut Frame, area: Rect, t: &theme::ThemeColors) {
    let lines = vec![
        Line::from(vec![
            Span::styled(" /scan  ", Style::default().fg(t.accent)),
            Span::styled("rescan project", Style::default().fg(t.muted)),
        ]),
        Line::from(vec![
            Span::styled(" /help  ", Style::default().fg(t.accent)),
            Span::styled("all commands", Style::default().fg(t.muted)),
        ]),
        Line::from(vec![
            Span::styled(" Ctrl+P ", Style::default().fg(t.accent)),
            Span::styled("command palette", Style::default().fg(t.muted)),
        ]),
    ];

    let p = Paragraph::new(lines).block(
        Block::default()
            .title(" Actions ")
            .title_style(Style::default().fg(t.muted))
            .borders(Borders::TOP)
            .border_style(Style::default().fg(t.border)),
    );
    frame.render_widget(p, area);
}

#[cfg(test)]
mod tests {
    use ratatui::backend::TestBackend;
    use ratatui::Terminal;

    use super::*;

    fn render_sidebar_to_string(app: &App, width: u16, height: u16) -> String {
        let backend = TestBackend::new(width, height);
        let mut terminal = Terminal::new(backend).expect("terminal");
        terminal
            .draw(|frame| render_sidebar(frame, frame.area(), app))
            .expect("render");
        let buf = terminal.backend().buffer().clone();
        let mut output = String::new();
        for y in 0..buf.area.height {
            for x in 0..buf.area.width {
                output.push_str(buf[(x, y)].symbol());
            }
            output.push('\n');
        }
        output
    }

    #[test]
    fn snapshot_sidebar_default() {
        crate::theme::init_theme("dark");
        let app = App::new(crate::config::TuiConfig::default());
        let buf = render_sidebar_to_string(&app, 30, 20);
        insta::with_settings!({
            filters => vec![
                (r"⚠ \d+d", "⚠ [Nd]"),
            ]
        }, {
            insta::assert_snapshot!(buf);
        });
    }

    #[test]
    fn test_sidebar_renders_without_scan() {
        crate::theme::init_theme("dark");
        let backend = TestBackend::new(30, 20);
        let mut terminal = Terminal::new(backend).expect("terminal");
        let app = App::new(crate::config::TuiConfig::default());

        terminal
            .draw(|frame| render_sidebar(frame, frame.area(), &app))
            .expect("render");
    }
}