lr2-oxytabler 0.9.1

Table manager for Lunatic Rave 2
use crate::{
    changelog::TableUpdateChangelog, output::OutputFolderKey, time::UnixEpochTs, url::ResolvedUrl,
};
use anyhow::{Context as _, Result, ensure};

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Table(pub Data, pub Context);

/// Table as fetched from the internet.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Data {
    /// URL to the html page. That page contains header URL in its meta tags.
    /// Tables can be uniquely identified by this field. Other fields may get updated.
    pub web_url: ResolvedUrl,
    pub name: String,
    pub symbol: String,

    pub entries: Vec<Entry>,
    pub folder_order: Vec<String>,
}

// Additional data we associate with a given table.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Context {
    pub last_update: Option<UnixEpochTs>,
    pub playlist_id: Option<crate::db::TableId>,
    /// Table symbol defined by the user.
    pub user_symbol: Option<String>,
    pub output: OutputFolderKey,

    pub changelog: TableUpdateChangelog,
    pub edited_symbol: Option<String>,
    pub edited_url: Option<String>,
    /// Checked on save, if 'true', this table will be deleted from the list.
    pub pending_removal: bool,
    pub status: Status,
}

#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct Entry {
    pub md5: String,
    pub level: String,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Status {
    Ready,
    Updating,
    Error(String),
}

impl Table {
    #[expect(clippy::similar_names, reason = "'now' vs 'new'")]
    pub fn apply_update(
        song_loader: &crate::changelog::SongNameAccessor,
        tables: &mut Vec<Self>,
        data: Data,
        locator: &crate::fetch::TableLocator,
        now: UnixEpochTs,
    ) -> Result<()> {
        let old = locator
            .locate(tables)
            .with_context(|| format!("failed to find table {locator}"))?;
        let old = tables.remove(old);
        let changelog = TableUpdateChangelog::try_new(
            song_loader,
            &old.0.entries,
            &data.entries,
            &data.folder_order,
            old.1.user_symbol.as_deref().unwrap_or(&data.symbol),
        )?;
        let new = Self(
            data,
            Context {
                status: Status::Ready,
                changelog,
                last_update: Some(now),
                ..old.1
            },
        );
        if !new.1.changelog.summary().is_empty() {
            log::info!(
                "{}: {}\n{}",
                new.0.name,
                new.1.changelog.summary(),
                new.1.changelog.full()
            );
        }
        let idx = tables.partition_point(|t| t.0.name < new.0.name);
        tables.insert(idx, new);
        debug_assert!(tables.is_sorted_by(|l, r| l.0.name < r.0.name));
        Ok(())
    }

    pub fn commit_removals(tables: &mut Vec<Self>) {
        let old_len = tables.len();
        tables.retain(|t| !t.1.pending_removal);
        if tables.len() != old_len {
            log::info!("Removed {} table(s) while saving", old_len - tables.len());
        }
    }

    pub fn insert_new(
        tables: &mut Vec<Self>,
        web_url: ResolvedUrl,
        output: OutputFolderKey,
    ) -> Result<()> {
        ensure!(
            !tables.iter().any(|t| t.0.web_url == web_url),
            "Table {} already exists",
            web_url.as_str()
        );
        let table = Self(
            Data {
                web_url,
                name: "NEW TABLE".to_string(),
                symbol: "?".to_string(),
                entries: vec![],
                folder_order: vec![],
            },
            Context {
                changelog: TableUpdateChangelog::default(),
                edited_symbol: None,
                edited_url: None,
                last_update: None,
                pending_removal: false,
                playlist_id: None,
                status: Status::Ready,
                user_symbol: None,
                output,
            },
        );
        tables.insert(tables.partition_point(|t| t.0.name < table.0.name), table);
        // this fails on empty names wtf
        debug_assert!(tables.is_sorted_by(|l, r| l.0.name < r.0.name));
        Ok(())
    }

    pub fn wants_updating(&self, now: UnixEpochTs, update_interval: UnixEpochTs) -> bool {
        pub const NEGATIVE_NEVER: UnixEpochTs = UnixEpochTs(0);
        if self.1.pending_removal || self.1.status == Status::Updating {
            return false;
        }
        if self.0.web_url.as_str().starts_with("file://") {
            return true;
        }
        self.1.last_update.unwrap_or(NEGATIVE_NEVER).0 + update_interval.0 < now.0
    }
}

#[cfg(test)]
impl Table {
    #[must_use]
    pub fn empty() -> Self {
        Self(
            Data {
                web_url: "http://".try_into().unwrap(),
                name: String::new(),
                symbol: String::new(),
                entries: vec![],
                folder_order: vec![],
            },
            Context {
                changelog: TableUpdateChangelog::default(),
                edited_symbol: None,
                edited_url: None,
                last_update: None,
                pending_removal: false,
                playlist_id: None,
                user_symbol: None,
                output: OutputFolderKey(String::new()),
                status: Status::Ready,
            },
        )
    }

    #[must_use]
    pub fn with_any_entry(self) -> Self {
        self.with_any_entry_leveled("1")
    }
    #[must_use]
    pub fn with_any_entry_leveled(self, level: impl Into<String>) -> Self {
        let idx = self.0.entries.len();
        self.with_new_entry(idx, level)
    }
    #[must_use]
    pub fn with_entry(mut self, entry: Entry) -> Self {
        self.0.entries.push(entry);
        self
    }
    #[must_use]
    pub fn with_id(mut self, id: &mut crate::db::TableId) -> Self {
        self.1.playlist_id = Some(*id);
        id.0 += 1;
        self
    }
    #[must_use]
    pub fn with_name(mut self, name: impl Into<String>) -> Self {
        self.0.name = name.into();
        self
    }
    pub fn with_new_entry(self, md5: usize, level: impl Into<String>) -> Self {
        self.with_entry(Entry {
            md5: format!("foodfoodfoodfoodfoodfoodfood{md5:0>4}"),
            level: level.into(),
        })
    }
    #[must_use]
    pub fn with_output(mut self, output: impl Into<String>) -> Self {
        self.1.output = OutputFolderKey(output.into());
        self
    }
    #[must_use]
    pub fn with_symbol(mut self, symbol: impl Into<String>) -> Self {
        self.0.symbol = symbol.into();
        self
    }
    #[must_use]
    pub fn with_url(mut self, web_url: impl Into<String>) -> Self {
        self.0.web_url = web_url.into().try_into().unwrap();
        self
    }
    #[must_use]
    pub fn with_user_symbol(mut self, symbol: impl Into<String>) -> Self {
        self.1.user_symbol = Some(symbol.into());
        self
    }
}