// Sync available crate categories from `src/categories.toml`.
// Runs when the server is started.

use anyhow::{Context, Result};
use crates_io_database::schema::categories;
use diesel::pg::upsert::excluded;
use diesel::prelude::*;
use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl};

#[derive(Debug)]
struct Category {
    slug: String,
    name: String,
    description: String,
}

impl Category {
    fn from_parent(
        slug: &str,
        name: &str,
        description: String,
        parent: Option<&Category>,
    ) -> Category {
        match parent {
            Some(parent) => Category {
                slug: format!("{}::{}", parent.slug, slug),
                name: format!("{}::{}", parent.name, name),
                description,
            },
            None => Category {
                slug: slug.into(),
                name: name.into(),
                description,
            },
        }
    }
}

fn required_string_from_toml<'a>(toml: &'a toml::value::Table, key: &str) -> Result<&'a str> {
    toml.get(key)
        .and_then(toml::Value::as_str)
        .with_context(|| format!("Expected category TOML attribute '{key}' to be a String"))
}

fn optional_string_from_toml<'a>(toml: &'a toml::value::Table, key: &str) -> &'a str {
    toml.get(key).and_then(toml::Value::as_str).unwrap_or("")
}

fn process_description(description: &str) -> String {
    description.split_whitespace().collect::<Vec<_>>().join(" ")
}

fn categories_from_toml(
    categories: &toml::value::Table,
    parent: Option<&Category>,
) -> Result<Vec<Category>> {
    let mut result = vec![];

    for (slug, details) in categories {
        let details = details
            .as_table()
            .with_context(|| format!("category {slug} was not a TOML table"))?;

        let description = optional_string_from_toml(details, "description");
        let description = process_description(description);

        let category = Category::from_parent(
            slug,
            required_string_from_toml(details, "name")?,
            description,
            parent,
        );

        if let Some(categories) = details.get("categories") {
            let categories = categories
                .as_table()
                .with_context(|| format!("child categories of {slug} were not a table"))?;

            result.extend(categories_from_toml(categories, Some(&category))?);
        }

        result.push(category)
    }

    Ok(result)
}

pub async fn sync_with_connection(toml_str: &str, conn: &mut AsyncPgConnection) -> Result<()> {
    let toml: toml::value::Table =
        toml::from_str(toml_str).context("Could not parse categories toml")?;

    let to_insert = categories_from_toml(&toml, None)
        .expect("Could not convert categories from TOML")
        .into_iter()
        .map(|c| {
            (
                categories::slug.eq(c.slug.to_lowercase()),
                categories::category.eq(c.name),
                categories::description.eq(c.description),
            )
        })
        .collect::<Vec<_>>();

    conn.transaction(async |conn| {
        let slugs: Vec<String> = diesel::insert_into(categories::table)
            .values(&to_insert)
            .on_conflict(categories::slug)
            .do_update()
            .set((
                categories::category.eq(excluded(categories::category)),
                categories::description.eq(excluded(categories::description)),
            ))
            .returning(categories::slug)
            .get_results(conn)
            .await?;

        diesel::delete(categories::table)
            .filter(categories::slug.ne_all(slugs))
            .execute(conn)
            .await?;

        Ok(())
    })
    .await
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_process_description() {
        // Leading whitespace
        assert_eq!(process_description("  description"), "description");
        assert_eq!(process_description("\tdescription"), "description");
        assert_eq!(process_description("\n\ndescription"), "description");

        // Trailing whitespace
        assert_eq!(process_description("description  "), "description");
        assert_eq!(process_description("description\t"), "description");
        assert_eq!(process_description("description\n\n"), "description");

        // Both leading and trailing whitespace
        assert_eq!(process_description("  description  "), "description");
        assert_eq!(process_description("\tdescription\t"), "description");
        assert_eq!(
            process_description("\n  description with spaces  \n"),
            "description with spaces"
        );

        // Collapses internal whitespace
        assert_eq!(
            process_description("  multi  word   description  "),
            "multi word description"
        );
        assert_eq!(
            process_description("  description\nwith\nnewlines  "),
            "description with newlines"
        );
        assert_eq!(
            process_description("text\n\n\nwith\t\tmultiple\n  whitespace"),
            "text with multiple whitespace"
        );

        // Empty string
        assert_eq!(process_description(""), "");
        assert_eq!(process_description("   "), "");
    }
}
