rmcl 0.4.0

A fully featured Minecraft TUI launcher
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::Instant;

use ratatui::{
    Frame,
    layout::{Constraint, Margin},
};

use crate::instance::ContentKind;
use crate::instance::content::entry::ContentEntry;
use crate::instance::content::updates::{BulkUpdatePlan, UpdateSnapshot};
use crate::tui::widgets::popups::base::PopupFrame;

pub enum PendingResult {
    Prepared(UpdateSnapshot, BulkUpdatePlan),
    Applied(Result<(), String>),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Phase {
    Checking,
    Conflicts,
    Review,
    Applying,
}

pub struct State {
    pub phase: Phase,
    pub plan: Option<BulkUpdatePlan>,
    pub snapshot: Option<UpdateSnapshot>,
    pub pending: Arc<Mutex<Vec<PendingResult>>>,
    pub kind: ContentKind,
    pub target_world: Option<(String, PathBuf)>,
    pub completed: bool,
    pub applied: bool,
    pub list: super::list::ContentListState,
    source_entries: HashMap<PathBuf, ContentEntry>,
}

impl State {
    pub fn checking(
        kind: ContentKind,
        target_world: Option<(String, PathBuf)>,
        entries: Vec<ContentEntry>,
    ) -> Self {
        Self {
            phase: Phase::Checking,
            plan: None,
            snapshot: None,
            pending: Arc::new(Mutex::new(Vec::new())),
            kind,
            target_world,
            completed: false,
            applied: false,
            list: super::list::ContentListState::default(),
            source_entries: entries
                .into_iter()
                .map(|entry| (entry.path.clone(), entry))
                .collect(),
        }
    }

    pub fn drain(&mut self) -> bool {
        let pending = match self.pending.lock() {
            Ok(mut pending) => pending.drain(..).collect::<Vec<_>>(),
            Err(_) => return false,
        };
        let mut changed = false;
        for result in pending {
            changed = true;
            match result {
                PendingResult::Prepared(snapshot, plan) => {
                    self.phase = if !plan.conflicts.is_empty() {
                        Phase::Conflicts
                    } else if !plan.roots.is_empty() {
                        Phase::Review
                    } else {
                        self.completed = true;
                        Phase::Review
                    };
                    self.snapshot = Some(snapshot);
                    self.plan = Some(plan);
                    self.rebuild_list();
                }
                PendingResult::Applied(Ok(())) => {
                    self.completed = true;
                    self.applied = true;
                }
                PendingResult::Applied(Err(error)) => {
                    crate::feedback::errors::push_error(crate::feedback::errors::ErrorEvent {
                        id: 0,
                        level: tracing::Level::ERROR,
                        message: format!("Failed to update content: {error}"),
                        pushed_at: Instant::now(),
                    });
                    self.completed = true;
                }
            }
        }
        changed
    }

    pub fn push(&self, result: PendingResult) {
        if let Ok(mut pending) = self.pending.lock() {
            pending.push(result);
            crate::feedback::request_redraw();
        }
    }

    pub fn visible(&self) -> bool {
        matches!(self.phase, Phase::Conflicts | Phase::Review) && !self.completed
    }

    pub fn has_updates(&self) -> bool {
        self.plan
            .as_ref()
            .is_some_and(|plan| !plan.roots.is_empty())
    }

    pub fn show_review(&mut self) {
        self.phase = Phase::Review;
        self.rebuild_list();
    }

    pub fn show_conflicts(&mut self) {
        if self
            .plan
            .as_ref()
            .is_some_and(|plan| !plan.conflicts.is_empty())
        {
            self.phase = Phase::Conflicts;
            self.rebuild_list();
        }
    }

    fn rebuild_list(&mut self) {
        let entries = match self.phase {
            Phase::Conflicts => self
                .plan
                .as_ref()
                .into_iter()
                .flat_map(|plan| plan.conflicts.iter())
                .map(|conflict| {
                    let mut entry = self.entry_for(&conflict.installed_path, &conflict.title);
                    entry.description = user_conflict_reason(&conflict.reason);
                    entry.title_suffix = Some("Skipped".to_owned());
                    entry.footer_label = None;
                    entry.footer_change = None;
                    entry
                })
                .collect(),
            Phase::Review => self
                .plan
                .as_ref()
                .into_iter()
                .flat_map(|plan| plan.roots.iter())
                .map(|root| {
                    let mut entry = self.entry_for(&root.installed_path, &root.title);
                    entry.title_suffix = Some("Update".to_owned());
                    entry.footer_label = None;
                    entry.footer_change = Some((
                        root.current_version.clone(),
                        root.target.version_number.clone(),
                    ));
                    entry
                })
                .collect(),
            Phase::Checking | Phase::Applying => Vec::new(),
        };
        self.list.set_entries(entries);
    }

    fn entry_for(&self, path: &Path, title: &str) -> ContentEntry {
        self.source_entries
            .get(path)
            .cloned()
            .unwrap_or_else(|| ContentEntry {
                file_stem: path
                    .file_stem()
                    .and_then(|stem| stem.to_str())
                    .unwrap_or(title)
                    .to_owned(),
                name: title.to_owned(),
                source_slug: None,
                installed_path: Some(path.to_owned()),
                provider_project: None,
                world_details: None,
                title_suffix: None,
                footer_label: None,
                footer_change: None,
                description: String::new(),
                enabled: true,
                icon_bytes: None,
                provider_icon: false,
                provider_description: false,
                path: path.to_owned(),
                icon_lines: Some(crate::instance::content::fallback_icon()),
            })
    }
}

pub fn render(frame: &mut Frame, state: &mut State, picker: &ratatui_image::picker::Picker) {
    if !state.visible() {
        return;
    }
    let count = state.list.entries.len() as u16;
    let height = count.saturating_mul(3).saturating_add(2).clamp(5, 20);
    let area = frame.area().centered(
        Constraint::Percentage(60),
        Constraint::Length(height.min(frame.area().height.saturating_sub(4))),
    );
    let theme = crate::config::theme::THEME.as_ref();
    let (title, keybinds) = match state.phase {
        Phase::Conflicts => (
            "Updates needing attention",
            if state.has_updates() {
                crate::tui::widgets::popups::keybind_line(&[
                    ("j/k", " navigate"),
                    ("Enter", " review updates"),
                    ("h/Esc", " close"),
                ])
            } else {
                crate::tui::widgets::popups::keybind_line(&[
                    ("j/k", " navigate"),
                    ("h/Esc", " close"),
                ])
            },
        ),
        Phase::Review => (
            "Update installed content",
            if state
                .plan
                .as_ref()
                .is_some_and(|plan| !plan.conflicts.is_empty())
            {
                crate::tui::widgets::popups::keybind_line(&[
                    ("j/k", " navigate"),
                    ("h", " back"),
                    ("Enter", " update all"),
                    ("Esc", " close"),
                ])
            } else {
                crate::tui::widgets::popups::keybind_line(&[
                    ("j/k", " navigate"),
                    ("Enter", " update all"),
                    ("Esc", " close"),
                ])
            },
        ),
        Phase::Checking | Phase::Applying => return,
    };
    let popup = PopupFrame {
        title: crate::tui::widgets::styled_title(title, false),
        border_color: theme.accent(),
        bg: Some(theme.surface()),
        keybinds: Some(keybinds),
        search_line: None,
        content: Box::new(|_, _| {}),
    };
    frame.render_widget(popup, area);
    super::list::render(
        frame,
        area.inner(Margin::new(1, 1)),
        &mut state.list,
        true,
        "",
        "",
        picker,
        false,
        true,
    );
}

fn user_conflict_reason(reason: &str) -> String {
    let reason = reason.strip_prefix("Parse error: ").unwrap_or(reason);
    if let Some(details) = reason.strip_prefix("Conflicting selected versions for '")
        && let Some((dependency, _)) = details.split_once("': '")
    {
        return format!(
            "Other selected updates require different versions of {dependency}.\nThis mod was left unchanged; update it separately with v."
        );
    }
    if let Some(details) = reason.strip_prefix("Conflicting required versions for '")
        && let Some((dependency, _)) = details.split_once("': '")
    {
        return format!(
            "This update requires incompatible versions of {dependency}.\nThis mod was left unchanged; choose another version with v."
        );
    }
    format!(
        "This update could not be prepared: {reason}. The installed version was left unchanged; try updating it separately with v."
    )
}

#[cfg(test)]
#[path = "../../tests/widgets/content/update.rs"]
mod tests;