use std::path::PathBuf;

use indexmap::IndexMap;
use serde::{Deserialize, Serialize};

use super::CiSystem;

/// Result of evaluating a single `rules:` entry.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuleResult {
    Matched,
    NotReached,
    IfFalse,
    IfIndeterminate,
    ChangesFalse,
    ChangesIndeterminate,
    ExistsSkipped,
}

/// A `rules:` entry together with its evaluation result.
#[derive(Debug, Clone)]
pub struct RuleSummary {
    pub text: String,
    pub result: RuleResult,
}

/// Where a variable value originated.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VarOrigin {
    /// Defined in the global `variables:` block.
    Global,
    /// Defined in the job's own `variables:` block.
    Job,
    /// Defined globally but overridden by the job.
    GlobalOverriddenByJob,
    /// A CI predefined variable (e.g. CI_COMMIT_SHA).
    Predefined,
    /// Injected by `parallel:matrix` expansion.
    Matrix,
    /// Injected by a matching `rules:` entry's `variables:` block.
    Rule,
    /// Forced via `-e` CLI flag or config file override.
    Override,
}

/// Source location of a definition (file path and line number).
#[derive(Debug, Clone)]
pub struct SourceLocation {
    /// File path, relative to the project root.
    pub file: String,
    pub line: usize,
}

/// Debug/provenance information for a resolved job.
#[derive(Debug, Clone, Default)]
pub struct JobDebugInfo {
    /// Where this job is defined (file:line).
    pub source: Option<SourceLocation>,
    /// The chain of `extends:` templates, in resolution order,
    /// with optional source location for each template.
    pub extends_chain: Vec<(String, Option<SourceLocation>)>,
    /// For each top-level key, which template/source provided it.
    /// Value is (source_name, optional_source_location).
    pub key_provenance: IndexMap<String, (String, Option<SourceLocation>)>,
    /// Origin of each variable.
    pub variable_origins: IndexMap<String, VarOrigin>,
}

/// A resolved dependency on another job.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedNeed {
    pub job_name: String,
    /// Whether artifacts should be downloaded from this dependency.
    /// `true` by default (GitLab's default), `false` if explicitly set.
    pub artifacts: bool,
    /// Whether this dependency is optional (`optional: true` in GitLab CI).
    /// Optional needs are satisfied even when the target job is skipped/never.
    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
    pub optional: bool,
}

/// Docker container action details, boxed to keep `StepAction` small.
#[derive(Debug, Clone)]
pub struct DockerAction {
    pub action_dir: PathBuf,
    pub image: String,
    pub entrypoint: Option<String>,
    pub args: Vec<String>,
    pub action_env: IndexMap<String, String>,
    pub input_vars: IndexMap<String, String>,
}

/// Resolved execution action for a GitHub Actions step.
#[derive(Debug, Clone)]
pub enum StepAction {
    /// Shell commands (from `run:` field).
    Run(String),
    /// Docker container action — needs its own container.
    Docker(Box<DockerAction>),
    /// Node action — runs node in the job container.
    Node {
        script: String,
        input_vars: IndexMap<String, String>,
    },
    /// Skipped step (e.g. actions/checkout, failed fetch).
    Skip(String),
}

/// A single step in a GitHub Actions job.
#[derive(Debug, Clone)]
pub struct Step {
    pub name: Option<String>,
    pub uses: Option<String>,
    pub run: Option<String>,
    pub with: IndexMap<String, String>,
    pub env: IndexMap<String, String>,
    pub if_condition: Option<String>,
    pub id: Option<String>,
    /// Resolved action type — populated during resolution.
    pub action: Option<StepAction>,
}

/// A fully resolved job, ready for display and script generation.
#[derive(Debug, Clone)]
pub struct Job {
    pub name: String,
    pub stage: String,
    pub image: Option<String>,
    #[allow(dead_code)] // Populated for future service container support
    pub services: Vec<String>,
    pub variables: IndexMap<String, String>,
    pub before_script: Vec<String>,
    pub script: Vec<String>,
    pub after_script: Vec<String>,
    pub needs: Vec<ResolvedNeed>,
    /// Which jobs' artifacts to download. Computed from `needs`, `dependencies`,
    /// or implicitly from all earlier-stage jobs. `None` is a sentinel meaning
    /// "fill from all previous-stage jobs" (resolved in post-processing).
    pub artifact_sources: Option<Vec<ResolvedNeed>>,
    pub tags: Vec<String>,
    pub when_val: String,
    pub allow_failure: bool,
    /// True if all rules evaluate to `when: never` (job can never run).
    pub rules_never: bool,
    /// Extends targets that could not be resolved (tolerant mode only).
    pub unresolved_extends: Vec<String>,
    pub rules_summary: Vec<RuleSummary>,
    pub artifacts_paths: Vec<String>,
    pub artifacts_exclude: Vec<String>,
    pub artifacts_untracked: bool,
    pub artifacts_when: String,
    /// Path declared in `artifacts:reports:dotenv` (dotenv report artifact).
    pub dotenv_report: Option<String>,
    #[allow(dead_code)] // Populated for future job detail display
    pub timeout: Option<String>,
    #[allow(dead_code)] // Populated for future job detail display
    pub resource_group: Option<String>,
    #[allow(dead_code)] // Populated for future job detail display
    pub coverage: Option<String>,
    /// The raw YAML text of this job (after extends resolution, before variable expansion)
    /// for display in the detail view.
    pub resolved_yaml: String,
    /// If this job uses `trigger: include: local:`, the local file path.
    pub trigger_file: Option<String>,
    /// Directory containing a Dockerfile to build before launching the job container.
    pub build_context: Option<PathBuf>,
    /// Entrypoint override for Docker action containers.
    pub docker_entrypoint: Option<String>,
    /// GitHub Actions step `id` (for GITHUB_OUTPUT namespacing in step-jobs).
    pub step_id: Option<String>,
    /// GitHub Actions steps (empty for GitLab jobs).
    pub steps: Vec<Step>,
    /// Debug/provenance information.
    pub debug_info: JobDebugInfo,
}

impl Job {
    pub fn need_names(&self) -> Vec<&str> {
        self.needs.iter().map(|n| n.job_name.as_str()).collect()
    }

    pub fn is_override_var(&self, key: &str) -> bool {
        self.debug_info
            .variable_origins
            .get(key)
            .is_some_and(|o| matches!(o, VarOrigin::Override))
    }

    pub fn is_manual(&self) -> bool {
        self.when_val == "manual"
    }

    pub fn is_never(&self) -> bool {
        self.rules_never
    }

    pub fn is_unresolved(&self) -> bool {
        !self.unresolved_extends.is_empty()
    }

    /// Build a shell script that approximates running this job locally.
    pub fn build_local_script(&self, ci_system: CiSystem) -> String {
        self.build_local_script_with_extra_vars(ci_system, &IndexMap::new())
    }

    pub fn build_local_script_with_extra_vars(
        &self,
        ci_system: CiSystem,
        extra_vars: &IndexMap<String, String>,
    ) -> String {
        // Force timeout to use --foreground so Ctrl-C reaches child processes.
        // Without this, timeout creates a new process group for the child,
        // which doesn't receive terminal signals (SIGINT from Ctrl-C).
        // Both GitHub Actions and GitLab CI use `set -eo pipefail` (no -u).
        let set_flags = "set -eo pipefail";
        let mut lines = vec![
            "#!/usr/bin/env bash".to_string(),
            set_flags.to_string(),
            "timeout() { command timeout --foreground \"$@\"; }".to_string(),
            String::new(),
        ];

        if !self.unresolved_extends.is_empty() {
            lines.push("# WARNING: This job has unresolved extends:".to_string());
            for ext in &self.unresolved_extends {
                lines.push(format!("#   - {}", ext));
            }
            lines.push("# The script may be incomplete.".to_string());
            lines.push(String::new());
        }

        // Variables (merge job vars with extra overrides)
        let mut merged_vars = self.variables.clone();
        for (k, v) in extra_vars {
            merged_vars.insert(k.clone(), v.clone());
        }
        if !merged_vars.is_empty() {
            lines.push("# Variables".to_string());
            let mut sorted_vars: Vec<_> = merged_vars.iter().collect();
            sorted_vars.sort_by_key(|(k, _)| k.as_str());
            for (k, v) in sorted_vars {
                lines.push(format!("export {}={}", k, shell_quote(v)));
            }
            lines.push(String::new());
        }

        // Image hint
        if let Some(ref image) = self.image {
            let label = match ci_system {
                CiSystem::GitHub => "Container",
                CiSystem::GitLab => "Image",
            };
            lines.push(format!("# {}: {}", label, image));
            lines.push(format!(
                "# To run in container: docker run --rm -v \"$PWD\":/build -w /build {} bash -c '...'",
                image
            ));
            lines.push(String::new());
        }

        // before_script (GitLab only)
        if ci_system == CiSystem::GitLab && !self.before_script.is_empty() {
            lines.push("# before_script".to_string());
            for cmd in &self.before_script {
                lines.push(cmd.clone());
            }
            lines.push(String::new());
        }

        // script / steps
        let script_header = match ci_system {
            CiSystem::GitHub => "# steps",
            CiSystem::GitLab => "# script",
        };
        lines.push(script_header.to_string());
        if self.build_context.is_some() {
            let ep = self
                .docker_entrypoint
                .as_deref()
                .unwrap_or("# (image entrypoint)");
            let args: Vec<_> = std::iter::once(ep)
                .chain(self.script.iter().map(|s| s.as_str()))
                .collect();
            lines.push(args.join(" "));
        } else {
            for cmd in &self.script {
                lines.push(cmd.clone());
            }
        }

        // after_script (GitLab only)
        if ci_system == CiSystem::GitLab && !self.after_script.is_empty() {
            lines.push(String::new());
            lines
                .push("# after_script (runs regardless of script exit code in GitLab)".to_string());
            for cmd in &self.after_script {
                lines.push(cmd.clone());
            }
        }

        lines.join("\n")
    }

    /// Build a script for running inside a container.
    /// Variables are omitted (passed via -e flags) and no image hint is included.
    pub fn build_container_script(&self, ci_system: CiSystem) -> String {
        // Force timeout to use --foreground so Ctrl-C reaches child processes.
        // Both GitHub Actions and GitLab CI use `set -eo pipefail` (no -u).
        let set_flags = "set -eo pipefail";
        let mut lines = vec![
            "#!/usr/bin/env bash".to_string(),
            set_flags.to_string(),
            "timeout() { command timeout --foreground \"$@\"; }".to_string(),
            String::new(),
        ];

        if ci_system == CiSystem::GitHub {
            // Create GITHUB_PATH/GITHUB_ENV/GITHUB_OUTPUT files and define a
            // helper that sources them between steps (mimics the real runner).
            lines.push("# Initialize GitHub Actions environment files".to_string());
            lines.push(
                "touch \"$GITHUB_OUTPUT\" \"$GITHUB_ENV\" \"$GITHUB_PATH\" \"$GITHUB_STEP_SUMMARY\""
                    .to_string(),
            );
            lines.push(String::new());
        }

        if ci_system == CiSystem::GitLab && !self.before_script.is_empty() {
            lines.push("# before_script".to_string());
            for cmd in &self.before_script {
                lines.push(cmd.clone());
            }
            lines.push(String::new());
        }

        let script_header = match ci_system {
            CiSystem::GitHub => "# steps",
            CiSystem::GitLab => "# script",
        };
        lines.push(script_header.to_string());
        for cmd in &self.script {
            lines.push(cmd.clone());
        }

        if ci_system == CiSystem::GitLab && !self.after_script.is_empty() {
            lines.push(String::new());
            lines
                .push("# after_script (runs regardless of script exit code in GitLab)".to_string());
            for cmd in &self.after_script {
                lines.push(cmd.clone());
            }
        }

        lines.join("\n")
    }
}

/// Quote a string for safe use in shell contexts (arguments, export values).
///
/// Uses single quotes for strings with special characters, which prevents
/// shell expansion of `$VAR` references. This is intentional: CI runners set
/// variables via the process environment, not via shell expansion. Literal
/// quoting ensures the generated scripts are reproducible and match the values
/// shown in the Variables tab.
pub fn shell_quote(s: &str) -> String {
    if !s.is_empty()
        && s.chars()
            .all(|c| c.is_alphanumeric() || c == '_' || c == '-' || c == '/' || c == '.')
    {
        s.to_string()
    } else {
        format!("'{}'", s.replace('\'', "'\\''"))
    }
}

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

    fn make_job(
        before_script: Vec<&str>,
        script: Vec<&str>,
        after_script: Vec<&str>,
        variables: Vec<(&str, &str)>,
        image: Option<&str>,
        when_val: &str,
    ) -> Job {
        Job {
            name: "test-job".to_string(),
            stage: "build".to_string(),
            image: image.map(|s| s.to_string()),
            services: vec![],
            variables: variables
                .into_iter()
                .map(|(k, v)| (k.to_string(), v.to_string()))
                .collect(),
            before_script: before_script.into_iter().map(|s| s.to_string()).collect(),
            script: script.into_iter().map(|s| s.to_string()).collect(),
            after_script: after_script.into_iter().map(|s| s.to_string()).collect(),
            needs: vec![],
            artifact_sources: Some(vec![]),
            tags: vec![],
            when_val: when_val.to_string(),
            allow_failure: false,
            rules_never: false,
            unresolved_extends: vec![],
            rules_summary: vec![],
            artifacts_paths: vec![],
            artifacts_exclude: vec![],
            artifacts_untracked: false,
            artifacts_when: "on_success".to_string(),
            dotenv_report: None,
            timeout: None,
            resource_group: None,
            coverage: None,
            resolved_yaml: String::new(),
            trigger_file: None,
            build_context: None,
            docker_entrypoint: None,
            step_id: None,
            steps: vec![],
            debug_info: JobDebugInfo::default(),
        }
    }

    #[test]
    fn test_shell_quote_simple() {
        assert_eq!(shell_quote("hello"), "hello");
        assert_eq!(shell_quote("foo/bar"), "foo/bar");
        assert_eq!(shell_quote("a-b_c.d"), "a-b_c.d");
    }

    #[test]
    fn test_shell_quote_special_chars() {
        assert_eq!(shell_quote("hello world"), "'hello world'");
        assert_eq!(shell_quote("it's"), "'it'\\''s'");
        assert_eq!(shell_quote("a=b"), "'a=b'");
        // Empty string gets quoted
        assert_eq!(shell_quote(""), "''");
    }

    #[test]
    fn test_shell_quote_dollar_vars_are_literal() {
        // $VAR references are single-quoted intentionally — CI runners set
        // variables via the process environment, not via shell expansion.
        assert_eq!(shell_quote("$HOME"), "'$HOME'");
        assert_eq!(
            shell_quote("${CI_REGISTRY}/image"),
            "'${CI_REGISTRY}/image'"
        );
        assert_eq!(shell_quote("prefix-$VAR-suffix"), "'prefix-$VAR-suffix'");
    }

    #[test]
    fn test_shell_quote_backticks_and_subshells() {
        assert_eq!(shell_quote("$(whoami)"), "'$(whoami)'");
        assert_eq!(shell_quote("`date`"), "'`date`'");
    }

    #[test]
    fn test_is_manual() {
        let job = make_job(vec![], vec!["echo hi"], vec![], vec![], None, "manual");
        assert!(job.is_manual());

        let job = make_job(vec![], vec!["echo hi"], vec![], vec![], None, "on_success");
        assert!(!job.is_manual());
    }

    #[test]
    fn test_build_local_script_basic() {
        let job = make_job(
            vec![],
            vec!["cargo build"],
            vec![],
            vec![],
            None,
            "on_success",
        );
        let script = job.build_local_script(CiSystem::GitLab);
        assert!(script.starts_with("#!/usr/bin/env bash\nset -eo pipefail"));
        assert!(script.contains("cargo build"));
        assert!(!script.contains("# Variables"));
    }

    #[test]
    fn test_build_local_script_with_variables() {
        let job = make_job(
            vec![],
            vec!["echo $FOO"],
            vec![],
            vec![("FOO", "bar")],
            None,
            "on_success",
        );
        let script = job.build_local_script(CiSystem::GitLab);
        assert!(script.contains("export FOO=bar"));
    }

    #[test]
    fn test_build_local_script_with_image() {
        let job = make_job(
            vec![],
            vec!["make"],
            vec![],
            vec![],
            Some("rust:latest"),
            "on_success",
        );
        let script = job.build_local_script(CiSystem::GitLab);
        assert!(script.contains("# Image: rust:latest"));
    }

    #[test]
    fn test_build_local_script_all_sections() {
        let job = make_job(
            vec!["setup"],
            vec!["build"],
            vec!["cleanup"],
            vec![],
            None,
            "on_success",
        );
        let script = job.build_local_script(CiSystem::GitLab);
        assert!(script.contains("# before_script\nsetup"));
        assert!(script.contains("# script\nbuild"));
        assert!(script.contains("# after_script"));
        assert!(script.contains("cleanup"));
    }

    #[test]
    fn test_build_container_script_no_variables() {
        let job = make_job(
            vec![],
            vec!["make"],
            vec![],
            vec![("FOO", "bar")],
            None,
            "on_success",
        );
        let script = job.build_container_script(CiSystem::GitLab);
        assert!(!script.contains("export"));
        assert!(!script.contains("FOO"));
        assert!(script.contains("make"));
    }

    #[test]
    fn test_is_override_var() {
        let mut job = make_job(
            vec![],
            vec!["echo"],
            vec![],
            vec![("A", "1")],
            None,
            "on_success",
        );
        job.debug_info
            .variable_origins
            .insert("A".to_string(), VarOrigin::Override);
        job.debug_info
            .variable_origins
            .insert("B".to_string(), VarOrigin::Job);

        assert!(job.is_override_var("A"));
        assert!(!job.is_override_var("B"));
        assert!(!job.is_override_var("MISSING"));
    }

    #[test]
    fn test_build_local_script_docker_action() {
        let mut job = make_job(
            vec![],
            vec!["--verbose", "--task=ci"],
            vec![],
            vec![],
            None,
            "on_success",
        );
        job.build_context = Some(std::path::PathBuf::from("/tmp/action"));
        job.docker_entrypoint = Some("/entrypoint.sh".to_string());

        let script = job.build_local_script(CiSystem::GitHub);
        assert!(script.contains("/entrypoint.sh --verbose --task=ci"));
    }

    #[test]
    fn test_build_local_script_docker_action_no_entrypoint() {
        let mut job = make_job(vec![], vec!["arg1"], vec![], vec![], None, "on_success");
        job.build_context = Some(std::path::PathBuf::from("/tmp/action"));

        let script = job.build_local_script(CiSystem::GitHub);
        assert!(script.contains("# (image entrypoint) arg1"));
    }

    #[test]
    fn test_build_container_script_no_image_hint() {
        let job = make_job(
            vec![],
            vec!["make"],
            vec![],
            vec![],
            Some("rust:latest"),
            "on_success",
        );
        let script = job.build_container_script(CiSystem::GitLab);
        assert!(!script.contains("Image:"));
    }
}
