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

pub mod project;

use anyhow::Result;
use kittycad_modeling_cmds::units::UnitLength;
use parse_display::{Display, FromStr};
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
use validator::Validate;

const DEFAULT_PROJECT_NAME_TEMPLATE: &str = "untitled";

/// 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 = "is_default")]
    #[validate(nested)]
    pub app: AppSettings,
    /// Settings that affect the behavior while modeling.
    #[serde(default, skip_serializing_if = "is_default")]
    #[validate(nested)]
    pub modeling: ModelingSettings,
    /// Settings that affect the behavior of the KCL text editor.
    #[serde(default, skip_serializing_if = "is_default")]
    #[validate(nested)]
    pub text_editor: TextEditorSettings,
    /// Settings that affect the behavior of project management.
    #[serde(default, skip_serializing_if = "is_default")]
    #[validate(nested)]
    pub project: ProjectSettings,
    /// Settings that affect the behavior of the command bar.
    #[serde(default, skip_serializing_if = "is_default")]
    #[validate(nested)]
    pub command_bar: CommandBarSettings,
}

/// 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 = "is_default")]
    #[validate(nested)]
    pub appearance: AppearanceSettings,
    /// The onboarding status of the app.
    #[serde(default, skip_serializing_if = "is_default")]
    pub onboarding_status: OnboardingStatus,
    /// When the user is idle, teardown the stream after some time.
    #[serde(
        default,
        deserialize_with = "deserialize_stream_idle_mode",
        alias = "streamIdleMode",
        skip_serializing_if = "is_default"
    )]
    stream_idle_mode: Option<u32>,
    /// Allow orbiting in sketch mode.
    #[serde(default, skip_serializing_if = "is_default")]
    pub allow_orbit_in_sketch_mode: bool,
    /// Whether to show the debug panel, which lets you see various states
    /// of the app to aid in development.
    #[serde(default, skip_serializing_if = "is_default")]
    pub show_debug_panel: bool,
}

/// Default to true.
fn make_it_so() -> bool {
    true
}

fn is_true(b: &bool) -> bool {
    *b
}

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 = "is_default")]
    pub theme: AppTheme,
}

/// 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!()
            }
        }
    }
}

/// Settings that affect the behavior while modeling.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ModelingSettings {
    /// The default unit to use in modeling dimensions.
    #[serde(default = "default_length_unit_millimeters", skip_serializing_if = "is_default")]
    pub base_unit: UnitLength,
    /// The projection mode the camera should use while modeling.
    #[serde(default, skip_serializing_if = "is_default")]
    pub camera_projection: CameraProjectionType,
    /// The methodology the camera should use to orbit around the model.
    #[serde(default, skip_serializing_if = "is_default")]
    pub camera_orbit: CameraOrbitType,
    /// The controls for how to navigate the 3D view.
    #[serde(default, skip_serializing_if = "is_default")]
    pub mouse_controls: MouseControlType,
    /// Which type of orientation gizmo to use.
    #[serde(default, skip_serializing_if = "is_default")]
    pub gizmo_type: GizmoType,
    /// Toggle touch controls for 3D view navigation
    #[serde(default, skip_serializing_if = "is_default")]
    pub enable_touch_controls: DefaultTrue,
    /// Toggle new sketch mode implementation
    #[serde(default, skip_serializing_if = "is_default")]
    pub use_new_sketch_mode: bool,
    /// Highlight edges of 3D objects?
    #[serde(default, skip_serializing_if = "is_default")]
    pub highlight_edges: DefaultTrue,
    /// Whether or not Screen Space Ambient Occlusion (SSAO) is enabled.
    #[serde(default, skip_serializing_if = "is_default")]
    pub enable_ssao: DefaultTrue,
    /// Whether or not to show a scale grid in the 3D modeling view
    #[serde(default, skip_serializing_if = "is_default")]
    pub show_scale_grid: 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 = "make_it_so", skip_serializing_if = "is_true")]
    pub fixed_size_grid: bool,
    /// When enabled, tools like line, rectangle, etc. will snap to the grid.
    #[serde(default, skip_serializing_if = "is_default")]
    pub snap_to_grid: bool,
    /// The space between major grid lines, specified in the current unit.
    #[serde(default, skip_serializing_if = "is_default")]
    pub major_grid_spacing: f64,
    /// The number of minor grid lines per major grid line.
    #[serde(default, skip_serializing_if = "is_default")]
    pub minor_grids_per_major: f64,
    /// The number of snaps between minor grid lines. 1 means snapping to each minor grid line.
    #[serde(default, skip_serializing_if = "is_default")]
    pub snaps_per_minor: f64,
}

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

impl Default for ModelingSettings {
    fn default() -> Self {
        Self {
            base_unit: UnitLength::Millimeters,
            camera_projection: Default::default(),
            camera_orbit: Default::default(),
            mouse_controls: Default::default(),
            gizmo_type: Default::default(),
            enable_touch_controls: Default::default(),
            use_new_sketch_mode: Default::default(),
            highlight_edges: Default::default(),
            enable_ssao: Default::default(),
            show_scale_grid: Default::default(),
            fixed_size_grid: true,
            snap_to_grid: Default::default(),
            major_grid_spacing: Default::default(),
            minor_grids_per_major: Default::default(),
            snaps_per_minor: Default::default(),
        }
    }
}

#[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 controls for how to navigate 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 MouseControlType {
    #[default]
    #[display("zoo")]
    #[serde(rename = "zoo")]
    Zoo,
    #[display("onshape")]
    #[serde(rename = "onshape")]
    OnShape,
    TrackpadFriendly,
    Solidworks,
    Nx,
    Creo,
    #[display("autocad")]
    #[serde(rename = "autocad")]
    AutoCad,
}

/// 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,
}

/// Which type of orientation gizmo to use.
#[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 GizmoType {
    /// 3D cube gizmo
    #[default]
    Cube,
    /// 3-axis gizmo
    Axis,
}

/// Settings that affect the behavior of the KCL text editor.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct TextEditorSettings {
    /// Whether to wrap text in the editor or overflow with scroll.
    #[serde(default, skip_serializing_if = "is_default")]
    pub text_wrapping: DefaultTrue,
    /// Whether to make the cursor blink in the editor.
    #[serde(default, skip_serializing_if = "is_default")]
    pub blinking_cursor: DefaultTrue,
}

/// Same as TextEditorSettings but applies to a per-project basis.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ProjectTextEditorSettings {
    /// Whether to wrap text in the editor or overflow with scroll.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub text_wrapping: Option<bool>,
    /// Whether to make the cursor blink in the editor.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub blinking_cursor: Option<bool>,
}

/// Settings that affect the behavior of project management.
#[derive(Debug, Clone, Default, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ProjectSettings {
    /// The directory to save and load projects from.
    #[serde(default, skip_serializing_if = "is_default")]
    pub directory: std::path::PathBuf,
    /// The default project name to use when creating a new project.
    #[serde(default, skip_serializing_if = "is_default")]
    pub default_project_name: ProjectNameTemplate,
}

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

impl Default for ProjectNameTemplate {
    fn default() -> Self {
        Self(DEFAULT_PROJECT_NAME_TEMPLATE.to_string())
    }
}

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

impl From<String> for ProjectNameTemplate {
    fn from(s: String) -> Self {
        Self(s)
    }
}

/// Settings that affect the behavior of the command bar.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct CommandBarSettings {
    /// Whether to include settings in the command bar.
    #[serde(default, skip_serializing_if = "is_default")]
    pub include_settings: DefaultTrue,
}

/// Same as CommandBarSettings but applies to a per-project basis.
#[derive(Debug, Default, Clone, Deserialize, Serialize, JsonSchema, ts_rs::TS, PartialEq, Eq, Validate)]
#[serde(rename_all = "snake_case")]
#[ts(export)]
pub struct ProjectCommandBarSettings {
    /// Whether to include settings in the command bar.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub include_settings: Option<bool>,
}

/// The types of onboarding status.
#[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 OnboardingStatus {
    /// The unset state.
    #[serde(rename = "")]
    #[display("")]
    Unset,
    /// The user has completed onboarding.
    Completed,
    /// The user has not completed onboarding.
    #[default]
    Incomplete,
    /// The user has dismissed onboarding.
    Dismissed,

    // Desktop Routes
    #[serde(rename = "/desktop")]
    #[display("/desktop")]
    DesktopWelcome,
    #[serde(rename = "/desktop/scene")]
    #[display("/desktop/scene")]
    DesktopScene,
    #[serde(rename = "/desktop/toolbar")]
    #[display("/desktop/toolbar")]
    DesktopToolbar,
    #[serde(rename = "/desktop/text-to-cad")]
    #[display("/desktop/text-to-cad")]
    DesktopTextToCadWelcome,
    #[serde(rename = "/desktop/text-to-cad-prompt")]
    #[display("/desktop/text-to-cad-prompt")]
    DesktopTextToCadPrompt,
    #[serde(rename = "/desktop/feature-tree-pane")]
    #[display("/desktop/feature-tree-pane")]
    DesktopFeatureTreePane,
    #[serde(rename = "/desktop/code-pane")]
    #[display("/desktop/code-pane")]
    DesktopCodePane,
    #[serde(rename = "/desktop/project-pane")]
    #[display("/desktop/project-pane")]
    DesktopProjectFilesPane,
    #[serde(rename = "/desktop/other-panes")]
    #[display("/desktop/other-panes")]
    DesktopOtherPanes,
    #[serde(rename = "/desktop/prompt-to-edit")]
    #[display("/desktop/prompt-to-edit")]
    DesktopPromptToEditWelcome,
    #[serde(rename = "/desktop/prompt-to-edit-prompt")]
    #[display("/desktop/prompt-to-edit-prompt")]
    DesktopPromptToEditPrompt,
    #[serde(rename = "/desktop/prompt-to-edit-result")]
    #[display("/desktop/prompt-to-edit-result")]
    DesktopPromptToEditResult,
    #[serde(rename = "/desktop/imports")]
    #[display("/desktop/imports")]
    DesktopImports,
    #[serde(rename = "/desktop/exports")]
    #[display("/desktop/exports")]
    DesktopExports,
    #[serde(rename = "/desktop/conclusion")]
    #[display("/desktop/conclusion")]
    DesktopConclusion,

    // Browser Routes
    #[serde(rename = "/browser")]
    #[display("/browser")]
    BrowserWelcome,
    #[serde(rename = "/browser/scene")]
    #[display("/browser/scene")]
    BrowserScene,
    #[serde(rename = "/browser/toolbar")]
    #[display("/browser/toolbar")]
    BrowserToolbar,
    #[serde(rename = "/browser/text-to-cad")]
    #[display("/browser/text-to-cad")]
    BrowserTextToCadWelcome,
    #[serde(rename = "/browser/text-to-cad-prompt")]
    #[display("/browser/text-to-cad-prompt")]
    BrowserTextToCadPrompt,
    #[serde(rename = "/browser/feature-tree-pane")]
    #[display("/browser/feature-tree-pane")]
    BrowserFeatureTreePane,
    #[serde(rename = "/browser/prompt-to-edit")]
    #[display("/browser/prompt-to-edit")]
    BrowserPromptToEditWelcome,
    #[serde(rename = "/browser/prompt-to-edit-prompt")]
    #[display("/browser/prompt-to-edit-prompt")]
    BrowserPromptToEditPrompt,
    #[serde(rename = "/browser/prompt-to-edit-result")]
    #[display("/browser/prompt-to-edit-result")]
    BrowserPromptToEditResult,
    #[serde(rename = "/browser/conclusion")]
    #[display("/browser/conclusion")]
    BrowserConclusion,
}

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

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

    use super::{
        AppSettings, AppTheme, AppearanceSettings, CameraProjectionType, CommandBarSettings, Configuration,
        ModelingSettings, MouseControlType, OnboardingStatus, ProjectNameTemplate, ProjectSettings, Settings,
        TextEditorSettings, UnitLength,
    };

    #[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());

        // 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());
    }

    #[test]
    fn test_settings_parse_basic() {
        let settings_file = r#"[settings.app]
default_project_name = "untitled"
directory = ""
onboarding_status = "dismissed"

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

[settings.modeling]
enable_ssao = false
base_unit = "in"
mouse_controls = "zoo"
camera_projection = "perspective"

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

[settings.text_editor]
text_wrapping = true"#;

        let expected = Configuration {
            settings: Settings {
                app: AppSettings {
                    onboarding_status: OnboardingStatus::Dismissed,
                    appearance: AppearanceSettings { theme: AppTheme::Dark },
                    ..Default::default()
                },
                modeling: ModelingSettings {
                    enable_ssao: false.into(),
                    base_unit: UnitLength::Inches,
                    mouse_controls: MouseControlType::Zoo,
                    camera_projection: CameraProjectionType::Perspective,
                    fixed_size_grid: true,
                    ..Default::default()
                },
                project: ProjectSettings {
                    default_project_name: ProjectNameTemplate("untitled".to_string()),
                    directory: "".into(),
                },
                text_editor: TextEditorSettings {
                    text_wrapping: true.into(),
                    ..Default::default()
                },
                command_bar: CommandBarSettings {
                    include_settings: true.into(),
                },
            },
        };
        let parsed = toml::from_str::<Configuration>(settings_file).unwrap();
        assert_eq!(parsed, expected);

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

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

[settings.modeling]
base_unit = "in"
camera_projection = "perspective"
enable_ssao = false
"#
        );

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