use std::collections::{BTreeMap, HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;

use crate::config::Config;
use crate::duration;
use crate::file;
use crate::task::task_fetcher::TaskFetcher;
use crate::task::{
    Deps, GetMatchingExt, Task, TaskCycleError, TaskKey, build_task_ref_map, resolve_task_pattern,
};
use crate::tera::contains_template_syntax;
use crate::ui::style;
use console::style as console_style;
use eyre::{Result, eyre};
use indexmap::IndexMap;
use itertools::Itertools;
use serde::Serialize;

/// Validate tasks for common errors and issues
#[derive(Debug, clap::Args)]
#[clap(verbatim_doc_comment, after_long_help = AFTER_LONG_HELP)]
pub struct TasksValidate {
    /// Tasks to validate
    /// If not specified, validates all tasks
    #[clap(verbatim_doc_comment)]
    pub tasks: Option<Vec<String>>,

    /// Only show errors (skip warnings)
    #[clap(long, verbatim_doc_comment)]
    pub errors_only: bool,

    /// Output validation results in JSON format
    #[clap(long, verbatim_doc_comment)]
    pub json: bool,
}

#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum Severity {
    Error,
    Warning,
}

#[derive(Debug, Clone, Serialize)]
struct ValidationIssue {
    task: String,
    severity: Severity,
    category: String,
    message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    details: Option<String>,
}

#[derive(Debug, Serialize)]
struct ValidationResults {
    tasks_validated: usize,
    errors: usize,
    warnings: usize,
    issues: Vec<ValidationIssue>,
}

impl TasksValidate {
    pub async fn run(self) -> Result<()> {
        let config = Config::get().await?;

        // Resolve all remote task files before validation
        // so we can properly validate remote tasks and circular dependencies
        let mut resolved_tasks: Vec<Task> = config.tasks().await?.values().cloned().collect();
        // always no_cache=false as the command doesn't take no-cache argument
        // MISE_TASK_REMOTE_NO_CACHE env var is still respected if set
        TaskFetcher::new(false)
            .fetch_tasks(&config, &mut resolved_tasks)
            .await?;
        let all_tasks: BTreeMap<String, Task> = resolved_tasks
            .into_iter()
            .map(|t| (t.name.clone(), t))
            .collect();

        // Filter tasks to validate
        let tasks = if let Some(ref task_names) = self.tasks {
            self.get_specific_tasks(&all_tasks, task_names).await?
        } else {
            self.get_all_tasks(&all_tasks)
        };

        // Run validation
        let mut issues = Vec::new();
        if let Err(err) = Deps::new_for_validation(&config, tasks.clone()).await {
            if let Some(cycle) = err.downcast_ref::<TaskCycleError>() {
                issues.extend(
                    self.find_additional_graph_issues(&config, &tasks, Some(cycle))
                        .await,
                );
            } else {
                let details = err.to_string();
                let additional_issues = self
                    .find_additional_graph_issues(&config, &tasks, None)
                    .await;
                let graph_error_is_represented = additional_issues.iter().any(|issue| {
                    issue.category == "dependency-graph-error"
                        && issue.details.as_deref() == Some(details.as_str())
                });
                if !graph_error_is_represented {
                    issues.push(graph_error_issue("task graph", details));
                }
                issues.extend(additional_issues);
            }
        }
        for task in &tasks {
            issues.extend(self.validate_task(task, &all_tasks, &config).await);
        }

        // Filter by severity if needed
        if self.errors_only {
            issues.retain(|i| i.severity == Severity::Error);
        }

        let results = ValidationResults {
            tasks_validated: tasks.len(),
            errors: issues
                .iter()
                .filter(|i| i.severity == Severity::Error)
                .count(),
            warnings: issues
                .iter()
                .filter(|i| i.severity == Severity::Warning)
                .count(),
            issues,
        };

        // Output results
        if self.json {
            self.output_json(&results)?;
        } else {
            self.output_human(&results)?;
        }

        // Exit with error if there are errors
        if results.errors > 0 {
            return Err(eyre!("Validation failed with {} error(s)", results.errors));
        }

        Ok(())
    }

    async fn find_additional_graph_issues(
        &self,
        config: &Arc<Config>,
        tasks: &[Task],
        initial_cycle: Option<&TaskCycleError>,
    ) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();
        let mut valid_tasks = Vec::new();
        let mut reported_cycles: HashSet<Vec<TaskKey>> = HashSet::new();
        let mut reported_error_details = HashSet::new();
        if let Some(cycle) = initial_cycle {
            push_cycle_issue(&mut issues, &mut reported_cycles, cycle);
        }
        for task in tasks {
            match Deps::new_for_validation(config, vec![task.clone()]).await {
                Ok(_) => valid_tasks.push(task.clone()),
                Err(err) => {
                    if let Some(cycle) = err.downcast_ref::<TaskCycleError>() {
                        push_cycle_issue(&mut issues, &mut reported_cycles, cycle);
                    } else {
                        let details = err.to_string();
                        reported_error_details.insert(details.clone());
                        issues.push(graph_error_issue(&task.name, details));
                    }
                }
            }
        }

        // A wait_for relationship only applies when both tasks are selected, so
        // retry the tasks whose individual graphs built successfully together.
        if valid_tasks.len() > 1
            && let Some(err) = Deps::new_for_validation(config, valid_tasks).await.err()
        {
            if let Some(cycle) = err.downcast_ref::<TaskCycleError>() {
                push_cycle_issue(&mut issues, &mut reported_cycles, cycle);
            } else {
                let details = err.to_string();
                if reported_error_details.insert(details.clone()) {
                    issues.push(graph_error_issue("task graph", details));
                }
            }
        }
        issues
    }

    fn get_all_tasks(&self, all_tasks: &BTreeMap<String, Task>) -> Vec<Task> {
        all_tasks.values().cloned().collect()
    }

    async fn get_specific_tasks(
        &self,
        all_tasks: &BTreeMap<String, Task>,
        task_names: &[String],
    ) -> Result<Vec<Task>> {
        let mut tasks = Vec::new();
        for name in task_names {
            // Check if task exists by name, display_name, or alias
            match all_tasks
                .get(name)
                .or_else(|| all_tasks.values().find(|t| &t.display_name == name))
                .or_else(|| {
                    all_tasks
                        .values()
                        .find(|t| t.aliases.contains(&name.to_string()))
                })
                .cloned()
            {
                Some(task) => tasks.push(task),
                None => {
                    return Err(eyre!(
                        "Task '{}' not found. Available tasks: {}",
                        name,
                        all_tasks.keys().map(style::ecyan).join(", ")
                    ));
                }
            }
        }
        Ok(tasks)
    }

    async fn validate_task(
        &self,
        task: &Task,
        all_tasks: &BTreeMap<String, Task>,
        config: &Arc<Config>,
    ) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();

        // 1. Validate missing task references
        issues.extend(self.validate_missing_references(task, all_tasks));

        // 2. Validate usage spec parsing
        issues.extend(self.validate_usage_spec(task, config).await);

        // 3. Validate timeout format
        issues.extend(self.validate_timeout(task));

        // 4. Validate alias conflicts
        issues.extend(self.validate_aliases(task, all_tasks));

        // 5. Validate file existence
        issues.extend(self.validate_file_existence(task));

        // 6. Validate directory template
        issues.extend(self.validate_directory(task, config).await);

        // 7. Validate shell command
        issues.extend(self.validate_shell(task));

        // 8. Validate source globs
        issues.extend(self.validate_source_patterns(task));

        // 9. Validate output patterns
        issues.extend(self.validate_output_patterns(task));

        // 10. Validate run entries
        issues.extend(self.validate_run_entries(task, all_tasks));

        issues
    }

    /// Check if a task exists by name, display_name, or alias.
    /// Monorepo-relative references are resolved the same way runtime task matching resolves them.
    fn task_exists(all_tasks: &BTreeMap<String, Task>, task_name: &str, parent: &Task) -> bool {
        let resolved_name = resolve_task_pattern(task_name, Some(parent));
        let task_refs = build_task_ref_map(all_tasks.iter());
        task_refs
            .get_matching(&resolved_name)
            .is_ok_and(|matches| !matches.is_empty())
            || all_tasks.values().any(|t| t.display_name == resolved_name)
    }

    fn validate_missing_references(
        &self,
        task: &Task,
        all_tasks: &BTreeMap<String, Task>,
    ) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();

        // Check all dependency types
        let all_deps = task
            .depends
            .iter()
            .map(|d| ("depends", d))
            .chain(task.depends_post.iter().map(|d| ("depends_post", d)))
            .chain(task.wait_for.iter().map(|d| ("wait_for", d)));

        for (dep_type, dep) in all_deps {
            if dep.optional {
                // Graph construction above still validates the selector and reports matcher
                // errors. Only the later missing-reference check is skipped here.
                continue;
            }
            let dep_name = &dep.task;
            // Skip pattern wildcards for now (they're resolved at runtime)
            if dep_name.contains('*') || dep_name.contains('?') {
                continue;
            }

            // Check if task exists
            if !Self::task_exists(all_tasks, dep_name, task) {
                issues.push(ValidationIssue {
                    task: task.name.clone(),
                    severity: Severity::Error,
                    category: "missing-dependency".to_string(),
                    message: format!("Dependency '{}' not found", dep_name),
                    details: Some(format!(
                        "Referenced in '{}' but no matching task exists",
                        dep_type
                    )),
                });
            }
        }

        issues
    }

    async fn validate_usage_spec(&self, task: &Task, config: &Arc<Config>) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();

        // Try to parse the usage spec
        match task.parse_usage_spec_for_display(config).await {
            Ok(_spec) => {
                // Successfully parsed
            }
            Err(e) => {
                issues.push(ValidationIssue {
                    task: task.name.clone(),
                    severity: Severity::Warning,
                    category: "usage-parse-error".to_string(),
                    message: "Failed to parse usage specification".to_string(),
                    details: Some(format!("{:#}", e)),
                });
            }
        }

        // If task has explicit usage field, validate it's not empty
        if !task.usage.is_empty() {
            // Check if usage contains common USAGE directive errors
            if task.usage.contains("#USAGE") || task.usage.contains("# USAGE") {
                issues.push(ValidationIssue {
                    task: task.name.clone(),
                    severity: Severity::Warning,
                    category: "usage-directive".to_string(),
                    message: "Usage field contains directive markers".to_string(),
                    details: Some(
                        "The 'usage' field should contain the spec directly, not #USAGE directives"
                            .to_string(),
                    ),
                });
            }
        }

        issues
    }

    fn validate_timeout(&self, task: &Task) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();

        if let Some(ref timeout) = task.timeout {
            // Try to parse as duration
            if let Err(e) = duration::parse_duration(timeout) {
                issues.push(ValidationIssue {
                    task: task.name.clone(),
                    severity: Severity::Error,
                    category: "invalid-timeout".to_string(),
                    message: format!("Invalid timeout format: '{}'", timeout),
                    details: Some(format!("Parse error: {}", e)),
                });
            }
        }

        issues
    }

    fn validate_aliases(
        &self,
        task: &Task,
        all_tasks: &BTreeMap<String, Task>,
    ) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();

        // Build a map of aliases to tasks
        let mut alias_map: HashMap<String, Vec<String>> = HashMap::new();
        for t in all_tasks.values() {
            for alias in &t.aliases {
                alias_map
                    .entry(alias.clone())
                    .or_default()
                    .push(t.name.clone());
            }
        }

        // Check for conflicts - only report once for the first task alphabetically
        for alias in &task.aliases {
            if let Some(tasks) = alias_map.get(alias)
                && tasks.len() > 1
            {
                // Only report the conflict for the first task (alphabetically) to avoid duplicates
                let mut sorted_tasks = tasks.clone();
                sorted_tasks.sort();
                if sorted_tasks[0] == task.name {
                    issues.push(ValidationIssue {
                        task: task.name.clone(),
                        severity: Severity::Error,
                        category: "alias-conflict".to_string(),
                        message: format!("Alias '{}' is used by multiple tasks", alias),
                        details: Some(format!("Tasks: {}", tasks.join(", "))),
                    });
                }
            }

            // Check if alias conflicts with a task name
            if all_tasks.contains_key(alias) {
                issues.push(ValidationIssue {
                    task: task.name.clone(),
                    severity: Severity::Error,
                    category: "alias-conflict".to_string(),
                    message: format!("Alias '{}' conflicts with task name", alias),
                    details: Some(format!("A task named '{}' already exists", alias)),
                });
            }
        }

        issues
    }

    fn validate_file_existence(&self, task: &Task) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();

        if let Some(ref file) = task.file {
            if !file.exists() {
                issues.push(ValidationIssue {
                    task: task.name.clone(),
                    severity: Severity::Error,
                    category: "missing-file".to_string(),
                    message: format!("Task file not found: {}", file::display_path(file)),
                    details: None,
                });
            } else if !file::is_executable(file) {
                issues.push(ValidationIssue {
                    task: task.name.clone(),
                    severity: Severity::Warning,
                    category: "not-executable".to_string(),
                    message: format!("Task file is not executable: {}", file::display_path(file)),
                    details: Some(file::make_executable_hint(file)),
                });
            }
        }

        issues
    }

    async fn validate_directory(&self, task: &Task, config: &Arc<Config>) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();

        if let Some(ref dir) = task.dir {
            // Try to render the directory template
            if contains_template_syntax(dir) {
                // Contains template syntax - try to render it
                match task.dir(config).await {
                    Ok(rendered_dir) => {
                        if let Some(rendered) = rendered_dir
                            && !rendered.exists()
                        {
                            issues.push(ValidationIssue {
                                task: task.name.clone(),
                                severity: Severity::Warning,
                                category: "missing-directory".to_string(),
                                message: format!(
                                    "Task directory does not exist: {}",
                                    file::display_path(&rendered)
                                ),
                                details: Some(format!("Template: {}", dir)),
                            });
                        }
                    }
                    Err(e) => {
                        issues.push(ValidationIssue {
                            task: task.name.clone(),
                            severity: Severity::Error,
                            category: "invalid-directory-template".to_string(),
                            message: "Failed to render directory template".to_string(),
                            details: Some(format!("Template: {}, Error: {:#}", dir, e)),
                        });
                    }
                }
            } else {
                // Static path - check if it exists
                let dir_path = PathBuf::from(dir);
                if dir_path.is_absolute() && !dir_path.exists() {
                    issues.push(ValidationIssue {
                        task: task.name.clone(),
                        severity: Severity::Warning,
                        category: "missing-directory".to_string(),
                        message: format!("Task directory does not exist: {}", dir),
                        details: None,
                    });
                }
            }
        }

        issues
    }

    fn validate_shell(&self, task: &Task) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();

        if let Some(ref shell) = task.shell {
            // Parse shell command (could be "bash -c" or just "bash")
            let shell_parts: Vec<&str> = shell.split_whitespace().collect();
            if let Some(shell_cmd) = shell_parts.first() {
                // Check if it's an absolute path
                let shell_path = PathBuf::from(shell_cmd);
                if shell_path.is_absolute() && !shell_path.exists() {
                    issues.push(ValidationIssue {
                        task: task.name.clone(),
                        severity: Severity::Error,
                        category: "invalid-shell".to_string(),
                        message: format!("Shell command not found: {}", shell_cmd),
                        details: Some(format!("Full shell: {}", shell)),
                    });
                }
            }
        }

        issues
    }

    fn validate_source_patterns(&self, task: &Task) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();

        let validate = |raw: &str, issues: &mut Vec<ValidationIssue>| {
            // Strip `!` prefix (negation) or `\!` escape before validating.
            let pattern = raw
                .strip_prefix('!')
                .or_else(|| raw.strip_prefix("\\!"))
                .unwrap_or(raw);
            if let Err(e) = globset::GlobBuilder::new(pattern).build() {
                issues.push(ValidationIssue {
                    task: task.name.clone(),
                    severity: Severity::Error,
                    category: "invalid-glob-pattern".to_string(),
                    message: format!("Invalid source glob pattern: '{}'", raw),
                    details: Some(format!("{}", e)),
                });
            }
        };

        for source in &task.sources {
            validate(source, &mut issues);
        }

        issues
    }

    fn validate_output_patterns(&self, task: &Task) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();

        // Validate output patterns if they exist
        let paths = task.outputs.patterns();
        for path in paths {
            // Try to compile as glob pattern
            if let Err(e) = globset::GlobBuilder::new(&path).build() {
                issues.push(ValidationIssue {
                    task: task.name.clone(),
                    severity: Severity::Error,
                    category: "invalid-glob-pattern".to_string(),
                    message: format!("Invalid output glob pattern: '{}'", path),
                    details: Some(format!("{}", e)),
                });
            }
        }

        issues
    }

    fn validate_run_entries(
        &self,
        task: &Task,
        all_tasks: &BTreeMap<String, Task>,
    ) -> Vec<ValidationIssue> {
        let mut issues = Vec::new();

        // Validate run entries
        for entry in task.run() {
            match entry {
                crate::task::RunEntry::Script(script) => {
                    // Check if script is empty
                    if script.trim().is_empty() {
                        issues.push(ValidationIssue {
                            task: task.name.clone(),
                            severity: Severity::Warning,
                            category: "empty-script".to_string(),
                            message: "Task contains empty script entry".to_string(),
                            details: None,
                        });
                    }
                }
                crate::task::RunEntry::SingleTask {
                    task: task_name, ..
                } => {
                    // Strip inline arguments before checking existence, matching runtime behavior
                    let (name, _) = crate::task::task_list::split_task_spec(task_name);
                    if !Self::task_exists(all_tasks, name, task) {
                        issues.push(ValidationIssue {
                            task: task.name.clone(),
                            severity: Severity::Error,
                            category: "missing-task-reference".to_string(),
                            message: format!("Task '{}' referenced in run entry not found", name),
                            details: None,
                        });
                    }
                }
                crate::task::RunEntry::TaskGroup { tasks } => {
                    // Strip inline arguments before checking existence, matching runtime behavior
                    for task_name in tasks {
                        let (name, _) = crate::task::task_list::split_task_spec(task_name);
                        if !Self::task_exists(all_tasks, name, task) {
                            issues.push(ValidationIssue {
                                task: task.name.clone(),
                                severity: Severity::Error,
                                category: "missing-task-reference".to_string(),
                                message: format!("Task '{}' in task group not found", name),
                                details: Some("Referenced in parallel task group".to_string()),
                            });
                        }
                    }
                }
            }
        }

        // Check if task has no run entries and no file
        // Allow tasks with dependencies but no run entries (meta/group tasks)
        if task.run().is_empty()
            && task.file.is_none()
            && task.depends.is_empty()
            && task.depends_post.is_empty()
        {
            issues.push(ValidationIssue {
                task: task.name.clone(),
                severity: Severity::Error,
                category: "no-execution".to_string(),
                message: "Task has no executable content".to_string(),
                details: Some(
                    "Task must have either 'run', 'run_windows', 'file', or 'depends' defined"
                        .to_string(),
                ),
            });
        }

        issues
    }

    fn output_json(&self, results: &ValidationResults) -> Result<()> {
        let json = serde_json::to_string_pretty(results)?;
        miseprintln!("{}", json);
        Ok(())
    }

    fn output_human(&self, results: &ValidationResults) -> Result<()> {
        if results.issues.is_empty() {
            miseprintln!(
                "{}",
                console_style(format!(
                    "✓ All {} task(s) validated successfully",
                    results.tasks_validated
                ))
                .green()
            );
            return Ok(());
        }

        // Group issues by task
        let mut issues_by_task: IndexMap<String, Vec<&ValidationIssue>> = IndexMap::new();
        for issue in &results.issues {
            issues_by_task
                .entry(issue.task.clone())
                .or_insert_with(Vec::new)
                .push(issue);
        }

        // Print summary
        miseprintln!(
            "\n{} task(s) validated with {} issue(s):\n",
            console_style(results.tasks_validated).bold(),
            console_style(results.errors + results.warnings).bold()
        );

        if results.errors > 0 {
            miseprintln!(
                "  {} {}",
                console_style("✗").red().bold(),
                console_style(format!("{} error(s)", results.errors))
                    .red()
                    .bold()
            );
        }
        if results.warnings > 0 {
            miseprintln!(
                "  {} {}",
                console_style("⚠").yellow().bold(),
                console_style(format!("{} warning(s)", results.warnings))
                    .yellow()
                    .bold()
            );
        }

        miseprintln!();

        // Print issues grouped by task
        for (task_name, task_issues) in issues_by_task {
            miseprintln!(
                "{} {}",
                console_style("Task:").bold(),
                console_style(&task_name).cyan()
            );

            for issue in task_issues {
                let severity_icon = match issue.severity {
                    Severity::Error => console_style("✗").red().bold(),
                    Severity::Warning => console_style("⚠").yellow().bold(),
                };

                miseprintln!(
                    "  {} {} [{}]",
                    severity_icon,
                    console_style(&issue.message).bold(),
                    console_style(&issue.category).dim()
                );

                if let Some(ref details) = issue.details {
                    for line in details.lines() {
                        miseprintln!("      {}", console_style(line).dim());
                    }
                }
            }

            miseprintln!();
        }

        Ok(())
    }
}

fn cycle_issue(path: &[String]) -> ValidationIssue {
    let task = path
        .first()
        .map(String::as_str)
        .unwrap_or("task graph")
        .to_string();
    ValidationIssue {
        task,
        severity: Severity::Error,
        category: "circular-dependency".to_string(),
        message: "Circular dependency detected".to_string(),
        details: Some(format!(
            "circular dependency detected: {}",
            path.iter().join(" -> ")
        )),
    }
}

fn push_cycle_issue(
    issues: &mut Vec<ValidationIssue>,
    reported_cycles: &mut HashSet<Vec<TaskKey>>,
    cycle: &TaskCycleError,
) {
    for (path, keys) in cycle.paths().iter().zip(cycle.keys()) {
        if reported_cycles.insert(canonical_cycle(keys)) {
            issues.push(cycle_issue(path));
        }
    }
}

fn canonical_cycle<T: Clone + Ord>(path: &[T]) -> Vec<T> {
    let path = if path.len() > 1 && path.first() == path.last() {
        &path[..path.len() - 1]
    } else {
        path
    };
    (0..path.len())
        .map(|offset| {
            path.iter()
                .cycle()
                .skip(offset)
                .take(path.len())
                .cloned()
                .collect::<Vec<_>>()
        })
        .min()
        .unwrap_or_default()
}

fn graph_error_issue(task: &str, details: String) -> ValidationIssue {
    ValidationIssue {
        task: task.to_string(),
        severity: Severity::Error,
        category: "dependency-graph-error".to_string(),
        message: "Failed to build task dependency graph".to_string(),
        details: Some(details),
    }
}

static AFTER_LONG_HELP: &str = color_print::cstr!(
    r#"<bold><underline>Examples:</underline></bold>

    # Validate all tasks
    $ <bold>mise tasks validate</bold>

    # Validate specific tasks
    $ <bold>mise tasks validate build test</bold>

    # Output results as JSON
    $ <bold>mise tasks validate --json</bold>

    # Only show errors (skip warnings)
    $ <bold>mise tasks validate --errors-only</bold>

<bold><underline>Validation Checks:</underline></bold>

The validate command performs the following checks:

  • <bold>Circular Dependencies</bold>: Detects dependency cycles
  • <bold>Missing References</bold>: Finds references to nonexistent tasks
  • <bold>Usage Spec Parsing</bold>: Validates #USAGE directives and specs
  • <bold>Timeout Format</bold>: Checks timeout values are valid durations
  • <bold>Alias Conflicts</bold>: Detects duplicate aliases across tasks
  • <bold>File Existence</bold>: Verifies file-based tasks exist
  • <bold>Directory Templates</bold>: Validates directory paths and templates
  • <bold>Shell Commands</bold>: Checks shell executables exist
  • <bold>Glob Patterns</bold>: Validates source and output patterns
  • <bold>Run Entries</bold>: Ensures tasks reference valid dependencies
"#
);

#[cfg(test)]
mod tests {
    use super::*;

    fn task_key(name: &str, value: &str) -> TaskKey {
        (
            name.to_string(),
            Vec::new(),
            vec![("TARGET".to_string(), value.to_string())],
            crate::task::TaskRunPhase::Normal,
        )
    }

    #[test]
    fn cycle_identity_includes_environment_values() {
        let red = vec![
            task_key("build", "red"),
            task_key("test", "red"),
            task_key("build", "red"),
        ];
        let blue = vec![
            task_key("build", "blue"),
            task_key("test", "blue"),
            task_key("build", "blue"),
        ];

        assert_ne!(canonical_cycle(&red), canonical_cycle(&blue));
    }
}
