anitrack 0.1.7

CLI/TUI companion for ani-cli with watch-progress tracking
use std::collections::HashMap;
use std::sync::mpsc;

use anyhow::Result;
use ratatui::widgets::TableState;

use crate::db::{Database, SeenEntry};

use super::super::episode::{fetch_episode_labels_with_diagnostics, parse_title_and_total_eps};
use super::super::tracking::{
    PlaybackOutcome, run_ani_cli_continue, run_ani_cli_previous, run_ani_cli_replay,
    run_ani_cli_select,
};
use super::{EpisodeListFetchResult, EpisodeListState, TuiAction};

pub(super) fn refresh_items(
    db: &Database,
    items: &mut Vec<SeenEntry>,
    table_state: &mut TableState,
    preferred_id: Option<&str>,
) -> Result<()> {
    *items = db.list_seen()?;
    if items.is_empty() {
        table_state.select(None);
        return Ok(());
    }

    if let Some(id) = preferred_id
        && let Some(idx) = items.iter().position(|item| item.ani_id == id)
    {
        table_state.select(Some(idx));
        return Ok(());
    }

    match table_state.selected() {
        Some(selected) => table_state.select(Some(selected.min(items.len() - 1))),
        None => table_state.select(Some(0)),
    }
    Ok(())
}

pub(super) fn status_info(msg: &str) -> String {
    format!("INFO: {msg}")
}

pub(super) fn status_error(msg: &str) -> String {
    format!("ERROR: {msg}")
}

fn playback_failure_message(outcome: &PlaybackOutcome) -> String {
    match outcome.failure_detail.as_deref() {
        Some(detail) => format!("Playback failed/interrupted: {detail}. Progress not updated."),
        None => "Playback failed/interrupted. Progress not updated.".to_string(),
    }
}

pub(super) fn run_selected_action(
    db: &Database,
    item: &SeenEntry,
    action: TuiAction,
    episode_list: Option<&[String]>,
) -> Result<String> {
    match action {
        TuiAction::Next => {
            let outcome = run_ani_cli_continue(item, &item.last_episode)?;
            if outcome.success {
                let updated_ep = outcome
                    .final_episode
                    .unwrap_or_else(|| item.last_episode.clone());
                db.upsert_seen(&item.ani_id, &item.title, &updated_ep)?;
                Ok(format!(
                    "Updated progress: {} -> episode {}",
                    item.title, updated_ep
                ))
            } else {
                Ok(playback_failure_message(&outcome))
            }
        }
        TuiAction::Replay => {
            let outcome = run_ani_cli_replay(item, episode_list)?;
            if outcome.success {
                let updated_ep = outcome
                    .final_episode
                    .unwrap_or_else(|| item.last_episode.clone());
                db.upsert_seen(&item.ani_id, &item.title, &updated_ep)?;
                Ok(format!(
                    "Replay finished: {} now on episode {}",
                    item.title, updated_ep
                ))
            } else {
                Ok(playback_failure_message(&outcome))
            }
        }
        TuiAction::Previous => {
            let outcome = run_ani_cli_previous(item, episode_list)?;
            if outcome.success {
                let updated_ep = outcome
                    .final_episode
                    .unwrap_or_else(|| item.last_episode.clone());
                db.upsert_seen(&item.ani_id, &item.title, &updated_ep)?;
                Ok(format!(
                    "Previous finished: {} now on episode {}",
                    item.title, updated_ep
                ))
            } else {
                Ok(playback_failure_message(&outcome))
            }
        }
        TuiAction::Select => {
            let outcome = run_ani_cli_select(item)?;
            if outcome.success {
                let updated_ep = outcome
                    .final_episode
                    .unwrap_or_else(|| item.last_episode.clone());
                db.upsert_seen(&item.ani_id, &item.title, &updated_ep)?;
                Ok(format!(
                    "Select finished: {} now on episode {}",
                    item.title, updated_ep
                ))
            } else {
                Ok(playback_failure_message(&outcome))
            }
        }
    }
}

pub(super) fn ensure_selected_episode_list(
    items: &[SeenEntry],
    table_state: &TableState,
    episode_lists_by_id: &mut HashMap<String, EpisodeListState>,
    tx: &mpsc::Sender<EpisodeListFetchResult>,
) {
    let Some(selected_idx) = table_state.selected() else {
        return;
    };
    let Some(item) = items.get(selected_idx) else {
        return;
    };
    if episode_lists_by_id.contains_key(&item.ani_id) {
        return;
    }

    episode_lists_by_id.insert(item.ani_id.clone(), EpisodeListState::Loading);
    let ani_id = item.ani_id.clone();
    let total_hint = parse_title_and_total_eps(&item.title).1;
    let tx = tx.clone();
    std::thread::spawn(move || {
        let outcome = fetch_episode_labels_with_diagnostics(&ani_id, total_hint);
        let warning = (!outcome.warnings.is_empty()).then(|| outcome.warnings.join(" | "));
        let _ = tx.send(EpisodeListFetchResult {
            ani_id,
            episode_list: outcome.episode_list,
            warning,
        });
    });
}

pub(super) fn drain_episode_fetch_results(
    rx: &mpsc::Receiver<EpisodeListFetchResult>,
    episode_lists_by_id: &mut HashMap<String, EpisodeListState>,
) {
    while let Ok(result) = rx.try_recv() {
        episode_lists_by_id.insert(
            result.ani_id,
            EpisodeListState::Ready {
                episode_list: result.episode_list,
                warning: result.warning,
            },
        );
    }
}