//! Types for kcl project and modeling-app settings.

pub mod project;

use anyhow::Result;
use kittycad_modeling_cmds::shared::Color;
use kittycad_modeling_cmds::units::UnitLength;
use parse_display::Display;
use parse_display::FromStr;
use schemars::JsonSchema;
use serde::Deserialize;
use serde::Deserializer;
use serde::Serialize;
use validator::Validate;

/// User specific settings for the app.
/// These live in `user.toml` in the app's configuration directory.
/// Updating the settings in the app will update this file automatically.
/// Do not edit this file manually, as it may be overwritten by the app.
/// Manual edits can cause corruption of the settings file.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct Configuration {
    /// The settings for the Design Studio.
    #[serde(default, skip_serializing_if = "is_default")]
    #[validate(nested)]
    pub settings: Settings,
}

impl Configuration {
    pub fn parse_and_validate(toml_str: &str) -> Result<Self> {
        let settings = toml::from_str::<Self>(toml_str)?;

        settings.validate()?;

        Ok(settings)
    }
}

/// High level settings.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct Settings {
    /// The settings for the Design Studio.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[validate(nested)]
    pub app: Option<AppSettings>,
    /// Settings that affect the behavior while modeling.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[validate(nested)]
    pub modeling: Option<ModelingSettings>,
    /// Other fields that weren't recognized by our schema.
    /// App-owned extension settings can live here without Rust understanding
    /// their inner structure.
    #[serde(flatten)]
    pub other: std::collections::HashMap<String, serde_json::Value>,
}

/// Application wide settings.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct AppSettings {
    /// The settings for the appearance of the app.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[validate(nested)]
    pub appearance: Option<AppearanceSettings>,
    /// When the user is idle, teardown the stream after some time.
    #[serde(
        default,
        deserialize_with = "deserialize_stream_idle_mode",
        alias = "streamIdleMode",
        skip_serializing_if = "Option::is_none"
    )]
    stream_idle_mode: Option<u32>,
    /// Other fields that weren't recognized by our schema.
    #[serde(flatten)]
    pub other: std::collections::HashMap<String, serde_json::Value>,
}

fn deserialize_stream_idle_mode<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
where
    D: Deserializer<'de>,
{
    #[derive(Deserialize)]
    #[serde(untagged)]
    enum StreamIdleModeValue {
        Number(u32),
        String(String),
        Boolean(bool),
    }

    const DEFAULT_TIMEOUT: u32 = 1000 * 60 * 5;

    Ok(match StreamIdleModeValue::deserialize(deserializer) {
        Ok(StreamIdleModeValue::Number(value)) => Some(value),
        Ok(StreamIdleModeValue::String(value)) => Some(value.parse::<u32>().unwrap_or(DEFAULT_TIMEOUT)),
        // The old type of this value. I'm willing to say no one used it but
        // we can never guarantee it.
        Ok(StreamIdleModeValue::Boolean(true)) => Some(DEFAULT_TIMEOUT),
        Ok(StreamIdleModeValue::Boolean(false)) => None,
        _ => None,
    })
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[ts(export)]
#[serde(untagged)]
pub enum FloatOrInt {
    String(String),
    Float(f64),
    Int(i64),
}

impl From<FloatOrInt> for f64 {
    fn from(float_or_int: FloatOrInt) -> Self {
        match float_or_int {
            FloatOrInt::String(s) => s.parse().unwrap(),
            FloatOrInt::Float(f) => f,
            FloatOrInt::Int(i) => i as f64,
        }
    }
}

/// The settings for the theme of the app.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
pub struct AppearanceSettings {
    /// The overall theme of the app.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub theme: Option<AppTheme>,
    /// Other fields that weren't recognized by our schema.
    #[serde(flatten)]
    pub other: std::collections::HashMap<String, serde_json::Value>,
}

/// The overall appearance of the app.
#[derive(
    Debug, Default, Copy, Clone, Deserialize, Serialize, JsonSchema, Display, FromStr, ts_rs::TS, PartialEq, Eq,
)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum AppTheme {
    /// A light theme.
    Light,
    /// A dark theme.
    Dark,
    /// Use the system theme.
    /// This will use dark theme if the system theme is dark, and light theme if the system theme is light.
    #[default]
    System,
}

impl From<AppTheme> for kittycad::types::Color {
    fn from(theme: AppTheme) -> Self {
        match theme {
            AppTheme::Light => kittycad::types::Color {
                r: 249.0 / 255.0,
                g: 249.0 / 255.0,
                b: 249.0 / 255.0,
                a: 1.0,
            },
            AppTheme::Dark => kittycad::types::Color {
                r: 28.0 / 255.0,
                g: 28.0 / 255.0,
                b: 28.0 / 255.0,
                a: 1.0,
            },
            AppTheme::System => {
                // TODO: Check the system setting for the user.
                todo!()
            }
        }
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[serde(transparent)]
pub struct LengthDefaultMm(pub UnitLength);

impl Default for LengthDefaultMm {
    fn default() -> Self {
        Self(default_length_unit_millimeters())
    }
}

impl From<LengthDefaultMm> for UnitLength {
    fn from(val: LengthDefaultMm) -> Self {
        val.0
    }
}

impl From<UnitLength> for LengthDefaultMm {
    fn from(unit: UnitLength) -> Self {
        Self(unit)
    }
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq)]
#[serde(transparent)]
pub struct BackfaceDefault(pub String);

impl Default for BackfaceDefault {
    fn default() -> Self {
        Self(default_backface_color())
    }
}

impl From<BackfaceDefault> for String {
    fn from(val: BackfaceDefault) -> Self {
        val.0
    }
}

impl BackfaceDefault {
    pub fn to_color(&self) -> Color {
        let color_str = &self.0;
        match csscolorparser::parse(color_str) {
            Ok(x) => Color::from_rgba(x.r, x.g, x.b, 1.0),
            // If the colour couldn't be parsed, just use the default blue.
            Err(_) => default_backface_color_struct(),
        }
    }
}

/// Settings that affect the behavior while modeling.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate, Default)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ModelingSettings {
    /// The default unit to use in modeling dimensions.
    /// If not given, defaults to millimeters.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub base_unit: Option<LengthDefaultMm>,
    /// The projection mode the camera should use while modeling.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub camera_projection: Option<CameraProjectionType>,
    /// The methodology the camera should use to orbit around the model.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub camera_orbit: Option<CameraOrbitType>,
    /// Highlight edges of 3D objects?
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub highlight_edges: Option<DefaultTrue>,
    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub enable_ssao: Option<DefaultTrue>,
    /// The default color to use for surface backfaces.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub backface_color: Option<BackfaceDefault>,
    /// Whether or not to show a scale grid in the 3D modeling view
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub show_scale_grid: Option<bool>,
    /// When enabled, the grid will use a fixed size based on your selected units rather than automatically scaling with zoom level.
    /// If true, the grid cells will be fixed-size, where the width is your default length unit.
    /// If false, the grid will get larger as you zoom out, and smaller as you zoom in.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub fixed_size_grid: Option<DefaultTrue>,
    /// Other fields that weren't recognized by our schema.
    #[serde(flatten)]
    pub other: std::collections::HashMap<String, serde_json::Value>,
}

fn default_length_unit_millimeters() -> UnitLength {
    UnitLength::Millimeters
}

// Also defined at src/lib/constants.ts#L333-L335
pub fn default_backface_color() -> String {
    // (0, 213, 255)
    "#00D5FF".to_string()
}
// Also defined at src/lib/constants.ts#L333-L335
pub fn default_backface_color_struct() -> Color {
    Color::from_rgba(0.0, 213.0 / 255.0, 1.0, 1.0)
}

#[derive(Debug, Copy, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq)]
#[ts(export)]
#[serde(transparent)]
pub struct DefaultTrue(pub bool);

impl Default for DefaultTrue {
    fn default() -> Self {
        Self(true)
    }
}

impl From<DefaultTrue> for bool {
    fn from(default_true: DefaultTrue) -> Self {
        default_true.0
    }
}

impl From<bool> for DefaultTrue {
    fn from(b: bool) -> Self {
        Self(b)
    }
}

/// The types of camera projection for the 3D view.
#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum CameraProjectionType {
    /// Perspective projection https://en.wikipedia.org/wiki/3D_projection#Perspective_projection
    Perspective,
    /// Orthographic projection https://en.wikipedia.org/wiki/3D_projection#Orthographic_projection
    #[default]
    Orthographic,
}

/// The types of camera orbit methods.
#[derive(Debug, Default, Eq, PartialEq, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, Display, FromStr)]
#[ts(export)]
#[serde(rename_all = "snake_case")]
#[display(style = "snake_case")]
pub enum CameraOrbitType {
    /// Orbit using a spherical camera movement.
    #[default]
    #[display("spherical")]
    Spherical,
    /// Orbit using a trackball camera movement.
    #[display("trackball")]
    Trackball,
}

fn is_default<T: Default + PartialEq>(t: &T) -> bool {
    t == &T::default()
}

#[cfg(test)]
mod tests {
    use pretty_assertions::assert_eq;
    use serde_json::json;

    use super::AppSettings;
    use super::AppTheme;
    use super::AppearanceSettings;
    use super::CameraProjectionType;
    use super::Configuration;
    use super::ModelingSettings;
    use super::Settings;
    use super::UnitLength;
    use super::default_backface_color;

    #[test]
    fn test_settings_empty_file_parses() {
        let empty_settings_file = r#""#;

        let parsed = toml::from_str::<Configuration>(empty_settings_file).unwrap();
        assert_eq!(parsed, Configuration::default());
        assert_eq!(
            parsed
                .clone()
                .settings
                .modeling
                .unwrap_or_default()
                .backface_color
                .unwrap_or_default()
                .0,
            default_backface_color()
        );

        // Write the file back out.
        let serialized = toml::to_string(&parsed).unwrap();
        assert_eq!(serialized, r#""#);

        let parsed = Configuration::parse_and_validate(empty_settings_file).unwrap();
        assert_eq!(parsed, Configuration::default());
        assert_eq!(
            parsed
                .settings
                .modeling
                .unwrap_or_default()
                .backface_color
                .unwrap_or_default()
                .0,
            default_backface_color()
        );
    }

    #[test]
    fn test_settings_parse_basic() {
        let settings_file = r#"[settings.app]
onboarding_status = "dismissed"
allow_orbit_in_sketch_mode = true
machine_api = true
foo = "bar"

[settings.app.appearance]
theme = "dark"

[settings.modeling]
base_unit = "in"
camera_projection = "perspective"
mouse_controls = "zoo"
gizmo_type = "axis"
enable_touch_controls = false
use_sketch_solve_mode = true
enable_ssao = false
snap_to_grid = true
major_grid_spacing = 2.5
minor_grids_per_major = 5
snaps_per_minor = 3

[settings.project]
directory = ""
default_project_name = "untitled"

[settings.command_bar]
include_settings = false

[settings.text_editor]
text_wrapping = true
"#;

        let expected = Configuration {
            settings: Settings {
                app: Some(AppSettings {
                    appearance: Some(AppearanceSettings {
                        theme: Some(AppTheme::Dark),
                        other: Default::default(),
                    }),
                    other: std::collections::HashMap::from([
                        ("allow_orbit_in_sketch_mode".to_owned(), true.into()),
                        ("foo".to_owned(), "bar".into()),
                        ("machine_api".to_owned(), true.into()),
                        ("onboarding_status".to_owned(), "dismissed".into()),
                    ]),
                    ..Default::default()
                }),
                modeling: Some(ModelingSettings {
                    enable_ssao: Some(false.into()),
                    base_unit: Some(From::from(UnitLength::Inches)),
                    camera_projection: Some(CameraProjectionType::Perspective),
                    fixed_size_grid: None,
                    other: std::collections::HashMap::from([
                        ("enable_touch_controls".to_owned(), false.into()),
                        ("gizmo_type".to_owned(), "axis".into()),
                        ("major_grid_spacing".to_owned(), json!(2.5)),
                        ("minor_grids_per_major".to_owned(), json!(5)),
                        ("mouse_controls".to_owned(), "zoo".into()),
                        ("snap_to_grid".to_owned(), true.into()),
                        ("snaps_per_minor".to_owned(), json!(3)),
                        ("use_sketch_solve_mode".to_owned(), true.into()),
                    ]),
                    ..Default::default()
                }),
                other: std::collections::HashMap::from([
                    (
                        "command_bar".to_owned(),
                        json!({
                            "include_settings": false,
                        }),
                    ),
                    (
                        "project".to_owned(),
                        json!({
                            "default_project_name": "untitled",
                            "directory": "",
                        }),
                    ),
                    (
                        "text_editor".to_owned(),
                        json!({
                            "text_wrapping": true,
                        }),
                    ),
                ]),
            },
        };
        let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
        assert_eq!(parsed, expected);

        let serialized = toml::to_string(&parsed).unwrap();
        assert!(serialized.contains("[settings.app]"));
        assert!(serialized.contains("onboarding_status = \"dismissed\""));
        assert!(serialized.contains("allow_orbit_in_sketch_mode = true"));
        assert!(serialized.contains("machine_api = true"));
        assert!(serialized.contains("foo = \"bar\""));
        assert!(serialized.contains("[settings.modeling]"));
        assert!(serialized.contains("mouse_controls = \"zoo\""));
        assert!(serialized.contains("gizmo_type = \"axis\""));
        assert!(serialized.contains("enable_touch_controls = false"));
        assert!(serialized.contains("use_sketch_solve_mode = true"));
        assert!(serialized.contains("snap_to_grid = true"));
        assert!(serialized.contains("major_grid_spacing = 2.5"));
        assert!(serialized.contains("minor_grids_per_major = 5"));
        assert!(serialized.contains("snaps_per_minor = 3"));
        assert!(serialized.contains("[settings.project]"));
        assert!(serialized.contains("directory = \"\""));
        assert!(serialized.contains("default_project_name = \"untitled\""));
        assert!(serialized.contains("[settings.command_bar]"));
        assert!(serialized.contains("include_settings = false"));
        assert!(serialized.contains("[settings.text_editor]"));
        assert!(serialized.contains("text_wrapping = true"));
        let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
        assert_eq!(reparsed, expected);

        let parsed = Configuration::parse_and_validate(settings_file).unwrap();
        assert_eq!(parsed, expected);
    }

    #[test]
    fn test_settings_backface_color_roundtrip() {
        let settings_file = r##"[settings.modeling]
backface_color = "#112233"
"##;

        let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
        assert_eq!(
            parsed
                .clone()
                .settings
                .modeling
                .unwrap_or_default()
                .backface_color
                .unwrap_or_default()
                .0,
            "#112233"
        );

        let serialized = toml::to_string(&parsed).unwrap();
        let reparsed = toml::from_str::<Configuration>(&serialized).unwrap();
        assert_eq!(reparsed, parsed);
        assert!(serialized.contains("backface_color = \"#112233\""));
    }
}
