use anyhow::bail;
use collections::HashMap;
use serde::Deserialize;
use util::ResultExt;

use crate::{EnvVariableReplacer, TaskTemplate, TaskTemplates, VariableName};

#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
struct TaskOptions {
    cwd: Option<String>,
    #[serde(default)]
    env: HashMap<String, String>,
}

#[derive(Clone, Debug, PartialEq)]
struct VsCodeTaskDefinition {
    label: String,
    command: Option<Command>,
    other_attributes: HashMap<String, serde_json_lenient::Value>,
    options: Option<TaskOptions>,
}

impl<'de> serde::Deserialize<'de> for VsCodeTaskDefinition {
    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        #[derive(Deserialize)]
        #[serde(rename_all = "camelCase")]
        struct TaskHelper {
            #[serde(default)]
            label: Option<String>,
            #[serde(flatten)]
            command: Option<Command>,
            #[serde(flatten)]
            other_attributes: HashMap<String, serde_json_lenient::Value>,
            options: Option<TaskOptions>,
        }

        let helper = TaskHelper::deserialize(deserializer)?;

        let label = helper
            .label
            .unwrap_or_else(|| generate_label(&helper.command));

        Ok(VsCodeTaskDefinition {
            label,
            command: helper.command,
            other_attributes: helper.other_attributes,
            options: helper.options,
        })
    }
}

#[derive(Clone, Deserialize, PartialEq, Debug)]
#[serde(tag = "type")]
#[serde(rename_all = "camelCase")]
enum Command {
    Npm {
        script: String,
    },
    Shell {
        command: String,
        #[serde(default)]
        args: Vec<String>,
    },
    Gulp {
        task: String,
    },
}

fn generate_label(command: &Option<Command>) -> String {
    match command {
        Some(Command::Npm { script }) => format!("npm: {}", script),
        Some(Command::Gulp { task }) => format!("gulp: {}", task),
        Some(Command::Shell { command, .. }) => {
            if command.trim().is_empty() {
                "shell".to_string()
            } else {
                command.clone()
            }
        }
        None => "Untitled Task".to_string(),
    }
}

impl VsCodeTaskDefinition {
    fn into_zed_format(
        self,
        replacer: &EnvVariableReplacer,
    ) -> anyhow::Result<Option<TaskTemplate>> {
        if self.other_attributes.contains_key("dependsOn") {
            log::warn!(
                "Skipping deserializing of a task `{}` with the unsupported `dependsOn` key",
                self.label
            );
            return Ok(None);
        }
        // `type` might not be set in e.g. tasks that use `dependsOn`; we still want to deserialize the whole object though (hence command is an Option),
        // as that way we can provide more specific description of why deserialization failed.
        // E.g. if the command is missing due to `dependsOn` presence, we can check other_attributes first before doing this (and provide nice error message)
        // catch-all if on value.command presence.
        let Some(command) = self.command else {
            bail!("Missing `type` field in task");
        };

        let (command, args) = match command {
            Command::Npm { script } => ("npm".to_owned(), vec!["run".to_string(), script]),
            Command::Shell { command, args } => (command, args),
            Command::Gulp { task } => ("gulp".to_owned(), vec![task]),
        };
        // Per VSC docs, only `command`, `args` and `options` support variable substitution.
        let command = replacer.replace(&command);
        let args = args.into_iter().map(|arg| replacer.replace(&arg)).collect();
        let mut template = TaskTemplate {
            label: self.label,
            command,
            args,
            ..TaskTemplate::default()
        };
        if let Some(options) = self.options {
            template.cwd = options.cwd.map(|cwd| replacer.replace(&cwd));
            template.env = options.env;
        }
        Ok(Some(template))
    }
}

/// [`VsCodeTaskFile`] is a superset of Code's task definition format.
#[derive(Debug, Deserialize, PartialEq)]
pub struct VsCodeTaskFile {
    tasks: Vec<VsCodeTaskDefinition>,
}

impl TryFrom<VsCodeTaskFile> for TaskTemplates {
    type Error = anyhow::Error;

    fn try_from(value: VsCodeTaskFile) -> Result<Self, Self::Error> {
        let replacer = EnvVariableReplacer::new(HashMap::from_iter([
            (
                "workspaceFolder".to_owned(),
                VariableName::WorktreeRoot.to_string(),
            ),
            ("file".to_owned(), VariableName::File.to_string()),
            ("lineNumber".to_owned(), VariableName::Row.to_string()),
            (
                "selectedText".to_owned(),
                VariableName::SelectedText.to_string(),
            ),
        ]));
        let templates = value
            .tasks
            .into_iter()
            .filter_map(|vscode_definition| {
                vscode_definition
                    .into_zed_format(&replacer)
                    .log_err()
                    .flatten()
            })
            .collect();
        Ok(Self(templates))
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use crate::{
        TaskTemplate, TaskTemplates, VsCodeTaskFile,
        vscode_format::{Command, VsCodeTaskDefinition},
    };

    use super::{EnvVariableReplacer, generate_label};

    fn compare_without_other_attributes(lhs: VsCodeTaskDefinition, rhs: VsCodeTaskDefinition) {
        assert_eq!(
            VsCodeTaskDefinition {
                other_attributes: Default::default(),
                ..lhs
            },
            VsCodeTaskDefinition {
                other_attributes: Default::default(),
                ..rhs
            },
        );
    }

    #[test]
    fn test_variable_substitution() {
        let replacer = EnvVariableReplacer::new(Default::default());
        assert_eq!(replacer.replace("Food"), "Food");
        // Unknown variables are left in tact.
        assert_eq!(
            replacer.replace("$PATH is an environment variable"),
            "$PATH is an environment variable"
        );
        assert_eq!(replacer.replace("${PATH}"), "${PATH}");
        assert_eq!(replacer.replace("${PATH:food}"), "${PATH:food}");
        // And now, the actual replacing
        let replacer = EnvVariableReplacer::new(HashMap::from_iter([(
            "PATH".to_owned(),
            "ZED_PATH".to_owned(),
        )]));
        assert_eq!(replacer.replace("Food"), "Food");
        assert_eq!(
            replacer.replace("$PATH is an environment variable"),
            "${ZED_PATH} is an environment variable"
        );
        assert_eq!(replacer.replace("${PATH}"), "${ZED_PATH}");
        assert_eq!(replacer.replace("${PATH:food}"), "${ZED_PATH:food}");
    }

    #[test]
    fn can_deserialize_ts_tasks() {
        const TYPESCRIPT_TASKS: &str = include_str!("../test_data/typescript.json");
        let vscode_definitions: VsCodeTaskFile =
            serde_json_lenient::from_str(TYPESCRIPT_TASKS).unwrap();

        let expected = vec![
            VsCodeTaskDefinition {
                label: "gulp: tests".to_string(),
                command: Some(Command::Npm {
                    script: "build:tests:notypecheck".to_string(),
                }),
                other_attributes: Default::default(),
                options: None,
            },
            VsCodeTaskDefinition {
                label: "tsc: watch ./src".to_string(),
                command: Some(Command::Shell {
                    command: "node".to_string(),
                    args: vec![
                        "${workspaceFolder}/node_modules/typescript/lib/tsc.js".to_string(),
                        "--build".to_string(),
                        "${workspaceFolder}/src".to_string(),
                        "--watch".to_string(),
                    ],
                }),
                other_attributes: Default::default(),
                options: None,
            },
            VsCodeTaskDefinition {
                label: "npm: build:compiler".to_string(),
                command: Some(Command::Npm {
                    script: "build:compiler".to_string(),
                }),
                other_attributes: Default::default(),
                options: None,
            },
            VsCodeTaskDefinition {
                label: "npm: build:tests".to_string(),
                command: Some(Command::Npm {
                    script: "build:tests:notypecheck".to_string(),
                }),
                other_attributes: Default::default(),
                options: None,
            },
        ];

        assert_eq!(vscode_definitions.tasks.len(), expected.len());
        vscode_definitions
            .tasks
            .iter()
            .zip(expected)
            .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));

        let expected = vec![
            TaskTemplate {
                label: "gulp: tests".to_string(),
                command: "npm".to_string(),
                args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
                ..Default::default()
            },
            TaskTemplate {
                label: "tsc: watch ./src".to_string(),
                command: "node".to_string(),
                args: vec![
                    "${ZED_WORKTREE_ROOT}/node_modules/typescript/lib/tsc.js".to_string(),
                    "--build".to_string(),
                    "${ZED_WORKTREE_ROOT}/src".to_string(),
                    "--watch".to_string(),
                ],
                ..Default::default()
            },
            TaskTemplate {
                label: "npm: build:compiler".to_string(),
                command: "npm".to_string(),
                args: vec!["run".to_string(), "build:compiler".to_string()],
                ..Default::default()
            },
            TaskTemplate {
                label: "npm: build:tests".to_string(),
                command: "npm".to_string(),
                args: vec!["run".to_string(), "build:tests:notypecheck".to_string()],
                ..Default::default()
            },
        ];

        let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
        assert_eq!(tasks.0, expected);
    }

    #[test]
    fn can_deserialize_rust_analyzer_tasks() {
        const RUST_ANALYZER_TASKS: &str = include_str!("../test_data/rust-analyzer.json");
        let vscode_definitions: VsCodeTaskFile =
            serde_json_lenient::from_str(RUST_ANALYZER_TASKS).unwrap();
        let expected = vec![
            VsCodeTaskDefinition {
                label: "Build Extension in Background".to_string(),
                command: Some(Command::Npm {
                    script: "watch".to_string(),
                }),
                options: None,
                other_attributes: Default::default(),
            },
            VsCodeTaskDefinition {
                label: "Build Extension".to_string(),
                command: Some(Command::Npm {
                    script: "build".to_string(),
                }),
                options: None,
                other_attributes: Default::default(),
            },
            VsCodeTaskDefinition {
                label: "Build Server".to_string(),
                command: Some(Command::Shell {
                    command: "cargo build --package rust-analyzer".to_string(),
                    args: Default::default(),
                }),
                options: None,
                other_attributes: Default::default(),
            },
            VsCodeTaskDefinition {
                label: "Build Server (Release)".to_string(),
                command: Some(Command::Shell {
                    command: "cargo build --release --package rust-analyzer".to_string(),
                    args: Default::default(),
                }),
                options: None,
                other_attributes: Default::default(),
            },
            VsCodeTaskDefinition {
                label: "Pretest".to_string(),
                command: Some(Command::Npm {
                    script: "pretest".to_string(),
                }),
                options: None,
                other_attributes: Default::default(),
            },
            VsCodeTaskDefinition {
                label: "Build Server and Extension".to_string(),
                command: None,
                options: None,
                other_attributes: Default::default(),
            },
            VsCodeTaskDefinition {
                label: "Build Server (Release) and Extension".to_string(),
                command: None,
                options: None,
                other_attributes: Default::default(),
            },
        ];
        assert_eq!(vscode_definitions.tasks.len(), expected.len());
        vscode_definitions
            .tasks
            .iter()
            .zip(expected)
            .for_each(|(lhs, rhs)| compare_without_other_attributes(lhs.clone(), rhs));
        let expected = vec![
            TaskTemplate {
                label: "Build Extension in Background".to_string(),
                command: "npm".to_string(),
                args: vec!["run".to_string(), "watch".to_string()],
                ..Default::default()
            },
            TaskTemplate {
                label: "Build Extension".to_string(),
                command: "npm".to_string(),
                args: vec!["run".to_string(), "build".to_string()],
                ..Default::default()
            },
            TaskTemplate {
                label: "Build Server".to_string(),
                command: "cargo build --package rust-analyzer".to_string(),
                ..Default::default()
            },
            TaskTemplate {
                label: "Build Server (Release)".to_string(),
                command: "cargo build --release --package rust-analyzer".to_string(),
                ..Default::default()
            },
            TaskTemplate {
                label: "Pretest".to_string(),
                command: "npm".to_string(),
                args: vec!["run".to_string(), "pretest".to_string()],
                ..Default::default()
            },
        ];
        let tasks: TaskTemplates = vscode_definitions.try_into().unwrap();
        assert_eq!(tasks.0, expected);
    }

    #[test]
    fn can_deserialize_tasks_without_labels() {
        const TASKS_WITHOUT_LABELS: &str = include_str!("../test_data/tasks-without-labels.json");
        let vscode_definitions: VsCodeTaskFile =
            serde_json_lenient::from_str(TASKS_WITHOUT_LABELS).unwrap();

        assert_eq!(vscode_definitions.tasks.len(), 4);
        assert_eq!(vscode_definitions.tasks[0].label, "npm: start");
        assert_eq!(vscode_definitions.tasks[1].label, "Explicit Label");
        assert_eq!(vscode_definitions.tasks[2].label, "gulp: build");
        assert_eq!(vscode_definitions.tasks[3].label, "echo hello");
    }

    #[test]
    fn test_generate_label() {
        assert_eq!(
            generate_label(&Some(Command::Npm {
                script: "start".to_string()
            })),
            "npm: start"
        );
        assert_eq!(
            generate_label(&Some(Command::Gulp {
                task: "build".to_string()
            })),
            "gulp: build"
        );
        assert_eq!(
            generate_label(&Some(Command::Shell {
                command: "echo hello".to_string(),
                args: vec![]
            })),
            "echo hello"
        );
        assert_eq!(
            generate_label(&Some(Command::Shell {
                command: "cargo build --release".to_string(),
                args: vec![]
            })),
            "cargo build --release"
        );
        assert_eq!(
            generate_label(&Some(Command::Shell {
                command: "  ".to_string(),
                args: vec![]
            })),
            "shell"
        );
        assert_eq!(
            generate_label(&Some(Command::Shell {
                command: "".to_string(),
                args: vec![]
            })),
            "shell"
        );
        assert_eq!(generate_label(&None), "Untitled Task");
    }
}
