#![allow(clippy::float_cmp)]
mod utils;

use crate::utils::REPO_NAME;
use anyhow::{ensure, Context, Result};
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::Path;
use tinted_builder::{SchemeSystem, SchemeVariant};
use utils::setup;

const SCHEME_COUNT: usize = 459;

#[test]
fn test_cli_list_subcommand_without_setup() -> Result<()> {
    // -------
    // Arrange
    // -------
    let (_, _data_path, command_vec, _temp_dir) =
        setup("test_cli_list_subcommand_without_setup", "list", false)?;
    let expected_output =
        format!("Schemes are missing, run install and then try again: `{REPO_NAME} install`");

    // ---
    // Act
    // ---
    let (_, stderr) = utils::run_command(&command_vec)?;

    // ------
    // Assert
    // ------
    ensure!(
        stderr.contains(&expected_output),
        "Expected stderr to contain: {expected_output}\nGot: {stderr}"
    );

    Ok(())
}

#[test]
fn test_cli_list_subcommand_without_setup_with_custom_schemes_flag() -> Result<()> {
    // -------
    // Arrange
    // -------
    let test_name = "test_cli_list_subcommand_without_setup_with_custom_schemes_flag";
    let (_, data_path, command_vec, _temp_dir) = setup(test_name, "list --custom-schemes", true)?;
    let expected_output = format!(
        "You don't have any local custom schemes at: {}/custom-schemes",
        data_path.display(),
    );

    // ---
    // Act
    // ---
    let (_, stderr) = utils::run_command(&command_vec)?;

    // ------
    // Assert
    // ------
    ensure!(
        stderr.contains(&expected_output),
        "Expected stderr to contain: {expected_output}\nGot: {stderr}"
    );

    Ok(())
}

#[test]
fn test_cli_list_subcommand_with_setup() -> Result<()> {
    // -------
    // Arrange
    // -------
    let (_, _data_path, command_vec, _temp_dir) =
        setup("test_cli_list_subcommand_with_setup", "list", true)?;
    let expected_output = fs::read_to_string(Path::new("fixtures/schemes.txt"))?;

    // ---
    // Act
    // ---
    let (stdout, _) = utils::run_command(&command_vec)?;

    // ------
    // Assert
    // ------
    // The sort order of the schemes differ slightly so do an assert on each line instead of the
    // whole file
    let lines: Vec<&str> = expected_output.lines().collect();
    for line in lines {
        ensure!(
            stdout.contains(line),
            format!("stdout does not contain the expected output: {line}")
        );
    }

    Ok(())
}

#[test]
fn test_cli_list_subcommand_with_custom() -> Result<()> {
    // -------
    // Arrange
    // -------
    let (_, data_path, command_vec, _temp_dir) =
        setup("test_cli_list_subcommand_with_custom", "list", true)?;
    let scheme_system = "base16";
    let scheme_name_one = "tinted-theming";
    let scheme_name_two = "tinty";
    let expected_output =
        format!("{scheme_system}-{scheme_name_one}\n{scheme_system}-{scheme_name_two}");
    let custom_scheme_path = data_path.join("custom-schemes");

    fs::create_dir_all(custom_scheme_path.join(scheme_system))?;
    fs::write(
        custom_scheme_path.join(format!("{scheme_system}/{scheme_name_one}.yaml")),
        "",
    )?;
    fs::write(
        custom_scheme_path.join(format!("{scheme_system}/{scheme_name_two}.yml")),
        "",
    )?;

    let mut command_vec = command_vec;
    command_vec.push("--custom-schemes".to_string());

    // ---
    // Act
    // ---
    let (stdout, _) = utils::run_command(&command_vec)?;

    // ------
    // Assert
    // ------
    // The sort order of the schemes differ slightly so do an assert on each line instead of the
    // whole file
    let lines: Vec<&str> = expected_output.lines().collect();
    for line in lines {
        ensure!(
            stdout.contains(line),
            "stdout does not contain expected scheme: {line}"
        );
    }

    Ok(())
}

// These structs may appear as duplicate of the serializable structs in operations/list.rs, but
// this is intentional. These are asserting the expected structure of the JSON output, and
// sharing the exact same struct would hide a certain class of issues (i.e. comparing a broken
// implementation w/ an equally-broken assertion)
#[derive(Clone, Deserialize, PartialEq)]
struct TestSchemeEntry {
    pub id: String,
    pub name: String,
    pub author: String,
    pub system: SchemeSystem,
    pub variant: SchemeVariant,
    pub slug: String,
    pub palette: BTreeMap<String, TestColorOut>,
    pub lightness: Option<TestLightness>,
    #[serde(default)]
    pub ui: Option<BTreeMap<String, TestColorOut>>,
    #[serde(default)]
    pub syntax: Option<BTreeMap<String, TestColorOut>>,
}

impl std::ops::Deref for TestSchemeEntry {
    type Target = SchemeSystem;

    fn deref(&self) -> &Self::Target {
        &self.system
    }
}

impl std::fmt::Debug for TestSchemeEntry {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("TestSchemeEntry")
            .field("id", &self.id)
            .field("name", &self.name)
            .field("author", &self.author)
            .field("system", &self.system)
            .field("variant", &self.variant)
            .field("slug", &self.slug)
            .field("palette", &self.palette)
            .field("lightness", &self.lightness)
            .field("ui", &self.ui)
            .field("syntax", &self.syntax)
            .finish()
    }
}

#[derive(Clone, Deserialize, Debug, PartialEq)]
struct TestColorOut {
    pub hex_str: String,
    pub hex: (String, String, String),
    pub rgb: (u8, u8, u8),
    pub dec: (f32, f32, f32),
}

impl TestColorOut {
    fn approx_eq(&self, other: &Self) -> bool {
        self.hex_str == other.hex_str
            && self.hex == other.hex
            && self.rgb == other.rgb
            && f32_approx_eq(self.dec.0, other.dec.0)
            && f32_approx_eq(self.dec.1, other.dec.1)
            && f32_approx_eq(self.dec.2, other.dec.2)
    }
}

#[derive(Clone, Deserialize, Debug, PartialEq)]
struct TestLightness {
    pub foreground: f32,
    pub background: f32,
}

impl TestLightness {
    fn approx_eq(&self, other: &Self) -> bool {
        f32_approx_eq(self.foreground, other.foreground)
            && f32_approx_eq(self.background, other.background)
    }
}

fn f32_approx_eq(a: f32, b: f32) -> bool {
    (a - b).abs() < 1e-5
}

impl TestSchemeEntry {
    fn approx_eq(&self, other: &Self) -> bool {
        self.id == other.id
            && self.name == other.name
            && self.author == other.author
            && self.system == other.system
            && self.variant == other.variant
            && self.slug == other.slug
            && color_map_approx_eq(&self.palette, &other.palette)
            && match (&self.lightness, &other.lightness) {
                (Some(a), Some(b)) => a.approx_eq(b),
                (None, None) => true,
                _ => false,
            }
            && optional_color_map_approx_eq(self.ui.as_ref(), other.ui.as_ref())
            && optional_color_map_approx_eq(self.syntax.as_ref(), other.syntax.as_ref())
    }
}

fn color_map_approx_eq(
    a: &BTreeMap<String, TestColorOut>,
    b: &BTreeMap<String, TestColorOut>,
) -> bool {
    a.len() == b.len()
        && a.iter()
            .all(|(k, v)| b.get(k).is_some_and(|other_v| v.approx_eq(other_v)))
}

fn optional_color_map_approx_eq(
    a: Option<&BTreeMap<String, TestColorOut>>,
    b: Option<&BTreeMap<String, TestColorOut>>,
) -> bool {
    match (a, b) {
        (Some(x), Some(y)) => color_map_approx_eq(x, y),
        (None, None) => true,
        _ => false,
    }
}

// This is the basis of the other as_json tests. We'll assert that TestSchemeEntry are
// deserializing fields as expected.
#[test]
#[allow(clippy::too_many_lines)]
fn test_cli_list_subcommand_deserialize_fixture_scheme_entry() -> Result<()> {
    // -------
    // Arrange
    // -------
    let scheme_json = fs::read_to_string(Path::new("fixtures/base16-dracula.json"))?;

    // ---
    // Act
    // ---
    let scheme_entry: TestSchemeEntry = serde_json::from_str(&scheme_json)?;

    ensure!(
        scheme_entry.name == "Dracula",
        format!("Expected name to be Dracula, got {}", scheme_entry.name)
    );
    ensure!(
        scheme_entry.system == SchemeSystem::Base16,
        format!("Expected system to be base16, got {}", scheme_entry.system)
    );
    ensure!(
        scheme_entry.variant == SchemeVariant::Dark,
        format!(
            "Expected variant to be base16, got {}",
            scheme_entry.variant
        )
    );
    ensure!(
        scheme_entry.author == "clach04 (https://github.com/clach04)",
        format!(
            "Expected author to be 'clach04 (https://github.com/clach04)', got {}",
            scheme_entry.author
        )
    );
    ensure!(
        scheme_entry.slug == "dracula",
        format!("Expected slug to be dracula, got {}", scheme_entry.slug)
    );
    ensure!(
        scheme_entry.id == "base16-dracula",
        format!("Expected id to be base16-dracula, got {}", scheme_entry.id)
    );
    ensure!(
        scheme_entry.palette.len() == 16,
        format!(
            "Expected 16 colors in palette, got {}",
            scheme_entry.palette.len()
        )
    );

    let expected_colors = vec![
        (
            "base00",
            "#282a36",
            ("28".to_string(), "2a".to_string(), "36".to_string()),
            (0.156_862_75, 0.164_705_89, 0.211_764_71),
            (40, 42, 54),
        ),
        (
            "base01",
            "#21222c",
            ("21".to_string(), "22".to_string(), "2c".to_string()),
            (0.129_411_77, 0.133_333_34, 0.172_549_02),
            (33, 34, 44),
        ),
        (
            "base02",
            "#44475a",
            ("44".to_string(), "47".to_string(), "5a".to_string()),
            (0.266_666_68, 0.278_431_4, 0.352_941_2),
            (68, 71, 90),
        ),
        (
            "base03",
            "#6272a4",
            ("62".to_string(), "72".to_string(), "a4".to_string()),
            (0.384_313_73, 0.447_058_83, 0.643_137_3),
            (98, 114, 164),
        ),
        (
            "base04",
            "#9ea8c7",
            ("9e".to_string(), "a8".to_string(), "c7".to_string()),
            (0.619_607_87, 0.658_823_55, 0.780_392_17),
            (158, 168, 199),
        ),
        (
            "base05",
            "#f8f8f2",
            ("f8".to_string(), "f8".to_string(), "f2".to_string()),
            (0.972_549_, 0.972_549_, 0.949_019_6),
            (248, 248, 242),
        ),
        (
            "base06",
            "#f8f8f2",
            ("f8".to_string(), "f8".to_string(), "f2".to_string()),
            (0.972_549_, 0.972_549_, 0.949_019_6),
            (248, 248, 242),
        ),
        (
            "base07",
            "#ffffff",
            ("ff".to_string(), "ff".to_string(), "ff".to_string()),
            (1.0, 1.0, 1.0),
            (255, 255, 255),
        ),
        (
            "base08",
            "#ff5555",
            ("ff".to_string(), "55".to_string(), "55".to_string()),
            (1.0, 0.333_333_34, 0.333_333_34),
            (255, 85, 85),
        ),
        (
            "base09",
            "#ffb86c",
            ("ff".to_string(), "b8".to_string(), "6c".to_string()),
            (1.0, 0.721_568_64, 0.423_529_42),
            (255, 184, 108),
        ),
        (
            "base0A",
            "#f1fa8c",
            ("f1".to_string(), "fa".to_string(), "8c".to_string()),
            (0.945_098_04, 0.980_392_16, 0.549_019_63),
            (241, 250, 140),
        ),
        (
            "base0B",
            "#50fa7b",
            ("50".to_string(), "fa".to_string(), "7b".to_string()),
            (0.313_725_5, 0.980_392_16, 0.482_352_94),
            (80, 250, 123),
        ),
        (
            "base0C",
            "#8be9fd",
            ("8b".to_string(), "e9".to_string(), "fd".to_string()),
            (0.545_098_07, 0.913_725_5, 0.992_156_86),
            (139, 233, 253),
        ),
        (
            "base0D",
            "#bd93f9",
            ("bd".to_string(), "93".to_string(), "f9".to_string()),
            (0.741_176_5, 0.576_470_6, 0.976_470_6),
            (189, 147, 249),
        ),
        (
            "base0E",
            "#ff79c6",
            ("ff".to_string(), "79".to_string(), "c6".to_string()),
            (1.0, 0.474_509_8, 0.776_470_6),
            (255, 121, 198),
        ),
        (
            "base0F",
            "#993333",
            ("99".to_string(), "33".to_string(), "33".to_string()),
            (0.6, 0.2, 0.2),
            (153, 51, 51),
        ),
    ];

    for (color, expected_hex_str, expected_hex, expected_dec, expected_rgb) in expected_colors {
        let palette_color = scheme_entry
            .palette
            .get(color)
            .context(format!("color {color} not found"))?;
        ensure!(
            palette_color.hex_str == expected_hex_str,
            format!(
                "Expected {color}.hex_str to equal {expected_hex_str}, got {}",
                palette_color.hex_str
            )
        );
        ensure!(
            palette_color.hex == expected_hex,
            format!(
                "Expected {color}.hex to equal {expected_hex_str}, got {}",
                palette_color.hex_str
            )
        );
        ensure!(
            palette_color.dec == expected_dec,
            format!(
                "Expected {color}.dec to equal ({}, {}, {}), got ({}, {}, {})",
                expected_dec.0,
                expected_dec.1,
                expected_dec.2,
                palette_color.dec.0,
                palette_color.dec.1,
                palette_color.dec.2,
            )
        );
        ensure!(
            palette_color.rgb == expected_rgb,
            format!(
                "Expected {color}.rgb to equal ({}, {}, {}), got ({}, {}, {})",
                expected_rgb.0,
                expected_rgb.1,
                expected_rgb.2,
                palette_color.rgb.0,
                palette_color.rgb.1,
                palette_color.rgb.2,
            )
        );
    }

    let (foreground, background) = scheme_entry
        .lightness
        .map(|l| (l.foreground, l.background))
        .unwrap();

    ensure!(
        background == 17.336_05,
        format!("Expected lightness.background to be 17.336_05, got {background}")
    );
    ensure!(
        foreground == 97.431,
        format!("Expected lightness.foreground to be 97.431, got {background}")
    );

    Ok(())
}

#[test]
fn test_cli_list_subcommand_base16_omits_ui_and_syntax() -> Result<()> {
    let scheme_json = fs::read_to_string(Path::new("fixtures/base16-dracula.json"))?;
    let scheme_entry: TestSchemeEntry = serde_json::from_str(&scheme_json)?;

    ensure!(
        scheme_entry.ui.is_none(),
        "Expected Base16 entry to omit `ui`, got Some(_)"
    );
    ensure!(
        scheme_entry.syntax.is_none(),
        "Expected Base16 entry to omit `syntax`, got Some(_)"
    );

    Ok(())
}

#[test]
fn test_cli_list_subcommand_deserialize_tinted8_fixture() -> Result<()> {
    let scheme_json = fs::read_to_string(Path::new("fixtures/tinted8-catppuccin-mocha.json"))?;
    let scheme_entry: TestSchemeEntry = serde_json::from_str(&scheme_json)?;

    ensure!(
        scheme_entry.system == SchemeSystem::Tinted8,
        format!("Expected system tinted8, got {}", scheme_entry.system)
    );
    ensure!(
        scheme_entry.id == "tinted8-catppuccin-mocha",
        format!(
            "Expected id tinted8-catppuccin-mocha, got {}",
            scheme_entry.id
        )
    );
    ensure!(
        scheme_entry.palette.len() == 33,
        format!(
            "Expected 33 tinted8 palette entries (11 colors x 3 variants), got {}",
            scheme_entry.palette.len()
        )
    );

    let ui = scheme_entry
        .ui
        .as_ref()
        .context("Expected tinted8 entry to include `ui`")?;
    let bg_normal = ui
        .get("global.background.normal")
        .context("ui.global.background.normal missing")?;
    ensure!(
        bg_normal.hex_str == "#1e1e2e",
        format!(
            "Expected ui.global.background.normal #1e1e2e, got {}",
            bg_normal.hex_str
        )
    );
    // 0.14.0 spec change: `ui.accent` was renamed to `ui.accent.normal`.
    let accent_normal = ui
        .get("accent.normal")
        .context("ui.accent.normal missing")?;
    ensure!(
        !accent_normal.hex_str.is_empty(),
        "Expected ui.accent.normal to be populated"
    );

    let syntax = scheme_entry
        .syntax
        .as_ref()
        .context("Expected tinted8 entry to include `syntax`")?;
    ensure!(
        syntax.len() == tinted_builder::tinted8::SyntaxKey::variants().len(),
        format!(
            "Expected syntax len to match SyntaxKey::variants().len() ({}), got {}",
            tinted_builder::tinted8::SyntaxKey::variants().len(),
            syntax.len()
        )
    );
    // Authored override in the source YAML — should round-trip into the fixture.
    let keyword_operator = syntax
        .get("keyword.operator")
        .context("syntax.keyword.operator missing")?;
    ensure!(
        keyword_operator.hex_str == "#94e2d5",
        format!(
            "Expected syntax.keyword.operator #94e2d5 (authored override), got {}",
            keyword_operator.hex_str
        )
    );
    // Default-derived value (no override in YAML) — exercises the inheritance path.
    let comment = syntax.get("comment").context("syntax.comment missing")?;
    ensure!(
        !comment.hex_str.is_empty(),
        "Expected syntax.comment to be populated by default inheritance"
    );

    Ok(())
}

#[test]
fn test_cli_list_subcommand_as_json_with_setup() -> Result<()> {
    // -------
    // Arrange
    // -------
    let (_, _data_path, command_vec, _temp_dir) = setup(
        "test_cli_list_subcommand_as_json_with_setup",
        "list --json",
        true,
    )?;

    // ---
    // Act
    // ---
    let (stdout, _) = utils::run_command(&command_vec)?;
    let results: Vec<TestSchemeEntry> = serde_json::from_str(&stdout).unwrap();

    ensure!(
        results.len() >= SCHEME_COUNT,
        format!(
            "expected JSON to contain {SCHEME_COUNT} entries, found {}",
            results.len()
        )
    );

    let entry_map: BTreeMap<String, TestSchemeEntry> =
        results.into_iter().map(|e| (e.id.clone(), e)).collect();

    let dracula = entry_map.get("base16-dracula").unwrap().clone();
    let gruvbox = entry_map
        .get("base16-gruvbox-material-dark-hard")
        .unwrap()
        .clone();
    // let catppuccin = entry_map.get("tinted8-catppuccin-mocha").unwrap().clone();
    let dracula_json = fs::read_to_string(Path::new("fixtures/base16-dracula.json"))?;
    let gruvbox_json = fs::read_to_string(Path::new("fixtures/gruvbox-material-dark-hard.json"))?;
    // let catppuccin_json = fs::read_to_string(Path::new("fixtures/tinted8-catppuccin-mocha.json"))?;

    let expected_dracula: TestSchemeEntry = serde_json::from_str(&dracula_json).unwrap();
    let expected_gruvbox: TestSchemeEntry = serde_json::from_str(&gruvbox_json).unwrap();
    // let expected_catppuccin: TestSchemeEntry = serde_json::from_str(&catppuccin_json).unwrap();

    ensure!(
        expected_dracula.approx_eq(&dracula),
        format!(
            "Dracula does not match expected.\nExpected: {expected_dracula:?}\nActual: {dracula:?}"
        )
    );
    ensure!(
        expected_gruvbox.approx_eq(&gruvbox),
        format!(
            "Gruvbox does not match expected.\nExpected: {expected_gruvbox:?}\nActual: {gruvbox:?}"
        )
    );
    // ensure!(
    //     expected_catppuccin.approx_eq(&catppuccin),
    //     format!(
    //         "Catppuccin (tinted8) does not match expected.\nExpected: {expected_catppuccin:?}\nActual: {catppuccin:?}"
    //     )
    // );
    ensure!(
        dracula.ui.is_none() && dracula.syntax.is_none(),
        "Expected Base16 Dracula entry to omit ui/syntax in live JSON output"
    );

    Ok(())
}

#[test]
fn test_cli_list_subcommand_as_json_with_custom() -> Result<()> {
    // -------
    // Arrange
    // -------
    let (_, data_path, command_vec, _temp_dir) = setup(
        "test_cli_list_subcommand_as_json_with_custom",
        "list --json",
        true,
    )?;
    let scheme_system = "base16";
    let scheme_name_one = "tinted-theming";
    let scheme_name_two = "tinty";
    let custom_scheme_path = data_path.join("custom-schemes");

    fs::create_dir_all(custom_scheme_path.join(scheme_system))?;
    fs::write(
        custom_scheme_path.join(format!("{scheme_system}/{scheme_name_one}.yaml")),
        "",
    )?;

    fs::copy(
        Path::new("fixtures/tinty-city-dark.yaml"),
        custom_scheme_path.join(format!("{scheme_system}/{scheme_name_two}.yaml")),
    )
    .context("failed to copy scheme from fixtures")?;

    let mut command_vec = command_vec;
    command_vec.push("--custom-schemes".to_string());

    // ---
    // Act
    // ---
    let (stdout, _) = utils::run_command(&command_vec)?;

    let expected_json = fs::read_to_string(Path::new("fixtures/tinty-city-dark.json"))?;
    let expected_entry: TestSchemeEntry = serde_json::from_str(&expected_json).unwrap();

    // ------
    // Assert
    // ------
    let results: Vec<TestSchemeEntry> = serde_json::from_str(&stdout).unwrap();

    // There are two YAMLs in the directory of custom schemes but only one of them
    // contains a YAML body
    ensure!(
        results.len() == 1,
        format!("expected JSON to contain 1 entry, found {}", results.len()),
    );
    ensure!(
        expected_entry.approx_eq(&results[0]),
        format!("{:?}\ndoes not match:\n{:?}", expected_entry, results[0])
    );

    Ok(())
}

#[test]
fn test_cli_list_subcommand_with_malformed_custom_scheme() -> Result<()> {
    // -------
    // Arrange
    // -------
    let (_, data_path, command_vec, _temp_dir) = setup(
        "test_cli_list_subcommand_with_malformed_custom_scheme",
        "list --json",
        true,
    )?;
    let custom_scheme_path = data_path.join("custom-schemes/base16");

    fs::create_dir_all(&custom_scheme_path)?;
    fs::write(
        custom_scheme_path.join("malformed.yaml"),
        "this is not valid: yaml: [broken",
    )?;

    let mut command_vec = command_vec;
    command_vec.push("--custom-schemes".to_string());

    // ---
    // Act
    // ---
    let (stdout, _) = utils::run_command(&command_vec)?;

    // ------
    // Assert
    // ------
    // Malformed YAML should be skipped gracefully, similar to empty scheme files
    let results: Vec<TestSchemeEntry> = serde_json::from_str(&stdout).unwrap_or_default();
    ensure!(
        results.is_empty(),
        "Expected malformed scheme to be skipped, got {} entries",
        results.len()
    );

    Ok(())
}
