use std::collections::HashMap;
use std::error;
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
use std::sync::LazyLock;

use config::{Config, File as ConfigFile, FileFormat};
use palette::named;
use serde::{Deserialize, Serialize};
use serde_json;
use strum_macros;

static DEFAULT_MAX_DEPTH: u8 = 10;

// Collection of settable "meanings" that can have colors set.
// NOTE: You can add a new meaning here without breaking backwards compatibility but please:
//     - update the atuin/docs repository, which has a list of available meanings
//     - add a fallback in the MEANING_FALLBACKS below, so that themes which do not have it
//       get a sensible fallback (see Title as an example)
#[derive(
    Serialize, Deserialize, Copy, Clone, Hash, Debug, Eq, PartialEq, strum_macros::Display,
)]
#[strum(serialize_all = "camel_case")]
pub enum Meaning {
    AlertInfo,
    AlertWarn,
    AlertError,
    Annotation,
    Base,
    Guidance,
    Important,
    Title,
    Muted,
    SyntaxCommand,
    SyntaxFlag,
    SyntaxString,
    SyntaxVariable,
    SyntaxOperator,
    SyntaxComment,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ThemeConfig {
    // Definition of the theme
    pub theme: ThemeDefinitionConfigBlock,

    // Colors
    pub colors: HashMap<Meaning, String>,
}

#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ThemeDefinitionConfigBlock {
    /// Name of theme ("default" for base)
    pub name: String,

    /// Whether any theme should be treated as a parent _if available_
    pub parent: Option<String>,
}

use crossterm::style::{Attribute, Attributes, Color, ContentStyle};

// For now, a theme is loaded as a mapping of meanings to colors, but it may be desirable to
// expand that in the future to general styles, so we populate a Meaning->ContentStyle hashmap.
pub struct Theme {
    pub name: String,
    pub parent: Option<String>,
    pub styles: HashMap<Meaning, ContentStyle>,
}

// Themes have a number of convenience functions for the most commonly used meanings.
// The general purpose `as_style` routine gives back a style, but for ease-of-use and to keep
// theme-related boilerplate minimal, the convenience functions give a color.
impl Theme {
    // This is the base "default" color, for general text
    #[must_use]
    pub fn get_base(&self) -> ContentStyle {
        self.styles[&Meaning::Base]
    }

    #[must_use]
    pub fn get_info(&self) -> ContentStyle {
        self.get_alert(tracing::Level::INFO)
    }

    #[must_use]
    pub fn get_warning(&self) -> ContentStyle {
        self.get_alert(tracing::Level::WARN)
    }

    #[must_use]
    pub fn get_error(&self) -> ContentStyle {
        self.get_alert(tracing::Level::ERROR)
    }

    // The alert meanings may be chosen by the Level enum, rather than the methods above
    // or the full Meaning enum, to simplify programmatic selection of a log-level.
    pub fn get_alert(&self, severity: tracing::Level) -> ContentStyle {
        self.styles[ALERT_TYPES.get(&severity).unwrap()]
    }

    #[must_use]
    pub fn new(
        name: String,
        parent: Option<String>,
        styles: HashMap<Meaning, ContentStyle>,
    ) -> Self {
        Self {
            name,
            parent,
            styles,
        }
    }

    pub fn closest_meaning<'a>(&self, meaning: &'a Meaning) -> &'a Meaning {
        if self.styles.contains_key(meaning) {
            meaning
        } else if MEANING_FALLBACKS.contains_key(meaning) {
            self.closest_meaning(&MEANING_FALLBACKS[meaning])
        } else {
            &Meaning::Base
        }
    }

    // General access - if you have a meaning, this will give you a (crossterm) style
    #[must_use]
    pub fn as_style(&self, meaning: Meaning) -> ContentStyle {
        self.styles[self.closest_meaning(&meaning)]
    }

    // Turns a map of meanings to colornames into a theme
    // If theme-debug is on, then we will print any colornames that we cannot load,
    // but we do not have this on in general, as it could print unfiltered text to the terminal
    // from a theme TOML file. However, it will always return a theme, falling back to
    // defaults on error, so that a TOML file does not break loading
    #[must_use]
    pub fn from_foreground_colors(
        name: String,
        parent: Option<&Self>,
        foreground_colors: &HashMap<Meaning, String>,
        debug: bool,
    ) -> Self {
        let styles: HashMap<Meaning, ContentStyle> = foreground_colors
            .iter()
            .map(|(name, color)| {
                (
                    *name,
                    StyleFactory::from_fg_string(color).unwrap_or_else(|err| {
                        if debug {
                            tracing::warn!(
                                "Tried to load string as a color unsuccessfully: ({name}={color}) \
                                 {err}"
                            );
                        }
                        ContentStyle::default()
                    }),
                )
            })
            .collect();
        Self::from_map(name, parent, &styles)
    }

    // Boil down a meaning-color hashmap into a theme, by taking the defaults
    // for any unknown colors
    fn from_map(
        name: String,
        parent: Option<&Self>,
        overrides: &HashMap<Meaning, ContentStyle>,
    ) -> Self {
        let styles = match parent {
            Some(theme) => Box::new(theme.styles.clone()),
            None => Box::new(DEFAULT_THEME.styles.clone()),
        }
        .iter()
        .map(|(name, color)| match overrides.get(name) {
            Some(value) => (*name, *value),
            None => (*name, *color),
        })
        .collect();
        Self::new(name, parent.map(|p| p.name.clone()), styles)
    }
}

// Use palette to get a color from a string name, if possible
fn from_string(name: &str) -> Result<Color, String> {
    if name.is_empty() {
        return Err("Empty string".into());
    }
    let first_char = name.chars().next().unwrap();
    match first_char {
        '#' => {
            let hexcode = &name[1..];
            let vec: Vec<u8> = hexcode
                .chars()
                .collect::<Vec<char>>()
                .chunks(2)
                .map(|pair| u8::from_str_radix(pair.iter().collect::<String>().as_str(), 16))
                .filter_map(|n| n.ok())
                .collect();
            if vec.len() != 3 {
                return Err("Could not parse 3 hex values from string".into());
            }
            Ok(Color::Rgb {
                r: vec[0],
                g: vec[1],
                b: vec[2],
            })
        }
        '@' => {
            // For full flexibility, we need to use serde_json, given
            // crossterm's approach.
            serde_json::from_str::<Color>(format!("\"{}\"", &name[1..]).as_str())
                .map_err(|_| format!("Could not convert color name {name} to Crossterm color"))
        }
        _ => {
            let srgb = named::from_str(name).ok_or("No such color in palette")?;
            Ok(Color::Rgb {
                r: srgb.red,
                g: srgb.green,
                b: srgb.blue,
            })
        }
    }
}

pub struct StyleFactory {}

impl StyleFactory {
    fn from_fg_string(name: &str) -> Result<ContentStyle, String> {
        match from_string(name) {
            Ok(color) => Ok(Self::from_fg_color(color)),
            Err(err) => Err(err),
        }
    }

    // For succinctness, if we are confident that the name will be known,
    // this routine is available to keep the code readable
    fn known_fg_string(name: &str) -> ContentStyle {
        Self::from_fg_string(name).unwrap()
    }

    fn from_fg_color(color: Color) -> ContentStyle {
        ContentStyle {
            foreground_color: Some(color),
            ..ContentStyle::default()
        }
    }

    fn from_fg_color_and_attributes(color: Color, attributes: Attributes) -> ContentStyle {
        ContentStyle {
            foreground_color: Some(color),
            attributes,
            ..ContentStyle::default()
        }
    }
}

// Built-in themes. Rather than having extra files added before any theming
// is available, this gives a couple of basic options, demonstrating the use
// of themes: autumn and marine
static ALERT_TYPES: LazyLock<HashMap<tracing::Level, Meaning>> = LazyLock::new(|| {
    HashMap::from([
        (tracing::Level::INFO, Meaning::AlertInfo),
        (tracing::Level::WARN, Meaning::AlertWarn),
        (tracing::Level::ERROR, Meaning::AlertError),
    ])
});

static MEANING_FALLBACKS: LazyLock<HashMap<Meaning, Meaning>> = LazyLock::new(|| {
    HashMap::from([
        (Meaning::Guidance, Meaning::AlertInfo),
        (Meaning::Annotation, Meaning::AlertInfo),
        (Meaning::Title, Meaning::Important),
        (Meaning::SyntaxComment, Meaning::Annotation),
    ])
});

static DEFAULT_THEME: LazyLock<Theme> = LazyLock::new(|| {
    Theme::new(
        "default".to_string(),
        None,
        HashMap::from([
            (Meaning::AlertError, StyleFactory::from_fg_color(Color::DarkRed)),
            (Meaning::AlertWarn, StyleFactory::from_fg_color(Color::DarkYellow)),
            (Meaning::AlertInfo, StyleFactory::from_fg_color(Color::DarkGreen)),
            (Meaning::Annotation, StyleFactory::from_fg_color(Color::DarkGrey)),
            (Meaning::Guidance, StyleFactory::from_fg_color(Color::DarkBlue)),
            (
                Meaning::Important,
                StyleFactory::from_fg_color_and_attributes(
                    Color::White,
                    Attributes::from(Attribute::Bold),
                ),
            ),
            (Meaning::Muted, StyleFactory::from_fg_color(Color::Grey)),
            (Meaning::Base, ContentStyle::default()),
            // Syntax highlighting uses ANSI palette colors, so they follow
            // the user's terminal color scheme out of the box.
            (Meaning::SyntaxCommand, StyleFactory::from_fg_color(Color::Green)),
            (Meaning::SyntaxFlag, StyleFactory::from_fg_color(Color::DarkCyan)),
            (Meaning::SyntaxString, StyleFactory::from_fg_color(Color::DarkYellow)),
            (Meaning::SyntaxVariable, StyleFactory::from_fg_color(Color::Magenta)),
            // Operators keep the terminal's default foreground
            (Meaning::SyntaxOperator, ContentStyle::default()),
            (Meaning::SyntaxComment, StyleFactory::from_fg_color(Color::DarkGrey)),
        ]),
    )
});

static BUILTIN_THEMES: LazyLock<HashMap<&'static str, Theme>> = LazyLock::new(|| {
    HashMap::from([
        ("default", HashMap::new()),
        (
            "(none)",
            DEFAULT_THEME
                .styles
                .keys()
                .map(|&meaning| (meaning, ContentStyle::default()))
                .collect(),
        ),
        (
            "autumn",
            HashMap::from([
                (Meaning::AlertError, StyleFactory::known_fg_string("saddlebrown")),
                (Meaning::AlertWarn, StyleFactory::known_fg_string("darkorange")),
                (Meaning::AlertInfo, StyleFactory::known_fg_string("gold")),
                (Meaning::Annotation, StyleFactory::from_fg_color(Color::DarkGrey)),
                (Meaning::Guidance, StyleFactory::known_fg_string("brown")),
            ]),
        ),
        (
            "marine",
            HashMap::from([
                (Meaning::AlertError, StyleFactory::known_fg_string("yellowgreen")),
                (Meaning::AlertWarn, StyleFactory::known_fg_string("cyan")),
                (Meaning::AlertInfo, StyleFactory::known_fg_string("turquoise")),
                (Meaning::Annotation, StyleFactory::known_fg_string("steelblue")),
                (Meaning::Base, StyleFactory::known_fg_string("lightsteelblue")),
                (Meaning::Guidance, StyleFactory::known_fg_string("teal")),
            ]),
        ),
        (
            // This is the default theme with default PowerShell syntax highlighting colors.
            "default-powershell",
            HashMap::from([
                (Meaning::SyntaxCommand, StyleFactory::from_fg_color(Color::Yellow)),
                (Meaning::SyntaxFlag, StyleFactory::from_fg_color(Color::DarkGrey)),
                (Meaning::SyntaxString, StyleFactory::from_fg_color(Color::DarkCyan)),
                (Meaning::SyntaxVariable, StyleFactory::from_fg_color(Color::Green)),
                (Meaning::SyntaxOperator, StyleFactory::from_fg_color(Color::DarkGrey)),
                (Meaning::SyntaxComment, StyleFactory::from_fg_color(Color::DarkGreen)),
            ]),
        ),
    ])
    .iter()
    .map(|(name, theme)| (*name, Theme::from_map(name.to_string(), None, theme)))
    .collect()
});

// To avoid themes being repeatedly loaded, we store them in a theme manager
pub struct ThemeManager {
    loaded_themes: HashMap<String, Theme>,
    debug: bool,
    override_theme_dir: Option<String>,
}

// Theme-loading logic
impl ThemeManager {
    #[must_use]
    pub fn new(debug: Option<bool>, theme_dir: Option<String>) -> Self {
        Self {
            loaded_themes: HashMap::new(),
            debug: debug.unwrap_or(false),
            override_theme_dir: match theme_dir {
                Some(theme_dir) => Some(theme_dir),
                None => std::env::var("ATUIN_THEME_DIR").ok(),
            },
        }
    }

    // Try to load a theme from a `{name}.toml` file in the theme directory. If an override is set
    // for the theme dir (via ATUIN_THEME_DIR env) we should load the theme from there
    pub fn load_theme_from_file(
        &mut self,
        name: &str,
        max_depth: u8,
    ) -> Result<&Theme, Box<dyn error::Error>> {
        let mut theme_file = if let Some(p) = &self.override_theme_dir {
            if p.is_empty() {
                return Err(Box::new(Error::new(
                    ErrorKind::NotFound,
                    "Empty theme directory override and could not find theme elsewhere",
                )));
            }
            PathBuf::from(p)
        } else {
            let config_dir = atuin_common::utils::config_dir();
            let mut theme_file = if let Ok(p) = std::env::var("ATUIN_CONFIG_DIR") {
                PathBuf::from(p)
            } else {
                let mut theme_file = PathBuf::new();
                theme_file.push(config_dir);
                theme_file
            };
            theme_file.push("themes");
            theme_file
        };

        let theme_toml = format!["{name}.toml"];
        theme_file.push(theme_toml);

        let mut config_builder = Config::builder();

        config_builder = config_builder
            .add_source(ConfigFile::new(theme_file.to_str().unwrap(), FileFormat::Toml));

        let config = config_builder.build()?;
        self.load_theme_from_config(name, config, max_depth)
    }

    pub fn load_theme_from_config(
        &mut self,
        name: &str,
        config: Config,
        max_depth: u8,
    ) -> Result<&Theme, Box<dyn error::Error>> {
        let debug = self.debug;
        let theme_config: ThemeConfig = match config.try_deserialize() {
            Ok(tc) => tc,
            Err(e) => {
                return Err(Box::new(Error::new(
                    ErrorKind::InvalidInput,
                    format!(
                        "Failed to deserialize theme: {}",
                        if debug {
                            e.to_string()
                        } else {
                            "set theme debug on for more info".to_string()
                        }
                    ),
                )));
            }
        };
        let colors: HashMap<Meaning, String> = theme_config.colors;
        let parent: Option<&Theme> = match theme_config.theme.parent {
            Some(parent_name) => {
                if max_depth == 0 {
                    return Err(Box::new(Error::new(
                        ErrorKind::InvalidInput,
                        "Parent requested but we hit the recursion limit",
                    )));
                }
                Some(self.load_theme(parent_name.as_str(), Some(max_depth - 1)))
            }
            None => Some(self.load_theme("default", Some(max_depth - 1))),
        };

        if debug && name != theme_config.theme.name {
            tracing::warn!(
                "Your theme config name is not the name of your loaded theme {} != {}",
                name,
                theme_config.theme.name
            );
        }

        let theme = Theme::from_foreground_colors(theme_config.theme.name, parent, &colors, debug);
        let name = name.to_string();
        self.loaded_themes.insert(name.clone(), theme);
        let theme = self.loaded_themes.get(&name).unwrap();
        Ok(theme)
    }

    // Check if the requested theme is loaded and, if not, then attempt to get it
    // from the builtins or, if not there, from file
    #[must_use]
    pub fn load_theme(&mut self, name: &str, max_depth: Option<u8>) -> &Theme {
        if self.loaded_themes.contains_key(name) {
            return self.loaded_themes.get(name).unwrap();
        }
        let built_ins = &BUILTIN_THEMES;
        match built_ins.get(name) {
            Some(theme) => theme,
            None => match self.load_theme_from_file(name, max_depth.unwrap_or(DEFAULT_MAX_DEPTH)) {
                Ok(theme) => theme,
                Err(err) => {
                    tracing::warn!("Could not load theme {name}: {err}");
                    built_ins.get("(none)").unwrap()
                }
            },
        }
    }
}

#[cfg(test)]
mod theme_tests {
    use atuin_common::test_utils::capture_logs;
    use rstest::*;

    use super::*;

    #[fixture]
    fn manager(#[default(false)] debug: bool) -> ThemeManager {
        ThemeManager::new(Some(debug), Some("".to_string()))
    }

    fn theme_config(toml: &str) -> Config {
        Config::builder().add_source(ConfigFile::from_str(toml, FileFormat::Toml)).build().unwrap()
    }

    #[rstest]
    fn test_can_load_builtin_theme(mut manager: ThemeManager) {
        let theme = manager.load_theme("autumn", None);
        assert_eq!(theme.as_style(Meaning::Guidance).foreground_color, from_string("brown").ok());
    }

    #[rstest]
    fn test_can_create_theme(mut manager: ThemeManager) {
        let mytheme = Theme::new(
            "mytheme".to_string(),
            None,
            HashMap::from([(Meaning::AlertError, StyleFactory::known_fg_string("yellowgreen"))]),
        );
        manager.loaded_themes.insert("mytheme".to_string(), mytheme);
        let theme = manager.load_theme("mytheme", None);
        assert_eq!(
            theme.as_style(Meaning::AlertError).foreground_color,
            from_string("yellowgreen").ok()
        );
    }

    #[rstest]
    fn test_can_fallback_when_meaning_missing(mut manager: ThemeManager) {
        // We use title as an example of a meaning that is not defined
        // even in the base theme.
        assert!(!DEFAULT_THEME.styles.contains_key(&Meaning::Title));

        let config = theme_config(
            "
        [theme]
        name = \"title_theme\"

        [colors]
        Guidance = \"white\"
        AlertInfo = \"zomp\"
        ",
        );
        let theme = manager.load_theme_from_config("config_theme", config, 1).unwrap();

        // Correctly picks overridden color.
        assert_eq!(theme.as_style(Meaning::Guidance).foreground_color, from_string("white").ok());

        // Does not fall back to any color.
        assert_eq!(theme.as_style(Meaning::AlertInfo).foreground_color, None);

        // Even for the base.
        assert_eq!(theme.as_style(Meaning::Base).foreground_color, None);

        // Falls back to red as meaning missing from theme, so picks base default.
        assert_eq!(theme.as_style(Meaning::AlertError).foreground_color, Some(Color::DarkRed));

        // Falls back to Important as Title not available.
        assert_eq!(
            theme.as_style(Meaning::Title).foreground_color,
            theme.as_style(Meaning::Important).foreground_color,
        );

        let title_config = theme_config(
            "
        [theme]
        name = \"title_theme\"

        [colors]
        Title = \"white\"
        AlertInfo = \"zomp\"
        ",
        );
        let title_theme = manager.load_theme_from_config("title_theme", title_config, 1).unwrap();

        assert_eq!(title_theme.as_style(Meaning::Title).foreground_color, Some(Color::White));
    }

    #[test]
    fn test_no_fallbacks_are_circular() {
        let mytheme = Theme::new("mytheme".to_string(), None, HashMap::from([]));
        MEANING_FALLBACKS
            .iter()
            .for_each(|pair| assert_eq!(mytheme.closest_meaning(pair.0), &Meaning::Base));
    }

    #[rstest]
    fn test_can_get_colors_via_convenience_functions(#[with(true)] mut manager: ThemeManager) {
        let theme = manager.load_theme("default", None);
        assert_eq!(theme.get_error().foreground_color.unwrap(), Color::DarkRed);
        assert_eq!(theme.get_warning().foreground_color.unwrap(), Color::DarkYellow);
        assert_eq!(theme.get_info().foreground_color.unwrap(), Color::DarkGreen);
        assert_eq!(theme.get_base().foreground_color, None);
        assert_eq!(
            theme.get_alert(tracing::Level::ERROR).foreground_color.unwrap(),
            Color::DarkRed
        );
    }

    #[rstest]
    fn test_can_use_parent_theme_for_fallbacks(mut manager: ThemeManager) {
        let logs = capture_logs();

        // First, we introduce a base theme
        let solarized = theme_config(
            "
        [theme]
        name = \"solarized\"

        [colors]
        Guidance = \"white\"
        AlertInfo = \"pink\"
        ",
        );
        let solarized_theme = manager.load_theme_from_config("solarized", solarized, 1).unwrap();

        assert_eq!(
            solarized_theme.as_style(Meaning::AlertInfo).foreground_color,
            from_string("pink").ok()
        );

        // Then we introduce a derived theme
        let unsolarized = theme_config(
            "
        [theme]
        name = \"unsolarized\"
        parent = \"solarized\"

        [colors]
        AlertInfo = \"red\"
        ",
        );
        let unsolarized_theme =
            manager.load_theme_from_config("unsolarized", unsolarized, 1).unwrap();

        // It will take its own values
        assert_eq!(
            unsolarized_theme.as_style(Meaning::AlertInfo).foreground_color,
            from_string("red").ok()
        );

        // ...or fall back to the parent
        assert_eq!(
            unsolarized_theme.as_style(Meaning::Guidance).foreground_color,
            from_string("white").ok()
        );

        assert_eq!(logs.get().len(), 0);

        // If the parent is not found, we end up with the no theme colors or styling
        // as this is considered a (soft) error state.
        let nunsolarized = theme_config(
            "
        [theme]
        name = \"nunsolarized\"
        parent = \"nonsolarized\"

        [colors]
        AlertInfo = \"red\"
        ",
        );
        let nunsolarized_theme =
            manager.load_theme_from_config("nunsolarized", nunsolarized, 1).unwrap();

        assert_eq!(nunsolarized_theme.as_style(Meaning::Guidance).foreground_color, None);

        let captured_logs = logs.get();
        assert_eq!(captured_logs.len(), 1);
        assert_eq!(
            captured_logs[0].message,
            "Could not load theme nonsolarized: Empty theme directory override and could not find \
             theme elsewhere"
        );
        assert_eq!(captured_logs[0].level, tracing::Level::WARN);
    }

    #[rstest]
    fn debug_theme_logs(#[values(true, false)] debug: bool) {
        let logs = capture_logs();

        let mut manager = ThemeManager::new(Some(debug), Some("".to_string()));
        let config = theme_config(
            "
        [theme]
        name = \"mytheme\"

        [colors]
        Guidance = \"white\"
        AlertInfo = \"xinetic\"
        ",
        );
        manager.load_theme_from_config("config_theme", config, 1).unwrap();
        if debug {
            let captured_logs = logs.get();
            assert_eq!(captured_logs.len(), 2);
            assert_eq!(
                captured_logs[0].message,
                "Your theme config name is not the name of your loaded theme config_theme != \
                 mytheme"
            );
            assert_eq!(captured_logs[0].level, tracing::Level::WARN);
            assert_eq!(
                captured_logs[1].message,
                "Tried to load string as a color unsuccessfully: (AlertInfo=xinetic) No such \
                 color in palette"
            );
            assert_eq!(captured_logs[1].level, tracing::Level::WARN);
            drop(captured_logs);
        } else {
            assert_eq!(logs.get().len(), 0);
        }
    }

    #[rstest]
    #[case::palette_name("brown", Color::Rgb { r: 165, g: 42, b: 42 })]
    #[case::hex("#ff1122", Color::Rgb { r: 255, g: 17, b: 34 })]
    #[case::at_named("@dark_grey", Color::DarkGrey)]
    #[case::at_rgb("@rgb_(255,255,255)", Color::Rgb { r: 255, g: 255, b: 255 })]
    #[case::at_ansi("@ansi_(255)", Color::AnsiValue(255))]
    fn parses_color_string(#[case] input: &str, #[case] expected: Color) {
        assert_eq!(from_string(input).unwrap(), expected);
    }

    #[rstest]
    #[case::empty("", "Empty string")]
    #[case::not_in_palette_word("manatee", "No such color in palette")]
    #[case::not_in_palette_phrase("caput mortuum", "No such color in palette")]
    #[case::not_in_palette_digits("123456", "No such color in palette")]
    #[case::hex_too_short("#1122", "Could not parse 3 hex values from string")]
    #[case::hex_too_long("#ffaa112", "Could not parse 3 hex values from string")]
    #[case::hex_not_hex("#brown", "Could not parse 3 hex values from string")]
    fn rejects_color_string(#[case] input: &str, #[case] expected_err: &str) {
        assert_eq!(from_string(input), Err(expected_err.into()));
    }

    /// The `@`-prefixed forms echo the offending input back in the error message.
    #[rstest]
    #[case::bare_at("@")]
    #[case::wrong_case("@DarkGray")]
    #[case::malformed_name("@Dark 4ay")]
    #[case::ansi_out_of_range("@ansi(256)")]
    fn rejects_at_prefixed_color_string(#[case] input: &str) {
        assert_eq!(
            from_string(input),
            Err(format!("Could not convert color name {input} to Crossterm color"))
        );
    }
}
