// SPDX-FileCopyrightText: 2026 The superseedr Contributors
// SPDX-License-Identifier: GPL-3.0-or-later

use crate::app::{AppCommand, AppMode, AppState, JournalFilter};
use crate::persistence::event_journal::{
    EventCategory, EventDetails, EventJournalEntry, EventType,
};
use crate::theme::ThemeContext;
use crate::tui::formatters::sanitize_text;
use crate::tui::screen_context::ScreenContext;
use chrono::{DateTime, Local};
use ratatui::crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEventKind};
use ratatui::prelude::{Alignment, Constraint, Frame, Line, Modifier, Span, Style, Stylize};
use ratatui::widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, TableState, Wrap};
use std::path::{Component, Path};
use tokio::sync::mpsc;

const JOURNAL_CLOSE_KEYS_LABEL: &str = "Esc / q";
const JOURNAL_FILTER_KEYS_LABEL: &str = "Tab / Shift+Tab";
const JOURNAL_MOVE_KEYS_LABEL: &str = "Up / Down / k / j";
const JOURNAL_REPLAY_KEYS_LABEL: &str = "Shift+Y";
const JOURNAL_CLOSE_DESCRIPTION: &str = "Close the event journal";
const JOURNAL_FILTER_DESCRIPTION: &str = "Cycle between ALL, QUEUE, COMMANDS, and HEALTH";
const JOURNAL_MOVE_DESCRIPTION: &str = "Move selection through journal entries";
const JOURNAL_REPLAY_DESCRIPTION: &str =
    "Replay the selected archived .torrent, .magnet, or .path source";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum JournalAction {
    ToNormal,
    FilterNext,
    FilterPrev,
    MoveUp,
    MoveDown,
    ReplaySelected,
}

fn map_key_to_journal_action(key_code: KeyCode, key_kind: KeyEventKind) -> Option<JournalAction> {
    if !matches!(key_kind, KeyEventKind::Press | KeyEventKind::Repeat) {
        return None;
    }

    match key_code {
        KeyCode::Esc | KeyCode::Char('q') => Some(JournalAction::ToNormal),
        KeyCode::Tab => Some(JournalAction::FilterNext),
        KeyCode::BackTab => Some(JournalAction::FilterPrev),
        KeyCode::Up | KeyCode::Char('k') => Some(JournalAction::MoveUp),
        KeyCode::Down | KeyCode::Char('j') => Some(JournalAction::MoveDown),
        KeyCode::Char('Y') => Some(JournalAction::ReplaySelected),
        _ => None,
    }
}

pub fn handle_event(
    event: CrosstermEvent,
    app_state: &mut AppState,
    app_command_tx: &mpsc::Sender<AppCommand>,
) {
    if !matches!(app_state.mode, AppMode::Journal) {
        return;
    }

    let CrosstermEvent::Key(key) = event else {
        return;
    };

    let Some(action) = map_key_to_journal_action(key.code, key.kind) else {
        return;
    };

    app_state.ui.journal.status_message = None;

    match action {
        JournalAction::ToNormal => app_state.mode = AppMode::Normal,
        JournalAction::FilterNext => {
            app_state.ui.journal.filter = app_state.ui.journal.filter.next();
            app_state.ui.journal.selected_index = 0;
        }
        JournalAction::FilterPrev => {
            app_state.ui.journal.filter = app_state.ui.journal.filter.prev();
            app_state.ui.journal.selected_index = 0;
        }
        JournalAction::MoveUp => {
            app_state.ui.journal.selected_index =
                app_state.ui.journal.selected_index.saturating_sub(1);
        }
        JournalAction::MoveDown => {
            let len = filtered_entries(app_state).len();
            if len > 0 {
                app_state.ui.journal.selected_index =
                    (app_state.ui.journal.selected_index + 1).min(len - 1);
            }
        }
        JournalAction::ReplaySelected => replay_selected_entry(app_state, app_command_tx),
    }
}

fn entry_matches_filter(entry: &EventJournalEntry, filter: JournalFilter) -> bool {
    match filter {
        JournalFilter::All => true,
        JournalFilter::Queue => matches!(entry.category, EventCategory::Ingest),
        JournalFilter::Commands => matches!(entry.category, EventCategory::Control),
        JournalFilter::Health => matches!(entry.category, EventCategory::DataHealth),
    }
}

fn filtered_entries(app_state: &AppState) -> Vec<&EventJournalEntry> {
    app_state
        .event_journal_state
        .entries
        .iter()
        .rev()
        .filter(|entry| entry_matches_filter(entry, app_state.ui.journal.filter))
        .collect()
}

fn event_type_label(entry: &EventJournalEntry) -> &'static str {
    match entry.event_type {
        EventType::IngestQueued => "Queued",
        EventType::IngestAdded => "Added",
        EventType::IngestDuplicate => "Duplicate",
        EventType::IngestInvalid => "Invalid",
        EventType::IngestFailed => "Failed",
        EventType::TorrentCompleted => "Complete",
        EventType::DataUnavailable => "Missing",
        EventType::DataRecovered => "Found",
        EventType::ControlQueued => "Queued",
        EventType::ControlApplied => "Applied",
        EventType::ControlFailed => "Error",
    }
}

fn command_action_label(entry: &EventJournalEntry) -> String {
    match &entry.details {
        EventDetails::Control { action, .. } => sanitize_text(action),
        _ => event_type_label(entry).to_string(),
    }
}

fn source_label(entry: &EventJournalEntry, anonymize: bool) -> String {
    if anonymize {
        return "/path/to/source".to_string();
    }

    entry
        .source_watch_folder
        .as_ref()
        .map(|path| compact_path_label(path, 2))
        .or_else(|| {
            entry
                .source_path
                .as_ref()
                .map(|path| compact_path_label(path, 2))
        })
        .unwrap_or_else(|| "-".to_string())
}

fn torrent_label(entry: &EventJournalEntry, anonymize: bool) -> String {
    if anonymize {
        return "Torrent".to_string();
    }

    entry
        .torrent_name
        .as_ref()
        .map(|name| sanitize_text(name))
        .unwrap_or_else(|| "-".to_string())
}

fn live_completion_percent(entry: &EventJournalEntry, app_state: &AppState) -> Option<f64> {
    if let Some(info_hash_hex) = entry.info_hash_hex.as_deref() {
        if let Some(display) = app_state
            .torrents
            .iter()
            .find(|(info_hash, _)| hex::encode(info_hash.as_slice()) == info_hash_hex)
            .map(|(_, display)| display)
        {
            return Some(crate::app::torrent_completion_percent(
                &display.latest_state,
            ));
        }
    }

    entry.torrent_name.as_ref().and_then(|torrent_name| {
        app_state
            .torrents
            .values()
            .filter(|display| display.latest_state.torrent_name == *torrent_name)
            .map(|display| crate::app::torrent_completion_percent(&display.latest_state))
            .max_by(|left, right| left.total_cmp(right))
    })
}

fn progress_label(entry: &EventJournalEntry, app_state: &AppState) -> String {
    live_completion_percent(entry, app_state)
        .map(|pct| format!("{pct:.0}%"))
        .unwrap_or_else(|| "-".to_string())
}

fn preferred_source_text(entry: &EventJournalEntry) -> Option<String> {
    entry
        .source_path
        .as_ref()
        .map(|path| path.display().to_string())
        .or_else(|| {
            entry
                .source_watch_folder
                .as_ref()
                .map(|path| path.display().to_string())
        })
}

fn pretty_timestamp(ts_iso: &str) -> String {
    DateTime::parse_from_rfc3339(ts_iso)
        .map(|dt| {
            dt.with_timezone(&Local)
                .format("%b %d %I:%M %p")
                .to_string()
        })
        .unwrap_or_else(|_| ts_iso.to_string())
}

fn compact_path_label(path: &Path, depth: usize) -> String {
    let components = path
        .components()
        .filter_map(|component| match component {
            Component::Normal(segment) => Some(segment.to_string_lossy().into_owned()),
            Component::Prefix(prefix) => Some(prefix.as_os_str().to_string_lossy().into_owned()),
            _ => None,
        })
        .collect::<Vec<_>>();

    if components.is_empty() {
        return sanitize_text(&path.display().to_string());
    }

    if components.len() <= depth {
        return sanitize_text(&components.join("/"));
    }

    sanitize_text(&format!(
        ".../{}",
        components[components.len() - depth..].join("/")
    ))
}

fn detail_text(entry: Option<&EventJournalEntry>, anonymize: bool) -> String {
    let Some(entry) = entry else {
        return "No journal entries yet.".to_string();
    };

    let mut text = entry
        .message
        .clone()
        .unwrap_or_else(|| "No journal entries yet.".to_string());

    if anonymize {
        if let Some(torrent_name) = &entry.torrent_name {
            text = text.replace(torrent_name, "Torrent");
        }
        if let Some(source_path) = &entry.source_path {
            text = text.replace(&source_path.display().to_string(), "/path/to/source");
        }
        if let Some(source_watch_folder) = &entry.source_watch_folder {
            text = text.replace(
                &source_watch_folder.display().to_string(),
                "/path/to/source",
            );
        }
    }

    sanitize_text(&text)
}

fn selected_detail_text(app_state: &AppState, entry: Option<&EventJournalEntry>) -> String {
    let Some(entry) = entry else {
        return "No journal entries yet.".to_string();
    };

    let source_text = preferred_source_text(entry);

    if let Some(source_text) = source_text {
        if app_state.anonymize_torrent_names {
            return "/path/to/source".to_string();
        }
        return sanitize_text(&source_text);
    }

    detail_text(Some(entry), app_state.anonymize_torrent_names)
}

pub fn journal_help_rows(ctx: &ThemeContext) -> Vec<Row<'static>> {
    vec![
        Row::new(vec![
            Cell::from(Span::styled(
                JOURNAL_CLOSE_KEYS_LABEL,
                ctx.apply(Style::default().fg(ctx.state_error())),
            )),
            Cell::from(JOURNAL_CLOSE_DESCRIPTION),
        ]),
        Row::new(vec![
            Cell::from(Span::styled(
                JOURNAL_FILTER_KEYS_LABEL,
                ctx.apply(Style::default().fg(ctx.state_selected())),
            )),
            Cell::from(JOURNAL_FILTER_DESCRIPTION),
        ]),
        Row::new(vec![
            Cell::from(Span::styled(
                JOURNAL_MOVE_KEYS_LABEL,
                ctx.apply(Style::default().fg(ctx.state_info())),
            )),
            Cell::from(JOURNAL_MOVE_DESCRIPTION),
        ]),
        Row::new(vec![
            Cell::from(Span::styled(
                JOURNAL_REPLAY_KEYS_LABEL,
                ctx.apply(Style::default().fg(ctx.state_success())),
            )),
            Cell::from(JOURNAL_REPLAY_DESCRIPTION),
        ]),
    ]
}

fn replay_command_for_path(path: &Path) -> Option<AppCommand> {
    match path.extension().and_then(|ext| ext.to_str()) {
        Some(ext) if ext.eq_ignore_ascii_case("torrent") => {
            Some(AppCommand::AddTorrentFromFile(path.to_path_buf()))
        }
        Some(ext) if ext.eq_ignore_ascii_case("magnet") => {
            Some(AppCommand::AddMagnetFromFile(path.to_path_buf()))
        }
        Some(ext) if ext.eq_ignore_ascii_case("path") => {
            Some(AppCommand::AddTorrentFromPathFile(path.to_path_buf()))
        }
        _ => None,
    }
}

fn replay_selected_entry(app_state: &mut AppState, app_command_tx: &mpsc::Sender<AppCommand>) {
    let entries = filtered_entries(app_state);
    let Some(entry) = entries.get(app_state.ui.journal.selected_index).copied() else {
        app_state.ui.journal.status_message = Some("No journal entry selected".to_string());
        return;
    };

    let Some(source_path) = entry.source_path.as_ref() else {
        app_state.ui.journal.status_message =
            Some("Selected entry has no replayable source file".to_string());
        return;
    };

    let Some(command) = replay_command_for_path(source_path) else {
        app_state.ui.journal.status_message =
            Some("Selected entry does not point to a replayable source file".to_string());
        return;
    };

    if !source_path.exists() {
        app_state.ui.journal.status_message =
            Some("Replay source file is no longer available".to_string());
        return;
    }

    match app_command_tx.try_send(command) {
        Ok(()) => {
            app_state.ui.journal.status_message =
                Some(format!("Replayed {}", compact_path_label(source_path, 2)));
        }
        Err(_) => {
            app_state.ui.journal.status_message = Some("Replay request queue is busy".to_string());
        }
    }
}

#[derive(Clone, Copy)]
enum JournalColumn {
    Time,
    Event,
    Done,
    Torrent,
    Source,
}

fn columns_for_filter(filter: JournalFilter) -> Vec<JournalColumn> {
    match filter {
        JournalFilter::All => vec![
            JournalColumn::Time,
            JournalColumn::Event,
            JournalColumn::Done,
            JournalColumn::Torrent,
            JournalColumn::Source,
        ],
        JournalFilter::Queue => vec![
            JournalColumn::Time,
            JournalColumn::Event,
            JournalColumn::Done,
            JournalColumn::Torrent,
            JournalColumn::Source,
        ],
        JournalFilter::Commands => {
            vec![
                JournalColumn::Time,
                JournalColumn::Event,
                JournalColumn::Source,
            ]
        }
        JournalFilter::Health => vec![
            JournalColumn::Time,
            JournalColumn::Event,
            JournalColumn::Torrent,
        ],
    }
}

fn column_header(column: JournalColumn) -> &'static str {
    match column {
        JournalColumn::Time => "Time",
        JournalColumn::Event => "Event",
        JournalColumn::Done => "Done",
        JournalColumn::Torrent => "Torrent",
        JournalColumn::Source => "Source",
    }
}

fn column_constraint(column: JournalColumn, filter: JournalFilter) -> Constraint {
    match (filter, column) {
        (_, JournalColumn::Time) => Constraint::Length(17),
        (JournalFilter::Commands, JournalColumn::Event) => Constraint::Percentage(34),
        (JournalFilter::Health, JournalColumn::Event) => Constraint::Length(10),
        (_, JournalColumn::Event) => Constraint::Length(10),
        (_, JournalColumn::Done) => Constraint::Length(8),
        (JournalFilter::Health, JournalColumn::Torrent) => Constraint::Min(10),
        (_, JournalColumn::Torrent) => Constraint::Percentage(41),
        (JournalFilter::Commands, JournalColumn::Source) => Constraint::Percentage(46),
        (_, JournalColumn::Source) => Constraint::Percentage(24),
    }
}

fn column_cell(
    column: JournalColumn,
    entry: &EventJournalEntry,
    app_state: &AppState,
) -> Cell<'static> {
    match column {
        JournalColumn::Time => Cell::from(pretty_timestamp(&entry.ts_iso)),
        JournalColumn::Event => {
            let label = if matches!(app_state.ui.journal.filter, JournalFilter::Commands) {
                command_action_label(entry)
            } else {
                event_type_label(entry).to_string()
            };
            Cell::from(label)
        }
        JournalColumn::Done => Cell::from(progress_label(entry, app_state)),
        JournalColumn::Torrent => {
            Cell::from(torrent_label(entry, app_state.anonymize_torrent_names))
        }
        JournalColumn::Source => Cell::from(source_label(entry, app_state.anonymize_torrent_names)),
    }
}

pub fn draw(f: &mut Frame, screen: &ScreenContext<'_>) {
    let app_state = screen.app.state;
    let ctx = screen.theme;
    let area = f.area();
    let popup = crate::tui::formatters::centered_rect(92, 84, area);
    let popup_layout =
        ratatui::layout::Layout::vertical([Constraint::Min(0), Constraint::Length(1)]).split(popup);
    let body_area = popup_layout[0];
    let footer_area = popup_layout[1];
    f.render_widget(Clear, popup);

    let outer = Block::default()
        .title(Span::styled(
            " Event Journal ",
            ctx.apply(Style::default().fg(ctx.accent_sapphire()).bold()),
        ))
        .borders(Borders::ALL)
        .border_style(ctx.apply(Style::default().fg(ctx.theme.semantic.border)));
    let inner = outer.inner(body_area);
    f.render_widget(outer, body_area);

    let rows = ratatui::layout::Layout::vertical([
        Constraint::Length(1),
        Constraint::Length(1),
        Constraint::Min(8),
        Constraint::Length(4),
    ])
    .split(inner);

    let filter_spans = [
        JournalFilter::All,
        JournalFilter::Queue,
        JournalFilter::Commands,
        JournalFilter::Health,
    ]
    .iter()
    .enumerate()
    .flat_map(|(idx, filter)| {
        let style = if *filter == app_state.ui.journal.filter {
            ctx.apply(
                Style::default()
                    .fg(ctx.state_selected())
                    .add_modifier(Modifier::BOLD),
            )
        } else {
            ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1))
        };
        let mut spans = vec![Span::styled(filter.label().to_string(), style)];
        if idx < 3 {
            spans.push(Span::styled(
                "  ",
                ctx.apply(Style::default().fg(ctx.theme.semantic.surface2)),
            ));
        }
        spans
    })
    .collect::<Vec<_>>();
    f.render_widget(Paragraph::new(Line::from(filter_spans)), rows[0]);

    let entries = filtered_entries(app_state);
    let status_line = app_state
        .ui
        .journal
        .status_message
        .as_ref()
        .map(|message| format!("{} entries  |  {}", entries.len(), sanitize_text(message)))
        .unwrap_or_else(|| format!("{} entries", entries.len()));
    f.render_widget(
        Paragraph::new(status_line)
            .style(ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1))),
        rows[1],
    );

    let body_rows = entries
        .iter()
        .map(|entry| {
            Row::new(
                columns_for_filter(app_state.ui.journal.filter)
                    .into_iter()
                    .map(|column| column_cell(column, entry, app_state))
                    .collect::<Vec<_>>(),
            )
        })
        .collect::<Vec<_>>();

    let columns = columns_for_filter(app_state.ui.journal.filter);
    let constraints = columns
        .iter()
        .map(|column| column_constraint(*column, app_state.ui.journal.filter))
        .collect::<Vec<_>>();
    let header_cells = columns
        .iter()
        .map(|column| column_header(*column))
        .collect::<Vec<_>>();

    let table = Table::new(body_rows, constraints)
        .header(
            Row::new(header_cells).style(
                ctx.apply(
                    Style::default()
                        .fg(ctx.theme.semantic.subtext0)
                        .add_modifier(Modifier::BOLD),
                ),
            ),
        )
        .row_highlight_style(
            ctx.apply(
                Style::default()
                    .fg(ctx.theme.semantic.text)
                    .bg(ctx.theme.semantic.surface0),
            ),
        )
        .block(
            Block::default()
                .borders(Borders::TOP | Borders::BOTTOM)
                .border_style(ctx.apply(Style::default().fg(ctx.theme.semantic.surface2))),
        );

    let mut table_state = TableState::default();
    if !entries.is_empty() {
        table_state.select(Some(
            app_state.ui.journal.selected_index.min(entries.len() - 1),
        ));
    }
    f.render_stateful_widget(table, rows[2], &mut table_state);

    let details_text = selected_detail_text(
        app_state,
        entries.get(app_state.ui.journal.selected_index).copied(),
    );
    f.render_widget(
        Paragraph::new(details_text)
            .wrap(Wrap { trim: true })
            .alignment(Alignment::Left)
            .style(ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1))),
        rows[3],
    );

    let footer_hint = Paragraph::new(Line::from(vec![
        Span::styled(
            "[Tab]",
            ctx.apply(Style::default().fg(ctx.state_selected()).bold()),
        ),
        Span::styled(
            " Filter  ",
            ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1)),
        ),
        Span::styled(
            "[Shift+Tab]",
            ctx.apply(Style::default().fg(ctx.state_selected()).bold()),
        ),
        Span::styled(
            " Back  ",
            ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1)),
        ),
        Span::styled(
            "[j/k]",
            ctx.apply(Style::default().fg(ctx.state_info()).bold()),
        ),
        Span::styled(
            " Move  ",
            ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1)),
        ),
        Span::styled(
            "[Shift+Y]",
            ctx.apply(Style::default().fg(ctx.state_success()).bold()),
        ),
        Span::styled(
            " Replay  ",
            ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1)),
        ),
        Span::styled(
            "[q]",
            ctx.apply(Style::default().fg(ctx.state_error()).bold()),
        ),
        Span::styled(
            " Close",
            ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1)),
        ),
    ]))
    .alignment(Alignment::Center);
    f.render_widget(footer_hint, footer_area);
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::{TorrentDisplayState, TorrentMetrics};
    use crate::persistence::event_journal::{EventCategory, EventJournalState};
    use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
    use std::fs;
    use std::path::Path;
    use tokio::sync::mpsc;

    fn base_state() -> AppState {
        let mut state = AppState {
            mode: AppMode::Journal,
            ..Default::default()
        };
        state.event_journal_state = EventJournalState {
            next_id: 4,
            entries: vec![
                EventJournalEntry {
                    id: 1,
                    category: EventCategory::Ingest,
                    event_type: EventType::IngestAdded,
                    torrent_name: Some("Sample Alpha".to_string()),
                    ..Default::default()
                },
                EventJournalEntry {
                    id: 2,
                    category: EventCategory::Control,
                    event_type: EventType::ControlApplied,
                    torrent_name: Some("Sample Beta".to_string()),
                    ..Default::default()
                },
                EventJournalEntry {
                    id: 3,
                    category: EventCategory::DataHealth,
                    event_type: EventType::DataUnavailable,
                    torrent_name: Some("Sample Gamma".to_string()),
                    ..Default::default()
                },
            ],
        };
        state
    }

    #[test]
    fn tab_cycles_filters() {
        let mut app_state = base_state();
        let (tx, _rx) = mpsc::channel(1);

        handle_event(
            CrosstermEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
            &mut app_state,
            &tx,
        );
        assert_eq!(app_state.ui.journal.filter, JournalFilter::Queue);

        handle_event(
            CrosstermEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
            &mut app_state,
            &tx,
        );
        assert_eq!(app_state.ui.journal.filter, JournalFilter::Commands);
    }

    #[test]
    fn filter_selection_matches_requested_groups() {
        let mut app_state = base_state();

        app_state.ui.journal.filter = JournalFilter::Queue;
        let added = filtered_entries(&app_state);
        assert_eq!(added.len(), 1);
        assert_eq!(added[0].event_type, EventType::IngestAdded);

        app_state.ui.journal.filter = JournalFilter::Commands;
        let commands = filtered_entries(&app_state);
        assert_eq!(commands.len(), 1);
        assert_eq!(commands[0].event_type, EventType::ControlApplied);

        app_state.ui.journal.filter = JournalFilter::Health;
        let health = filtered_entries(&app_state);
        assert_eq!(health.len(), 1);
        assert_eq!(health[0].event_type, EventType::DataUnavailable);
    }

    #[test]
    fn compact_path_label_keeps_tail_components() {
        let label = compact_path_label(Path::new("/alpha/beta/watch_files"), 2);
        assert_eq!(label, ".../beta/watch_files");
    }

    #[test]
    fn pretty_timestamp_formats_rfc3339_values() {
        let label = pretty_timestamp("2026-03-15T14:26:28Z");
        assert!(label.contains("Mar"));
    }

    #[test]
    fn progress_label_uses_live_torrent_metrics_when_info_hash_matches() {
        let mut app_state = base_state();
        let info_hash = vec![0x11; 20];
        app_state.event_journal_state.entries[0].info_hash_hex = Some(hex::encode(&info_hash));
        app_state.torrents.insert(
            info_hash,
            TorrentDisplayState {
                latest_state: TorrentMetrics {
                    number_of_pieces_total: 10,
                    number_of_pieces_completed: 4,
                    ..Default::default()
                },
                ..Default::default()
            },
        );

        assert_eq!(
            progress_label(&app_state.event_journal_state.entries[0], &app_state),
            "40%"
        );
    }

    #[test]
    fn anonymized_journal_hides_torrent_names_and_paths() {
        let entry = EventJournalEntry {
            torrent_name: Some("Sample Alpha".to_string()),
            source_path: Some(Path::new("/alpha/beta/watch_files/sample.torrent").to_path_buf()),
            message: Some(
                "Added Sample Alpha from /alpha/beta/watch_files/sample.torrent".to_string(),
            ),
            ..Default::default()
        };

        assert_eq!(torrent_label(&entry, true), "Torrent");
        assert_eq!(source_label(&entry, true), "/path/to/source");

        let details = detail_text(Some(&entry), true);
        assert!(!details.contains("Sample Alpha"));
        assert!(!details.contains("/alpha/beta/watch_files/sample.torrent"));
        assert!(details.contains("Torrent"));
        assert!(details.contains("/path/to/source"));
    }

    #[test]
    fn selected_detail_text_prefers_recorded_source_path_over_live_magnet() {
        let mut app_state = base_state();
        let info_hash = vec![0x22; 20];
        app_state.event_journal_state.entries[0].info_hash_hex = Some(hex::encode(&info_hash));
        app_state.event_journal_state.entries[0].source_path =
            Some(Path::new("/alpha/archive/sample.magnet").to_path_buf());
        app_state.torrents.insert(
            info_hash,
            TorrentDisplayState {
                latest_state: TorrentMetrics {
                    torrent_name: "Sample Alpha".to_string(),
                    torrent_or_magnet:
                        "magnet:?xt=urn:btih:2222222222222222222222222222222222222222".to_string(),
                    ..Default::default()
                },
                ..Default::default()
            },
        );

        let details =
            selected_detail_text(&app_state, Some(&app_state.event_journal_state.entries[0]));
        assert_eq!(details, "/alpha/archive/sample.magnet");
    }

    #[test]
    fn command_filter_uses_action_label_and_reduced_columns() {
        let entry = EventJournalEntry {
            details: EventDetails::Control {
                origin: crate::persistence::event_journal::ControlOrigin::CliOnline,
                action: "pause".to_string(),
                target_info_hash_hex: None,
                file_index: None,
                file_path: None,
                priority: None,
            },
            ..Default::default()
        };

        assert_eq!(command_action_label(&entry), "pause");
        assert_eq!(columns_for_filter(JournalFilter::Commands).len(), 3);
        assert_eq!(
            column_header(columns_for_filter(JournalFilter::Commands)[1]),
            "Event"
        );
        assert_eq!(
            column_header(columns_for_filter(JournalFilter::Commands)[2]),
            "Source"
        );
        assert_eq!(command_action_label(&entry), "pause");
    }

    #[test]
    fn health_filter_hides_source_column() {
        let columns = columns_for_filter(JournalFilter::Health);
        assert_eq!(columns.len(), 3);
        assert!(columns
            .iter()
            .all(|column| !matches!(column, JournalColumn::Source)));
    }

    #[test]
    fn shift_y_replays_selected_magnet_source() {
        let mut app_state = base_state();
        app_state.ui.journal.filter = JournalFilter::Queue;
        let replay_path = std::env::temp_dir().join(format!(
            "superseedr-journal-replay-{}.magnet",
            std::process::id()
        ));
        fs::write(
            &replay_path,
            "magnet:?xt=urn:btih:4444444444444444444444444444444444444444",
        )
        .expect("write replay file");
        app_state.event_journal_state.entries[0].source_path = Some(replay_path.clone());
        let (tx, mut rx) = mpsc::channel(1);

        handle_event(
            CrosstermEvent::Key(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::SHIFT)),
            &mut app_state,
            &tx,
        );

        match rx.try_recv() {
            Ok(AppCommand::AddMagnetFromFile(path)) => assert_eq!(path, replay_path),
            Ok(_) => panic!("expected replayed magnet command"),
            Err(error) => panic!("expected replay command, got {error:?}"),
        }

        fs::remove_file(&replay_path).ok();
    }

    #[test]
    fn shift_y_reports_missing_replay_source() {
        let mut app_state = base_state();
        let (tx, _rx) = mpsc::channel(1);

        handle_event(
            CrosstermEvent::Key(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::SHIFT)),
            &mut app_state,
            &tx,
        );

        assert_eq!(
            app_state.ui.journal.status_message.as_deref(),
            Some("Selected entry has no replayable source file")
        );
    }
}
