melors 0.2.2

Keyboard-first terminal MP3 player with queue, search, and tag editing
use super::*;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionStatusLevel {
    Info,
    Warning,
    Error,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ActionStatusMessage {
    pub level: ActionStatusLevel,
    pub code: &'static str,
    pub text: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlaylistActionResult {
    PlaylistCreated {
        playlist_id: i64,
        name: String,
    },
    PlaylistDeleted {
        playlist_id: i64,
    },
    PlaylistRenamed {
        playlist_id: i64,
        name: String,
    },
    PlaylistItemAdded {
        playlist_id: i64,
        track_id: i64,
    },
    PlaylistItemRemoved {
        playlist_id: i64,
        order_index: i64,
    },
    PlaybackStarted {
        playlist_id: i64,
        track_id: i64,
    },
    NoPlayableItem {
        playlist_id: i64,
    },
    ValidationError {
        context: &'static str,
        message: String,
    },
    NotFoundError {
        context: &'static str,
        message: String,
    },
    PersistenceError {
        context: &'static str,
        message: String,
    },
}

impl PlaylistActionResult {
    pub fn status_message(&self) -> ActionStatusMessage {
        match self {
            Self::PlaylistCreated { name, .. } => ActionStatusMessage {
                level: ActionStatusLevel::Info,
                code: "playlist.created",
                text: format!("Playlist created: {name}"),
            },
            Self::PlaylistDeleted { .. } => ActionStatusMessage {
                level: ActionStatusLevel::Info,
                code: "playlist.deleted",
                text: String::from("Playlist deleted"),
            },
            Self::PlaylistRenamed { name, .. } => ActionStatusMessage {
                level: ActionStatusLevel::Info,
                code: "playlist.renamed",
                text: format!("Playlist renamed: {name}"),
            },
            Self::PlaylistItemAdded { .. } => ActionStatusMessage {
                level: ActionStatusLevel::Info,
                code: "playlist.item_added",
                text: String::from("Track added to playlist"),
            },
            Self::PlaylistItemRemoved { .. } => ActionStatusMessage {
                level: ActionStatusLevel::Info,
                code: "playlist.item_removed",
                text: String::from("Track removed from playlist"),
            },
            Self::PlaybackStarted { .. } => ActionStatusMessage {
                level: ActionStatusLevel::Info,
                code: "playlist.playback_started",
                text: String::from("Playback started from playlist"),
            },
            Self::NoPlayableItem { .. } => ActionStatusMessage {
                level: ActionStatusLevel::Warning,
                code: "playlist.no_playable_item",
                text: String::from("No playable item found in playlist"),
            },
            Self::ValidationError { message, .. } => ActionStatusMessage {
                level: ActionStatusLevel::Error,
                code: "playlist.validation_error",
                text: message.clone(),
            },
            Self::NotFoundError { message, .. } => ActionStatusMessage {
                level: ActionStatusLevel::Error,
                code: "playlist.not_found",
                text: message.clone(),
            },
            Self::PersistenceError { message, .. } => ActionStatusMessage {
                level: ActionStatusLevel::Error,
                code: "playlist.persistence_error",
                text: message.clone(),
            },
        }
    }
}

impl App {
    pub fn create_playlist_action(&self, name: &str) -> PlaylistActionResult {
        match self.storage.create_playlist(name) {
            Ok(playlist_id) => PlaylistActionResult::PlaylistCreated {
                playlist_id,
                name: name.trim().to_string(),
            },
            Err(err) => classify_storage_error("create_playlist", err),
        }
    }

    pub fn delete_playlist_action(&mut self, playlist_id: i64) -> PlaylistActionResult {
        match self.storage.delete_playlist(playlist_id) {
            Ok(()) => {
                if self.session.active_playlist_id == Some(playlist_id) {
                    self.clear_active_playlist_context();
                    if let Err(err) = self.rebuild_queue() {
                        return classify_storage_error("delete_playlist", err);
                    }
                }
                PlaylistActionResult::PlaylistDeleted { playlist_id }
            }
            Err(err) => classify_storage_error("delete_playlist", err),
        }
    }

    pub fn rename_playlist_action(&mut self, playlist_id: i64, name: &str) -> PlaylistActionResult {
        match self.storage.rename_playlist(playlist_id, name) {
            Ok(()) => {
                if self.session.active_playlist_id == Some(playlist_id) {
                    self.session.active_playlist_name = Some(name.trim().to_string());
                }
                PlaylistActionResult::PlaylistRenamed {
                    playlist_id,
                    name: name.trim().to_string(),
                }
            }
            Err(err) => classify_storage_error("rename_playlist", err),
        }
    }

    pub fn list_playlists_action(&self) -> Result<Vec<crate::services::storage::Playlist>> {
        self.storage.list_playlists()
    }

    pub fn list_playlist_items_action(
        &self,
        playlist_id: i64,
    ) -> Result<Vec<crate::services::storage::PlaylistItem>> {
        self.storage.load_playlist_items(playlist_id)
    }

    pub fn add_playlist_item_action(
        &mut self,
        playlist_id: i64,
        track_id: i64,
    ) -> PlaylistActionResult {
        match self.storage.add_playlist_item(playlist_id, track_id) {
            Ok(()) => {
                if let Err(err) = self.sync_active_playlist_queue_if_needed(playlist_id) {
                    return classify_storage_error("add_playlist_item", err);
                }
                PlaylistActionResult::PlaylistItemAdded {
                    playlist_id,
                    track_id,
                }
            }
            Err(err) => classify_storage_error("add_playlist_item", err),
        }
    }

    pub fn remove_playlist_item_action(
        &mut self,
        playlist_id: i64,
        order_index: i64,
    ) -> PlaylistActionResult {
        match self.storage.remove_playlist_item(playlist_id, order_index) {
            Ok(()) => {
                if let Err(err) = self.sync_active_playlist_queue_if_needed(playlist_id) {
                    return classify_storage_error("remove_playlist_item", err);
                }
                PlaylistActionResult::PlaylistItemRemoved {
                    playlist_id,
                    order_index,
                }
            }
            Err(err) => classify_storage_error("remove_playlist_item", err),
        }
    }

    pub fn play_from_playlist_action(
        &mut self,
        playlist_id: i64,
        selected_index: Option<usize>,
    ) -> PlaylistActionResult {
        let items = match self.storage.load_playlist_items(playlist_id) {
            Ok(items) => items,
            Err(err) => return classify_storage_error("play_from_playlist", err),
        };

        let start = selected_index.unwrap_or(0);
        let playable_track_id = resolve_playable_track_id(&items, start, |track_id| {
            self.track_by_id(track_id).is_some()
        });

        let Some(track_id) = playable_track_id else {
            return PlaylistActionResult::NoPlayableItem { playlist_id };
        };

        let playlist_name = self
            .storage
            .list_playlists()
            .ok()
            .and_then(|playlists| playlists.into_iter().find(|p| p.id == playlist_id))
            .map(|playlist| playlist.name)
            .unwrap_or_else(|| format!("Playlist {}", playlist_id));

        match self.activate_playlist_queue(playlist_id, playlist_name) {
            Ok(track_ids) => {
                if !track_ids.contains(&track_id) {
                    return PlaylistActionResult::NoPlayableItem { playlist_id };
                }

                match self.play_track(track_id) {
                    Ok(()) => PlaylistActionResult::PlaybackStarted {
                        playlist_id,
                        track_id,
                    },
                    Err(err) => classify_storage_error("play_from_playlist", err),
                }
            }
            Err(err) => classify_storage_error("play_from_playlist", err),
        }
    }
}

fn resolve_playable_track_id(
    items: &[crate::services::storage::PlaylistItem],
    selected_index: usize,
    mut track_exists: impl FnMut(i64) -> bool,
) -> Option<i64> {
    if items.is_empty() {
        return None;
    }

    let start = selected_index.min(items.len().saturating_sub(1));
    for item in &items[start..] {
        if item.is_missing {
            continue;
        }
        if let Some(track_id) = item.track_id
            && track_exists(track_id)
        {
            return Some(track_id);
        }
    }
    None
}

fn classify_storage_error(context: &'static str, err: anyhow::Error) -> PlaylistActionResult {
    let message = err.to_string();
    let lowered = message.to_ascii_lowercase();
    if lowered.contains("not found") {
        return PlaylistActionResult::NotFoundError { context, message };
    }
    if lowered.contains("already exists")
        || lowered.contains("cannot be empty")
        || lowered.contains("out of bounds")
    {
        return PlaylistActionResult::ValidationError { context, message };
    }
    PlaylistActionResult::PersistenceError { context, message }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::services::storage::PlaylistItem;

    #[test]
    fn resolve_playable_track_id_skips_missing_items() {
        let items = vec![
            PlaylistItem {
                track_id: None,
                original_path: Some(String::from("/tmp/missing.mp3")),
                is_missing: true,
                order_index: 0,
            },
            PlaylistItem {
                track_id: Some(42),
                original_path: Some(String::from("/tmp/42.mp3")),
                is_missing: false,
                order_index: 1,
            },
        ];

        let id = resolve_playable_track_id(&items, 0, |track_id| track_id == 42);
        assert_eq!(id, Some(42));
    }

    #[test]
    fn resolve_playable_track_id_returns_none_when_no_valid_track() {
        let items = vec![PlaylistItem {
            track_id: Some(9),
            original_path: Some(String::from("/tmp/9.mp3")),
            is_missing: false,
            order_index: 0,
        }];

        let id = resolve_playable_track_id(&items, 0, |_track_id| false);
        assert_eq!(id, None);
    }

    #[test]
    fn resolve_playable_track_id_honors_selected_index_start_point() {
        let items = vec![
            PlaylistItem {
                track_id: Some(1),
                original_path: Some(String::from("/tmp/1.mp3")),
                is_missing: false,
                order_index: 0,
            },
            PlaylistItem {
                track_id: Some(2),
                original_path: Some(String::from("/tmp/2.mp3")),
                is_missing: false,
                order_index: 1,
            },
        ];

        let id = resolve_playable_track_id(&items, 1, |_track_id| true);
        assert_eq!(id, Some(2));
    }

    #[test]
    fn status_mapping_for_no_playable_item_is_deterministic() {
        let result = PlaylistActionResult::NoPlayableItem { playlist_id: 1 };
        let status = result.status_message();
        assert_eq!(status.level, ActionStatusLevel::Warning);
        assert_eq!(status.code, "playlist.no_playable_item");
        assert_eq!(status.text, "No playable item found in playlist");
    }

    #[test]
    fn classify_storage_error_maps_validation_context() {
        let result = classify_storage_error(
            "create_playlist",
            anyhow::anyhow!("playlist name already exists"),
        );
        assert!(matches!(
            result,
            PlaylistActionResult::ValidationError {
                context: "create_playlist",
                ..
            }
        ));
    }
}